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
@@ -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();