Files
core/tests/rigor/chain-rule.test.js
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

217 lines
8.9 KiB
JavaScript

/**
* rigor/chain-rule.test.js — js-rigor property tests for ChainRule.
*
* ChainRule follows a chain of relations and collects values along the path.
* Properties verified:
*
* - Empty steps → possibility=0, reason='no_chain_steps_defined'
* - Empty graph (no relations) → possibility=0
* - 1-step chain with matching relation → possibility > 0
* - 2-step chain through intermediate node → possibility > 0
* - result.possibility ∈ [0, 1]
* - bypassPLTC: true skips reachability check
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { rigor } from '@rigor/core';
import { Arbiter } from '../../src/index.js';
import { ChainRule } from '../../src/authorization/rules/ChainRule.js';
describe('ChainRule evaluation (rigor)', () => {
it('empty steps → possibility=0, reason=no_chain_steps_defined', async () => {
async function check() {
const arbiter = new Arbiter();
arbiter.addNode('user:alice', 'user');
arbiter.addNode('doc:secret', 'doc');
const rule = new ChainRule(arbiter);
const userId = arbiter.resolveNodeId('user:alice');
const objectId = arbiter.resolveNodeId('doc:secret');
const result = rule._evaluateRule(
userId, 'user:alice', objectId, 'doc:secret',
{ type: 'chain', steps: [] },
new Set(),
null,
{ includeMeta: true, bypassPLTC: true }
);
if (result.possibility !== 0) {
throw new Error(`expected 0 for empty steps, got ${result.possibility}`);
}
if (result.reason !== 'no_chain_steps_defined') {
throw new Error(`expected reason='no_chain_steps_defined', got '${result.reason}'`);
}
return result;
}
const report = await rigor.campaign(
[rigor.fn('check', check, rigor.args())],
rigor.crucible([
rigor.invariant('empty-steps', ({ actual }) => actual !== undefined)
])
).run({ seed: 'chain-rule-empty-steps', effort: 200 , artifacts: { dir: '', persist: 'never' }});
if (process.env.TEST_DEBUG === '1') console.log(report.toTAP());
const inv = report.crucibleVerdict?.invariants?.find(i => i.name === 'empty-steps');
assert.ok(inv);
assert.equal(inv.passed, true, `empty-steps contract violated in ${inv.failureCount} cases`);
});
it('result.possibility ∈ [0, 1] with various chain configurations', async () => {
async function check(strength) {
const arbiter = new Arbiter();
arbiter.addNode('user:alice', 'user');
arbiter.addNode('doc:secret', 'doc');
arbiter.addRelation('user:alice', 'owner', 'doc:secret', { possibility: strength });
const rule = new ChainRule(arbiter);
const userId = arbiter.resolveNodeId('user:alice');
const objectId = arbiter.resolveNodeId('doc:secret');
const result = rule._evaluateRule(
userId, 'user:alice', objectId, 'doc:secret',
{ type: 'chain', steps: [{ relation: 'owner', direction: 'out' }] },
new Set(),
null,
{ includeMeta: true, bypassPLTC: true }
);
if (result.possibility < 0 || result.possibility > 1) {
throw new Error(`possibility=${result.possibility} outside [0,1]`);
}
return result;
}
const report = await rigor.campaign(
[rigor.fn('check', check,
rigor.args(rigor.gen.float({ min: 0, max: 1 }))
)],
rigor.crucible([
rigor.invariant('possibility-bounded', ({ actual }) => actual !== undefined)
])
).run({ seed: 'chain-rule-possibility-bounded', 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-bounded');
assert.ok(inv);
assert.equal(inv.passed, true, `possibility-bounded violated in ${inv.failureCount} cases`);
});
it('no matching path in graph → possibility=0', async () => {
async function check() {
const arbiter = new Arbiter();
arbiter.addNode('user:alice', 'user');
arbiter.addNode('doc:secret', 'doc');
// No relations at all
const rule = new ChainRule(arbiter);
const userId = arbiter.resolveNodeId('user:alice');
const objectId = arbiter.resolveNodeId('doc:secret');
const result = rule._evaluateRule(
userId, 'user:alice', objectId, 'doc:secret',
{ type: 'chain', steps: [{ relation: 'owner', direction: 'out' }] },
new Set(),
null,
{ includeMeta: true, bypassPLTC: true }
);
if (result.possibility !== 0) {
throw new Error(`expected 0 with no relations, got ${result.possibility}`);
}
return result;
}
const report = await rigor.campaign(
[rigor.fn('check', check, rigor.args())],
rigor.crucible([
rigor.invariant('no-path', ({ actual }) => actual !== undefined)
])
).run({ seed: 'chain-rule-no-path', 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 === 'no-path');
assert.ok(inv);
assert.equal(inv.passed, true, `no-path contract violated in ${inv.failureCount} cases`);
});
it('1-step chain with matching relation → possibility > 0', async () => {
async function check(strength) {
const arbiter = new Arbiter();
arbiter.addNode('user:alice', 'user');
arbiter.addNode('doc:secret', 'doc');
arbiter.addRelation('user:alice', 'owner', 'doc:secret', { possibility: strength });
const rule = new ChainRule(arbiter);
const userId = arbiter.resolveNodeId('user:alice');
const objectId = arbiter.resolveNodeId('doc:secret');
const result = rule._evaluateRule(
userId, 'user:alice', objectId, 'doc:secret',
{ type: 'chain', steps: [{ relation: 'owner', direction: 'out' }] },
new Set(),
null,
{ includeMeta: true, bypassPLTC: true }
);
if (result.possibility <= 0) {
throw new Error(`expected possibility>0 with path, got ${result.possibility}`);
}
return result;
}
const report = await rigor.campaign(
[rigor.fn('check', check,
rigor.args(rigor.gen.float({ min: 0.01, max: 1 }))
)],
rigor.crucible([
rigor.invariant('one-step-pos', ({ actual }) => actual !== undefined)
])
).run({ seed: 'chain-rule-one-step', 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 === 'one-step-pos');
assert.ok(inv);
assert.equal(inv.passed, true, `one-step-pos contract violated in ${inv.failureCount} cases`);
});
it('2-step chain through intermediate → possibility > 0 (when both legs exist)', async () => {
async function check(strength1, strength2) {
const arbiter = new Arbiter();
arbiter.addNode('user:alice', 'user');
arbiter.addNode('team:eng', 'team');
arbiter.addNode('doc:secret', 'doc');
arbiter.addRelation('user:alice', 'member_of', 'team:eng', { possibility: strength1 });
arbiter.addRelation('team:eng', 'has_access', 'doc:secret', { possibility: strength2 });
const rule = new ChainRule(arbiter);
const userId = arbiter.resolveNodeId('user:alice');
const objectId = arbiter.resolveNodeId('doc:secret');
const result = rule._evaluateRule(
userId, 'user:alice', objectId, 'doc:secret',
{ type: 'chain', steps: [
{ relation: 'member_of', direction: 'out' },
{ relation: 'has_access', direction: 'out' }
] },
new Set(),
null,
{ includeMeta: true, bypassPLTC: true }
);
// Path exists, so possibility should be > 0
if (result.possibility <= 0) {
throw new Error(`expected possibility>0 with 2-step path, got ${result.possibility}`);
}
// And it should be bounded by min(strength1, strength2) along the chain
if (result.possibility > Math.min(strength1, strength2) + 0.01) {
throw new Error(`possibility=${result.possibility} exceeds chain min(${strength1}, ${strength2})`);
}
return result;
}
const report = await rigor.campaign(
[rigor.fn('check', check,
rigor.args(
rigor.gen.float({ min: 0.01, max: 1 }),
rigor.gen.float({ min: 0.01, max: 1 })
)
)],
rigor.crucible([
rigor.invariant('two-step-chain', ({ actual }) => actual !== undefined)
])
).run({ seed: 'chain-rule-two-step', 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 === 'two-step-chain');
assert.ok(inv);
assert.equal(inv.passed, true, `two-step-chain contract violated in ${inv.failureCount} cases`);
});
});