From 4da3158c63d1ce76289d59c9a1b4fa21bb7ab2b7 Mon Sep 17 00:00:00 2001 From: John Dvorak Date: Mon, 3 Aug 2026 12:00:16 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20ChainRule=20condition=20steps=20?= =?UTF-8?q?=E2=80=94=20rule-based=20final=20hops=20for=20chains?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A chain step of the form { rule: , 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. Enables a chain's object-side hop to reference a defeasible/logical evidence (e.g. member_of(user,*g){ gated(g,doc) } where gated is WHEN/UNLESS). - RuleEvaluator wires the ChainRule with itself so condition steps can evaluate sub-rules recursively. - ChainRule constructor accepts the ruleEvaluator; the traversal loop handles a final condition step (min-combined possibility, reliability product, threshold respect). Non-final condition steps return condition_step_not_final. - The rule config is part of the chain cache key (JSON.stringify of steps). Tests: chain-condition-step (grant/deny/missing-edge/non-final/parallel-max). --- package.json | 2 +- src/authorization/RuleEvaluator.js | 2 +- src/authorization/rules/ChainRule.js | 53 ++++++++++- tests/rules/chain-condition-step.test.js | 108 +++++++++++++++++++++++ 4 files changed, 161 insertions(+), 4 deletions(-) create mode 100644 tests/rules/chain-condition-step.test.js diff --git a/package.json b/package.json index 4767cc3..ed7d027 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@arbiter/core", - "version": "1.0.2", + "version": "1.0.3", "description": "Arbiter core engine: graph indices, relation/reachability, authorization rule evaluator, DSL/AST, condensed & sharded snapshots, and evidence fusion.", "license": "ISC", "author": "", diff --git a/src/authorization/RuleEvaluator.js b/src/authorization/RuleEvaluator.js index 4bda1f9..2fb6806 100644 --- a/src/authorization/RuleEvaluator.js +++ b/src/authorization/RuleEvaluator.js @@ -20,7 +20,7 @@ export class RuleEvaluator { tuple_to_userset: new TupleToUsersetRule(arbiter), multi_hop: new MultiHopRule(arbiter), relational_comparator: new RelationalComparatorRouter(arbiter, this), - chain: new ChainRule(arbiter), + chain: new ChainRule(arbiter, this), challenge: new ChallengeRule(arbiter) }; diff --git a/src/authorization/rules/ChainRule.js b/src/authorization/rules/ChainRule.js index 8e5d1b0..58fb681 100644 --- a/src/authorization/rules/ChainRule.js +++ b/src/authorization/rules/ChainRule.js @@ -44,8 +44,9 @@ import { QualitativeScale } from '../../qualitative/QualitativeScale.js'; * } */ export class ChainRule extends BaseRule { - constructor(arbiter) { + constructor(arbiter, ruleEvaluator = null) { super(arbiter); + this.ruleEvaluator = ruleEvaluator; // Chain-specific caching with HyperbolicLRUCache for better memory management this.maxCacheSize = 2000; @@ -199,7 +200,55 @@ export class ChainRule extends BaseRule { ? { relation: rawStep, direction: 'out' } : rawStep; const { relation: stepRelation, direction } = step; - + + // CONDITION STEP (rule-based step): a step carrying a `rule` config is a + // condition-gated hop, not an edge traversal. It is only valid as the + // FINAL step: the object is known, so each current path's node is checked + // against the object through the referenced rule (e.g. a defeasible + // evidence like `WHEN can_view(group, doc) UNLESS banned(group)`). The + // DSL compiler emits these when a chain's object-side hop references a + // logical/defeasible evidence. The rule config is part of the chain + // cache key (JSON.stringify of steps), so cache correctness is preserved. + if (step.rule) { + if (stepIndex !== steps.length - 1) { + return this._createStandardResult({ + possibility: 0, + reliability: 1.0, + ...(includeMeta && { meta: null }), + reason: 'condition_step_not_final' + }, []); + } + if (!this.ruleEvaluator) { + return this._createStandardResult({ + possibility: 0, + reliability: 1.0, + ...(includeMeta && { meta: null }), + reason: 'condition_step_requires_rule_evaluator' + }, []); + } + const conditionPaths = []; + for (const currentPath of currentPaths) { + const condResult = this.ruleEvaluator.evaluateRule( + currentPath.id, currentPath.key, objectIdNum, objectKey, + step.rule, new Set(visited || []), currentRelation, options + ); + const condPossibility = condResult.possibility ?? 0; + if (condPossibility <= 0) continue; + const nextPossibility = Math.min(currentPath.possibility, condPossibility); + if (fastPath && nextPossibility < minPossibility) continue; + conditionPaths.push({ + id: objectIdNum, + key: objectKey, + possibility: nextPossibility, + reliability: (currentPath.reliability ?? 1.0) * (condResult.reliability ?? 1.0), + path: [...currentPath.path, objectKey], + pathEntities: [...currentPath.pathEntities, { id: objectIdNum, key: objectKey, source: 'condition' }] + }); + } + currentPaths = conditionPaths; + break; + } + if (!stepRelation || !direction) { currentPaths = []; break; diff --git a/tests/rules/chain-condition-step.test.js b/tests/rules/chain-condition-step.test.js new file mode 100644 index 0000000..ceaf977 --- /dev/null +++ b/tests/rules/chain-condition-step.test.js @@ -0,0 +1,108 @@ +/** + * tests/rules/chain-condition-step.test.js — ChainRule CONDITION STEP. + * + * A chain step of the form { rule: , 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('rejects a condition step that is not the final step', () => { + const rule = { + type: 'chain', + steps: [{ rule: CONDITION_CONFIG, conditionStep: true }, 'can_view'] + }; + const res = evalRule('user:u', 'doc:d', rule); + assert.equal(res.possibility, 0); + assert.equal(res.reason, 'condition_step_not_final'); + }); + + 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})`); + }); +});