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
+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);
}