import { DSLCompiler } from '../DSLCompiler.js'; const PRIMITIVE_TYPES = new Set(['string', 'number', 'boolean']); /** * DSLRuntime — higher-order wrapper combining the Evidence DSL with an * @arbiter/core Arbiter. * * The DSL declares a typed schema: `definition` blocks (entity types with * typed fields), `fact` declarations (relations with typed params, optional * `*` injectable marker), and `evidence` rules (relations the runtime can * 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. * * Trust boundary follows the core: caller-supplied evidence (partial graph / * provider results) is trusted, never policed; only structure is validated. */ export class DSLRuntime { /** * @param {object} arbiter - An @arbiter/core Arbiter instance. * @param {object} options * @param {object} options.factProviders - relation → async fn(subject, object, ctx) * returning a boolean, possibility number, { possibility, value }, or an * array of { src, relation, dst, possibility, value } partial-graph edges. * @param {object} options.policy * @param {boolean} options.policy.strictTypes - throw on unknown types/relations * (default true; false degrades to arbiter behavior for undeclared names). */ constructor(arbiter, options = {}) { this.arbiter = arbiter; this.compiler = new DSLCompiler(this.arbiter); this.factProviders = options.factProviders || {}; this.strictTypes = options.policy?.strictTypes !== false; this.program = null; this.types = new Map(); // typeName -> { fields: Map(field -> {type,isArray}) } this.relations = new Map(); // relation -> { kind: 'fact'|'evidence', params, injectable } this.dependsOn = new Map(); // evidence relation -> Set(fact relations) } /** * Compile a DSL program and index its schema. Returns this for chaining. * @param {string} dsl * @param {string} name */ compile(dsl, name) { const result = this.compiler.compile(dsl, name); if (!result.success) { const error = new Error(`DSLRuntime compile failed: ${(result.errors || []).join('; ')}`); error.errors = result.errors || []; throw error; } this.program = result.program; this._indexSchema(); return this; } _indexSchema() { this.types.clear(); this.relations.clear(); this.dependsOn.clear(); for (const def of this.program.definitions || []) { const fields = new Map(); for (const field of def.fields || []) { fields.set(field.name, { type: field.fieldType, isArray: !!field.isArray }); } this.types.set(def.name, { fields }); } for (const fact of this.program.facts || []) { this.relations.set(fact.name, { kind: 'fact', params: (fact.params || []).map(p => ({ name: p.name, type: p.paramType, isArray: !!p.isArray })), injectable: !!fact.injectable }); } for (const ev of this.program.evidence || []) { this.relations.set(ev.name, { kind: 'evidence', params: (ev.params || []).map(p => ({ name: p.name, type: p.paramType, isArray: !!p.isArray })), injectable: false }); } // Index each evidence's fact dependencies from the compiled arbiter configs. for (const ev of this.program.evidence || []) { const config = this.arbiter.relationConfigs.get(ev.name); const deps = new Set(); const collect = (rule) => { if (!rule || typeof rule !== 'object') return; if (rule.type === 'direct' && rule.relation) deps.add(rule.relation); if (rule.type === 'tuple_to_userset') { if (rule.tuplesetRelation) deps.add(rule.tuplesetRelation); if (rule.computedRelation) deps.add(rule.computedRelation); } if (rule.type === 'chain' && Array.isArray(rule.steps)) { for (const s of rule.steps) { if (typeof s === 'string') deps.add(s); else if (s && s.relation) deps.add(s.relation); else if (s && s.rule) collect(s.rule); } } if (rule.type === 'parent' && rule.parentRelation) deps.add(rule.parentRelation); if (rule.type === 'multi_hop' && rule.relation) deps.add(rule.relation); if (rule.type === 'relational_comparator') { collect(rule.left?.rule); collect(rule.right?.rule); if (rule.left?.valueRelation) deps.add(rule.left.valueRelation); if (rule.right?.valueRelation) deps.add(rule.right.valueRelation); } for (const key of ['union', 'intersection', 'exclusion', 'never', 'always', 'requires', 'when', 'unless']) { const node = rule[key]; if (!node) continue; if (Array.isArray(node.rules)) for (const c of node.rules) collect(c); if (Array.isArray(node.union?.rules)) for (const c of node.union.rules) collect(c); if (Array.isArray(node.intersection?.rules)) for (const c of node.intersection.rules) collect(c); if (node.direct) collect(node.direct); if (node.rule) collect(node.rule); } }; if (config && Array.isArray(config.dependsOn)) { for (const d of config.dependsOn) deps.add(d); } else { collect(config); } this.dependsOn.set(ev.name, deps); } } // --------------------------------------------------------------------------- // Schema validation helpers // --------------------------------------------------------------------------- _isPrimitive(typeName) { return PRIMITIVE_TYPES.has(typeName); } _nodeType(key) { const nodeId = this.arbiter.resolveNodeId(key); if (nodeId === undefined) return null; const node = this.arbiter.nodes.get(nodeId); return node ? node.type : null; } _checkNodeExists(key, position) { if (!this.arbiter.nodeIdByKey.has(key)) { throw new Error(`DSLRuntime: ${position} node '${key}' does not exist`); } } _checkNodeType(key, expectedType, position) { if (this._isPrimitive(expectedType)) return; // value positions are validated separately const actual = this._nodeType(key); if (actual === null) { this._checkNodeExists(key, position); return; } if (actual !== expectedType) { throw new Error(`DSLRuntime: ${position} node '${key}' has type '${actual}', expected '${expectedType}'`); } } _checkFieldValue(field, value, path) { if (field.isArray) { if (!Array.isArray(value)) { throw new Error(`DSLRuntime: field '${path}' must be an array of ${field.type}`); } for (const item of value) this._checkScalarValue(field.type, item, path); return; } this._checkScalarValue(field.type, value, path); } _checkScalarValue(type, value, path) { const ok = type === 'string' ? typeof value === 'string' : type === 'number' ? typeof value === 'number' : type === 'boolean' ? typeof value === 'boolean' : true; // entity-typed fields accept any key if (!ok) { throw new Error(`DSLRuntime: field '${path}' must be ${type}, got ${typeof value}`); } } // --------------------------------------------------------------------------- // Typed mutations // --------------------------------------------------------------------------- /** * Insert a node, validating the type exists (when declared) and that `data` * conforms to the definition's typed fields. */ addNode(key, typeName, data = {}) { if (this.types.has(typeName)) { const { fields } = this.types.get(typeName); for (const [name, field] of fields) { if (data[name] !== undefined) this._checkFieldValue(field, data[name], `${typeName}.${name}`); } } else if (this.strictTypes) { throw new Error(`DSLRuntime: unknown type '${typeName}'`); } return this.arbiter.addNode(key, typeName, data); } /** * Update node data, validating fields against the node's declared type. */ updateNodeData(key, data) { const typeName = this._nodeType(key); if (typeName && this.types.has(typeName)) { const { fields } = this.types.get(typeName); for (const [name, field] of fields) { if (data[name] !== undefined) this._checkFieldValue(field, data[name], `${typeName}.${name}`); } } return this.arbiter.updateNodeData(key, data); } _relationOrThrow(relation) { const meta = this.relations.get(relation); if (!meta) { if (this.strictTypes) throw new Error(`DSLRuntime: unknown relation '${relation}'`); return null; } return meta; } /** * Insert a relation edge. Validates the relation is declared, that the * subject/object nodes match the declared entity param types, and that any * primitive value param is supplied in attrs.value of the correct type. */ addRelation(src, relation, dst, attrs = {}) { const meta = this._relationOrThrow(relation); if (meta) { this._validateRelationEndpoints(relation, meta, src, dst, attrs); } return this.arbiter.addRelation(src, relation, dst, attrs); } /** * Update a relation edge (idempotent replace). Validates like addRelation. */ updateRelation(src, relation, dst, attrs = {}) { const meta = this._relationOrThrow(relation); if (meta) { this._validateRelationEndpoints(relation, meta, src, dst, attrs); } this.arbiter.removeRelation(src, relation, dst); return this.arbiter.addRelation(src, relation, dst, attrs); } _validateRelationEndpoints(relation, meta, src, dst, attrs) { const params = meta.params; if (params.length === 0) { throw new Error(`DSLRuntime: relation '${relation}' declares no parameters`); } // First param is always the subject (entity). const subjectType = params[0].type; if (this._isPrimitive(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)) { // 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. if (attrs.value === undefined) { attrs.value = dst; } this._checkScalarValue(secondType, attrs.value, `${relation}.${params[1].name}`); if (dst !== src) { throw new Error(`DSLRuntime: value param '${params[1].name}' must be supplied as attrs.value with dst = src (self-edge), got dst '${dst}'`); } } else { this._checkNodeType(dst, secondType, 'object'); } } } /** * The partial-graph requirements of an evidence relation: the declared * injectable facts it depends on. */ requiredFacts(relation) { const deps = this.dependsOn.get(relation); if (!deps) return []; const required = []; for (const dep of deps) { const meta = this.relations.get(dep); if (meta && meta.kind === 'fact' && meta.injectable) required.push(dep); } return required; } // --------------------------------------------------------------------------- // DSL-informed check // --------------------------------------------------------------------------- /** * Validate a check request against the DSL schema, derive and retrieve the * evidence's injectable facts, inject them into a partial graph, and delegate * to the arbiter. * * @param {string} user - subject key * @param {string} relation - evidence (or fact) relation name * @param {string} object - object key * @param {object} options * @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 * @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'); } } const required = this.requiredFacts(relation); const providers = options.factProviders || this.factProviders; const injectedRelations = []; const missingFacts = []; const partialRelations = []; if (options.partialGraph && Array.isArray(options.partialGraph.relations)) { partialRelations.push(...options.partialGraph.relations); } 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') { try { result = await provider(user, object, { relation: fact, params: factMeta.params, runtime: this, options }); } catch (err) { error = err; } } 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 }); } const checkOptions = { ...options }; if (partialRelations.length > 0) { checkOptions.partialGraph = { ...(options.partialGraph || {}), relations: partialRelations }; } const result = this.arbiter.check(user, relation, object, checkOptions); return { ...result, requiredFacts: required, providedFacts: injectedRelations.map(r => r.relation), missingFacts }; } }