/** * tests/DSLRuntime.test.js — higher-order DSL+Core wrapper. * * Covers: * - schema indexing (types, relations, injectable facts, dependency graph) * - typed inserts/updates (addNode / updateNodeData / addRelation / updateRelation) * reject unknown types, wrong node types, and mistyped field values * - DSL-informed check: derives partial-graph requirements, retrieves missing * injectable facts through providers, injects them, and delegates * - missing-fact reporting * * NOTE: referencing a derived evidence relation as a sub-rule of another rule * (e.g. `WHEN can_read(user, doc)` where can_read is an evidence) lowers to a * direct edge lookup and does NOT re-derive the evidence's config. Evidence * composition across rules is a documented gap (use fusion or facts). */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { Arbiter } from '@arbiter/core'; import { DSLRuntime } from '../src/runtime/DSLRuntime.js'; const BASE_DSL = ` definition Employee { id: string? level: number? active: boolean? } definition Group { id: string? } definition Doc { id: string? } fact member_of(user: Employee, group: Group) fact *owns(user: Employee, doc: Doc) fact *user_score(user: Employee, value: number) fact *granted(user: Employee, doc: Doc) fact can_access(group: Group, doc: Doc) evidence can_read(user: Employee, doc: Doc) { owns(user, doc) } evidence can_enter(user: Employee, doc: Doc) { member_of(user, *g) { can_access(g, doc) } } evidence can_borrow(user: Employee, doc: Doc) { WHEN granted(user, doc) UNLESS user_score(user, 1) } `; function makeRuntime() { return new DSLRuntime(new Arbiter()).compile(BASE_DSL, 'rt-test'); } describe('DSLRuntime', () => { it('indexes the DSL schema', () => { const rt = makeRuntime(); assert.ok(rt.types.has('Employee')); assert.equal(rt.types.get('Employee').fields.get('level').type, 'number'); assert.equal(rt.relations.get('owns').kind, 'fact'); assert.equal(rt.relations.get('owns').injectable, true); assert.equal(rt.relations.get('member_of').injectable, false); assert.equal(rt.relations.get('can_read').kind, 'evidence'); assert.deepEqual(rt.requiredFacts('can_read'), ['owns']); }); it('validates typed node inserts', () => { const rt = makeRuntime(); rt.addNode('u:1', 'Employee', { level: 3, active: true }); assert.throws(() => rt.addNode('g:1', 'Ghost', {}), /unknown type/); assert.throws(() => rt.addNode('u:2', 'Employee', { level: 'high' }), /must be number/); assert.throws(() => rt.addNode('u:3', 'Employee', { active: 'yes' }), /must be boolean/); }); it('validates node updates against the declared type', () => { const rt = makeRuntime(); rt.addNode('u:1', 'Employee', { level: 3, active: true }); rt.updateNodeData('u:1', { level: 5 }); assert.throws(() => rt.updateNodeData('u:1', { level: 'x' }), /must be number/); }); it('validates relation endpoints against declared param types', () => { const rt = makeRuntime(); rt.addNode('u:1', 'Employee', {}); rt.addNode('g:1', 'Group', {}); rt.addNode('doc:9', 'Doc', {}); rt.addRelation('u:1', 'member_of', 'g:1', { possibility: 1.0 }); assert.throws(() => rt.addRelation('u:1', 'member_of', 'doc:9', {}), /expected 'Group'/); assert.throws(() => rt.addRelation('u:1', 'ghost_relation', 'g:1', {}), /unknown relation/); // value-param fact: second param is a number value, dst must be the subject rt.addRelation('u:1', 'user_score', 'u:1', { possibility: 1.0, value: 5 }); assert.throws(() => rt.addRelation('u:1', 'user_score', 'g:1', { possibility: 1.0, value: 5 }), /self-edge/); assert.throws(() => rt.addRelation('u:1', 'user_score', 'u:1', { possibility: 1.0, value: 'high' }), /must be number/); }); it('updateRelation validates and replaces', () => { const rt = makeRuntime(); rt.addNode('u:1', 'Employee', {}); rt.addNode('g:1', 'Group', {}); rt.addNode('doc:9', 'Doc', {}); rt.addRelation('u:1', 'member_of', 'g:1', { possibility: 0.5 }); rt.updateRelation('u:1', 'member_of', 'g:1', { possibility: 1.0 }); assert.equal(rt.arbiter.check('u:1', 'member_of', 'g:1').possibility, 1.0); assert.throws(() => rt.updateRelation('u:1', 'member_of', 'doc:9', {}), /expected 'Group'/); }); it('DSL-informed check retrieves injectable facts via providers', async () => { const rt = makeRuntime(); rt.addNode('u:1', 'Employee', {}); rt.addNode('doc:9', 'Doc', {}); const res = await rt.check('u:1', 'can_read', 'doc:9', { factProviders: { owns: async () => 0.8 } }); assert.equal(res.possibility, 0.8); assert.equal(res.reason, 'allow_rule_matched'); assert.deepEqual(res.requiredFacts, ['owns']); assert.deepEqual(res.providedFacts, ['owns']); assert.deepEqual(res.missingFacts, []); }); it('reports missing facts when a provider declines', async () => { const rt = makeRuntime(); rt.addNode('u:1', 'Employee', {}); rt.addNode('doc:9', 'Doc', {}); const res = await rt.check('u:1', 'can_read', 'doc:9', { factProviders: { owns: async () => null } }); assert.equal(res.possibility, 0); assert.deepEqual(res.missingFacts, [{ relation: 'owns', reason: 'not_provided' }]); }); it('merges caller-supplied partial graphs with provider results', async () => { const rt = makeRuntime(); rt.addNode('u:1', 'Employee', {}); rt.addNode('doc:9', 'Doc', {}); const res = await rt.check('u:1', 'can_read', 'doc:9', { partialGraph: { relations: [{ src: 'u:1', relation: 'owns', dst: 'doc:9', possibility: 1.0 }] }, factProviders: { owns: async () => null } }); assert.equal(res.possibility, 1.0); }); it('unary condition inside binary evidence (subject-as-object) defeats the grant', async () => { const rt = makeRuntime(); rt.addNode('u:1', 'Employee', {}); rt.addNode('doc:9', 'Doc', {}); // can_borrow: WHEN granted(user, doc) UNLESS user_score(user, 1). // user_score is injectable+unary; the provider injects a user self-edge // with value 1 -> the unless fires and defeats the grant. const res = await rt.check('u:1', 'can_borrow', 'doc:9', { factProviders: { granted: async () => 0.9, user_score: async () => ({ possibility: 1.0, value: 1 }) } }); assert.equal(res.possibility, 0); assert.equal(res.reason, 'defeated_by_unless'); assert.deepEqual(res.requiredFacts, ['granted', 'user_score']); }); it('chain evidence across an intermediate validates and checks', async () => { const rt = makeRuntime(); rt.addNode('u:1', 'Employee', {}); rt.addNode('g:1', 'Group', {}); rt.addNode('doc:9', 'Doc', {}); rt.addRelation('u:1', 'member_of', 'g:1', { possibility: 1.0 }); rt.addRelation('g:1', 'can_access', 'doc:9', { possibility: 0.7 }); // can_enter: member_of(user, *g) { can_access(g, doc) } — chain [member_of, can_access] const res = await rt.check('u:1', 'can_enter', 'doc:9', {}); assert.equal(res.possibility, 0.7); }); it('rejects checks against unknown relations in strict mode', async () => { const rt = makeRuntime(); rt.addNode('u:1', 'Employee', {}); rt.addNode('doc:9', 'Doc', {}); await assert.rejects(() => rt.check('u:1', 'does_not_exist', 'doc:9'), /unknown relation/); }); it('derives transitive required facts through evidence composition', async () => { const dsl = ` definition Employee { id: string? } definition Doc { id: string? } fact *owns(user: Employee, doc: Doc) fact *banned(user: Employee) evidence can_read(user: Employee, doc: Doc) { owns(user, doc) } evidence can_open(user: Employee, doc: Doc) { WHEN can_read(user, doc) UNLESS banned(user) } `; const rt = new DSLRuntime(new Arbiter()).compile(dsl, 'rt-comp'); rt.addNode('u:1', 'Employee', {}); rt.addNode('doc:9', 'Doc', {}); // can_open composes can_read, so its requirements reach through to owns. assert.deepEqual(rt.requiredFacts('can_open'), ['owns', 'banned']); const granted = await rt.check('u:1', 'can_open', 'doc:9', { factProviders: { owns: async () => 0.9, banned: async () => 0 } }); assert.equal(granted.possibility, 0.9); assert.equal(granted.reason, 'allow_rule_matched'); assert.deepEqual(granted.providedFacts, ['owns', 'banned']); const denied = await rt.check('u:1', 'can_open', 'doc:9', { factProviders: { owns: async () => 0.9, banned: async () => 1 } }); assert.equal(denied.possibility, 0); assert.equal(denied.reason, 'defeated_by_unless'); }); it('derives transitive required facts through a condition-step chain', () => { const dsl = ` definition Employee { id: string? } definition Group { id: string? } definition Doc { id: string? } fact *member_of(user: Employee, group: Group) fact *can_view(group: Group, doc: Doc) fact *banned(group: Group) 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) } } `; const rt = new DSLRuntime(new Arbiter()).compile(dsl, 'rt-cond'); // The condition step's facts (can_view, banned) reach through to the // evidence's requirements, alongside the edge-traversal fact. assert.deepEqual(rt.requiredFacts('can_via'), ['member_of', 'can_view', 'banned']); }); });