feat(purge): exécuteur réel de purge de compte (§16.12) — worker BullMQ + preuve d'effacement auditée - #43
Conversation
…MQ + preuve d'effacement auditée Comble le gap HAUTE de SECURITY_PRIVACY_COMPLIANCE v3 (§Rétention) : la machine request→grâce 14 j→ready_to_purge existait mais RIEN ne consommait ready_to_purge — purgedAt n'était jamais écrit hors des specs. - account-purge.ts (pur) : preuve d'effacement structurée par classe (supprimées/anonymisées/conservées+motif, vérif « 0 ligne restante »). - store.purgeUserAccount (prisma + in-memory) : purge réelle classe par classe dans UNE transaction pg_advisory_xact_lock par user — idempotente, sûre en concurrence (2 workers → 1 purge) ; rétention financière 7 ans fail-closed (canPurgeFinancialRecord) ; audit logs RÉDIGÉS jamais supprimés ; ledger 0078 immuable respecté (retenu + consigné) ; tombstone User anonymisé portant purgedAt ; recomptage post-purge par classe, toute ligne restante ⇒ rollback complet. - POST /internal/account-purge (secret interne, DRY-RUN par défaut, ACCOUNT_PURGE_ENABLED=true pour armer) : consomme la file account.pendingDeletionUserIds, persiste la preuve dans l'AdminAuditLog (account.purge_completed) AVANT de sortir l'id de la file. - worker : job account.purge (enterprise-jobs) + CronJob Helm accountPurge (30 4 * * *) — même patron que inactivity.gc. - tests : 9 route-level négatifs d'abord (fenêtre non échue, annulation, dry-run, double exécution, course, rétention consignée, purge complète, org partagée) + 4 preuves DURABLES vrai Postgres (SQL 0-ligne, preuve relue de la DB, 2 clients en course, trigger ledger refuse DELETE). - preuve PG jouée sur pgvector:pg16 jetable : docs/deploy-evidence/ 2026-07-22-account-purge-worker/ (journal + logs + preuve JSON). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e549d9266e
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
| const retainedFinancial = { | ||
| UsageEvent: soleOrgIds.length > 0 ? await tx.usageEvent.count({ where: soleOrgWhere }) : 0, | ||
| AiCostLedger: soleOrgIds.length > 0 ? await tx.aiCostLedger.count({ where: soleOrgWhere }) : 0, | ||
| CreditLedger: soleOrgIds.length > 0 ? await tx.creditLedger.count({ where: soleOrgWhere }) : 0, | ||
| StripeEvent: soleOrgIds.length > 0 ? await tx.stripeEvent.count({ where: soleOrgWhere }) : 0, | ||
| Subscription: soleOrgIds.length > 0 ? await tx.subscription.count({ where: soleOrgWhere }) : 0, |
There was a problem hiding this comment.
Cancel active subscriptions before purging sole owners
When a user is the sole member of an organization with an active paid subscription, this code only counts and retains the Subscription row; neither the deletion request route nor this purge path cancels the external Stripe subscription or changes its status. The purge then removes all sessions and memberships, so the customer can continue being renewed after losing access to the account. Cancel or otherwise terminate active billing before committing the tombstone while retaining the required financial history separately.
Useful? React with 👍 / 👎.
| if (status === 'purged') { | ||
| alreadyPurged += 1; | ||
| await store.mutateSystemSettingIds(ACCOUNT_DELETION_PENDING_KEY, { remove: userId }); | ||
| continue; |
There was a problem hiding this comment.
Persist the erasure proof atomically with the purge
If purgeUserAccount commits but the subsequent recordAdminAudit call fails transiently, the tombstone already contains purgedAt while no proof was persisted. The next sweep enters this status === 'purged' branch and removes the pending ID without recreating the proof, making the promised erasure evidence permanently unrecoverable. The proof and tombstone need one transaction, or this branch must verify/recover the proof before dequeuing.
Useful? React with 👍 / 👎.
| for (const orgId of orgIds) { | ||
| const members = await tx.organizationMember.count({ where: { organizationId: orgId } }); | ||
| (members === 1 ? soleOrgIds : sharedOrgIds).push(orgId); |
There was a problem hiding this comment.
Lock organization membership before classifying sole-owner data
When an invitation is accepted or a member is added after this count but before the project deletion, the organization is classified as sole-member from a stale snapshot and all of its projects are deleted even though it has become shared. Membership mutation routes serialize on org-members:<orgId>, but the purge only holds account-purge:<userId> and never acquires that lock. Acquire the same per-organization lock before counting and keep it through deletion to prevent this data-loss race.
Useful? React with 👍 / 👎.
| // Marketing: unsubscribe by e-mail BEFORE the tombstone rewrites it. | ||
| const newsletter = await tx.newsletterSubscriber.deleteMany({ where: { email: user.email } }); | ||
| classes.push({ dataClass: 'marketing', action: 'deleted', models: { NewsletterSubscriber: newsletter.count } }); |
There was a problem hiding this comment.
Erase transactional email delivery records
For users who received verification, reset, or other transactional mail, deleting only the newsletter row leaves EmailDeliveryEvent records untouched. That model stores the original address and full provider payload without a User foreign key (packages/database/prisma/schema.prisma:1842-1852), so the account can be stamped purged with verifiedZeroRemaining: true while directly identifying data remains. Delete or anonymize events selected by user.email and include them in the post-purge proof.
Useful? React with 👍 / 👎.
| const adminAuditRedacted = await tx.adminAuditLog.updateMany({ | ||
| where: { actorUserId: userId }, | ||
| data: { ipAddress: null }, | ||
| }); |
There was a problem hiding this comment.
Redact admin audit metadata during anonymization
When the purged user has acted as a platform administrator, this update clears only ipAddress; AdminAuditLog.metadata remains intact even though the proof reports the entire audit_logs class as anonymized. Admin events can contain free-form reasons and other user-associated details, so this contradicts the redaction applied to ordinary AuditLog rows immediately above. Replace the metadata with the same redaction marker or explicitly consign it as retained.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Pull request overview
Cette PR comble le gap §16.12 en ajoutant un exécuteur réel de purge de compte (consommation de ready_to_purge), orchestré via BullMQ + CronJob Helm, avec génération d’une preuve d’effacement structurée persistée en audit, et une suite de tests (in-memory + Postgres réel) pour prouver l’idempotence, la concurrence et la rétention fail-closed.
Changes:
- Ajout de
store.purgeUserAccount(impl Prisma + miroir TestApiStore) + types/helpers de preuve d’effacement (account-purge.ts). - Ajout de la route interne
POST /internal/account-purge+ câblage workeraccount.purgeet CronJob Helm. - Ajout/ajustement de tests (routes, DB durable, régression vitest discovery) + pièces de preuve de déploiement.
Reviewed changes
Copilot reviewed 15 out of 15 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| services/worker/src/index.ts | Ajoute le trigger account.purge côté worker (appel /internal/account-purge). |
| services/api/src/app.ts | Ajoute la route interne /internal/account-purge (scan + purge + audit proof + dequeue). |
| services/api/src/store.ts | Étend l’interface store avec purgeUserAccount. |
| services/api/src/prisma-store.ts | Implémente la purge transactionnelle Postgres + preuve d’effacement + vérifs post-purge. |
| services/api/src/account-purge.ts | Définit les types de preuve + helpers IO-free (buildErasureProof, tombstones). |
| services/api/src/tests/test-api-store.ts | Ajoute un miroir in-memory de purgeUserAccount pour tests route/unit. |
| services/api/src/tests/account-purge-routes.spec.ts | Ajoute tests E2E route interne (négatifs d’abord, puis preuve complète). |
| services/api/src/tests/account-purge-db.spec.ts | Ajoute tests durables sur Postgres réel (idempotence, concurrence, ledger immuable). |
| services/api/src/tests/vitest-config-discovery.spec.ts | Rend le test de discovery vitest non-bloquant (exec async) + timeout. |
| infra/helm/platform/templates/cronjobs.yaml | Ajoute le CronJob accountPurge (enqueue account.purge). |
| infra/helm/platform/values-prod.yaml | Ajuste un commentaire (pseudo-rollback). |
| apps/admin/src/admin-model.test.ts | Met à jour l’attendu sur le nombre de sections admin (+ agent-routing). |
| docs/deploy-evidence/2026-07-22-account-purge-worker/README.md | Documentation de la preuve et du runbook de repro. |
| docs/deploy-evidence/2026-07-22-account-purge-worker/JOURNAL.md | Journal des commandes de preuve Postgres/Helm. |
| docs/deploy-evidence/2026-07-22-account-purge-worker/proof-sample.json | Exemple de preuve (actuellement invalide tel que commité). |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| { + | ||
| "kind": "account-erasure-proof", + | ||
| "userId": "cmrvpdz4t000fi0lnerebnm5i", + | ||
| "classes": [ + | ||
| { + |
| if (status === 'purged') { | ||
| alreadyPurged += 1; | ||
| await store.mutateSystemSettingIds(ACCOUNT_DELETION_PENDING_KEY, { remove: userId }); | ||
| continue; | ||
| } |
| const memberships = await tx.organizationMember.findMany({ where: { userId }, select: { organizationId: true } }); | ||
| const orgIds = [...new Set(memberships.map((m) => m.organizationId))]; | ||
| const soleOrgIds: string[] = []; | ||
| const sharedOrgIds: string[] = []; | ||
|
|
||
| for (const orgId of orgIds) { | ||
| const members = await tx.organizationMember.count({ where: { organizationId: orgId } }); | ||
| (members === 1 ? soleOrgIds : sharedOrgIds).push(orgId); | ||
| } |
| const adminAuditRedacted = await tx.adminAuditLog.updateMany({ | ||
| where: { actorUserId: userId }, | ||
| data: { ipAddress: null }, | ||
| }); |
| for (const event of this.adminAuditLogs) { | ||
| if (event.actorUserId === userId && event.ipAddress !== undefined) { | ||
| event.ipAddress = undefined; | ||
| adminAuditRedacted += 1; | ||
| } | ||
| } |
… workspace PVCs) Closes the physical half of the §16.12 deletion gap left by PR #43 (which erases DB rows only). eraseProjectsStorage() erases a purged account's per-project object-storage buckets and workspace volumes with the same evidence discipline as the DB purge — list BEFORE → delete → re-count AFTER — and emits PurgeClassReport{object_storage,workspace_volumes} so it folds straight into ErasureProof.verifiedZeroRemaining. FAIL-CLOSED: any bucket/volume that doesn't re-count to 0 (or whose delete threw) leaves remainingAfterPurge > 0. Idempotent (missing bucket/workspace = verified no-op) so the worker can retry. All I/O behind injected ports → unit-testable + replayable against a throwaway bucket/volume, never prod data. 6 tests: happy-path evidence, fail-closed (bucket won't delete / workspace throws), idempotent no-op, empty-account vacuous verify, multi-project aggregation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…volume) A tamper-evident, reproducible proof of physical erasure — mirrors PR #43's DB evidence discipline for storage. - scripts/physical-purge-proof.ts: runs the REAL eraseProjectsStorage against a throwaway test bucket + workspace volume (never prod data) — seeds objects, lists BEFORE, erases, re-counts AFTER (0), folds into an ErasureProof, and emits canonical proof.json + its SHA-256. Deterministic (fixed ids/timestamps). - docs/deploy-evidence/2026-07-22-account-physical-purge/{README,proof.json, SHA256SUMS}: the committed artifact (5 objects → 0, 1 workspace → 0, verified). JSON, not an ignored .log. - src/tests/physical-purge-proof.spec.ts: CI replays it and asserts the committed artifact reproduces byte-for-byte, so the hashed proof can't drift from code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… workspace PVCs) Closes the physical half of the §16.12 deletion gap left by PR #43 (which erases DB rows only). eraseProjectsStorage() erases a purged account's per-project object-storage buckets and workspace volumes with the same evidence discipline as the DB purge — list BEFORE → delete → re-count AFTER — and emits PurgeClassReport{object_storage,workspace_volumes} so it folds straight into ErasureProof.verifiedZeroRemaining. FAIL-CLOSED: any bucket/volume that doesn't re-count to 0 (or whose delete threw) leaves remainingAfterPurge > 0. Idempotent (missing bucket/workspace = verified no-op) so the worker can retry. All I/O behind injected ports → unit-testable + replayable against a throwaway bucket/volume, never prod data. 6 tests: happy-path evidence, fail-closed (bucket won't delete / workspace throws), idempotent no-op, empty-account vacuous verify, multi-project aggregation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…volume) A tamper-evident, reproducible proof of physical erasure — mirrors PR #43's DB evidence discipline for storage. - scripts/physical-purge-proof.ts: runs the REAL eraseProjectsStorage against a throwaway test bucket + workspace volume (never prod data) — seeds objects, lists BEFORE, erases, re-counts AFTER (0), folds into an ErasureProof, and emits canonical proof.json + its SHA-256. Deterministic (fixed ids/timestamps). - docs/deploy-evidence/2026-07-22-account-physical-purge/{README,proof.json, SHA256SUMS}: the committed artifact (5 objects → 0, 1 workspace → 0, verified). JSON, not an ignored .log. - src/tests/physical-purge-proof.spec.ts: CI replays it and asserts the committed artifact reproduces byte-for-byte, so the hashed proof can't drift from code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… workspace PVCs) Closes the physical half of the §16.12 deletion gap left by PR #43 (which erases DB rows only). eraseProjectsStorage() erases a purged account's per-project object-storage buckets and workspace volumes with the same evidence discipline as the DB purge — list BEFORE → delete → re-count AFTER — and emits PurgeClassReport{object_storage,workspace_volumes} so it folds straight into ErasureProof.verifiedZeroRemaining. FAIL-CLOSED: any bucket/volume that doesn't re-count to 0 (or whose delete threw) leaves remainingAfterPurge > 0. Idempotent (missing bucket/workspace = verified no-op) so the worker can retry. All I/O behind injected ports → unit-testable + replayable against a throwaway bucket/volume, never prod data. 6 tests: happy-path evidence, fail-closed (bucket won't delete / workspace throws), idempotent no-op, empty-account vacuous verify, multi-project aggregation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…volume) A tamper-evident, reproducible proof of physical erasure — mirrors PR #43's DB evidence discipline for storage. - scripts/physical-purge-proof.ts: runs the REAL eraseProjectsStorage against a throwaway test bucket + workspace volume (never prod data) — seeds objects, lists BEFORE, erases, re-counts AFTER (0), folds into an ErasureProof, and emits canonical proof.json + its SHA-256. Deterministic (fixed ids/timestamps). - docs/deploy-evidence/2026-07-22-account-physical-purge/{README,proof.json, SHA256SUMS}: the committed artifact (5 objects → 0, 1 workspace → 0, verified). JSON, not an ignored .log. - src/tests/physical-purge-proof.spec.ts: CI replays it and asserts the committed artifact reproduces byte-for-byte, so the hashed proof can't drift from code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… workspace PVCs) Closes the physical half of the §16.12 deletion gap left by PR #43 (which erases DB rows only). eraseProjectsStorage() erases a purged account's per-project object-storage buckets and workspace volumes with the same evidence discipline as the DB purge — list BEFORE → delete → re-count AFTER — and emits PurgeClassReport{object_storage,workspace_volumes} so it folds straight into ErasureProof.verifiedZeroRemaining. FAIL-CLOSED: any bucket/volume that doesn't re-count to 0 (or whose delete threw) leaves remainingAfterPurge > 0. Idempotent (missing bucket/workspace = verified no-op) so the worker can retry. All I/O behind injected ports → unit-testable + replayable against a throwaway bucket/volume, never prod data. 6 tests: happy-path evidence, fail-closed (bucket won't delete / workspace throws), idempotent no-op, empty-account vacuous verify, multi-project aggregation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…volume) A tamper-evident, reproducible proof of physical erasure — mirrors PR #43's DB evidence discipline for storage. - scripts/physical-purge-proof.ts: runs the REAL eraseProjectsStorage against a throwaway test bucket + workspace volume (never prod data) — seeds objects, lists BEFORE, erases, re-counts AFTER (0), folds into an ErasureProof, and emits canonical proof.json + its SHA-256. Deterministic (fixed ids/timestamps). - docs/deploy-evidence/2026-07-22-account-physical-purge/{README,proof.json, SHA256SUMS}: the committed artifact (5 objects → 0, 1 workspace → 0, verified). JSON, not an ignored .log. - src/tests/physical-purge-proof.spec.ts: CI replays it and asserts the committed artifact reproduces byte-for-byte, so the hashed proof can't drift from code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… workspace PVCs) Closes the physical half of the §16.12 deletion gap left by PR #43 (which erases DB rows only). eraseProjectsStorage() erases a purged account's per-project object-storage buckets and workspace volumes with the same evidence discipline as the DB purge — list BEFORE → delete → re-count AFTER — and emits PurgeClassReport{object_storage,workspace_volumes} so it folds straight into ErasureProof.verifiedZeroRemaining. FAIL-CLOSED: any bucket/volume that doesn't re-count to 0 (or whose delete threw) leaves remainingAfterPurge > 0. Idempotent (missing bucket/workspace = verified no-op) so the worker can retry. All I/O behind injected ports → unit-testable + replayable against a throwaway bucket/volume, never prod data. 6 tests: happy-path evidence, fail-closed (bucket won't delete / workspace throws), idempotent no-op, empty-account vacuous verify, multi-project aggregation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…volume) A tamper-evident, reproducible proof of physical erasure — mirrors PR #43's DB evidence discipline for storage. - scripts/physical-purge-proof.ts: runs the REAL eraseProjectsStorage against a throwaway test bucket + workspace volume (never prod data) — seeds objects, lists BEFORE, erases, re-counts AFTER (0), folds into an ErasureProof, and emits canonical proof.json + its SHA-256. Deterministic (fixed ids/timestamps). - docs/deploy-evidence/2026-07-22-account-physical-purge/{README,proof.json, SHA256SUMS}: the committed artifact (5 objects → 0, 1 workspace → 0, verified). JSON, not an ignored .log. - src/tests/physical-purge-proof.spec.ts: CI replays it and asserts the committed artifact reproduces byte-for-byte, so the hashed proof can't drift from code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… workspace PVCs) Closes the physical half of the §16.12 deletion gap left by PR #43 (which erases DB rows only). eraseProjectsStorage() erases a purged account's per-project object-storage buckets and workspace volumes with the same evidence discipline as the DB purge — list BEFORE → delete → re-count AFTER — and emits PurgeClassReport{object_storage,workspace_volumes} so it folds straight into ErasureProof.verifiedZeroRemaining. FAIL-CLOSED: any bucket/volume that doesn't re-count to 0 (or whose delete threw) leaves remainingAfterPurge > 0. Idempotent (missing bucket/workspace = verified no-op) so the worker can retry. All I/O behind injected ports → unit-testable + replayable against a throwaway bucket/volume, never prod data. 6 tests: happy-path evidence, fail-closed (bucket won't delete / workspace throws), idempotent no-op, empty-account vacuous verify, multi-project aggregation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…volume) A tamper-evident, reproducible proof of physical erasure — mirrors PR #43's DB evidence discipline for storage. - scripts/physical-purge-proof.ts: runs the REAL eraseProjectsStorage against a throwaway test bucket + workspace volume (never prod data) — seeds objects, lists BEFORE, erases, re-counts AFTER (0), folds into an ErasureProof, and emits canonical proof.json + its SHA-256. Deterministic (fixed ids/timestamps). - docs/deploy-evidence/2026-07-22-account-physical-purge/{README,proof.json, SHA256SUMS}: the committed artifact (5 objects → 0, 1 workspace → 0, verified). JSON, not an ignored .log. - src/tests/physical-purge-proof.spec.ts: CI replays it and asserts the committed artifact reproduces byte-for-byte, so the hashed proof can't drift from code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… workspace PVCs) Closes the physical half of the §16.12 deletion gap left by PR #43 (which erases DB rows only). eraseProjectsStorage() erases a purged account's per-project object-storage buckets and workspace volumes with the same evidence discipline as the DB purge — list BEFORE → delete → re-count AFTER — and emits PurgeClassReport{object_storage,workspace_volumes} so it folds straight into ErasureProof.verifiedZeroRemaining. FAIL-CLOSED: any bucket/volume that doesn't re-count to 0 (or whose delete threw) leaves remainingAfterPurge > 0. Idempotent (missing bucket/workspace = verified no-op) so the worker can retry. All I/O behind injected ports → unit-testable + replayable against a throwaway bucket/volume, never prod data. 6 tests: happy-path evidence, fail-closed (bucket won't delete / workspace throws), idempotent no-op, empty-account vacuous verify, multi-project aggregation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…volume) A tamper-evident, reproducible proof of physical erasure — mirrors PR #43's DB evidence discipline for storage. - scripts/physical-purge-proof.ts: runs the REAL eraseProjectsStorage against a throwaway test bucket + workspace volume (never prod data) — seeds objects, lists BEFORE, erases, re-counts AFTER (0), folds into an ErasureProof, and emits canonical proof.json + its SHA-256. Deterministic (fixed ids/timestamps). - docs/deploy-evidence/2026-07-22-account-physical-purge/{README,proof.json, SHA256SUMS}: the committed artifact (5 objects → 0, 1 workspace → 0, verified). JSON, not an ignored .log. - src/tests/physical-purge-proof.spec.ts: CI replays it and asserts the committed artifact reproduces byte-for-byte, so the hashed proof can't drift from code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… workspace PVCs) Closes the physical half of the §16.12 deletion gap left by PR #43 (which erases DB rows only). eraseProjectsStorage() erases a purged account's per-project object-storage buckets and workspace volumes with the same evidence discipline as the DB purge — list BEFORE → delete → re-count AFTER — and emits PurgeClassReport{object_storage,workspace_volumes} so it folds straight into ErasureProof.verifiedZeroRemaining. FAIL-CLOSED: any bucket/volume that doesn't re-count to 0 (or whose delete threw) leaves remainingAfterPurge > 0. Idempotent (missing bucket/workspace = verified no-op) so the worker can retry. All I/O behind injected ports → unit-testable + replayable against a throwaway bucket/volume, never prod data. 6 tests: happy-path evidence, fail-closed (bucket won't delete / workspace throws), idempotent no-op, empty-account vacuous verify, multi-project aggregation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…volume) A tamper-evident, reproducible proof of physical erasure — mirrors PR #43's DB evidence discipline for storage. - scripts/physical-purge-proof.ts: runs the REAL eraseProjectsStorage against a throwaway test bucket + workspace volume (never prod data) — seeds objects, lists BEFORE, erases, re-counts AFTER (0), folds into an ErasureProof, and emits canonical proof.json + its SHA-256. Deterministic (fixed ids/timestamps). - docs/deploy-evidence/2026-07-22-account-physical-purge/{README,proof.json, SHA256SUMS}: the committed artifact (5 objects → 0, 1 workspace → 0, verified). JSON, not an ignored .log. - src/tests/physical-purge-proof.spec.ts: CI replays it and asserts the committed artifact reproduces byte-for-byte, so the hashed proof can't drift from code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… workspace PVCs) Closes the physical half of the §16.12 deletion gap left by PR #43 (which erases DB rows only). eraseProjectsStorage() erases a purged account's per-project object-storage buckets and workspace volumes with the same evidence discipline as the DB purge — list BEFORE → delete → re-count AFTER — and emits PurgeClassReport{object_storage,workspace_volumes} so it folds straight into ErasureProof.verifiedZeroRemaining. FAIL-CLOSED: any bucket/volume that doesn't re-count to 0 (or whose delete threw) leaves remainingAfterPurge > 0. Idempotent (missing bucket/workspace = verified no-op) so the worker can retry. All I/O behind injected ports → unit-testable + replayable against a throwaway bucket/volume, never prod data. 6 tests: happy-path evidence, fail-closed (bucket won't delete / workspace throws), idempotent no-op, empty-account vacuous verify, multi-project aggregation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…volume) A tamper-evident, reproducible proof of physical erasure — mirrors PR #43's DB evidence discipline for storage. - scripts/physical-purge-proof.ts: runs the REAL eraseProjectsStorage against a throwaway test bucket + workspace volume (never prod data) — seeds objects, lists BEFORE, erases, re-counts AFTER (0), folds into an ErasureProof, and emits canonical proof.json + its SHA-256. Deterministic (fixed ids/timestamps). - docs/deploy-evidence/2026-07-22-account-physical-purge/{README,proof.json, SHA256SUMS}: the committed artifact (5 objects → 0, 1 workspace → 0, verified). JSON, not an ignored .log. - src/tests/physical-purge-proof.spec.ts: CI replays it and asserts the committed artifact reproduces byte-for-byte, so the hashed proof can't drift from code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… workspace PVCs) Closes the physical half of the §16.12 deletion gap left by PR #43 (which erases DB rows only). eraseProjectsStorage() erases a purged account's per-project object-storage buckets and workspace volumes with the same evidence discipline as the DB purge — list BEFORE → delete → re-count AFTER — and emits PurgeClassReport{object_storage,workspace_volumes} so it folds straight into ErasureProof.verifiedZeroRemaining. FAIL-CLOSED: any bucket/volume that doesn't re-count to 0 (or whose delete threw) leaves remainingAfterPurge > 0. Idempotent (missing bucket/workspace = verified no-op) so the worker can retry. All I/O behind injected ports → unit-testable + replayable against a throwaway bucket/volume, never prod data. 6 tests: happy-path evidence, fail-closed (bucket won't delete / workspace throws), idempotent no-op, empty-account vacuous verify, multi-project aggregation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…volume) A tamper-evident, reproducible proof of physical erasure — mirrors PR #43's DB evidence discipline for storage. - scripts/physical-purge-proof.ts: runs the REAL eraseProjectsStorage against a throwaway test bucket + workspace volume (never prod data) — seeds objects, lists BEFORE, erases, re-counts AFTER (0), folds into an ErasureProof, and emits canonical proof.json + its SHA-256. Deterministic (fixed ids/timestamps). - docs/deploy-evidence/2026-07-22-account-physical-purge/{README,proof.json, SHA256SUMS}: the committed artifact (5 objects → 0, 1 workspace → 0, verified). JSON, not an ignored .log. - src/tests/physical-purge-proof.spec.ts: CI replays it and asserts the committed artifact reproduces byte-for-byte, so the hashed proof can't drift from code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… workspace PVCs) Closes the physical half of the §16.12 deletion gap left by PR #43 (which erases DB rows only). eraseProjectsStorage() erases a purged account's per-project object-storage buckets and workspace volumes with the same evidence discipline as the DB purge — list BEFORE → delete → re-count AFTER — and emits PurgeClassReport{object_storage,workspace_volumes} so it folds straight into ErasureProof.verifiedZeroRemaining. FAIL-CLOSED: any bucket/volume that doesn't re-count to 0 (or whose delete threw) leaves remainingAfterPurge > 0. Idempotent (missing bucket/workspace = verified no-op) so the worker can retry. All I/O behind injected ports → unit-testable + replayable against a throwaway bucket/volume, never prod data. 6 tests: happy-path evidence, fail-closed (bucket won't delete / workspace throws), idempotent no-op, empty-account vacuous verify, multi-project aggregation. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…volume) A tamper-evident, reproducible proof of physical erasure — mirrors PR #43's DB evidence discipline for storage. - scripts/physical-purge-proof.ts: runs the REAL eraseProjectsStorage against a throwaway test bucket + workspace volume (never prod data) — seeds objects, lists BEFORE, erases, re-counts AFTER (0), folds into an ErasureProof, and emits canonical proof.json + its SHA-256. Deterministic (fixed ids/timestamps). - docs/deploy-evidence/2026-07-22-account-physical-purge/{README,proof.json, SHA256SUMS}: the committed artifact (5 objects → 0, 1 workspace → 0, verified). JSON, not an ignored .log. - src/tests/physical-purge-proof.spec.ts: CI replays it and asserts the committed artifact reproduces byte-for-byte, so the hashed proof can't drift from code. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Invariant §16.12 — l'exécuteur de purge manquant (gap HAUTE)
SECURITY_PRIVACY_COMPLIANCE.mdv3 (§Rétention, ligne « Comptes & profils ») déclarait le gap HAUTE : la machine d'états de suppression self-serve (data-deletion.ts: request → grâce 14 j →ready_to_purge) et la file admin/admin/account-deletionsexistaient, mais aucun worker ne consommaitready_to_purge—purgedAtn'était jamais écrit hors des specs, la purge n'était JAMAIS exécutée en réel.Invariant §16.12 : toute suppression = tombstone → fenêtre de récupération → purge réelle → PREUVE d'effacement. Cette PR livre l'exécuteur.
Ce que fait la purge (
store.purgeUserAccount)Une transaction Postgres par utilisateur, ouverte par
pg_advisory_xact_lock('account-purge:<userId>'):redactAuditLogs-style : ipAddress→NULL, metadata→{redacted:true}), références détachées (UsageEvent, AgentCallLog, LedgerReservation, AgentCheckpoint, ProjectActivity, ImportJob, GalleryListing, SupportTicket), org shells, tombstone User portantpurgedAtcanPurgeFinancialRecord; les lignes > 2555 j sont effacées), ledger 0078 (triggers d'immutabilité — jamais de DELETE, compté+consigné), contenu des orgs partagéesalready_purged, no-op prouvé, 1 seule preuve.purgedAt).remainingAfterPurge,verifiedZeroRemaining) écrit dans l'AdminAuditLog (account.purge_completed) AVANT de sortir l'id de la file — un effacement ne peut pas être à la fois délisté et non prouvé.Câblage worker (patron du repo)
POST /internal/account-purge(requireInternalSecret, DRY-RUN par défaut — armement viaACCOUNT_PURGE_ENABLED=trueoubody.enabled— miroir exact d'/internal/inactivity-gc).account.purge(queueenterprise-jobs,triggerAccountPurge) + CronJob HelmaccountPurge(30 4 * * *) — renduhelm templatevérifié.Tests (13 nouveaux ; négatifs d'abord)
account-purge-routes.spec.ts(9) : 401 sans secret ; fenêtre non échue → refus + données intactes ; annulation pendant la grâce → jamais purgé ; dry-run par défaut ; double exécution → no-op ; course → 1 purge ; rétention financière consignée ; purge complète (0 ligne/classe, tombstone, session morte → 401) ; org partagée conservée+consignée.account-purge-db.spec.ts(4, gatéDATABASE_URL, tourne en CI) : preuves DURABLES vrai Postgres.Preuve PG réelle (docker
pgvector/pgvector:pg16jetable)Harness sur
PrismaApiStore: compte semé multi-classes (projet, session, import, conversation IA, usage, audit) → suppression demandée →requestedAtreculé DANS LA DB (jamais l'horloge) → route worker exécutée → vérifs SQL « 0 ligne » par classe → preuve relue depuisAdminAuditLog(verifiedZeroRemaining=true, 17 classes, 3 exceptions consignées) → re-run no-op → trigger 0078 refuse le DELETE ledger (append-only). Journal + logs + preuve JSON :docs/deploy-evidence/2026-07-22-account-purge-worker/. Conteneur détruit après.Fixes CI rejoués (inclus dans le commit — hérités de main)
Les 3 casse-CI connus de main, identiques aux fixes prouvés sur
zone/cloud-tenant-factory-iam(4abc301, 147a622) :admin-model.test.ts31→32 sections + assertionagent-routing; commentairevalues-prod.yaml« fake URL-copy rollback » → « URL-copy pseudo-rollback » ;vitest-config-discovery.spec.tsexecFile promisifié (timeout 180 s).Écarts / notes honnêtes
AiTokenUsage(compteur par message) cascade avec les messages par design du schéma — consigné dans la preuve ; la vérité de facturation (AiCostLedger/UsageEvent/Ledger) est org-scopée et retenue.ACCOUNT_PURGE_ENABLEDnon armé) — l'armement est une décision d'exploitation d'Avi.PROVEN_REVIEW_PENDING — pas de merge sans feu vert d'Avi.
🤖 Generated with Claude Code