f530532e48
Two remaining clock/shape inconsistencies from the audit:
1. The direct fast path emitted a bare collected-value object
{value, source, relation, userKey, objectKey} — no possibility, no
path, no metadata. Value consumers (comparators, chains) rely on the
self-describing shape the rule paths emit. The fast path now emits
the standard shape (value/possibility/path/source/metadata), matching
DirectRule's existing _createCollectedValue contract.
2. Collected-value timestamps fell back to the WALL clock (Date.now())
even for pinned-clock callers in BaseRule._createCollectedValue,
ChainRule, and TupleToUsersetRule. The metadata timestamp now honors
options.now when pinned (changed_last_at wins, then pinned now, then
wall clock). ValueContext's collectedAt remains metadata-only.
Pinned by ttl-contract.test.js: the fast-path collected value carries
the full shape and its timestamp honors the pinned clock. Rigor 251/251,
full suite 853/791/0.
141 lines
7.0 KiB
JavaScript
141 lines
7.0 KiB
JavaScript
/**
|
|
* 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);
|
|
});
|
|
});
|
|
});
|