oficina / docs Public

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
Paul Campbell pushed by paul@keymail.dev 8a011f987228e21f4fbb67fc8b584597fcb9d528 parent a4e7d3c
8 files changed, +68 −10
  • .amadan/README.md +5 −5
  • .amadan/ci.d/10-gate +1 −1
  • core/tests/wasm_trap_containment.rs +23 −0
  • docs/superpowers/specs/2026-08-30-docs-design.md +11 −3
  • internal/core/testdata/docs_core.wasm binary
  • rust-toolchain.toml +4 −0
  • scripts/build-wasm.sh +23 −0
  • scripts/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"