ci: pre-merge fix wave — reproducible wasm build, honest containment claim
Fix 1 (blocking): the committed docs_core.wasm embedded absolute build paths (registry + worktree, leaking a username), so the gate's cmp only passed on the machine that built it and misreported every other machine as "stale". Remap both roots via RUSTFLAGS in scripts/build-wasm.sh, pin the toolchain in rust-toolchain.toml so a rustc bump can't silently change the bytes the same way, and recommit the rebuilt artifact. Also fixed core/tests/wasm_trap_containment.rs's own cargo build helper, which writes to the same target path and was clobbering the remapped artifact with an unremapped one during `cargo test` once build-wasm.sh started setting RUSTFLAGS. Verified byte-identical output across two full builds under different $HOME. Fix 2 (blocking): corrected §5.2 of the design spec — wasm only converts *some* of yrs's unchecked-UTF-8-decode UB into contained traps; some corrupted updates are accepted outright and their invalid UTF-8 flows through to Handle.Text untouched, per this branch's own fix-round-2 test comment. Downstream must not assume valid UTF-8. Fix 3 (minor): add rustfmt to the gate's tool-presence loop and to .amadan/README.md's prerequisites, matching the pattern used for every other tool. Fix 4 (minor): add --locked to wire-gate.sh's `cargo run --bin emit_update`, matching every other cargo invocation in the gate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011F5CivVm4CmL9ByfSSAaXA
8 files changed,
+68
−10
.amadan/README.md+5 −5.amadan/ci.d/10-gate+1 −1core/tests/wasm_trap_containment.rs+23 −0docs/superpowers/specs/2026-08-30-docs-design.md+11 −3internal/core/testdata/docs_core.wasmbinaryrust-toolchain.toml+4 −0scripts/build-wasm.sh+23 −0scripts/wire-gate.sh+1 −1
CI passed — run details
diff --git a/.amadan/README.md b/.amadan/README.md| index b5eba52..ffa66c9 100644 |
| --- a/.amadan/README.md |
| +++ b/.amadan/README.md |
| @@ -4,11 +4,11 @@ |
| 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 `npm` on `PATH`. 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`. |
| +It requires a Rust toolchain with the `wasm32-unknown-unknown` target and |
| +the `rustfmt` component, Go with `CGO_ENABLED=0`, and Node with `npm` on |
| +`PATH`. 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 clones a fresh worktree per branch, so `fixtures/node_modules` |
| never carries over — the gate runs `npm ci` itself, against |
diff --git a/.amadan/ci.d/10-gate b/.amadan/ci.d/10-gate| index e4f385a..0ae00a0 100755 |
| --- a/.amadan/ci.d/10-gate |
| +++ b/.amadan/ci.d/10-gate |
| @@ -11,7 +11,7 @@ set -euo pipefail |
| # machine is not a gate. |
| [ -f "$HOME/.cargo/env" ] && . "$HOME/.cargo/env" |
| -for tool in cargo rustc go node npm; do |
| +for tool in cargo rustc rustfmt go node npm; 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 \ |
diff --git a/core/tests/wasm_trap_containment.rs b/core/tests/wasm_trap_containment.rs| index 55dd9e1..3d2ac57 100644 |
| --- a/core/tests/wasm_trap_containment.rs |
| +++ b/core/tests/wasm_trap_containment.rs |
| @@ -260,6 +260,28 @@ impl Harness { |
| /// to this test) and instead runs just the `cargo build` step directly. |
| fn build_wasm() -> Vec<u8> { |
| let core_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); |
| + let workspace_root = core_dir |
| + .parent() |
| + .expect("core/ always has a parent (the repo root)"); |
| + // Must match scripts/build-wasm.sh's RUSTFLAGS exactly: this test |
| + // writes to the same `target/wasm32-unknown-unknown/release/ |
| + // docs_core.wasm` path the gate's `cmp` checks against the committed |
| + // testdata copy. A cargo build here with different flags (even just |
| + // "none", the default before this fix) would fingerprint-mismatch |
| + // against build-wasm.sh's build and silently overwrite that path with |
| + // differently-flagged bytes -- reintroducing embedded absolute paths |
| + // (and failing `cmp`) any time `cargo test` runs after `build-wasm.sh`. |
| + let cargo_home = std::env::var("CARGO_HOME") |
| + .map(PathBuf::from) |
| + .unwrap_or_else(|_| { |
| + PathBuf::from(std::env::var("HOME").expect("HOME must be set")).join(".cargo") |
| + }); |
| + let registry_root = cargo_home.join("registry").join("src"); |
| + let rustflags = format!( |
| + "--cfg getrandom_backend=\"custom\" --remap-path-prefix={}=/cargo-registry-src --remap-path-prefix={}=/docs-core", |
| + registry_root.display(), |
| + workspace_root.display(), |
| + ); |
| let status = Command::new("cargo") |
| .args([ |
| "build", |
| @@ -269,6 +291,7 @@ fn build_wasm() -> Vec<u8> { |
| "--locked", |
| "--lib", |
| ]) |
| + .env("RUSTFLAGS", rustflags) |
| .current_dir(&core_dir) |
| .status() |
| .expect("failed to run cargo build for wasm32-unknown-unknown"); |
diff --git a/docs/superpowers/specs/2026-08-30-docs-design.md b/docs/superpowers/specs/2026-08-30-docs-design.md| index 9019305..fd2d0aa 100644 |
| --- a/docs/superpowers/specs/2026-08-30-docs-design.md |
| +++ b/docs/superpowers/specs/2026-08-30-docs-design.md |
| @@ -258,9 +258,17 @@ cycle is bursty and coarse. |
| We cannot validate our way out — locating string fields inside a Yjs update |
| means parsing it, which is what `yrs` does. Filed upstream. |
| **This makes the wasm sandbox load-bearing for security, not only |
| - portability**: inside wasm the fault is a contained trap, where a native |
| - release build would corrupt silently. That is an argument for the wazero |
| - design that was not made when it was chosen. |
| + portability**: inside wasm *some* of this undefined behaviour becomes a |
| + contained trap, where a native release build would corrupt silently. That |
| + is an argument for the wazero design that was not made when it was |
| + chosen. It is not a containment guarantee, though: the same unchecked |
| + decode also accepts some corrupted input outright rather than trapping on |
| + it — flipping a single byte of a UTF-8 continuation byte in our own |
| + `unicode` fixture is applied without complaint (return code 0), leaving |
| + invalid UTF-8 in the document content, retained in the update log, and |
| + served back through `Handle.Text` untouched (Go's `string(buf)` does not |
| + itself validate UTF-8). Downstream layers must not assume document text |
| + is valid UTF-8. |
| - **Execution must be capped, not merely trapped.** A 36-byte corrupted |
| fixture ran `apply` for **163 real seconds** before returning successfully — |
| pathological slowness, not a hang, and invisible to any correctness test |
diff --git a/internal/core/testdata/docs_core.wasm b/internal/core/testdata/docs_core.wasm| index d32f557..1048e3e 100755 |
| Binary files a/internal/core/testdata/docs_core.wasm and b/internal/core/testdata/docs_core.wasm differ |
diff --git a/rust-toolchain.toml b/rust-toolchain.toml| new file mode 100644 |
| index 0000000..ec92786 |
| --- /dev/null |
| +++ b/rust-toolchain.toml |
| @@ -0,0 +1,4 @@ |
| +[toolchain] |
| +channel = "1.98.0" |
| +components = ["rustfmt", "clippy"] |
| +targets = ["wasm32-unknown-unknown"] |
diff --git a/scripts/build-wasm.sh b/scripts/build-wasm.sh| index 12dd863..522f045 100755 |
| --- a/scripts/build-wasm.sh |
| +++ b/scripts/build-wasm.sh |
| @@ -14,6 +14,29 @@ cargo clippy --manifest-path core/Cargo.toml --all-targets -- -D warnings -D cli |
| # wire-gate.sh) is never part of that artifact and does not define the |
| # __getrandom_v03_custom symbol itself, so building it for wasm32 fails to |
| # link -- it was never meant to run there. |
| +# |
| +# --remap-path-prefix (x2): rustc embeds the absolute source path of every |
| +# panic site (Location::caller()) into the binary. Left alone, that means |
| +# the registry path of every dependency (which contains $HOME, so it |
| +# leaks a username) and this worktree's own absolute path end up baked |
| +# into docs_core.wasm -- so the artifact only reproduces byte-for-byte on |
| +# the machine, and under the $HOME, that built it. The gate's `cmp` |
| +# against the committed copy then fails everywhere else and misreports |
| +# a stale artifact instead of a non-reproducible build. Remapping both |
| +# roots to fixed placeholders makes the build's output independent of |
| +# where it ran. |
| +# |
| +# Setting RUSTFLAGS here does not merge with .cargo/config.toml's |
| +# target.wasm32-unknown-unknown.rustflags -- an env RUSTFLAGS replaces a |
| +# config-file rustflags for the same target outright, it does not append |
| +# to it. That config entry (--cfg getrandom_backend="custom") is what |
| +# routes getrandom to our no-JS-import backend on wasm32; dropping it |
| +# breaks the build (getrandom falls back to a backend wasm32-unknown- |
| +# unknown doesn't support) rather than just losing the cfg silently. So |
| +# it has to be repeated here alongside the remap flags. |
| +registry_root="${CARGO_HOME:-$HOME/.cargo}/registry/src" |
| +workspace_root="$(pwd)" |
| +export RUSTFLAGS="--cfg getrandom_backend=\"custom\" --remap-path-prefix=${registry_root}=/cargo-registry-src --remap-path-prefix=${workspace_root}=/docs-core" |
| cargo build --release --manifest-path core/Cargo.toml --target wasm32-unknown-unknown --locked --lib |
| out="core/target/wasm32-unknown-unknown/release/docs_core.wasm" |
| [ -f "$out" ] || { echo "build-wasm: no artifact at $out" >&2; exit 1; } |
diff --git a/scripts/wire-gate.sh b/scripts/wire-gate.sh| index 9ce81af..4b41870 100755 |
| --- a/scripts/wire-gate.sh |
| +++ b/scripts/wire-gate.sh |
| @@ -18,6 +18,6 @@ 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 |
| +(cd core && cargo run --quiet --locked --bin emit_update) | node fixtures/verify-rust.mjs |
| echo "wire-gate: ok" |