S30 — Export artefact HTML: GUI download + MCP read-back tools - #12
Merged
Merged
Conversation
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_artefactbut 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_artefactreplaces 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:refis a slug or id. Resolution and access reuseresolveViewableArtefact, so effective-tier resolution through a collection root (AH20) and theAccessPolicycell (AH18) are inherited, not re-implemented. The body isPayloadStore.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.tsemits bothfilenameand the RFC 5987filename*, unit-tested on its own.2. MCP read-back —
get_artefact_html+get_artefact_dataBoth owner-scoped via
loadOwnActiveArtefact, consistent with every existing tool.get_artefact_datareturns the caller's own entry only (AD2/AD4), verbatim throughgetOwnDataEntry— 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 pinnulluntil 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.mdandPERSISTENCE_CONTRACT_SUMMARYnow 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, neverclear(). 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, ornullwhen 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, reachingArtefactRow+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-declaredversion.CLAUDE.md— status line + MCP tool list.Verification
pnpm checkclean — re-run after rebasing onto currentmain.shasum), correctContent-Type, bothContent-Dispositionfilename forms for a non-ASCII title, andContent-Length.Two implementation notes for review
/artefactsdeclaring/:ref/download. Hono'sapp.route("/artefacts/:ref/download", sub)with a"/"sub-route does not match, unlike the/datamount whose sub-routes carry a segment.Content-Lengthcomes from the bytes being sent rather than the row'spayloadBytes, so a store/row disagreement can't produce a malformed response. The test still asserts it equalspayloadBytes, which now also proves the two agree.Out of scope
Download for "shared with you" / the
/a/:slugtoolbar (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