oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1# πŸ€– Docs β€” design
2
3Design, 2026-08-30. Direction approved by Paul in conversation; this is the
4written form for review.
5
6**Revision 2, after a Fable review of revision 1 returned "not ready to
7become an implementation plan" with seventeen defects.** Three of them holed
8the centre of the design. What follows is the corrected version, and the
9sections that changed say what they used to claim and why it was wrong β€”
10because a spec that quietly fixes its own errors teaches the next reader
11nothing.
12
13CARLOS Docs is a document editor: WYSIWYG, paginated, collaborative, with
14DOCX, PDF and Markdown in and out. One instance per team, documents shared
15inside it and with outside guests, on the CARLOS platform.
16
17Built alongside [Sheets](https://amadan.net/oficina/sheets) and a shared
18suite home. Where a decision is already made over there, this spec consumes
19it rather than restating it.
20
21---
22
23## 0. The one rule, stated correctly
24
25Revision 1 said: *"the document's content and its layout are computed in
26exactly one place."* **That was false and unachievable**, because the editor
27is ProseMirror, ProseMirror is contentEditable, and contentEditable means
28the *browser* breaks lines. The rule and the editor choice contradicted each
29other, and the conformance gate could not have caught it.
30
31The corrected rule, in two halves, each true:
32
33**Layout: the engine owns page geometry; the browser owns line breaking
34inside it.** The engine decides page size, margins, and where blocks break
35across pages. The browser lays out lines within that geometry, using the
36same self-hosted fonts the engine measures with (Β§11.1). Divergence is
37confined to the line level and is small when fonts match; it is not zero,
38and this spec does not claim it is.
39
40**The CRDT: one wire format, two implementations, fixtures as the
41contract.** The browser runs JS `yjs`; the server runs Rust `y-crdt`. They
42agree because checked-in fixtures generated by the JS side must be
43reproduced by the Rust side, which is the golden-vector discipline CARLOS
44already mandates for a protocol living in two languages.
45
46Revision 1 claimed "the same compiled artifact both sides" and treated that
47as strictly better than golden vectors. It was not available: `ywasm` is
48built with wasm-bindgen and will not instantiate under wazero, so one
49artifact 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** β€”
51which is to say giving up the implementation path for offline and presence,
52in 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
77Decision 8 changed. Revision 1 put suggestion mode in the first slice and
78then admitted in its own sequencing that it was the item most likely to
79slip β€” a spec planning its own violation. Suggestion mode, footnotes with
80tracked-changes import, and the version-history timeline are now in Β§14.
81
82---
83
84## 2. Trust model, and the leak list
85
86Sealed at rest, in Seapointish's position. Content is encrypted in SQLite
87under a DEK delivered by `carlos secrets`, so the Litestream-replicated
88bucket holds ciphertext. The running instance holds the DEK, which is what
89lets Go render a PDF, produce a thumbnail, and export.
90
91**Enforced, not asserted.** A test greps raw SQLite bytes and bucket objects
92for known plaintext from a fixture document and fails if it finds any. That
93test is the boundary; the prose is a description of it.
94
95### 2.1 The leak list
96
971. **The running instance can read everything.** It holds the DEK. This is
98 the deliberate trade that buys server-side export and thumbnails.
992. **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.
1023. **Blob metadata is visible.** `ContentType` passes through and the
103 address is the ciphertext's hash (Β§11.5).
1044. **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).
1075. **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.
1106. **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
119One 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
122platform.
123
124```
125cmd/docs/ main: dispatch and env only
126internal/docs/ models, app, handlers, render β€” the rastrillo shape
127internal/core/ the wazero host: module lifecycle, marshalling (Β§5.2)
128internal/docx/ OOXML read and write
129internal/pdf/ PDF emission, font subsetting and embedding
130internal/raster/ thumbnail rasterization (Β§11.4)
131core/ the Rust crate: layout + y-crdt, compiled to WASM
132static/ vendored ProseMirror + yjs bundles, editor modules, fonts
133```
134
135---
136
137## 4. Data model
138
139```
140documents id, sqid, title, created_by, created_at, updated_at,
141 page_setup (sealed), deleted_at
142doc_updates doc_id, seq, update (sealed), actor_id, created_at
143doc_snapshots doc_id, seq, state (sealed), created_at
144versions doc_id, seq, name, actor_id, created_at -- named, pinned
145grants doc_id | NULL, subject, role, created_by, created_at
146memberships user_id, role, created_at
147comments 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
153Revision 1 made `doc_updates` "both the sync log and the version history β€”
154one structure, not two", and presented that as elegant. **It forbids
155compaction forever**: every keystroke-granularity update becomes a sealed
156row, retained for the life of the document, with per-row AES-GCM overhead on
157payloads of a few bytes, amplifying the WAL stream the platform replicates.
158
159Three 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
171because the naive reading says it does.** A snapshot encodes full state, so a
172client returning after the horizon receives one large update instead of many
173small ones and still converges β€” that is the CRDT's whole point. What
174compaction costs is *history* between snapshots, not reachability.
175
176**The horizon is a fixed period, never "once every client has caught up".**
177Any prune-when-all-consumers-have-advanced rule is unbounded in a
178hibernating fleet, because a dormant consumer never advances and the table
179grows forever while reading as bounded. That pattern has now appeared twice
180in the home spec β€” a `jti` store and a rekey journal β€” and this is the third
181place it would have fitted.
182
183**Cutting the version timeline (Β§14) is what makes compaction tractable.**
184With a browsable timeline, every historical point must remain reachable and
185the log can never be collapsed. With named versions only, the set of points
186that must survive is finite, small, and explicitly chosen by a person β€”
187named versions pin their snapshot and are never compacted away. The scope
188cut 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
191log, 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`
196declares `func Owned` at `:19` with the body `g.Where("user_id = ?", owner)`
197at `:20`; `OwnedBy` at `:27` is the same thing with a configurable column.
198Neither expresses "rows this team can see, plus rows shared with me, minus
199rows I have been removed from."
200
201Applied to a team-shared table, `Owned` returns only the creator's rows. It
202fails as a **short list, not an error** β€” a document library that looks empty
203rather than broken, which is the worst available failure shape, on the helper
204an author would naturally reach for.
205
206Docs implements its own scope over a `grants` join, preserving the
207404-not-403 contract. One function, tested directly, and no handler queries
208documents any other way.
209
210### 4.3 Rejected: `rastrillo/eventlog`
211
212Recorded because the doctrine requires the rejected alternative to be named,
213and revision 1 omitted it. `eventlog` carries JSON payloads and a Lamport
214merge order. Yjs updates are opaque binary and carry their own ordering, so
215the payload shape fights it and the merge order is redundant. `doc_updates`
216is a plain append-only table instead.
217
218---
219
220## 5. The Rust core
221
222Two hard parts are badly served by Go. **The CRDT**: the reference non-JS
223implementation of Yjs is `y-crdt`, and it is Rust; the Go ports are young and
224are not what other implementations are tested against. **Text layout**: Rust
225has `rustybuzz`, `swash` and `parley`; Go's `x/image/font/sfnt` reads metrics
226and cannot shape complex scripts.
227
228So one Rust crate, compiled to WASM, run server-side through **wazero** β€” a
229WebAssembly runtime in pure Go with no C dependency, so the binary stays
230static and cgo-free. It does two jobs: apply and query the CRDT, and lay out
231documents.
232
233The browser does **not** run this crate. It runs JS `yjs` with
234`y-prosemirror` and `y-indexeddb`, and its layout duty is line breaking
235inside geometry the crate computed (Β§6.1). This is the revision-2 correction;
236see Β§0.
237
238### 5.1 What the server actually does with it
239
240Apply incoming updates, produce snapshots, and lay out documents for export
241and thumbnails. **The server never lays out per keystroke** β€” that is the
242browser's job β€” so wazero's throughput is not on the hot path. Its duty
243cycle 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
309The engine computes page geometry and the block-level break positions. The
310browser renders those breaks and lays out lines within them.
311
312**Page breaks are decorations, never nodes** (decision 14). Inserting spacer
313nodes into the document is the common approach and it is a trap: it causes
314layout reflows on every keystroke and makes the page count flicker. Using
315ProseMirror's `DecorationSet` β€” a position-aware structure that maps
316efficiently as the document changes β€” keeps breaks out of the document
317entirely. Which also means **page geometry never enters the CRDT**, so two
318collaborators on different page setups never fight, and pagination is not
319something that can conflict.
320
321Editing 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
336scriptless comment creation anchors to a **block**, and the degradation is
337stated rather than discovered. Replying and resolving are unaffected.
338
339The rule the framework asks for is that no *form* requires script. That
340holds. Editing a document is not a form.
341
342### 6.3 Chrome
343
344A short toolbar β€” paragraph style, weight and emphasis, lists, link,
345alignment β€” grouped with dividers, plus an inspector that opens on the right
346for depth (page setup, styles, headers and footers, image placement) and
347closes again, so the page has full width by default.
348
349Rejected: the dense undifferentiated toolbar, the always-on inspector, and
350the floating-only model, which leaves page setup nowhere to live.
351
352### 6.4 The document list
353
354A list, newest first, grouped by recency, with a real rendered thumbnail of
355page 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
357commercial and platform chrome.
358
359### 6.5 ProseMirror, re-examined
360
361Re-checked against Lexical after the review, since decision 4 was made
362before decision 5 changed what the editor has to do. Kept, for three
363reasons 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
375Lexical's real advantage is React ergonomics. There is no build step here and
376no React, so that is a strength we cannot spend.
377
378---
379
380## 7. Collaboration
381
382### 7.1 Merge and transport
383
384JS `yjs` with `y-prosemirror` in the browser; Rust `y-crdt` server-side.
385Character-level convergence, so two people in one paragraph both keep their
386words.
387
388**Updates travel over a Server-Sent Events stream plus the ordinary update
389POST, and the sequence number is the document's own, not a connection's.**
390This is revision 3, and it replaces rather than annotates what revisions 1 and
3912 said here β€” see the correction below for why the old wording could not
392honestly describe what shipped.
393
394The stream (`GET /d/{sqid}/stream`) carries a seq and no document bytes at
395all. It says "this document has content through seq N"; the client fetches
396whatever is newer than its own cursor through `GET /d/{sqid}/updates?since=N`,
397the same binary endpoint it already replays at mount. Upstream is unchanged:
398the client POSTs batched updates, and the response names the seq that row
399received. `doc_updates.seq` is that number β€” durable, global to the document,
400and meaningful across connections, tabs and devices.
401
402The 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
406typo.** 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
408with a monotonic sequence number **per connection**, and the server
409acknowledges the high-water mark." A per-connection counter cannot survive the
410connection. It cannot tell a reconnecting tab what it missed, which makes it
411useless for exactly the case Β§7.3's idle timeout guarantees will happen
412constantly β€” every parked tab, every hibernation cycle, every five-minute
413stream TTL. The document's own log seq answers that by construction: a tab
414that reconnects after an hour asks `?since=` its own cursor and receives
415precisely what it missed.
416
417So what shipped is not this section's fallback with the socket still to come.
418It is a different and better protocol, and calling it "the fallback" would
419have left the spec describing a design nobody built. (Paul's ruling,
4202026-09-01, on the live-sync spec's Β§9.)
421
422**The WebSocket is now the upgrade that arrives with presence, not the
423primary this replaced.** Awareness needs ephemeral, high-frequency, duplex
424messages, which is what a socket is for and what an SSE-plus-POST pair is bad
425at; content sync needs none of that. When presence lands, the socket lands
426with it, and the update-log format above is what it will stream β€” unchanged,
427because 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 β€”
432available because the browser runs JS Yjs (Β§0).
433
434Two consequences: it moves conflict resolution from seconds to days, which
435stresses the merge far harder than live editing; and it puts plaintext on the
436user's device, which is leak-list item 6.
437
438### 7.3 Presence and hibernation
439
440Presence renders nothing without script rather than degrading to a stale
441list. A stale presence indicator asserts somebody is there, which is a lie
442rather than a degradation. A view link gets no cursor and no name, which
443falls out of Β§8.2's attribution rule rather than being a special case.
444
445**Hibernation, which revision 1 never mentioned.** Platform hibernation is
446live and default-on, and Docs holds a WebSocket per open editor β€” so a
447document 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
459Threads anchor to a **Yjs relative position**, so an anchor survives
460concurrent edits and re-flow. Scriptless creation anchors to a block (Β§6.2).
461
462When the anchored text is deleted the thread is **orphaned, not destroyed**:
463it moves to a detached list carrying its last known location and its content,
464with a re-attach affordance. A comment is somebody's words about something,
465and losing the anchor is not permission to lose the words. Sheets reaches the
466same obligation from the opposite direction β€” a stable anchor whose content
467is 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
476treated the home as the only way in, which made instance-local sign-in a
477stopgap. It is not. There are two deployment shapes and they have different
478correct 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
506So an instance is built to accept identity from **either**: its own
507magic-link flow, or a verified assertion. The second is not a replacement for
508the first; it is what a suite adds.
509
510**Why magic-link-at-the-instance does not violate the one-relying-party
511ruling.** That ruling is about **passkeys** β€” a person in three teams must not
512enrol three credentials, and `passkey.Config.Origin` derives the RPID from the
513app's own host. Magic link enrols **no credential at all**, so it creates no
514relying party and strands nothing. An instance running it and later accepting
515assertions 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
518rules that **instances hold readable addresses and the home never does**. An
519app instance storing an address is the design, not a deviation β€” which is
520exactly 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
524Revision 1 held three positions at once: it specced instance-local
525`rastrillo/auth` magic links, then said sign-in was unresolved, while Β§9 call
5261 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
529only relying party.** Instances never enroll passkeys and never mount `Gate`.
530Sign-in happens at the home for members and guests alike; the passkey second
531factor completes there, on the RP origin, *before any assertion exists*, so
532no cross-origin hand-off is required. Instances receive identity as **signed
533assertions** and verify a signature β€” Tito's pattern, where signing rather
534than stripping is what stops a peer socket forging them.
535
536**Confirmed by Paul directly, 2026-08-30.** It first reached this session by
537relay from the Sheets session, and was held as unconfirmed until asked
538directly β€” a relayed ruling is not a confirmed one, and both sessions applied
539that standard to each other in both directions during this design.
540
541**Assertions carry identity, never authorization.** A guest's assertion is
542the same shape as an owner's; the instance decides capability from its own
543`grants` table. Encoding capability would require the home to know
544capability, putting an authorization model inside the identity origin.
545Expiry is seconds β€” it is exchanged immediately for a session.
546
547**The framework gap that remains** is that rastrillo has no helper for
548verifying a signed assertion: audience binding, replay, key rotation and
549algorithm confusion are all hand-rolled per app. Filed jointly with Sheets,
550Docs as a named consumer, with one Docs-specific requirement β€” our
551single-use replay store churns against a guest population far larger than
552the membership list and unbounded in time, so **it must be self-pruning,
553sized by assertion lifetime rather than user count.**
554
555**The cross-device magic link β€” closed 2026-08-30.** A link-share viewer who
556signs in to comment completes the link in whichever browser opens the email,
557so the capability context must survive that round trip or opening it on a
558phone strands the pending state on the laptop. `auth.Config.Authorize` is
559`func(address string) bool` and cannot carry document scope.
560
561The resolution keeps capability **entirely instance-side**, which is what
562makes it compatible with "assertions carry identity, never authorization":
563
5641. 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.
5662. The person signs in at the home. The home learns nothing about links or
567 capabilities; it mints an ordinary assertion.
5683. 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
571Because the nonce rides the URL rather than a cookie, the round trip
572reassembles in whichever browser opened the email β€” the cross-device problem
573falls out of the same fix rather than needing its own. *(Resolved jointly
574with the home session; the tempting alternative was letting the assertion
575carry the capability, which would have holed Β§5.3 of the home spec in the
576same week it was tightened.)*
577
578This is the seam where Β§8.2's central rule executes, and it gets its own
579test: an assertion for a person with no grant is a valid assertion that gets
580nothing, and the nonce redemption is what turns it into access.
581
582### 8.2 Sharing
583
584Team members see team documents. Outside guests are granted a specific
585document and become guest members. Link shares are a per-document
586capability, viewer or commenter, never editor.
587
588**Anything that writes is attributed, and attribution is what mints
589membership.** A view link mints nothing. The moment somebody comments they
590sign in, which mints a guest member and produces a directory entry then.
591
592"Sharing is membership" forbids *data crossing a team boundary* (suite
593ruling 2). A link does not move data β€” whoever holds it reads the document
594here, 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
598the suite contract, and Docs serves S1, S2, S3 and S8 wherever sibling
599consumer tokens are in its platform config. That switch is independent of
600whether a home is present (suite Β§3): Docs serves a lone memoria with no
601home in the picture.
602
603**Only the team rung is team-visible** (suite ruling 15). A document that is
604private, granted to named people, or reachable only by link is never
605team-visible, so no sibling may hold a copy of it β€” and Β§8.2's existing rule
606that a link share is *viewer or commenter, never editor* is the rule suite
607ruling 15 adopts suite-wide. It stays here as the original; the doctrine
608cites it rather than restating it.
609
610---
611
612## 9. The home, as a dependency
613
614Re-synced 2026-08-30 against the revised Sheets spec. **Revision 1 was
615written against a superseded draft** and got the contract wrong in two ways
616that mattered: it said four calls where there are five, and it had the
617instance 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
622contract as `oficina/suite` states it.** Neither is copied here.
623
624This section used to list five calls. That list was the drift the suite
625doctrine was written to stop: it was correct when written, the home spec now
626has six calls, the activity design adds a seventh and the roster an eighth,
627and nothing made this copy notice. A reader who trusted it would have
628implemented against a contract that no longer existed.
629
630Two 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
640The rules below belong to the home spec or the suite doctrine and are cited,
641not 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
681Per person, never per document, so it lives with the home as suite-level
682account state. **It must resolve before first paint**, which makes it a
683cookie on the instance's own origin, written from the assertion at session
684mint β€” never a fetch the first paint waits on.
685
686---
687
688## 10. Design system
689
690Everything around the document is design system; the canvas diverges, because
691a page has different requirements from an admin screen. **The thing to watch
692is drift in the chrome**: if you find yourself copying a partial and editing
693three lines, propose it upstream instead.
694
695### 10.1 Three states, not two β€” and the release gap
696
697Revision 1 treated the design-system spec as shipped code. Revision 2 first
698corrected that against the wrong tree. Both errors have the same root: **a
699spec ruling, code on `main`, and code in the latest release are three
700different things**, and reading any one of them tells you nothing about the
701other two.
702
703Verified 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:
713carries `ThemeCSS`, `ThemeNames` and `LayoutNames`; zero commits behind
714`main`). For six days `v0.19.0` was the only release and lacked the themes,
715shells and twelve locales the design-system spec spends its length
716describing, so an app running `go get` got none of them. Docs pins `v0.20.0`
717rather than a `main` pseudo-version.
718
719*(A measurement correction worth keeping, because it is the same shape as the
720two traps below: the release was described β€” including to this spec β€” as
721removing 20 MB of vendored gallery from `go get`. Measured, the 20 MB never
722reached 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
724and applies only to someone who was on a `main` pseudo-version, which Docs
725was. `v0.20.0` is in fact **larger** than `v0.19.0`, because three themes,
726twelve catalogs and a date parser are real content. A plausible number that
727nothing contradicted until somebody measured.)*
728
729*(Two traps for whoever checks this next. The repo squash-merges, so `git
730branch --merged` reports nothing useful about whether work has landed β€”
731compare content, not ancestry. And a local checkout may sit on a feature
732branch 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
736This reversed twice in one day, which is worth recording because both
737reversals were the same mistake in opposite directions: applying a
738conclusion 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
742in no rastrillo tree (verified: spec-only at `origin/main` `7a30456`, no
743branch working on it) so an app writing it renders unstyled. **Then reversed
744back**, once `oficina/estilo` existed, because that argument is about an app
745consuming 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
756independently verified: `<div rst-*>` is shipped (`v0.21.0` styles both
757spellings) and the design-system guidance exists. Docs writes the attribute
758grammar, adopting **per screen rather than per element** β€” mixed authoring
759inside one component matches neither spelling by design β€” and pinning
760`v0.21.0` or later, below which attributes render unstyled.
761Docs 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
763decision that is about to be made upstream.
764
765**Until it is decided: change nothing, and let no task quietly decide it.** A
766parked question that an implementer resolves in passing is worse than an open
767one, because it looks settled to the next reader and nobody knows it was
768chosen rather than ruled.
769
770Estilo ships its own components *and their styling*, so attribute-grammar
771components styled by estilo are not interim anything β€” that is just estilo's
772styling, permanently, with nothing to migrate later. The unstyled-today
773problem only ever applied to markup leaning on rastrillo's stylesheet.
774
775And estilo is the variant system that exists *because* of this direction, so
776building it on the spelling being replaced is backwards. Estilo is where the
777new grammar gets proven rather than where it waits.
778
779Docs consumes rastrillo partials for chrome, and those emit classes. That is
780rastrillo's output, not a choice this spec makes, and it changes when
781rastrillo changes.
782
783`tokens.css` is frozen into `static/` at scaffold time while partials upgrade
784with the module. **`rastrillo doctor` landed in `v0.21.0`**, so the manual
785re-copy step is retired: `doctor` compares an app's frozen `static/` against
786the module's and `--fix` re-copies, and it refuses across a version mismatch
787rather than manufacturing the fault it detects.
788
789### 10.2 Colour
790
791Docs is a declared consumer of the colour engine, recorded upstream in the
792design-system spec (`fe26c68`).
793
794`Pair(hue, chroma, background)` takes an explicit background. Docs calls it
795against **four** β€” paper white, dark paper, and the theme surfaces β€” because
796the same stored highlight is read on white paper by one person and dark paper
797by another, in the same document, at the same moment. *(Docs' canvas case is
798what established the original `scheme` enum could not work: two of those four
799live inside one scheme.)*
800
801Author colours: hash the opaque key for the preferred hue, then deterministic
802open addressing β€” probe `(hash(key) + i) mod N` over a **lexicographic
803ordering of the key set**, taking the first hue neither taken in this pass nor
804in `avoid`. Allocation must never depend on arrival order, or two clients
805render the same comment in different colours and both trust it. `avoid`
806applies **inside** the probe; filtering first changes the modulus and shifts
807every allocation. The key is the person's identity, or their membership row
808for a guest. Past capacity the function reports that separation is no longer
809guaranteed and the name carries identity, which Β§10.3 requires anyway.
810
811### 10.3 Accessibility
812
813WCAG 2.2 AA, no exemptions: 24Γ—24 targets, 320px reflow, and colour never
814carries meaning alone β€” which binds the presence stack directly. Every
815user-visible string is a catalog key; twelve base locales.
816
817### 10.4 Primitives, and where they go
818
819Docs owns **inspector panel**, **presence stack** and **comment thread**;
820Sheets owns toolbar groups. Each carries a sentence saying what it is *not*,
821every string is a prose key with the eleven non-English locales
822machine-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
825Paul, 2026-08-30, correcting earlier advice this spec had taken. An
826inspector panel shared by a document editor and a spreadsheet is the
827*suite's* component; the design system stays rastrillo's.
828
829The variant is **estilo**, at `amadan.net/oficina/estilo` β€” named by Paul and
830created 2026-08-30. Primitives stay app-local until one app copies or imports
831another's; **a copy is the trigger**, because "a second consumer appears"
832never fires while copying is cheap and immediate.
833
834**Estilo's components take an `estilo-*` prefix, never `rst-*`.** That
835namespace belongs to the framework and a future version may claim a name we
836took β€” 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
839components belong, the extraction rule, and upgrading via `doctor`. Its
840earlier claim that the class spelling would be dropped before 1.0 has been
841corrected to what is true today: attributes are what the framework writes,
842and classes are kept so apps can migrate.
843
844**`v0.22.0` (2026-08-31): the framework itself now writes the attribute
845grammar.** Verified β€” `class=` occurrences in `ui/ui.go` fell from 56 to 5,
846the remainder being the utilities that keep the class spelling by design. So
847Docs and rastrillo now write the same spelling.
848
849**`rastrillo markup` exists**, the codemod that performed that flip. Docs does
850not need it β€” we were written in the attribute grammar from the first line β€”
851but the order matters if it is ever used: `doctor --fix` first, because the
852CSS 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
854catches: markup flips, stylesheet does not.
855
856**One breaking rename in `v0.22.0` does not reach us**, checked rather than
857assumed: 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
859neither. The upgrade from `v0.21.0` is therefore optional and low-risk, and
860is Paul's call rather than an overnight dependency bump.
861
862The correction is worth keeping because the reasoning was subtly wrong
863rather 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
865nothing about **where to**, and the earlier answer treated the first
866question as settling the second.
867
868A variant depends on `ui`, keeps its bones β€” tokens, partial vocabulary,
869accessibility floors β€” and adds or replaces on top with its own components,
870themes and gallery. Not a fork, and more than a theme: a theme changes
871colour, 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
874module's own vocabulary. It is what a variant's gallery needs, so neither
875suite 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
878repo is cheap and reversible; an interface two apps depend on is neither.
879Creating a repo commits nobody to a shape, and agreeing an inspector's props
880commits everybody.
881
882Two specs are guesses; two implementations are facts. A document editor's
883inspector holds text and style properties, a spreadsheet's holds cell format
884and data validation, and an abstraction designed from both specs at once
885risks 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
888two implementations merged, and not an interface designed from two specs. The
889second *use* corrects the shape, which is what working code tells you and a
890spec cannot. Until that second adoption the shared place is a **home, not an
891agreed interface** β€” treating it as an interface earlier produces exactly the
892ill-fitting abstraction the sequencing exists to avoid.
893
894**What a separate repo costs, priced rather than assumed:** CI, a release
895process, a version, and a dependency edge into two apps. It also reproduces
896the frozen-`tokens.css` trap one layer down β€” the variant pins a rastrillo
897version, the apps pin a variant version, and there are two pins to keep in
898step instead of one. That is the trap that already cost this project a manual
899runbook step and a `rastrillo doctor` to mechanise.
900
901**Against the alternative of starting it as a directory inside whichever app
902builds first:** Go's module boundaries make the second consumer's import
903awkward β€” importing `oficina/docs/...` would drag the entire Docs application
904in as a dependency of Sheets, and avoiding that means a nested module, which
905is fiddly in a different way. Recorded because the directory-first option is
906otherwise the cheaper-looking one.
907
908Paul's call; unresolved here (Β§15).
909
910**Unchanged: the colour engine stays a `ui` dependency** (Β§10.2). It is a
911function rather than a component, and Docs remains a declared consumer of it
912upstream.
913
914---
915
916## 11. Fonts, DOCX, PDF and Markdown
917
918### 11.1 Fonts β€” the section revision 1 did not have
919
920The word "font" did not appear in revision 1. Deterministic layout is
921impossible without settling this, so it is settled here.
922
923**A curated core set, self-hosted as woff2**, shipped to the browser and
924embedded in the binary for server-side layout, so both measure identically.
925
926**Metric-compatible substitution for the proprietary faces DOCX imports
927name.** Calibri maps to **Carlito** and Cambria to **Caladea** β€” both open
928licensed and designed to have identical metrics, so an imported Word document
929paginates the way Word paginated it rather than merely looking similar.
930Everything unmapped substitutes through a documented table, and the import
931report names the substitution, which is the degrade-loudly rule already
932agreed (Β§11.2).
933
934**CJK and the wider locale set are not in the core payload.** Shipping every
935locale's faces is tens of megabytes to the browser; per-locale lazy loading
936is a subsystem rather than a decision, and it is in Β§14 with its trigger.
937Until then, a document in an unshipped script lays out with substitution and
938says so.
939
940### 11.2 Import fidelity
941
942Model a real feature set and degrade loudly. The schema covers headings and
943named styles, lists, tables, images, links, headers and footers, page setup
944and comments. Import maps all of it and shows a report naming exactly what it
945could not bring in, and where.
946
947Footnotes and tracked-changes import are cut from the first slice (Β§14). The
948schema still models them, because a later import must not require a
949migration.
950
951Rejected on the record: pandoc (a second runtime, breaks the static binary)
952and 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
956is.** The suite's egress sentence is one rule for every app: egress that is a
957property of the product is forbidden; egress that is per-team opt-in, to one
958named recipient, disclosed in the README's first paragraph and on every
959artefact 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
963conversion API ships customer documents to a third party, and that reason
964does not by itself distinguish an opt-in converter with one named provider
965from a compulsory one. Suite ruling 6 draws that line; the earlier text did
966not, and claiming it always meant to would be reading something into it that
967is not there. A per-team, named, disclosed converter with a working floor
968would satisfy suite Β§8 β€” Docs has not been asked for one and is not adding
969one here.
970
971This closes `oficina/docs` discussion 33, which asked for the boundary to be
972recorded rather than the rejection reversed. memoria's model provider is
973admissible under the second clause and Docs does not restate that reasoning:
974see suite Β§8.
975
976### 11.3 Export
977
978DOCX as clean OOXML. PDF emitted from the same box tree the screen renders,
979with **font subsetting and embedding** in `internal/pdf` β€” real machinery,
980named here because revision 1 assumed it away. Markdown lossless within its
981subset.
982
983### 11.4 Thumbnails
984
985Revision 1 called these "nearly free because the render already exists". That
986**conflated layout with rasterization**. The engine produces boxes; a
987thumbnail is a raster image, needing glyph rasterization, image decode and
988scale, and PNG composition β€” a pipeline nothing else needs, since the PDF path
989embeds fonts and never rasterizes. `internal/raster/` is a real component.
990Thumbnails 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
993conclusion was half right. What this missed is the *scale*: the list's
994thumbnail is 26Γ—34, so a US-Letter page is about 23 points to the pixel and a
995glyph is a fraction of a pixel wide. There is nothing to rasterize at that
996size β€” a rasterizer would spend the whole pipeline above to produce a grey
997smudge. So the shipped thumbnail draws the line boxes, and they are real
998ones: `export.PageOne` runs the PDF exporter's own layout, with its own font
999metrics and its own line breaking, and reports where every line of page one
1000actually landed. Decision 11's "real rendered" is satisfied in the sense that
1001matters β€” the row shows where this document's first page really breaks β€” and
1002what is elided is the glyphs, not the layout. `internal/raster/` remains
1003unbuilt and remains what a *larger* thumbnail would need; it moved from Β§13's
1004sequence to Β§14's trigger list.
1005
1006### 11.5 Blobs
1007
1008**All blob storage goes through `blobs.Sealed`** (`blobs/blobs.go:218`); the
1009package 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
1011type on a sealed import announces "this is a Word document" in a bucket that
1012is otherwise ciphertext. The address becomes the ciphertext's hash, so dedup
1013is per key and `Ref.Size` is the sealed size.
1014
1015---
1016
1017## 12. Testing gates
1018
1019Revision 1's gates would not have caught the failures it was most exposed to.
1020Corrected.
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
10841. The Rust crate: `y-crdt` wire fixtures green, then layout, with the
1085 layout-agreement gate from the first commit.
10862. The wazero host with its pool, trap recovery and memory budget.
10873. The Go app on rastrillo: list, create, open, server-rendered read-only.
10884. The editor: ProseMirror, JS Yjs, decoration-based pagination.
10895. Collaboration: transport, presence, offline, hibernation behaviour.
10906. Sharing: grants, guests, link shares, the home outbox.
10917. Markdown, then DOCX, then PDF.
10928. Comments; version capture and restore; the outline pane.
10939. ~~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,
1099then offline. **The layout engine cannot slip**, because pagination, PDF and
1100thumbnails all fall out of it. *(Thumbnails turned out not to β€” see Β§11.4's
1101amendment. 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
1123The suite sharing doctrine (`oficina/suite`) binds this spec. Read against
1124**revision 4, commit `210312f`**.
1125
1126Rulings 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
1142Docs does **not** restate any of them. Where this spec previously did β€” the
1143five-call contract in Β§9.1, the instance boundary in Β§8.2, the egress reason
1144in Β§11.2 β€” the restatement has been replaced by a citation.
1145
1146**Not yet consumed**, because they need work this spec has not specified:
1147the hand-off (suite Β§7.1), which needs a receipt route, pending state and a
1148confirmation step on the create endpoint; and S8 write-as-self (suite Β§7.2),
1149which needs a designated-area grant in the share UI.
1150
1151---
1152
1153## 15. Open
1154
11551. ~~**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.
11582. **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.
11623. ~~**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).
11704. ~~**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.
11755. **`.amadan/ci.d/10-gate`** arrives with the first code branch.
11766. **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.
11817. **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