1 Commits

Author SHA1 Message Date
John Dvorak 9111c4b20d feat: duration seconds; required fields; type-validated insertions/updates/retrievals
CI / publish (push) Successful in 9s
CI / test (push) Successful in 19s
- Duration literals now accept s/m/h/d/w (was m/h/d/w): 'BEHAVES { ttl 30s }'
  is 30s, and 'within 30s' temporal expressions parse.
- Definition fields are REQUIRED by default ('field: type'); 'field: type?'
  marks a field optional. addNode enforces presence on insert, getSchema
  exposes per-field requiredness, and updateNodeData still validates the
  provided fields' types.
- Provider-returned edges are validated against the fact's declared typing:
  a value-carrying fact must return { value, possibility } with a value of the
  declared type (bare-number shorthand is rejected); a non-value fact must not
  carry a value; and every possibility must lie in [0, 1]. Violations throw a
  clear provider-authoring error instead of silently injecting malformed edges.

Tests: DSLRuntimeTyping (duration units, required-field enforcement, schema
requiredness, value-type + shape + possibility validation).
2026-08-03 16:00:49 -07:00
8 changed files with 856 additions and 707 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@arbiter/evidence-dsl",
"version": "1.9.0",
"version": "1.10.0",
"description": "Evidence DSL v2 compiler: translates the natural Evidence DSL (ADR-000) into @arbiter/core relation configurations.",
"license": "ISC",
"type": "module",
+5 -2
View File
@@ -54,11 +54,14 @@ Definition "A type definition"
}
Field
= name:Identifier _ ":" _ fieldType:Type _ isArray:("[]")? _ behavior:Behavior? _ cache:CacheDirective? {
= name:Identifier _ ":" _ fieldType:Type optional:("?")? _ isArray:("[]")? _ behavior:Behavior? _ cache:CacheDirective? {
return {
type: "Field",
name,
fieldType,
// `field: type` is REQUIRED on node insert; `field: type?` is optional.
// Presence is enforced by the DSLRuntime when a node is created.
required: !optional,
isArray: !!isArray,
behavior: behavior || null,
cache: cache || null
@@ -392,7 +395,7 @@ Boolean "A boolean literal"
= value:("true" / "false") { return { type: "Literal", value: value === "true" }; }
Duration "A time duration literal"
= value:([0-9]+ ("h" / "d" / "w" / "m")) { return { type: "Literal", value: text(), unit: text().slice(-1) }; }
= value:([0-9]+ ("s" / "m" / "h" / "d" / "w")) { return { type: "Literal", value: text(), unit: text().slice(-1) }; }
// -- Core Tokens & Whitespace --
File diff suppressed because it is too large Load Diff
+52 -17
View File
@@ -103,7 +103,8 @@ export class DSLRuntime {
fields: [...fields.entries()].map(([fieldName, f]) => ({
name: fieldName,
type: f.type,
isArray: f.isArray
isArray: f.isArray,
required: f.required !== false
}))
}));
const facts = [...this.relations.entries()]
@@ -132,7 +133,7 @@ export class DSLRuntime {
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 });
fields.set(field.name, { type: field.fieldType, isArray: !!field.isArray, required: field.required !== false });
}
this.types.set(def.name, { fields });
}
@@ -302,7 +303,7 @@ export class DSLRuntime {
const b = behavior.behavior || behavior;
if (b && b.behaviorType === 'ttl' && b.duration) {
const n = parseInt(String(b.duration.value), 10);
const mult = { h: 3600_000, d: 86_400_000, w: 604_800_000, m: 60_000 }[b.duration.unit];
const mult = { s: 1000, m: 60_000, h: 3600_000, d: 86_400_000, w: 604_800_000 }[b.duration.unit];
if (!Number.isNaN(n) && mult) return n * mult;
}
return null;
@@ -313,30 +314,59 @@ export class DSLRuntime {
* array of edge objects) into an array of partial-graph edge objects. The
* destination follows the DSL fact's declared shape: unary and value-carrying
* facts are self-edges on the subject; binary entity facts go subject object.
* Provider-returned edges are validated against the fact's declared typing:
* a value-carrying fact must return an object with a value of the declared
* type, and possibilities must be in [0, 1]. A violation throws it is a
* provider-authoring error, not a denial.
*/
_normalizeProviderEdges(result, factMeta, user, object) {
_normalizeProviderEdges(result, factMeta, fact, user, object) {
const edges = Array.isArray(result) ? result : [result];
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);
const isValueFact = factMeta.params.length >= 2 && this._isValueType(secondParamType);
const defaultDst = isValueFact ? user : (factMeta.params.length >= 2 ? object : user);
const label = `provider for '${fact}'`;
const out = [];
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 } : {})
};
? (() => {
if (isValueFact) {
throw new Error(`DSLRuntime: ${label} is a value-carrying fact — return { value, possibility } (got a bare ${typeof edge === 'number' ? 'number' : 'boolean'})`);
}
const possibility = edge === true ? 1 : edge;
this._checkPossibility(possibility, label);
return { src: user, dst: defaultDst, possibility };
})()
: (() => {
const possibility = edge.possibility ?? 1;
this._checkPossibility(possibility, label);
if (edge.value !== undefined) {
if (!isValueFact) {
throw new Error(`DSLRuntime: ${label} returned a value for a non-value fact '${fact}'`);
}
this._checkScalarValue(secondParamType, edge.value, `${label}.value`);
} else if (isValueFact) {
throw new Error(`DSLRuntime: ${label} must supply a 'value' of type ${secondParamType}`);
}
return {
...(edge.relation ? { relation: edge.relation } : {}),
src: edge.src ?? user,
dst: edge.dst ?? defaultDst,
possibility,
...(edge.value !== undefined ? { value: edge.value } : {}),
...(edge.reliability !== undefined ? { reliability: edge.reliability } : {})
};
})();
out.push(normalized);
}
return out;
}
_checkPossibility(possibility, label) {
if (typeof possibility !== 'number' || !Number.isFinite(possibility) || possibility < 0 || possibility > 1) {
throw new Error(`DSLRuntime: ${label} returned invalid possibility ${possibility} (expected a number in [0, 1])`);
}
}
// ---------------------------------------------------------------------------
// Schema validation helpers
// ---------------------------------------------------------------------------
@@ -405,6 +435,11 @@ export class DSLRuntime {
if (this.types.has(typeName)) {
const { fields } = this.types.get(typeName);
for (const [name, field] of fields) {
// Required fields must be present on insert (`field: type` in the DSL;
// `field?: type` marks a field optional).
if (field.required && data[name] === undefined) {
throw new Error(`DSLRuntime: missing required field '${typeName}.${name}' on node insert`);
}
if (data[name] !== undefined) this._checkFieldValue(field, data[name], `${typeName}.${name}`);
}
} else if (this.strictTypes) {
@@ -634,7 +669,7 @@ export class DSLRuntime {
satisfied.add(fact);
continue;
}
edges = this._normalizeProviderEdges(result, factMeta, user, object);
edges = this._normalizeProviderEdges(result, factMeta, fact, user, object);
if (cachingEnabled && !isPerCheckOverride) this._providerCacheSet(fact, user, object, edges);
} else {
missingFacts.push({ relation: fact, reason: 'no_provider' });
+8 -8
View File
@@ -20,9 +20,9 @@ import { Arbiter } from '@arbiter/core';
import { DSLRuntime } from '../src/runtime/DSLRuntime.js';
const BASE_DSL = `
definition Employee { id: string level: number active: boolean }
definition Group { id: string }
definition Doc { id: string }
definition Employee { id: string? level: number? active: boolean? }
definition Group { id: string? }
definition Doc { id: string? }
fact member_of(user: Employee, group: Group)
fact *owns(user: Employee, doc: Doc)
fact *user_score(user: Employee, value: number)
@@ -164,8 +164,8 @@ describe('DSLRuntime', () => {
it('derives transitive required facts through evidence composition', async () => {
const dsl = `
definition Employee { id: string }
definition Doc { id: string }
definition Employee { id: string? }
definition Doc { id: string? }
fact *owns(user: Employee, doc: Doc)
fact *banned(user: Employee)
evidence can_read(user: Employee, doc: Doc) { owns(user, doc) }
@@ -191,9 +191,9 @@ describe('DSLRuntime', () => {
it('derives transitive required facts through a condition-step chain', () => {
const dsl = `
definition Employee { id: string }
definition Group { id: string }
definition Doc { id: string }
definition Employee { id: string? }
definition Group { id: string? }
definition Doc { id: string? }
fact *member_of(user: Employee, group: Group)
fact *can_view(group: Group, doc: Doc)
fact *banned(group: Group)
+6 -6
View File
@@ -14,8 +14,8 @@ import { Arbiter } from '@arbiter/core';
import { DSLRuntime } from '../src/runtime/DSLRuntime.js';
const BASE_DSL = `
definition Employee { id: string }
definition Doc { id: string }
definition Employee { id: string? }
definition Doc { id: string? }
fact *owns(user: Employee, doc: Doc)
evidence can_read(user: Employee, doc: Doc) { owns(user, doc) }
`;
@@ -102,8 +102,8 @@ describe('DSLRuntime provider-result caching', () => {
it('invalidateProviderCache() clears all or per relation', async () => {
const dsl = `
definition Employee { id: string }
definition Doc { id: string }
definition Employee { id: string? }
definition Doc { id: string? }
fact *owns(user: Employee, doc: Doc)
fact *banned(user: Employee)
evidence can_read(user: Employee, doc: Doc) { owns(user, doc) }
@@ -151,8 +151,8 @@ describe('DSLRuntime provider-result caching', () => {
it('uses the DSL-declared fact TTL (BEHAVES { ttl X })', async () => {
const dsl = `
definition Employee { id: string }
definition Doc { id: string }
definition Employee { id: string? }
definition Doc { id: string? }
fact *balance(user: Employee, amount: number) BEHAVES { ttl 1h }
evidence can_spend(user: Employee, doc: Doc) { balance(user, 1) }
`;
+4 -4
View File
@@ -14,8 +14,8 @@ 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 }
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) }
@@ -73,8 +73,8 @@ describe('DSLRuntime extended', () => {
// 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 }
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) }
+96
View File
@@ -0,0 +1,96 @@
/**
* tests/DSLRuntimeTyping.test.js duration seconds, required fields, and
* type validation of insertions / updates / provider retrievals.
*
* - Duration literals now accept s/m/h/d/w: `BEHAVES { ttl 30s }` is 30s.
* - Definition fields are REQUIRED by default (`field: type`); `field: type?`
* marks a field optional. addNode enforces presence on insert.
* - Provider-returned edges are validated against the fact's declared typing:
* a value-carrying fact must return { value, possibility } with a value of
* the declared type, and possibilities must lie in [0, 1].
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { Arbiter } from '@arbiter/core';
import { DSLRuntime } from '../src/runtime/DSLRuntime.js';
describe('DSLRuntime typing', () => {
it('accepts seconds/minutes/hours/days/weeks in duration literals', async () => {
const dsl = `
definition Employee { id: string? }
definition Doc { id: string? }
fact *a(user: Employee, amount: number) BEHAVES { ttl 30s }
fact *b(user: Employee, amount: number) BEHAVES { ttl 2m }
fact *c(user: Employee, amount: number) BEHAVES { ttl 1h }
fact *d(user: Employee, amount: number) BEHAVES { ttl 3d }
fact *e(user: Employee, amount: number) BEHAVES { ttl 1w }
`;
const rt = new DSLRuntime(new Arbiter()).compile(dsl, 'rt-units');
assert.equal(rt.relations.get('a').ttlMs, 30_000);
assert.equal(rt.relations.get('b').ttlMs, 120_000);
assert.equal(rt.relations.get('c').ttlMs, 3_600_000);
assert.equal(rt.relations.get('d').ttlMs, 259_200_000);
assert.equal(rt.relations.get('e').ttlMs, 604_800_000);
});
it('enforces required definition fields on node insert', () => {
const rt = new DSLRuntime(new Arbiter()).compile(`
definition Employee { id: string level: number active: boolean? }
`, 'rt-req');
// id and level are required (no `?`); active is optional.
assert.throws(() => rt.addNode('u:1', 'Employee', { level: 3 }), /missing required field 'Employee.id'/);
assert.throws(() => rt.addNode('u:2', 'Employee', { id: 'u:2' }), /missing required field 'Employee.level'/);
rt.addNode('u:3', 'Employee', { id: 'u:3', level: 5 }); // both required, no active -> ok
rt.addNode('u:4', 'Employee', { id: 'u:4', level: 5, active: true });
});
it('exposes requiredness in the schema snapshot', () => {
const rt = new DSLRuntime(new Arbiter()).compile(`
definition Employee { id: string level: number? }
`, 'rt-schema-req');
const employee = rt.getSchema().types.find(t => t.name === 'Employee');
assert.equal(employee.fields.find(f => f.name === 'id').required, true);
assert.equal(employee.fields.find(f => f.name === 'level').required, false);
});
it('validates a provider-returned value against the declared value type', async () => {
const rt = new DSLRuntime(new Arbiter()).compile(`
definition Employee { id: string? }
definition Doc { id: string? }
fact *balance(user: Employee, amount: number)
evidence can_spend(user: Employee, doc: Doc) { balance(user, 1) }
`, 'rt-valuetype');
rt.addNode('u:1', 'Employee', {});
rt.addNode('doc:9', 'Doc', {});
rt.registerFact('balance', async () => ({ possibility: 1.0, value: 'high' }));
await assert.rejects(() => rt.check('u:1', 'can_spend', 'doc:9'), /must be number/);
});
it('requires a value for a value-carrying fact (no bare-number shorthand)', async () => {
const rt = new DSLRuntime(new Arbiter()).compile(`
definition Employee { id: string? }
definition Doc { id: string? }
fact *balance(user: Employee, amount: number)
evidence can_spend(user: Employee, doc: Doc) { balance(user, 1) }
`, 'rt-valshape');
rt.addNode('u:1', 'Employee', {});
rt.addNode('doc:9', 'Doc', {});
rt.registerFact('balance', async () => 0.9);
await assert.rejects(() => rt.check('u:1', 'can_spend', 'doc:9'), /value-carrying fact/);
rt.registerFact('balance', async () => ({ possibility: 1.0 })); // missing value
await assert.rejects(() => rt.check('u:1', 'can_spend', 'doc:9'), /must supply a 'value'/);
});
it('rejects a provider-returned possibility outside [0, 1]', 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-poss');
rt.addNode('u:1', 'Employee', {});
rt.addNode('doc:9', 'Doc', {});
rt.registerFact('owns', async () => ({ possibility: 2.0 }));
await assert.rejects(() => rt.check('u:1', 'can_read', 'doc:9'), /invalid possibility/);
});
});