Skip to content

feat(slots): the incentive loop and cross-artifact reach - #580

Merged
an1va merged 11 commits into
mainfrom
feat/slot-incentives
Jul 31, 2026
Merged

feat(slots): the incentive loop and cross-artifact reach#580
an1va merged 11 commits into
mainfrom
feat/slot-incentives

Conversation

@an1va

@an1va an1va commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

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:

pass 48 · fail 0 · flaky 1 · HTML · 14 versions · on Derive

slotSummary() is deliberately shallow and lossy — scalars only, first three fields, short values, floats rounded — and returns null rather than an empty flourish, so an artifact without slots renders exactly the card it did before. One optional field on UnfurlInfo, one query in infoFor. This is the OG mechanic applied to agent-emitted data, and it's the cheapest high-leverage item in the whole space.

2. catch_up reports what the numbers did.

data_changes: ["checks.pass 41 → 44", "checks.fail 2 → 0"]

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:

"data": [{"slot": "checks", "bytes": 73}]

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 complete pnpm run ci lint battery, and pnpm test2,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


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

an1va and others added 2 commits July 30, 2026 11:32
…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>
@an1va an1va changed the title feat(slots): make publishing a slot pay off — cards, deltas, and a receipt feat(slots): the incentive loop and cross-artifact reach Jul 30, 2026
an1va added 2 commits July 30, 2026 13:14
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.
@github-actions

github-actions Bot commented Jul 30, 2026

Copy link
Copy Markdown

Preview

https://derive-pr-580.derive-to.workers.dev

Deployed from 58f13db as derive-pr-580.

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.

an1va added 6 commits July 30, 2026 14:15
…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
an1va merged commit 15dd7f0 into main Jul 31, 2026
9 checks passed
@an1va
an1va deleted the feat/slot-incentives branch July 31, 2026 02:20
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.
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