diff --git a/src/authorization/AuthorizationChecker.js b/src/authorization/AuthorizationChecker.js index 06b11b4..940039c 100644 --- a/src/authorization/AuthorizationChecker.js +++ b/src/authorization/AuthorizationChecker.js @@ -211,12 +211,24 @@ export class AuthorizationChecker { ? valueManager._isValueExpired(directRel, options.now !== undefined && options.now !== null ? options.now : null) : false; if (!expired) { + // Standard collected-value shape (parity with the rule + // paths): value, possibility, path, source, metadata with a + // caller-clock-honoring timestamp. + const ts = directRel.changed_last_at || directRel.updated_last_at || + (options.now !== undefined && options.now !== null ? options.now : Date.now()); result.collectedValues = [{ value: directRel.value, - source: 'direct_relation', - relation: relation, - userKey: userKey, - objectKey: objectKey + possibility: directRel.possibility ?? 1.0, + path: [userKey, objectKey], + source: { + entityKey: userKey, + relation: relation, + step: 0 + }, + metadata: { + timestamp: ts, + reliability: directRel.reliability !== undefined ? directRel.reliability : 1.0 + } }]; } } diff --git a/src/authorization/rules/BaseRule.js b/src/authorization/rules/BaseRule.js index c426e4d..186e457 100644 --- a/src/authorization/rules/BaseRule.js +++ b/src/authorization/rules/BaseRule.js @@ -177,6 +177,9 @@ export class BaseRule { * @protected */ _createCollectedValue(value, possibility, path, source, metadata = {}) { + // Timestamps honor the caller's pinned clock when present; the wall + // clock is only the fallback for unpinned callers. + const clockNow = (typeof metadata._now === 'number') ? metadata._now : Date.now(); return { value: value, possibility: possibility ?? 1.0, @@ -188,7 +191,7 @@ export class BaseRule { step: source.step !== undefined ? source.step : 0 }, metadata: { - timestamp: metadata.timestamp || Date.now(), + timestamp: metadata.timestamp || clockNow, reliability: metadata.reliability !== undefined ? metadata.reliability : 1.0, decay: metadata.decay || null, ...metadata diff --git a/src/authorization/rules/ChainRule.js b/src/authorization/rules/ChainRule.js index 1d37878..b6ed495 100644 --- a/src/authorization/rules/ChainRule.js +++ b/src/authorization/rules/ChainRule.js @@ -539,7 +539,8 @@ export class ChainRule extends BaseRule { source: relation.source || 'persistent' }, { - timestamp: relation.changed_last_at || relation.updated_last_at || Date.now(), + timestamp: relation.changed_last_at || relation.updated_last_at || + (options && options.now !== undefined && options.now !== null ? options.now : Date.now()), reliability: blurred.reliability, pathPossibility: currentPath.possibility, relationPossibility: relation.possibility ?? 1.0, diff --git a/src/authorization/rules/TupleToUsersetRule.js b/src/authorization/rules/TupleToUsersetRule.js index c1dc311..443ee77 100644 --- a/src/authorization/rules/TupleToUsersetRule.js +++ b/src/authorization/rules/TupleToUsersetRule.js @@ -188,7 +188,8 @@ export class TupleToUsersetRule extends BaseRule { [tupleSrcKey, intermediateKey], { entityKey: tupleSrcKey, relation: rule.tuplesetRelation, step: 0 }, { - timestamp: tupleEdge.changed_last_at || tupleEdge.updated_last_at || Date.now(), + timestamp: tupleEdge.changed_last_at || tupleEdge.updated_last_at || + (options && options.now !== undefined && options.now !== null ? options.now : Date.now()), reliability: tupleEdge.reliability || 1.0, source: tupleEdge.source || 'persistent' } diff --git a/tests/rigor/ttl-contract.test.js b/tests/rigor/ttl-contract.test.js index 6990c0c..d83039b 100644 --- a/tests/rigor/ttl-contract.test.js +++ b/tests/rigor/ttl-contract.test.js @@ -91,6 +91,28 @@ describe('TTL contract (rigor)', () => { assert.equal(atWrite.possibility, 1); }); + it('CONTRACT: collected values carry the standard shape and honor the caller clock', () => { + // Value consumers (comparators, chains) rely on collected values being + // self-describing: value, possibility, path, source, and a timestamp. + // The direct fast path must emit the same shape as the rule paths, and + // the timestamp must honor the pinned clock — never the wall clock. + const a = new Arbiter(); + a.addNode('u:1', 'user'); + a.addNode('doc:9', 'doc'); + a.setRelationConfig('can_read', { type: 'direct' }); + const T0 = 1_000_000_000_000; + a.addRelation('u:1', 'can_read', 'doc:9', { possibility: 0.7, value: 42, changed_last_at: T0, reliability: 0.8 }); + const cv = a.check('u:1', 'can_read', 'doc:9', { now: T0, collectValues: true }).collectedValues[0]; + assert.equal(cv.value, 42); + assert.equal(cv.possibility, 0.7); + assert.ok(Array.isArray(cv.path) && cv.path[0] === 'u:1' && cv.path[1] === 'doc:9'); + assert.equal(cv.source.entityKey, 'u:1'); + assert.equal(cv.source.relation, 'can_read'); + assert.equal(cv.source.step, 0); + assert.equal(cv.metadata.timestamp, T0, 'timestamp must honor changed_last_at under a pinned clock'); + assert.equal(cv.metadata.reliability, 0.8); + }); + 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.