oficina / docs Public

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
Paul Campbell pushed by paul@keymail.dev bf010938de39e00a495640f4f00c717ad4d77bd8 parent 66d8a69
5 files changed, +168 −3
  • core/src/abi.rs +47 −1
  • core/src/lib.rs +4 −1
  • internal/core/core.go +72 −1
  • internal/core/core_test.go +45 −0
  • internal/core/testdata/docs_core.wasm binary

Intent

Human

<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&lt;Diff&lt;D&gt;&gt;` where `Diff` has `insert: Out` and `attributes: Option&lt;Box&lt;Attrs&gt;&gt;`. 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>

Human

<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 `&lt;p&gt;&lt;/p&gt;` 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