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:
@@ -211,12 +211,24 @@ export class AuthorizationChecker {
|
|||||||
? valueManager._isValueExpired(directRel, options.now !== undefined && options.now !== null ? options.now : null)
|
? valueManager._isValueExpired(directRel, options.now !== undefined && options.now !== null ? options.now : null)
|
||||||
: false;
|
: false;
|
||||||
if (!expired) {
|
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 = [{
|
result.collectedValues = [{
|
||||||
value: directRel.value,
|
value: directRel.value,
|
||||||
source: 'direct_relation',
|
possibility: directRel.possibility ?? 1.0,
|
||||||
relation: relation,
|
path: [userKey, objectKey],
|
||||||
userKey: userKey,
|
source: {
|
||||||
objectKey: objectKey
|
entityKey: userKey,
|
||||||
|
relation: relation,
|
||||||
|
step: 0
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
timestamp: ts,
|
||||||
|
reliability: directRel.reliability !== undefined ? directRel.reliability : 1.0
|
||||||
|
}
|
||||||
}];
|
}];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -177,6 +177,9 @@ export class BaseRule {
|
|||||||
* @protected
|
* @protected
|
||||||
*/
|
*/
|
||||||
_createCollectedValue(value, possibility, path, source, metadata = {}) {
|
_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 {
|
return {
|
||||||
value: value,
|
value: value,
|
||||||
possibility: possibility ?? 1.0,
|
possibility: possibility ?? 1.0,
|
||||||
@@ -188,7 +191,7 @@ export class BaseRule {
|
|||||||
step: source.step !== undefined ? source.step : 0
|
step: source.step !== undefined ? source.step : 0
|
||||||
},
|
},
|
||||||
metadata: {
|
metadata: {
|
||||||
timestamp: metadata.timestamp || Date.now(),
|
timestamp: metadata.timestamp || clockNow,
|
||||||
reliability: metadata.reliability !== undefined ? metadata.reliability : 1.0,
|
reliability: metadata.reliability !== undefined ? metadata.reliability : 1.0,
|
||||||
decay: metadata.decay || null,
|
decay: metadata.decay || null,
|
||||||
...metadata
|
...metadata
|
||||||
|
|||||||
@@ -539,7 +539,8 @@ export class ChainRule extends BaseRule {
|
|||||||
source: relation.source || 'persistent'
|
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,
|
reliability: blurred.reliability,
|
||||||
pathPossibility: currentPath.possibility,
|
pathPossibility: currentPath.possibility,
|
||||||
relationPossibility: relation.possibility ?? 1.0,
|
relationPossibility: relation.possibility ?? 1.0,
|
||||||
|
|||||||
@@ -188,7 +188,8 @@ export class TupleToUsersetRule extends BaseRule {
|
|||||||
[tupleSrcKey, intermediateKey],
|
[tupleSrcKey, intermediateKey],
|
||||||
{ entityKey: tupleSrcKey, relation: rule.tuplesetRelation, step: 0 },
|
{ 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,
|
reliability: tupleEdge.reliability || 1.0,
|
||||||
source: tupleEdge.source || 'persistent'
|
source: tupleEdge.source || 'persistent'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -91,6 +91,28 @@ describe('TTL contract (rigor)', () => {
|
|||||||
assert.equal(atWrite.possibility, 1);
|
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', () => {
|
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
|
// The direct-check cache must never serve a result whose collected
|
||||||
// values were captured before expiry: values are TTL-gated evidence.
|
// values were captured before expiry: values are TTL-gated evidence.
|
||||||
|
|||||||
Reference in New Issue
Block a user