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 · endFive 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 runA 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 behindreactMode: '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:
| tag | what it is | example |
|---|---|---|
milestone:<kind> | the kind — what you filter on | milestone:llm-turn |
milestone-label:<label> | the human word | milestone-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 turnAnd 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; // trueThe 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]); // schemasThe 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 fromThat 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
- 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 —
recordSystemPromptis 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. - 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 therunIdtoo: a short value (a PIN, a yes/no) can still be confirmed by hashing guesses. - 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 soGaps: 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,epochThe recording travelled withoutinitialState, 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'sepoch: a fold that cannot readiterationnumbers the turn by its position instead. The receipt'sbasis.epochis 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 carriesinitialStateand 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.requestOnlyThe call committed neitherhistorynormessagesInjections, so the turns that went out are unknown, not empty: an emptyasSentis 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,replayPreparationsNo 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 acause.'no-receipt-committed'means nothing was written under the receipt key: a pre-9.88 recording, a run withrecordReceipt: false, or a chart whosecall-llmstage mints none (LLMCalland the message-API charts run acall-llmof their own that does not mint;LLMCallhas 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.omittedForAttentionandcache.strategyare 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. (omittedForAttentionwas a key ofUNGAPPED_FIELDSuntil 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 carriesparamsand always carries acache.transformverdict, and an absence insideparams—{}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.entriesAn 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.requestOnlyA 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 toolwantsthe staged-refs nudge is composed from. Losing the name also loses theschemaHashesrow 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 itscommitLogemptied, 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.withheldRaised 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 sayscache.strategy: null—LLMCalland 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.forcedandtools.withheldjoined in 9.93.0, because both are written from assembly's own decision and never from the prepared request. Where a strategy did run, itsprepareRequestrewrites 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) andcache.markersApplied(the breakpoints the strategy actually applied;scope.cacheMarkersholds 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. Readcache.transform, nottransformHash === 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 —paramsexcepted, 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 quotesRECEIPT_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 toprovider-defaults, which is raised only where a receipt was read. -
thinking-replay→ fields:messages.entries,requestMeasurementA leaf provider reported a thinking omission after the provider-port receipt was prepared.replayPreparationsrecords 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 byno-receipt-on-chartinstead; no annotation is invented. -
provider-defaults→ fields:paramsThe 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.schemaHashesUnder 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 turnNothing 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.
Replay a saved run
Save a run as three things — snapshot, events, structure — then show it in Why Lens or Flow Lens. Nothing re-runs, no model is called.
File a bug with the run attached
A bug report IS the evidence. describeBugReport() measures the run into selectable units so a human can consent; exportBugReport() bundles what they kept as a real zip; githubBugReporter() commits it and files the issue.
