feat(slots): structured data slots — publish extracts, read queries - #574
Merged
Conversation
An artifact is a document an agent authored; a slot lets that same document carry a small named JSON payload the agent can query back instead of re-parsing its own old markup. The data lives INSIDE the source — an inert <script type="application/derive-data" data-slot="…"> in HTML, or a ```derive-data <slot> fence in markdown — so it travels through every publish path for free and can never drift from the page, because it IS the page. - packages/core/data-slots.ts: parseDataSlots, a pure DOM-free tokenizer (Workers-safe). First-occurrence-wins; a bad name, invalid JSON, an oversize body (32KB/slot) or a slot past the cap (20/version) each yields an advisory and is skipped, never a failed publish. - version_data table (sqlite + pg + d1 via the DDL generator): natural key (artifact_id, n, slot), immutable like the version it hangs off; `gen` marks which rules produced a row so a grammar change can re-extract lazily. - Persistence rides emitVersionBump (the universal publish/restore/approve chokepoint, sibling to search indexing, best-effort). Advisories ride the existing publishAdvisories — both call the one parser so they can't disagree. - read gains `data` (a slot by name, or "*" to list) for the current/selected version. `versions` ranges + a raw JSON route are PR C. - read logs a one-line derived-view timing tripwire on the whole-doc HTML→md path: the evidence base for the derived-view cache decision (#433), no schema. Tests: 16-case parse matrix, store-contract round-trip (both dialects), and a publish→read end-to-end plus the advisory-without-failing-publish path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Found by dogfooding on the live Fly app: deleting an artifact that carried a data slot returned 500 with "FOREIGN KEY constraint failed" — deleteArtifact cleared every other child table (version, comment, proposal, …) but not the new version_data, whose artifact_id references artifact.id. Green CI missed it because no test deleted a slot-bearing artifact. There are three deleteArtifact implementations and all three needed the row cleared before the artifact delete: - sqlite.ts (the synchronous better-sqlite3 path the node/self-host deploy runs — the one that actually 500'd live) - pg.ts (Postgres transaction) - repos.ts (the shared async path D1 uses) Regression test added to the store-contract's "hard-deletes all FK-dependent rows" case, so it runs on SQLite and real Postgres. Confirmed: fails on the unfixed code with the exact FK error, passes after. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…estly Ran ~50 adversarial inputs through the parser (malformed tags, boundary sizes, unicode, pathological pages, markdown fence variants, name edge cases). Nothing crashed and perf held (1ms on a 1.6MB page, 1ms with 5000 decoy scripts), but two results were wrong in ways the assertions had been letting pass: - A `type="application/derive-data "` attribute with a stray space was a SILENT no-op: not a data block, no slot, no advisory, nothing to notice. Browsers trim the type attribute; now so do we (and data-slot too). - A literal `</script>` inside a JSON string ends the block early — HTML rules, exactly what a browser does — and we reported "not valid JSON". That verdict is actively misleading: the author is looking at JSON that IS valid and gets sent hunting in the wrong place. Detect the cut-mid-string case and name the real cause plus the escape (`<\/script>`), keeping the plain message for ordinary bad JSON so there is no false blame. 7 chaos cases promoted into the permanent suite (the throwaway harness is gone): the two above, bytes-not-characters sizing, one bad slot not taking good ones down with it, and a perf guard. Also documents slots where they're used: a "Structured data slots" section in the publishing core skill (grammar, read-back, the rules, the </script> gotcha). Verified live on the deployed app across 15 lifecycle scenarios a unit test can't reach: per-version history, a slotless republish leaving old versions intact, markdown fences, the cap, unicode round trip, out-of-range versions, and deleting slot-bearing artifacts (the FK bug, still fixed). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… call
The payoff the foundation was built for. "How did this move over thirty days"
was thirty calls; it is now one, answered by one indexed query.
read(short_id, data:"checks", versions:"all")
→ { count: 30, series: [ {n:1, at:"…", data:{pass:41}}, … ] }
- `read` gains `versions`: "1-30", "12", "20-", or "all". Same range grammar as
`lines` (one syntax to learn), plus "all" because "every version" is the common
ask here and "1-" reads oddly. A string, so a client that connected before this
shipped can use it without reconnecting.
- getVersionDataSeries on all three dialects: ONE query over the
(artifact_id, n, slot) index, ordered oldest-first, row-capped. Never a
per-version loop — a thirty-point series costing thirty round trips would
defeat the entire point.
- Honest coverage rather than invented gaps: versions carrying no such slot are
absent from the series and the response says how many. Past 200 rows it says it
truncated and hands back the range to ask for instead.
Plus the raw JSON route, for everything that is not an MCP client (a fetch() from
the artifact's own page, a curl, a script with a bearer):
GET /raw/:shortId/v/:n/data/:slot(.json) pinned, immutable-cached
GET /raw/:shortId/data/:slot(.json) current version, no-cache
Registered before the /v/:n/* catch-all so its wildcard can't swallow the path,
and it reuses authorize() + the anon-history gate: a slot is part of its version,
so it can never be more readable than the page carrying it. Two tests pin exactly
that (a private artifact does not leak; an old version's slot is as hidden to an
anonymous caller as that version's bytes).
Tests: series + coverage + bad-range through the real MCP server, 8 raw-route
cases including both authorization properties, a range-grammar unit suite
(inverted, out-of-range, clamped, quoted), and a store-contract case verified on
SQLite and real Postgres.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ing skill Phase B shipped, so the skill an agent actually reads at the point of publishing now covers it: versions ranges, the series shape, coverage reporting, and the /raw/<id>/data/<slot>.json URL for callers outside MCP. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t the last one Three things, one thread. 1. `organize state:'deleted'` — permanent delete from the tool an agent already tidies its library with. The capability existed at REST (DELETE /v1/artifacts/:id) and was simply unreachable from MCP, so finishing a cleanup meant minting a second credential. Parity, not new power, which is why the gate MATCHES the REST one (manage) rather than being relaxed to fit the caller: publish-grade creates artifacts, manage-grade destroys them. The response carries no `undo`, because there isn't one, and says so — every other state change here hands back its reversal and pretending this one has a way back would be the most expensive lie on the surface. Cascaded contexts are named. 2. `derive delete [short_id…] [--yes]` on the CLI. Asks for the id typed back rather than y/n (a reflex "y" should not be enough for the one command with nothing behind it), and refuses without a TTY unless --yes. Note `--yes` had to be registered as a boolean flag: the catch-all parser would otherwise eat the next argument as its value and silently leave it unconfirmed. 3. scripts/check-delete-cascade.mjs, in the CI gate: every table with an artifact_id FK must be cleared by all THREE deleteArtifact implementations. Written because version_data shipped without that and 500'd live. It immediately found a second, PRE-EXISTING instance: artifact-scoped `webhook` rows FK to artifact.id and no delete path cleared them, so deleting an artifact that had one would fail exactly the same way. Fixed in all three, scoped to artifact_id so workspace-wide webhooks (null artifact_id) survive — they were never about this artifact. Regression added to the store-contract delete case, green on SQLite and Postgres. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The plan called this "PR B" and assumed it needed Cloudflare Images. It does not: the useful half is measurement, and dimensions can be read from the header with no decode, no dependency, and nothing that sharp (which does not run on Workers) would have been needed for. - imageDimensions() in lib/image.ts: PNG IHDR, GIF logical screen, WebP in all three sub-formats (VP8/VP8L/VP8X), and JPEG by walking marker segments to the first SOF. A natural extension of the magic-byte sniffing already there — same "trust the bytes, not the client" posture, a few more bytes read. Returns null on anything unreadable and NEVER throws: this is the upload path, where a malformed image must cost a dimension, not the request. - `asset` gains nullable width/height (all three dialects via the generator). - The upload response gains `cost`: what the asset weighs, at what dimensions, and what half density would cost instead. Named at the moment of the mistake rather than discovered later by a viewer on a slow connection. - A publish referencing >1MB of assets gets an advisory on the same I/O pattern as missingBlobAdvisory. Deliberately NOT re-encoding anything: these are the user's bytes. Derive names the cost and leaves the decision, which is also why the note gives the measured numbers (halving density cut Derive's own renders ~78%; re-encoding bought 15%) instead of a vague "consider optimizing". Tests build fixtures as hand-written byte arrays rather than checked-in images, so each asserts exactly which bytes carry the meaning, plus the pathological cases (truncated headers, a JPEG with no SOF, 2KB of 0xff that must terminate). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…usage Caught by running it against a live server, which is the only place it could be caught: `derive delete <id> --yes` printed the usage banner and deleted nothing. The command block was written correctly but sat behind a LOOP allowlist it was never added to, so the dispatcher never reached it — a silent no-op that looks like a typo'd command. Two fixes: - `delete` joins LOOP, so the block runs at all. - It is exempt from the repo-pin requirement when ids are given positionally. Every other loop verb operates on the one pinned artifact; delete names its targets (and can take several), so demanding a pinned id would have made `derive delete abc` fail with "no artifact id" while holding the id. Verified live end to end: --yes deletes (404 after), and a non-TTY run without --yes refuses rather than deleting unattended. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The last item on the ergonomics list, built to the half that can be verified. A number arriving as a string is PROOF that the client validated against a tool schema cached before that parameter existed. Until now the server just quietly coerced it, so an agent could hold a stale surface for its entire life with no signal — and every fix in this area so far has been the server bending further around old clients, which works and does not scale. `ctx.num(param, bounds?)` coerces exactly as before and records the proof on a per-request tracker (this server builds a fresh McpServer and ToolContext per request, so the schema closure and the response that reports it always agree). When it fires, the response carries a note naming the parameter and the one action that fixes it — reconnecting, which is the agent's to take, not the server's. The note says the call SUCCEEDED, because reading it as an error would be worse than saying nothing. Bounds ride in the helper rather than being .pipe()d on by callers, so `z.coerce.number()` stays the only numeric schema in the surface: a caller-side `.pipe(z.number())` is textually a bare z.number(), and check-mcp-coercion.mjs was right to reject it — it cannot see that something upstream already coerced. DELIBERATELY NOT SHIPPED: the `notifications/tools/list_changed` push that was the other half of the design. Whether real clients act on that notification when it arrives on a POST response stream is a question about client implementations, not about the spec, and it needs a matrix run against actual clients (Claude Desktop, Cursor, Codex) that has not happened. Shipping a nudge whose effect nobody has observed would be indistinguishable from shipping nothing, and would let us believe the problem was solved. The note above is the part that provably reaches the agent; the push stays gated on that experiment. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…delete `derive://skills/organize` is the procedure an agent reads before organizing, and it documented only tags and collections — `state` was absent entirely, including the pre-existing removed/live. So the reversible cleanup path was discoverable from the tool description and nowhere an agent would look first, and `deleted` would have shipped the same way. Adds the three states with the guidance that matters more than the syntax: prefer `removed` (reversible, hands back its own undo) unless permanence is the actual goal, `deleted` needs manage and has no way back, and the CLI has the same verb. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Written because of a specific, embarrassing observation: I built data slots and
then published roughly fifteen artifact versions in the same session — including
two documents ABOUT slots, full of tables — without emitting a single one. The gap
was never knowing how. It was that nothing asked at the moment of publishing.
That is the exact shape every other advisory here exists to fix, so this rides the
same channel: a page whose tables carry figures, with no derive-data block, gets
told once that those numbers are readable only by parsing the markup, and that a
slot would make them queryable across versions.
It also restores the property the design otherwise broke. Every prior ergonomics
win deleted a decision; slots ADD one, at authoring time, with the payoff weeks
later. An advisory converts "remember to do this" back into "get told at the moment
you'd have wanted it", which is the only version of this that survives contact with
a real session.
Tuned for precision over recall, because a false advisory trains the reader to skip
the channel and that channel is load-bearing for everything else: it fires only on
NUMERIC TABLE CELLS (four or more), so prose that mentions figures stays quiet, a
table of words stays quiet, and one or two numbers is not a dataset. It says so
itself ("ignorable: plenty of pages are prose that happens to contain a table"),
and it is silent when the page already carries a slot or when a malformed block
already has its own, more specific advisory.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The quiet way a trend read goes wrong, and the first item off the "what else is in this space" list. Nothing rejects a slot whose keys change: rename `pass` to `passed` at v20 and versions:"all" still returns thirty happy-looking points that are silently two different metrics, with the break invisible unless you read every one. A series that gets LESS trustworthy the longer it runs is worse than no series. slotShape() fingerprints a payload as sorted key paths with value kinds (`fail:number|pass:number`), so a value change is the same shape and a rename or a retype is not. On publish, the new version's shapes are compared against the previous version's stored rows and any drift is named — including which keys went and which arrived, since a rename is the common case and the old versions still carry the old keys. Silent on a first version, a brand-new slot, and a slot that simply stopped being published: those are ordinary authoring, not a broken series. Depth- and count-limited, and never throws — it runs on the publish path, where a weird payload must cost an advisory, not the publish. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
# Conflicts: # package.json
…re bare
check-mcp-coercion.mjs scanned line by line for `z.number(`, so it never saw the
form the codebase actually uses:
wait: z
.number()
It has been reporting "ok" while NINE parameters sat bare across catch-up, find,
publish and use — the exact stale-client bug the check exists to prevent (a client
that connected before a parameter existed sends it as a string; the server rejects
a value the caller passed correctly).
Worse than the bug: earlier in this branch's work an exploration pass flagged
publish's `occurrence` and `base_version` as suspicious, I checked them WITH THIS
SCRIPT, and reported them clean. They were not. A guard that passes while the thing
it guards against is present is worse than no guard, because it is also a claim.
- The check now joins a line ending in a bare `z` with the lines that continue it,
collapsing the whitespace inside the chain (`z .number(` would otherwise still
hide the match), and still reports the offender at its own line number.
- All 9 now coerce: catch_up since_version/to_version/wait, find
context/max_matches/version, publish occurrence/base_version, use wait.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
an1va
added a commit
that referenced
this pull request
Jul 30, 2026
…ries as a URL Phase 2 of the querying-artifacts plan. Phase 1 made publishing a slot pay off; this makes the data reachable beyond the one artifact and the one client. CROSS-ARTIFACT READS. `read(data, versions)` answers "how did this ONE page change over time"; `find(data:"checks")` answers "where does this metric stand everywhere", which is the question a workspace of nightly reports actually gets asked. Every row is that artifact's CURRENT version, joined at the store, so a superseded row can never be reported as the present state — the failure that would make this quietly wrong rather than visibly broken. `tag` scopes it to a set (tags are already how a group of artifacts is named), and `find(data:"*")` lists the workspace's slot vocabulary with artifact counts, because you cannot query a slot whose name you do not know and nothing else in the surface listed them. The store method landed with #574; this is the surface plus one new query (listWorkspaceSlots) on all three dialects. THE SERIES EXPORT. `/raw/<id>/data/<slot>.jsonl` — the whole history, one JSON object per version, oldest first. This is the substrate the rest of the plan stands on: a page charts its own history from it, an agent pulls a series with no MCP client, a shell pipes it to jq, and anything wanting real SQL points DuckDB-WASM at it. Derive precomputes and serves; the consumer queries, which is why there is no query language here and no per-request compute to defend. JSONL rather than a JSON array on purpose: a new version is a LINE append, and it streams. It shares serveSlot rather than living on its own route, because the existing `.json` pattern matches `checks.jsonl` first — a sibling route could never win the match (found by the tests 404ing). It also re-checks the public-history gate: an anonymous caller who may not read history gets only the current point, so the export can never become a way around a gate the per-version route enforces. Tests: cross-artifact current-version-only + limit, the workspace catalog with counts, and 5 export cases including both authorization properties. Store cases run on SQLite and Postgres. Full gate green — 2,778 tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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.
What
Structured data slots — the first item, and the only grade-mover, on the agent-ergonomics next build-out. An artifact is a document an agent authored; a slot lets that same document carry a small named JSON payload the agent can query back across versions instead of re-parsing its own old markup. "How did this check trend over thirty days" stops meaning thirty reads and thirty parses of your own old HTML.
The data lives inside the source — an inert
<script type="application/derive-data" data-slot="…">in HTML, or a```derive-data <slot>fence in markdown — so it travels through every publish path (inline, staged doc, REST, CLI, the editor) for free and can never drift from the page, because it is the page. No new tool, no second artifact, no sidecar.This is PR A of the slot work: extraction + current-version read. Version ranges + a raw JSON route are PR C.
How
packages/core/data-slots.ts—parseDataSlots, a pure DOM-free tokenizer (must run on Workers). First-occurrence-wins; a bad name, invalid JSON, an oversize body (32KB/slot) or a slot past the cap (20/version) each yields an advisory and is skipped, never a failed publish.version_datatable (sqlite + pg + d1, all via the DDL generator — no hand-written migration): natural key(artifact_id, n, slot), immutable like the version it hangs off.genmarks which extraction rules produced a row, so a future grammar change can re-extract older versions lazily — the same generation lever the derived-view cache uses.emitVersionBump— the universal publish/restore/proposal-approve chokepoint, a sibling to search indexing, same best-effort contract (a hiccup never fails a publish that already went live).publishAdvisories— both the persistence and the advice call the one parser, so what's stored and what's warned about can never disagree.readgainsdata— a slot by name, or"*"to list what a version carries, for the current (orversion-selected) version. Both new read params are strings, so a client that connected before this shipped can use them the day it deploys.The #433 tripwire (evidence, not a feature)
Per the build plan's decision to gate the derived-view cache PR on real usage rather than merge it on momentum,
readlogs one line on the whole-doc HTML→markdown path: source chars, compute ms, and whether it crosses that PR's 150K-char gate. No schema, no flag. A week of these numbers says whether #433 is worth landing or closing.Tests
data-slots.test.ts): multi-slot, attribute-order, other script types, duplicate, invalid name/JSON, oversize, per-version cap, markdown fences vs ordinary fences, content-type gating, determinism.setVersionData/getVersionData— verified against both SQLite and real Postgres (115 pg-store cases green on an ephemeral pg16).mcp.test.ts: publish→read end-to-end through the real MCP server, plus the advisory-without-failing-the-publish path.Full gate green:
pnpm typecheck, the entirepnpm run cilint battery (incl.lint:schema,lint:surfaces,lint:api,lint:mcp-coercion), andpnpm test(2500+ tests).Gate loop (post-deploy)
Per the build plan: republish a real nightly results page carrying a slot, then read the slot back on a live connection that predates the deploy. That runs after merge, and its result goes in the living agent-ergonomics doc.
🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmithwith what you need. Autofix is disabled.