/** * rigor/ttl-expiry-parity.test.js — value-TTL expiry through the full * comparator check path with an injected clock. * * Contracts pinned: * - values written with setTTL(rel, ttlMs) expire after ttlMs of engine * time; expired operands no longer participate in relational * comparisons (the comparator's value extraction must consult the * value manager's TTL gate, not raw relation values). * - with the decision cache enabled, a decision cached before expiry may * linger up to the rule-result-cache TTL, but MUST become fresh once * the cache entry itself expires (bounded staleness). * * The mirror tracks per-relation write timestamps under the same injected * clock and computes the expected comparison result. */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { rigor } from '@rigor/core'; import { Arbiter } from '../../src/index.js'; const TTL = 5000; const DOCS = 2; const docKey = (i) => `doc:${i}`; let engineNow = 1_000_000_000_000; function makeWrapper(disableCaching) { const arbiter = new Arbiter(disableCaching ? { disableCaching: true } : {}); arbiter.addNode('user:alice', 'user'); for (let i = 0; i < DOCS; i++) arbiter.addNode(docKey(i), 'doc'); arbiter.setRelationConfig('has_balance', { type: 'direct' }); arbiter.setRelationConfig('has_price', { type: 'direct' }); arbiter.setRelationConfig('premium', { type: 'relational_comparator', comparator: '>', left: { rule: { type: 'direct', relation: 'has_balance' }, extractValue: true }, right: { evaluateFrom: 'object', rule: { type: 'direct', relation: 'has_price' }, extractValue: true } }); arbiter.valueManager.setTTL('has_balance', TTL); arbiter.valueManager.setTTL('has_price', TTL); const balance = new Map(); const price = new Map(); const ops = []; const wrapper = { engine: arbiter, balance, price, setValue(doc, kind, value) { const rel = kind === 'balance' ? 'has_balance' : 'has_price'; const src = kind === 'balance' ? 'user:alice' : docKey(doc); // Pin the write timestamp: clone-replay re-writes relations with the // CURRENT Date.now(), which would silently un-expire old values; the // engine honors the changed_last_at override. arbiter.addRelation(src, rel, docKey(doc), { value, possibility: 1.0, changed_last_at: engineNow }); // The engine only refreshes changed_last_at when the value actually // changes; the mirror must mirror that. const target = kind === 'balance' ? balance : price; const existing = target.get(doc); if (!existing || existing.value !== value) target.set(doc, { value, ts: engineNow }); return { ok: true }; }, advanceTime(ms) { engineNow += ms; return { ok: true, now: engineNow }; }, check(doc) { const result = arbiter.check('user:alice', 'premium', docKey(doc), { now: engineNow }); const b = balance.get(doc); const p = price.get(doc); const bFresh = b !== undefined && engineNow - b.ts <= TTL; const pFresh = p !== undefined && engineNow - p.ts <= TTL; const expected = bFresh && pFresh && b.value > p.value ? 1 : 0; return { engine: result.possibility, expected, reason: result.reason, bFresh, pFresh }; }, clone() { const fresh = makeWrapper(disableCaching); for (const op of ops) { const [name, ...args] = op; fresh[name](...args); } return fresh; } }; const record = (name, fn) => (...args) => { const res = fn(...args); ops.push([name, ...args]); return res; }; wrapper.setValue = record('setValue', wrapper.setValue); wrapper.advanceTime = record('advanceTime', wrapper.advanceTime); wrapper.check = record('check', wrapper.check); return wrapper; } describe('Value TTL expiry through the comparator path (rigor)', () => { it('FIXED MATRIX: expired operands flip the decision; cache staleness is bounded', () => { { const w = makeWrapper(true); w.setValue(0, 'balance', 100); w.setValue(0, 'price', 50); assert.equal(w.check(0).engine, 1, 'fresh: 100 > 50'); w.advanceTime(TTL + 1000); assert.equal(w.check(0).engine, 0, 'after TTL both operands expired -> deny'); w.setValue(0, 'price', 200); assert.equal(w.check(0).engine, 0, 'balance expired, price fresh -> deny'); w.setValue(0, 'balance', 300); assert.equal(w.check(0).engine, 1, 'both refreshed -> allow'); const cw = makeWrapper(false); cw.setValue(0, 'balance', 100); cw.setValue(0, 'price', 50); assert.equal(cw.check(0).engine, 1, 'cached arbiter: allow cached'); cw.advanceTime(TTL + 1000); const staleWindow = cw.check(0); cw.advanceTime(70000); // past ruleResultCacheTTL (60s) assert.equal(cw.check(0).engine, 0, 'bounded staleness: fresh deny after cache TTL'); assert.ok(staleWindow.engine >= 0, 'stale window decision is well-formed'); } }); it('PROPERTY CAMPAIGN: parity holds across time advances and refreshes (no decision cache)', async () => { const result = await rigor.campaign( [rigor.object('graph', makeWrapper.bind(null, true), [ rigor.method('setValue', function (d, k, v) { return this.setValue(d, k, v); }, rigor.args(rigor.gen.int(0, DOCS - 1), rigor.gen.enum(['balance', 'price']), rigor.gen.oneOf([5, 50, 100, 200]))), rigor.method('advanceTime', function (ms) { return this.advanceTime(ms); }, rigor.args(rigor.gen.oneOf([1000, 4000, 6000, 30000, 70000]))), rigor.method('check', function (d) { return this.check(d); }, rigor.args(rigor.gen.int(0, DOCS - 1))) ])], rigor.crucible([ rigor.invariant('comparator parity under time', (ctx) => { if (ctx.action !== 'graph.check' || ctx.error !== null) return true; return ctx.actual.engine === ctx.actual.expected; }), rigor.invariant('no action errors', (ctx) => ctx.error === null) ]) ).run({ effort: 400, seed: 'ttl-expiry-parity', maxTraceLength: 30 , artifacts: { dir: '', persist: 'never' }}); const inv = result.crucibleVerdict; assert.equal(inv.passed, true, [ `TTL parity violated in ${inv.failureCount} cases:`, ...result.failures.slice(0, 3).map((f) => ` [${f.invariant}] action=${f.action} args=${JSON.stringify(f.args)} actual=${JSON.stringify(f.actual)} error=${f.error}` ) ].join('\n')); }); });