Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 20 additions & 6 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,12 @@ extend this: shared collections are **viewer-facing** for signed-in users (both-
collection slugs) and **collaborative** — the root's access list doubles as its **contributor**
list (place-own-artefacts-only; CL12), containment is co-owned (owner may eject, CL13), and
lifecycle cascades **evict foreign artefacts instead of touching them** (CL14). Context:
`docs/specs/ddd/artefact-collections.md`. New work
`docs/specs/ddd/artefact-collections.md`. **S30** (**Export artefact HTML**) is **done**:
`GET /api/artefacts/:ref/download` returns the **stored** payload verbatim (no S13 bootstrap,
no S12 shell) under the same access matrix as the data reads — archived 404s for the owner
too (AH7, no carve-out) — plus the MCP read-back tools `get_artefact_html` /
`get_artefact_data` and the **migrate-forward** doctrine + **declared data schema** convention
(`<script type="application/artefactor-schema+json">`) in the skill. New work
either implements a pending enabler seam or adds a new slice (with its governing DDD invariant)
before coding, per the spec-driven process below.

Expand Down Expand Up @@ -52,16 +57,23 @@ adapters in `src/server/adapters.ts`. **Programmatic access (S18)** is a remote
bearer (BetterAuth's `mcp` plugin — discovery at `/.well-known/oauth-*`, dynamic client
registration, authorize/consent/token under `/api/auth/mcp/*`, OIDC tables
`oauth_application|oauth_access_token|oauth_consent`). Tools in `src/server/mcp/` wrap the
existing Hosting commands (create/update/list/get/set-visibility/archive/restore), each
attributed to the token's Account. Because connector-only clients (e.g. Claude design) **can't
existing Hosting commands (create/update/list/get/set-visibility/archive/restore) plus the
**S30 read-back pair** — `get_artefact_html` (the stored HTML) and `get_artefact_data` (the
caller's **own** blob verbatim + the artefact's declared schema + the
`currentPayloadVersion`/`authoredAgainstVersion` pin, the latter reserved and `null` until
S19) — each attributed to the token's Account. Both read-back tools hard-error above a context
cap (~1 MB HTML / 256 KB blob) instead of truncating, pointing at the GUI download. Because connector-only clients (e.g. Claude design) **can't
load the `artefactor` Agent Skill**, the connector self-describes its authoring contract: the
MCP server's `instructions` carry a compact persistence summary (ambient, present before any
tool call) and a `get_authoring_guide` tool returns the full `skills/artefactor/SKILL.md` body
on demand (the Dockerfile copies `skills/` into the runtime image for this). The short summary
in `src/server/mcp/authoring-guide.ts` and the skill are kept in sync (same no-drift rule). **Data blobs stay opaque** — there is no data-write tool
and no merge-patch (a backend merge would break opacity); `get_artefact`/`update_artefact`
return `dataAuthorCount` so a breaking HTML change can be flagged, and the artefact owns its
own data-shape compatibility (versioned localStorage keys). The old **S8/S9** (API-key REST
own data-shape compatibility (versioned localStorage keys + a forward migration shipped in its
own HTML). S30's `get_artefact_data` **reads** a snapshot of the caller's own blob without
interpreting it, which is not a write tool; an actual `set_artefact_data` is a separate slice
(S31) and would amend this decision deliberately. The old **S8/S9** (API-key REST
push) and **S17** (data merge-patch) are **dropped**. See `docs/specs/fdd/slice-dag.md`.

**The client UI (Svelte SPA, `src/client`) is built and is the human-facing app** — not a stub.
Expand All @@ -74,7 +86,9 @@ switcher; manage-access (`ManageAccessModal.svelte`); and archive / restore / pe
to get an artefact that embeds **raster images** into Artefactor, because the MCP connector
("Path A") cannot carry base64 image bytes through a tool call. Both paths run the same
create/edit commands, so invariants are identical. (See the two-path guidance in
`skills/artefactor/SKILL.md` and the MCP `instructions`.)
`skills/artefactor/SKILL.md` and the MCP `instructions`.) S30 adds the **return** trip:
"Download HTML" in an owned artefact's `MoreMenu` (a plain anchor — session-cookie auth) hands
back the stored document byte-for-byte, so download → edit → re-upload round-trips.

Development is **spec-driven**: locate the governing
DDD invariant and FDD slice before coding, build test-first, and keep spec ↔ tests ↔ code in
Expand Down Expand Up @@ -104,7 +118,7 @@ The domain and build plan live in `docs/specs/` and are the **source of truth**:
- `docs/specs/ddd/` — domain model: ubiquitous language, the **Identity & Access**,
**Artefact Hosting**, and **Artefact Data** bounded contexts, with aggregates and
invariants.
- `docs/specs/fdd/slice-dag.md` — the feature slice DAG (S0–S18; S8/S9/S17 dropped) and per-slice acceptance
- `docs/specs/fdd/slice-dag.md` — the feature slice DAG (S0–S30; S8/S9/S17 dropped) and per-slice acceptance
criteria (the seeds for TDD tests) with build order. `s0-scaffold.md` has the full S0 spec.

`skills/artefactor/SKILL.md` is an Agent Skill for the **authoring + publishing** side
Expand Down
74 changes: 71 additions & 3 deletions docs/specs/ddd/artefact-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,64 @@ artefact itself stays opaque and single-dataset.
> blob's structure, breaking opacity. Writes are whole-blob `PUT`s. When an artefact's data
> *shape* changes (e.g. the MCP connector replaces its HTML), the backend cannot and does not
> migrate existing blobs — compatibility is the **artefact's** responsibility (versioned
> `localStorage` keys; see `skills/artefactor`), and a genuinely breaking change is
> best published as a **new artefact** rather than edited in place.
> `localStorage` keys; see `skills/artefactor`). A breaking change is therefore best handled by
> a **forward migration shipped in the artefact's own HTML** (read the old key, transform,
> write the new one), which is the only place such a migration *can* live; publishing a
> separate **new artefact** keeps the old one intact for existing users.

### Snapshot read (S30)

An agent on the MCP connector may read a **snapshot** of the data — `get_artefact_data`. This
adds no authority and no interpretation:

- It returns the **caller's own entry only** (AD2/AD4), the same one `GET …/data/me` returns,
and **verbatim** (AD8). There is deliberately **no** server-side summarising and no key/type
digest: passing bytes through is transport, but *describing* their structure would be the
backend interpreting the blob.
- It is **one blob, not the population.** `dataAuthorCount` says how many other authors hold
data; their entries are never returned, and they may sit on older key versions, be partial,
or have been written by HTML two revisions back. Any migration written from a snapshot must
therefore tolerate shapes its author never saw.
- It refuses an over-cap result rather than truncating (a truncated blob is unparseable JSON,
which invites acting on a fragment as though it were whole).

### Declared data schema — a payload convention (S30)

An artefact may declare its own data shape in an inert block inside its **HTML**:

```html
<script type="application/artefactor-schema+json">
{ "key": "habit-tracker-v2", "version": 2, "description": "…",
"example": { "habits": [ { "id": "h1", "name": "Run" } ] } }
</script>
```

This is a **payload convention**, not a domain rule. The backend **may forward** the block (the
snapshot read returns it as parsed JSON, or `null` when it is absent, malformed, or not a JSON
object — never an error) but **never interprets or enforces it**: a blob is never validated
against a declared schema, because enforcing a schema would make the backend interpret the blob
and collapse AD8. An unknown script `type` is ignored by browsers, so the block is inert, and
because it lives in the trusted HTML it travels with an export automatically (see the export
read path in `artefact-hosting.md`).

It is best-effort by nature — a *second* representation of a truth that actually lives in the
JS, with nothing enforcing agreement — so a stale declaration produces a **confident** wrong
migration, which is worse than inference (inference fails visibly). The authoring rule is
therefore: schema and code are written in the same breath; a reader trusts the schema for
orientation and **verifies it against the HTML before any shape-changing write**. The block also
says nothing about the population, so the "tolerate shapes you never saw" rule above stands
regardless.

**Two version notions, reconciled.** The schema's `version` and AD9's `authoredAgainstVersion`
answer different questions and must not be conflated:

| | Says | Set by |
|--|------|--------|
| `authoredAgainstVersion` (AD9) | this blob was written against payload hash X | the **backend**, on every write |
| schema `version` | this HTML expects shape v2 | the **authoring agent**, by hand |

They are complementary: the pin is *mechanical* and per-entry (is this user's data stale?), the
declared version is *semantic* and per-payload (what shape does this HTML expect?). Keep both.

## Artefact runtime contract

Expand Down Expand Up @@ -159,6 +215,9 @@ read-only (AD5).
- **Cross-user viewing is a host feature**, not an artefact capability: a host user-picker
loads another author's data **read-only** by re-seeding the artefact. The artefact never
knows whose data it holds.
- **A snapshot may be read, never interpreted (S30).** The connector can return the caller's
own blob verbatim and forward the artefact's declared schema block, but the backend neither
summarises the blob nor validates it against that schema — AD8 is unchanged.

## Amendment (post-v0.2) — payload version pin

Expand All @@ -185,7 +244,16 @@ host can detect the mismatch.
**Use.**
- **OSS:** even with a single mutable payload, the host can tell whether a viewer's saved data
**predates the current payload** (pin ≠ current hash) — a sharper form of the `dataAuthorCount`
breaking-change signal already exposed to the MCP connector.
breaking-change signal already exposed to the MCP connector. **S30 reserves this field
without depending on it**: the snapshot read already returns the pair
(`currentPayloadVersion`, `authoredAgainstVersion`), with the pin `null` until this slice
lands. That is sound precisely because AD9 is advisory and gates nothing — when S19 ships,
the pin populates with no change to the tool's shape and no rewrite of the doctrine written
against it (`null` ⇒ unknown, treat as possibly stale; ≠ current ⇒ written against older
HTML, migration owed; = current ⇒ matches what is deployed).

Do not conflate this pin with the **declared schema's** `version` (see "Declared data schema"
above): this one is mechanical and backend-set, that one semantic and author-set.
- **Superset (history / rollback):** before serving an old or rolled-back version, compare the
seeded entry's pin to the target version to decide whether the data is compatible (seed it, or
warn / seed cautiously). This is the **pin-for-compatibility** decision — there is deliberately
Expand Down
11 changes: 11 additions & 0 deletions docs/specs/ddd/artefact-hosting.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,17 @@ States are the product of `visibility × status`. Allowed transitions:
concern, not a security boundary.
- Because artefacts are served **same-origin**, their JS can call the backend store (see
`artefact-data.md`) carrying the viewer's session — this is how forms persist data.
- **Export (S30).** Alongside the render there is a second read path: the **export**, which
returns the **stored payload** — never the injected render. It is governed by the same
matrix as the render (AH7/AH8/AH9, on the *effective* tier per AH20): unknown handle,
not-viewable, and **archived** all surface as a flat 404, with **no owner carve-out** for an
archived artefact (restore → export → re-archive is the escape hatch; AH7 keeps archived
inert). The distinction from the render is the point: the render injects the localStorage
bootstrap and wraps the artefact in the host shell, whereas the export is byte-identical to
what was uploaded, so export → edit → re-upload round-trips through the same create/edit
commands and yields the same artefact. A payload convention carried *inside* the HTML — such
as the declared data schema in `artefact-data.md` — therefore travels with the export for
free, with no extra plumbing.

## Relationship to Artefact Data

Expand Down
80 changes: 79 additions & 1 deletion docs/specs/fdd/slice-dag.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ S1 Identity (BetterAuth — email+password for dev; Google OAuth added later)
│
└────────────► S18 MCP connector (remote MCP server + OAuth via BetterAuth `mcp` plugin;
wraps the Hosting commands as tools — needs S2, S3, S4, S5, S7, S10)
│
└──► S30 Export artefact HTML (GUI download + MCP read-back
tools — needs S2, S4, S6, S11, S18)
┊
┊ (optional sharpener, NOT a dependency)
┄┄┄ S19 data version pin (AD9)

~~S8 Issue / revoke API key~~ and ~~S9 API push ingestion~~ are **dropped** — the pinned
better-auth has no api-key plugin and a raw token API was deemed unnecessary; programmatic
Expand Down Expand Up @@ -785,6 +791,75 @@ Deps: **S28.** (CL1 relaxed; CL12/CL13/CL14.)
not re-attach evicted artefacts.
- **Boundary:** **OSS**. No schema change (contributors ride `collection_access`).

### S30 — Export artefact HTML (GUI download + MCP read-back tools)
Deps: **S2, S4, S6, S11, S18.** (AH7/AH8/AH9 for the export read path; AD2/AD4/AD8 for the
data snapshot.) 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 (A) nor
safely update one in place after losing the original from context (B). (B) 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.

- **Domain** — `extractDeclaredSchema(html)`: a pure, best-effort lift of the artefact's
**declared data schema** block (below) from its trusted HTML. Absent/malformed/non-object
→ `null`, never an error. A payload *convention*, never enforced.
- **Shared** — `artefactFilename(title, fallback)` + `attachmentDisposition(filename)`: pure
download-naming helpers (slugify, bound, ASCII-fold, RFC 5987), unit-tested on their own.
- **BFF** — `GET /api/artefacts/:ref/download` (`:ref` = 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**. Body
= `PayloadStore.get(payloadRef)` **verbatim** — no S13 bootstrap, no S12 host shell, so the
download round-trips (download → edit → re-upload / `update_artefact` yields the same
artefact). `Content-Type: text/html; charset=UTF-8`; `Content-Disposition: attachment` with
both `filename` and `filename*`; `Content-Length` = `payloadBytes`. Unknown ref /
not-viewable / **archived** → flat 404.
- **MCP** — `get_artefact_html { id }` → `{ id, title, kind, html, dataAuthorCount }`;
`get_artefact_data { id }` → `{ id, blob, bytes, updatedAt, dataAuthorCount, schema,
currentPayloadVersion, authoredAgainstVersion }` — the caller's **own** entry only, verbatim
via `getOwnDataEntry`, with no server-side summarising or key/type digest (that would be the
backend interpreting the blob). Both owner-scoped via `loadOwnActiveArtefact`. Each
hard-errors above its 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 unparseable, and either invites the agent to act on
a fragment as though it were whole.
- **Doctrine** — `skills/artefactor/SKILL.md` + `PERSISTENCE_CONTRACT_SUMMARY`: **migrate
forward** now leads the breaking-change guidance (read old key → transform → write new;
idempotent, at load before first render, old key kept one generation, never `clear()`), the
snapshot is one blob and not the population, and the schema block joins the template +
checklist. Bumping the key without migrating is named for what it is — silent data loss.
- **Client** — "Download HTML" in `MoreMenu` (reaching `ArtefactRow` + `ArtefactCard`), owned
artefacts only. A plain anchor: session-cookie auth needs no fetch/blob dance.
- **Acceptance:** owner downloads own artefact by id at any visibility, byte-identical to what
was uploaded; viewer downloads a shared artefact by slug; anonymous downloads a `public` one;
private-to-a-non-owner, **archived (including for the owner)**, and unknown ref all 404; both
`Content-Disposition` forms present and `Content-Length` = `payloadBytes`; no bootstrap or
shell in the body; filename helper covers ASCII / non-ASCII (åäö) / symbol-only (falls back
to slug-or-id) / 300-char (bounded) / always `.html`. `get_artefact_html` returns the exact
stored HTML with `dataAuthorCount`; `get_artefact_data` returns the caller's own blob
verbatim, `blob: null` when they have none, never another author's; over-cap on either →
error naming the actual size; non-owner / unknown / archived / out-of-scope → not found;
`schema` is parsed JSON when present and `null` when absent, malformed, or not valid JSON —
**never** an error; a blob is never validated against a declared schema (AD8 holds); an
artefact with a declared schema survives export → re-upload intact; `currentPayloadVersion`
equals `payloadHash` and `authoredAgainstVersion` is `null` while S19 is unbuilt (both
fields' presence and shape asserted).
- **Archived stays inert (AH7)** — no owner carve-out. Restore → download → re-archive is one
click, which is not worth an exception in AH7 for an escape hatch.
- **On S19/AD9 — reserve, don't depend.** `get_artefact_data` returns the version-pin **pair**
but S19 is **not** a dependency edge, and the missing edge is deliberate, not an oversight:
AD9 is *advisory by spec* and never gates a read or write, so nothing here is incorrect while
the pin is `null`; and S19 also carries the unrelated AH15 `PayloadRetentionPolicy` port in
the edit command, which read-back has no business pulling in. `currentPayloadVersion` is free
today (`payloadHash` is already on the aggregate). When S19 lands, the pin populates with
**no tool-shape change and no doctrine rewrite** — the rule "pin present and ≠ current ⇒ that
user's data predates this payload" is written now and becomes true then.
- **Out of scope:** the download affordance for "shared with you" (`GalleryCard`/`GalleryRow`)
and the `/a/:slug` shell toolbar (the endpoint already honours the matrix — widening is
client-only); baking a data snapshot into the downloaded file; any data **write** tool (S31);
a per-artefact "allow download" toggle (new field + invariant + migration, and defeated by
view-source anyway).
- **Boundary:** **OSS**. No schema change.

## Build order

Topological: **S0 → S1 → S2 → {S3, S4, S5, S7, S10, S11}**, **S5 → {S6, S14, S16}**,
Expand All @@ -804,4 +879,7 @@ dependency of the EE **Postgres persistence** context. **S25** (collections) dep
hosting core + sharing (**S2/S5/S6/S10/S16**); **S26** (collection lifecycle) on **S25 + S15**;
**S27** (bookmarks) on **S25**; **S28** (viewer-facing shared collections) on **S25** and
**S29** (contributors + evict-on-cascade) on **S28**. All five are OSS feature slices
(context `ddd/artefact-collections.md`).
(context `ddd/artefact-collections.md`). **S30** (export HTML) depends on the hosting read
path + the data store + the connector (**S2/S4/S6/S11/S18**); **S19** is an *optional
sharpener* of S30's staleness signal, **not** a dependency edge (see the slice's
reserve-don't-depend note).
Loading