From fe162251fc63920ba25b1fcb27973a7c963d9760 Mon Sep 17 00:00:00 2001 From: John Dvorak Date: Mon, 3 Aug 2026 12:45:16 -0700 Subject: [PATCH] feat: DSLRuntime schema introspection, per-relation providers, retrieval loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The higher-order DSL+Core wrapper now covers the full contract the DSL informs, beyond the typed mutations already present: - getSchema(): serializable introspection of the compiled type system — entity types/fields, facts (params + injectable flag), evidence (with transitive dependsOn), and registered providers. relationNames() lists all declared relations. (The DSL's type system was always present; this exposes it programmatically.) - registerFact(relation, fn) / unregisterFact / registeredFacts: per-relation async providers that retrieve missing partial-graph edges; per-check factProviders merge OVER registered ones. - Bounded fixed-point provider retrieval loop (maxProviderRounds): each round invokes providers for required facts whose edges are not yet injected. A provider may return edges for relations other than its own — those satisfy the other required facts and can unblock later rounds. - check() now type-validates FACT relations too (not just evidence); edge normalization preserves a provider edge's own relation name. - removeNode / removeRelation passthroughs; require() throws on denial for middleware. - Field typing extended to the DSL's full value-type universe (timestamp/duration accept number or string; object/any accept anything). Tests: DSLRuntimeExt (schema, registration, merge, fixed-point, require, removal, fact-check validation, timestamp typing). --- package.json | 2 +- src/runtime/DSLRuntime.js | 263 ++++++++++++++++++++++++++++-------- tests/DSLRuntimeExt.test.js | 147 ++++++++++++++++++++ 3 files changed, 351 insertions(+), 61 deletions(-) create mode 100644 tests/DSLRuntimeExt.test.js diff --git a/package.json b/package.json index 93d3b27..6a05b03 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@arbiter/evidence-dsl", - "version": "1.4.0", + "version": "1.5.0", "description": "Evidence DSL v2 compiler: translates the natural Evidence DSL (ADR-000) into @arbiter/core relation configurations.", "license": "ISC", "type": "module", diff --git a/src/runtime/DSLRuntime.js b/src/runtime/DSLRuntime.js index cfbe0c5..0856404 100644 --- a/src/runtime/DSLRuntime.js +++ b/src/runtime/DSLRuntime.js @@ -1,6 +1,6 @@ import { DSLCompiler } from '../DSLCompiler.js'; -const PRIMITIVE_TYPES = new Set(['string', 'number', 'boolean']); +const VALUE_TYPES = new Set(['string', 'number', 'boolean', 'timestamp', 'duration', 'object', 'any']); /** * DSLRuntime — higher-order wrapper combining the Evidence DSL with an @@ -12,13 +12,19 @@ const PRIMITIVE_TYPES = new Set(['string', 'number', 'boolean']); * check). A raw Arbiter accepts untyped inserts; this wrapper adds the * DSL-informed layer: * - * - addNode / updateNodeData / addRelation / updateRelation validate their - * arguments against the compiled schema — known types, known relations, - * matching param types, typed field values — before mutating the arbiter. - * - check() validates the request, derives the injectable facts the - * evidence requires (its partial-graph requirements), retrieves the - * missing facts through caller-provided data callbacks, injects them into - * a partial graph, then delegates to the arbiter. + * - schema introspection: getSchema() exposes the compiled type system + * (entity types/fields, facts, evidence, dependencies, providers); + * - typed mutations: addNode / updateNodeData / addRelation / updateRelation + * validate their arguments against the compiled schema — known types, + * known relations, matching param types, typed field values — before + * mutating the arbiter; removeNode / removeRelation pass through; + * - per-relation data retrieval: registerFact(relation, asyncFn) registers a + * provider that retrieves the missing partial-graph edges for a fact; a + * bounded retrieval loop runs providers to a fixed point so a provider's + * edges can satisfy another required fact; + * - DSL-informed check: derives the evidence's injectable facts, retrieves + * them via providers, injects them into a partial graph, and delegates to + * the arbiter; require() throws on denial for middleware use. * * Trust boundary follows the core: caller-supplied evidence (partial graph / * provider results) is trusted, never policed; only structure is validated. @@ -62,6 +68,43 @@ export class DSLRuntime { return this; } + // --------------------------------------------------------------------------- + // Schema introspection + // --------------------------------------------------------------------------- + + /** + * A serializable snapshot of the compiled type system: entity types with + * typed fields, facts, evidence (with their dependencies), and registered + * providers. Callers can use this to render forms, build clients, or audit + * a compiled program without reaching into the internal Maps. + */ + getSchema() { + const types = [...this.types.entries()].map(([name, { fields }]) => ({ + name, + fields: [...fields.entries()].map(([fieldName, f]) => ({ + name: fieldName, + type: f.type, + isArray: f.isArray + })) + })); + const facts = [...this.relations.entries()] + .filter(([, r]) => r.kind === 'fact') + .map(([name, r]) => ({ name, params: r.params, injectable: r.injectable })); + const evidence = [...this.relations.entries()] + .filter(([, r]) => r.kind === 'evidence') + .map(([name, r]) => ({ + name, + params: r.params, + dependsOn: [...(this.dependsOn.get(name) || [])] + })); + return { types, facts, evidence, providers: this.registeredFacts() }; + } + + /** All relation names declared by the program (facts + evidence). */ + relationNames() { + return [...this.relations.keys()]; + } + _indexSchema() { this.types.clear(); this.relations.clear(); @@ -136,12 +179,43 @@ export class DSLRuntime { } } + // --------------------------------------------------------------------------- + // Provider registration (per-relation data retrieval) + // --------------------------------------------------------------------------- + + /** + * Register (or replace) an async provider for a relation name. When a check + * needs that relation's facts and they are not in the graph, the provider is + * invoked to retrieve the missing partial-graph edges. + * + * @param {string} relation - fact relation name + * @param {Function} provider - async (subject, object, ctx) => edges + */ + registerFact(relation, provider) { + if (typeof provider !== 'function') { + throw new Error(`DSLRuntime: provider for '${relation}' must be a function`); + } + this.factProviders[relation] = provider; + return this; + } + + /** Remove a registered provider. */ + unregisterFact(relation) { + delete this.factProviders[relation]; + return this; + } + + /** Relation names that currently have a registered provider. */ + registeredFacts() { + return Object.keys(this.factProviders); + } + // --------------------------------------------------------------------------- // Schema validation helpers // --------------------------------------------------------------------------- - _isPrimitive(typeName) { - return PRIMITIVE_TYPES.has(typeName); + _isValueType(typeName) { + return VALUE_TYPES.has(typeName); } _nodeType(key) { @@ -158,7 +232,7 @@ export class DSLRuntime { } _checkNodeType(key, expectedType, position) { - if (this._isPrimitive(expectedType)) return; // value positions are validated separately + if (this._isValueType(expectedType)) return; // value positions are validated separately const actual = this._nodeType(key); if (actual === null) { this._checkNodeExists(key, position); @@ -184,7 +258,9 @@ export class DSLRuntime { const ok = type === 'string' ? typeof value === 'string' : type === 'number' ? typeof value === 'number' : type === 'boolean' ? typeof value === 'boolean' - : true; // entity-typed fields accept any key + : (type === 'timestamp' || type === 'duration') + ? (typeof value === 'number' || typeof value === 'string') + : true; // object / any / entity-typed fields accept any value if (!ok) { throw new Error(`DSLRuntime: field '${path}' must be ${type}, got ${typeof value}`); } @@ -224,6 +300,14 @@ export class DSLRuntime { return this.arbiter.updateNodeData(key, data); } + /** Remove a node (passthrough to the arbiter's node manager). */ + removeNode(key) { + if (this.arbiter.nodeManager && typeof this.arbiter.nodeManager.removeNode === 'function') { + return this.arbiter.nodeManager.removeNode(key); + } + return this.arbiter.removeNode?.(key); + } + _relationOrThrow(relation) { const meta = this.relations.get(relation); if (!meta) { @@ -258,6 +342,11 @@ export class DSLRuntime { return this.arbiter.addRelation(src, relation, dst, attrs); } + /** Remove a relation edge (passthrough to the arbiter). */ + removeRelation(src, relation, dst) { + return this.arbiter.removeRelation(src, relation, dst); + } + _validateRelationEndpoints(relation, meta, src, dst, attrs) { const params = meta.params; if (params.length === 0) { @@ -265,14 +354,14 @@ export class DSLRuntime { } // First param is always the subject (entity). const subjectType = params[0].type; - if (this._isPrimitive(subjectType)) { + if (this._isValueType(subjectType)) { throw new Error(`DSLRuntime: relation '${relation}' subject param must be an entity type, got '${subjectType}'`); } this._checkNodeType(src, subjectType, 'subject'); if (params.length >= 2) { const secondType = params[1].type; - if (this._isPrimitive(secondType)) { + if (this._isValueType(secondType)) { // Value-carrying fact (e.g. session(user, token: string)): the value // lives on the edge's `value` field; the graph edge is a self-edge on // the subject so the value is discoverable by value extraction. @@ -313,6 +402,13 @@ export class DSLRuntime { * evidence's injectable facts, inject them into a partial graph, and delegate * to the arbiter. * + * Providers run in a bounded fixed-point loop: each round invokes the + * provider for every required fact whose edges are not yet in the partial + * graph. Because a provider may return edges for relations other than its + * own name, an edge injected in one round can satisfy another required fact + * (or unblock another provider) in a later round. The loop stops when a + * round injects no new relation or the round budget is exhausted. + * * @param {string} user - subject key * @param {string} relation - evidence (or fact) relation name * @param {string} object - object key @@ -320,71 +416,103 @@ export class DSLRuntime { * @param {object} options.partialGraph - caller-supplied partial graph edges * ({ relations: [{ src, relation, dst, possibility, value }], nodes, challenges }) * @param {object} options.factProviders - per-call provider overrides + * (merged over registered providers) + * @param {number} options.maxProviderRounds - fixed-point loop budget (default 3) * @returns {object} core check result extended with { requiredFacts, providedFacts, missingFacts } */ async check(user, relation, object, options = {}) { const meta = this.relations.get(relation); if (!meta) { if (this.strictTypes) throw new Error(`DSLRuntime: unknown relation '${relation}'`); - } else if (meta.kind === 'evidence') { - if (meta.params.length === 2) { - this._checkNodeType(user, meta.params[0].type, 'subject'); - this._checkNodeType(object, meta.params[1].type, 'object'); - } + } else if (meta.params.length === 2) { + this._checkNodeType(user, meta.params[0].type, 'subject'); + this._checkNodeType(object, meta.params[1].type, 'object'); + } else if (meta.params.length === 1) { + this._checkNodeType(user, meta.params[0].type, 'subject'); } const required = this.requiredFacts(relation); - const providers = options.factProviders || this.factProviders; - const injectedRelations = []; - const missingFacts = []; + const providers = { ...this.factProviders, ...(options.factProviders || {}) }; + const maxRounds = options.maxProviderRounds ?? 3; const partialRelations = []; + const injectedRelations = []; // { relation, edges, round } + const missingFacts = []; + const satisfied = new Set(); // facts whose edges are in the partial graph if (options.partialGraph && Array.isArray(options.partialGraph.relations)) { - partialRelations.push(...options.partialGraph.relations); + for (const rel of options.partialGraph.relations) { + partialRelations.push(rel); + if (rel && rel.relation) satisfied.add(rel.relation); + } } - for (const fact of required) { - const factMeta = this.relations.get(fact); - const provider = providers[fact]; - let result = null; - let error = null; - if (typeof provider === 'function') { + // Fixed-point provider retrieval loop. + for (let round = 1; round <= maxRounds; round++) { + let newRelationsThisRound = 0; + for (const fact of required) { + if (satisfied.has(fact)) continue; + const factMeta = this.relations.get(fact); + const provider = providers[fact]; + if (typeof provider !== 'function') { + missingFacts.push({ relation: fact, reason: 'no_provider' }); + satisfied.add(fact); + continue; + } + let result = null; + let error = null; try { - result = await provider(user, object, { relation: fact, params: factMeta.params, runtime: this, options }); + result = await provider(user, object, { + relation: fact, + params: factMeta.params, + runtime: this, + options, + round, + alreadyInjected: [...satisfied] + }); } catch (err) { error = err; } + if (error) { + missingFacts.push({ relation: fact, reason: error.message }); + satisfied.add(fact); + continue; + } + if (result === false || result === null || result === undefined) { + missingFacts.push({ relation: fact, reason: 'not_provided' }); + satisfied.add(fact); + continue; + } + const edges = Array.isArray(result) ? result : [result]; + // Resolve the edge destination the same way the DSL declares the fact: + // - unary fact (1 param) -> self-edge on the subject + // - value fact (2nd param value) -> self-edge on the subject carrying the value + // - binary entity fact -> subject → object + const secondParamType = factMeta.params[1] && factMeta.params[1].type; + const defaultDst = factMeta.params.length >= 2 && this._isValueType(secondParamType) + ? user + : (factMeta.params.length >= 2 ? object : user); + for (const edge of edges) { + const normalized = typeof edge === 'boolean' || typeof edge === 'number' + ? { src: user, dst: defaultDst, possibility: edge === true ? 1 : edge } + : { + ...(edge.relation ? { relation: edge.relation } : {}), + src: edge.src ?? user, + dst: edge.dst ?? defaultDst, + possibility: edge.possibility ?? 1, + ...(edge.value !== undefined ? { value: edge.value } : {}), + ...(edge.reliability !== undefined ? { reliability: edge.reliability } : {}) + }; + // A provider may return edges for relations other than its own; the + // injected relation names satisfy those facts too (fixed point). + const injectedRelation = normalized.relation ?? fact; + partialRelations.push({ relation: injectedRelation, ...normalized }); + satisfied.add(injectedRelation); + } + injectedRelations.push({ relation: fact, edges: edges.length, round }); + newRelationsThisRound += edges.length; + satisfied.add(fact); } - if (error) { - missingFacts.push({ relation: fact, reason: error.message }); - continue; - } - if (result === false || result === null || result === undefined) { - missingFacts.push({ relation: fact, reason: 'not_provided' }); - continue; - } - const edges = Array.isArray(result) ? result : [result]; - // Resolve the edge destination the same way the DSL declares the fact: - // - unary fact (1 param) -> self-edge on the subject - // - value fact (2nd param value) -> self-edge on the subject carrying the value - // - binary entity fact -> subject → object - const secondParamType = factMeta.params[1] && factMeta.params[1].type; - const defaultDst = factMeta.params.length >= 2 && this._isPrimitive(secondParamType) - ? user - : (factMeta.params.length >= 2 ? object : user); - for (const edge of edges) { - const normalized = typeof edge === 'boolean' || typeof edge === 'number' - ? { src: user, dst: defaultDst, possibility: edge === true ? 1 : edge } - : { - src: edge.src ?? user, - dst: edge.dst ?? defaultDst, - possibility: edge.possibility ?? 1, - ...(edge.value !== undefined ? { value: edge.value } : {}), - ...(edge.reliability !== undefined ? { reliability: edge.reliability } : {}) - }; - partialRelations.push({ relation: fact, ...normalized }); - } - injectedRelations.push({ relation: fact, edges: edges.length }); + if (newRelationsThisRound === 0) break; } const checkOptions = { ...options }; @@ -404,4 +532,19 @@ export class DSLRuntime { missingFacts }; } + + /** + * Check and throw on denial — convenience for middleware / guards. + * @returns {object} the check result on success. + * @throws {Error} with `.result` attached when the decision denies. + */ + async require(user, relation, object, options = {}) { + const result = await this.check(user, relation, object, options); + if (result.possibility <= 0) { + const error = new Error(`DSLRuntime: authorization denied for '${relation}' (${result.reason || 'denied'})`); + error.result = result; + throw error; + } + return result; + } } diff --git a/tests/DSLRuntimeExt.test.js b/tests/DSLRuntimeExt.test.js new file mode 100644 index 0000000..f4aad6e --- /dev/null +++ b/tests/DSLRuntimeExt.test.js @@ -0,0 +1,147 @@ +/** + * 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/); + }); +});