/** * rigor/ttl-contract.test.js — pins the TTL contract. * * TTL is a VALUE-FRESHNESS mechanism, not an access-expiry mechanism: * * - Possibility-based grants (direct allow/deny, union, chain traversal) * are TIMELESS. A relation edge grants regardless of how old its * changed_last_at is. setTTL('can_read', ...) does NOT expire access. * - TTL gates VALUE EXTRACTION: comparator/chain/multi-hop paths read * values through valueManager.getBlurredValue, which returns a null * interval once age > TTL. An expired value denies the comparator * decision and drops the value from collected values. * * This file pins BOTH halves so a future change can never silently flip * one without breaking the contract test. */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { Arbiter } from '../../src/index.js'; const T0 = 1_000_000_000_000; const TTL = 60_000; function buildDirectEngine() { const a = new Arbiter(); a.addNode('u:1', 'user'); a.addNode('doc:9', 'doc'); a.setRelationConfig('can_read', { type: 'direct' }); a.valueManager.setTTL('can_read', TTL); a.addRelation('u:1', 'can_read', 'doc:9', { possibility: 0.9, value: 42, changed_last_at: T0 }); return a; } function buildComparatorEngine() { const a = new Arbiter(); a.addNode('user:alice', 'user'); a.addNode('doc:9', 'doc'); a.setRelationConfig('has_balance', { type: 'direct' }); a.setRelationConfig('has_price', { type: 'direct' }); a.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 } }); a.valueManager.setTTL('has_balance', TTL); a.valueManager.setTTL('has_price', TTL); a.addRelation('user:alice', 'has_balance', 'doc:9', { value: 100, possibility: 1.0, changed_last_at: T0 }); a.addRelation('doc:9', 'has_price', 'doc:9', { value: 50, possibility: 1.0, changed_last_at: T0 }); return a; } describe('TTL contract (rigor)', () => { it('CONTRACT: direct possibility grants are timeless (TTL does not expire access)', () => { const a = buildDirectEngine(); const fresh = a.check('u:1', 'can_read', 'doc:9', { now: T0 }); assert.equal(fresh.possibility, 0.9); const expired = a.check('u:1', 'can_read', 'doc:9', { now: T0 + TTL + 1 }); assert.equal(expired.possibility, 0.9, 'direct grant must survive value TTL expiry'); assert.equal(expired.reason, 'direct_match'); // Binary mode agrees. const binary = a.check('u:1', 'can_read', 'doc:9', { now: T0 + TTL + 1, binary: true }); assert.equal(binary.possibility > 0, true); }); it('CONTRACT: TTL gates value extraction — expired values deny the comparator', () => { const a = buildComparatorEngine(); const fresh = a.check('user:alice', 'premium', 'doc:9', { now: T0 }); assert.equal(fresh.possibility, 1, 'fresh values grant'); const withinTtl = a.check('user:alice', 'premium', 'doc:9', { now: T0 + TTL - 1 }); assert.equal(withinTtl.possibility, 1, 'still fresh at TTL-1'); const expired = a.check('user:alice', 'premium', 'doc:9', { now: T0 + TTL + 1 }); assert.equal(expired.possibility, 0, 'expired values must deny'); }); it('CONTRACT: collected values disappear once expired (value freshness, not access)', () => { const a = buildDirectEngine(); const fresh = a.check('u:1', 'can_read', 'doc:9', { now: T0, collectValues: true }); assert.ok(fresh.collectedValues && fresh.collectedValues.length === 1, 'fresh value collected'); const expired = a.check('u:1', 'can_read', 'doc:9', { now: T0 + TTL + 1, collectValues: true }); assert.ok(!expired.collectedValues || expired.collectedValues.length === 0, 'expired value not collected'); }); it('CONTRACT: the caller clock (now) drives expiry — the wall clock does not', () => { const a = buildComparatorEngine(); // Pinned far in the past: expired even though wall clock is "now". const past = a.check('user:alice', 'premium', 'doc:9', { now: T0 + TTL + 1 }); assert.equal(past.possibility, 0); // Pinned at write time: fresh even if the wall clock has moved on. const atWrite = a.check('user:alice', 'premium', 'doc:9', { now: T0 }); assert.equal(atWrite.possibility, 1); }); it('CONTRACT: collected values carry the standard shape and honor the caller clock', () => { // Value consumers (comparators, chains) rely on collected values being // self-describing: value, possibility, path, source, and a timestamp. // The direct fast path must emit the same shape as the rule paths, and // the timestamp must honor the pinned clock — never the wall clock. const a = new Arbiter(); a.addNode('u:1', 'user'); a.addNode('doc:9', 'doc'); a.setRelationConfig('can_read', { type: 'direct' }); const T0 = 1_000_000_000_000; a.addRelation('u:1', 'can_read', 'doc:9', { possibility: 0.7, value: 42, changed_last_at: T0, reliability: 0.8 }); const cv = a.check('u:1', 'can_read', 'doc:9', { now: T0, collectValues: true }).collectedValues[0]; assert.equal(cv.value, 42); assert.equal(cv.possibility, 0.7); assert.ok(Array.isArray(cv.path) && cv.path[0] === 'u:1' && cv.path[1] === 'doc:9'); assert.equal(cv.source.entityKey, 'u:1'); assert.equal(cv.source.relation, 'can_read'); assert.equal(cv.source.step, 0); assert.equal(cv.metadata.timestamp, T0, 'timestamp must honor changed_last_at under a pinned clock'); assert.equal(cv.metadata.reliability, 0.8); }); it('CONTRACT: value-carrying direct results are not served stale from the decision cache', () => { // The direct-check cache must never serve a result whose collected // values were captured before expiry: values are TTL-gated evidence. // (Regression: the cache previously bundled collectedValues with the // timeless decision and served stale values to later unpinned callers.) const a = new Arbiter(); a.addNode('u:1', 'user'); a.addNode('doc:9', 'doc'); a.setRelationConfig('can_read', { type: 'direct' }); // Unpinned callers use the wall clock, so write at wall time with a // short TTL to make expiry observable without a long sleep. const wallT0 = Date.now(); a.valueManager.setTTL('can_read', 200); a.addRelation('u:1', 'can_read', 'doc:9', { possibility: 0.9, value: 42, changed_last_at: wallT0 }); const warm = a.check('u:1', 'can_read', 'doc:9', { collectValues: true }); assert.equal(warm.collectedValues.length, 1, 'fresh value collected on warm'); // Advance the wall clock past the value TTL without any mutation. return new Promise(r => setTimeout(r, 250)).then(() => { const after = a.check('u:1', 'can_read', 'doc:9', { collectValues: true }); assert.ok(!after.collectedValues || after.collectedValues.length === 0, 'stale value must not be served from cache'); // The timeless decision still grants. assert.equal(after.possibility, 0.9); }); }); });