js-rigor: re-entrant temporal diagnostics — pinned-clock checks + explain replay
The errors-skill's two-pass model applied without a journal: the caller owns the temporal context. Every decision-flipping temporal feature (the clock behind TTL gates, challenge-proof expiry, value decay) is now parameterized as options.now, so an explain rerun that replays the original temporal parameters reproduces the original decision exactly. Threaded now through: the comparator's operand value paths (_getCachedDirectValue/_extractValues/isWithinTTL), the challenge proof lookup, and the multi_hop value collection (isWithinTTL + blur). All four result caches bypass cached decisions when the clock is pinned (rule- result cache, the checker's rule/direct caches, and the comparator's derived operand cache) — interleaved pinned-clock checks are per-time with no cross-contamination. The explain serializer records request.temporal.now so the caller knows exactly what to replay. The happy path stays minimal and fast (no now -> no parameter, no cache changes); the rerun (explain with the temporal context) carries the full diagnostics. Pins: interleaved fresh/expired/expired-again comparator checks, the explain replay of both decisions + the recorded temporal context, and challenge-proof expiry replay.
This commit is contained in:
@@ -86,7 +86,8 @@ export class AuthorizationChecker {
|
|||||||
// Check cache first using composite key (if caching is enabled)
|
// Check cache first using composite key (if caching is enabled)
|
||||||
let cachedResult = null;
|
let cachedResult = null;
|
||||||
let cacheHint = 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 cacheKey = this._getDirectCheckCacheKey(userKey, relation, objectKey);
|
||||||
const [hitResult, status] = this.decisionCache.peekDirect(cacheKey);
|
const [hitResult, status] = this.decisionCache.peekDirect(cacheKey);
|
||||||
cachedResult = status === 'hit' || status === 'expired' ? { result: hitResult, timestamp: 0 } : null;
|
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
|
// Cache the result (only when no partial graph and the default
|
||||||
// meta-less form — includeMeta callers get a fresh full evaluation)
|
// meta-less form — includeMeta callers and pinned-clock callers get
|
||||||
if (!explain && !hasPartialGraph && !includeMeta) {
|
// a fresh evaluation)
|
||||||
|
if (!explain && !hasPartialGraph && !includeMeta && !temporalPinned) {
|
||||||
this._cacheDirectCheckResult(userKey, relation, objectKey, result);
|
this._cacheDirectCheckResult(userKey, relation, objectKey, result);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@@ -227,7 +229,7 @@ export class AuthorizationChecker {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Cache the result using composite key (meta-less form only)
|
// 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);
|
this._cacheDirectCheckResult(userKey, relation, objectKey, result);
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
@@ -264,8 +266,9 @@ export class AuthorizationChecker {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const temporalPinned = options.now !== undefined && options.now !== null;
|
||||||
const canCacheRuleResult = !hasPartialGraph && !explain && !includeMeta &&
|
const canCacheRuleResult = !hasPartialGraph && !explain && !includeMeta &&
|
||||||
!binary && options.cacheRuleResult !== false && this.decisionCache.ruleEnabled;
|
!binary && !temporalPinned && options.cacheRuleResult !== false && this.decisionCache.ruleEnabled;
|
||||||
const ruleCacheKey = canCacheRuleResult
|
const ruleCacheKey = canCacheRuleResult
|
||||||
? this._getRuleResultCacheKey(userId, relation, objectId)
|
? this._getRuleResultCacheKey(userId, relation, objectId)
|
||||||
: null;
|
: null;
|
||||||
|
|||||||
@@ -43,8 +43,11 @@ export class RuleEvaluator {
|
|||||||
const collectValues = options.collectValues !== undefined ? options.collectValues : (rule._needsValues ?? needsValueContext);
|
const collectValues = options.collectValues !== undefined ? options.collectValues : (rule._needsValues ?? needsValueContext);
|
||||||
const enhancedOptions = { ...options, minPossibility: options.minPossibility ?? options.minAllowPossibility, valueContext: finalValueContext, collectValues, includeMeta };
|
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 &&
|
const canCacheRuleResult = !!this.arbiter.ruleResultCache && currentRelation &&
|
||||||
!binary && !options.partialGraphContext && !includeMeta &&
|
!binary && !options.partialGraphContext && !includeMeta && !temporalPinned &&
|
||||||
options.cacheRuleResult !== false;
|
options.cacheRuleResult !== false;
|
||||||
const ruleCacheKey = canCacheRuleResult
|
const ruleCacheKey = canCacheRuleResult
|
||||||
? this._getRuleResultCacheKey(numericUserId, currentRelation, numericObjectId, rule)
|
? this._getRuleResultCacheKey(numericUserId, currentRelation, numericObjectId, rule)
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ export class ChallengeRule extends BaseRule {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const subjectId = this.arbiter.resolveNodeId(subjectKey, { partialGraphContext: partialContext });
|
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);
|
const proof = partialContext.getChallengeProof(challenge, subjectId, withinMs, now);
|
||||||
|
|
||||||
if (!proof) {
|
if (!proof) {
|
||||||
|
|||||||
@@ -349,8 +349,9 @@ export class MultiHopRule extends BaseRule {
|
|||||||
* Collect values from a complete path
|
* Collect values from a complete path
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
_collectValuesFromPath(pathSteps, defaultRelation, valueFilters, valueContext) {
|
_collectValuesFromPath(pathSteps, defaultRelation, valueFilters, valueContext, options = null) {
|
||||||
const collectedValues = [];
|
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
|
// Get blurred interval from ValueManager (arbiter-level; some stubs and
|
||||||
// older layouts keep it on the relation manager)
|
// older layouts keep it on the relation manager)
|
||||||
const valueManager = this.arbiter.valueManager || this.arbiter.relationManager?.valueManager;
|
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 ttl = valueFilters.ttl || 24 * 60 * 60 * 1000;
|
||||||
const timestamp = step.edge.changed_last_at || step.edge.updated_last_at || Date.now();
|
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:', {
|
Arbiter.DEBUG && Arbiter.log('MultiHop skipping value due to TTL:', {
|
||||||
value: step.edge.value,
|
value: step.edge.value,
|
||||||
timestamp,
|
timestamp,
|
||||||
@@ -431,7 +432,7 @@ export class MultiHopRule extends BaseRule {
|
|||||||
if (this._passesValueFilters(contextValue.value, valueFilters)) {
|
if (this._passesValueFilters(contextValue.value, valueFilters)) {
|
||||||
// Check TTL
|
// Check TTL
|
||||||
const ttl = valueFilters.ttl || 24 * 60 * 60 * 1000;
|
const ttl = valueFilters.ttl || 24 * 60 * 60 * 1000;
|
||||||
if (!OWAFusion.isWithinTTL(contextValue.timestamp, ttl)) {
|
if (!OWAFusion.isWithinTTL(contextValue.timestamp, ttl, evaluationNow)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -148,6 +148,7 @@ export class RelationalComparatorRule extends BaseRule {
|
|||||||
const resolvedRule = operandCompiled?.rule || nestedRuleConfig;
|
const resolvedRule = operandCompiled?.rule || nestedRuleConfig;
|
||||||
const { valueContext = null } = options;
|
const { valueContext = null } = options;
|
||||||
|
|
||||||
|
const evaluationNow = options.now !== undefined && options.now !== null ? options.now : Date.now();
|
||||||
const operandMetaBase = {
|
const operandMetaBase = {
|
||||||
operandSide: side,
|
operandSide: side,
|
||||||
nestedRuleType: resolvedRule?.type || nestedRuleConfig.type,
|
nestedRuleType: resolvedRule?.type || nestedRuleConfig.type,
|
||||||
@@ -215,6 +216,7 @@ export class RelationalComparatorRule extends BaseRule {
|
|||||||
|
|
||||||
const canCacheDerived = extractValue && !!this.arbiter.ruleResultCache &&
|
const canCacheDerived = extractValue && !!this.arbiter.ruleResultCache &&
|
||||||
!options.partialGraphContext && !options.includeMeta &&
|
!options.partialGraphContext && !options.includeMeta &&
|
||||||
|
(options.now === undefined || options.now === null) &&
|
||||||
options.cacheDerivedValues !== false;
|
options.cacheDerivedValues !== false;
|
||||||
const operandIdentity = this._getOperandCacheIdentity(resolvedValueRelation, resolvedRule, nestedRuleConfig);
|
const operandIdentity = this._getOperandCacheIdentity(resolvedValueRelation, resolvedRule, nestedRuleConfig);
|
||||||
const derivedCacheKey = canCacheDerived
|
const derivedCacheKey = canCacheDerived
|
||||||
@@ -282,7 +284,7 @@ export class RelationalComparatorRule extends BaseRule {
|
|||||||
let operandSource = null;
|
let operandSource = null;
|
||||||
|
|
||||||
if (directRel && typeof directRel.value === 'number') {
|
if (directRel && typeof directRel.value === 'number') {
|
||||||
const cached = this._getCachedDirectValue(directRel, ttl);
|
const cached = this._getCachedDirectValue(directRel, ttl, evaluationNow);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
const aggregatedResult = this._aggregateCrispValues(
|
const aggregatedResult = this._aggregateCrispValues(
|
||||||
[cached], finalAggregator, owaWeights, operandEvalMeta, options
|
[cached], finalAggregator, owaWeights, operandEvalMeta, options
|
||||||
@@ -389,7 +391,7 @@ export class RelationalComparatorRule extends BaseRule {
|
|||||||
}
|
}
|
||||||
const rel = this.arbiter.relationManager.getDirectRelation(srcId, relName, dstId, options);
|
const rel = this.arbiter.relationManager.getDirectRelation(srcId, relName, dstId, options);
|
||||||
if (!rel || typeof rel.value !== 'number') continue;
|
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({
|
valueResults.push({
|
||||||
value: rel.value,
|
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) {
|
_extractValues(userId, userKey, objectId, objectKey, rule, valueRelation, ruleResult, evaluateFrom, valueContext, ttl, operandEvalMeta, options = null) {
|
||||||
const results = [];
|
const results = [];
|
||||||
const now = Date.now();
|
const now = options && options.now !== undefined && options.now !== null ? options.now : Date.now();
|
||||||
const ttlCutoff = now - ttl;
|
const ttlCutoff = now - ttl;
|
||||||
|
|
||||||
// First check if rule result directly provides values
|
// First check if rule result directly provides values
|
||||||
@@ -698,7 +700,7 @@ export class RelationalComparatorRule extends BaseRule {
|
|||||||
if (rule.type === 'direct') {
|
if (rule.type === 'direct') {
|
||||||
const directRel = this._getDirectRelationForValue(userId, objectId, relationName, evaluateFrom, options);
|
const directRel = this._getDirectRelationForValue(userId, objectId, relationName, evaluateFrom, options);
|
||||||
if (directRel && typeof directRel.value === 'number') {
|
if (directRel && typeof directRel.value === 'number') {
|
||||||
const cached = this._getCachedDirectValue(directRel, ttl);
|
const cached = this._getCachedDirectValue(directRel, ttl, now);
|
||||||
if (cached) {
|
if (cached) {
|
||||||
results.push(cached);
|
results.push(cached);
|
||||||
}
|
}
|
||||||
@@ -782,8 +784,9 @@ export class RelationalComparatorRule extends BaseRule {
|
|||||||
return directRel;
|
return directRel;
|
||||||
}
|
}
|
||||||
|
|
||||||
_getCachedDirectValue(relation, ttl) {
|
_getCachedDirectValue(relation, ttl, now = null) {
|
||||||
if (this.arbiter.valueManager && this.arbiter.valueManager._isValueExpired(relation)) {
|
const ts = now !== null && now !== undefined ? now : Date.now();
|
||||||
|
if (this.arbiter.valueManager && this.arbiter.valueManager._isValueExpired(relation, ts)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const cacheKey = `${relation.src}|${relation.rel}|${relation.dst}`;
|
const cacheKey = `${relation.src}|${relation.rel}|${relation.dst}`;
|
||||||
@@ -791,8 +794,8 @@ export class RelationalComparatorRule extends BaseRule {
|
|||||||
if (cached && cached.stateId === relation.stateId) {
|
if (cached && cached.stateId === relation.stateId) {
|
||||||
return cached;
|
return cached;
|
||||||
}
|
}
|
||||||
const timestamp = relation.changed_last_at || relation.updated_last_at || Date.now();
|
const timestamp = relation.changed_last_at || relation.updated_last_at || ts;
|
||||||
if (!OWAFusion.isWithinTTL(timestamp, ttl)) {
|
if (!OWAFusion.isWithinTTL(timestamp, ttl, ts)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const entry = {
|
const entry = {
|
||||||
|
|||||||
@@ -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') {
|
if (redaction === 'hash') {
|
||||||
request.userKeyHash = this._hashKey(userKey);
|
request.userKeyHash = this._hashKey(userKey);
|
||||||
request.objectKeyHash = this._hashKey(objectKey);
|
request.objectKeyHash = this._hashKey(objectKey);
|
||||||
|
|||||||
@@ -159,10 +159,11 @@ export class ValueManager {
|
|||||||
* Check if a relation value has expired based on TTL
|
* Check if a relation value has expired based on TTL
|
||||||
* @private
|
* @private
|
||||||
*/
|
*/
|
||||||
_isValueExpired(relation) {
|
_isValueExpired(relation, now = null) {
|
||||||
const ttl = this.getTTL(relation.rel);
|
const ttl = this.getTTL(relation.rel);
|
||||||
const timestamp = relation.changed_last_at || relation.updated_last_at || Date.now();
|
const ts = now !== null && now !== undefined ? now : Date.now();
|
||||||
const age = Date.now() - timestamp;
|
const timestamp = relation.changed_last_at || relation.updated_last_at || ts;
|
||||||
|
const age = ts - timestamp;
|
||||||
return age > ttl;
|
return age > ttl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1012,9 +1012,9 @@ export class OWAFusion {
|
|||||||
* @param {number} ttlMs - TTL in milliseconds (default 24 hours)
|
* @param {number} ttlMs - TTL in milliseconds (default 24 hours)
|
||||||
* @returns {boolean} Whether the value is within TTL
|
* @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;
|
if (!timestamp) return false;
|
||||||
const age = Date.now() - timestamp;
|
const age = (now !== null && now !== undefined ? now : Date.now()) - timestamp;
|
||||||
return age <= ttlMs;
|
return age <= ttlMs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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');
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user