| 1 | # π€ Docs β design |
| 2 | |
| 3 | Design, 2026-08-30. Direction approved by Paul in conversation; this is the |
| 4 | written form for review. |
| 5 | |
| 6 | **Revision 2, after a Fable review of revision 1 returned "not ready to |
| 7 | become an implementation plan" with seventeen defects.** Three of them holed |
| 8 | the centre of the design. What follows is the corrected version, and the |
| 9 | sections that changed say what they used to claim and why it was wrong β |
| 10 | because a spec that quietly fixes its own errors teaches the next reader |
| 11 | nothing. |
| 12 | |
| 13 | CARLOS Docs is a document editor: WYSIWYG, paginated, collaborative, with |
| 14 | DOCX, PDF and Markdown in and out. One instance per team, documents shared |
| 15 | inside it and with outside guests, on the CARLOS platform. |
| 16 | |
| 17 | Built alongside [Sheets](https://amadan.net/oficina/sheets) and a shared |
| 18 | suite home. Where a decision is already made over there, this spec consumes |
| 19 | it rather than restating it. |
| 20 | |
| 21 | --- |
| 22 | |
| 23 | ## 0. The one rule, stated correctly |
| 24 | |
| 25 | Revision 1 said: *"the document's content and its layout are computed in |
| 26 | exactly one place."* **That was false and unachievable**, because the editor |
| 27 | is ProseMirror, ProseMirror is contentEditable, and contentEditable means |
| 28 | the *browser* breaks lines. The rule and the editor choice contradicted each |
| 29 | other, and the conformance gate could not have caught it. |
| 30 | |
| 31 | The corrected rule, in two halves, each true: |
| 32 | |
| 33 | **Layout: the engine owns page geometry; the browser owns line breaking |
| 34 | inside it.** The engine decides page size, margins, and where blocks break |
| 35 | across pages. The browser lays out lines within that geometry, using the |
| 36 | same self-hosted fonts the engine measures with (Β§11.1). Divergence is |
| 37 | confined to the line level and is small when fonts match; it is not zero, |
| 38 | and this spec does not claim it is. |
| 39 | |
| 40 | **The CRDT: one wire format, two implementations, fixtures as the |
| 41 | contract.** The browser runs JS `yjs`; the server runs Rust `y-crdt`. They |
| 42 | agree because checked-in fixtures generated by the JS side must be |
| 43 | reproduced by the Rust side, which is the golden-vector discipline CARLOS |
| 44 | already mandates for a protocol living in two languages. |
| 45 | |
| 46 | Revision 1 claimed "the same compiled artifact both sides" and treated that |
| 47 | as strictly better than golden vectors. It was not available: `ywasm` is |
| 48 | built with wasm-bindgen and will not instantiate under wazero, so one |
| 49 | artifact for both hosts would have meant a hand-rolled C ABI and **giving up |
| 50 | `y-prosemirror`, `y-indexeddb`, `y-websocket` and the awareness protocol** β |
| 51 | which is to say giving up the implementation path for offline and presence, |
| 52 | in exchange for a purity the spec was congratulating itself on. |
| 53 | |
| 54 | --- |
| 55 | |
| 56 | ## 1. Settled decisions |
| 57 | |
| 58 | | # | Decision | Date | |
| 59 | |---|---|---| |
| 60 | | 1 | Instance boundary is a **team**. Sharing is membership; an outside guest is a guest member. | 08-30 | |
| 61 | | 2 | **Sealed at rest**; the running instance holds the DEK, so the server can compute. | 08-30 | |
| 62 | | 3 | **Yjs.** JS `yjs` in the browser, Rust `y-crdt` on the server, fixtures as the contract. | 08-30 r2 | |
| 63 | | 4 | **ProseMirror**, vendored as a prebuilt bundle. Re-examined against Lexical after review and kept β see Β§6.5. | 08-30 | |
| 64 | | 5 | **Engine owns page geometry; the browser owns lines.** | 08-30 r2 | |
| 65 | | 6 | **Go + rastrillo for the app; one Rust crate** (layout + server-side CRDT) via wazero. | 08-30 r2 | |
| 66 | | 7 | **DOCX: model a real feature set, degrade loudly.** | 08-30 | |
| 67 | | 8 | First slice: comments, version capture and restore, offline, outline pane. | 08-30 r2 | |
| 68 | | 9 | **Canvas light by default; dark is a per-person preference.** Export always renders true colours. | 08-30 | |
| 69 | | 10 | Chrome is a **short toolbar plus an inspector on demand**. | 08-30 | |
| 70 | | 11 | The document list is **a list with real rendered page-one thumbnails**. | 08-30 | |
| 71 | | 12 | The **home is a separate repo and spec**; Docs is a declared consumer. | 08-30 | |
| 72 | | 13 | **Curated font set**, metric-compatible substitutes, documented substitution. | 08-30 r2 | |
| 73 | | 14 | **Page breaks are decorations, never nodes.** | 08-30 r2 | |
| 74 | | 15 | **Full-text search is not in the first slice.** Title and metadata search only. | 08-30 r2 | |
| 75 | | 16 | **Markup uses `class="rst-*"`**, not the bare-attribute grammar, which exists in no tree. | 08-30 r2 | |
| 76 | |
| 77 | Decision 8 changed. Revision 1 put suggestion mode in the first slice and |
| 78 | then admitted in its own sequencing that it was the item most likely to |
| 79 | slip β a spec planning its own violation. Suggestion mode, footnotes with |
| 80 | tracked-changes import, and the version-history timeline are now in Β§14. |
| 81 | |
| 82 | --- |
| 83 | |
| 84 | ## 2. Trust model, and the leak list |
| 85 | |
| 86 | Sealed at rest, in Seapointish's position. Content is encrypted in SQLite |
| 87 | under a DEK delivered by `carlos secrets`, so the Litestream-replicated |
| 88 | bucket holds ciphertext. The running instance holds the DEK, which is what |
| 89 | lets Go render a PDF, produce a thumbnail, and export. |
| 90 | |
| 91 | **Enforced, not asserted.** A test greps raw SQLite bytes and bucket objects |
| 92 | for known plaintext from a fixture document and fails if it finds any. That |
| 93 | test is the boundary; the prose is a description of it. |
| 94 | |
| 95 | ### 2.1 The leak list |
| 96 | |
| 97 | 1. **The running instance can read everything.** It holds the DEK. This is |
| 98 | the deliberate trade that buys server-side export and thumbnails. |
| 99 | 2. **The event log holds document content by construction.** Version |
| 100 | history is unimplementable unless the log carries old values, and old |
| 101 | values are prose. Sealed on the same terms as the document. |
| 102 | 3. **Blob metadata is visible.** `ContentType` passes through and the |
| 103 | address is the ciphertext's hash (Β§11.5). |
| 104 | 4. **Document titles are plaintext in the instance**, because the list, |
| 105 | search and sharing UI need them without a per-row DEK round trip. |
| 106 | Titles are never sent to the home (Β§9). |
| 107 | 5. **Error text can quote content into platform-held logs** via `carlos |
| 108 | logs`. Handlers must never interpolate document content into an error. |
| 109 | Enforced by review, not by a test, which makes it the weakest item here. |
| 110 | 6. **Offline puts plaintext on the user's own device**, in IndexedDB (Β§7.2). |
| 111 | The mitigation differs in kind from everything above: it is the user's |
| 112 | machine, and the control is a "forget this device" action that clears it, |
| 113 | not a server-side seal. |
| 114 | |
| 115 | --- |
| 116 | |
| 117 | ## 3. System shape |
| 118 | |
| 119 | One Go binary per instance, `CGO_ENABLED=0`, cross-compiled to |
| 120 | `linux/arm64`, speaking the CARLOS activation contract through |
| 121 | `rastrillo.Run`. One SQLite file per instance, Litestream-replicated by the |
| 122 | platform. |
| 123 | |
| 124 | ``` |
| 125 | cmd/docs/ main: dispatch and env only |
| 126 | internal/docs/ models, app, handlers, render β the rastrillo shape |
| 127 | internal/core/ the wazero host: module lifecycle, marshalling (Β§5.2) |
| 128 | internal/docx/ OOXML read and write |
| 129 | internal/pdf/ PDF emission, font subsetting and embedding |
| 130 | internal/raster/ thumbnail rasterization (Β§11.4) |
| 131 | core/ the Rust crate: layout + y-crdt, compiled to WASM |
| 132 | static/ vendored ProseMirror + yjs bundles, editor modules, fonts |
| 133 | ``` |
| 134 | |
| 135 | --- |
| 136 | |
| 137 | ## 4. Data model |
| 138 | |
| 139 | ``` |
| 140 | documents id, sqid, title, created_by, created_at, updated_at, |
| 141 | page_setup (sealed), deleted_at |
| 142 | doc_updates doc_id, seq, update (sealed), actor_id, created_at |
| 143 | doc_snapshots doc_id, seq, state (sealed), created_at |
| 144 | versions doc_id, seq, name, actor_id, created_at -- named, pinned |
| 145 | grants doc_id | NULL, subject, role, created_by, created_at |
| 146 | memberships user_id, role, created_at |
| 147 | comments id, doc_id, thread_id, anchor (sealed), body (sealed), |
| 148 | actor_id, resolved_at, orphaned_at, created_at |
| 149 | ``` |
| 150 | |
| 151 | ### 4.1 The log is bounded β a correction |
| 152 | |
| 153 | Revision 1 made `doc_updates` "both the sync log and the version history β |
| 154 | one structure, not two", and presented that as elegant. **It forbids |
| 155 | compaction forever**: every keystroke-granularity update becomes a sealed |
| 156 | row, retained for the life of the document, with per-row AES-GCM overhead on |
| 157 | payloads of a few bytes, amplifying the WAL stream the platform replicates. |
| 158 | |
| 159 | Three mechanisms, all required: |
| 160 | |
| 161 | - **Coalescing before persistence.** Updates from one actor within a short |
| 162 | window merge into one row before it is written β Yjs's standard |
| 163 | capture-timeout behaviour, applied at the storage boundary rather than |
| 164 | only in the undo manager. |
| 165 | - **Snapshot cadence.** A snapshot every N updates or M bytes, whichever |
| 166 | first, so opening a long document never replays the whole log. |
| 167 | - **A compaction horizon.** Beyond it, updates are collapsed into the |
| 168 | preceding snapshot and dropped. |
| 169 | |
| 170 | **Compaction does not strand an offline client, and this is worth stating |
| 171 | because the naive reading says it does.** A snapshot encodes full state, so a |
| 172 | client returning after the horizon receives one large update instead of many |
| 173 | small ones and still converges β that is the CRDT's whole point. What |
| 174 | compaction costs is *history* between snapshots, not reachability. |
| 175 | |
| 176 | **The horizon is a fixed period, never "once every client has caught up".** |
| 177 | Any prune-when-all-consumers-have-advanced rule is unbounded in a |
| 178 | hibernating fleet, because a dormant consumer never advances and the table |
| 179 | grows forever while reading as bounded. That pattern has now appeared twice |
| 180 | in the home spec β a `jti` store and a rekey journal β and this is the third |
| 181 | place it would have fitted. |
| 182 | |
| 183 | **Cutting the version timeline (Β§14) is what makes compaction tractable.** |
| 184 | With a browsable timeline, every historical point must remain reachable and |
| 185 | the log can never be collapsed. With named versions only, the set of points |
| 186 | that must survive is finite, small, and explicitly chosen by a person β |
| 187 | named versions pin their snapshot and are never compacted away. The scope |
| 188 | cut and the retention design are the same decision. |
| 189 | |
| 190 | `deleted_at` is a soft delete; a purge verb hard-deletes a document and its |
| 191 | log, and is what makes an erasure request answerable. |
| 192 | |
| 193 | ### 4.2 Scoping β and a trap |
| 194 | |
| 195 | **`scope.Owned` must not be used for documents.** Verified: `scope/scope.go` |
| 196 | declares `func Owned` at `:19` with the body `g.Where("user_id = ?", owner)` |
| 197 | at `:20`; `OwnedBy` at `:27` is the same thing with a configurable column. |
| 198 | Neither expresses "rows this team can see, plus rows shared with me, minus |
| 199 | rows I have been removed from." |
| 200 | |
| 201 | Applied to a team-shared table, `Owned` returns only the creator's rows. It |
| 202 | fails as a **short list, not an error** β a document library that looks empty |
| 203 | rather than broken, which is the worst available failure shape, on the helper |
| 204 | an author would naturally reach for. |
| 205 | |
| 206 | Docs implements its own scope over a `grants` join, preserving the |
| 207 | 404-not-403 contract. One function, tested directly, and no handler queries |
| 208 | documents any other way. |
| 209 | |
| 210 | ### 4.3 Rejected: `rastrillo/eventlog` |
| 211 | |
| 212 | Recorded because the doctrine requires the rejected alternative to be named, |
| 213 | and revision 1 omitted it. `eventlog` carries JSON payloads and a Lamport |
| 214 | merge order. Yjs updates are opaque binary and carry their own ordering, so |
| 215 | the payload shape fights it and the merge order is redundant. `doc_updates` |
| 216 | is a plain append-only table instead. |
| 217 | |
| 218 | --- |
| 219 | |
| 220 | ## 5. The Rust core |
| 221 | |
| 222 | Two hard parts are badly served by Go. **The CRDT**: the reference non-JS |
| 223 | implementation of Yjs is `y-crdt`, and it is Rust; the Go ports are young and |
| 224 | are not what other implementations are tested against. **Text layout**: Rust |
| 225 | has `rustybuzz`, `swash` and `parley`; Go's `x/image/font/sfnt` reads metrics |
| 226 | and cannot shape complex scripts. |
| 227 | |
| 228 | So one Rust crate, compiled to WASM, run server-side through **wazero** β a |
| 229 | WebAssembly runtime in pure Go with no C dependency, so the binary stays |
| 230 | static and cgo-free. It does two jobs: apply and query the CRDT, and lay out |
| 231 | documents. |
| 232 | |
| 233 | The browser does **not** run this crate. It runs JS `yjs` with |
| 234 | `y-prosemirror` and `y-indexeddb`, and its layout duty is line breaking |
| 235 | inside geometry the crate computed (Β§6.1). This is the revision-2 correction; |
| 236 | see Β§0. |
| 237 | |
| 238 | ### 5.1 What the server actually does with it |
| 239 | |
| 240 | Apply incoming updates, produce snapshots, and lay out documents for export |
| 241 | and thumbnails. **The server never lays out per keystroke** β that is the |
| 242 | browser's job β so wazero's throughput is not on the hot path. Its duty |
| 243 | cycle is bursty and coarse. |
| 244 | |
| 245 | ### 5.2 Lifecycle, which revision 1 left to chance |
| 246 | |
| 247 | - **Traps poison memory.** A Rust panic compiled to WASM traps; the module |
| 248 | instance's linear memory must then be considered unusable. The host |
| 249 | discards that instance and rebuilds the document from its latest snapshot |
| 250 | plus subsequent updates. The request fails cleanly; the document does not. |
| 251 | - **A malformed update can trap, and that is contained rather than fixed.** |
| 252 | *(Found by executing plan 1, 2026-08-30.)* `yrs-0.23.5` calls |
| 253 | `std::str::from_utf8_unchecked` on wire data with no validation |
| 254 | (`src/encoding/read.rs:137`, and two further sites), so a corrupted update |
| 255 | reaches undefined behaviour. A mutation fuzz hit it on **0.4β5.1% of |
| 256 | single-byte corruptions** of our own fixtures; purely random bytes are |
| 257 | harmless, which is why a naive garbage-input test passes and proves nothing. |
| 258 | We cannot validate our way out β locating string fields inside a Yjs update |
| 259 | means parsing it, which is what `yrs` does. Filed upstream. |
| 260 | **This makes the wasm sandbox load-bearing for security, not only |
| 261 | portability**: inside wasm *some* of this undefined behaviour becomes a |
| 262 | contained trap, where a native release build would corrupt silently. That |
| 263 | is an argument for the wazero design that was not made when it was |
| 264 | chosen. It is not a containment guarantee, though: the same unchecked |
| 265 | decode also accepts some corrupted input outright rather than trapping on |
| 266 | it β flipping a single byte of a UTF-8 continuation byte in our own |
| 267 | `unicode` fixture is applied without complaint (return code 0), leaving |
| 268 | invalid UTF-8 in the document content, retained in the update log, and |
| 269 | served back through `Handle.Text` untouched (Go's `string(buf)` does not |
| 270 | itself validate UTF-8). Downstream layers must not assume document text |
| 271 | is valid UTF-8. |
| 272 | - **Execution must be capped, not merely trapped.** A 36-byte corrupted |
| 273 | fixture ran `apply` for **163 real seconds** before returning successfully β |
| 274 | pathological slowness, not a hang, and invisible to any correctness test |
| 275 | because the answer is right. On a server accepting updates from anyone |
| 276 | holding a share link that is a denial-of-service vector. The host therefore |
| 277 | sets a fuel or deadline bound per call and treats exhaustion like a trap. |
| 278 | The original design covered an instance that dies; it said nothing about an |
| 279 | instance that never returns, and both are denial of service. |
| 280 | - **Instances are not concurrency-safe.** One module instance per open |
| 281 | document, mutex-guarded, held in a pool. |
| 282 | - **Linear memory grows and never shrinks.** One large layout permanently |
| 283 | inflates its instance. The pool therefore has a **total memory budget |
| 284 | sized for a t4g.nano (512MB)** and evicts least-recently-used instances, |
| 285 | which is cheap because an evicted document rebuilds from its snapshot. |
| 286 | - **Layout is incremental.** The engine reflows from the first dirty block |
| 287 | and stops when page geometry reconverges, returning a box-tree delta |
| 288 | rather than the whole tree. Without this a keystroke on page 3 of 120 |
| 289 | relays out everything and marshals the result across the boundary. |
| 290 | - **The ABI must export state encode/decode before any real document uses |
| 291 | it.** *(Found by executing plan 1, 2026-08-30.)* ABI v1 has no |
| 292 | state-export, so the host can only recover a trapped instance by replaying |
| 293 | every update it has seen. That is not merely slow β it makes the retained |
| 294 | log load-bearing, and a bound on it becomes a bound on the document. A cap |
| 295 | of 4096 updates sounds generous until you notice Yjs emits an update per |
| 296 | keystroke burst, and that **rehydrating a document from persisted updates |
| 297 | replays them through the same path**, so a document with more history than |
| 298 | the cap would be unloadable rather than merely slow. Recovery must |
| 299 | therefore restore a snapshot (Β§4.1) and replay only what follows it, which |
| 300 | is what Β§4.1's snapshot cadence exists for and what ABI v1 cannot express. |
| 301 | Until then the host is falsification-grade, not production-grade. |
| 302 | |
| 303 | --- |
| 304 | |
| 305 | ## 6. Pages, the editor, and where zero-JS stops |
| 306 | |
| 307 | ### 6.1 How pagination actually works |
| 308 | |
| 309 | The engine computes page geometry and the block-level break positions. The |
| 310 | browser renders those breaks and lays out lines within them. |
| 311 | |
| 312 | **Page breaks are decorations, never nodes** (decision 14). Inserting spacer |
| 313 | nodes into the document is the common approach and it is a trap: it causes |
| 314 | layout reflows on every keystroke and makes the page count flicker. Using |
| 315 | ProseMirror's `DecorationSet` β a position-aware structure that maps |
| 316 | efficiently as the document changes β keeps breaks out of the document |
| 317 | entirely. Which also means **page geometry never enters the CRDT**, so two |
| 318 | collaborators on different page setups never fight, and pagination is not |
| 319 | something that can conflict. |
| 320 | |
| 321 | Editing is paginated. A pageless mode is not in the first slice (Β§14). |
| 322 | |
| 323 | ### 6.2 The scriptless path |
| 324 | |
| 325 | | Surface | Without script | |
| 326 | |---|---| |
| 327 | | Reading a document | Full fidelity β the server renders the same layout to HTML | |
| 328 | | The document list, search, sharing, settings | Working form posts | |
| 329 | | Reading, replying to and resolving comments | Working form posts | |
| 330 | | **Creating a comment** | **Block-level anchor only** β see below | |
| 331 | | Editing prose | Not available | |
| 332 | | Live collaboration, presence | Not available, by design | |
| 333 | |
| 334 | **The correction:** revision 1 promised scriptless comment *posting* against |
| 335 | Β§7.4's range anchors. A form post cannot express a text-range selection. So |
| 336 | scriptless comment creation anchors to a **block**, and the degradation is |
| 337 | stated rather than discovered. Replying and resolving are unaffected. |
| 338 | |
| 339 | The rule the framework asks for is that no *form* requires script. That |
| 340 | holds. Editing a document is not a form. |
| 341 | |
| 342 | ### 6.3 Chrome |
| 343 | |
| 344 | A short toolbar β paragraph style, weight and emphasis, lists, link, |
| 345 | alignment β grouped with dividers, plus an inspector that opens on the right |
| 346 | for depth (page setup, styles, headers and footers, image placement) and |
| 347 | closes again, so the page has full width by default. |
| 348 | |
| 349 | Rejected: the dense undifferentiated toolbar, the always-on inspector, and |
| 350 | the floating-only model, which leaves page setup nowhere to live. |
| 351 | |
| 352 | ### 6.4 The document list |
| 353 | |
| 354 | A list, newest first, grouped by recency, with a real rendered thumbnail of |
| 355 | page one on each row. Cut: the template carousel, the "Owned by anyone" and |
| 356 | "Last opened by me" filter chrome, the grid/list/AZ/folder toggles, and all |
| 357 | commercial and platform chrome. |
| 358 | |
| 359 | ### 6.5 ProseMirror, re-examined |
| 360 | |
| 361 | Re-checked against Lexical after the review, since decision 4 was made |
| 362 | before decision 5 changed what the editor has to do. Kept, for three |
| 363 | reasons specific to this product: |
| 364 | |
| 365 | - **Schema enforcement.** ProseMirror enforces its schema on every edit, and |
| 366 | `y-prosemirror` ensures a document still conforms to that schema after a |
| 367 | remote update β so a concurrent edit cannot produce a document the DOCX |
| 368 | writer refuses to serialise. Lexical has no enforced schema; validation is |
| 369 | your own code. |
| 370 | - **Collaboration maturity.** In production since 2016, with the edge cases |
| 371 | found. Lexical's Yjs plugins are first-party but less proven. |
| 372 | - **Decorations.** Page breaks, comment ranges and presence cursors are all |
| 373 | one mechanism, and it is the mechanism Β§6.1 depends on. |
| 374 | |
| 375 | Lexical's real advantage is React ergonomics. There is no build step here and |
| 376 | no React, so that is a strength we cannot spend. |
| 377 | |
| 378 | --- |
| 379 | |
| 380 | ## 7. Collaboration |
| 381 | |
| 382 | ### 7.1 Merge and transport |
| 383 | |
| 384 | JS `yjs` with `y-prosemirror` in the browser; Rust `y-crdt` server-side. |
| 385 | Character-level convergence, so two people in one paragraph both keep their |
| 386 | words. |
| 387 | |
| 388 | **Updates travel over a Server-Sent Events stream plus the ordinary update |
| 389 | POST, and the sequence number is the document's own, not a connection's.** |
| 390 | This is revision 3, and it replaces rather than annotates what revisions 1 and |
| 391 | 2 said here β see the correction below for why the old wording could not |
| 392 | honestly describe what shipped. |
| 393 | |
| 394 | The stream (`GET /d/{sqid}/stream`) carries a seq and no document bytes at |
| 395 | all. It says "this document has content through seq N"; the client fetches |
| 396 | whatever is newer than its own cursor through `GET /d/{sqid}/updates?since=N`, |
| 397 | the same binary endpoint it already replays at mount. Upstream is unchanged: |
| 398 | the client POSTs batched updates, and the response names the seq that row |
| 399 | received. `doc_updates.seq` is that number β durable, global to the document, |
| 400 | and meaningful across connections, tabs and devices. |
| 401 | |
| 402 | The full protocol, its cursor rules and its hibernation policy are |
| 403 | `docs/superpowers/specs/2026-09-01-live-sync-design.md`. |
| 404 | |
| 405 | **The correction, recorded because the old text was a decision and not a |
| 406 | typo.** Revision 1 said updates travel over a WebSocket; revision 2 added that |
| 407 | "the SSE fallback needs an upstream path⦠the client POSTs batched updates |
| 408 | with a monotonic sequence number **per connection**, and the server |
| 409 | acknowledges the high-water mark." A per-connection counter cannot survive the |
| 410 | connection. It cannot tell a reconnecting tab what it missed, which makes it |
| 411 | useless for exactly the case Β§7.3's idle timeout guarantees will happen |
| 412 | constantly β every parked tab, every hibernation cycle, every five-minute |
| 413 | stream TTL. The document's own log seq answers that by construction: a tab |
| 414 | that reconnects after an hour asks `?since=` its own cursor and receives |
| 415 | precisely what it missed. |
| 416 | |
| 417 | So what shipped is not this section's fallback with the socket still to come. |
| 418 | It is a different and better protocol, and calling it "the fallback" would |
| 419 | have left the spec describing a design nobody built. (Paul's ruling, |
| 420 | 2026-09-01, on the live-sync spec's Β§9.) |
| 421 | |
| 422 | **The WebSocket is now the upgrade that arrives with presence, not the |
| 423 | primary this replaced.** Awareness needs ephemeral, high-frequency, duplex |
| 424 | messages, which is what a socket is for and what an SSE-plus-POST pair is bad |
| 425 | at; content sync needs none of that. When presence lands, the socket lands |
| 426 | with it, and the update-log format above is what it will stream β unchanged, |
| 427 | because nothing about `doc_updates` is transport-specific (Β§4.3). |
| 428 | |
| 429 | ### 7.2 Offline |
| 430 | |
| 431 | `y-indexeddb` persists the document locally and syncs on reconnect β |
| 432 | available because the browser runs JS Yjs (Β§0). |
| 433 | |
| 434 | Two consequences: it moves conflict resolution from seconds to days, which |
| 435 | stresses the merge far harder than live editing; and it puts plaintext on the |
| 436 | user's device, which is leak-list item 6. |
| 437 | |
| 438 | ### 7.3 Presence and hibernation |
| 439 | |
| 440 | Presence renders nothing without script rather than degrading to a stale |
| 441 | list. A stale presence indicator asserts somebody is there, which is a lie |
| 442 | rather than a degradation. A view link gets no cursor and no name, which |
| 443 | falls out of Β§8.2's attribution rule rather than being a special case. |
| 444 | |
| 445 | **Hibernation, which revision 1 never mentioned.** Platform hibernation is |
| 446 | live and default-on, and Docs holds a WebSocket per open editor β so a |
| 447 | document left open on Friday would pin its instance awake all weekend. |
| 448 | |
| 449 | - The editor socket has an **idle timeout**: no edits and no presence |
| 450 | activity for a set period closes it, and any activity reconnects. A parked |
| 451 | tab stops holding the instance open. |
| 452 | - On wake, an instance re-syncs from `doc_updates` before accepting writes. |
| 453 | - **The home outbox drains on wake and on each share mutation, never on a |
| 454 | ticker.** A hibernating instance runs no timers, so a `time.Ticker` retry |
| 455 | loop is a retry loop that dies at hibernation. Consumed from Sheets Β§9.1.1. |
| 456 | |
| 457 | ### 7.4 Comments |
| 458 | |
| 459 | Threads anchor to a **Yjs relative position**, so an anchor survives |
| 460 | concurrent edits and re-flow. Scriptless creation anchors to a block (Β§6.2). |
| 461 | |
| 462 | When the anchored text is deleted the thread is **orphaned, not destroyed**: |
| 463 | it moves to a detached list carrying its last known location and its content, |
| 464 | with a re-attach affordance. A comment is somebody's words about something, |
| 465 | and losing the anchor is not permission to lose the words. Sheets reaches the |
| 466 | same obligation from the opposite direction β a stable anchor whose content |
| 467 | is destroyed β so the primitive serves both. |
| 468 | |
| 469 | --- |
| 470 | |
| 471 | ## 8. Identity and sharing |
| 472 | |
| 473 | ### 8.1 Sign-in β two shapes, and which one a deployment is |
| 474 | |
| 475 | **Ruled by Paul, 2026-08-31, and it reframes what follows.** An earlier draft |
| 476 | treated the home as the only way in, which made instance-local sign-in a |
| 477 | stopgap. It is not. There are two deployment shapes and they have different |
| 478 | correct answers: |
| 479 | |
| 480 | - **A single instance** β one team, self-hosted, no suite. It has no |
| 481 | "one person, many teams" problem, because there is one instance. So it |
| 482 | signs people in **itself**, with rastrillo's `auth` plugin: magic link, |
| 483 | auto-upgrading to keymail when the address resolves to a claimed inbox. |
| 484 | This is the right answer for that shape, not a placeholder for one. |
| 485 | |
| 486 | **And it is required rather than merely correct.** Factor VII holds that |
| 487 | self-hosting is a right and first-class, not a degraded tier. An earlier |
| 488 | draft of this section made the home the only way in β which would have |
| 489 | obliged a person self-hosting one instance to stand up the entire suite |
| 490 | home, split origins, blind-index directory, assertion minting and key |
| 491 | rotation included, in order to sign in to their own documents. That is not |
| 492 | a degraded tier; it is a prohibitive one, and it would have made |
| 493 | self-hosting nominal. *(Argument owed to the Sheets session, 2026-08-31, |
| 494 | which is a better justification than the one this spec first gave.)* |
| 495 | |
| 496 | It also sharpens what the home **is**: not "how you sign in", but *how one |
| 497 | person reaches many instances without many credentials*. Split origins, the |
| 498 | blind index, the rekey journal, the pepper living in exactly one place β |
| 499 | every part of it serves that and nothing else. A lone instance has none of |
| 500 | that problem and therefore needs none of that machinery. |
| 501 | - **A suite deployment** β Docs beside Sheets and others, one person |
| 502 | spanning several teams. Here three instances must not mean three |
| 503 | credentials, which is the problem the home exists to solve, and sign-in |
| 504 | moves to its credential origin with instances verifying signed assertions. |
| 505 | |
| 506 | So an instance is built to accept identity from **either**: its own |
| 507 | magic-link flow, or a verified assertion. The second is not a replacement for |
| 508 | the first; it is what a suite adds. |
| 509 | |
| 510 | **Why magic-link-at-the-instance does not violate the one-relying-party |
| 511 | ruling.** That ruling is about **passkeys** β a person in three teams must not |
| 512 | enrol three credentials, and `passkey.Config.Origin` derives the RPID from the |
| 513 | app's own host. Magic link enrols **no credential at all**, so it creates no |
| 514 | relying party and strands nothing. An instance running it and later accepting |
| 515 | assertions is not a migration; people simply sign in by a different route. |
| 516 | |
| 517 | **And it is consistent with the home's own blindness rule.** The home spec |
| 518 | rules that **instances hold readable addresses and the home never does**. An |
| 519 | app instance storing an address is the design, not a deviation β which is |
| 520 | exactly why `rastrillo/auth` is wrong for the *home* and right for an *app*. |
| 521 | |
| 522 | ### 8.1.1 The suite path β one relying party |
| 523 | |
| 524 | Revision 1 held three positions at once: it specced instance-local |
| 525 | `rastrillo/auth` magic links, then said sign-in was unresolved, while Β§9 call |
| 526 | 1 gave sign-in to the home. The review caught all three. |
| 527 | |
| 528 | **Resolution, consumed from Sheets Β§8.1: the home's credential origin is the |
| 529 | only relying party.** Instances never enroll passkeys and never mount `Gate`. |
| 530 | Sign-in happens at the home for members and guests alike; the passkey second |
| 531 | factor completes there, on the RP origin, *before any assertion exists*, so |
| 532 | no cross-origin hand-off is required. Instances receive identity as **signed |
| 533 | assertions** and verify a signature β Tito's pattern, where signing rather |
| 534 | than stripping is what stops a peer socket forging them. |
| 535 | |
| 536 | **Confirmed by Paul directly, 2026-08-30.** It first reached this session by |
| 537 | relay from the Sheets session, and was held as unconfirmed until asked |
| 538 | directly β a relayed ruling is not a confirmed one, and both sessions applied |
| 539 | that standard to each other in both directions during this design. |
| 540 | |
| 541 | **Assertions carry identity, never authorization.** A guest's assertion is |
| 542 | the same shape as an owner's; the instance decides capability from its own |
| 543 | `grants` table. Encoding capability would require the home to know |
| 544 | capability, putting an authorization model inside the identity origin. |
| 545 | Expiry is seconds β it is exchanged immediately for a session. |
| 546 | |
| 547 | **The framework gap that remains** is that rastrillo has no helper for |
| 548 | verifying a signed assertion: audience binding, replay, key rotation and |
| 549 | algorithm confusion are all hand-rolled per app. Filed jointly with Sheets, |
| 550 | Docs as a named consumer, with one Docs-specific requirement β our |
| 551 | single-use replay store churns against a guest population far larger than |
| 552 | the membership list and unbounded in time, so **it must be self-pruning, |
| 553 | sized by assertion lifetime rather than user count.** |
| 554 | |
| 555 | **The cross-device magic link β closed 2026-08-30.** A link-share viewer who |
| 556 | signs in to comment completes the link in whichever browser opens the email, |
| 557 | so the capability context must survive that round trip or opening it on a |
| 558 | phone strands the pending state on the laptop. `auth.Config.Authorize` is |
| 559 | `func(address string) bool` and cannot carry document scope. |
| 560 | |
| 561 | The resolution keeps capability **entirely instance-side**, which is what |
| 562 | makes it compatible with "assertions carry identity, never authorization": |
| 563 | |
| 564 | 1. The instance builds the sign-in return URL with a **nonce**, keying |
| 565 | pending state it holds itself β the link token and what it grants. |
| 566 | 2. The person signs in at the home. The home learns nothing about links or |
| 567 | capabilities; it mints an ordinary assertion. |
| 568 | 3. On return the instance verifies the assertion, redeems its own pending |
| 569 | state by nonce, mints the grant, and only then applies the session. |
| 570 | |
| 571 | Because the nonce rides the URL rather than a cookie, the round trip |
| 572 | reassembles in whichever browser opened the email β the cross-device problem |
| 573 | falls out of the same fix rather than needing its own. *(Resolved jointly |
| 574 | with the home session; the tempting alternative was letting the assertion |
| 575 | carry the capability, which would have holed Β§5.3 of the home spec in the |
| 576 | same week it was tightened.)* |
| 577 | |
| 578 | This is the seam where Β§8.2's central rule executes, and it gets its own |
| 579 | test: an assertion for a person with no grant is a valid assertion that gets |
| 580 | nothing, and the nonce redemption is what turns it into access. |
| 581 | |
| 582 | ### 8.2 Sharing |
| 583 | |
| 584 | Team members see team documents. Outside guests are granted a specific |
| 585 | document and become guest members. Link shares are a per-document |
| 586 | capability, viewer or commenter, never editor. |
| 587 | |
| 588 | **Anything that writes is attributed, and attribution is what mints |
| 589 | membership.** A view link mints nothing. The moment somebody comments they |
| 590 | sign in, which mints a guest member and produces a directory entry then. |
| 591 | |
| 592 | "Sharing is membership" forbids *data crossing a team boundary* (suite |
| 593 | ruling 2). A link does not move data β whoever holds it reads the document |
| 594 | here, on this instance β so it forbids nothing here. |
| 595 | |
| 596 | **Within a team, Docs is one of several apps sharing one trust domain** |
| 597 | (suite ruling 1). Same-team siblings may pull team-visible documents under |
| 598 | the suite contract, and Docs serves S1, S2, S3 and S8 wherever sibling |
| 599 | consumer tokens are in its platform config. That switch is independent of |
| 600 | whether a home is present (suite Β§3): Docs serves a lone memoria with no |
| 601 | home in the picture. |
| 602 | |
| 603 | **Only the team rung is team-visible** (suite ruling 15). A document that is |
| 604 | private, granted to named people, or reachable only by link is never |
| 605 | team-visible, so no sibling may hold a copy of it β and Β§8.2's existing rule |
| 606 | that a link share is *viewer or commenter, never editor* is the rule suite |
| 607 | ruling 15 adopts suite-wide. It stays here as the original; the doctrine |
| 608 | cites it rather than restating it. |
| 609 | |
| 610 | --- |
| 611 | |
| 612 | ## 9. The home, as a dependency |
| 613 | |
| 614 | Re-synced 2026-08-30 against the revised Sheets spec. **Revision 1 was |
| 615 | written against a superseded draft** and got the contract wrong in two ways |
| 616 | that mattered: it said four calls where there are five, and it had the |
| 617 | instance computing the directory HMAC when Sheets ruled the opposite. |
| 618 | |
| 619 | ### 9.1 The contract, cited rather than restated |
| 620 | |
| 621 | **Docs consumes the home contract as the home spec states it, and the suite |
| 622 | contract as `oficina/suite` states it.** Neither is copied here. |
| 623 | |
| 624 | This section used to list five calls. That list was the drift the suite |
| 625 | doctrine was written to stop: it was correct when written, the home spec now |
| 626 | has six calls, the activity design adds a seventh and the roster an eighth, |
| 627 | and nothing made this copy notice. A reader who trusted it would have |
| 628 | implemented against a contract that no longer existed. |
| 629 | |
| 630 | Two things that are Docs' own and stay: |
| 631 | |
| 632 | - **Instances never hold the pepper.** The home hashes the address. An |
| 633 | instance holding the pepper would enable offline dictionary attacks |
| 634 | against the whole suite directory from one compromised team, which |
| 635 | inverts the point of instance-per-team. |
| 636 | - **Docs never sends a document title to the home.** |
| 637 | |
| 638 | ### 9.2 What Docs does about it |
| 639 | |
| 640 | The rules below belong to the home spec or the suite doctrine and are cited, |
| 641 | not restated. What is written out is the part Docs owes. |
| 642 | |
| 643 | - **The directory is a hint, never an authority** (home ruling 9, suite |
| 644 | ruling 12 β a directory row never grants team standing however its `kind` |
| 645 | reads). Docs' obligation: a failed upsert never fails a share; call 2 goes |
| 646 | through an outbox, never the request path; every instance re-checks |
| 647 | authorization on every request; the outbox drains on wake and on share |
| 648 | mutation, not on a ticker (Β§7.3). |
| 649 | - **Split origins** (home's). Docs holds no credential origin of its own. |
| 650 | - **A guest grant discloses the granting team's name**, and the share dialog |
| 651 | says so in words. Docs' own, and it stays here. |
| 652 | - **Re-keying** is the home's journal call. Docs' obligation: pepper |
| 653 | rotation needs a re-announce sweep, not outbox retry. |
| 654 | - **The RP rename drill is `LegacyRPID` plus a window**, not a multi-phase |
| 655 | invention. The window is still sized by the guest tail β the person who |
| 656 | signed in once, months ago, to comment on one document. Docs has far more |
| 657 | of them than Sheets does. |
| 658 | - **The hostnames, revised 2026-09-03** (home ruling 16, superseding the |
| 659 | 2026-08-30 record). The suite's primary domain is **`oficina.works`**. |
| 660 | The two home origins are `id.oficina.works` (credentials, the RP) and the |
| 661 | apex `oficina.works` (directory); a Docs instance is |
| 662 | **`<team>.docs.oficina.works`**, one team to one hostname. Hosting stays |
| 663 | on Carloku with `oficina.works` as a custom domain. This replaces the |
| 664 | provisional `<app>.<sqid>.oncarlos.com` under account sqid `bds`, which |
| 665 | was a hosting artifact. (`carloku.com` was the console brand and was |
| 666 | never this.) |
| 667 | - **The rename cost nothing, because it happened first.** No app in the |
| 668 | suite had a `passkey.Config` when the domain moved β sign-in is |
| 669 | magic-link throughout β so no credential was scoped to an `oncarlos.com` |
| 670 | host. The `LegacyRPID` window above is sized for a *future* move. |
| 671 | - **The RP ID is the full host, always** β a constraint that binds Docs even |
| 672 | though the RP is the home's. WebAuthn permits an RP ID that is a |
| 673 | registrable suffix of the origin's hostname, so `oficina.works` would be |
| 674 | *accepted* for `id.oficina.works`. The old objection was that |
| 675 | `oncarlos.com` is shared by every Carloku tenant; on a zone we own the |
| 676 | objection is that a suffix RP ID scopes one credential across every team |
| 677 | instance under it. Asserted by a test rather than remembered. |
| 678 | |
| 679 | ### 9.3 The canvas preference |
| 680 | |
| 681 | Per person, never per document, so it lives with the home as suite-level |
| 682 | account state. **It must resolve before first paint**, which makes it a |
| 683 | cookie on the instance's own origin, written from the assertion at session |
| 684 | mint β never a fetch the first paint waits on. |
| 685 | |
| 686 | --- |
| 687 | |
| 688 | ## 10. Design system |
| 689 | |
| 690 | Everything around the document is design system; the canvas diverges, because |
| 691 | a page has different requirements from an admin screen. **The thing to watch |
| 692 | is drift in the chrome**: if you find yourself copying a partial and editing |
| 693 | three lines, propose it upstream instead. |
| 694 | |
| 695 | ### 10.1 Three states, not two β and the release gap |
| 696 | |
| 697 | Revision 1 treated the design-system spec as shipped code. Revision 2 first |
| 698 | corrected that against the wrong tree. Both errors have the same root: **a |
| 699 | spec ruling, code on `main`, and code in the latest release are three |
| 700 | different things**, and reading any one of them tells you nothing about the |
| 701 | other two. |
| 702 | |
| 703 | Verified against `origin/main` and `v0.19.0`: |
| 704 | |
| 705 | | Symbol | State | |
| 706 | |---|---| |
| 707 | | `ui.TokensCSS()` | Released | |
| 708 | | `ui.ThemeCSS(name)`, `ThemeNames()`, `Layout(name)`, `LayoutNames()` | **Released in `v0.20.0`** (2026-08-30) β were `main`-only for six days | |
| 709 | | `Pair`, the allocation entry point | **Ruled only.** In no tree | |
| 710 | | The bare-attribute grammar (`<div rst-inspector>`) | **Released in `v0.21.0`** (2026-08-31). Verified: `ui/tokens.css` carries 343 bare-attribute selectors alongside 398 class selectors, so both spellings are styled | |
| 711 | |
| 712 | **Resolved 2026-08-30: `v0.20.0` is tagged and level with `main`** (verified: |
| 713 | carries `ThemeCSS`, `ThemeNames` and `LayoutNames`; zero commits behind |
| 714 | `main`). For six days `v0.19.0` was the only release and lacked the themes, |
| 715 | shells and twelve locales the design-system spec spends its length |
| 716 | describing, so an app running `go get` got none of them. Docs pins `v0.20.0` |
| 717 | rather than a `main` pseudo-version. |
| 718 | |
| 719 | *(A measurement correction worth keeping, because it is the same shape as the |
| 720 | two traps below: the release was described β including to this spec β as |
| 721 | removing 20 MB of vendored gallery from `go get`. Measured, the 20 MB never |
| 722 | reached a release at all; it was added and removed between `v0.19.0` and |
| 723 | `v0.20.0`, and the zip is compressed. The real reduction is 4.28 MB β 1.98 MB |
| 724 | and applies only to someone who was on a `main` pseudo-version, which Docs |
| 725 | was. `v0.20.0` is in fact **larger** than `v0.19.0`, because three themes, |
| 726 | twelve catalogs and a date parser are real content. A plausible number that |
| 727 | nothing contradicted until somebody measured.)* |
| 728 | |
| 729 | *(Two traps for whoever checks this next. The repo squash-merges, so `git |
| 730 | branch --merged` reports nothing useful about whether work has landed β |
| 731 | compare content, not ancestry. And a local checkout may sit on a feature |
| 732 | branch at a tag; cite `origin/main` explicitly or you will report the past.)* |
| 733 | |
| 734 | ### 10.1.1 Markup β the attribute grammar, and which repo the question is about |
| 735 | |
| 736 | This reversed twice in one day, which is worth recording because both |
| 737 | reversals were the same mistake in opposite directions: applying a |
| 738 | conclusion drawn about one repo to a different one without re-deriving it. |
| 739 | |
| 740 | **Revision 2** said write the attribute grammar and carry interim styling. |
| 741 | **Then reversed** to `class="rst-*"`, on the grounds that the grammar exists |
| 742 | in no rastrillo tree (verified: spec-only at `origin/main` `7a30456`, no |
| 743 | branch working on it) so an app writing it renders unstyled. **Then reversed |
| 744 | back**, once `oficina/estilo` existed, because that argument is about an app |
| 745 | consuming rastrillo's CSS and estilo does not do that. |
| 746 | |
| 747 | **The settled split:** |
| 748 | |
| 749 | | Markup | Spelling | Styled by | Status | |
| 750 | |---|---|---|---| |
| 751 | | Estilo's own components | Attribute grammar | Estilo | **Settled** | |
| 752 | | Docs' own markup | Attribute grammar | Estilo | **Settled 2026-08-31** | |
| 753 | | rastrillo partials we consume | Whatever rastrillo emits β classes today | rastrillo | Not our call | |
| 754 | |
| 755 | **Unparked by Paul, 2026-08-31**, once both of his conditions were met and |
| 756 | independently verified: `<div rst-*>` is shipped (`v0.21.0` styles both |
| 757 | spellings) and the design-system guidance exists. Docs writes the attribute |
| 758 | grammar, adopting **per screen rather than per element** β mixed authoring |
| 759 | inside one component matches neither spelling by design β and pinning |
| 760 | `v0.21.0` or later, below which attributes render unstyled. |
| 761 | Docs writes no markup yet β plans 1 and 2 are a Rust crate and a wazero host |
| 762 | β so nothing is blocked, and answering it now would mean converging on a |
| 763 | decision that is about to be made upstream. |
| 764 | |
| 765 | **Until it is decided: change nothing, and let no task quietly decide it.** A |
| 766 | parked question that an implementer resolves in passing is worse than an open |
| 767 | one, because it looks settled to the next reader and nobody knows it was |
| 768 | chosen rather than ruled. |
| 769 | |
| 770 | Estilo ships its own components *and their styling*, so attribute-grammar |
| 771 | components styled by estilo are not interim anything β that is just estilo's |
| 772 | styling, permanently, with nothing to migrate later. The unstyled-today |
| 773 | problem only ever applied to markup leaning on rastrillo's stylesheet. |
| 774 | |
| 775 | And estilo is the variant system that exists *because* of this direction, so |
| 776 | building it on the spelling being replaced is backwards. Estilo is where the |
| 777 | new grammar gets proven rather than where it waits. |
| 778 | |
| 779 | Docs consumes rastrillo partials for chrome, and those emit classes. That is |
| 780 | rastrillo's output, not a choice this spec makes, and it changes when |
| 781 | rastrillo changes. |
| 782 | |
| 783 | `tokens.css` is frozen into `static/` at scaffold time while partials upgrade |
| 784 | with the module. **`rastrillo doctor` landed in `v0.21.0`**, so the manual |
| 785 | re-copy step is retired: `doctor` compares an app's frozen `static/` against |
| 786 | the module's and `--fix` re-copies, and it refuses across a version mismatch |
| 787 | rather than manufacturing the fault it detects. |
| 788 | |
| 789 | ### 10.2 Colour |
| 790 | |
| 791 | Docs is a declared consumer of the colour engine, recorded upstream in the |
| 792 | design-system spec (`fe26c68`). |
| 793 | |
| 794 | `Pair(hue, chroma, background)` takes an explicit background. Docs calls it |
| 795 | against **four** β paper white, dark paper, and the theme surfaces β because |
| 796 | the same stored highlight is read on white paper by one person and dark paper |
| 797 | by another, in the same document, at the same moment. *(Docs' canvas case is |
| 798 | what established the original `scheme` enum could not work: two of those four |
| 799 | live inside one scheme.)* |
| 800 | |
| 801 | Author colours: hash the opaque key for the preferred hue, then deterministic |
| 802 | open addressing β probe `(hash(key) + i) mod N` over a **lexicographic |
| 803 | ordering of the key set**, taking the first hue neither taken in this pass nor |
| 804 | in `avoid`. Allocation must never depend on arrival order, or two clients |
| 805 | render the same comment in different colours and both trust it. `avoid` |
| 806 | applies **inside** the probe; filtering first changes the modulus and shifts |
| 807 | every allocation. The key is the person's identity, or their membership row |
| 808 | for a guest. Past capacity the function reports that separation is no longer |
| 809 | guaranteed and the name carries identity, which Β§10.3 requires anyway. |
| 810 | |
| 811 | ### 10.3 Accessibility |
| 812 | |
| 813 | WCAG 2.2 AA, no exemptions: 24Γ24 targets, 320px reflow, and colour never |
| 814 | carries meaning alone β which binds the presence stack directly. Every |
| 815 | user-visible string is a catalog key; twelve base locales. |
| 816 | |
| 817 | ### 10.4 Primitives, and where they go |
| 818 | |
| 819 | Docs owns **inspector panel**, **presence stack** and **comment thread**; |
| 820 | Sheets owns toolbar groups. Each carries a sentence saying what it is *not*, |
| 821 | every string is a prose key with the eleven non-English locales |
| 822 | machine-drafted, and each states which parts are script-required (Β§6.2). |
| 823 | |
| 824 | **They go to a CARLOS suite variant, not to rastrillo's `ui`** β ruled by |
| 825 | Paul, 2026-08-30, correcting earlier advice this spec had taken. An |
| 826 | inspector panel shared by a document editor and a spreadsheet is the |
| 827 | *suite's* component; the design system stays rastrillo's. |
| 828 | |
| 829 | The variant is **estilo**, at `amadan.net/oficina/estilo` β named by Paul and |
| 830 | created 2026-08-30. Primitives stay app-local until one app copies or imports |
| 831 | another's; **a copy is the trigger**, because "a second consumer appears" |
| 832 | never fires while copying is cheap and immediate. |
| 833 | |
| 834 | **Estilo's components take an `estilo-*` prefix, never `rst-*`.** That |
| 835 | namespace belongs to the framework and a future version may claim a name we |
| 836 | took β which is not hypothetical: rastrillo's own stage-1 migration found |
| 837 | `rst-form__foot` and `rst-form-foot` both live inside `ui`. The guidance is published at **https://rastrillo.org/docs/design-system** |
| 838 | (2026-08-31); it covers the customisation ladder, where app-specific |
| 839 | components belong, the extraction rule, and upgrading via `doctor`. Its |
| 840 | earlier claim that the class spelling would be dropped before 1.0 has been |
| 841 | corrected to what is true today: attributes are what the framework writes, |
| 842 | and classes are kept so apps can migrate. |
| 843 | |
| 844 | **`v0.22.0` (2026-08-31): the framework itself now writes the attribute |
| 845 | grammar.** Verified β `class=` occurrences in `ui/ui.go` fell from 56 to 5, |
| 846 | the remainder being the utilities that keep the class spelling by design. So |
| 847 | Docs and rastrillo now write the same spelling. |
| 848 | |
| 849 | **`rastrillo markup` exists**, the codemod that performed that flip. Docs does |
| 850 | not need it β we were written in the attribute grammar from the first line β |
| 851 | but the order matters if it is ever used: `doctor --fix` first, because the |
| 852 | CSS must match both spellings before the markup moves. It also lists every |
| 853 | `.rst-` selector in an app's own stylesheet, which is the case nothing else |
| 854 | catches: markup flips, stylesheet does not. |
| 855 | |
| 856 | **One breaking rename in `v0.22.0` does not reach us**, checked rather than |
| 857 | assumed: the hand-written sticky save-bar idiom became `rst-form-bar`, with |
| 858 | `rst-form-foot` now belonging to the partial that emits it. Docs uses |
| 859 | neither. The upgrade from `v0.21.0` is therefore optional and low-risk, and |
| 860 | is Paul's call rather than an overnight dependency bump. |
| 861 | |
| 862 | The correction is worth keeping because the reasoning was subtly wrong |
| 863 | rather than simply wrong. Two callers at design time is the right bar for |
| 864 | **extraction**, and Sheets and Docs applied it correctly. But it says |
| 865 | nothing about **where to**, and the earlier answer treated the first |
| 866 | question as settling the second. |
| 867 | |
| 868 | A variant depends on `ui`, keeps its bones β tokens, partial vocabulary, |
| 869 | accessibility floors β and adds or replaces on top with its own components, |
| 870 | themes and gallery. Not a fork, and more than a theme: a theme changes |
| 871 | colour, type and shape, where a variant can add a component a theme cannot. |
| 872 | |
| 873 | `cmd/dsgen` went public on 2026-08-30 and generates a gallery for any |
| 874 | module's own vocabulary. It is what a variant's gallery needs, so neither |
| 875 | suite app should design a documentation tool before looking at it. |
| 876 | |
| 877 | **How the variant gets built β the axis that matters is not the repo.** A |
| 878 | repo is cheap and reversible; an interface two apps depend on is neither. |
| 879 | Creating a repo commits nobody to a shape, and agreeing an inspector's props |
| 880 | commits everybody. |
| 881 | |
| 882 | Two specs are guesses; two implementations are facts. A document editor's |
| 883 | inspector holds text and style properties, a spreadsheet's holds cell format |
| 884 | and data validation, and an abstraction designed from both specs at once |
| 885 | risks a shape fitting neither β discovered only after both are built. |
| 886 | |
| 887 | **So: one app builds it, in the shared place, and the other adopts it.** Not |
| 888 | two implementations merged, and not an interface designed from two specs. The |
| 889 | second *use* corrects the shape, which is what working code tells you and a |
| 890 | spec cannot. Until that second adoption the shared place is a **home, not an |
| 891 | agreed interface** β treating it as an interface earlier produces exactly the |
| 892 | ill-fitting abstraction the sequencing exists to avoid. |
| 893 | |
| 894 | **What a separate repo costs, priced rather than assumed:** CI, a release |
| 895 | process, a version, and a dependency edge into two apps. It also reproduces |
| 896 | the frozen-`tokens.css` trap one layer down β the variant pins a rastrillo |
| 897 | version, the apps pin a variant version, and there are two pins to keep in |
| 898 | step instead of one. That is the trap that already cost this project a manual |
| 899 | runbook step and a `rastrillo doctor` to mechanise. |
| 900 | |
| 901 | **Against the alternative of starting it as a directory inside whichever app |
| 902 | builds first:** Go's module boundaries make the second consumer's import |
| 903 | awkward β importing `oficina/docs/...` would drag the entire Docs application |
| 904 | in as a dependency of Sheets, and avoiding that means a nested module, which |
| 905 | is fiddly in a different way. Recorded because the directory-first option is |
| 906 | otherwise the cheaper-looking one. |
| 907 | |
| 908 | Paul's call; unresolved here (Β§15). |
| 909 | |
| 910 | **Unchanged: the colour engine stays a `ui` dependency** (Β§10.2). It is a |
| 911 | function rather than a component, and Docs remains a declared consumer of it |
| 912 | upstream. |
| 913 | |
| 914 | --- |
| 915 | |
| 916 | ## 11. Fonts, DOCX, PDF and Markdown |
| 917 | |
| 918 | ### 11.1 Fonts β the section revision 1 did not have |
| 919 | |
| 920 | The word "font" did not appear in revision 1. Deterministic layout is |
| 921 | impossible without settling this, so it is settled here. |
| 922 | |
| 923 | **A curated core set, self-hosted as woff2**, shipped to the browser and |
| 924 | embedded in the binary for server-side layout, so both measure identically. |
| 925 | |
| 926 | **Metric-compatible substitution for the proprietary faces DOCX imports |
| 927 | name.** Calibri maps to **Carlito** and Cambria to **Caladea** β both open |
| 928 | licensed and designed to have identical metrics, so an imported Word document |
| 929 | paginates the way Word paginated it rather than merely looking similar. |
| 930 | Everything unmapped substitutes through a documented table, and the import |
| 931 | report names the substitution, which is the degrade-loudly rule already |
| 932 | agreed (Β§11.2). |
| 933 | |
| 934 | **CJK and the wider locale set are not in the core payload.** Shipping every |
| 935 | locale's faces is tens of megabytes to the browser; per-locale lazy loading |
| 936 | is a subsystem rather than a decision, and it is in Β§14 with its trigger. |
| 937 | Until then, a document in an unshipped script lays out with substitution and |
| 938 | says so. |
| 939 | |
| 940 | ### 11.2 Import fidelity |
| 941 | |
| 942 | Model a real feature set and degrade loudly. The schema covers headings and |
| 943 | named styles, lists, tables, images, links, headers and footers, page setup |
| 944 | and comments. Import maps all of it and shows a report naming exactly what it |
| 945 | could not bring in, and where. |
| 946 | |
| 947 | Footnotes and tracked-changes import are cut from the first slice (Β§14). The |
| 948 | schema still models them, because a later import must not require a |
| 949 | migration. |
| 950 | |
| 951 | Rejected on the record: pandoc (a second runtime, breaks the static binary) |
| 952 | and conversion APIs (ships customer documents to a third party, contradicting |
| 953 | Β§2). |
| 954 | |
| 955 | **The conversion-API rejection stands, and suite Β§8 now says where its edge |
| 956 | is.** The suite's egress sentence is one rule for every app: egress that is a |
| 957 | property of the product is forbidden; egress that is per-team opt-in, to one |
| 958 | named recipient, disclosed in the README's first paragraph and on every |
| 959 | artefact it produced, with a working floor when it is off, is admissible |
| 960 | (suite ruling 6). |
| 961 | |
| 962 | **This is a qualification, not a discovery.** Β§11.2's own reason was that a |
| 963 | conversion API ships customer documents to a third party, and that reason |
| 964 | does not by itself distinguish an opt-in converter with one named provider |
| 965 | from a compulsory one. Suite ruling 6 draws that line; the earlier text did |
| 966 | not, and claiming it always meant to would be reading something into it that |
| 967 | is not there. A per-team, named, disclosed converter with a working floor |
| 968 | would satisfy suite Β§8 β Docs has not been asked for one and is not adding |
| 969 | one here. |
| 970 | |
| 971 | This closes `oficina/docs` discussion 33, which asked for the boundary to be |
| 972 | recorded rather than the rejection reversed. memoria's model provider is |
| 973 | admissible under the second clause and Docs does not restate that reasoning: |
| 974 | see suite Β§8. |
| 975 | |
| 976 | ### 11.3 Export |
| 977 | |
| 978 | DOCX as clean OOXML. PDF emitted from the same box tree the screen renders, |
| 979 | with **font subsetting and embedding** in `internal/pdf` β real machinery, |
| 980 | named here because revision 1 assumed it away. Markdown lossless within its |
| 981 | subset. |
| 982 | |
| 983 | ### 11.4 Thumbnails |
| 984 | |
| 985 | Revision 1 called these "nearly free because the render already exists". That |
| 986 | **conflated layout with rasterization**. The engine produces boxes; a |
| 987 | thumbnail is a raster image, needing glyph rasterization, image decode and |
| 988 | scale, and PNG composition β a pipeline nothing else needs, since the PDF path |
| 989 | embeds fonts and never rasterizes. `internal/raster/` is a real component. |
| 990 | Thumbnails stay, because they are worth it (Β§6.4), but they are not free. |
| 991 | |
| 992 | **Amended 2026-09-02, after building them.** The conflation was real and the |
| 993 | conclusion was half right. What this missed is the *scale*: the list's |
| 994 | thumbnail is 26Γ34, so a US-Letter page is about 23 points to the pixel and a |
| 995 | glyph is a fraction of a pixel wide. There is nothing to rasterize at that |
| 996 | size β a rasterizer would spend the whole pipeline above to produce a grey |
| 997 | smudge. So the shipped thumbnail draws the line boxes, and they are real |
| 998 | ones: `export.PageOne` runs the PDF exporter's own layout, with its own font |
| 999 | metrics and its own line breaking, and reports where every line of page one |
| 1000 | actually landed. Decision 11's "real rendered" is satisfied in the sense that |
| 1001 | matters β the row shows where this document's first page really breaks β and |
| 1002 | what is elided is the glyphs, not the layout. `internal/raster/` remains |
| 1003 | unbuilt and remains what a *larger* thumbnail would need; it moved from Β§13's |
| 1004 | sequence to Β§14's trigger list. |
| 1005 | |
| 1006 | ### 11.5 Blobs |
| 1007 | |
| 1008 | **All blob storage goes through `blobs.Sealed`** (`blobs/blobs.go:218`); the |
| 1009 | package doc at `:24` is explicit that E2EE apps wrap any backend in it. |
| 1010 | `ContentType` is set to `application/octet-stream`, because a truthful content |
| 1011 | type on a sealed import announces "this is a Word document" in a bucket that |
| 1012 | is otherwise ciphertext. The address becomes the ciphertext's hash, so dedup |
| 1013 | is per key and `Ref.Size` is the sealed size. |
| 1014 | |
| 1015 | --- |
| 1016 | |
| 1017 | ## 12. Testing gates |
| 1018 | |
| 1019 | Revision 1's gates would not have caught the failures it was most exposed to. |
| 1020 | Corrected. |
| 1021 | |
| 1022 | - `go build ./...`, `go vet ./...`, `go test ./... -count=1`, `gofmt -l .`, |
| 1023 | `CGO_ENABLED=0` throughout. `cargo test` and `cargo clippy` for the crate. |
| 1024 | - **Wire compatibility:** our Rust `y-crdt` updates are applied by upstream JS |
| 1025 | `yjs` and vice versa, against checked-in fixtures generated by the JS side. |
| 1026 | This is the CRDT half of Β§0 and the fixtures are the contract. |
| 1027 | - **Zero host imports:** the compiled wasm module must import nothing. |
| 1028 | *(Added 2026-08-30, from executing plan 1 β the review found `wasm-bindgen` |
| 1029 | in the wasm32 dependency graph via `yrs β fastrand("js") β |
| 1030 | getrandom("wasm_js")`.)* "No wasm-bindgen in the dependency graph" turned |
| 1031 | out to be **untestable while using `yrs`**, which enables that feature |
| 1032 | unconditionally. The property actually required is that the module needs no |
| 1033 | JS host, and that is directly checkable: zero imports. A dependency that is |
| 1034 | never called is dead-stripped and harmless; one that is called appears as an |
| 1035 | import and fails the gate. |
| 1036 | The corollary, which the gate enforces rather than trusts: **never call |
| 1037 | `yrs::Doc::new()`** β it reaches `fastrand` for a random client ID, which is |
| 1038 | what would pull JS entropy. Always `Doc::with_client_id()`, which is what an |
| 1039 | assigned server-side client ID wants anyway. `fastrand` is also reached from |
| 1040 | `yrs`'s observer paths, which is precisely why this is a build-time |
| 1041 | assertion rather than a rule somebody remembers. |
| 1042 | - **Layout agreement:** a browser drive renders fixture documents and asserts |
| 1043 | the DOM's actual page-break positions match the engine's computed ones. This |
| 1044 | is the gate revision 1 lacked β its conformance test compared wazero to a |
| 1045 | browser running *the same module* and never checked what the DOM painted. |
| 1046 | - **No entropy from the CRDT library**, enforced by `clippy.toml` |
| 1047 | `disallowed-methods` on `yrs::Doc::new`, `yrs::Doc::with_client_id` and |
| 1048 | `yrs::uuid_v4`, run before every build. *(Added 2026-08-30 from executing |
| 1049 | plan 1.)* On wasm the custom `getrandom` hook refuses to produce entropy |
| 1050 | and `fastrand` masks that with a constant seed, so anything drawing |
| 1051 | randomness from `yrs` is **identical across processes** β and a Yjs client |
| 1052 | id or document GUID that repeats across processes corrupts documents. |
| 1053 | Construction therefore always uses |
| 1054 | `Options::with_guid_and_client_id(guid, client_id)`, with both values |
| 1055 | assigned by the instance that owns the document, which is where they |
| 1056 | should come from anyway. |
| 1057 | |
| 1058 | This is a gate rather than a rule because the rule was wrong three times |
| 1059 | in three different ways while being written: the dependency was assumed |
| 1060 | dead-stripped (naming `Doc` pulls it in), an erroring hook was assumed to |
| 1061 | fail loudly (`fastrand` swallows it), and `Doc::with_client_id` was assumed |
| 1062 | safe (it draws a GUID). Each version was correct about what its author |
| 1063 | could see. A rule with that history does not get enforced by a comment. |
| 1064 | - **Performance budgets, as gates rather than hopes:** keystroke-to-repaint on |
| 1065 | a 120-page fixture, full layout time, and the wazero pool's peak memory |
| 1066 | against a 512MB ceiling. |
| 1067 | - **Long-divergence merge:** two clients diverge for a simulated week and |
| 1068 | reconcile, because Β§7.2 says days-long divergence stresses the merge hardest. |
| 1069 | - **Plaintext grep:** raw SQLite bytes and bucket objects, covering documents, |
| 1070 | the event log, comments and blobs. |
| 1071 | - **Scope:** a document not granted to a user 404s, for every read and write |
| 1072 | path. |
| 1073 | - **DOCX round-trip** against checked-in expectation files, compared |
| 1074 | structurally. Revision 1 said "reopened in a real Word file comparison", |
| 1075 | which is not runnable in CI on linux/arm64 β Word does not run there. |
| 1076 | - **A browser drive of the editor itself**, because a JS syntax error takes |
| 1077 | the whole client down. |
| 1078 | - 300-line cap per JS module, one coordinator at a stated higher cap. |
| 1079 | |
| 1080 | --- |
| 1081 | |
| 1082 | ## 13. Sequencing |
| 1083 | |
| 1084 | 1. The Rust crate: `y-crdt` wire fixtures green, then layout, with the |
| 1085 | layout-agreement gate from the first commit. |
| 1086 | 2. The wazero host with its pool, trap recovery and memory budget. |
| 1087 | 3. The Go app on rastrillo: list, create, open, server-rendered read-only. |
| 1088 | 4. The editor: ProseMirror, JS Yjs, decoration-based pagination. |
| 1089 | 5. Collaboration: transport, presence, offline, hibernation behaviour. |
| 1090 | 6. Sharing: grants, guests, link shares, the home outbox. |
| 1091 | 7. Markdown, then DOCX, then PDF. |
| 1092 | 8. Comments; version capture and restore; the outline pane. |
| 1093 | 9. ~~Thumbnails.~~ **Done 2026-09-02, and not the way this line assumed.** |
| 1094 | They did not wait for the layout engine: the PDF exporter's layout was |
| 1095 | enough to report where page one's lines fall, which at 26Γ34 is the whole |
| 1096 | of what a thumbnail shows. See Β§11.4's amendment. |
| 1097 | |
| 1098 | **Slip order, written rather than discovered:** the outline pane goes first, |
| 1099 | then offline. **The layout engine cannot slip**, because pagination, PDF and |
| 1100 | thumbnails all fall out of it. *(Thumbnails turned out not to β see Β§11.4's |
| 1101 | amendment. Pagination and PDF still do.)* |
| 1102 | |
| 1103 | --- |
| 1104 | |
| 1105 | ## 14. Deliberately not built yet |
| 1106 | |
| 1107 | | Not built | Trigger | |
| 1108 | |---|---| |
| 1109 | | Suggestion mode | Comments are in use and the absence of accept/reject is the complaint. Schema already models tracked changes | |
| 1110 | | Footnotes; tracked-changes import | The layout engine is stable enough that footnote-vs-page-break placement is a feature rather than a risk | |
| 1111 | | 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 | |
| 1112 | | Full-text search | See Β§15.2 β it needs a design compatible with the plaintext-grep gate, not just a decision | |
| 1113 | | Per-locale font loading | A team works in a script outside the core set | |
| 1114 | | Pageless mode | Long-document editing proves painful in page view | |
| 1115 | | Embedded live Sheets ranges | Both apps stable, and the seam between two collaboration models designed deliberately | |
| 1116 | | Templates | Somebody has made the same document three times | |
| 1117 | | Rasterized thumbnails (Β§11.4's `internal/raster/`) | A thumbnail is wanted at a size where glyphs are legible. The list already draws real page-one line boxes β Β§11.4 | |
| 1118 | |
| 1119 | --- |
| 1120 | |
| 1121 | ## 14a. Consumed suite rulings |
| 1122 | |
| 1123 | The suite sharing doctrine (`oficina/suite`) binds this spec. Read against |
| 1124 | **revision 4, commit `210312f`**. |
| 1125 | |
| 1126 | Rulings Docs consumes: |
| 1127 | |
| 1128 | | # | What it settles for Docs | |
| 1129 | |---|---| |
| 1130 | | 1 | The team is the boundary. A team's instances across every app are one trust domain, so a same-team memoria may hold team-visible documents. | |
| 1131 | | 2 | Sharing is membership across teams. Nothing crosses a team label; a link share moves nothing. | |
| 1132 | | 3 | Custody and authorization are two rules, and authorization is never cached at any scope. | |
| 1133 | | 5 | Two shapes, one switch. Docs is lone or suite, and the sibling-token switch is independent of it. | |
| 1134 | | 6 | The egress sentence. Β§11.2 cites it rather than restating it. | |
| 1135 | | 7 | One contract, one module, fixtures as the interface. Docs imports it, serves its half, and runs the conformance suite in CI. | |
| 1136 | | 8 | Two credentials: an assertion proves a person, a consumer token proves a same-team sibling. | |
| 1137 | | 9 | A write names its real actor. No sibling ever writes as a person. | |
| 1138 | | 12 | Membership is the home's own record. A directory row never grants team standing. | |
| 1139 | | 14 | Custody is bounded at acquisition and retired by the feed, not enforced by erasure. | |
| 1140 | | 15 | Three rungs and a read-only link. Docs' own "viewer or commenter, never editor" is the rule this adopts suite-wide. | |
| 1141 | |
| 1142 | Docs does **not** restate any of them. Where this spec previously did β the |
| 1143 | five-call contract in Β§9.1, the instance boundary in Β§8.2, the egress reason |
| 1144 | in Β§11.2 β the restatement has been replaced by a citation. |
| 1145 | |
| 1146 | **Not yet consumed**, because they need work this spec has not specified: |
| 1147 | the hand-off (suite Β§7.1), which needs a receipt route, pending state and a |
| 1148 | confirmation step on the create endpoint; and S8 write-as-self (suite Β§7.2), |
| 1149 | which needs a designated-area grant in the share UI. |
| 1150 | |
| 1151 | --- |
| 1152 | |
| 1153 | ## 15. Open |
| 1154 | |
| 1155 | 1. ~~**The passkey RP ruling**~~ β **closed 2026-08-30**, confirmed by Paul |
| 1156 | directly. Β§8.1 is settled: one relying party at the home's credential |
| 1157 | origin, instances verify signed assertions and never enroll passkeys. |
| 1158 | 2. **Full-text search has no design.** An FTS5 index is plaintext in SQLite |
| 1159 | and would fail our own gate (Β§12); decrypting the corpus per query is |
| 1160 | O(corpus). Title and metadata search ships; full text waits for a design, |
| 1161 | not for a decision. |
| 1162 | 3. ~~**rastrillo needs a release**~~ β **closed 2026-08-30.** `v0.20.0` is |
| 1163 | tagged, serving from the proxy, and level with `main`. Docs pins it rather |
| 1164 | than a pseudo-version. It also carries two things the chrome wants |
| 1165 | directly whenever that work resumes: menus that cap their height and flip |
| 1166 | to fit the viewport via CSS anchor positioning β so a style dropdown near |
| 1167 | the foot of a page opens upward with no script β and `scrollbar-gutter: |
| 1168 | stable` behind a token, which matters most against a fixed-width sheet of |
| 1169 | paper. Re-copy `tokens.css` deliberately on adopting it (Β§10.1). |
| 1170 | 4. ~~**The identity-assertion helper**~~ β **filed 2026-08-30** as |
| 1171 | `rastrillo/rastrillo` discussion #1 on amadan, drafted jointly with |
| 1172 | Sheets, both apps named as consumers. Neither app is blocked on it; |
| 1173 | both can hand-roll verification and would rather not do it twice, |
| 1174 | differently. |
| 1175 | 5. **`.amadan/ci.d/10-gate`** arrives with the first code branch. |
| 1176 | 6. **ABI v2 state export**, before `internal/core` has a real caller. The |
| 1177 | in-memory replay log is capped at 4096 updates with a clean failure, which |
| 1178 | bounds the count but not the bytes, has no recovery path, and would make a |
| 1179 | long-lived document unloadable. Acceptable only while nothing imports the |
| 1180 | package β verified by grep, not assumed. |
| 1181 | 7. **The three-language cost.** Revisit once the crate is real. Revision 2 |
| 1182 | already spent this clause once β the "same artifact both sides" claim did |
| 1183 | not survive contact β so the next honest answer may be that the crate |
| 1184 | shrinks to layout alone. |
| 1185 | |