feat: fix lowering, DSLRuntime wrapper, rigor oracle + rejection campaigns
Lowering fixes (validate/lower/compile into known-correct core structures):
- tuple_to_userset: structural classification by object-side predicate
(owner(*g, doc) { member_of(user, g) } -> tuple_to_userset with direction
'in'/'out'); the old heuristic routed every outer-wildcard to chain.
- relational_comparator: operands now lower to real direct-rule configs
(evaluateFrom derived from evidence param positions; expectedValue for
literal args) instead of raw AST nodes the engine could not evaluate.
- defeasible: multi-level bodies (NEVER/REQUIRES/ALWAYS/WHEN/UNLESS) merge
into one five-level rule instead of ANDed level-only rules that always
resolved 0; nested PatternMatches flatten to N-step chains; unary predicate
calls mark _subjectAsObject (subject-as-object semantics).
- validation: reject duplicate fact/evidence definitions.
DSLRuntime (higher-order DSL+Core wrapper):
- typed addNode/updateNodeData/addRelation/updateRelation against the DSL
schema (known types, relation params, field types, value-carrying facts);
- check() derives the evidence's injectable partial-graph requirements,
retrieves missing facts through caller data callbacks, injects them, and
delegates, returning requiredFacts/providedFacts/missingFacts.
js-rigor campaigns:
- generative oracle: generate legal DSL per construct and compare every
verdict against an independent hand-computed oracle (8 constructs x P grid)
plus an exhaustive deterministic sweep;
- illegal mutations: one-flaw perturbations of a valid program must be
reliably rejected (duplicate evidence/fact, arity/type mismatches, reserved
built-ins, malformed syntax), with a control that must compile.
Depends on @arbiter/core@^1.0.2 (reason codes + _subjectAsObject).
This commit is contained in:
@@ -0,0 +1,403 @@
|
||||
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) deps.add(typeof s === 'string' ? s : s.relation);
|
||||
}
|
||||
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
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user