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}}
Header
| 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 || ... ) )
previousis the previous round’sinput_digestas hex text. It is empty for round 1.d1,d2and the rest are the SHA-256 of each whole landed file with a sequence fromfrom_seqthroughthrough_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: onkeptandchanged, the predecessor’s node id.history_reply, andhistory_reply_reasonwhen it isunavailable: what became of the predecessor’s reply.unavailablewithno_replywhen the predecessor’s response names none, and withform_mismatchwhen its form is not the requests’. Onkeptit is looked for after the predecessor’s history, where no entry is one of the predecessor’s:backwhen an entry there is equal to it,back_changedwhen one carries its id, elsenot_found; a request that continues the predecessor’s own reply isbackby construction. Onchangedthe reply isunavailablewithambiguouswhen 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, orunavailable. 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.unavailablewhen 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
revisionsupersedes 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. terminalmeans 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 lacksconversation,session,parser,policyorinput_digest. Itsroundis 0. Itspreviousis missing after round 1, or present on round 1. Itsfrom_seqis 0, or itsthrough_seqis belowfrom_seqminus one. Athrough_seqof exactlyfrom_seqminus one is valid: that round consumed no new evidence. - An entity has no id, or an id appears twice in one round.
- A
revisiondiffers from the header’sround. - 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_bodiesattribute is neither null nor a list of{role, ref}, aroleis neitherrequestnorresponse, or arefhas aseqor arowof 0. Eachrefis a reference, and the rule above applies to it as well. - A relation that is not a tombstone lacks
type,fromorto. - An unresolved entry that is not a tombstone has a state other than
open,resolvedorterminal. - The commit is missing, so the round is truncated, or its
digestorcountsdo not match what was read.
asz verify checks six things for every round:
- No round is missing before it.
- Its
previousis the digest of the round before. - Its own digest matches its bytes, and it passes every rule above.
- Its
from_seqis the previous round’sthrough_seqplus one. - Every landed sequence it consumed is still on disk.
- 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.