fix: pin TTL contract, gate caches on caller clock, stop caching stale values

Three related findings from the nervous-item audit:

1. TTL contract pinned (ttl-contract.test.js + README): TTL is a
   VALUE-FRESHNESS gate, not an access-expiry mechanism. Direct grants
   are timeless; expired values deny comparators and drop from collected
   values. The direct fast path collected values WITHOUT the TTL gate
   (comparators skipped expired relations, the direct path did not) —
   now gated identically.

2. ChainRule cache served pinned-clock callers (ChainRule.js): a chain
   result captured at one time (with then-fresh values) was served to
   callers asking about another time. The chain cache now bypasses
   reads AND writes when options.now is pinned, matching the rule-result
   cache contract.

3. Decision caches bundled stale values (AuthorizationChecker.js):
   the direct-check cache stored collectedValues alongside the timeless
   decision; an unpinned caller past wall-clock expiry got the stale
   value. Value-carrying results are now never cached (the decision is
   timeless, the values are not). The rule-result cache is unchanged —
   it serves snapshots under explicit write-invalidation (its own
   contract, asserted by cache-invalidation tests).

Rigor 250/250, full suite 852/790/0.
This commit is contained in:
John Dvorak
2026-08-02 16:07:55 -07:00
parent 8141930764
commit 342c29f38b
4 changed files with 161 additions and 15 deletions
+4
View File
@@ -62,6 +62,10 @@ Denied decisions never leak a source's reliability. `includeMeta: true` adds `me
- **Evidence**: relation strengths and validity labels come from the caller. The engine derives and fuses but never judges.
- **Time**: TTL-gated evidence uses the caller's clock. Pass `{ now }` (or `partialGraph.now`) to pin the temporal context; a rerun with the same context reproduces the decision.
**TTL is a value-freshness gate, not an access-expiry mechanism.** `valueManager.setTTL(relation, ms)` controls how long a relation's *value* stays fresh for value-consuming paths (relational comparators, chain/multi-hop value collection): once `age > TTL` the value is treated as absent, which denies the comparator and drops the value from collected values. Possibility-based grants — a direct relation's allow/deny, union disjunction, chain traversal — are **timeless**: an edge grants regardless of its age. If you need access to expire, express it in the policy (e.g. a comparator over a time-carrying value), not via `setTTL`.
**Clocks and caches.** The decision caches (direct-check, rule-result, chain) are keyed on the meta-less decision form only: `includeMeta` callers and pinned-clock callers always get a fresh evaluation, and value-carrying results are never cached (values are TTL-gated evidence). Unpinned callers share wall-clock cache entries — the correct default for timeless decisions. Pin `{ now }` whenever the answer depends on when you ask; every cache bypasses itself for pinned-clock callers, so a rerun with the same `{ now }` reproduces the decision exactly.
### Overlays and partial graphs
`check` accepts a `PartialGraphContext` overlay. Overlay relations take precedence over the base graph, letting you answer "what changes if this evidence appears?" without mutating the graph.
+20 -5
View File
@@ -116,8 +116,11 @@ export class AuthorizationChecker {
// Cache the result (only when no partial graph and the default
// meta-less form — includeMeta callers and pinned-clock callers get
// a fresh evaluation)
if (!explain && !hasPartialGraph && !includeMeta && !temporalPinned) {
// a fresh evaluation). Value-carrying results are NEVER cached: the
// decision is timeless, but collected values are TTL-gated evidence
// and a cached entry would serve stale values past their TTL (the
// default TTL is 24h even without an explicit setTTL).
if (!explain && !hasPartialGraph && !includeMeta && !temporalPinned && !result.collectedValues) {
this._cacheDirectCheckResult(userKey, relation, objectKey, result);
}
return result;
@@ -197,8 +200,17 @@ export class AuthorizationChecker {
reason: 'direct_match'
};
// Collect values if present
// Collect values if present — but only while the value is
// FRESH. TTL is a value-freshness gate (see ValueManager
// _isValueExpired); an expired value must not surface in
// collectedValues, matching the comparator path which skips
// expired relations entirely.
if (collectValues && directRel.value !== undefined) {
const valueManager = this.arbiter.valueManager;
const expired = valueManager && valueManager._isValueExpired
? valueManager._isValueExpired(directRel, options.now !== undefined && options.now !== null ? options.now : null)
: false;
if (!expired) {
result.collectedValues = [{
value: directRel.value,
source: 'direct_relation',
@@ -208,6 +220,7 @@ export class AuthorizationChecker {
}];
}
}
}
} else {
// Fast-path miss: attach remediation when the missing (effective)
// relation is declared as an injectable witness source — the caller
@@ -235,8 +248,10 @@ export class AuthorizationChecker {
result.meta.cache = cacheHint;
}
// Cache the result using composite key (meta-less form only)
if (!explain && !hasPartialGraph && !includeMeta && !temporalPinned) {
// Cache the result using composite key (meta-less form only).
// Value-carrying results are never cached (collected values are
// TTL-gated evidence; a cached entry would serve stale values).
if (!explain && !hasPartialGraph && !includeMeta && !temporalPinned && !result.collectedValues) {
this._cacheDirectCheckResult(userKey, relation, objectKey, result);
}
return result;
+12 -3
View File
@@ -153,9 +153,16 @@ export class ChainRule extends BaseRule {
// checks get served full-mode values (and vice versa).
const isThresholdEval = options.binary === true || (options.fastPath === true && options.minPossibility != null);
// A caller-pinned clock (options.now) makes the result per-clock: a
// chain result captured at one time (with then-fresh values) must not
// be served to a caller asking about another time. Same contract as
// the rule result cache (RuleEvaluator): pinned-clock callers bypass
// the chain cache entirely — both reads and writes.
const temporalPinned = options.now !== undefined && options.now !== null;
// Check for cached chain result (use numeric IDs) - only if caching is enabled
// Skip cache when a partial graph is present to prevent cross-request leakage
if (this.chainResultCache && !hasPartialGraph && !isThresholdEval) {
if (this.chainResultCache && !hasPartialGraph && !isThresholdEval && !temporalPinned) {
const cachedResult = this._getCachedChainResult(userIdNum, objectIdNum, steps);
if (cachedResult) {
return cachedResult;
@@ -387,8 +394,10 @@ export class ChainRule extends BaseRule {
// Cache the chain result (use numeric IDs) - only if caching is enabled
// Do not cache when a partial graph is present to prevent cross-request leakage
// Do not cache threshold-mode results (see isThresholdEval above)
if (this.chainResultCache && !hasPartialGraph && !isThresholdEval) {
// Do not cache threshold-mode results (see isThresholdEval above).
// Do not cache pinned-clock results either — the entry is per-clock
// and would be served to later unpinned callers as if it were timeless.
if (this.chainResultCache && !hasPartialGraph && !isThresholdEval && !temporalPinned) {
this._cacheChainResult(userIdNum, objectIdNum, steps, result);
}
+118
View File
@@ -0,0 +1,118 @@
/**
* 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: 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);
});
});
});