Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| aa38fbfd8c | |||
| ad365a65a9 |
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@arbiter/evidence-dsl",
|
"name": "@arbiter/evidence-dsl",
|
||||||
"version": "1.7.0",
|
"version": "1.9.0",
|
||||||
"description": "Evidence DSL v2 compiler: translates the natural Evidence DSL (ADR-000) into @arbiter/core relation configurations.",
|
"description": "Evidence DSL v2 compiler: translates the natural Evidence DSL (ADR-000) into @arbiter/core relation configurations.",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -1111,6 +1111,15 @@ export class RuleGenerator {
|
|||||||
out.push(...this._expandChainSteps(resolved.steps, refStack));
|
out.push(...this._expandChainSteps(resolved.steps, refStack));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (resolved.type === 'relational_comparator' && idx !== steps.length - 1) {
|
||||||
|
// A comparator compares values at (src, candidate) but provides no
|
||||||
|
// candidate set — it cannot enumerate intermediate nodes, so only
|
||||||
|
// a FINAL comparator step (verified at the known object) lowers.
|
||||||
|
this.errors.push(`Chain step '${stepName}' references a comparator evidence at a non-final position. ` +
|
||||||
|
'Comparators can only be the final chain step (the object is known); intermediate positions are not enumerable.');
|
||||||
|
out.push(step);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
// Condition step: inline the evidence's config as a rule step. As the
|
// Condition step: inline the evidence's config as a rule step. As the
|
||||||
// FINAL step the engine verifies it at (intermediate, object); as an
|
// FINAL step the engine verifies it at (intermediate, object); as an
|
||||||
// INTERMEDIATE step the engine EXPANDS it from the current node
|
// INTERMEDIATE step the engine EXPANDS it from the current node
|
||||||
|
|||||||
@@ -60,6 +60,12 @@ export class DSLRuntime {
|
|||||||
this.clock = typeof options.clock === 'function' ? options.clock : (() => Date.now());
|
this.clock = typeof options.clock === 'function' ? options.clock : (() => Date.now());
|
||||||
// Default provider-result TTL in ms (0 disables caching).
|
// Default provider-result TTL in ms (0 disables caching).
|
||||||
this.defaultProviderCacheTTL = options.policy?.providerCacheTTL ?? options.providerCacheTTL ?? 30_000;
|
this.defaultProviderCacheTTL = options.policy?.providerCacheTTL ?? options.providerCacheTTL ?? 30_000;
|
||||||
|
// Provider caching is a STORE-RETRIEVAL cache (wall-clock), deliberately
|
||||||
|
// independent of the caller's decision `{ now }` — a provider returns the
|
||||||
|
// store's current data, not a time-travel snapshot. Callers who pin time
|
||||||
|
// or otherwise want fresh retrieval can disable it per-check
|
||||||
|
// (options.cacheProviderResults: false) or globally (policy).
|
||||||
|
this.cacheProviderResults = options.policy?.cacheProviderResults ?? options.cacheProviderResults ?? true;
|
||||||
// Per-fact overrides (ms). DSL-declared ttl behaviors are indexed here too.
|
// Per-fact overrides (ms). DSL-declared ttl behaviors are indexed here too.
|
||||||
this.factTTLs = new Map(Object.entries(options.factTTLs || {}));
|
this.factTTLs = new Map(Object.entries(options.factTTLs || {}));
|
||||||
}
|
}
|
||||||
@@ -559,7 +565,13 @@ export class DSLRuntime {
|
|||||||
this._checkNodeType(user, meta.params[0].type, 'subject');
|
this._checkNodeType(user, meta.params[0].type, 'subject');
|
||||||
}
|
}
|
||||||
|
|
||||||
const required = this.requiredFacts(relation);
|
// Retrieval set: for an evidence, the injectable facts it depends on; for
|
||||||
|
// a direct FACT check, the fact itself is the retrieval target (its
|
||||||
|
// provider, if registered, supplies the edge — checking `owns` directly
|
||||||
|
// must consult the `owns` provider, not only evidence-mediated checks).
|
||||||
|
const required = new Set(this.requiredFacts(relation));
|
||||||
|
if (meta && meta.kind === 'fact') required.add(relation);
|
||||||
|
const requiredList = [...required];
|
||||||
const providers = { ...this.factProviders, ...(options.factProviders || {}) };
|
const providers = { ...this.factProviders, ...(options.factProviders || {}) };
|
||||||
const maxRounds = options.maxProviderRounds ?? 3;
|
const maxRounds = options.maxProviderRounds ?? 3;
|
||||||
const partialRelations = [];
|
const partialRelations = [];
|
||||||
@@ -577,7 +589,7 @@ export class DSLRuntime {
|
|||||||
// Fixed-point provider retrieval loop.
|
// Fixed-point provider retrieval loop.
|
||||||
for (let round = 1; round <= maxRounds; round++) {
|
for (let round = 1; round <= maxRounds; round++) {
|
||||||
let newRelationsThisRound = 0;
|
let newRelationsThisRound = 0;
|
||||||
for (const fact of required) {
|
for (const fact of requiredList) {
|
||||||
if (satisfied.has(fact)) continue;
|
if (satisfied.has(fact)) continue;
|
||||||
const factMeta = this.relations.get(fact);
|
const factMeta = this.relations.get(fact);
|
||||||
const provider = providers[fact];
|
const provider = providers[fact];
|
||||||
@@ -587,8 +599,11 @@ export class DSLRuntime {
|
|||||||
// provider overrides are one-off observations — they bypass the cache
|
// provider overrides are one-off observations — they bypass the cache
|
||||||
// entirely (no read, no write) so a fresh override is never masked by
|
// entirely (no read, no write) so a fresh override is never masked by
|
||||||
// a cached registered-provider result, nor does it pollute the cache.
|
// a cached registered-provider result, nor does it pollute the cache.
|
||||||
|
// options.cacheProviderResults:false (or the policy default) disables
|
||||||
|
// the cache for this check.
|
||||||
|
const cachingEnabled = options.cacheProviderResults ?? this.cacheProviderResults;
|
||||||
const isPerCheckOverride = !!(options.factProviders && fact in options.factProviders);
|
const isPerCheckOverride = !!(options.factProviders && fact in options.factProviders);
|
||||||
const cacheHit = isPerCheckOverride ? null : this._providerCacheGet(fact, user, object);
|
const cacheHit = (cachingEnabled && !isPerCheckOverride) ? this._providerCacheGet(fact, user, object) : null;
|
||||||
let edges = null;
|
let edges = null;
|
||||||
let fromCache = false;
|
let fromCache = false;
|
||||||
if (cacheHit) {
|
if (cacheHit) {
|
||||||
@@ -620,7 +635,7 @@ export class DSLRuntime {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
edges = this._normalizeProviderEdges(result, factMeta, user, object);
|
edges = this._normalizeProviderEdges(result, factMeta, user, object);
|
||||||
if (!isPerCheckOverride) this._providerCacheSet(fact, user, object, edges);
|
if (cachingEnabled && !isPerCheckOverride) this._providerCacheSet(fact, user, object, edges);
|
||||||
} else {
|
} else {
|
||||||
missingFacts.push({ relation: fact, reason: 'no_provider' });
|
missingFacts.push({ relation: fact, reason: 'no_provider' });
|
||||||
satisfied.add(fact);
|
satisfied.add(fact);
|
||||||
@@ -653,7 +668,7 @@ export class DSLRuntime {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
requiredFacts: required,
|
requiredFacts: requiredList,
|
||||||
providedFacts: injectedRelations.map(r => r.relation),
|
providedFacts: injectedRelations.map(r => r.relation),
|
||||||
missingFacts
|
missingFacts
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -173,4 +173,31 @@ describe('DSLRuntime provider-result caching', () => {
|
|||||||
await rt.check('u:1', 'can_spend', 'doc:9');
|
await rt.check('u:1', 'can_spend', 'doc:9');
|
||||||
assert.equal(calls, 2, 're-invoked past the DSL-declared 1h TTL');
|
assert.equal(calls, 2, 're-invoked past the DSL-declared 1h TTL');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('cacheProviderResults:false bypasses the cache per check', async () => {
|
||||||
|
const rt = makeRuntime();
|
||||||
|
rt.addNode('u:1', 'Employee', {});
|
||||||
|
rt.addNode('doc:9', 'Doc', {});
|
||||||
|
let calls = 0;
|
||||||
|
rt.registerFact('owns', async () => { calls++; return 0.9; });
|
||||||
|
await rt.check('u:1', 'can_read', 'doc:9');
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
// Bypass forces a fresh retrieval without clearing the cache.
|
||||||
|
await rt.check('u:1', 'can_read', 'doc:9', { cacheProviderResults: false });
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
// Cache still intact for the next default check.
|
||||||
|
await rt.check('u:1', 'can_read', 'doc:9');
|
||||||
|
assert.equal(calls, 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('policy.cacheProviderResults:false disables caching globally', async () => {
|
||||||
|
const rt = makeRuntime({ policy: { cacheProviderResults: false } });
|
||||||
|
rt.addNode('u:1', 'Employee', {});
|
||||||
|
rt.addNode('doc:9', 'Doc', {});
|
||||||
|
let calls = 0;
|
||||||
|
rt.registerFact('owns', async () => { calls++; return 0.9; });
|
||||||
|
await rt.check('u:1', 'can_read', 'doc:9');
|
||||||
|
await rt.check('u:1', 'can_read', 'doc:9');
|
||||||
|
assert.equal(calls, 2, 'no caching when disabled globally');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -144,4 +144,25 @@ describe('DSLRuntime extended', () => {
|
|||||||
rt.updateNodeData('doc:9', { created: '2026-08-03T00:00:00Z' });
|
rt.updateNodeData('doc:9', { created: '2026-08-03T00:00:00Z' });
|
||||||
assert.throws(() => rt.addNode('doc:8', 'Doc', { created: {} }), /must be timestamp/);
|
assert.throws(() => rt.addNode('doc:8', 'Doc', { created: {} }), /must be timestamp/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('direct FACT checks consult the registered provider', async () => {
|
||||||
|
const rt = makeRuntime();
|
||||||
|
rt.addNode('u:1', 'Employee', {});
|
||||||
|
rt.addNode('doc:9', 'Doc', {});
|
||||||
|
let calls = 0;
|
||||||
|
rt.registerFact('owns', async () => { calls++; return 0.9; });
|
||||||
|
// Checking the fact directly (not via an evidence) must retrieve it.
|
||||||
|
const res = await rt.check('u:1', 'owns', 'doc:9');
|
||||||
|
assert.equal(res.possibility, 0.9);
|
||||||
|
assert.equal(calls, 1);
|
||||||
|
assert.deepEqual(res.requiredFacts, ['owns']);
|
||||||
|
assert.deepEqual(res.providedFacts, ['owns']);
|
||||||
|
// Without a provider and without an edge, it reports the missing fact.
|
||||||
|
const rt2 = new DSLRuntime(new Arbiter()).compile(BASE_DSL, 'rt-fact-miss');
|
||||||
|
rt2.addNode('u:1', 'Employee', {});
|
||||||
|
rt2.addNode('doc:9', 'Doc', {});
|
||||||
|
const missed = await rt2.check('u:1', 'owns', 'doc:9');
|
||||||
|
assert.equal(missed.possibility, 0);
|
||||||
|
assert.deepEqual(missed.missingFacts, [{ relation: 'owns', reason: 'no_provider' }]);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user