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
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. |