Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a875089b65 | |||
| 40fa1f8eb2 | |||
| f6e3ae1922 | |||
| bd9c74fb0e | |||
| ab0e569552 |
+57
-57
@@ -1,140 +1,140 @@
|
||||
{
|
||||
"version": 1,
|
||||
"generated": "2026-08-03T03:25:46.644Z",
|
||||
"generated": "2026-08-03T22:37:59.060Z",
|
||||
"actions": {
|
||||
"check[direct-hit]": {
|
||||
"mostPlausible": 0.2641342114909732,
|
||||
"mostPlausible": 0.10672237227527051,
|
||||
"alphaCuts": {
|
||||
"p50": {
|
||||
"lower": 0.11993100000017193,
|
||||
"upper": 0.6825795028928235
|
||||
"lower": 0.105663874043709,
|
||||
"upper": 0.10779178510155751
|
||||
},
|
||||
"p95": {
|
||||
"lower": 0.11993100000017193,
|
||||
"upper": 0.6825795028928235
|
||||
"lower": 0.10367558838055704,
|
||||
"upper": 0.10985914641251807
|
||||
},
|
||||
"p99": {
|
||||
"lower": 0.11993100000017193,
|
||||
"upper": 0.7088692690070402
|
||||
"lower": 0.10273583797909441,
|
||||
"upper": 0.11086333384764993
|
||||
}
|
||||
}
|
||||
},
|
||||
"check[union-ttu]": {
|
||||
"mostPlausible": 0.5572143564747223,
|
||||
"mostPlausible": 0.11249228961043199,
|
||||
"alphaCuts": {
|
||||
"p50": {
|
||||
"lower": 0.10743099999989908,
|
||||
"upper": 0.5572148030094048
|
||||
"lower": 0.111604019277894,
|
||||
"upper": 0.11338800600185026
|
||||
},
|
||||
"p95": {
|
||||
"lower": 0.10743099999989908,
|
||||
"upper": 0.5572148030094048
|
||||
"lower": 0.10993048219120201,
|
||||
"upper": 0.11511402952231622
|
||||
},
|
||||
"p99": {
|
||||
"lower": 0.10743099999989908,
|
||||
"upper": 0.5572148030094048
|
||||
"lower": 0.1091378967617418,
|
||||
"upper": 0.11594970606842417
|
||||
}
|
||||
}
|
||||
},
|
||||
"check[denied-miss]": {
|
||||
"mostPlausible": 0.17001072644302176,
|
||||
"mostPlausible": 0.08747524001151907,
|
||||
"alphaCuts": {
|
||||
"p50": {
|
||||
"lower": 0.10983099999997875,
|
||||
"upper": 0.27403866354448264
|
||||
"lower": 0.08668417849217541,
|
||||
"upper": 0.08827353325683636
|
||||
},
|
||||
"p95": {
|
||||
"lower": 0.10983099999997875,
|
||||
"upper": 0.27403866354448264
|
||||
"lower": 0.085196295997943,
|
||||
"upper": 0.08981478022606769
|
||||
},
|
||||
"p99": {
|
||||
"lower": 0.10983099999997875,
|
||||
"upper": 0.2792968083762517
|
||||
"lower": 0.08449268650925873,
|
||||
"upper": 0.09056310270952082
|
||||
}
|
||||
}
|
||||
},
|
||||
"check[include-meta]": {
|
||||
"mostPlausible": 0.4313421276490078,
|
||||
"mostPlausible": 0.17368093893555905,
|
||||
"alphaCuts": {
|
||||
"p50": {
|
||||
"lower": 0.2319310000000246,
|
||||
"upper": 0.9209354557840623
|
||||
"lower": 0.17235808249234505,
|
||||
"upper": 0.17501399072522753
|
||||
},
|
||||
"p95": {
|
||||
"lower": 0.2319310000000246,
|
||||
"upper": 0.9209354557840623
|
||||
"lower": 0.1698647154489402,
|
||||
"upper": 0.17758260042495247
|
||||
},
|
||||
"p99": {
|
||||
"lower": 0.2319310000000246,
|
||||
"upper": 0.9491636618587536
|
||||
"lower": 0.16868343694884977,
|
||||
"upper": 0.178826872871734
|
||||
}
|
||||
}
|
||||
},
|
||||
"check[overlay-on-top]": {
|
||||
"mostPlausible": 2.4209884301909703,
|
||||
"mostPlausible": 0.34658090606556824,
|
||||
"alphaCuts": {
|
||||
"p50": {
|
||||
"lower": 2.4209884301909703,
|
||||
"upper": 2.4209887680862785
|
||||
"lower": 0.343563635087355,
|
||||
"upper": 0.3496248120197711
|
||||
},
|
||||
"p95": {
|
||||
"lower": 2.4209884301909703,
|
||||
"upper": 2.4209887680862785
|
||||
"lower": 0.33788494447103923,
|
||||
"upper": 0.3555006559358586
|
||||
},
|
||||
"p99": {
|
||||
"lower": 2.4209884301909703,
|
||||
"upper": 2.4209887680862785
|
||||
"lower": 0.33519739588838326,
|
||||
"upper": 0.35835082770353416
|
||||
}
|
||||
}
|
||||
},
|
||||
"check[binary-direct]": {
|
||||
"mostPlausible": 0.24005599110530157,
|
||||
"mostPlausible": 0.10136432040214981,
|
||||
"alphaCuts": {
|
||||
"p50": {
|
||||
"lower": 0.154731000000304,
|
||||
"upper": 0.2756826494555559
|
||||
"lower": 0.10038730449515523,
|
||||
"upper": 0.10235072528492808
|
||||
},
|
||||
"p95": {
|
||||
"lower": 0.154731000000304,
|
||||
"upper": 0.41012851350897955
|
||||
"lower": 0.0985518364498951,
|
||||
"upper": 0.10425698760463886
|
||||
},
|
||||
"p99": {
|
||||
"lower": 0.154731000000304,
|
||||
"upper": 0.41896614930102694
|
||||
"lower": 0.09768455509480933,
|
||||
"upper": 0.10518292733500886
|
||||
}
|
||||
}
|
||||
},
|
||||
"snapshot[build-binary]": {
|
||||
"mostPlausible": 7.446251599999954,
|
||||
"mostPlausible": 2.848037533444555,
|
||||
"alphaCuts": {
|
||||
"p50": {
|
||||
"lower": 5.3102783288306075,
|
||||
"upper": 9.582224244110606
|
||||
"lower": 2.827413825511722,
|
||||
"upper": 2.868811889350609
|
||||
},
|
||||
"p95": {
|
||||
"lower": 5.3102783288306075,
|
||||
"upper": 9.582224244110606
|
||||
"lower": 2.788520370277795,
|
||||
"upper": 2.9088252291455836
|
||||
},
|
||||
"p99": {
|
||||
"lower": 5.225227783447299,
|
||||
"upper": 9.667235344457687
|
||||
"lower": 2.770077133158435,
|
||||
"upper": 2.9281936091667653
|
||||
}
|
||||
}
|
||||
},
|
||||
"snapshot[restore-binary]": {
|
||||
"mostPlausible": 9.853481541731316,
|
||||
"mostPlausible": 2.9488926124434385,
|
||||
"alphaCuts": {
|
||||
"p50": {
|
||||
"lower": 7.845275634169648,
|
||||
"upper": 12.375747204692138
|
||||
"lower": 2.9247809483999716,
|
||||
"upper": 2.9732031186198484
|
||||
},
|
||||
"p95": {
|
||||
"lower": 4.7317310000000745,
|
||||
"upper": 21.54997911145655
|
||||
"lower": 2.8793716893148757,
|
||||
"upper": 3.020092288118393
|
||||
},
|
||||
"p99": {
|
||||
"lower": 4.7317310000000745,
|
||||
"upper": 25.40483299999982
|
||||
"lower": 2.8578657882531426,
|
||||
"upper": 3.0428172805315117
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@arbiter/core",
|
||||
"version": "1.0.4",
|
||||
"version": "1.0.8",
|
||||
"description": "Arbiter core engine: graph indices, relation/reachability, authorization rule evaluator, DSL/AST, condensed & sharded snapshots, and evidence fusion.",
|
||||
"license": "ISC",
|
||||
"author": "",
|
||||
|
||||
+100
-16
@@ -28,11 +28,24 @@ const BASELINE_PATH = (() => {
|
||||
|
||||
benchmark.config({
|
||||
measurements: ['timing'],
|
||||
uncertaintyThreshold: 0.99,
|
||||
minSamples: 0,
|
||||
maxSamples: 200,
|
||||
// Stop collecting once 90% confident (was 0.99 ≈ "confident" immediately,
|
||||
// so the loop stopped at the first check and the sub-ms checks were
|
||||
// measured from ~5 noisy samples). A tighter threshold forces the loop to
|
||||
// keep sampling until variance actually tightens.
|
||||
uncertaintyThreshold: 0.1,
|
||||
// Sample-count floor before any early stop (was 0). Sub-ms checks need a
|
||||
// real sample population for a stable mostPlausible / p95.
|
||||
minSamples: 200,
|
||||
// Headroom for noisy actions to run to (was 200, which was the effective
|
||||
// cap and doubled as the practical floor).
|
||||
maxSamples: 2000,
|
||||
overheadCompensation: true,
|
||||
gcBetweenSamples: true,
|
||||
// Per-sample GC injected a global.gc() between every iteration, which
|
||||
// dominated sub-ms timings and produced the wild run-to-run swings
|
||||
// (check[overlay-on-top] flagged -23%..-83% "faster" across runs of
|
||||
// unchanged code). GC is left to the runtime; the sample floor + tight
|
||||
// confidence threshold now stabilize the distribution instead.
|
||||
gcBetweenSamples: false,
|
||||
})
|
||||
|
||||
let CRASHED = 0
|
||||
@@ -63,34 +76,64 @@ function buildEngine() {
|
||||
|
||||
const engine = buildEngine()
|
||||
|
||||
// ── Warmup ────────────────────────────────────────────────────────────
|
||||
// Sub-ms checks measured without a warmup phase mix first-touch allocation,
|
||||
// lazy index construction, and JIT compilation into the sample population —
|
||||
// a bimodal distribution (check[direct-hit] ~4µs vs ~20µs) that flapped the
|
||||
// regression gate across runs of unchanged code. Run each hot path to a
|
||||
// steady state before any sampling.
|
||||
function warmupChecks(a, iterations = 20000) {
|
||||
const ctx = new PartialGraphContext(a, {
|
||||
relations: [{ src: 'user:1', relation: 'owner', dst: 'doc:1', possibility: 0.5 }]
|
||||
})
|
||||
for (let i = 0; i < iterations; i++) {
|
||||
a.check('user:1', 'owner', 'doc:1')
|
||||
a.check('user:1', 'can_read', 'doc:1')
|
||||
a.check('user:5', 'owner', 'doc:1')
|
||||
a.check('user:1', 'can_read', 'doc:1', { includeMeta: true })
|
||||
a.check('user:1', 'owner', 'doc:1', { partialGraphContext: ctx })
|
||||
}
|
||||
}
|
||||
warmupChecks(engine)
|
||||
|
||||
// Sub-ms checks are 4–20µs per call — below reliable single-call timing
|
||||
// resolution, so one GC tick or context switch inflates a sample (bimodal
|
||||
// distributions flapped the gate). Each sub-ms action measures a BATCH of
|
||||
// calls per sample; jitter amortizes across the batch and the relative
|
||||
// comparison against the baseline (which uses the same BATCH) stays exact.
|
||||
const BATCH = 100
|
||||
|
||||
const directBench = benchmark('check[direct-hit]', () => {
|
||||
engine.check('user:1', 'owner', 'doc:1')
|
||||
for (let i = 0; i < BATCH; i++) engine.check('user:1', 'owner', 'doc:1')
|
||||
})
|
||||
|
||||
const unionBench = benchmark('check[union-ttu]', () => {
|
||||
engine.check('user:1', 'can_read', 'doc:1')
|
||||
for (let i = 0; i < BATCH; i++) engine.check('user:1', 'can_read', 'doc:1')
|
||||
})
|
||||
|
||||
const deniedBench = benchmark('check[denied-miss]', () => {
|
||||
engine.check('user:5', 'owner', 'doc:1')
|
||||
for (let i = 0; i < BATCH; i++) engine.check('user:5', 'owner', 'doc:1')
|
||||
})
|
||||
|
||||
const metaBench = benchmark('check[include-meta]', () => {
|
||||
engine.check('user:1', 'can_read', 'doc:1', { includeMeta: true })
|
||||
for (let i = 0; i < BATCH; i++) engine.check('user:1', 'can_read', 'doc:1', { includeMeta: true })
|
||||
})
|
||||
|
||||
const overlayBench = benchmark('check[overlay-on-top]', () => {
|
||||
const ctx = new PartialGraphContext(engine, {
|
||||
relations: [{ src: 'user:1', relation: 'owner', dst: 'doc:1', possibility: 0.5 }]
|
||||
})
|
||||
engine.check('user:1', 'owner', 'doc:1', { partialGraphContext: ctx })
|
||||
for (let i = 0; i < BATCH; i++) {
|
||||
const ctx = new PartialGraphContext(engine, {
|
||||
relations: [{ src: 'user:1', relation: 'owner', dst: 'doc:1', possibility: 0.5 }]
|
||||
})
|
||||
engine.check('user:1', 'owner', 'doc:1', { partialGraphContext: ctx })
|
||||
}
|
||||
})
|
||||
|
||||
const binaryBench = (() => {
|
||||
const snap = buildEngine()
|
||||
snap.enableCondensedSnapshot()
|
||||
for (let i = 0; i < 5000; i++) snap.check('user:1', 'owner', 'doc:1', { binary: true })
|
||||
return benchmark('check[binary-direct]', () => {
|
||||
snap.check('user:1', 'owner', 'doc:1', { binary: true })
|
||||
for (let i = 0; i < BATCH; i++) snap.check('user:1', 'owner', 'doc:1', { binary: true })
|
||||
})
|
||||
})()
|
||||
|
||||
@@ -164,9 +207,50 @@ if (SAVE) {
|
||||
const baseline = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8'))
|
||||
const regResult = detectRegressions(current, baseline)
|
||||
if (!AS_JSON) console.log(formatRegressions(regResult, 'pretty'))
|
||||
const critical = regResult.regressions.filter(r => r.severity === 'high')
|
||||
if (critical.length > 0) {
|
||||
console.error(`❌ ${critical.length} critical regression(s): ${critical.map(r => r.name).join(', ')}`)
|
||||
|
||||
// ── Ratio-based self-calibration ─────────────────────────────────────
|
||||
// Absolute timings swing with machine load (a shared runner at load 19
|
||||
// shifted every action +15..+50%). Comparing each action's RATIO to a cheap
|
||||
// reference action cancels the load: load scales all actions proportionally,
|
||||
// while a code regression shifts only the affected action's ratio. The
|
||||
// reference action itself is still gate-checked absolutely with a loose
|
||||
// bound (a regression of the reference would otherwise mask every ratio).
|
||||
const RATIO_REFERENCE = process.env.BENCH_RATIO_REFERENCE || 'check[direct-hit]'
|
||||
const RATIO_PERCENT = Number(process.env.RATIO_REGRESSION_PERCENT ?? 15)
|
||||
const REFERENCE_PERCENT = Number(process.env.REFERENCE_REGRESSION_PERCENT ?? 30)
|
||||
|
||||
const currentRef = current.actions?.[RATIO_REFERENCE]?.mostPlausible
|
||||
const baselineRef = baseline.actions?.[RATIO_REFERENCE]?.mostPlausible
|
||||
const ratioViolations = []
|
||||
let referenceViolation = false
|
||||
|
||||
if (currentRef > 0 && baselineRef > 0) {
|
||||
if (currentRef > baselineRef * (1 + REFERENCE_PERCENT / 100)) {
|
||||
referenceViolation = true
|
||||
ratioViolations.push(
|
||||
`${RATIO_REFERENCE} (reference) baseline=${baselineRef.toFixed(4)} → current=${currentRef.toFixed(4)} (+${(((currentRef / baselineRef) - 1) * 100).toFixed(1)}%)`
|
||||
)
|
||||
}
|
||||
for (const [name, cur] of Object.entries(current.actions)) {
|
||||
if (name === RATIO_REFERENCE) continue
|
||||
const base = baseline.actions?.[name]
|
||||
if (!base || !(base.mostPlausible > 0)) continue
|
||||
const curRatio = cur.mostPlausible / currentRef
|
||||
const baseRatio = base.mostPlausible / baselineRef
|
||||
if (curRatio > baseRatio * (1 + RATIO_PERCENT / 100)) {
|
||||
ratioViolations.push(
|
||||
`${name} ratio ${curRatio.toFixed(3)} → baseline ${baseRatio.toFixed(3)} (+${(((curRatio / baseRatio) - 1) * 100).toFixed(1)}%)`
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (ratioViolations.length > 0) {
|
||||
console.error(`❌ ${ratioViolations.length} ratio regression(s):`)
|
||||
for (const v of ratioViolations) console.error(` - ${v}`)
|
||||
if (referenceViolation) {
|
||||
console.error(' (the reference action regressed absolutely — ratios may be unreliable)')
|
||||
}
|
||||
process.exit(1)
|
||||
}
|
||||
} else if (!SAVE) {
|
||||
|
||||
@@ -172,7 +172,30 @@ export class AuthorizationChecker {
|
||||
...(includeMeta && { meta: { reason: 'threshold_not_met', threshold: effectiveThreshold, actual: directRel.possibility } }),
|
||||
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 = {
|
||||
possibility: directRel.possibility,
|
||||
validity: includeMeta
|
||||
@@ -308,7 +331,8 @@ export class AuthorizationChecker {
|
||||
const canCacheRuleResult = !hasPartialGraph && !explain && !includeMeta &&
|
||||
!binary && !temporalPinned && options.cacheRuleResult !== false && this.decisionCache.ruleEnabled;
|
||||
const ruleCacheKey = canCacheRuleResult
|
||||
? this._getRuleResultCacheKey(userId, relation, objectId)
|
||||
? this._getRuleResultCacheKey(userId, relation, objectId) +
|
||||
(options.expectedValue !== undefined ? `:v${options.expectedValue}` : '')
|
||||
: null;
|
||||
if (canCacheRuleResult) {
|
||||
const cached = this.decisionCache.getRule(ruleCacheKey);
|
||||
@@ -967,6 +991,15 @@ export class AuthorizationChecker {
|
||||
if (!this.decisionCache.directEnabled) return;
|
||||
const cacheKey = this._getDirectCheckCacheKey(userKey, relation, objectKey);
|
||||
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) {
|
||||
|
||||
@@ -174,32 +174,9 @@ export class DecisionCache {
|
||||
if (!a?.directCheckCache) return;
|
||||
const nodeId = a.nodeIdByKey?.get(nodeKey);
|
||||
if (nodeId === undefined) return;
|
||||
const cache = a.directCheckCache;
|
||||
|
||||
// Pattern-based invalidation is the safest path — the cache file
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Keys are rolling hashes (not pipe-delimited strings), so node-level
|
||||
// invalidation uses the per-node key index populated at cache-set time.
|
||||
a._invalidateDirectCheckCacheByNode?.(nodeId);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -210,6 +187,9 @@ export class DecisionCache {
|
||||
const a = this.arbiter;
|
||||
if (!a) return;
|
||||
a.directCheckCache?.clear?.();
|
||||
a.directCheckCacheKeysByRelation?.clear?.();
|
||||
a.directCheckCacheKeysByNode?.clear?.();
|
||||
a.directKeyToRelations?.clear?.();
|
||||
a.invalidateAllRuleResultCache?.();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,8 @@ export class RuleEvaluator {
|
||||
!binary && !options.partialGraphContext && !includeMeta && !temporalPinned &&
|
||||
options.cacheRuleResult !== false;
|
||||
const ruleCacheKey = canCacheRuleResult
|
||||
? this._getRuleResultCacheKey(numericUserId, currentRelation, numericObjectId, rule)
|
||||
? this._getRuleResultCacheKey(numericUserId, currentRelation, numericObjectId, rule) +
|
||||
(options.expectedValue !== undefined ? `:v${options.expectedValue}` : '')
|
||||
: null;
|
||||
|
||||
if (canCacheRuleResult) {
|
||||
@@ -95,17 +96,17 @@ export class RuleEvaluator {
|
||||
|
||||
if (rule.union) {
|
||||
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) {
|
||||
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) {
|
||||
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 })
|
||||
@@ -191,6 +192,27 @@ export class RuleEvaluator {
|
||||
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) {
|
||||
const leftSig = this._getComparatorOperandSignature(rule.left || rule.leftOperand);
|
||||
const rightSig = this._getComparatorOperandSignature(rule.right || rule.rightOperand);
|
||||
|
||||
@@ -515,6 +515,9 @@ export class ChainRule extends BaseRule {
|
||||
if (rule.type === 'chain' && Array.isArray(rule.steps)) {
|
||||
return this._expandChainRuleFromSrc(srcId, rule, options);
|
||||
}
|
||||
if (rule.type === 'tuple_to_userset') {
|
||||
return this._expandTtuFromSrc(srcId, rule, options);
|
||||
}
|
||||
if (rule.type === 'logical') {
|
||||
if (rule.when || rule.unless || rule.never || rule.requires || rule.always) {
|
||||
return this._expandDefeasibleFromSrc(srcId, rule, options);
|
||||
@@ -538,6 +541,39 @@ export class ChainRule extends BaseRule {
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand a tuple_to_userset config from a source node. Semantics (forward,
|
||||
* the common case): src →computed→ intermediate, and intermediate ↔object
|
||||
* via tupleset (direction decides which end the intermediate sits on). The
|
||||
* reachable set is the OBJECTS sharing an intermediate with src. Combined
|
||||
* possibility is the weakest link across the two hops.
|
||||
*/
|
||||
_expandTtuFromSrc(srcId, rule, options = null) {
|
||||
const { tuplesetRelation, computedRelation, tuplesetDirection = 'out', reverse = false } = rule;
|
||||
if (!tuplesetRelation || !computedRelation) return new Map();
|
||||
// reverse swaps the roles: src is the object, intermediates come from the
|
||||
// tupleset side, and computed edges go from intermediate to the user.
|
||||
const srcAsUser = !reverse;
|
||||
const computedRel = srcAsUser ? computedRelation : tuplesetRelation;
|
||||
const tuplesetRel = srcAsUser ? tuplesetRelation : computedRelation;
|
||||
const computedEdges = this.arbiter.relationManager.getRelationsFromSrc(srcId, computedRel, options);
|
||||
const out = new Map();
|
||||
for (const ce of computedEdges || []) {
|
||||
const intermediateId = ce.dst;
|
||||
const tsEdges = tuplesetDirection === 'in'
|
||||
? this.arbiter.relationManager.getRelationsFromSrc(intermediateId, tuplesetRel, options)
|
||||
: this.arbiter.relationManager.getRelationsToDst(intermediateId, tuplesetRel, options);
|
||||
for (const te of tsEdges || []) {
|
||||
const objId = tuplesetDirection === 'in' ? te.dst : te.src;
|
||||
const poss = Math.min(ce.possibility ?? 1, te.possibility ?? 1);
|
||||
const rel = (ce.reliability ?? 1) * (te.reliability ?? 1);
|
||||
const cur = out.get(objId);
|
||||
if (!cur || poss > cur.possibility) out.set(objId, { possibility: poss, reliability: rel });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
_expandLogicalNodeFromSrc(srcId, node, op, options = null) {
|
||||
const rules = (node && node.rules) || [];
|
||||
if (rules.length === 0) return new Map();
|
||||
|
||||
@@ -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 _source = directRel.source || 'persistent';
|
||||
|
||||
+90
-10
@@ -70,6 +70,16 @@ export class Arbiter {
|
||||
// Direct check cache - use HyperbolicLRUCache for better memory management
|
||||
this.directCheckCacheSize = options.directCheckCacheSize || 10000;
|
||||
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
|
||||
if (!this.disableCaching && !this.disableDirectCaching) {
|
||||
@@ -226,6 +236,14 @@ export class Arbiter {
|
||||
this.dependencyIndex.set(baseRel, entry);
|
||||
}
|
||||
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);
|
||||
}
|
||||
|
||||
// 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) {
|
||||
// Composite cache keys are `<stringId|relationId|stringId>` — the
|
||||
// middle component is the relation ID, so match on that (a name-based
|
||||
// pattern can never hit). This also covers keys built from either
|
||||
// string ids or node ids, since only the middle component matters.
|
||||
if (typeof this.directCheckCache.invalidateByPattern === 'function') {
|
||||
const relId = this.keyManager._getRelationId(rel);
|
||||
const pattern = new RegExp(`^[^|]*\\|${relId}\\|[^|]*$`);
|
||||
this.directCheckCache.invalidateByPattern(pattern);
|
||||
const keys = this.directCheckCacheKeysByRelation.get(rel);
|
||||
if (!keys) continue;
|
||||
for (const key of keys) {
|
||||
this._deleteFromCache(this.directCheckCache, key);
|
||||
this.directKeyToRelations.delete(key);
|
||||
}
|
||||
keys.clear();
|
||||
}
|
||||
|
||||
// Explicit composite-key deletes (kept for cache implementations that
|
||||
// lack invalidateByPattern).
|
||||
// Explicit composite-key deletes (recomputed hashes) — cheap belt for the
|
||||
// exact (src, relation, dst) pair.
|
||||
const srcId = this.keyManager.getStringId(srcKey);
|
||||
const dstId = this.keyManager.getStringId(dstKey);
|
||||
const key1 = this.keyManager.createCompositeKey(srcId, relation, dstId);
|
||||
@@ -462,6 +483,65 @@ export class Arbiter {
|
||||
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) {
|
||||
if (!this.ruleResultCache || !cacheKey) return;
|
||||
let set = this.ruleResultCacheKeysByRelation.get(relation);
|
||||
|
||||
@@ -90,38 +90,41 @@ export class UnifiedKeyManager {
|
||||
}
|
||||
|
||||
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
|
||||
* @param {number} srcId - Source ID
|
||||
* @param {string} relation - Relation name
|
||||
* @returns {string} Composite key
|
||||
* @returns {number} Rolling-hash key
|
||||
*/
|
||||
createSrcRelKey(srcId, relation) {
|
||||
if (srcId > this.options.maxSrcId) {
|
||||
throw new Error(`Source ID ${srcId} exceeds max range ${this.options.maxSrcId}`);
|
||||
}
|
||||
const relationId = this._getRelationId(relation);
|
||||
// Direction marker: fromSrc and toDst lookups share the relation lookup
|
||||
// cache, and identical `id|relId` keys made the second-direction lookup
|
||||
// return the first direction's cached edges.
|
||||
return `${srcId}|${relationId}|s`;
|
||||
// Direction marker keeps fromSrc and toDst lookups distinct (identical
|
||||
// srcId|relationId pairs previously shared keys across directions).
|
||||
return this._rollingHash(srcId, relationId, 0x73); // 's'
|
||||
}
|
||||
|
||||
/**
|
||||
* Create destination-relation key
|
||||
* @param {number} dstId - Destination ID
|
||||
* @param {string} relation - Relation name
|
||||
* @returns {number} Composite key
|
||||
* @returns {number} Rolling-hash key
|
||||
*/
|
||||
createDstRelKey(dstId, relation) {
|
||||
if (dstId > this.options.maxDstId) {
|
||||
throw new Error(`Destination ID ${dstId} exceeds max range ${this.options.maxDstId}`);
|
||||
}
|
||||
const relationId = this._getRelationId(relation);
|
||||
return `${dstId}|${relationId}|d`;
|
||||
return this._rollingHash(dstId, relationId, 0x64); // 'd'
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -66,11 +66,11 @@ export class RelationCaches {
|
||||
}
|
||||
|
||||
makeValueCacheKeyBySrc(srcId, relationId) {
|
||||
return `${srcId}|${relationId}`;
|
||||
return this.manager.arbiter.keyManager._rollingHash(srcId, relationId, 0x5352); // 'SR'
|
||||
}
|
||||
|
||||
makeValueCacheKeyByDst(dstId, relationId) {
|
||||
return `${dstId}|${relationId}`;
|
||||
return this.manager.arbiter.keyManager._rollingHash(dstId, relationId, 0x4454); // 'DT'
|
||||
}
|
||||
|
||||
deleteFromCache(cache, key) {
|
||||
|
||||
@@ -151,9 +151,14 @@ describe('HyperbolicLRUCache Invalidation', () => {
|
||||
const bobTouchingAlice = arbiter.keyManager.createCompositeKey(bobId, 'can_read', aliceId);
|
||||
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._trackDirectCheckKey('can_read', aliceId, bobId, aliceKey);
|
||||
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._trackDirectCheckKey('can_read', carolId, bobId, carolKey);
|
||||
|
||||
assert.ok(arbiter.directCheckCache.has(aliceKey));
|
||||
assert.ok(arbiter.directCheckCache.has(bobTouchingAlice));
|
||||
|
||||
@@ -106,6 +106,35 @@ describe('ChainRule condition step (rule-based final hop)', () => {
|
||||
assert.equal(denied.possibility, 0);
|
||||
});
|
||||
|
||||
it('expands a tuple_to_userset evidence as an intermediate condition step', () => {
|
||||
arbiter.setRelationConfig('member_of', { type: 'direct' });
|
||||
arbiter.setRelationConfig('owner', { type: 'direct' });
|
||||
arbiter.setRelationConfig('doc_route', { type: 'direct' });
|
||||
arbiter.addNode('group:g', 'group');
|
||||
arbiter.addNode('doc:1', 'doc');
|
||||
arbiter.addNode('doc:2', 'doc');
|
||||
arbiter.addRelation('user:u', 'member_of', 'group:g', { possibility: 1.0 });
|
||||
arbiter.addRelation('group:g', 'owner', 'doc:1', { possibility: 0.8 });
|
||||
arbiter.addRelation('doc:1', 'doc_route', 'doc:2', { possibility: 0.9 });
|
||||
const ttuConfig = {
|
||||
type: 'tuple_to_userset',
|
||||
tuplesetRelation: 'owner',
|
||||
computedRelation: 'member_of',
|
||||
tuplesetDirection: 'in'
|
||||
};
|
||||
const rule = {
|
||||
type: 'chain',
|
||||
steps: [{ rule: ttuConfig, conditionStep: true }, 'doc_route']
|
||||
};
|
||||
// TTU reachable from user = doc:1 (min(1.0, 0.8) = 0.8), then doc_route → 0.9
|
||||
const res = evalRule('user:u', 'doc:2', rule);
|
||||
assert.ok(Math.abs(res.possibility - 0.8) < 1e-9, `expected 0.8, got ${res.possibility} (${res.reason})`);
|
||||
// Direct expansion unit check
|
||||
const expanded = chainRule._expandRuleFromSrc(arbiter.resolveNodeId('user:u'), ttuConfig, {});
|
||||
assert.equal(expanded.size, 1);
|
||||
assert.equal(arbiter.resolveKey([...expanded.keys()][0]), 'doc:1');
|
||||
});
|
||||
|
||||
it('combines across multiple parallel intermediates (max aggregation)', () => {
|
||||
arbiter.addNode('group:g2', 'group');
|
||||
arbiter.addRelation('user:u', 'member_of', 'group:g', { possibility: 0.5 });
|
||||
|
||||
@@ -127,18 +127,25 @@ describe('invalidateByNodeKey', () => {
|
||||
const bobId = arbiter.resolveNodeId('user:bob');
|
||||
const carolId = arbiter.resolveNodeId('user:carol');
|
||||
|
||||
// Real cache-key format: `${srcId}|${rel}|${dstId}` (no prefix).
|
||||
cache.setDirect(`${aliceId}|member_of|${bobId}`, { reason: 'a-b' });
|
||||
cache.setDirect(`${bobId}|member_of|${aliceId}`, { reason: 'b-a' });
|
||||
cache.setDirect(`${carolId}|member_of|${bobId}`, { reason: 'c-b' });
|
||||
// Keys are rolling hashes; node-level invalidation uses the per-node
|
||||
// key index, so every injected key must be tracked.
|
||||
const keyAB = arbiter.keyManager.createCompositeKey(aliceId, 'member_of', bobId);
|
||||
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.invalidateByNodeKey('user:alice');
|
||||
|
||||
// Both alice-involving entries cleared; the others remain.
|
||||
assert.equal(cache.getDirect(`${aliceId}|member_of|${bobId}`), undefined);
|
||||
assert.equal(cache.getDirect(`${bobId}|member_of|${aliceId}`), undefined);
|
||||
assert.ok(cache.getDirect(`${carolId}|member_of|${bobId}`));
|
||||
assert.equal(cache.getDirect(keyAB), undefined);
|
||||
assert.equal(cache.getDirect(keyBA), undefined);
|
||||
assert.ok(cache.getDirect(keyCB));
|
||||
assert.ok(cache.getDirect('unrelated-key'));
|
||||
});
|
||||
});
|
||||
@@ -187,19 +194,26 @@ describe('invalidateByNodeKey', () => {
|
||||
arbiter.addNode('user:bob', 'user');
|
||||
const aliceId = arbiter.resolveNodeId('user:alice');
|
||||
const bobId = arbiter.resolveNodeId('user:bob');
|
||||
// Pre-populate cache with a key that contains alice's id
|
||||
arbiter.decisionCache.setDirect(`${aliceId}|member_of|${bobId}`, { reason: 'stale' });
|
||||
arbiter.decisionCache.setDirect(`${bobId}|member_of|${aliceId}`, { reason: 'stale' });
|
||||
arbiter.decisionCache.setDirect(`${aliceId}|other|${bobId}`, { reason: 'stale' });
|
||||
// Pre-populate cache with keys touching alice (tracked so node-level
|
||||
// invalidation can find them)
|
||||
const k1 = arbiter.keyManager.createCompositeKey(aliceId, 'member_of', bobId);
|
||||
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' });
|
||||
|
||||
// Trigger invalidation through the NodeManager path
|
||||
arbiter.nodeManager.updateNodeData('user:alice', { foo: 'bar' });
|
||||
|
||||
// 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(`${bobId}|member_of|${aliceId}`), undefined);
|
||||
assert.equal(arbiter.decisionCache.getDirect(`${aliceId}|other|${bobId}`), undefined);
|
||||
assert.equal(arbiter.decisionCache.getDirect(k1), undefined);
|
||||
assert.equal(arbiter.decisionCache.getDirect(k2), undefined);
|
||||
assert.equal(arbiter.decisionCache.getDirect(k3), undefined);
|
||||
assert.ok(arbiter.decisionCache.getDirect('unrelated'));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user