Build

Artifact architecture

The five-verb port, the three dispatch legs, and what a ref is allowed to move — every claim carrying a status, three named gaps, and one worked refusal taken from a real run.

Two tools need the same six megabytes and the only road between them runs through the model. It reads the data it never needed, paraphrases it imperfectly back out, and the bill arrives twice. Enterprise messaging solved this decades ago and named the law the claim check: the expensive channel carries a ticket plus enough description to decide; the payload never rides the channel.

This page is the architecture of that law in agentfootprint: what a reference is, who is allowed to redeem one, what happens at dispatch when the model routes one, and — precisely — what a reference is not allowed to move.

It is the long-form companion to Artifacts, which is the feature surface: the adapters, the option shapes, the event table, the runnable examples. Read that one to use the store. Read this one to know why it has five verbs and not six, and what it refuses to do for you.

How to read the status column

Every capability claim on this page carries a status. That is deliberate. An architecture chapter written in specification voice makes an aspiration and a shipped behaviour read identically, and the difference is exactly what a reader needs. A status column cannot fix a wrong sentence, but it stops an aspiration wearing the same clothes as a fact.

StatusMeans
shippedPresent in 9.38.0 and on by default. Calling it is enough.
opt-inPresent in 9.38.0 and off until you ask for it by name. The default behaviour is the older one.
application-providedThe library gives you the seam and the vocabulary. The behaviour is code you write.
plannedDesigned and not built. Do not call it; it does not exist.

Where something is limited, the limit is in the sentence that makes the claim, not in a footnote at the bottom.


Chapter 0 — Five verbs, and the sixth that will never arrive

The port

ArtifactStore is the vendor-neutral port every adapter implements. Scope is always the first argument — the same constitution MemoryStore follows, so isolation is enforced at the boundary rather than remembered at each call site.

VerbReturnsThe law it carriesStatus
put(scope, input)ArtifactPutResult — the minted ArtifactMeta, plus every SweptArtifact retention evicted to admit itvalidates the input, proves parentRefs resolve in the same scope, measures bytes, stamps expiresAtshipped
head(scope, ref)ArtifactMeta | nullthe ticket without the bytes — this IS the render-by-ref decisionshipped
get(scope, ref)ArtifactRecord | nullticket + payload, and it verifies the digest when the meta carries oneshipped
delete(scope, ref)voidremoving an absence is agreement, not an errorshipped
list(scope, options)ArtifactListResultcursored pages, newest first, meta onlyshipped

head earns its place because a consumer picks what to do from kind and bytes without paying for the payload. That is the whole of render-by-ref, and it is one round trip.

The refusal, written down so a later round can be refused by citation: the port will never grow query() or transform(). The moment it does, this is a database — and a database wearing a claim-check name is worse than either. A store answers "what is this, and give me the bytes." Compute over artifacts belongs to code that runs somewhere else (chapter 3). Five verbs is the ceiling, not the current state.

A ref is minted, never derived

An ArtifactRef is art_ plus 22 crypto-random characters — about 26 in total. It is opaque and minted, never content-addressed, and that is a decision with two reasons stated at the type: the same bytes stored by two tenants must be two objects, and a digest can never name two generations of "the current dataset". shipped

The content digest (sha-256, computed at put when you ask for it) is metadata. It buys integrity checking, and a mismatch on get throws ArtifactIntegrityError rather than handing back corrupt bytes as if whole. It is never the key. opt-in (ask with digest: 'sha-256').

…and two optional members, one of which cannot keep a promise

putStream and getStream let a payload move without either side holding it whole. They are optional members, not a sixth and seventh verb: a store that cannot honour the promise leaves them absent rather than faking it, and you feature-detect with narrowing guards.

const  = ({ : './artifacts' });

if (()) {
  const  = await .(, );
  // `meta.digest` rides — but NOTHING here checked it. See below.
  ?..;
}

After the guard the call compiles with no ! and no cast, so the honest branch is also the comfortable one. Calling an undetected member is a type error, not a runtime surprise.

StoreStreams?WhyStatus
fileArtifactsyesbytes go to a sibling <ref>.bin as they arriveshipped
s3Artifacts / gcsArtifactsyesthe SDKs' own streaming upload and downloadshipped
sqliteArtifactsno — absentnode:sqlite reads and writes a BLOB whole; a "stream" here would buffer everything and call itself a streamshipped (the absence is the feature)
inMemoryArtifactsno — absentit holds payloads whole under a byte budget; streaming into it would defeat bothshipped (the absence is the feature)

Two costs ride a streamed put, and both are stated rather than traded quietly. bytes is required — it is stated, not measured, because retention has to plan an eviction and an object store has to declare a content length before the first chunk arrives; a payload that does not match what it declared is refused rather than stored under a meta that lies about it. And:

getStream does not verify the digest. get does. Verification needs the whole payload, which is the exact thing streaming exists to avoid holding. meta.digest rides on the record anyway, so a caller who needs the guarantee can hash what it collected and compare — the loss is named, never silently swapped in. If you need a verified read, use get. shipped, with the gap in the same sentence.

ctx.artifacts does not grow streaming, and that is a decision rather than an omission: a tool receives its artifacts pre-scoped and answers a model, and a model cannot hold a stream. A tool genuinely moving gigabytes holds the store — it constructed it — and calls that directly. planned (streaming on the tool capability): not designed, not wanted.

The proof ships with the port

Five stores implement ArtifactStore here. Until 9.40 the laws they shared lived in a test file, and the published package excludes dist/test — so anyone implementing the port over Postgres, Azure Blob or their own service could read the interface and had no way to run the checks the in-tree stores are held to. The sibling port learned why that matters the hard way: a split-brain ownership defect was live in four session stores at once, every one of them tested, and invisible to all of them simultaneously because each was tested against its own doubles. A flaw in the port's semantics is exactly the flaw per-store tests cannot see.

So the battery is now exported beside the port. artifactStoreConformance is an ordinary array of cases; runArtifactStoreConformance(harness) runs all of them and answers an ArtifactStoreReport; runArtifactStoreCase(case, harness) runs one, so you get an assertion per law in whatever runner you use; and formatArtifactStoreReport(report) renders the whole thing as text for a failure message or a log. Nothing in it imports a test framework — a case throws to fail — so it runs under vitest, jest, node:test, or a plain script. shipped (9.40.0).

import { runArtifactStoreConformance, formatArtifactStoreReport } from 'agentfootprint';

const report = await runArtifactStoreConformance({
  name: 'ourOwnArtifacts',
  createStore: () => ourOwnArtifacts({ pool, clock }),
  disposeStore: (store) => store.close(),
  advanceTime: (store, ms) => clock.tick(ms),
  corrupt: (store, scope, ref) => pool.query('update artifacts set payload = $1 …'),
  boundedStore: (maxBytesPerScope) => ourOwnArtifacts({ pool, clock, retention: { maxBytesPerScope } }),
});

if (!report.ok) throw new Error(formatArtifactStoreReport(report));

You hand it an ArtifactStoreHarness: a factory, not a store, because most cases need a store with nothing in it and a closed store cannot be reset. Three of its fields are hooks for things the port deliberately has no verb for — the ArtifactStoreHarnessHook union names them: advanceTime (expiry cannot be observed without time passing), corrupt (the integrity law needs damage staged from outside), and boundedStore (a ceiling is configuration, not a verb).

An ArtifactStoreOutcome distinguishes three ways a case does not simply pass, and they mean different things:

  • not-applicable — the case is about an optional member (ArtifactStoreMember: putStream or getStream) this store does not implement. Feature detection, which is the port's own rule.
  • declared — the store implements it and still cannot satisfy the case, named in declared with a reason. inMemoryArtifacts declares exactly one: its payloads live in a Map closed over by the factory, so the corruption the integrity case stages cannot happen to it from outside. A declared case is still run, and a declaration that starts passing is reported STALE — a suppression nobody revisits is how a fixed defect keeps its exemption.
  • failed — including "this case needed a harness hook nobody supplied and nobody declared." An undeclared skip is a pass with the evidence removed.

There is deliberately no way to make a case quietly disappear. The case names are a closed union (ArtifactStoreCaseName) so a declaration cannot go on suppressing a case that was renamed, each ArtifactStoreCase carries the one law it holds — printed beside a failure, so a break reads as a broken promise rather than a broken assertion — and the ArtifactConformanceKit a case is handed gives it unique scopes and the store's own clock.


Chapter 1 — Who holds the scope

The capability a tool gets

Every tool's execute context carries ctx.artifacts (a ToolArtifacts) and ctx.hasArtifacts. It is shaped exactly like ctx.credentials, and the shape carries three guarantees.

GuaranteeWhat it meansStatus
always present, fail-closedwith no store attached, every method throws a teaching refusal naming the fix. A missing store can never read as an empty one, and a tool can never optional-chain past it.shipped
scope is composed by the frameworkthe capability closes over the run's tenant/principal/conversation tuple. The five verbs here take no scope argument on purpose: that argument was already answered by whoever built the context. A tool cannot name, widen, or replace the scope it resolves under.shipped
origin is stamped, not suppliedevery mint carries { runId, toolCallId } from the run's own facts. A caller-supplied origin is discarded.shipped

The middle row is the security property. A ref pasted into a log, a model response, or a bug report opens nothing: resolution takes the ref and the caller's scope, and under any other session's scope the same ref answers null.

const  = ({
  : 'get_sales',
  : 'Fetch Q3 sales and check them into the artifact store.',
  : async (, ) => {
    if (!.) {
      // The branchable fact, for a tool that genuinely supports both modes.
      // Everything else should just let the fail-closed refusal happen.
      return 'no artifact store attached — returning a summary instead';
    }
    const  = .({ : 48_000 }, (, ) => ({ : , : ( % 97) + 1 }));
    const  = await ..({
      : 'dataset/rows',
      : 'application/json',
      : ,
      : 'Q3 sales by region',
    });
    // The model reads THIS — one line, about 30 tokens.
    return `stored ${.} [${.} · ${.} bytes]`;
  },
});

Note what the tool did not do: it never named a tenant, never composed a key, and never decided who may read this back. Those are not its questions.

One absence, four causes

get and head return null for missing or expired. The ambiguity is deliberate, and it is wider than it first looks: four different situations end in exactly the same answer.

What actually happenedWhat the caller sees
the ref was never storednull
it was stored and the ttl swept itnull
it belongs to another tenant or principalnull
it belongs to another conversationnull

Distinguishing them would let a caller probe another scope's contents — "not found" versus "not yours" is an oracle, and possession of a ref is the whole entitlement this system honours. So "no data" is the only actionable fact the caller gets. shipped

The same single answer reaches the screen. A hosting door that cannot resolve a ticket raises ArtifactNotFoundError, whose message covers all four causes in one sentence — missing, expired, swept, or never stored under this session's scope — and tells the reader what to do instead. shipped

The record still tells the truth. Every miss lands as agentfootprint.artifacts.refused with reason: 'missing-or-expired', so an operator reading the trace sees the event the caller was not given. That split — opaque to the caller, legible to the operator — is the design, not an accident of implementation.


Chapter 2 — Three legs at dispatch

The store alone still leaves the model redeeming tickets by hand inside a tool body. Three legs make a ref a first-class citizen of dispatch itself.

Leg 1 — wants: refs as tool arguments

A tool declares which of its arguments are claim tickets and what kind each must redeem to. The framework resolves them before execute, and the handler receives the data; the ticket rides ctx.wanted.

const  = <{ : string }, string>({
  : 'transform_report',
  : 'Aggregate a stored dataset. Pass the art_… ref from get_data.',
  : {
    : 'object',
    : { : { : 'string' } },
    : ['dataset'], // ← load-bearing; see below
  },
  : { : 'tool-result/get_data' },
  : async (, ) => {
    // args.dataset is the resolved DATA, not the ticket.
    const  = .(.) as <{ : number }>;
    const  = .((, ) =>  + ., 0);
    return `total: ${} (from ${.?.?.})`;
  },
});

const  = .({
  ,
  : 'claude-sonnet-4-5',
  : { : (), : { : 2_000 } },
})
  .()
  .()
  .();

The declaration's shape is the exported ToolWants type — Readonly<Record<argName, kind>>. Its law, verb for verb the needs (credentials) precedent:

RuleWhy it is that wayStatus
resolution happens at dispatch, before execute and before credentialsa call whose data cannot be delivered never acquires a secret and never runsshipped
a stale, unknown, or wrong-kind ref never reaches the toolthe model reads a teaching refusal that lists the live refs of the wanted kind in scope — correcting by naming what can resolve, bounded at 10 shown from 200 scannedshipped
the kind check is exact string equality'dataset/rows' matches only 'dataset/rows'. No wildcards, no hierarchy — a kind is an id, not a familyshipped
all declared arguments are judged before answeringone refusal teaches the whole correction instead of one argument per retry loopshipped
declared honestly or refused at defineToola wants-argument must exist in inputSchema.properties and be type: 'string' — the model speaks the ref, never the bytesshipped
a statically registered wants-tool on a storeless agent refuses at buildconfiguration that could never work should not wait for the first oversized run to say so. mcpServe refuses wants-tools by name at its own doorshipped
ctx.wanted is absent, not empty, when nothing resolvedabsent and empty are different factsshipped
a tool without wants takes the exact path it always tookzero-cost when unusedshipped

A required ref, omitted, is now refused by name (9.38.0)

This is the correction most likely to change what you see in a transcript.

A wants declaration names an argument. Whether omitting that argument is a choice or a hole is answered by the tool's own inputSchema.required — and until 9.38.0 that question was effectively delegated to toolArgValidation, an agent-wide dial an operator may set to 'warn' or 'off'. Under those settings a required, declared ref could be omitted and the tool would run anyway, with the argument simply absent: the handler believing the framework resolved its payload, and holding nothing. Accepted-and-silently-wrong, in the one place this feature exists to make impossible.

Now the resolver reads inputSchema.required itself:

  • required and omitted → dispatch refuses by name, listing the live refs of the wanted kind. The tool does not run. shipped
  • optional and omitted → the model legitimately chose not to pass one. The call runs and ctx.wanted simply has no entry for it. shipped (unchanged)

Two things worth being precise about. First, this does not replace toolArgValidation: under its 'enforce' default the args gate still runs first and answers a missing required argument with its own structured retry message. The wants belt is what makes the refusal survive an operator turning that dial down — which is exactly the configuration where a silent hole was possible before.

Second, both dispatch doors enforce it. The batch loop enforces it, and so does the resume path a human approval comes back through: an approved call is not a waived one. A person clicking "approve" agreed to the call, not to the framework skipping the delivery it promised.

Leg 2 — placement: an oversized result becomes a ticket

artifacts: { store, placement: { maxInlineChars: N } } is the operator's dial. Any tool result whose finalized text strictly exceeds N is checked into the store as kind tool-result/<toolName> and the model reads a short substitute instead.

RuleDetailStatus
the dial{ maxInlineChars }, a positive whole number, refused at build otherwise. Placement cannot be spelled without a store — the option's own shape prevents itopt-in
the thresholdstrictly exceeds: a result exactly at N is not placedshipped
the kindtool-result/<toolName>, composed by the exported placedResultKind(toolName) so a wants declaration can name it without spelling the prefixshipped
the payloadthe exact text the model would have read — not a summary, not a re-encodingshipped
the substituteone shape, always an object: { placed: true, ref, kind, mediaType, bytes, reason }. Branch on .placed with isPlacedToolResultshipped
when it is judgedonly on a finalized result, and never on an errored, denied, or ceiling-refused call — tool-result/<name> must never claim an error is the tool's resultshipped
where it is judgedat all five places a tool result is finalized: the batch loop, and the four pause/resume paths (an approved ask, a decision, a consent, and a human's own pasted answer — which costs a window exactly what a tool's rows do)shipped
a placed result is a ticket, not a refusaldeclared tool effects are still judged and procedure steps still advance — the result arrived; it just travels by referenceshipped

Precedence with the other two ceilings, since all three measure the same string:

  1. the tool's own resultCeiling — the author's refusal — is judged first, at the execute boundary, so a refused result is already short and placement never sees the payload;
  2. placement — the operator's ref-ing — is judged next, on whatever the after-tool governance chain let through;
  3. the agent-level maxToolResultChars truncation net runs last, and with placement on it measures the ticket, so it should rarely fire.

The footgun: placement rewrites the string your routing reads

Stated here because it is not guessable from a number.

The substitute replaces the result string everywhere downstream — message history, lastToolResult, toolResults. That is precisely where skill-graph when edges and rule triggers look. Turning placement on, or changing maxInlineChars, can therefore change which edge fires for any graph that matches on result text.

This is intentional, and the alternative is worse: routing judges what the model was told, and predicates reading a string the conversation never contained would be a lie in the other direction. But it is a real trap, so: an edge that must survive the dial should key on the tool name (onToolReturn) or a declared status (onToolStatus), not on result text. shipped (the coupling); application-provided (choosing edges that survive it).

Leg 3 — present: hand a ref to the screen

When a store is attached, one more tool is auto-attached and its name is reserved exactly then (PRESENT_TOOL_NAME): present({ ref, as, label? }). A storeless agent may keep its own present, because the framework attaches nothing there.

It heads the ref. It never gets it. Presenting is the render-by-ref decision; the screen pays for the bytes later, under its own identity. What the model — and therefore the transcript — keeps is a PresentedResult carrying a description snapshot: { kind, mediaType, bytes, label }.

The snapshot lives inside the tool result, the one thing provider message history keeps typed and durable. So a conversation reloaded long after the artifact expired can still render an honest placeholder from history alone — "Chart — 'Q3 sales by region' (bar-chart, 41 KB) — expired; re-run to regenerate" — instead of a blank pane. shipped

A miss is a teaching refusal listing the live refs in scope, sets error: true on the call (so a procedure step whose tool presented nothing does not advance), and lands as artifacts.refused. A hit emits agentfootprint.artifacts.presented. shipped

as is consumer vocabulary — 'bar-chart', 'table', 'image' — and it is stored as data, deliberately unvalidated. What that does and does not buy is chapter 4.


Chapter 3 — The code leg, and what lineage is allowed to do

Data goes in

codeRunnerTool({ wants }) is the wants leg pointed at an interpreter: the resolved payload is staged into the session as a file through CodeSession.stageInputs, and the model is told the paths through an environment variable naming a JSON manifest keyed by argument name. A dataset reaches the interpreter without one byte of it entering the prompt. shipped

A runner whose sessions cannot stage refuses by name, naming both the runner and the tool, rather than running code against files that are not there. shipped

Outputs are a gap, and the gap is named

CodeResult.artifacts is the field an adapter fills with the files a run produced; codeRunnerTool mints every entry that carries data into the store with no further wiring, and the rendered result names the ref.

No shipped runner populates it. localCodeRunner leaves the field absent — never [] — and that distinction is the whole point: [] says this execution produced no files, while absent says this runner does not report produced files at all. It has no declared output location to collect from; the child's working directory is the caller's own cwd, so "files the code wrote" is not a set it can identify without guessing which of a developer's files were meant. A dev-loop runner that quietly uploaded whatever appeared beside your source would be the worse failure.

A runner that wants to produce outputs owes exactly three things, and then the minting works with no further wiring:

  1. a declared output location the model is told about — so "what the code wrote" is a set, not a guess;
  2. a bounded read-back of what landed there — so one runaway loop cannot hand back a directory;
  3. a real byte countbytes must be the payload's true length, because retention plans evictions against it.

Status: shipped (the minting path, codeRunnerTool side) · application-provided (a runner that fills the field) · planned (a shipped runner that does).

Lineage is stamped automatically now (9.38.0)

When code produces a file and the same call resolved input refs, the mint carries parentRefs stamped from those inputs, deduplicated. The framework already redeemed them — that is what ctx.wanted is — so the lineage is a fact it holds, not an inference. A consumer folding head() over parentRefs walks from the chart back to the dataset without the tool author having remembered to wire it. shipped

Two details that are decisions rather than implementation noise:

  • absent, not [], when nothing resolved. An empty array reads as "derived from nothing", which is a different statement from "nothing said where this came from".
  • a parent that expired mid-call fails that mint rather than silently dropping the lineage. parentRefs are validated at mint — a foreign key that cannot dangle at birth — and an artifact minted with an unprovable derivation is exactly the lie the validation exists to refuse. The failure is contained per entry and stated in the line the model reads; the code's success is not turned into a throw.

…and lineage moves nothing

This is the claim worth being loudest about, because it is the one a reader will assume otherwise.

parentRefs does not move the cursor, gate a tool, satisfy a step, or influence routing in any way. It is recorded at mint, validated at mint, stored, carried on the artifacts.minted event, and drawn on a read-only lens card. That is the complete list of what reads it.

It was worth trying to falsify, and it does not falsify: outside minting, storage, its event and that card, nothing in the runtime reads the field. Derivation is a fact, not an engine. There is deliberately no lineage-graph walker, because the moment your question becomes "why does this chart disagree with that dataset", that is causation — and causation is what the trace already owns. shipped (the fact) · planned (nothing: a lineage engine is refused, not deferred).


Chapter 4 — The screen

The frontend half ships in a separate package, agentfootprint-lens (0.34.0 and later). Naming it precisely matters here, because a plausible- sounding component name that does not exist costs a reader an afternoon.

What shipsWhat it isStatus
ArtifactResolverthe abstraction a pane redeems through — head then getshipped (in agentfootprint-lens)
httpArtifactResolver({ url, sessionId })redeems against a served agent, carrying session identity on every requestshipped (in agentfootprint-lens)
storeArtifactResolver({ … })redeems against a same-process storeshipped (in agentfootprint-lens)
registerArtifactComponent({ kind, component })a module-level registry: one page has one vocabulary. Returns an unregister functionshipped (in agentfootprint-lens)
<ArtifactPane presented resolver />renders one present call: live → the registered component; absent → the stated placeholder built from the snapshot alone; failed → the door's own refusal, verbatimshipped (in agentfootprint-lens)
humanizeArtifactMinted / …Resolved / …Expired / …Refused / …Presentedthe five-verb narration vocabulary, one function per lifecycle eventshipped (in agentfootprint-lens)

The wire underneath is two read-only operations — artifact-head and artifact-get — answered by a served agent on its invoke path. There is deliberately no put, delete, or list over the wire: a screen redeems tickets; it does not mint or sweep, and list over a wire would let a caller enumerate a scope. shipped

"The model cannot invent a component" — true, but not for the reason you would guess

It is tempting to say the model cannot summon an arbitrary renderer because as is validated against a registry. That is not what happens.

Renderer selection is by kind alone. The registry keys on the artifact's kind — the producer's vocabulary, stamped at mint by the tool that stored the data. as is the model's hint, it is never validated anywhere, and it never selects anything. It travels through to the component as context and shows up in the narration.

So the safety claim survives, by a stronger mechanism than validation: the model cannot summon a component the producing tool did not describe, because the model is not the one choosing. A kind nothing is registered for renders the honest metadata card plus a line naming the gap — never a blank pane, never a crash. shipped (kind-keyed selection) · planned (validating as: not built, and on this reading not needed).


One worked failure

Every example above succeeds. This one does not, which is the point: the wants refusal is the mechanism you will actually meet in a transcript, and it is the one almost never shown. Everything below was captured from a real run against this repository's source, not composed by hand.

The setup: three tools and a placement dial. fetch_sales returns a large result that placement checks in as tool-result/fetch_sales. fetch_regions mints a dataset/rows artifact explicitly. summarize declares wants: { dataset: 'dataset/rows' } with dataset required in its schema. The agent runs with toolArgValidation: 'off' — deliberately, because that is the configuration in which the pre-9.38.0 hole existed.

The model then makes the two mistakes models make: it grabs the first ref it saw, and then it calls the tool with no ref at all.

What placement told it in the first place

The role: 'tool' message after fetch_sales, verbatim:

{"placed":true,"ref":"art_eTDFC0vngTcQaQGPMc4YuT","kind":"tool-result/fetch_sales","mediaType":"application/json","bytes":165179,"reason":"fetch_sales returned 165179 chars, over the 2000-char placement threshold, so the full result was stored as artifact 'art_eTDFC0vngTcQaQGPMc4YuT' (tool-result/fetch_sales) instead of entering this conversation. Route the ref: pass the string 'art_eTDFC0vngTcQaQGPMc4YuT' to a tool whose argument wants 'tool-result/fetch_sales', or call present({ ref: 'art_eTDFC0vngTcQaQGPMc4YuT', as: … }) to hand it to the screen. Do not retype or summarize content you have not read."}

165,179 characters became one line. The reason field names the ref twice and says what to do with it, because a ticket the model cannot route is just a truncation with better manners.

Failure 1 — the wrong parcel

The model passed that ref to summarize, which wants 'dataset/rows'. Verbatim, the entire feedback the model receives:

Tool 'summarize' was not executed — its declared artifact argument did not resolve. 'dataset' = art_eTDFC0vngTcQaQGPMc4YuT resolves, but it is 'tool-result/fetch_sales' and this argument wants 'dataset/rows' — the wrong parcel for this ticket window. Live 'dataset/rows' refs in scope: art_zaObKXGSPDxwSf2btQf95w ('region table', 72 bytes). Pass one of these.

Three things are doing work in that sentence. It says the tool was not executed, so the model does not reason about a result that never existed. It names both kinds, so the mistake is legible rather than mysterious. And it ends by listing what would fit — the live dataset/rows refs in scope, with labels and sizes — because a refusal that only says "no" moves the puzzle without solving it.

Failure 2 — the hole that used to be silent

Corrected but overcorrecting, the model called summarize with no dataset at all. This is the case 9.38.0 fixed. Verbatim:

Tool 'summarize' was not executed — its declared artifact argument did not resolve. 'dataset' is required and no value was passed — this tool declares it wants a 'dataset/rows' artifact there, and the framework resolves the ref BEFORE the tool runs, so the tool is never executed without it. Pass the art_… ref as 'dataset'. Live 'dataset/rows' refs in scope: art_zaObKXGSPDxwSf2btQf95w ('region table', 72 bytes). Pass one of these.

Before 9.38.0, with toolArgValidation at 'off' or 'warn', that call ran — the handler executing with args.dataset undefined, believing the framework had resolved it.

What the record says

Both refusals landed on the causal record as agentfootprint.artifacts.refused, at the framework's own door:

[
  {
    "op": "dispatch",
    "reason": "kind-mismatch",
    "ref": "art_eTDFC0vngTcQaQGPMc4YuT",
    "detail": "'dataset' = art_eTDFC0vngTcQaQGPMc4YuT resolves, but it is 'tool-result/fetch_sales' and this argument wants 'dataset/rows' — the wrong parcel for this ticket window. Live 'dataset/rows' refs in scope: art_zaObKXGSPDxwSf2btQf95w ('region table', 72 bytes). Pass one of these.",
    "tool": "summarize"
  },
  {
    "op": "dispatch",
    "reason": "invalid-input",
    "detail": "'dataset' is required and no value was passed — this tool declares it wants a 'dataset/rows' artifact there, and the framework resolves the ref BEFORE the tool runs, so the tool is never executed without it. Pass the art_… ref as 'dataset'. Live 'dataset/rows' refs in scope: art_zaObKXGSPDxwSf2btQf95w ('region table', 72 bytes). Pass one of these.",
    "tool": "summarize"
  }
]

op: 'dispatch' is the marker worth knowing: it is not one of the five store verbs. It means the tool-calls stage declined to run a tool whose declared data could not be delivered. Two refusals, no execution, no invented data, and every hop on the same record as the rest of the run.

That is the whole invariant in one run: a ref the framework cannot deliver is answered in words the model can act on, is on the record as an event, and the tool does not run.


What does not exist

Named plainly, because an absence described in the present tense is the most expensive kind of documentation error.

The thingThe reality
a runner that reports produced filesNone ships. localCodeRunner leaves CodeResult.artifacts absent by design (chapter 3). The minting path is real and works the moment a runner fills the field. planned.
getStream verifying integrityIt does not, and cannot cheaply. get is the verifying read. meta.digest rides so you can check it yourself. shipped limitation.
a registry that validates asNot built, and on chapter 4's reading not needed — selection is by kind, so as never chooses anything. planned.
defineArtifactKindDoes not exist. A proposed way to declare a kind's schema once. Do not import it. planned.
artifactComputeToolDoes not exist. A proposed compute-over-artifacts tool. Note that any such thing must live outside the port — see chapter 0's refusal. planned.
a lineage-graph engineRefused, not deferred. Walking parents is a fold over head(); asking why two artifacts disagree is a question for the trace.
query() / transform() on the portRefused, permanently. Adding either makes this a database.

For the adapters, the option shapes, retention dials, the full event table and the runnable examples, go to Artifacts.

On this page