fix(platform): re-check legal holds per erasure pass and fix the receipt - #3144
Closed
Israeltheminer wants to merge 3 commits into
Closed
fix(platform): re-check legal holds per erasure pass and fix the receipt#3144Israeltheminer wants to merge 3 commits into
Israeltheminer wants to merge 3 commits into
Conversation
The 0.5 cascade ran 10 passes where 0.4 ran 19 arms. The subject's cloud-import OAuth grant, their Drive and OneDrive sync configs, their video-link jobs and the blobs behind their bare uploads all survived a request that reported done. Adds purgeBlobArtefacts to the retention service (the corpus-then-blob pair lifted out of purgeDocument, which now calls it), routes the uploads pass through it, and adds videoLinks, cloudGrants and syncConfigs passes. Closes #3120.
The 0.5 cascade ran 10 passes where 0.4 ran 19 arms. Beyond the cloud grants, sync configs and video-link jobs this branch already covered, five more categories of subject data survived a request that reported done: - app.notifications, the org-level bells ABOUT the subject. subject_user_id was added for this pass and never read, and the lockout alert stamps it on the row carrying the subject's email and IP. - app.automation_runs the subject started, whose input, output, trace and effects hold every node's resolved values. - app.approvals and app.tasks.reviewer_user_id. The decision is kept and de-identified; the reviewer pointer is live routing and is cleared. - app.login_attempts and app.login_block_counters, keyed by email. - app.two_factor_attempts and app.two_factor_grace, keyed by user id. The last two are global rather than org-scoped, so they are refused while the subject is still an active member elsewhere: those counters protect those organizations too. 0.4 paged Better Auth at a 256-membership cap and failed closed; SQL answers exactly. Video-link blobs now delete strictly rather than best-effort. A receipt that says done is a claim about the bytes, so a swallowed failure would make it false. Refs #3120.
Two ways the erasure receipt could claim more than happened. The legal hold was read once, before the cascade, and never again. The ten passes are not one transaction, and two of them fan out per-thread and per-document deletes, so a hold placed mid-cascade was ignored for everything after it. 0.4 re-read holds inside all 19 of its arms and named the reason: FRCP 37(e) spoliation. The re-check now runs before every pass, and an unreadable hold table skips the pass rather than running it. A held-off or failed pass now lands the receipt 'partial' and says which. Before, only a thrown pass did, so a hold that stopped the cascade halfway still reported 'done' — and that receipt is the subject's Art 19 confirmation. The Full breakdown panel also rendered blank for every recent request: a pass records a plain count, and the renderer skipped anything that was not an object, so every entry was dropped before the empty-category branch. It now reads both shapes, because receipts written earlier still carry the older one. The fold is extracted so the rule is testable at all. Refs #3142.
This was referenced Sep 2, 2026
Collaborator
Author
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two ways an erasure receipt could claim more than happened, and the panel meant to show the detail rendered blank.
Stacked on #3122 — review that first. Refs #3142.
Why
The legal hold was read once and never again.
processErasuregates on it before the cascade, then runs ten passes that are not one transaction. Two of them fan out per-thread lineage purges and per-document blob and corpus deletes, so the window is real. A hold placed during that window was ignored for every remaining pass.0.4 re-read holds inside all 19 of its arms, and the helper's docstring names why: "a hold placed AFTER scheduling and BEFORE the per-table mutation runs must still win… Pre-fix, only
orgHeldwas checked, leaving a window where a custodian-hold race could let GDPR erasure delete data the hold was meant to preserve — FRCP 37(e) spoliation."A hold that stopped the cascade halfway still reported
done. Only a thrown pass setpartial. Under Art 19 that receipt is the subject's confirmation, sodonehas to mean every category was reached.The Full breakdown panel rendered blank for every recent request. A pass records a plain count; the renderer did
if (typeof value !== 'object' || value === null) continue;, so every entry was dropped before the empty-category branch. The result was an empty list with not even the "no data in N other categories" line. The headline counts still rendered, so it looked populated.What changed
The hold is re-read before each pass. An unreadable hold table skips the pass rather than running it — a table you cannot read is not evidence that nothing is held.
A held-off pass is tracked separately from a failed one. Both make the receipt
partial, and the error line names which, so an operator can tell a hold from a fault.The breakdown fold reads both shapes. Receipts written before the backend change still carry the object form, so dropping it would have broken the panel in the other direction.
What is not covered
The per-pass re-check itself has no automated test. Proving it needs a hold to appear between two passes, and every way I could arrange that was timing-dependent — a flaky test on a spoliation guard is worse than none. What is pinned instead is the rule the re-check feeds: that a held-off pass makes the receipt
partialand says so. If someone reverts the re-check, that test stays green. Naming the gap rather than implying coverage.Tests
erasureReceiptStatusanderasureReceiptErrorare extracted from the cascade so the "done means every category was reached" rule is testable at all, with 6 assertions.foldBreakdownEntriesis extracted from the drawer for the same reason, with 6 more.The fold mutation is the original bug, so seeing that assertion red is seeing the defect reproduced.
Gate:
typecheck,oxlint --type-aware,oxfmt --check,lint:sastgreen; 12 unit assertions passing.