# 🤖 Slides — design

Design, 2026-09-07. Direction approved by Paul in conversation; this is the
written form. **Revision 1.** Drafted by the Slides session (Fable 5.1)
from a brainstorm with Paul the same day. Every ruling in §1 attributed to
Paul was given in that conversation.

Slides is a presentation editor for the Oficina suite: a deck of slides
that looks like a Keynote or PowerPoint deck on a desktop and reads like
a document on a phone, edited by several people at once, presented from a
laptop and driven from a phone, exported to PDF, and driveable by an agent
through the same operations a person has. One instance per team, on the
CARLOS platform, at `slides.oficina.works` for the first team.

Built beside [Docs](https://amadan.net/oficina/docs),
[Sheets](https://amadan.net/oficina/sheets) and the suite
[doctrine](https://amadan.net/oficina/suite). Where a decision is already
made over there, this spec consumes it rather than restating it (§0.2).

---

## 0. The rules this app lives under

### 0.1 The one rule

**A slide is one block tree, rendered many ways.** The tree is the only
truth: the editor edits it, the viewer renders it, the phone reflows it,
the PDF prints it, the agent patches it. Nothing that matters to what a
reader sees lives outside the tree, so there is never a "desktop version"
and a "mobile version" of a deck, only one deck and a viewport.

Everything below derives from it. The hybrid layout modes (§2.3) are two
ways of placing the same blocks, not two kinds of slide. The phone
overrides (§2.5) are entries in the tree, not a second file. The drive
API (§7) is the inspector's operations with a JSON body.

### 0.2 Consumed suite rulings

This app cites the suite sharing doctrine
(`oficina/suite`, `docs/superpowers/specs/2026-09-05-suite-sharing-doctrine.md`,
revision 8) by ruling number and never restates it.

| Ruling | Used for |
|---|---|
| 1, 5 | One instance per team; the first deploy is the **lone shape** (§6.1). |
| 4, 12 | Identity and roster belong to the home; until the home exists (it is a skeleton, `oficina/home` `internal/home/app.go`), the lone instance keeps its own allowlist exactly as Docs does. |
| 6 | No product-wide egress. A PDF leaves as a download the person asked for; nothing is sent anywhere. |
| 9 | Every write names its real actor. An agent's write names the agent (§7). |
| 15 | Three rungs (private, granted, team) plus a **read-only link capability**. A share link never edits (§5). |

One ruling made by Paul during this brainstorm is suite-wide and is
filed as an `oficina/suite` discussion rather than restated here as if it
were this app's: **agents drive every Oficina app** as their own
identified principal, `claude~paul` style, on behalf of a person, never
as the person (2026-09-07). Slides is the first app to build the surface
(§7).

### 0.3 What this spec does not claim

It does not claim pixel identity between the browser stage and the PDF.
§4 says where they may differ and by how much. It does not claim the
suite home exists. It does not claim a native remote exists; §3.4
designs the phone web page so a native app later speaks the same three
calls.

---

## 1. Settled decisions (don't relitigate casually)

| # | Ruling | Rejected alternative | Who, when |
|---|---|---|---|
| 1 | A slide is a tree of typed blocks; the slide's container has a **layout mode**, `flow` or `canvas`. Same blocks, same editor, two placements. | Blocks only (no free placement; feels like Gamma, not Keynote). Canvas only (unreadable on phones). Two separate slide kinds (two models to maintain). | Paul, 2026-09-07 |
| 2 | Phones get **reflow** for flow slides and **scale-to-fit** for canvas slides, with a per-slide switch to reflow a canvas slide in reading order. | Hand-authored second layout per slide. | Paul, 2026-09-07 |
| 3 | Collab core is **Yjs in the browser, yrs under wazero on the server**, Docs' pattern and Docs' live-sync transport (SSE advisory, bytes over POST). Slides is the second implementation of live-sync. | Server-authoritative JSON with last-write-wins (two people in one text block clobber each other). | Paul, 2026-09-07 |
| 4 | **One Go renderer produces HTML** for the viewer, presenter, remote and the editor's stage. **Export** uses a Go layout engine that emits SVG per slide, and PDF is SVG to PDF through a Rust module under wazero. No Chromium. | Extend Docs' hand-rolled PDF with boxes and images (a second layout engine to keep in step). Headless Chromium (no such platform capability; one static binary). | Paul approved the SVG-and-Rust export path 2026-09-07; the HTML-first split for the live surfaces is the session's refinement, recorded in §4.1. |
| 5 | Presenting is a **server-side presentation session**: current slide, presenter, pairing code. The phone remote, the presenter view and audience follow all read and write that one record. | Present as a pure viewer with no session (no remote possible). | Paul, 2026-09-07 ("include plans for speaker notes, presentation mode, and a mobile remote control") |
| 6 | The remote is a **phone web page** first; a native app later speaks the same calls. | Native first. | Paul, 2026-09-07 |
| 7 | **Every write names its actor**, and an agent is its own principal with an operator, never the person (suite ruling 9 plus Paul's 2026-09-07 ruling). Agent tokens are minted in the app by a signed-in person, named `<agent>~<person>`, stored hashed. | Agents borrowing a person's session. | Paul, 2026-09-07 |
| 8 | First deploy is the **lone shape**: magic-link sign-in with an address allowlist, exactly Docs' `internal/docs/auth.go` shape. | Waiting for the home. | Session, 2026-09-07, from the survey: no app signs in through the home; the home is a skeleton. |
| 9 | Themes are a **built-in set of token bundles** over estilo's variables. A deck may override accent colour and the two typefaces. No per-block fonts. | Free font picker per block (the fastest route to an ugly deck). | Paul approved the model 2026-09-07 |
| 10 | Bytes (images) live in the **platform object store**, referenced by `blobs.Ref`; never on the instance disk, never in a column beyond the 4 KiB inline rule. | SQLite blobs (the instance hibernates and its disk is ephemeral; rastrillo §2). | rastrillo rule, adopted |
| 11 | Repo `oficina/slides`, module `amadan.net/oficina/slides`, scaffolded by `rastrillo new` v0.26.0 (day theme, topbar shell). | Spanish name (`presentaciones`); the suite names generic apps in English. | Session, by the naming precedent |
| 12 | `href` stored as `{href}`; marks named after Y attributes | plain-string href (does not survive y-prosemirror) | Session, 2026-09-07, from the Task 2 finding |
| 13 | PDF via Rust svg2pdf under wazero: **viable** — `svg2pdf` 0.13 with `usvg` 0.45 (text) and `fontdb` fed the Liberation TTFs from linear memory builds for wasm32-unknown-unknown with zero host imports, a 3.5 MiB reproducible module, and renders text as subset embedded TrueType (Type0/CIDFontType2, FontFile2, ToUnicode), 200 pages in 140 ms; slice 3 proceeds with svg2pdf (`core/pdf`, `internal/slides/pdfcore`). | The Go paint-list writer (`pdfgo`), §4.3's recorded fallback: stays recorded, not built. | Session, 2026-09-08, Task 1 spike |
| 13a | **`svg2pdf` replaced by `krilla` 0.8.2 + `krilla-svg` 0.8.1 behind the same ABI.** typst/svg2pdf was archived 2026-07-10 naming krilla — same author, same usvg tree — as its successor, so the module was wrapping an unmaintained dependency the spike had not checked. The swap builds for wasm32-unknown-unknown with **no feature flags turned off at all** (krilla-svg has none, and its resvg/tiny-skia/usvg/fontdb defaults compile clean for wasm32), **0 host imports**, and keeps every property ruling 13 asked for: one page per SVG, 1920×1080 pt, Type0/CIDFontType2, FontFile2, ToUnicode, subset tags, embedded raster images. Module 3,672,407 → **5,301,002 bytes** (3.50 → 5.06 MiB). 200 pages through the Go host: 642 ms → **220 ms**, and the PDF 1,719,771 → **140,444 bytes**, because krilla writes one subset per document where svg2pdf wrote one per page. `slides_pdf_abi_version` is still 1 and `internal/slides/pdfcore` was not touched. | Staying on an archived dependency; or waiting for a forced upgrade with a CVE attached to it. Also rejected: keeping krilla's default XMP packet, whose RDF namespace URLs would put `http` in a PDF that names nothing outside itself. | Session, 2026-09-09, krilla spike |
| 14 | Uploads are **staged then promoted**: `POST /uploads` mints a staging key of the app's own and signs a PUT for that alone; registration resolves an upload receipt (this deck, this actor, unspent, under two hours), reads the bytes, and puts them back at their own SHA-256. No PUT is ever signed for an address anything is served from, and a ref alone registers nothing. | Signing a PUT for the client's declared hash and trusting the object afterwards — which made a verified picture overwritable and made knowing a ref permission to read it. Also rejected: an authorised cross-deck copy, which nothing asks for yet. | Session, 2026-09-08, from codex 1 and codex 2 |
| 15 | **Presenting is every member's; minting a share link is the owner's**, and an audience follows through the **share link**, never the session sqid. The sqid is a projector address: unrevocable, so it goes only to whoever is presenting. | Making presenting owner-only (reading a deck to a room is reading a deck), or leaving followers on `/p/{session}` where the link's revocation could not reach them. | Session, 2026-09-08, from codex 5 |
| 16 | **Admission is rechecked wherever a principal is resolved** — the session's subject in `currentUser`, the operator's address in the bearer gate — and fails closed. Removing an address revokes its sessions on every device and ends its live talks; its share links keep working, because a share link is the deck's and the deck is the team's. | The allowlist gating only the minting of a session, which left a removed person everything they already held, agents included. | Session, 2026-09-08, from codex 6 |
| 17 | A presentation command is recorded as **`remote:<session sqid>`**, explicitly anonymous, with the session's issuer logged beside it. The remote token is transferable by design, so no name can be put on those writes honestly. | Recording the session creator, which made ruling 7 false for exactly the writes nobody could vouch for. | Session, 2026-09-08, from codex 15 |
| 18 | **A slide's `blocks` array is the set of its blocks; reading order is an `order` key on each block map** (a number; array index when absent, array index as the tie-break), and every reorder — `move_block`, canvas-to-flow — writes the key rather than moving array entries. The view sorts by it and does not expose it, so every reader sees one sequence. | Delete-and-reinsert in the array (loses a peer's concurrent edit to the moved block, reproduced by the review). A separate `blockOrder` id list on the slide (a second place the block set lives, which can drift from the array). | Session, 2026-09-08 (codex 4) |
| 19 | **The write gate checks the document, not its projection.** A posted update is refused (422) if any text fragment holds anything outside §2.6 — a formatting attribute other than `b`, `i`, `c`, `h`, `href`, a non-`paragraph` child, an element or fragment inside a paragraph, an embed — because y-prosemirror 1.3.7 deletes an `XmlText` whose attributes it cannot map to a mark, and the tolerant view reads right through exactly that. The check is a core ABI call (`slides_core_check`, ABI 2) mirrored in `fixtures/check.mjs`; the browser also strips unknown attributes from a fragment before binding, for documents that already carry one. | Parsing the JSON view alone (what slice 2 did; passes the update that loses the words). | Session, 2026-09-08 (codex 3) |
| 20 | **The children of a slide's `columns` blocks form a forest**: a block appears in at most one `children` list on its slide, at most once, and a columns block is never placed under its own descendant. Both implementations refuse the op with the same message. A document that nonetheless holds a cycle (two peers' concurrent assignments merged) renders and exports with the unreached blocks appended at the end, marked, never dropped. | Rejecting only self-reference (what slice 2 did; a two-block cycle vanished from the page in silence). | Session, 2026-09-08 (codex 7) |
| 21 | **Ruling 18 again, one level up: the deck's `slides` array is the set of its slides and their order is an `order` key on each slide map**, on the same terms (absent ranks at the array index; array index breaks ties). `move_slide`, `add_slide`, `duplicate_slide` and `remove_slide` never rebuild the array, so a slide reordered while somebody types on it keeps the typing. One idea at both levels, one helper each side (`ordered_maps` / `orderedMaps`). | Leaving `move_slide` cloning and re-inserting, which loses a concurrent edit exactly as `move_block` did. Inventing a second ordering mechanism for slides, which would be two ideas where the document has one. | Session, 2026-09-08 (codex 4, at slide level) |

---

## 2. The deck model

### 2.1 The Y.Doc

A deck is one Y.Doc. Top-level shared types:

- `meta` — Y.Map: `title`, `theme` (theme id), `accent` (optional hex),
  `fonts` (optional `{heading, body}`), `aspect` (`16:9` only in v1; the
  key exists so `4:3` can arrive without a migration).
- `slides` — Y.Array of Y.Map, one per slide: the SET of the deck's
  slides. Deck order is the `order` key on each slide map, exactly as a
  slide's `blocks` array carries its reading order (ruling 21, codex 4
  at slide level).
- `assets` — Y.Map from asset id to `{ref, name, width, height, alt}`,
  where `ref` is the object store address (§2.7).

### 2.2 A slide

A slide Y.Map holds:

- `id` — stable, random, 12 lowercase base32 characters, assigned at
  creation, never reused. The drive API addresses slides by it.
- `order` — the slide's place in the deck, a number, on exactly the
  terms the block `order` below has: absent ranks at the array index,
  array index breaks ties, and a deck nobody has reordered carries none
  (ruling 21, codex 4). `move_slide` writes a dense `0..n-1` onto every
  slide; `add_slide` and `duplicate_slide` on a deck that carries
  orders renumber so the new slide lands where `after` asked. The JSON
  view sorts by it and does not expose it.
- `layout` — `flow` or `canvas`.
- `template` — for flow slides: `title`, `title-body`, `two-column`,
  `section`, `image-full`, `quote`, `blank`. Ignored on canvas slides.
- `blocks` — Y.Array of block Y.Maps: the SET of the slide's blocks.
  Reading order is the `order` key on each block map, a number; a block
  without one ranks at its array index, and array index breaks ties
  (ruling 18, codex 4). A slide nobody has reordered carries no `order`
  and reads in array order, as before. `move_block` and canvas-to-flow
  write a dense `0..n-1` onto every block; `add_block` on a slide that
  carries orders renumbers so the new block lands where `after` asked.
  The JSON view sorts by it and does not expose it. On a canvas slide
  `z` is stacking, `order` is reading order, and the two are different
  things.
- `notes` — Y.XmlFragment, speaker notes, the same text schema as §2.6.
- `background` — `{kind: theme | color | image, value}`.
- `phone` — `inherit` (default) or `reflow`. Only meaningful on canvas
  slides.
- `transition` — `none` or `fade`.

### 2.3 Layout modes

**Flow.** The template names regions (`heading`, `body`, `aside`,
`media`), each block has a `region` prop, and blocks stack inside their
region in array order. The renderer places regions with CSS grid, and
the template's grid collapses to one column at the phone breakpoint, so
reflow is CSS, not a second layout.

**Canvas.** Each block has `box: {x, y, w, h}` as fractions of the 16:9
frame (0 to 1, four decimals) and `z`. The renderer positions blocks
absolutely inside a frame that scales to the viewport width. On a phone
the frame scales to fit, text and all, unless `phone` is `reflow`, in
which case the renderer emits the blocks as a flow slide with the
`blank` template in `z` then top-left reading order.

Switching a slide's mode is an operation, not a conversion the person
does by hand: flow to canvas assigns each block the box its region gave
it (`z` is the reading-order index); canvas to flow drops boxes,
assigns regions by position, and sets the reading order top to bottom
then left to right. Both are lossy in one direction and say so in the
inspector.

The reorder in canvas-to-flow is written as `order` on each block map
(§2.2) and never by rebuilding the `blocks` array (codex 4). Rebuilding
it — clone every block, clear the array, insert the copies — is what
slice 2 did, and a peer typing into a block at that moment lost the
sentence: their edit addressed the deleted original and the merge kept
the copy. Placement is allowed to be lossy; content is not. The same
rule holds for `move_block`.

It holds for `move_slide` too, and by the same mechanism rather than a
second one (ruling 21, codex 4 at slide level). A slide reordered while
somebody is typing on it used to lose the typing for exactly the reason
a moved block did: the slide map the other client's text, notes and
blocks all hang off was deleted and replaced by a copy. So the deck's
`slides` array is the set of its slides and their order is an `order`
key on each slide map (§2.1, §2.2) — one idea at both levels, one
helper in each implementation, and `move_slide`, `add_slide`,
`duplicate_slide` and `remove_slide` never rebuild the array.

### 2.4 Blocks

A block Y.Map holds `id` (same scheme as slides), `type`, `props`
(Y.Map), and for text-bearing types `text` (Y.XmlFragment). The v1
vocabulary, the "maximally viable" set:

| Type | Props | Text |
|---|---|---|
| `heading` | `level` 1 to 3 | yes |
| `text` | `size` (`s`, `m`, `l`), `align` | yes |
| `list` | `style` (`bullet`, `number`), `size` | yes, one paragraph per item |
| `quote` | `cite` | yes |
| `image` | `asset`, `fit` (`cover`, `contain`), `alt` (required, may be empty only if `decorative` is true), `radius` | no |
| `columns` | `count` 2 or 3, `children` (Y.Array of block ids laid out left to right, each a block in this slide's `blocks`) | no |
| `code` | `language`, `size` | yes, plain, one paragraph per line |
| `shape` | `kind` (`rect`, `ellipse`, `line`, `arrow`), `fill`, `stroke`, `strokeWidth`, `radius` | optional single-paragraph label |
| `table` | `rows`, `cols`, `header` (bool), `cells` (Y.Array of Y.XmlFragment, row-major) | per cell |
| `divider` | none | no |
| `spacer` | `size` | no |

Every block additionally carries `region` (flow) or `box` and `z`
(canvas), an optional `order` (§2.2), and an optional `phone` override
map (§2.5).

The `children` of a slide's `columns` blocks form a forest (ruling 16,
codex 7): a block id appears in at most one `children` list on its
slide, at most once in that list, and a columns block may not name a
block that it already sits under. `add_block` and `set_block` refuse
any of those, in both implementations with one message per case
(`names "x" twice`, `names "x", already a child of columns "y"`, `names
"x", which would put columns "self" inside itself`), and the batch
applies nothing. A concurrent pair of assignments can still merge into a
shared child or a cycle, so the renderer and the layout engine apply
one deterministic policy: the first columns block in reading order that
names a child owns it, and whatever no root reaches is rendered after
everything else — in its own trailing region on a flow slide, at its box
on a canvas one — marked `data-unreached` in HTML and reported as a
`unreached` warning by the layout engine. Content is never dropped for
being badly parented.

Charts from Sheets, embeds and video are **not in v1** (§9).

### 2.5 Phone overrides

A block's `phone` Y.Map may hold: `hidden` (bool), `order` (integer,
sorts before array order among siblings), `size` (a size step), and on
canvas slides `box`. Nothing else. The desktop layout is always the
base; the phone layer only adds.

The editor's desktop/phone switcher (§3.2) writes to this map when the
stage is in phone view. The renderer reads it when the viewport is a
phone, and the print engine never reads it.

`order` is reading order, not a visual effect (codex 10). Rendering for
the phone viewport EMITS the blocks in the effective phone sequence —
regions in region order; within a region and within a columns block, a
stable sort by `order` with an unset order counting as 0 — so the
sequence a screen reader hears and the Tab key follows is the one a
sighted reader sees (§8, WCAG 2.2 AA, no exemptions). The stylesheet's
`order: var(--slides-phone-order, 0)` is kept with the same key for the
one case it still serves — a desktop rendering opened on a narrow
screen — and is no longer what makes the order true. A reflowed canvas
slide takes §2.3's z-then-top-left sequence first and the phone `order`
on top of it. Rendering for the desktop viewport keeps the desktop
document order.

### 2.6 Text

Text is a ProseMirror document under a constrained schema: `paragraph`
only at the block level; bold, italic, code, link and highlight as marks.
No nested blocks, no headings inside text (a heading is a block). Lists
are `list` blocks whose paragraphs are items; a nested list is a second
`list` block with `indent` 1. The same schema drives speaker notes and
table cells.

Over Yjs, that document is an XmlFragment of `<paragraph>` elements, each
holding one XmlText whose formatting attributes are the marks. **The
ProseMirror marks are named `b`, `i`, `c`, `h`, `href` — the Y attribute
names — and `href` is stored as the one-key map `{"href": url}`, not as a
plain string** (ruling 12). Both follow from y-prosemirror's attribute
mapping, which has no hook to override: it writes
`attributes[mark.type.name] = mark.attrs` and reads
`schema.mark(attrName, attrValue)`. So a mark's name must be the
attribute name, and the mark `href` with attrs `{href}` is the attribute
`href` carrying that object. A plain string there throws inside
`schema.mark`, and y-prosemirror deletes the XmlText on a throw. Naming
the marks this way makes the mapping the identity and keeps mapping code
out of the editor.

The writer is exact; the reader is tolerant, because updates reach the
server from browsers as well. `b`, `i`, `c`, `h` are written `true` and
read as set on any truthy value (y-prosemirror writes `{}` for an
attr-less mark); `href` is written as the map and read as either the map
or a plain string (documents written before this ruling).

**The accepted shape is narrower than what the reader tolerates**
(ruling 19, codex 3). The reader's tolerance is for documents that
already exist; it is not the gate on a write, because the editor does
not consume the reader's projection — it binds y-prosemirror to the
shared text, and y-prosemirror 1.3.7 deletes an `XmlText` whose
attributes name a mark the schema lacks (`schema.mark` throws a
`RangeError`; `createTextNodesFromYText`'s catch deletes the item). A
posted update is therefore accepted only if every text fragment in the
document it produces — each slide's `notes`, each block's `text`, each
table's `cells` — is `<paragraph>` elements holding only text whose
formatting attributes are among `b`, `i`, `c`, `h`, `href`, with no
embeds. Anything else is refused before it is stored (§3.3). The
browser, for its part, strips an unknown attribute from a fragment
before binding, so a document that already carries one loses the mark
and not the words — that is the smallest change the library leaves
room for, since it offers no hook between reading the delta and
constructing the mark.

### 2.7 Assets

An image is uploaded browser-to-bucket by presigned PUT (rastrillo
`blobs`), then registered in `assets` with its `blobs.Ref`, dimensions
and alt text. The server verifies the object exists and is an image
before registering. Limits: 20 MiB per image, PNG, JPEG, WebP, SVG
(SVG is sanitised on registration). Deleting the last block that uses an
asset leaves the asset; a deck-level "unused assets" list lets a person
remove them. There is no cross-deck asset library in v1.

**Stage, then promote (codex 1, codex 2).** The browser never names the
address its bytes will live at, and a ref is never on its own enough to
get anything registered. Ruling 14; the shape:

- `POST /d/{sqid}/uploads` mints a **staging key** — a hash of the deck,
  the actor and fresh randomness — writes an upload **receipt**
  (`asset_uploads`: deck, actor, staging key, object key, declared
  hash, consumed_at) and signs a PUT for that key and nothing else. It
  refuses to sign for an address any `deck_assets` row verifies, which
  a fresh random key can never be: the check is there so the mistake
  cannot come back.
- `POST /d/{sqid}/assets` resolves the receipt — same deck, same actor,
  unconsumed, under two hours — reads the staged bytes, sniffs them,
  sanitises an SVG, and **puts what it read back** as a final object
  addressed by its own SHA-256. That address is what the deck refers
  to. The receipt is then spent, single-use, and the staged copy is
  deleted unless it is already the promoted object.
- Nothing anywhere signs a PUT for a final address. A verified object
  is immutable by construction, not by nobody having tried.

Why, in one line each. A presigned PUT is unconditional
(`rastrillo/blobs`' own note says the server must verify what landed),
so an address a browser can name is an address a browser can overwrite —
naming a ref another deck was already serving from changed that
picture, in that deck, with no document update anywhere. And
registration wrote the destination deck's verification row for any ref
it was handed, with no receipt and no access to the source deck — so
knowing a content address, which is knowing the SHA-256 of somebody's
file, was permission to read it. Both were reachable by any member.

The cost, accepted: registration now reads **and** re-writes up to
20 MiB per picture instead of only reading it. The download was already
happening; the upload is the price of a final address derived from
bytes this app has actually seen.

**An agent cannot add a picture.** It can name a ref in a
`register_asset` op and the document will hold it, but no surface will
serve those bytes, because serving requires a verification row and a
verification row is only written for an upload this app signed. A
bearer-authenticated upload is the obvious next thing and is not built
(§9).

The **copy** path codex 2 offers as an alternative — registering a ref
from a deck the person may read, scoped through `visibleDecks` — is
deliberately not built. Nothing asks for it, and a cross-deck asset
library is already out of v1 by the paragraph above; when it lands it
goes through `visibleDecks` and takes a receipt of its own.

### 2.8 Fixtures are the contract

The JS side generates Y.Doc updates for a fixed set of decks
(`fixtures/data/*.json` describing the tree, `fixtures/generate.mjs`
producing `*.yjs` updates). The Rust core must load each and produce the
same JSON view (`slides_core_json_read`). Docs' rule, adopted unchanged.

---

## 3. The editor, presenting, and the remote

### 3.1 Editor

A JS bundle built like Docs' (`fixtures/build-bundles.mjs`, esbuild,
committed hashed output, embedded by `assets.go`), ProseMirror plus yjs,
no framework. Three panes on a desktop: the **strip** (slide thumbnails,
drag to reorder, keyboard reorder with a menu equivalent), the **stage**,
and the **inspector**. On a phone the strip is a horizontal filmstrip
under the stage and the inspector is a bottom sheet.

The stage shows the server's HTML for the current slide (§4.1) with the
editor bound to it: each text-bearing block is a ProseMirror view over
its XmlFragment; canvas blocks get drag, resize handles, snap guides at
the frame's thirds and other blocks' edges, and z-order from the
inspector. Flow blocks reorder by drag or by the inspector's move
buttons.

The inspector is per selection: block props, slide props (layout,
template, background, transition, phone behaviour), deck props (title,
theme, accent, fonts). **Every inspector control is one drive operation
(§7.2)** and the editor calls the same function the API handler calls,
so a person and an agent can never do different things.

Undo is per person (yjs `UndoManager`, scoped to that client's origin).
Keyboard: the ordinary text shortcuts, plus `Cmd/Ctrl+Enter` for a new
slide, `Cmd/Ctrl+D` duplicate slide, `Cmd/Ctrl+Shift+F` present.

### 3.2 The desktop/phone switcher

A three-state control on the stage: **desktop**, **phone**, **both**.
Phone view renders the stage at 390 CSS pixels wide with the phone
layer applied. Edits to layout while in phone view go to the `phone`
override map (§2.5); edits to text go to the text (there is one text).
A block with an override shows a small badge in both views with "reset
to desktop" in its inspector. "Both" shows desktop and phone side by
side, the phone read-only.

*Slice 2 deviation:* the override badge lives in the inspector, beside
"reset to desktop", not on the block on the stage — the stage is the
server's own slide HTML and carries no editor chrome yet. Putting it on
the block is a slice 3 candidate.

### 3.3 Collab

Docs' live-sync spec is adopted as written
(`oficina/docs`, `docs/superpowers/specs/2026-09-01-live-sync-design.md`
§3 to §7): `GET /d/{sqid}/stream` SSE emitting `event: update` with a
seq; bytes by `GET|POST /d/{sqid}/updates`; the cursor+1 rule; broadcast
on success only; the 15 s ping, 30 s write deadline, 5 minute TTL;
hibernation-safe because the stream carries no bytes. Slides is the
second implementation, which is the estilo rule's trigger: whatever
Docs' hub and stream code turns out to need in both places is extracted
that week, and the Slides spec records the extraction when it happens.

The gate on `POST /d/{sqid}/updates` applies the bytes to a fresh
handle and, before the transaction opens, checks the document they
leave (codex 3): first the core's accepted-shape check over every text
fragment (§2.6; `slides_core_check`, ABI 2), then the typed parse of
the JSON view for shapes the reader cannot hold at all. Either finding
is a 422 and nothing is stored. The check is on the document rather
than on the view because the view is a tolerant projection, and what it
tolerates is precisely what the editor cannot.

Presence rides the same stream as a second event type, `presence`,
carrying `{client, name, colour, slide, block}`; the server holds
presence in memory only (it is not document state) and drops a client
after two missed pings. The editor shows avatars on the strip
thumbnails and a coloured outline on the block another person has
selected. Docs owns the first build of the presence stack per estilo's
ownership table; if Docs has not built it when Slides needs it, Slides
builds it and Docs adopts.

### 3.4 Presenting, speaker notes, the remote

**Presentation session.** A row: `deck`, `presenter` (actor), `slide`
(index), `blackout` (bool), `started_at`, `ended_at`, `code` (six
characters, unambiguous alphabet), `remote_token_hash`. Created by
`POST /d/{sqid}/present`, which returns the session sqid and, once, a
remote capability URL. Ended explicitly or after 12 hours. One live
session per deck per presenter.

**Presentation mode.** `GET /p/{session}` is the fullscreen surface:
the current slide filling the viewport, keyboard (arrows, space, `b`
for blackout, `n` for notes, `Esc`), swipe, a thin progress bar, and
the slide's transition. The page subscribes to the session's SSE stream
(`GET /p/{session}/stream`, `event: slide` with the index and blackout
state) and applies what it hears, so it is the same page whether the
presenter's keyboard or the phone moved the deck. It works with scripts
off as a page per slide with next and previous links (§8).

**Presenter view.** `GET /p/{session}/notes` shows the current slide,
the next slide, the notes, a clock and elapsed time, and the pairing
code and its QR. Meant for a second window on a desktop or the
presenter's phone. Its controls post the same commands as the remote.

**Remote.** `GET /r/{code}` on a phone: enter the code or scan the QR,
which carries the remote capability URL. The page shows notes, current
and next slide thumbnails, and **next**, **previous**, **blackout**,
plus **end**. Commands are `POST /p/{session}/command` with
`{op: next | prev | goto | blackout | end, slide?}` and the remote
capability as a bearer (or, scriptless, as a form field). The presenting
browser applies the resulting `slide` event. Latency target under 300 ms
on the same continent. The capability lives as long as the session. A
native remote later speaks exactly `present`, `command` and the
`stream`; nothing else is needed.

**Audience follow.** A share link (§5) opened while a session is live
for that deck shows "follow the presenter". Following subscribes the
viewer to the session stream read-only; the viewer can detach and
browse, and reattach.

**An audience stays behind the capability it arrived with (codex 5).**
Following is `/s/{token}?follow=1`, and every read that page then makes
is a `/s/{token}` route: the slide fragments
(`?follow=1&slide=N&fragment=1`), the stream (`/s/{token}/stream`), the
thumbnails and the pictures. Each resolves the token again, and the
stream re-resolves it before every event and every heartbeat, so
revoking the link ends the follow immediately rather than at the end of
the talk. **The session sqid is never rendered onto a follow page**, and
the two URLs the script uses come down as `data-stream` and
`data-fragment` attributes rather than being assembled in the browser
out of a sqid it does not have. Before this, following handed over the
sqid and every later read went to `/p/…`, which asks only for a live
presentation — a capability the person who granted the link could not
take back.

**Who may publish which capability**, settled (codex 5 asked; ruling
15). Presenting stays open to **every member who can read the deck**,
not the owner alone: reading a deck to a room is reading a deck, and
`POST /d/{sqid}/present` is `deckFor`'s audience like the editor and
the export. Minting a **share link** stays the owner's, because a share
link grants read to people with no account here and outlives any talk.
The two are different powers and the mismatch was only apparent: the
session sqid is a **projector address** — it lives as long as the talk,
carries no notes and cannot be revoked — and it is now given only to
whoever is presenting, never to an audience. An audience gets a share
link, which is revocable, which is why audience-follow derives from the
link and not from the session.

**A projector page is not a nothing-happens page.** `/p/{session}`
still answers to anybody holding the sqid, deliberately: that is what
makes "put this on the screen" a URL. What changed is that nobody is
handed one who was not meant to have it.

**Out-of-order fragments (codex 13).** The script tracks *requested*
and *displayed* separately and stamps every fetch with a monotonic id:
a response whose id is not the newest is discarded, displayed only
moves after an install succeeds, and a failed fetch rolls requested
back to displayed. One state — "the slide the page has announced" —
left a projector permanently behind: with the record already at 3, a
late response for 2 overwrote the stage and the event that would have
corrected it was suppressed as a slide the page was already on.

---

## 4. Rendering and export

### 4.1 The HTML renderer

One Go package, `internal/render`, turns a slide (from the Rust core's
JSON view) into HTML: a `<section slides-slide>` with the template's
grid or the canvas frame, blocks as semantic elements (`h1` to `h3`,
`p`, `ul`, `ol`, `blockquote`, `figure`, `pre`, `table`), theme tokens
as CSS custom properties on the section, and the phone layer as
`data-phone-*` attributes that the stylesheet honours under the phone
media query. It serves the viewer, the presenter, the remote thumbnails
and the editor stage.

The one rule's refinement recorded here: the session had said SVG would
serve the scriptless viewer. HTML is the better choice for every live
surface because it reflows, it is accessible without extra work, and it
is what the editor must bind to. SVG remains for export and thumbnails,
where a fixed geometry is the point.

### 4.2 The layout engine and SVG

`internal/layout` measures text with the theme's fonts (self-hosted
TTFs embedded in the binary, metrics via `golang.org/x/image/font/sfnt`)
and places blocks in a 1920 by 1080 frame, producing a paint list:
rectangles, images, text runs with font, size, position. `internal/svg`
serialises a paint list as SVG with text as `<text>` runs and images by
reference. Thumbnails are the SVG scaled down, served as SVG.

Divergence from the browser is confined to line breaking inside a text
block and is small when fonts match, the Docs finding. It is not zero
and this spec does not claim it is.

**Amended 2026-09-08, from what slice 3 shipped.** The paint list feeds
exactly two things, the thumbnails and the PDF. Every live surface — the
viewer, the presenter's stage, the editor's stage, the API's `.html`
twin — is still `render.Slide`'s HTML, so §0.1's one tree is rendered
by two renderers and never a third. Images are *inlined* rather than
referenced wherever the bytes can be read, because an SVG inside an
`<img src>` fetches nothing at all — a referenced picture is simply
absent from a thumbnail. The image an inline cannot carry falls back to
a URL, which draws only when the `.svg` is opened as a document. See
§4.3's amendment for the fonts, which is the other half of the same
restriction.

### 4.3 PDF

`core/` (Rust) gains a second module beside the CRDT: `slides_pdf`,
wrapping `krilla` and `krilla-svg` with `usvg` and `fontdb` fed the same
embedded fonts, compiled to wasm32 and run under wazero like the CRDT
core. (It wrapped `svg2pdf` from slice 3's Task 1 spike until
2026-09-09; see the amendment at the end of this section.) Input: one
SVG per slide plus the fonts; output: one PDF, 1920 by 1080 points per
page, with the speaker notes as an optional second page per slide
("with notes" export). Images are embedded at their stored resolution
capped at 2000 px on the long edge.

If SVG-to-PDF in Rust under wasm32 proves unworkable in the plan's spike, the
fallback recorded now is a Go PDF writer over the paint list directly,
reusing Docs' font subsetting (`oficina/docs` `internal/pdf`) by
extraction to a shared module, not by copy.

Export runs as a rastrillo job: `POST /d/{sqid}/export` starts it, the
status page polls, the result is a presigned GET from the object store
that expires in one hour. A deck over 200 slides is refused with a
message.

**Amended 2026-09-08, from what slice 3 shipped.** Four things this
section said differently:

- The fonts are the twelve Liberation faces (Sans, Serif, Mono in four
  styles), embedded in the binary for the layout engine and the PDF and
  served as `@font-face` to every slide surface, rather than "the
  theme's fonts" per theme. Metrics come from this repo's own TrueType
  reader (`internal/slides/fonts`), not `x/image/font/sfnt`.
- The packages are `internal/slides/layout` and `internal/slides/svg`
  (§4.2's `internal/layout` and `internal/svg` predate the
  one-directory-under-internal rule).
- **A thumbnail carries no embedded fonts.** An SVG in an `<img src>`
  fetches nothing, so a thumbnail is set in whatever face the reader's
  machine has under the same stack `slides.css` names; the text wraps
  where the paint list says either way. Embedding the faces would be
  about 800 KB per thumbnail. The PDF is unaffected — the module is
  handed the faces directly.
- **With no object store bound the finished PDF is held in memory for an
  hour** and served from the instance, capped at 50 MiB per deck and
  200 MiB per instance; with a bucket the job uploads it and the
  download route 302s to the presigned GET. Both paths are one handler.
  The spike ruled SVG-to-PDF in Rust under wasm32 viable, so the
  fallback Go PDF writer above was not built.
- **The status page and the file agree about the hour.** rastrillo/jobs
  forgets a finished job ten minutes after it ends; the bytes are
  answerable for the hour the page promised. So the status page, its
  polled fragment and the API's status document all fall back to this
  app's own held record once the registry has forgotten — done, with
  the download still offered — rather than 404ing for fifty minutes
  while `.pdf` at the same address still hands over the file.
  Shortening the hold to the job's ten minutes was the other way to
  make the two agree and takes away a download that was promised.

A WebP image is re-encoded as a PNG on its way into a page
(`golang.org/x/image/webp`), because a browser rendering an `<img src>`
SVG fetches nothing and WebP is one of the four types §2.7 admits.
`krilla-svg` does decode WebP directly, so since the swap below this is
belt and braces on the PDF side; it is kept because dropping it changes
what the SVG encoder emits.

**Amended 2026-09-09: the converter is krilla, not svg2pdf.**
`typst/svg2pdf` was archived on 2026-07-10 naming krilla as its
successor, which the original spike had not checked, so the module was
built on a dependency nobody was maintaining. A second timeboxed spike
swapped it: `krilla` 0.8.2 with `krilla-svg` 0.8.1 over `usvg` 0.47 and
the same `fontdb` fed from linear memory. It builds for
wasm32-unknown-unknown with no features disabled — krilla-svg has none,
and the resvg and tiny-skia defaults it drags in compile clean — with
zero host imports, and every property ruling 13 asked for holds: one
page per SVG at 1920×1080 pt, text as subset embedded TrueType
(Type0/CIDFontType2, FontFile2, ToUnicode), raster images embedded, and
a frame error still a refusal rather than a trap. The module grew from
3,672,407 to 5,301,002 bytes. Against that, a 200-page export through
the Go host went from 642 ms to 220 ms and from 1,719,771 to 140,444
bytes, because krilla writes one font subset per document where svg2pdf
wrote one per page — a handout that used to be 1.7 MB of repeated font
programs is now 140 KB. krilla's XMP metadata packet is turned off:
this module has no title, author or clock to put in one, and what it
would carry instead is RDF namespace URLs, which would break the plainer
rule that a Slides PDF names nothing outside itself. The C ABI
(`slides_pdf_abi_version` is still 1) and `internal/slides/pdfcore` did
not change; what did change above the module is the tests, which grep
PDF bytes and now take a dictionary entry in either spelling, because
pdf-writer 0.12 wrote `/Type /Page` and 0.15 writes `/Type/Page`.

---

## 5. Sharing and links

Rungs are suite ruling 15's: **private** (the creator), **granted**
(named people on this instance), **team** (everyone on the instance can
open it). Every deck starts private. Editing needs sign-in and a rung of
`granted` or `team`; a `team` deck is editable by every member, which is
the Docs reading and the one that suits a small team.

A **share link** is a read-only capability, calendar's pattern
(`oficina/calendar` calendar design §6): `GET /s/{token}` mounted
outside the session group, only the token's SHA-256 stored, the URL
shown once at creation, revocable from the deck's share page, with a
view count. The link opens the viewer (§8) with the deck as it is now,
not a snapshot. If a presentation session is live, the link offers
follow (§3.4) — and following stays **on** `/s/{token}` routes, so
revocation reaches it (codex 5).

**Revocation is the property, so nothing may outlive it.** Every read a
link holder makes — page, slide fragment, thumbnail, picture,
presentation stream — resolves the token on that request. A capability
handed out *through* a link that the link's own revocation cannot
reach is a bug, not a feature; codex 5 found the one there was.

A share link is the **deck's** capability and not the person's who
minted it. It survives that person being offboarded (§6.1), and it is
turned off from the deck's share page by somebody who still has access.

The **present link** is the same capability with `?present=1`, opening
the fullscreen viewer directly.

An "edit link" does not exist; ruling 15 says an edit capability is a
sign-in that mints a guest grant, which waits on the home.

---

## 6. Identity and the instance

### 6.1 Lone shape

Sign-in is Docs' `internal/docs/auth.go` adopted whole: rastrillo/auth
magic links, `SLIDES_ALLOWED_ADDRESSES` gating `POST /signin` in front
of `Begin` and in `Authorize`, `SLIDES_ORIGIN`, `SLIDES_INSTANCE_KEY`,
`SLIDES_SMTP_*`, each a startup refusal outside `-env development`. The
same 303 for permitted and non-permitted addresses. This is the second
copy of that file, which is the extraction trigger: the plan includes a
task to propose `auth.go` to rastrillo or estilo as a package.

The person row is `users(id, email, name, colour)` created on first
sign-in. `sessions.Current(r).Subject` is the email, mapped to the row
before any scoping, per the rastrillo §5 warning.

**Offboarding (codex 6, ruling 16).** The allowlist is the *only*
membership control a lone instance has, so it has to work in both
directions: taking an address off `SLIDES_ALLOWED_ADDRESSES` must
remove the access that address already had, not merely stop it getting
more. It gated the minting of a session and nothing else — an existing
session resolved its user row without asking, rastrillo's
`RequireSession` asks nothing, and an agent needed only an unrevoked
token and an operator row — so a removed person kept everything until
their session expired, agents included.

So admission is **rechecked wherever a principal is resolved**, on
every request, and fails closed:

- `currentUser` (auth.go) checks the session's subject. Not admitted
  means: every session row for that address is deleted (real
  revocation, every device, not just this browser's cookie), the cookie
  is cleared, and the person is sent to `/signin?err=removed` with a
  sentence saying their access was removed rather than that sign-in
  failed. A non-page request gets a 403 instead, which is rauth's own
  split.
- The bearer middleware (agents.go) checks the **operator's** address.
  Not admitted means 401 — the same 401 a revoked token gets. An agent
  sees exactly what its operator sees, and this is the one path out of
  the instance that survives a browser being signed out.

The outstanding capabilities, decided:

| Capability | On offboarding | Why |
|---|---|---|
| Sessions (every device) | **revoked** | It is the person's own credential and nothing else depends on it. |
| Agent tokens | **refused, not revoked** | The row stays, so re-admitting the address restores the agent rather than making somebody mint a new token; the recheck is what makes the refusal current. |
| Live presentations they started | **ended** | A live session is a public URL that reads the deck out. Every projector, phone and follower gets the `ended` event at once. |
| Share links on their decks | **kept working** | A share link is the *deck's* capability (§5), the deck is the team's, and revoking the team's links because one member left is an outage the departure did not call for. Somebody with access turns them off from the share page. |
| Decks they own | **kept** | Ownership is not access. A deck with an owner who cannot sign in is still readable by the team rung and by grants; transferring ownership needs a screen and has none (§9). |

Re-adding the address restores everything except the sessions and the
talks, which are gone: signing in again is one email.

### 6.2 Actors

Every write records an **actor**: `person:<user id>` or
`agent:<agent id>`. Presentation sessions, share links, exports, agent
tokens and the deck's activity list all carry it. The Y.Doc update log
carries it too (a column on the updates table, not in the CRDT).

**One explicitly anonymous actor: `remote:<session sqid>`** (codex 15,
ruling 17). A presentation command (`POST /p/{session}/command`) is
authenticated by the remote token, which is a **transferable
capability** — that is the feature: it goes on a phone, the phone is
shown as a QR to a room, the phone may be somebody else's. The server
therefore cannot establish who is holding it, and recording the
session's creator on those writes was a claim about identity the app
could not support. So a remote command is recorded as
`remote:<session sqid>`, which is honest, with the **issuer** — the
session's own actor, the person or agent who started the talk — logged
beside it, so the trail still reaches somebody answerable.

This is the exception and it is named as one: ruling 7's "every write
names its real actor" holds, and this is what the real actor of a
transferable capability is. Nothing else in the app writes anonymously,
and a remote command touches only the presentation row — never the
document, whose log has no anonymous entries at all.

### 6.3 Deploy

One static binary, `CGO_ENABLED=0`, `linux-arm64`, `make release` with
the version stamped, `carlos deploy --app slides --account oficina`
with the build probe on `/api/version`. `carlos store create` for the
object store; `carlos domains attach slides.oficina.works`. Secrets
`SLIDES_INSTANCE_KEY` and `SLIDES_SMTP_PASS` via `carlos secrets`, the
rest via `carlos env`, mirroring Docs' set.

---

## 7. The drive surface

### 7.1 Principals

A signed-in person mints an **agent token** from Settings, naming the
agent (`claude`, `codex`); the app stores `agents(id, name, operator,
token_hash, created_at, revoked_at)` and shows the token once. The
agent's principal is `<name>~<operator email>`; every write it makes is
badged with a robot mark and the operator's name in the activity list,
following amadan's own convention. A revoked token fails closed. Tokens
are `Authorization: Bearer` on the API only; the API never reads a
session cookie and the HTML routes never read a bearer.

A token also fails closed on its **operator's** admission, rechecked on
every request (§6.1, codex 6): an operator taken off the allowlist
means 401, indistinguishable from a revoked token. An agent sees
exactly what its operator sees, and that includes seeing nothing.

### 7.2 Operations

`POST /api/v1/decks/{sqid}/ops` takes a JSON array of operations and
applies them in order inside one transaction against the Y.Doc through
the Rust core, producing one update, which is then broadcast exactly as
a browser's POST would be. Operations, each with the ids of §2:

- deck: `set_meta`
- slide: `add_slide` (after, layout, template), `remove_slide`,
  `move_slide`, `set_slide` (props), `set_notes` (text as Markdown
  subset, converted server-side), `duplicate_slide`
- block: `add_block` (slide, type, props, text, after or box),
  `remove_block`, `set_block` (props, box, z, region), `set_text`
  (Markdown subset), `move_block`, `set_phone` (the §2.5 map)
- asset: `register_asset` after a presigned upload obtained from
  `POST /api/v1/decks/{sqid}/uploads`

Reads: `GET /api/v1/decks` (list), `GET /api/v1/decks/{sqid}` (the JSON
view, with an `ETag` of the seq), `GET /api/v1/decks/{sqid}/slides/{id}.svg`
(a thumbnail, so an agent can look at what it made),
`GET /api/v1/decks/{sqid}/export.pdf`. `POST /api/v1/decks` creates.

The editor's inspector calls the same `ops` function in-process through
the JS side of yjs; the server's `ops` handler calls the Rust core. The
fixtures (§2.8) include one file per operation so both sides are held
to the same result.

### 7.3 MCP

An MCP server is a thin adapter over §7.2, deferred until a consumer
asks for it by name, memoria's rule. The ops are designed so each maps
to one tool with the same name.

---

## 8. The scriptless floor

With scripts off, a person can: sign in, list decks, create a deck
(title and theme, from a form), open a deck as a paged viewer (one slide
per page, next and previous links, notes below when the deck allows),
present from a share link as the same paged viewer, read the presenter
notes page, download a PDF, manage sharing, mint and revoke agent
tokens, and use the remote (each button is a form that POSTs and
redirects back). They cannot edit slide content; the editor is the one
surface that needs the bundle, Docs' ruling.

WCAG 2.2 AA with no exemptions, 24 by 24 targets, 320 px reflow (the
canvas stage may scroll inside its own region in the editor, the estilo
allowance), colour never alone, every visible string a `rastrillo.T`
key.

---

## 9. Deliberately not built yet

| Not built | Trigger |
|---|---|
| Sign-in through the home; guest grants; edit links | The home's identity layer (home spec §5.3, §9). |
| Charts from Sheets by S6 | Sheets' S6 server exists and a deck wants a live number. |
| Video, embeds, audio | A person asks and the platform has a media store policy. |
| Follow-the-presenter inside Meet | Meet v1 ships. |
| Native remote | The web remote has been used in anger for a month. |
| MCP | A consumer names it. |
| 4:3 and custom aspect | A person asks; the key already exists. |
| Cross-deck asset library, templates gallery beyond built-ins | Ten decks on one instance. |
| Comments on slides | Docs' comment thread lands in estilo. |
| Version history and named snapshots | Docs' snapshots design is adopted rather than reinvented. |

---

## 10. Slices

1. **Live at `slides.oficina.works`:** scaffold, sign-in, deck list,
   Rust core with the schema and JSON view, HTML renderer for both
   modes with themes, viewer, share links, presentation sessions with
   presenter view and phone remote, scriptless floor throughout, the
   drive API for create and ops, first deploy.
2. **Editor and collab:** the bundle, live-sync adoption, presence, the
   switcher, undo, asset upload.
3. **Export:** layout engine, SVG thumbnails, the PDF module, the export
   job.
4. **Extractions:** `auth.go` and the live-sync hub to shared modules;
   suite discussion outcomes adopted.

Slice 1 is what Paul asked to try first. It is a deck a person or an
agent can build through the API and present from a phone before the
editor exists, which is also the order that proves the model before the
most expensive part is built on it.

---

## 11. Testing

- Rust core: unit tests per operation; fixture replay against the JS
  generated updates (`fixtures/verify-rust.mjs` pattern from Docs).
- Renderer: golden HTML per template and per block type, both modes,
  both viewports; an axe pass in the browser drive.
- Presentation sessions: a Go test driving two subscribers and a remote
  command, asserting both see the slide event within the deadline.
- Share links: token stored hashed, revoked link 404s, live-session
  follow offered only while live.
- Drive API: every op has a fixture; a bearer from a revoked agent is
  refused; the actor column names the agent.
- Scriptless: the browser drive with JavaScript disabled walks sign-in,
  create, view, present, remote.
- Deploy: `/api/version` reports the stamped version on the live host
  before the deploy is called done.

---

## 12. Review record

Revision 1, with §2.6 and ruling 12 rewritten during slice 2, §4.2
and §4.3 amended on 2026-09-08 from what slice 3 shipped, and §2.2,
§2.3, §2.4, §2.5, §2.6 and §3.3 amended with rulings 18 to 20 the same
day from the Codex review's findings 3, 4, 7 and 10 (each paragraph
marked `codex N`): unreviewed.
The review pass is the first item after slice 1
ships, so that the reviewer has a running instance and not a document.
