From 2dc478f5a3080ecdbca61539827471f67cd31c09 Mon Sep 17 00:00:00 2001 From: John Dvorak Date: Mon, 3 Aug 2026 11:34:52 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20chain-step=20evidence=20composition=20?= =?UTF-8?q?=E2=80=94=20expand=20evidence=20steps=20in=20chains?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A chain step that references a derived evidence is now expanded at compile time, keeping the engine a flat edge-traversal evaluator: - DIRECT evidence step -> renamed to its underlying relation (member_of(user,*g){ group_read(g,doc) } where group_read = can_view becomes step 'can_view'). - CHAIN evidence step -> its steps are spliced into the parent chain (a sub-path flattens into the linear source->...->object traversal). - Any other evidence type (defeasible/logical/comparator) as a step is a compile-time error: it is a condition, not an edge traversal. - Cycles and self-references through chain steps are compile-time errors (the existing composition cycle guard now covers steps). Rigor: oracle campaign gains a chain_step_composition construct; illegal mutations gain a non-lowerable-chain-step case. Fixture suites updated to retarget the self-recursive 'canRead/canAccess/...' terminals (an unsupported recursion pattern that now fails loudly) to an any-typed 'reachable' fact, preserving the nested-pattern parsing intent. --- package.json | 2 +- src/generator/RuleGenerator.js | 53 ++++++++++ tests/ChainStepComposition.test.js | 123 ++++++++++++++++++++++ tests/EvidenceTests.js | 27 ++--- tests/IntegrationTests.js | 28 ++--- tests/rigor/dsl-generative-oracle.test.js | 14 ++- tests/rigor/dsl-illegal-mutations.test.js | 8 ++ 7 files changed, 228 insertions(+), 27 deletions(-) create mode 100644 tests/ChainStepComposition.test.js diff --git a/package.json b/package.json index 35d08cc..4a0ab4a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@arbiter/evidence-dsl", - "version": "1.2.0", + "version": "1.3.0", "description": "Evidence DSL v2 compiler: translates the natural Evidence DSL (ADR-000) into @arbiter/core relation configurations.", "license": "ISC", "type": "module", diff --git a/src/generator/RuleGenerator.js b/src/generator/RuleGenerator.js index eb11e76..af9355b 100644 --- a/src/generator/RuleGenerator.js +++ b/src/generator/RuleGenerator.js @@ -1054,6 +1054,59 @@ export class RuleGenerator { if (out.left?.rule) out.left = { ...out.left, rule: this._resolveRule(out.left.rule, stack) }; if (out.right?.rule) out.right = { ...out.right, rule: this._resolveRule(out.right.rule, stack) }; } + // Chain steps may reference a derived evidence; expand those steps + // (direct evidence → underlying relation, chain evidence → spliced steps). + if (rule.type === 'chain' && Array.isArray(out.steps)) { + out.steps = this._expandChainSteps(out.steps, stack); + } + return out; + } + + /** + * Expand chain steps that reference a derived evidence: + * - direct evidence → rename the step to the underlying relation + * (member_of(user,*g){ group_read(g,doc) } where group_read = can_view + * becomes step 'can_view'); + * - chain evidence → splice its steps into this chain (flattening) + * (a step that is itself a sub-path becomes its steps, preserving the + * linear source→…→object traversal); + * - anything else (defeasible/logical/comparator) → compile error: such a + * step is a condition, not an edge traversal, and cannot lower to a flat + * chain step. + */ + _expandChainSteps(steps, stack) { + const out = []; + for (const step of steps) { + const stepName = typeof step === 'string' ? step : step.relation; + if (stepName && this.evidenceNames.has(stepName)) { + if (stack.has(stepName)) { + this.errors.push(`Cyclic evidence reference involving '${stepName}'. Evidence composition must be acyclic.`); + out.push(step); + continue; + } + const referencedConfig = this.generatedRules.get(stepName); + if (referencedConfig) { + const refStack = new Set(stack); + refStack.add(stepName); + const resolved = this._resolveRule(referencedConfig, refStack); + if (resolved.type === 'direct' && resolved.relation && resolved.relation !== stepName) { + out.push(typeof step === 'string' + ? resolved.relation + : { ...step, relation: resolved.relation }); + continue; + } + if (resolved.type === 'chain' && Array.isArray(resolved.steps)) { + out.push(...this._expandChainSteps(resolved.steps, refStack)); + continue; + } + this.errors.push(`Chain step '${stepName}' references an evidence with type '${resolved.type || 'logical'}'. ` + + 'Chain steps can only reference facts, direct evidence, or chain evidence.'); + out.push(step); + continue; + } + } + out.push(step); + } return out; } diff --git a/tests/ChainStepComposition.test.js b/tests/ChainStepComposition.test.js new file mode 100644 index 0000000..de6604a --- /dev/null +++ b/tests/ChainStepComposition.test.js @@ -0,0 +1,123 @@ +/** + * tests/ChainStepComposition.test.js — evidence composition inside CHAIN + * steps. A chain step that references a derived evidence is expanded at + * compile time: + * - a DIRECT evidence step → renamed to its underlying relation + * (member_of(user,*g){ group_read(g,doc) } where group_read = can_view + * becomes step 'can_view'); + * - a CHAIN evidence step → its steps are spliced into the parent chain + * (a sub-path flattens into the linear source→…→object traversal); + * - a DEFEASIBLE / LOGICAL / COMPARATOR evidence step is not an edge + * traversal and is rejected at compile time; + * - cycles and self-references through chain steps are compile errors. + */ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { Arbiter } from '@arbiter/core'; +import { DSLCompiler } from '../src/DSLCompiler.js'; + +const DEFS = ` + definition Employee { id: string } + definition Group { id: string } + definition Doc { id: string } + fact member_of(user: Employee, group: Group) + fact group_has(group: Group, sub: Group) + fact can_view(group: Group, doc: Doc) + fact can_access(group: Group, doc: Doc) + fact banned(group: Group) +`; + +function compile(dsl, name = 'chain-compose') { + const arb = new Arbiter(); + const compiler = new DSLCompiler(arb); + const result = compiler.compile(dsl, name); + return { arb, result }; +} + +describe('Chain step composition', () => { + it('renames a direct-evidence chain step to its underlying relation', () => { + const { arb, result } = compile(` + ${DEFS} + evidence group_read(group: Group, doc: Doc) { can_view(group, doc) } + evidence can_via(user: Employee, doc: Doc) { member_of(user, *g) { group_read(g, doc) } } + `); + assert.ok(result.success, JSON.stringify(result.errors)); + // step 'group_read' → 'can_view' + assert.deepEqual(arb.relationConfigs.get('can_via').steps, ['member_of', 'can_view']); + arb.addNode('u:1', 'Employee'); arb.addNode('g:1', 'Group'); arb.addNode('doc:9', 'Doc'); + arb.addRelation('u:1', 'member_of', 'g:1', { possibility: 1.0 }); + arb.addRelation('g:1', 'can_view', 'doc:9', { possibility: 0.7 }); + assert.equal(arb.check('u:1', 'can_via', 'doc:9').possibility, 0.7); + }); + + it('splices a chain-evidence step into the parent chain', () => { + const { arb, result } = compile(` + ${DEFS} + evidence group_enter(group: Group, doc: Doc) { group_has(group, *s) { can_access(s, doc) } } + evidence can_deep(user: Employee, doc: Doc) { member_of(user, *g) { group_enter(g, doc) } } + `); + assert.ok(result.success, JSON.stringify(result.errors)); + // step 'group_enter' → its steps [group_has, can_access] + assert.deepEqual(arb.relationConfigs.get('can_deep').steps, ['member_of', 'group_has', 'can_access']); + arb.addNode('u:1', 'Employee'); arb.addNode('g:1', 'Group'); arb.addNode('g2:2', 'Group'); arb.addNode('doc:9', 'Doc'); + arb.addRelation('u:1', 'member_of', 'g:1', { possibility: 1.0 }); + arb.addRelation('g:1', 'group_has', 'g2:2', { possibility: 0.9 }); + arb.addRelation('g2:2', 'can_access', 'doc:9', { possibility: 0.8 }); + assert.equal(arb.check('u:1', 'can_deep', 'doc:9').possibility, 0.8); + }); + + it('expands a chain step whose direct evidence is itself composed', () => { + const { arb, result } = compile(` + ${DEFS} + evidence group_view(group: Group, doc: Doc) { can_view(group, doc) } + evidence group_read(group: Group, doc: Doc) { group_view(group, doc) } + evidence can_via(user: Employee, doc: Doc) { member_of(user, *g) { group_read(g, doc) } } + `); + assert.ok(result.success, JSON.stringify(result.errors)); + assert.deepEqual(arb.relationConfigs.get('can_via').steps, ['member_of', 'can_view']); + arb.addNode('u:1', 'Employee'); arb.addNode('g:1', 'Group'); arb.addNode('doc:9', 'Doc'); + arb.addRelation('u:1', 'member_of', 'g:1', { possibility: 1.0 }); + arb.addRelation('g:1', 'can_view', 'doc:9', { possibility: 0.6 }); + assert.equal(arb.check('u:1', 'can_via', 'doc:9').possibility, 0.6); + }); + + it('rejects a defeasible/logical evidence as a chain step', () => { + const { result } = compile(` + ${DEFS} + evidence gated(group: Group, doc: Doc) { WHEN can_view(group, doc) UNLESS banned(group) } + evidence can_via(user: Employee, doc: Doc) { member_of(user, *g) { gated(g, doc) } } + `); + assert.equal(result.success, false); + assert.ok(result.errors.some(e => /Chain step 'gated'/.test(e)), JSON.stringify(result.errors)); + }); + + it('rejects a mutual cycle through chain steps', () => { + const { result } = compile(` + ${DEFS} + evidence cyc_a(group: Group, doc: Doc) { group_has(group, *g) { cyc_b(g, doc) } } + evidence cyc_b(group: Group, doc: Doc) { cyc_a(group, doc) } + `); + assert.equal(result.success, false); + assert.ok(result.errors.some(e => /[Cc]yclic/.test(e)), JSON.stringify(result.errors)); + }); + + it('rejects a self-reference through its own chain step', () => { + const { result } = compile(` + ${DEFS} + evidence cyc_c(group: Group, doc: Doc) { group_has(group, *g) { cyc_c(g, doc) } } + `); + assert.equal(result.success, false); + assert.ok(result.errors.some(e => /[Cc]yclic/.test(e)), JSON.stringify(result.errors)); + }); + + it('re-derives transitive dependencies through expanded chain steps', () => { + const { arb, result } = compile(` + ${DEFS} + evidence group_read(group: Group, doc: Doc) { can_view(group, doc) } + evidence can_via(user: Employee, doc: Doc) { member_of(user, *g) { group_read(g, doc) } } + `); + assert.ok(result.success, JSON.stringify(result.errors)); + // dependsOn reflects the expanded step, not the evidence reference + assert.deepEqual(arb.relationConfigs.get('can_via').dependsOn, ['member_of', 'can_view']); + }); +}); diff --git a/tests/EvidenceTests.js b/tests/EvidenceTests.js index 3b4437f..f016f3d 100644 --- a/tests/EvidenceTests.js +++ b/tests/EvidenceTests.js @@ -45,6 +45,7 @@ const DSL_SUPPORT = ` fact isMember(user: any, group: any) fact isFriend(user: any, friend: any) fact similar(a: any, b: any) + fact reachable(user: any, doc: any) fact parentOf(user: any, parent: any) fact isEditable(doc: any) fact isPublic(doc: any) @@ -159,7 +160,7 @@ describe('Evidence Rules', () => { { input: `evidence canRead(user: Employee, doc: Document) { isMember(user, *group) { - canRead(group, doc) + reachable(group, doc) } }`, description: 'Basic pattern matching with wildcard' @@ -167,7 +168,7 @@ describe('Evidence Rules', () => { { input: `evidence canRead(user: Employee, doc: Document) { isMember(user, *group) { - canRead(group, doc) + reachable(group, doc) } limit 5 }`, description: 'Pattern matching with limit' @@ -175,7 +176,7 @@ describe('Evidence Rules', () => { { input: `evidence canRead(user: Employee, doc: Document) { similar(doc, *similar) |similarity| { - canRead(user, similar) + reachable(user, similar) } with similarity > 0.7 }`, description: 'Pattern matching with binding and condition' @@ -183,7 +184,7 @@ describe('Evidence Rules', () => { { input: `evidence canRead(user: Employee, doc: Document) { similar(doc, *similar) |similarity| { - canRead(user, similar) + reachable(user, similar) } limit 5 with similarity > 0.7 }`, description: 'Pattern matching with binding, condition, and limit' @@ -192,7 +193,7 @@ describe('Evidence Rules', () => { input: `evidence canRead(user: Employee, doc: Document) { isMember(user, *group) { isMember(group, *parentGroup) { - canRead(parentGroup, doc) + reachable(parentGroup, doc) } limit 2 } limit 3 }`, @@ -202,7 +203,7 @@ describe('Evidence Rules', () => { input: `evidence canRead(user: Employee, doc: Document) { isFriend(user, *friend) { isMember(friend, *group) { - canRead(group, doc) + reachable(group, doc) } limit 1 } limit 5 }`, @@ -291,15 +292,15 @@ describe('Evidence Rules', () => { owns(user, doc) isMember(user, *group) { - canRead(group, doc) + reachable(group, doc) } limit 5 parentOf(user, *parent) { - canRead(parent, doc) + reachable(parent, doc) } limit 3 similar(doc, *similar) |similarity| { - canRead(user, similar) + reachable(user, similar) } limit 5 with similarity > 0.7 WHEN hasRole(user, 'admin') UNLESS isSuspended(user) @@ -337,11 +338,11 @@ describe('Evidence Rules', () => { owns(user, doc) isMember(user, *group) { - canModify(group, doc) + reachable(group, doc) } limit 3 similar(doc, *similar) |similarity| { - canModify(user, similar) + reachable(user, similar) isEditable(similar) } limit 2 with similarity > 0.8 @@ -385,7 +386,7 @@ describe('Evidence Rules', () => { { input: `evidence canRead(user: Employee, doc: Document) { isMember(user, *group) { - canRead(group, doc) + reachable(group, doc) } with }`, description: 'Incomplete with clause should fail' @@ -393,7 +394,7 @@ describe('Evidence Rules', () => { { input: `evidence canRead(user: Employee, doc: Document) { isMember(user, *group) { - canRead(group, doc) + reachable(group, doc) } limit }`, description: 'Incomplete limit should fail' diff --git a/tests/IntegrationTests.js b/tests/IntegrationTests.js index 16201ee..6923458 100644 --- a/tests/IntegrationTests.js +++ b/tests/IntegrationTests.js @@ -105,6 +105,7 @@ describe('Integration Tests', () => { fact hasAccess(user: Employee, resource: Resource, level: string) CACHE lazy fact isColleague(user: any, colleague: any) symmetrical CACHE lazy limit 50 fact isParentOf(parent: Employee, child: Employee) transitive CACHE eager limit 3 + fact reachable(user: any, doc: any) CACHE lazy fact hasClearance(user: Employee, level: string) CACHE eager fact parentOf(user: any, parent: any) CACHE eager fact similar(a: any, b: any) CACHE lazy @@ -119,15 +120,15 @@ describe('Integration Tests', () => { owns(user, doc) isMember(user, *group) { - canRead(group, doc) + reachable(group, doc) } limit 5 parentOf(user, *parent) { - canRead(parent, doc) + reachable(parent, doc) } limit 3 similar(doc, *similar) |similarity| { - canRead(user, similar) + reachable(user, similar) } limit 5 with similarity > 0.7 WHEN hasRole(user, 'admin') UNLESS isSuspended(user) @@ -137,7 +138,7 @@ describe('Integration Tests', () => { owns(user, doc) isMember(user, *group) { - canWrite(group, doc) + reachable(group, doc) } limit 3 WHEN hasRole(user, 'admin') UNLESS isSuspended(user) @@ -330,6 +331,7 @@ describe('Integration Tests', () => { fact isMember(user: any, org: any) transitive CACHE lazy limit 5 fact isParentOf(parent: Organization, child: Organization) transitive CACHE eager limit 3 + fact reachable(user: any, doc: any) CACHE lazy fact hasRole(user: Employee, role: string) CACHE eager fact hasClearance(user: Employee, level: string) CACHE eager fact isSuspended(user: any) CACHE lazy @@ -339,7 +341,7 @@ describe('Integration Tests', () => { isMember(user, org) isParentOf(org, *parentOrg) { - canAccessOrg(user, parentOrg) + reachable(user, parentOrg) } limit 3 WHEN hasRole(user, 'admin') UNLESS isSuspended(user) @@ -347,11 +349,11 @@ describe('Integration Tests', () => { evidence canAccessResource(user: Employee, resource: Resource) { isMember(user, *org) { - canAccessResource(org, resource) + reachable(org, resource) } limit 5 parentOf(user, *parent) { - canAccessResource(parent, resource) + reachable(parent, resource) } limit 2 } `; @@ -379,6 +381,7 @@ describe('Integration Tests', () => { fact hasInterest(user: any, interest: string) CACHE lazy fact hasTag(doc: any, tag: string) CACHE lazy fact owns(user: any, doc: any) CACHE eager + fact reachable(user: any, doc: any) CACHE lazy fact similar(a: any, b: any) CACHE lazy fact isPublic(doc: any) CACHE eager fact hasInterests(user: any) CACHE lazy @@ -390,12 +393,12 @@ describe('Integration Tests', () => { owns(user, doc) similar(doc, *similar) |similarity| { - canRead(user, similar) + reachable(user, similar) isPublic(similar) } limit 10 with similarity > 0.7 isFriend(user, *friend) { - canRead(friend, doc) + reachable(friend, doc) } limit 5 fusion majority { @@ -406,7 +409,7 @@ describe('Integration Tests', () => { evidence canRecommend(user: Employee, doc: Document) { similar(user, *similarUser) |similarity| { - canRead(similarUser, doc) + reachable(similarUser, doc) } limit 20 with similarity > 0.8 fusion average { @@ -556,13 +559,14 @@ describe('Integration Tests', () => { fact isFriend(user: any, friend: any) symmetrical CACHE eager limit 50 fact hasPermission(user: Employee, resource: Resource, action: string) CACHE eager fact owns(user: Employee, resource: Resource) CACHE eager + fact reachable(user: any, doc: any) CACHE lazy // Optimized evidence rules evidence canAccess(user: Employee, resource: Resource) { owns(user, resource) isMember(user, *group) { - canAccess(group, resource) + reachable(group, resource) } limit 3 WHEN hasPermission(user, resource, 'read') @@ -572,7 +576,7 @@ describe('Integration Tests', () => { owns(user, resource) isMember(user, *group) { - canModify(group, resource) + reachable(group, resource) } limit 2 WHEN hasPermission(user, resource, 'write') diff --git a/tests/rigor/dsl-generative-oracle.test.js b/tests/rigor/dsl-generative-oracle.test.js index 47131aa..44d7df2 100644 --- a/tests/rigor/dsl-generative-oracle.test.js +++ b/tests/rigor/dsl-generative-oracle.test.js @@ -32,6 +32,7 @@ const FACTS = ` fact can_access(group: Group, doc: Doc) fact owner(group: Group, doc: Doc) fact granted(user: Employee, doc: Doc) + fact group_perm(group: Group, doc: Doc) fact banned(user: Employee) fact mfa(user: Employee) `; @@ -116,6 +117,17 @@ function buildProgram(kind, ps) { oracle = pOwn; break; } + case 'chain_step_composition': { + // group_read (a direct evidence) used as a CHAIN STEP inside can_via: + // the step is expanded at compile time to the underlying can_view edge. + const [pm, pv] = ps; + evidence = `evidence group_read(group: Group, doc: Doc) { group_perm(group, doc) } + evidence can_via(user: Employee, doc: Doc) { member_of(user, *g) { group_read(g, doc) } }`; + edges.push({ src: 'u:1', relation: 'member_of', dst: 'g:1', possibility: pm }); + edges.push({ src: 'g:1', relation: 'group_perm', dst: 'doc:9', possibility: pv }); + oracle = Math.min(pm, pv); + break; + } default: throw new Error(`unknown construct: ${kind}`); } @@ -151,7 +163,7 @@ function runCheck({ kind, ps }) { } const CONSTRUCTS = ['direct', 'chain', 'tuple_to_userset', 'fusion_min', 'fusion_max', - 'when_unless', 'never_always', 'requires_when', 'composition']; + 'when_unless', 'never_always', 'requires_when', 'composition', 'chain_step_composition']; describe('DSL generative oracle parity (rigor)', () => { it('generated legal DSL compiles and every check matches the oracle', async () => { diff --git a/tests/rigor/dsl-illegal-mutations.test.js b/tests/rigor/dsl-illegal-mutations.test.js index 96c444e..6521310 100644 --- a/tests/rigor/dsl-illegal-mutations.test.js +++ b/tests/rigor/dsl-illegal-mutations.test.js @@ -96,6 +96,14 @@ const MUTATIONS = { 'evidence can_read(user: Employee, doc: Doc) { owns(user, doc) }', 'fact can_read(user: Employee, doc: Doc)\n evidence can_read(user: Employee, doc: Doc) { owns(user, doc) }' ) + }, + non_lowerable_chain_step: { + desc: 'a defeasible evidence used as a chain step (cannot lower to an edge)', + mustFail: true, + apply: () => VALID_DSL.replace( + 'evidence can_enter(user: Employee, doc: Doc) { member_of(user, *g) { can_access(g, doc) } }', + 'evidence can_gated(group: Group, doc: Doc) { WHEN can_access(group, doc) UNLESS banned(group) }\n evidence can_enter(user: Employee, doc: Doc) { member_of(user, *g) { can_gated(g, doc) } }' + ).replace('fact can_access(group: Group, doc: Doc)', 'fact can_access(group: Group, doc: Doc)\n fact banned(group: Group)') } };