feat: take the data out, and get rid of it for good - #250
Merged
Conversation
Exports a project as a gzipped NDJSON archive, hard-deletes a project or a workspace including its object-store artifacts, and records both. The archive streams: a manifest line, one line per row, an artifact inventory, then a summary saying how much the file should contain. Without that trailing line a download cut off halfway is indistinguishable from a small project, since both are valid gzip. Paging is keyset, not offset, so the last page of a million executions does not scan the table. Token hashes, webhook signing secrets and the badge token are left out. An export is served on a read credential, and shipping any of them would turn "download my data" into "take the project's keys". A test reads schema.prisma and fails if a project-scoped table is neither exported nor explicitly excluded, or if a credential-shaped column was never classified either way. Deletion revokes the ingest tokens when the request is made rather than when it runs — the sweep is a minute away and CI does not pause for a deletion request. Artifacts go before rows, because once the rows are gone nothing says which keys belonged to the tenant. Then the erasure verifies itself: every table carrying a project id, asked of the live database rather than of a list in the source, is counted again and the bucket prefix listed again. Anything left is recorded as residue and the request fails loudly. The audit row has no foreign key to the tenant it describes, so a cascade cannot delete the evidence along with the data. Closes #69
AKogut
added a commit
that referenced
this pull request
Aug 5, 2026
Four separate places where the README described an older product. **Status line** said "M4–M7 (platform, scale, launch, actionability) are open". M7 is closed — four features shipped in it. **Capability table** stopped at AI RCA. Missing from the front page: flake bisect, cost of flakiness, tracker issues, health badges, the public read API, signed webhooks, the CLI, and data export/deletion. Someone evaluating the project was reading a list about half the size of what exists. **Roadmap table** had no M7 row at all, and no indication which milestones were done. Now carries a status column, and says plainly why M7 landed ahead of M4–M6 — a reader who sees milestones completed out of order deserves the reason rather than the puzzle. **Monorepo layout** listed nine packages out of sixteen. `vitest-reporter` and `jest-reporter` were absent even though the setup section thirty lines above documents both, and `queries`, `notify` and `storage` were missing entirely. Every claim added here was checked against the code, not against the issue titles: the gate action's `strictness` levels, the tracker closing issues on recovery (`kind: 'close'` in `packages/queries/src/tracker.ts`), and the export/retention/erasure paths merged in #250. Related: #178 has also been updated — its auto-generated API reference scope item shipped in #246, so only versioned docs remain, and the title now says so.
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.
Closes #69.
Three of the four scope items; retention was already built and is documented here for the first time.
Export
GET /v1/export(read scope) and Data → Download export in the dashboard both stream the same gzipped NDJSON archive.The summary line is the point. A gzip stream cut off halfway is still a valid gzip, so a truncated download and a small project look identical. The trailing count is what tells them apart.
Paging is keyset, not offset — the last page of a million executions would otherwise scan the whole table. Composite keys compare as a row (
(a, b) > ($2, $3)), so the second column is not silently dropped.What the archive does not contain
ingest_token.token_hash,notification_channel.secret,project.badge_token.An export is served to anyone holding a
readcredential. Shipping a token hash or a webhook signing key would turn "download my data" into "take the project's keys" — and turn a read-only credential into one that can write.A guard reads
schema.prismaand fails if a project-scoped table is neither exported nor listed inEXPORT_EXCLUDEDwith a reason, or if a column matchingsecret|token|password|credentialwas never classified as either omitted or reviewed-and-fine. Two such columns exist and are genuinely not credentials (error_signature.tokens,rca_report.token_cost); both had to be named explicitly. There is a second test that re-runs the check with one omission removed and asserts it trips, so the silence means "decided", not "stopped looking".Deletion
Owner-only, slug typed to confirm, both checked on the server — a confirmation the client enforces is one that anybody bypassing the client never has to give.
Ordering is the substance here:
deleteManythroughout, so the retry does not fail on what the first pass already did."Verifiably"
The acceptance criterion asks for verifiable, so the erasure checks its own work rather than trusting that a delete returned without an error.
Every table carrying a
project_idis counted again — asked ofinformation_schema, not of a list in the source. A hand-maintained list is correct the day it is written and wrong the first time someone adds a model, and a table missing from it is one whose rows survive an erasure silently. A bareprojectIdcolumn with no cascading foreign key would not be deleted at all; this is what catches that. Anything left is recorded as residue and the request is marked failed.Org erasure counts by
org_id, which is complete only because every project-scoped table also carries one — so there is a test asserting exactly that.The audit row has no foreign key to the tenant it describes. A cascade would delete the evidence along with the data, so
subjectandartifactPrefixare copied in at request time; afterwards there is nothing left to read them from.Ran it, not just tested it
app.injectis not a socket, so I booted the API and downloaded over HTTP:423 lines for a summary claiming 421 rows — manifest plus rows plus summary, exactly.
grepfortoken_hash,secretandbadge_token: nothing. Then queued a real erasure and ran the actual worker binary:Afterwards: 0 projects, 0 executions, 0 runs, 0 tokens, 2 audit rows — the export record and the erasure record, both intact.
The residue check was proved non-vacuous by making
eraseTargetdelete only executions: it reported 19 tables of residue instead of passing.Deliberately not built
User deletion. A user is a person who may belong to several workspaces; removing one of them is not consent to remove the person. Their sessions, accounts and RCA feedback already cascade from the user record. Wiring a button to that belongs with a decision about what happens to their memberships elsewhere, not tacked onto tenant deletion. Called out in the docs rather than left as a gap someone discovers.
Artifact bytes in the archive. Base64 inside NDJSON inflates a multi-gigabyte export by a third. The inventory carries every key, size and timestamp, which is what you need to pull the bytes from your own bucket.
62/62 turbo tasks green.