Paul asked overnight whether we can do Google Docs / PDF / Word import — drag a file onto the document list and get something you can edit. This branch carries the spec (three revisions, two review rounds), plan 1 of three, and the mechanism the whole feature rests on, built and proven.
Nothing here is merged and nothing should be until Paul has read it. Two decisions below are his.
What is on the branch
docs/superpowers/specs/2026-09-01-import-design.md — revision 3docs/superpowers/plans/2026-09-02-import-mechanism.md — plan 1 of threecore/src/build.rs + core/tests/build.rs — canonical ProseMirror JSON → Y.XmlFragment, 33 testscore/src/bin/emit_import.rs + fixtures/verify-import.mjs, wired into scripts/wire-gate.sh
The design in one paragraph
Import is the mirror of export. The core already emits ProseMirror-shaped JSON (fragment_json) so internal/export can write Markdown and PDF; import parses files in Go into that same shape, and one new Rust function builds a fragment from it. The resulting whole-state snapshot is persisted through Store.ApplyUpdate — the path a person's typing already takes — so an imported document is indistinguishable from a typed one, and there is no second write path to keep correct. The existing docs_core_snapshot_len/read pair already means "this document's whole state as one update", so the core needs exactly one new function.
The defect that nearly shipped
Revision 1 had the importer author content under the document row's own ClientID. Store.Create draws that from mrand.Int64() — uniform in [0, 2^63) (store.go:267) — and the browser decodes client ids through lib0's readVarUint, which accumulates into a JS double and throws past MAX_SAFE_INTEGER. Measured over 1000 trials at that exact shape: 861 threw Integer out of Range, 139 worked. So 861 of every 1000 imported documents would have been unopenable in the editor.
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. Found by review, not by testing — my own spike used client_id = 7 and passed happily.
Two related things worth recording for whoever touches this next:
Store.Create's 63-bit ClientID is a latent trap for any future server-side authoring, not just import.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.
A methodological mistake I made twice
Two of revision 1's four "spike-proven" findings were wrong in the same way: they were true of this repo's fixtures and not of the editor those fixtures claim to represent. I concluded heading.level is a string from decoding seed .update.bin files — but those were written by generate-fragment.mjs:50 ({ level: '2' }), while the editor's toolbar writes Number(...) (toolbar.mjs:167). yrs's get_attribute stringifies, so a spike reading through Rust cannot tell the two apart.
A fixture is evidence about the fixture generator. That belongs in AGENTS.md's "tools that answer wrongly instead of failing" list, and I will add it there unless someone objects.
The gate that matters
Every Rust test of the builder reads the document back through the same yrs that wrote it, so Rust agrees with itself even where both halves are wrong about what the browser expects. scripts/wire-gate.sh now also runs emit_import → verify-import.mjs, which has upstream yjs, y-prosemirror and the real editor schema turn the bytes back into a ProseMirror tree. It reconstructs exactly what was built, nested list and all. That is what retires the risk this design carries: a server-built document the browser cannot open.
Two rulings I need from Paul
1. 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 it, with a passing regression test — then found generate-fragment.mjs:99, where a previous session deliberately chose that behaviour, having verified yrs's get_string matches Y.XmlText.toString() in both implementations. So I backed the fix out.
It is still wrong for its consumers: fragment_text's own doc comment calls it "the words without the markup", and the two fixture families already contradict each other (fixtures/data/formatted.expected.json says plain bold plain; fixtures/vectors/fragment/formatted.expected.json says plain <strong>bold</strong> plain). It is latent today — no seed document carries a mark and Store.Text has no non-test caller — and stops being latent the moment a .docx can be imported. The change is one line in node_text plus regenerating four .expected.json files. Reversing another session's documented ruling is not mine to do.
2. May snapshotEncodingVersion be split off the ABI number? Bumping the ABI 4 → 5 discards every stored snapshot (snapshots.go:161 filters on abi_version), so every document replays its whole log until its next write, and one past 4096 rows re-seeds on every read with a warning each time — pure waste, since the state encoding does not change. Splitting the constant avoids it, but it overrides the snapshots design's D1, which chose "older ABI → discarded" deliberately. Plan 1's task 1 depends on the answer.
What is not done, and why
Plan 1 is written but only task 0 is implemented. Tasks 1–12 take it from "the core can build a document" to "you can drag a file onto the list": the ABI export, Store.Import, the text and Markdown importers, POST /documents/import, the form and drop zone, and the import report. DOCX is plan 2, PDF is plan 3.
I stopped rather than pressing on because the next task depends on ruling 2, and because every user-visible string in tasks 9 and 10 has to go through copy review with Paul before it is written into a template.
Two findings that will shape plan 2 (DOCX), from probing real Word files:
- A paragraph can carry both
pStyle=Heading1 and numPr — a numbered heading. "Has numPr ⇒ list item" would turn every numbered heading into a bullet. - The body part is not always
word/document.xml; a real file on this machine uses word/document2.xml. It has to be resolved through the OPC relationships.
And one for plan 3 (PDF): CGO_ENABLED=0 rules out MuPDF bindings, and the pure-Go candidate's convenience API is unusable — on a real spec PDF, GetPlainText returns Thisisversion0.21ofthe… with every space gone. Its Content() primitive is good (per-glyph x/y/width/font/size), so reconstruction is possible, but we would be writing it. That is why PDF is its own plan and last.
State
Local gates green: cargo test, cargo clippy -D warnings, cargo fmt --check, ./scripts/wire-gate.sh, CGO_ENABLED=0 go build ./... && go test ./..., and the committed wasm reproduces byte-for-byte.