oficina / docs Public

Plan 7, revision 3: what 250db1c already did for it

Nothing is re-decided. The word-processor basics landed while this plan
sat on main, and they moved four things it describes.

MARKS now holds eleven point-size marks plus the four named sizes they
replaced. All of them are still attribute-free spans with literal
classes, so `link` remains the FIRST valued mark and D1 is still unspent
— which was the live risk when that work started, since a size mark
carrying "14pt" would have spent the property fragment_html's unescaped
output rests on, for a feature nobody thought of as a security change.
The closed enumeration is what makes it additive instead.

node_json was restructured to collect attributes into one object rather
than write a complete `,"attrs":{…}` from independent per-attribute
arms. That is now the shape a valued mark has to copy, and the reason is
worth reading before touching the JSON path: the old shape would have
emitted "attrs" twice in one object the moment a node carried two, which
every JSON parser accepts, none agrees on, and nothing would report.

D3 gains the address revision 2 could not give. mark_names
(fragment.rs:520) returns names and text_json emits each as a bare
{"type":…}; that function is the single point both the JSON consumers
and the HTML renderer agree on what marks a run carries, so it is where
the allowlist belongs — which is what makes "the check runs once, in the
shared path" true rather than aspirational. Also noted: mark_names
carries UNKNOWN marks through by name, so the allowlist must reject an
unknown mark's value rather than assume only link has one.

Tasks 4 and 5 shrink. document.go's Attrs is already widened, so only
Mark is left. The fixture generator now runs the real encoding path and
already carries cases for values no editor can produce but a raw POST
can — the exact shape the adversarial URLs need. And the Insert menu
ships Link, Image and Table present-but-disabled, reserving
insert-link, insert-image and insert-table, so implementing against
those names needs no menu change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 9b728c3ec597c1e16ae1bee06b9519a276d09b02 parent 1859534
1 file changed, +57 −16
  • docs/superpowers/plans/2026-09-02-images-links-tables.md +57 −16
diff --git a/docs/superpowers/plans/2026-09-02-images-links-tables.md b/docs/superpowers/plans/2026-09-02-images-links-tables.md
index b4a6b14..a342cb8 100644
--- a/docs/superpowers/plans/2026-09-02-images-links-tables.md
+++ b/docs/superpowers/plans/2026-09-02-images-links-tables.md
@@ -15,6 +15,16 @@ depend on, and a storage seam the app does not yet have.
ordering and D6's expectation — and corrected several facts, the largest
being that revision 1 proposed writing an escaper that already exists.*
+*Revision 3, after `250db1c` landed the word-processor basics. Nothing
+here is re-decided; three tasks got smaller and one got a precise
+address. `MARKS` now holds eleven point-size marks plus the four named
+sizes it replaced, all still attribute-free — so `link` remains the
+first valued mark and D1 remains unspent. `node_json` was restructured
+to collect attributes into one object, which is the shape a valued mark
+must copy. `document.go`'s `Attrs` is already widened. The fixture
+generator now runs the real encoding path with raw-`POST`-only cases in
+it. And the Insert menu already reserves the three command names.*
+
## What is actually broken today
Verified against `origin/main` by running Google-Docs clipboard HTML
@@ -141,9 +151,32 @@ because it stops the document's own URL — sqid and all — reaching the
link target. No `target`: a document is a page, and opening a new tab is
the reader's choice, not the author's.
-`internal/export/document.go` has to grow with this: its `Mark` is
-`{Type string}` and its `Attrs` is closed to `Level` and `Align`, so both
-need widening and `text_json` needs to emit mark attributes.
+`internal/export/document.go` has to grow with this. Revision 2 said its
+`Mark` and its `Attrs` both needed widening; `250db1c` has since widened
+`Attrs` (adding `Indent` beside `Level` and `Align`), so the pattern
+exists and only `Mark` is left — it is still `{Type string}`.
+
+The Rust side is more specific than revision 2 could be. `mark_names`
+(`fragment.rs:520`) returns a `Vec<String>` of names, and `text_json`
+emits each as a bare `{"type":"…"}`. **That function is where the
+allowlist belongs**: it is the single place both the JSON consumers and
+the HTML renderer agree on what marks a run carries, so a rejected
+`href` dropping the mark there is what makes point 3 above true rather
+than aspirational. It needs to return names *with their values* — and
+`node_json`'s attribute handling, restructured in `250db1c` to collect
+into one object rather than write a complete `,"attrs":{…}` from
+independent arms, is the shape to copy. That restructure is worth
+reading before touching this: the old shape would have emitted `"attrs"`
+twice in one object the moment a node carried two attributes, which
+every JSON parser accepts, none agrees on, and nothing would report.
+
+One thing `mark_names` already does that matters here: it carries
+*unknown* marks through by name rather than dropping them, on the same
+reasoning as unknown nodes rendering their children. So a mark a raw
+`POST` invents reaches the JSON output as a name. It is escaped by
+`escape_json` and contributes no HTML wrapper, so this is not a hole —
+but a valued mark makes the question live, and the allowlist has to
+reject an unknown mark's *value* rather than assume only `link` has one.
## D4 — the image is on screen at once; the node lands when the bytes do
@@ -322,22 +355,30 @@ a wrapper so a wide table scrolls inside the page, and `.selectedCell`.
AA, every string a catalog key.
### Task 4 — new wire fixtures
-`fixtures/generate-fragment.mjs` hand-builds its vectors from
-y-prosemirror's described behaviour. That is fine for tables (element
-names and numeric attrs, the same shape as `aligned.bin`) and **not fine
-for `link`**, whose wire value is a `mark.attrs` object under the mark
-name — a shape no existing fixture has. Generate `links.bin` through
-y-prosemirror's own `prosemirrorToYXmlFragment` so Rust is tested against
-the real encoding rather than a transcription of it. `tables.bin` and
-`images.bin` alongside. Existing fixtures do not change; these are
-additions, and they are the gate for the new types.
+Revision 2 warned that `fixtures/generate-fragment.mjs` hand-built its
+vectors from y-prosemirror's *described* behaviour, which is fine for
+element names and numeric attrs and not fine for a mark, whose wire value
+is a `mark.attrs` object under the mark name — a shape no existing
+fixture had. `250db1c` moved the generator onto the real path and added
+cases for values no editor can produce but a raw `POST` can, which is
+exactly the shape the allowlist needs. So this task is now smaller than
+it was: add `links.bin`, `tables.bin` and `images.bin` through the
+existing generated path, with the adversarial URLs among them. Existing
+fixtures do not change; these are additions, and they are the gate for
+the new types.
### Task 5 — the allowlist, then links
-First commit: D1's grammar, the reject-to-text path in the shared
-`fragment_json`/`text_json` path, and the adversarial tests. Second:
-`MARKS`' valued variant, `document.go`'s `Mark` and `Attrs`,
+First commit: D1's grammar, the reject-to-text path in `mark_names` (see
+D3 — that is the shared point, not `node_html`), and the adversarial
+tests. Second: `MARKS`' valued variant, `document.go`'s `Mark`,
`markdown.go`'s `[text](href)`, `pdf.go`'s link styling and `/Link`
-annotation, `toolbar.mjs`'s add/remove link.
+annotation, and the `insert-link` / `remove-link` commands.
+
+The menu is already shaped for all three: `250db1c` ships File/Edit/View/
+Insert/Format/Tools with **Insert → Link, Image and Table present but
+disabled**, reserving `insert-link`, `insert-image` and `insert-table` in
+`toolbar.mjs`'s command table. Implement against those names and no menu
+changes are needed — the items simply become enabled.
### Task 6 — the sealing key, the blob store and the route
**Blocked on Paul's key decision.** Then `models.go` plus a migration for