Skip to content

feat(slots): structured data slots — publish extracts, read queries - #574

Merged
an1va merged 14 commits into
mainfrom
feat/data-slots
Jul 30, 2026
Merged

feat(slots): structured data slots — publish extracts, read queries#574
an1va merged 14 commits into
mainfrom
feat/data-slots

Conversation

@an1va

@an1va an1va commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

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.tsparseDataSlots, 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_data table (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. gen marks 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.
  • Persistence rides 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).
  • Advisories ride the existing publishAdvisories — both the persistence and the advice call the one parser, so what's stored and what's warned about can never disagree.
  • read gains data — a slot by name, or "*" to list what a version carries, for the current (or version-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, read logs 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

  • 16-case parse matrix (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.
  • store-contract round-trip for setVersionData/getVersionDataverified 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 entire pnpm run ci lint battery (incl. lint:schema, lint:surfaces, lint:api, lint:mcp-coercion), and pnpm 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


View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith with what you need. Autofix is disabled.

an1va and others added 14 commits July 29, 2026 16:59
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>
…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
an1va merged commit f4007dc into main Jul 30, 2026
6 checks passed
@an1va
an1va deleted the feat/data-slots branch July 30, 2026 18:23
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>
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