Monument Labs·Review desk
PR 418 · Free export storage when a project closes
Patch 418 · 12 Aug 2026
The hidden cost is outside the diff.
PR 418 passes one new option to archive() inside closeProject() so that closing a project frees its export storage. The same option deletes every linked export on the spot, inside the 30 days in which the product still says a closed project can be restored.
- Verdict
- Request changes
- Ship blockers
- 1
- Author
- Lena Okafor
- Reviewer
- Mats Everly
- Base
main @ 7c1d0e2- Lines changed
- +1 −1 in 1 file
@@ -19,7 +19,7 @@ closeProject19 export async function closeProject(id) {20 const project = await projects.get(id)21 assertCanClose(project)22- await archive(id)22+ await archive(id, { cascade: true })23 audit.log("project.closed", { id })24 return { closed: true }25 }
-
1
src/storage/archive.ts · line 22
await exports.purge(projectId)
Linked exports are hard-deleted at close. No tombstone is written.
-
2
src/ui/CloseProjectDialog.tsx · line 18
“You can restore a closed project for 30 days.”
Shown on every close. The code no longer keeps this promise.
-
3
src/projects/restore-project.ts · line 9
await exports.restore(id)
Finds no tombstones. The project comes back without its exports.
-
4
test/projects/close-project.test.ts · line 12
expect(result).toEqual({ closed: true })
Still passes. It checks the return value and never looks at exports.
Ship blocker 01
Cascade crosses the retention boundary
Before this change, closeProject() called archive(id) with no options. On that path, archive() marks the project closed and soft-deletes its linked exports: each export gets a tombstone with restoreUntil set 30 days out, and the nightly retention job purges whatever is still tombstoned after that.
{ cascade: true } takes the other branch. archive() skips the tombstone and calls exports.purge(), which removes the export rows and their stored files in the same request. Nothing is left for the retention job, and nothing is left for restoreProject() to bring back.
The product still makes the old promise. CloseProjectDialog tells the user they can restore a closed project for 30 days, and retention policy RET-3 says linked exports follow their project for the same window. A user who closes a project by mistake and restores it an hour later gets the project back with no exports and no message.
This is not a rare path. In July, 41 projects were restored inside the window, and 33 of them had linked exports. The goal of the PR is sound: exports on closed projects hold 1.6 TB that STOR-212 wants back. That storage can be freed on day 30 without breaking the promise.
12 export async function archive(projectId, { cascade = false } = {}) {13 await projects.update(projectId, { status: "closed", closedAt: now() })14 15 if (!cascade) {16 // Tombstone: restorable until the retention job runs on day 3017 await exports.softDelete(projectId, { restoreUntil: daysFromNow(30) })18 return19 }20 21 // Rows and stored files, removed now22 await exports.purge(projectId)23 await audit.log("exports.purged", { projectId })24 }
1With cascade: true, close lands on line 22. There is no tombstone, so there is nothing to restore.
Coverage
Why the tests still pass
The only test for closeProject() checks its return value. The return value did not change, so the suite stays green and CI passed in four minutes.
No test closes a project and then restores it. That round trip is the one path where the dialog’s promise matters, and it is the path this change breaks.
In the suite now
Passes on main and on this branch.
9 test("closes a project", async () => {10 const { id } = await seedProject()11 const result = await closeProject(id)12 expect(result).toEqual({ closed: true })13 })
The round trip that should exist
Proposed addition to the same file.
15+test("close then restore keeps linked exports", async () => {16+ const { id } = await seedProject({ exports: 3 })17+ await closeProject(id)18+ await restoreProject(id)19+ expect(await exports.list(id)).toHaveLength(3)20+})
On this branch: fails · expected length 3, received 0
On main: passes
Before merge
Requested changes, in order
-
Keep close non-cascading and free the storage on day 30
Revert line 22 to
archive(id). AddpurgeLinkedExports(projectId)to the day-30 retention job, so exports are removed when their tombstone expires. STOR-212 gets its storage back, a month later than this PR would.jobs/retention.tssketchfor (const p of await projects.closedBefore(daysAgo(30))) { await purgeLinkedExports(p.id) } -
Add the close-then-restore test
Use the test above. It must fail on this branch as it stands and pass after change 01. Keep the existing return-value test; it still earns its place.
No new fixtures are needed:
seedProject()already takes an export count. The test runs in about 0.4 s against the seeded test database. -
If deletion at close is the intended behaviour, change the promise in the same PR
Update the
CloseProjectDialogcopy and retention policy RET-3 alongside the code, and get sign-off from Support before merge. The code and the promise ship together or not at all.src/ui/CloseProjectDialog.tsxonly if chosen-You can restore a closed project for 30 days.
+Closing deletes this project’s exports now. The project itself can be restored for 30 days.
Non-blocking
Rename cascade to an option that says it deletes, for example { purgeExports: true }. A reader should not need to open archive() to learn that the flag is destructive.
Verdict · request changes
Hold until recovery semantics and tests move together.
Mats Everly · Review desk · 12 Aug 2026
Open the reviewer verdictFull note to the author
Lena, thanks for picking up STOR-212. The storage numbers make the case, and the change is small enough to review properly.
I’m requesting changes on one point. { cascade: true } sends archive() down the purge branch, so linked exports are gone the moment a project closes. The close dialog and RET-3 still promise 30 days of recovery, and restoreProject() now returns a project without its exports and without an error. The existing test can’t see any of this because it only checks { closed: true }.
Please move the purge into the day-30 retention job and add the close-then-restore test in this PR. If product wants deletion at close, change the dialog copy and RET-3 here too, so the code and the promise ship together. The cascade rename is optional. I’ll re-review as soon as that test is in.
Mats