diff --git a/src/authorization/AuthorizationChecker.js b/src/authorization/AuthorizationChecker.js index 39f65f0..96396e0 100644 --- a/src/authorization/AuthorizationChecker.js +++ b/src/authorization/AuthorizationChecker.js @@ -86,7 +86,8 @@ export class AuthorizationChecker { // Check cache first using composite key (if caching is enabled) let cachedResult = null; let cacheHint = null; - if (!hasPartialGraph && this.decisionCache.directEnabled && !includeMeta) { + const temporalPinned = options.now !== undefined && options.now !== null; + if (!hasPartialGraph && this.decisionCache.directEnabled && !includeMeta && !temporalPinned) { const cacheKey = this._getDirectCheckCacheKey(userKey, relation, objectKey); const [hitResult, status] = this.decisionCache.peekDirect(cacheKey); cachedResult = status === 'hit' || status === 'expired' ? { result: hitResult, timestamp: 0 } : null; @@ -112,8 +113,9 @@ export class AuthorizationChecker { }; // Cache the result (only when no partial graph and the default - // meta-less form — includeMeta callers get a fresh full evaluation) - if (!explain && !hasPartialGraph && !includeMeta) { + // meta-less form — includeMeta callers and pinned-clock callers get + // a fresh evaluation) + if (!explain && !hasPartialGraph && !includeMeta && !temporalPinned) { this._cacheDirectCheckResult(userKey, relation, objectKey, result); } return result; @@ -227,7 +229,7 @@ export class AuthorizationChecker { } // Cache the result using composite key (meta-less form only) - if (!explain && !hasPartialGraph && !includeMeta) { + if (!explain && !hasPartialGraph && !includeMeta && !temporalPinned) { this._cacheDirectCheckResult(userKey, relation, objectKey, result); } return result; @@ -264,8 +266,9 @@ export class AuthorizationChecker { }; } + const temporalPinned = options.now !== undefined && options.now !== null; const canCacheRuleResult = !hasPartialGraph && !explain && !includeMeta && - !binary && options.cacheRuleResult !== false && this.decisionCache.ruleEnabled; + !binary && !temporalPinned && options.cacheRuleResult !== false && this.decisionCache.ruleEnabled; const ruleCacheKey = canCacheRuleResult ? this._getRuleResultCacheKey(userId, relation, objectId) : null; diff --git a/src/authorization/RuleEvaluator.js b/src/authorization/RuleEvaluator.js index b2153b7..8cba107 100644 --- a/src/authorization/RuleEvaluator.js +++ b/src/authorization/RuleEvaluator.js @@ -43,8 +43,11 @@ export class RuleEvaluator { const collectValues = options.collectValues !== undefined ? options.collectValues : (rule._needsValues ?? needsValueContext); const enhancedOptions = { ...options, minPossibility: options.minPossibility ?? options.minAllowPossibility, valueContext: finalValueContext, collectValues, includeMeta }; + // A caller-pinned clock (options.now) makes the result per-clock: the + // result caches must not serve a decision evaluated at another time. + const temporalPinned = options.now !== undefined && options.now !== null; const canCacheRuleResult = !!this.arbiter.ruleResultCache && currentRelation && - !binary && !options.partialGraphContext && !includeMeta && + !binary && !options.partialGraphContext && !includeMeta && !temporalPinned && options.cacheRuleResult !== false; const ruleCacheKey = canCacheRuleResult ? this._getRuleResultCacheKey(numericUserId, currentRelation, numericObjectId, rule) diff --git a/src/authorization/rules/ChallengeRule.js b/src/authorization/rules/ChallengeRule.js index 3c34613..69798bb 100644 --- a/src/authorization/rules/ChallengeRule.js +++ b/src/authorization/rules/ChallengeRule.js @@ -28,7 +28,7 @@ export class ChallengeRule extends BaseRule { } const subjectId = this.arbiter.resolveNodeId(subjectKey, { partialGraphContext: partialContext }); - const now = Date.now(); + const now = options.now !== undefined && options.now !== null ? options.now : Date.now(); const proof = partialContext.getChallengeProof(challenge, subjectId, withinMs, now); if (!proof) { diff --git a/src/authorization/rules/MultiHopRule.js b/src/authorization/rules/MultiHopRule.js index 84b26db..f036e2f 100644 --- a/src/authorization/rules/MultiHopRule.js +++ b/src/authorization/rules/MultiHopRule.js @@ -349,8 +349,9 @@ export class MultiHopRule extends BaseRule { * Collect values from a complete path * @private */ - _collectValuesFromPath(pathSteps, defaultRelation, valueFilters, valueContext) { + _collectValuesFromPath(pathSteps, defaultRelation, valueFilters, valueContext, options = null) { const collectedValues = []; + const evaluationNow = options && options.now !== undefined && options.now !== null ? options.now : Date.now(); // Get blurred interval from ValueManager (arbiter-level; some stubs and // older layouts keep it on the relation manager) const valueManager = this.arbiter.valueManager || this.arbiter.relationManager?.valueManager; @@ -373,7 +374,7 @@ export class MultiHopRule extends BaseRule { const ttl = valueFilters.ttl || 24 * 60 * 60 * 1000; const timestamp = step.edge.changed_last_at || step.edge.updated_last_at || Date.now(); - if (!OWAFusion.isWithinTTL(timestamp, ttl)) { + if (!OWAFusion.isWithinTTL(timestamp, ttl, evaluationNow)) { Arbiter.DEBUG && Arbiter.log('MultiHop skipping value due to TTL:', { value: step.edge.value, timestamp, @@ -431,7 +432,7 @@ export class MultiHopRule extends BaseRule { if (this._passesValueFilters(contextValue.value, valueFilters)) { // Check TTL const ttl = valueFilters.ttl || 24 * 60 * 60 * 1000; - if (!OWAFusion.isWithinTTL(contextValue.timestamp, ttl)) { + if (!OWAFusion.isWithinTTL(contextValue.timestamp, ttl, evaluationNow)) { continue; } diff --git a/src/authorization/rules/RelationalComparatorRule.js b/src/authorization/rules/RelationalComparatorRule.js index 95e7bd6..87f247c 100644 --- a/src/authorization/rules/RelationalComparatorRule.js +++ b/src/authorization/rules/RelationalComparatorRule.js @@ -148,6 +148,7 @@ export class RelationalComparatorRule extends BaseRule { const resolvedRule = operandCompiled?.rule || nestedRuleConfig; const { valueContext = null } = options; + const evaluationNow = options.now !== undefined && options.now !== null ? options.now : Date.now(); const operandMetaBase = { operandSide: side, nestedRuleType: resolvedRule?.type || nestedRuleConfig.type, @@ -215,6 +216,7 @@ export class RelationalComparatorRule extends BaseRule { const canCacheDerived = extractValue && !!this.arbiter.ruleResultCache && !options.partialGraphContext && !options.includeMeta && + (options.now === undefined || options.now === null) && options.cacheDerivedValues !== false; const operandIdentity = this._getOperandCacheIdentity(resolvedValueRelation, resolvedRule, nestedRuleConfig); const derivedCacheKey = canCacheDerived @@ -282,7 +284,7 @@ export class RelationalComparatorRule extends BaseRule { let operandSource = null; if (directRel && typeof directRel.value === 'number') { - const cached = this._getCachedDirectValue(directRel, ttl); + const cached = this._getCachedDirectValue(directRel, ttl, evaluationNow); if (cached) { const aggregatedResult = this._aggregateCrispValues( [cached], finalAggregator, owaWeights, operandEvalMeta, options @@ -389,7 +391,7 @@ export class RelationalComparatorRule extends BaseRule { } const rel = this.arbiter.relationManager.getDirectRelation(srcId, relName, dstId, options); if (!rel || typeof rel.value !== 'number') continue; - if (this.arbiter.valueManager && this.arbiter.valueManager._isValueExpired(rel)) continue; + if (this.arbiter.valueManager && this.arbiter.valueManager._isValueExpired(rel, evaluationNow)) continue; valueResults.push({ value: rel.value, @@ -615,7 +617,7 @@ export class RelationalComparatorRule extends BaseRule { */ _extractValues(userId, userKey, objectId, objectKey, rule, valueRelation, ruleResult, evaluateFrom, valueContext, ttl, operandEvalMeta, options = null) { const results = []; - const now = Date.now(); + const now = options && options.now !== undefined && options.now !== null ? options.now : Date.now(); const ttlCutoff = now - ttl; // First check if rule result directly provides values @@ -698,7 +700,7 @@ export class RelationalComparatorRule extends BaseRule { if (rule.type === 'direct') { const directRel = this._getDirectRelationForValue(userId, objectId, relationName, evaluateFrom, options); if (directRel && typeof directRel.value === 'number') { - const cached = this._getCachedDirectValue(directRel, ttl); + const cached = this._getCachedDirectValue(directRel, ttl, now); if (cached) { results.push(cached); } @@ -782,8 +784,9 @@ export class RelationalComparatorRule extends BaseRule { return directRel; } - _getCachedDirectValue(relation, ttl) { - if (this.arbiter.valueManager && this.arbiter.valueManager._isValueExpired(relation)) { + _getCachedDirectValue(relation, ttl, now = null) { + const ts = now !== null && now !== undefined ? now : Date.now(); + if (this.arbiter.valueManager && this.arbiter.valueManager._isValueExpired(relation, ts)) { return null; } const cacheKey = `${relation.src}|${relation.rel}|${relation.dst}`; @@ -791,8 +794,8 @@ export class RelationalComparatorRule extends BaseRule { if (cached && cached.stateId === relation.stateId) { return cached; } - const timestamp = relation.changed_last_at || relation.updated_last_at || Date.now(); - if (!OWAFusion.isWithinTTL(timestamp, ttl)) { + const timestamp = relation.changed_last_at || relation.updated_last_at || ts; + if (!OWAFusion.isWithinTTL(timestamp, ttl, ts)) { return null; } const entry = { diff --git a/src/core/ExplainSerializer.js b/src/core/ExplainSerializer.js index ec847d4..0776f7e 100644 --- a/src/core/ExplainSerializer.js +++ b/src/core/ExplainSerializer.js @@ -71,6 +71,13 @@ export class ExplainSerializer { } }; + // Temporal context: the explain rerun must reproduce the original + // decision, so the temporal parameters (now, the TTL/proof clock) are + // recorded here — the caller replays them verbatim on the rerun. + if (options && options.now !== undefined && options.now !== null) { + request.temporal = { now: options.now }; + } + if (redaction === 'hash') { request.userKeyHash = this._hashKey(userKey); request.objectKeyHash = this._hashKey(objectKey); diff --git a/src/core/ValueManager.js b/src/core/ValueManager.js index d690cd8..d709b03 100644 --- a/src/core/ValueManager.js +++ b/src/core/ValueManager.js @@ -159,10 +159,11 @@ export class ValueManager { * Check if a relation value has expired based on TTL * @private */ - _isValueExpired(relation) { + _isValueExpired(relation, now = null) { const ttl = this.getTTL(relation.rel); - const timestamp = relation.changed_last_at || relation.updated_last_at || Date.now(); - const age = Date.now() - timestamp; + const ts = now !== null && now !== undefined ? now : Date.now(); + const timestamp = relation.changed_last_at || relation.updated_last_at || ts; + const age = ts - timestamp; return age > ttl; } diff --git a/src/utils/OWAFusion.js b/src/utils/OWAFusion.js index 47e00d5..f7134b4 100644 --- a/src/utils/OWAFusion.js +++ b/src/utils/OWAFusion.js @@ -1012,9 +1012,9 @@ export class OWAFusion { * @param {number} ttlMs - TTL in milliseconds (default 24 hours) * @returns {boolean} Whether the value is within TTL */ - static isWithinTTL(timestamp, ttlMs = 24 * 60 * 60 * 1000) { + static isWithinTTL(timestamp, ttlMs = 24 * 60 * 60 * 1000, now = null) { if (!timestamp) return false; - const age = Date.now() - timestamp; + const age = (now !== null && now !== undefined ? now : Date.now()) - timestamp; return age <= ttlMs; } diff --git a/tests/rigor/validity-parity.test.js b/tests/rigor/validity-parity.test.js index 99d9b8e..8019129 100644 --- a/tests/rigor/validity-parity.test.js +++ b/tests/rigor/validity-parity.test.js @@ -243,3 +243,49 @@ describe('Persistence losslessness (rigor)', () => { assert.equal(restored2.relationManager.getDirectRelation(restored2.resolveNodeId('u:0'), 'owner', restored2.resolveNodeId('d:0')).validity, 'finite_sample'); }); }); + +describe('Temporal replay (re-entrant diagnostics) (rigor)', () => { + it('FIXED: pinned-clock checks are per-time, and the explain rerun reproduces the decision', () => { + const a = new Arbiter(); + a.addNode('u:0', 'user'); + a.addNode('d:0', 'doc'); + 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', 60000); + a.valueManager.setTTL('has_price', 60000); + const t0 = 1000000; + a.addRelation('u:0', 'has_balance', 'd:0', { value: 100, changed_last_at: t0 }); + a.addRelation('d:0', 'has_price', 'd:0', { value: 50, changed_last_at: t0 }); + + // Interleaved pinned-clock checks must each reflect their own clock — + // no cross-contamination through any result cache. + assert.equal(a.check('u:0', 'premium', 'd:0', { now: t0 + 120000 }).possibility, 0, 'expired at +120s'); + assert.equal(a.check('u:0', 'premium', 'd:0', { now: t0 + 1000 }).possibility, 1, 'fresh at +1s'); + assert.equal(a.check('u:0', 'premium', 'd:0', { now: t0 + 120000 }).possibility, 0, 'expired again'); + + // The explain rerun reproduces the pinned decision and records the + // temporal context the caller must replay. + const e = a.explain('u:0', 'premium', 'd:0', { now: t0 + 120000 }); + assert.equal(e.decision.possibility, 0, 'explain rerun reproduces the expired decision'); + assert.deepEqual(e.request.temporal, { now: t0 + 120000 }, 'temporal context recorded for replay'); + const e2 = a.explain('u:0', 'premium', 'd:0', { now: t0 + 1000 }); + assert.equal(e2.decision.possibility, 1, 'explain rerun reproduces the fresh decision'); + }); + + it('FIXED: challenge proof expiry is replayable via the pinned clock', () => { + const a = new Arbiter(); + a.addNode('u:0', 'user'); + a.addNode('d:0', 'doc'); + a.setRelationConfig('can_download', { type: 'challenge', challenge: 'mfa', subject: 'user', withinMinutes: 5 }); + const issued = 500000; + const proof = [{ name: 'mfa', subject: 'u:0', issuedAt: issued, expiresAt: null }]; + assert.equal(a.check('u:0', 'can_download', 'd:0', { partialGraph: { challenges: proof }, now: issued + 299000 }).possibility, 1, 'within window'); + assert.equal(a.check('u:0', 'can_download', 'd:0', { partialGraph: { challenges: proof }, now: issued + 301000 }).possibility, 0, 'past window'); + const e = a.explain('u:0', 'can_download', 'd:0', { partialGraph: { challenges: proof }, now: issued + 301000 }); + assert.equal(e.decision.possibility, 0, 'explain reproduces the expired proof'); + assert.deepEqual(e.request.temporal, { now: issued + 301000 }, 'challenge temporal recorded'); + }); +});