oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

Editing a Document Implementation Plan

> For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Open a document, type into it, reload the page, and the text is still there — with the scriptless read view still rendering the same content server-side.

Architecture: The document's CRDT root changes from a Y.Text named "content" to a Y.XmlFragment named "prosemirror", which is the shape y-prosemirror requires. The Rust core learns to walk that fragment and render it to plain text and to HTML, so the server keeps rendering the read view without running JavaScript. The browser loads a document by fetching its raw update log and replaying it through JS yjs; edits produce new Yjs updates which are POSTed back, validated through the same Rust core, and appended to doc_updates. Transport is plain HTTP for this plan — the WebSocket belongs with live collaboration.

Tech Stack: Rust yrs 0.23.5 in WebAssembly (wazero host, no cgo), Go, JS yjs 13.6.27, prosemirror-* and y-prosemirror, vendored as prebuilt ESM bundles.

Spec: docs/superpowers/specs/2026-08-30-docs-design.md — particularly §0 (one wire format, two implementations), §5.2 (the sandbox is load-bearing), §6.1–6.5 (pages, the scriptless path, ProseMirror), §7.1 (merge and transport).

---

Sequencing decisions made when writing this plan

These resolve things the spec settles for the product but not for this plan. They are decisions, not open questions — an implementer should follow them, and a reviewer should hold the work to them.

D1 — Transport is plain HTTP, not a WebSocket. §7.1 specifies a WebSocket with an SSE fallback. That is the transport for live collaboration between people. This plan delivers one person editing one document with durable persistence, for which a POST is sufficient and a socket is a liability: §7.3's hibernation problem exists precisely because Docs holds a socket per open editor. Live collaboration, presence, and the socket's idle timeout arrive together in a later plan, and the update-log format this plan establishes is what that plan will stream.

D2 — Loading sends the whole update log. No new state-export ABI is needed: the server already stores raw Yjs update bytes, and the browser's yjs can apply them directly. §4.1's snapshot cadence and compaction horizon exist to bound this and are not in this plan. This is honest but O(n) in edits, so Task 6 caps the response and Task 6's test pins the cap.

D3 — No pagination in this plan. §6.1 says editing is paginated and a pageless mode is not in the first slice. That is a statement about the shipped product, not about plan order. internal/docs/views/show.html's own comment already sequences it: "pagination arrives with a later plan's layout engine, and this shape … is what lets that land without restructuring this markup." Keep that shape. Do not add page-break logic.

D4 — No toolbar in this plan. §6.3's toolbar and inspector are a later plan. ProseMirror's baseKeymap plus prosemirror-keymap give working keyboard editing, including bold and italic, which is enough to satisfy "edit a document" and to exercise the merge path with marks.

D5 — The existing Y.Text wire fixtures stay. They test the update encoding — that Rust and JS agree on bytes — which is §0's actual contract and is independent of which shared type an application puts in the document. Task 1 adds fragment fixtures alongside them. Deleting the text fixtures would remove wire coverage and replace it with nothing.

Global Constraints

Every task's requirements implicitly include this section.

  • CGO_ENABLED=0. One static Go binary. The WebAssembly runtime is wazero, pure Go.
  • The core module must have zero host imports. core/tests/no_host_imports.rs asserts this; DOCS_REQUIRE_WASM=1 makes a missing artifact a failure rather than a skip.
  • No entropy from the CRDT library. Doc::new, Options::with_client_id and friends are banned by clippy disallowed-methods. Construct documents with Options::with_guid_and_client_id. A new call site that needs a Doc must take both a guid and a client id from its caller.
  • Document text exists only as replayed Yjs updates. There is no content column and nothing may add one.
  • Markup uses the bare rst-* attribute grammar, never class="rst-*". This app's own vocabulary is docs-* (see show.html's comment), also as attributes.
  • Every user-visible string is a catalog key in internal/docs/render.go, never a literal in a template.
  • Never call sessions.UserID. An AST guard in internal/lint fails the build, including through aliased and dot imports. Use auth.From(r).Address.
  • Never use scope.Owned. It fails as a short list rather than an error.
  • Store.ApplyUpdate re-derives ownership from the database before any write. Do not weaken or bypass that check.
  • The fixtures in fixtures/ are the contract, not an output. .amadan/ci.d/10-gate fails if they change.
  • Run .amadan/ci.d/10-gate before reporting any task complete. Exit 0 is green; exit 2 (pending) and exit 3 (nothing ran) are not.

File Structure

| File | Responsibility | |---|---| | core/src/fragment.rs (new) | Walk a Y.XmlFragment and render it to plain text and to HTML. The only place that knows the ProseMirror node vocabulary on the Rust side. | | core/src/lib.rs | Gains fragment_text / fragment_html entry points delegating to fragment.rs. Keeps text_content for the existing wire fixtures. | | core/src/abi.rs | Gains docs_core_html_len / docs_core_html_read. ABI version 1 → 2. | | internal/core/core.go | abiVersion 1 → 2; (*Handle).HTML(ctx). | | fixtures/generate-fragment.mjs (new) | Builds Y.XmlFragment fixtures with a pinned client id, for the Rust side to reproduce. | | fixtures/vectors/fragment/* (new) | The fragment fixtures themselves. Contract, not output. | | internal/docs/seeddata/*.update.bin | Regenerated as fragment updates. | | fixtures/generate-seed.mjs | Emits ProseMirror-shaped fragments instead of Y.Text. | | internal/docs/views/show.html | Renders server-side HTML from the fragment; hosts the editor mount point. | | static/ (new) | Vendored prebuilt ESM bundles: editor.<hash>.js. | | fixtures/build-editor.mjs (new) | esbuild script producing static/editor.js. Run by a human, output committed. | | internal/docs/assets.go (new) | Embeds and serves static/, with a staleness check mirroring the wasm one. | | internal/docs/handlers.go | GET /d/{sqid}/updates, POST /d/{sqid}/updates. | | internal/docs/editor.js (new, under static/) | Binds ProseMirror to the Yjs fragment, loads the log, posts updates. |

---

Task 1: The Rust core reads a ProseMirror fragment

The core can currently only read a Y.Text named "content". y-prosemirror stores its document in a Y.XmlFragment named "prosemirror", whose children are Y.XmlElement nodes (paragraph, heading, bullet_list, list_item) containing Y.XmlText runs that carry marks as attributes. Without this, a document edited in the browser reads back as empty text, and the scriptless read view (§6.2) silently shows nothing.

Files:

  • Create: core/src/fragment.rs
  • Create: fixtures/generate-fragment.mjs
  • Create: fixtures/vectors/fragment/simple.bin, fixtures/vectors/fragment/marks.bin, fixtures/vectors/fragment/nested.bin (generated, then committed)
  • Modify: core/src/lib.rs
  • Test: core/tests/fragment.rs

Interfaces:

  • Consumes: yrs::Doc, built via Options::with_guid_and_client_id — never Doc::new.
  • Produces: - pub fn fragment_text(doc: &Doc) -> String — block-separated plain text. - pub fn fragment_html(doc: &Doc) -> String — escaped HTML. - The CRDT root name "prosemirror", part of the wire contract.
  • [ ] Step 1: Write the fixture generator

fixtures/generate-fragment.mjs. Mirror generate.mjs's structure — same pinned clientID, same "the JS side is the contract" stance.

import * as Y from 'yjs'
import { writeFileSync, mkdirSync } from 'node:fs'

// The client id is pinned so the bytes are reproducible. Options::
// with_guid_and_client_id on the Rust side pins the same pair; nothing
// in this pipeline may draw entropy (see the plan's Global Constraints).
function docWith(build) {
  const doc = new Y.Doc()
  doc.clientID = 1
  build(doc.getXmlFragment('prosemirror'))
  return Y.encodeStateAsUpdate(doc)
}

function el(name, attrs, ...children) {
  const e = new Y.XmlElement(name)
  for (const [k, v] of Object.entries(attrs ?? {})) e.setAttribute(k, v)
  if (children.length) e.insert(0, children)
  return e
}

function text(s, marks) {
  const t = new Y.XmlText()
  t.insert(0, s, marks ?? undefined)
  return t
}

mkdirSync('vectors/fragment', { recursive: true })

writeFileSync('vectors/fragment/simple.bin', docWith(f => {
  f.insert(0, [
    el('paragraph', null, text('First paragraph.')),
    el('paragraph', null, text('Second paragraph.')),
  ])
}))

writeFileSync('vectors/fragment/marks.bin', docWith(f => {
  f.insert(0, [
    el('paragraph', null,
      text('Plain then '), text('bold', { strong: {} }),
      text(' then '), text('italic', { em: {} }), text('.')),
  ])
}))

writeFileSync('vectors/fragment/nested.bin', docWith(f => {
  f.insert(0, [
    el('heading', { level: '2' }, text('A heading')),
    el('bullet_list', null,
      el('list_item', null, el('paragraph', null, text('One'))),
      el('list_item', null, el('paragraph', null, text('Two')))),
  ])
}))

console.log('wrote 3 fragment vectors')

Run it once from fixtures/: node generate-fragment.mjs. Commit the .bin files.

  • [ ] Step 2: Write the failing test

core/tests/fragment.rs:

use docs_core::{fragment_html, fragment_text};
use yrs::{Doc, Options, ReadTxn, Transact, Update};

fn doc_from(bytes: &[u8]) -> Doc {
    let doc = Doc::with_options(Options::with_guid_and_client_id("fixture".into(), 1));
    let update = Update::decode_v1(bytes).expect("decode fixture");
    doc.transact_mut().apply_update(update).expect("apply fixture");
    doc
}

#[test]
fn reads_paragraphs_as_blocks() {
    let doc = doc_from(include_bytes!("../../fixtures/vectors/fragment/simple.bin"));
    assert_eq!(fragment_text(&doc), "First paragraph.\n\nSecond paragraph.");
}

#[test]
fn renders_paragraphs_as_html() {
    let doc = doc_from(include_bytes!("../../fixtures/vectors/fragment/simple.bin"));
    assert_eq!(
        fragment_html(&doc),
        "<p>First paragraph.</p><p>Second paragraph.</p>"
    );
}

#[test]
fn renders_marks() {
    let doc = doc_from(include_bytes!("../../fixtures/vectors/fragment/marks.bin"));
    let html = fragment_html(&doc);
    assert!(html.contains("<strong>bold</strong>"), "got {html}");
    assert!(html.contains("<em>italic</em>"), "got {html}");
}

#[test]
fn renders_headings_and_lists() {
    let doc = doc_from(include_bytes!("../../fixtures/vectors/fragment/nested.bin"));
    let html = fragment_html(&doc);
    assert_eq!(
        html,
        "<h2>A heading</h2><ul><li><p>One</p></li><li><p>Two</p></li></ul>"
    );
}

// The read view puts this straight into a page. A document whose text
// contains markup must not be able to inject it.
#[test]
fn escapes_html_in_text() {
    let doc = Doc::with_options(Options::with_guid_and_client_id("esc".into(), 1));
    {
        let f = doc.get_or_insert_xml_fragment("prosemirror");
        let mut txn = doc.transact_mut();
        let p = f.push_back(&mut txn, yrs::XmlElementPrelim::empty("paragraph"));
        p.push_back(&mut txn, yrs::XmlTextPrelim::new("<script>alert(1)</script>"));
    }
    let html = fragment_html(&doc);
    assert!(!html.contains("<script>"), "unescaped script tag: {html}");
    assert!(html.contains("&lt;script&gt;"), "not escaped: {html}");
}

// An empty document is an empty canvas, not an error (show.html's own
// comment says so).
#[test]
fn empty_document_is_empty_output() {
    let doc = Doc::with_options(Options::with_guid_and_client_id("empty".into(), 1));
    assert_eq!(fragment_text(&doc), "");
    assert_eq!(fragment_html(&doc), "");
}

// An unknown node name must not vanish silently and must not panic —
// a future editor version can introduce nodes this build has never
// seen, and dropping their text would be data loss in the read view.
#[test]
fn unknown_nodes_keep_their_text() {
    let doc = Doc::with_options(Options::with_guid_and_client_id("unk".into(), 1));
    {
        let f = doc.get_or_insert_xml_fragment("prosemirror");
        let mut txn = doc.transact_mut();
        let n = f.push_back(&mut txn, yrs::XmlElementPrelim::empty("call_to_action"));
        n.push_back(&mut txn, yrs::XmlTextPrelim::new("keep me"));
    }
    assert!(fragment_text(&doc).contains("keep me"));
    assert!(fragment_html(&doc).contains("keep me"));
}
  • [ ] Step 3: Run it and watch it fail
cd core && cargo test --test fragment

Expected: fails to compile — fragment_text and fragment_html do not exist.

  • [ ] Step 4: Implement core/src/fragment.rs

Walk the fragment recursively. Two rules matter and both are pinned by tests above: escape all text, and never drop an unknown node's content.

//! Rendering a ProseMirror-shaped Y.XmlFragment to text and HTML.
//!
//! This is the only place on the Rust side that knows ProseMirror's
//! node vocabulary. It exists because the read view is server-rendered
//! (spec §6.2: reading a document works with no script), so the server
//! must be able to render what the browser's editor wrote.
//!
//! Unknown nodes render their children rather than disappearing. A
//! future editor build can introduce a node this binary has never
//! heard of, and a reader silently losing that text would be data
//! loss, not a degradation.

use std::fmt::Write as _;
use yrs::types::xml::{XmlFragment, XmlOut, XmlTextRef};
use yrs::{Doc, GetString, ReadTxn, Transact, Xml, XmlFragmentRef};

/// The CRDT root name. y-prosemirror's default, and part of the wire
/// contract — changing it silently orphans every existing document.
pub const ROOT: &str = "prosemirror";

fn root(doc: &Doc) -> XmlFragmentRef {
    doc.get_or_insert_xml_fragment(ROOT)
}

fn escape(s: &str, out: &mut String) {
    for c in s.chars() {
        match c {
            '&' => out.push_str("&amp;"),
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            '"' => out.push_str("&quot;"),
            '\'' => out.push_str("&#39;"),
            _ => out.push(c),
        }
    }
}

/// Marks this build knows, in a fixed order so output is deterministic.
/// A mark not listed here contributes no wrapper but never suppresses
/// its text.
const MARKS: &[(&str, &str)] = &[("strong", "strong"), ("em", "em"), ("code", "code")];

fn text_html<T: ReadTxn>(txn: &T, t: &XmlTextRef, out: &mut String) {
    // Diff gives runs with their attributes, which is where marks live.
    for chunk in t.diff(txn, yrs::types::text::YChange::identity) {
        let mut open = Vec::new();
        if let Some(attrs) = &chunk.attributes {
            for (name, tag) in MARKS {
                if attrs.contains_key(*name) {
                    open.push(*tag);
                }
            }
        }
        for tag in &open {
            let _ = write!(out, "<{tag}>");
        }
        if let yrs::Out::Any(yrs::Any::String(s)) = &chunk.insert {
            escape(s, out);
        }
        for tag in open.iter().rev() {
            let _ = write!(out, "</{tag}>");
        }
    }
}

fn node_html<T: ReadTxn>(txn: &T, node: &XmlOut, out: &mut String) {
    match node {
        XmlOut::Text(t) => text_html(txn, t, out),
        XmlOut::Element(e) => {
            let name = e.tag().to_string();
            let (open, close) = match name.as_str() {
                "paragraph" => ("<p>".to_string(), "</p>".to_string()),
                "heading" => {
                    // Clamp: a corrupt or hostile level must not emit
                    // <h0> or <h99>.
                    let level = e
                        .get_attribute(txn, "level")
                        .and_then(|v| v.parse::<u8>().ok())
                        .unwrap_or(1)
                        .clamp(1, 6);
                    (format!("<h{level}>"), format!("</h{level}>"))
                }
                "bullet_list" => ("<ul>".to_string(), "</ul>".to_string()),
                "ordered_list" => ("<ol>".to_string(), "</ol>".to_string()),
                "list_item" => ("<li>".to_string(), "</li>".to_string()),
                "blockquote" => ("<blockquote>".to_string(), "</blockquote>".to_string()),
                // Unknown: render children with no wrapper. See the
                // module comment — silence here is data loss.
                _ => (String::new(), String::new()),
            };
            out.push_str(&open);
            for child in e.children(txn) {
                node_html(txn, &child, out);
            }
            out.push_str(&close);
        }
        XmlOut::Fragment(f) => {
            for child in f.children(txn) {
                node_html(txn, &child, out);
            }
        }
    }
}

/// The document rendered as HTML, escaped and ready to place inside the
/// page container.
pub fn fragment_html(doc: &Doc) -> String {
    let f = root(doc);
    let txn = doc.transact();
    let mut out = String::new();
    for child in f.children(&txn) {
        node_html(&txn, &child, &mut out);
    }
    out
}

fn node_text<T: ReadTxn>(txn: &T, node: &XmlOut, blocks: &mut Vec<String>, cur: &mut String) {
    match node {
        XmlOut::Text(t) => cur.push_str(&t.get_string(txn)),
        XmlOut::Element(e) => {
            let name = e.tag().to_string();
            let is_block = matches!(
                name.as_str(),
                "paragraph" | "heading" | "list_item" | "blockquote"
            );
            for child in e.children(txn) {
                node_text(txn, &child, blocks, cur);
            }
            if is_block && !cur.is_empty() {
                blocks.push(std::mem::take(cur));
            }
        }
        XmlOut::Fragment(f) => {
            for child in f.children(txn) {
                node_text(txn, &child, blocks, cur);
            }
        }
    }
}

/// The document as plain text, one blank line between blocks. Used by
/// the list's preview and by anything that wants the words without the
/// markup.
pub fn fragment_text(doc: &Doc) -> String {
    let f = root(doc);
    let txn = doc.transact();
    let mut blocks = Vec::new();
    let mut cur = String::new();
    for child in f.children(&txn) {
        node_text(&txn, &child, &mut blocks, &mut cur);
    }
    if !cur.is_empty() {
        blocks.push(cur);
    }
    blocks.join("\n\n")
}

Add to core/src/lib.rs:

mod fragment;
pub use fragment::{fragment_html, fragment_text, ROOT as FRAGMENT_ROOT};

Keep text_content exactly as it is. D5: the Y.Text fixtures are wire coverage and stay.

  • [ ] Step 5: Run the tests
cd core && cargo test --test fragment

Expected: all seven pass. If yrs 0.23.5's diff/children signatures differ from the above, adapt the call sites — do not change what the tests assert. The assertions are the contract.

  • [ ] Step 6: Commit
git add core/src/fragment.rs core/src/lib.rs core/tests/fragment.rs \
        fixtures/generate-fragment.mjs fixtures/vectors/fragment
git commit -m "core: render a ProseMirror XmlFragment to text and HTML"

---

Task 2: Expose HTML across the ABI

The Go host can call text_len/text_read but has no way to ask for HTML. The read view needs it.

Files:

  • Modify: core/src/abi.rs
  • Modify: internal/core/core.go
  • Test: internal/core/core_test.go

Interfaces:

  • Consumes: fragment_html from Task 1.
  • Produces: - docs_core_html_len(handle) -> i32 (negative is an error, matching docs_core_text_len) - docs_core_html_read(handle, out_ptr, out_len) -> i32 - docs_core_abi_version() -> 2 - Go: func (h *Handle) HTML(ctx context.Context) (string, error)
  • [ ] Step 1: Write the failing Go test

In internal/core/core_test.go:

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.
}
  • [ ] Step 2: Run it and watch it fail
go test ./internal/core/ -run TestHandleHTMLRendersFragment

Expected: compile failure — h.HTML undefined.

  • [ ] Step 3: Add the ABI functions

In core/src/abi.rs, mirror docs_core_text_len and docs_core_text_read exactly — same error conventions, same bounds checks, same negative-is-error contract. Then bump:

#[no_mangle]
pub extern "C" fn docs_core_abi_version() -> u32 {
    2
}
  • [ ] Step 4: Bump the host's pin and add HTML

In internal/core/core.go, change const abiVersion = 1 to 2, and add HTML mirroring Text — the two-call len-then-read dance, the same guest-call timeout, the same RecoveredError handling.

  • [ ] Step 5: Rebuild the wasm and run the tests
./scripts/build-wasm.sh && go test ./internal/core/ ./internal/docs/

Expected: pass. The embedded-wasm staleness check in the gate compares the committed artifact against a fresh build — if it complains, commit the rebuilt .wasm.

  • [ ] Step 6: Commit
git add core/src/abi.rs internal/core/core.go internal/core/core_test.go internal/docs/*.wasm
git commit -m "core: ABI v2 — read a document as HTML"

---

Task 3: Seed documents become ProseMirror fragments, and the read view renders them

The six seeded documents are Y.Text updates. After Task 1 they would read back empty through the fragment path. This task moves them and switches the read view to server-rendered HTML.

Files:

  • Modify: fixtures/generate-seed.mjs
  • Modify: internal/docs/seeddata/*.update.bin (regenerated)
  • Modify: internal/docs/store.go (Text → also HTML)
  • Modify: internal/docs/handlers.go (show)
  • Modify: internal/docs/views/show.html
  • Test: internal/docs/seed_test.go, internal/docs/render_test.go

Interfaces:

  • Consumes: (*core.Handle).HTML from Task 2.
  • Produces: func (s *Store) HTML(ctx context.Context, doc *Document) (template.HTML, error)
  • [ ] Step 1: Update the seed generator to emit blocks

In fixtures/generate-seed.mjs, replace doc.getText('content').insert(0, text) with fragment construction, splitting the .txt source on blank lines into paragraphs. A line that is short, has no trailing full stop, and is followed by a blank line becomes a heading with level="2" — this is what gives "Early years" in the waterworks history its real shape, and demonstrates the schema doing work.

function blocksFor(text) {
  const paras = text.split(/\n\s*\n/).map(s => s.trim()).filter(Boolean)
  return paras.map((p, i) => {
    const looksLikeHeading =
      p.length < 60 && !p.includes('\n') && !/[.!?:]$/.test(p) && i !== 0
    if (looksLikeHeading) {
      const h = new Y.XmlElement('heading')
      h.setAttribute('level', '2')
      const t = new Y.XmlText(); t.insert(0, p); h.insert(0, [t])
      return h
    }
    const el = new Y.XmlElement('paragraph')
    const t = new Y.XmlText(); t.insert(0, p); el.insert(0, [t])
    return el
  })
}

Keep doc.clientID = 1. Regenerate: cd fixtures && node generate-seed.mjs.

  • [ ] Step 2: Write the failing test

internal/docs/seed_test.go already checks the .update.bin fixtures against their .txt sources. Extend it:

// The seeded documents must survive the round trip through the CRDT as
// HTML, not merely as bytes. A fixture that applies cleanly but renders
// to nothing is the failure this test exists to catch.
func TestSeededDocumentsRenderAsHTML(t *testing.T) {
	app, err := NewForTest(t)
	if err != nil {
		t.Fatalf("NewForTest: %v", err)
	}
	ctx := context.Background()
	owner := app.TestPerson(t, "render@example.com")
	if err := app.Store().Seed(ctx, owner); err != nil {
		t.Fatalf("Seed: %v", err)
	}
	docs, err := app.Store().List(ctx, owner)
	if err != nil {
		t.Fatalf("List: %v", err)
	}
	for _, d := range docs {
		html, err := app.Store().HTML(ctx, &d)
		if err != nil {
			t.Fatalf("HTML(%q): %v", d.Title, err)
		}
		if !strings.Contains(string(html), "<p>") {
			t.Errorf("%q rendered no paragraphs: %.120q", d.Title, html)
		}
	}
}

func TestWaterworksHistoryHasHeadings(t *testing.T) {
	app, err := NewForTest(t)
	if err != nil {
		t.Fatalf("NewForTest: %v", err)
	}
	ctx := context.Background()
	owner := app.TestPerson(t, "headings@example.com")
	if err := app.Store().Seed(ctx, owner); err != nil {
		t.Fatalf("Seed: %v", err)
	}
	docs, _ := app.Store().List(ctx, owner)
	for _, d := range docs {
		if !strings.Contains(d.Title, "Millbrook") {
			continue
		}
		html, err := app.Store().HTML(ctx, &d)
		if err != nil {
			t.Fatalf("HTML: %v", err)
		}
		if !strings.Contains(string(html), "<h2>") {
			t.Error("the long history rendered no headings — the block " +
				"classifier in generate-seed.mjs is not doing its job")
		}
		return
	}
	t.Fatal("the Millbrook waterworks document is missing from the seed set")
}
  • [ ] Step 3: Run it and watch it fail
go test ./internal/docs/ -run 'TestSeededDocumentsRenderAsHTML|TestWaterworksHistoryHasHeadings'

Expected: Store.HTML undefined.

  • [ ] Step 4: Add Store.HTML and render it

In store.go, mirror Text exactly — same replay, same defer h.Close(ctx) — returning template.HTML. Returning the typed value rather than a string is deliberate: it puts the "this is trusted markup" decision in one place, next to the escaping that earns it, rather than at the template.

In handlers.go's show handler, call HTML instead of Text. In show.html, replace the pre-wrap text node with {{.HTML}} inside the existing [docs-text] container. Keep [docs-canvas]/[docs-page] exactly as they are (D3).

Add CSS for h2, p, ul, ol, li, blockquote, strong, em within [docs-text], keeping the canvas's color-scheme: light and literal colours — the page is paper and must not follow the reader's dark mode.

  • [ ] Step 5: Run the full suite
go test ./... && .amadan/ci.d/10-gate

Expected: both green. The gate will flag the changed seeddata/*.update.bin only if the generator is nondeterministic — if it does, the client id is not pinned.

  • [ ] Step 6: Look at it
./scripts/run-dev.sh

Sign in, open the waterworks history, confirm headings and paragraphs render. Paste the real HTML into your report.

  • [ ] Step 7: Commit
git add fixtures/generate-seed.mjs internal/docs/seeddata internal/docs/store.go \
        internal/docs/handlers.go internal/docs/views/show.html internal/docs/seed_test.go
git commit -m "app: seed documents as ProseMirror fragments, render the read view server-side"

---

Task 4: Vendor the editor bundle

§6.5 keeps ProseMirror partly because there is no build step. That means a committed prebuilt bundle, not a build the app performs. There is no static/ directory yet.

Files:

  • Create: fixtures/build-editor.mjs, fixtures/editor-entry.js
  • Create: static/editor.js (built artifact, committed)
  • Create: internal/docs/assets.go
  • Test: internal/docs/assets_test.go

Interfaces:

  • Produces: GET /static/editor.<hash>.js, and func editorScriptPath() string for the template.
  • [ ] Step 1: Add the dependencies and the entry point

In fixtures/package.json, add prosemirror-state, prosemirror-view, prosemirror-model, prosemirror-schema-basic, prosemirror-schema-list, prosemirror-keymap, prosemirror-commands, prosemirror-history, y-prosemirror, and esbuild, all pinned to exact versions. yjs stays at 13.6.27 — the same version the wire fixtures are generated with, which is what makes the fixtures meaningful.

fixtures/editor-entry.js re-exports what static/editor.js needs, so the bundle's surface is explicit rather than incidental.

  • [ ] Step 2: Write the build script
// fixtures/build-editor.mjs — run by a person, output committed.
// The app performs no build step (spec §6.5); this produces the
// artifact that makes that true.
import { build } from 'esbuild'
import { createHash } from 'node:crypto'
import { readFileSync, writeFileSync, rmSync, readdirSync } from 'node:fs'

await build({
  entryPoints: ['editor-entry.js'],
  bundle: true, format: 'esm', minify: true, target: 'es2020',
  outfile: '../static/editor.js',
})

// Content hash in the filename so the asset is immutable-cacheable and
// a stale browser cache cannot serve an editor that disagrees with the
// server's ABI.
const bytes = readFileSync('../static/editor.js')
const hash = createHash('sha256').update(bytes).digest('hex').slice(0, 16)
for (const f of readdirSync('../static')) {
  if (/^editor\.[0-9a-f]{16}\.js$/.test(f)) rmSync(`../static/${f}`)
}
writeFileSync(`../static/editor.${hash}.js`, bytes)
rmSync('../static/editor.js')
console.log(`wrote static/editor.${hash}.js (${bytes.length} bytes)`)
  • [ ] Step 3: Write the failing test
// The bundle is committed, not built at boot. This asserts exactly one
// is present and that the path the template emits resolves to it — a
// mismatch would serve a 404 and leave the editor silently absent,
// which looks identical to "editing is not implemented yet".
func TestExactlyOneEditorBundleIsEmbedded(t *testing.T) {
	entries, err := fs.ReadDir(staticFS, "static")
	if err != nil {
		t.Fatalf("read static: %v", err)
	}
	var found []string
	for _, e := range entries {
		if strings.HasPrefix(e.Name(), "editor.") && strings.HasSuffix(e.Name(), ".js") {
			found = append(found, e.Name())
		}
	}
	if len(found) != 1 {
		t.Fatalf("want exactly one editor bundle, got %v", found)
	}
	if got := editorScriptPath(); got != "/static/"+found[0] {
		t.Errorf("editorScriptPath() = %q, want %q", got, "/static/"+found[0])
	}
}

func TestEditorBundleIsServed(t *testing.T) {
	app, err := NewForTest(t)
	if err != nil {
		t.Fatalf("NewForTest: %v", err)
	}
	rec := app.TestRequest(t, "", http.MethodGet, editorScriptPath(), nil)
	if rec.Code != http.StatusOK {
		t.Fatalf("GET %s = %d, want 200", editorScriptPath(), rec.Code)
	}
	if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "javascript") {
		t.Errorf("Content-Type = %q, want javascript", ct)
	}
}

Note the empty address in TestRequest: the bundle must be served without a session, or a signed-out reader gets a broken page. Static assets are not behind RequireSession.

  • [ ] Step 4: Run it, watch it fail, then implement

internal/docs/assets.go: //go:embed static plus a handler mounted at /static/, Cache-Control: public, max-age=31536000, immutable (safe because the name carries the content hash), and editorScriptPath() resolving the single bundle at init. If there is not exactly one, panic at init — a wrong number of bundles is a build mistake that must never reach a running instance.

  • [ ] Step 5: Commit
git add fixtures/package.json fixtures/package-lock.json fixtures/editor-entry.js \
        fixtures/build-editor.mjs static internal/docs/assets.go internal/docs/assets_test.go
git commit -m "app: vendor the prebuilt editor bundle"

---

Task 5: The update log endpoints

The browser needs the document's updates to build its Yjs doc, and a way to send new ones back.

Files:

  • Modify: internal/docs/handlers.go, internal/docs/app.go (routes)
  • Test: internal/docs/updates_test.go

Interfaces:

  • Produces: - GET /d/{sqid}/updates → application/octet-stream, the concatenated update log, owner-scoped. - POST /d/{sqid}/updates → applies one update via Store.ApplyUpdate, 204 on success.
  • [ ] Step 1: Write the failing tests
// Both endpoints are owner-scoped. A stranger must get 404, never 403:
// 403 confirms the document exists, which is the enumeration leak the
// read side already avoids.
func TestUpdatesEndpointsAreOwnerScoped(t *testing.T) {
	app, err := NewForTest(t)
	if err != nil {
		t.Fatalf("NewForTest: %v", err)
	}
	ctx := context.Background()
	owner := app.TestPerson(t, "owner@example.com")
	app.TestPerson(t, "stranger@example.com")
	doc, err := app.Store().Create(ctx, owner, "Private")
	if err != nil {
		t.Fatalf("Create: %v", err)
	}

	get := app.TestRequest(t, "stranger@example.com", http.MethodGet, "/d/"+doc.Sqid+"/updates", nil)
	if get.Code != http.StatusNotFound {
		t.Errorf("stranger GET = %d, want 404", get.Code)
	}
	post := app.TestRequest(t, "stranger@example.com", http.MethodPost,
		"/d/"+doc.Sqid+"/updates", bytes.NewReader(validUpdate(t)))
	if post.Code != http.StatusNotFound {
		t.Errorf("stranger POST = %d, want 404", post.Code)
	}
}

// The round trip is the whole feature: what the browser posts must come
// back out, through a real CRDT replay.
func TestPostedUpdateSurvivesRoundTrip(t *testing.T) {
	app, err := NewForTest(t)
	if err != nil {
		t.Fatalf("NewForTest: %v", err)
	}
	ctx := context.Background()
	owner := app.TestPerson(t, "owner@example.com")
	doc, _ := app.Store().Create(ctx, owner, "Round trip")

	update := readFixture(t, "../../fixtures/vectors/fragment/simple.bin")
	rec := app.TestRequest(t, "owner@example.com", http.MethodPost,
		"/d/"+doc.Sqid+"/updates", bytes.NewReader(update))
	if rec.Code != http.StatusNoContent {
		t.Fatalf("POST = %d, want 204: %s", rec.Code, rec.Body)
	}

	html, err := app.Store().HTML(ctx, doc)
	if err != nil {
		t.Fatalf("HTML: %v", err)
	}
	if !strings.Contains(string(html), "First paragraph.") {
		t.Errorf("posted update did not reach the document: %q", html)
	}

	got := app.TestRequest(t, "owner@example.com", http.MethodGet, "/d/"+doc.Sqid+"/updates", nil)
	if got.Code != http.StatusOK {
		t.Fatalf("GET = %d, want 200", got.Code)
	}
	if got.Body.Len() == 0 {
		t.Error("GET returned an empty log after a successful POST")
	}
}

// Garbage must be refused as a client error, not a 500, and must not be
// persisted. yrs calls from_utf8_unchecked on wire data (spec §5.2), so
// this path is exactly why the wasm sandbox is load-bearing.
func TestGarbageUpdateIsRejectedAndNotPersisted(t *testing.T) {
	app, err := NewForTest(t)
	if err != nil {
		t.Fatalf("NewForTest: %v", err)
	}
	ctx := context.Background()
	owner := app.TestPerson(t, "owner@example.com")
	doc, _ := app.Store().Create(ctx, owner, "Garbage")

	rec := app.TestRequest(t, "owner@example.com", http.MethodPost,
		"/d/"+doc.Sqid+"/updates", bytes.NewReader([]byte{0xff, 0x00, 0x13, 0x37}))
	if rec.Code != http.StatusBadRequest {
		t.Errorf("garbage POST = %d, want 400", rec.Code)
	}
	got := app.TestRequest(t, "owner@example.com", http.MethodGet, "/d/"+doc.Sqid+"/updates", nil)
	if got.Body.Len() != 0 {
		t.Error("a rejected update was persisted anyway")
	}
}

// An oversized body must be refused before it is read into memory.
func TestOversizedUpdateIsRefused(t *testing.T) {
	app, err := NewForTest(t)
	if err != nil {
		t.Fatalf("NewForTest: %v", err)
	}
	ctx := context.Background()
	owner := app.TestPerson(t, "owner@example.com")
	doc, _ := app.Store().Create(ctx, owner, "Huge")

	rec := app.TestRequest(t, "owner@example.com", http.MethodPost,
		"/d/"+doc.Sqid+"/updates", bytes.NewReader(make([]byte, maxUpdateBytes+1)))
	if rec.Code != http.StatusRequestEntityTooLarge {
		t.Errorf("oversized POST = %d, want 413", rec.Code)
	}
}
  • [ ] Step 2: Run them and watch them fail
go test ./internal/docs/ -run 'Updates|RoundTrip|Garbage|Oversized'

Expected: 404 for unrouted paths, which is a false pass on the owner-scoping test. Note that in your report and confirm the tests fail for the right reason after the routes exist.

  • [ ] Step 3: Implement

Add const maxUpdateBytes = 1 << 20 with a comment on why a megabyte: a single keystroke's update is tens of bytes, a whole seeded document is a few kilobytes, so a megabyte is far above legitimate use and far below anything that threatens the instance.

Wrap the body in http.MaxBytesReader. Load the document with Store.Get(ctx, owner, sqid) — already owner-scoped, already 404-not-403 — then Store.ApplyUpdate, which re-derives ownership itself. A rejection from the core is a 400, not a 500: it is a client sending bad bytes.

For GET, stream the rows in seq order. Cap the response at maxLogBytes = 8 << 20 and, on exceeding it, fail with 500 and a log line naming the document — per D2 this is the bound that a later snapshot plan removes, and it must be loud rather than silently truncating a document into corruption.

CSRF: rastrillo's protection is origin-checking, so the POST needs a correct Origin. TestRequest must send one. Do not exempt the route.

  • [ ] Step 4: Run the tests, then the gate
go test ./internal/docs/ && .amadan/ci.d/10-gate
  • [ ] Step 5: Commit
git add internal/docs/handlers.go internal/docs/app.go internal/docs/updates_test.go
git commit -m "app: update-log endpoints, owner-scoped and size-capped"

---

Task 6: The editor

Bind ProseMirror to the Yjs fragment, load the log, and post updates as they happen.

Files:

  • Create: fixtures/editor-entry.js (filled in properly here)
  • Modify: internal/docs/views/show.html
  • Modify: internal/docs/render.go (catalog keys)
  • Test: internal/docs/editor_view_test.go
  • [ ] Step 1: Write the editor
import * as Y from 'yjs'
import { EditorState } from 'prosemirror-state'
import { EditorView } from 'prosemirror-view'
import { Schema } from 'prosemirror-model'
import { schema as basic } from 'prosemirror-schema-basic'
import { addListNodes } from 'prosemirror-schema-list'
import { keymap } from 'prosemirror-keymap'
import { baseKeymap, toggleMark } from 'prosemirror-commands'
import { ySyncPlugin, yUndoPlugin, undo, redo } from 'y-prosemirror'

const schema = new Schema({
  nodes: addListNodes(basic.spec.nodes, 'paragraph block*', 'block'),
  marks: basic.spec.marks,
})

export async function mountEditor(el) {
  const sqid = el.getAttribute('data-sqid')
  const base = `/d/${sqid}/updates`
  const ydoc = new Y.Doc()

  // Load: replay the server's log. Applying inside one transaction
  // keeps the editor from rendering half a document.
  const res = await fetch(base, { credentials: 'same-origin' })
  if (!res.ok) throw new Error(`load ${base}: ${res.status}`)
  const log = new Uint8Array(await res.arrayBuffer())
  if (log.length) Y.applyUpdate(ydoc, log, 'server')

  const fragment = ydoc.getXmlFragment('prosemirror')

  // Send only what originated here. The 'server' origin above marks
  // replayed bytes, and echoing those back would grow the log without
  // adding information.
  let queue = []
  let timer = null
  ydoc.on('update', (update, origin) => {
    if (origin === 'server') return
    queue.push(update)
    if (timer) return
    // Coalesce a burst of keystrokes into one request. 400ms is below
    // the threshold where a person notices a save is pending and well
    // above per-character chatter.
    timer = setTimeout(flush, 400)
  })

  async function flush() {
    timer = null
    if (!queue.length) return
    const merged = Y.mergeUpdates(queue)
    queue = []
    try {
      const r = await fetch(base, {
        method: 'POST',
        credentials: 'same-origin',
        headers: { 'Content-Type': 'application/octet-stream' },
        body: merged,
      })
      if (!r.ok) throw new Error(`save: ${r.status}`)
      el.removeAttribute('data-save-failed')
    } catch (err) {
      // Put the bytes back so the next flush retries them. Dropping
      // them would lose the edit silently, which is the one outcome a
      // text editor may never produce.
      queue.unshift(merged)
      el.setAttribute('data-save-failed', '1')
      console.error(err)
    }
  }

  // A close or reload with a pending timer must not lose the edit.
  addEventListener('beforeunload', () => {
    if (!queue.length) return
    navigator.sendBeacon(base, new Blob([Y.mergeUpdates(queue)],
      { type: 'application/octet-stream' }))
  })

  const view = new EditorView(el, {
    state: EditorState.create({
      schema,
      plugins: [
        ySyncPlugin(fragment),
        yUndoPlugin(),
        keymap({
          'Mod-z': undo, 'Mod-y': redo, 'Mod-Shift-z': redo,
          'Mod-b': toggleMark(schema.marks.strong),
          'Mod-i': toggleMark(schema.marks.em),
        }),
        keymap(baseKeymap),
      ],
    }),
  })
  return view
}

const mount = document.querySelector('[docs-editor]')
if (mount) mountEditor(mount).catch(err => {
  // Leave the server-rendered read view in place rather than replacing
  // it with a blank editor: unreadable is worse than uneditable.
  mount.setAttribute('data-editor-failed', '1')
  console.error(err)
})
  • [ ] Step 2: Rebuild the bundle
cd fixtures && npm ci && node build-editor.mjs

Commit the new hashed artifact and delete the old one — Task 4's test asserts exactly one exists.

  • [ ] Step 3: Write the failing view test
// The read view must render its content server-side whether or not the
// editor loads. Spec §6.2: reading works with no script. A page that
// renders only after the bundle executes is a regression against that,
// and it is invisible in a browser that runs the script.
func TestShowRendersContentWithoutScript(t *testing.T) {
	app, err := NewForTest(t)
	if err != nil {
		t.Fatalf("NewForTest: %v", err)
	}
	ctx := context.Background()
	owner := app.TestPerson(t, "reader@example.com")
	doc, _ := app.Store().Create(ctx, owner, "Readable")
	update := readFixture(t, "../../fixtures/vectors/fragment/simple.bin")
	if err := app.Store().ApplyUpdate(ctx, doc, update); err != nil {
		t.Fatalf("ApplyUpdate: %v", err)
	}

	rec := app.TestRequest(t, "reader@example.com", http.MethodGet, "/d/"+doc.Sqid, nil)
	body := rec.Body.String()
	if !strings.Contains(body, "First paragraph.") {
		t.Error("the document's text is not in the server-rendered HTML")
	}
	if !strings.Contains(body, "docs-editor") {
		t.Error("no editor mount point")
	}
	if !strings.Contains(body, editorScriptPath()) {
		t.Error("the editor bundle is not referenced")
	}
}
  • [ ] Step 4: Add the mount point

In show.html, give the existing [docs-text] container docs-editor and data-sqid="{{.Sqid}}", and load the bundle with <script type="module" defer src="{{.EditorScript}}"></script>. ProseMirror replaces the container's contents on mount, so the server-rendered HTML is what a scriptless reader sees and what a scripted reader sees until the editor takes over.

  • [ ] Step 5: Run everything
go test ./... && .amadan/ci.d/10-gate
  • [ ] Step 6: Actually edit a document
./scripts/run-dev.sh

Sign in, open a seeded document, type a sentence, wait a second, reload, and confirm it is still there. Then check the read view still renders with JavaScript disabled. Paste both results into your report. This step is the plan's whole purpose — a green suite that has not been exercised this way proves nothing about whether a person can edit a document.

  • [ ] Step 7: Commit
git add fixtures/editor-entry.js static internal/docs/views/show.html \
        internal/docs/render.go internal/docs/editor_view_test.go
git commit -m "app: edit a document — ProseMirror bound to the shared fragment"

---

Self-review

Spec coverage. §6.1 pagination — deliberately deferred (D3), and show.html's existing comment already sequences it. §6.2 scriptless reading — Task 3 renders server-side, Task 6 pins it with a test. §6.3 toolbar/inspector — deferred (D4). §6.4 list — already shipped. §6.5 ProseMirror — Tasks 4 and 6. §7.1 WebSocket transport — deferred (D1); the update-log format this plan establishes is what a socket will stream. §7.2 offline/IndexedDB — not in this plan; it needs the socket. §7.3 presence and hibernation — not in this plan; no socket is opened, so no instance is pinned awake, which is why D1 is also the conservative choice.

Gaps consciously left open. The update log grows without bound until §4.1's snapshot plan lands; Task 5 caps it loudly rather than silently. Two browser tabs on one document will diverge until the collaboration plan adds a socket — single-user editing is what this plan claims. There is no conflict UI because there is no live merge yet.

Type consistency. fragment_text/fragment_html (Rust) → docs_core_html_len/docs_core_html_read (ABI) → (*Handle).HTML (Go host) → (*Store).HTML returning template.HTML (app) → {{.HTML}} (template). The CRDT root is "prosemirror" in fragment.rs, generate-fragment.mjs, generate-seed.mjs and editor.js. maxUpdateBytes and maxLogBytes are defined in Task 5 and referenced only there.

API surface, verified against yrs 0.23.5's own source before this plan was committed (~/.cargo/registry/src/*/yrs-0.23.5), because writing Rust against a half-remembered API is how a plan wastes an implementer's afternoon:

  • Doc::get_or_insert_xml_fragment(name) -> XmlFragmentRef — exists, doc.rs:253.
  • The child enum is XmlOut, not XmlNode — types/xml.rs:89, variants Element(XmlElementRef), Fragment(XmlFragmentRef), Text(XmlTextRef). The code above uses XmlOut.
  • children(txn) yields XmlOut — types/xml.rs:1223.
  • XmlElementRef::tag() -> &Arc<str> — types/xml.rs:280.
  • get_attribute(txn, name) -> Option<String> — types/xml.rs:1026. It returns the String directly, so the heading level parses with .and_then(|v| v.parse::<u8>().ok()) and no intermediate to_string().

Still unverified, and the one thing to check first. XmlTextRef::diff's exact signature and the shape of its returned chunks — this is where marks come from, and MARKS handling in text_html depends on it. Read types/xml.rs and types/text.rs before writing that function. If diff proves awkward, the fallback is get_string plus a separate attribute walk, which loses mark fidelity — take that only after reading, and say so in your report, because the marks test is not optional.

The assertions in Step 2 are the contract; adapt call sites, never the tests.