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.