feat(slots): the incentive loop and cross-artifact reach - #580
Merged
Conversation
…ceipt
Phase 1 of the querying-artifacts plan, and the part the research says gates
everything else. Embedded-data conventions live or die on whether publishing is
REWARDED at the moment of publishing: OpenGraph became near-universal because
Facebook rendered a prettier card the instant you added four meta tags, while
better-specified conventions with no rewarding consumer (CSVW, Frictionless, W3C
Web Annotation) died unadopted. Slots had good querying and no payoff. Three
places that now pay:
- UNFURL CARDS. A shared artifact link leads with its own numbers: "pass 48 ·
fail 0 · flaky 1 · HTML · 14 versions · on Derive". slotSummary() is
deliberately shallow and lossy — scalars only, first three fields, short
values — and returns null rather than an empty flourish, so a card without
slots is exactly what it was. This is the OG mechanic applied to agent data,
and it cost one field on UnfurlInfo plus one query in infoFor.
- CATCH_UP DELTAS. `data_changes: ["checks.pass 41 → 44", "checks.fail 2 → 0"]`
beside the prose diff. The review loop is where humans already are, and a diff
that shows what a page SAYS but not the figures it is about is half a diff.
Slot appearing or disappearing is its own line; capped at 8 to stay readable.
- A PUBLISH RECEIPT. Success was silent: a slot was only ever mentioned when
something went wrong, which is a poor way to teach a capability whose whole
point is that it accrues. Both publish paths now return `data: [{slot, bytes}]`
read back FROM THE STORED ROWS, not echoed from the parser — so it reports what
is actually queryable and a persistence failure reads as an empty list instead
of a confident claim. The MCP response also hands back the exact read call.
Every read is best-effort: a share card must never fail to render, and a publish
must never fail, because a slot query hiccuped.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…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>
Extraction runs at publish, so adding a slot to an artifact that already has versions gave you data from that moment forward and nothing before it. The older pages usually already carried the block; nothing had ever read them. This repo's own fourteen-version demo came back empty. When a slot is new relative to the previous version, walk back up to 50 versions, parse only that slot, and merge it into whatever rows those versions already have. Fires on the transition only, so an ordinary republish costs nothing, and rides the caller's background() where one exists (every publish path) so the blob reads stay off the hot path.
find(data:) and the slot catalog reach artifacts by a metric NAME, so the store could only scope them to the org. An org is not a read permission: workspace_access:"none" means invite-only within the workspace. A member asking for a metric got back the title and the actual figures of documents they had deliberately been left off, and the catalog counted those documents into its totals. Both now pass the same gate workspace search uses, extracted to lib/visibility.ts so there is one copy rather than two that drift. The catalog is aggregated after gating, since a count taken in SQL is already computed over artifacts the caller may not see. Also: the SVG card, oembed and embed responses carried Cache-Control: public while holding a gated artifact's revealed title, counts and slot figures, with no Vary on the credential. The PNG branch has always gone private for this; the siblings now share one helper. Pre-existing, but slot data made the payload worth protecting.
Previewhttps://derive-pr-580.derive-to.workers.dev Deployed from It shares production's database — sign in with your real account, and treat anything you change here as changed for real. It has no routes, no cron, no queue consumer and no OG renderer, so it cannot serve derive.to, run scheduled work, or write images onto real artifacts. Unlike production it serves artifact HTML on its own origin (that is what makes frame-side changes visible here). Storage is still sandboxed away, but untrusted HTML and the sign-in form share a hostname — treat this URL as you would any link: don't type a password into it because a page asked you to. |
…slot recall The raw slot routes were the same shared-cache mistake as the OG card, and worse: /raw/<id>/v/<n>/data/<slot> asked a CDN to keep a gated artifact's figures for a year. Those 200s exist only because the caller cleared authorize, and nothing in them varies on the credential. World-readable artifacts keep the hard cache that makes these URLs cheap to poll. find(data:) also gated a hard 200-row page, so a run of artifacts the caller cannot see could eat the page and answer "no artifact carries this slot" — a wrong answer wearing an empty one's clothes. Over-fetch, gate, then cut. Nothing reports what was dropped: a "results were filtered" note would disclose the documents the gate exists to hide. Backfill now logs what it recovered and whether the version cap stopped it, so a waitUntil-truncated walk is distinguishable from a complete one.
…ds it find(data:) reaches what a search reaches, and its catalog counts what the caller can see rather than what the workspace holds. Both are surprising enough to belong in the parameter description, not only in the code.
Workspace search had a careful visibility gate. The slot readers, written later for the same shape of problem, had org scoping and nothing else, and the whole suite stayed green because tests prove a feature works and none of them asked who else could see the answer. Fails the gate if a file calls one of the org-scoped multi-artifact store methods without narrowing the rows through lib/visibility.ts, and fails loudly if it matches NO call site at all — a guard that passes because it looked in the wrong place is a claim, not a check. Verified against a known -bad input (gate removed from find.ts) before being wired in.
The share card leading with the artifact's own numbers is the highest- leverage incentive in this PR, and no test asserted it anywhere. It reaches the card through infoFor -> dataSummary, so any refactor of that path could delete the feature with the whole suite green. That is not hypothetical: #585 (open, someone else's) extracts infoFor into a shared lib/unfurl-info.ts from a copy that predates the slot read, and a mechanical conflict resolution in either merge order drops the numbers silently. This test fails when dataSummary goes null, in the OG card and in the server-rendered og:description a crawler actually reads, and asserts a slotless artifact keeps the plain description so the lead stays data-driven.
A browser pointed at a 404 renders the 404 and the screenshot SUCCEEDS, so the job stored a picture of the words "not found" and marked itself ready. Worst possible shape: the card is permanently wrong, nothing retries because nothing failed, the dead-render self-heal never fires because a render exists, and republishing is the only cure — which nobody thinks to do. Hit twice in one afternoon on two different artifacts, 4.5KB images where a real one is 70-110KB. Both renderers discarded the navigation response. They now share one pure assertNavigationOk so the two runtimes cannot drift, and an error status routes into the existing failure path: status failed, a real message, the retry/backoff, the self-heal. It also makes the cause diagnosable, which it was not: three separate paths in the raw routes return the identical bare "not found" — an unverifiable preview token falling through to anonymous authorization, a claim mismatch, and a version row that reads back missing — and a screenshot of the result cannot tell them apart. The status code is the first evidence about which. The URL carries a short-lived pv capability token and the message reaches both the log and a stored column, so the token is redacted; a test pins that the credential does not escape while the artifact and version still do.
Artifacts render in an OPAQUE ORIGIN — the sandbox CSP grants no allow-same-origin — so a page fetching its own slot is a cross-origin request from a null origin. Every other raw route carries Access-Control-Allow-Origin via RAW_HEADERS; the two slot routes built their headers from scratch and lost it, so the browser refused to hand the body to the script and fetch threw a bare "Failed to fetch". "A page that charts its own history" is published as a worked example in two docs. It could not work. Found by publishing a real probe page, which self-discovered its short_id from the sandbox path and then died on the very next line. The header grants no access: an opaque origin cannot send credentials and "*" forbids credentialed reads anyway, so a cross-origin caller sees what an anonymous one sees — for a gated artifact, the 404 the authorize check already returned. Which is the second half of the finding: self-reading works for artifacts with a world link, and cannot work for gated ones, because the page has no credentials to prove with. The docs claim it universally and are being corrected.
an1va
added a commit
that referenced
this pull request
Jul 31, 2026
Phase 3, prototyped on top of #580 rather than shipped. Nothing here is published as a standard, and it should not be until somebody outside the team emits a slot — publishing a spec for a format nothing emits is how CSVW and W3C Web Annotation died, and both were better specified than the things that beat them. Writing the parser is what showed the parser is the least valuable part: a few hundred lines, no incumbent, nothing an implementer would get wrong. SPEC.md is the actual contribution — the read contract, where every item is a decision an independent host must make and the wrong choice fails silently. It is written from THIS host's live defects: absence is not a zero, cross-record rows join to the current version, aggregates disclose so gate before counting, never report what was filtered, a caller-specific response is not shared-cacheable, and a sandboxed page needs CORS to read its own data (and still cannot if the record is gated). Extraction is a MOVE, not a copy: core re-exports the package, so every consumer is unchanged and there is one implementation. A second copy is the failure this codebase has now shipped three times. lint:slots-portable keeps the claim true — zero imports, zero runtime deps — and fails loudly if it matches no source at all. Verified against a known-bad import before wiring in, and the package was bundled standalone (8.8KB, esbuild, bare Node, no repo) and run to prove it is copyable.
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.
Phase 1 of Querying artifacts like data, the exploration that followed #574. Builds on slots now that they're on main.
Why this first
The research behind that doc is blunt about what separates embedded-data conventions that got adopted from ones that died: a consumer that rewards publishing, before anything else. JSON-LD is on ~51% of crawled pages because Google paid publishers in rich results. OpenGraph is near-universal because Facebook rendered a prettier card the instant you added four meta tags. CSVW, Frictionless and the W3C Web Annotation model were better specified than several winners and died because nothing with reach rewarded compliance.
Slots shipped with good querying and no payoff. This PR is the payoff, in the three places an author actually looks.
What it does
1. Unfurl cards carry the numbers. A shared artifact link now leads with its own data:
slotSummary()is deliberately shallow and lossy — scalars only, first three fields, short values, floats rounded — and returnsnullrather than an empty flourish, so an artifact without slots renders exactly the card it did before. One optional field onUnfurlInfo, one query ininfoFor. This is the OG mechanic applied to agent-emitted data, and it's the cheapest high-leverage item in the whole space.2.
catch_upreports what the numbers did.The review loop is where humans already are, and a diff that shows what a page says but not the figures it's about is half a diff. A slot appearing or disappearing gets its own line (usually the more important event); capped at 8 so a review stays readable; nested scalars reached.
3. Publishing a slot gets a receipt. Success used to be silent — a slot was only ever mentioned when something went wrong, which is a poor way to teach a capability whose whole point is that it accrues. Both publish paths now return:
Read back from the stored rows, not echoed from the parser, so it reports what is genuinely queryable and a persistence failure reads as an empty list rather than a confident claim. The MCP response also hands back the exact
read(...)call to fetch it.Safety
Every new read is best-effort and falls back to prior behavior: a share card must never fail to render, and a publish must never fail, because a slot query hiccuped.
Tests
19 new cases in
data-slots.test.ts(52 total in that file): summary shape/caps/rounding/nested-skipping/null-fallback, and deltas for changed fields, new and gone slots, new fields, nested scalars, the cap, and unparseable rows.Full gate green:
pnpm typecheck, the completepnpm run cilint battery, andpnpm test— 2,771 tests.Next in the plan (not this PR)
Phase 2 is reach: cross-artifact reads (
find(data:"checks", tag:"nightly")— the store method already exists on all three dialects from #574) plus a JSONL export route. Phase 3 (the standalone spec + parser, client charting helper, slot diff) is deliberately gated on evidence that slots get emitted, since publishing a spec for an unused format is the exact corpse pattern the research documents.🤖 Generated with Claude Code
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled.