Debug

Scrub a finished run

Time travel over an agent's commit log — stop on iterations, LLM turns, tool calls and decisions instead of on all forty stages, and read at each stop what the model was actually handed.

A finished run is a commit log: one bundle per executed stage, in order. footprintjs 9.17 opens a reader's cursor over it — timeTravel — with a fold at every stop, and takes a strategy that says where the cursor may rest.

Its own strategy, commitStops, stops on every stage. That is the truth and it is unreadable: a two-turn agent commits around forty of them, most called context, sf-cache or sf-thinking. milestoneStopsStrategy is the agent's answer to "which of those would a person scrub to?" — the same milestoneFor classifier the library has always used, mapped onto the log.

import { timeTravel } from 'foottrace';
import { milestoneStopsStrategy, milestoneOf } from 'agentfootprint';

const cursor = timeTravel(agent.getSnapshot()!, { strategy: milestoneStopsStrategy });

cursor.stops.map((s) => `${s.label} · ${milestoneOf(s)?.kind ?? s.kind}`);
// Run start · start
// Iteration · iteration
// System prompt · slot
// Messages · slot
// Tools · slot
// LLM turn · llm-turn
// Route · decision
// Tool call · tool-call
// … Run end · end

Five kinds, and they are the domain's, not the chart's: iteration · slot · llm-turn · tool-call · decision. The milestone rides on the stop: every stop this strategy makes is a Stop<Milestone>, so stop.meta?.kind is typed and milestoneOf(stop) reads that same slot (and, for a stop from another strategy that carries no milestone, falls back to classifying the stop's runtimeStageId). A stage that classifies as none of them gets no stop; its commits fold into the stop before it, so nothing is lost from stateAt — it just stops being a place the slider can land.

Under the hood the strategy is one filter over footprintjs's own stop grammar — filterStops(commitStops(log, tree), keep), the composer footprintjs 9.18 shipped — so there is exactly one owner of the [start, …stages, end] contract, the re-partition and the vocabulary slot: the library that returns the axis. The axis itself did not move between 9.88 and 9.89; a test drives a verbatim copy of the old implementation over every recorded fixture and requires agreement on every stop and every fold.

What `'start'` means on this axis

On footprintjs's own per-stage axis, 'start' is the fold base: the state before any stage ran. Here it also absorbs every stage that ran before the first milestone — and an agent seeds a couple of dozen keys in seed first. Measured on a two-turn run: commitStops' start folds no commits and 0 keys; this one folds the first commit and 32. So stateAt(startStop) is the state the first milestone read, not the run's raw base. Right for an axis whose stops must still partition the log; wrong to assume from kind === 'start' alone — so the start says it: cursor.stops[0].prologue === true whenever it absorbed a stage (footprintjs 9.18's flag). A renderer that means "before anything ran" checks kind === 'start' && !prologue.

Why this and not "one stop per stage"

Granularity used to be a structural accident: a stage earned a scrub stop by happening to be a subflow. That put an observability decision behind a chart lever, and it put agent vocabulary ("what is a slot?") inside a generic renderer. The classifier moves the decision to the layer that owns the meaning, and this strategy is how every reader gets the same answer from it — a Why Lens, a custom panel, a script over a saved recording.

Ask the three questions

The cursor answers what a reader actually wants to know. One position, always:

const turns = cursor.stops.filter((s) => milestoneOf(s)?.kind === 'llm-turn');

cursor.jumpTo(turns[1]!.step);
cursor.stateAt().state.currentSkillId;    // 'beta'  — where the run stood at turn 2
cursor.changedSince(turns[0]!);           // ['currentSkillId', 'history', …]
cursor.mark('the turn that went wrong');  // the READER's note — never written into the run

A refused move never moves. jumpTo('seed#0') — a stage that really ran and really committed, but is not a milestone — comes back { moved: false, reason: 'miss', at, nearest }, and the panel keeps showing what it was showing.

Where the LLM turn lives depends on the chart shape

One strategy, both shapes

milestoneStops classifies the LOCAL segment of a stage id, so it never needs to be told whether it was handed a run's log or a subflow's. Hand it either.

reactMode: 'dynamic' — the turn is a commit on the run's own log, so the llm-turn stop is on the outer cursor, beside the iteration and slot stops.

const cursor = timeTravel(agent.getSnapshot()!, { strategy: milestoneStopsStrategy });
const turn = cursor.stops.find((s) => milestoneOf(s)?.kind === 'llm-turn')!;
cursor.stateAt(turn).state;   // the state that turn left behind

reactMode: 'dynamic-grouped' — each turn is an sf-llm-call subflow with its own isolated log, so the outer axis holds the iterations and the turn is one drill down. drill() returns a separate cursor over that subflow's own log; the same strategy reads it.

const iterations = cursor.stops.filter((s) => milestoneOf(s)?.kind === 'iteration');

const inner = cursor.drill(iterations[1]!.runtimeStageId)!;   // its own log, its own base
const turn = inner.stops.find((s) => milestoneOf(s)?.kind === 'llm-turn')!;
inner.stateAt(turn).state;

Drill by runtimeStageId, not by path: a subflow inside a loop runs many times and every iteration shares one path, so sf-llm-call#1 and sf-llm-call#26 are two different turns and two different logs.

Which keys are visible where

In the grouped shape the settled skill cursor for turn k is on the OUTER axis, at iteration k (currentSkillId). Inside the drill, currentSkillId is the value the turn started from — it crosses the mount as a read-only input — and the move this turn made is nextSkillCursor, which the outputMapper merges back out. Both logs are truthful about different questions; scrub the outer axis for what settled, drill for what happened.

The recording carries its own milestones

Since 9.90.0 the charts DECLARE their milestones as footprintjs tags at build time, and footprintjs 9.21 stamps them on the stage's first commit bundle (CommitBundle.tags). Two tags per stage, one vocabulary, owned by conventions.ts:

tagwhat it isexample
milestone:<kind>the kind — what you filter onmilestone:llm-turn
milestone-label:<label>the human wordmilestone-label:LLM turn

The tag is the fact; the id is the fallback. milestoneStops reads the bundle first and classifies from the stage id only for a bundle with no tags — a recording made before 9.90.0. Every milestone stage is declared, the three context-slot branch mounts included, so on a 9.90.0 recording that fallback runs zero times (the test suite counts it). Same table either way, so the axis a 9.88 consumer scrubbed is the axis it still scrubs.

What it buys you: a reader needs NO agent id conventions. footprintjs's own tagStops — a strategy that has never heard of an agent — scrubs a stored recording by your word:

import { tagStops, timeTravel } from 'foottrace';
import { milestoneTag } from 'agentfootprint';

const turns = timeTravel(agent.getSnapshot()!, { strategy: tagStops([milestoneTag('llm-turn')]) });
turns.stops.map((s) => s.label);   // ['Run start', 'CallLLM', 'CallLLM', 'Run end']
turns.stops[1].meta;               // ['milestone:llm-turn', 'milestone-label:LLM turn']
turns.stops[0].prologue;           // true — seeding and context assembly ran before the first turn

And the Map advertises the vocabulary before any run — buildTimeStructure lists the tags each stage can produce, so a lens draws its legend first.

The vocabulary is exported so no reader spells a string: MILESTONE_KINDS is every kind the table knows; MILESTONE_TAG_PREFIX ('milestone:') and MILESTONE_LABEL_TAG_PREFIX ('milestone-label:') are the two prefixes; milestoneTag(kind) builds the kind tag; milestoneTags(milestone) builds both tags for a Milestone; milestoneTagsFor(localStageId) builds them from the table for one of the agent's own stage ids (and throws for an id the table does not classify, so a wiring mistake is loud); and milestoneFromTags(tags, labelWhenUndeclared?) reads a bundle's tags back into a Milestone — null when they carry no milestone kind tag, non-strings ignored, the stop's own label used when no label tag was declared.

A derived mark is the other kind: computed at read time from what the log says happened, never stored. "Stops where currentSkillId was written" is a keep rule over each stop's own trace — the same filterStops composition, no new API. examples/observability/25-declared-vs-derived-stops.ts scrubs one real run both ways and measures them: declared 0.016 ms, write-set predicate 0.020 ms — and a full stateAt fold per candidate stop, 11.7 ms (≈ 750×). That cost is why the declaration is the fact and the derivation is the fallback.

Receipt at the stop

An llm-turn stop is where a person actually stands when they ask the question this library exists for: what did the model read, right here?

Until 9.88.0 the honest answer was "most of it". The request a provider receives is assembled from committed pieces and is itself never committed — the call-llm bundle holds the response, not the ask. The pieces are all on the record (systemPromptInjections, history, dynamicToolSchemas, cacheMarkers, wrapUpAsked), but the rules that turn them into a request lived inside the calling stage, so a reader who wanted the request back had to re-implement them and hope.

Two things now stand at every llm-turn stop, and one law binds them.

import { receiptAt, receiptHash, servedAt } from 'agentfootprint';

const snapshot = agent.getSnapshot()!;

// SERVED — the request, rebuilt from the committed pieces.
const view = servedAt(snapshot, 1)!;
view.system.text;                // the joined system prompt, as sent
view.messages.asSent;            // the conversation that went out
view.messages.requestOnly;       // lines written to no history (the nudge)
view.tools.names;                // ['read_skill', 'lookup', 'skip_step']
view.tools.withheld;             // 'wrap-up' on the out-of-budget call
view.basis?.model;               // which model saw this
view.gaps.map((g) => g.gap);     // ['cache-transform', 'provider-defaults'] — an agent; an LLMCall reads ['provider-defaults']

// RECEIPT — hashes and references, written by the call itself.
const receipt = receiptAt(snapshot, 1)!;
receipt.basis.model;             // the model that answered
receipt.messages.count;          // how many turns went out
receipt.tools.forced;            // the forced answer tool, or null
receipt.params.temperature;      // the dial this turn went out on
receipt.cache.markersApplied;    // which cache breakpoints the strategy applied
receipt.cache.transform;         // 'unchanged' | 'rewritten' | 'unknown'

// THE LAW
receiptHash(receipt.basis.runId, view.system.text) === receipt.system.hash; // true

The second argument is the epoch — the run's own iteration number, 1-based, the same number on iteration_start. servedViews(snapshot) gives every epoch in one pass, and epochAt / epochLocations are the one owner of where an epoch's pieces live: the run's own log under reactMode: 'dynamic', the turn's inner sf-llm-call history under 'dynamic-grouped'. Everything above works unchanged in both shapes because everything above asks them.

When the two agree, the record is complete: everything the model read is derivable from the trace. When they disagree, something reached the model that the run never wrote down — a defect in the record, not in the check.

Measure the initial prepared request

receiptAt(snapshot, epoch)?.requestMeasurement reports the JSON size of the complete initial prepared canonical request, including full final tool schemas and excluding only the root signal field. The shared receipt mint adds it whenever recording a receipt is enabled; older receipts can lack the field. Its RequestMeasurement type and the RequestJsonSize type for each size are exported from agentfootprint. There is no new runtime export or configuration.

const measurement = receiptAt(snapshot, 1)?.requestMeasurement;
if (measurement?.status === 'measured') {
  console.log(measurement.total.jsonBytes);
  console.log(measurement.slots.tools?.jsonBytes);
} else {
  console.log(measurement?.reason ?? 'Not recorded');
}

Both result shapes declare boundary: 'initial-prepared-request' and format: 'json-utf8-v1'. A measured result has total and separately serialized slots.systemPrompt, slots.messages and slots.tools when present. Each size contains jsonChars (JSON UTF-16 code units) and jsonBytes (UTF-8 bytes of that JSON). Slot sizes do not sum to the total, which also includes field names, delimiters and other request fields. These are counts, without recorded content.

An unavailable result states unsupported-value, cyclic-value or measurement-limit. Internal work limits are 100,000 visited values, 64 nested containers and 4,000,000 string/key UTF-16 code units while copying or serialized JSON characters. Reaching a limit skips the measurement; it never truncates or rejects provider input. An absent or unavailable measurement is not zero.

This measures the request after initial preparation, including cache preparation where configured. It does not count tokens, HTTP/vendor payloads, provider decorator changes or later retry attempts. Existing advisory slot-budget counters remain unchanged, and the measurement enforces no request limit.

Which charts mint a receipt

Every chart that hands a model a request mints one (9.91.0). Agent and LLMCall own their executor and salt each run's hashes with that run's own id; both take recordReceipt: false to decline the mint on a run that does not want to pay for it. The two messageAPI charts are chart builders handed to an executor you own, and nothing in a stage's scope carries that executor's run id — so they take the salt as a dep:

const runId = `run-${Date.now()}`;
const chart = buildAgentMessageApiChart({ provider, model, systemPrompt, tools, getRunId: () => runId });
await new FlowChartExecutor(chart).run({ input: { message: 'weather in paris?' } });

Omit getRunId and no receipt is minted — deliberately. Every hash a receipt carries is salted with the run id so a short prompt or a two-word turn cannot be fingerprinted across runs, and a receipt travels inside recordings; a chart that minted with an empty salt would ship guessable fingerprints by default. The view is rebuilt exactly as before and says what it has not got: no-receipt-on-chart, cause 'no-receipt-committed'.

The shapes, one line each

servedAt returns a ServedView: its system.pieces are ServedPiece records (the text, its slot, its source, in wire order) and its messages.requestOnly entries are ServedRequestOnly records (role, text, and the mechanism that composed it). receiptAt returns a Receipt, whose system.pieces are ReceiptPiece fingerprints, whose messages.entries are ReceiptMessage fingerprints and whose messages.requestOnly entries are ReceiptRequestOnlyMessage fingerprints — the same three lists, hashed instead of quoted.

receiptHash(runId, content) is the digest rule both sides use, and messageDigestInput(message) is what a message's hash covers — role, text, the join keys that pair a tool result to its call (toolCallId and toolName: two shipped providers join on the name), any signed thinking blocks, and, for an assistant turn, the calls it asked for. toolDigestInput(tool) is what a tool schema's hash covers — the whole LLMToolSchema as sorted-key JSON, with a schema JSON cannot express marked rather than dropped. Import them rather than writing a fourth copy of the rule: checking a rebuild with your own hash proves your hash, not the record.

Verify every row from outside

Each digest takes exactly the object a served view already holds, so a panel that shows a receipt can mark every row Verified without reaching into this package:

import { receiptAt, receiptHash, servedAt, messageDigestInput, toolDigestInput } from 'agentfootprint';

const view = servedAt(snapshot, 1)!;
const receipt = receiptAt(snapshot, 1)!;
const hash = (input: string) => receiptHash(receipt.basis.runId, input);

hash(view.system.text) === receipt.system.hash;                                  // system
view.system.pieces.every((p, i) => hash(p.text) === receipt.system.pieces[i].hash); // pieces
view.messages.asSent.every((m, i) => hash(messageDigestInput(m)) === receipt.messages.entries[i].hash); // messages
view.tools.schemas.every((t) => hash(toolDigestInput(t)) === receipt.tools.schemaHashes[t.name]);       // schemas

The last line is new in 9.89.0. Until then a consumer could prove everything the model was served except the tools' schemas: the receipt hashed each one through a serializer the package did not export, so a schema row could never read Verified without copying the rule — and a copy is a second owner that drifts the day the digest changes. toolDigestInput is the only spelling of that rule; the receipt builder calls it too. Pass servedAt(k).tools.schemas[i] — the schema as the port was handed it — not a Tool definition, which carries fields the model never saw. A forced answer tool is in schemaHashes and not in schemas; its body is the declared forced-tool-schema gap, so there is no row to check.

cache.transformHash — set only when a cache strategy rewrote the request — is recomputed with transformHashOf(receipt.basis.runId, request), from the request the strategy handed back (a provider decorator sees it). It is a chain: each message digested on its own, in order, then the rest of the request, so a long run hashes only what each call added. The value names its scheme: it starts with TRANSFORM_HASH_PREFIX ('chain-v1:'). A receipt minted before the chain carries a bare 16-hex value, which verifies as receiptHash(runId, stableJson(request)).

A gap is a ServedGap — { gap, fields, why, cause? } — and its gap is a ServedGapKind. fields is a list because one missing fact can leave several fields unproved. SERVED_GAPS is the catalogue those sentences come from, so a renderer prints the library's own wording, and RECEIPT_BOUNDARY is the one sentence that scopes all of them.

A gap sentence says three things and no more: which fields it covers, what they mean on this view, and what therefore follows. Anything else — how many causes there are, that they are harmless, that you can tell them apart, what some other module does — is a claim frozen into a constant that the next release falsifies without anyone editing it, which is exactly what happened to the one gap that listed its causes. So the discriminating fact is computed where it is known and carried as data: cause is a ServedGapCause, set by the read that failed. A renderer prints the sentence and, if it wants, the cause.

That rule keeps the sentences short; it does not make them true. A sentence that tells you something useful is a claim about how the rebuild behaves, and the rebuild is code — so every claim each sentence makes is asserted against a real view in test/time-travel/gap-sentences.test.ts, one run per entry, with the clauses required to partition the sentence so no printed word goes unchecked. UNGAPPED_FIELDS is the other half of the account: the fields no gap names, each with the one sentence saying why none is needed. Between them they cover every field of both shapes, and a walk (test/time-travel/gap-catalogue-walk.test.ts) is what keeps that true — it derives the field list rather than reading it, so a field added later cannot go unnamed. epochAt and epochLocations return EpochLocation records: the epoch number, the call-llm stage's runtimeStageId, the log holding it, the index it sits at, and the fold basis that log was read against. keyedFold(source) is that fold on its own, for a reader that wants one key at one commit.

view.basis — { model, provider, runId } — says which model saw this. It is read off the receipt (the only place the run records it), so it is absent when there is no receipt, and it is deliberately not part of the law.

receipt.params is a ReceiptParams — the sampling dials the call went out on (temperature, maxTokens, thinkingBudget, stop, toolChoice), read off the request the port was handed and each present only when the library actually sent it, so an absent key means none was sent rather than the model ran without one. receipt.cache.markersApplied is a list of ReceiptCacheMarker — { field, boundaryIndex, ttl } per cache_control breakpoint the strategy really applied, which is what makes two turns comparable when you need to know whether the cached prefix moved.

keyedFold(source) returns a KeyedFold: valueAt(key, commitIdx) for one state key at one commit, plus the FoldBasis — 'initial+log' when the recording carried its fold base, 'log-only' when it did not — that every gap above is decided from. Use it when you want one key rather than a whole request:

import { keyedFold } from 'agentfootprint';

const fold = keyedFold(agent.getSnapshot());
fold.basis;                     // 'initial+log' — the base travelled
fold.valueAt('history', 12);    // the conversation after the 13th commit
fold.valueAt('history', -1);    // …and what the run STARTED from

That base is why a resumed run reads correctly. A resume is a fresh executor seeded from checkpoint.sharedState, so the pre-pause prompt, conversation and tool list are the resumed run's fold base rather than its log; folding the log alone reported an empty prompt and a one-message window for a call that went out with both.

The receipt's three laws

  1. Hashes and references, never bytes. A receipt records that a piece of a given shape and size was in a given position, and nothing about what it said. The bytes are governed elsewhere — recordSystemPrompt is opt-in for exactly this, redaction scrubs the committed mirror, a window strategy decides what survives — and a receipt carrying content would quietly reopen all three.
  2. Run-salted digests. sha256(runId + '\u001f' + content), first 16 hex characters. Hashes are not redacted, and the salt is what limits them: an unsalted hash of a one-line prompt or a two-word user turn is a dictionary lookup away from being read back, and receipts travel inside recordings. Salted per run, a hash answers "is this the same as that piece of this run?" — the only question the law asks — and cannot be matched across runs or against a precomputed table. It does not keep a GUESSABLE value secret from someone holding the recording, which carries the runId too: a short value (a PIN, a yes/no) can still be confirmed by hashing guesses.
  3. No authority omissions. A receipt never names, and never counts, what a caller's role was not allowed to see. Committed state is readable by the trace toolpack's debugging tools, so a receipt carrying hiddenSkillIds — or even "3 skills withheld" — would restate a permission decision one layer down where nobody is checking.

The vintage law: a reader reads the receipt it is handed

A missing container is a fact about the vintage, never a throw. A recording is older than the reader that opens it. Receipt is what a mint writes, and a mint writes every container it knows — but what a mint knows has grown (cache came with the receipt in 9.88.0; cache.strategy in 9.93.0), and a receipt you already hold was written by the release that wrote it. So receiptAt hands back a StoredReceipt: the same shape with the containers the narrowing does not check declared optional, returned as stored — nothing repaired, no cache: {} fabricated, the record never written to. Absence reads as cannot say: servedAt raises cache-transform on a receipt with no cache.strategy exactly as on one with no cache and on no receipt at all; only a receipt that says null lifts it. The basis is the one container every vintage carries, so every hash row a receipt has still verifies.

Why it is a law: 9.93.0 read receipt.cache.strategy past that narrowing, and every stored receipt with a basis and no cache made servedAt throw where 9.92 built a view — found by the lens the day after, on a recording it already held. A Lens may omit, never deny; a reader never throws on a stored recording.

import { receiptAt, servedAt, type StoredReceipt } from 'agentfootprint';

// a recording minted by 9.92, opened by this release
const receipt: StoredReceipt = receiptAt(olderRecording, 1)!;
receipt.cache?.strategy;                              // undefined — the mint predates the key; not null, not repaired
receipt.basis.runId;                                  // the one container every vintage carries
servedAt(olderRecording, 1)!.gaps.map((g) => g.gap);  // ['cache-transform', 'provider-defaults'] — it cannot say, and says so

Gaps: what the view cannot prove, said out loud

A rebuild that quietly omits a piece looks exactly like a rebuild that proved the piece was absent. servedAt(k).gaps keeps the two apart, and every entry names the fields it covers. servedAt returns undefined for one reason only — the run has no such epoch. Anything it cannot prove about an epoch that does exist is a named gap, never a missing view and never a confident empty one.

What is written below is longer than what a reader is shown. The sentence a renderer prints — gap.why — names no mechanism at all: not a module, not a function, not a key, not a version, not a chart, not a strategy, not an option. It says which fields it covers, what they mean on this view, and what to do differently. Five review rounds tried to write true mechanism sentences into that frozen constant and the rate of new falsehoods held constant, so the sixth stopped writing them. The mechanism did not disappear — it is in the comment beside each entry in the source, the cause is a value on the gap (ServedGap.cause), and it is spelled out here, because a doc is versioned with the code and you can open the file.

That rule keeps the sentences short; it does not make them true. The sixth round said a sentence with no code claim in it cannot go false, and a seventh round measured that and found otherwise: ten of the eleven printed sentences still claim something the rebuild decides — may be short, empty means unknown, the list is complete and the schemas are one short — because a sentence that claims nothing cannot inform. So every claim a printed sentence makes is asserted against a real run in a test beside it, test/time-travel/gap-sentences.test.ts: each sentence is cut into quoted clauses, each clause carries its own assertion, and the clauses must cover the whole sentence, so no printed word sits outside a checked claim. That is how the seventh round caught a sentence the sixth had just written — no-run-log claimed its fields "could not be fully recovered" on a view where, measured, nothing had been lost. The one blind spot left is a claim nobody wrote an assertion for: an assertion can be weaker than the clause it checks, and only a person reading the two together sees the difference.

  • no-fold-base → fields: system.hash, system.chars, system.pieces, messages.count, messages.entries, messages.requestOnly, tools.schemaHashes, tools.names, tools.forced, tools.withheld, epoch The recording travelled without initialState, so anything the run inherited rather than set reads as absent. On a resumed run that is most of it. Either base counts: the log holding this epoch's call, or the run log holding its build-time constants. Under 'dynamic-grouped' those are separate logs, so a grouped recording can lose the run base by itself. The counts move with the things they count, and so does this view's epoch: a fold that cannot read iteration numbers the turn by its position instead. The receipt's basis.epoch is not on the list — it was minted live and rides in the call's own bundle, whatever the base. Re-read the run from a snapshot that carries initialState and the gap goes away. The printed clause used to say the view's number "may differ from the one the receipt for this turn carries" — a sentence about a receipt, printed on views that have none. It now says what the number means: it may be the turn's place in run order rather than the count the run kept.

  • no-conversation-on-record → fields: messages.count, messages.entries, messages.requestOnly The call committed neither history nor messagesInjections, so the turns that went out are unknown, not empty: an empty asSent is the absence of a record, never a record of absence. The request-only lines are recomposed from the conversation, so they are unproved with it.

  • no-receipt-on-chart → fields: basis.model, basis.provider, basis.runId, basis.epoch, params, cache.transform, cache.transformHash, cache.markersApplied, cache.strategy, omittedForAttention, requestMeasurement, replayPreparations No usable receipt was read for this epoch, so nothing on the view has been checked against what went out. Not "none was written": the causes below differ on exactly that point, and the printed sentence used to get it wrong — it opened "No receipt was found" and closed "absent here means unrecorded", both false when a receipt was written and was refused. The fields listed are carried only by a receipt; everything else is rebuilt from the log alone, unverified. Why there is none is data rather than prose — the gap carries a cause. 'no-receipt-committed' means nothing was written under the receipt key: a pre-9.88 recording, a run with recordReceipt: false, or a chart whose call-llm stage mints none (LLMCall and the message-API charts run a call-llm of their own that does not mint; LLMCall has a run id and could, the two exported message-API chart builders run on a consumer's executor and have none to salt hashes with). Those leave the same record, and the read does not separate them. 'receipt-shape-rejected' means something was written there and carries no basis, so it was refused — that one says the recording is damaged. omittedForAttention and cache.strategy are on the list since 9.93.0: both are receipt-only facts — what the window dropped before this call, which strategy the request went through — and they go with the receipt. (omittedForAttention was a key of UNGAPPED_FIELDS until then, on a measurement that no chart supplied it; the agent's window stage does, on every run whose strategy evicts.) The sentence also used to close "their absence here is a gap in the record, never a call made without them", which holds for each field as a whole and fails one level down: a receipt always carries params and always carries a cache.transform verdict, and an absence inside params — {} on an agent that set no dials — really is a call made without one. It now claims nothing about what is inside a field it cannot see.

  • findings-layout → fields: system.pieces, system.chars, messages.requestOnly, messages.count, messages.entries An unknown or masked findings layout or bound withholds its context. An absent discriminator on an older record retains the legacy layout. The request that actually went out may still have contained findings data; absence in this view is not evidence that nothing was served.

  • no-run-log → fields: tools.names, tools.forced, tools.schemaHashes, messages.requestOnly A subtree was handed in on its own, and run constants live in the run log and only there — the forced output tool's name, and the tool wants the staged-refs nudge is composed from. Losing the name also loses the schemaHashes row the receipt keeps under it. Pass the whole snapshot to read them. May, never did: measured on a 'dynamic-grouped' agent with one plain tool and its commitLog emptied, the damaged rebuild is byte-identical to the intact one — that run has no constant to lose, so the gap costs it nothing. The condition is not narrowed to the runs where it does cost something, because whether the run had one is recorded in the log whose absence raises the gap.

  • cache-transform → fields: cache.transform, cache.transformHash, cache.markersApplied, cache.strategy, system.hash, system.chars, system.pieces, messages.count, messages.entries, messages.requestOnly, tools.names, tools.schemaHashes, tools.forced, tools.withheld Raised where a strategy could have rewritten the request (9.93.0): where the receipt names one (cache.strategy — every agent; '*' is the built-in pass-through), or where no receipt can say (a receipt-less view, a receipt minted before the field existed). Not raised where the receipt says cache.strategy: null — LLMCall and the message-API charts, which hand the port the request assembly built with nothing in between. Until 9.93.0 it was on every view, because 'unchanged' could not tell a pass-through strategy from no strategy and the view refuses to infer that from any other key. The list names every field the strategy holds — tools.forced and tools.withheld joined in 9.93.0, because both are written from assembly's own decision and never from the prepared request. Where a strategy did run, its prepareRequest rewrites the request after assembly, and the strategy is code: the commit log holds the request it was handed and never the one it handed back. What the receipt holds of the output — and the printed sentence used to deny, saying "only its INPUTS are on the record" — is three fields: cache.transform (the verdict of comparing the two), cache.transformHash (the digest of what came back, when it differed) and cache.markersApplied (the breakpoints the strategy actually applied; scope.cacheMarkers holds the candidates it was offered, and those are the inputs). Those three are scalars per breakpoint, so two epochs are comparable and "did the breakpoints move?" is answerable. Read cache.transform, not transformHash === null: 'unchanged' means the cache strategy returned what it was given. A strategy holds the whole request, so the gap names the composition fields the receipt takes from that request — params excepted, because that one is read off the request the port really got. Where another gap on the same view covers one of those fields, that gap is the stronger claim. It no longer quotes RECEIPT_BOUNDARY, and being raised on receipt-less views is exactly why: that sentence opens "A receipt describes the request…" and this entry prints on views with no receipt at all. The boundary claim is now this entry's own first sentence, in the vocabulary of a view; the quote moved to provider-defaults, which is raised only where a receipt was read.

  • thinking-replay → fields: messages.entries, requestMeasurement A leaf provider reported a thinking omission after the provider-port receipt was prepared. replayPreparations records its boundary, phase and count, without reasoning or signatures. Original hashes and size remain port-level; the annotation does not reconstruct the downstream request or prove transport or acceptance. With no receipt, the missing witness is covered by no-receipt-on-chart instead; no annotation is invented.

  • provider-defaults → fields: params The sampling dials are read off the request the provider port was handed — after the cache strategy, so a rewritten dial is recorded as the port's value. What is still past the record is the vendor: an adapter or SDK may resolve a final value the port never saw.

  • forced-tool-schema → fields: tools.schemaHashes Under a 'tool-forced' output strategy the synthetic answer tool is added at assembly from a build-time schema. Its name is on the record; its schema body is not.

What you are handed is detached

A fold's answers are memoized: keyedFold gives the same object back for the same question, and the forward cursor seeds every later epoch's replay from that very object. So everything these readers hand out is deep-frozen — every keyedFold(...).valueAt(...), receiptAt(k), the whole of servedAt(k) (the view, its sub-objects and all six containers), and epochLocations(...) with the locations on it. Editing what you were handed would otherwise rewrite what a later epoch reports was served, silently and only for the readers who came after you. structuredClone (or a spread) before you change anything.

Two of a view's containers are also copies — messages.asSent and tools.schemas — because those two alone would alias the memoized answers. The other four alias nothing and are frozen anyway, so that ServedView's readonly is true throughout rather than true of two containers out of six.

What is not frozen is the recording you handed in: EpochLocation.log, .source and .runSource point at your own snapshot.

Where the record stops

RECEIPT_BOUNDARY, exported so a renderer prints the library's own sentence:

A receipt describes the request as this library last saw it. Whatever handled it after that could have changed it; notes about later preparation do not prove what was sent or accepted.

It is PRINTED, so it obeys the same rule as the gap sentences and names no module, function or call. Where "last saw it" is, for a reader who can open the file: buildReceipt is called from the call-llm stage with the request about to be passed to LLMProvider.complete — the provider port. Downstream of that call sit a provider the consumer decorated (a complete() wrapping a complete()), a vendor adapter's serializer, and the vendor's own server-side defaults; none is on the record. A decorator that appends a system suffix and an unregistered tool leaves cache.transform reading 'unchanged' — correctly, and uselessly, unless you know where the record stops.

Four other things that looked like gaps are not, because 9.88.0 closed them instead: the system-prompt join is one exported function both sides call, the wrap-up call's withholding is rebuilt from the committed wrapUpAsked, the forced tool's name is committed at seed, and the staged-refs nudge — the one model-facing line written to no history — is recomposed by the same pure function from the same three committed inputs.

What a recording actually contains

An agent run is recorded as it ran unless you name what to keep out. Without a policy, a secret in a system prompt is in servedAt(k).system.text and in the commit log, verbatim; treat a recording accordingly. With Agent.create({ redact }) — conversationRedaction() names the system prompt's pieces among the conversation — the commit log holds the placeholder where a selected value was, and the rebuild reads a placeholder as absent, never as text the model was sent (Keep personal data out of the record).

The receipt carries no bytes — hashes, counts and names only — and those hashes are not redacted either. The run salt keeps them from matching across runs, not an assumption that something scrubbed them; a short value the policy kept out can still be confirmed by hashing guesses against the recording's own runId.

Redaction is the executor's: an agent's redact, and flowchartAsTool({ redact }) / runbookAsTool({ redact }), scrub the run's commit log at write time and serve everything else from the same redacted view. A snapshot served under a policy omits initialState, so folding it reports basis: 'log-only' and servedAt raises no-fold-base rather than quietly rebuilding a short view — the gap that already says a value it could not recover reads as empty rather than as unknown.

Turning the receipt off

Agent.create({ recordReceipt: false }) declines the mint. There is no privacy reason to — it carries no bytes — but there is a cost reason: one commit-log value per iteration plus a SHA-256 per system piece, per message and per tool schema. servedAt still rebuilds every epoch; receiptAt returns undefined, exactly as on a pre-9.88 recording.

A resumed run gets an axis of its own

The cursor reads the snapshot it is handed, and a resume is its own execution with its own commit log. After agent.resume(checkpoint, answer), getSnapshot() carries the resumed half: the axis begins at the stage the resume re-entered, and the milestones from before the pause are not on it — they are on the snapshot you took at the pause.

const paused = await agent.run({ message: 'go' });
const beforeTheBreak = timeTravel(agent.getSnapshot()!, { strategy: milestoneStopsStrategy });

await agent.resume(paused.checkpoint, 'approved');
const afterTheBreak = timeTravel(agent.getSnapshot()!, { strategy: milestoneStopsStrategy });
// two cursors, two halves — `beforeTheBreak` still reads the pre-pause turn

Nothing about the strategy changes across a pause: both axes tile their log and both carry unique stops. It is the snapshot that split, not the cursor.

What it is not

  • Not a second cursor. A strategy only says where the one cursor may rest. Position, folds, marks and drilling stay with timeTravel.
  • Not a recorder. Nothing is emitted at run time; marks live in the cursor and never appear in a recording.
  • Not a re-walk. A milestone that never committed gets no stop. A cursor that stops where no evidence exists is telling a story, not reading one.

Runnable end-to-end: examples/observability/23-time-travel-milestones.ts.

On this page