From f0dc14fb724ab1cfd9fb2ea69382fc50e67afcca Mon Sep 17 00:00:00 2001 From: John Dvorak Date: Sat, 1 Aug 2026 08:08:36 -0700 Subject: [PATCH] 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. --- src/authorization/rules/TupleToUsersetRule.js | 25 +++++++++- src/core/relation/RelationUpdates.js | 15 ++++-- tests/rigor/rule-kind-partial-parity.test.js | 47 +++++++++++++++++++ 3 files changed, 80 insertions(+), 7 deletions(-) diff --git a/src/authorization/rules/TupleToUsersetRule.js b/src/authorization/rules/TupleToUsersetRule.js index 9939941..253f383 100644 --- a/src/authorization/rules/TupleToUsersetRule.js +++ b/src/authorization/rules/TupleToUsersetRule.js @@ -175,6 +175,27 @@ export class TupleToUsersetRule extends BaseRule { let reasons = []; 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(); @@ -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); diff --git a/src/core/relation/RelationUpdates.js b/src/core/relation/RelationUpdates.js index 2353a00..26db32c 100644 --- a/src/core/relation/RelationUpdates.js +++ b/src/core/relation/RelationUpdates.js @@ -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); diff --git a/tests/rigor/rule-kind-partial-parity.test.js b/tests/rigor/rule-kind-partial-parity.test.js index 17983b1..c067c29 100644 --- a/tests/rigor/rule-kind-partial-parity.test.js +++ b/tests/rigor/rule-kind-partial-parity.test.js @@ -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();