oficina / docs Public

Import spec revision 3: settle what two implementers would guess at

The second review came back "not ready -- but close": the architecture
held, and ten points were under-specified in ways two people would
resolve differently. Those are now settled -- the canonical form's full
rule set (including that a block's runs share one Y.XmlText, or
y-prosemirror rewrites the block on first edit), what an empty file
does, the file-size caps as actual numbers rather than a promise that
they were derived, where the import report lives and when it is written,
what happens to a failed file in the no-JS path, Markdown beyond this
app's own export subset, and the fact that splitting
snapshotEncodingVersion off the ABI number overrides a ruling in the
snapshots design and so is Paul's call.

One prescription is withdrawn rather than implemented. Revision 2, having
established that the editor writes heading.level as a number, told the
builder to do the same. Implementing it showed that yrs 0.23.5's public
API cannot store a non-string XML attribute at all: insert_attribute is
typed V: Into<String>, and the typed path runs through MapRef, which
wraps a BranchPtr that is private to the crate. So the builder writes a
string, which is what this repo's fixtures already contain, and §4 D2
records exactly what that costs -- including that prosemirror-model's
validate:"number" would reject it if anything ever called node.check(),
a tripwire that already exists for every heading in the fixtures.
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 cb15e141161d3adce804d129d8d83ad16085e82f parent ed1a16b
1 file changed, +155 −20
  • docs/superpowers/specs/2026-09-01-import-design.md +155 −20

CI passed — run details

diff --git a/docs/superpowers/specs/2026-09-01-import-design.md b/docs/superpowers/specs/2026-09-01-import-design.md
index 7ab3180..b3cb3d8 100644
--- a/docs/superpowers/specs/2026-09-01-import-design.md
+++ b/docs/superpowers/specs/2026-09-01-import-design.md
@@ -8,6 +8,12 @@ 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.
+**Revision 3, after a second review returned "not ready — but close": the
+architecture held, and ten points were under-specified in ways two
+implementers would resolve differently. Revision 3 settles them, and
+withdraws one prescription that turned out not to be implementable at all —
+yrs cannot write a numeric attribute (§4 D2).**
+
**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
@@ -103,9 +109,14 @@ importer trips the first of them constantly:
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.
+> **Canonical form.** Within one block, consecutive text runs whose mark-name
+> *sets* are equal are merged (sets, not sequences — order does not matter,
+> because marks are then written in `MARKS` order). No empty text runs. Marks
+> in `MARKS` order, deduplicated, and the `marks` key is **omitted entirely**
+> when a run has none — never `"marks":[]`. A mark is exactly
+> `{"type": name}`, with no `attrs`. A text node carries no `content`; an
+> element carries no `text` and no `marks`. `attrs` appears only on
+> `heading`; `content` always appears. 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
@@ -126,6 +137,28 @@ list_item → paragraph block* (fixtures/editor-entry.js:38
blockquote → block+
```
+where **block** is `paragraph | heading | bullet_list | ordered_list |
+blockquote`. `list_item` is not a block and appears only inside a list.
+
+**One `XmlText` per block.** y-prosemirror groups every adjacent ProseMirror
+text node in a block into a single `Y.XmlText` (`sync-plugin.js`'s
+`normalizePNodeContent`), and `fragment_json` emits one `text_json` call per
+`XmlText` child, never merging across two siblings. So a builder that gave
+each run its own `XmlText` would round-trip perfectly and still differ from
+what the browser would have written — which makes y-prosemirror rewrite the
+whole block on the first edit. Runs go into one `XmlText` per block.
+
+**An empty document is refused** (`doc → block+`), so a file that yields no
+blocks — an empty or whitespace-only `.txt`, a `.md` of nothing but blank
+lines — fails with a clear message and creates no document, exactly as a
+text-free PDF does (§8). It does not quietly create a blank document: "I
+dragged a file in and got an empty document" is indistinguishable from the
+feature being broken.
+
+Nested lists are legal here and reachable only through import: the editor's
+schema permits them, but its toolbar binds no `sinkListItem`, so until now
+only fixtures have produced one. That widens what documents contain, not what
+the schema allows.
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:
@@ -147,6 +180,10 @@ mark as `{ name: mark.attrs }`, i.e. `{}` for the attribute-free marks
browser writes gets rewritten by y-prosemirror's attribute comparison on the
first edit.
+**`heading.level` is the exception, written as a string because yrs cannot
+write anything else.** See §4 D2. It is the one place where matching the
+browser exactly is not achievable, and it is documented rather than hidden.
+
### 3.1 The Go side
Revision 1 left the Go tree type undefined and implied `export.Node` could
@@ -197,11 +234,10 @@ 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 `node.check()` remark in revision 1 was also inverted: today's
+ editor-written headings pass validation, and string-level ones are the
+ failures. Revision 2 concluded the builder should therefore write a
+ number. **It cannot — see D2.**
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
@@ -239,6 +275,34 @@ been on the wire — and every wire fixture uses `clientID` 1, 2 or 3.
real edits are attributed to the row's `ClientID` — they are attributed to
the browser's own `new Y.Doc()` id.
+**D2 — the builder writes `heading.level` as a string, and that is forced.**
+Revision 2, having established that the editor writes a number, told the
+builder to write `Any::Number`. Implementing it showed that **yrs 0.23.5's
+public API cannot store a non-string XML attribute at all.** The only setter
+the `Xml` trait exposes is `insert_attribute`, typed `V: Into<String>`
+(`xml.rs:1002`). The typed path — an `XmlElement`'s attributes live in the
+same branch map a `Y.Map` uses, so a `MapRef` over it would take an `Any` —
+is closed, because `MapRef` wraps `BranchPtr` (`map.rs:62`, `:512`) and
+`BranchPtr` is private to the crate. Short of forking a library that is half
+of a wire contract and pinned at `=0.23.5`, a string is the only value the
+builder can write.
+
+ What that costs, stated here rather than discovered later: it is what this
+ repo's fixtures already contain (`generate-fragment.mjs:50`); the editor
+ opens such a document, verified by the cross-implementation gate, and a
+ level-2 heading still renders `<h2>`; `fragment_json` cannot see the
+ difference, because it reads through `get_attribute`, which stringifies
+ (`xml.rs:1026`); it differs from what the browser would write, so
+ y-prosemirror may rewrite an imported heading on its first edit — CRDT
+ churn, not lost text; and `prosemirror-model` 1.25's `validate: "number"`
+ rejects a string level, so any future code that calls `node.check()`
+ breaks. That tripwire already exists for every heading in this repo's
+ fixtures; import does not introduce it, and does not clear it either.
+
+ `generate-fragment.mjs:50` stays a string, then — it turns out to
+ represent what the *server* writes, even though it does not represent what
+ the editor writes.
+
**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
@@ -248,7 +312,7 @@ 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
+ it is "the words without the markup", it would feed the list preview if anything called it, 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
@@ -291,11 +355,22 @@ 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)
+2. open a scratch core document: fresh GUID, **fresh client id in `[0, 2^32)`**
+ (§4 D1). Both drawn **in Go** (`uuid.NewString()`, and a `uint32` from
+ `math/rand/v2`), never from yrs — the rule `Store.Create` already follows
+ and `clippy.toml` enforces on the Rust side. Drawn per import, never a
+ fixed "importer id", which would collide the day "import into an existing
+ document" (§14) is built
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
+6. `Store.ApplyUpdate` — the ordinary persistence path
+
+If step 6 fails, the cleanup deletes `documents`, `doc_updates` **and**
+`doc_import_reports` for that id, through `owned()`. Not the row alone:
+`ApplyUpdate` can fail after its `doc_updates` insert has committed
+(`store.go:837`), so deleting only `documents` orphans the log. `Store`
+exposes no delete at all today, so plan 1 adds one.
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
@@ -317,7 +392,18 @@ natural fuzz target, and gets one.
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.
+`snapshotEncodingVersion` from the ABI number** and bump only the ABI. The
+column stays `abi_version` — a misnomer afterwards, worth a comment rather
+than a migration — existing rows stay valid if the new constant starts at 4,
+and `TestNewestSnapshotIgnoresAnotherABIsRows` is re-keyed to it. The new
+constant bumps on any yrs upgrade or any change to the encode call, because
+nothing else will remind anyone.
+
+**This overrides a ruling, so it is Paul's call.** The snapshots design's D1
+chose "from an older ABI → discarded" deliberately. Splitting the version is
+a better answer to a case that design did not have — an ABI bump that leaves
+the state encoding untouched — but it is still a reversal, and belongs on the
+same list as the `fragment_text` decision in §4.
---
@@ -353,7 +439,18 @@ 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.
**Markdown** (`markdown.go`) — the subset `internal/export/markdown.go`
-writes, which makes export→import a testable round trip.
+writes, which makes export→import a testable round trip; unescaping what
+that file's `escapeMarkdown` wrote is what makes it identity rather than
+merely close.
+
+But §1 promises `.md` from *anything*, and real Markdown carries more than
+this app's exporter emits. Every construct outside the subset has a defined
+answer, so no implementer has to invent one: **link** → text kept, target
+dropped, warn; **image** → dropped, warn; **fenced or indented code** →
+paragraphs carrying the `code` mark, warn (import's subset has no
+`code_block`); **table** → cell text as paragraphs, warn; **HTML block or
+inline** → its text kept, warn; **thematic break** → dropped, warn; **hard
+line break** → paragraph split, warn — the same answer `<w:br/>` gets (§3).
**Plain text** (`text.go`) — blank-line-separated paragraphs. **UTF-8 is
validated in Go**, with a BOM stripped and invalid bytes a refusal, before
@@ -422,8 +519,12 @@ 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.
-**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
+**Storage: a `doc_import_reports` row, keyed by document**, written after
+`Store.ApplyUpdate` returns, and rendered on `show.html`. Not "in the same
+transaction": no such transaction exists — `ApplyUpdate` opens and commits
+its own (`store.go:795`) and `Create` autocommits. A failed report write
+fails the import and triggers §5's cleanup, because a document that silently
+lost a table is the outcome main spec §11.2 forbids. 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.
@@ -464,7 +565,11 @@ 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.
+the list. A file that *fails* produces no document and therefore no report
+row, so those results cannot live where the reports do: the redirect carries
+a count, and the list renders one notice naming how many files did not
+import and why. Otherwise the only invisible results would be the ones that
+matter.
**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
@@ -503,8 +608,34 @@ that actually bind are three, and the budget is derived from them:
(`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
+document's subsequent editing life. Seed data puts that in proportion: an
+`.update.bin` runs 1.05–1.18× its source text, so 2 MB of update is on the
+order of 1.7 MB of plain text — or perhaps 1 MB once heavily marked. Several
+hundred pages either way.
+
+The file caps protect that budget and are deliberately not the same number,
+because a 20 MB `.docx` can hold 100 KB of text with images making up the
+rest:
+
+| Cap | Value |
+|---|---|
+| Whole request body (`MaxBytesReader`) | 25 MB |
+| One file (per-part `LimitReader`) | 20 MB |
+| Uncompressed bytes read from one zip | 60 MB |
+| Zip entries | 512 |
+| The body part (`document.xml`) | 20 MB |
+| Tree nodes / tree depth | 200,000 / 32 |
+| Resulting update | 2 MB |
+
+A build whose snapshot exceeds 2 MB is refused at step 4 of §5 — before any
+row exists.
+
+**One thing here is still unmeasured**, and is a gate rather than a guess:
+that building, snapshotting and re-applying a 2 MB tree each fit inside the
+1-second guest-call cap. `find_position` walks from the start of an
+`XmlText`, so a block with *k* runs costs O(k²) — blocks are small, so this
+is probably fine, and "probably" is what a gate is for. Plan 1 measures it
+and asserts a ceiling (≤ 250 ms) on the CI runner. 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
@@ -533,8 +664,12 @@ In order, first that yields something: the document's own title metadata
(`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
+**For Markdown only, a leading H1 is consumed into the title when it is the
+first block and no metadata title exists.** For DOCX it is `dc:title`, then
+the filename — never the first heading, and body content is never removed:
+Word files routinely carry an empty or stale `dc:title` ("Document1") above a
+genuine `Heading1`, and consuming it would delete a real section heading from
+the document. 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.