js-rigor: TTU operand values flow; modify honors changed_last_at override

Two issues found by the extended probe sweep:

- A relational_comparator operand backed by a tuple_to_userset rule always
  denied: the TTU rule collected only the intermediate KEY, which the
  operand extraction skips as non-numeric, so no value was ever available.
  The TTU rule now emits a value-carrying collected entry when the
  tupleset edge carries a numeric value (entityKey = tuple src, relation =
  tupleset relation), keeping the bare intermediate key when there is no
  value. Comparator-with-TTU-operand now allows/denies on the tuple value
  through both persistent and partial contexts.

- _modifyRelation ignored the changed_last_at override that the add path
  honors: value-changing modifies stamped fresh Date.now() regardless of
  the pin, so replay/restore tools pinning timestamps got different
  semantics via modify vs add. The override now applies to refresh events
  (value/reliability/possibility change) and is ignored for value-unchanged
  writes, preserving the TTL parity contract that identical replays never
  un-expire old values.

Verified clean: intersection through partial, defeasible with logical
when, challenge subject object/session with sessionKey, non-binary
minAllowPossibility threshold, batch value updates, explain agreement
under partial.
This commit is contained in:
John Dvorak
2026-08-01 08:08:36 -07:00
parent 02d7a5dd75
commit f0dc14fb72
3 changed files with 80 additions and 7 deletions
+23 -2
View File
@@ -176,6 +176,27 @@ export class TupleToUsersetRule extends BaseRule {
let intermediateEvaluationDetails = includeMeta ? [] : null;
let processedCount = 0;
// Collected values carry the tupleset edge's value when it has one (the
// intermediate key alone is not enough for downstream value consumers
// like relational_comparator operands); otherwise the intermediate key
// is collected for path-based value lookups.
const buildCollectedValue = (tupleEdge, tupleSrcKey, intermediateKey) => {
if (tupleEdge && typeof tupleEdge.value === 'number') {
return this._createCollectedValue(
tupleEdge.value,
tupleEdge.possibility,
[tupleSrcKey, intermediateKey],
{ entityKey: tupleSrcKey, relation: rule.tuplesetRelation, step: 0 },
{
timestamp: tupleEdge.changed_last_at || tupleEdge.updated_last_at || Date.now(),
reliability: tupleEdge.reliability || 1.0,
source: tupleEdge.source || 'persistent'
}
);
}
return intermediateKey;
};
// Process direct tuples with early exit optimization
const computedRelationCache = new Map();
const computedByIntermediate = joinMode === 'tuples' && computedEdges
@@ -258,7 +279,7 @@ export class TupleToUsersetRule extends BaseRule {
computedRelation: { relation: rule.computedRelation, possibility: res.possibility, reliability: res.reliability, meta: res.meta }
}
}),
...(!useLightweightPaths && collectValues && { collectedValue: intermediateKey })
...(!useLightweightPaths && collectValues && { collectedValue: buildCollectedValue(tupleEdge, resolveKey(tupleEdge.src, options), intermediateKey) })
};
allValidPaths.push(path);
if (!bestPath || combinedPossibility > bestPath.possibility) {
@@ -365,7 +386,7 @@ export class TupleToUsersetRule extends BaseRule {
computedRelation: { relation: rule.computedRelation, possibility: res.possibility, reliability: res.reliability, meta: res.meta }
}
}),
...(!useLightweightPaths && collectValues && { collectedValue: intermediateKey })
...(!useLightweightPaths && collectValues && { collectedValue: buildCollectedValue(t, entry.srcKey, intermediateKey) })
};
allValidPaths.push(pathData);
+10 -5
View File
@@ -600,11 +600,16 @@ export class RelationUpdates {
oldRelation.value = newOptions?.value !== undefined ? newOptions.value : oldRelation.value;
oldRelation.decayConfig = newOptions?.decayConfig !== undefined ? newOptions.decayConfig : oldRelation.decayConfig;
oldRelation.updated_last_at = currentTime;
oldRelation.changed_last_at = (newOptions?.value !== oldValue ||
(newOptions?.reliability !== undefined && newOptions.reliability !== oldReliability) ||
(newOptions?.possibility !== undefined && newOptions.possibility !== oldPossibility))
? currentTime
: oldRelation.changed_last_at;
// Honor an explicit changed_last_at override like the add path does,
// but only on writes that would refresh anyway: a write that does not
// change the value (or its interpretation) keeps the old timestamp,
// so replaying identical tuples never un-expires old values.
const shouldRefresh = newOptions?.value !== oldValue ||
(newOptions?.reliability !== undefined && newOptions.reliability !== oldReliability) ||
(newOptions?.possibility !== undefined && newOptions.possibility !== oldPossibility);
oldRelation.changed_last_at = shouldRefresh
? (newOptions?.changed_last_at !== undefined ? newOptions.changed_last_at : currentTime)
: oldRelation.changed_last_at;
oldRelation.stateId = newStateId;
this.manager._relationsChanged = true;
this.manager._invalidateRelationCaches(srcId, relation, dstId);
@@ -442,6 +442,53 @@ describe('Rule-kind × partial-graph parity (rigor)', () => {
const p = restored.check('u:0', 'can_read', 'doc:0');
assert.equal(round4(p.possibility), 0.7, 'snapshot-restored TTU');
}
// ---- comparator operand as TTU (value flows via the tupleset edge) ----
{
const a = mkArbiter();
a.setRelationConfig('can_access', {
type: 'relational_comparator', comparator: '>=',
left: { rule: { type: 'tuple_to_userset', tuplesetRelation: 'owner', computedRelation: 'member_of' }, extractValue: true },
right: { rule: { type: 'direct', relation: 'requested_level' }, extractValue: true, evaluateFrom: 'object' }
});
a.setRelationConfig('member_of', { type: 'direct', relation: 'member_of' });
a.addRelation('doc:0', 'owner', 'g:0', { possibility: 0.9, value: 7 });
a.addRelation('u:0', 'member_of', 'g:0', { possibility: 0.8 });
a.addRelation('doc:0', 'requested_level', 'doc:0', { possibility: 1.0, value: 4 });
assert.equal(a.check('u:0', 'can_access', 'doc:0').possibility, 1, 'ttu operand persistent allow');
a.removeRelation('doc:0', 'owner', 'g:0');
a.removeRelation('u:0', 'member_of', 'g:0');
a.removeRelation('doc:0', 'requested_level', 'doc:0');
const r = a.check('u:0', 'can_access', 'doc:0', {
partialGraph: { relations: [
{ src: 'doc:0', relation: 'owner', dst: 'g:0', possibility: 0.9, value: 7 },
{ src: 'u:0', relation: 'member_of', dst: 'g:0', possibility: 0.8 },
{ src: 'doc:0', relation: 'requested_level', dst: 'doc:0', possibility: 1.0, value: 4 }
] }
});
assert.equal(r.possibility, 1, 'ttu operand partial allow');
a.addRelation('doc:0', 'owner', 'g:0', { possibility: 0.9, value: 3 });
a.addRelation('u:0', 'member_of', 'g:0', { possibility: 0.8 });
a.addRelation('doc:0', 'requested_level', 'doc:0', { possibility: 1.0, value: 4 });
assert.equal(a.check('u:0', 'can_access', 'doc:0').possibility, 0, 'ttu operand below threshold deny');
a.removeRelation('doc:0', 'owner', 'g:0');
a.addRelation('doc:0', 'owner', 'g:0', { possibility: 0.9 });
assert.equal(a.check('u:0', 'can_access', 'doc:0').possibility, 0, 'ttu operand without value denies');
}
// ---- modify changed_last_at override (value-changing modify honors the pin) ----
{
const a = mkArbiter();
a.addRelation('u:0', 'balance', 'doc:0', { value: 10, changed_last_at: 1000 });
a.relationManager.updateRelationsBatch([
{ operation: 'modify', srcKey: 'u:0', relation: 'balance', dstKey: 'doc:0', options: { value: 20, changed_last_at: 5000 } }
]);
const after = a.relationManager.getDirectRelation(a.resolveNodeId('u:0'), 'balance', a.resolveNodeId('doc:0'));
assert.equal(after.changed_last_at, 5000, 'value-changing modify honors changed_last_at override');
a.relationManager.updateRelationsBatch([
{ operation: 'modify', srcKey: 'u:0', relation: 'balance', dstKey: 'doc:0', options: { value: 20, changed_last_at: 9999 } }
]);
const unchanged = a.relationManager.getDirectRelation(a.resolveNodeId('u:0'), 'balance', a.resolveNodeId('doc:0'));
assert.equal(unchanged.changed_last_at, 5000, 'value-unchanged modify keeps old timestamp (override not a refresh)');
}
// ---- binary mode agrees with normal at the same threshold ----
{
const a = mkArbiter();