diff --git a/README.md b/README.md index d73c5b9..d6cc3cd 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/authorization/AuthorizationChecker.js b/src/authorization/AuthorizationChecker.js index afffad8..06b11b4 100644 --- a/src/authorization/AuthorizationChecker.js +++ b/src/authorization/AuthorizationChecker.js @@ -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; diff --git a/src/authorization/rules/ChainRule.js b/src/authorization/rules/ChainRule.js index 14de55b..1d37878 100644 --- a/src/authorization/rules/ChainRule.js +++ b/src/authorization/rules/ChainRule.js @@ -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); } diff --git a/tests/rigor/ttl-contract.test.js b/tests/rigor/ttl-contract.test.js new file mode 100644 index 0000000..6990c0c --- /dev/null +++ b/tests/rigor/ttl-contract.test.js @@ -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); + }); + }); +});