diff --git a/package-lock.json b/package-lock.json index 8a035be..1197f7a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "@arbiter/evidence-dsl", - "version": "1.4.0", + "version": "1.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@arbiter/evidence-dsl", - "version": "1.4.0", + "version": "1.6.0", "license": "ISC", "dependencies": { - "@arbiter/core": "^1.0.3" + "@arbiter/core": "^1.0.4" }, "devDependencies": { "@rigor/core": "^3.1.0", @@ -17,9 +17,9 @@ } }, "node_modules/@arbiter/core": { - "version": "1.0.3", - "resolved": "https://hub.kl1.tenere.ai/api/packages/Arbiter/npm/%40arbiter%2Fcore/-/1.0.3/core-1.0.3.tgz", - "integrity": "sha512-MCXxyeWBoYjEJMrdO8N8q9uEdX7JgDvwRH39D+8x65zFz+JCNWIQ8H4DgsP2rgF+yzv/cdVH4BX9PfFt6i0ftQ==", + "version": "1.0.4", + "resolved": "https://hub.kl1.tenere.ai/api/packages/Arbiter/npm/%40arbiter%2Fcore/-/1.0.4/core-1.0.4.tgz", + "integrity": "sha512-1zXy3mZACjwELptsV8QpYvZJmMU7BhUQ4FsKNfp3/oJDwjfMqAImcAF3mJ+HNZpQnLQXScmELTwow5VduvN27g==", "license": "ISC", "dependencies": { "@tenere/pltc-core": "^0.6.3", diff --git a/package.json b/package.json index 6a05b03..cfa9f4f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@arbiter/evidence-dsl", - "version": "1.5.0", + "version": "1.6.0", "description": "Evidence DSL v2 compiler: translates the natural Evidence DSL (ADR-000) into @arbiter/core relation configurations.", "license": "ISC", "type": "module", @@ -24,7 +24,7 @@ "generate:parser": "node scripts/generate-parser.js" }, "dependencies": { - "@arbiter/core": "^1.0.3" + "@arbiter/core": "^1.0.4" }, "devDependencies": { "@rigor/core": "^3.1.0", diff --git a/src/generator/RuleGenerator.js b/src/generator/RuleGenerator.js index 0da679a..477b935 100644 --- a/src/generator/RuleGenerator.js +++ b/src/generator/RuleGenerator.js @@ -486,14 +486,21 @@ export class RuleGenerator { reverse: false }; - // Subject-scoped (unary) call: the predicate call's args omit the - // evidence's object parameter (user_risk(user, 1) inside a binary - // evidence) → check the relation on the subject itself. + // Unary predicate calls check the relation as a self-edge on the call's + // subject entity (the graph stores unary facts as self-edges). The subject + // entity may be the evidence's SUBJECT or its OBJECT parameter — mark the + // matching rewrite flag. const evidenceParams = (evidence && evidence.params) || []; const objectVar = evidenceParams[1] && evidenceParams[1].name; const argName = a => a && (a.name !== undefined ? a.name : a.value); - if (objectVar !== undefined && !(predicate.arguments || []).some(a => argName(a) === objectVar)) { - rule._subjectAsObject = true; + const args = predicate.arguments || []; + if (objectVar !== undefined) { + const hasObjectArg = args.some(a => argName(a) === objectVar); + if (args.length === 1 && argName(args[0]) === objectVar) { + rule._subjectIsObject = true; + } else if (!hasObjectArg) { + rule._subjectAsObject = true; + } } return rule; @@ -1082,7 +1089,6 @@ export class RuleGenerator { const out = []; for (let idx = 0; idx < steps.length; idx++) { const step = steps[idx]; - const isLast = idx === steps.length - 1; const stepName = typeof step === 'string' ? step : step.relation; if (stepName && this.evidenceNames.has(stepName)) { if (stack.has(stepName)) { @@ -1105,15 +1111,11 @@ export class RuleGenerator { out.push(...this._expandChainSteps(resolved.steps, refStack)); continue; } - if (isLast) { - // Condition-gated final hop: inline the evidence's config as a - // rule step the engine evaluates at (intermediate, object). - out.push({ rule: this._deepCloneRule(resolved), conditionStep: true }); - continue; - } - this.errors.push(`Chain step '${stepName}' references an evidence with type '${resolved.type || 'logical'}'. ` + - 'Only the final chain step may reference a defeasible/logical evidence (a condition-gated hop); intermediate steps must be edge traversals.'); - out.push(step); + // Condition step: inline the evidence's config as a rule step. As the + // FINAL step the engine verifies it at (intermediate, object); as an + // INTERMEDIATE step the engine EXPANDS it from the current node + // (rule-based reachability) and continues from each discovered node. + out.push({ rule: this._deepCloneRule(resolved), conditionStep: true }); continue; } } @@ -1280,16 +1282,23 @@ export class RuleGenerator { reverse: false }; - // Subject-scoped (unary) predicate call: the call's variable args omit the - // evidence's object parameter (banned(user) inside can_open(user, doc)). - // Mark _subjectAsObject so the engine checks the relation on the subject - // itself — the unary fact's self-edge — instead of (subject, object). + // Unary predicate calls check the relation as a self-edge on the call's + // subject entity (the graph stores unary facts as self-edges). The subject + // entity may be the evidence's SUBJECT or its OBJECT parameter: + // banned(user) in can_open(user, doc) -> self-edge on the user + // trusted(other) in peer_trusted(user, other) -> self-edge on the other + // Mark _subjectAsObject (subject-as-object on the subject entity) or + // _subjectIsObject (the subject entity IS the object parameter) so the + // engine rewrites the pair accordingly. const evidenceParams = (evidence && evidence.params) || []; const objectVar = evidenceParams[1] && evidenceParams[1].name; + const argName = a => a && (a.name !== undefined ? a.name : a.value); if (objectVar !== undefined) { - const hasObjectArg = (expression.args || []).some(a => - a && a.type === 'Variable' && a.name === objectVar); - if (!hasObjectArg) { + const args = expression.args || []; + const hasObjectArg = args.some(a => a && a.type === 'Variable' && a.name === objectVar); + if (args.length === 1 && argName(args[0]) === objectVar) { + rule._subjectIsObject = true; + } else if (!hasObjectArg) { rule._subjectAsObject = true; } } diff --git a/tests/ChainConditionStep.test.js b/tests/ChainConditionStep.test.js index ef50b00..ffca13c 100644 --- a/tests/ChainConditionStep.test.js +++ b/tests/ChainConditionStep.test.js @@ -98,4 +98,32 @@ describe('Chain condition step (logical evidence as final hop)', () => { // max over paths: min(0.5,0.7)=0.5, min(1.0,0.8)=0.8 -> 0.8 assert.equal(arb.check('u:1', 'can_via', 'doc:9').possibility, 0.8); }); + + it('expands an INTERMEDIATE condition step via rule-based reachability', () => { + const { arb, result } = compile(` + definition Employee { id: string } + definition Doc { id: string } + fact peer(user: Employee, other: Employee) + fact trusted(other: Employee) + fact can_read(user: Employee, doc: Doc) + evidence peer_trusted(user: Employee, other: Employee) { WHEN peer(user, other) UNLESS trusted(other) } + evidence can_access(user: Employee, doc: Doc) { peer_trusted(user, *p) { can_read(p, doc) } } + `); + assert.ok(result.success, JSON.stringify(result.errors)); + const steps = arb.relationConfigs.get('can_access').steps; + assert.equal(steps[0].conditionStep, true); + assert.equal(steps[0].rule.type, 'logical'); + + arb.addNode('u:1', 'Employee'); arb.addNode('p:1', 'Employee'); arb.addNode('p:2', 'Employee'); arb.addNode('doc:9', 'Doc'); + arb.addRelation('u:1', 'peer', 'p:1', { possibility: 1.0 }); + arb.addRelation('u:1', 'peer', 'p:2', { possibility: 1.0 }); + arb.addRelation('p:1', 'trusted', 'p:1', { possibility: 1.0 }); // p:1 filtered + arb.addRelation('p:1', 'can_read', 'doc:9', { possibility: 0.9 }); + arb.addRelation('p:2', 'can_read', 'doc:9', { possibility: 0.7 }); + // only untrusted peer p:2 survives the intermediate condition -> 0.7 + assert.equal(arb.check('u:1', 'can_access', 'doc:9').possibility, 0.7); + // trusting p:2 too removes all intermediates -> 0 + arb.addRelation('p:2', 'trusted', 'p:2', { possibility: 1.0 }); + assert.equal(arb.check('u:1', 'can_access', 'doc:9').possibility, 0); + }); }); diff --git a/tests/rigor/dsl-generative-oracle.test.js b/tests/rigor/dsl-generative-oracle.test.js index 6e7aa21..c62e759 100644 --- a/tests/rigor/dsl-generative-oracle.test.js +++ b/tests/rigor/dsl-generative-oracle.test.js @@ -33,8 +33,11 @@ const FACTS = ` fact owner(group: Group, doc: Doc) fact granted(user: Employee, doc: Doc) fact group_perm(group: Group, doc: Doc) - fact banned(user: Employee) fact group_banned(group: Group) + fact banned(user: Employee) + fact peer(user: Employee, other: Employee) + fact trusted(other: Employee) + fact doc_read(user: Employee, doc: Doc) fact mfa(user: Employee) `; @@ -142,6 +145,20 @@ function buildProgram(kind, ps) { oracle = Math.min(pm, pv * (1 - pb)); break; } + case 'chain_intermediate_condition': { + // peer_trusted (a defeasible evidence) as an INTERMEDIATE chain step: + // the engine expands it from the source (peer edges filtered by the + // trusted defeater) then continues to can_read. Oracle = min of the + // surviving peer leg and the read leg. + const [pp, pt, pr] = ps; + evidence = `evidence peer_trusted(user: Employee, other: Employee) { WHEN peer(user, other) UNLESS trusted(other) } + evidence can_via(user: Employee, doc: Doc) { peer_trusted(user, *p) { doc_read(p, doc) } }`; + edges.push({ src: 'u:1', relation: 'peer', dst: 'p:1', possibility: pp }); + edges.push({ src: 'p:1', relation: 'trusted', dst: 'p:1', possibility: pt }); + edges.push({ src: 'p:1', relation: 'doc_read', dst: 'doc:9', possibility: pr }); + oracle = Math.min(pp * (1 - pt), pr); + break; + } default: throw new Error(`unknown construct: ${kind}`); } @@ -162,6 +179,10 @@ function runCheck({ kind, ps }) { arbiter.addNode('u:1', 'Employee'); arbiter.addNode('g:1', 'Group'); arbiter.addNode('doc:9', 'Doc'); + for (const e of edges) { + arbiter.addNode(e.src, e.dst === 'doc:9' ? 'Doc' : 'Employee'); + arbiter.addNode(e.dst, e.dst === 'doc:9' ? 'Doc' : 'Employee'); + } const compiler = new DSLCompiler(arbiter); const compiled = compiler.compile(dsl, 'oracle'); if (!compiled.success) { @@ -178,7 +199,7 @@ function runCheck({ kind, ps }) { const CONSTRUCTS = ['direct', 'chain', 'tuple_to_userset', 'fusion_min', 'fusion_max', 'when_unless', 'never_always', 'requires_when', 'composition', 'chain_step_composition', - 'chain_condition_step']; + 'chain_condition_step', 'chain_intermediate_condition']; describe('DSL generative oracle parity (rigor)', () => { it('generated legal DSL compiles and every check matches the oracle', async () => { @@ -220,6 +241,10 @@ describe('DSL generative oracle parity (rigor)', () => { arbiter.addNode('u:1', 'Employee'); arbiter.addNode('g:1', 'Group'); arbiter.addNode('doc:9', 'Doc'); + for (const e of edges) { + arbiter.addNode(e.src, e.dst === 'doc:9' ? 'Doc' : 'Employee'); + arbiter.addNode(e.dst, e.dst === 'doc:9' ? 'Doc' : 'Employee'); + } const compiled = new DSLCompiler(arbiter).compile(dsl, 'sweep'); assert.ok(compiled.success, `${kind} compile failed: ${(compiled.errors || []).join('; ')}`); for (const e of edges) arbiter.addRelation(e.src, e.relation, e.dst, { possibility: e.possibility });