diff --git a/apps/gateway/src/hierarchy/hierarchy-audit.integration.test.ts b/apps/gateway/src/hierarchy/hierarchy-audit.integration.test.ts index 926539bb..2b12e2f5 100644 --- a/apps/gateway/src/hierarchy/hierarchy-audit.integration.test.ts +++ b/apps/gateway/src/hierarchy/hierarchy-audit.integration.test.ts @@ -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(); diff --git a/apps/gateway/src/hierarchy/hierarchy-audit.repository.ts b/apps/gateway/src/hierarchy/hierarchy-audit.repository.ts index 6b7be8aa..8929b76b 100644 --- a/apps/gateway/src/hierarchy/hierarchy-audit.repository.ts +++ b/apps/gateway/src/hierarchy/hierarchy-audit.repository.ts @@ -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) ); } diff --git a/packages/db/src/hierarchy-audit.witness.test.ts b/packages/db/src/hierarchy-audit.witness.test.ts index 00450b8f..4b6543e5 100644 --- a/packages/db/src/hierarchy-audit.witness.test.ts +++ b/packages/db/src/hierarchy-audit.witness.test.ts @@ -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.