The last milestone makes the data leavable, destroyable and deployable: a three-sheet workbook built entirely in the browser, a purge that clears R2, D1 and KV together with a swept, time-boxed debug exception, and a production environment that cannot be deployed to by accident.
After M4 the product forecasts and renders. What it could not do was let the data leave, let the data be destroyed, or run anywhere but dev. M5 closes all three — and turns the retention sentence from copy into enforced behaviour.
Summary, Forecast and Exceptions, assembled entirely from payloads the page already holds — no export endpoint, no second round trip. The vendored 925 kB spreadsheet library is code-split and fetched only when someone actually clicks export.
Deleting a dataset purges both R2 prefixes, the narrative KV keys and the D1 rows. A qa-only, time-boxed debug flag is the single exception, and a daily cron sweeps it — with a §0.3-signed hook so the gate can prove the sweep really deletes.
The wrangler config's top level becomes an inert base, dev and prod become explicit environments, and production ships through a manual workflow that provisions, migrates, deploys both halves, pushes secrets and smoke-tests the custom domain.
Two paths destroy data: the one the user asks for, and the one that runs whether anyone asks or not. Hover any node to isolate its edges.
404, so deletion cannot be used to probe for ids.debug_until to 7 days out, or to an explicit until so the gate can put it in the past.wrangler dev --test-scheduled.debug_until has passed, destroys their raw and intermediate artifacts, then clears the flag so the work is not repeated.datasets/{uid}/{ds}/ and uploads/{uid}/{ds}/, listed and deleted in pages so a large dataset cannot half-delete.Objects first, rows last. If a purge fails halfway, what remains is a dataset row whose objects are partly gone — visible, retryable, and 404-free only once the row itself is deleted. The reverse order would leave orphaned objects with nothing left to point at them, which no later sweep could find.
The plan's §3 commitment lives in the codebase as one exported constant, quoted wherever the product asks for a file or offers to destroy one — and asserted by the gate.
“Your uploaded file is parsed and then deleted from our servers — we keep only the normalized demand history and the forecasts derived from it, visible only to your account. To analyse a new or corrected file, re-upload it; we cannot recover the original. Deleting a dataset removes everything derived from it.”
shown on the upload screen · quoted in the type-to-confirm deletion dialog · asserted by scripts/qa/m5.spec.ts| enforcement point | milestone | what is destroyed | trigger |
|---|---|---|---|
| parse-result callback | M2 | the uploaded workbook itself | the running → awaiting_mapping transition |
| forecast-result callback | M3 | extracted.json, the intermediate artifact | the running → complete transition |
| purgeDataset() | M5 | both R2 prefixes, narrative KV keys, D1 jobs and dataset row | the user, through a type-to-confirm dialog |
| sweepExpiredDebug() | M5 | raw and intermediate artifacts a qa user held back | daily cron, or the signed hook |
| R2 lifecycle rule | M0 | anything left under uploads/ after 9 days | the storage layer, unconditionally |
A failing parse is impossible to investigate once its input has been destroyed. The debug flag keeps raw and intermediate artifacts — but only for qa-role users, only for 7 days, only per dataset, and inside the 9-day storage backstop that M0 put underneath everything. The sweeper is the primary mechanism; the lifecycle rule is the net. See finding R2.
Every cell comes from a payload the browser already loaded, so exporting costs one client-side assembly and no server work — and the file contains exactly what the screen showed.
A series with no fitted model prints fallback: <reason> where a score would be
— never a number that was never measured.
The same deterministic queue M4 renders, which is why the gate can assert the row count against what is on screen.
The vendored SheetJS build is imported as a URL and injected as a script tag the first time an export is requested, with the load promise memoised. Login, dashboard and the forecast screen never pay for a library they cannot use. See finding N3.
Two changes make a production deploy a deliberate act: the config's top level stops being deployable, and prod ships through a manually dispatched workflow that provisions before it deploys.
| environment | worker name | workers_dev | bindings | cron |
|---|---|---|---|---|
| top level | ton-app-base-do-not-deploy | false | none — inert by design | — |
| env.dev | ton-app-dev | true | D1 · R2 · KV, ids committed | 17 3 * * * |
| env.prod | ton-app | false | D1 · R2 · KV, ids pending first provision | 17 3 * * * |
| per-PR preview | ton-app-pr-<n> | true | generated config, shared dev resources, no cron | — |
The 100-second forecast benchmark is excluded here; it has its own CI job.
Creates D1, R2 and KV if absent and fails loudly if the committed ids disagree with reality.
Schema before code, always.
Same file as dev; the app name resolves from the environment, and the shared HMAC secret is recreated with --force so rotation is a redeploy.
Secrets are pushed from GitHub secrets on every deploy, never set by hand.
The domain itself is Terraform state, not application state — owned by the infrastructure repo. This step asserts it works; it never creates it. See finding N5.
Two Playwright assertions for what a user does, one shell gate for what the storage layer actually contains afterwards — all against a real local stack in real Chromium.
The downloaded file is parsed by the same vendored SheetJS that wrote it and must contain exactly
Summary, Forecast, Exceptions in that order — with the
Forecast row count equal to the payload's series count and the Exceptions row count equal to the
queue total rendered on screen.
The second test deletes the dataset: the dialog must quote the retention promise, the app must navigate home, and a re-read of the dataset must return 404.
A spec can only see what the API answers. The shell gate goes behind it, through the qa-only
listing, and asserts the objects themselves — including that debug retention provably keeps
extracted.json and that the sweeper provably removes it.
scripts/qa/m5.spec.ts 2/2 —
the downloaded workbook parses to exactly Summary/Forecast/Exceptions with row counts matching the
payload/screen, and deletion navigates home with every subsequent read 404. scripts/qa/m5.sh
PASSES: post-delete API 404s, R2 prefix [] via the qa-only listing, debug retention
provably keeps extracted.json, the sweeper provably removes it. M1 gate re-run as
regression: PASS.
27 files, +1,268 −66 — the smallest milestone by diff and the one that touches the most surfaces: UI copy, a Worker module, two workflows, the wrangler config and a vendored library.
Two review items carried since M0 and closed here, plus five decisions that keep production and retention honest. Click any row to expand.
Six milestones on, every claim the product makes has something enforcing it: the security topology was proved before any data existed, the raw file is destroyed at a state transition rather than on a timer, the forecasts are measured against a baseline that CI re-checks on every run, the screen cannot show a number the payload cannot back, and the retention sentence now has four enforcement points and a storage-layer backstop underneath them. What is left is provisioning, not building.
scripts/seed-client.sh.purgeDataset deletes objects before rows — the retry-safe order.