Two modules closed in one arc, because they share a theme: things that change over time have to stay exact about what they refer to.
M.5 — schema migrations, run like releases
Until now, changing a content type in a way stored entries couldn’t take — making a field required, renaming one — was refused outright with a list of the entries in the way. M.5 turns that refusal into a plan:
- A plan is a dry run first.
POST {P}/collections/{c}/migrationssnapshots the target definition and walks every draft, recording per entry whether the change applies cleanly, needs a decision (filla new required field,movea value), or is blocked. Approving a plan names its hash; one running plan per collection, andmigration_in_progressblocks competing type edits — the same “reviews pin what they approved” discipline as changesets. - The worker does the rewriting, once each. A plan in
runningis claimed under an advisory lock; every item is rewritten in its own transaction as a full new revision — references, asset references, unique claims, draft pointer, schema version — viaSECURITY DEFINERfunctions that keep the worker role inside its row-level-security lane (migration 0041).fillonly creates the final path;moverefuses to overwrite, rejects same-path and descendant moves, and treats a missing source as a no-op. The crash test kills the worker mid-run and a new one finishes the plan with nothing applied twice. Plans enddoneorblocked; arepublish_requiredflag stays live while open publications still pin an older shape. - People drive it. The Studio’s new migrations section lists plans with counters, a filterable item table, a per-item resolution dialog, and approve/apply/cancel bound to the plan’s hash. A refused save in the type builder now offers to plan the migration — the attempted definition goes straight into the plan. The simulator got a new invariant too: racing schema changes against saves, no revision may ever be written against a superseded version — 2,000 seeded runs with crashes and lost answers held it.
E1 — comments that can’t drift, and tasks
- Comments pin the revision they were written on. A composite foreign key binds each comment to
(entry, revision)— the anchor can never silently move to newer content. Bodies are immutable text; resolve and reopen are ETag’d writes that refuse onIf-Matchmismatch. Deleting the entry keeps the comments readable under a snapshot of who wrote them, while new writes are refused. - Tasks are real tasks. Title, assignee, status, optional revision and changeset links; updates
are ETag-bound patches where an explicit
nullunassigns and omission preserves — the tri-state distinction that took a small enum in the API to express honestly. Permissions honorcontent.read,content.updateand theowncondition throughout. - Surfaces: a Comments tab on the entry editor bound to the revision on screen, a project Tasks page with filters and an assignee picker, a testbed page driving the whole lifecycle, and a k6 scenario registered with the rest.
Meanwhile, under the hood: the query API has begun
DX.1 and DX.2 landed in the same window — the bounded, hashable query language (one JSON document,
canonicalized, SHA-256’d so a hash names the exact query for caches and cursors; only "index": true
fields may filter or sort) and cursors that seal a position inside the query’s hash, so a cursor only
ever works where it came from. SQL translation and routes are in flight now.
Next: the query API’s delivery side, named review stages (E2), and lists that update as others edit (E3).