fix(audit): GDPR/merge — M6 (drop false merge-reversibility claims), M7 (GDPR export adds 4 PII tables), L14 (docstring), L15 (hard-delete breadcrumb note)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-02 13:07:21 +02:00
parent aedbcfd58d
commit ebe5fe6ed8
3 changed files with 81 additions and 14 deletions

View File

@@ -8,11 +8,15 @@
* actually merges two pre-existing clients
* - the migration script's `--apply` (eventually)
*
* Reversibility: every merge writes a `client_merge_log` row containing
* the loser's full pre-merge state. Within the configured undo window
* (default 7 days, see `dedup_undo_window_days` in system_settings) a
* follow-up `unmergeClients` call can restore the loser and detach
* everything that was reattached.
* NOT reversible: a merge is permanent. Every merge writes a
* `client_merge_log` row containing a snapshot of the loser's pre-merge
* state, but this is a forensic/audit record only — there is NO
* `unmergeClients` implementation, and the child rows reattached to the
* winner are not restorable from the snapshot. Operators must treat
* merge as a destructive, one-way operation. (The original design called
* for a 7-day `dedup_undo_window_days` reversibility window; that undo
* pathway was never built, so the setting has no effect and the snapshot
* is retained purely as an audit trail.)
*
* Design reference: docs/superpowers/specs/2026-05-03-dedup-and-migration-design.md §6.
*/
@@ -138,8 +142,9 @@ export async function mergeClients(opts: MergeOptions): Promise<MergeResult> {
throw new ConflictError('Cannot merge into an archived client');
}
// ── Snapshot the loser's full state before any mutation. Used by
// `unmergeClients` to restore within the undo window. ──────────────
// ── Snapshot the loser's full state before any mutation. Written to
// `client_merge_log.mergeDetails` as a forensic/audit record only;
// NOT used to restore — merge is one-way (no `unmergeClients`). ─────
const loserContacts = await tx
.select()
.from(clientContacts)
@@ -245,8 +250,8 @@ export async function mergeClients(opts: MergeOptions): Promise<MergeResult> {
const key = `${c.channel}::${c.value.toLowerCase()}`;
if (winnerContactKeys.has(key)) {
// Winner already has this contact - drop loser's row (cascade
// will clean up when loser is archived). But we keep snapshot
// so undo restores it.
// will clean up when loser is archived). The snapshot records it
// for audit, but this drop is not reversible (merge is one-way).
continue;
}
await tx
@@ -393,9 +398,11 @@ export async function mergeClients(opts: MergeOptions): Promise<MergeResult> {
.returning({ id: invoices.id })
).length;
// ── Archive the loser. Row stays in DB for the undo window;
// `mergedIntoClientId` is the redirect pointer for any stragglers
// (links / direct queries / saved views). ──────────────────────────
// ── Archive the loser. The row stays in the DB (soft-archived, not
// deleted) so `mergedIntoClientId` can act as the redirect pointer
// for any stragglers (links / direct queries / saved views). This
// is a permanent redirect — the loser is never un-archived by a
// reverse-merge, as no unmerge pathway exists. ─────────────────────
await tx
.update(clients)
.set({