What shipped
Three pieces, and each one earned its place by catching something real.
The Studio’s release slider. Every release receipt now has a stepper — older, newer, and a range input across all generations — plus a “Roll back to this release” button that appears only where it can act (before the latest release, for people who may publish). It builds the restore changeset and opens it; the restore then follows the same submit → exact-hash review → publish path as any release. The browser spec drives the whole story: slide to release 1, start the rollback, approve it, publish it as generation 3.
The check that almost wasn’t there. Rolling back pins revisions that may predate the current
content-type version — a field narrowed to [0-9]+ after “abc” was published is the test. Type
changes only re-check drafts and open publications, so a closed publication could slip under a
later definition, and a rollback pins exactly those. Publishing now revalidates any pin older than
its collection’s version, under the same advisory locks, and refuses 409 revision_outdated
naming the entry and the failing field. The test publishes “abc”, republishes “123”, narrows the
field, and watches the restore get refused — while the live release never moves.
Deleting uploads without orphaning content. DELETE /assets/{id} checks who still names the
asset — a live draft, or any publication ever (publications pin forever) — and either refuses
still_referenced or commits only an intent: a delete job, so 202 means “requested”, not gone.
The worker locks the asset, rechecks under the lock, marks it deleting, and only then removes
bytes — public key, every rendition, the quarantine original — outside any transaction, so a
crash mid-delete just retries. Rows are tombstoned, never deleted.
The bug the tests caught this time
PostgreSQL 18 requires an UPDATE grant for every row-lock mode — FOR KEY SHARE included —
and the API role deliberately has no UPDATE on assets. So the “lock the asset when someone
references it” rule can’t live in API code at all. The fix is two SECURITY DEFINER triggers
owned by the worker role: one locks the named asset on every asset_references insert and
refuses once a delete is anywhere in flight, the other takes the same lock on every publication
insert so a publish either wins before the delete or waits it out and is refused as unusable. A
live serialization test proved the semantics before the happy paths were even run.
The sweeper
A retention Sweeper rides beside the worker: every pass enqueues bounded expire jobs for
assets older than the 24-hour spec window that nothing references — quarantined, rejected, and
yes, usable-but-unused uploads — then walks quarantine/ for orphan objects whose rows never
committed. Expire jobs share the delete machinery: lock, recheck, tombstone, bytes gone.
P4 is done. Any release can be browsed and rolled back; uploads are verified, served, and now safely deleted and collected. Next: schema migrations you can dry-run and approve.