feat: sources wired as recency-gated injectables; transitive closure, NOT scoping, recompile-scope uninstall; targeted parse errors
CI / test (push) Successful in 20s
CI / publish (push) Has been skipped

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.
This commit is contained in:
John Dvorak
2026-08-03 20:27:13 -07:00
parent 4d498b07e8
commit 2a7f4c315b
10 changed files with 556 additions and 28 deletions
+119
View File
@@ -205,4 +205,123 @@ describe('DSLRuntime', () => {
// evidence's requirements, alongside the edge-traversal fact.
assert.deepEqual(rt.requiredFacts('can_via'), ['member_of', 'can_view', 'banned']);
});
it('negates a NOT predicate (1 - possibility)', async () => {
const dsl = `
definition Employee { id: string? }
fact banned(user: Employee)
evidence can_enter(user: Employee) { NOT banned(user) }
`;
// Absent predicate negates to allow.
const absent = new DSLRuntime(new Arbiter()).compile(dsl, 'rt-not-absent');
absent.addNode('u:1', 'Employee', {});
assert.equal((await absent.check('u:1', 'can_enter', 'u:1')).possibility, 1);
// Present predicate negates to its complement.
const present = new DSLRuntime(new Arbiter()).compile(dsl, 'rt-not-present');
present.addNode('u:1', 'Employee', {});
present.addRelation('u:1', 'banned', 'u:1', { possibility: 0.9 });
const denied = await present.check('u:1', 'can_enter', 'u:1');
assert.ok(Math.abs(denied.possibility - 0.1) < 1e-9, `expected 0.1, got ${denied.possibility}`);
// Nested inside an AND: the negated child still scopes to the subject.
const nested = new DSLRuntime(new Arbiter()).compile(`
definition Employee { id: string? }
definition Doc { id: string? }
fact owns(user: Employee, doc: Doc)
fact banned(user: Employee)
evidence can_open(user: Employee, doc: Doc) { owns(user, doc) NOT banned(user) }
`, 'rt-not-nested');
nested.addNode('u:1', 'Employee', {});
nested.addNode('doc:9', 'Doc', {});
nested.addRelation('u:1', 'owns', 'doc:9', { possibility: 0.8 });
nested.addRelation('u:1', 'banned', 'u:1', { possibility: 0.6 });
assert.equal((await nested.check('u:1', 'can_open', 'doc:9')).possibility, 0.4);
});
it('resolves BEHAVES AS transitive facts as bounded transitive closure', async () => {
const dsl = `
definition Employee { id: string? }
fact reports_to(user: Employee, boss: Employee) BEHAVES AS transitive
evidence can_see(user: Employee, doc: Employee) { reports_to(user, doc) }
`;
const rt = new DSLRuntime(new Arbiter()).compile(dsl, 'rt-transitive');
['e:1', 'e:2', 'e:3', 'e:4'].forEach(k => rt.addNode(k, 'Employee', {}));
rt.addRelation('e:1', 'reports_to', 'e:2', { possibility: 1.0 });
rt.addRelation('e:2', 'reports_to', 'e:3', { possibility: 0.9 });
rt.addRelation('e:3', 'reports_to', 'e:4', { possibility: 0.8 });
// Direct checks on the transitive fact follow multi-hop paths.
assert.equal((await rt.check('e:1', 'reports_to', 'e:3')).possibility, 0.9);
assert.equal((await rt.check('e:1', 'reports_to', 'e:4')).possibility, 0.8);
// Reverse direction does not grant.
assert.equal((await rt.check('e:2', 'reports_to', 'e:1')).possibility, 0);
// Evidence references inherit the closure.
assert.equal((await rt.check('e:1', 'can_see', 'e:4')).possibility, 0.8);
// Non-transitive facts stay direct-only.
const direct = new DSLRuntime(new Arbiter()).compile(`
definition Employee { id: string? }
fact knows(user: Employee, peer: Employee)
evidence can_ping(user: Employee, doc: Employee) { knows(user, doc) }
`, 'rt-nontransitive');
['a:1', 'a:2', 'a:3'].forEach(k => direct.addNode(k, 'Employee', {}));
direct.addRelation('a:1', 'knows', 'a:2', { possibility: 1.0 });
direct.addRelation('a:2', 'knows', 'a:3', { possibility: 1.0 });
assert.equal((await direct.check('a:1', 'can_ping', 'a:3')).possibility, 0);
});
it('bounds transitive closure depth by the fact limit', async () => {
const rt = new DSLRuntime(new Arbiter()).compile(`
definition Employee { id: string? }
fact reports_to(user: Employee, boss: Employee) BEHAVES AS transitive limit 2
evidence can_see(user: Employee, doc: Employee) { reports_to(user, doc) }
`, 'rt-transitive-limit');
['e:1', 'e:2', 'e:3', 'e:4'].forEach(k => rt.addNode(k, 'Employee', {}));
rt.addRelation('e:1', 'reports_to', 'e:2', { possibility: 1.0 });
rt.addRelation('e:2', 'reports_to', 'e:3', { possibility: 1.0 });
rt.addRelation('e:3', 'reports_to', 'e:4', { possibility: 1.0 });
assert.equal((await rt.check('e:1', 'can_see', 'e:3')).possibility, 1.0);
assert.equal((await rt.check('e:1', 'can_see', 'e:4')).possibility, 0);
});
it('retrieves sources through providers and gates them by within recency', async () => {
const rt = new DSLRuntime(new Arbiter()).compile(`
definition Employee { id: string? }
definition Doc { id: string? }
source *session(user: Employee) within 1h
fact *owns(user: Employee, doc: Doc)
evidence can_read(user: Employee, doc: Doc) { session(user) owns(user, doc) }
`, 'rt-source');
rt.addNode('u:1', 'Employee', {});
rt.addNode('doc:9', 'Doc', {});
assert.ok(rt.relations.has('session'));
assert.equal(rt.relations.get('session').kind, 'source');
assert.equal(rt.relations.get('session').withinMs, 3_600_000);
// A fresh session proof grants.
const fresh = await rt.check('u:1', 'can_read', 'doc:9', {
factProviders: { session: async () => ({ possibility: 1.0, value: Date.now() }), owns: async () => 0.9 }
});
assert.equal(fresh.possibility, 0.9);
assert.deepEqual(fresh.providedFacts, ['session', 'owns']);
// A stale proof (2h old, beyond the 1h window) denies as stale.
const stale = await rt.check('u:1', 'can_read', 'doc:9', {
factProviders: { session: async () => ({ possibility: 1.0, value: Date.now() - 2 * 3600_000 }), owns: async () => 0.9 }
});
assert.equal(stale.possibility, 0);
assert.deepEqual(stale.missingFacts, [{ relation: 'session', reason: 'stale' }]);
});
it('warns about and drops provider edges referencing unknown nodes', async () => {
const rt = new DSLRuntime(new Arbiter()).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-ghost');
rt.addNode('u:1', 'Employee', {});
rt.addNode('doc:9', 'Doc', {});
const res = await rt.check('u:1', 'can_read', 'doc:9', {
factProviders: { owns: async () => [{ src: 'ghost:1', dst: 'doc:9', possibility: 0.9 }] }
});
assert.equal(res.possibility, 0);
assert.equal(res.warnings.length, 1);
assert.match(res.warnings[0], /unknown source node 'ghost:1'/);
});
});