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:
@@ -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,15 +200,25 @@ 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) {
|
||||
result.collectedValues = [{
|
||||
value: directRel.value,
|
||||
source: 'direct_relation',
|
||||
relation: relation,
|
||||
userKey: userKey,
|
||||
objectKey: objectKey
|
||||
}];
|
||||
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',
|
||||
relation: relation,
|
||||
userKey: userKey,
|
||||
objectKey: objectKey
|
||||
}];
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user