/** * rigor/tx-rollback-parity.test.js — transactional batch atomicity and * batch+PLTC reachability. * * Contracts pinned: * - updateRelationsBatchTransactional is ATOMIC: a poison op anywhere in * the batch (invalid possibility) fails the whole batch and rolls back * every earlier op; on success every op applies in the given order. * - batch ADD validates possibility exactly like single adds (an invalid * possibility must never be stored, even via the batch path). * - with enableReachabilityCheck, batch adds/removes update the PLTC * indices: chain checks reflect batch-written edges immediately. */ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { rigor } from '@rigor/core'; import { Arbiter } from '../../src/index.js'; const DOCS = 3; const docKey = (i) => `doc:${i}`; const tupleKey = (d) => `u:0|owner|doc:${d}`; function applyMirror(tuples, ops) { for (const op of ops) { if (op.operation === 'remove') { tuples.delete(tupleKey(op.doc)); } else if (op.operation === 'modify' && !tuples.has(tupleKey(op.doc))) { continue; // engine no-op on missing tuple } else { tuples.set(tupleKey(op.doc), op.value); } } } function makeWrapper() { const arbiter = new Arbiter(); arbiter.addNode('u:0', 'user'); for (let i = 0; i < DOCS; i++) arbiter.addNode(docKey(i), 'doc'); arbiter.setRelationConfig('can_read', { type: 'direct', relation: 'owner' }); const tuples = new Map(); const ops = []; const wrapper = { engine: arbiter, tuples, add(doc, value) { arbiter.addRelation('u:0', 'owner', docKey(doc), { possibility: value }); tuples.set(tupleKey(doc), value); return { ok: true }; }, txBatch(batchOps, poisonAt) { const batch = batchOps.map((op, i) => ({ operation: op.operation, srcKey: 'u:0', relation: 'owner', dstKey: docKey(op.doc), options: op.operation === 'remove' ? {} : { possibility: i === poisonAt ? 1.5 : op.value } })); const result = arbiter.relationManager.updateRelationsBatchTransactional(batch); if (result.success) { applyMirror(tuples, batchOps); } return { success: result.success, error: result.error }; }, check(doc) { const result = arbiter.check('u:0', 'can_read', docKey(doc)); const expected = tuples.get(tupleKey(doc)) ?? 0; return { engine: result.possibility, expected, reason: result.reason }; }, clone() { const fresh = makeWrapper(); for (const op of ops) { const [name, ...args] = op; fresh[name](...args); } return fresh; } }; const record = (name, fn) => (...args) => { const res = fn(...args); ops.push([name, ...args]); return res; }; wrapper.add = record('add', wrapper.add); wrapper.txBatch = record('txBatch', wrapper.txBatch); wrapper.check = record('check', wrapper.check); return wrapper; } const opGen = rigor.gen.record({ operation: rigor.gen.enum(['add', 'modify', 'remove']), doc: rigor.gen.int(0, DOCS - 1), value: rigor.gen.oneOf([0.2, 0.5, 0.8, 0.9]) }); describe('Transactional batch atomicity and batch+PLTC (rigor)', () => { it('FIXED MATRIX: poison at every position rolls back everything; batch add validates possibility', () => { const w = makeWrapper(); w.add(0, 0.9); w.add(1, 0.7); for (const poisonAt of [0, 1, 2]) { const tx = w.txBatch([ { operation: 'add', doc: 2, value: 0.4 }, { operation: 'modify', doc: 1, value: 0.6 }, { operation: 'modify', doc: 0, value: 0.6 } ], poisonAt); assert.equal(tx.success, false, `poison at ${poisonAt} must fail`); assert.equal(w.check(0).engine, 0.9, `poison at ${poisonAt}: doc0 rolled back`); assert.equal(w.check(1).engine, 0.7, `poison at ${poisonAt}: doc1 rolled back`); assert.equal(w.check(2).engine, 0, `poison at ${poisonAt}: doc2 rolled back`); } const clean = w.txBatch([ { operation: 'add', doc: 2, value: 0.4 }, { operation: 'modify', doc: 1, value: 0.6 }, { operation: 'modify', doc: 0, value: 0.6 } ], -1); assert.equal(clean.success, true, 'clean tx succeeds'); assert.equal(w.check(0).engine, 0.6, 'clean tx: doc0 last-write-wins'); assert.equal(w.check(1).engine, 0.6, 'clean tx: doc1 modified'); assert.equal(w.check(2).engine, 0.4, 'clean tx: doc2 added'); assert.throws( () => w.engine.relationManager.updateRelationsBatch([ { operation: 'add', srcKey: 'u:0', relation: 'owner', dstKey: 'doc:0', options: { possibility: 7 } } ]), /Invalid possibility/, 'batch add must reject invalid possibility' ); }); it('BATCH+PLTC: chain reachability reflects batch writes immediately', () => { const arb = new Arbiter({ enableReachabilityCheck: true }); arb.addNode('u:0', 'user'); arb.addNode('g:0', 'group'); arb.addNode('doc:0', 'doc'); arb.setRelationConfig('can_access', { type: 'chain', steps: [ { relation: 'member_of', direction: 'out' }, { relation: 'reads', direction: 'out' } ] }); arb.relationManager.updateRelationsBatch([ { operation: 'add', srcKey: 'u:0', relation: 'member_of', dstKey: 'g:0', options: { possibility: 0.8 } }, { operation: 'add', srcKey: 'g:0', relation: 'reads', dstKey: 'doc:0', options: { possibility: 0.7 } } ]); assert.equal(arb.check('u:0', 'can_access', 'doc:0').possibility, 0.7, 'chain allow after batch add'); arb.relationManager.updateRelationsBatch([ { operation: 'remove', srcKey: 'g:0', relation: 'reads', dstKey: 'doc:0', options: {} } ]); assert.equal(arb.check('u:0', 'can_access', 'doc:0').possibility, 0, 'chain deny after batch remove'); }); it('PROPERTY CAMPAIGN: tx atomicity and in-order application hold under fuzzed batches', async () => { const result = await rigor.campaign( [rigor.object('graph', makeWrapper, [ rigor.method('add', function (d, v) { return this.add(d, v); }, rigor.args(rigor.gen.int(0, DOCS - 1), rigor.gen.oneOf([0.2, 0.5, 0.8, 0.9]))), rigor.method('txBatch', function (batchOps, poisonAt) { return this.txBatch(batchOps, poisonAt); }, rigor.args(rigor.gen.array(opGen, 1, 4), rigor.gen.oneOf([-1, 0, 1, 2, 3]))), rigor.method('check', function (d) { return this.check(d); }, rigor.args(rigor.gen.int(0, DOCS - 1))) ])], rigor.crucible([ rigor.invariant('decision parity after every step', (ctx) => { if (ctx.action !== 'graph.check' || ctx.error !== null) return true; return ctx.actual.engine === ctx.actual.expected; }), rigor.invariant('tx returns a boolean success flag', (ctx) => { if (ctx.action !== 'graph.txBatch') return true; return typeof ctx.actual.success === 'boolean'; }), rigor.invariant('no action errors', (ctx) => ctx.error === null) ]) ).run({ effort: 400, seed: 'tx-rollback-parity', maxTraceLength: 25 }); const inv = result.crucibleVerdict; assert.equal(inv.passed, true, [ `tx atomicity violated in ${inv.failureCount} cases:`, ...result.failures.slice(0, 3).map((f) => ` [${f.invariant}] action=${f.action} args=${JSON.stringify(f.args)} actual=${JSON.stringify(f.actual)} error=${f.error}` ) ].join('\n')); }); });