oficina / docs Public

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

Plain git — no account needed to clone.

Download

Download this file

1# Rust core: Yjs wire compatibility under wazero — Implementation Plan
2
3> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
5**Goal:** Prove that a Rust crate using `yrs` compiles to plain `wasm32-unknown-unknown` with a C ABI, runs under wazero inside a Go binary, and stays byte-compatible with browser JS `yjs` in both directions.
6
7**Architecture:** One Rust crate (`core/`) exporting a small C ABI — no wasm-bindgen, because wasm-bindgen emits JS glue that wazero cannot provide. A Node script generates binary update fixtures from real `yjs` and checks them in; Rust tests apply them, and Node re-applies what Rust produces. A minimal Go host loads the compiled module under wazero and round-trips one update through it.
8
9**Tech Stack:** Rust (`yrs`), `wasm32-unknown-unknown`, Go 1.26 + `wazero`, Node 24 + `yjs`.
10
11**Spec:** `docs/superpowers/specs/2026-08-30-docs-design.md` — §0 (the CRDT half of the one rule), §5 (the Rust core), §5.2 (lifecycle), §12 (wire-compatibility gate).
12
13## Why this plan exists, and what it is allowed to conclude
14
15This is the load-bearing bet of the whole design, and revision 1 of the spec got it wrong: it claimed the browser and the server would run *the same compiled artifact*, which is not available because `ywasm` is built with wasm-bindgen. The corrected design (§0) is two implementations agreeing through fixtures.
16
17**This plan may conclude that the design is wrong.** If `yrs` cannot be built without wasm-bindgen for a wazero host, or the marshalling cost is prohibitive, the honest outcome is to say so and fall back to a pure-Go Yjs port — which spec §15.6 already reserves. Record the finding; do not defend the design.
18
19**Nothing here touches rastrillo.** That is deliberate: rastrillo's `main` is 18 commits ahead of `v0.19.0` (counted at `ac287b5`, 2026-08-30) and the dependency decision is deferred to a later plan.
20
21## Global Constraints
22
23- **No cgo, ever.** `CGO_ENABLED=0` on every Go build and test command. A cgo dependency sneaking in is a bug, not a tradeoff.
24- **The compiled wasm module must import nothing.** *(Amended during execution, 2026-08-30. This originally read "no wasm-bindgen, no wasm-pack" and was **untestable as written**: `yrs` unconditionally enables `fastrand`'s `js` feature, so wasm-bindgen is in the wasm32 dependency graph of any crate using it.)* The property actually required is that the module needs no JS host, and zero imports is that property, asserted by `core/tests/no_host_imports.rs`. Target stays `wasm32-unknown-unknown` with `crate-type = ["cdylib", "rlib"]` and `extern "C"` exports; `wasm-pack` is still never used.
25- **Never draw entropy from `yrs`.** `Doc::new`, `Doc::with_client_id` and `uuid_v4` are all banned and enforced by `clippy.toml` `disallowed-methods`, run before every build. *(Amended twice during execution: `Doc::new` was banned first for its random client id, then `Doc::with_client_id` too — `Options::with_client_id` at `doc.rs:916` sets `guid: uuid_v4()`, so it draws entropy one layer down.)* On wasm the custom `getrandom` hook refuses entropy and `fastrand` masks that with a constant seed, so any value drawn from `yrs` is **identical across processes** — and a client id or document GUID repeating across processes corrupts documents. Always construct with `Doc::with_options(Options::with_guid_and_client_id(guid, client_id))`, both values supplied by the caller.
26- **Fixtures are generated by the JS side and are the contract.** They are never edited to make a test pass. A rule change regenerates them in the same commit.
27- **Pin exact versions.** No `*` or caret-only ranges in `Cargo.toml` or `package.json` for `yrs`/`yjs`.
28- Commit after every task. Small commits, pushed.
29- Work happens on a branch, never `main`. Squash-merge when done, approved, and CI green.
30
31---
32
33### Task 1: Toolchain, crate skeleton, and the build gate
34
35**Files:**
36- Create: `core/Cargo.toml`, `core/src/lib.rs`, `core/.gitignore`
37- Create: `scripts/build-wasm.sh`
38- Modify: `.gitignore`
39
40**Interfaces:**
41- Consumes: nothing.
42- Produces: a `core/` crate that builds for host and for `wasm32-unknown-unknown`; `scripts/build-wasm.sh` emitting `core/target/wasm32-unknown-unknown/release/docs_core.wasm`.
43
44- [ ] **Step 1: Install the Rust toolchain and the wasm target**
45
46Rust is not installed on this machine. This is a system change; run it outside the sandbox.
47
48```bash
49curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal
50source "$HOME/.cargo/env"
51rustup target add wasm32-unknown-unknown
52```
53
54- [ ] **Step 2: Verify the toolchain**
55
56```bash
57rustc --version && cargo --version && rustup target list --installed | grep wasm32-unknown-unknown
58```
59
60Expected: three lines, the last being `wasm32-unknown-unknown`. If the target is missing, step 1's `rustup target add` did not run.
61
62- [ ] **Step 3: Create the crate**
63
64`core/Cargo.toml`:
65
66```toml
67[package]
68name = "docs-core"
69version = "0.1.0"
70edition = "2021"
71
72[lib]
73crate-type = ["cdylib", "rlib"]
74
75[dependencies]
76yrs = "0.23"
77
78[profile.release]
79opt-level = "s"
80lto = true
81panic = "abort"
82```
83
84`panic = "abort"` matters: a Rust panic in wasm traps, and §5.2 says the host treats a trapped instance's memory as poisoned and rebuilds from a snapshot. Unwinding across the wasm boundary is not something to rely on.
85
86Pin `yrs` to the exact current version rather than the line above if `cargo add yrs` resolves differently — run `cargo add yrs` and take what it writes.
87
88`core/src/lib.rs`:
89
90```rust
91//! The Docs core: Yjs CRDT and (later) text layout, compiled to wasm and
92//! run both under wazero on the server and — for layout only — in the browser.
93
94/// Returns the ABI version this module exports. The Go host checks this on
95/// load and refuses a module it does not understand, so a stale .wasm on a
96/// box fails loudly instead of behaving strangely.
97#[no_mangle]
98pub extern "C" fn docs_core_abi_version() -> u32 {
99 1
100}
101```
102
103`core/.gitignore`:
104
105```
106/target
107```
108
109- [ ] **Step 4: Verify both builds**
110
111```bash
112cd core && cargo build && cargo build --release --target wasm32-unknown-unknown
113```
114
115Expected: both succeed. The wasm artifact lands at `core/target/wasm32-unknown-unknown/release/docs_core.wasm`.
116
117- [ ] **Step 5: Add the build script**
118
119`scripts/build-wasm.sh`:
120
121```bash
122#!/usr/bin/env bash
123# Build the core crate for wazero. No wasm-bindgen, no wasm-pack: the Go host
124# provides no JS glue, so the module must be plain wasm32 with a C ABI.
125set -euo pipefail
126cd "$(dirname "$0")/.."
127cargo build --release --manifest-path core/Cargo.toml --target wasm32-unknown-unknown
128out="core/target/wasm32-unknown-unknown/release/docs_core.wasm"
129[ -f "$out" ] || { echo "build-wasm: no artifact at $out" >&2; exit 1; }
130echo "$out ($(wc -c < "$out") bytes)"
131```
132
133```bash
134chmod +x scripts/build-wasm.sh && ./scripts/build-wasm.sh
135```
136
137Expected: a path and a byte count.
138
139- [ ] **Step 6: Commit**
140
141```bash
142git add core scripts/build-wasm.sh .gitignore
143git commit -m "core: Rust crate skeleton building for wazero
144
145Plain wasm32-unknown-unknown with a C ABI and no wasm-bindgen, because
146the Go host provides no JS glue. panic = abort, since §5.2 treats a
147trapped instance's memory as poisoned rather than unwinding across the
148boundary."
149```
150
151---
152
153### Task 2: Fixture generator — real JS yjs produces the contract
154
155**Files:**
156- Create: `fixtures/package.json`, `fixtures/generate.mjs`
157- Create: `fixtures/data/` (generated, checked in)
158
159**Interfaces:**
160- Consumes: nothing.
161- Produces: `fixtures/data/<name>.update.bin` (binary v1 updates) and `fixtures/data/<name>.expected.json` (the text content each update should produce), read by Tasks 3, 4 and 6.
162
163- [ ] **Step 1: Create the fixture package**
164
165`fixtures/package.json`:
166
167```json
168{
169 "name": "docs-fixtures",
170 "private": true,
171 "type": "module",
172 "description": "Generates Yjs wire fixtures. The JS side is the contract; Rust must reproduce these.",
173 "dependencies": {
174 "yjs": "13.6.27"
175 }
176}
177```
178
179Pin whatever `npm install yjs` actually resolves; do not leave a range.
180
181```bash
182cd fixtures && npm install
183```
184
185- [ ] **Step 2: Write the generator**
186
187`fixtures/generate.mjs`:
188
189```javascript
190// Generates the Yjs wire fixtures that define compatibility for the Rust
191// core. These are the contract: never edit a fixture to make a test pass.
192// A deliberate rule change regenerates them in the same commit.
193import * as Y from 'yjs'
194import { mkdirSync, writeFileSync } from 'node:fs'
195import { join, dirname } from 'node:path'
196import { fileURLToPath } from 'node:url'
197
198const dataDir = join(dirname(fileURLToPath(import.meta.url)), 'data')
199mkdirSync(dataDir, { recursive: true })
200
201function emit(name, build) {
202 const doc = new Y.Doc()
203 build(doc)
204 const update = Y.encodeStateAsUpdate(doc)
205 writeFileSync(join(dataDir, `${name}.update.bin`), Buffer.from(update))
206 writeFileSync(
207 join(dataDir, `${name}.expected.json`),
208 JSON.stringify({ text: doc.getText('content').toString() }, null, 2) + '\n'
209 )
210 console.log(`${name}: ${update.length} bytes`)
211}
212
213emit('hello', (doc) => {
214 doc.getText('content').insert(0, 'hello world')
215})
216
217emit('unicode', (doc) => {
218 // Surrogate pairs and combining marks: Yjs indexes UTF-16 code units and
219 // Rust indexes UTF-8 bytes, so this is where an index-conversion bug shows.
220 doc.getText('content').insert(0, 'héllo 🌍 café')
221})
222
223emit('formatted', (doc) => {
224 const t = doc.getText('content')
225 t.insert(0, 'plain bold plain')
226 t.format(6, 4, { bold: true })
227})
228
229emit('concurrent', (doc) => {
230 // Two docs diverge and merge. The merged state is order-independent, so
231 // Rust must arrive at the same bytes from the same inputs.
232 const a = new Y.Doc()
233 const b = new Y.Doc()
234 a.getText('content').insert(0, 'AAA')
235 b.getText('content').insert(0, 'BBB')
236 Y.applyUpdate(a, Y.encodeStateAsUpdate(b))
237 Y.applyUpdate(b, Y.encodeStateAsUpdate(a))
238 Y.applyUpdate(doc, Y.encodeStateAsUpdate(a))
239})
240```
241
242- [ ] **Step 3: Generate and inspect**
243
244```bash
245cd fixtures && node generate.mjs && ls -la data/
246```
247
248Expected: four `.update.bin` files with non-zero sizes and four `.expected.json`. Read `data/hello.expected.json` and confirm it says `"text": "hello world"`.
249
250- [ ] **Step 4: Commit**
251
252```bash
253git add fixtures
254git commit -m "fixtures: Yjs wire fixtures generated by real JS yjs
255
256These are the contract for the Rust core. Never edited to make a test
257pass; a deliberate rule change regenerates them in the same commit.
258
259The unicode fixture is the one that matters most: Yjs indexes UTF-16
260code units and Rust indexes UTF-8 bytes, so an index-conversion bug
261shows there and nowhere else."
262```
263
264---
265
266### Task 3: Rust applies JS-generated updates
267
268**Files:**
269- Create: `core/tests/wire_compat.rs`
270- Modify: `core/src/lib.rs`
271
272**Interfaces:**
273- Consumes: `fixtures/data/*.update.bin` and `*.expected.json` from Task 2.
274- Produces: `docs_core::apply_update(doc: &Doc, update: &[u8]) -> Result<(), CoreError>` and `docs_core::text_content(doc: &Doc) -> String`, used by Tasks 4 and 5.
275
276- [ ] **Step 1: Write the failing test**
277
278`core/tests/wire_compat.rs`:
279
280```rust
281use std::fs;
282use std::path::PathBuf;
283use yrs::Doc;
284
285fn fixture(name: &str) -> (Vec<u8>, String) {
286 let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
287 .parent()
288 .unwrap()
289 .join("fixtures/data");
290 let update = fs::read(dir.join(format!("{name}.update.bin")))
291 .unwrap_or_else(|e| panic!("missing fixture {name}.update.bin: {e}"));
292 let expected: serde_json::Value = serde_json::from_str(
293 &fs::read_to_string(dir.join(format!("{name}.expected.json"))).unwrap(),
294 )
295 .unwrap();
296 (update, expected["text"].as_str().unwrap().to_string())
297}
298
299fn check(name: &str) {
300 let (update, expected_text, expected_delta) = fixture(name);
301 let doc = Doc::with_options(Options::with_guid_and_client_id(
302 "wire-compat-test-guid".into(),
303 9001,
304 )); // never Doc::new or Doc::with_client_id — see Global Constraints
305 docs_core::apply_update(&doc, &update).expect("apply failed");
306 assert_eq!(docs_core::text_content(&doc), expected_text, "fixture {name} text");
307 assert_eq!(delta_of(&doc), expected_delta, "fixture {name} delta");
308}
309
310#[test]
311fn applies_hello() {
312 check("hello");
313}
314
315#[test]
316fn applies_unicode() {
317 check("unicode");
318}
319
320#[test]
321fn applies_formatted() {
322 check("formatted");
323}
324
325#[test]
326fn applies_concurrent() {
327 check("concurrent");
328}
329```
330
331Add the dev-dependency to `core/Cargo.toml` (exact pin, per Global Constraints):
332
333```toml
334[dev-dependencies]
335serde_json = "=1.0.145"
336```
337
338Take whatever exact version `cargo add --dev serde_json` resolves.
339
340**The delta comparison — amended during execution (ruling R5).** The fixtures
341now carry a `delta` field alongside `text`, because `text` alone cannot
342distinguish a correct implementation from one that decoded the update and
343dropped the formatting entirely. `fixture()` must therefore return three
344values, and the test needs a `delta_of(&Doc)` helper that produces the same
345attributed-run shape Yjs's `toDelta()` writes.
346
347Verified API facts to build it from — check each against the pinned `yrs`
348rather than trusting this list:
349
350- `Text::diff<T, D, F>(&self, txn: &T, compute_ychange: F) -> Vec<Diff<D>>` —
351 `yrs-0.23.5 src/types/text.rs:420`, on the `Text` trait.
352- `Diff<T> { insert: Out, attributes: Option<Box<Attrs>>, ychange: Option<T> }`
353 — `text.rs:1103`.
354- `YChange::identity` — `text.rs:1201`, the pass-through for `compute_ychange`.
355- `Attrs = HashMap<Arc<str>, Any>` — `types/mod.rs:882`.
356
357Attribute order must be normalised (sort by key) before comparison, or the
358test is flaky on `HashMap` iteration order.
359
360**The delta assertion must be proven to bite**, the same way the zero-imports
361gate was: temporarily make `delta_of` discard attributes, confirm the
362`formatted` fixture fails and the other three still pass, then revert. A
363comparison that cannot fail is not an assertion.
364
365- [ ] **Step 2: Run the test to verify it fails**
366
367```bash
368cd core && cargo test --test wire_compat
369```
370
371Expected: FAIL to compile — `apply_update` and `text_content` do not exist in `docs_core`.
372
373- [ ] **Step 3: Implement**
374
375Append to `core/src/lib.rs`:
376
377```rust
378use yrs::updates::decoder::Decode;
379use yrs::{Doc, GetString, Text, Transact, Update};
380
381#[derive(Debug)]
382pub enum CoreError {
383 /// The bytes were not a valid Yjs v1 update.
384 Decode(String),
385 /// The update decoded but could not be applied.
386 Apply(String),
387}
388
389impl std::fmt::Display for CoreError {
390 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
391 match self {
392 CoreError::Decode(m) => write!(f, "decode: {m}"),
393 CoreError::Apply(m) => write!(f, "apply: {m}"),
394 }
395 }
396}
397
398/// Applies a Yjs v1 update to `doc`.
399pub fn apply_update(doc: &Doc, update: &[u8]) -> Result<(), CoreError> {
400 let update = Update::decode_v1(update).map_err(|e| CoreError::Decode(e.to_string()))?;
401 let mut txn = doc.transact_mut();
402 txn.apply_update(update)
403 .map_err(|e| CoreError::Apply(e.to_string()))
404}
405
406/// The plain-text content of the document's root text type. The name
407/// "content" matches what the fixture generator writes; it is the CRDT root
408/// name and is part of the wire contract.
409pub fn text_content(doc: &Doc) -> String {
410 let text = doc.get_or_insert_text("content");
411 let txn = doc.transact();
412 text.get_string(&txn)
413}
414```
415
416If `txn.apply_update` returns `()` rather than a `Result` in the pinned `yrs`, drop the `map_err` and keep `CoreError::Apply` for Task 5's ABI. Check `cargo doc --open -p yrs` or docs.rs for the pinned version rather than guessing — the API changed across the 0.1x/0.2x line.
417
418- [ ] **Step 4: Run the tests to verify they pass**
419
420```bash
421cd core && cargo test --test wire_compat
422```
423
424Expected: 4 passed. If `applies_unicode` alone fails, the bug is UTF-16 versus UTF-8 indexing — do not "fix" it by editing the fixture.
425
426- [ ] **Step 5: Commit**
427
428```bash
429git add core
430git commit -m "core: apply JS-generated Yjs updates
431
432Rust reproduces what real yjs produced, including the unicode fixture,
433which is where UTF-16 versus UTF-8 index conversion would break."
434```
435
436---
437
438### Task 4: JS applies Rust-generated updates — the other direction
439
440**Files:**
441- Create: `core/src/bin/emit_update.rs`
442- Create: `fixtures/verify-rust.mjs`
443- Create: `scripts/wire-gate.sh`
444
445**Interfaces:**
446- Consumes: `apply_update`, `text_content` from Task 3.
447- Produces: `scripts/wire-gate.sh`, the whole-loop gate used by Task 7's CI.
448
449One direction is not compatibility. A Rust implementation that reads JS updates but writes something JS cannot read would pass Task 3 completely.
450
451- [ ] **Step 1: Write the Rust emitter**
452
453`core/src/bin/emit_update.rs`:
454
455```rust
456//! Builds a document in Rust and writes a v1 update to stdout, so the JS
457//! side can verify it reads what Rust writes.
458use std::io::Write;
459use yrs::{Doc, Text, Transact};
460
461fn main() {
462 // Both values fixed: never drawn from yrs (Global Constraints).
463 let doc = Doc::with_options(Options::with_guid_and_client_id(
464 "emit-update-guid".into(),
465 7001,
466 ));
467 {
468 let text = doc.get_or_insert_text("content");
469 let mut txn = doc.transact_mut();
470 text.insert(&mut txn, 0, "from rust 🦀 café");
471 }
472 let txn = doc.transact();
473 let update = txn.encode_state_as_update_v1(&yrs::StateVector::default());
474 std::io::stdout().write_all(&update).unwrap();
475}
476```
477
478- [ ] **Step 2: Write the failing JS verifier**
479
480`fixtures/verify-rust.mjs`:
481
482```javascript
483// Reads a Rust-produced v1 update on stdin and asserts JS yjs decodes it to
484// the expected text. This is the direction Task 3 cannot test.
485import * as Y from 'yjs'
486
487const EXPECTED = 'from rust 🦀 café'
488
489const chunks = []
490for await (const chunk of process.stdin) chunks.push(chunk)
491const update = new Uint8Array(Buffer.concat(chunks))
492
493if (update.length === 0) {
494 console.error('verify-rust: empty update on stdin')
495 process.exit(1)
496}
497
498const doc = new Y.Doc()
499Y.applyUpdate(doc, update)
500const actual = doc.getText('content').toString()
501
502if (actual !== EXPECTED) {
503 console.error(`verify-rust: expected ${JSON.stringify(EXPECTED)}, got ${JSON.stringify(actual)}`)
504 process.exit(1)
505}
506console.log(`verify-rust: ok (${update.length} bytes)`)
507```
508
509- [ ] **Step 3: Run it to verify it fails**
510
511```bash
512cd core && cargo run --bin emit_update | node ../fixtures/verify-rust.mjs
513```
514
515Expected: fails — either the binary does not exist yet, or (once it compiles) it passes. If it passes immediately, that is a real pass: the emitter is the implementation and the test is the verifier. Confirm the test genuinely catches breakage by temporarily changing `EXPECTED` and re-running; it must fail, and change it back.
516
517- [ ] **Step 4: Write the gate script**
518
519`scripts/wire-gate.sh`:
520
521```bash
522#!/usr/bin/env bash
523# The wire-compatibility gate, both directions. This is the CRDT half of the
524# spec's §0: two implementations, fixtures as the contract.
525set -euo pipefail
526cd "$(dirname "$0")/.."
527
528echo "--- regenerating fixtures from JS yjs"
529(cd fixtures && node generate.mjs)
530
531echo "--- fixtures must be unchanged (they are the contract, not an output)"
532if ! git diff --quiet -- fixtures/data; then
533 echo "wire-gate: fixtures changed. If deliberate, commit them WITH the rule change." >&2
534 git diff --stat -- fixtures/data >&2
535 exit 1
536fi
537
538echo "--- Rust reads what JS wrote"
539(cd core && cargo test --test wire_compat)
540
541echo "--- JS reads what Rust wrote"
542(cd core && cargo run --quiet --bin emit_update) | node fixtures/verify-rust.mjs
543
544echo "wire-gate: ok"
545```
546
547```bash
548chmod +x scripts/wire-gate.sh && ./scripts/wire-gate.sh
549```
550
551Expected: all four sections pass, ending `wire-gate: ok`.
552
553The fixture-drift check is the important part: it makes "never edit a fixture to make a test pass" mechanical rather than a rule people remember.
554
555- [ ] **Step 5: Commit**
556
557```bash
558git add core/src/bin fixtures/verify-rust.mjs scripts/wire-gate.sh
559git commit -m "core: JS reads what Rust writes, and gate both directions
560
561One direction is not compatibility -- a Rust implementation that reads
562JS updates but writes something JS cannot read would have passed the
563previous task completely.
564
565wire-gate.sh also fails if regenerating fixtures changes them, which
566makes 'never edit a fixture to make a test pass' mechanical."
567```
568
569---
570
571### Task 5: The C ABI — updates across the wasm boundary
572
573**Files:**
574- Modify: `core/src/lib.rs`
575- Create: `core/src/abi.rs`
576
577**Interfaces:**
578- Consumes: `apply_update`, `text_content` from Task 3.
579- Produces, as wasm exports for Task 6:
580 - `docs_core_abi_version() -> u32`
581 - `docs_core_alloc(len: u32) -> u32` — returns a pointer into linear memory
582 - `docs_core_free(ptr: u32, len: u32)`
583 - `docs_core_doc_new(guid_ptr: u32, guid_len: u32, client_id: u64) -> u32` — returns a document handle, 0 on failure. **The GUID and client id come from the host, never from `yrs`** (Global Constraints)
584 - `docs_core_doc_free(handle: u32)`
585 - `docs_core_apply(handle: u32, ptr: u32, len: u32) -> i32` — 0 ok, negative on error
586 - `docs_core_text_len(handle: u32) -> i32` — byte length, or a negative error code. **Amended during execution:** it was `u32`, which made an unknown handle indistinguishable from an empty document
587 - `docs_core_text_read(handle: u32, out_ptr: u32, out_len: u32) -> i32` — bytes written, negative on error
588
589- [ ] **Step 1: Write the failing test**
590
591Append to `core/tests/wire_compat.rs`:
592
593```rust
594#[test]
595fn abi_round_trips_a_fixture() {
596 let (update, expected) = fixture("unicode");
597
598 let handle = docs_core::abi::doc_new();
599 assert_ne!(handle, 0, "doc_new returned a null handle");
600
601 let rc = docs_core::abi::apply_slice(handle, &update);
602 assert_eq!(rc, 0, "apply returned {rc}");
603
604 let got = docs_core::abi::text_string(handle);
605 assert_eq!(got, expected);
606
607 docs_core::abi::doc_free(handle);
608}
609
610#[test]
611fn abi_rejects_garbage_without_panicking() {
612 let handle = docs_core::abi::doc_new();
613 let rc = docs_core::abi::apply_slice(handle, &[0xff, 0xff, 0xff, 0xff]);
614 assert!(rc < 0, "garbage input should return an error code, got {rc}");
615 docs_core::abi::doc_free(handle);
616}
617```
618
619The second test matters more than it looks: §5.2 says a trap poisons the instance's memory and forces a rebuild from snapshot. An error code costs nothing; a trap costs a document reload.
620
621- [ ] **Step 2: Run it to verify it fails**
622
623```bash
624cd core && cargo test --test wire_compat
625```
626
627Expected: FAIL to compile — no `docs_core::abi` module.
628
629- [ ] **Step 3: Implement the ABI**
630
631`core/src/abi.rs`:
632
633```rust
634//! The C ABI the Go host calls through wazero. No wasm-bindgen: wazero
635//! provides no JS glue, so everything crosses as integers and linear-memory
636//! offsets.
637//!
638//! Handles are indices into a slab rather than raw pointers, so a stale
639//! handle from the host is a null lookup instead of a wild dereference.
640
641use crate::{apply_update, text_content, CoreError};
642use std::cell::RefCell;
643use std::collections::HashMap;
644use yrs::Doc;
645
646thread_local! {
647 static DOCS: RefCell<HashMap<u32, Doc>> = RefCell::new(HashMap::new());
648 static NEXT: RefCell<u32> = const { RefCell::new(1) };
649}
650
651pub const ERR_NO_DOC: i32 = -1;
652pub const ERR_DECODE: i32 = -2;
653pub const ERR_APPLY: i32 = -3;
654pub const ERR_BUFFER_TOO_SMALL: i32 = -4;
655
656pub fn doc_new(guid: &str, client_id: u64) -> u32 {
657 let id = NEXT.with(|n| {
658 let mut n = n.borrow_mut();
659 let id = *n;
660 *n += 1;
661 id
662 });
663 let doc = Doc::with_options(Options::with_guid_and_client_id(guid.into(), client_id));
664 DOCS.with(|d| d.borrow_mut().insert(id, doc));
665 id
666}
667
668pub fn doc_free(handle: u32) {
669 DOCS.with(|d| d.borrow_mut().remove(&handle));
670}
671
672pub fn apply_slice(handle: u32, update: &[u8]) -> i32 {
673 DOCS.with(|d| match d.borrow().get(&handle) {
674 None => ERR_NO_DOC,
675 Some(doc) => match apply_update(doc, update) {
676 Ok(()) => 0,
677 Err(CoreError::Decode(_)) => ERR_DECODE,
678 Err(CoreError::Apply(_)) => ERR_APPLY,
679 },
680 })
681}
682
683pub fn text_string(handle: u32) -> String {
684 DOCS.with(|d| match d.borrow().get(&handle) {
685 None => String::new(),
686 Some(doc) => text_content(doc),
687 })
688}
689
690// --- wasm exports ---
691
692// Amended by pre-flight ruling R2. The obvious version — Vec::with_capacity
693// then Vec::from_raw_parts(ptr, 0, len) — is undefined behaviour: with_capacity
694// guarantees AT LEAST len, so reconstructing with capacity `len` is wrong
695// whenever the allocator rounded up. std::alloc with an identical Layout on
696// both sides has no such gap.
697
698fn layout_for(len: u32) -> std::alloc::Layout {
699 // align 1: these are byte buffers the host writes into.
700 std::alloc::Layout::from_size_align(len.max(1) as usize, 1)
701 .expect("byte layout is always valid")
702}
703
704#[no_mangle]
705pub extern "C" fn docs_core_alloc(len: u32) -> u32 {
706 unsafe { std::alloc::alloc(layout_for(len)) as u32 }
707}
708
709#[no_mangle]
710pub unsafe extern "C" fn docs_core_free(ptr: u32, len: u32) {
711 if ptr != 0 {
712 std::alloc::dealloc(ptr as *mut u8, layout_for(len));
713 }
714}
715
716#[no_mangle]
717pub unsafe extern "C" fn docs_core_doc_new(
718 guid_ptr: u32,
719 guid_len: u32,
720 client_id: u64,
721) -> u32 {
722 let bytes = std::slice::from_raw_parts(guid_ptr as *const u8, guid_len as usize);
723 match std::str::from_utf8(bytes) {
724 Ok(guid) => doc_new(guid, client_id),
725 Err(_) => 0, // invalid UTF-8 GUID is a null handle, not a trap
726 }
727}
728
729#[no_mangle]
730pub extern "C" fn docs_core_doc_free(handle: u32) {
731 doc_free(handle)
732}
733
734#[no_mangle]
735pub unsafe extern "C" fn docs_core_apply(handle: u32, ptr: u32, len: u32) -> i32 {
736 let bytes = std::slice::from_raw_parts(ptr as *const u8, len as usize);
737 apply_slice(handle, bytes)
738}
739
740#[no_mangle]
741pub extern "C" fn docs_core_text_len(handle: u32) -> u32 {
742 text_string(handle).len() as u32
743}
744
745#[no_mangle]
746pub unsafe extern "C" fn docs_core_text_read(handle: u32, out_ptr: u32, out_len: u32) -> i32 {
747 let s = text_string(handle);
748 if s.len() > out_len as usize {
749 return ERR_BUFFER_TOO_SMALL;
750 }
751 std::ptr::copy_nonoverlapping(s.as_ptr(), out_ptr as *mut u8, s.len());
752 s.len() as i32
753}
754```
755
756Add to `core/src/lib.rs`, above the existing code:
757
758```rust
759pub mod abi;
760```
761
762- [ ] **Step 4: Run the tests to verify they pass**
763
764```bash
765cd core && cargo test --test wire_compat && ./../scripts/build-wasm.sh
766```
767
768Expected: 6 passed, then a wasm artifact with a byte count.
769
770- [ ] **Step 5: Verify the exports are actually in the module**
771
772```bash
773cd core && cargo install wasm-tools --locked 2>/dev/null || true
774wasm-tools print target/wasm32-unknown-unknown/release/docs_core.wasm | grep -o '(export "[a-z_]*"' | sort -u
775```
776
777Expected: `docs_core_alloc`, `docs_core_apply`, `docs_core_abi_version`, `docs_core_doc_free`, `docs_core_doc_new`, `docs_core_free`, `docs_core_text_len`, `docs_core_text_read`.
778
779If exports are missing, `crate-type` is wrong or LTO stripped them — add `#[used]` or build with `-C link-arg=--export=<name>`. Missing exports here is the failure mode that would otherwise surface as a confusing wazero error in Task 6.
780
781- [ ] **Step 6: Commit**
782
783```bash
784git add core
785git commit -m "core: C ABI for the wazero host
786
787Handles are slab indices rather than raw pointers, so a stale handle
788from the host is a null lookup instead of a wild dereference. Garbage
789input returns an error code rather than trapping: §5.2 treats a trap as
790poisoning the instance's memory, so a trap costs a document rebuild
791where an error code costs nothing."
792```
793
794---
795
796### Task 6: The Go host loads it under wazero
797
798**Files:**
799- Create: `internal/core/core.go`, `internal/core/core_test.go`
800- Create: `internal/core/testdata/` (symlink or copy step documented below)
801- Create: `go.mod`
802
803**Interfaces:**
804- Consumes: the wasm exports from Task 5, the fixtures from Task 2.
805- Produces: `core.New(ctx) (*Runtime, error)`, `(*Runtime).NewDoc(ctx, guid string, clientID uint64) (*Handle, error)`, `(*Handle).Apply(ctx, update []byte) error`, `(*Handle).Text(ctx) (string, error)`, `(*Handle).Close(ctx) error`.
806
807This is the task that answers the plan's question. Everything before it proves Rust and JS agree; only this proves the artifact runs where the server needs it.
808
809**Two requirements added during execution, both from Task 5's findings:**
810
811**The runtime must cap execution, not merely survive traps.** A 36-byte
812corrupted fixture ran `apply` for **163 real seconds** before returning the
813correct answer. That is not a hang and no correctness test can see it, but on
814a server accepting updates from anyone holding a share link it is denial of
815service. Build the runtime with
816`wazero.NewRuntimeConfig().WithCloseOnContextDone(true)` and give every
817guest call a `context.WithTimeout`. Treat a deadline exceedance exactly like
818a trap: discard the instance, rebuild from snapshot. The original design
819covered an instance that dies and said nothing about one that never returns.
820
821**A trap must discard the module instance.** `yrs` calls
822`from_utf8_unchecked` on wire data, so a malformed update can trap and its
823linear memory is then unusable. The per-document instance is what contains
824that, and the containment is only real if the host actually throws the
825instance away — a host that reuses a trapped instance makes it theoretical.
826There must be a test: trap an instance, then confirm a fresh one for the same
827document rebuilds correctly from its updates.
828
829- [ ] **Step 1: Initialise the Go module and vendor the wasm**
830
831```bash
832CGO_ENABLED=0 go mod init github.com/carlosframework/docs
833CGO_ENABLED=0 go get github.com/tetratelabs/wazero@latest
834mkdir -p internal/core/testdata
835./scripts/build-wasm.sh
836cp core/target/wasm32-unknown-unknown/release/docs_core.wasm internal/core/testdata/
837```
838
839The copy is deliberate: `go:embed` cannot reach outside the module directory, and a build step that silently uses a stale `.wasm` is exactly the class of bug `docs_core_abi_version` exists to catch.
840
841- [ ] **Step 2: Write the failing test**
842
843`internal/core/core_test.go`:
844
845```go
846package core_test
847
848import (
849 "context"
850 "encoding/json"
851 "os"
852 "path/filepath"
853 "testing"
854
855 "github.com/carlosframework/docs/internal/core"
856)
857
858func fixture(t *testing.T, name string) ([]byte, string) {
859 t.Helper()
860 dir := filepath.Join("..", "..", "fixtures", "data")
861 update, err := os.ReadFile(filepath.Join(dir, name+".update.bin"))
862 if err != nil {
863 t.Fatalf("reading fixture: %v", err)
864 }
865 raw, err := os.ReadFile(filepath.Join(dir, name+".expected.json"))
866 if err != nil {
867 t.Fatalf("reading expectation: %v", err)
868 }
869 var want struct {
870 Text string `json:"text"`
871 }
872 if err := json.Unmarshal(raw, &want); err != nil {
873 t.Fatalf("parsing expectation: %v", err)
874 }
875 return update, want.Text
876}
877
878func TestAppliesFixturesUnderWazero(t *testing.T) {
879 ctx := context.Background()
880 rt, err := core.New(ctx)
881 if err != nil {
882 t.Fatalf("core.New: %v", err)
883 }
884 t.Cleanup(func() { rt.Close(ctx) })
885
886 for _, name := range []string{"hello", "unicode", "formatted", "concurrent"} {
887 t.Run(name, func(t *testing.T) {
888 update, want := fixture(t, name)
889 doc, err := rt.NewDoc(ctx, "go-host-test-guid", 9001)
890 if err != nil {
891 t.Fatalf("NewDoc: %v", err)
892 }
893 defer doc.Close(ctx)
894
895 if err := doc.Apply(ctx, update); err != nil {
896 t.Fatalf("Apply: %v", err)
897 }
898 got, err := doc.Text(ctx)
899 if err != nil {
900 t.Fatalf("Text: %v", err)
901 }
902 if got != want {
903 t.Errorf("got %q, want %q", got, want)
904 }
905 })
906 }
907}
908
909func TestGarbageUpdateReturnsErrorNotTrap(t *testing.T) {
910 ctx := context.Background()
911 rt, err := core.New(ctx)
912 if err != nil {
913 t.Fatalf("core.New: %v", err)
914 }
915 t.Cleanup(func() { rt.Close(ctx) })
916
917 doc, err := rt.NewDoc(ctx, "go-host-test-guid", 9001)
918 if err != nil {
919 t.Fatalf("NewDoc: %v", err)
920 }
921 defer doc.Close(ctx)
922
923 if err := doc.Apply(ctx, []byte{0xff, 0xff, 0xff, 0xff}); err == nil {
924 t.Fatal("expected an error for garbage input, got nil")
925 }
926
927 // The instance must still be usable: an error is not a trap.
928 update, want := fixture(t, "hello")
929 if err := doc.Apply(ctx, update); err != nil {
930 t.Fatalf("instance unusable after a rejected update: %v", err)
931 }
932 got, _ := doc.Text(ctx)
933 if got != want {
934 t.Errorf("after recovery got %q, want %q", got, want)
935 }
936}
937```
938
939- [ ] **Step 3: Run it to verify it fails**
940
941```bash
942CGO_ENABLED=0 go test ./internal/core/ -run TestAppliesFixturesUnderWazero -v
943```
944
945Expected: FAIL to build — package `core` has no `New`.
946
947- [ ] **Step 4: Implement the host**
948
949`internal/core/core.go`:
950
951```go
952// Package core hosts the Rust document core as a WebAssembly module.
953//
954// The module is plain wasm32 with a C ABI and no wasm-bindgen, because
955// wazero provides no JS glue. Everything crosses the boundary as integers
956// and offsets into the module's linear memory.
957package core
958
959import (
960 "context"
961 _ "embed"
962 "fmt"
963
964 "github.com/tetratelabs/wazero"
965 "github.com/tetratelabs/wazero/api"
966)
967
968//go:embed testdata/docs_core.wasm
969var moduleWASM []byte
970
971// abiVersion is the contract this host understands. A module reporting
972// anything else is refused at load, so a stale .wasm fails loudly rather
973// than behaving strangely.
974const abiVersion = 1
975
976// Runtime owns the compiled module. Compiling is expensive and
977// instantiating is not, so one Runtime is shared and each document gets its
978// own instance (spec §5.2: instances are not safe for concurrent use).
979type Runtime struct {
980 rt wazero.Runtime
981 compiled wazero.CompiledModule
982}
983
984func New(ctx context.Context) (*Runtime, error) {
985 rt := wazero.NewRuntime(ctx)
986 compiled, err := rt.CompileModule(ctx, moduleWASM)
987 if err != nil {
988 rt.Close(ctx)
989 return nil, fmt.Errorf("compiling core module: %w", err)
990 }
991 return &Runtime{rt: rt, compiled: compiled}, nil
992}
993
994func (r *Runtime) Close(ctx context.Context) error { return r.rt.Close(ctx) }
995
996// Handle is one document, backed by its own module instance. Not safe for
997// concurrent use; callers serialise access per document.
998type Handle struct {
999 mod api.Module
1000 handle uint64
1001
1002 alloc, free api.Function
1003 docNew, docFree api.Function
1004 apply api.Function
1005 textLen, textRead api.Function
1006}
1007
1008// NewDoc creates a document. The GUID and client id are supplied by the
1009// caller and never drawn from yrs: on wasm the CRDT library's entropy is a
1010// constant seed, so a value it generates is identical in every process, and
1011// a repeated client id or GUID corrupts the document (Global Constraints).
1012func (r *Runtime) NewDoc(ctx context.Context, guid string, clientID uint64) (*Handle, error) {
1013 mod, err := r.rt.InstantiateModule(ctx, r.compiled,
1014 wazero.NewModuleConfig().WithName(""))
1015 if err != nil {
1016 return nil, fmt.Errorf("instantiating core module: %w", err)
1017 }
1018
1019 version, err := mod.ExportedFunction("docs_core_abi_version").Call(ctx)
1020 if err != nil {
1021 mod.Close(ctx)
1022 return nil, fmt.Errorf("reading abi version: %w", err)
1023 }
1024 if version[0] != abiVersion {
1025 mod.Close(ctx)
1026 return nil, fmt.Errorf("core module reports ABI %d, host understands %d", version[0], abiVersion)
1027 }
1028
1029 h := &Handle{
1030 mod: mod,
1031 alloc: mod.ExportedFunction("docs_core_alloc"),
1032 free: mod.ExportedFunction("docs_core_free"),
1033 docNew: mod.ExportedFunction("docs_core_doc_new"),
1034 docFree: mod.ExportedFunction("docs_core_doc_free"),
1035 apply: mod.ExportedFunction("docs_core_apply"),
1036 textLen: mod.ExportedFunction("docs_core_text_len"),
1037 textRead: mod.ExportedFunction("docs_core_text_read"),
1038 }
1039
1040 guidBytes := []byte(guid)
1041 guidPtrRes, err := h.alloc.Call(ctx, uint64(len(guidBytes)))
1042 if err != nil {
1043 mod.Close(ctx)
1044 return nil, fmt.Errorf("alloc for guid: %w", err)
1045 }
1046 guidPtr := guidPtrRes[0]
1047 defer h.free.Call(ctx, guidPtr, uint64(len(guidBytes)))
1048 if !mod.Memory().Write(uint32(guidPtr), guidBytes) {
1049 mod.Close(ctx)
1050 return nil, fmt.Errorf("writing guid at %d: out of range", guidPtr)
1051 }
1052
1053 res, err := h.docNew.Call(ctx, guidPtr, uint64(len(guidBytes)), clientID)
1054 if err != nil {
1055 mod.Close(ctx)
1056 return nil, fmt.Errorf("doc_new: %w", err)
1057 }
1058 if res[0] == 0 {
1059 mod.Close(ctx)
1060 return nil, fmt.Errorf("doc_new returned a null handle")
1061 }
1062 h.handle = res[0]
1063 return h, nil
1064}
1065
1066func (h *Handle) Close(ctx context.Context) error {
1067 if _, err := h.docFree.Call(ctx, h.handle); err != nil {
1068 return err
1069 }
1070 return h.mod.Close(ctx)
1071}
1072
1073func (h *Handle) Apply(ctx context.Context, update []byte) error {
1074 ptrRes, err := h.alloc.Call(ctx, uint64(len(update)))
1075 if err != nil {
1076 return fmt.Errorf("alloc: %w", err)
1077 }
1078 ptr := ptrRes[0]
1079 defer h.free.Call(ctx, ptr, uint64(len(update)))
1080
1081 if !h.mod.Memory().Write(uint32(ptr), update) {
1082 return fmt.Errorf("writing %d bytes at %d: out of range", len(update), ptr)
1083 }
1084
1085 rc, err := h.apply.Call(ctx, h.handle, ptr, uint64(len(update)))
1086 if err != nil {
1087 return fmt.Errorf("apply trapped: %w", err)
1088 }
1089 if code := int32(rc[0]); code != 0 {
1090 return fmt.Errorf("apply rejected the update: code %d", code)
1091 }
1092 return nil
1093}
1094
1095func (h *Handle) Text(ctx context.Context) (string, error) {
1096 lenRes, err := h.textLen.Call(ctx, h.handle)
1097 if err != nil {
1098 return "", fmt.Errorf("text_len: %w", err)
1099 }
1100 // text_len returns i32: negative is an error code, not a length. Reading
1101 // it as unsigned would turn ERR_NO_DOC into 0xFFFFFFFF and then try to
1102 // allocate four gigabytes.
1103 if code := int32(lenRes[0]); code < 0 {
1104 return "", fmt.Errorf("text_len failed: code %d", code)
1105 }
1106 n := lenRes[0]
1107 if n == 0 {
1108 return "", nil
1109 }
1110
1111 ptrRes, err := h.alloc.Call(ctx, n)
1112 if err != nil {
1113 return "", fmt.Errorf("alloc: %w", err)
1114 }
1115 ptr := ptrRes[0]
1116 // alloc returns 0 on failure, including for any length over isize::MAX.
1117 if ptr == 0 {
1118 return "", fmt.Errorf("alloc(%d) failed", n)
1119 }
1120 defer h.free.Call(ctx, ptr, n)
1121
1122 rc, err := h.textRead.Call(ctx, h.handle, ptr, n)
1123 if err != nil {
1124 return "", fmt.Errorf("text_read: %w", err)
1125 }
1126 if written := int32(rc[0]); written < 0 {
1127 return "", fmt.Errorf("text_read failed: code %d", written)
1128 }
1129
1130 buf, ok := h.mod.Memory().Read(uint32(ptr), uint32(n))
1131 if !ok {
1132 return "", fmt.Errorf("reading %d bytes at %d: out of range", n, ptr)
1133 }
1134 return string(buf), nil
1135}
1136```
1137
1138- [ ] **Step 5: Run the tests to verify they pass**
1139
1140```bash
1141CGO_ENABLED=0 go test ./internal/core/ -v
1142```
1143
1144Expected: both tests pass, the first with four subtests.
1145
1146If `TestGarbageUpdateReturnsErrorNotTrap` fails with "apply trapped", the Rust side is panicking where it should return an error code — fix it in `core/src/abi.rs`, not by loosening the test. That test is the whole argument for §5.2 being cheap.
1147
1148- [ ] **Step 6: Commit**
1149
1150```bash
1151git add go.mod go.sum internal/core
1152git commit -m "internal/core: run the Rust core under wazero
1153
1154This is the task the plan existed to answer: a Rust y-crdt crate
1155compiles to plain wasm32 with a C ABI and runs inside a Go binary with
1156no cgo and no JS glue, reproducing fixtures generated by real yjs.
1157
1158One module instance per document, since instances are not safe for
1159concurrent use. Garbage input returns an error and leaves the instance
1160usable, which is what makes §5.2's trap-recovery path rare rather than
1161routine."
1162```
1163
1164---
1165
1166### Task 7: The CI gate
1167
1168**Files:**
1169- Create: `.amadan/ci.d/10-gate`, `.amadan/README.md`
1170- Modify: `AGENTS.md`
1171
1172**Interfaces:**
1173- Consumes: `scripts/wire-gate.sh` from Task 4, the Go tests from Task 6.
1174- Produces: a green `amadan ci status`, which closes the bootstrap exception the first merge was granted under.
1175
1176- [ ] **Step 1: Write the gate**
1177
1178`.amadan/ci.d/10-gate`:
1179
1180```bash
1181#!/usr/bin/env bash
1182# The only CI this repo has. Everything a branch must pass before it can
1183# squash-merge, in one script so CI and what you run locally are one
1184# definition rather than two that drift.
1185set -euo pipefail
1186
1187# rustup installs to ~/.cargo/bin, which is not on the PATH of a systemd
1188# user unit and is not on the PATH of a login shell when rustup was run
1189# with --no-modify-path. Source it here rather than depending on whoever
1190# configured the runner having got it right — a gate that only works on one
1191# machine is not a gate.
1192[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env"
1193
1194for tool in cargo rustc go node; do
1195 command -v "$tool" >/dev/null || { echo "gate: $tool not on PATH" >&2; exit 1; }
1196done
1197rustup target list --installed 2>/dev/null | grep -qx wasm32-unknown-unknown \
1198 || { echo "gate: wasm32-unknown-unknown target missing — rustup target add wasm32-unknown-unknown" >&2; exit 1; }
1199
1200export CGO_ENABLED=0
1201
1202echo "=== rust: build, clippy, test"
1203cargo build --release --manifest-path core/Cargo.toml
1204cargo clippy --manifest-path core/Cargo.toml -- -D warnings
1205cargo test --manifest-path core/Cargo.toml
1206
1207echo "=== wasm: the artifact the host embeds"
1208./scripts/build-wasm.sh
1209
1210echo "=== the embedded wasm must match a fresh build"
1211if ! cmp -s core/target/wasm32-unknown-unknown/release/docs_core.wasm internal/core/testdata/docs_core.wasm; then
1212 echo "gate: internal/core/testdata/docs_core.wasm is stale. Re-copy it." >&2
1213 exit 1
1214fi
1215
1216echo "=== wire compatibility, both directions"
1217./scripts/wire-gate.sh
1218
1219echo "=== go: vet, test, gofmt"
1220go vet ./...
1221go test ./... -count=1
1222test -z "$(gofmt -l .)" || { echo "gate: gofmt"; gofmt -l .; exit 1; }
1223
1224echo "gate: ok"
1225```
1226
1227```bash
1228chmod +x .amadan/ci.d/10-gate
1229```
1230
1231The staleness check earns its place: `go:embed` will happily embed last week's module, the tests will pass against it, and the failure surfaces much later as a confusing behaviour difference.
1232
1233- [ ] **Step 2: Document what the gate is**
1234
1235`.amadan/README.md`:
1236
1237```markdown
1238# CI
1239
1240`ci.d/10-gate` is the only CI this repo has. It runs on every push to the
1241amadan remote, and it is the same script you can run locally — one
1242definition rather than two that drift.
1243
1244It requires a Rust toolchain with the `wasm32-unknown-unknown` target, Go
1245with `CGO_ENABLED=0`, and Node with `fixtures/` installed. The gate sources
1246`~/.cargo/env` itself and checks every tool up front, so a missing
1247dependency fails on the first line with its own name rather than fifty
1248lines in as `cargo: command not found`.
1249
1250## The runner
1251
1252Jobs are claimed by an `amadan runner` on the machine that has one. Two
1253things about it were wrong when this repo was created, and both fail
1254silently rather than loudly:
1255
1256- **The runner must be told to serve this repo.** `amadan runner -repo …`
1257 is an allowlist. A repo nobody listed gets `amadan ci status` exit 3
1258 forever — "no status recorded", which reads like the gate has not run yet
1259 rather than like nothing will ever run it.
1260- **The runner's `PATH` is the systemd unit's, not your shell's.**
1261 `~/.cargo/bin` is not on it by default.
1262
1263Both were fixed on `DOCS-T1` on 2026-08-30 by editing
1264`~/.config/systemd/user/amadan-runner.service` and restarting. A restart
1265abandons any job already running, so check it is idle first.
1266
1267The gate fails if `internal/core/testdata/docs_core.wasm` differs from a
1268fresh build, because `go:embed` will otherwise embed a stale module and the
1269tests will pass against it.
1270
1271It also fails if regenerating the Yjs fixtures changes them. Fixtures are
1272the wire contract, not an output: a deliberate rule change regenerates them
1273in the same commit as the change.
1274```
1275
1276- [ ] **Step 3: Run the gate locally**
1277
1278```bash
1279./.amadan/ci.d/10-gate
1280```
1281
1282Expected: every section passes, ending `gate: ok`. Fix anything it catches before pushing — that is the point of it being the same script.
1283
1284- [ ] **Step 4: Record the gate in AGENTS.md**
1285
1286Replace the "CI is `.amadan/ci.d/`, triggered by a push to the remote." sentence in the shipping-cadence section with:
1287
1288```markdown
1289**CI is `.amadan/ci.d/10-gate`**, triggered by a push to the remote, and it
1290is the same script you can run locally. It covers the Rust crate, the wasm
1291artifact, Yjs wire compatibility in both directions, and the Go build and
1292tests. `amadan ci status -branch <n>` exit 0 is the merge gate; exit 2 is
1293pending and exit 3 means nothing ran, and neither is green.
1294```
1295
1296- [ ] **Step 5: Push and confirm CI actually runs**
1297
1298```bash
1299git add .amadan AGENTS.md
1300git commit -m "ci: the gate, and the end of the bootstrap exception
1301
1302One script, run identically in CI and locally. Covers the Rust crate,
1303the wasm artifact, wire compatibility both directions, and the Go build.
1304
1305It fails if the embedded .wasm differs from a fresh build -- go:embed
1306will otherwise embed a stale module and the tests pass against it, which
1307surfaces much later as a confusing behaviour difference.
1308
1309The first branch merged under an explicit bootstrap exception because CI
1310had nothing to gate. It has something now."
1311git push
1312amadan ci status oficina/docs -branch "$(git branch --show-current)"
1313```
1314
1315Expected: exit 2 (pending) at first, then exit 0 once the run completes. If it stays exit 3, no runner is serving this repo — say so in the branch discussion rather than working around it.
1316
1317---
1318
1319## Self-Review
1320
1321**Spec coverage.** This plan implements §0's CRDT half, §5's crate and its server role, §5.2's trap behaviour and per-document instances, and §12's wire-compatibility gate. It deliberately does **not** implement: layout (§6.1, §11.1 — plan 2), the editor (§6.3–6.5), collaboration transport (§7), identity and sharing (§8–9), import and export (§11), or the design system (§10). Those are later plans, listed below.
1322
1323**Not covered here, and why:** §5.2's memory budget and LRU eviction need a pool, and a pool needs more than one document in flight — that lands in plan 2 alongside layout, where a large document makes the budget measurable rather than theoretical. Task 6 builds one instance per document, which is the shape the pool will manage.
1324
1325**Placeholder scan:** no TBDs. Every code step has runnable content. The one hedge is explicit and bounded: Task 3 Step 3 says to check `yrs`'s pinned signature for `apply_update` rather than trusting the snippet, because that API changed across the 0.1x/0.2x line and this plan was written without a Rust toolchain available to verify against.
1326
1327**Type consistency:** `apply_update`/`text_content` (Task 3) are consumed by `abi.rs` (Task 5); the eight exported symbol names in Task 5's Interfaces match Task 5 Step 5's expected `wasm-tools` output and Task 6's `ExportedFunction` lookups. Fixture names `hello`/`unicode`/`formatted`/`concurrent` are consistent across Tasks 2, 3 and 6.
1328
1329## The remaining plans
1330
1331Each produces working, testable software on its own:
1332
13332. **The layout engine** — fonts (§11.1), shaping, line breaking, page geometry, the box tree, and the layout-agreement gate. The wazero pool and memory budget land here.
13343. **The Go app on rastrillo** — list, create, open, server-rendered read-only. The rastrillo dependency decision (release versus `main` pseudo-version) is made at the start of this one.
13354. **The editor** — ProseMirror, JS Yjs, decoration-based pagination.
13365. **Collaboration** — transport, presence, offline, hibernation behaviour.
13376. **Sharing** — grants, guests, link shares, the home outbox.
13387. **Import and export** — Markdown, then DOCX, then PDF.
13398. **Comments, version capture, the outline pane.**
13409. **Thumbnails.**
1341