oficina / docs Public

Merge the spec amendments onto the implementation branch

Found by the whole-branch review, and it is a structural error of mine
rather than a code defect.

Eleven spec commits were made on plan-rust-core while implementation ran
on core-wire-compat, which had already forked from it. So the two halves
of the same work sat on branches neither of which was an ancestor of the
other. Squash-merging core-wire-compat alone would have put the code on
main beside a spec that contradicts it: §5.2 would still claim recovery
"rebuilds from its latest snapshot", which ABI v1 cannot do -- recovery
is capped in-memory log replay -- and §15 would lack the item that makes
ABI v2 state export blocking before this package gets a real caller.

The discipline of "the spec describes what exists" holds inside a branch
and fails at the merge boundary unless both halves travel together. They
now do.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011F5CivVm4CmL9ByfSSAaXA
Paul Campbell pushed by paul@keymail.dev a4e7d3ceec4c0650df5972020fb5baddd8a740f8 parent 3bc95caparent fb58b1f
1 file changed, +251 −40
  • docs/superpowers/specs/2026-08-30-docs-design.md +251 −40
diff --git a/docs/superpowers/specs/2026-08-30-docs-design.md b/docs/superpowers/specs/2026-08-30-docs-design.md
index 6f2c791..9019305 100644
--- a/docs/superpowers/specs/2026-08-30-docs-design.md
+++ b/docs/superpowers/specs/2026-08-30-docs-design.md
@@ -167,6 +167,19 @@ Three mechanisms, all required:
- **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
@@ -235,6 +248,27 @@ cycle is bursty and coarse.
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.5` calls
+ `std::str::from_utf8_unchecked` on 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 what `yrs` does. Filed upstream.
+ **This makes the wasm sandbox load-bearing for security, not only
+ portability**: inside wasm the fault is 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.
+- **Execution must be capped, not merely trapped.** A 36-byte corrupted
+ fixture ran `apply` for **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
@@ -245,6 +279,18 @@ cycle is bursty and coarse.
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.
---
@@ -413,14 +459,32 @@ single-use replay store churns against a guest population far larger than
the membership list and unbounded in time, so **it must be self-pruning,
sized by assertion lifetime rather than user count.**
-**Open seam: the cross-device magic link.** A link-share viewer who signs in
-to comment completes the link in whichever browser opens the email. The
-capability context must survive that round trip, or opening it on a phone
-strands the pending context on the laptop. `auth.Config.Authorize` is
-`func(address string) bool` and cannot carry document scope. The capability
-therefore rides the return-to through the home and the assertion returns to
-the originating origin. Designed here, but it is the seam where §8.2's
-central rule executes and it deserves a test of its own.
+**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":
+
+1. The instance builds the sign-in return URL with a **nonce**, keying
+ pending state it holds itself — the link token and what it grants.
+2. The person signs in at the home. The home learns nothing about links or
+ capabilities; it mints an ordinary assertion.
+3. 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
@@ -476,6 +540,17 @@ Docs never sends a document title to the home.
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, recorded 2026-08-30.** The account is `oficina`, sqid
+ `bds`; default addresses are `<app>.<sqid>.<apps-domain>` on the hosted
+ apps-domain `oncarlos.com`. So both home origins and every Docs instance
+ are distinct `oncarlos.com` hosts under `bds`. (`carloku.com` is the
+ console brand and is not this.)
+- **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 `oncarlos.com` would be
+ *accepted* — and it is shared by every Carloku tenant, which would scope
+ our people's credentials across other people's apps. Asserted by a test
+ rather than remembered.
### 9.3 The canvas preference
@@ -505,36 +580,77 @@ Verified against `origin/main` and `v0.19.0`:
| Symbol | State |
|---|---|
-| `ui.TokensCSS()` | Released (`v0.19.0`) |
-| `ui.ThemeCSS(name)`, `ThemeNames()`, `Layout(name)`, `LayoutNames()` | **On `main` only** — `main:ui/ui.go:309` etc.; absent from `v0.19.0` |
+| `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>`) | **Ruled only.** In no tree, no branch, unstarted |
-**`main` is 18 commits ahead of `v0.19.0`, which was tagged 2026-08-24**
-(counted at `ac287b5`, 2026-08-30; the count moves, the gap is the point). An
-app that runs `go get` today gets a rastrillo with none of the themes, none
-of the shells and none of the twelve locales — most of what the
-design-system spec describes. Docs therefore pins a `main` pseudo-version
-until a release carries them, and §15 tracks that as a dependency on
-somebody cutting one.
+**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 uses the class vocabulary — reversed
+### 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 | — | Estilo | **PARKED** — see below |
+| rastrillo partials we consume | Whatever rastrillo emits — classes today | rastrillo | Not our call |
+
+**The middle row is parked, deliberately** (Paul, 2026-08-30): design-system
+questions wait until rastrillo's design system settles and estilo is real.
+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.
-Revision 2 said to write the bare-attribute grammar and carry interim
-styling until it shipped. **Reversed**, because the thing being waited for is
-unstarted rather than in flight and carries no date.
+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 writes `class="rst-*"`. That is what `ui` styles today and what ~585
-call sites in rastrillo use. When the attribute grammar ships, Docs migrates
-mechanically alongside the framework's own call sites — the same change,
-done once, by whoever automates it. The alternative was a parallel styling
-implementation maintained for an unbounded period against a target nobody
-has started.
+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, so re-copy it deliberately on every rastrillo upgrade until
@@ -568,13 +684,69 @@ 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 owed upstream
+### 10.4 Primitives, and where they go
Docs owns **inspector panel**, **presence stack** and **comment thread**;
-Sheets owns toolbar groups. Each in the attribute grammar, every string a
-prose key with the eleven non-English locales machine-drafted, each carrying
-a sentence saying what it is *not*, and each stating which parts are
-script-required (§6.2).
+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 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.
---
@@ -654,10 +826,43 @@ Corrected.
- **Wire compatibility:** our Rust `y-crdt` updates are applied by upstream JS
`yjs` and vice versa, against checked-in fixtures generated by the JS side.
This is the CRDT half of §0 and the fixtures are the contract.
+- **Zero host imports:** the compiled wasm module must import nothing.
+ *(Added 2026-08-30, from executing plan 1 — the review found `wasm-bindgen`
+ in the wasm32 dependency graph via `yrs → fastrand("js") →
+ getrandom("wasm_js")`.)* "No wasm-bindgen in the dependency graph" turned
+ out to be **untestable while using `yrs`**, 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 call
+ `yrs::Doc::new()`** — it reaches `fastrand` for a random client ID, which is
+ what would pull JS entropy. Always `Doc::with_client_id()`, which is what an
+ assigned server-side client ID wants anyway. `fastrand` is also reached from
+ `yrs`'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.toml`
+ `disallowed-methods` on `yrs::Doc::new`, `yrs::Doc::with_client_id` and
+ `yrs::uuid_v4`, run before every build. *(Added 2026-08-30 from executing
+ plan 1.)* On wasm the custom `getrandom` hook refuses to produce entropy
+ and `fastrand` masks that with a constant seed, so anything drawing
+ randomness from `yrs` is **identical across processes** — and a Yjs client
+ id or document GUID that repeats across processes corrupts documents.
+ Construction therefore always uses
+ `Options::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.
@@ -719,20 +924,26 @@ thumbnails all fall out of it.
and would fail our own gate (§12); decrypting the corpus per query is
O(corpus). Title and metadata search ships; full text waits for a design,
not for a decision.
-3. **rastrillo needs a release** (§10.1). `main` is 18 commits ahead of
- `v0.19.0` and carries the themes, shells and locales Docs depends on —
- plus viewport-fitting menus and a stable scrollbar gutter, which the
- inspector and toolbar dropdowns want directly.
- Until one is cut, Docs pins a `main` pseudo-version — workable, but a
- pinned pseudo-version is not a dependency anyone should ship on.
- `ui-guidelines-52` is raising it; two apps hit it in one afternoon.
+3. ~~**rastrillo needs a release**~~ — **closed 2026-08-30.** `v0.20.0` is
+ tagged, serving from the proxy, and level with `main`. 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 — and `scrollbar-gutter:
+ stable` behind a token, which matters most against a fixed-width sheet of
+ paper. Re-copy `tokens.css` deliberately on adopting it (§10.1).
4. ~~**The identity-assertion helper**~~ — **filed 2026-08-30** as
`rastrillo/rastrillo` discussion #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.
5. **`.amadan/ci.d/10-gate`** arrives with the first code branch.
-6. **The three-language cost.** Revisit once the crate is real. Revision 2
+6. **ABI v2 state export**, before `internal/core` has 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.
+7. **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.