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; output that names no call is grouped into none
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. Cut segments activity windows

Eight 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.

Output that names no call is grouped into none. Kiro’s V3 engine lists the request ids of a turn and ties no record to one of them, so no record names its call. Grouping such output by turn or by record would draw call boundaries the runtime never recorded. So no llm.call is written, and the text, reasoning and tool uses are steps of the run that holds them.

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 cuts activity windows. A window says where the work happened. It says nothing about whether the work is finished: a round carries no clock, so only a reader, who knows the time, can tell whether the last window is still going.

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. 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 it is measured between Talks. A long quiet period between two records often falls inside a Talk rather than between two, so a window is never cut there: stage 8 compares only one Talk’s last record with the next Talk’s first. Among the gaps between consecutive records 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 why the cut is made between Talks: cut on the gap between records, about two cuts in five would fall inside one interaction. 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. A gap of twenty minutes would find 57% fewer gaps that long.

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 cut under one gap never fold together with windows cut under another. That is why it stays at 10 minutes: Session Flow says how a chain refuses a change of policy.

A segment node carries no attributes. Its Talks are its in_segment relations, and its time range is theirs. asz.view shows each window to a reader with the count of its Talks and its time range.

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.assistant · thinking · tool · agent.call, where no call is named
             ├ 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, result_of, 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 its own 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.

The way back from a stream a call started is a relation, because it crosses streams and both of its ends are nodes. A sub-agent does a task and hands its result back; with a way in and no way out, every child reads as a dead end. So a stream’s final step - a child’s agent.output, or an auxiliary stream’s last llm.call - has a result_of relation to the call that started it. It is written only when the shape makes it exact: one call started the stream directly, that call’s result was joined and is its own, and the result is not the acknowledgement of a background launch. A child launched in the background comes back through a runtime notification instead, which is reports, and a batch of children is launched by a result that names the batch rather than answered by one. A child that never came back has no result_of. The relation’s evidence is the result the call received, and the child’s agent.output then says its returned_value is observed.

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:

  1. 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.
  2. 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.
  3. 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.

An upgrade’s revisions go first, cut by their frames. A newer parser can change what it writes for every node, and a round cannot narrow below one landed file. When the round of one landed file is still over the budget and the chain changes even with no new evidence, those changes go first, in rounds of their own over no new file. Each holds as many frames as fit the budget, and at least one. The round of the new evidence follows. On a 17 MB chain, the revision of 35,434 nodes that came with dropping repeated attributes was one round of 12.5 MB, and is six rounds of at most 2 MiB this way.

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. It holds once the chain has reached the index: between the rounds of an upgrade’s revisions, which are cut by their frames, the fold holds part of the revision, and the next rounds write the rest.

Rounds and segments are separate. The round is the unit of appending. A segment is an activity window. When a later Talk joins a window, the round the parse writes adds that Talk’s in_segment relation, and the segment node itself does not change. There is no separate mechanism for it.

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. 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.