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
+22
View File
@@ -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.