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:
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user