/** * tests/DSLRuntimeExt.test.js — extended DSLRuntime capabilities: * - schema introspection (getSchema) * - per-relation provider registration (registerFact/unregisterFact) * - provider merging (registered + per-check overrides) * - bounded fixed-point provider retrieval loop (edges satisfy other facts) * - require() throw-on-deny * - removal passthroughs and fact-relation check validation * - timestamp/duration field typing */ 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 Doc { id: string? created: timestamp? } 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) } `; function makeRuntime() { return new DSLRuntime(new Arbiter()).compile(BASE_DSL, 'rt-ext'); } describe('DSLRuntime extended', () => { it('exposes a serializable schema snapshot', () => { const rt = makeRuntime(); const schema = rt.getSchema(); assert.ok(Array.isArray(schema.types)); const employee = schema.types.find(t => t.name === 'Employee'); assert.ok(employee); assert.ok(employee.fields.some(f => f.name === 'level' && f.type === 'number')); const owns = schema.facts.find(f => f.name === 'owns'); assert.equal(owns.injectable, true); assert.equal(owns.params[1].type, 'Doc'); const can_open = schema.evidence.find(e => e.name === 'can_open'); assert.ok(can_open.dependsOn.includes('owns')); assert.deepEqual(schema.providers, []); assert.ok(rt.relationNames().includes('owns') && rt.relationNames().includes('can_read')); }); it('registers, lists, and unregisters per-relation providers', () => { const rt = makeRuntime(); rt.registerFact('owns', async () => 0.8); assert.deepEqual(rt.registeredFacts(), ['owns']); rt.registerFact('banned', async () => 0); assert.deepEqual(rt.registeredFacts().sort(), ['banned', 'owns']); rt.unregisterFact('banned'); assert.deepEqual(rt.registeredFacts(), ['owns']); assert.throws(() => rt.registerFact('owns', 'not a function'), /must be a function/); }); it('merges registered providers with per-check overrides', async () => { const rt = makeRuntime(); rt.registerFact('owns', async () => 0.5); rt.registerFact('banned', async () => 0); rt.addNode('u:1', 'Employee', {}); rt.addNode('doc:9', 'Doc', {}); // registered owns (0.5) wins over nothing; per-check banned overrides const res = await rt.check('u:1', 'can_open', 'doc:9', { factProviders: { banned: async () => 0 } }); assert.equal(res.possibility, 0.5); assert.deepEqual(res.providedFacts.sort(), ['banned', 'owns']); }); it('runs providers to a fixed point when edges satisfy other required facts', async () => { // can_open needs owns (injectable). A registered owns provider returns an // edge for a DIFFERENT injectable fact that can_open also requires via // composition — here we add a transitive requirement to prove the loop. const dsl = ` definition Employee { id: string? } definition Doc { id: string? } fact *owns(user: Employee, doc: Doc) fact *granted(user: Employee, doc: Doc) evidence base_read(user: Employee, doc: Doc) { owns(user, doc) } evidence can_open(user: Employee, doc: Doc) { WHEN base_read(user, doc) UNLESS granted(user, doc) } `; const rt = new DSLRuntime(new Arbiter()).compile(dsl, 'rt-loop'); rt.addNode('u:1', 'Employee', {}); rt.addNode('doc:9', 'Doc', {}); let ownsCalls = 0; let grantedCalls = 0; rt.registerFact('owns', async () => { ownsCalls++; // First round the owns provider also supplies the granted edge (a // fixed-point dependency: granted needs owns to have been retrieved). return [ { src: 'u:1', relation: 'owns', dst: 'doc:9', possibility: 0.9 }, { src: 'u:1', relation: 'granted', dst: 'doc:9', possibility: 0 } ]; }); rt.registerFact('granted', async () => { grantedCalls++; return 0; }); const res = await rt.check('u:1', 'can_open', 'doc:9', { maxProviderRounds: 3 }); assert.equal(res.possibility, 0.9); // granted was satisfied by the owns provider's extra edge, so its own // provider was never needed in a later round. assert.equal(grantedCalls, 0); assert.ok(ownsCalls >= 1); assert.deepEqual(res.providedFacts, ['owns']); assert.deepEqual(res.missingFacts, []); }); it('require() throws on denial and returns the result on grant', async () => { const rt = makeRuntime(); rt.addNode('u:1', 'Employee', {}); rt.addNode('doc:9', 'Doc', {}); rt.registerFact('owns', async () => 0.9); const ok = await rt.require('u:1', 'can_read', 'doc:9'); assert.equal(ok.possibility, 0.9); rt.registerFact('owns', async () => 0); await assert.rejects( () => rt.require('u:1', 'can_read', 'doc:9'), (err) => err.result && err.result.possibility === 0 && /denied/.test(err.message) ); }); it('passes through node/relation removal', () => { const rt = makeRuntime(); rt.addNode('u:1', 'Employee', {}); rt.addNode('doc:9', 'Doc', {}); rt.addRelation('u:1', 'owns', 'doc:9', { possibility: 1.0 }); rt.removeRelation('u:1', 'owns', 'doc:9'); assert.equal(rt.arbiter.check('u:1', 'owns', 'doc:9').possibility, 0); rt.removeNode('u:1'); assert.equal(rt.arbiter.nodeIdByKey.has('u:1'), false); }); it('validates fact-relation check endpoints like evidence', async () => { const rt = makeRuntime(); rt.addNode('u:1', 'Employee', {}); rt.addNode('doc:9', 'Doc', {}); // can_read is evidence; owns is a fact — checking a fact still validates. await assert.rejects(() => rt.check('u:1', 'owns', 'u:1', {}), /expected 'Doc'/); }); it('accepts timestamp field values and rejects mistyped ones', () => { const rt = makeRuntime(); rt.addNode('doc:9', 'Doc', { created: 1720000000000 }); rt.updateNodeData('doc:9', { created: '2026-08-03T00:00:00Z' }); assert.throws(() => rt.addNode('doc:8', 'Doc', { created: {} }), /must be timestamp/); }); it('direct FACT checks consult the registered provider', async () => { const rt = makeRuntime(); rt.addNode('u:1', 'Employee', {}); rt.addNode('doc:9', 'Doc', {}); let calls = 0; rt.registerFact('owns', async () => { calls++; return 0.9; }); // Checking the fact directly (not via an evidence) must retrieve it. const res = await rt.check('u:1', 'owns', 'doc:9'); assert.equal(res.possibility, 0.9); assert.equal(calls, 1); assert.deepEqual(res.requiredFacts, ['owns']); assert.deepEqual(res.providedFacts, ['owns']); // Without a provider and without an edge, it reports the missing fact. const rt2 = new DSLRuntime(new Arbiter()).compile(BASE_DSL, 'rt-fact-miss'); rt2.addNode('u:1', 'Employee', {}); rt2.addNode('doc:9', 'Doc', {}); const missed = await rt2.check('u:1', 'owns', 'doc:9'); assert.equal(missed.possibility, 0); assert.deepEqual(missed.missingFacts, [{ relation: 'owns', reason: 'no_provider' }]); }); it('recompiling a scope uninstalls its stale relation configs', async () => { const rt = new DSLRuntime(new Arbiter()); rt.compile(` definition Employee { id: string? } definition Doc { id: string? } fact owns(user: Employee, doc: Doc) evidence can_read(user: Employee, doc: Doc) { owns(user, doc) } `, 'rt-stale'); rt.addNode('u:1', 'Employee', {}); rt.addNode('doc:9', 'Doc', {}); rt.addRelation('u:1', 'owns', 'doc:9', { possibility: 1.0 }); assert.equal(rt.arbiter.check('u:1', 'can_read', 'doc:9').possibility, 1.0); // Recompile the SAME scope with a different program: can_read/owns are no // longer declared and must not keep granting. A second scope's relations // would be unaffected (compileMultiple coexistence). rt.compile(` definition Employee { id: string? } definition Doc { id: string? } fact shares(user: Employee, doc: Doc) evidence can_share(user: Employee, doc: Doc) { shares(user, doc) } `, 'rt-stale'); const stale = rt.arbiter.check('u:1', 'can_read', 'doc:9'); assert.equal(stale.possibility, 0, 'revoked can_read must not keep granting'); assert.ok(!rt.arbiter.relationConfigs.has('can_read')); assert.ok(!rt.arbiter.relationConfigs.has('owns')); assert.ok(rt.arbiter.relationConfigs.has('can_share')); }); });