oficina / docs Public

Plan 4: editing a document

Paul Campbell pushed by paul@keymail.dev 5be20b3b5977ec2302c38e92e63480d24ee3ab75 parent 25930b1
1 file changed, +1137 −0
  • docs/superpowers/plans/2026-08-31-editing.md +1137 −0
diff --git a/docs/superpowers/plans/2026-08-31-editing.md b/docs/superpowers/plans/2026-08-31-editing.md
new file mode 100644
index 0000000..495df33
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-31-editing.md
@@ -0,0 +1,1137 @@
+# 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.
+
+```js
+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`:
+
+```rust
+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**
+
+```bash
+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.
+
+```rust
+//! 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, XmlNode, 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: &XmlNode, out: &mut String) {
+ match node {
+ XmlNode::Text(t) => text_html(txn, t, out),
+ XmlNode::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.to_string().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);
+ }
+ XmlNode::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: &XmlNode, blocks: &mut Vec<String>, cur: &mut String) {
+ match node {
+ XmlNode::Text(t) => cur.push_str(&t.get_string(txn)),
+ XmlNode::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));
+ }
+ }
+ XmlNode::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`:
+
+```rust
+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**
+
+```bash
+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**
+
+```bash
+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`:
+
+```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**
+
+```bash
+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:
+
+```rust
+#[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**
+
+```bash
+./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**
+
+```bash
+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.
+
+```js
+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:
+
+```go
+// 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**
+
+```bash
+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**
+
+```bash
+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**
+
+```bash
+./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**
+
+```bash
+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**
+
+```js
+// 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**
+
+```go
+// 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**
+
+```bash
+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**
+
+```go
+// 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**
+
+```bash
+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**
+
+```bash
+go test ./internal/docs/ && .amadan/ci.d/10-gate
+```
+
+- [ ] **Step 5: Commit**
+
+```bash
+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**
+
+```js
+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**
+
+```bash
+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**
+
+```go
+// 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**
+
+```bash
+go test ./... && .amadan/ci.d/10-gate
+```
+
+- [ ] **Step 6: Actually edit a document**
+
+```bash
+./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**
+
+```bash
+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.
+
+**Risk to watch.** Task 1's `yrs` 0.23.5 API surface for `diff`, `children` and `get_attribute` may differ from what is written above. The assertions are the contract; adapt the call sites, not the tests.