Session Flow

Session Flow is the conversation structure: the talks, streams, runs, steps and relations that assembly builds from landed records. It is published as an append-only chain of immutable rounds. A round file has the extension .sf, and the conversation is the fold of every round. There is no other state.

The format is public. Package pkg/sessionflow defines it, and a consumer reads it. The schema is sf/1.

There is one kind of .sf file, a round; on the wire and in a receiver it is round.

A round

One round is one file of JSON lines. Every line carries t, its frame type, and the frames come in a fixed order: one header, then any number of node, relation and unresolved frames, then one commit.

{"t":"header","schema":"sf/1","conversation":"1213…","session":"1213…","round":1,
 "from_seq":1,"through_seq":118,"input_digest":"48d8…","parser":"v1","policy":"v1+idle=10m0s",
 "from_time":"2026-08-06T16:21:30.556Z","through_time":"2026-08-07T02:14:51.749Z",
 "session_from_time":"2026-08-06T16:20:45.531Z","session_through_time":"2026-08-12T02:36:31.749Z",
 "title":"Check otel-rules support for meter mal","talks":357,"steps":16121,"streams":132,"segments":50,"unresolved":0}
{"t":"node","id":"ack/1/1338","revision":1,"kind":"agent.launch_ack","parent":"run/…","ref":{…}}
{"t":"relation","id":"rel/starts/tool_toolu%5F01Ax…/stream_a308…","revision":1,"type":"starts","from":"tool/toolu_01Ax…","to":"stream/a308…","quality":"exact_unique","via":"child sidecar","evidence":[…]}
{"t":"unresolved","id":"unres/tool_result/toolu_01Bq…","revision":1,"kind":"tool_result","ref":"toolu_01Bq…","reason":"no result landed for this tool use","state":"open"}
{"t":"commit","digest":"d829…","counts":{"nodes":1338,"relations":52,"unresolved":0}}
Field Meaning
conversation, session the conversation this chain belongs to, and the session the round was assembled from
round counts from 1
previous the digest of round N-1. Empty only for round 1. This is a dependency, not audit metadata: a round is built on the fold of the round it names.
from_seq, through_seq the landed sequence range this round consumed
input_digest binds the round to the landed evidence it read. It is chained rather than recomputed, so producing it stays proportional to new data. How it is computed follows the table.
parser, policy the interpretation versions. policy includes the idle gap, the quiet period between two Talks that opens a new activity window. asz writes v1+idle=10m0s, and a round made under another gap has another policy.
from_time, through_time the earliest and the latest record time among the landed files this round consumed, as the runtime wrote them, in UTC. Absent when no record in the window carries a time.
session_from_time, session_through_time the session’s own range as of this round: when it began, and its last activity so far. The session node carries the same pair as from_time and through_time in its attributes; the header repeats it so a reader of the header alone learns it without folding.
title, talks, steps, streams, segments, unresolved what a list of conversations shows, as of this round: the session’s title and the fold’s counts of talks, steps, streams, segments and open unresolved references. A receiver lists conversations off the newest round’s header, or off the attributes a sender copies from it, and never folds. When revisions that need no new evidence are cut by their frames across several rounds, each of those rounds gives the counts the last of them reaches.
changes, lines_added, lines_removed the workspace change records the landed files carry up to through_seq. changes counts the distinct records: one for each tool call a producer watched for file changes, whether or not the call changed a file. The other two are the lines those records’ diffs add and remove.
llm_calls, subagents, bash_runs the fold’s llm.call nodes, its streams whose role is child, and its tool and agent.call nodes named Bash. A call that started a child agent becomes an agent.call and keeps its name, so bash_runs does not fall when the child’s transcript lands.

Each of the six counts in the last two rows is absent from a round written before that count existed. A reader takes an absent count as unknown, never as zero. The list that asz view serves shows a dash for it.

The input digest is a SHA-256 in lowercase hex:

input_digest = hex( SHA-256( previous || 0x00 || d1 || 0x00 || d2 || ... ) )
  • previous is the previous round’s input_digest as hex text. It is empty for round 1.
  • d1, d2 and the rest are the SHA-256 of each whole landed file with a sequence from from_seq through through_seq, in lowercase hex. They are sorted as strings first, because directory listing order is not stable.

A chain holds one interpretation, so its parser and policy never change. The parser refuses to extend a chain built by another parser or policy, and the fold refuses to mix them. To move a conversation to a new parser or policy, remove _conversations/<id>/ and parse again. The landed files rebuild it, as Storage Root says.

A header carries no wall-clock time. A round’s bytes must be reproducible from its inputs, so the same landed range, the same previous digest, and the same parser and policy versions yield the same digest. When a round was produced lives outside every digest, in conversation.state. Record times are different: the runtime wrote them into the landed files, so they are evidence, and the time fields above reproduce with the round.

The order of the frames is part of the bytes. asz writes the header, then every node, then every relation, then every unresolved entry, each group sorted by id, and then the commit. Without the sort, two runs over the same input could order the frames differently and produce different digests. For the same reason, a node’s attrs is one JSON object whose keys asz writes in sorted order. Ids and keys sort in code point order: two strings compare by their Unicode code points, one by one, which is also the order of their UTF-8 bytes.

Entities

Nodes, relations and unresolved references share one envelope: an id, a revision and an optional tombstone. An id comes from stable evidence in the landed records, never from position, so an id cannot shift when an earlier source is backfilled later.

Frame Fields
node kind, parent for containment, stream, ref or refs pointing at the landed records that back it, and attrs
relation type, from, to, quality, via, and evidence. A typed edge that is not containment. Cross-stream flow is a relation, never containment: a child’s work stays under the child.
unresolved kind, ref, reason, and state: open, resolved or terminal. Qualification lists the kinds asz writes and when it writes each.

An id joins its parts with /, and an empty part is left out. Inside a part, % is written as %25 and / as %2F, and every other character as it is. A part that is itself an id, a relation’s two ends or a run’s talk, is written differently: each % in that id becomes %25, each _ becomes %5F, then each / becomes _, and nothing is escaped a second time. So the run in talk talk/main/8d53… is run/talk_main_8d53…/8d53…, and the relation from tool/toolu_01Ax… is rel/starts/tool_toolu%5F01Ax…/…. With both rules, two different lists of non-empty parts never give one id, however deep ids are nested. The parts are joined rather than hashed, so an id stays readable in a round file and in an error message.

Entity Id
a node with an identity from the runtime a prefix and that identity, such as tool/<tool use id> or stream/<stream name>. Where the identity is not unique across streams, the stream is a part as well, as in talk/<stream>/<prompt cycle id>. A chain holds one session, so no other id needs the session as a part.
a node with no identity from the runtime a prefix and the landed location of its evidence: <prefix>/<seq>/<row>, with :<block> added for one content block, such as ack/1/1338. A landed record never moves, so neither does the id.
a relation rel/<type>/<from>/<to>. It is keyed by what it joins, not by the evidence that showed it, so one edge observed twice is one relation. The assembler merges the evidence and keeps the stronger quality. Once either observation says conflict, the relation stays conflict, because more agreement does not make a disagreement untrue.
an unresolved reference unres/<kind>/<ref>. It is keyed by what is missing, so the revision that resolves it has the same id. Keyed by the round that noticed it, every round would open a new one.

A node’s prefix is short, and it is not always the node’s kind. An llm.call is call/<id>. A tool call that started a child agent becomes an agent.call and keeps its tool/<tool use id>. These are the prefixes asz writes:

Kind Id
session session/<session id>
stream stream/<stream name>
epoch epoch/<stream>/id/<id of the reset record>. A reset record with no id is named by its location, epoch/<stream>/at/<seq>/<row>. A stream’s first epoch, when no reset opens it, is epoch/<stream>/0; a stream whose first record is a reset has no such epoch, and its first epoch is the one that reset opens. The three forms differ in their parts, so a reset whose id is 0, or reads like a location, still has an epoch of its own.
segment segment/at/<seq>/<row>, the location of its first talk’s first record
talk talk/<stream>/<prompt cycle id>, from its first run. A child stream is one talk, so its talk is talk/<stream>.
run run/<talk id>/<prompt cycle id>
llm.call call/<provider call id>, the call its fragments share
tool, agent.call tool/<tool use id>
agent.output output/<stream>
message.assistant, message.synthetic msg/<seq>/<row>:<block>
thinking think/<seq>/<row>:<block>
message.external input/<seq>/<row>
context.injection inject/<seq>/<row>
agent.launch_ack ack/<seq>/<row>
runtime.notification notify/<seq>/<row>
epoch.boundary boundary/<seq>/<row>
epoch.summary summary/<seq>/<row>
history.change history/<provider call id>: the same key as the call whose history changed, call/<provider call id>, so a reader finds the call from the card
error.api error/<seq>/<row>
turn.duration turn/<seq>/<row>
control.command command/<seq>/<row>
control.notice notice/<seq>/<row>

The bodies a call exchanged

An llm.call node carries provider_bodies in its attrs when the session holds what the call sent to its model provider and what came back:

"provider_bodies": [
  {"role": "request",  "ref": {"seq": 4, "row": 3}},
  {"role": "response", "ref": {"seq": 4, "row": 4}}
]

The bodies themselves are never in a round. Each entry names the provider_body record it rebuilds from, and a reader loads that record and the session’s provider_body files with that sequence or lower, which hold everything it refers to. See Session Data, Provider bodies.

The join is made when the round is parsed, and it is made again from the evidence each round covers. A response joins by the message id it carries, which is the call’s own, and so does a request whose adapter knew its call, when exactly one request names that call. A request that names no call - one Claude Code writes names only the request before it and its prompt - joins to the call of its stream whose previous call’s response carries that request id and whose prompt is the one it names, when exactly one request and exactly one call carry the pair. Nothing is joined by position or by time, and a call whose join is not exact carries nothing. A body that lands after the round covering its call joins in a later round, as a new revision of the call; evidence that makes a join ambiguous withdraws it the same way.

The session node states provider_bodies_landed, how many bodies the session holds as of that round, joined or not, so a reader can say what was captured without opening a landed file. It is a count, named apart from the list a call carries so that one name never means two things.

Where a call’s history changed

A call’s history is the message list its request records, as the request body’s manifest writes it (Session Data, History). A request that continues a provider thread holds only what follows the message it names: its history is that message’s call’s history, then that call’s reply, then its own messages. The call it names must be in its stream, of its form, and have a reply. A history is built through at most 65,536 calls and holds at most 65,536 entries.

Each call is compared with its predecessor: the nearest earlier call of its stream and epoch, skipping only an internal_call. A call of unknown class, or with no request or no list, is never skipped, since skipping a turn would compare a call with the one before it and show a change that did not happen. Calls are ordered by their first fragment’s landed position, as the body join orders them. Every call whose request carries history_form has history in its attrs, the first row of this table that applies:

history when history_reason
not_compared the call is in an auxiliary stream, or is an internal_call auxiliary_stream, internal_call
unavailable the call’s class is unknown class_unknown
unavailable its own history cannot be built messages:<reason>, thread_unresolved, thread_reply:<reason>
first no call comes before it in its stream and epoch
unavailable the predecessor has no request, or none written with history_form, or is of unknown class, or another form, or a history that cannot be built earlier_no_request, earlier_messages:<reason>, earlier_class_unknown, form_mismatch, earlier_<reason>
kept the predecessor’s history is an ordered prefix of the call’s
changed it is not

Two entries are equal when both have an id or neither does, the ids are equal where they are, and the digests are equal. Beside history:

  • history_messages: how many entries the call’s history holds, whenever it can be built, so a page can say how many messages a continue request did not send.
  • history_from: on kept and changed, the predecessor’s node id.
  • history_reply, and history_reply_reason when it is unavailable: what became of the predecessor’s reply. unavailable with no_reply when the predecessor’s response names none, and with form_mismatch when its form is not the requests’. On kept it is looked for after the predecessor’s history, where no entry is one of the predecessor’s: back when an entry there is equal to it, back_changed when one carries its id, else not_found; a request that continues the predecessor’s own reply is back by construction. On changed the reply is unavailable with ambiguous when it is equal to an entry of the predecessor’s history or shares its id with one, and is otherwise looked for in the whole history.
  • system_changed, between two Claude Code calls: yes, no, or unavailable. A request that creates a thread or carries none has its own system prompt; a continue request has the thread’s, unless it carries a system block other than the billing header, which makes it, and every call continuing from it, unknown. unavailable when either call’s is.

A changed call has a history.change node beside it, under the same parent. It stands at the call’s first fragment, its refs are the two requests’ records, and it carries no content. It sorts just before the call: among nodes at one position, a history.change node is ordered by the call it names, and before it. Its attrs:

Key Value
from_call, to_call the predecessor and the call
kept, changed, dropped, added counts by multiset, so repeated messages need no unique ids: for each distinct entry the smaller of its numbers of occurrences in the two histories is kept; of what is left, for each id the smaller of its numbers of occurrences on the two sides is changed; the rest of the predecessor’s is dropped, and of the call’s added. A runtime that sends no message id never reports changed: an edited message is one dropped and one added
order_changed, order_changed_reason yes when the messages kept or changed are no longer in their earlier order, else no. An entry’s identity is its id, else its digest; with no id repeated on a side, the order changed exactly when the longest common subsequence of the two identity sequences is shorter than kept and changed together. unavailable with repeated_id when an id occurs twice on a side, and with too_large past 4,194,304 pairs of equal identities
reply, reply_reason, system_changed as on the call
cause unavailable. A changed history names no cause: a context reset is an explicit epoch.boundary, and a changed history is not one. The calls stay in their epoch

All of it is derived from the index, carries no wall-clock time, and is assembled again each round, so a body or a response that lands later revises the call and its card, and a card whose pair is no longer changed is tombstoned.

A reference into the landed data is a landed sequence, a row, and optionally a content block. That is the whole address. A reader takes both the content and the time from the .sd record it names. The record’s time field is when the runtime says it happened. The page that asz view serves never uses the index for either, as Storage Root says, so a root without its index still shows its times. Apart from the session node’s range, a node carries no time of its own.

Content is referenced, never inlined. A provider call’s input is everything before it in its context. Copying each call’s input into its node would store every message again in each later call, so storage would grow with the depth of the conversation rather than with its content. With references only, the rounds of a 48-session root measured 105 MB, 8.9% of the source records, on 2026-09-03, as Storage Root shows.

Commit

digest covers every preceding line of the file, so a round verifies itself, and it is the value the next round names as previous. It is the SHA-256 of those lines, each ending in its newline, in lowercase hex. counts lets a reader detect a truncated round without folding it.

The fold

The conversation as of round N is the fold of rounds 1 through N, applied in order. A fold takes a round only if it carries the next round number, names the digest of the round before as previous, and carries the chain’s conversation and round 1’s session, parser and policy. It refuses any other round, so a reader detects a mix of interpretations instead of folding through it.

  • A frame with a higher revision supersedes the earlier one with the same id.
  • Absence in a later round means unchanged, never deleted. Removal is an explicit tombstone, which removes the entity from the result and leaves it in history.
  • An unresolved reference that later resolves is superseded by a revision whose state says resolved. It never vanishes, because absence never means resolved.
  • terminal means evidence says the reference will never resolve: a pruned source, a record the runtime never wrote. It is never inferred from elapsed rounds.

A round is never rewritten. Later evidence produces a new revision in a later round.

On disk

_conversations/<conversation-id>/
  conversation.state          head, head digest, through_seq, input digest, parser, policy
  rounds/r000001-<digest>.sf  round number and the first twelve characters of its digest

The rounds directory is the authority. The state file is a cache, and a crash between publishing a round and saving state leaves a round the state does not mention, so the head is recovered by listing the directory and the previous input digest is taken from the last round’s own header.

A round is never empty. An empty file under a round’s name is a reservation that a writer left on a filesystem without an exclusive rename, as the storage root describes. It is not a round: a reader passes over it, and never takes it for the head.

Reading it

asz conversation ID folds a chain and prints what it holds. The node kinds and relation types are the vocabulary of the Unified Conversation Model, and the glossary fixes each word.

A reader rejects a round, rather than folding it, when any of these is true. A server that mirrors the format must reject the same rounds. The rules are strict because nothing looks at a round again once it is folded. A reader that accepts what a correct producer never writes can disagree with another reader about what the round said.

  • The first line is not a header, a second header appears, or a line follows the commit.
  • A line does not decode, or its frame type is unknown. An empty line is a line that does not decode, and a round never holds one. A reader may count a line that holds more than 256 objects and lists open at once as one that does not decode, and the OAP does. Measured over 360,941 lines of 94 real conversations’ rounds, the deepest held 5.
  • The header breaks one of its own rules. Its schema is not sf/1. It lacks conversation, session, parser, policy or input_digest. Its round is 0. Its previous is missing after round 1, or present on round 1. Its from_seq is 0, or its through_seq is below from_seq minus one. A through_seq of exactly from_seq minus one is valid: that round consumed no new evidence.
  • An entity has no id, or an id appears twice in one round.
  • A revision differs from the header’s round.
  • A reference is sequence 0 row 0, or names a sequence past through_seq, which the round’s input digest does not cover.
  • A node’s provider_bodies attribute is neither null nor a list of {role, ref}, a role is neither request nor response, or a ref has a seq or a row of 0. Each ref is a reference, and the rule above applies to it as well.
  • A relation that is not a tombstone lacks type, from or to.
  • An unresolved entry that is not a tombstone has a state other than open, resolved or terminal.
  • The commit is missing, so the round is truncated, or its digest or counts do not match what was read.

asz verify checks six things for every round:

  1. No round is missing before it.
  2. Its previous is the digest of the round before.
  3. Its own digest matches its bytes, and it passes every rule above.
  4. Its from_seq is the previous round’s through_seq plus one.
  5. Every landed sequence it consumed is still on disk.
  6. The input digest recomputed from those files equals its header’s.

Check 4 is the one no digest can make. The digests prove that the rounds were not changed. Only contiguous from_seq and through_seq prove that no landed evidence was skipped.