Plan: carry the entropy ban through tasks 4, 5 and 6
Task 3's execution banned Doc::new, Doc::with_client_id and uuid_v4 by clippy, and the plan still told tasks 4, 5 and 6 to call them -- so all three would have failed the lint the moment they ran. The pre-flight scan could not have caught this: the ban did not exist when it ran. Task 5's ABI changes shape as a result. docs_core_doc_new now takes a GUID pointer, length and client id rather than nothing, because both values must come from the host. Task 6's Go host carries the matching signature and writes the GUID into linear memory before the call. That is a better design than the one it replaces: a document's GUID and client id should be assigned by the instance that owns the document, not invented by the CRDT library. The constraint improved the interface.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011F5CivVm4CmL9ByfSSAaXA
1 file changed,
+46
−13
docs/superpowers/plans/2026-08-30-rust-core-wire-compat.md+46 −13
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| index bb2a90d..2989035 100644 |
| --- a/docs/superpowers/plans/2026-08-30-rust-core-wire-compat.md |
| +++ b/docs/superpowers/plans/2026-08-30-rust-core-wire-compat.md |
| @@ -22,7 +22,7 @@ This is the load-bearing bet of the whole design, and revision 1 of the spec got |
| - **No cgo, ever.** `CGO_ENABLED=0` on 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**: `yrs` unconditionally enables `fastrand`'s `js` feature, so wasm-bindgen is in the wasm32 dependency graph of any crate using it.)* The property actually required is that the module needs no JS host, and zero imports is that property, asserted by `core/tests/no_host_imports.rs`. Target stays `wasm32-unknown-unknown` with `crate-type = ["cdylib", "rlib"]` and `extern "C"` exports; `wasm-pack` is still never used. |
| -- **Never call `yrs::Doc::new()`.** It reaches `fastrand` for a random client ID, which is what pulls JS entropy. Always `Doc::with_client_id()` — an assigned client ID is what the server wants anyway. `fastrand` is also reachable from `yrs`'s observer paths, which is why the gate is a build-time assertion rather than a rule somebody remembers. |
| +- **Never draw entropy from `yrs`.** `Doc::new`, `Doc::with_client_id` and `uuid_v4` are all banned and enforced by `clippy.toml` `disallowed-methods`, run before every build. *(Amended twice during execution: `Doc::new` was banned first for its random client id, then `Doc::with_client_id` too — `Options::with_client_id` at `doc.rs:916` sets `guid: uuid_v4()`, so it draws entropy one layer down.)* On wasm the custom `getrandom` hook refuses entropy and `fastrand` masks that with a constant seed, so any value drawn from `yrs` is **identical across processes** — and a client id or document GUID repeating across processes corrupts documents. Always construct with `Doc::with_options(Options::with_guid_and_client_id(guid, client_id))`, both values supplied by the caller. |
| - **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. |
| @@ -298,7 +298,10 @@ fn fixture(name: &str) -> (Vec<u8>, String) { |
| fn check(name: &str) { |
| let (update, expected_text, expected_delta) = fixture(name); |
| - let doc = Doc::with_client_id(9001); // never Doc::new() — see Global Constraints |
| + 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"); |
| @@ -456,7 +459,11 @@ use std::io::Write; |
| use yrs::{Doc, Text, Transact}; |
| fn main() { |
| - let doc = Doc::new(); |
| + // 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(); |
| @@ -573,7 +580,7 @@ makes 'never edit a fixture to make a test pass' mechanical." |
| - `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_new(guid_ptr: u32, guid_len: u32, client_id: u64) -> u32` — returns a document handle, 0 on failure. **The GUID and client id come from the host, never from `yrs`** (Global Constraints) |
| - `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` |
| @@ -646,14 +653,15 @@ 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 { |
| +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 |
| }); |
| - DOCS.with(|d| d.borrow_mut().insert(id, Doc::new())); |
| + 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 |
| } |
| @@ -695,8 +703,16 @@ pub unsafe extern "C" fn docs_core_free(ptr: u32, len: u32) { |
| } |
| #[no_mangle] |
| -pub extern "C" fn docs_core_doc_new() -> u32 { |
| - doc_new() |
| +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] |
| @@ -775,7 +791,7 @@ where an error code costs nothing." |
| **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`. |
| +- 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. |
| @@ -839,7 +855,7 @@ func TestAppliesFixturesUnderWazero(t *testing.T) { |
| 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) |
| + doc, err := rt.NewDoc(ctx, "go-host-test-guid", 9001) |
| if err != nil { |
| t.Fatalf("NewDoc: %v", err) |
| } |
| @@ -867,7 +883,7 @@ func TestGarbageUpdateReturnsErrorNotTrap(t *testing.T) { |
| } |
| t.Cleanup(func() { rt.Close(ctx) }) |
| - doc, err := rt.NewDoc(ctx) |
| + doc, err := rt.NewDoc(ctx, "go-host-test-guid", 9001) |
| if err != nil { |
| t.Fatalf("NewDoc: %v", err) |
| } |
| @@ -958,7 +974,11 @@ type Handle struct { |
| textLen, textRead api.Function |
| } |
| -func (r *Runtime) NewDoc(ctx context.Context) (*Handle, error) { |
| +// 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 { |
| @@ -986,7 +1006,20 @@ func (r *Runtime) NewDoc(ctx context.Context) (*Handle, error) { |
| textRead: mod.ExportedFunction("docs_core_text_read"), |
| } |
| - res, err := h.docNew.Call(ctx) |
| + 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) |