oficina / docs Public
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=0on every Go build and test command. A cgo dependency sneaking in is a bug, not a tradeoff. - 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:
yrsunconditionally enablesfastrand'sjsfeature, 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 bycore/tests/no_host_imports.rs. Target stayswasm32-unknown-unknownwithcrate-type = ["cdylib", "rlib"]andextern "C"exports;wasm-packis still never used. - Never draw entropy from
yrs.Doc::new,Doc::with_client_idanduuid_v4are all banned and enforced byclippy.tomldisallowed-methods, run before every build. (Amended twice during execution:Doc::newwas banned first for its random client id, thenDoc::with_client_idtoo —Options::with_client_idatdoc.rs:916setsguid: uuid_v4(), so it draws entropy one layer down.) On wasm the customgetrandomhook refuses entropy andfastrandmasks that with a constant seed, so any value drawn fromyrsis identical across processes — and a client id or document GUID repeating across processes corrupts documents. Always construct withDoc::with_options(Options::with_guid_and_client_id(guid, client_id)), both values supplied by the caller. - 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 inCargo.tomlorpackage.jsonforyrs/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 forwasm32-unknown-unknown;scripts/build-wasm.shemittingcore/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.
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
rustc --version && cargo --version && rustup target list --installed | grep wasm32-unknown-unknownExpected: 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:
[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:
//! 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
cd core && cargo build && cargo build --release --target wasm32-unknown-unknownExpected: 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:
#!/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)"chmod +x scripts/build-wasm.sh && ./scripts/build-wasm.shExpected: a path and a byte count.
- [ ] Step 6: Commit
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) andfixtures/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:
{
"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.
cd fixtures && npm install- [ ] Step 2: Write the generator
fixtures/generate.mjs:
// 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
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
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.binand*.expected.jsonfrom Task 2. - Produces:
docs_core::apply_update(doc: &Doc, update: &[u8]) -> Result<(), CoreError>anddocs_core::text_content(doc: &Doc) -> String, used by Tasks 4 and 5.
- [ ] Step 1: Write the failing test
core/tests/wire_compat.rs:
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_text, expected_delta) = fixture(name);
let doc = Doc::with_options(Options::with_guid_and_client_id(
"wire-compat-test-guid".into(),
9001,
)); // never Doc::new or Doc::with_client_id — see Global Constraints
docs_core::apply_update(&doc, &update).expect("apply failed");
assert_eq!(docs_core::text_content(&doc), expected_text, "fixture {name} text");
assert_eq!(delta_of(&doc), expected_delta, "fixture {name} delta");
}
#[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 (exact pin, per Global Constraints):
[dev-dependencies]
serde_json = "=1.0.145"Take whatever exact version cargo add --dev serde_json resolves.
The delta comparison — amended during execution (ruling R5). The fixtures now carry a delta field alongside text, because text alone cannot distinguish a correct implementation from one that decoded the update and dropped the formatting entirely. fixture() must therefore return three values, and the test needs a delta_of(&Doc) helper that produces the same attributed-run shape Yjs's toDelta() writes.
Verified API facts to build it from — check each against the pinned yrs rather than trusting this list:
Text::diff<T, D, F>(&self, txn: &T, compute_ychange: F) -> Vec<Diff<D>>—yrs-0.23.5 src/types/text.rs:420, on theTexttrait.Diff<T> { insert: Out, attributes: Option<Box<Attrs>>, ychange: Option<T> }—text.rs:1103.YChange::identity—text.rs:1201, the pass-through forcompute_ychange.Attrs = HashMap<Arc<str>, Any>—types/mod.rs:882.
Attribute order must be normalised (sort by key) before comparison, or the test is flaky on HashMap iteration order.
The delta assertion must be proven to bite, the same way the zero-imports gate was: temporarily make delta_of discard attributes, confirm the formatted fixture fails and the other three still pass, then revert. A comparison that cannot fail is not an assertion.
- [ ] Step 2: Run the test to verify it fails
cd core && cargo test --test wire_compatExpected: FAIL to compile — apply_update and text_content do not exist in docs_core.
- [ ] Step 3: Implement
Append to core/src/lib.rs:
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
cd core && cargo test --test wire_compatExpected: 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
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_contentfrom 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:
//! 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() {
// Both values fixed: never drawn from yrs (Global Constraints).
let doc = Doc::with_options(Options::with_guid_and_client_id(
"emit-update-guid".into(),
7001,
));
{
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:
// 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
cd core && cargo run --bin emit_update | node ../fixtures/verify-rust.mjsExpected: 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:
#!/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"chmod +x scripts/wire-gate.sh && ./scripts/wire-gate.shExpected: 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
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_contentfrom 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(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 fromyrs(Global Constraints) -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) -> i32— byte length, or a negative error code. Amended during execution: it wasu32, which made an unknown handle indistinguishable from an empty document -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:
#[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
cd core && cargo test --test wire_compatExpected: FAIL to compile — no docs_core::abi module.
- [ ] Step 3: Implement the ABI
core/src/abi.rs:
//! 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(guid: &str, client_id: u64) -> u32 {
let id = NEXT.with(|n| {
let mut n = n.borrow_mut();
let id = *n;
*n += 1;
id
});
let doc = Doc::with_options(Options::with_guid_and_client_id(guid.into(), client_id));
DOCS.with(|d| d.borrow_mut().insert(id, doc));
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 ---
// Amended by pre-flight ruling R2. The obvious version — Vec::with_capacity
// then Vec::from_raw_parts(ptr, 0, len) — is undefined behaviour: with_capacity
// guarantees AT LEAST len, so reconstructing with capacity `len` is wrong
// whenever the allocator rounded up. std::alloc with an identical Layout on
// both sides has no such gap.
fn layout_for(len: u32) -> std::alloc::Layout {
// align 1: these are byte buffers the host writes into.
std::alloc::Layout::from_size_align(len.max(1) as usize, 1)
.expect("byte layout is always valid")
}
#[no_mangle]
pub extern "C" fn docs_core_alloc(len: u32) -> u32 {
unsafe { std::alloc::alloc(layout_for(len)) as u32 }
}
#[no_mangle]
pub unsafe extern "C" fn docs_core_free(ptr: u32, len: u32) {
if ptr != 0 {
std::alloc::dealloc(ptr as *mut u8, layout_for(len));
}
}
#[no_mangle]
pub unsafe extern "C" fn docs_core_doc_new(
guid_ptr: u32,
guid_len: u32,
client_id: u64,
) -> u32 {
let bytes = std::slice::from_raw_parts(guid_ptr as *const u8, guid_len as usize);
match std::str::from_utf8(bytes) {
Ok(guid) => doc_new(guid, client_id),
Err(_) => 0, // invalid UTF-8 GUID is a null handle, not a trap
}
}
#[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:
pub mod abi;- [ ] Step 4: Run the tests to verify they pass
cd core && cargo test --test wire_compat && ./../scripts/build-wasm.shExpected: 6 passed, then a wasm artifact with a byte count.
- [ ] Step 5: Verify the exports are actually in the module
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 -uExpected: 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
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, guid string, clientID uint64) (*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.
Two requirements added during execution, both from Task 5's findings:
The runtime must cap execution, not merely survive traps. A 36-byte corrupted fixture ran apply for 163 real seconds before returning the correct answer. That is not a hang and no correctness test can see it, but on a server accepting updates from anyone holding a share link it is denial of service. Build the runtime with wazero.NewRuntimeConfig().WithCloseOnContextDone(true) and give every guest call a context.WithTimeout. Treat a deadline exceedance exactly like a trap: discard the instance, rebuild from snapshot. The original design covered an instance that dies and said nothing about one that never returns.
A trap must discard the module instance. yrs calls from_utf8_unchecked on wire data, so a malformed update can trap and its linear memory is then unusable. The per-document instance is what contains that, and the containment is only real if the host actually throws the instance away — a host that reuses a trapped instance makes it theoretical. There must be a test: trap an instance, then confirm a fresh one for the same document rebuilds correctly from its updates.
- [ ] Step 1: Initialise the Go module and vendor the wasm
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:
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, "go-host-test-guid", 9001)
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, "go-host-test-guid", 9001)
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
CGO_ENABLED=0 go test ./internal/core/ -run TestAppliesFixturesUnderWazero -vExpected: FAIL to build — package core has no New.
- [ ] Step 4: Implement the host
internal/core/core.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
}
// NewDoc creates a document. The GUID and client id are supplied by the
// caller and never drawn from yrs: on wasm the CRDT library's entropy is a
// constant seed, so a value it generates is identical in every process, and
// a repeated client id or GUID corrupts the document (Global Constraints).
func (r *Runtime) NewDoc(ctx context.Context, guid string, clientID uint64) (*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"),
}
guidBytes := []byte(guid)
guidPtrRes, err := h.alloc.Call(ctx, uint64(len(guidBytes)))
if err != nil {
mod.Close(ctx)
return nil, fmt.Errorf("alloc for guid: %w", err)
}
guidPtr := guidPtrRes[0]
defer h.free.Call(ctx, guidPtr, uint64(len(guidBytes)))
if !mod.Memory().Write(uint32(guidPtr), guidBytes) {
mod.Close(ctx)
return nil, fmt.Errorf("writing guid at %d: out of range", guidPtr)
}
res, err := h.docNew.Call(ctx, guidPtr, uint64(len(guidBytes)), clientID)
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)
}
// text_len returns i32: negative is an error code, not a length. Reading
// it as unsigned would turn ERR_NO_DOC into 0xFFFFFFFF and then try to
// allocate four gigabytes.
if code := int32(lenRes[0]); code < 0 {
return "", fmt.Errorf("text_len failed: code %d", code)
}
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]
// alloc returns 0 on failure, including for any length over isize::MAX.
if ptr == 0 {
return "", fmt.Errorf("alloc(%d) failed", n)
}
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
CGO_ENABLED=0 go test ./internal/core/ -vExpected: 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
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.shfrom 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:
#!/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"chmod +x .amadan/ci.d/10-gateThe 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:
# 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
./.amadan/ci.d/10-gateExpected: 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:
**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
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:
- 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.
- The Go app on rastrillo — list, create, open, server-rendered read-only. The rastrillo dependency decision (release versus
mainpseudo-version) is made at the start of this one. - The editor — ProseMirror, JS Yjs, decoration-based pagination.
- Collaboration — transport, presence, offline, hibernation behaviour.
- Sharing — grants, guests, link shares, the home outbox.
- Import and export — Markdown, then DOCX, then PDF.
- Comments, version capture, the outline pane.
- Thumbnails.