fix: standard collected-value shape on the direct fast path + caller-clock timestamps

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.
This commit is contained in:
John Dvorak
2026-08-02 17:07:20 -07:00
parent f9d4fbe2f0
commit f530532e48
5 changed files with 46 additions and 7 deletions
+16 -4
View File
@@ -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
}
}];
}
}
+4 -1
View File
@@ -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
+2 -1
View File
@@ -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,
@@ -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'
}