Skip to content
Closed
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
10 changes: 6 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ points that let a closed superset extend the core without forking it** (each shi
that keeps OSS behaviour byte-identical). **S24** (inject persistence ports into the composition)
and **S22** (tenant scope + access-policy seams — scope-aware repo reads, the `AccessPolicy` port
deciding the one overridable `authenticated`-tier cell, and the allow-all sign-up option) are
**done**; **S19** (payload-retention seam) and **S23** (quota / payload-size / branding policies)
are **specced but not yet implemented**. **S25–S27**
**done**; **S19** is **half done** — its **data version pin** (AD9: every `putOwnDataEntry`
write stamps `DataEntry.authoredAgainstVersion` with the artefact's payload hash; advisory,
surfaced by `get_artefact_data`) has shipped, while its **payload-retention seam** (AH15) and
**S23** (quota / payload-size / branding policies) are **specced but not yet implemented**. **S25–S27**
(**Collections & Bookmarks**) are **done**: an owner-only nestable folder tree whose **root's**
access (all four tiers incl. `selected`) the contained artefacts **inherit at read time**
(AH20/AH21 — own tier dormant, slug minted on effective share), archive/restore/permanent-delete
Expand Down Expand Up @@ -66,8 +68,8 @@ registration, authorize/consent/token under `/api/auth/mcp/*`, OIDC tables
existing Hosting commands (create/update/list/get/set-visibility/archive/restore), the S31 `set_artefact_data` write, 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
`currentPayloadVersion`/`authoredAgainstVersion` pin — the latter stamped on every data write by
S19, `null` for no entry or a pre-pin entry) — 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
Expand Down
37 changes: 21 additions & 16 deletions docs/specs/ddd/artefact-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,8 +195,8 @@ saved** in the seconds between its read and its write — not merely that the ar
After a successful agent write, an open tab keeps showing the old data until reloaded; its
next save is refused and the host shell prompts the reload.

**AD9.** Because the tool goes through `putOwnDataEntry`, once S19 lands a connector write
stamps `authoredAgainstVersion` exactly as a shim write does — no connector-specific path.
**AD9.** Because the tool goes through `putOwnDataEntry`, a connector write stamps
`authoredAgainstVersion` exactly as a shim write does (S19) — no connector-specific path.

## Artefact runtime contract

Expand Down Expand Up @@ -299,9 +299,9 @@ read-only (AD5).

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

> **Status:** DDD amendment (FDD slice **S19**). A small **additive** field on `DataEntry` —
> harmless in OSS, and the hook a superset's rollback uses to judge data compatibility. It does
> not weaken opacity.
> **Status:** **implemented** (FDD slice **S19**, AD9 half; the AH15 retention half of S19 is
> still pending). A small **additive** field on `DataEntry` — harmless in OSS, and the hook a
> superset's rollback uses to judge data compatibility. It does not weaken opacity.

**Problem.** A `DataEntry.blob` is shaped by whatever artefact payload was live when it was
written. When the payload later changes shape — edited in place, or (in the superset) rolled
Expand All @@ -313,22 +313,27 @@ host can detect the mismatch.

| Field | Type | Notes |
|-------|------|-------|
| `authoredAgainstVersion` | ContentHash \| null | The artefact's **payload content hash** at the moment of the (upsert) write. `null` for entries written before this field existed. Opaque — it names a payload, never describes the blob. |
| `authoredAgainstVersion` | ContentHash \| null | The artefact's **payload content hash** at the moment of the (upsert) write, re-stamped on every write. `null` for entries written before this field existed (no backfill — which payload they were written against is unknowable). Opaque — it names a payload, never describes the blob. |

**AD9 — pin on write.** Every `PUT …/data/me` sets `authoredAgainstVersion` to the artefact's
*current* payload content hash. It is **advisory metadata only**: it never gates a read or write
(AD3–AD5 unchanged) and the backend still never interprets the blob (AD8 holds).
**AD9 — pin on write.** Every write through `putOwnDataEntry` — `PUT …/data/me` (the served
shim) and, identically, the connector's `set_artefact_data` (S31) — sets
`authoredAgainstVersion` to the artefact's *current* payload content hash. There is no
connector-specific path. It is **advisory metadata only**: it never gates a read or write
(AD3–AD5 unchanged), and the backend still never interprets the blob (AD8 holds). Because it is
a **content** hash, an edit that restores byte-identical HTML makes an older pin current again.
That is correct, since the blob matches that HTML. The pin is not exposed on the BFF
`DataEntryResponse` or the S12 author list; today only the connector's snapshot read consumes it.

**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. **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).
breaking-change signal already exposed to the MCP connector. **S30 reserved this field
without depending on it**: the snapshot read returned the pair (`currentPayloadVersion`,
`authoredAgainstVersion`) with the pin `null` before this slice existed. That was sound
precisely because AD9 is advisory and gates nothing. S19 populated the pin with no change to
the tool's shape and no rewrite of the doctrine written against it (`null` ⇒ no entry, or one
that predates the pin: unknown, so treat it 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.
Expand Down
68 changes: 45 additions & 23 deletions docs/specs/fdd/slice-dag.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@ S1 Identity (BetterAuth — email+password for dev; Google OAuth added later)
tools — needs S2, S4, S6, S11, S18)
│ ┊
│ ┊ (optional sharpener, NOT a dependency)
│ ┄┄┄ S19 data version pin (AD9)
│ ┄┄┄ S19 data version pin (AD9 — done;
│ AH15 retention seam still pending)
│
└──► S31 Agent edits data: set_artefact_data
(needs S11, S18, S30; S19 likewise
Expand Down Expand Up @@ -503,24 +504,42 @@ flagged (see S18).
with `ddd/artefact-data.md`, the tools in `src/server/mcp/`, and the `instructions` summary in
`src/server/mcp/authoring-guide.ts` — same no-drift rule as specs).

### S19 — Payload-retention seam + data version pin *(enabler; behaviour-preserving)*
### S19 — Payload-retention seam + data version pin *(enabler; behaviour-preserving)* — **data pin done; retention seam pending**
The single core change that makes artefact **history / rollback** buildable by a superset,
without adding versioning to OSS. (DDD amendments: `ddd/artefact-hosting.md` AH15,
`ddd/artefact-data.md` AD9.)
- **Hosting — retention seam.** Replace the unconditional delete of the superseded payload in
`edit-artefact.command.ts` with a **`PayloadRetentionPolicy`** port. OSS wires the default
`DiscardSupersededPayload` (deletes — **byte-identical behaviour**); the seam is the one place
a superset swaps in a retaining policy. The artefact still has exactly one head payload. *(AH 15)*
- **Data — version pin.** `DataEntry` gains `authoredAgainstVersion`; every `PUT …/data/me`
stamps it with the artefact's current payload content hash. Advisory only — opacity and the
read/write access rules are unchanged. *(AD 9)*
- **Acceptance:** edit still leaves exactly one payload file under the default policy (no orphan,
no retained file); a fresh data write records the current payload hash; an entry written before
a subsequent edit reads back a pin ≠ the new hash (the staleness signal); permanent delete still
erases payload + data, and the policy is given the chance to purge anything it retained.
- **Boundary:** this slice is **OSS** (the seam must live where the deletion does). The retaining
policy, the version store, and rollback are the **EE** *Artefact History* context — see
`ee/docs/specs/`. Migration adds the nullable `authoredAgainstVersion` column.
`ddd/artefact-data.md` AD9.) The two halves are independent and **ship apart**: the AD9 data
pin is **done** (ALI-269); the AH15 retention seam is **pending**. The EE *Artefact History*
context needs **both**, so the pin alone doesn't unblock E1.
- **Hosting — retention seam** *(pending)*. Replace the unconditional delete of the superseded
payload in `edit-artefact.command.ts` with a **`PayloadRetentionPolicy`** port. OSS wires the
default `DiscardSupersededPayload` (deletes — **byte-identical behaviour**); the seam is the one
place a superset swaps in a retaining policy. The artefact still has exactly one head payload.
*(AH 15)*
- **Acceptance:** edit still leaves exactly one payload file under the default policy (no
orphan, no retained file); permanent delete still erases payload + data, and the policy gets
the chance to purge anything it retained.
- **Data — version pin** *(done)*. `DataEntry` gains `authoredAgainstVersion`. `upsertDataEntry`
requires it, so no write path can skip it, and `putOwnDataEntry` stamps it with the resolved
artefact's `payloadHash`. That one site covers `PUT …/data/me` **and** `set_artefact_data`
(S31), with no connector-specific path. Advisory only: opacity and the read/write access rules
are unchanged. The Drizzle upsert's `ON CONFLICT DO UPDATE SET` carries the pin, so it
**re-stamps** on update and doesn't freeze at the first write. `get_artefact_data` now returns
the entry's pin in the field S30 reserved (same shape). Not exposed on the BFF
`DataEntryResponse` or the S12 author list (no consumer). *(AD 9)*
- **Acceptance:** a fresh data write records the current payload hash; an entry written before
a subsequent payload edit reads back a pin ≠ the new hash (the staleness signal), and the
next write re-stamps it; an entry predating the column reads `null` and is still read and
written normally (its next write stamps it); the pin never grants or refuses access (a stale
pin doesn't block its author; a current one doesn't admit a non-viewer); the Drizzle adapter
re-stamps on update; `get_artefact_data` returns `null` with no entry, `= currentPayloadVersion`
after a write, and `≠` after `update_artefact` replaces the HTML; `set_artefact_data` and a
direct `putOwnDataEntry` stamp the same pin.
- **Persistence:** migration `0008` adds the nullable `authored_against_version` column (no
backfill). The EE Postgres mirror (`pg-schema.ts` + `PgDataRepository`) carries the same
column and mapping (P3 parity).
- **Boundary:** this slice is **OSS** (the seam must live where the deletion does, and the pin
where the write does). The retaining policy, the version store, and rollback are the **EE**
*Artefact History* context — see `ee/docs/specs/`.

### S20 — Hide the data-context switcher for non-persisting artefacts
Stop showing the "Data context" picker (S12 chrome) on artefacts that can't usefully use it.
Expand Down Expand Up @@ -854,8 +873,8 @@ and the backend treats blobs as opaque (AD8), so it cannot migrate them.
`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).
equals `payloadHash`, and `authoredAgainstVersion` is present with its shape asserted (its
value was `null` until the S19 data pin; S19's own acceptance now covers the populated value).
- **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**
Expand All @@ -865,7 +884,8 @@ and the backend treats blobs as opaque (AD8), so it cannot migrate them.
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.
user's data predates this payload" is written now and becomes true then. *(Borne out: the
S19 data pin shipped on its own, ahead of AH15, as a one-line change to this tool.)*
- **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);
Expand Down Expand Up @@ -927,8 +947,9 @@ quarter"), write the whole blob back.
not found; a tool-written blob — including one built from the declared schema's `example`
into an empty entry — is what the served artefact's localStorage shim seeds.
- **On S19/AD9 — sharpener, not dependency** (as S30). The write path stamps the pin for free
once S19 exists, because it is the same `putOwnDataEntry`; S19's own tests assert it. The
doctrine holds either way.
once S19 exists, because it is the same `putOwnDataEntry`; S19's own tests assert it
(`set_artefact_data` stamps exactly as `PUT …/data/me` does, and this tool needed no change).
The doctrine holds either way.
- **Open question, decided: owner-scoped v1.** `putOwnDataEntry` already permits writing your
own blob on any viewable artefact, but a write reaching further than the owner-scoped read
would break read-modify-write exactly where the reach was wanted. Widening read + write
Expand Down Expand Up @@ -1068,7 +1089,8 @@ independent of the sharing branch and can proceed in parallel once S2 exists. ~~
keys) and ~~S17~~ (data merge-patch) are dropped — see the DAG note. S18 is the programmatic
surface. **S19** (retention seam + data pin) depends only on **S3** (the edit/replace path) and
**S11** (`DataEntry`); it is behaviour-preserving in OSS and is the sole core dependency of the
EE *Artefact History* context. **S22** (tenant scope + access-policy seam) depends on the repo +
EE *Artefact History* context. Its halves ship apart: the **S11**-side data pin is done, and the
**S3**-side retention seam is pending. **S22** (tenant scope + access-policy seam) depends on the repo +
serving/access path (**S6/S10/S14**) and **S23** (EE policy seams) on the create/edit commands
(**S2/S3**) + the S12 shell; both are behaviour-preserving enablers and the sole core dependencies
of the EE **Tenancy/Organizations** and **Usage & Quota** contexts respectively. **S24** (inject
Expand Down
42 changes: 38 additions & 4 deletions src/domain/data/data-entry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ describe("upsertDataEntry (AD1)", () => {
artefactId: "a1",
authorId: "u1",
blob: "{}",
authoredAgainstVersion: null,
now,
});
expect(e).toMatchObject({ id: "d1", artefactId: "a1", authorId: "u1", blob: "{}" });
Expand All @@ -44,6 +45,7 @@ describe("upsertDataEntry (AD1)", () => {
artefactId: "a1",
authorId: "u1",
blob: "{}",
authoredAgainstVersion: null,
now: new Date("2026-01-01T00:00:00Z"),
});
const later = new Date("2026-02-01T00:00:00Z");
Expand All @@ -52,6 +54,7 @@ describe("upsertDataEntry (AD1)", () => {
artefactId: "a1",
authorId: "u1",
blob: '{"v":2}',
authoredAgainstVersion: null,
existing: created,
now: later,
});
Expand All @@ -63,26 +66,57 @@ describe("upsertDataEntry (AD1)", () => {

it("validates the blob before upserting (AD8)", () => {
expect(() =>
upsertDataEntry({ id: "d", artefactId: "a", authorId: "u", blob: "nope" }),
upsertDataEntry({ id: "d", artefactId: "a", authorId: "u", blob: "nope", authoredAgainstVersion: null }),
).toThrow(InvalidBlob);
});
});

describe("upsertDataEntry — payload version pin (AD9)", () => {
const base = { id: "d1", artefactId: "a1", authorId: "u1", blob: "{}" };

it("stamps a new entry with the payload hash it was written against", () => {
const e = upsertDataEntry({ ...base, authoredAgainstVersion: "hash-1" });
expect(e.authoredAgainstVersion).toBe("hash-1");
});

it("re-stamps on update, replacing the older pin", () => {
const created = upsertDataEntry({ ...base, authoredAgainstVersion: "hash-1" });
const updated = upsertDataEntry({
...base,
blob: '{"v":2}',
authoredAgainstVersion: "hash-2",
existing: created,
});
expect(updated.authoredAgainstVersion).toBe("hash-2");
});

it("stamps an entry that predates the pin (null) on its next write", () => {
const legacy = upsertDataEntry({ ...base, authoredAgainstVersion: null });
expect(legacy.authoredAgainstVersion).toBeNull();
const updated = upsertDataEntry({
...base,
authoredAgainstVersion: "hash-1",
existing: legacy,
});
expect(updated.authoredAgainstVersion).toBe("hash-1");
});
});

describe("InMemoryDataRepository (AD1)", () => {
it("keeps one entry per (artefact, author) and upserts", async () => {
const repo = new InMemoryDataRepository();
await repo.save(
upsertDataEntry({ id: "d1", artefactId: "a1", authorId: "u1", blob: "{}" }),
upsertDataEntry({ id: "d1", artefactId: "a1", authorId: "u1", blob: "{}", authoredAgainstVersion: null }),
);
await repo.save(
upsertDataEntry({ id: "d2", artefactId: "a1", authorId: "u1", blob: '{"v":2}' }),
upsertDataEntry({ id: "d2", artefactId: "a1", authorId: "u1", blob: '{"v":2}', authoredAgainstVersion: null }),
);
const found = await repo.findByArtefactAndAuthor("a1", "u1");
expect(found?.blob).toBe('{"v":2}');

// Different author → separate entry.
await repo.save(
upsertDataEntry({ id: "d3", artefactId: "a1", authorId: "u2", blob: "[]" }),
upsertDataEntry({ id: "d3", artefactId: "a1", authorId: "u2", blob: "[]", authoredAgainstVersion: null }),
);
expect((await repo.findByArtefactAndAuthor("a1", "u2"))?.blob).toBe("[]");

Expand Down
Loading