diff --git a/src/core/PartialGraphContext.js b/src/core/PartialGraphContext.js index 5e45a1a..7644336 100644 --- a/src/core/PartialGraphContext.js +++ b/src/core/PartialGraphContext.js @@ -4,6 +4,13 @@ import { validateReducerConfig, getAllowedRelationsForLayer } from './partial-gr export class PartialGraphContext { constructor(arbiter, partialGraph) { this.arbiter = arbiter; + // The caller's view of the world at a moment in time: the partial graph + // may carry that time. When present it becomes the evaluation clock for + // this check, so a rerun that replays the same partial graph reproduces + // the same decision (TTL gates, proof expiry, decay all honor it). + this.now = (partialGraph && partialGraph.now !== undefined && partialGraph.now !== null) + ? partialGraph.now + : null; const policy = arbiter?.partialGraphPolicy || null; this.nodeIdByKey = new Map(); this.keyByNodeId = new Map(); diff --git a/src/core/arbiter/ArbiterChecks.js b/src/core/arbiter/ArbiterChecks.js index ce99f3a..42789fd 100644 --- a/src/core/arbiter/ArbiterChecks.js +++ b/src/core/arbiter/ArbiterChecks.js @@ -28,6 +28,9 @@ export class ArbiterChecks { throw new Error(`Partial graph exceeds max nodes: ${nodes.length} > ${maxNodes}`); } options.partialGraphContext = this.arbiter._createPartialGraphContext(options.partialGraph); + if (options.now === undefined && options.partialGraphContext.now !== null) { + options.now = options.partialGraphContext.now; + } } const { returnHints = false, _visited = new Set(), _currentRelation = null, binary = false, epsilon, delta, clientStateId } = options; @@ -115,6 +118,9 @@ export class ArbiterChecks { if (options.partialGraph && !options.partialGraphContext) { options.partialGraphContext = this.arbiter._createPartialGraphContext(options.partialGraph); + if (options.now === undefined && options.partialGraphContext.now !== null) { + options.now = options.partialGraphContext.now; + } } const explainOptions = { diff --git a/tests/rigor/validity-parity.test.js b/tests/rigor/validity-parity.test.js index 8019129..394bd9b 100644 --- a/tests/rigor/validity-parity.test.js +++ b/tests/rigor/validity-parity.test.js @@ -289,3 +289,25 @@ describe('Temporal replay (re-entrant diagnostics) (rigor)', () => { assert.deepEqual(e.request.temporal, { now: issued + 301000 }, 'challenge temporal recorded'); }); }); + +describe('Partial graph carries the temporal context (rigor)', () => { + it('FIXED: partialGraph.now drives the decision, and the rerun replays the same object', () => { + 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; + // One self-contained request object: evidence + the caller's time. + const pg = { challenges: [{ name: 'mfa', subject: 'u:0', issuedAt: issued, expiresAt: null }], now: issued + 299000 }; + assert.equal(a.check('u:0', 'can_download', 'd:0', { partialGraph: pg }).possibility, 1, 'within window via partialGraph.now'); + pg.now = issued + 301000; + assert.equal(a.check('u:0', 'can_download', 'd:0', { partialGraph: pg }).possibility, 0, 'past window via partialGraph.now'); + // an explicit options.now overrides the partial graph's time + assert.equal(a.check('u:0', 'can_download', 'd:0', { partialGraph: pg, now: issued + 299000 }).possibility, 1, 'explicit now overrides'); + // the rerun replays the same object and reproduces the decision + pg.now = issued + 299000; + const e = a.explain('u:0', 'can_download', 'd:0', { partialGraph: pg }); + assert.equal(e.decision.possibility, 1, 'explain rerun with the same partial graph reproduces'); + assert.deepEqual(e.request.temporal, { now: issued + 299000 }, 'temporal recorded from the partial graph'); + }); +});