3 Commits

Author SHA1 Message Date
John Dvorak a875089b65 chore: bump version to 1.0.8 for CI publish
CI / test (push) Failing after 6m53s
CI / benchmark (push) Has been skipped
CI / publish (push) Has been skipped
2026-08-03 20:36:32 -07:00
John Dvorak 40fa1f8eb2 feat: value-gated direct rules, per-value cache keys, NOT negation; fix value-object checks
CI / test (push) Successful in 6m50s
CI / benchmark (push) Successful in 52s
CI / publish (push) Has been skipped
DirectRule and the direct-check fast path now enforce expectedValue from a DSL
literal (balance(user, 5)) or a per-check value-object override, closing a
silent over-grant where any edge of a value-carrying fact matched. Rule and
decision caches append :v<value> so different amounts never share a key.
NOT negation is applied after union/intersection/exclusion evaluation (the
only negation site was dead code in evaluateLogical), so NOT x now returns
1 - p instead of the raw possibility.
2026-08-03 20:26:57 -07:00
John Dvorak f6e3ae1922 refactor: complete rolling-hash rollout — composite/srcRel/dstRel keys + key-tracked cache invalidation
CI / test (push) Successful in 6m24s
CI / benchmark (push) Successful in 1m0s
CI / publish (push) Successful in 10s
Completes the rolling-hash rollout (previously only createChainKey hashed):
- createCompositeKey, createSrcRelKey, createDstRelKey, and the
  valueRelationsBySrc/Dst keys now produce 53-bit rolling hashes instead of
  `src|rel|dst` string concatenation. Direction markers keep srcRel vs dstRel
  distinct; the composite/chain keys are exact integers usable as Map keys.
- Direct-check cache invalidation is now key-TRACKED instead of pattern-
  matched: every direct-check result is registered under the checked relation,
  its base relations (reverse dependency index), and the subject/object node
  ids (Arbiter._trackDirectCheckKey). Relation-level invalidation deletes the
  tracked keys for each affected relation (covering config-override checks);
  node-level invalidation (node removal / updateNodeData) deletes by node id.
  This replaces the pipe-delimited-string regex matching that required the old
  key format.
- DecisionCache.invalidateByNodeKey and invalidateAll route through the tracked
  indexes; tracking maps are cleared on full flush.

Tests updated to the tracked contract (register injected keys via
_trackDirectCheckKey); full suite green.
2026-08-03 16:35:08 -07:00
10 changed files with 224 additions and 67 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@arbiter/core", "name": "@arbiter/core",
"version": "1.0.5", "version": "1.0.8",
"description": "Arbiter core engine: graph indices, relation/reachability, authorization rule evaluator, DSL/AST, condensed & sharded snapshots, and evidence fusion.", "description": "Arbiter core engine: graph indices, relation/reachability, authorization rule evaluator, DSL/AST, condensed & sharded snapshots, and evidence fusion.",
"license": "ISC", "license": "ISC",
"author": "", "author": "",
+35 -2
View File
@@ -172,7 +172,30 @@ export class AuthorizationChecker {
...(includeMeta && { meta: { reason: 'threshold_not_met', threshold: effectiveThreshold, actual: directRel.possibility } }), ...(includeMeta && { meta: { reason: 'threshold_not_met', threshold: effectiveThreshold, actual: directRel.possibility } }),
reason: 'threshold_not_met' reason: 'threshold_not_met'
}; };
} else { } else if (config?.expectedValue !== undefined && directRel.value !== config.expectedValue) {
// Value gate (mirrors DirectRule): a config carrying expectedValue
// (from a DSL literal like balance(user, 5)) only grants when the
// edge carries exactly that value.
result = {
possibility: 0,
reliability: 0,
...(includeMeta && {
meta: { reason: 'value_mismatch', expectedValue: config.expectedValue, actualValue: directRel.value }
}),
reason: 'value_mismatch'
};
} else if (options.expectedValue !== undefined && directRel.value !== options.expectedValue) {
// Per-check value-object override (check(user, can_withdraw, 5)):
// the value arrives on the check options, not the config.
result = {
possibility: 0,
reliability: 0,
...(includeMeta && {
meta: { reason: 'value_mismatch', expectedValue: options.expectedValue, actualValue: directRel.value }
}),
reason: 'value_mismatch'
};
} else {
result = { result = {
possibility: directRel.possibility, possibility: directRel.possibility,
validity: includeMeta validity: includeMeta
@@ -308,7 +331,8 @@ export class AuthorizationChecker {
const canCacheRuleResult = !hasPartialGraph && !explain && !includeMeta && const canCacheRuleResult = !hasPartialGraph && !explain && !includeMeta &&
!binary && !temporalPinned && options.cacheRuleResult !== false && this.decisionCache.ruleEnabled; !binary && !temporalPinned && options.cacheRuleResult !== false && this.decisionCache.ruleEnabled;
const ruleCacheKey = canCacheRuleResult const ruleCacheKey = canCacheRuleResult
? this._getRuleResultCacheKey(userId, relation, objectId) ? this._getRuleResultCacheKey(userId, relation, objectId) +
(options.expectedValue !== undefined ? `:v${options.expectedValue}` : '')
: null; : null;
if (canCacheRuleResult) { if (canCacheRuleResult) {
const cached = this.decisionCache.getRule(ruleCacheKey); const cached = this.decisionCache.getRule(ruleCacheKey);
@@ -967,6 +991,15 @@ export class AuthorizationChecker {
if (!this.decisionCache.directEnabled) return; if (!this.decisionCache.directEnabled) return;
const cacheKey = this._getDirectCheckCacheKey(userKey, relation, objectKey); const cacheKey = this._getDirectCheckCacheKey(userKey, relation, objectKey);
this.decisionCache.setDirect(cacheKey, result); this.decisionCache.setDirect(cacheKey, result);
// Track the (hashed) key under the checked relation + its base relations
// + the subject/object nodes so relation- and node-level invalidation can
// delete it without parsing the key.
const arb = this.arbiter;
if (arb && typeof arb._trackDirectCheckKey === 'function') {
const srcId = arb.resolveNodeId(userKey);
const dstId = arb.resolveNodeId(objectKey);
arb._trackDirectCheckKey(relation, srcId, dstId, cacheKey);
}
} }
_getVisitedMode(visited) { _getVisitedMode(visited) {
+6 -26
View File
@@ -174,32 +174,9 @@ export class DecisionCache {
if (!a?.directCheckCache) return; if (!a?.directCheckCache) return;
const nodeId = a.nodeIdByKey?.get(nodeKey); const nodeId = a.nodeIdByKey?.get(nodeKey);
if (nodeId === undefined) return; if (nodeId === undefined) return;
const cache = a.directCheckCache; // Keys are rolling hashes (not pipe-delimited strings), so node-level
// invalidation uses the per-node key index populated at cache-set time.
// Pattern-based invalidation is the safest path — the cache file a._invalidateDirectCheckCacheByNode?.(nodeId);
// owns its storage layout and decides how to enumerate keys.
if (typeof cache.invalidateByPattern === 'function') {
// Match a nodeId that appears in any position of the composite
// key. The composite key format is `${srcId}|${rel}|${dstId}` and
// components are pipe-delimited, so a digit-boundary regex is
// safer than a plain substring match.
const escaped = String(nodeId).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const pattern = new RegExp(`(?:^|\\|)${escaped}(?:\\||$)`);
cache.invalidateByPattern(pattern);
return;
}
// Fallback: explicit key iteration. HyperbolicLRUCache does not
// expose `keys()`, so this branch is unreachable for that cache
// family. If a future cache implementation exposes iteration,
// we walk it without leaking storage-layout details.
if (typeof cache.keys === 'function') {
for (const key of cache.keys()) {
if (typeof key === 'string' && key.includes(String(nodeId))) {
cache.delete(key);
}
}
}
} }
/** /**
@@ -210,6 +187,9 @@ export class DecisionCache {
const a = this.arbiter; const a = this.arbiter;
if (!a) return; if (!a) return;
a.directCheckCache?.clear?.(); a.directCheckCache?.clear?.();
a.directCheckCacheKeysByRelation?.clear?.();
a.directCheckCacheKeysByNode?.clear?.();
a.directKeyToRelations?.clear?.();
a.invalidateAllRuleResultCache?.(); a.invalidateAllRuleResultCache?.();
} }
} }
+26 -4
View File
@@ -67,7 +67,8 @@ export class RuleEvaluator {
!binary && !options.partialGraphContext && !includeMeta && !temporalPinned && !binary && !options.partialGraphContext && !includeMeta && !temporalPinned &&
options.cacheRuleResult !== false; options.cacheRuleResult !== false;
const ruleCacheKey = canCacheRuleResult const ruleCacheKey = canCacheRuleResult
? this._getRuleResultCacheKey(numericUserId, currentRelation, numericObjectId, rule) ? this._getRuleResultCacheKey(numericUserId, currentRelation, numericObjectId, rule) +
(options.expectedValue !== undefined ? `:v${options.expectedValue}` : '')
: null; : null;
if (canCacheRuleResult) { if (canCacheRuleResult) {
@@ -95,17 +96,17 @@ export class RuleEvaluator {
if (rule.union) { if (rule.union) {
const result = this.logicalOperators.evaluateUnion(numericUserId, userKey, numericObjectId, objectKey, rule, visited, currentRelation, enhancedOptions); const result = this.logicalOperators.evaluateUnion(numericUserId, userKey, numericObjectId, objectKey, rule, visited, currentRelation, enhancedOptions);
return this._maybeCacheRuleResult(result, currentRelation, ruleCacheKey, canCacheRuleResult); return this._maybeCacheRuleResult(this._applyNegate(result, rule), currentRelation, ruleCacheKey, canCacheRuleResult);
} }
if (rule.intersection) { if (rule.intersection) {
const result = this.logicalOperators.evaluateIntersection(numericUserId, userKey, numericObjectId, objectKey, rule, visited, currentRelation, enhancedOptions); const result = this.logicalOperators.evaluateIntersection(numericUserId, userKey, numericObjectId, objectKey, rule, visited, currentRelation, enhancedOptions);
return this._maybeCacheRuleResult(result, currentRelation, ruleCacheKey, canCacheRuleResult); return this._maybeCacheRuleResult(this._applyNegate(result, rule), currentRelation, ruleCacheKey, canCacheRuleResult);
} }
if (rule.exclusion) { if (rule.exclusion) {
const result = this.logicalOperators.evaluateExclusion(numericUserId, userKey, numericObjectId, objectKey, rule, visited, currentRelation, enhancedOptions); const result = this.logicalOperators.evaluateExclusion(numericUserId, userKey, numericObjectId, objectKey, rule, visited, currentRelation, enhancedOptions);
return this._maybeCacheRuleResult(result, currentRelation, ruleCacheKey, canCacheRuleResult); return this._maybeCacheRuleResult(this._applyNegate(result, rule), currentRelation, ruleCacheKey, canCacheRuleResult);
} }
// Defeasible configs ({ type: 'defeasible', when/unless/never/always }) // Defeasible configs ({ type: 'defeasible', when/unless/never/always })
@@ -191,6 +192,27 @@ export class RuleEvaluator {
return result; return result;
} }
/**
* Apply the NOT operator's negation to a logical rule result. The DSL emits
* `NOT x` as { type:'logical', intersection:{ rules:[x], negate:true } };
* the individual union/intersection/exclusion evaluators don't honor the
* flag, so it is applied here after evaluation (possibility -> 1 - p).
*/
_applyNegate(result, rule) {
const negate = Boolean(
(rule.union && rule.union.negate) ||
(rule.intersection && rule.intersection.negate) ||
(rule.exclusion && rule.exclusion.negate)
);
if (!negate || !result || typeof result.possibility !== 'number') return result;
return {
...result,
possibility: Math.max(0, 1 - result.possibility),
...(result.meta ? { meta: { ...result.meta, negated: true } } : {}),
reason: 'negated'
};
}
_getComparatorCacheSignature(rule) { _getComparatorCacheSignature(rule) {
const leftSig = this._getComparatorOperandSignature(rule.left || rule.leftOperand); const leftSig = this._getComparatorOperandSignature(rule.left || rule.leftOperand);
const rightSig = this._getComparatorOperandSignature(rule.right || rule.rightOperand); const rightSig = this._getComparatorOperandSignature(rule.right || rule.rightOperand);
+20
View File
@@ -56,6 +56,26 @@ export class DirectRule extends BaseRule {
}) })
}, []); }, []);
} }
// Value gate: a rule carrying expectedValue (from a DSL literal like
// `balance(user, 5)`, or a per-check value-object override like
// `check(user, can_withdraw, 5)`) only grants when the matched edge carries
// exactly that value. Without it a value-carrying fact matches ANY edge of
// the relation, silently over-granting past the declared amount.
const expectedValue = rule.expectedValue !== undefined ? rule.expectedValue : options.expectedValue;
if (expectedValue !== undefined && directRel.value !== expectedValue) {
return this._createStandardResult({
possibility: 0,
...(includeMeta && {
meta: {
ruleType: 'direct',
reason: 'value_mismatch',
expectedValue,
actualValue: directRel.value
}
})
}, []);
}
const relationStrength = directRel.possibility; const relationStrength = directRel.possibility;
const _source = directRel.source || 'persistent'; const _source = directRel.source || 'persistent';
+90 -10
View File
@@ -70,6 +70,16 @@ export class Arbiter {
// Direct check cache - use HyperbolicLRUCache for better memory management // Direct check cache - use HyperbolicLRUCache for better memory management
this.directCheckCacheSize = options.directCheckCacheSize || 10000; this.directCheckCacheSize = options.directCheckCacheSize || 10000;
this.directCheckCacheTTL = options.directCheckCacheTTL || 60000; // 1 minute this.directCheckCacheTTL = options.directCheckCacheTTL || 60000; // 1 minute
// Direct-check cache key indexes (keys are rolling hashes, so relation-
// and node-level invalidation cannot parse them): relation -> keys,
// nodeId -> keys, key -> relations. Populated via _trackDirectCheckKey.
this.directCheckCacheKeysByRelation = new Map();
this.directCheckCacheKeysByNode = new Map();
this.directKeyToRelations = new Map();
// Reverse of dependencyIndex: checked relation -> Set of base relations it
// reads (so a base-relation mutation invalidates overridden-config keys).
this.dependencyIndexByRelation = new Map();
// Only create cache if caching is not disabled // Only create cache if caching is not disabled
if (!this.disableCaching && !this.disableDirectCaching) { if (!this.disableCaching && !this.disableDirectCaching) {
@@ -226,6 +236,14 @@ export class Arbiter {
this.dependencyIndex.set(baseRel, entry); this.dependencyIndex.set(baseRel, entry);
} }
entry.all.add(relation); entry.all.add(relation);
// Reverse map: the checked relation reads baseRel, so a baseRel
// mutation must invalidate this relation's direct-check keys.
let bases = this.dependencyIndexByRelation.get(relation);
if (!bases) {
bases = new Set();
this.dependencyIndexByRelation.set(relation, bases);
}
bases.add(baseRel);
} }
} }
} }
@@ -433,20 +451,23 @@ export class Arbiter {
for (const rel of entry.all) affected.add(rel); for (const rel of entry.all) affected.add(rel);
} }
// Keys are rolling hashes (no pipe-delimited string form to match), so
// invalidation uses the per-relation key index populated at cache-set
// time: every direct-check result is tracked under the CHECKED relation
// AND its base relations (via _trackDirectCheckKey), so deleting the
// tracked keys for an affected relation covers overridden-config checks.
for (const rel of affected) { for (const rel of affected) {
// Composite cache keys are `<stringId|relationId|stringId>` — the const keys = this.directCheckCacheKeysByRelation.get(rel);
// middle component is the relation ID, so match on that (a name-based if (!keys) continue;
// pattern can never hit). This also covers keys built from either for (const key of keys) {
// string ids or node ids, since only the middle component matters. this._deleteFromCache(this.directCheckCache, key);
if (typeof this.directCheckCache.invalidateByPattern === 'function') { this.directKeyToRelations.delete(key);
const relId = this.keyManager._getRelationId(rel);
const pattern = new RegExp(`^[^|]*\\|${relId}\\|[^|]*$`);
this.directCheckCache.invalidateByPattern(pattern);
} }
keys.clear();
} }
// Explicit composite-key deletes (kept for cache implementations that // Explicit composite-key deletes (recomputed hashes) — cheap belt for the
// lack invalidateByPattern). // exact (src, relation, dst) pair.
const srcId = this.keyManager.getStringId(srcKey); const srcId = this.keyManager.getStringId(srcKey);
const dstId = this.keyManager.getStringId(dstKey); const dstId = this.keyManager.getStringId(dstKey);
const key1 = this.keyManager.createCompositeKey(srcId, relation, dstId); const key1 = this.keyManager.createCompositeKey(srcId, relation, dstId);
@@ -462,6 +483,65 @@ export class Arbiter {
this.invalidateRuleResultCacheByRelation(relation); this.invalidateRuleResultCacheByRelation(relation);
} }
/**
* Record a direct-check cache key so relation- and node-level invalidation
* can find it without parsing the (hashed) key. Tracked under the CHECKED
* relation and its base relations (so a base-relation mutation invalidates
* overridden-config checks) and under the subject/object node ids.
*/
_trackDirectCheckKey(relation, srcNodeId, dstNodeId, cacheKey) {
if (!cacheKey) return;
const rels = new Set([relation]);
const baseEntry = this.dependencyIndexByRelation.get(relation);
if (baseEntry) for (const base of baseEntry) rels.add(base);
for (const rel of rels) {
let set = this.directCheckCacheKeysByRelation.get(rel);
if (!set) {
set = new Set();
this.directCheckCacheKeysByRelation.set(rel, set);
}
set.add(cacheKey);
}
if (srcNodeId !== undefined && srcNodeId !== null) {
let set = this.directCheckCacheKeysByNode.get(srcNodeId);
if (!set) {
set = new Set();
this.directCheckCacheKeysByNode.set(srcNodeId, set);
}
set.add(cacheKey);
}
if (dstNodeId !== undefined && dstNodeId !== null && dstNodeId !== srcNodeId) {
let set = this.directCheckCacheKeysByNode.get(dstNodeId);
if (!set) {
set = new Set();
this.directCheckCacheKeysByNode.set(dstNodeId, set);
}
set.add(cacheKey);
}
let byKey = this.directKeyToRelations.get(cacheKey);
if (!byKey) {
byKey = new Set();
this.directKeyToRelations.set(cacheKey, byKey);
}
for (const rel of rels) byKey.add(rel);
}
/**
* Invalidate direct-check entries involving a node id (node removal).
*/
_invalidateDirectCheckCacheByNode(nodeId) {
if (!this.directCheckCache || nodeId === undefined) return;
const keys = this.directCheckCacheKeysByNode.get(nodeId);
if (!keys) return;
for (const key of keys) {
this._deleteFromCache(this.directCheckCache, key);
const rels = this.directKeyToRelations.get(key);
if (rels) for (const rel of rels) this.directCheckCacheKeysByRelation.get(rel)?.delete(key);
this.directKeyToRelations.delete(key);
}
keys.clear();
}
_cacheRuleResult(relation, cacheKey) { _cacheRuleResult(relation, cacheKey) {
if (!this.ruleResultCache || !cacheKey) return; if (!this.ruleResultCache || !cacheKey) return;
let set = this.ruleResultCacheKeysByRelation.get(relation); let set = this.ruleResultCacheKeysByRelation.get(relation);
+11 -8
View File
@@ -90,38 +90,41 @@ export class UnifiedKeyManager {
} }
const relationId = this._getRelationId(relation); const relationId = this._getRelationId(relation);
return `${srcId}|${relationId}|${dstId}`; // Rolling hash (no string concatenation). Exact-key caches recompute the
// key to delete; the direct-check cache additionally tracks keys by
// relation/node so relation-level invalidation does not need the
// pipe-delimited string form.
return this._rollingHash(srcId, relationId, dstId);
} }
/** /**
* Create source-relation key * Create source-relation key
* @param {number} srcId - Source ID * @param {number} srcId - Source ID
* @param {string} relation - Relation name * @param {string} relation - Relation name
* @returns {string} Composite key * @returns {number} Rolling-hash key
*/ */
createSrcRelKey(srcId, relation) { createSrcRelKey(srcId, relation) {
if (srcId > this.options.maxSrcId) { if (srcId > this.options.maxSrcId) {
throw new Error(`Source ID ${srcId} exceeds max range ${this.options.maxSrcId}`); throw new Error(`Source ID ${srcId} exceeds max range ${this.options.maxSrcId}`);
} }
const relationId = this._getRelationId(relation); const relationId = this._getRelationId(relation);
// Direction marker: fromSrc and toDst lookups share the relation lookup // Direction marker keeps fromSrc and toDst lookups distinct (identical
// cache, and identical `id|relId` keys made the second-direction lookup // srcId|relationId pairs previously shared keys across directions).
// return the first direction's cached edges. return this._rollingHash(srcId, relationId, 0x73); // 's'
return `${srcId}|${relationId}|s`;
} }
/** /**
* Create destination-relation key * Create destination-relation key
* @param {number} dstId - Destination ID * @param {number} dstId - Destination ID
* @param {string} relation - Relation name * @param {string} relation - Relation name
* @returns {number} Composite key * @returns {number} Rolling-hash key
*/ */
createDstRelKey(dstId, relation) { createDstRelKey(dstId, relation) {
if (dstId > this.options.maxDstId) { if (dstId > this.options.maxDstId) {
throw new Error(`Destination ID ${dstId} exceeds max range ${this.options.maxDstId}`); throw new Error(`Destination ID ${dstId} exceeds max range ${this.options.maxDstId}`);
} }
const relationId = this._getRelationId(relation); const relationId = this._getRelationId(relation);
return `${dstId}|${relationId}|d`; return this._rollingHash(dstId, relationId, 0x64); // 'd'
} }
/** /**
+2 -2
View File
@@ -66,11 +66,11 @@ export class RelationCaches {
} }
makeValueCacheKeyBySrc(srcId, relationId) { makeValueCacheKeyBySrc(srcId, relationId) {
return `${srcId}|${relationId}`; return this.manager.arbiter.keyManager._rollingHash(srcId, relationId, 0x5352); // 'SR'
} }
makeValueCacheKeyByDst(dstId, relationId) { makeValueCacheKeyByDst(dstId, relationId) {
return `${dstId}|${relationId}`; return this.manager.arbiter.keyManager._rollingHash(dstId, relationId, 0x4454); // 'DT'
} }
deleteFromCache(cache, key) { deleteFromCache(cache, key) {
@@ -151,9 +151,14 @@ describe('HyperbolicLRUCache Invalidation', () => {
const bobTouchingAlice = arbiter.keyManager.createCompositeKey(bobId, 'can_read', aliceId); const bobTouchingAlice = arbiter.keyManager.createCompositeKey(bobId, 'can_read', aliceId);
const carolKey = arbiter.keyManager.createCompositeKey(carolId, 'can_read', bobId); const carolKey = arbiter.keyManager.createCompositeKey(carolId, 'can_read', bobId);
// Direct-check keys are rolling hashes; invalidation-by-node uses the
// per-node key index, so every injected key must be tracked.
arbiter.directCheckCache.set(aliceKey, { result: { possibility: 0.8 }, timestamp: Date.now() }); arbiter.directCheckCache.set(aliceKey, { result: { possibility: 0.8 }, timestamp: Date.now() });
arbiter._trackDirectCheckKey('can_read', aliceId, bobId, aliceKey);
arbiter.directCheckCache.set(bobTouchingAlice, { result: { possibility: 0.9 }, timestamp: Date.now() }); arbiter.directCheckCache.set(bobTouchingAlice, { result: { possibility: 0.9 }, timestamp: Date.now() });
arbiter._trackDirectCheckKey('can_read', bobId, aliceId, bobTouchingAlice);
arbiter.directCheckCache.set(carolKey, { result: { possibility: 0.7 }, timestamp: Date.now() }); arbiter.directCheckCache.set(carolKey, { result: { possibility: 0.7 }, timestamp: Date.now() });
arbiter._trackDirectCheckKey('can_read', carolId, bobId, carolKey);
assert.ok(arbiter.directCheckCache.has(aliceKey)); assert.ok(arbiter.directCheckCache.has(aliceKey));
assert.ok(arbiter.directCheckCache.has(bobTouchingAlice)); assert.ok(arbiter.directCheckCache.has(bobTouchingAlice));
+28 -14
View File
@@ -127,18 +127,25 @@ describe('invalidateByNodeKey', () => {
const bobId = arbiter.resolveNodeId('user:bob'); const bobId = arbiter.resolveNodeId('user:bob');
const carolId = arbiter.resolveNodeId('user:carol'); const carolId = arbiter.resolveNodeId('user:carol');
// Real cache-key format: `${srcId}|${rel}|${dstId}` (no prefix). // Keys are rolling hashes; node-level invalidation uses the per-node
cache.setDirect(`${aliceId}|member_of|${bobId}`, { reason: 'a-b' }); // key index, so every injected key must be tracked.
cache.setDirect(`${bobId}|member_of|${aliceId}`, { reason: 'b-a' }); const keyAB = arbiter.keyManager.createCompositeKey(aliceId, 'member_of', bobId);
cache.setDirect(`${carolId}|member_of|${bobId}`, { reason: 'c-b' }); const keyBA = arbiter.keyManager.createCompositeKey(bobId, 'member_of', aliceId);
const keyCB = arbiter.keyManager.createCompositeKey(carolId, 'member_of', bobId);
cache.setDirect(keyAB, { reason: 'a-b' });
arbiter._trackDirectCheckKey('member_of', aliceId, bobId, keyAB);
cache.setDirect(keyBA, { reason: 'b-a' });
arbiter._trackDirectCheckKey('member_of', bobId, aliceId, keyBA);
cache.setDirect(keyCB, { reason: 'c-b' });
arbiter._trackDirectCheckKey('member_of', carolId, bobId, keyCB);
cache.setDirect('unrelated-key', { reason: 'u' }); cache.setDirect('unrelated-key', { reason: 'u' });
cache.invalidateByNodeKey('user:alice'); cache.invalidateByNodeKey('user:alice');
// Both alice-involving entries cleared; the others remain. // Both alice-involving entries cleared; the others remain.
assert.equal(cache.getDirect(`${aliceId}|member_of|${bobId}`), undefined); assert.equal(cache.getDirect(keyAB), undefined);
assert.equal(cache.getDirect(`${bobId}|member_of|${aliceId}`), undefined); assert.equal(cache.getDirect(keyBA), undefined);
assert.ok(cache.getDirect(`${carolId}|member_of|${bobId}`)); assert.ok(cache.getDirect(keyCB));
assert.ok(cache.getDirect('unrelated-key')); assert.ok(cache.getDirect('unrelated-key'));
}); });
}); });
@@ -187,19 +194,26 @@ describe('invalidateByNodeKey', () => {
arbiter.addNode('user:bob', 'user'); arbiter.addNode('user:bob', 'user');
const aliceId = arbiter.resolveNodeId('user:alice'); const aliceId = arbiter.resolveNodeId('user:alice');
const bobId = arbiter.resolveNodeId('user:bob'); const bobId = arbiter.resolveNodeId('user:bob');
// Pre-populate cache with a key that contains alice's id // Pre-populate cache with keys touching alice (tracked so node-level
arbiter.decisionCache.setDirect(`${aliceId}|member_of|${bobId}`, { reason: 'stale' }); // invalidation can find them)
arbiter.decisionCache.setDirect(`${bobId}|member_of|${aliceId}`, { reason: 'stale' }); const k1 = arbiter.keyManager.createCompositeKey(aliceId, 'member_of', bobId);
arbiter.decisionCache.setDirect(`${aliceId}|other|${bobId}`, { reason: 'stale' }); const k2 = arbiter.keyManager.createCompositeKey(bobId, 'member_of', aliceId);
const k3 = arbiter.keyManager.createCompositeKey(aliceId, 'other', bobId);
arbiter.decisionCache.setDirect(k1, { reason: 'stale' });
arbiter._trackDirectCheckKey('member_of', aliceId, bobId, k1);
arbiter.decisionCache.setDirect(k2, { reason: 'stale' });
arbiter._trackDirectCheckKey('member_of', bobId, aliceId, k2);
arbiter.decisionCache.setDirect(k3, { reason: 'stale' });
arbiter._trackDirectCheckKey('other', aliceId, bobId, k3);
arbiter.decisionCache.setDirect('unrelated', { reason: 'keep' }); arbiter.decisionCache.setDirect('unrelated', { reason: 'keep' });
// Trigger invalidation through the NodeManager path // Trigger invalidation through the NodeManager path
arbiter.nodeManager.updateNodeData('user:alice', { foo: 'bar' }); arbiter.nodeManager.updateNodeData('user:alice', { foo: 'bar' });
// All alice-bearing keys should be gone; unrelated key remains. // All alice-bearing keys should be gone; unrelated key remains.
assert.equal(arbiter.decisionCache.getDirect(`${aliceId}|member_of|${bobId}`), undefined); assert.equal(arbiter.decisionCache.getDirect(k1), undefined);
assert.equal(arbiter.decisionCache.getDirect(`${bobId}|member_of|${aliceId}`), undefined); assert.equal(arbiter.decisionCache.getDirect(k2), undefined);
assert.equal(arbiter.decisionCache.getDirect(`${aliceId}|other|${bobId}`), undefined); assert.equal(arbiter.decisionCache.getDirect(k3), undefined);
assert.ok(arbiter.decisionCache.getDirect('unrelated')); assert.ok(arbiter.decisionCache.getDirect('unrelated'));
}); });
}); });