oficina / docs Public

Design: Docs, a CARLOS document editor

Twelve settled decisions from the brainstorm with Paul, plus the rulings
that came out of coordinating with the Sheets and design-system sessions.

The load-bearing one is §5. Yjs on both sides and a real text layout
engine are both badly served by Go: y-crdt's reference implementation is
Rust, and Go's font stack reads metrics without shaping. So the CRDT and
the layout engine are one Rust crate compiled to WASM, running in the
browser and inside the Go binary through wazero. Not two engines agreeing
by golden vectors -- the same compiled instructions in both places. The
app around it stays Go and rastrillo.

Records four things verified against rastrillo source rather than assumed,
three of which came from a Fable review of the Sheets spec:

- scope.Owned is Where("user_id = ?") and cannot express a team-shared
  row. Applied to one it returns a short list rather than an error, so a
  document library looks empty instead of broken. Docs scopes over a
  grants join instead.
- passkey.Config.Origin derives the RPID from the app's own hostname, so
  an instance-per-team app enrolls one passkey per team. Filed jointly
  with Sheets; neither app specs sign-in until it is resolved. LegacyRPID
  on the same struct turns out to be the RP rename mechanism we had both
  designed independently.
- blobs.Sealed is mandatory, and its ContentType passes through as visible
  metadata, so it is set to octet-stream.
- The event log holds document content by construction, because version
  history needs old values and old values are prose. Named in the leak
  list rather than discovered later.

.gitignore adds .superpowers/ for the visual brainstorming companion.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011F5CivVm4CmL9ByfSSAaXA
Paul Campbell pushed by paul@keymail.dev 66e9ff62901358fe10b4f23086a8831d0d1da0ab parent 0a07f0a
2 files changed, +618 −0
  • .gitignore +1 −0
  • docs/superpowers/specs/2026-08-30-docs-design.md +617 −0
diff --git a/.gitignore b/.gitignore
index 22047d2..2c748e7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,7 @@ writing/
# Agent scratch.
.claude/.cc-writes/
+.superpowers/
# More sandbox placeholders, this time inside .claude/. Everything real in
# here (settings.json, skills/) is committed; these are character devices the
diff --git a/docs/superpowers/specs/2026-08-30-docs-design.md b/docs/superpowers/specs/2026-08-30-docs-design.md
new file mode 100644
index 0000000..f1534eb
--- /dev/null
+++ b/docs/superpowers/specs/2026-08-30-docs-design.md
@@ -0,0 +1,617 @@
+# 🤖 Docs — design
+
+Design, 2026-08-30. Direction approved by Paul in conversation; this is the
+written form for review.
+
+CARLOS Docs is a document editor: WYSIWYG, paginated, collaborative, with
+DOCX, PDF and Markdown in and out. One instance per team, documents shared
+inside it and with outside guests, on the CARLOS platform.
+
+It is built alongside [Sheets](../../../../sheets/docs/superpowers/specs/2026-08-30-sheets-design.md)
+and a shared suite home. Where a decision is already made over there, this
+spec consumes it rather than restating it.
+
+---
+
+## 0. The one rule
+
+**One engine, one truth.** The document's content and its layout are
+computed in exactly one place, and the screen, the PDF, the DOCX and the
+thumbnail are all renderings of that one computation. Every architectural
+decision below follows from refusing to have two things that can disagree.
+
+Sheets adopted the same rule for its formula engine and paid for it with a
+Go engine plus golden vectors against the browser. Docs pays for it
+differently — and, we think, better: see §5.
+
+---
+
+## 1. Settled decisions (don't relitigate casually)
+
+| # | Decision | Date |
+|---|---|---|
+| 1 | Instance boundary is a **team**. Sharing is membership; an outside guest is a guest member, never a copy elsewhere. | 08-30 |
+| 2 | **Sealed at rest.** Cells — here, content — encrypted in SQLite; the running instance holds the DEK from platform secrets, so the server can compute. | 08-30 |
+| 3 | **Yjs on both sides.** A real CRDT, not last-write-wins. | 08-30 |
+| 4 | **ProseMirror**, vendored as a prebuilt bundle. A published deviation. | 08-30 |
+| 5 | **Layout is computed in-process** and drives the screen, the PDF and the thumbnail alike. | 08-30 |
+| 6 | **Go + rastrillo for the app; Rust core compiled to WASM** for CRDT and layout, run in the browser and server-side via wazero. | 08-30 |
+| 7 | **DOCX: model a real feature set, degrade loudly.** Never silently mangle. | 08-30 |
+| 8 | First slice includes comments and suggestion mode, version history, offline editing, and the outline pane. | 08-30 |
+| 9 | **Canvas is light by default; dark canvas is a per-person preference.** Export always renders true colours. | 08-30 |
+| 10 | Chrome is a **short toolbar plus an inspector on demand**, not a dense toolbar and not an always-on inspector. | 08-30 |
+| 11 | The team's document list is **a list with real rendered page-one thumbnails**. | 08-30 |
+| 12 | The **home is a separate repo and spec**, drafted by the Sheets session; Docs is a declared consumer. | 08-30 |
+
+---
+
+## 2. Trust model, and the leak list
+
+Sealed at rest, in Seapointish's position. Document content is encrypted in
+SQLite under a DEK delivered by `carlos secrets`, so the Litestream-replicated
+bucket holds ciphertext. The running instance holds the DEK, which is what
+lets Go render a PDF, produce a thumbnail, and search.
+
+**Enforced, not asserted.** A test greps raw SQLite bytes and bucket objects
+for known plaintext from a fixture document and fails if it finds any. That
+test is the boundary; the prose above is a description of it.
+
+### 2.1 The leak list
+
+Published, because every CARLOS app publishes its softenings rather than
+claiming absolutism.
+
+1. **The running instance can read everything.** It holds the DEK. An
+ attacker with code execution on a live instance gets plaintext. This is
+ the deliberate trade that buys server-side export, search and thumbnails.
+2. **The event log holds document content by construction.** Version history
+ is unimplementable unless the log carries old values, and old values are
+ prose. The event log is therefore sealed on the same terms as the document
+ and named here rather than discovered later. *(Found by a Fable review of
+ the Sheets spec, 2026-08-30; the identical hole existed in both.)*
+3. **Blob metadata is visible.** `blobs.Sealed` seals bytes, but `ContentType`
+ passes through as visible metadata and the address is the ciphertext's
+ hash. See §11.4.
+4. **Document titles are plaintext in the instance**, because the list, search
+ and sharing UI all need them without a round trip through the DEK for every
+ row. Titles are content the author chose to be a label. They are never sent
+ to the home (§9).
+5. **Error text can quote content into platform-held logs** via `carlos logs`.
+ Handlers must never interpolate document content into an error. Enforced by
+ review, not by a test, which is the weakest item on this list.
+
+---
+
+## 3. System shape
+
+One Go binary per instance, built with `CGO_ENABLED=0`, cross-compiled to
+`linux/arm64`, speaking the CARLOS activation contract through
+`rastrillo.Run`. One SQLite file per instance, Litestream-replicated by the
+platform. Nothing hand-rolled from the carlos core.
+
+Inside that binary sits one more thing than a normal rastrillo app: a
+WebAssembly module (§5).
+
+```
+cmd/docs/ main: dispatch and env only
+internal/docs/ models, app, handlers, render — the rastrillo five-file shape
+internal/core/ the wazero host: loads the WASM module, marshals to and from it
+internal/docx/ OOXML read and write
+internal/pdf/ PDF emission from laid-out boxes
+core/ the Rust crate (CRDT + layout), compiled to WASM
+static/ vendored ProseMirror bundle, editor modules, tokens.css
+```
+
+---
+
+## 4. Data model
+
+A document is **not** a row of prose. It is a Yjs document, persisted as an
+ordered log of updates plus periodic snapshots.
+
+```
+documents id, sqid, title, created_by, created_at, updated_at,
+ page_setup (sealed), deleted_at
+doc_updates doc_id, seq, update (sealed), actor_id, created_at
+doc_snapshots doc_id, seq, state (sealed), created_at
+versions doc_id, seq, name, actor_id, created_at -- named versions
+grants doc_id | NULL, subject, role, created_by, created_at
+memberships user_id, role, created_at
+comments id, doc_id, thread_id, anchor (sealed), body (sealed),
+ actor_id, resolved_at, orphaned_at, created_at
+```
+
+`doc_updates` is append-only and is both the sync log and the version history
+— one structure, not two. Snapshots exist so opening a long document does not
+mean replaying ten thousand updates; they are an optimisation and never the
+only copy.
+
+### 4.1 Scoping — and a trap
+
+**`scope.Owned` must not be used for documents.** Verified against source:
+`scope/scope.go:19` is literally `g.Where("user_id = ?", owner)`, and
+`scope.OwnedBy` at `:27` is the same thing with a configurable column.
+Neither expresses "rows this team can see, plus rows shared with me, minus
+rows I have been removed from".
+
+Applied to a team-shared table, `Owned` returns only the creator's rows. It
+fails as a **short list, not an error** — a document library that looks empty
+rather than broken. That is the worst available failure shape, on the helper
+an author would naturally reach for.
+
+Docs therefore implements its own scope over a `grants` join, preserving the
+404-not-403 contract (a document you cannot see does not exist, rather than
+being forbidden). It is one function, it is tested directly, and no handler
+queries documents any other way.
+
+---
+
+## 5. The Rust core, and why it exists
+
+Two of this app's hard parts are badly served by Go, and they are the two the
+product is judged on.
+
+**The CRDT.** The reference non-JS implementation of Yjs is `y-crdt`, and it
+is Rust. The Go ports are young, third-party, and are not what other
+implementations are tested against.
+
+**Text layout.** Rust has `rustybuzz`, `swash`, `parley` and `cosmic-text` —
+a production typography stack. Go's `x/image/font/sfnt` reads font metrics and
+cannot shape complex scripts at all.
+
+So the CRDT and the layout engine are **one Rust crate compiled to
+WebAssembly**, and that same compiled artifact runs in two places:
+
+- **In the browser**, as the editor's document model and its layout pass.
+- **In the Go binary**, through **wazero** — a WebAssembly runtime written in
+ pure Go with no C dependency, so the static binary stays static and cgo-free.
+
+This is what §0 buys. Sheets has one engine in Go and one in the browser, kept
+in step by golden vectors; Docs has one compiled engine running in both
+places. Not two implementations that agree — the same instructions.
+
+**What stays in Go:** everything else. Accounts, sessions, sharing, storage,
+HTTP, DOCX and PDF serialisation, mail. The Rust boundary is deliberately
+narrow: bytes in, laid-out boxes or CRDT state out.
+
+**The residual risk** is that three languages in one repo is more machinery
+than a solo maintainer would choose without a reason, and cleverness is itself
+a risk. The mitigating factor is that the boundary is small and testable in
+isolation: the crate has its own test suite, and the host has a conformance
+test that runs the same fixtures through wazero and through the browser.
+
+---
+
+## 6. Pages, and where zero-JS stops
+
+### 6.1 Pagination
+
+The layout engine computes line breaks and page breaks. The editor renders
+discrete pages with real margins; the PDF is emitted from the same box tree.
+"What you see is what you print" is a property of the architecture rather than
+a goal to keep chasing.
+
+Editing is paginated by default. A pageless mode is **not** in the first slice
+(§14).
+
+### 6.2 The scriptless path
+
+Server-rendered markup is the default, not the fallback. Stating the line
+precisely, because vagueness here is how the rule gets quietly abandoned:
+
+| Surface | Without script |
+|---|---|
+| Reading a document | Full fidelity — the server renders the same layout to HTML |
+| The document list, search, sharing, settings | Fully working form posts |
+| Reading, posting and resolving comments | Working form posts |
+| **Editing prose** | **Not available.** A CRDT editor cannot be scriptless, and pretending otherwise would ship a second editor that disagrees with the first |
+| Live collaboration, presence, suggestion mode | Not available, by design |
+
+The rule the framework actually asks for is that no *form* requires script.
+That holds throughout. Editing a document is not a form.
+
+### 6.3 Chrome
+
+**A short toolbar plus an inspector on demand.** Roughly eight controls in
+the toolbar — paragraph style, weight and emphasis, lists, link, alignment —
+grouped with dividers rather than run together. The inspector opens on the
+right when depth is needed (page setup, styles, headers and footers,
+footnotes, image placement) and closes again, so the page has full width by
+default.
+
+Rejected: the dense undifferentiated toolbar (Paul's own jank ruling for
+Sheets), the always-on inspector (costs page width permanently), and the
+floating-only model (nothing discoverable, and page setup has nowhere to
+live).
+
+The floating selection toolbar still exists as pure enhancement — everything
+on it is also reachable from the inspector.
+
+### 6.4 The document list
+
+A list, newest first, grouped by recency, with **a real rendered thumbnail of
+page one** on each row. Not a file-type icon.
+
+The thumbnail is a render we already have: the layout engine lays out page one
+for the editor and for PDF regardless. Competitors ship a generic icon because
+thumbnailing is a separate service for them. It also fixes the real weakness
+of a list — people remember what a document looked like long after they have
+forgotten its name.
+
+Cut, per Paul's four jank rulings: the template carousel, "Owned by anyone"
+and "Last opened by me" filter chrome, the grid/list/AZ/folder toggles, and
+all commercial and platform chrome.
+
+---
+
+## 7. Collaboration
+
+### 7.1 Merge
+
+Yjs. Character-level convergence, so two people in one paragraph both keep
+their words. Last-write-wins was considered and rejected: same-cell collisions
+in a spreadsheet are rare, same-paragraph collisions in a document are the
+normal case.
+
+Updates arrive over a WebSocket, fall back to SSE, and persist to
+`doc_updates`. The server is not a relay — it holds the same CRDT and can
+compute the document independently, which is what makes export, search and
+thumbnails possible without a connected client.
+
+### 7.2 Offline
+
+The document persists to IndexedDB and syncs on reconnect. This is the reason
+to pay for a CRDT rather than operational transform, and having paid, we take
+it.
+
+Two consequences, both stated rather than discovered:
+
+- It moves conflict resolution from seconds to days, which stresses the merge
+ far harder than live editing does.
+- **A local copy sits outside the instance's encryption boundary.** The
+ browser holds plaintext at rest on the user's own device. It belongs on the
+ leak list in §2.1 and is stated here instead because the mitigation is
+ different in kind: it is the user's own device, and the honest control is a
+ "forget this device" action that clears it, not a server-side seal.
+
+### 7.3 Presence
+
+Presence renders nothing without script, rather than degrading to a stale
+list. A stale presence indicator asserts that somebody is there, which is a
+lie rather than a degradation.
+
+**A view link gets no cursor and no name in the presence stack.** This falls
+out of the attribution rule (§8.2) rather than being a special case: presence
+is a write to shared awareness state, and anything that writes is attributed.
+
+### 7.4 Comments
+
+Threads anchor to a **Yjs relative position**, not a character offset, so an
+anchor survives concurrent edits and re-flow.
+
+When the anchored text is deleted, the thread is **orphaned, not destroyed**:
+it moves to a detached-comments list carrying its last known location and its
+content, with a re-attach affordance. A comment is somebody's words about
+something, and losing the anchor is not permission to lose the words. *(Sheets
+reaches the same obligation from the opposite direction — a stable anchor
+whose content is destroyed — so the primitive is designed for both.)*
+
+**Suggestion mode** lets a thread carry a proposed change that applies as a
+CRDT transaction on accept. This is what Word users mean by track changes, and
+it is the feature most likely to decide whether somebody can switch.
+
+---
+
+## 8. Identity and sharing
+
+### 8.1 Sign-in
+
+rastrillo's `auth` plugin: magic link, auto-upgrading to keymail when the
+address resolves to a claimed inbox.
+
+**Filed gap — the passkey RP contradiction.** `passkey.Config.Origin` derives
+the WebAuthn RPID from *the app's own hostname* (`passkey/passkey.go:161`,
+`RPID: u.Hostname()`). So wiring `Config.SecondFactor = passkey.Handlers.Gate`
+on a per-team instance enrolls a credential whose relying party is that
+instance — and a person in three teams enrolls three passkeys, which is
+precisely the problem `blueprint.md` says to solve on day one. Central 2FA at
+the home's credential origin cannot use rastrillo's `Gate` at all, because the
+assertion ceremony must run on the RP origin and rastrillo has no cross-origin
+hand-off.
+
+This is a framework gap with two consumers. **It is filed once, jointly with
+Sheets, and neither app specs sign-in until it is resolved.**
+
+Usefully, the same struct already carries the fix for a related problem:
+`LegacyRPID` — "a relying party this instance used to be, accepted for
+assertions (never registration) so a hostname move doesn't strand enrolled
+keys". That is exactly the crossover mechanism the RP rename drill needs
+(§9.3), already built.
+
+### 8.2 Sharing
+
+Three kinds of grant, and one rule that orders them.
+
+- **Team members** see team documents.
+- **Outside guests** are granted a specific document and become guest members
+ of this instance. Data never crosses the boundary.
+- **Link shares** are a per-document capability, viewer or commenter, never
+ editor.
+
+**The rule: anything that writes is attributed, and attribution is what mints
+membership.** A view link mints nothing — the instance serves read-only
+against a capability token, with no member and no directory entry, which is
+correctly nothing. The moment somebody wants to comment, they sign in, which
+mints a guest member and produces a directory entry then. Every version row
+and every comment carries an actor, and an anonymous writer would put
+unattributed rows into version history.
+
+"Sharing is membership" forbids *data crossing the instance boundary*. A link
+does not move data, so it forbids nothing here. This sentence exists because
+the ruling reads like a prohibition and somebody will otherwise conclude it
+was one.
+
+---
+
+## 9. The home, as a dependency
+
+The home is a separate repo and spec, drafted by the Sheets session with Docs
+as a declared consumer. Recorded here is only what Docs depends on.
+
+### 9.1 The contract
+
+Four calls: sign-in and session minting; directory upsert/revoke carrying
+`HMAC(pepper, lowercased email) → (app, team sqid, host)`; directory list for
+a signed-in person; provisioning enqueue.
+
+**Docs never sends a document title to the home.** The home learns that a
+person has access to a team in an app, and nothing about what is in it.
+
+### 9.2 Split origins
+
+A credential origin serving sign-in and the WebAuthn ceremony and no
+account-authored string of any kind; a separate directory origin that renders
+team names and holds no credentials. An XSS on the directory cannot reach the
+RP because it is not the same origin.
+
+### 9.3 The RP rename drill
+
+The RP is provisionally a `carloku.com` subdomain, which *schedules* the
+rename drill rather than avoiding it. `LegacyRPID` (§8.1) is the mechanism.
+
+**Size the crossover window by the guest tail, not by active members.** Docs
+makes this sharper than Sheets does: our third-party guests signed in once,
+months ago, to comment on one document. They have no ongoing relationship, no
+reason to be paying attention, and are invisible to any plan that assumes
+people re-authenticate when nudged. The window is measured in months.
+
+### 9.4 Corrections filed as a consumer
+
+Raised against the Sheets draft on 2026-08-30 and recorded here because Docs
+builds against them either way:
+
+1. **A failed directory upsert must never fail a share.** The home is glue;
+ losing it costs a convenience, never content. Docs writes grants to an
+ outbox and retries. The directory is a hint, never an authority, and every
+ instance re-checks on every request.
+2. **Re-keying has no procedure**, and two ordinary events trigger it — a
+ person changing their email, and pepper rotation.
+3. **A guest grant discloses the granting team's name to the guest.** Correct
+ behaviour, matching Google, but it should be a recorded disclosure with the
+ share UI saying so rather than an emergent property.
+4. **Directory rows need a member/guest discriminator**, or a guest's row
+ implies an org membership they do not have. Docs will have far more guests
+ than Sheets.
+
+### 9.5 The canvas preference
+
+Per person, never per document (§10.2). It therefore lives with the home as
+suite-level account state, not per instance — otherwise a person sets it once
+per team, which is the "three instances must not mean three credentials"
+problem wearing a different hat.
+
+**It must resolve before first paint**, or the canvas flashes on every load.
+That is a rendering-order constraint on the home's session response, not a
+colour question: in practice a cookie or an inline blocking read, never a
+fetch.
+
+---
+
+## 10. Design system
+
+Everything around the document is pure rastrillo design system. The document
+canvas itself diverges, because a page has genuinely different requirements
+from an admin screen, and Paul's steer is explicit: less Rastrillo, more Docs
+and Pages and Word, aiming at simplicity and clarity.
+
+**The thing to watch is drift in the chrome.** The moment sharing or settings
+grows Docs-specific styling, the split has failed quietly. The mechanical
+test: if you find yourself copying a partial and editing three lines, propose
+it upstream instead.
+
+### 10.1 Tokens and themes
+
+Vendor tokens through `ui.TokensCSS()` and `ui.ThemeCSS(name)` — the bytes,
+not a hand copy. Markup uses the bare `rst-*` attribute grammar
+(`<div rst-inspector>`), never the `class="rst-*"` vocabulary, which is
+mid-migration; writing classes now means writing the losing spelling.
+
+`tokens.css` is frozen into `static/` at scaffold time while partials upgrade
+with the module. **Re-copy it deliberately on every rastrillo upgrade** until
+`rastrillo doctor` lands and makes the check mechanical.
+
+The page header rule is `--rst-header-rule`; the accent rake line is retired.
+
+### 10.2 The canvas, light and dark
+
+Light by default. A dark canvas is a per-person preference, because a white
+rectangle on a dark ground in a dark room is an accessibility complaint rather
+than a matter of taste.
+
+The canvas opts out of the theme's scheme: `color-scheme: light` (or `dark`,
+per the preference) on the canvas element, painted from literals rather than
+scheme-dependent surface tokens. The surrounding theme is unaffected.
+
+**The export renderer cannot reach the preference.** Not "must not" — it is
+not passed to it, so print and export always render true colours by
+construction. A rule enforced by what a component can reach beats a rule
+enforced by remembering.
+
+### 10.3 Colour
+
+Docs is a declared consumer of `ui`'s colour engine, recorded upstream in the
+design-system spec (`fe26c68`).
+
+`Pair(hue, chroma, background) → {fill, on-fill}` takes an explicit background
+colour. Docs calls it against **four** backgrounds — paper white, dark paper,
+and the theme surfaces — because the same stored highlight is read on white
+paper by one person and dark paper by another, in the same document, at the
+same moment. Paper white and dark paper are Docs-defined literals supplied
+with the intent set.
+
+*(The original signature took a scheme enum. Docs' canvas case is what
+established it could not work: two of those four backgrounds live inside the
+same scheme.)*
+
+Author colours use the allocation entry point: hash the opaque key for the
+preferred hue, then deterministic open addressing — probe `(hash(key) + i) mod
+N` over a **lexicographic ordering of the key set**, taking the first hue
+neither taken in this pass nor in `avoid`. The ordering matters: allocation
+must never depend on arrival order, or two clients render the same comment in
+different colours and both trust it. `avoid` is applied **inside** the probe,
+never as a pre-filter — filtering first changes the modulus and shifts every
+allocation.
+
+The opaque key is the person's identity where they have one, and their
+membership row where they do not, which is the guest case (§8.2). Past the
+set's capacity the function reports that separation is no longer guaranteed;
+colour degrades to decoration and the name carries identity, which it must
+anyway under §10.4.
+
+### 10.4 Accessibility
+
+WCAG 2.2 AA, no exemptions: 24×24 targets, 320px reflow, and colour never
+carries meaning alone — which binds the presence stack directly, since a
+collaborator cannot be identified by their colour alone.
+
+Every user-visible string is a catalog key. Twelve base locales; a hardcoded
+English string is a bug.
+
+### 10.5 Primitives owed upstream
+
+Split with Sheets so the library gets one proposal rather than three variants
+to reconcile. Docs owns **inspector panel**, **presence stack** and **comment
+thread**; Sheets owns toolbar groups. Each written in the attribute grammar,
+every string a prose key with the eleven non-English locales machine-drafted,
+each carrying a sentence saying what it is *not*, and each stating plainly
+which parts are script-required (§6.2).
+
+---
+
+## 11. DOCX, PDF and Markdown
+
+### 11.1 The fidelity rule
+
+Model a real feature set and **degrade loudly**. The schema covers headings
+and named styles, lists, tables, images, links, footnotes, headers and
+footers, page setup, comments and tracked changes. Import maps all of it and
+shows a report naming exactly what it could not bring in and where.
+
+A Word file with SmartArt, equations or floating text boxes arrives
+diminished — and the user is told precisely how. Silently mangling is the
+thing being rejected.
+
+Rejected on the record: shelling out to pandoc (a second runtime, breaks the
+static binary) and conversion APIs (ships customer documents to a third party,
+contradicting §2 outright).
+
+### 11.2 Export
+
+DOCX is written as clean OOXML for our model. PDF is emitted from the same box
+tree the screen renders. Markdown is lossless within its subset.
+
+### 11.3 Thumbnails
+
+Page one, rendered by the layout engine, sealed, stored as a blob, regenerated
+on save. Cheap because the render already exists (§6.4).
+
+### 11.4 Blobs
+
+**All blob storage goes through `blobs.Sealed`.** `blobs/blobs.go:24` is
+explicit that E2EE apps wrap any backend in it; skipping it puts whole
+plaintext documents in the bucket, and it is expensive to retrofit.
+
+Two consequences from its doc comment, both accepted: the address becomes the
+ciphertext's hash, so dedup is per key and `Ref.Size` is the sealed size; and
+**`ContentType` passes through as visible metadata**, so it is set to
+`application/octet-stream`. A truthful content type on a sealed import
+announces "this is a Word document" in a bucket that is otherwise ciphertext.
+
+---
+
+## 12. Testing gates
+
+- `go build ./...`, `go vet ./...`, `go test ./... -count=1`, `gofmt -l .`
+ clean, `CGO_ENABLED=0` throughout.
+- `cargo test` for the core crate, and `cargo clippy` clean.
+- **Conformance:** the same fixture documents run through the WASM module in
+ wazero and in a headless browser, and must produce byte-identical layout
+ output. This is the test that makes §0 true rather than aspirational.
+- **Wire compatibility:** our Yjs updates are applied by upstream `yjs` and
+ vice versa, against checked-in fixtures generated by the JS side.
+- **Plaintext grep:** raw SQLite bytes and bucket objects, for fixture
+ content. Covers documents, the event log, comments and blobs.
+- **Scope:** a direct test that a document not granted to a user 404s, for
+ every read and write path.
+- **Round-trip:** DOCX in, DOCX out, reopened in a real Word file comparison
+ against a checked-in expectation.
+- 300-line cap per JS module, one coordinator exempted at a stated higher cap.
+
+---
+
+## 13. Sequencing
+
+1. The Rust core: Yjs wire compatibility, then layout, with the conformance
+ harness from the first commit.
+2. The wazero host and the Go app skeleton on rastrillo; documents list,
+ create, open, read-only server render.
+3. The editor: ProseMirror bound to the core, toolbar and inspector.
+4. Collaboration: updates, presence, offline.
+5. Sharing: grants, guests, link shares, the home outbox.
+6. Import and export: Markdown, then DOCX, then PDF.
+7. Comments and suggestion mode; version history; the outline pane.
+8. Thumbnails.
+
+Comments and suggestion mode is the item most likely to slip, being the
+largest of the four additions. It is sequenced late deliberately so that
+slipping it costs nothing already built.
+
+---
+
+## 14. Deliberately not built yet
+
+| Not built | Trigger that brings it in |
+|---|---|
+| Pageless / infinite editing mode | A user asks for it, or long-document editing proves painful in page view |
+| Real-time embedded Sheets ranges | Both apps stable, and the seam between two collaboration models designed deliberately |
+| Templates | Somebody has made the same document three times |
+| Equations, SmartArt, text boxes | Import reports show them appearing in real documents |
+| Pegamento vault for the directory | The vault facet lands with a real recovery story; then §9 names move into a sealed blob |
+| Mobile-native editing | The web editor is good enough that its absence is the complaint |
+
+---
+
+## 15. Open
+
+1. **The passkey RP gap (§8.1)** — filed jointly with Sheets, blocking both
+ sign-in designs.
+2. **Namespace** — this repo is `carlos/docs`; the suite is intended for
+ `oficina`. Nothing exists at `oficina` yet and no grant has been made.
+ Cheap to move while history is two commits.
+3. **Home re-keying (§9.4 item 2)** — Sheets owns the answer; Docs is blocked
+ on it only for email changes.
+4. **`.amadan/ci.d/10-gate`** — arrives with the first code branch, where it
+ has something to gate.
+5. **The three-language cost.** Revisit after the core is real: if the wazero
+ boundary is costing more than golden vectors would have, the honest move is
+ to say so rather than defend the design.