core: ABI v2 — read a document as HTML
Adds docs_core_html_len/docs_core_html_read, mirroring the existing text_len/text_read pair exactly (same error codes, bounds checks, and buffer-too-small handling), and the Go Handle.HTML method that drives them. Bumps docs_core_abi_version and the Go host's abiVersion pin from 1 to 2 in lockstep, so a stale module is refused at load rather than silently misread. Rebuilds and commits the embedded wasm artifact.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011F5CivVm4CmL9ByfSSAaXA
core/src/abi.rs+47 −1core/src/lib.rs+4 −1internal/core/core.go+72 −1internal/core/core_test.go+45 −0internal/core/testdata/docs_core.wasmbinary
Intent
<task-notification> <task-id>a431402616dfeb486</task-id> <tool-use-id>toolu_011D9dwLPDNpy6BEDHim2Tqw</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-carlos-docs/70f302d1-9d5d-4eb2-8b38-e09a15fdd1c3/tasks/a431402616dfeb486.output</output-file> <status>completed</status> <summary>Agent "Editing Task 1 Rust fragment" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>Task 1 complete. Status: done. Commit `66d8a69` on branch `editing`. `XmlTextRef::diff` comes from the `Text` trait it implements (`yrs` `src/types/text.rs:420`), returning `Vec<Diff<D>>` where `Diff` has `insert: Out` and `attributes: Option<Box<Attrs>>`. The brief's guessed signature and `Out::Any(Any::String(s))` match pattern were exactly correct — used verbatim, no fallback needed. Watched it fail first: `cargo test --test fragment` failed with `E0432: unresolved imports docs_core::fragment_html, docs_core::fragment_text` before `core/src/fragment.rs` existed (the test file's own import bugs — `Decode` trait, `XmlFragment` trait — were fixed in that same pass so the real missing-symbol error was visible). After implementing `core/src/fragment.rs` and wiring `core/src/lib.rs`: all 7 tests pass on first run, no assertions weakened. Full `.amadan/ci.d/10-gate`: exit 0. Required re-copying `internal/core/testdata/docs_core.wasm` from the fresh build (gate caught the stale artifact), now committed. Go steps needed `dangerouslyDisableSandbox` once for a sandbox-readonly build cache, unrelated to this change. Full report at `.superpowers/sdd/2026-08-31-editing/task-1-report.md`.</result> <usage><subagent_tokens>76427</subagent_tokens><tool_uses>30</tool_uses><duration_ms>296626</duration_ms></usage> </task-notification>
<task-notification> <task-id>ac5d9670dd88a6e8c</task-id> <tool-use-id>toolu_01MYokSHC6SmJZPt15a78X9r</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-carlos-docs/70f302d1-9d5d-4eb2-8b38-e09a15fdd1c3/tasks/ac5d9670dd88a6e8c.output</output-file> <status>completed</status> <summary>Agent "Review editing task 1" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>Good — `.superpowers/` is gitignored, so the review file doesn't show in git status (expected and fine); the tree is clean of tracked changes. Spec verdict: **PASS**. Quality verdict: **APPROVED** (two low-severity, non-blocking findings). Findings (ranked): 1. Low — `core/src/fragment.rs` `node_text`: text sibling before a block inside an unknown wrapper merges with no separator (verified: produces `"orphanactual"`). Only reachable via hand-built Yjs updates outside ProseMirror's own schema; affects `fragment_text` only, not HTML. 2. Low — asymmetry: `fragment_html` preserves empty `<p></p>` blocks, `fragment_text` silently drops empty blocks entirely (untested by the shipped suite). 3. Informational — none of the 7 tests exercise `fragment_text` against nested list/heading structure, and `unknown_nodes_keep_their_text` only covers single-level nesting (I verified nested-unknown-wrapping-known manually; it works, just uncovered by committed tests). Verified via throwaway tests (written, run, then deleted — tree left clean, confirmed via `git status --porcelain`): hostile tag names (`"script"`), hostile attribute injection (`onerror`, `style`), and heading-level extremes (`0,99,-1,abc,"",300,6.5,1e9`) all produce valid escaped output with no wrapper leakage; `text_content` and existing wire fixtures are byte-identical in the diff. One line: **a crafted document cannot inject HTML** — tag names are matched against a closed literal set (unknown falls to no wrapper), no attribute is ever echoed into output, and all text content is routed through `escape()`. Full review written to `/home/paulca/amadan.net/carlos/docs/.superpowers/sdd/2026-08-31-editing/task-1-review.md`.</result> <usage><subagent_tokens>84189</subagent_tokens><tool_uses>18</tool_uses><duration_ms>172835</duration_ms></usage> </task-notification>
diff --git a/core/src/abi.rs b/core/src/abi.rs| index bd1f1ac..10038bb 100644 |
| --- a/core/src/abi.rs |
| +++ b/core/src/abi.rs |
| @@ -46,7 +46,7 @@ |
| //! held a stale handle is exactly the kind of silent corruption §5.2 |
| //! exists to avoid. |
| -use crate::{apply_update, text_content, CoreError}; |
| +use crate::{apply_update, fragment_html, text_content, CoreError}; |
| use std::cell::RefCell; |
| use std::collections::HashMap; |
| use yrs::{Doc, Options}; |
| @@ -113,6 +113,13 @@ pub fn text_string(handle: u32) -> Option<String> { |
| get_doc(handle).map(|doc| text_content(&doc)) |
| } |
| +/// `None` for an unknown handle — distinct from `Some(String::new())`, a |
| +/// real empty document. Callers must not collapse the two (Finding 3), |
| +/// exactly as `text_string` above. |
| +pub fn html_string(handle: u32) -> Option<String> { |
| + get_doc(handle).map(|doc| fragment_html(&doc)) |
| +} |
| + |
| // --- wasm exports --- |
| // Amended by pre-flight ruling R2. The obvious version — Vec::with_capacity |
| @@ -286,3 +293,42 @@ pub unsafe extern "C" fn docs_core_text_read(handle: u32, out_ptr: u32, out_len: |
| // larger, so clamp explicitly rather than rely on that coincidence. |
| s.len().try_into().unwrap_or(i32::MAX) |
| } |
| + |
| +/// Returns the byte length of `handle`'s HTML rendering, or `ERR_NO_DOC` |
| +/// if `handle` doesn't name a live document — distinct from 0, which |
| +/// means a real, empty document (Finding 3), exactly as |
| +/// `docs_core_text_len` above. |
| +#[no_mangle] |
| +pub extern "C" fn docs_core_html_len(handle: u32) -> i32 { |
| + match html_string(handle) { |
| + Some(s) => s.len().try_into().unwrap_or(i32::MAX), |
| + None => ERR_NO_DOC, |
| + } |
| +} |
| + |
| +/// # Safety |
| +/// |
| +/// `out_ptr` and `out_len` must describe a byte range that lies entirely |
| +/// within this module's linear memory and is valid for writes for the |
| +/// duration of this call. (Checked by `in_bounds` before any write — this |
| +/// safety requirement covers what that check cannot see, such as |
| +/// concurrent mutation.) |
| +#[no_mangle] |
| +pub unsafe extern "C" fn docs_core_html_read(handle: u32, out_ptr: u32, out_len: u32) -> i32 { |
| + let s = match html_string(handle) { |
| + Some(s) => s, |
| + None => return ERR_NO_DOC, // stale/unknown handle, not an empty read |
| + }; |
| + if s.len() > out_len as usize { |
| + return ERR_BUFFER_TOO_SMALL; |
| + } |
| + if !in_bounds(out_ptr, out_len) { |
| + return ERR_INVALID_LENGTH; |
| + } |
| + std::ptr::copy_nonoverlapping(s.as_ptr(), out_ptr as *mut u8, s.len()); |
| + // s.len() <= out_len, and in_bounds(out_ptr, out_len) already proved |
| + // out_len <= isize::MAX, so this fits in i32... only when isize is |
| + // 32-bit (wasm32). On native 64-bit test builds isize::MAX is far |
| + // larger, so clamp explicitly rather than rely on that coincidence. |
| + s.len().try_into().unwrap_or(i32::MAX) |
| +} |
diff --git a/core/src/lib.rs b/core/src/lib.rs| index 73f5c0d..a132909 100644 |
| --- a/core/src/lib.rs |
| +++ b/core/src/lib.rs |
| @@ -69,9 +69,12 @@ extern "Rust" fn __getrandom_v03_custom( |
| /// 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. |
| +/// |
| +/// Bumped to 2 for the HTML read pair in `abi.rs` |
| +/// (`docs_core_html_len` / `docs_core_html_read`). |
| #[no_mangle] |
| pub extern "C" fn docs_core_abi_version() -> u32 { |
| - 1 |
| + 2 |
| } |
| use yrs::updates::decoder::Decode; |
diff --git a/internal/core/core.go b/internal/core/core.go| index 958c998..68c359b 100644 |
| --- a/internal/core/core.go |
| +++ b/internal/core/core.go |
| @@ -62,7 +62,7 @@ var moduleWASM []byte |
| // anything else is refused at load, so a stale .wasm (see |
| // scripts/build-wasm.sh and the copy step in Task 6's brief) fails loudly |
| // rather than behaving strangely. |
| -const abiVersion = 1 |
| +const abiVersion = 2 |
| // defaultGuestCallTimeout bounds every single call into the wasm module, |
| // unless overridden with WithGuestCallTimeout. |
| @@ -522,3 +522,74 @@ func textOn(ctx context.Context, timeout time.Duration, mod api.Module, docHandl |
| } |
| return string(buf), nil |
| } |
| + |
| +// HTML returns the document's current content rendered as HTML (spec |
| +// §6.2: the read view is server-rendered, no script required). Mirrors |
| +// Text exactly, including the trap/timeout-then-rebuild-then-retry-once |
| +// behaviour: if the read itself traps or times out, the instance is |
| +// discarded and rebuilt, and the read is retried once against the freshly |
| +// rebuilt instance rather than reporting the content as gone. |
| +func (h *Handle) HTML(ctx context.Context) (string, error) { |
| + html, err := htmlOn(ctx, h.rt.callTimeout, h.mod, h.handle) |
| + if err == nil { |
| + return html, nil |
| + } |
| + if !isFatal(err) { |
| + return "", err |
| + } |
| + if rebuildErr := h.rebuild(ctx); rebuildErr != nil { |
| + return "", errors.Join(err, rebuildErr) |
| + } |
| + html, err = htmlOn(ctx, h.rt.callTimeout, h.mod, h.handle) |
| + if err != nil { |
| + return "", fmt.Errorf("reading html from the rebuilt instance: %w", err) |
| + } |
| + return html, nil |
| +} |
| + |
| +// htmlOn is textOn's HTML counterpart — the same two-call |
| +// len-then-read dance against docs_core_html_len/docs_core_html_read |
| +// instead of the text pair. |
| +func htmlOn(ctx context.Context, timeout time.Duration, mod api.Module, docHandle uint64) (string, error) { |
| + lenRes, err := callGuest(ctx, timeout, mod.ExportedFunction("docs_core_html_len"), docHandle) |
| + if err != nil { |
| + return "", fmt.Errorf("html_len trapped or timed out: %w", err) |
| + } |
| + // html_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("html_len failed: code %d", code) |
| + } |
| + n := lenRes[0] |
| + if n == 0 { |
| + return "", nil |
| + } |
| + |
| + ptr, err := allocIn(ctx, timeout, mod, n) |
| + if err != nil { |
| + return "", fmt.Errorf("alloc: %w", err) |
| + } |
| + defer freeIn(ctx, timeout, mod, ptr, n) |
| + |
| + rc, err := callGuest(ctx, timeout, mod.ExportedFunction("docs_core_html_read"), docHandle, ptr, n) |
| + if err != nil { |
| + return "", fmt.Errorf("html_read trapped or timed out: %w", err) |
| + } |
| + written := int32(rc[0]) |
| + if written < 0 { |
| + return "", fmt.Errorf("html_read failed: code %d", written) |
| + } |
| + |
| + // docs_core_html_read returns s.len(), guaranteed only <= out_len — the |
| + // buffer sized by html_len is an upper bound, not a promise the two |
| + // calls agree exactly, exactly as docs_core_text_read. Reading n bytes |
| + // instead of the written count would, if they ever diverged, append |
| + // uninitialised allocator bytes past the real content to the returned |
| + // string — read exactly what html_read says it wrote. |
| + buf, ok := mod.Memory().Read(uint32(ptr), uint32(written)) |
| + if !ok { |
| + return "", fmt.Errorf("reading %d bytes at %d: out of range", written, ptr) |
| + } |
| + return string(buf), nil |
| +} |
diff --git a/internal/core/core_test.go b/internal/core/core_test.go| index 02ab2a3..ff6daba 100644 |
| --- a/internal/core/core_test.go |
| +++ b/internal/core/core_test.go |
| @@ -102,3 +102,48 @@ func TestGarbageUpdateReturnsErrorNotTrap(t *testing.T) { |
| // success (TestAppliesFixturesUnderWazero) and a clean rejection |
| // (TestGarbageUpdateReturnsErrorNotTrap), both of which the public API is |
| // enough to verify. |
| + |
| +func TestHandleHTMLRendersFragment(t *testing.T) { |
| + ctx := context.Background() |
| + rt, err := core.New(ctx) |
| + if err != nil { |
| + t.Fatalf("core.New: %v", err) |
| + } |
| + defer rt.Close(ctx) |
| + |
| + h, err := rt.NewDoc(ctx, "html-test", 1) |
| + if err != nil { |
| + t.Fatalf("NewDoc: %v", err) |
| + } |
| + defer h.Close(ctx) |
| + |
| + update, err := os.ReadFile("../../fixtures/vectors/fragment/simple.bin") |
| + if err != nil { |
| + t.Fatalf("read fixture: %v", err) |
| + } |
| + if err := h.Apply(ctx, update); err != nil { |
| + t.Fatalf("Apply: %v", err) |
| + } |
| + |
| + got, err := h.HTML(ctx) |
| + if err != nil { |
| + t.Fatalf("HTML: %v", err) |
| + } |
| + want := "<p>First paragraph.</p><p>Second paragraph.</p>" |
| + if got != want { |
| + t.Errorf("HTML() = %q, want %q", got, want) |
| + } |
| +} |
| + |
| +// The host pins the ABI it understands. A module built from a |
| +// different contract must be refused loudly, not read as garbage. |
| +func TestABIVersionIsPinnedAtTwo(t *testing.T) { |
| + ctx := context.Background() |
| + rt, err := core.New(ctx) |
| + if err != nil { |
| + t.Fatalf("core.New: %v", err) |
| + } |
| + defer rt.Close(ctx) |
| + // core.New itself refuses a mismatch; reaching here means the |
| + // module reported exactly what this host expects. |
| +} |
diff --git a/internal/core/testdata/docs_core.wasm b/internal/core/testdata/docs_core.wasm| index ca533da..ee39021 100755 |
| Binary files a/internal/core/testdata/docs_core.wasm and b/internal/core/testdata/docs_core.wasm differ |