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
docs/superpowers/specs/2026-09-01-import-design.md+461 −286
CI cancelled — superseded by a newer push — run details
Intent
<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 `< 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(&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 `<>&`, 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 << 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. `<w:br/>` (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 (`<input multiple>`) 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(&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 `<key>` 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. |