From f530532e48274fba15305d26238819da5d5c6b9a Mon Sep 17 00:00:00 2001 From: John Dvorak Date: Sun, 2 Aug 2026 17:07:20 -0700 Subject: [PATCH] fix: standard collected-value shape on the direct fast path + caller-clock timestamps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two remaining clock/shape inconsistencies from the audit: 1. The direct fast path emitted a bare collected-value object {value, source, relation, userKey, objectKey} — no possibility, no path, no metadata. Value consumers (comparators, chains) rely on the self-describing shape the rule paths emit. The fast path now emits the standard shape (value/possibility/path/source/metadata), matching DirectRule's existing _createCollectedValue contract. 2. Collected-value timestamps fell back to the WALL clock (Date.now()) even for pinned-clock callers in BaseRule._createCollectedValue, ChainRule, and TupleToUsersetRule. The metadata timestamp now honors options.now when pinned (changed_last_at wins, then pinned now, then wall clock). ValueContext's collectedAt remains metadata-only. Pinned by ttl-contract.test.js: the fast-path collected value carries the full shape and its timestamp honors the pinned clock. Rigor 251/251, full suite 853/791/0. --- src/authorization/AuthorizationChecker.js | 20 +++++++++++++---- src/authorization/rules/BaseRule.js | 5 ++++- src/authorization/rules/ChainRule.js | 3 ++- src/authorization/rules/TupleToUsersetRule.js | 3 ++- tests/rigor/ttl-contract.test.js | 22 +++++++++++++++++++ 5 files changed, 46 insertions(+), 7 deletions(-) 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.