Design r2: fix what the Fable review holed
The review returned "not ready to become an implementation plan" with seventeen defects. Three of them holed the centre, and all three were the kind that pass every check the spec itself had defined. r1 claimed "one engine, one truth" and then chose ProseMirror, which is contentEditable, which means the browser breaks lines. Two layout authorities, and the conformance gate compared wazero to a browser running the same module -- so it would have gone green while the product was wrong. Corrected: the engine owns page geometry, the browser owns lines inside it, using the same self-hosted fonts. Divergence is confined to the line level and the spec no longer claims it is zero. r1 also claimed the same compiled artifact ran both sides. It was not available: ywasm is wasm-bindgen and will not instantiate under wazero, so one artifact would have meant a hand-rolled C ABI and giving up y-prosemirror, y-indexeddb and y-websocket -- which is to say giving up the implementation path for offline and presence, in exchange for a purity being congratulated in the same paragraph. Corrected: JS yjs in the browser, Rust y-crdt on the server, fixtures as the contract. Which is golden vectors, the thing r1 said it had transcended. And the word "font" did not appear in r1 at all. Now §11.1: a curated self-hosted set, with Carlito and Caladea as metric-compatible stand-ins for Calibri and Cambria so imported Word documents paginate the way Word paginated them. Also: doc_updates is bounded now (coalescing, snapshot cadence, compaction horizon) -- and cutting the version timeline is what makes that possible, since named versions are a finite set of points to preserve where a browsable timeline is not. Hibernation versus held WebSockets addressed. Page breaks are decorations, never nodes, which keeps page geometry out of the CRDT entirely. §9 re-synced against the real Sheets contract, which is five calls and hashes the address home-side. §8.1's three contradictory sign-in positions collapsed to one. Full-text search cut, because an FTS5 index would fail our own plaintext gate. Scope cut so decision 8 stops planning its own violation: suggestion mode, footnotes with tracked-changes import, and the version timeline. ProseMirror re-examined against Lexical rather than kept by inertia, and kept: schema enforced on every edit and preserved across remote updates by y-prosemirror, the mature Yjs binding, and decorations -- which §6.1 now depends on.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011F5CivVm4CmL9ByfSSAaXA
1 file changed,
+520
−437
docs/superpowers/specs/2026-08-30-docs-design.md+520 −437
diff --git a/docs/superpowers/specs/2026-08-30-docs-design.md b/docs/superpowers/specs/2026-08-30-docs-design.md| index e1f122e..b12eba2 100644 |
| --- a/docs/superpowers/specs/2026-08-30-docs-design.md |
| +++ b/docs/superpowers/specs/2026-08-30-docs-design.md |
| @@ -3,618 +3,701 @@ |
| Design, 2026-08-30. Direction approved by Paul in conversation; this is the |
| written form for review. |
| +**Revision 2, after a Fable review of revision 1 returned "not ready to |
| +become an implementation plan" with seventeen defects.** Three of them holed |
| +the centre of the design. What follows is the corrected version, and the |
| +sections that changed say what they used to claim and why it was wrong — |
| +because a spec that quietly fixes its own errors teaches the next reader |
| +nothing. |
| + |
| 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. |
| +Built alongside [Sheets](https://amadan.net/oficina/sheets) 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. |
| +## 0. The one rule, stated correctly |
| + |
| +Revision 1 said: *"the document's content and its layout are computed in |
| +exactly one place."* **That was false and unachievable**, because the editor |
| +is ProseMirror, ProseMirror is contentEditable, and contentEditable means |
| +the *browser* breaks lines. The rule and the editor choice contradicted each |
| +other, and the conformance gate could not have caught it. |
| + |
| +The corrected rule, in two halves, each true: |
| + |
| +**Layout: the engine owns page geometry; the browser owns line breaking |
| +inside it.** The engine decides page size, margins, and where blocks break |
| +across pages. The browser lays out lines within that geometry, using the |
| +same self-hosted fonts the engine measures with (§11.1). Divergence is |
| +confined to the line level and is small when fonts match; it is not zero, |
| +and this spec does not claim it is. |
| + |
| +**The CRDT: one wire format, two implementations, fixtures as the |
| +contract.** The browser runs JS `yjs`; the server runs Rust `y-crdt`. They |
| +agree because checked-in fixtures generated by the JS side must be |
| +reproduced by the Rust side, which is the golden-vector discipline CARLOS |
| +already mandates for a protocol living in two languages. |
| + |
| +Revision 1 claimed "the same compiled artifact both sides" and treated that |
| +as strictly better than golden vectors. It was not available: `ywasm` is |
| +built with wasm-bindgen and will not instantiate under wazero, so one |
| +artifact for both hosts would have meant a hand-rolled C ABI and **giving up |
| +`y-prosemirror`, `y-indexeddb`, `y-websocket` and the awareness protocol** — |
| +which is to say giving up the implementation path for offline and presence, |
| +in exchange for a purity the spec was congratulating itself on. |
| --- |
| -## 1. Settled decisions (don't relitigate casually) |
| +## 1. Settled decisions |
| | # | 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 | |
| +| 1 | Instance boundary is a **team**. Sharing is membership; an outside guest is a guest member. | 08-30 | |
| +| 2 | **Sealed at rest**; the running instance holds the DEK, so the server can compute. | 08-30 | |
| +| 3 | **Yjs.** JS `yjs` in the browser, Rust `y-crdt` on the server, fixtures as the contract. | 08-30 r2 | |
| +| 4 | **ProseMirror**, vendored as a prebuilt bundle. Re-examined against Lexical after review and kept — see §6.5. | 08-30 | |
| +| 5 | **Engine owns page geometry; the browser owns lines.** | 08-30 r2 | |
| +| 6 | **Go + rastrillo for the app; one Rust crate** (layout + server-side CRDT) via wazero. | 08-30 r2 | |
| +| 7 | **DOCX: model a real feature set, degrade loudly.** | 08-30 | |
| +| 8 | First slice: comments, version capture and restore, offline, outline pane. | 08-30 r2 | |
| +| 9 | **Canvas light by default; dark is a per-person preference.** Export always renders true colours. | 08-30 | |
| +| 10 | Chrome is a **short toolbar plus an inspector on demand**. | 08-30 | |
| +| 11 | The document list is **a list with real rendered page-one thumbnails**. | 08-30 | |
| +| 12 | The **home is a separate repo and spec**; Docs is a declared consumer. | 08-30 | |
| +| 13 | **Curated font set**, metric-compatible substitutes, documented substitution. | 08-30 r2 | |
| +| 14 | **Page breaks are decorations, never nodes.** | 08-30 r2 | |
| +| 15 | **Full-text search is not in the first slice.** Title and metadata search only. | 08-30 r2 | |
| + |
| +Decision 8 changed. Revision 1 put suggestion mode in the first slice and |
| +then admitted in its own sequencing that it was the item most likely to |
| +slip — a spec planning its own violation. Suggestion mode, footnotes with |
| +tracked-changes import, and the version-history timeline are now in §14. |
| --- |
| ## 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 |
| +Sealed at rest, in Seapointish's position. 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. |
| +lets Go render a PDF, produce a thumbnail, and export. |
| **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. |
| +test is the boundary; the prose 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. |
| +1. **The running instance can read everything.** It holds the DEK. This is |
| + the deliberate trade that buys server-side export 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. Sealed on the same terms as the document. |
| +3. **Blob metadata is visible.** `ContentType` passes through and the |
| + address is the ciphertext's hash (§11.5). |
| +4. **Document titles are plaintext in the instance**, because the list, |
| + search and sharing UI need them without a per-row DEK round trip. |
| + Titles 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 makes it the weakest item here. |
| +6. **Offline puts plaintext on the user's own device**, in IndexedDB (§7.2). |
| + The mitigation differs in kind from everything above: it is the user's |
| + machine, and the control is a "forget this device" action that clears it, |
| + not a server-side seal. |
| --- |
| ## 3. System shape |
| -One Go binary per instance, built with `CGO_ENABLED=0`, cross-compiled to |
| +One Go binary per instance, `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). |
| +platform. |
| ``` |
| 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/docs/ models, app, handlers, render — the rastrillo shape |
| +internal/core/ the wazero host: module lifecycle, marshalling (§5.2) |
| 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 |
| +internal/pdf/ PDF emission, font subsetting and embedding |
| +internal/raster/ thumbnail rasterization (§11.4) |
| +core/ the Rust crate: layout + y-crdt, compiled to WASM |
| +static/ vendored ProseMirror + yjs bundles, editor modules, fonts |
| ``` |
| --- |
| ## 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 |
| +versions doc_id, seq, name, actor_id, created_at -- named, pinned |
| 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 The log is bounded — a correction |
| -### 4.1 Scoping — and a trap |
| +Revision 1 made `doc_updates` "both the sync log and the version history — |
| +one structure, not two", and presented that as elegant. **It forbids |
| +compaction forever**: every keystroke-granularity update becomes a sealed |
| +row, retained for the life of the document, with per-row AES-GCM overhead on |
| +payloads of a few bytes, amplifying the WAL stream the platform replicates. |
| -**`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". |
| +Three mechanisms, all required: |
| -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. |
| +- **Coalescing before persistence.** Updates from one actor within a short |
| + window merge into one row before it is written — Yjs's standard |
| + capture-timeout behaviour, applied at the storage boundary rather than |
| + only in the undo manager. |
| +- **Snapshot cadence.** A snapshot every N updates or M bytes, whichever |
| + first, so opening a long document never replays the whole log. |
| +- **A compaction horizon.** Beyond it, updates are collapsed into the |
| + preceding snapshot and dropped. |
| ---- |
| +**Cutting the version timeline (§14) is what makes compaction tractable.** |
| +With a browsable timeline, every historical point must remain reachable and |
| +the log can never be collapsed. With named versions only, the set of points |
| +that must survive is finite, small, and explicitly chosen by a person — |
| +named versions pin their snapshot and are never compacted away. The scope |
| +cut and the retention design are the same decision. |
| -## 5. The Rust core, and why it exists |
| +`deleted_at` is a soft delete; a purge verb hard-deletes a document and its |
| +log, and is what makes an erasure request answerable. |
| -Two of this app's hard parts are badly served by Go, and they are the two the |
| -product is judged on. |
| +### 4.2 Scoping — and a trap |
| -**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. |
| +**`scope.Owned` must not be used for documents.** Verified: `scope/scope.go` |
| +declares `func Owned` at `:19` with the body `g.Where("user_id = ?", owner)` |
| +at `:20`; `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." |
| -**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. |
| +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, which is the worst available failure shape, on the helper |
| +an author would naturally reach for. |
| -So the CRDT and the layout engine are **one Rust crate compiled to |
| -WebAssembly**, and that same compiled artifact runs in two places: |
| +Docs implements its own scope over a `grants` join, preserving the |
| +404-not-403 contract. One function, tested directly, and no handler queries |
| +documents any other way. |
| -- **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. |
| +### 4.3 Rejected: `rastrillo/eventlog` |
| -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. |
| +Recorded because the doctrine requires the rejected alternative to be named, |
| +and revision 1 omitted it. `eventlog` carries JSON payloads and a Lamport |
| +merge order. Yjs updates are opaque binary and carry their own ordering, so |
| +the payload shape fights it and the merge order is redundant. `doc_updates` |
| +is a plain append-only table instead. |
| -**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. |
| +## 5. The Rust core |
| + |
| +Two hard parts are badly served by Go. **The CRDT**: the reference non-JS |
| +implementation of Yjs is `y-crdt`, and it is Rust; the Go ports are young and |
| +are not what other implementations are tested against. **Text layout**: Rust |
| +has `rustybuzz`, `swash` and `parley`; Go's `x/image/font/sfnt` reads metrics |
| +and cannot shape complex scripts. |
| + |
| +So one Rust crate, compiled to WASM, run server-side through **wazero** — a |
| +WebAssembly runtime in pure Go with no C dependency, so the binary stays |
| +static and cgo-free. It does two jobs: apply and query the CRDT, and lay out |
| +documents. |
| + |
| +The browser does **not** run this crate. It runs JS `yjs` with |
| +`y-prosemirror` and `y-indexeddb`, and its layout duty is line breaking |
| +inside geometry the crate computed (§6.1). This is the revision-2 correction; |
| +see §0. |
| + |
| +### 5.1 What the server actually does with it |
| + |
| +Apply incoming updates, produce snapshots, and lay out documents for export |
| +and thumbnails. **The server never lays out per keystroke** — that is the |
| +browser's job — so wazero's throughput is not on the hot path. Its duty |
| +cycle is bursty and coarse. |
| + |
| +### 5.2 Lifecycle, which revision 1 left to chance |
| + |
| +- **Traps poison memory.** A Rust panic compiled to WASM traps; the module |
| + instance's linear memory must then be considered unusable. The host |
| + discards that instance and rebuilds the document from its latest snapshot |
| + plus subsequent updates. The request fails cleanly; the document does not. |
| +- **Instances are not concurrency-safe.** One module instance per open |
| + document, mutex-guarded, held in a pool. |
| +- **Linear memory grows and never shrinks.** One large layout permanently |
| + inflates its instance. The pool therefore has a **total memory budget |
| + sized for a t4g.nano (512MB)** and evicts least-recently-used instances, |
| + which is cheap because an evicted document rebuilds from its snapshot. |
| +- **Layout is incremental.** The engine reflows from the first dirty block |
| + and stops when page geometry reconverges, returning a box-tree delta |
| + rather than the whole tree. Without this a keystroke on page 3 of 120 |
| + relays out everything and marshals the result across the boundary. |
| --- |
| -## 6. Pages, and where zero-JS stops |
| +## 6. Pages, the editor, and where zero-JS stops |
| -### 6.1 Pagination |
| +### 6.1 How pagination actually works |
| -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. |
| +The engine computes page geometry and the block-level break positions. The |
| +browser renders those breaks and lays out lines within them. |
| -Editing is paginated by default. A pageless mode is **not** in the first slice |
| -(§14). |
| +**Page breaks are decorations, never nodes** (decision 14). Inserting spacer |
| +nodes into the document is the common approach and it is a trap: it causes |
| +layout reflows on every keystroke and makes the page count flicker. Using |
| +ProseMirror's `DecorationSet` — a position-aware structure that maps |
| +efficiently as the document changes — keeps breaks out of the document |
| +entirely. Which also means **page geometry never enters the CRDT**, so two |
| +collaborators on different page setups never fight, and pagination is not |
| +something that can conflict. |
| -### 6.2 The scriptless path |
| +Editing is paginated. A pageless mode is not in the first slice (§14). |
| -Server-rendered markup is the default, not the fallback. Stating the line |
| -precisely, because vagueness here is how the rule gets quietly abandoned: |
| +### 6.2 The scriptless path |
| | 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 document list, search, sharing, settings | Working form posts | |
| +| Reading, replying to and resolving comments | Working form posts | |
| +| **Creating a comment** | **Block-level anchor only** — see below | |
| +| Editing prose | Not available | |
| +| Live collaboration, presence | 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. |
| +**The correction:** revision 1 promised scriptless comment *posting* against |
| +§7.4's range anchors. A form post cannot express a text-range selection. So |
| +scriptless comment creation anchors to a **block**, and the degradation is |
| +stated rather than discovered. Replying and resolving are unaffected. |
| -### 6.3 Chrome |
| +The rule the framework asks for is that no *form* requires script. That |
| +holds. Editing a document is not a form. |
| -**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. |
| +### 6.3 Chrome |
| -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). |
| +A short toolbar — paragraph style, weight and emphasis, lists, link, |
| +alignment — grouped with dividers, plus an inspector that opens on the right |
| +for depth (page setup, styles, headers and footers, image placement) and |
| +closes again, so the page has full width by default. |
| -The floating selection toolbar still exists as pure enhancement — everything |
| -on it is also reachable from the inspector. |
| +Rejected: the dense undifferentiated toolbar, the always-on inspector, and |
| +the floating-only model, which leaves page setup nowhere to live. |
| ### 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. |
| +A list, newest first, grouped by recency, with a real rendered thumbnail of |
| +page one on each row. Cut: the template carousel, the "Owned by anyone" and |
| +"Last opened by me" filter chrome, the grid/list/AZ/folder toggles, and all |
| +commercial and platform chrome. |
| -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. |
| +### 6.5 ProseMirror, re-examined |
| -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. |
| +Re-checked against Lexical after the review, since decision 4 was made |
| +before decision 5 changed what the editor has to do. Kept, for three |
| +reasons specific to this product: |
| + |
| +- **Schema enforcement.** ProseMirror enforces its schema on every edit, and |
| + `y-prosemirror` ensures a document still conforms to that schema after a |
| + remote update — so a concurrent edit cannot produce a document the DOCX |
| + writer refuses to serialise. Lexical has no enforced schema; validation is |
| + your own code. |
| +- **Collaboration maturity.** In production since 2016, with the edge cases |
| + found. Lexical's Yjs plugins are first-party but less proven. |
| +- **Decorations.** Page breaks, comment ranges and presence cursors are all |
| + one mechanism, and it is the mechanism §6.1 depends on. |
| + |
| +Lexical's real advantage is React ergonomics. There is no build step here and |
| +no React, so that is a strength we cannot spend. |
| --- |
| ## 7. Collaboration |
| -### 7.1 Merge |
| +### 7.1 Merge and transport |
| -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. |
| +JS `yjs` with `y-prosemirror` in the browser; Rust `y-crdt` server-side. |
| +Character-level convergence, so two people in one paragraph both keep their |
| +words. |
| -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. |
| +Updates travel over a WebSocket. **The SSE fallback needs an upstream path**, |
| +which revision 1 omitted: SSE is server-to-client only, so under fallback the |
| +client POSTs batched updates with a monotonic sequence number per connection, |
| +and the server acknowledges the high-water mark. |
| ### 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. |
| +`y-indexeddb` persists the document locally and syncs on reconnect — |
| +available because the browser runs JS Yjs (§0). |
| -Two consequences, both stated rather than discovered: |
| +Two consequences: it moves conflict resolution from seconds to days, which |
| +stresses the merge far harder than live editing; and it puts plaintext on the |
| +user's device, which is leak-list item 6. |
| -- 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 and hibernation |
| -### 7.3 Presence |
| +Presence renders nothing without script rather than degrading to a stale |
| +list. A stale presence indicator asserts somebody is there, which is a lie |
| +rather than a degradation. A view link gets no cursor and no name, which |
| +falls out of §8.2's attribution rule rather than being a special case. |
| -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. |
| +**Hibernation, which revision 1 never mentioned.** Platform hibernation is |
| +live and default-on, and Docs holds a WebSocket per open editor — so a |
| +document left open on Friday would pin its instance awake all weekend. |
| -**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. |
| +- The editor socket has an **idle timeout**: no edits and no presence |
| + activity for a set period closes it, and any activity reconnects. A parked |
| + tab stops holding the instance open. |
| +- On wake, an instance re-syncs from `doc_updates` before accepting writes. |
| +- **The home outbox drains on wake and on each share mutation, never on a |
| + ticker.** A hibernating instance runs no timers, so a `time.Ticker` retry |
| + loop is a retry loop that dies at hibernation. Consumed from Sheets §9.1.1. |
| ### 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.)* |
| +Threads anchor to a **Yjs relative position**, so an anchor survives |
| +concurrent edits and re-flow. Scriptless creation anchors to a block (§6.2). |
| -**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. |
| +When the anchored text is deleted the thread is **orphaned, not destroyed**: |
| +it moves to a detached 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 serves both. |
| --- |
| ## 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.1 Sign-in — one relying party |
| + |
| +Revision 1 held three positions at once: it specced instance-local |
| +`rastrillo/auth` magic links, then said sign-in was unresolved, while §9 call |
| +1 gave sign-in to the home. The review caught all three. |
| + |
| +**Resolution, consumed from Sheets §8.1: the home's credential origin is the |
| +only relying party.** Instances never enroll passkeys and never mount `Gate`. |
| +Sign-in happens at the home for members and guests alike; the passkey second |
| +factor completes there, on the RP origin, *before any assertion exists*, so |
| +no cross-origin hand-off is required. Instances receive identity as **signed |
| +assertions** and verify a signature — Tito's pattern, where signing rather |
| +than stripping is what stops a peer socket forging them. |
| + |
| +**This is marked in the Sheets spec as ruled but needing Paul's direct |
| +confirmation, and it is recorded the same way here.** It reached this session |
| +by relay, and a relayed ruling is not a confirmed one. |
| + |
| +**Assertions carry identity, never authorization.** A guest's assertion is |
| +the same shape as an owner's; the instance decides capability from its own |
| +`grants` table. Encoding capability would require the home to know |
| +capability, putting an authorization model inside the identity origin. |
| +Expiry is seconds — it is exchanged immediately for a session. |
| + |
| +**The framework gap that remains** is that rastrillo has no helper for |
| +verifying a signed assertion: audience binding, replay, key rotation and |
| +algorithm confusion are all hand-rolled per app. Filed jointly with Sheets, |
| +Docs as a named consumer, with one Docs-specific requirement — our |
| +single-use replay store churns against a guest population far larger than |
| +the membership list and unbounded in time, so **it must be self-pruning, |
| +sized by assertion lifetime rather than user count.** |
| + |
| +**Open seam: the cross-device magic link.** A link-share viewer who signs in |
| +to comment completes the link in whichever browser opens the email. The |
| +capability context must survive that round trip, or opening it on a phone |
| +strands the pending context on the laptop. `auth.Config.Authorize` is |
| +`func(address string) bool` and cannot carry document scope. The capability |
| +therefore rides the return-to through the home and the assertion returns to |
| +the originating origin. Designed here, but it is the seam where §8.2's |
| +central rule executes and it deserves a test of its own. |
| ### 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. |
| +Team members see team documents. Outside guests are granted a specific |
| +document and become guest members. 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. |
| +**Anything that writes is attributed, and attribution is what mints |
| +membership.** A view link mints nothing. The moment somebody comments they |
| +sign in, which mints a guest member and produces a directory entry then. |
| "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. |
| +does not move data, so it forbids nothing here. |
| --- |
| ## 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. |
| +Re-synced 2026-08-30 against the revised Sheets spec. **Revision 1 was |
| +written against a superseded draft** and got the contract wrong in two ways |
| +that mattered: it said four calls where there are five, and it had the |
| +instance computing the directory HMAC when Sheets ruled the opposite. |
| + |
| +### 9.1 The five-call contract |
| + |
| +1. **Sign-in and session minting** — the home is the identity authority. |
| +2. **Directory upsert/revoke**, carrying the address over the authenticated |
| + channel with `(app, team sqid, host, kind)`, `kind` being member or guest. |
| +3. **Directory list** for a signed-in person. |
| +4. **Provisioning enqueue.** |
| +5. **Rekey journal pull.** |
| + |
| +**Instances never hold the pepper.** Call 2 sends the address and *the home |
| +hashes it*. An instance holding the pepper would enable offline dictionary |
| +attacks against the entire suite directory from one compromised team, which |
| +inverts the whole point of instance-per-team. |
| + |
| +Docs never sends a document title to the home. |
| + |
| +### 9.2 Consumed rulings |
| + |
| +- **The directory is a hint, never an authority.** A failed upsert never |
| + fails a share; call 2 goes through an outbox, never the request path; every |
| + instance re-checks authorization on every request. The outbox drains on |
| + wake and on share mutation, not on a ticker (§7.3). |
| +- **Split origins.** A credential origin carrying no account-authored string, |
| + and a separate directory origin carrying no credentials. |
| +- **A guest grant discloses the granting team's name**, and the share dialog |
| + says so in words. |
| +- **Re-keying** is call 5, a cursor-based journal carrying `old_hmac → |
| + new_hmac`. Pepper rotation needs a re-announce sweep, not outbox retry. |
| +- **The RP rename drill is `LegacyRPID` plus a window**, not a multi-phase |
| + invention. The window is still sized by the guest tail — the person who |
| + signed in once, months ago, to comment on one document. Docs has far more |
| + of them than Sheets does. |
| + |
| +### 9.3 The canvas preference |
| + |
| +Per person, never per document, so it lives with the home as suite-level |
| +account state. **It must resolve before first paint**, which makes it a |
| +cookie on the instance's own origin, written from the assertion at session |
| +mint — never a fetch the first paint waits on. |
| --- |
| ## 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. |
| +Everything around the document is design system; the canvas diverges, because |
| +a page has different requirements from an admin screen. **The thing to watch |
| +is drift in the chrome**: if you find yourself copying a partial and editing |
| +three lines, propose it upstream instead. |
| -**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 What is shipped, and what is not — a correction |
| -### 10.1 Tokens and themes |
| +Revision 1 treated the design-system spec as a shipped dependency. Verified: |
| +`ui.TokensCSS()` exists (`ui/ui.go:192`). **`ui.ThemeCSS(name)`, `Pair` and |
| +the allocation entry point do not exist in shipped code** — they are in the |
| +design-system spec, on unmerged branches. The shipped `ui` documents and |
| +styles the `class="rst-*"` vocabulary. |
| -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. |
| +So revision 1's instruction — "never the `class="rst-*"` vocabulary, writing |
| +classes now means writing the losing spelling" — is inverted *today*: the |
| +bare-attribute grammar is currently the unstyled one. |
| -`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. |
| +**The position: build against the attribute grammar, and treat the |
| +design-system v2 merge as a dependency with a date, not an assumption.** |
| +Until it lands, Docs carries its own minimal styling for the attribute |
| +spelling and deletes it on merge. This is recorded as an open dependency in |
| +§15 rather than buried. |
| -### 10.2 The canvas, light and dark |
| +`tokens.css` is frozen into `static/` at scaffold time while partials upgrade |
| +with the module, so re-copy it deliberately on every rastrillo upgrade until |
| +`rastrillo doctor` lands. |
| -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. |
| +### 10.2 Colour |
| -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. |
| +Docs is a declared consumer of the colour engine, recorded upstream in the |
| +design-system spec (`fe26c68`). |
| -**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. |
| +`Pair(hue, chroma, background)` takes an explicit background. Docs calls it |
| +against **four** — 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. *(Docs' canvas case is |
| +what established the original `scheme` enum could not work: two of those four |
| +live inside one scheme.)* |
| + |
| +Author colours: 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`. Allocation must never depend on arrival order, or two clients |
| +render the same comment in different colours and both trust it. `avoid` |
| +applies **inside** the probe; filtering first changes the modulus and shifts |
| +every allocation. The key is the person's identity, or their membership row |
| +for a guest. Past capacity the function reports that separation is no longer |
| +guaranteed and the name carries identity, which §10.3 requires anyway. |
| + |
| +### 10.3 Accessibility |
| -### 10.3 Colour |
| +WCAG 2.2 AA, no exemptions: 24×24 targets, 320px reflow, and colour never |
| +carries meaning alone — which binds the presence stack directly. Every |
| +user-visible string is a catalog key; twelve base locales. |
| -Docs is a declared consumer of `ui`'s colour engine, recorded upstream in the |
| -design-system spec (`fe26c68`). |
| +### 10.4 Primitives owed upstream |
| -`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 |
| +Docs owns **inspector panel**, **presence stack** and **comment thread**; |
| +Sheets owns toolbar groups. Each 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 which parts are |
| +script-required (§6.2). |
| -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. |
| +## 11. Fonts, DOCX, PDF and Markdown |
| -### 10.5 Primitives owed upstream |
| +### 11.1 Fonts — the section revision 1 did not have |
| -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). |
| +The word "font" did not appear in revision 1. Deterministic layout is |
| +impossible without settling this, so it is settled here. |
| ---- |
| +**A curated core set, self-hosted as woff2**, shipped to the browser and |
| +embedded in the binary for server-side layout, so both measure identically. |
| -## 11. DOCX, PDF and Markdown |
| +**Metric-compatible substitution for the proprietary faces DOCX imports |
| +name.** Calibri maps to **Carlito** and Cambria to **Caladea** — both open |
| +licensed and designed to have identical metrics, so an imported Word document |
| +paginates the way Word paginated it rather than merely looking similar. |
| +Everything unmapped substitutes through a documented table, and the import |
| +report names the substitution, which is the degrade-loudly rule already |
| +agreed (§11.2). |
| -### 11.1 The fidelity rule |
| +**CJK and the wider locale set are not in the core payload.** Shipping every |
| +locale's faces is tens of megabytes to the browser; per-locale lazy loading |
| +is a subsystem rather than a decision, and it is in §14 with its trigger. |
| +Until then, a document in an unshipped script lays out with substitution and |
| +says so. |
| -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. |
| +### 11.2 Import fidelity |
| -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. |
| +Model a real feature set and degrade loudly. The schema covers headings and |
| +named styles, lists, tables, images, links, headers and footers, page setup |
| +and comments. Import maps all of it and shows a report naming exactly what it |
| +could not bring in, and where. |
| -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). |
| +Footnotes and tracked-changes import are cut from the first slice (§14). The |
| +schema still models them, because a later import must not require a |
| +migration. |
| -### 11.2 Export |
| +Rejected on the record: pandoc (a second runtime, breaks the static binary) |
| +and conversion APIs (ships customer documents to a third party, contradicting |
| +§2). |
| -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 Export |
| -### 11.3 Thumbnails |
| +DOCX as clean OOXML. PDF emitted from the same box tree the screen renders, |
| +with **font subsetting and embedding** in `internal/pdf` — real machinery, |
| +named here because revision 1 assumed it away. Markdown lossless within its |
| +subset. |
| -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 Thumbnails |
| -### 11.4 Blobs |
| +Revision 1 called these "nearly free because the render already exists". That |
| +**conflated layout with rasterization**. The engine produces boxes; a |
| +thumbnail is a raster image, needing glyph rasterization, image decode and |
| +scale, and PNG composition — a pipeline nothing else needs, since the PDF path |
| +embeds fonts and never rasterizes. `internal/raster/` is a real component. |
| +Thumbnails stay, because they are worth it (§6.4), but they are not free. |
| -**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. |
| +### 11.5 Blobs |
| -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. |
| +**All blob storage goes through `blobs.Sealed`** (`blobs/blobs.go:218`); the |
| +package doc at `:24` is explicit that E2EE apps wrap any backend in it. |
| +`ContentType` is set to `application/octet-stream`, because a truthful content |
| +type on a sealed import announces "this is a Word document" in a bucket that |
| +is otherwise ciphertext. The address becomes the ciphertext's hash, so dedup |
| +is per key and `Ref.Size` is the sealed size. |
| --- |
| ## 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. |
| +Revision 1's gates would not have caught the failures it was most exposed to. |
| +Corrected. |
| + |
| +- `go build ./...`, `go vet ./...`, `go test ./... -count=1`, `gofmt -l .`, |
| + `CGO_ENABLED=0` throughout. `cargo test` and `cargo clippy` for the crate. |
| +- **Wire compatibility:** our Rust `y-crdt` updates are applied by upstream JS |
| + `yjs` and vice versa, against checked-in fixtures generated by the JS side. |
| + This is the CRDT half of §0 and the fixtures are the contract. |
| +- **Layout agreement:** a browser drive renders fixture documents and asserts |
| + the DOM's actual page-break positions match the engine's computed ones. This |
| + is the gate revision 1 lacked — its conformance test compared wazero to a |
| + browser running *the same module* and never checked what the DOM painted. |
| +- **Performance budgets, as gates rather than hopes:** keystroke-to-repaint on |
| + a 120-page fixture, full layout time, and the wazero pool's peak memory |
| + against a 512MB ceiling. |
| +- **Long-divergence merge:** two clients diverge for a simulated week and |
| + reconcile, because §7.2 says days-long divergence stresses the merge hardest. |
| +- **Plaintext grep:** raw SQLite bytes and bucket objects, covering documents, |
| + the event log, comments and blobs. |
| +- **Scope:** a document not granted to a user 404s, for every read and write |
| + path. |
| +- **DOCX round-trip** against checked-in expectation files, compared |
| + structurally. Revision 1 said "reopened in a real Word file comparison", |
| + which is not runnable in CI on linux/arm64 — Word does not run there. |
| +- **A browser drive of the editor itself**, because a JS syntax error takes |
| + the whole client down. |
| +- 300-line cap per JS module, one coordinator 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. |
| +1. The Rust crate: `y-crdt` wire fixtures green, then layout, with the |
| + layout-agreement gate from the first commit. |
| +2. The wazero host with its pool, trap recovery and memory budget. |
| +3. The Go app on rastrillo: list, create, open, server-rendered read-only. |
| +4. The editor: ProseMirror, JS Yjs, decoration-based pagination. |
| +5. Collaboration: transport, presence, offline, hibernation behaviour. |
| +6. Sharing: grants, guests, link shares, the home outbox. |
| +7. Markdown, then DOCX, then PDF. |
| +8. Comments; version capture and restore; the outline pane. |
| +9. Thumbnails. |
| + |
| +**Slip order, written rather than discovered:** the outline pane goes first, |
| +then offline. **The layout engine cannot slip**, because pagination, PDF and |
| +thumbnails all fall out of it. |
| --- |
| ## 14. Deliberately not built yet |
| -| Not built | Trigger that brings it in | |
| +| Not built | Trigger | |
| |---|---| |
| -| 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 | |
| +| Suggestion mode | Comments are in use and the absence of accept/reject is the complaint. Schema already models tracked changes | |
| +| Footnotes; tracked-changes import | The layout engine is stable enough that footnote-vs-page-break placement is a feature rather than a risk | |
| +| Version history timeline and diffing | Named versions are in use and people ask to browse between them. Note this reopens §4.1's compaction horizon | |
| +| Full-text search | See §15.2 — it needs a design compatible with the plaintext-grep gate, not just a decision | |
| +| Per-locale font loading | A team works in a script outside the core set | |
| +| Pageless mode | Long-document editing proves painful in page view | |
| +| Embedded live 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**~~ — **closed 2026-08-30.** The repo is `oficina/docs`, |
| - moved from `carlos/docs` at three commits. `oficina` holds `docs`, |
| - `sheets` and `home`. Note that `amadan repo create` and `share list` |
| - both refuse for an agent account without membership-management rights, |
| - which is not a signal about push access — pushing works. `carlos/docs` |
| - still exists; retiring it is Paul's. |
| -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. |
| +1. **The passkey RP ruling (§8.1)** reached this session by relay and needs |
| + Paul's direct confirmation. Sheets records it the same way. |
| +2. **Full-text search has no design.** An FTS5 index is plaintext in SQLite |
| + and would fail our own gate (§12); decrypting the corpus per query is |
| + O(corpus). Title and metadata search ships; full text waits for a design, |
| + not for a decision. |
| +3. **Design-system v2 is a dated dependency, not an assumption** (§10.1). |
| + Docs carries interim styling for the attribute grammar and deletes it on |
| + merge. |
| +4. **The identity-assertion helper** — filed jointly with Sheets, pending |
| + Paul's go-ahead to open it on the rastrillo GitHub repo. |
| +5. **`.amadan/ci.d/10-gate`** arrives with the first code branch. |
| +6. **The three-language cost.** Revisit once the crate is real. Revision 2 |
| + already spent this clause once — the "same artifact both sides" claim did |
| + not survive contact — so the next honest answer may be that the crate |
| + shrinks to layout alone. |