2a7f4c315b
BEHAVES AS transitive now emits bounded multi_hop configs (direct checks and evidence references), fixing a silent no-op. NOT builds keep _subjectAsObject scoping so unary predicates negate the right node, and value-typed evidence objects gate by exact edge value. Recompiling a scope uninstalls its stale relation configs (compileMultiple coexistence preserved). Sources become injectable relations honored by requiredFacts with a within-X recency gate. Duplicate definition fields and three common declaration mistakes (within on a fact, two BEHAVES clauses, limit on a non-pattern body) now produce targeted errors. Provider edges referencing unknown nodes are warned and dropped.
198 lines
8.6 KiB
JavaScript
198 lines
8.6 KiB
JavaScript
/**
|
|
* 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'));
|
|
});
|
|
});
|