Skip to content

fix(platform): re-check legal holds per erasure pass and fix the receipt - #3144

Closed
Israeltheminer wants to merge 3 commits into
mainfrom
fix/erasure-receipt-and-hold-recheck
Closed

fix(platform): re-check legal holds per erasure pass and fix the receipt#3144
Israeltheminer wants to merge 3 commits into
mainfrom
fix/erasure-receipt-and-hold-recheck

Conversation

@Israeltheminer

Copy link
Copy Markdown
Collaborator

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. processErasure gates 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 orgHeld was 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 set partial. Under Art 19 that receipt is the subject's confirmation, so done has 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 partial and says so. If someone reverts the re-check, that test stays green. Naming the gap rather than implying coverage.

Tests

erasureReceiptStatus and erasureReceiptError are extracted from the cascade so the "done means every category was reached" rule is testable at all, with 6 assertions. foldBreakdownEntries is extracted from the drawer for the same reason, with 6 more.

Mutation Went red
status ignores held-off passes the hold case
the hold reason dropped from the error line the combined-reason case
the fold's plain-count branch removed the count-shape case
the fold treats a held-off category as empty two cases

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:sast green; 12 unit assertions passing.

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.
@Israeltheminer

Copy link
Copy Markdown
Collaborator Author

Closing in favour of the replacement above: this branch was stacked on #3122's pre-merge branch, and #3122 was re-applied and then merged, so the stack's base no longer exists. The replacement is the same change applied directly onto main, with both mutations re-verified on the merged base.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant