Plan 1: Rust core, Yjs wire compatibility under wazero
The spec covers eight independent subsystems, so it becomes several plans rather than one. This is the first, scoped to the question everything else depends on: can a Rust y-crdt crate compile to plain wasm32 with a C ABI, run under wazero inside a Go binary, and stay byte-compatible with browser JS yjs in both directions? Seven tasks, each ending in an independently testable deliverable. It touches no rastrillo at all, which is deliberate -- the dependency decision is deferred to plan 3, and rastrillo's main is 18 commits ahead of its latest release. The plan is allowed to conclude the design is wrong. If yrs cannot build without wasm-bindgen for a wazero host, the honest outcome is a pure-Go Yjs port, which spec §15.6 already reserves. Two details worth naming here because they are the ones that bite: Fixtures are generated by real JS yjs and are the contract, so wire-gate.sh fails if regenerating them changes anything. That makes "never edit a fixture to make a test pass" mechanical rather than a rule someone remembers. Garbage input must return an error code rather than trapping. §5.2 treats a trap as poisoning the instance's linear memory and forcing a rebuild from snapshot, so a trap costs a document reload where an error code costs nothing. There is a test for exactly that. Also records two runner facts found while setting this up, both of which fail silently: amadan runner -repo is an allowlist, so an unlisted repo gets ci status exit 3 forever and it reads like "not run yet" rather than "nothing will ever run it"; and the runner's PATH is the systemd unit's, which has no ~/.cargo/bin. The gate now sources cargo env itself and checks its tools up front.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011F5CivVm4CmL9ByfSSAaXA
1 file changed,
+1237
−0
docs/superpowers/plans/2026-08-30-rust-core-wire-compat.md+1237 −0
No CI script — run details
Intent
diff --git a/docs/superpowers/plans/2026-08-30-rust-core-wire-compat.md b/docs/superpowers/plans/2026-08-30-rust-core-wire-compat.md| new file mode 100644 |
| index 0000000..196452c |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-08-30-rust-core-wire-compat.md |
| @@ -0,0 +1,1237 @@ |
| +# Rust core: Yjs wire compatibility under wazero — Implementation Plan |
| + |
| +> **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. |
| + |
| +**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. |
| + |
| +**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. |
| + |
| +**Tech Stack:** Rust (`yrs`), `wasm32-unknown-unknown`, Go 1.26 + `wazero`, Node 24 + `yjs`. |
| + |
| +**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). |
| + |
| +## Why this plan exists, and what it is allowed to conclude |
| + |
| +This 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. |
| + |
| +**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. |
| + |
| +**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. |
| + |
| +## Global Constraints |
| + |
| +- **No cgo, ever.** `CGO_ENABLED=0` on every Go build and test command. A cgo dependency sneaking in is a bug, not a tradeoff. |
| +- **No wasm-bindgen, no `wasm-pack`.** Target is `wasm32-unknown-unknown` with `crate-type = ["cdylib"]` and `extern "C"` exports. |
| +- **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. |
| +- **Pin exact versions.** No `*` or caret-only ranges in `Cargo.toml` or `package.json` for `yrs`/`yjs`. |
| +- Commit after every task. Small commits, pushed. |
| +- Work happens on a branch, never `main`. Squash-merge when done, approved, and CI green. |
| + |
| +--- |
| + |
| +### Task 1: Toolchain, crate skeleton, and the build gate |
| + |
| +**Files:** |
| +- Create: `core/Cargo.toml`, `core/src/lib.rs`, `core/.gitignore` |
| +- Create: `scripts/build-wasm.sh` |
| +- Modify: `.gitignore` |
| + |
| +**Interfaces:** |
| +- Consumes: nothing. |
| +- 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`. |
| + |
| +- [ ] **Step 1: Install the Rust toolchain and the wasm target** |
| + |
| +Rust is not installed on this machine. This is a system change; run it outside the sandbox. |
| + |
| +```bash |
| +curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal |
| +source "$HOME/.cargo/env" |
| +rustup target add wasm32-unknown-unknown |
| +``` |
| + |
| +- [ ] **Step 2: Verify the toolchain** |
| + |
| +```bash |
| +rustc --version && cargo --version && rustup target list --installed | grep wasm32-unknown-unknown |
| +``` |
| + |
| +Expected: three lines, the last being `wasm32-unknown-unknown`. If the target is missing, step 1's `rustup target add` did not run. |
| + |
| +- [ ] **Step 3: Create the crate** |
| + |
| +`core/Cargo.toml`: |
| + |
| +```toml |
| +[package] |
| +name = "docs-core" |
| +version = "0.1.0" |
| +edition = "2021" |
| + |
| +[lib] |
| +crate-type = ["cdylib", "rlib"] |
| + |
| +[dependencies] |
| +yrs = "0.23" |
| + |
| +[profile.release] |
| +opt-level = "s" |
| +lto = true |
| +panic = "abort" |
| +``` |
| + |
| +`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. |
| + |
| +Pin `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. |
| + |
| +`core/src/lib.rs`: |
| + |
| +```rust |
| +//! The Docs core: Yjs CRDT and (later) text layout, compiled to wasm and |
| +//! run both under wazero on the server and — for layout only — in the browser. |
| + |
| +/// Returns the ABI version this module exports. The Go host checks this on |
| +/// load and refuses a module it does not understand, so a stale .wasm on a |
| +/// box fails loudly instead of behaving strangely. |
| +#[no_mangle] |
| +pub extern "C" fn docs_core_abi_version() -> u32 { |
| + 1 |
| +} |
| +``` |
| + |
| +`core/.gitignore`: |
| + |
| +``` |
| +/target |
| +``` |
| + |
| +- [ ] **Step 4: Verify both builds** |
| + |
| +```bash |
| +cd core && cargo build && cargo build --release --target wasm32-unknown-unknown |
| +``` |
| + |
| +Expected: both succeed. The wasm artifact lands at `core/target/wasm32-unknown-unknown/release/docs_core.wasm`. |
| + |
| +- [ ] **Step 5: Add the build script** |
| + |
| +`scripts/build-wasm.sh`: |
| + |
| +```bash |
| +#!/usr/bin/env bash |
| +# Build the core crate for wazero. No wasm-bindgen, no wasm-pack: the Go host |
| +# provides no JS glue, so the module must be plain wasm32 with a C ABI. |
| +set -euo pipefail |
| +cd "$(dirname "$0")/.." |
| +cargo build --release --manifest-path core/Cargo.toml --target wasm32-unknown-unknown |
| +out="core/target/wasm32-unknown-unknown/release/docs_core.wasm" |
| +[ -f "$out" ] || { echo "build-wasm: no artifact at $out" >&2; exit 1; } |
| +echo "$out ($(wc -c < "$out") bytes)" |
| +``` |
| + |
| +```bash |
| +chmod +x scripts/build-wasm.sh && ./scripts/build-wasm.sh |
| +``` |
| + |
| +Expected: a path and a byte count. |
| + |
| +- [ ] **Step 6: Commit** |
| + |
| +```bash |
| +git add core scripts/build-wasm.sh .gitignore |
| +git commit -m "core: Rust crate skeleton building for wazero |
| + |
| +Plain wasm32-unknown-unknown with a C ABI and no wasm-bindgen, because |
| +the Go host provides no JS glue. panic = abort, since §5.2 treats a |
| +trapped instance's memory as poisoned rather than unwinding across the |
| +boundary." |
| +``` |
| + |
| +--- |
| + |
| +### Task 2: Fixture generator — real JS yjs produces the contract |
| + |
| +**Files:** |
| +- Create: `fixtures/package.json`, `fixtures/generate.mjs` |
| +- Create: `fixtures/data/` (generated, checked in) |
| + |
| +**Interfaces:** |
| +- Consumes: nothing. |
| +- 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. |
| + |
| +- [ ] **Step 1: Create the fixture package** |
| + |
| +`fixtures/package.json`: |
| + |
| +```json |
| +{ |
| + "name": "docs-fixtures", |
| + "private": true, |
| + "type": "module", |
| + "description": "Generates Yjs wire fixtures. The JS side is the contract; Rust must reproduce these.", |
| + "dependencies": { |
| + "yjs": "13.6.27" |
| + } |
| +} |
| +``` |
| + |
| +Pin whatever `npm install yjs` actually resolves; do not leave a range. |
| + |
| +```bash |
| +cd fixtures && npm install |
| +``` |
| + |
| +- [ ] **Step 2: Write the generator** |
| + |
| +`fixtures/generate.mjs`: |
| + |
| +```javascript |
| +// Generates the Yjs wire fixtures that define compatibility for the Rust |
| +// core. These are the contract: never edit a fixture to make a test pass. |
| +// A deliberate rule change regenerates them in the same commit. |
| +import * as Y from 'yjs' |
| +import { mkdirSync, writeFileSync } from 'node:fs' |
| +import { join, dirname } from 'node:path' |
| +import { fileURLToPath } from 'node:url' |
| + |
| +const dataDir = join(dirname(fileURLToPath(import.meta.url)), 'data') |
| +mkdirSync(dataDir, { recursive: true }) |
| + |
| +function emit(name, build) { |
| + const doc = new Y.Doc() |
| + build(doc) |
| + const update = Y.encodeStateAsUpdate(doc) |
| + writeFileSync(join(dataDir, `${name}.update.bin`), Buffer.from(update)) |
| + writeFileSync( |
| + join(dataDir, `${name}.expected.json`), |
| + JSON.stringify({ text: doc.getText('content').toString() }, null, 2) + '\n' |
| + ) |
| + console.log(`${name}: ${update.length} bytes`) |
| +} |
| + |
| +emit('hello', (doc) => { |
| + doc.getText('content').insert(0, 'hello world') |
| +}) |
| + |
| +emit('unicode', (doc) => { |
| + // Surrogate pairs and combining marks: Yjs indexes UTF-16 code units and |
| + // Rust indexes UTF-8 bytes, so this is where an index-conversion bug shows. |
| + doc.getText('content').insert(0, 'héllo 🌍 café') |
| +}) |
| + |
| +emit('formatted', (doc) => { |
| + const t = doc.getText('content') |
| + t.insert(0, 'plain bold plain') |
| + t.format(6, 4, { bold: true }) |
| +}) |
| + |
| +emit('concurrent', (doc) => { |
| + // Two docs diverge and merge. The merged state is order-independent, so |
| + // Rust must arrive at the same bytes from the same inputs. |
| + const a = new Y.Doc() |
| + const b = new Y.Doc() |
| + a.getText('content').insert(0, 'AAA') |
| + b.getText('content').insert(0, 'BBB') |
| + Y.applyUpdate(a, Y.encodeStateAsUpdate(b)) |
| + Y.applyUpdate(b, Y.encodeStateAsUpdate(a)) |
| + Y.applyUpdate(doc, Y.encodeStateAsUpdate(a)) |
| +}) |
| +``` |
| + |
| +- [ ] **Step 3: Generate and inspect** |
| + |
| +```bash |
| +cd fixtures && node generate.mjs && ls -la data/ |
| +``` |
| + |
| +Expected: four `.update.bin` files with non-zero sizes and four `.expected.json`. Read `data/hello.expected.json` and confirm it says `"text": "hello world"`. |
| + |
| +- [ ] **Step 4: Commit** |
| + |
| +```bash |
| +git add fixtures |
| +git commit -m "fixtures: Yjs wire fixtures generated by real JS yjs |
| + |
| +These are the contract for the Rust core. Never edited to make a test |
| +pass; a deliberate rule change regenerates them in the same commit. |
| + |
| +The unicode fixture is the one that matters most: Yjs indexes UTF-16 |
| +code units and Rust indexes UTF-8 bytes, so an index-conversion bug |
| +shows there and nowhere else." |
| +``` |
| + |
| +--- |
| + |
| +### Task 3: Rust applies JS-generated updates |
| + |
| +**Files:** |
| +- Create: `core/tests/wire_compat.rs` |
| +- Modify: `core/src/lib.rs` |
| + |
| +**Interfaces:** |
| +- Consumes: `fixtures/data/*.update.bin` and `*.expected.json` from Task 2. |
| +- 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. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +`core/tests/wire_compat.rs`: |
| + |
| +```rust |
| +use std::fs; |
| +use std::path::PathBuf; |
| +use yrs::Doc; |
| + |
| +fn fixture(name: &str) -> (Vec<u8>, String) { |
| + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")) |
| + .parent() |
| + .unwrap() |
| + .join("fixtures/data"); |
| + let update = fs::read(dir.join(format!("{name}.update.bin"))) |
| + .unwrap_or_else(|e| panic!("missing fixture {name}.update.bin: {e}")); |
| + let expected: serde_json::Value = serde_json::from_str( |
| + &fs::read_to_string(dir.join(format!("{name}.expected.json"))).unwrap(), |
| + ) |
| + .unwrap(); |
| + (update, expected["text"].as_str().unwrap().to_string()) |
| +} |
| + |
| +fn check(name: &str) { |
| + let (update, expected) = fixture(name); |
| + let doc = Doc::new(); |
| + docs_core::apply_update(&doc, &update).expect("apply failed"); |
| + assert_eq!(docs_core::text_content(&doc), expected, "fixture {name}"); |
| +} |
| + |
| +#[test] |
| +fn applies_hello() { |
| + check("hello"); |
| +} |
| + |
| +#[test] |
| +fn applies_unicode() { |
| + check("unicode"); |
| +} |
| + |
| +#[test] |
| +fn applies_formatted() { |
| + check("formatted"); |
| +} |
| + |
| +#[test] |
| +fn applies_concurrent() { |
| + check("concurrent"); |
| +} |
| +``` |
| + |
| +Add the dev-dependency to `core/Cargo.toml`: |
| + |
| +```toml |
| +[dev-dependencies] |
| +serde_json = "1" |
| +``` |
| + |
| +- [ ] **Step 2: Run the test to verify it fails** |
| + |
| +```bash |
| +cd core && cargo test --test wire_compat |
| +``` |
| + |
| +Expected: FAIL to compile — `apply_update` and `text_content` do not exist in `docs_core`. |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| +Append to `core/src/lib.rs`: |
| + |
| +```rust |
| +use yrs::updates::decoder::Decode; |
| +use yrs::{Doc, GetString, Text, Transact, Update}; |
| + |
| +#[derive(Debug)] |
| +pub enum CoreError { |
| + /// The bytes were not a valid Yjs v1 update. |
| + Decode(String), |
| + /// The update decoded but could not be applied. |
| + Apply(String), |
| +} |
| + |
| +impl std::fmt::Display for CoreError { |
| + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
| + match self { |
| + CoreError::Decode(m) => write!(f, "decode: {m}"), |
| + CoreError::Apply(m) => write!(f, "apply: {m}"), |
| + } |
| + } |
| +} |
| + |
| +/// Applies a Yjs v1 update to `doc`. |
| +pub fn apply_update(doc: &Doc, update: &[u8]) -> Result<(), CoreError> { |
| + let update = Update::decode_v1(update).map_err(|e| CoreError::Decode(e.to_string()))?; |
| + let mut txn = doc.transact_mut(); |
| + txn.apply_update(update) |
| + .map_err(|e| CoreError::Apply(e.to_string())) |
| +} |
| + |
| +/// The plain-text content of the document's root text type. The name |
| +/// "content" matches what the fixture generator writes; it is the CRDT root |
| +/// name and is part of the wire contract. |
| +pub fn text_content(doc: &Doc) -> String { |
| + let text = doc.get_or_insert_text("content"); |
| + let txn = doc.transact(); |
| + text.get_string(&txn) |
| +} |
| +``` |
| + |
| +If `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. |
| + |
| +- [ ] **Step 4: Run the tests to verify they pass** |
| + |
| +```bash |
| +cd core && cargo test --test wire_compat |
| +``` |
| + |
| +Expected: 4 passed. If `applies_unicode` alone fails, the bug is UTF-16 versus UTF-8 indexing — do not "fix" it by editing the fixture. |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add core |
| +git commit -m "core: apply JS-generated Yjs updates |
| + |
| +Rust reproduces what real yjs produced, including the unicode fixture, |
| +which is where UTF-16 versus UTF-8 index conversion would break." |
| +``` |
| + |
| +--- |
| + |
| +### Task 4: JS applies Rust-generated updates — the other direction |
| + |
| +**Files:** |
| +- Create: `core/src/bin/emit_update.rs` |
| +- Create: `fixtures/verify-rust.mjs` |
| +- Create: `scripts/wire-gate.sh` |
| + |
| +**Interfaces:** |
| +- Consumes: `apply_update`, `text_content` from Task 3. |
| +- Produces: `scripts/wire-gate.sh`, the whole-loop gate used by Task 7's CI. |
| + |
| +One direction is not compatibility. A Rust implementation that reads JS updates but writes something JS cannot read would pass Task 3 completely. |
| + |
| +- [ ] **Step 1: Write the Rust emitter** |
| + |
| +`core/src/bin/emit_update.rs`: |
| + |
| +```rust |
| +//! Builds a document in Rust and writes a v1 update to stdout, so the JS |
| +//! side can verify it reads what Rust writes. |
| +use std::io::Write; |
| +use yrs::{Doc, Text, Transact}; |
| + |
| +fn main() { |
| + let doc = Doc::new(); |
| + { |
| + let text = doc.get_or_insert_text("content"); |
| + let mut txn = doc.transact_mut(); |
| + text.insert(&mut txn, 0, "from rust 🦀 café"); |
| + } |
| + let txn = doc.transact(); |
| + let update = txn.encode_state_as_update_v1(&yrs::StateVector::default()); |
| + std::io::stdout().write_all(&update).unwrap(); |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Write the failing JS verifier** |
| + |
| +`fixtures/verify-rust.mjs`: |
| + |
| +```javascript |
| +// Reads a Rust-produced v1 update on stdin and asserts JS yjs decodes it to |
| +// the expected text. This is the direction Task 3 cannot test. |
| +import * as Y from 'yjs' |
| + |
| +const EXPECTED = 'from rust 🦀 café' |
| + |
| +const chunks = [] |
| +for await (const chunk of process.stdin) chunks.push(chunk) |
| +const update = new Uint8Array(Buffer.concat(chunks)) |
| + |
| +if (update.length === 0) { |
| + console.error('verify-rust: empty update on stdin') |
| + process.exit(1) |
| +} |
| + |
| +const doc = new Y.Doc() |
| +Y.applyUpdate(doc, update) |
| +const actual = doc.getText('content').toString() |
| + |
| +if (actual !== EXPECTED) { |
| + console.error(`verify-rust: expected ${JSON.stringify(EXPECTED)}, got ${JSON.stringify(actual)}`) |
| + process.exit(1) |
| +} |
| +console.log(`verify-rust: ok (${update.length} bytes)`) |
| +``` |
| + |
| +- [ ] **Step 3: Run it to verify it fails** |
| + |
| +```bash |
| +cd core && cargo run --bin emit_update | node ../fixtures/verify-rust.mjs |
| +``` |
| + |
| +Expected: 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. |
| + |
| +- [ ] **Step 4: Write the gate script** |
| + |
| +`scripts/wire-gate.sh`: |
| + |
| +```bash |
| +#!/usr/bin/env bash |
| +# The wire-compatibility gate, both directions. This is the CRDT half of the |
| +# spec's §0: two implementations, fixtures as the contract. |
| +set -euo pipefail |
| +cd "$(dirname "$0")/.." |
| + |
| +echo "--- regenerating fixtures from JS yjs" |
| +(cd fixtures && node generate.mjs) |
| + |
| +echo "--- fixtures must be unchanged (they are the contract, not an output)" |
| +if ! git diff --quiet -- fixtures/data; then |
| + echo "wire-gate: fixtures changed. If deliberate, commit them WITH the rule change." >&2 |
| + git diff --stat -- fixtures/data >&2 |
| + exit 1 |
| +fi |
| + |
| +echo "--- Rust reads what JS wrote" |
| +(cd core && cargo test --test wire_compat) |
| + |
| +echo "--- JS reads what Rust wrote" |
| +(cd core && cargo run --quiet --bin emit_update) | node fixtures/verify-rust.mjs |
| + |
| +echo "wire-gate: ok" |
| +``` |
| + |
| +```bash |
| +chmod +x scripts/wire-gate.sh && ./scripts/wire-gate.sh |
| +``` |
| + |
| +Expected: all four sections pass, ending `wire-gate: ok`. |
| + |
| +The 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. |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add core/src/bin fixtures/verify-rust.mjs scripts/wire-gate.sh |
| +git commit -m "core: JS reads what Rust writes, and gate both directions |
| + |
| +One direction is not compatibility -- a Rust implementation that reads |
| +JS updates but writes something JS cannot read would have passed the |
| +previous task completely. |
| + |
| +wire-gate.sh also fails if regenerating fixtures changes them, which |
| +makes 'never edit a fixture to make a test pass' mechanical." |
| +``` |
| + |
| +--- |
| + |
| +### Task 5: The C ABI — updates across the wasm boundary |
| + |
| +**Files:** |
| +- Modify: `core/src/lib.rs` |
| +- Create: `core/src/abi.rs` |
| + |
| +**Interfaces:** |
| +- Consumes: `apply_update`, `text_content` from Task 3. |
| +- Produces, as wasm exports for Task 6: |
| + - `docs_core_abi_version() -> u32` |
| + - `docs_core_alloc(len: u32) -> u32` — returns a pointer into linear memory |
| + - `docs_core_free(ptr: u32, len: u32)` |
| + - `docs_core_doc_new() -> u32` — returns a document handle, 0 on failure |
| + - `docs_core_doc_free(handle: u32)` |
| + - `docs_core_apply(handle: u32, ptr: u32, len: u32) -> i32` — 0 ok, negative on error |
| + - `docs_core_text_len(handle: u32) -> u32` |
| + - `docs_core_text_read(handle: u32, out_ptr: u32, out_len: u32) -> i32` — bytes written, negative on error |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +Append to `core/tests/wire_compat.rs`: |
| + |
| +```rust |
| +#[test] |
| +fn abi_round_trips_a_fixture() { |
| + let (update, expected) = fixture("unicode"); |
| + |
| + let handle = docs_core::abi::doc_new(); |
| + assert_ne!(handle, 0, "doc_new returned a null handle"); |
| + |
| + let rc = docs_core::abi::apply_slice(handle, &update); |
| + assert_eq!(rc, 0, "apply returned {rc}"); |
| + |
| + let got = docs_core::abi::text_string(handle); |
| + assert_eq!(got, expected); |
| + |
| + docs_core::abi::doc_free(handle); |
| +} |
| + |
| +#[test] |
| +fn abi_rejects_garbage_without_panicking() { |
| + let handle = docs_core::abi::doc_new(); |
| + let rc = docs_core::abi::apply_slice(handle, &[0xff, 0xff, 0xff, 0xff]); |
| + assert!(rc < 0, "garbage input should return an error code, got {rc}"); |
| + docs_core::abi::doc_free(handle); |
| +} |
| +``` |
| + |
| +The 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. |
| + |
| +- [ ] **Step 2: Run it to verify it fails** |
| + |
| +```bash |
| +cd core && cargo test --test wire_compat |
| +``` |
| + |
| +Expected: FAIL to compile — no `docs_core::abi` module. |
| + |
| +- [ ] **Step 3: Implement the ABI** |
| + |
| +`core/src/abi.rs`: |
| + |
| +```rust |
| +//! The C ABI the Go host calls through wazero. No wasm-bindgen: wazero |
| +//! provides no JS glue, so everything crosses as integers and linear-memory |
| +//! offsets. |
| +//! |
| +//! Handles are indices into a slab rather than raw pointers, so a stale |
| +//! handle from the host is a null lookup instead of a wild dereference. |
| + |
| +use crate::{apply_update, text_content, CoreError}; |
| +use std::cell::RefCell; |
| +use std::collections::HashMap; |
| +use yrs::Doc; |
| + |
| +thread_local! { |
| + static DOCS: RefCell<HashMap<u32, Doc>> = RefCell::new(HashMap::new()); |
| + static NEXT: RefCell<u32> = const { RefCell::new(1) }; |
| +} |
| + |
| +pub const ERR_NO_DOC: i32 = -1; |
| +pub const ERR_DECODE: i32 = -2; |
| +pub const ERR_APPLY: i32 = -3; |
| +pub const ERR_BUFFER_TOO_SMALL: i32 = -4; |
| + |
| +pub fn doc_new() -> u32 { |
| + let id = NEXT.with(|n| { |
| + let mut n = n.borrow_mut(); |
| + let id = *n; |
| + *n += 1; |
| + id |
| + }); |
| + DOCS.with(|d| d.borrow_mut().insert(id, Doc::new())); |
| + id |
| +} |
| + |
| +pub fn doc_free(handle: u32) { |
| + DOCS.with(|d| d.borrow_mut().remove(&handle)); |
| +} |
| + |
| +pub fn apply_slice(handle: u32, update: &[u8]) -> i32 { |
| + DOCS.with(|d| match d.borrow().get(&handle) { |
| + None => ERR_NO_DOC, |
| + Some(doc) => match apply_update(doc, update) { |
| + Ok(()) => 0, |
| + Err(CoreError::Decode(_)) => ERR_DECODE, |
| + Err(CoreError::Apply(_)) => ERR_APPLY, |
| + }, |
| + }) |
| +} |
| + |
| +pub fn text_string(handle: u32) -> String { |
| + DOCS.with(|d| match d.borrow().get(&handle) { |
| + None => String::new(), |
| + Some(doc) => text_content(doc), |
| + }) |
| +} |
| + |
| +// --- wasm exports --- |
| + |
| +#[no_mangle] |
| +pub extern "C" fn docs_core_alloc(len: u32) -> u32 { |
| + let mut buf = Vec::<u8>::with_capacity(len as usize); |
| + let ptr = buf.as_mut_ptr() as u32; |
| + std::mem::forget(buf); |
| + ptr |
| +} |
| + |
| +#[no_mangle] |
| +pub unsafe extern "C" fn docs_core_free(ptr: u32, len: u32) { |
| + drop(Vec::from_raw_parts(ptr as *mut u8, 0, len as usize)); |
| +} |
| + |
| +#[no_mangle] |
| +pub extern "C" fn docs_core_doc_new() -> u32 { |
| + doc_new() |
| +} |
| + |
| +#[no_mangle] |
| +pub extern "C" fn docs_core_doc_free(handle: u32) { |
| + doc_free(handle) |
| +} |
| + |
| +#[no_mangle] |
| +pub unsafe extern "C" fn docs_core_apply(handle: u32, ptr: u32, len: u32) -> i32 { |
| + let bytes = std::slice::from_raw_parts(ptr as *const u8, len as usize); |
| + apply_slice(handle, bytes) |
| +} |
| + |
| +#[no_mangle] |
| +pub extern "C" fn docs_core_text_len(handle: u32) -> u32 { |
| + text_string(handle).len() as u32 |
| +} |
| + |
| +#[no_mangle] |
| +pub unsafe extern "C" fn docs_core_text_read(handle: u32, out_ptr: u32, out_len: u32) -> i32 { |
| + let s = text_string(handle); |
| + if s.len() > out_len as usize { |
| + return ERR_BUFFER_TOO_SMALL; |
| + } |
| + std::ptr::copy_nonoverlapping(s.as_ptr(), out_ptr as *mut u8, s.len()); |
| + s.len() as i32 |
| +} |
| +``` |
| + |
| +Add to `core/src/lib.rs`, above the existing code: |
| + |
| +```rust |
| +pub mod abi; |
| +``` |
| + |
| +- [ ] **Step 4: Run the tests to verify they pass** |
| + |
| +```bash |
| +cd core && cargo test --test wire_compat && ./../scripts/build-wasm.sh |
| +``` |
| + |
| +Expected: 6 passed, then a wasm artifact with a byte count. |
| + |
| +- [ ] **Step 5: Verify the exports are actually in the module** |
| + |
| +```bash |
| +cd core && cargo install wasm-tools --locked 2>/dev/null || true |
| +wasm-tools print target/wasm32-unknown-unknown/release/docs_core.wasm | grep -o '(export "[a-z_]*"' | sort -u |
| +``` |
| + |
| +Expected: `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`. |
| + |
| +If 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. |
| + |
| +- [ ] **Step 6: Commit** |
| + |
| +```bash |
| +git add core |
| +git commit -m "core: C ABI for the wazero host |
| + |
| +Handles are slab indices rather than raw pointers, so a stale handle |
| +from the host is a null lookup instead of a wild dereference. Garbage |
| +input returns an error code rather than trapping: §5.2 treats a trap as |
| +poisoning the instance's memory, so a trap costs a document rebuild |
| +where an error code costs nothing." |
| +``` |
| + |
| +--- |
| + |
| +### Task 6: The Go host loads it under wazero |
| + |
| +**Files:** |
| +- Create: `internal/core/core.go`, `internal/core/core_test.go` |
| +- Create: `internal/core/testdata/` (symlink or copy step documented below) |
| +- Create: `go.mod` |
| + |
| +**Interfaces:** |
| +- Consumes: the wasm exports from Task 5, the fixtures from Task 2. |
| +- Produces: `core.New(ctx) (*Runtime, error)`, `(*Runtime).NewDoc(ctx) (*Handle, error)`, `(*Handle).Apply(ctx, update []byte) error`, `(*Handle).Text(ctx) (string, error)`, `(*Handle).Close(ctx) error`. |
| + |
| +This 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. |
| + |
| +- [ ] **Step 1: Initialise the Go module and vendor the wasm** |
| + |
| +```bash |
| +CGO_ENABLED=0 go mod init github.com/carlosframework/docs |
| +CGO_ENABLED=0 go get github.com/tetratelabs/wazero@latest |
| +mkdir -p internal/core/testdata |
| +./scripts/build-wasm.sh |
| +cp core/target/wasm32-unknown-unknown/release/docs_core.wasm internal/core/testdata/ |
| +``` |
| + |
| +The 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. |
| + |
| +- [ ] **Step 2: Write the failing test** |
| + |
| +`internal/core/core_test.go`: |
| + |
| +```go |
| +package core_test |
| + |
| +import ( |
| + "context" |
| + "encoding/json" |
| + "os" |
| + "path/filepath" |
| + "testing" |
| + |
| + "github.com/carlosframework/docs/internal/core" |
| +) |
| + |
| +func fixture(t *testing.T, name string) ([]byte, string) { |
| + t.Helper() |
| + dir := filepath.Join("..", "..", "fixtures", "data") |
| + update, err := os.ReadFile(filepath.Join(dir, name+".update.bin")) |
| + if err != nil { |
| + t.Fatalf("reading fixture: %v", err) |
| + } |
| + raw, err := os.ReadFile(filepath.Join(dir, name+".expected.json")) |
| + if err != nil { |
| + t.Fatalf("reading expectation: %v", err) |
| + } |
| + var want struct { |
| + Text string `json:"text"` |
| + } |
| + if err := json.Unmarshal(raw, &want); err != nil { |
| + t.Fatalf("parsing expectation: %v", err) |
| + } |
| + return update, want.Text |
| +} |
| + |
| +func TestAppliesFixturesUnderWazero(t *testing.T) { |
| + ctx := context.Background() |
| + rt, err := core.New(ctx) |
| + if err != nil { |
| + t.Fatalf("core.New: %v", err) |
| + } |
| + t.Cleanup(func() { rt.Close(ctx) }) |
| + |
| + for _, name := range []string{"hello", "unicode", "formatted", "concurrent"} { |
| + t.Run(name, func(t *testing.T) { |
| + update, want := fixture(t, name) |
| + doc, err := rt.NewDoc(ctx) |
| + if err != nil { |
| + t.Fatalf("NewDoc: %v", err) |
| + } |
| + defer doc.Close(ctx) |
| + |
| + if err := doc.Apply(ctx, update); err != nil { |
| + t.Fatalf("Apply: %v", err) |
| + } |
| + got, err := doc.Text(ctx) |
| + if err != nil { |
| + t.Fatalf("Text: %v", err) |
| + } |
| + if got != want { |
| + t.Errorf("got %q, want %q", got, want) |
| + } |
| + }) |
| + } |
| +} |
| + |
| +func TestGarbageUpdateReturnsErrorNotTrap(t *testing.T) { |
| + ctx := context.Background() |
| + rt, err := core.New(ctx) |
| + if err != nil { |
| + t.Fatalf("core.New: %v", err) |
| + } |
| + t.Cleanup(func() { rt.Close(ctx) }) |
| + |
| + doc, err := rt.NewDoc(ctx) |
| + if err != nil { |
| + t.Fatalf("NewDoc: %v", err) |
| + } |
| + defer doc.Close(ctx) |
| + |
| + if err := doc.Apply(ctx, []byte{0xff, 0xff, 0xff, 0xff}); err == nil { |
| + t.Fatal("expected an error for garbage input, got nil") |
| + } |
| + |
| + // The instance must still be usable: an error is not a trap. |
| + update, want := fixture(t, "hello") |
| + if err := doc.Apply(ctx, update); err != nil { |
| + t.Fatalf("instance unusable after a rejected update: %v", err) |
| + } |
| + got, _ := doc.Text(ctx) |
| + if got != want { |
| + t.Errorf("after recovery got %q, want %q", got, want) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 3: Run it to verify it fails** |
| + |
| +```bash |
| +CGO_ENABLED=0 go test ./internal/core/ -run TestAppliesFixturesUnderWazero -v |
| +``` |
| + |
| +Expected: FAIL to build — package `core` has no `New`. |
| + |
| +- [ ] **Step 4: Implement the host** |
| + |
| +`internal/core/core.go`: |
| + |
| +```go |
| +// Package core hosts the Rust document core as a WebAssembly module. |
| +// |
| +// The module is plain wasm32 with a C ABI and no wasm-bindgen, because |
| +// wazero provides no JS glue. Everything crosses the boundary as integers |
| +// and offsets into the module's linear memory. |
| +package core |
| + |
| +import ( |
| + "context" |
| + _ "embed" |
| + "fmt" |
| + |
| + "github.com/tetratelabs/wazero" |
| + "github.com/tetratelabs/wazero/api" |
| +) |
| + |
| +//go:embed testdata/docs_core.wasm |
| +var moduleWASM []byte |
| + |
| +// abiVersion is the contract this host understands. A module reporting |
| +// anything else is refused at load, so a stale .wasm fails loudly rather |
| +// than behaving strangely. |
| +const abiVersion = 1 |
| + |
| +// Runtime owns the compiled module. Compiling is expensive and |
| +// instantiating is not, so one Runtime is shared and each document gets its |
| +// own instance (spec §5.2: instances are not safe for concurrent use). |
| +type Runtime struct { |
| + rt wazero.Runtime |
| + compiled wazero.CompiledModule |
| +} |
| + |
| +func New(ctx context.Context) (*Runtime, error) { |
| + rt := wazero.NewRuntime(ctx) |
| + compiled, err := rt.CompileModule(ctx, moduleWASM) |
| + if err != nil { |
| + rt.Close(ctx) |
| + return nil, fmt.Errorf("compiling core module: %w", err) |
| + } |
| + return &Runtime{rt: rt, compiled: compiled}, nil |
| +} |
| + |
| +func (r *Runtime) Close(ctx context.Context) error { return r.rt.Close(ctx) } |
| + |
| +// Handle is one document, backed by its own module instance. Not safe for |
| +// concurrent use; callers serialise access per document. |
| +type Handle struct { |
| + mod api.Module |
| + handle uint64 |
| + |
| + alloc, free api.Function |
| + docNew, docFree api.Function |
| + apply api.Function |
| + textLen, textRead api.Function |
| +} |
| + |
| +func (r *Runtime) NewDoc(ctx context.Context) (*Handle, error) { |
| + mod, err := r.rt.InstantiateModule(ctx, r.compiled, |
| + wazero.NewModuleConfig().WithName("")) |
| + if err != nil { |
| + return nil, fmt.Errorf("instantiating core module: %w", err) |
| + } |
| + |
| + version, err := mod.ExportedFunction("docs_core_abi_version").Call(ctx) |
| + if err != nil { |
| + mod.Close(ctx) |
| + return nil, fmt.Errorf("reading abi version: %w", err) |
| + } |
| + if version[0] != abiVersion { |
| + mod.Close(ctx) |
| + return nil, fmt.Errorf("core module reports ABI %d, host understands %d", version[0], abiVersion) |
| + } |
| + |
| + h := &Handle{ |
| + mod: mod, |
| + alloc: mod.ExportedFunction("docs_core_alloc"), |
| + free: mod.ExportedFunction("docs_core_free"), |
| + docNew: mod.ExportedFunction("docs_core_doc_new"), |
| + docFree: mod.ExportedFunction("docs_core_doc_free"), |
| + apply: mod.ExportedFunction("docs_core_apply"), |
| + textLen: mod.ExportedFunction("docs_core_text_len"), |
| + textRead: mod.ExportedFunction("docs_core_text_read"), |
| + } |
| + |
| + res, err := h.docNew.Call(ctx) |
| + if err != nil { |
| + mod.Close(ctx) |
| + return nil, fmt.Errorf("doc_new: %w", err) |
| + } |
| + if res[0] == 0 { |
| + mod.Close(ctx) |
| + return nil, fmt.Errorf("doc_new returned a null handle") |
| + } |
| + h.handle = res[0] |
| + return h, nil |
| +} |
| + |
| +func (h *Handle) Close(ctx context.Context) error { |
| + if _, err := h.docFree.Call(ctx, h.handle); err != nil { |
| + return err |
| + } |
| + return h.mod.Close(ctx) |
| +} |
| + |
| +func (h *Handle) Apply(ctx context.Context, update []byte) error { |
| + ptrRes, err := h.alloc.Call(ctx, uint64(len(update))) |
| + if err != nil { |
| + return fmt.Errorf("alloc: %w", err) |
| + } |
| + ptr := ptrRes[0] |
| + defer h.free.Call(ctx, ptr, uint64(len(update))) |
| + |
| + if !h.mod.Memory().Write(uint32(ptr), update) { |
| + return fmt.Errorf("writing %d bytes at %d: out of range", len(update), ptr) |
| + } |
| + |
| + rc, err := h.apply.Call(ctx, h.handle, ptr, uint64(len(update))) |
| + if err != nil { |
| + return fmt.Errorf("apply trapped: %w", err) |
| + } |
| + if code := int32(rc[0]); code != 0 { |
| + return fmt.Errorf("apply rejected the update: code %d", code) |
| + } |
| + return nil |
| +} |
| + |
| +func (h *Handle) Text(ctx context.Context) (string, error) { |
| + lenRes, err := h.textLen.Call(ctx, h.handle) |
| + if err != nil { |
| + return "", fmt.Errorf("text_len: %w", err) |
| + } |
| + n := lenRes[0] |
| + if n == 0 { |
| + return "", nil |
| + } |
| + |
| + ptrRes, err := h.alloc.Call(ctx, n) |
| + if err != nil { |
| + return "", fmt.Errorf("alloc: %w", err) |
| + } |
| + ptr := ptrRes[0] |
| + defer h.free.Call(ctx, ptr, n) |
| + |
| + rc, err := h.textRead.Call(ctx, h.handle, ptr, n) |
| + if err != nil { |
| + return "", fmt.Errorf("text_read: %w", err) |
| + } |
| + if written := int32(rc[0]); written < 0 { |
| + return "", fmt.Errorf("text_read failed: code %d", written) |
| + } |
| + |
| + buf, ok := h.mod.Memory().Read(uint32(ptr), uint32(n)) |
| + if !ok { |
| + return "", fmt.Errorf("reading %d bytes at %d: out of range", n, ptr) |
| + } |
| + return string(buf), nil |
| +} |
| +``` |
| + |
| +- [ ] **Step 5: Run the tests to verify they pass** |
| + |
| +```bash |
| +CGO_ENABLED=0 go test ./internal/core/ -v |
| +``` |
| + |
| +Expected: both tests pass, the first with four subtests. |
| + |
| +If `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. |
| + |
| +- [ ] **Step 6: Commit** |
| + |
| +```bash |
| +git add go.mod go.sum internal/core |
| +git commit -m "internal/core: run the Rust core under wazero |
| + |
| +This is the task the plan existed to answer: a Rust y-crdt crate |
| +compiles to plain wasm32 with a C ABI and runs inside a Go binary with |
| +no cgo and no JS glue, reproducing fixtures generated by real yjs. |
| + |
| +One module instance per document, since instances are not safe for |
| +concurrent use. Garbage input returns an error and leaves the instance |
| +usable, which is what makes §5.2's trap-recovery path rare rather than |
| +routine." |
| +``` |
| + |
| +--- |
| + |
| +### Task 7: The CI gate |
| + |
| +**Files:** |
| +- Create: `.amadan/ci.d/10-gate`, `.amadan/README.md` |
| +- Modify: `AGENTS.md` |
| + |
| +**Interfaces:** |
| +- Consumes: `scripts/wire-gate.sh` from Task 4, the Go tests from Task 6. |
| +- Produces: a green `amadan ci status`, which closes the bootstrap exception the first merge was granted under. |
| + |
| +- [ ] **Step 1: Write the gate** |
| + |
| +`.amadan/ci.d/10-gate`: |
| + |
| +```bash |
| +#!/usr/bin/env bash |
| +# The only CI this repo has. Everything a branch must pass before it can |
| +# squash-merge, in one script so CI and what you run locally are one |
| +# definition rather than two that drift. |
| +set -euo pipefail |
| + |
| +# rustup installs to ~/.cargo/bin, which is not on the PATH of a systemd |
| +# user unit and is not on the PATH of a login shell when rustup was run |
| +# with --no-modify-path. Source it here rather than depending on whoever |
| +# configured the runner having got it right — a gate that only works on one |
| +# machine is not a gate. |
| +[ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env" |
| + |
| +for tool in cargo rustc go node; do |
| + command -v "$tool" >/dev/null || { echo "gate: $tool not on PATH" >&2; exit 1; } |
| +done |
| +rustup target list --installed 2>/dev/null | grep -qx wasm32-unknown-unknown \ |
| + || { echo "gate: wasm32-unknown-unknown target missing — rustup target add wasm32-unknown-unknown" >&2; exit 1; } |
| + |
| +export CGO_ENABLED=0 |
| + |
| +echo "=== rust: build, clippy, test" |
| +cargo build --release --manifest-path core/Cargo.toml |
| +cargo clippy --manifest-path core/Cargo.toml -- -D warnings |
| +cargo test --manifest-path core/Cargo.toml |
| + |
| +echo "=== wasm: the artifact the host embeds" |
| +./scripts/build-wasm.sh |
| + |
| +echo "=== the embedded wasm must match a fresh build" |
| +if ! cmp -s core/target/wasm32-unknown-unknown/release/docs_core.wasm internal/core/testdata/docs_core.wasm; then |
| + echo "gate: internal/core/testdata/docs_core.wasm is stale. Re-copy it." >&2 |
| + exit 1 |
| +fi |
| + |
| +echo "=== wire compatibility, both directions" |
| +./scripts/wire-gate.sh |
| + |
| +echo "=== go: vet, test, gofmt" |
| +go vet ./... |
| +go test ./... -count=1 |
| +test -z "$(gofmt -l .)" || { echo "gate: gofmt"; gofmt -l .; exit 1; } |
| + |
| +echo "gate: ok" |
| +``` |
| + |
| +```bash |
| +chmod +x .amadan/ci.d/10-gate |
| +``` |
| + |
| +The 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. |
| + |
| +- [ ] **Step 2: Document what the gate is** |
| + |
| +`.amadan/README.md`: |
| + |
| +```markdown |
| +# CI |
| + |
| +`ci.d/10-gate` is the only CI this repo has. It runs on every push to the |
| +amadan remote, and it is the same script you can run locally — one |
| +definition rather than two that drift. |
| + |
| +It requires a Rust toolchain with the `wasm32-unknown-unknown` target, Go |
| +with `CGO_ENABLED=0`, and Node with `fixtures/` installed. The gate sources |
| +`~/.cargo/env` itself and checks every tool up front, so a missing |
| +dependency fails on the first line with its own name rather than fifty |
| +lines in as `cargo: command not found`. |
| + |
| +## The runner |
| + |
| +Jobs are claimed by an `amadan runner` on the machine that has one. Two |
| +things about it were wrong when this repo was created, and both fail |
| +silently rather than loudly: |
| + |
| +- **The runner must be told to serve this repo.** `amadan runner -repo …` |
| + is an allowlist. A repo nobody listed gets `amadan ci status` exit 3 |
| + forever — "no status recorded", which reads like the gate has not run yet |
| + rather than like nothing will ever run it. |
| +- **The runner's `PATH` is the systemd unit's, not your shell's.** |
| + `~/.cargo/bin` is not on it by default. |
| + |
| +Both were fixed on `DOCS-T1` on 2026-08-30 by editing |
| +`~/.config/systemd/user/amadan-runner.service` and restarting. A restart |
| +abandons any job already running, so check it is idle first. |
| + |
| +The gate fails if `internal/core/testdata/docs_core.wasm` differs from a |
| +fresh build, because `go:embed` will otherwise embed a stale module and the |
| +tests will pass against it. |
| + |
| +It also fails if regenerating the Yjs fixtures changes them. Fixtures are |
| +the wire contract, not an output: a deliberate rule change regenerates them |
| +in the same commit as the change. |
| +``` |
| + |
| +- [ ] **Step 3: Run the gate locally** |
| + |
| +```bash |
| +./.amadan/ci.d/10-gate |
| +``` |
| + |
| +Expected: every section passes, ending `gate: ok`. Fix anything it catches before pushing — that is the point of it being the same script. |
| + |
| +- [ ] **Step 4: Record the gate in AGENTS.md** |
| + |
| +Replace the "CI is `.amadan/ci.d/`, triggered by a push to the remote." sentence in the shipping-cadence section with: |
| + |
| +```markdown |
| +**CI is `.amadan/ci.d/10-gate`**, triggered by a push to the remote, and it |
| +is the same script you can run locally. It covers the Rust crate, the wasm |
| +artifact, Yjs wire compatibility in both directions, and the Go build and |
| +tests. `amadan ci status -branch <n>` exit 0 is the merge gate; exit 2 is |
| +pending and exit 3 means nothing ran, and neither is green. |
| +``` |
| + |
| +- [ ] **Step 5: Push and confirm CI actually runs** |
| + |
| +```bash |
| +git add .amadan AGENTS.md |
| +git commit -m "ci: the gate, and the end of the bootstrap exception |
| + |
| +One script, run identically in CI and locally. Covers the Rust crate, |
| +the wasm artifact, wire compatibility both directions, and the Go build. |
| + |
| +It fails if the embedded .wasm differs from a fresh build -- go:embed |
| +will otherwise embed a stale module and the tests pass against it, which |
| +surfaces much later as a confusing behaviour difference. |
| + |
| +The first branch merged under an explicit bootstrap exception because CI |
| +had nothing to gate. It has something now." |
| +git push |
| +amadan ci status oficina/docs -branch "$(git branch --show-current)" |
| +``` |
| + |
| +Expected: 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. |
| + |
| +--- |
| + |
| +## Self-Review |
| + |
| +**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. |
| + |
| +**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. |
| + |
| +**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. |
| + |
| +**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. |
| + |
| +## The remaining plans |
| + |
| +Each produces working, testable software on its own: |
| + |
| +2. **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. |
| +3. **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. |
| +4. **The editor** — ProseMirror, JS Yjs, decoration-based pagination. |
| +5. **Collaboration** — transport, presence, offline, hibernation behaviour. |
| +6. **Sharing** — grants, guests, link shares, the home outbox. |
| +7. **Import and export** — Markdown, then DOCX, then PDF. |
| +8. **Comments, version capture, the outline pane.** |
| +9. **Thumbnails.** |