Conversation Assembly
Assembly turns landed records into the structure defined by the Unified Conversation Model, and appends the result to an append-only chain of immutable rounds.
It reads the derived index, not the payloads. Structure resolution needs identifiers, not content: removing duplicate records needs record ids, grouping a provider call needs message ids, joining a tool needs tool-use ids, joining a spawn needs agent and run ids. Message text is read only when a conversation is rendered, and only for the records that appear in it.
Every parse assembles the whole session from its index, not only the records that arrived since the last round. So the same evidence always gives the same entities, and a round can be re-derived rather than only trusted. The round chain says how the result becomes a round.
Where this page says in the corpus, it means one corpus of Claude Code history: 2,970 source files holding 365,825 records, 1.09 GB in all, from 25 project directories. The date of that measurement is unavailable.
Assembling everything each time is cheap because the index is small. In the corpus, 365,554 index entries describing 1.0 GB of landed payload took 47 MB of index, about 21 times smaller. The largest single session’s index is 16 MB, and it loads in 4.5 ms with every lookup map rebuilt. That is why the lookup maps are rebuilt on load and never stored. Storage Root gives the index size on a later sample.
asz parse # assemble every session, append a round to each chain
asz parse SESSION # one session
asz conversation ID # fold the chain and show the structure
asz verify # check landed data and every round chain
The pipeline
Eight stages. The order is forced: each depends on the one before it.
| Stage | What it establishes |
|---|---|
| 1. Remove duplicate records | the first copy of each record id wins, and nothing downstream is correct before it |
| 2. Partition streams | one ordered lineage per agent, from the file it was written in |
| 3. Group provider calls | by message id, in line order |
| 4. Join tools | request and result become one step |
| 5. Join child agents | which call started which child stream |
| 6. Cut context epochs | only from an explicit reset record |
| 7. Build Talks and Runs | the conversation a person reads |
| 8. Propose segments | activity windows, and whether each could be committed |
Seven rules in here are the opposite of the obvious choice, and each is the opposite because a measurement said so.
A record type is not what a record is. Every tool result in the corpus sits on a record whose
type is user. Only a small fraction of user records are things a person said. Classification
reads the trigger and the content blocks, never the type alone.
Keep the first copy of a repeated record, not the last. A runtime re-writes a block of history just before it resets model context. The later copy is the worse one: it has a rewritten prompt cycle, and in many cases its captured tool output has been blanked. Keeping the last both moves records away from their true position and destroys data.
The key is the record id across the whole session, not within one file. First means first in landed order: the lower sequence, then the lower row. So the same rule also removes the copies that a crash between landing a file and committing its cursor leaves in a later file, as Storage Root describes. A record with no id is never a duplicate, because a key built on an empty id would collapse every such record into one. In the corpus, 44,585 of 365,825 records carry no id.
A repeated copy is left out of every lookup, not only the lookup by id. In call grouping it would inflate a call’s fragment count. In the tool join it would make an exact join look ambiguous. In a stream it would appear twice. The copy stays in the index as evidence of what the runtime wrote again.
Group a provider call by message id, in line order. Never by walking the parent pointer forward, which drops a tool use on nearly one call in five; and never by request id, because a record the client fabricated can carry a real call’s request id.
Take usage from the last fragment in line order. Not from the fragment with a stop reason — a parent lineage stamps the same stop reason on every fragment — and never by adding fragments up, which multiplies the real number by the fragment count.
A Talk starts on input from outside the agent, or on a cycle with no stated trigger at all. A background agent finishing and the parent resuming is mechanically a new prompt cycle, but nobody said anything; it is the same interaction continuing. A locally typed command is the reverse case — a person acting with no trigger recorded — so both are Talk starts and everything between them is not.
A run id belongs to its stream. A run id is not unique across a session. A child stream is written under a run id that also appears in the parent, and one id has been seen in three different child streams. Keyed on the id alone, a child’s records land inside the parent’s Talk, and the child gets no run of its own. So every Talk and Run is keyed by the stream and the run id together.
Membership is resolved by walking backward. A model response carries no cycle id but reaches one through its containment parents, which is single-valued. Line proximity agrees in the simple case and disagrees exactly where it matters. The walk passes through records of every kind. An attachment carries a parent and is named as one, so a walk that skipped every record that is not a message would break. The walk follows at most 64 parent links, and the limit exists only so that corrupt data cannot loop. A small limit would be wrong: in the corpus, 140 of 44,973 records need more than 7.
One rule is asserted rather than measured.
A child stream is always exactly one Talk. Every run in it is a Run inside that Talk: the
delegated prompt comes in, the final output goes out, and nobody outside the agent takes part. The
Talk’s trigger is unknown, because the parent wrote the prompt. The rule follows from what a child
stream is. The main stream’s rule cannot stand in for it, because that rule reads the trigger each
run states, and in the corpus exactly one of 221,592 child records states a trigger. Applied to a
child, it would start a new Talk on every run.
Segments
Stage 8 proposes segments. It never commits one.
Only the main stream is cut. Its Talks are taken in order of their first record time. A new window
opens where the gap between one Talk’s last record and the next Talk’s first record is longer than
the idle gap. Only records that resolve to a run count toward a Talk’s time range. A window’s id
comes from where it opens, the landed position of its first Talk, and not from its place in the
list. So late evidence that adds an earlier window cannot renumber a later one. The last window is
open, and every earlier one is a candidate. A child stream’s Talk joins the window whose time
range holds its first record, as strong_inference, because delegated work belongs to the window
of the Talk that asked for it.
A negative gap is no gap. Record times can step backward. In the corpus, 0.315% of consecutive records step back, by up to 4.06 hours. Provider-error records are the main cause: their time is when the failed request began, not when the record was written. So a negative difference between two Talks is treated as no gap, never as a large one.
The idle gap is 10 minutes, and a gap only proposes. A long gap often falls inside a turn rather than between two, so it cannot decide on its own. In the corpus:
| Idle gap | Gaps that long | Share inside a run | Share inside a Talk |
|---|---|---|---|
| 10 minutes | 697 | 23.67% | 38.74% |
| 15 minutes | 444 | unavailable | 30.41% |
| 20 minutes | 302 | 9.60% | 21.52% |
The Talk column is the one that matters. A window cut inside a Talk splits one interaction between two windows. Whether provider-error records count on this timeline moves the run column by five percentage points. In asz such a record counts only when its containment parents reach a run, because only those records set a Talk’s time range.
Twenty minutes would propose 57% fewer candidates. The default stays at 10 minutes because the gates below decide what could be committed, and a shorter gap only gives them more candidates to judge. How often the gates stop a bad candidate has not been measured.
The gap is fixed in the build. No configuration key and no command flag changes it. It is part of
the policy every round header carries, v1+idle=10m0s, so windows proposed under one gap never
fold together with windows proposed under another. Session Flow
says how a chain refuses a change of policy.
Four gates decide whether a window could be committed.
| Gate | Passes when |
|---|---|
activity_boundary |
the window is not the last one |
no_crossing_open_operation |
every tool use whose request time falls inside the window has exactly one result, also timed inside the window, and the session holds no open child_stream or notified_child entry. The second test covers the whole session, not the window. |
lateness_watermark |
the window is not the last one, and the main stream’s last record is more than one idle gap after the window ends |
conversation_identity |
the conversation id is not empty. Assembly refuses to run without one, so this gate always passes. |
A segment node carries state, talks, gates_unmet and committable. gates_unmet lists the
gates that fail, and committable is true when none fail. asz.view shows
the same windows to a reader.
no_crossing_open_operation is the gate that does real work. It does not exist for a request left
with no result at the end of a file. In the corpus that is 0 of 25,892 tool requests on the main
stream, because the runtime writes a result even for a failure. It exists for a result that arrives
after the quiet period, which in the corpus happens on 85 of the 697 gaps longer than 10 minutes,
12.2%. Committing that window would freeze the request in one window while its result lands in the
next.
Nothing commits a segment. Assembly proposes windows and reports committable, and no command
acts on it. The model calls a segment the commit unit, but the
commit step is not implemented.
What comes out
The containment tree carries direct ownership only. Every node has at most one parent.
session
└ stream
└ epoch
└ talk
└ run
├ llm.call
│ ├ message.assistant · thinking
│ └ tool · agent.call
├ message.external · context.injection
├ agent.launch_ack
└ runtime.notification
A tool becomes an agent.call when a child stream is joined to it. Only its kind changes, so it
stays under the provider call whose response asked for it. The agent.launch_ack is a separate
record, and it sits in the run.
Everything else is a typed relation carrying its own correlation quality: starts, reports,
ends_with, follows, summarizes, in_segment. Cross-stream flow is never containment, which is
what stops a rendered conversation repeating every subagent’s work inside its parent.
A tool’s request and result are not joined by a relation. The tool node references the request
record and, once the join finds it, the result record. Those references are the join. Its quality
is the node’s result_join attribute. An edge would say the same thing again, and its far end would
be a record with no node of its own. So assembly never writes the result_of relation that the
Glossary lists.
A Segment is a relation rather than a parent. It is a time window and a session outlives many of them, so a session cannot sit under a segment in a tree where every node has one parent.
The round chain
A round is an immutable delta. The conversation is the fold of every round from the first to the latest.
data/_conversations/<conversation-id>/
conversation.state the mutable head pointer
rounds/r000001-<digest>.sf immutable, read-only
rounds/r000002-<digest>.sf
Later evidence — a tool result that arrives after its call, a child transcript that appears after its spawn — produces a new revision in a later round, never an edit to an earlier one. That is what lets a round be digested, archived or shipped the moment it is written.
Three rules govern the fold, and each exists because its opposite loses information:
- Last writer wins, per id, whole entity. Not field by field: a partial merge cannot express “this is now absent”, so a correction that removes something could never be recorded.
- Absence means unchanged. An entity missing from round 7 is exactly what round 4 published. Removal is explicit, and an unresolved reference that gets resolved is superseded with that state rather than deleted — absence cannot say that a gap existed and closed.
- Order is chain order, not timestamp order. Timestamps come from the runtime and can run backwards; the chain cannot.
Rounds are linked by digest, not by filename: round N names the digest of round N−1. A round carries
no wall-clock time, so the same landed evidence and the same parser and policy versions reproduce
the same bytes and the same digest. Anything mutable or temporal lives in conversation.state,
outside every digest.
A parse builds a round in three steps. It fixes its watermark at the sequence the index has reached. It assembles the whole session up to that watermark. It then compares the result with the fold of the chain and writes only the difference:
- An entity assembled unchanged is left out, because absence means unchanged.
- A node or relation the fold holds and the assembly no longer produces gets a tombstone.
- An open unresolved entry the assembly no longer produces is superseded as
resolved.
The comparison ignores an entity’s revision and its frame type. The revision is the round number,
and only the writer sets the frame type, so comparing either would make every entity look changed
in every round. Because the whole session is assembled each time, duplicate removal always sees the
first copy. A repeated record therefore cannot become a second node in a later round. A round over
the byte budget is assembled again over a narrower window, as
max_round_bytes describes.
The watermark is fixed before assembly, and assembly reads nothing past it. Deciding it afterwards would let a round hold nodes drawn from evidence that its own header and input digest do not cover. A collector writing at the same time makes that likely. A parse also refuses to run when the chain already covers more landed evidence than the index holds.
A round is written whenever landed evidence advanced, even when no entity changed. Such a round
has no entity frames and only moves through_seq. Without it, the next parse would read the same
evidence again and again, and “no round” would mean both “nothing new arrived” and “nothing new
mattered”. Records landed twice after a crash are one such case. The chain test
TestInterruptedPassRepeatsRatherThanLoses checks that they move the watermark with no node,
relation or tombstone. The scenario replay checks the same for a repeated block.
The fold of rounds 1 to N equals one full assembly of the landed evidence through round N’s
through_seq. The full assembly is the reference, because it is simple and plainly correct.
Where the two disagree, the fold is wrong. Every scenario checks
this in both formats, on every node’s kind, parent and attributes and on the number of relations.
It holds under any round budget, because the budget changes where the chain is cut, never what it
says. A chain test builds one session as a single round and again under a one-byte budget, one round
per landed file, and the two folds hold the same entities. The rule does not say that the fold of
round N+1 extends the fold of round N. Later evidence replaces entities that earlier rounds wrote.
Rounds and segments are separate. The round is the unit of appending. The segment is a proposed commit window. A segment is a node like any other, so when a later Talk turns the open window into a candidate, that change is an ordinary node revision in whatever round the parse writes. There is no separate mechanism for it, and nothing freezes a segment today, as Segments says.
The page that asz view serves folds the same chain in the same way, every round in order.
asz.view says what it does with a broken chain, and when it folds again.
A fold as of an earlier round, one entity’s history across rounds, and a fold that takes rounds as
they arrive are not implemented. The rounds hold enough to support each of them.
asz verify checks every round against the round before it and against the landed files, as
Session Flow lists. One of those checks is not about
digests: the landed sequences the rounds consumed must be contiguous. It catches skipped evidence
that no digest would reveal.
Qualification
Nothing is presented as observed execution unless it was observed.
Every join carries a correlation quality: exact_unique, exact_ambiguous, strong_inference,
weak_inference, unresolved, conflict. An exact identifier does not guarantee a unique match —
where several candidates share a key the relation stays exact_ambiguous and the assembler does not
choose one.
What could not be resolved is carried as data. An assembler that drops what it could not resolve presents a partial conversation as a complete one.
A count of what could not be resolved also needs the number it is out of. Without it, a reader
cannot tell a small gap from a large one. The rounds do not carry that number. An unresolved entry
records its kind, its reference, a reason and a state. Only asz parse
prints the number, and only for tool calls and child launches.
The assembler writes seven kinds of unresolved entry:
| Kind | Written when |
|---|---|
tool_result |
no result landed for a tool use, or several results carry its id |
tool_use |
a result landed, and the request it answers never did |
child_stream |
a child was announced, and its stream has not landed |
notified_child |
a notification names a child whose stream has not landed |
spawn_call |
a child, or a batch of children, names a launch call that never landed |
spawn_of_child |
a child stream landed, and no landed record names the call that started it |
epoch_continuation |
a reset names a record that is not in the landed data |
Every entry is written open, and it resolves when the missing evidence lands. child_stream and
notified_child are routine in a live session, because a child can be announced before its stream
lands. While either is open, no segment is committable. A child in a batch that never reports a
result is normal, and it is not recorded.
The assembler makes one change of state. An open entry it no longer produces is superseded as
resolved, and the fold keeps it. The scenario
unfinished-tool checks a tool_result going from open to resolved. The format also defines
terminal, for a producer with evidence that a reference will never resolve, such as a pruned
source. The current assembler never writes it. Nothing counts rounds either. “Still open after N
rounds” measures how often the chain was built, not the data, which is why the state has three
values and not a counter.