Two more arcs closed since the last checkpoint — one about who may publish, one about how you ask for content.
E2 — named review stages
A changeset’s review used to be a single gate: someone approves the current hash, you publish. Real editorial teams want ordered steps — copy review, then legal — and only then a publish.
- Stages are environment configuration. A project may define an ordered list of named stages,
each optionally requiring someone other than the sender to approve. The definition is replaced
whole (add/rename/reorder/remove) under its own ETag —
stale_stagesrefuses a raced save; the rows are immutable per environment version, so reviews that name a stage can’t be retargeted. - Reviews name their stage. The changeset view carries each stage’s state — waiting, approved,
changes requested, approval expired — and who approved it when. Publishing requires every
configured stage approved on the current changeset hash; the API answers
not_approvednaming what’s missing rather than silently succeeding. - Serialization, honestly. Stage replace/review/publish share a lock on the environment row
(
FOR UPDATE/FOR SHARErespectively) — the first design triedFOR SHAREon the stage rows themselves, which PostgreSQL rightly refuses without UPDATE privilege; locking the parent row achieves the same ordering without granting write on immutable rows (D-104). - Surfaces: a Settings → Review stages editor (reorder, rename, someone-else flag, read-only
for roles without
schema.change), the changeset page showing the ordered stages and a Deciding selector on every review, one-click publish that approves its eligible stages and names the ones blocking it, a testbed page driving the full gate, and a k6 scenario — with the guarded list-replace deliberately documented as a config write rather than load-looped (it serializes on one global row by design).
DX.3–5 — the query language executes
DX.1–2 gave the language a shape and hash-bound cursors; this arc made it run.
- SQL translation (DX.3): bounded canonical queries compile to verbatim-parameter
WHERE/ORDER BYincolophon-db;POST {P}/collections/{c}/entries/queryexecutes with keyset pagination and stable entry-id tie-breaks. The response echoes theappliedquery (canonical form + hash). - Delivery side (DX.4):
POST /content/v1/{project}/{collection}/queryreads ascolophon_deliveryunder the exact publication-validity predicate — latest generation by default, arelease-pinned generation when the caller names it. Cache keys incorporate the query hash and the page boundary hash, so two pages of one query never collide and generation pinning behaves exactly like the plain collection read (D-105). depthpopulates (DX.5): reference fields can return expanded objects{id, collection, revision_id, title, content, hidden_fields?}— resolved level by level with one batchedANYread per level, never N+1. The draft side resolves in the caller’s transaction narrowed by theircontent.read(a denied target keeps its stored id andhidden_fieldsnames what was cut); the delivery side resolves publications pinned at the page’s own generation. Cycles terminate at the bound because there is no level past it (D-107).
CLI and MCP reached parity
The CLI gained query, release, migration, comment, and task modules; the MCP server
gained query and collaboration tools — all authorization-bound wrappers over the same HTTP API.
Dangerous actions stay refused by design: no approve, publish, stage administration, token/role
administration, or destructive collaboration writes through either surface. 43 stack tests cover
tools/list permission filtering, query round-trips with cursor paging, mismatched-cursor refusal,
and rejected forbidden names (D-106).
Next: E3 — lists that update as PostgreSQL commits (a scoped SSE fan-out design is already written), and the rest of the DX program: field-permission narrowing on filters, OpenAPI, the TypeScript SDK.