oficina / docs Public

Import spec revision 2: act on the Fable review

Revision 1 came back "not ready" with sixteen defects, one of which holed
the centre.

The importer was to author content under the document row's own ClientID.
Store.Create draws that from mrand.Int64() -- uniform in [0, 2^63) -- and
the browser decodes client ids through lib0's readVarUint, which
accumulates into a JS double and throws past MAX_SAFE_INTEGER. Measured at
the exact shape Store.Create produces: 861 of 1000 threw "Integer out of
Range". So 861 of every 1000 imported documents would have been unopenable
in the editor, and nothing existing would have caught it -- the server has
never authored a block, so its client id has never been on the wire, and
every wire fixture uses clientID 1, 2 or 3. The builder now draws a fresh
id in [0, 2^32), mirroring Yjs itself.

Two of revision 1's four "spike-proven" findings were also wrong, and
wrong in the same interesting way: they were true of this repo's fixtures
and not of the editor those fixtures claim to represent. heading.level is
a *number* (toolbar.mjs:167 Number(...)); the string lives only in
generate-fragment.mjs:50, and yrs's get_attribute stringifies, so a spike
reading through Rust cannot tell the two apart. A fixture is evidence
about the fixture generator.

Also: the round-trip gate as written did not hold (fragment_json
normalises five ways) and did not prove editor-openability, so it is now
stated over a canonical tree plus content-model enforcement in the
builder; the size limits are derived from the three that actually bind
(8 MB maxLogBytes, the 1 s guest call, the 512 MB box) rather than from a
round number; the report gets storage, lifetime and catalog-key rules;
the row is no longer created before there is a verified update to write;
and the ABI bump no longer silently discards every stored snapshot.

Split into three plans, as the review recommended: the mechanism, then
DOCX, then PDF.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RMNxUbTqqxehd9JdtkYfbe
Paul Campbell pushed by paul@keymail.dev 167cf274d02283a74ef2399116e95ef4b77209c8 parent 32ea808
1 file changed, +461 −286
  • docs/superpowers/specs/2026-09-01-import-design.md +461 −286

CI cancelled — superseded by a newer push — run details

Intent

Human

<task-notification> <task-id>a0ffd8eacbfc26de9</task-id> <tool-use-id>toolu_01TUbcyBBdGnNqfodBgiCMJS</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-oficina-docs/f51dc426-0d05-413b-a979-c81d95ac5994/tasks/a0ffd8eacbfc26de9.output</output-file> <status>completed</status> <summary>Agent "Fable spec review" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>**VERDICT: not ready.** The core mechanism (Go parses → one new Rust builder → existing snapshot pair → `Store.ApplyUpdate`) is sound and most of the spec's checkable claims hold. But one claim at the centre is wrong in a way that would make roughly 999 of every 1000 imported documents unopenable in the editor, two of the four "spike-proven" findings are misdescribed, and the round-trip gate as written does not prove what the spec says it proves. Smallest set of changes to make it ready: 1. §5 step 2: do **not** build under the row's `ClientID`. Draw a fresh id in `[0, 2^32)` for the build, state why (lib0 decodes client ids as JS numbers and throws past 2^53), and make the §12 cross-implementation fixture use the production id path. 2. Correct F4 (the editor writes `level` as a **number**; import must too) and F3's explanation (yrs already unsets omitted marks on `insert_with_attributes`; the inheritance is on plain `insert`). 3. Define the round-trip over a **canonical** tree, compared structurally, and add ProseMirror content-model validation to the builder — otherwise the gate passes trees the editor cannot open. 4. Replace "25 MB" with budgets derived from the three real limits (8 MB `maxLogBytes`, 1 s guest call, 512 MB box), plus a concurrency cap and no temp files. 5. Specify the report's storage, lifetime, dismissal and content rules. 6. Reorder §5 so no row exists until there is a verified update to write; specify no-JS multi-file and how fetch vs form is distinguished. 7. State the ABI-bump consequence (every existing snapshot discarded) or decouple the snapshot version from the ABI. 8. Split into three plans (steps 1–4; DOCX; PDF). Also a process note: `core/src/fragment.rs` and `core/tests/fragment.rs` are already modified and uncommitted in this worktree (the `fragment_text` fix from §4). Implementation has started ahead of the review AGENTS.md requires. --- ## Defects **1. holes-the-centre — Building under the row's `ClientID` produces updates the browser cannot decode.** Spec §5 steps 1–2: "a row, with its app-assigned GUID and client id … open a scratch core document on that same GUID and client id." Evidence: `internal/docs/store.go:267` `clientID := uint64(mrand.Int64())` — uniform in `[0, 2^63)`. yrs encodes it as a u64 varint (`yrs-0.23.5/src/block.rs:76`, `src/updates/encoder.rs:141-143`). The browser decodes it with `lib0@0.2.117/decoding.js:245-264` `readVarUint`, which accumulates into a JS double and throws `Integer out of Range` once the partial value exceeds `Number.MAX_SAFE_INTEGER`; `yjs@13.6.27/src/utils/UpdateDecoder.js:53-55` `readClient()` goes through it, and the editor's load path is exactly that decoder (`fixtures/editor-entry.js:120-126`, `:388`). Yjs's own ids are `random.uint32` (`yjs/src/utils/Doc.js:23`). I reproduced it: a doc with `clientID = 2**62 + 2**55` encoded by yjs and re-applied throws "Integer out of Range". For a random 63-bit id, roughly 7/8 throw outright; most of the rest decode to a *rounded* id (precision loss), so a later browser delete-set referencing the importer's blocks would name the wrong client. Only ids `&lt; 2^53` (probability ~2^-10) work. Why nothing has caught this: the server has never created a block, so its id has never been on the wire; every wire fixture uses `clientID = 1/2/3` (`fixtures/generate.mjs:17`, `generate-fragment.mjs:13`, `generate-seed.mjs:122`). `generate-seed.mjs:117-121` even records the wrong belief that real edits are "attributed to" the row's ClientID — they are attributed to the browser's `new Y.Doc()` id. Correction: the builder takes a client id drawn fresh per import in Go, in `[0, 2^32)` (mirror Yjs), and the spec says the row's `ClientID` is never used to author blocks. Separately flag `Store.Create`'s 63-bit range as a latent trap for any future server-side write. The §12 cross-implementation gate must be built with the same id-generation code the handler uses, not a literal `1`. **2. material — F4 is wrong about what the editor writes; import "writes the string" would make imported headings the odd ones out.** Spec F4: "every document the editor has ever written stores `level: "2"`, because that is what y-prosemirror produces." Evidence: y-prosemirror passes the ProseMirror attr straight through (`y-prosemirror@1.3.7/src/plugins/sync-plugin.js:874-881` `type.setAttribute(key, val)`), and this app's toolbar supplies a number (`fixtures/toolbar.mjs:167` `Number(value.split('-')[1])`). Probe: a heading built that way round-trips through yjs with `typeof level === 'number'`. What *is* a string is the repo's own fixture: `fixtures/generate-fragment.mjs:50` `el('heading', { level: '2' }, …)`, so `nested.bin` decodes to `"2"`. The Rust reader hides the difference — `xml.rs:1026-1030` `get_attribute` returns `value.to_string()` and `any.rs:224` prints `2.0` as `2` — which is presumably how the spike concluded "string". The `node.check()` remark is therefore inverted: today's editor-written headings pass; imported string-level headings would be the ones `validate: "number"` rejects. Correction: the builder writes `level` as a numeric `Any`; F4 and its footnote are rewritten; fix `generate-fragment.mjs:50` to a number so the fixture represents the editor. **3. minor — F3's mechanism is misdescribed (the prescription is harmless but the reasoning is wrong).** Spec F3: "each run is inserted with the full mark set and `Any::Null` for every mark that should not apply." Evidence: `yrs-0.23.5/src/types/text.rs:707` `pos.unset_missing(&amp;mut attributes)` → `block.rs:952-962` inserts `Any::Null` for every currently-active attribute the caller omitted — the same step as Yjs's `insertText` (`YText.js:255-257`). The inheritance F3 observed comes from plain `insert` (`text.rs:212-227`, no attribute handling) or `XmlTextPrelim::new`. Correction: "unmarked runs are inserted with `insert_with_attributes(…, Attrs::new())`, never `insert`; yrs unsets the rest." Explicit Nulls stay legal, but the spec should not teach a false model of Y.Text. **4. material — The round-trip gate does not hold as stated, and does not prove editor-openability.** Spec §3/§12: "for any tree the importers can produce, `fragment_json(build(tree))` must equal `tree`." Asymmetries in `core/src/fragment.rs`: (a) adjacent runs with identical marks are merged by `diff` (`text.rs` `pack_str`, ~:685) — a DOCX importer will emit adjacent identical `w:r` runs constantly; (b) empty text runs are dropped (`fragment.rs:302-304`); (c) marks are re-ordered into `MARKS` order and deduplicated (`:277-291`); (d) `attrs` is emitted only for `heading` (`:343-355`) and `content:[]` always; (e) the output is a hand-written string — a Go `encoding/json` encoding of the same tree differs (escapes `&lt;&gt;&amp;`, key order), so "equal" must mean structural. And the property is silent on *structure*: `fragment_json` happily emits `text` directly under `doc`, a `list_item` outside a list, or a heading inside a paragraph, and the builder as specified would build them — the F2 gate is per-fixture, so an importer bug producing a schema-invalid tree passes the round-trip and breaks the editor (F2's exact fear). Correction: define a canonical tree (adjacent equal-mark runs merged, no empty text, marks in `MARKS` order, no duplicates, `attrs` only on `heading`), require importers to emit it, compare via `serde_json::Value`, and have `build_fragment` enforce the content model: `doc → block+`, `paragraph|heading → text*`, `bullet_list|ordered_list → list_item+`, `list_item → paragraph block*` (`fixtures/editor-entry.js:38`), `blockquote → block+`; anything else is a refusal. **5. material — The Go side of the interchange is unspecified and, if `export.Node` is reused, wrong.** Spec §2/§6: `Import(r, size) (Tree, Report, error)` — `Tree` undefined. `internal/export/document.go:25-36` `Node.Attrs` is a value struct, so encoding it emits `"attrs":{"level":0}` on every paragraph, plus `"text":""`, `"marks":null`, `"content":null` — none of which `fragment_json` produces and all of which a parser that "refuses everything else" (§5) would reject. Correction: define the Go tree type and its exact JSON (or state the builder is fed canonical JSON per defect 4), with `omitempty`/pointers pinned by a golden test against `fragment_json`. **6. material — Mark and attribute *values* on the wire are unpinned.** §3 lists mark names only. y-prosemirror writes each mark as `{ name: mark.attrs }` = `{}` for attribute-free marks (`sync-plugin.js:856-866`, `attributesToMarks` at `:837`); the fixtures agree (`generate-fragment.mjs:43,70` `{ strong: {} }`). A builder writing `Any::Bool(true)` renders fine but differs from what the browser writes, so y-prosemirror's attribute comparison rewrites the run on first edit. Correction: pin `Any::Map(empty)` for marks and numeric for `level` (defect 2). **7. material — Size limits are disconnected from the limits that actually bind.** Spec §10: "25 MB per file … capped in node count and depth." Evidence: the imported update is served to every browser load by `updatesGet`, which 500s above `maxLogBytes = 8 &lt;&lt; 20` (`internal/docs/handlers.go:393, :509-513`) — an import bigger than that yields a document that renders server-side and can never be edited. Every guest call is capped at 1 s with no override wired in this app (`internal/core/core.go:100`; no `WithGuestCallTimeout` caller under `internal/docs` or `cmd`), and a timeout is treated as a trap — so `docs_core_build`, the snapshot encode, and `ApplyUpdate`'s re-apply each have 1 s under wazero. Memory: main spec §5.2 sizes the box at 512 MB; one 25 MB file exists simultaneously as the body, the parsed XML, the JSON string, its copy in wasm linear memory, the yrs doc, the snapshot, `Handle.applied`'s copy (`core.go:561-563`) and the row. Correction: cap the *update* (e.g. ≤ 2 MB, leaving headroom under 8 MB), derive file caps from that, put a semaphore on concurrent imports, stream parts via `r.MultipartReader()` with a per-part `LimitReader` and a total-body `MaxBytesReader` (25 MB "per file" via `MaxBytesReader` is a total cap, not per-file), never `ParseMultipartForm` (spills plaintext to temp files). Also enforce zip sizes on the actual read, not only the declared `UncompressedSize64`. **8. material — The report has no storage, lifetime or content rules.** §8 "shown after import, on the document, and dismissible"; §9 "the page inserts the new rows". A 303 to `/d/{sqid}` (`handlers.go:255` precedent) carries nothing, so the report must live somewhere between POST and GET: a table keyed by doc (with a `POST` dismiss route), a flash cookie (4 KB — too small for a real DOCX report), or a query string. Unspecified, along with what the fetch path shows and whether catalog rules apply. Content rule: `Where`/`Detail` must not quote document text or link targets (main spec §2.1 items 4–5 — titles are plaintext by ruling; reports are not), and `Kind`/`Detail` as free strings violate "every user-visible string is a catalog key" (AGENTS.md). Correction: a `doc_import_reports` row (structured: kind enum → catalog key, locator, bounded non-content detail), rendered on `show.html`, dismissed by POST; the JSON response returns the same structure. **9. material — Step order leaves an empty document behind on failure, contradicting §7.** §5 creates the row first; §7: a text-free PDF "creates no document." `build`, `Snapshot` and `ApplyUpdate` (`store.go:709-855`) can all fail after `Store.Create` (`:262-295`); `seed.go:85-99` has the same shape but tolerates it. Correction: parse → build on the scratch doc → snapshot → verify → *then* `Create` and `ApplyUpdate`, deleting the row if `ApplyUpdate` fails. (Only possible once the build id is decoupled from the row — defect 1.) **10. material — The ABI bump discards every existing snapshot; the spec does not say so.** §5 "ABI 4 → 5". `internal/docs/snapshots.go:161-166` filters `abi_version = coreABIVersion`; after the bump every document replays its full log until its next write, and one past 4096 rows re-seeds on every read with a Warn per read (`store.go:470-471`). The state encoding does not change. Correction: state and accept the cost, or split a `snapshotEncodingVersion` from the ABI number. **11. material — PDF's in-process posture is not the containment the spec claims.** §7 "Extraction is sandboxed the same as everything else (§9)" — §9 is the HTTP section; §10 is meant, and §10's containment (`internal/core` traps) covers only wasm. A Go PDF library runs native: `recover()` catches panics, not stack exhaustion or OOM from a recursive object graph, and main spec §5.2 called the wasm sandbox "load-bearing for security" for exactly this class of input. Correction: decide the posture explicitly (accept native with depth/size pre-checks and a fuzz corpus; or subprocess; or a wasm-compiled extractor), and fix the cross-reference. **12. material — Markdown export→import→export cannot be identity as specified.** `internal/export/markdown.go:152-158` writes the title as a leading `# H1`; §11 takes "the first heading in the imported content" as the title without saying whether it is consumed. Kept → re-export doubles the H1; consumed → a genuine content H1 is lost. Correction: consume a leading H1 into the title *only* when it is the first block and the file supplies no metadata title; state it. **13. minor — "The vocabulary is what the editor's schema already speaks, no more" is inaccurate, and the gap matters for DOCX.** `fixtures/editor-entry.js:37-39` builds from `prosemirror-schema-basic` + `addListNodes`, so the editor also speaks `hard_break`, `code_block`, `horizontal_rule`, `image`, `link`. The importer's set is a strict subset of the editor's, not equal. `&lt;w:br/&gt;` (a line break inside a paragraph — ubiquitous in Word and Google Docs) has no target; specify: split the paragraph, or collapse to a space, and warn. **14. minor — Ambiguities that can be built two ways.** (a) How the server tells the fetch path from the form (Accept header? a field?) and what a no-JS post of *multiple* files (`&lt;input multiple&gt;`) redirects to, and mixed results in no-JS. (b) `docs_core_build` on a non-empty root: refuse or append. (c) `.txt` encoding: UTF-8 only? BOM? Windows-1252? Invalid bytes replaced or refused (Go's importers can and should validate UTF-8 before it reaches the hand-written Rust parser). (d) DOCX `Title` style → title metadata, H1, or dropped; whether DOCX fonts/sizes ever map to `font_*`/`size_*` (§3 says the marks are in scope; §6 mentions only `code`). (e) Which fonts count as "monospace by name". (f) `word/document.xml` located by fixed path vs `_rels/.rels` officeDocument relationship — the §12 malformed-input gate assumes the fixed path. **15. minor — Cross-references and small factual slips.** §1 "§2 of the main spec rules that out for conversion APIs" — it is main §11.2 (`2026-08-30-docs-design.md:919-921`) that rejects them, citing §2. §7 "(§9)" should be §10. §14 step 2 says "four-step sequence in §5"; §5 lists five. **16. minor — UI constraints not acknowledged.** `list.html:150-158` documents the header already at its 320 px reflow limit with five controls; a file input plus button is two more. New strings (labels, the scan-PDF error, warning kinds) are catalog keys and go through copy review; the drop-zone script is a new module under main §12's 300-line cap, served the way `jumpToScriptPath()` is. **C. Scope.** Not one plan. Steps 1–4 (core, `Store.Import`, text+Markdown, HTTP+form+drop+report) are a coherent, shippable plan. DOCX (step 5: `numbering.xml`, style resolution, three real-world fixture families) is a second. PDF (step 6: new third-party dependency, licence, undecided containment posture) is a third and is correctly last. The §14 order is right; the split is what is missing. --- **Checked and holds** (so the caller knows what was verified rather than assumed): `snapshot_bytes` is `encode_state_as_update_v1(&amp;StateVector::default())` (`core/src/snapshot.rs:169-172`) — whole state including pending queue, no state vector prefix, right call for this use; ABI is 4 on both sides (`core/src/lib.rs:101`, `internal/core/core.go:79`); the node/mark vocabulary matches `MARKS` (`fragment.rs:62-71`) and `node_html`/`node_json` exactly; `Store.Text` has no non-test caller (only `store.go:568` inside itself); no seed carries a mark (`generate-seed.mjs:93-104`, plain `insert`); `fragment_text`'s markup leak is real (`yrs xml.rs:548-567` writes `&lt;key&gt;` tags); `Store.Create`/`ApplyUpdate` have the assumed shape and the snapshot cadence (`store.go:744`) has no bad interaction with a whole-state first update; CSRF is fine — `csrf.Protect` passes `Sec-Fetch-Site: same-origin` first (`rastrillo@v0.22.0/csrf/csrf.go:26-27`), which a same-origin `fetch` multipart POST sends; the "same GUID" is harmless (the GUID is not in the update); Go's `encoding/xml` claim is right.</result> <usage><subagent_tokens>241901</subagent_tokens><tool_uses>50</tool_uses><duration_ms>820656</duration_ms></usage> </task-notification>

diff --git a/docs/superpowers/specs/2026-09-01-import-design.md b/docs/superpowers/specs/2026-09-01-import-design.md
index 0ec518b..7ab3180 100644
--- a/docs/superpowers/specs/2026-09-01-import-design.md
+++ b/docs/superpowers/specs/2026-09-01-import-design.md
@@ -8,10 +8,17 @@ This is the other half of §11 of the [main design](2026-08-30-docs-design.md),
which has always said Docs is an editor "with DOCX, PDF and Markdown **in and
out**". Out shipped (`internal/export`, `internal/pdf`). In is this document.
-Four load-bearing claims here were established by throwaway spikes before
-anything was written down, not reasoned from the library docs. They are in
-§4, and the two that surprised me are the reason this spec exists in its
-current shape rather than a worse one.
+**Revision 2, after a Fable review of revision 1 returned "not ready" with
+sixteen defects.** One of them holed the centre: revision 1 had the importer
+author its content under the document row's own `ClientID`, and **861 of
+every 1000 documents built that way cannot be opened in the browser at all**
+(§4, D1). Two of revision 1's four "spike-proven" findings were also wrong —
+they were true of this repo's *fixtures* and not of the editor those fixtures
+claim to represent, which is a more interesting mistake than being merely
+wrong and is written up as such in §4.
+
+Sections that changed say what revision 1 claimed and why it was wrong. A
+spec that quietly fixes its own errors teaches the next reader nothing.
---
@@ -20,161 +27,246 @@ current shape rather than a worse one.
**A person drags one or more files onto their document list. Each becomes a
new document, theirs, immediately editable, at its own URL.**
-The formats, in the order they earn their place:
-
| Format | Route in | Fidelity |
|---|---|---|
| `.docx` | Word, Google Docs "Download → Word", LibreOffice, Pages | Structural: headings, lists, blockquotes, bold/italic/code |
| `.md` | Anything | Structural, within Markdown's own subset |
| `.txt` | Anything | Paragraphs |
-| `.pdf` | Anything | **Text only, best effort.** §7 |
+| `.pdf` | Anything | **Text only, best effort.** §8 |
**There is no Google Drive integration in this slice, and that is a
decision rather than an omission.** "Google Docs import" reaches us as the
`.docx` (or `.odt`) a person downloads, because the alternative — OAuth
-against Drive, then pulling document bodies through Google's API — ships
-customer documents through a third party and holds a token that can read
-every file in someone's Drive. §2 of the main spec rules that out for
-conversion APIs already, and the same reasoning applies to the source.
-A Drive picker is a real feature, and it belongs in §14 behind its own
-consent design, not smuggled in under "drag and drop".
-
-`.odt` is deliberately *not* in the table above. It is the same
-zip-plus-XML shape as `.docx` and would likely fall out of that work in an
-afternoon, but "likely" is how scope grows overnight; it is listed in §12
-as the first thing to add next.
+against Drive — ships customer documents through a third party and holds a
+token that can read every file in someone's Drive. Main spec §11.2 rejects
+conversion APIs on exactly that ground (citing §2); the same reasoning
+applies to the source. A Drive picker belongs in §14 behind its own consent
+design, not smuggled in under "drag and drop".
+
+`.odt` is deliberately absent: same zip-and-XML shape as `.docx`, and
+listed in §14 as the first thing to add next.
---
## 2. The shape: parse in Go, build the CRDT in Rust
-Import is the mirror of export, and it is worth stating why that symmetry
-is not just tidiness.
-
```
┌─────────────── export (shipped) ───────────────┐
document ──▶ core: fragment_json ──▶ ProseMirror JSON ──▶ internal/export ──▶ .md / .pdf
▲
- the interchange
+ the interchange (§3)
│
.docx / .md / .pdf ──▶ internal/importer ──▶ ProseMirror JSON ──▶ core: build ──▶ Yjs update ──▶ document
└─────────────── import (this spec) ──────────────┘
```
-**Go parses the files.** `archive/zip` and `encoding/xml` are in the
-standard library and a `.docx` is exactly those two things. Go is also
-where the HTTP surface, the size limits and the ownership scoping already
-live.
+**Go parses the files.** `archive/zip` and `encoding/xml` are in the standard
+library and a `.docx` is exactly those two things. Go is also where the HTTP
+surface, the limits and the ownership scoping already live.
**Rust builds the CRDT.** Nothing outside the core has ever encoded a Yjs
-update and nothing outside the core should start. The wire format is the
-one thing in this system with two implementations and a fixture gate
-holding them honest (§0 of the main spec); a third encoder, in Go, written
-for import, would be a fourth thing to keep in step and the least tested of
-them. `internal/docs/seed.go` already says this out loud — it has no Go-side
+update and nothing outside it should start. The wire format is the one thing
+here with two implementations and a fixture gate holding them honest (main
+spec §0); a third encoder, in Go, would be the least tested of them.
+`internal/docs/seed.go` already says this out loud — it has no Go-side
encoder either, which is why the sample documents are committed `.bin` files
generated by a Node script.
-**And that seed pipeline is the honest precedent for the whole design.**
-Seeding already needs "turn authored content into a Yjs update"; it solves
-it by generating the update offline, at build time, because there was no way
-to do it at runtime. Import is the same need with a file a person supplies
-thirty seconds ago. Building the runtime path retires the offline hack: once
-the core can build an update from a document tree, `generate-seed.mjs` and
-its six committed `.update.bin` files are redundant. Retiring them is §12,
-not this slice, but the direction matters — this is not a parallel mechanism
-beside the seed one, it is the general form of it.
+**That seed pipeline is the honest precedent.** Seeding already needs "turn
+authored content into a Yjs update" and solves it at *build* time because
+there was no runtime path. Import is the same need with a file supplied
+thirty seconds ago. Building the runtime path makes `generate-seed.mjs` and
+its six committed `.update.bin` files redundant — retiring them is §14, not
+this slice, but the direction matters: this is the general form of the seed
+mechanism, not a second one beside it.
---
-## 3. The interchange: ProseMirror JSON, now in both directions
+## 3. The interchange: canonical ProseMirror JSON
-`core/src/fragment.rs` already emits ProseMirror-shaped JSON
-(`fragment_json`, ABI 3) so `internal/export` can ask "where does this list
-end, which run is bold". That JSON becomes the import contract too, read in
-the other direction.
+`core/src/fragment.rs`'s `fragment_json` already emits ProseMirror-shaped
+JSON so `internal/export` can ask "where does this list end, which run is
+bold". That shape becomes the import contract, read in the other direction.
-One shape, both ways, which buys a gate that is otherwise hard to get:
+Revision 1 stated the gate as `fragment_json(build(tree)) == tree` "for any
+tree the importers can produce". **That does not hold**, because
+`fragment_json` normalises on the way out in at least five ways, and a DOCX
+importer trips the first of them constantly:
-> **Round-trip:** for any tree the importers can produce,
-> `fragment_json(build(tree))` must equal `tree`.
+| Normalisation | Where |
+|---|---|
+| Adjacent runs with identical marks are merged | `yrs` `text.rs` `pack_str` — and Word splits one sentence across many `w:r` runs |
+| Empty text runs are dropped | `fragment.rs:302` |
+| Marks are re-ordered into `MARKS` order and deduplicated | `fragment.rs:277` |
+| `attrs` is emitted only for `heading`; `content` always | `fragment.rs:343` |
+| Output is a hand-written string; Go's `encoding/json` escapes differently | `fragment.rs` vs `encoding/json` |
+
+So the contract is stated over a **canonical tree** instead:
+
+> **Canonical form.** Adjacent runs with identical mark sets are merged; no
+> empty text runs; marks in `MARKS` order, deduplicated; `attrs` only on
+> `heading`; `content` always present. Importers emit canonical trees.
+>
+> **Round-trip gate:** for any canonical tree, `fragment_json(build(tree))`
+> parses to a structurally equal value (`serde_json::Value`, never string
+> equality).
+
+**And the round trip is not enough on its own.** `fragment_json` will
+happily emit a `text` node directly under `doc`, a `list_item` outside a
+list, or a heading nested in a paragraph — so a buggy importer producing a
+schema-invalid tree passes the round trip and then breaks the editor, which
+is precisely the failure §4's F2 exists to prevent. **`build_fragment`
+therefore enforces the editor's content model** and refuses anything else:
-That is a real property test over a corpus, and it catches the whole class
-of "the importer emitted something the core silently dropped" — the failure
-mode that turns into a person's missing paragraph.
+```
+doc → block+
+paragraph | heading → text*
+bullet_list | ordered_list → list_item+
+list_item → paragraph block* (fixtures/editor-entry.js:38)
+blockquote → block+
+```
-The vocabulary is what the editor's schema already speaks and what
-`fragment.rs` already renders, no more:
+The vocabulary is a strict **subset** of the editor's — revision 1 said
+"what the editor's schema already speaks, no more", which was inaccurate:
- **Blocks:** `paragraph`, `heading` (`level` 1–6), `bullet_list`,
`ordered_list`, `list_item`, `blockquote`
-- **Marks:** `strong`, `em`, `code`, and the font/size span marks
- (`font_sans`, `font_mono`, `size_sm`, `size_lg`, `size_xl`)
-
-Import emits no node and no mark outside that set. Anything a source file
-carries that does not map onto it is a line in the import report (§8), not
-an invented node type — the core carries unknown nodes through on purpose,
-and inventing one here would put a node in a document that no renderer,
-exporter or editor understands.
+- **Marks:** `strong`, `em`, `code`, `font_sans`, `font_mono`, `size_sm`,
+ `size_lg`, `size_xl`
+
+The editor also speaks `hard_break`, `code_block`, `horizontal_rule`,
+`image` and `link`, which import does not emit. That gap has one consequence
+worth naming: **`<w:br/>` — a line break inside a Word or Google Docs
+paragraph — is ubiquitous and has no target.** It splits the paragraph, and
+warns.
+
+**Values on the wire are pinned, not just names.** y-prosemirror writes a
+mark as `{ name: mark.attrs }`, i.e. `{}` for the attribute-free marks
+(`sync-plugin.js:856`), and the fixtures agree. The builder writes
+`Any::Map(empty)`, not `Any::Bool(true)` — a run that differs from what the
+browser writes gets rewritten by y-prosemirror's attribute comparison on the
+first edit.
+
+### 3.1 The Go side
+
+Revision 1 left the Go tree type undefined and implied `export.Node` could
+be reused. **It cannot:** `export.Node.Attrs` is a value struct, so encoding
+one emits `"attrs":{"level":0}` on every paragraph plus `"text":""`,
+`"marks":null`, `"content":null` — none of which `fragment_json` produces,
+and all of which the builder's strict parser refuses. `internal/importer`
+defines its own tree type whose JSON is exactly `fragment_json`'s, pinned by
+a golden test against real core output.
---
-## 4. What the spikes proved
-
-Four findings, from running the code rather than reading about it. Two were
-surprises, and both would have been expensive to meet during implementation.
+## 4. What the spikes proved, and where revision 1 was wrong
-**F1 — the core can build an update, and the readers accept it.** A yrs
-`XmlFragmentRef` built with `XmlElementPrelim`/`XmlTextPrelim` and encoded
-with `encode_state_as_update_v1` applies cleanly to a fresh document, and
+**F1 — the core can build an update.** A yrs `XmlFragmentRef` built with
+`XmlElementPrelim`/`XmlTextPrelim` and encoded with
+`encode_state_as_update_v1` applies cleanly to a fresh document, and
`fragment_html`/`fragment_text`/`fragment_json` all render it as expected.
-The mechanism is real; no new dependency.
-
-**F2 — real Yjs and y-prosemirror accept it too.** The update, applied by
-upstream `yjs` 13.6.27 and converted through `yXmlFragmentToProseMirrorRootNode`
-against the *actual editor schema*, produced exactly the intended
-ProseMirror tree. This is the check that mattered: a server-built document
-that the browser editor cannot open is the failure this whole design risks,
-and it does not happen.
-
-**F3 — every text run must state every mark explicitly, including the ones
-that are off.** Text inserted at the right edge of a bold run silently
-inherits the bold: building `"plain "` + bold `"bold"` + `" and"` yields
-`<strong>bold and </strong>`. Y.Text formatting is boundary-inheriting, so
-each run is inserted with the full mark set and `Any::Null` for every mark
-that should not apply. Found by an assertion failing, not by reasoning —
-and an importer that got this wrong would produce documents where a single
-bold word bleeds across the rest of the paragraph.
-
-**F4 — `heading.level` is a *string* on this app's wire, and import must
-match.** Writing it as a number is the obvious "correct" choice and is
-wrong: every document the editor has ever written stores `level: "2"`,
-because that is what y-prosemirror produces, and `fragment.rs` reads it back
-with `.parse::<u8>()`. Import writes the string.
-
- Noted while establishing this, not a blocker and not this slice's to fix:
- `prosemirror-model` 1.25 validates `level` as a number, so
- `node.check()` rejects every heading in the app today. Nothing calls
- `check()`, so nothing is broken — but it is a live tripwire for any future
- code that validates a document, and it is written down here because it
- cost an hour to understand and would cost it again.
-
-**And one pre-existing bug found on the way, reported not fixed:**
-`fragment_text` renders marks as literal markup — a bold word comes back as
-`plain and <strong>bold</strong>`, because yrs's `XmlTextRef::get_string`
-serialises formatting as tags. No seed document carries a mark and
-`Store.Text` currently has no non-test caller, so it is latent today. It
-stops being latent the moment import exists, because imported documents are
-full of marks. **Fixing it is a task in this plan** (§10), since import is
-what makes it reachable, but it is a bug in the renderer rather than a bug
-in import.
+No new dependency.
+
+**F2 — real Yjs and y-prosemirror accept it.** Applied by upstream `yjs`
+13.6.27 and converted through `yXmlFragmentToProseMirrorRootNode` against
+the *actual editor schema*, the update produced exactly the intended
+ProseMirror tree. This is the check that matters: a server-built document
+the browser cannot open is what this design risks.
+
+**F3 — unmarked runs must not be inserted with plain `insert`.** Revision 1
+observed correctly that text inserted after a bold run inherits the bold,
+and then explained it wrongly ("each run needs `Any::Null` for every mark
+that should not apply"). The real mechanism: `insert_with_attributes`
+already unsets omitted marks (`text.rs:707` `unset_missing` →
+`block.rs:952`, the same step as Yjs's own `insertText`). The inheritance
+comes from plain `insert`, which does no attribute handling at all. **The
+rule is therefore: every run goes through `insert_with_attributes`, with
+`Attrs::new()` for an unmarked run; never `insert`, never
+`XmlTextPrelim::new` with content.** Explicit nulls are legal but teach a
+false model of Y.Text.
+
+**F4 — `heading.level` is a *number*, and revision 1 had this backwards.**
+Revision 1 claimed "every document the editor has ever written stores
+`level: "2"`" and told the builder to write a string. What the spike
+actually sampled was the *seed fixtures*, not editor output. The editor's
+toolbar writes a number (`fixtures/toolbar.mjs:167` `Number(...)`), and
+y-prosemirror passes the attribute straight through
+(`sync-plugin.js:874`). The string lives only in this repo's own generator
+(`fixtures/generate-fragment.mjs:50` `el('heading', { level: '2' }, …)`),
+and yrs's reader hides the difference — `get_attribute` stringifies, so
+`2.0` prints as `2` and a spike reading through Rust cannot tell the two
+apart.
+
+ **The builder writes `level` as a numeric `Any`.** And
+ `generate-fragment.mjs:50` is corrected to a number, so the fixture
+ represents the editor it claims to represent. The `node.check()` remark in
+ revision 1 was inverted: today's editor-written headings pass validation;
+ string-level ones would be the failures.
+
+ The general lesson, since this repo has now been bitten by it twice
+ (AGENTS.md's "tools that answer wrongly instead of failing"): **a fixture
+ is evidence about the fixture generator, not about the editor.** Sampling
+ committed `.bin` files told me what a Node script wrote in August.
+
+**D1 — the defect that holed revision 1: the client id.** Revision 1 had the
+builder author content under the document row's `ClientID`. `Store.Create`
+draws that from `mrand.Int64()` — uniform in `[0, 2^63)`
+(`internal/docs/store.go:267`). The browser decodes client ids through
+lib0's `readVarUint`, which accumulates into a JS double and throws once the
+value passes `Number.MAX_SAFE_INTEGER`. Measured, 1000 trials at the exact
+shape `Store.Create` produces:
+
+```
+threw = 861 silently rounded = 0 exact = 139
+2^62 + 2^55 -> Integer out of Range
+3735928559 (uint32) -> round-trips exactly
+```
+
+So **861 of every 1000 imported documents would have been unopenable**, and
+the survivors are those that happened to land under 2^53. Nothing caught
+this because the server has never authored a block — its client id has never
+been on the wire — and every wire fixture uses `clientID` 1, 2 or 3.
+
+ **The builder takes a client id drawn fresh per import, in `[0, 2^32)`,
+ mirroring what Yjs itself uses (`Doc.js:23` `random.uint32`). The row's
+ `ClientID` is never used to author blocks.** The §12 cross-implementation
+ gate must exercise the real id-generation code, not a literal `1`, or it
+ reproduces exactly the blind spot that hid this.
+
+ Two things to record rather than fix here: `Store.Create`'s 63-bit
+ `ClientID` is a latent trap for *any* future server-side authoring, not
+ just this one; and `generate-seed.mjs:117` states the wrong belief that
+ real edits are attributed to the row's `ClientID` — they are attributed to
+ the browser's own `new Y.Doc()` id.
+
+**Reported, not fixed: `fragment_text` renders marks as literal markup.**
+A bold word comes back as `plain <strong>bold</strong> plain`, because yrs's
+`XmlTextRef::get_string` serialises formatting as tags. I fixed this, with a
+passing regression test, and then backed the fix out: `generate-fragment.mjs:99`
+records a previous session **deliberately** choosing that behaviour, having
+verified that yrs's `get_string` and `Y.XmlText.toString()` agree on it —
+"XmlText's own plain-text convention in both implementations".
+
+ It is still wrong for its consumers. `fragment_text`'s own doc comment says
+ it is "the words without the markup", it feeds the list preview, and the
+ two fixture families already disagree with each other
+ (`fixtures/data/formatted.expected.json` says `plain bold plain`;
+ `fixtures/vectors/fragment/formatted.expected.json` says
+ `plain <strong>bold</strong> plain`). An imported `.docx` is full of marks,
+ so this stops being latent the moment this feature ships.
+
+ **This is Paul's call, not mine**, because reversing it means regenerating
+ wire fixtures and overriding a documented ruling. The change is one line in
+ `node_text` (walk `diff` like `text_html` does) plus regenerating four
+ `.expected.json` files and correcting the now-stale comment at
+ `generate-fragment.mjs:165`, which still describes the old `toDelta`
+ behaviour.
---
## 5. The new core surface
-Deliberately one new function, because the machinery to get bytes back out
-already exists.
+One new function; the machinery to get bytes back out already exists.
```rust
// core/src/build.rs
@@ -182,45 +274,57 @@ pub fn build_fragment(doc: &Doc, tree_json: &str) -> Result<(), BuildError>
```
```c
-// core/src/abi.rs — ABI 4 → 5
+// core/src/abi.rs
docs_core_build(handle: u32, json_ptr: u32, json_len: u32) -> i32
```
-It populates the document behind `handle` from the tree. The host then reads
-the result through the **existing** `docs_core_snapshot_len` /
-`docs_core_snapshot_read` pair, which already means "this document's whole
-state as one update". So the sequence, host-side, is:
-
-1. `Store.Create` — a row, with its app-assigned GUID and client id
-2. open a scratch core document on that same GUID and client id
-3. `docs_core_build` with the tree
-4. `Snapshot` — the update bytes
-5. `Store.ApplyUpdate` — the ordinary persistence path, byte-identical in
- shape to what seeding and a person's own typing already do
-
-Step 5 is the point of steps 2–4. Import writes nothing to the database that
-an edit does not; there is no second write path to keep correct, and an
-imported document is indistinguishable from a typed one the moment it lands.
-
-**The JSON parser is hand-written**, matching the choice `fragment_json`
-already made and documents ("the crate has no serialisation dependency
-outside dev-dependencies"). It parses one closed shape and **refuses
-everything else** — unknown node type, unknown mark, missing field, depth
-past a fixed limit, a `level` outside 1–6. This inverts the reader's
-carry-unknowns-through rule on purpose: a renderer meeting an unknown node
-should preserve the text, but a *builder* meeting one has been handed
-something its only caller could not have meant, and continuing would write
-the mistake into a document permanently.
-
-`build_fragment` is also the natural fuzz target, and gets one.
+It refuses a non-empty root (it builds documents, it does not append to
+them). The host reads the result through the **existing**
+`docs_core_snapshot_len` / `docs_core_snapshot_read` pair, which is
+`encode_state_as_update_v1(&StateVector::default())`
+(`core/src/snapshot.rs:169`) — whole state, no state-vector prefix, exactly
+right for this.
+
+**The order matters, and revision 1 had it wrong.** It created the database
+row first, which leaves an empty document behind whenever the build fails —
+directly contradicting §8's rule that a text-free PDF creates no document.
+Corrected:
+
+1. parse the file → a canonical tree (§3)
+2. open a scratch core document: fresh GUID, **fresh client id in `[0, 2^32)`** (§4 D1)
+3. `docs_core_build`
+4. `Snapshot` → the update bytes; check them against the update budget (§10)
+5. **only now** `Store.Create` — the row
+6. `Store.ApplyUpdate` — the ordinary persistence path, deleting the row if it fails
+
+Step 6 is the point of the rest. Import writes nothing the editor does not;
+there is no second write path, and an imported document is indistinguishable
+from a typed one the moment it lands.
+
+**The JSON parser is hand-written**, matching what `fragment_json` already
+does and documents ("the crate has no serialisation dependency outside
+dev-dependencies"). It parses one closed shape and refuses everything else —
+unknown node, unknown mark, missing field, depth past a fixed limit, `level`
+outside 1–6, and any content-model violation (§3). This inverts the
+*reader's* carry-unknowns-through rule on purpose: a renderer meeting an
+unknown node should preserve the text, but a builder meeting one has been
+handed something its only caller could not have meant. It is also the
+natural fuzz target, and gets one.
+
+**The ABI bump has a cost revision 1 did not mention.**
+`internal/docs/snapshots.go:161` filters stored snapshots by
+`abi_version = coreABIVersion`, so bumping 4 → 5 discards every existing
+snapshot; each document then replays its whole log until its next write, and
+one past 4096 rows re-seeds on every read, logging a warning each time. The
+state *encoding* does not change, so this is pure waste. **Split a
+`snapshotEncodingVersion` from the ABI number** and bump only the ABI.
---
## 6. The importers
-`internal/importer`, one file per format behind a small interface. Each
-returns a tree and a report; neither the HTTP layer nor the core knows
-which format it came from.
+`internal/importer`, one file per format behind a small interface. Neither
+the HTTP layer nor the core knows which format a tree came from.
```go
type Importer interface {
@@ -228,199 +332,270 @@ type Importer interface {
}
```
-**DOCX** (`docx.go`) — `archive/zip` for the container, `encoding/xml` for
-`word/document.xml`. Paragraphs from `w:p`, style from `w:pStyle`
-(`Heading1`–`Heading6`, `Title`, `Quote`), runs from `w:r`/`w:t` with
-`w:b`/`w:i`, monospace runs mapped to `code` by font name, lists from
-`w:numPr` with `w:ilvl` for depth and `word/numbering.xml` for
-bullet-vs-ordered. This is the format that answers both "Microsoft Word" and
-"Google Docs" in the ask, and it gets the most care.
+**DOCX** (`docx.go`). Two things real-world files taught me that a
+tutorial would not:
-**Markdown** (`markdown.go`) — a small parser for the subset
-`internal/export/markdown.go` already writes, which makes export→import a
-round trip we can test rather than assert.
+- **The body part is found through the OPC relationships, never at a fixed
+ path.** A real `.docx` on this machine stores its body at
+ `word/document2.xml`. Resolve `_rels/.rels` → the `officeDocument`
+ relationship → its target.
+- **Heading beats list.** A paragraph can carry `pStyle=Heading1` *and*
+ `numPr` — a numbered heading, which Word produces constantly. "Has `numPr`
+ ⇒ list item" would turn every numbered heading into a bullet.
-**Plain text** (`text.go`) — blank-line-separated paragraphs. Twelve lines,
-and it is the fallback for anything textual we cannot place.
+Otherwise: paragraphs from `w:p`, style from `w:pStyle`
+(`Heading1`–`Heading6`, `Title`, `Quote`, `ListParagraph`), runs from
+`w:r`/`w:t` with `w:b`/`w:i`, list kind resolved through `numPr`'s `numId`
+into `word/numbering.xml`, `w:ilvl` for depth. `Title` maps to the document
+title (§11), not to an H1. Monospace runs map to `code` by font name against
+a documented list. DOCX fonts and sizes do **not** map to `font_*`/`size_*`
+in this slice — those marks exist for the toolbar, and inferring them from
+Word's own font table is a fidelity project of its own.
-**PDF** (`pdf.go`) — §7.
+**Markdown** (`markdown.go`) — the subset `internal/export/markdown.go`
+writes, which makes export→import a testable round trip.
+
+**Plain text** (`text.go`) — blank-line-separated paragraphs. **UTF-8 is
+validated in Go**, with a BOM stripped and invalid bytes a refusal, before
+anything reaches the hand-written Rust parser.
+
+**PDF** (`pdf.go`) — §8.
---
-## 7. PDF, honestly
+## 7. Fixtures
-A PDF is a description of marks on a page. It does not contain paragraphs,
-headings, lists, or reading order — those were discarded when it was made.
-Anything that claims to import a PDF "as a document" is reconstructing,
-and reconstruction is sometimes wrong.
+Real files, committed, from all three producers — they differ, and a
+fixture from one tests one. Word-produced `.docx` files with headings,
+numbered headings, lists, images and footnotes already exist on the
+development machine and are the starting corpus. LibreOffice can generate
+the rest.
-So: **PDF import extracts text and produces paragraphs, and the import
-report says exactly that, every time, on every PDF.** No heading detection
-by font size, no list detection by bullet glyph, in this slice. Those
-heuristics are individually plausible and collectively produce a document
-that is subtly wrong in ways a person has to hunt for, which is worse than
+---
+
+## 8. PDF, honestly
+
+A PDF describes marks on a page. It does not contain paragraphs, headings,
+lists, or reading order — those were discarded when it was made. Anything
+claiming to import a PDF "as a document" is reconstructing, and
+reconstruction is sometimes wrong.
+
+**PDF import extracts text and produces paragraphs, and the report says so
+on every PDF.** No heading-by-font-size, no list-by-bullet-glyph in this
+slice: individually plausible heuristics that collectively produce a
+document subtly wrong in ways a person must hunt for, which is worse than
one that is plainly plain.
-Two behaviours that are not negotiable:
-
-- **A PDF with no text layer** — a scan, a photo — imports *nothing*. It
- fails with "this PDF has no text in it; it looks like a scan", and creates
- no document. Silently creating an empty document is the worst available
- outcome: it looks like the feature worked.
-- **Extraction is sandboxed the same as everything else** (§9). PDF parsers
- are a classic source of panics on malformed input, and this one is reading
- files from the internet.
-
-Text extraction needs machinery Docs does not have: `internal/pdf` *writes*
-PDFs, and the reader in `internal/export/pdfread_test.go` is a test fixture
-that walks files we generated ourselves. Reading arbitrary PDFs means
-xref tables, object streams, font encodings and ToUnicode CMaps.
-**Recommendation: take a dependency** rather than write that. Pandoc and
-conversion APIs stay rejected (§11.2 of the main spec — a second runtime,
-and third-party data egress); a pure-Go, in-process library is neither.
-The choice of library, with its licence and its failure behaviour on
-malformed input, is a task in the plan rather than a decision made here on
-no evidence.
+Two non-negotiables:
+
+- **A PDF with no text layer imports nothing.** It fails with "this PDF has
+ no text in it; it looks like a scan", and creates no document. Silently
+ creating an empty one is the worst available outcome: it looks like the
+ feature worked.
+- **The containment posture must be decided explicitly.** Revision 1 said
+ extraction is "sandboxed the same as everything else", which is false: the
+ core's containment covers wasm, and a Go PDF library runs native, where
+ `recover()` catches panics but not stack exhaustion or OOM from a
+ recursive object graph. Main spec §5.2 calls the wasm sandbox
+ "load-bearing for security" for exactly this class of input. The options
+ are native-with-pre-checks-and-a-fuzz-corpus, a subprocess, or a
+ wasm-compiled extractor. **That decision belongs to PDF's own plan (§13),
+ made with evidence.**
+
+**What the evidence already says.** `CGO_ENABLED=0` is a hard constraint
+(main spec §12, `.amadan/ci.d/10-gate:20`), so MuPDF bindings are out. The
+pure-Go candidate's convenience API is unusable — on a real spec PDF,
+`GetPlainText` returns `Thisisversion0.21ofthe…`, every space gone, and
+`GetTextByRow` reports one row with no font data. Its `Content()` primitive
+is a different story: per-glyph `x`, `y`, `w`, font name and size, correct
+and complete (1182 items on page 1, three fonts, five sizes). So
+reconstruction is *possible* — words from x-gaps, lines from y, paragraphs
+from leading and indentation, bold and italic from font names — but **we
+would be writing it**, not calling it. That is why PDF is its own plan and
+last.
---
-## 8. The report — degrading loudly
+## 9. The report — degrading loudly
-§11.2 of the main spec requires it: "shows a report naming exactly what it
-could not bring in, and where".
+Main spec §11.2 requires it: "a report naming exactly what it could not
+bring in, and where". Revision 1 left its storage, lifetime and content
+rules undefined, which was its largest under-specification.
-```go
-type Report struct {
- Format string // "docx"
- Warnings []Warning // Kind, Where ("page 3", "paragraph 41"), Detail
-}
-```
+**Storage: a `doc_import_reports` row, keyed by document**, written in the
+same transaction as the import and rendered on `show.html`. A `303` to
+`/d/{sqid}` carries nothing, and a flash cookie's 4 KB cannot hold a real
+DOCX report. Dismissal is a `POST` that deletes the row; the `fetch` path
+receives the same structure as JSON.
+
+**Content rules, which are not optional:**
-What produces a warning: a table (dropped, its cell text kept as
-paragraphs), an image, a footnote, a comment, a tracked change, a link
-(its text is kept, the target is not), a font with no mapping, a heading
-level past 6, and — always, once — a PDF.
+- **A warning never quotes document text or a link target.** Main spec §2.1
+ makes titles plaintext by an explicit ruling; reports are not covered by
+ it, and a warning reading `dropped table containing "Q3 severance…"` would
+ put document content in a place the ruling never sanctioned.
+- **`Kind` is an enum resolved to a catalog key**, never a free string —
+ AGENTS.md: every user-visible string is a catalog key, twelve base
+ locales, "a hardcoded English string is a bug". `Where` is a structured
+ locator (block index, page number), formatted at render time.
-The report is shown after import, on the document, and dismissible. A
-document that came in clean says so briefly rather than showing an empty
-report. What it must never do is stay silent about a table it threw away.
+What warns: a table (dropped, cell text kept as paragraphs), an image, a
+footnote, a comment, a tracked change, a link (text kept, target not), an
+unmapped font, a heading past level 6, a `<w:br/>` split, and — always,
+once — a PDF. A clean import says so briefly rather than rendering an empty
+report.
---
-## 9. The HTTP surface, and the drop zone
+## 10. The HTTP surface, and the drop zone
**`POST /documents/import`**, inside the existing `RequireSession` group,
-next to `POST /documents`. Multipart, one or more files.
-
-**It is a real form first, and drag-and-drop second.** `list.html` gets a
-file input in the page header beside the create form; it works with no
-JavaScript, and posts to the same endpoint. The drop zone is progressive
-enhancement over it: `dragover` on the list reveals a target, `drop` posts
-the same multipart body through `fetch`. This is the same shape §6.2 of the
-main spec already requires of reading a document ("works with no script")
-and the same shape the create form already has.
-
-Responses: a no-JS post of a single file redirects `303` to the new
-document, exactly as `POST /documents` does. The `fetch` path gets JSON —
-per-file status, so three files where the middle one fails report as such
-rather than as one failure — and the page inserts the new rows.
-
-**Ownership and scoping are not novel here.** The owner comes from
-`h.owner(r)` like every other handler; `Store.Create` takes it; nothing in
-the multipart body names an owner or a document id. This is called out only
-because "a new endpoint that creates documents" is exactly where a
-mass-assignment mistake would live.
+beside `POST /documents`.
+
+**A real form first, drag-and-drop second.** `list.html` gets a file input
+in the page header; it works with no JavaScript and posts to the same
+endpoint. The drop zone is progressive enhancement: `dragover` reveals a
+target, `drop` posts the same multipart body through `fetch`. Same shape
+main spec §6.2 requires of reading a document, and the same shape the create
+form already has.
+
+**The two paths are distinguished by an explicit form field**, not by
+sniffing `Accept`. No-JS with several files selected redirects to the list
+(not to any one document) with the report rows waiting on each; a single
+file redirects to that document, matching `POST /documents`
+(`handlers.go:255`). Mixed success and failure in the no-JS path lands on
+the list, where each result is visible.
+
+**Ownership is not novel here** — the owner comes from `h.owner(r)` like
+every other handler, `Store.Create` takes it, and nothing in the multipart
+body names an owner or a document id. Called out only because "a new
+endpoint that creates documents" is exactly where a mass-assignment mistake
+would live.
+
+**CSRF is already handled** and was verified rather than assumed: rastrillo
+v0.22.0's `csrf.Protect` checks `Sec-Fetch-Site` first
+(`csrf/csrf.go:26`), which a same-origin `fetch` multipart POST sends.
+
+**UI constraints that are real.** `list.html:150` documents the header as
+already at its 320 px reflow limit with five controls; a file input and
+button are two more, and that is a layout problem to solve rather than
+discover. Every new string is a catalog key and goes through copy review
+before it is written into a template. The drop-zone script is a new module
+under main spec §12's 300-line cap, served the way `jumpToScriptPath()` is.
---
-## 10. Untrusted files
-
-Everything in this feature parses a file a person can put anything into, on
-a public instance. The limits are part of the design, not hardening to add
-later:
-
-- **25 MB per file**, enforced by `http.MaxBytesReader` before the body is
- read into memory, exactly as `maxUpdateBytes` already does for updates
-- **Zip:** a cap on entry count, a cap on total *uncompressed* bytes, and a
- refusal of any entry whose declared size exceeds it — a `.docx` is a zip,
- and a zip bomb is a 40 KB file that becomes 4 GB
+## 11. Limits, and untrusted files
+
+Revision 1 said "25 MB per file", a number derived from nothing. The limits
+that actually bind are three, and the budget is derived from them:
+
+- **`maxLogBytes = 8 << 20`** (`handlers.go:393`). `updatesGet` serves a
+ document's whole log to every browser load and **500s above it**. An
+ import larger than that produces a document that renders server-side and
+ can *never be edited* — the exact opposite of the ask.
+- **A 1-second guest-call cap** (`internal/core/core.go:100`), with no
+ override wired in this app, and a timeout treated as a trap. `docs_core_build`,
+ the snapshot encode and `ApplyUpdate`'s re-apply each get 1 second.
+- **A 512 MB box** (main spec §5.2). One large file exists simultaneously as
+ the request body, the parsed XML, the JSON string, its copy in wasm linear
+ memory, the yrs doc, the snapshot, `Handle.applied`'s copy
+ (`core.go:561`) and the row.
+
+So: **the update is capped at 2 MB**, leaving headroom under 8 MB for a
+document's subsequent editing life, and the file caps are derived from that
+rather than chosen. A build whose snapshot exceeds the cap is refused at
+step 4 of §5 — before any row exists.
+
+- **Stream the parts** via `r.MultipartReader()` with a per-part
+ `LimitReader` and a total-body `MaxBytesReader`. Never
+ `ParseMultipartForm`: it spills plaintext document content to temp files,
+ which on an E2EE-intending app is a straightforward leak. (Note that
+ `MaxBytesReader` caps the *body*, so revision 1's "25 MB per file" was not
+ even the limit it claimed to be.)
+- **A semaphore on concurrent imports.** Ten simultaneous 20 MB DOCX files
+ is a memory event, and nothing else in this app is that expensive per
+ request.
+- **Zip:** cap entry count and total uncompressed bytes, and enforce the cap
+ **on the actual read**, not on the declared `UncompressedSize64` — a zip
+ bomb lies in its header.
- **XML:** Go's `encoding/xml` does not expand external entities, which
- removes the classic attack; a nesting-depth cap covers the rest
-- **A wall-clock deadline** on the whole import, and the parse runs where a
- panic is recovered and becomes a failed import rather than a dead process
-- **The core's existing containment** covers `docs_core_build`: a trap
- discards and rebuilds the instance (`internal/core`), and the JSON it is
- fed came from our own encoder, not from the file
-
-Also in this section because it belongs to no other: the tree the importers
-build is capped in node count and depth before it reaches the core. A
-50 MB text file is a legitimate `.txt` and an illegitimate document.
+ removes the classic attack; a nesting-depth cap covers the rest.
+- **A wall-clock deadline** on the whole import; parsers run where a panic
+ becomes a failed import rather than a dead process.
+- **The tree** is capped in node count and depth before it reaches the core.
---
-## 11. Titles
+## 12. Titles
In order, first that yields something: the document's own title metadata
-(`docProps/core.xml`'s `dc:title` for DOCX, PDF's `/Title`), then the first
-heading in the imported content, then the filename with its extension
-stripped. Never empty.
+(`docProps/core.xml`'s `dc:title`, PDF's `/Title`), then the first heading in
+the content, then the filename with its extension stripped. Never empty.
+
+**A leading H1 is consumed into the title only when it is the first block
+and no metadata title exists.** This is what makes Markdown's
+export→import→export round trip identity: `internal/export/markdown.go:152`
+writes the title as a leading `# H1`, so keeping it doubles the H1 on
+re-export, and consuming it unconditionally loses a genuine content heading.
---
-## 12. Gates
+## 13. Gates
-On top of §12 of the main spec, which all still applies:
+On top of main spec §12, which all still applies:
-- **Round-trip:** `fragment_json(build(tree)) == tree` over a corpus, in
- Rust
+- **Round-trip:** `fragment_json(build(tree))` structurally equal to `tree`
+ over a canonical corpus (§3), in Rust
+- **Content model:** trees violating §3's model are refused, with a test per
+ rule
- **Cross-implementation:** a Rust-built update applied by upstream `yjs`
and converted with the editor's real schema produces the expected
- ProseMirror tree — F2's spike, promoted into `fixtures/` beside the
- existing wire-compat fixtures. This is the gate that would catch a
- server-built document the editor cannot open.
-- **Export→import→export** for Markdown, which must be identity within the
- subset
-- **Real files, committed:** a `.docx` from Word, one from Google Docs, one
- from LibreOffice — they differ, and a fixture from only one of them tests
- only one of them
-- **Malformed input:** a truncated zip, a `.docx` with no
- `word/document.xml`, a zip bomb, a text-free PDF — each asserts a clean
- failure, not a panic and not an empty document
+ ProseMirror tree — F2's spike promoted into `fixtures/`, **built through
+ the real client-id path**, not a literal `1` (§4 D1)
+- **Export→import→export** identity for Markdown (§12)
+- **Real files:** Word, Google Docs and LibreOffice `.docx`, each asserted
+- **Malformed input:** truncated zip, no body part, zip bomb, text-free PDF,
+ invalid UTF-8 `.txt` — each a clean failure, no panic, no empty document
- **Fuzz** `build_fragment`
---
-## 13. Cut from this slice
-
-Each with what would bring it back:
+## 14. Cut from this slice
| Cut | Trigger to build it |
|---|---|
-| Google Drive picker / OAuth | Its own consent and token-scope design (§14 of the main spec) |
-| `.odt` | First — it is the same shape as `.docx` |
-| Tables, images | The schema does not have them; they arrive with the editor's, not the importer's |
-| PDF heading/list heuristics | Evidence from real imported PDFs that the plain version is not enough |
-| Footnotes, tracked changes | Already §14 of the main spec |
-| Retiring `generate-seed.mjs` in favour of the runtime builder | This lands and is trusted (§2) |
-| Import into an *existing* document | Someone asks; "replace what I am editing" is a different and more dangerous feature than "make me a new document" |
+| Google Drive picker / OAuth | Its own consent and token-scope design |
+| `.odt` | First — same shape as `.docx` |
+| Tables, images, links | The schema does not have them; they arrive with the editor's |
+| `font_*` / `size_*` from DOCX fonts | A fidelity project with its own evidence |
+| PDF heading/list heuristics | Evidence from real imports that plain is not enough |
+| Footnotes, tracked changes | Already main spec §14 |
+| Retiring `generate-seed.mjs` | This lands and is trusted |
+| Import into an *existing* document | Someone asks; "replace what I am editing" is a different and more dangerous feature |
---
-## 14. Sequence
+## 15. Three plans, not one
+
+Fable's review is right that this is not one implementation plan.
+
+**Plan 1 — the mechanism.** `build_fragment`, `docs_core_build`, the ABI
+bump with `snapshotEncodingVersion` split out, the round-trip and
+content-model gates, the cross-implementation fixture, `Handle.Build`,
+`Store.Import`, the text and Markdown importers, `POST /documents/import`,
+the form, the drop zone, and the report. **At the end of plan 1 the feature
+works end to end** — you can drag a `.md` or `.txt` file onto the list and
+edit the result.
-The order is chosen so the thing Paul actually asked for works end to end as
-early as possible, and each step leaves the tree green.
+**Plan 2 — DOCX.** `numbering.xml`, style resolution, OPC relationship
+resolution, the three real-world fixture families. This is what makes the
+answer to Paul's actual question "yes".
-1. **Core: `build_fragment`, `docs_core_build`, ABI 5**, round-trip test,
- fuzz target — plus the `fragment_text` mark-leak fix (§4)
-2. **Go: `internal/core.Handle.Build`, `Store.Import`** — the four-step
- sequence in §5, tested against the real wasm
-3. **Text and Markdown importers** — the smallest real end-to-end path
-4. **`POST /documents/import`, the form, the drop zone, the report** — at
- the end of this step the feature is usable
-5. **DOCX** — the format the ask is actually about, and the most work
-6. **PDF** — dependency choice first, then extraction, then the honest
- report
+**Plan 3 — PDF.** The dependency choice and its licence, the containment
+posture (§8), glyph-level reconstruction, the honest report.
-Steps 1–5 are the feature. Step 6 is the one whose scope is least certain
-and it is last for that reason.
+Plan 1 first, and it is the one whose correctness rests on several
+interacting invariants at once — the client id, the content model, the
+canonical form, the update budget. Per AGENTS.md's ladder, that is Fable
+work rather than a precise-brief-to-Sonnet task.