diff --git a/src/authorization/DecisionCache.js b/src/authorization/DecisionCache.js index 15ad221..9b55521 100644 --- a/src/authorization/DecisionCache.js +++ b/src/authorization/DecisionCache.js @@ -36,7 +36,9 @@ export class DecisionCache { */ constructor(arbiter, options = {}) { this.arbiter = arbiter; - this.clock = options.clock || (() => Date.now()); + // Precedence: an explicit cache-level clock wins; else the arbiter's + // injected clock (options.clock on the Arbiter); else the wall clock. + this.clock = options.clock || ((arbiter && typeof arbiter.clock === 'function') ? arbiter.clock.bind(arbiter) : (() => Date.now())); this._enabled = !!arbiter; } diff --git a/src/authorization/RuleEvaluator.js b/src/authorization/RuleEvaluator.js index 8cba107..77f017b 100644 --- a/src/authorization/RuleEvaluator.js +++ b/src/authorization/RuleEvaluator.js @@ -55,7 +55,8 @@ export class RuleEvaluator { if (canCacheRuleResult) { const cached = this.arbiter.ruleResultCache.get(ruleCacheKey); - if (cached && Date.now() - cached.timestamp < this.arbiter.ruleResultCacheTTL) { + const cacheNow = this.arbiter.clock ? this.arbiter.clock() : Date.now(); + if (cached && cacheNow - cached.timestamp < this.arbiter.ruleResultCacheTTL) { this.arbiter.ruleResultCacheStats.hits++; if (collectValues && finalValueContext && cached.result?.collectedValues?.length) { const ruleType = rule?.type || (rule?.union ? 'union' : rule?.intersection ? 'intersection' : rule?.exclusion ? 'exclusion' : 'rule'); @@ -159,9 +160,10 @@ export class RuleEvaluator { _maybeCacheRuleResult(result, relation, cacheKey, enabled) { if (!enabled || !cacheKey || !relation) return result; + const cacheNow = this.arbiter.clock ? this.arbiter.clock() : Date.now(); this.arbiter.ruleResultCache.set(cacheKey, { result, - timestamp: Date.now() + timestamp: cacheNow }); this.arbiter._cacheRuleResult(relation, cacheKey); return result; diff --git a/src/authorization/rules/ChainRule.js b/src/authorization/rules/ChainRule.js index b6ed495..8e5d1b0 100644 --- a/src/authorization/rules/ChainRule.js +++ b/src/authorization/rules/ChainRule.js @@ -433,7 +433,8 @@ export class ChainRule extends BaseRule { const key = this._getChainResultCacheKey(userId, objectId, steps); const entry = this.chainResultCache.get(key); - if (entry && Date.now() - entry.timestamp < this.cacheTTL) { + const cacheNow = this.arbiter.clock ? this.arbiter.clock() : Date.now(); + if (entry && cacheNow - entry.timestamp < this.cacheTTL) { return entry.result; } @@ -451,9 +452,10 @@ export class ChainRule extends BaseRule { const key = this._getChainResultCacheKey(userId, objectId, steps); // HyperbolicLRUCache handles eviction automatically based on frequency and recency + const cacheNow = this.arbiter.clock ? this.arbiter.clock() : Date.now(); this.chainResultCache.set(key, { result, - timestamp: Date.now() + timestamp: cacheNow }); } @@ -514,7 +516,8 @@ export class ChainRule extends BaseRule { return collectedValues; } - const blurred = this.arbiter.valueManager.getBlurredValue(relation); + const callerNow = options && options.now !== undefined && options.now !== null ? options.now : null; + const blurred = this.arbiter.valueManager.getBlurredValue(relation, callerNow); if (blurred.interval) { const sourceEntity = direction === 'in' ? @@ -590,7 +593,8 @@ export class ChainRule extends BaseRule { Arbiter.DEBUG && Arbiter.log('ChainRule: relationManager or valueManager is undefined'); return collectedValues; } - const blurred = this.arbiter.relationManager.valueManager.getBlurredValue(tempRelation); + const callerNow = options && options.now !== undefined && options.now !== null ? options.now : null; + const blurred = this.arbiter.relationManager.valueManager.getBlurredValue(tempRelation, callerNow); if (blurred.interval) { const collectedValue = this._createCollectedValue( diff --git a/src/authorization/rules/DirectRule.js b/src/authorization/rules/DirectRule.js index dabb6cf..84079bb 100644 --- a/src/authorization/rules/DirectRule.js +++ b/src/authorization/rules/DirectRule.js @@ -108,7 +108,8 @@ export class DirectRule extends BaseRule { step: 0 }, { - timestamp: directRel.changed_last_at || directRel.updated_last_at || Date.now(), + timestamp: directRel.changed_last_at || directRel.updated_last_at || + (options && options.now !== undefined && options.now !== null ? options.now : Date.now()), reliability: 1.0, source: directRel.source || 'persistent' } diff --git a/src/authorization/rules/MultiHopRule.js b/src/authorization/rules/MultiHopRule.js index f036e2f..ad89638 100644 --- a/src/authorization/rules/MultiHopRule.js +++ b/src/authorization/rules/MultiHopRule.js @@ -370,21 +370,27 @@ export class MultiHopRule extends BaseRule { // Collect from direct edge if available if (step.edge && step.edge.value !== undefined && step.edge.value !== null) { if (this._passesValueFilters(step.edge.value, valueFilters)) { - // Check TTL - const ttl = valueFilters.ttl || 24 * 60 * 60 * 1000; - const timestamp = step.edge.changed_last_at || step.edge.updated_last_at || Date.now(); + // Check TTL. The valueManager's per-relation TTL is the + // authority (matching chain/comparator paths); valueFilters.ttl + // is an explicit override. Timestamps and the age computation + // use the caller-pinned clock, never the wall clock. + const ttl = valueFilters.ttl || (valueManager && valueManager.getTTL + ? valueManager.getTTL(step.edge.rel) + : 24 * 60 * 60 * 1000); + const timestamp = step.edge.changed_last_at || step.edge.updated_last_at || + (evaluationNow !== undefined && evaluationNow !== null ? evaluationNow : Date.now()); if (!OWAFusion.isWithinTTL(timestamp, ttl, evaluationNow)) { Arbiter.DEBUG && Arbiter.log('MultiHop skipping value due to TTL:', { value: step.edge.value, timestamp, ttl, - age: Date.now() - timestamp + age: evaluationNow - timestamp }); continue; } - const blurred = valueManager.getBlurredValue(step.edge); + const blurred = valueManager.getBlurredValue(step.edge, evaluationNow); if (blurred.interval) { const collectedValue = this._createCollectedValue( @@ -447,7 +453,7 @@ export class MultiHopRule extends BaseRule { changed_last_at: contextValue.timestamp }; - const blurred = valueManager.getBlurredValue(tempRelation); + const blurred = valueManager.getBlurredValue(tempRelation, evaluationNow); if (blurred.interval) { const collectedValue = this._createCollectedValue( diff --git a/src/authorization/rules/QualitativeRelationalComparatorRule.js b/src/authorization/rules/QualitativeRelationalComparatorRule.js index 2adbde6..d7caec9 100644 --- a/src/authorization/rules/QualitativeRelationalComparatorRule.js +++ b/src/authorization/rules/QualitativeRelationalComparatorRule.js @@ -244,7 +244,7 @@ export class QualitativeRelationalComparatorRule extends BaseRule { } // Extract blurred values with qualitative decay and blur - const blurredValues = this._extractBlurredValues(adjustedValues, operandConfig, scale); + const blurredValues = this._extractBlurredValues(adjustedValues, operandConfig, scale, options); if (operandMeta) { operandMeta.blurredValues = blurredValues; @@ -299,7 +299,7 @@ export class QualitativeRelationalComparatorRule extends BaseRule { values.push({ value: qualitativeValue, possibility: qualitativeValue, - timestamp: Date.now(), + timestamp: options && options.now !== undefined && options.now !== null ? options.now : Date.now(), relation: 'rule_result', meta: { source: 'rule_possibility' } }); @@ -351,7 +351,7 @@ export class QualitativeRelationalComparatorRule extends BaseRule { * Extract blurred values with qualitative decay and blur * @private */ - _extractBlurredValues(values, operandConfig, scale) { + _extractBlurredValues(values, operandConfig, scale, options = null) { const { decaySteps = 1, decayPeriod = 'HOUR', @@ -366,10 +366,11 @@ export class QualitativeRelationalComparatorRule extends BaseRule { for (const valueObj of values) { const pointValue = valueObj.value; const initialPossibility = valueObj.possibility; - const timestamp = valueObj.timestamp || Date.now(); + const timestamp = valueObj.timestamp || + (options && options.now !== undefined && options.now !== null ? options.now : Date.now()); // Calculate periods elapsed - const periodsElapsed = this._calculatePeriodsElapsed(timestamp, decayPeriod); + const periodsElapsed = this._calculatePeriodsElapsed(timestamp, decayPeriod, options); // Calculate decayed possibility const decayedPossibility = this._calculateDecayedPossibility( @@ -415,8 +416,8 @@ export class QualitativeRelationalComparatorRule extends BaseRule { * Calculate periods elapsed since timestamp * @private */ - _calculatePeriodsElapsed(timestamp, decayPeriod) { - const now = Date.now(); + _calculatePeriodsElapsed(timestamp, decayPeriod, options = null) { + const now = options && options.now !== undefined && options.now !== null ? options.now : Date.now(); const elapsed = now - timestamp; const periodMs = { @@ -533,7 +534,8 @@ export class QualitativeRelationalComparatorRule extends BaseRule { step: index }, metadata: { - timestamp: bv.timestamp || Date.now(), + timestamp: bv.timestamp || + (options && options.now !== undefined && options.now !== null ? options.now : Date.now()), reliability: 1.0, originalValue: bv.originalValue, originalPossibility: bv.originalPossibility, diff --git a/src/authorization/rules/RelationalComparatorRule.js b/src/authorization/rules/RelationalComparatorRule.js index 87f247c..cc60183 100644 --- a/src/authorization/rules/RelationalComparatorRule.js +++ b/src/authorization/rules/RelationalComparatorRule.js @@ -228,7 +228,8 @@ export class RelationalComparatorRule extends BaseRule { : null; if (canCacheDerived) { const cached = this.arbiter.ruleResultCache.get(derivedCacheKey); - if (cached && Date.now() - cached.timestamp < this.arbiter.ruleResultCacheTTL) { + const cacheNow = this.arbiter.clock ? this.arbiter.clock() : Date.now(); + if (cached && cacheNow - cached.timestamp < this.arbiter.ruleResultCacheTTL) { const cachedResult = cached.result; let valueInterval = cachedResult.valueInterval; let operandPossibility = cachedResult.operandPossibility || 0; @@ -316,7 +317,8 @@ export class RelationalComparatorRule extends BaseRule { step: 0 }, { - timestamp: directRel.changed_last_at || directRel.updated_last_at || Date.now(), + timestamp: directRel.changed_last_at || directRel.updated_last_at || + (evaluationNow !== undefined && evaluationNow !== null ? evaluationNow : Date.now()), reliability: directRel.reliability || 1.0, source: directRel.source || 'persistent' } @@ -324,6 +326,7 @@ export class RelationalComparatorRule extends BaseRule { } if (canCacheDerived && derivedCacheKey) { + const cacheNow = this.arbiter.clock ? this.arbiter.clock() : Date.now(); this.arbiter.ruleResultCache.set(derivedCacheKey, { result: { valueInterval, @@ -333,7 +336,7 @@ export class RelationalComparatorRule extends BaseRule { source: operandSource, collectedValues: operandCollectedValues }, - timestamp: Date.now() + timestamp: cacheNow }); this.arbiter._cacheRuleResult(currentRelation, derivedCacheKey); if (operandEvalMeta) operandEvalMeta.steps.push({ step: 'DerivedValueCached', derivedCacheKey }); @@ -397,7 +400,8 @@ export class RelationalComparatorRule extends BaseRule { value: rel.value, possibility: rel.possibility !== undefined ? rel.possibility : 1.0, reliability: rel.reliability || 1.0, - timestamp: rel.changed_last_at || rel.updated_last_at || Date.now(), + timestamp: rel.changed_last_at || rel.updated_last_at || + (evaluationNow !== undefined && evaluationNow !== null ? evaluationNow : Date.now()), source: 'direct_relation', originalValue: rel.value }); @@ -414,7 +418,8 @@ export class RelationalComparatorRule extends BaseRule { step: 0 }, { - timestamp: rel.changed_last_at || rel.updated_last_at || Date.now(), + timestamp: rel.changed_last_at || rel.updated_last_at || + (evaluationNow !== undefined && evaluationNow !== null ? evaluationNow : Date.now()), reliability: rel.reliability || 1.0, source: rel.source || 'persistent' } @@ -450,6 +455,7 @@ export class RelationalComparatorRule extends BaseRule { } if (canCacheDerived && derivedCacheKey) { + const cacheNow = this.arbiter.clock ? this.arbiter.clock() : Date.now(); this.arbiter.ruleResultCache.set(derivedCacheKey, { result: { valueInterval, @@ -459,7 +465,7 @@ export class RelationalComparatorRule extends BaseRule { source: operandSource, collectedValues: operandCollectedValues }, - timestamp: Date.now() + timestamp: cacheNow }); this.arbiter._cacheRuleResult(currentRelation, derivedCacheKey); if (operandEvalMeta) operandEvalMeta.steps.push({ step: 'DerivedValueCached', derivedCacheKey }); @@ -566,6 +572,7 @@ export class RelationalComparatorRule extends BaseRule { } if (canCacheDerived && derivedCacheKey) { + const cacheNow = this.arbiter.clock ? this.arbiter.clock() : Date.now(); this.arbiter.ruleResultCache.set(derivedCacheKey, { result: { valueInterval, @@ -575,7 +582,7 @@ export class RelationalComparatorRule extends BaseRule { source: operandSource, collectedValues: operandCollectedValues }, - timestamp: Date.now() + timestamp: cacheNow }); this.arbiter._cacheRuleResult(currentRelation, derivedCacheKey); if (operandEvalMeta) operandEvalMeta.steps.push({ step: 'DerivedValueCached', derivedCacheKey }); diff --git a/src/core/Arbiter.js b/src/core/Arbiter.js index 05ca1aa..88fb340 100644 --- a/src/core/Arbiter.js +++ b/src/core/Arbiter.js @@ -30,6 +30,12 @@ export class Arbiter { // audit state — the caller owns persistence and retention. The record // is built only when the hook exists, so the default path is untouched. this._auditHook = typeof options.audit === 'function' ? options.audit : null; + + // The engine's clock. The caller may inject a clock (options.clock); + // the wall clock is the fallback for unpinned callers only. Per-check + // time is always the caller's `{ now }` / partialGraph.now — this + // clock only drives UNPINNED cache-entry freshness. + this.clock = typeof options.clock === 'function' ? options.clock : (() => Date.now()); // Injectable cache factory (DI): defaults to the built-in SimpleLRUCache. // Pass options.cacheFactory to substitute another cache implementation. diff --git a/src/core/PartialGraphContext.js b/src/core/PartialGraphContext.js index 7644336..a6fdcf4 100644 --- a/src/core/PartialGraphContext.js +++ b/src/core/PartialGraphContext.js @@ -126,7 +126,9 @@ export class PartialGraphContext { _addChallengeProof(proof) { const subjectId = this._resolveNodeId(proof.subject); - const now = Date.now(); + // The partial graph carries the caller's clock; fall back to the wall + // clock only when the caller provided no time at all. + const now = this.now !== null && this.now !== undefined ? this.now : Date.now(); // 0 is a valid timestamp (epoch-issued / already-expired); `||` would // replace it with the wall clock or null, making an epoch-issued proof // the most recent one and an epoch-expired proof never expire. @@ -150,7 +152,7 @@ export class PartialGraphContext { const srcId = this._resolveNodeId(rel.src); const dstId = this._resolveNodeId(rel.dst); const relation = rel.relation; - const now = Date.now(); + const now = this.now !== null && this.now !== undefined ? this.now : Date.now(); const relationObj = { src: srcId, rel: relation, diff --git a/src/core/RelationManager.js b/src/core/RelationManager.js index f52cb62..4640080 100644 --- a/src/core/RelationManager.js +++ b/src/core/RelationManager.js @@ -327,7 +327,8 @@ export class RelationManager { this._ensureIndicesBuilt(); const rel = this.arbiter.indices.getDirectRelation(srcId, relation, dstId); if (rel && rel.value !== undefined) { - const blurred = this.arbiter.valueManager.getBlurredValue(rel); + const callerNow = options && options.now !== undefined && options.now !== null ? options.now : null; + const blurred = this.arbiter.valueManager.getBlurredValue(rel, callerNow); const result = { pointValue: rel.value, // Original crisp value value: rel.value, // Backward compatibility @@ -543,7 +544,7 @@ export class RelationManager { * @param {number} epsilon - Optional epsilon for equality * @returns {number} Possibility (0-1) that comparison holds */ - compareRelationValues(leftRelation, rightRelation, comparator, epsilon = null) { + compareRelationValues(leftRelation, rightRelation, comparator, epsilon = null, options = null) { // Get actual relation objects if needed const leftRel = leftRelation.value !== undefined ? leftRelation : this.getDirectRelation(leftRelation.srcId, leftRelation.relation, leftRelation.dstId); @@ -554,8 +555,9 @@ export class RelationManager { return 0; // Cannot compare if either has no value } - const leftBlurred = this.arbiter.valueManager.getBlurredValue(leftRel); - const rightBlurred = this.arbiter.valueManager.getBlurredValue(rightRel); + const callerNow = options && options.now !== undefined && options.now !== null ? options.now : null; + const leftBlurred = this.arbiter.valueManager.getBlurredValue(leftRel, callerNow); + const rightBlurred = this.arbiter.valueManager.getBlurredValue(rightRel, callerNow); if (!leftBlurred.interval || !rightBlurred.interval) { return 0; // Cannot compare null intervals diff --git a/src/core/ValueManager.js b/src/core/ValueManager.js index d709b03..4ff9708 100644 --- a/src/core/ValueManager.js +++ b/src/core/ValueManager.js @@ -170,9 +170,11 @@ export class ValueManager { /** * Get or calculate the blurred value interval for a relation * @param {Object} relation - The relation object + * @param {number|null} [now] - Caller-pinned clock; the wall clock is + * only the fallback for unpinned callers. * @returns {Object} { interval: {min, max}, possibility: number, reliability: number } */ - getBlurredValue(relation) { + getBlurredValue(relation, now = null) { // If relation has no value, return null interval if (relation.value === undefined || relation.value === null) { return { @@ -183,7 +185,7 @@ export class ValueManager { } // Check TTL first - if expired, return null interval - if (this._isValueExpired(relation)) { + if (this._isValueExpired(relation, now)) { return { interval: null, possibility: 0, @@ -204,7 +206,7 @@ export class ValueManager { * @param {Object} relation - The relation object * @returns {Object} { pointValue, blurredInterval, currentPossibility, originalPossibility, reliability } */ - getDecayedRelation(relation) { + getDecayedRelation(relation, now = null) { if (relation.value === undefined || relation.value === null) { return { pointValue: null, @@ -232,18 +234,19 @@ export class ValueManager { * Calculate separated decay for value blurring and possibility * @private */ - _calculateSeparatedDecay(relation) { + _calculateSeparatedDecay(relation, now = null) { const pointValue = relation.value; const initialPossibility = relation.possibility !== undefined ? relation.possibility : 1.0; const reliability = relation.reliability !== undefined ? relation.reliability : 1.0; - const timestamp = relation.changed_last_at || relation.updated_last_at || Date.now(); + const timestamp = relation.changed_last_at || relation.updated_last_at || + (now !== null && now !== undefined ? now : Date.now()); // Get decay configuration const config = this._getRelationDecayConfig(relation); // Calculate age - const now = Date.now(); - const ageMs = Math.max(0, now - timestamp); + const evalNow = now !== null && now !== undefined ? now : Date.now(); + const ageMs = Math.max(0, evalNow - timestamp); const periodMs = PERIOD_TO_MS[config.decayPeriod.toUpperCase()] || PERIOD_TO_MS.HOUR; const ageInPeriod = ageMs / periodMs; @@ -636,18 +639,19 @@ export class ValueManager { * Calculate the blurred value interval for a relation * @private */ - _calculateBlurredValue(relation) { + _calculateBlurredValue(relation, now = null) { const pointValue = relation.value; const initialPossibility = relation.possibility !== undefined ? relation.possibility : 1.0; const reliability = relation.reliability !== undefined ? relation.reliability : 1.0; - const timestamp = relation.changed_last_at || relation.updated_last_at || Date.now(); + const timestamp = relation.changed_last_at || relation.updated_last_at || + (now !== null && now !== undefined ? now : Date.now()); // Get decay configuration const config = this._getRelationDecayConfig(relation); // Calculate age - const now = Date.now(); - const ageMs = Math.max(0, now - timestamp); // Ensure non-negative + const evalNow = now !== null && now !== undefined ? now : Date.now(); + const ageMs = Math.max(0, evalNow - timestamp); // Ensure non-negative const periodMs = PERIOD_TO_MS[config.decayPeriod.toUpperCase()] || PERIOD_TO_MS.HOUR; const ageInPeriod = ageMs / periodMs;