# Plan 7 — images, links and tables: what a pasted document keeps

Brief, from Paul, 2026-09-02: *"make a plan to handle images, links, and
tables. Images can be rendered immediately and uploaded async to object
storage"* — after pasting a Google Doc and finding the table flattened
and the formatting gone.

Spec §11.2 already commits to all three: *"The schema covers headings and
named styles, lists, tables, images, links, headers and footers, page
setup and comments."* So none of this is a new decision about scope. What
it is, is one decision about a safety property that two of the three
depend on, and a storage seam the app does not yet have.

*Revision 2, after Fable's review. It changed two decisions — D4's
ordering and D6's expectation — and corrected several facts, the largest
being that revision 1 proposed writing an escaper that already exists.*

## What is actually broken today

Verified against `origin/main` by running Google-Docs clipboard HTML
through the shipped schema and the real wazero core.

**Tables are not in the schema at all.** A pasted table becomes one
top-level paragraph per cell, in row-major order, the grid gone. The
When/What/Who table in Paul's rollout doc came out as six consecutive
paragraphs. `core/src/fragment.rs`, `internal/export/markdown.go` and
`internal/export/pdf.go` know nothing about tables either.

**Links and images are half-present, which is worse than absent.**
`link` and `image` come from `prosemirror-schema-basic`, so they are in
the *browser* schema. A paste writes them into the CRDT. Then
`fragment.rs` drops them: `link` is not in `MARKS` (`fragment.rs:77-87`),
so the read view renders the anchor text with no anchor, and `image` hits
the unknown-element arm (`fragment.rs:203-204`) and renders as **nothing
at all**. Both exporters are equally blind. This is the module comment's
own "silence here is data loss", happening now.

## D1 — the safety property, and what it costs to spend it

`fragment_html`'s output is returned as `template.HTML` and written
unescaped by `views/show.html`. It is safe today because of a property
the module states and maintains: **no byte from a document reaches
attribute position**. Marks are attribute-free and render as fixed
literals; `paragraph.align` is only ever a key into a closed table whose
right-hand side is a literal; `heading.level` is parsed as an integer and
clamped.

**The escaper already exists** — `fragment.rs:25` escapes `&`, `<`, `>`,
`"` and `'`, ampersand first. What is missing is everything about
*which* values are allowed to reach an attribute at all.

**Tables do not spend the property.** `colspan` and `rowspan` are
integers, clamped exactly as `heading.level` already is.

**Links and images do.** So this plan buys it back deliberately:

**1. A positive scheme grammar, not a denylist.** A rejected-characters
rule is the right instinct and the wrong shape: browsers strip ASCII tab
and newline anywhere in a URL and strip leading C0 controls, but not
U+00A0 or U+2028, and a value with no colon at all is a third case. One
rule covers all of them — the value must match
`^[A-Za-z][A-Za-z0-9+.-]*:`, and that scheme, case-folded, must be one of
`http`, `https`, `mailto`. Everything else renders as text: no colon,
`//evil`, `data:`, `blob:`, `java\tscript:`, `\u{0}javascript:`.
`blob:` matters more than it looks — it is what this very feature
manipulates in the browser, and it must never reach the CRDT.

**2. `src` is matched against its literal shape, not merely "app-relative".**
`//evil.example/x.png` begins with `/`. The rule is
`^/d/[A-Za-z0-9]+/blobs/[0-9a-f]{64}$` and nothing else. It does not
check that the sqid is *this* document — `fragment_html(doc)` has no sqid
— because the GET route does that by construction (D4).

**3. The allowlist runs once, in the shared path, not per renderer.**
This is the correction that matters most. If the check lived only in
`node_html`, then `markdown.go` would emit `[text](javascript:…)` and the
PDF's `/Link` annotation would carry the same, unless each exporter
re-implemented the check — a second copy of a security table, which is
the exact coupling this plan claims to avoid. Instead a rejected `href`
means the run is emitted **with no `link` mark at all**, in
`fragment_json`/`text_json` as much as in `fragment_html`. No consumer
can then see a rejected URL, because none is ever produced.

**4. Accepted image types are sniffed, and SVG is not one of them.** If
`/d/{sqid}/blobs/{hash}` can serve `image/svg+xml`, navigating to it runs
script on the app's own origin. JPEG, PNG, GIF and WebP only, sniffed
from the bytes with `http.DetectContentType` and never taken from the
client's header, served with `X-Content-Type-Options: nosniff` and
`Content-Security-Policy: sandbox`. This is a safety rule, not an
implementation detail of the upload route.

**5. Adversarial tests in `core/tests/fragment.rs`**, covering every item
in 1 and 2 plus `"><script>` in `title` and `alt`.

## D2 — tables are `prosemirror-tables`, not a hand-rolled node set

`prosemirror-tables` (1.8.5) is the same team's package, is what TipTap's
table extension wraps, and brings the parts that are genuinely hard: cell
selection, the column and row commands, and the fixups that keep a table
rectangular after every edit. Hand-rolling those is how you get a model
that can represent a broken grid.

It contributes `table`, `table_row`, `table_cell`, `table_header`, with
`colspan`, `rowspan` and `colwidth` on cells. Its dependency ranges all
dedupe onto the versions `fixtures/package.json` already pins — check
with `npm ls prosemirror-model` after installing, because two copies of
`prosemirror-model` break ProseMirror silently. It exports commands and
the `tableEditing()` plugin but **no keymap**, so Tab and Shift-Tab are
ours to bind, and it needs `.selectedCell` CSS for cell selection to be
visible at all.

**Column resizing is off in this slice.** `columnResizing` is what
populates `colwidth`, and `colwidth` is an *array* — a different shape
from anything on the wire today. Off, it stays null, and y-prosemirror
skips null attributes entirely. Google Docs' own cell widths arrive as
`style="width:…"` rather than `data-colwidth`, so a paste leaves it null
too. Resizing can come later with no migration.

`colspan` and `rowspan` are **not** null: they default to `1`, so every
cell carries both as numeric attributes. That is the one way the wire
gets busier. Clamp both to `1..=1000` on the Rust side, the way
`heading.level` is clamped. `table_header` renders `<th scope="col">`, a
literal. No new closed set of *values* — these are node names, which
every consumer already dispatches on by name.

Google Docs tables contain no `<th>`, so a paste produces zero
`table_header` rows. `<tbody>` and `<colgroup>` are transparent to
ProseMirror's DOMParser.

## D3 — a link is a mark with an href, and the read view renders it

`link` is already in the browser schema and already round-trips. What is
missing is the reader. Once D1 exists this is a row in `MARKS` — except
`MARKS` is a table of literal open/close strings and `link` must compute
its open tag, so the table grows a variant for a valued mark rather than
gaining a second table beside it.

Rendered as `<a href="…" rel="noreferrer">`. Not `noopener`, which does
nothing without `target`; `noreferrer` is the one that earns its place,
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.

## D4 — the image is on screen at once; the node lands when the bytes do

Paul's brief is that an image renders immediately and uploads in the
background. Revision 1 satisfied that by inserting the node with its
final `src` up front — hashing the bytes in the browser so the URL was
known before the upload. **That was the wrong half of the trick**, and
this revision reverses it.

The reason is what the other windows see. A node inserted before its
bytes exist reaches every other tab within ~250ms, where the GET 404s and
the reader gets a broken image; a no-JavaScript reader gets the same; and
a permanently failed upload leaves a dangling `src` in a CRDT every tab
already holds, so "replace the node with its alt text" becomes cross-tab
node surgery with no obvious owner. It did not even buy what it claimed:
`crypto.subtle.digest` is async and `transformPasted` is synchronous, and
the Google Docs path has no bytes at paste time at all, so a placeholder
was always required.

**So: render by decoration, insert on success.** §6.1's own rule is that
placeholders are decorations, never nodes. The local tab shows the
`blob:` preview as a decoration — ProseMirror's own upload example — and
the node is inserted into the CRDT only when the upload returns 2xx, at
the decoration's mapped position. The decoration set maps through every
intervening transaction; if an undo removed it, the result is discarded.
Paul's brief is met exactly — instant on screen, upload async — and the
entire dangling-`src` failure class disappears: no other tab and no
reader ever sees a `src` that cannot resolve, and retry-then-drop becomes
"remove the decoration and say so". Given the save queue's debounce,
collaborators would not have seen the image sooner the other way round.

Once insertion follows the upload, **the server computes the hash and
returns the URL**. The browser never hashes, which also disposes of
`crypto.subtle`'s secure-context requirement and of the client/server
mismatch case.

**The mapping table is still needed.** `blobs.Sealed.Put` runs
`crypto.SealSym`, which draws a random IV, so the stored address is the
ciphertext's hash and is not derivable from the plaintext (§11.5 says as
much). `Store` has no put-under-key. So:

    document_blobs(document_id, plain_hash, blob_hash, content_type,
                   plain_size, created_at)
    unique (document_id, plain_hash)

`plain_hash` is what the URL carries and the CRDT holds; `blob_hash` is
the `blobs.Ref.Hash` that `Store.Get` wants. `content_type` is a column
because §11.5 requires the *stored* type to be
`application/octet-stream`, so the true type cannot be recovered from the
store at serve time. `plain_size` is the plaintext size, not `Ref.Size`,
which is the sealed one. The unique constraint short-circuits a repeated
paste of the same image before `Put`, so re-pasting does not mint a fresh
ciphertext object every time.

Authorisation falls out of placement: `GET /d/{sqid}/blobs/{hash}` sits
inside the existing owner-scoped group in `app.go`, and the handler
resolves the document through `Store.Get` **before** looking up the row,
so an image is exactly as reachable as the document referencing it — by
construction, not by a second check somebody has to remember.

**A sealing key does not exist yet, and that is a decision, not a line.**
Nothing in `internal/` or `cmd/` calls `SealSym` today; this is the first
sealing the app does. It needs a key with the same fail-closed-outside-
development shape as `InstanceKey`, and a stated relationship to the
eventual per-document DEK. **This needs Paul's call before Task 6.**

Two things to add to the spec's §2.1 leak list while doing it: the
plaintext SHA-256 sits in a plaintext column and in every access-log line
for the URL (confirm-a-guess only, but new), and `alt` text rides in
`doc_updates.update_bin`, which is itself still plaintext.

**Copying an image between documents** gives document B an
`<img src="/d/A/blobs/…">` that B's readers may not be able to fetch. The
paste handler should recognise a same-origin `/d/…/blobs/…` src from
another document, fetch it (same origin, no CORS) and re-upload under B.

## D5 — what each renderer does with an image

`image` in `prosemirror-schema-basic` is **inline**, and Google Docs
pastes images inside `<p>`, so it stays inline. That is the path of least
resistance and it means the PDF breaks the line around an image rather
than laying it out as a block.

- **Read view** (`fragment.rs`): `<img src="…" alt="…">`, both escaped,
  `src` matched against D1's literal shape. No dimensions in this slice,
  so the page reflows as images load.
- **Markdown** (`markdown.go`): `![alt](/d/…/blobs/…)` — and this is a
  **dead link the moment the `.md` leaves the app**. Say so in the export
  rather than calling it lossless.
- **PDF** (`pdf.go` and `internal/pdf`): JPEG embeds as `DCTDecode` and
  PNG decodes with `image/png` and re-deflates — no rasterizer, so
  §11.4's `internal/raster` is **not** pulled in. Three details revision 1
  missed: `internal/pdf`'s `addStream` Flate-compresses every stream
  unconditionally and needs an uncompressed variant for JPEG; the
  `/Resources` dict it builds carries only `/Font` and needs `/XObject`;
  and PNG alpha needs a separate `/SMask` XObject or the image renders on
  black, with 16-bit and palette PNGs decoded to 8-bit RGB first. CMYK
  JPEGs need `/Decode [1 0 1 0 1 0 1 0]` or should take the decode path.
- **A blob the store does not have** renders as the alt text — in the Go
  exporters, which can ask the store. `fragment.rs` cannot: it is a pure
  function of the CRDT with no store access. The browser covers the
  residual race by retrying on `img.onerror` with bounded backoff.

## D6 — the Google Docs image, and what to test first

A Google Docs paste gives a `googleusercontent.com` URL, not bytes.
Revision 1 assumed fetching it from the browser would be CORS-blocked and
planned around that. **The evidence points the other way**: a
`googleusercontent.com` fetch with a cross-origin `Origin` header returns
`access-control-allow-origin: *` and `cross-origin-resource-policy:
cross-origin`, from the same `fife` infrastructure that serves the
`lh7-rt.googleusercontent.com/docsz/…` URLs in the clipboard HTML — which
are signed-and-expiring rather than cookie-gated, so "credentialed" was
probably wrong too.

That is one observation on one URL, not proof for `docsz`. So the test
stands, but it moves: **fetch a real Docs image URL cross-origin before
building any of Task 7**, because the expected answer is now "it works",
and that changes the deliverable rather than confirming a fallback.

The real risk is **expiry**, not CORS: the URL dies after some window, so
a paste sitting in a retry loop can go from fetchable to 403. Fetch
early, once, and fail loudly.

Server-side fetching stays out. It is an SSRF surface with its own threat
model and should not arrive as an implementation detail of a paste.

**"Degrade loudly" needs something to degrade into, and it does not
exist.** There is no paste report anywhere in the codebase, and §11.2's
import report is unbuilt. Both D4's failure path and D6's depend on it,
so it is a task with UI, catalog keys and a copy review — not a sentence.

## D7 — order, and why

**Tables, then links, then images.** Tables need no new safety property
and no new storage. Links buy D1 and pay for it with adversarial tests.
Images need D1, a new table, a new route, a sealing key, an upload path
and PDF work — and carry D6.

D1 is **not** its own branch. Revision 1 proposed landing it with no
caller "so it can be reviewed on its own"; AGENTS.md's own record is
against that — `devauth.go` was deleted for having zero readers. It is
the first commit of the links branch instead, reviewed as a commit.

## The shape of the work

### Task 1 — tables in the schema and on the wire
`fixtures/editor-entry.js` (the four node types, `tableEditing()`, Tab
and Shift-Tab bound by hand), `fixtures/package.json`,
`fixtures/tables.test.mjs`, the gate. Measure the bundle: roughly 40KB
minified against the current 320KB, and visible in the diff because §6.5
forbids a build step.

### Task 2 — tables in the three renderers
`core/src/fragment.rs`: the four arms, `colspan`/`rowspan` parsed and
clamped, `table_header` → `<th scope="col">`. `node_text` needs a ruling:
cells contain paragraphs, which already push their own entries into
`blocks`, so either add a mode or accept one block per cell in the
plain-text preview — the latter is fine for a list preview and should be
stated rather than discovered. `internal/export/markdown.go`: GFM tables,
whose delimiter row is mandatory and whose first row **is** the header —
so a Google Docs table with no `table_header` needs its first row
promoted. Pipes escape as `\|`. `colspan`, `rowspan`, multi-paragraph
cells and lists in cells are outside GFM entirely and the export must say
so rather than claiming losslessness. `internal/export/pdf.go`: **bound
this explicitly or it swallows the branch** — equal column widths from
the text column, cells wrap, a row that will not fit starts a new page,
no cell merging across pages, `colspan` rendered, `rowspan` degraded to
repeated cells.

### Task 3 — tables on screen
`views/show.html`: table CSS in the `[docs-text]` vocabulary, borders
that read as a document rather than a spreadsheet, `overflow-x: auto` on
a wrapper so a wide table scrolls inside the page, and `.selectedCell`.
`fixtures/toolbar.mjs`: insert table, add/remove row and column. WCAG 2.2
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.

### 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`,
`markdown.go`'s `[text](href)`, `pdf.go`'s link styling and `/Link`
annotation, `toolbar.mjs`'s add/remove link.

### Task 6 — the sealing key, the blob store and the route
**Blocked on Paul's key decision.** Then `models.go` plus a migration for
`document_blobs`; `blobs.Sealed` over `blobs.S3FromEnv()` in
`cmd/docs/main.go`, falling back to `blobs.Dir` for local runs;
`POST /d/{sqid}/blobs` and `GET /d/{sqid}/blobs/{hash}` in the
owner-scoped group, with the size cap, D1's sniffed type list and its
response headers. §12's plaintext-grep gate already covers bucket
objects.

### Task 7 — the paste report
The thing every "degrade loudly" path needs and none of them has. UI,
catalog keys, copy review.

### Task 8 — images from a file
`fixtures/image-upload.mjs` (decoration, upload, retry-then-drop, reusing
save-queue's shape) and its test; insertion on success at the mapped
position; `fragment.rs`, `markdown.go` per D5.

### Task 9 — images from Google Docs
Only after D6's fetch test. The cross-origin fetch, its expiry handling,
and the report entry when it fails.

### Task 10 — images in the PDF
`internal/pdf`'s uncompressed stream variant, `/XObject` in `/Resources`,
`/SMask` for alpha, the decode paths. Its own task because it is its own
body of work.

### Task 11 — copying an image between documents
Recognise a same-origin `/d/…/blobs/…` src from another document, fetch
and re-upload under this one. Or an explicit "not in this slice" line —
but not silence.

## Risks

| Risk | Where | What to do |
|---|---|---|
| The Docs fetch behaves differently from the probe | D6 | Test a real Doc image **first**. It sets the shape of Tasks 9 and 7. |
| A Docs URL expires mid-retry | D6 | Fetch early, once, fail loudly. |
| No sealing key decision | D4, Task 6 | Paul's call. Blocks Task 6. |
| PDF tables run away | Task 2 | The explicit bound above. |
| PDF images touch `internal/pdf`'s stream and resource assumptions | Task 10 | Its own task, not a corner of another. |
| `validate: "string"` on `image.src`/`link.href` | All | A raw update with a non-string attr makes y-prosemirror **delete the type**. Pre-existing, but links and images widen it from "attrs the editor never writes" to "attrs every paste writes". Not this plan's to fix; recorded so it is not rediscovered as a mystery. |
| Two `prosemirror-model` copies | Task 1 | `npm ls prosemirror-model` after install. Breaks silently. |
| Two branches inventing the same vocabulary | All | Discussion #20 (`worktree-import-documents`) builds DOCX import, which produces these same nodes. Agree names and attrs there first. |

## What this plan does not do

Headers and footers, page setup, comments, footnotes and tracked changes.
Column resizing (D2). Image dimensions and lazy loading (D5).
Server-side image fetching (D6). Underline, strikethrough, colour and
highlight, which are a separate question about the attribute-free `MARKS`
discipline rather than about these three nodes.
