fix(hierarchy): include transfer snapshots in replay content comparison
ci/woodpecker/pr/ci Pipeline was successful

Review round 1 blocker: sameEvent omitted transferFrom/transferTo, so a
retried transfer re-routed to a different destination would silently
replay instead of conflicting. Transfer source/destination are semantic
event content per §5.2. Adds a witness varying the transfer destination
on a duplicate idempotency key; also fixes a stale cross-reference in
the db witness header.
This commit is contained in:
fred
2026-08-27 21:04:59 -05:00
parent 6a49de55bf
commit 08410dbd84
3 changed files with 27 additions and 2 deletions
@@ -143,6 +143,27 @@ describe('hierarchy audit repository integration', (): void => {
).rejects.toThrow(HierarchyAuditIdempotencyConflictError);
});
it('throws on a duplicate idempotency key whose transfer destination differs', async () => {
const from = { kind: 'company' as const, id: randomUUID(), slug: 'src-co' };
const to = { kind: 'company' as const, id: randomUUID(), slug: 'dst-co' };
const first = input({
verb: 'transfer',
targetKind: 'estate',
transferFrom: from,
transferTo: to,
});
const original = await handle.db.transaction(async (tx) => repo.append(tx, first));
expect(original.replayed).toBe(false);
// Identical retry replays; a retry re-routed to a different destination must conflict.
const replay = await handle.db.transaction(async (tx) => repo.append(tx, first));
expect(replay.replayed).toBe(true);
await expect(
handle.db.transaction(async (tx) =>
repo.append(tx, { ...first, transferTo: { ...to, id: randomUUID() } }),
),
).rejects.toThrow(HierarchyAuditIdempotencyConflictError);
});
it('builds root-first parent chains and rejects unknown nodes', async () => {
const companyId = randomUUID();
const estateId = randomUUID();
@@ -165,7 +165,11 @@ function sameEvent(row: HierarchyAuditEventRow, input: AppendHierarchyEventInput
row.targetId === input.targetId &&
row.correlationId === input.correlationId &&
(row.causationId ?? null) === (input.causationId ?? null) &&
canonicalJson(row.targetSnapshot) === canonicalJson(input.targetSnapshot)
canonicalJson(row.targetSnapshot) === canonicalJson(input.targetSnapshot) &&
// Transfer source/destination are semantic content (§5.2): a retry with a
// different destination must conflict, never silently replay.
canonicalJson(row.transferFrom ?? null) === canonicalJson(input.transferFrom ?? null) &&
canonicalJson(row.transferTo ?? null) === canonicalJson(input.transferTo ?? null)
);
}
@@ -9,7 +9,7 @@
* table — events survive the deletion of their target), the causation
* self-FK, and the outbox's FK/uniqueness/status shape. The repository-level
* half of §6.4 (same-transaction atomicity, rollback, replay) is witnessed in
* apps/gateway/src/hierarchy/hierarchy-audit.witness.test.ts.
* apps/gateway/src/hierarchy/hierarchy-audit.integration.test.ts.
*
* Two legs run the same witness body:
* - PGlite (WASM Postgres): always runs.