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_01Ax…/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 after which a segment can be closed. 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. |
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.
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 /. An empty part is left out, and a / inside a part becomes _, so
the run in talk talk/main/8d53… is run/talk_main_8d53…/8d53…. 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 of the reset record>. A stream’s first epoch is epoch/<stream>/0. A reset record with no id is named by its location, at_<seq>_<row>. |
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> |
error.api |
error/<seq>/<row> |
turn.duration |
turn/<seq>/<row> |
control.command |
command/<seq>/<row> |
control.notice |
notice/<seq>/<row> |
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.
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.
- 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 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.