oficina / docs Public
π€ Docs β design
Design, 2026-08-30. Direction approved by Paul in conversation; this is the written form for review.
Revision 2, after a Fable review of revision 1 returned "not ready to become an implementation plan" with seventeen defects. Three of them holed the centre of the design. What follows is the corrected version, and the sections that changed say what they used to claim and why it was wrong β because a spec that quietly fixes its own errors teaches the next reader nothing.
CARLOS Docs is a document editor: WYSIWYG, paginated, collaborative, with DOCX, PDF and Markdown in and out. One instance per team, documents shared inside it and with outside guests, on the CARLOS platform.
Built alongside Sheets and a shared suite home. Where a decision is already made over there, this spec consumes it rather than restating it.
---
0. The one rule, stated correctly
Revision 1 said: "the document's content and its layout are computed in exactly one place." That was false and unachievable, because the editor is ProseMirror, ProseMirror is contentEditable, and contentEditable means the browser breaks lines. The rule and the editor choice contradicted each other, and the conformance gate could not have caught it.
The corrected rule, in two halves, each true:
Layout: the engine owns page geometry; the browser owns line breaking inside it. The engine decides page size, margins, and where blocks break across pages. The browser lays out lines within that geometry, using the same self-hosted fonts the engine measures with (Β§11.1). Divergence is confined to the line level and is small when fonts match; it is not zero, and this spec does not claim it is.
The CRDT: one wire format, two implementations, fixtures as the contract. The browser runs JS yjs; the server runs Rust y-crdt. They agree because checked-in fixtures generated by the JS side must be reproduced by the Rust side, which is the golden-vector discipline CARLOS already mandates for a protocol living in two languages.
Revision 1 claimed "the same compiled artifact both sides" and treated that as strictly better than golden vectors. It was not available: ywasm is built with wasm-bindgen and will not instantiate under wazero, so one artifact for both hosts would have meant a hand-rolled C ABI and giving up y-prosemirror, y-indexeddb, y-websocket and the awareness protocol β which is to say giving up the implementation path for offline and presence, in exchange for a purity the spec was congratulating itself on.
---
1. Settled decisions
| # | Decision | Date | |---|---|---| | 1 | Instance boundary is a team. Sharing is membership; an outside guest is a guest member. | 08-30 | | 2 | Sealed at rest; the running instance holds the DEK, so the server can compute. | 08-30 | | 3 | Yjs. JS yjs in the browser, Rust y-crdt on the server, fixtures as the contract. | 08-30 r2 | | 4 | ProseMirror, vendored as a prebuilt bundle. Re-examined against Lexical after review and kept β see Β§6.5. | 08-30 | | 5 | Engine owns page geometry; the browser owns lines. | 08-30 r2 | | 6 | Go + rastrillo for the app; one Rust crate (layout + server-side CRDT) via wazero. | 08-30 r2 | | 7 | DOCX: model a real feature set, degrade loudly. | 08-30 | | 8 | First slice: comments, version capture and restore, offline, outline pane. | 08-30 r2 | | 9 | Canvas light by default; dark is a per-person preference. Export always renders true colours. | 08-30 | | 10 | Chrome is a short toolbar plus an inspector on demand. | 08-30 | | 11 | The document list is a list with real rendered page-one thumbnails. | 08-30 | | 12 | The home is a separate repo and spec; Docs is a declared consumer. | 08-30 | | 13 | Curated font set, metric-compatible substitutes, documented substitution. | 08-30 r2 | | 14 | Page breaks are decorations, never nodes. | 08-30 r2 | | 15 | Full-text search is not in the first slice. Title and metadata search only. | 08-30 r2 | | 16 | *Markup uses `class="rst-"`**, not the bare-attribute grammar, which exists in no tree. | 08-30 r2 |
Decision 8 changed. Revision 1 put suggestion mode in the first slice and then admitted in its own sequencing that it was the item most likely to slip β a spec planning its own violation. Suggestion mode, footnotes with tracked-changes import, and the version-history timeline are now in Β§14.
---
2. Trust model, and the leak list
Sealed at rest, in Seapointish's position. Content is encrypted in SQLite under a DEK delivered by carlos secrets, so the Litestream-replicated bucket holds ciphertext. The running instance holds the DEK, which is what lets Go render a PDF, produce a thumbnail, and export.
Enforced, not asserted. A test greps raw SQLite bytes and bucket objects for known plaintext from a fixture document and fails if it finds any. That test is the boundary; the prose is a description of it.
2.1 The leak list
- The running instance can read everything. It holds the DEK. This is the deliberate trade that buys server-side export and thumbnails.
- The event log holds document content by construction. Version history is unimplementable unless the log carries old values, and old values are prose. Sealed on the same terms as the document.
- Blob metadata is visible.
ContentTypepasses through and the address is the ciphertext's hash (Β§11.5). - Document titles are plaintext in the instance, because the list, search and sharing UI need them without a per-row DEK round trip. Titles are never sent to the home (Β§9).
- Error text can quote content into platform-held logs via
carlos logs. Handlers must never interpolate document content into an error. Enforced by review, not by a test, which makes it the weakest item here. - Offline puts plaintext on the user's own device, in IndexedDB (Β§7.2). The mitigation differs in kind from everything above: it is the user's machine, and the control is a "forget this device" action that clears it, not a server-side seal.
---
3. System shape
One Go binary per instance, CGO_ENABLED=0, cross-compiled to linux/arm64, speaking the CARLOS activation contract through rastrillo.Run. One SQLite file per instance, Litestream-replicated by the platform.
cmd/docs/ main: dispatch and env only
internal/docs/ models, app, handlers, render β the rastrillo shape
internal/core/ the wazero host: module lifecycle, marshalling (Β§5.2)
internal/docx/ OOXML read and write
internal/pdf/ PDF emission, font subsetting and embedding
internal/raster/ thumbnail rasterization (Β§11.4)
core/ the Rust crate: layout + y-crdt, compiled to WASM
static/ vendored ProseMirror + yjs bundles, editor modules, fonts---
4. Data model
documents id, sqid, title, created_by, created_at, updated_at,
page_setup (sealed), deleted_at
doc_updates doc_id, seq, update (sealed), actor_id, created_at
doc_snapshots doc_id, seq, state (sealed), created_at
versions doc_id, seq, name, actor_id, created_at -- named, pinned
grants doc_id | NULL, subject, role, created_by, created_at
memberships user_id, role, created_at
comments id, doc_id, thread_id, anchor (sealed), body (sealed),
actor_id, resolved_at, orphaned_at, created_at4.1 The log is bounded β a correction
Revision 1 made doc_updates "both the sync log and the version history β one structure, not two", and presented that as elegant. It forbids compaction forever: every keystroke-granularity update becomes a sealed row, retained for the life of the document, with per-row AES-GCM overhead on payloads of a few bytes, amplifying the WAL stream the platform replicates.
Three mechanisms, all required:
- Coalescing before persistence. Updates from one actor within a short window merge into one row before it is written β Yjs's standard capture-timeout behaviour, applied at the storage boundary rather than only in the undo manager.
- Snapshot cadence. A snapshot every N updates or M bytes, whichever first, so opening a long document never replays the whole log.
- A compaction horizon. Beyond it, updates are collapsed into the preceding snapshot and dropped.
Compaction does not strand an offline client, and this is worth stating because the naive reading says it does. A snapshot encodes full state, so a client returning after the horizon receives one large update instead of many small ones and still converges β that is the CRDT's whole point. What compaction costs is history between snapshots, not reachability.
The horizon is a fixed period, never "once every client has caught up". Any prune-when-all-consumers-have-advanced rule is unbounded in a hibernating fleet, because a dormant consumer never advances and the table grows forever while reading as bounded. That pattern has now appeared twice in the home spec β a jti store and a rekey journal β and this is the third place it would have fitted.
Cutting the version timeline (Β§14) is what makes compaction tractable. With a browsable timeline, every historical point must remain reachable and the log can never be collapsed. With named versions only, the set of points that must survive is finite, small, and explicitly chosen by a person β named versions pin their snapshot and are never compacted away. The scope cut and the retention design are the same decision.
deleted_at is a soft delete; a purge verb hard-deletes a document and its log, and is what makes an erasure request answerable.
4.2 Scoping β and a trap
scope.Owned must not be used for documents. Verified: scope/scope.go declares func Owned at :19 with the body g.Where("user_id = ?", owner) at :20; OwnedBy at :27 is the same thing with a configurable column. Neither expresses "rows this team can see, plus rows shared with me, minus rows I have been removed from."
Applied to a team-shared table, Owned returns only the creator's rows. It fails as a short list, not an error β a document library that looks empty rather than broken, which is the worst available failure shape, on the helper an author would naturally reach for.
Docs implements its own scope over a grants join, preserving the 404-not-403 contract. One function, tested directly, and no handler queries documents any other way.
4.3 Rejected: rastrillo/eventlog
Recorded because the doctrine requires the rejected alternative to be named, and revision 1 omitted it. eventlog carries JSON payloads and a Lamport merge order. Yjs updates are opaque binary and carry their own ordering, so the payload shape fights it and the merge order is redundant. doc_updates is a plain append-only table instead.
---
5. The Rust core
Two hard parts are badly served by Go. The CRDT: the reference non-JS implementation of Yjs is y-crdt, and it is Rust; the Go ports are young and are not what other implementations are tested against. Text layout: Rust has rustybuzz, swash and parley; Go's x/image/font/sfnt reads metrics and cannot shape complex scripts.
So one Rust crate, compiled to WASM, run server-side through wazero β a WebAssembly runtime in pure Go with no C dependency, so the binary stays static and cgo-free. It does two jobs: apply and query the CRDT, and lay out documents.
The browser does not run this crate. It runs JS yjs with y-prosemirror and y-indexeddb, and its layout duty is line breaking inside geometry the crate computed (Β§6.1). This is the revision-2 correction; see Β§0.
5.1 What the server actually does with it
Apply incoming updates, produce snapshots, and lay out documents for export and thumbnails. The server never lays out per keystroke β that is the browser's job β so wazero's throughput is not on the hot path. Its duty cycle is bursty and coarse.
5.2 Lifecycle, which revision 1 left to chance
- Traps poison memory. A Rust panic compiled to WASM traps; the module instance's linear memory must then be considered unusable. The host discards that instance and rebuilds the document from its latest snapshot plus subsequent updates. The request fails cleanly; the document does not.
- A malformed update can trap, and that is contained rather than fixed. (Found by executing plan 1, 2026-08-30.)
yrs-0.23.5callsstd::str::from_utf8_uncheckedon wire data with no validation (src/encoding/read.rs:137, and two further sites), so a corrupted update reaches undefined behaviour. A mutation fuzz hit it on 0.4β5.1% of single-byte corruptions of our own fixtures; purely random bytes are harmless, which is why a naive garbage-input test passes and proves nothing. We cannot validate our way out β locating string fields inside a Yjs update means parsing it, which is whatyrsdoes. Filed upstream. This makes the wasm sandbox load-bearing for security, not only portability: inside wasm some of this undefined behaviour becomes a contained trap, where a native release build would corrupt silently. That is an argument for the wazero design that was not made when it was chosen. It is not a containment guarantee, though: the same unchecked decode also accepts some corrupted input outright rather than trapping on it β flipping a single byte of a UTF-8 continuation byte in our ownunicodefixture is applied without complaint (return code 0), leaving invalid UTF-8 in the document content, retained in the update log, and served back throughHandle.Textuntouched (Go'sstring(buf)does not itself validate UTF-8). Downstream layers must not assume document text is valid UTF-8. - Execution must be capped, not merely trapped. A 36-byte corrupted fixture ran
applyfor 163 real seconds before returning successfully β pathological slowness, not a hang, and invisible to any correctness test because the answer is right. On a server accepting updates from anyone holding a share link that is a denial-of-service vector. The host therefore sets a fuel or deadline bound per call and treats exhaustion like a trap. The original design covered an instance that dies; it said nothing about an instance that never returns, and both are denial of service. - Instances are not concurrency-safe. One module instance per open document, mutex-guarded, held in a pool.
- Linear memory grows and never shrinks. One large layout permanently inflates its instance. The pool therefore has a total memory budget sized for a t4g.nano (512MB) and evicts least-recently-used instances, which is cheap because an evicted document rebuilds from its snapshot.
- Layout is incremental. The engine reflows from the first dirty block and stops when page geometry reconverges, returning a box-tree delta rather than the whole tree. Without this a keystroke on page 3 of 120 relays out everything and marshals the result across the boundary.
- The ABI must export state encode/decode before any real document uses it. (Found by executing plan 1, 2026-08-30.) ABI v1 has no state-export, so the host can only recover a trapped instance by replaying every update it has seen. That is not merely slow β it makes the retained log load-bearing, and a bound on it becomes a bound on the document. A cap of 4096 updates sounds generous until you notice Yjs emits an update per keystroke burst, and that rehydrating a document from persisted updates replays them through the same path, so a document with more history than the cap would be unloadable rather than merely slow. Recovery must therefore restore a snapshot (Β§4.1) and replay only what follows it, which is what Β§4.1's snapshot cadence exists for and what ABI v1 cannot express. Until then the host is falsification-grade, not production-grade.
---
6. Pages, the editor, and where zero-JS stops
6.1 How pagination actually works
The engine computes page geometry and the block-level break positions. The browser renders those breaks and lays out lines within them.
Page breaks are decorations, never nodes (decision 14). Inserting spacer nodes into the document is the common approach and it is a trap: it causes layout reflows on every keystroke and makes the page count flicker. Using ProseMirror's DecorationSet β a position-aware structure that maps efficiently as the document changes β keeps breaks out of the document entirely. Which also means page geometry never enters the CRDT, so two collaborators on different page setups never fight, and pagination is not something that can conflict.
Editing is paginated. A pageless mode is not in the first slice (Β§14).
6.2 The scriptless path
| Surface | Without script | |---|---| | Reading a document | Full fidelity β the server renders the same layout to HTML | | The document list, search, sharing, settings | Working form posts | | Reading, replying to and resolving comments | Working form posts | | Creating a comment | Block-level anchor only β see below | | Editing prose | Not available | | Live collaboration, presence | Not available, by design |
The correction: revision 1 promised scriptless comment posting against Β§7.4's range anchors. A form post cannot express a text-range selection. So scriptless comment creation anchors to a block, and the degradation is stated rather than discovered. Replying and resolving are unaffected.
The rule the framework asks for is that no form requires script. That holds. Editing a document is not a form.
6.3 Chrome
A short toolbar β paragraph style, weight and emphasis, lists, link, alignment β grouped with dividers, plus an inspector that opens on the right for depth (page setup, styles, headers and footers, image placement) and closes again, so the page has full width by default.
Rejected: the dense undifferentiated toolbar, the always-on inspector, and the floating-only model, which leaves page setup nowhere to live.
6.4 The document list
A list, newest first, grouped by recency, with a real rendered thumbnail of page one on each row. Cut: the template carousel, the "Owned by anyone" and "Last opened by me" filter chrome, the grid/list/AZ/folder toggles, and all commercial and platform chrome.
6.5 ProseMirror, re-examined
Re-checked against Lexical after the review, since decision 4 was made before decision 5 changed what the editor has to do. Kept, for three reasons specific to this product:
- Schema enforcement. ProseMirror enforces its schema on every edit, and
y-prosemirrorensures a document still conforms to that schema after a remote update β so a concurrent edit cannot produce a document the DOCX writer refuses to serialise. Lexical has no enforced schema; validation is your own code. - Collaboration maturity. In production since 2016, with the edge cases found. Lexical's Yjs plugins are first-party but less proven.
- Decorations. Page breaks, comment ranges and presence cursors are all one mechanism, and it is the mechanism Β§6.1 depends on.
Lexical's real advantage is React ergonomics. There is no build step here and no React, so that is a strength we cannot spend.
---
7. Collaboration
7.1 Merge and transport
JS yjs with y-prosemirror in the browser; Rust y-crdt server-side. Character-level convergence, so two people in one paragraph both keep their words.
Updates travel over a Server-Sent Events stream plus the ordinary update POST, and the sequence number is the document's own, not a connection's. This is revision 3, and it replaces rather than annotates what revisions 1 and 2 said here β see the correction below for why the old wording could not honestly describe what shipped.
The stream (GET /d/{sqid}/stream) carries a seq and no document bytes at all. It says "this document has content through seq N"; the client fetches whatever is newer than its own cursor through GET /d/{sqid}/updates?since=N, the same binary endpoint it already replays at mount. Upstream is unchanged: the client POSTs batched updates, and the response names the seq that row received. doc_updates.seq is that number β durable, global to the document, and meaningful across connections, tabs and devices.
The full protocol, its cursor rules and its hibernation policy are docs/superpowers/specs/2026-09-01-live-sync-design.md.
The correction, recorded because the old text was a decision and not a typo. Revision 1 said updates travel over a WebSocket; revision 2 added that "the SSE fallback needs an upstream pathβ¦ the client POSTs batched updates with a monotonic sequence number per connection, and the server acknowledges the high-water mark." A per-connection counter cannot survive the connection. It cannot tell a reconnecting tab what it missed, which makes it useless for exactly the case Β§7.3's idle timeout guarantees will happen constantly β every parked tab, every hibernation cycle, every five-minute stream TTL. The document's own log seq answers that by construction: a tab that reconnects after an hour asks ?since= its own cursor and receives precisely what it missed.
So what shipped is not this section's fallback with the socket still to come. It is a different and better protocol, and calling it "the fallback" would have left the spec describing a design nobody built. (Paul's ruling, 2026-09-01, on the live-sync spec's Β§9.)
The WebSocket is now the upgrade that arrives with presence, not the primary this replaced. Awareness needs ephemeral, high-frequency, duplex messages, which is what a socket is for and what an SSE-plus-POST pair is bad at; content sync needs none of that. When presence lands, the socket lands with it, and the update-log format above is what it will stream β unchanged, because nothing about doc_updates is transport-specific (Β§4.3).
7.2 Offline
y-indexeddb persists the document locally and syncs on reconnect β available because the browser runs JS Yjs (Β§0).
Two consequences: it moves conflict resolution from seconds to days, which stresses the merge far harder than live editing; and it puts plaintext on the user's device, which is leak-list item 6.
7.3 Presence and hibernation
Presence renders nothing without script rather than degrading to a stale list. A stale presence indicator asserts somebody is there, which is a lie rather than a degradation. A view link gets no cursor and no name, which falls out of Β§8.2's attribution rule rather than being a special case.
Hibernation, which revision 1 never mentioned. Platform hibernation is live and default-on, and Docs holds a WebSocket per open editor β so a document left open on Friday would pin its instance awake all weekend.
- The editor socket has an idle timeout: no edits and no presence activity for a set period closes it, and any activity reconnects. A parked tab stops holding the instance open.
- On wake, an instance re-syncs from
doc_updatesbefore accepting writes. - The home outbox drains on wake and on each share mutation, never on a ticker. A hibernating instance runs no timers, so a
time.Tickerretry loop is a retry loop that dies at hibernation. Consumed from Sheets Β§9.1.1.
7.4 Comments
Threads anchor to a Yjs relative position, so an anchor survives concurrent edits and re-flow. Scriptless creation anchors to a block (Β§6.2).
When the anchored text is deleted the thread is orphaned, not destroyed: it moves to a detached list carrying its last known location and its content, with a re-attach affordance. A comment is somebody's words about something, and losing the anchor is not permission to lose the words. Sheets reaches the same obligation from the opposite direction β a stable anchor whose content is destroyed β so the primitive serves both.
---
8. Identity and sharing
8.1 Sign-in β two shapes, and which one a deployment is
Ruled by Paul, 2026-08-31, and it reframes what follows. An earlier draft treated the home as the only way in, which made instance-local sign-in a stopgap. It is not. There are two deployment shapes and they have different correct answers:
- A single instance β one team, self-hosted, no suite. It has no "one person, many teams" problem, because there is one instance. So it signs people in itself, with rastrillo's
authplugin: magic link, auto-upgrading to keymail when the address resolves to a claimed inbox. This is the right answer for that shape, not a placeholder for one.
And it is required rather than merely correct. Factor VII holds that self-hosting is a right and first-class, not a degraded tier. An earlier draft of this section made the home the only way in β which would have obliged a person self-hosting one instance to stand up the entire suite home, split origins, blind-index directory, assertion minting and key rotation included, in order to sign in to their own documents. That is not a degraded tier; it is a prohibitive one, and it would have made self-hosting nominal. (Argument owed to the Sheets session, 2026-08-31, which is a better justification than the one this spec first gave.)
It also sharpens what the home is: not "how you sign in", but how one person reaches many instances without many credentials. Split origins, the blind index, the rekey journal, the pepper living in exactly one place β every part of it serves that and nothing else. A lone instance has none of that problem and therefore needs none of that machinery.
- A suite deployment β Docs beside Sheets and others, one person spanning several teams. Here three instances must not mean three credentials, which is the problem the home exists to solve, and sign-in moves to its credential origin with instances verifying signed assertions.
So an instance is built to accept identity from either: its own magic-link flow, or a verified assertion. The second is not a replacement for the first; it is what a suite adds.
Why magic-link-at-the-instance does not violate the one-relying-party ruling. That ruling is about passkeys β a person in three teams must not enrol three credentials, and passkey.Config.Origin derives the RPID from the app's own host. Magic link enrols no credential at all, so it creates no relying party and strands nothing. An instance running it and later accepting assertions is not a migration; people simply sign in by a different route.
And it is consistent with the home's own blindness rule. The home spec rules that instances hold readable addresses and the home never does. An app instance storing an address is the design, not a deviation β which is exactly why rastrillo/auth is wrong for the home and right for an app.
8.1.1 The suite path β one relying party
Revision 1 held three positions at once: it specced instance-local rastrillo/auth magic links, then said sign-in was unresolved, while Β§9 call 1 gave sign-in to the home. The review caught all three.
Resolution, consumed from Sheets Β§8.1: the home's credential origin is the only relying party. Instances never enroll passkeys and never mount Gate. Sign-in happens at the home for members and guests alike; the passkey second factor completes there, on the RP origin, before any assertion exists, so no cross-origin hand-off is required. Instances receive identity as signed assertions and verify a signature β Tito's pattern, where signing rather than stripping is what stops a peer socket forging them.
Confirmed by Paul directly, 2026-08-30. It first reached this session by relay from the Sheets session, and was held as unconfirmed until asked directly β a relayed ruling is not a confirmed one, and both sessions applied that standard to each other in both directions during this design.
Assertions carry identity, never authorization. A guest's assertion is the same shape as an owner's; the instance decides capability from its own grants table. Encoding capability would require the home to know capability, putting an authorization model inside the identity origin. Expiry is seconds β it is exchanged immediately for a session.
The framework gap that remains is that rastrillo has no helper for verifying a signed assertion: audience binding, replay, key rotation and algorithm confusion are all hand-rolled per app. Filed jointly with Sheets, Docs as a named consumer, with one Docs-specific requirement β our single-use replay store churns against a guest population far larger than the membership list and unbounded in time, so it must be self-pruning, sized by assertion lifetime rather than user count.
The cross-device magic link β closed 2026-08-30. A link-share viewer who signs in to comment completes the link in whichever browser opens the email, so the capability context must survive that round trip or opening it on a phone strands the pending state on the laptop. auth.Config.Authorize is func(address string) bool and cannot carry document scope.
The resolution keeps capability entirely instance-side, which is what makes it compatible with "assertions carry identity, never authorization":
- The instance builds the sign-in return URL with a nonce, keying pending state it holds itself β the link token and what it grants.
- The person signs in at the home. The home learns nothing about links or capabilities; it mints an ordinary assertion.
- On return the instance verifies the assertion, redeems its own pending state by nonce, mints the grant, and only then applies the session.
Because the nonce rides the URL rather than a cookie, the round trip reassembles in whichever browser opened the email β the cross-device problem falls out of the same fix rather than needing its own. (Resolved jointly with the home session; the tempting alternative was letting the assertion carry the capability, which would have holed Β§5.3 of the home spec in the same week it was tightened.)
This is the seam where Β§8.2's central rule executes, and it gets its own test: an assertion for a person with no grant is a valid assertion that gets nothing, and the nonce redemption is what turns it into access.
8.2 Sharing
Team members see team documents. Outside guests are granted a specific document and become guest members. Link shares are a per-document capability, viewer or commenter, never editor.
Anything that writes is attributed, and attribution is what mints membership. A view link mints nothing. The moment somebody comments they sign in, which mints a guest member and produces a directory entry then.
"Sharing is membership" forbids data crossing a team boundary (suite ruling 2). A link does not move data β whoever holds it reads the document here, on this instance β so it forbids nothing here.
Within a team, Docs is one of several apps sharing one trust domain (suite ruling 1). Same-team siblings may pull team-visible documents under the suite contract, and Docs serves S1, S2, S3 and S8 wherever sibling consumer tokens are in its platform config. That switch is independent of whether a home is present (suite Β§3): Docs serves a lone memoria with no home in the picture.
Only the team rung is team-visible (suite ruling 15). A document that is private, granted to named people, or reachable only by link is never team-visible, so no sibling may hold a copy of it β and Β§8.2's existing rule that a link share is viewer or commenter, never editor is the rule suite ruling 15 adopts suite-wide. It stays here as the original; the doctrine cites it rather than restating it.
---
9. The home, as a dependency
Re-synced 2026-08-30 against the revised Sheets spec. Revision 1 was written against a superseded draft and got the contract wrong in two ways that mattered: it said four calls where there are five, and it had the instance computing the directory HMAC when Sheets ruled the opposite.
9.1 The contract, cited rather than restated
Docs consumes the home contract as the home spec states it, and the suite contract as oficina/suite states it. Neither is copied here.
This section used to list five calls. That list was the drift the suite doctrine was written to stop: it was correct when written, the home spec now has six calls, the activity design adds a seventh and the roster an eighth, and nothing made this copy notice. A reader who trusted it would have implemented against a contract that no longer existed.
Two things that are Docs' own and stay:
- Instances never hold the pepper. The home hashes the address. An instance holding the pepper would enable offline dictionary attacks against the whole suite directory from one compromised team, which inverts the point of instance-per-team.
- Docs never sends a document title to the home.
9.2 What Docs does about it
The rules below belong to the home spec or the suite doctrine and are cited, not restated. What is written out is the part Docs owes.
- The directory is a hint, never an authority (home ruling 9, suite ruling 12 β a directory row never grants team standing however its
kindreads). Docs' obligation: a failed upsert never fails a share; call 2 goes through an outbox, never the request path; every instance re-checks authorization on every request; the outbox drains on wake and on share mutation, not on a ticker (Β§7.3). - Split origins (home's). Docs holds no credential origin of its own.
- A guest grant discloses the granting team's name, and the share dialog says so in words. Docs' own, and it stays here.
- Re-keying is the home's journal call. Docs' obligation: pepper rotation needs a re-announce sweep, not outbox retry.
- The RP rename drill is
LegacyRPIDplus a window, not a multi-phase invention. The window is still sized by the guest tail β the person who signed in once, months ago, to comment on one document. Docs has far more of them than Sheets does. - The hostnames, revised 2026-09-03 (home ruling 16, superseding the 2026-08-30 record). The suite's primary domain is
oficina.works. The two home origins areid.oficina.works(credentials, the RP) and the apexoficina.works(directory); a Docs instance is<team>.docs.oficina.works, one team to one hostname. Hosting stays on Carloku withoficina.worksas a custom domain. This replaces the provisional<app>.<sqid>.oncarlos.comunder account sqidbds, which was a hosting artifact. (carloku.comwas the console brand and was never this.) - The rename cost nothing, because it happened first. No app in the suite had a
passkey.Configwhen the domain moved β sign-in is magic-link throughout β so no credential was scoped to anoncarlos.comhost. TheLegacyRPIDwindow above is sized for a future move. - The RP ID is the full host, always β a constraint that binds Docs even though the RP is the home's. WebAuthn permits an RP ID that is a registrable suffix of the origin's hostname, so
oficina.workswould be accepted forid.oficina.works. The old objection was thatoncarlos.comis shared by every Carloku tenant; on a zone we own the objection is that a suffix RP ID scopes one credential across every team instance under it. Asserted by a test rather than remembered.
9.3 The canvas preference
Per person, never per document, so it lives with the home as suite-level account state. It must resolve before first paint, which makes it a cookie on the instance's own origin, written from the assertion at session mint β never a fetch the first paint waits on.
---
10. Design system
Everything around the document is design system; the canvas diverges, because a page has different requirements from an admin screen. The thing to watch is drift in the chrome: if you find yourself copying a partial and editing three lines, propose it upstream instead.
10.1 Three states, not two β and the release gap
Revision 1 treated the design-system spec as shipped code. Revision 2 first corrected that against the wrong tree. Both errors have the same root: a spec ruling, code on main, and code in the latest release are three different things, and reading any one of them tells you nothing about the other two.
Verified against origin/main and v0.19.0:
| Symbol | State | |---|---| | ui.TokensCSS() | Released | | ui.ThemeCSS(name), ThemeNames(), Layout(name), LayoutNames() | Released in v0.20.0 (2026-08-30) β were main-only for six days | | Pair, the allocation entry point | Ruled only. In no tree | | 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 |
Resolved 2026-08-30: v0.20.0 is tagged and level with main (verified: carries ThemeCSS, ThemeNames and LayoutNames; zero commits behind main). For six days v0.19.0 was the only release and lacked the themes, shells and twelve locales the design-system spec spends its length describing, so an app running go get got none of them. Docs pins v0.20.0 rather than a main pseudo-version.
(A measurement correction worth keeping, because it is the same shape as the two traps below: the release was described β including to this spec β as removing 20 MB of vendored gallery from go get. Measured, the 20 MB never reached a release at all; it was added and removed between v0.19.0 and v0.20.0, and the zip is compressed. The real reduction is 4.28 MB β 1.98 MB and applies only to someone who was on a main pseudo-version, which Docs was. v0.20.0 is in fact larger than v0.19.0, because three themes, twelve catalogs and a date parser are real content. A plausible number that nothing contradicted until somebody measured.)
(Two traps for whoever checks this next. The repo squash-merges, so git branch --merged reports nothing useful about whether work has landed β compare content, not ancestry. And a local checkout may sit on a feature branch at a tag; cite origin/main explicitly or you will report the past.)
10.1.1 Markup β the attribute grammar, and which repo the question is about
This reversed twice in one day, which is worth recording because both reversals were the same mistake in opposite directions: applying a conclusion drawn about one repo to a different one without re-deriving it.
Revision 2 said write the attribute grammar and carry interim styling. Then reversed to class="rst-*", on the grounds that the grammar exists in no rastrillo tree (verified: spec-only at origin/main 7a30456, no branch working on it) so an app writing it renders unstyled. Then reversed back, once oficina/estilo existed, because that argument is about an app consuming rastrillo's CSS and estilo does not do that.
The settled split:
| Markup | Spelling | Styled by | Status | |---|---|---|---| | Estilo's own components | Attribute grammar | Estilo | Settled | | Docs' own markup | Attribute grammar | Estilo | Settled 2026-08-31 | | rastrillo partials we consume | Whatever rastrillo emits β classes today | rastrillo | Not our call |
Unparked by Paul, 2026-08-31, once both of his conditions were met and independently verified: <div rst-*> is shipped (v0.21.0 styles both spellings) and the design-system guidance exists. Docs writes the attribute grammar, adopting per screen rather than per element β mixed authoring inside one component matches neither spelling by design β and pinning v0.21.0 or later, below which attributes render unstyled. Docs writes no markup yet β plans 1 and 2 are a Rust crate and a wazero host β so nothing is blocked, and answering it now would mean converging on a decision that is about to be made upstream.
Until it is decided: change nothing, and let no task quietly decide it. A parked question that an implementer resolves in passing is worse than an open one, because it looks settled to the next reader and nobody knows it was chosen rather than ruled.
Estilo ships its own components and their styling, so attribute-grammar components styled by estilo are not interim anything β that is just estilo's styling, permanently, with nothing to migrate later. The unstyled-today problem only ever applied to markup leaning on rastrillo's stylesheet.
And estilo is the variant system that exists because of this direction, so building it on the spelling being replaced is backwards. Estilo is where the new grammar gets proven rather than where it waits.
Docs consumes rastrillo partials for chrome, and those emit classes. That is rastrillo's output, not a choice this spec makes, and it changes when rastrillo changes.
tokens.css is frozen into static/ at scaffold time while partials upgrade with the module. rastrillo doctor landed in v0.21.0, so the manual re-copy step is retired: doctor compares an app's frozen static/ against the module's and --fix re-copies, and it refuses across a version mismatch rather than manufacturing the fault it detects.
10.2 Colour
Docs is a declared consumer of the colour engine, recorded upstream in the design-system spec (fe26c68).
Pair(hue, chroma, background) takes an explicit background. Docs calls it against four β paper white, dark paper, and the theme surfaces β because the same stored highlight is read on white paper by one person and dark paper by another, in the same document, at the same moment. (Docs' canvas case is what established the original scheme enum could not work: two of those four live inside one scheme.)
Author colours: hash the opaque key for the preferred hue, then deterministic open addressing β probe (hash(key) + i) mod N over a lexicographic ordering of the key set, taking the first hue neither taken in this pass nor in avoid. Allocation must never depend on arrival order, or two clients render the same comment in different colours and both trust it. avoid applies inside the probe; filtering first changes the modulus and shifts every allocation. The key is the person's identity, or their membership row for a guest. Past capacity the function reports that separation is no longer guaranteed and the name carries identity, which Β§10.3 requires anyway.
10.3 Accessibility
WCAG 2.2 AA, no exemptions: 24Γ24 targets, 320px reflow, and colour never carries meaning alone β which binds the presence stack directly. Every user-visible string is a catalog key; twelve base locales.
10.4 Primitives, and where they go
Docs owns inspector panel, presence stack and comment thread; Sheets owns toolbar groups. Each carries a sentence saying what it is not, every string is a prose key with the eleven non-English locales machine-drafted, and each states which parts are script-required (Β§6.2).
They go to a CARLOS suite variant, not to rastrillo's ui β ruled by Paul, 2026-08-30, correcting earlier advice this spec had taken. An inspector panel shared by a document editor and a spreadsheet is the suite's component; the design system stays rastrillo's.
The variant is estilo, at amadan.net/oficina/estilo β named by Paul and created 2026-08-30. Primitives stay app-local until one app copies or imports another's; a copy is the trigger, because "a second consumer appears" never fires while copying is cheap and immediate.
*Estilo's components take an `estilo- prefix, never rst-`. That namespace belongs to the framework and a future version may claim a name we took β which is not hypothetical: rastrillo's own stage-1 migration found rst-form__foot and rst-form-foot both live inside ui. The guidance is published at https://rastrillo.org/docs/design-system* (2026-08-31); it covers the customisation ladder, where app-specific components belong, the extraction rule, and upgrading via doctor. Its earlier claim that the class spelling would be dropped before 1.0 has been corrected to what is true today: attributes are what the framework writes, and classes are kept so apps can migrate.
v0.22.0 (2026-08-31): the framework itself now writes the attribute grammar. Verified β class= occurrences in ui/ui.go fell from 56 to 5, the remainder being the utilities that keep the class spelling by design. So Docs and rastrillo now write the same spelling.
rastrillo markup exists, the codemod that performed that flip. Docs does not need it β we were written in the attribute grammar from the first line β but the order matters if it is ever used: doctor --fix first, because the CSS must match both spellings before the markup moves. It also lists every .rst- selector in an app's own stylesheet, which is the case nothing else catches: markup flips, stylesheet does not.
One breaking rename in v0.22.0 does not reach us, checked rather than assumed: the hand-written sticky save-bar idiom became rst-form-bar, with rst-form-foot now belonging to the partial that emits it. Docs uses neither. The upgrade from v0.21.0 is therefore optional and low-risk, and is Paul's call rather than an overnight dependency bump.
The correction is worth keeping because the reasoning was subtly wrong rather than simply wrong. Two callers at design time is the right bar for extraction, and Sheets and Docs applied it correctly. But it says nothing about where to, and the earlier answer treated the first question as settling the second.
A variant depends on ui, keeps its bones β tokens, partial vocabulary, accessibility floors β and adds or replaces on top with its own components, themes and gallery. Not a fork, and more than a theme: a theme changes colour, type and shape, where a variant can add a component a theme cannot.
cmd/dsgen went public on 2026-08-30 and generates a gallery for any module's own vocabulary. It is what a variant's gallery needs, so neither suite app should design a documentation tool before looking at it.
How the variant gets built β the axis that matters is not the repo. A repo is cheap and reversible; an interface two apps depend on is neither. Creating a repo commits nobody to a shape, and agreeing an inspector's props commits everybody.
Two specs are guesses; two implementations are facts. A document editor's inspector holds text and style properties, a spreadsheet's holds cell format and data validation, and an abstraction designed from both specs at once risks a shape fitting neither β discovered only after both are built.
So: one app builds it, in the shared place, and the other adopts it. Not two implementations merged, and not an interface designed from two specs. The second use corrects the shape, which is what working code tells you and a spec cannot. Until that second adoption the shared place is a home, not an agreed interface β treating it as an interface earlier produces exactly the ill-fitting abstraction the sequencing exists to avoid.
What a separate repo costs, priced rather than assumed: CI, a release process, a version, and a dependency edge into two apps. It also reproduces the frozen-tokens.css trap one layer down β the variant pins a rastrillo version, the apps pin a variant version, and there are two pins to keep in step instead of one. That is the trap that already cost this project a manual runbook step and a rastrillo doctor to mechanise.
Against the alternative of starting it as a directory inside whichever app builds first: Go's module boundaries make the second consumer's import awkward β importing oficina/docs/... would drag the entire Docs application in as a dependency of Sheets, and avoiding that means a nested module, which is fiddly in a different way. Recorded because the directory-first option is otherwise the cheaper-looking one.
Paul's call; unresolved here (Β§15).
Unchanged: the colour engine stays a ui dependency (Β§10.2). It is a function rather than a component, and Docs remains a declared consumer of it upstream.
---
11. Fonts, DOCX, PDF and Markdown
11.1 Fonts β the section revision 1 did not have
The word "font" did not appear in revision 1. Deterministic layout is impossible without settling this, so it is settled here.
A curated core set, self-hosted as woff2, shipped to the browser and embedded in the binary for server-side layout, so both measure identically.
Metric-compatible substitution for the proprietary faces DOCX imports name. Calibri maps to Carlito and Cambria to Caladea β both open licensed and designed to have identical metrics, so an imported Word document paginates the way Word paginated it rather than merely looking similar. Everything unmapped substitutes through a documented table, and the import report names the substitution, which is the degrade-loudly rule already agreed (Β§11.2).
CJK and the wider locale set are not in the core payload. Shipping every locale's faces is tens of megabytes to the browser; per-locale lazy loading is a subsystem rather than a decision, and it is in Β§14 with its trigger. Until then, a document in an unshipped script lays out with substitution and says so.
11.2 Import fidelity
Model a real feature set and degrade loudly. The schema covers headings and named styles, lists, tables, images, links, headers and footers, page setup and comments. Import maps all of it and shows a report naming exactly what it could not bring in, and where.
Footnotes and tracked-changes import are cut from the first slice (Β§14). The schema still models them, because a later import must not require a migration.
Rejected on the record: pandoc (a second runtime, breaks the static binary) and conversion APIs (ships customer documents to a third party, contradicting Β§2).
The conversion-API rejection stands, and suite Β§8 now says where its edge is. The suite's egress sentence is one rule for every app: egress that is a property of the product is forbidden; egress that is per-team opt-in, to one named recipient, disclosed in the README's first paragraph and on every artefact it produced, with a working floor when it is off, is admissible (suite ruling 6).
This is a qualification, not a discovery. Β§11.2's own reason was that a conversion API ships customer documents to a third party, and that reason does not by itself distinguish an opt-in converter with one named provider from a compulsory one. Suite ruling 6 draws that line; the earlier text did not, and claiming it always meant to would be reading something into it that is not there. A per-team, named, disclosed converter with a working floor would satisfy suite Β§8 β Docs has not been asked for one and is not adding one here.
This closes oficina/docs discussion 33, which asked for the boundary to be recorded rather than the rejection reversed. memoria's model provider is admissible under the second clause and Docs does not restate that reasoning: see suite Β§8.
11.3 Export
DOCX as clean OOXML. PDF emitted from the same box tree the screen renders, with font subsetting and embedding in internal/pdf β real machinery, named here because revision 1 assumed it away. Markdown lossless within its subset.
11.4 Thumbnails
Revision 1 called these "nearly free because the render already exists". That conflated layout with rasterization. The engine produces boxes; a thumbnail is a raster image, needing glyph rasterization, image decode and scale, and PNG composition β a pipeline nothing else needs, since the PDF path embeds fonts and never rasterizes. internal/raster/ is a real component. Thumbnails stay, because they are worth it (Β§6.4), but they are not free.
Amended 2026-09-02, after building them. The conflation was real and the conclusion was half right. What this missed is the scale: the list's thumbnail is 26Γ34, so a US-Letter page is about 23 points to the pixel and a glyph is a fraction of a pixel wide. There is nothing to rasterize at that size β a rasterizer would spend the whole pipeline above to produce a grey smudge. So the shipped thumbnail draws the line boxes, and they are real ones: export.PageOne runs the PDF exporter's own layout, with its own font metrics and its own line breaking, and reports where every line of page one actually landed. Decision 11's "real rendered" is satisfied in the sense that matters β the row shows where this document's first page really breaks β and what is elided is the glyphs, not the layout. internal/raster/ remains unbuilt and remains what a larger thumbnail would need; it moved from Β§13's sequence to Β§14's trigger list.
11.5 Blobs
All blob storage goes through blobs.Sealed (blobs/blobs.go:218); the package doc at :24 is explicit that E2EE apps wrap any backend in it. ContentType is set to application/octet-stream, because a truthful content type on a sealed import announces "this is a Word document" in a bucket that is otherwise ciphertext. The address becomes the ciphertext's hash, so dedup is per key and Ref.Size is the sealed size.
---
12. Testing gates
Revision 1's gates would not have caught the failures it was most exposed to. Corrected.
go build ./...,go vet ./...,go test ./... -count=1,gofmt -l .,CGO_ENABLED=0throughout.cargo testandcargo clippyfor the crate.- Wire compatibility: our Rust
y-crdtupdates are applied by upstream JSyjsand vice versa, against checked-in fixtures generated by the JS side. This is the CRDT half of Β§0 and the fixtures are the contract. - Zero host imports: the compiled wasm module must import nothing. (Added 2026-08-30, from executing plan 1 β the review found
wasm-bindgenin the wasm32 dependency graph viayrs β fastrand("js") β getrandom("wasm_js").) "No wasm-bindgen in the dependency graph" turned out to be untestable while usingyrs, which enables that feature unconditionally. The property actually required is that the module needs no JS host, and that is directly checkable: zero imports. A dependency that is never called is dead-stripped and harmless; one that is called appears as an import and fails the gate. The corollary, which the gate enforces rather than trusts: never callyrs::Doc::new()β it reachesfastrandfor a random client ID, which is what would pull JS entropy. AlwaysDoc::with_client_id(), which is what an assigned server-side client ID wants anyway.fastrandis also reached fromyrs's observer paths, which is precisely why this is a build-time assertion rather than a rule somebody remembers. - Layout agreement: a browser drive renders fixture documents and asserts the DOM's actual page-break positions match the engine's computed ones. This is the gate revision 1 lacked β its conformance test compared wazero to a browser running the same module and never checked what the DOM painted.
- No entropy from the CRDT library, enforced by
clippy.tomldisallowed-methodsonyrs::Doc::new,yrs::Doc::with_client_idandyrs::uuid_v4, run before every build. (Added 2026-08-30 from executing plan 1.) On wasm the customgetrandomhook refuses to produce entropy andfastrandmasks that with a constant seed, so anything drawing randomness fromyrsis identical across processes β and a Yjs client id or document GUID that repeats across processes corrupts documents. Construction therefore always usesOptions::with_guid_and_client_id(guid, client_id), with both values assigned by the instance that owns the document, which is where they should come from anyway.
This is a gate rather than a rule because the rule was wrong three times in three different ways while being written: the dependency was assumed dead-stripped (naming Doc pulls it in), an erroring hook was assumed to fail loudly (fastrand swallows it), and Doc::with_client_id was assumed safe (it draws a GUID). Each version was correct about what its author could see. A rule with that history does not get enforced by a comment.
- Performance budgets, as gates rather than hopes: keystroke-to-repaint on a 120-page fixture, full layout time, and the wazero pool's peak memory against a 512MB ceiling.
- Long-divergence merge: two clients diverge for a simulated week and reconcile, because Β§7.2 says days-long divergence stresses the merge hardest.
- Plaintext grep: raw SQLite bytes and bucket objects, covering documents, the event log, comments and blobs.
- Scope: a document not granted to a user 404s, for every read and write path.
- DOCX round-trip against checked-in expectation files, compared structurally. Revision 1 said "reopened in a real Word file comparison", which is not runnable in CI on linux/arm64 β Word does not run there.
- A browser drive of the editor itself, because a JS syntax error takes the whole client down.
- 300-line cap per JS module, one coordinator at a stated higher cap.
---
13. Sequencing
- The Rust crate:
y-crdtwire fixtures green, then layout, with the layout-agreement gate from the first commit. - The wazero host with its pool, trap recovery and memory budget.
- The Go app on rastrillo: list, create, open, server-rendered read-only.
- The editor: ProseMirror, JS Yjs, decoration-based pagination.
- Collaboration: transport, presence, offline, hibernation behaviour.
- Sharing: grants, guests, link shares, the home outbox.
- Markdown, then DOCX, then PDF.
- Comments; version capture and restore; the outline pane.
- ~~Thumbnails.~~ Done 2026-09-02, and not the way this line assumed. They did not wait for the layout engine: the PDF exporter's layout was enough to report where page one's lines fall, which at 26Γ34 is the whole of what a thumbnail shows. See Β§11.4's amendment.
Slip order, written rather than discovered: the outline pane goes first, then offline. The layout engine cannot slip, because pagination, PDF and thumbnails all fall out of it. (Thumbnails turned out not to β see Β§11.4's amendment. Pagination and PDF still do.)
---
14. Deliberately not built yet
| Not built | Trigger | |---|---| | Suggestion mode | Comments are in use and the absence of accept/reject is the complaint. Schema already models tracked changes | | Footnotes; tracked-changes import | The layout engine is stable enough that footnote-vs-page-break placement is a feature rather than a risk | | Version history timeline and diffing | Named versions are in use and people ask to browse between them. Note this reopens Β§4.1's compaction horizon | | Full-text search | See Β§15.2 β it needs a design compatible with the plaintext-grep gate, not just a decision | | Per-locale font loading | A team works in a script outside the core set | | Pageless mode | Long-document editing proves painful in page view | | Embedded live Sheets ranges | Both apps stable, and the seam between two collaboration models designed deliberately | | Templates | Somebody has made the same document three times | | 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 |
---
14a. Consumed suite rulings
The suite sharing doctrine (oficina/suite) binds this spec. Read against revision 4, commit 210312f.
Rulings Docs consumes:
| # | What it settles for Docs | |---|---| | 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. | | 2 | Sharing is membership across teams. Nothing crosses a team label; a link share moves nothing. | | 3 | Custody and authorization are two rules, and authorization is never cached at any scope. | | 5 | Two shapes, one switch. Docs is lone or suite, and the sibling-token switch is independent of it. | | 6 | The egress sentence. Β§11.2 cites it rather than restating it. | | 7 | One contract, one module, fixtures as the interface. Docs imports it, serves its half, and runs the conformance suite in CI. | | 8 | Two credentials: an assertion proves a person, a consumer token proves a same-team sibling. | | 9 | A write names its real actor. No sibling ever writes as a person. | | 12 | Membership is the home's own record. A directory row never grants team standing. | | 14 | Custody is bounded at acquisition and retired by the feed, not enforced by erasure. | | 15 | Three rungs and a read-only link. Docs' own "viewer or commenter, never editor" is the rule this adopts suite-wide. |
Docs does not restate any of them. Where this spec previously did β the five-call contract in Β§9.1, the instance boundary in Β§8.2, the egress reason in Β§11.2 β the restatement has been replaced by a citation.
Not yet consumed, because they need work this spec has not specified: the hand-off (suite Β§7.1), which needs a receipt route, pending state and a confirmation step on the create endpoint; and S8 write-as-self (suite Β§7.2), which needs a designated-area grant in the share UI.
---
15. Open
- ~~The passkey RP ruling~~ β closed 2026-08-30, confirmed by Paul directly. Β§8.1 is settled: one relying party at the home's credential origin, instances verify signed assertions and never enroll passkeys.
- Full-text search has no design. An FTS5 index is plaintext in SQLite and would fail our own gate (Β§12); decrypting the corpus per query is O(corpus). Title and metadata search ships; full text waits for a design, not for a decision.
- ~~rastrillo needs a release~~ β closed 2026-08-30.
v0.20.0is tagged, serving from the proxy, and level withmain. Docs pins it rather than a pseudo-version. It also carries two things the chrome wants directly whenever that work resumes: menus that cap their height and flip to fit the viewport via CSS anchor positioning β so a style dropdown near the foot of a page opens upward with no script β andscrollbar-gutter: stablebehind a token, which matters most against a fixed-width sheet of paper. Re-copytokens.cssdeliberately on adopting it (Β§10.1). - ~~The identity-assertion helper~~ β filed 2026-08-30 as
rastrillo/rastrillodiscussion #1 on amadan, drafted jointly with Sheets, both apps named as consumers. Neither app is blocked on it; both can hand-roll verification and would rather not do it twice, differently. .amadan/ci.d/10-gatearrives with the first code branch.- ABI v2 state export, before
internal/corehas a real caller. The in-memory replay log is capped at 4096 updates with a clean failure, which bounds the count but not the bytes, has no recovery path, and would make a long-lived document unloadable. Acceptable only while nothing imports the package β verified by grep, not assumed. - The three-language cost. Revisit once the crate is real. Revision 2 already spent this clause once β the "same artifact both sides" claim did not survive contact β so the next honest answer may be that the crate shrinks to layout alone.