Skip to content

S30 — Export artefact HTML: GUI download + MCP read-back tools - #12

Merged
oskarhagberg merged 1 commit into
mainfrom
worktree-ali-266-artefact-export-html
Sep 12, 2026
Merged

oskarhagberg merged 1 commit into
mainfrom
worktree-ali-266-artefact-export-html

Conversation

@oskarhagberg

Copy link
Copy Markdown
Collaborator

Closes ALI-266.

Artefactor could take HTML in but never give it back. The client had no download affordance, and an agent on the connector could create_artefact/update_artefact but never read one — so it could neither derive a new artefact from an existing one (A) nor safely update one in place after losing the original from context (B). (B) is a correctness problem, not a convenience one: update_artefact replaces the HTML and leaves every per-user data blob untouched, and the backend treats blobs as opaque (AD8), so it cannot migrate them.

1. Export endpoint — GET /api/artefacts/:ref/download

:ref is a slug or id. Resolution and access reuse resolveViewableArtefact, so effective-tier resolution through a collection root (AH20) and the AccessPolicy cell (AH18) are inherited, not re-implemented. The body is PayloadStore.get() verbatim — never the injected render: no S13 localStorage bootstrap, no S12 host shell — so download → edit → re-upload round-trips. Unknown ref / not-viewable / archived → flat 404, with no owner carve-out for archived (AH7 keeps it inert; restore → download → re-archive is one click).

New pure helper src/shared/artefact-filename.ts emits both filename and the RFC 5987 filename*, unit-tested on its own.

2. MCP read-back — get_artefact_html + get_artefact_data

Both owner-scoped via loadOwnActiveArtefact, consistent with every existing tool. get_artefact_data returns the caller's own entry only (AD2/AD4), verbatim through getOwnDataEntry — no server-side summarising or key/type digest, since passing bytes through is transport but describing their structure would be the backend interpreting the blob.

Each hard-errors above its context cap (MAX_MCP_HTML_BYTES ≈ 1 MB, MAX_MCP_BLOB_BYTES ≈ 256 KB), naming the real size and pointing at the GUI download. No truncation — truncated HTML is unusable for editing and truncated JSON is unparseable, and either invites the agent to act on a fragment as though it were whole.

AD9 is reserved, not depended on. The tool returns the pair (currentPayloadVersion, authoredAgainstVersion), the pin null until S19 (ALI-269). Sound because AD9 is advisory by spec and gates nothing, and because S19 also carries the unrelated AH15 retention port that read-back has no business pulling in. Tests assert both fields' presence and shape, so S19 populates the pin with no tool-shape change and no doctrine rewrite.

3. Doctrine + declared data schema

SKILL.md and PERSISTENCE_CONTRACT_SUMMARY now lead with migrate forward — read the old key, transform, write the new one; idempotent, run at load before first render, old key kept one generation, never clear(). Bumping the storage key without migrating is named for what it is: silent loss of every user's saved data. Two further rules: the snapshot is one blob, not the population, so a migration must tolerate shapes its author never saw; and the declared schema is trusted for orientation but verified against the HTML before any shape-changing write.

An artefact may declare its own shape in an inert <script type="application/artefactor-schema+json"> block. A payload convention: the backend forwards it (as parsed JSON, or null when absent/malformed — never an error) but never validates a blob against it, which would collapse AD8. It lives in the trusted HTML, so it travels with the export for free.

Client

"Download HTML" in MoreMenu, reaching ArtefactRow + ArtefactCard — owned artefacts only. A plain anchor; session-cookie auth needs no fetch/blob dance.

Specs (same change, no drift)

  • fdd/slice-dag.md — S30 with acceptance criteria and edges, plus the reserve-don't-depend decision recorded as an explicit non-edge to S19 so the missing edge doesn't read as an oversight.
  • ddd/artefact-hosting.md — the export read path under "Serving model" (AH7/AH8/AH9; stored payload, never the injected render).
  • ddd/artefact-data.md — the snapshot read (AD2/AD4, verbatim, AD8 preserved), the declared schema as a payload convention, and a table reconciling the mechanical backend-set pin against the semantic author-declared version.
  • CLAUDE.md — status line + MCP tool list.

Verification

  • 342 tests pass (39 files), pnpm check clean — re-run after rebasing onto current main.
  • Built server checked end-to-end: upload → download returns a byte-identical file (matching shasum), correct Content-Type, both Content-Disposition filename forms for a non-ASCII title, and Content-Length.
  • The browser extension wasn't available, so the menu item was verified by confirming it compiled into the shipped client bundle with the right href, not by clicking it.

Two implementation notes for review

  • The route is mounted under /artefacts declaring /:ref/download. Hono's app.route("/artefacts/:ref/download", sub) with a "/" sub-route does not match, unlike the /data mount whose sub-routes carry a segment.
  • Content-Length comes from the bytes being sent rather than the row's payloadBytes, so a store/row disagreement can't produce a malformed response. The test still asserts it equals payloadBytes, which now also proves the two agree.

Out of scope

Download for "shared with you" / the /a/:slug toolbar (endpoint already honours the matrix — widening is client-only); baking a data snapshot into the file; any data write tool (ALI-268); a per-artefact "allow download" toggle.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FPEYPdsySg5waxY7qqKyuH

Artefactor could take HTML in but never give it back. The client had no
download affordance, and an agent on the connector could create/update an
artefact but never *read* one — so it could neither derive a new artefact from
an existing one nor safely update one in place after losing the original from
context. The latter is a correctness problem: `update_artefact` replaces the
HTML and leaves every per-user data blob untouched, and the backend treats
blobs as opaque (AD8), so it cannot migrate them.

Export endpoint — `GET /api/artefacts/:ref/download` (slug or id). Resolution
and access reuse `resolveViewableArtefact`, so effective-tier resolution
through a collection root (AH20) and the `AccessPolicy` cell (AH18) are
inherited rather than re-implemented. The body is `PayloadStore.get()`
**verbatim** — never the injected render: no S13 bootstrap, no S12 shell — so
download → edit → re-upload round-trips. Unknown ref, not-viewable, and
archived are a flat 404, with no owner carve-out for archived (AH7 keeps it
inert; restore → download → re-archive is one click). New pure filename helper
in `src/shared/` emits both `filename` and the RFC 5987 `filename*`.

MCP read-back — `get_artefact_html` and `get_artefact_data`, both owner-scoped
via `loadOwnActiveArtefact`. The data tool returns the caller's OWN entry only
(AD2/AD4), verbatim through `getOwnDataEntry`, with no server-side summarising
or key/type digest: passing bytes through is transport, describing their
structure would be the backend interpreting the blob. Each hard-errors above
its context cap (~1 MB HTML, 256 KB blob) naming the real size and pointing at
the GUI download — no truncation, since truncated HTML can't be edited and
truncated JSON can't be parsed.

Declared data schema — an artefact may state its own data shape in an inert
`<script type="application/artefactor-schema+json">` block, extracted
best-effort (absent/malformed/non-object → null, never an error). A payload
*convention*: the backend forwards it but never validates a blob against it,
which would collapse AD8. It lives in the trusted HTML, so it travels with the
export for free.

Migration doctrine — SKILL.md and PERSISTENCE_CONTRACT_SUMMARY now lead with
**migrate forward** (read old key, transform, write new; idempotent, at load
before first render, old key kept one generation, never clear()). Bumping the
storage key without migrating is named for what it is: silent loss of every
user's saved data. Two further rules: the snapshot is one blob and not the
population, so a migration must tolerate shapes its author never saw; and the
declared schema is trusted for orientation but verified against the HTML
before any shape-changing write.

AD9 is reserved, not depended on. `get_artefact_data` returns the pair
(`currentPayloadVersion`, `authoredAgainstVersion`), the pin `null` until S19
(ALI-269). Sound because AD9 is advisory and gates nothing, and S19 also
carries the unrelated AH15 retention port that read-back has no business
pulling in. Tests assert both fields' presence and shape, so S19 populates the
pin with no tool-shape change and no doctrine rewrite.

Client — "Download HTML" in MoreMenu, reaching ArtefactRow + ArtefactCard,
owned artefacts only. A plain anchor: session-cookie auth needs no fetch/blob
dance.

Specs updated in the same change: S30 in the slice DAG (with the
reserve-don't-depend decision recorded as an explicit non-edge to S19), the
export read path under Hosting's serving model, and in artefact-data.md the
snapshot read, the schema convention, and a table reconciling the mechanical
backend-set pin against the semantic author-declared `version`.

Two notes on the implementation. The route is mounted under `/artefacts`
declaring `/:ref/download`: Hono's `app.route("/artefacts/:ref/download", sub)`
with a `"/"` sub-route does not match, unlike the `/data` mount whose
sub-routes carry a segment. And `Content-Length` is taken from the bytes being
sent rather than the row's `payloadBytes`, so a store/row disagreement cannot
produce a malformed response; the test still asserts it equals `payloadBytes`,
which now also proves the two agree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FPEYPdsySg5waxY7qqKyuH
@oskarhagberg
oskarhagberg merged commit 02a2d8d into main Sep 12, 2026
1 check passed
@oskarhagberg
oskarhagberg deleted the worktree-ali-266-artefact-export-html branch September 12, 2026 19:04
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant