Files
core/tests/rigor/computed-rule.test.js
T
John Dvorak ed34df4474
CI / benchmark (push) Successful in 48s
CI / test (push) Successful in 5m26s
CI / publish (push) Has been skipped
feat: intermediate chain condition steps, graph-version cache invalidation, rolling-hash chain keys; fix vacuous rigor invariants
Chain intermediates (rule-based reachability):
- ChainRule: a condition step ({ rule, conditionStep }) at an INTERMEDIATE
  position is now EXPANDED from the current node — the rule's base edges'
  destinations, filtered by its defeaters/requirements — and traversal
  continues from each discovered node. Adds _expandRuleFromSrc / direct /
  logical(union/intersection) / defeasible / nested-chain expansion.
- RuleEvaluator: _subjectIsObject flag for unary predicate calls whose subject
  entity IS the object parameter (trusted(other) inside peer_trusted(user,
  other)); previously only subject-var unary calls (_subjectAsObject) were
  handled, so object-var unary defeaters never fired.

Graph-version cache invalidation:
- Arbiter gains a monotonic _graphVersion, incremented on every relation
  mutation. ChainRule result cache, RuleEvaluator rule-result cache, and
  DecisionCache rule cache now stamp entries with the graph version and treat
  any mismatch as a miss — graph mutations can no longer serve stale
  chain/authorization results.

Rolling-hash cache keys:
- UnifiedKeyManager.createChainKey now builds a 53-bit rolling hash (dual
  FNV-1a lanes, exact for ints/floats/strings/nested configs) instead of
  JSON.stringify — no string allocation or serialization on the chain-cache
  hot path. Composite keys stay structured strings because the direct-check
  cache pattern-invalidates by relation ID.

Rigor invariant migration (correctness):
- All 43 rigor test files' throw-based invariants ({ error, errorMessage } =>
  !error && !errorMessage) never saw fn throws — vacuous. Migrated to
  ({ actual }) => actual !== undefined, which fails on any thrown violation
  while passing legitimate null-skips. The migration immediately surfaced
  two latent bugs, now fixed:
    * node-manager/graph-indices skip paths returned bare undefined (falsy
      sentinel) — return { skipped: true }.
    * complex-graph-values-crucible expiry section rewrote values equal to the
      mutation loop's last write; the engine (by design) keeps the old
      timestamp on same-value rewrites so the pre-expiry grant never
      materialized. Now writes guaranteed-different values.
2026-08-03 13:26:42 -07:00

322 lines
13 KiB
JavaScript

/**
* rigor/computed-rule.test.js — js-rigor property tests for ComputedRule.
*
* ComputedRule delegates to arbiter.authChecker.check(userKey, computedRelation,
* objectKey, options) and adapts the result. Properties verified:
*
* - result.possibility equals the delegated authChecker.check result.possibility
* - result.reason defaults to 'computed_delegation' if delegated has no reason
* - result.reason passes through the delegated reason when present
* - meta.ruleType='computed' and meta.computedRelation=rule.relation
* - meta.delegated=true
* - collectedValues pass through from delegated result
* - trackEvaluation=true produces a populated result.evaluation block
* - result.shape is stable across many inputs
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { rigor } from '@rigor/core';
import { ComputedRule } from '../../src/authorization/rules/ComputedRule.js';
const RELATIONS = ['owner', 'viewer', 'editor', 'member', 'parent'];
/**
* Build an arbiter stub whose authChecker.check returns a programmable value.
* Records all calls for assertions.
*/
function makeArbiter(delegate) {
const calls = [];
const arbiter = {
authChecker: {
check(userKey, computedRelation, objectKey, options) {
calls.push({ userKey, computedRelation, objectKey, hasVisited: !!options._visited, hasCurrentRel: !!options._currentRelation });
return delegate(userKey, computedRelation, objectKey, options);
}
}
};
return { arbiter, calls };
}
describe('ComputedRule evaluation (rigor)', () => {
it('result.possibility equals delegated authChecker.check result.possibility', async () => {
async function check(userKey, computedRel, objectKey, possibility) {
const { arbiter, calls } = makeArbiter(() => ({ possibility, reliability: 1.0 }));
const rule = new ComputedRule(arbiter);
const result = rule.evaluate(
0, userKey, 1, objectKey,
{ type: 'computed', relation: computedRel },
new Set(),
'unused',
{}
);
if (result.possibility !== possibility) {
throw new Error(`result.possibility=${result.possibility}, expected ${possibility}`);
}
// Delegation must have happened with the rule.relation as the computed relation
if (calls.length !== 1) throw new Error(`expected 1 authChecker.check call, got ${calls.length}`);
if (calls[0].userKey !== userKey) throw new Error(`userKey=${calls[0].userKey}, expected ${userKey}`);
if (calls[0].computedRelation !== computedRel) throw new Error(`computedRelation=${calls[0].computedRelation}, expected ${computedRel}`);
if (calls[0].objectKey !== objectKey) throw new Error(`objectKey=${calls[0].objectKey}, expected ${objectKey}`);
return result;
}
const report = await rigor.campaign(
[rigor.fn('check', check,
rigor.args(
rigor.gen.string(1, 30),
rigor.gen.enum(RELATIONS),
rigor.gen.string(1, 30),
rigor.gen.float({ min: 0, max: 1 })
)
)],
rigor.crucible([
rigor.invariant('possibility-passthrough', ({ actual }) => actual !== undefined)
])
).run({ seed: 'computed-rule-possibility-passthrough', effort: 800 , artifacts: { dir: '', persist: 'never' }});
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'possibility-passthrough');
assert.ok(inv);
assert.equal(inv.passed, true, `possibility passthrough violated in ${inv.failureCount} cases`);
});
it('result.reason defaults to "computed_delegation" when delegated has no reason', async () => {
async function check(userKey, computedRel, objectKey) {
const { arbiter } = makeArbiter(() => ({ possibility: 0.5, reliability: 1.0 }));
const rule = new ComputedRule(arbiter);
const result = rule.evaluate(
0, userKey, 1, objectKey,
{ type: 'computed', relation: computedRel },
new Set(),
'unused',
{}
);
if (result.reason !== 'computed_delegation') {
throw new Error(`reason=${result.reason}, expected 'computed_delegation'`);
}
return result;
}
const report = await rigor.campaign(
[rigor.fn('check', check,
rigor.args(
rigor.gen.string(1, 30),
rigor.gen.enum(RELATIONS),
rigor.gen.string(1, 30)
)
)],
rigor.crucible([
rigor.invariant('reason-default', ({ actual }) => actual !== undefined)
])
).run({ seed: 'computed-rule-reason-default', effort: 500 , artifacts: { dir: '', persist: 'never' }});
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'reason-default');
assert.ok(inv);
assert.equal(inv.passed, true, `reason default violated in ${inv.failureCount} cases`);
});
it('result.reason passes through the delegated reason when present', async () => {
async function check(userKey, computedRel, objectKey, reason) {
const { arbiter } = makeArbiter(() => ({ possibility: 0.5, reliability: 1.0, reason }));
const rule = new ComputedRule(arbiter);
const result = rule.evaluate(
0, userKey, 1, objectKey,
{ type: 'computed', relation: computedRel },
new Set(),
'unused',
{}
);
if (result.reason !== reason) {
throw new Error(`reason=${result.reason}, expected ${reason}`);
}
return result;
}
const report = await rigor.campaign(
[rigor.fn('check', check,
rigor.args(
rigor.gen.string(1, 30),
rigor.gen.enum(RELATIONS),
rigor.gen.string(1, 30),
rigor.gen.enum(['direct_match', 'no_relation', 'inferred', 'chain_match', 'computed_delegation'])
)
)],
rigor.crucible([
rigor.invariant('reason-passthrough', ({ actual }) => actual !== undefined)
])
).run({ seed: 'computed-rule-reason-passthrough', effort: 800 , artifacts: { dir: '', persist: 'never' }});
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'reason-passthrough');
assert.ok(inv);
assert.equal(inv.passed, true, `reason passthrough violated in ${inv.failureCount} cases`);
});
it('meta.ruleType="computed" and meta.computedRelation=rule.relation', async () => {
async function check(userKey, computedRel, objectKey) {
const { arbiter } = makeArbiter(() => ({ possibility: 0.5, reliability: 1.0 }));
const rule = new ComputedRule(arbiter);
const result = rule.evaluate(
0, userKey, 1, objectKey,
{ type: 'computed', relation: computedRel },
new Set(),
'unused',
{ includeMeta: true, trackEvaluation: false }
);
if (!result.meta) throw new Error(`result.meta is missing (full result: ${JSON.stringify(result)})`);
if (result.meta.ruleType !== 'computed') {
throw new Error(`meta.ruleType=${result.meta.ruleType}, expected 'computed' (full meta: ${JSON.stringify(result.meta)})`);
}
if (result.meta.computedRelation !== computedRel) {
throw new Error(`meta.computedRelation=${result.meta.computedRelation}, expected ${computedRel}`);
}
if (result.meta.delegated !== true) {
throw new Error(`meta.delegated=${result.meta.delegated}, expected true`);
}
return result;
}
const report = await rigor.campaign(
[rigor.fn('check', check,
rigor.args(
rigor.gen.string(1, 30),
rigor.gen.enum(RELATIONS),
rigor.gen.string(1, 30)
)
)],
rigor.crucible([
rigor.invariant('meta-contract', ({ actual }) => actual !== undefined)
])
).run({ seed: 'computed-rule-meta-contract', effort: 800 , artifacts: { dir: '', persist: 'never' }});
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'meta-contract');
assert.ok(inv);
assert.equal(inv.passed, true, `meta contract violated in ${inv.failureCount} cases`);
});
it('collectedValues pass through from delegated result', async () => {
async function check(userKey, computedRel, objectKey, nValues) {
const values = Array.from({ length: nValues }, (_, i) => ({
value: i + 1,
possibility: 0.5,
path: [userKey, objectKey],
source: { entityKey: userKey, relation: computedRel, step: 0 },
metadata: { timestamp: 1000, reliability: 1.0 }
}));
const { arbiter } = makeArbiter(() => ({ possibility: 0.5, reliability: 1.0, collectedValues: values }));
const rule = new ComputedRule(arbiter);
const result = rule.evaluate(
0, userKey, 1, objectKey,
{ type: 'computed', relation: computedRel },
new Set(),
'unused',
{}
);
if (result.collectedValues.length !== nValues) {
throw new Error(`collectedValues.length=${result.collectedValues.length}, expected ${nValues}`);
}
for (let i = 0; i < nValues; i++) {
if (result.collectedValues[i].value !== values[i].value) {
throw new Error(`collectedValues[${i}].value=${result.collectedValues[i].value}, expected ${values[i].value}`);
}
}
return result;
}
const report = await rigor.campaign(
[rigor.fn('check', check,
rigor.args(
rigor.gen.string(1, 30),
rigor.gen.enum(RELATIONS),
rigor.gen.string(1, 30),
rigor.gen.int(0, 5)
)
)],
rigor.crucible([
rigor.invariant('collected-values-passthrough', ({ actual }) => actual !== undefined)
])
).run({ seed: 'computed-rule-collected-values', effort: 800 , artifacts: { dir: '', persist: 'never' }});
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'collected-values-passthrough');
assert.ok(inv);
assert.equal(inv.passed, true, `collected values passthrough violated in ${inv.failureCount} cases`);
});
it('result.possibility defaults to 0 when delegated has no possibility', async () => {
async function check(userKey, computedRel, objectKey) {
const { arbiter } = makeArbiter(() => ({ reliability: 1.0 })); // no possibility
const rule = new ComputedRule(arbiter);
const result = rule.evaluate(
0, userKey, 1, objectKey,
{ type: 'computed', relation: computedRel },
new Set(),
'unused',
{}
);
if (result.possibility !== 0) {
throw new Error(`result.possibility=${result.possibility}, expected 0 (fallback when delegated is undefined)`);
}
return result;
}
const report = await rigor.campaign(
[rigor.fn('check', check,
rigor.args(
rigor.gen.string(1, 30),
rigor.gen.enum(RELATIONS),
rigor.gen.string(1, 30)
)
)],
rigor.crucible([
rigor.invariant('possibility-fallback-zero', ({ actual }) => actual !== undefined)
])
).run({ seed: 'computed-rule-possibility-fallback', effort: 500 , artifacts: { dir: '', persist: 'never' }});
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'possibility-fallback-zero');
assert.ok(inv);
assert.equal(inv.passed, true, `possibility fallback violated in ${inv.failureCount} cases`);
});
it('authChecker.check is called with the visited set and currentRelation passed through options', async () => {
async function check(userKey, computedRel, objectKey, currentRel) {
const { arbiter, calls } = makeArbiter(() => ({ possibility: 0.5, reliability: 1.0 }));
const rule = new ComputedRule(arbiter);
const visited = new Set([`visited:1`, `visited:2`]);
rule.evaluate(
0, userKey, 1, objectKey,
{ type: 'computed', relation: computedRel },
visited,
currentRel,
{}
);
if (calls.length !== 1) throw new Error(`expected 1 call, got ${calls.length}`);
if (!calls[0].hasVisited) throw new Error('authChecker.check did not receive options._visited');
if (!calls[0].hasCurrentRel) throw new Error('authChecker.check did not receive options._currentRelation');
return true;
}
const report = await rigor.campaign(
[rigor.fn('check', check,
rigor.args(
rigor.gen.string(1, 30),
rigor.gen.enum(RELATIONS),
rigor.gen.string(1, 30),
rigor.gen.enum(RELATIONS)
)
)],
rigor.crucible([
rigor.invariant('options-passthrough', ({ actual }) => actual !== undefined)
])
).run({ seed: 'computed-rule-options-passthrough', effort: 800 , artifacts: { dir: '', persist: 'never' }});
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'options-passthrough');
assert.ok(inv);
assert.equal(inv.passed, true, `options passthrough violated in ${inv.failureCount} cases`);
});
});