diff --git a/package.json b/package.json index 1f04eb3..63596df 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@arbiter/evidence-dsl", - "version": "1.8.0", + "version": "1.9.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/generator/RuleGenerator.js b/src/generator/RuleGenerator.js index 477b935..a61e463 100644 --- a/src/generator/RuleGenerator.js +++ b/src/generator/RuleGenerator.js @@ -1111,6 +1111,15 @@ export class RuleGenerator { out.push(...this._expandChainSteps(resolved.steps, refStack)); 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 // FINAL step the engine verifies it at (intermediate, object); as an // INTERMEDIATE step the engine EXPANDS it from the current node diff --git a/src/runtime/DSLRuntime.js b/src/runtime/DSLRuntime.js index d93f984..2d573da 100644 --- a/src/runtime/DSLRuntime.js +++ b/src/runtime/DSLRuntime.js @@ -60,6 +60,12 @@ export class DSLRuntime { this.clock = typeof options.clock === 'function' ? options.clock : (() => Date.now()); // Default provider-result TTL in ms (0 disables caching). 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. this.factTTLs = new Map(Object.entries(options.factTTLs || {})); } @@ -593,8 +599,11 @@ export class DSLRuntime { // provider overrides are one-off observations — they bypass the cache // entirely (no read, no write) so a fresh override is never masked by // 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 cacheHit = isPerCheckOverride ? null : this._providerCacheGet(fact, user, object); + const cacheHit = (cachingEnabled && !isPerCheckOverride) ? this._providerCacheGet(fact, user, object) : null; let edges = null; let fromCache = false; if (cacheHit) { @@ -626,7 +635,7 @@ export class DSLRuntime { continue; } 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 { missingFacts.push({ relation: fact, reason: 'no_provider' }); satisfied.add(fact); diff --git a/tests/DSLRuntimeCache.test.js b/tests/DSLRuntimeCache.test.js index 330c3da..74af5bb 100644 --- a/tests/DSLRuntimeCache.test.js +++ b/tests/DSLRuntimeCache.test.js @@ -173,4 +173,31 @@ describe('DSLRuntime provider-result caching', () => { await rt.check('u:1', 'can_spend', 'doc:9'); 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'); + }); });