Files
core/tests/rules/chain-condition-step.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

124 lines
5.6 KiB
JavaScript

/**
* tests/rules/chain-condition-step.test.js — ChainRule CONDITION STEP.
*
* A chain step of the form { rule: <config>, conditionStep: true } is a
* condition-gated hop instead of an edge traversal. It is valid only as the
* FINAL step: the object is known, so the engine verifies the referenced rule
* at (intermediate, object) for each current path. The DSL compiler emits
* these when a chain's object-side hop references a defeasible/logical
* evidence (e.g. `member_of(user,*g){ gated(g,doc) }` where gated is
* `WHEN can_view(group, doc) UNLESS banned(group)`).
*/
import { describe, it, beforeEach } from 'node:test';
import assert from 'node:assert/strict';
import { Arbiter } from '../../src/index.js';
import { RuleEvaluator } from '../../src/authorization/RuleEvaluator.js';
import { ChainRule } from '../../src/authorization/rules/ChainRule.js';
let arbiter, evaluator, chainRule;
function evalRule(userKey, objectKey, rule, options = {}) {
const userId = arbiter.resolveNodeId(userKey);
const objectId = arbiter.resolveNodeId(objectKey);
return chainRule._evaluateRule(userId, userKey, objectId, objectKey, rule, new Set(), null, {
includeMeta: true,
...options
});
}
const CONDITION_CONFIG = {
type: 'logical',
when: { intersection: { rules: [{ type: 'direct', relation: 'can_view' }], aggregator: 'min' } },
// banned(group) is unary → subject-as-object (self-edge), as the DSL emits
unless: { union: { rules: [{ type: 'direct', relation: 'banned', _subjectAsObject: true }], aggregator: 'max' } }
};
describe('ChainRule condition step (rule-based final hop)', () => {
beforeEach(() => {
arbiter = new Arbiter();
evaluator = new RuleEvaluator(arbiter);
chainRule = new ChainRule(arbiter, evaluator);
arbiter.addNode('user:u', 'user');
arbiter.addNode('group:g', 'group');
arbiter.addNode('doc:d', 'doc');
arbiter.setRelationConfig('can_view', { type: 'direct' });
arbiter.setRelationConfig('banned', { type: 'direct' });
});
it('grants when the condition holds at the object', () => {
arbiter.addRelation('user:u', 'member_of', 'group:g', { possibility: 1.0 });
arbiter.addRelation('group:g', 'can_view', 'doc:d', { possibility: 0.7 });
const rule = {
type: 'chain',
steps: ['member_of', { rule: CONDITION_CONFIG, conditionStep: true }]
};
const res = evalRule('user:u', 'doc:d', rule);
// min(member_of, can_view*(1 - banned)) = min(1.0, 0.7) = 0.7
assert.ok(Math.abs(res.possibility - 0.7) < 1e-9, `expected 0.7, got ${res.possibility} (${res.reason})`);
});
it('denies when the condition is defeated at the object', () => {
arbiter.addRelation('user:u', 'member_of', 'group:g', { possibility: 1.0 });
arbiter.addRelation('group:g', 'can_view', 'doc:d', { possibility: 0.7 });
arbiter.addRelation('group:g', 'banned', 'group:g', { possibility: 1.0 });
const rule = {
type: 'chain',
steps: ['member_of', { rule: CONDITION_CONFIG, conditionStep: true }]
};
const res = evalRule('user:u', 'doc:d', rule);
// min(1.0, 0.7*(1 - 1.0)) = 0
assert.equal(res.possibility, 0);
});
it('denies when an earlier edge is missing', () => {
arbiter.addRelation('group:g', 'can_view', 'doc:d', { possibility: 0.7 });
const rule = {
type: 'chain',
steps: ['member_of', { rule: CONDITION_CONFIG, conditionStep: true }]
};
const res = evalRule('user:u', 'doc:d', rule);
assert.equal(res.possibility, 0);
assert.equal(res.reason, 'no_chain_path_found');
});
it('expands an intermediate condition step (rule-based reachability)', () => {
arbiter.setRelationConfig('member_of', { type: 'direct' });
arbiter.addNode('group:g', 'group');
arbiter.addRelation('user:u', 'member_of', 'group:g', { possibility: 1.0 });
arbiter.addRelation('group:g', 'can_view', 'doc:d', { possibility: 0.8 });
// [condition(member_of unless banned), can_view] — the condition step is
// INTERMEDIATE and discovers its reachable nodes (its base relation's
// neighbors from the source, filtered by its defeater).
const intermediateConfig = {
type: 'logical',
when: { intersection: { rules: [{ type: 'direct', relation: 'member_of' }], aggregator: 'min' } },
unless: { union: { rules: [{ type: 'direct', relation: 'banned', _subjectIsObject: true }], aggregator: 'max' } }
};
const rule = {
type: 'chain',
steps: [{ rule: intermediateConfig, conditionStep: true }, 'can_view']
};
// g not banned → reachable via condition → can_view → doc
assert.ok(Math.abs(evalRule('user:u', 'doc:d', rule).possibility - 0.8) < 1e-9);
// banning g filters it out of the intermediate expansion → no path
arbiter.addRelation('group:g', 'banned', 'group:g', { possibility: 1.0 });
const denied = evalRule('user:u', 'doc:d', rule);
assert.equal(denied.possibility, 0);
});
it('combines across multiple parallel intermediates (max aggregation)', () => {
arbiter.addNode('group:g2', 'group');
arbiter.addRelation('user:u', 'member_of', 'group:g', { possibility: 0.5 });
arbiter.addRelation('group:g', 'can_view', 'doc:d', { possibility: 0.7 });
arbiter.addRelation('user:u', 'member_of', 'group:g2', { possibility: 1.0 });
arbiter.addRelation('group:g2', 'can_view', 'doc:d', { possibility: 0.8 });
const rule = {
type: 'chain',
steps: ['member_of', { rule: CONDITION_CONFIG, conditionStep: true }]
};
const res = evalRule('user:u', 'doc:d', rule);
// paths: min(0.5,0.7)=0.5 and min(1.0,0.8)=0.8 -> max = 0.8
assert.ok(Math.abs(res.possibility - 0.8) < 1e-9, `expected 0.8, got ${res.possibility} (${res.reason})`);
});
});