Storage Root
The storage root is a directory. Everything the project keeps is under it, in two halves: what was collected, one directory per session, and what was assembled, one directory per conversation. Beside them sit the metrics spool and the export state, which belong to the root rather than to a session.
<root>/
<session-id>/
session.state next_seq, liveness (always unknown), last scan
.lock one collector per session
removing.json a removal in progress, see Retention
removal.state the generation of the last removal that deleted files
provider/ the bytes of removed chain tips and pieces, see Retention
metrics/ immutable decisions of local metric derivation
000001.json exact request and accounting for landed file 1
streams/
main/
transcript.cursor how far the source has been read
transcript-<collected-at>-<seq>.sd
transcript-<collected-at>-<seq>.sk the skeleton of a landed file retention removed
changes.cursor the plugin's change records for the stream, when
changes-<collected-at>-<seq>.sd the asz Claude Code plugin is installed
execution.cursor the plugin's execution records for the stream: which
execution-<collected-at>-<seq>.sd MCP server each call went to, and how it ended
<agent-id>/ one directory per child agent
transcript.cursor
transcript-<collected-at>-<seq>.sd
meta.cursor
meta-<collected-at>-<seq>.sd
runs/
<run-id>/ one directory per workflow run
journal.cursor · journal-…sd
manifest.cursor · manifest-…sd
script.cursor · script-…sd
provider_body/ the bodies the runtime exchanged with its model
provider_body-<collected-at>-<seq>.sd provider, when claude-code-provider collects them
index/
entries.bin the derived index
index.state how far the index reaches
_conversations/
<conversation-id>/
conversation.state the head pointer, outside every digest
.lock one parser per chain
rounds/
r000001-<digest>.sf
r000002-<digest>.sf
_provider/ when claude-code-provider runs
seen.state which body files landed, in which session, and which wait
.lock one provider collector per root
_metrics/ the metrics spool, when metrics are on and sent
.lock one derivation per root
metrics.state what the derivation has counted
metrics-<session-id>-<seq>-local.pb the points derived from the session's landed file <seq>
push.state what has been sent: each file's digest, the receivers,
the owner of each session, and the files a receiver
rejected records of
_root/
.lock one writing command per root, for one pass
_export/
.lock one pusher per root
_scenario/ only in a root a claude-code scenario build wrote
.lock one pipeline per scenario root
_removed/ what a removal of a scenario session is deleting
<session-id>/ the session directory, moved here whole
<session-id>.chain/ its conversation's directory, moved here whole
Child streams are flat siblings of main, keyed by agent id. Provider bodies sit under the
session, not under a stream, because a body names no stream; the view joins each to its call. The storage path deliberately does
not mirror the source tree: a path must not encode a relationship the pipeline is supposed to
derive. OTLP Records says how the metrics spool fills and
what push.state records. _scenario/ and _removed/ exist only in a root a scenario build
wrote. Retention says what they are for.
Every command that writes to a root holds _root/.lock for one pass: asz collect and asz server for each pass, asz parse, asz push and asz repack for their run, and asz scenario build for each session it builds. Another command over the same root waits for it, a watching
pipeline by doing nothing that period, so two commands never write the same root at once. asz view and asz index only read, and take no lock. The LangSmith receiver is the one writer outside
a pass. It only adds requests to _langsmith/, each under a new name, and a pass takes them under
that directory’s own lock.
Locks are taken in one order: _scenario, _root, _export, _metrics, _provider, a
session’s, a chain’s.
liveness in session.state is always unknown. asz has no check for whether a session is
still running.
When local metrics are enabled, each derived file gets a receipt under its session’s metrics/.
It holds the landed digest, counted call ids, series windows and exact protobuf request, encoded
as base64 in JSON. The receipt is published before the request enters _metrics/, so a retry
after an interrupted progress save uses the same accounting even if new fragments have arrived.
Receipts are local collection state and are not sent as session data. metrics.state also keeps
the first pass’s look-back for each session that pass did not finish, whether a file waited for
its grace or an error stopped the session, until every file of the session is derived.
Landed files
A landed file is Session Data: one header line and then one record per source record. It is written once to a temporary name, synced, made read-only, and only then renamed into place. So a file under its final name is read-only from the moment it appears. It is never appended to and never rewritten. A source that keeps growing produces more files, each starting where the previous cursor stopped.
After the rename, the directory is synced, an attempt to make the new name survive a power loss as well; a directory the write had to create is not synced into its own parent. A rename survives a process crash whether or not its directory was synced, and that is the safety asz promises. When the sync fails for a disk error, the write still succeeds, since the file is in place and another process may have read it, and the failure is reported once for the directory: a power loss may lose the newest names there. A filesystem that cannot sync a directory at all, as some network, FUSE and container mounts cannot, reports nothing, and there a rename’s survival of a power loss is up to the filesystem, as it is on Windows, where a directory is not synced. State files and rounds are written the same way.
A landed file is named <kind>-<collected-at>-<seq>.sd. <collected-at> is the UTC time the
session’s pass started, with nanoseconds and no separators, such as 20260831T091204.481523000Z.
Names in this form sort in time order as plain text. They hold no :, which Windows does not allow
in a file name. Every file that one pass lands for a session carries the same stamp. <seq> is
zero-padded to at least six digits. A reader orders files by the sequence, never by the stamp.
The sequence number in the name is issued per session and is monotonic across every stream in it. That is what lets assembly track progress with one watermark instead of one entry per stream, and it is why a session is collected single-threaded under its lock. The filesystem is the authority on the counter: on start, a collector raises its counter above the highest sequence already on disk, so a crash between writing a file and saving state cannot reissue a number.
A file lands before its cursor is committed. A crash between the two lands the same records twice
in the next pass. The reverse order would lose data. The cursor would already point past records
that never landed, and no later pass would read them. The index keeps the first copy of each
record id and marks later copies as duplicates, which assembly skips. A record with no id has
nothing to compare, so both of its copies stay in the index. asz verify counts records whose
source line had already landed as re-landed, and does not fail on them.
A file is cut once. The collector cuts at max_delta_bytes, 2 MiB by default, and a round then
addresses every record by file and line and binds itself to the file digests, so re-cutting a file
that a round references would break every reference. A change of budget applies to new files
only. To bring an existing root under a new budget, asz repack DEST re-cuts every file into a
new root, keeping each record’s bytes and order, carries the cursors over, and builds the chains
again on the new files, which describe the same conversation at new positions.
Cursors
Each source has one cursor, beside the files it tracks, named <kind>.cursor. The dot matters.
Landed files are named <kind>-<collected-at>-<seq>.sd, so a cursor named with a hyphen would share
their prefix, and a scan for one kind’s files would take the cursor for data.
A cursor belongs to one source rather than to the session, because a session can hold hundreds of sources. Measured on 2026-09-11 on one machine’s storage root of 60 Claude Code sessions, the largest session has 616 streams and 1,309 cursors, 614,788 bytes in all. One file per session would mean rewriting all of that to record that one source moved. A cursor is rewritten only when its own source changed. The other numbers in this section come from the same root.
A cursor is plain text, one key and value per line, replaced atomically. There are two kinds.
Append cursors
Sources that only grow are tracked by position: transcripts, workflow journals, and the plugin’s change records.
schema 1
kind append
source -Users-me-github-project/221ac729-….jsonl
dev 16777232
ino 287890584
offset 62230163
ord 17108
last_uuid 573afdf5-…
tail_sha256 98ebccc0…
size 62230163
mtime 1787283075
last_seq 35
updated_at 2026-09-06T15:02:49.496762Z
state active
| Field | What it is for |
|---|---|
source |
the source path, relative to the adapter’s root, with forward slashes |
origin |
the adapter’s root the source is under, with forward slashes: relative to the storage root when the source root lies inside it, so a root copied whole keeps its cursors, and absolute otherwise. Written since 0.5.0 by an adapter that reads more than one root, since two roots can hold one relative path. A cursor without it was written before 0.5.0 and belongs to the root whose file it was read from - the file with its dev and ino and the bytes of its tail_sha256, since two filesystems can give one inode number to unrelated files - and that root writes the key on its next pass. A legacy cursor whose file was copied or restored since is not claimed; that source is read again from its start behind a cursor of its own, and the index keeps the first record it holds for an id |
dev, ino |
the device and the inode the file had when it was read, or on Windows its volume serial number and file index. Resuming a cursor compares ino alone, and it is what this page calls the file’s identity: a change means the file was replaced, or is being reached another way, and the bytes decide which, see below. Claiming a cursor that carries no origin compares both, since that decides whether the cursor was read from this file at all and not from another with the same bytes at its end. |
offset |
the bytes consumed, always at the end of a line, and where the next read starts. Compared with the file’s size, it says whether anything is new. A size below it means the file was cut short. |
ord |
the last source line consumed, so line numbers continue without counting again from byte 0 |
last_uuid |
the id of the last record that had one. For diagnosis only: many lines carry no id, and nothing compares it. |
tail_sha256 |
the SHA-256 of the 1 MiB before offset. A mismatch means bytes already read were rewritten. |
size, mtime |
the size and modification time last seen. Recorded, never compared. |
last_seq |
the sequence of the last file this source landed |
state |
active, source_gone or conflict |
Every pass takes each append source through the same steps:
- The file is gone, though discovery listed it a moment before. The cursor becomes
source_gone, and the landed files stay. A file pruned before the pass began is not listed at all, and Pruned sources says how a pass finds its cursor. - The file is shorter than
offset. It was cut short, and the source is aconflict. - The size equals
offsetand the identity is unchanged. Nothing is new, and nothing more is read. A change is judged by the size, never bymtime, so clock skew and timestamp granularity cannot hide a change or invent one. - Otherwise the 1 MiB before
offsetis digested. If it differs fromtail_sha256, the source is aconflict. - The read starts at
offset, takes at mostmax_delta_bytes, and stops at the last complete newline. An unterminated last line is a write in progress. It is left for a later pass, so a landed record is always a whole source line. A single line longer than the budget is read to its end, so the source cannot get stuck behind it. - Each line is converted to a record. A line that is not valid JSON does not stop collection. It
lands as an
unknownpart that keeps its bytes. - The file lands. Then the cursor is saved with the new
offset,ordandtail_sha256. - While complete lines remain, the pass reads the next window, up to 65 windows from one source.
A source that still has data after that counts as pending. A watching collector carries on at
its next pass, and a single pass of
asz collectexits non-zero. The limit stops one source that grows faster than it is read from keeping every other source waiting.
A changed ino is not a conflict by itself. The same file reached through another file system
has a new inode: a bind mount into a container, a restored backup, a copied source tree. So the
bytes decide. If the 1 MiB before offset still matches, collection continues and the cursor
takes the new dev and ino. If it does not match, the source is a conflict. A cursor with no
tail digest cannot be checked this way, so for it a new ino is a conflict. The rule comes from a
failure: when the inode alone decided, every source collected on a host was a conflict when the
same root was collected inside a container.
A change of dev alone is never noticed. dev is saved with the cursor and never read back.
When nothing lands and ino is unchanged, dev is not refreshed, so the dev a cursor holds can
be out of date. Such a pass saves the cursor only when its state changes, and it saves the old
dev.
The window is 1 MiB rather than the whole consumed prefix, because the whole prefix would be read again on every pass that finds new data. In the same root, the largest transcript has 62,230,163 bytes consumed. The window catches a change to any byte inside it. A rewrite further back than 1 MiB is not caught. A record can be longer than the window, so the window can cover only part of the last record: 22 of the 404,956 records from growing sources are over 1 MiB, the largest 4,629,074 bytes.
A conflict is kept. The cursor is saved with state conflict, every later pass skips the source,
and nothing in asz clears it. Collection stops because an offset into bytes that changed no
longer means what it meant.
The pass that finds the conflict names the file and the reason on standard error. Every later pass
reports only how many sources are in conflict, not which ones. A watching collector prints that
count after each pass, and a single pass of asz collect exits non-zero. To find the sources, look
for cursors whose state is conflict.
All of this rests on transcripts being append-only, and that is observed, not proven. None of the 6,080 cursors in the same root is in conflict.
Snapshot cursors
Sources that are rewritten whole are tracked by a digest of their content: child-agent sidecars,
workflow manifests and workflow scripts. A snapshot cursor carries content_sha256 in place of the
position fields. Every pass reads the whole file, trims trailing newlines and digests it. When the
digest differs from content_sha256, the whole file lands as a new file of one record, and the
cursor takes the new digest. An empty file lands nothing.
So a change to such a source becomes a new immutable version, never an edit, and a run directory
can hold several manifest files, one per version. This kind exists for the workflow manifest. It is
rewritten as its run goes on, and its final version carries the run’s status, durationMs,
totalTokens and result. A sidecar uses the same cursor. In the same root, each of 2,782 child
streams holds one sidecar version, and each of 153 runs holds one manifest version.
Pruned sources
Claude Code prunes its own files. It often deletes a session’s main transcript while the session’s
other files stay for a while, and in the end it deletes them all. Discovery lists only what is on
disk, so a pass never takes a pruned file through the steps above. Its cursor is found another way
and set to source_gone. The landed files stay, so the storage root outlives the source. In the
same root, discovery no longer found 8 of the 60 sessions, which held 415 cursors of Claude Code
files between them. It also no longer found 1 file of a session it did find.
- A session discovery still finds: after its sources, the pass reads the session’s other cursors,
and sets
source_goneon each whose file is no longer there. - A session discovery no longer finds: the pass lists the storage root and checks each such session the same way. Discovery no longer finds a session when none of its files is left, or when every file left is in a directory it could not read.
Either way, the pass holds the session’s lock, and it leaves alone a session the adapter’s
include and exclude keep out. A collector checks a session of the second kind once, and again
only after discovery has found it in between. When another process holds the session’s lock, or
the check fails, a later pass checks it again. Claude Code prunes by age, so a long-lived root
holds more such sessions every week, and reading all their cursors on every pass would cost more
every week too.
Only a cursor that says active moves, and only when the file it names does not exist. A
conflict stays until a person looks at it. A cursor that names a path this adapter does not
produce, as the cursors of a scenario sd build do, is left alone.
Discovery reports a project directory it cannot read, and a pass that met one checks no session of
the second kind, because an unreadable directory hides files the way pruning does. Discovery does
not report a directory it cannot read inside a session, such as subagents/, so such a directory
does not stop the check. Checking a file in such a directory gives a permission error, not a
missing file, so its cursor is left as it is.
When discovery lists a file whose cursor says source_gone, restored from a backup for instance,
the pass sets the cursor back to active and saves it before it reads the file. An append source
then goes through the steps above like any other. So an older copy, shorter than offset, becomes
a conflict in the same pass. For a copy that still holds every byte that was read, collection
goes on from offset. A file in a session the adapter’s include and exclude keep out is not
read, so its cursor stays source_gone.
The plugin’s change records are not Claude Code’s files, and none of this applies to them. The
plugin removes an output file itself once it has not been written to for retention.ttl, 30 days
by default (see Retention). Their adapter sets
source_gone only on a file that goes while a pass reads it, so the cursor of a file the plugin
removed is left as it was.
A session a scenario build marked is the one case where asz deletes a source itself. Once all of the session is sent, a pipeline removes its source first and then the whole session directory, as Retention describes.
Provider bodies
Claude Code writes the bodies of every session into one flat directory, one file per body, and never
appends to one. Neither kind of cursor fits: an append cursor tracks a position in one growing file,
and a session of thousands of calls would need thousands of snapshot cursors. So the adapter keeps
one table for the root, _provider/seen.state: for each body file, its size, its modification
time, the start of its digest, its state, its session, and the ids that join it. The states are
landed, waiting for a session, excluded by the filter, unreadable, changed after it landed,
and conflict with a body of the same name the session already holds.
The table is derived. Every landed body is a record whose manifest names its file, its size and its
digest, so a missing table is rebuilt from the landed records, and the first pass after reads each
file once more to compare its digest. A file lands before the table says so, and the table says so only after the session’s index and
state are saved: a pass that stops between the two reads the file again, finds the session already
holds it, lands nothing, and closes the index gap. A landed line is
trusted only while its session’s provider_body/ directory exists, so a body whose session was removed
lands again. A line whose file and session are both gone is dropped. A landed provider file that does not read is
left out of a rebuilt table and reported, and every other session’s bodies are collected. Every pass
also compares each session’s index with its landed provider files, and brings a session whose index
is behind up to date, so a pass that stopped between landing and saving the index, or met the session
busy, leaves no lasting gap.
A pass holds _provider/.lock for the table and each session’s own lock while it lands into that
session. It lands every new body of a session into one file, in the order the files were written,
cut at max_delta_bytes.
The index
index/ holds identifiers and roles, never text: which record is in which stream, which call a
fragment belongs to, which tool use a result answers, which child a launch started. Assembly reads
this and never the payloads; the page does not read it at all, and takes every time from the
record itself. It is derived and disposable. Delete it and the next collect or parse
rebuilds it from the landed files; a schema change discards it rather than migrating it.
index/index.state records indexed_seq, the landed sequence the index covers, with its schema
and its counts of entries, blocks and strings. Cursors commit per source, but the index is written
once per session, at the end of its pass. A crash between the two leaves the index behind what
landed, and nothing would land again to fill the gap. So the next collect compares indexed_seq
with the highest sequence on disk and indexes the landed files above it again. Parse never reads
past indexed_seq, so a round cannot cite a record the index does not hold.
The chain
_conversations/<id>/rounds/ holds Session Flow: an append-only chain of
immutable rounds. The conversation is the fold of every round; there is no other state. The file
name carries the round number and the first twelve characters of the round’s digest.
conversation.state is a cache of the head. It is rebuilt by listing the directory, because a
crash between publishing a round and saving state must not lose the round.
Publishing a round reads the head and then writes the next round. .lock lets one parser do that
at a time. Without it, two parsers could both read round N and both write a round N+1. The digest
is part of the file name, so the two files would not collide, and the chain would fork with no
error. A second parser gets a lock error and writes nothing. Under the lock, a round that does not
follow the head is refused. The next round is written to a temporary file, made read-only and
synced before an atomic operation installs its final name without replacing an existing file, and
the directory is synced after it, as for a landed file. An interrupted write leaves no partial
round for a reader to fold. Tests in tests/chain cover
an interrupted writer and four parsers running at once.
The atomic operation is renameat2 with RENAME_NOREPLACE on Linux, renamex_np with
RENAME_EXCL on macOS, and MoveFileEx without replacement on Windows. A filesystem without an
exclusive rename uses a hard link, which has the same guarantees. exFAT on macOS has neither: on a
disk image, both calls returned “operation not supported”. There asz creates an empty file with
O_EXCL, which still refuses an existing round, then renames the complete round over it. Between
those two calls, with no write between them, a reader or a crash can see an empty round file.
No round or receipt is ever empty, so an empty file under such a name is a reservation. The chain does not count an empty round file, and the next publication removes one older than a minute. An empty receipt holds no decision, so its file is derived again, and a publication replaces a receipt reservation older than a minute. A younger one may belong to a writer still running, so the file waits for a later pass.
Parse takes the lock before it reads the index. A parser that read the index first could hold an old one while a scenario removal took the session away, and then publish a round over evidence that is gone. A parse of a session with no landed file and no round publishes nothing, and creates no chain directory when there was none. So a parse that runs after a removal leaves nothing behind.
What travels
A storage root is complete on its own. A copy of it can be listed, verified, re-parsed and read on
another machine with no source files and no collector. Session Data is already converted: its
records name identifiers by role, not by any runtime’s fields. So the index rebuilds from the
landed files with no adapter and no runtime-specific code, and the chain head is recovered from the
rounds directory. asz verify checks every landed file against its digest and every round against
its commit digest without touching the source.
For the same reason, the index, assembly and the chain hold no code for any one runtime. Supporting another runtime needs an adapter that writes Session Data, and nothing on the assembling side. The mock dialect the tests use is one: it writes Session Data directly, and the same code assembles it.
Size
Measured on 2026-09-03 on one machine’s storage root of 48 Claude Code sessions:
| Files | Size | Against the source | |
|---|---|---|---|
landed .sd |
6,615 | 800 MB | describes 1.2 GB of source records |
rounds .sf |
97 | 105 MB | 8.9% |
| index | 48 | 52 MB | 4.5% |
| cursors and state | 6,766 | 3 MB |
The landed data is smaller than its source because a record keeps its content and its provenance, not its envelope. Of the transcript content, 550 MB is carried whole; 64,667 reasoning parts are marked unavailable because Claude Code stores only a signature for them, and each record says so. The store had also outlived its source: it held 48 sessions where the source directory still had 44.
Retention
storage.retention
storage.retention removes the landed files of a conversation that has been idle for longer than
it, by asz’s clock: nothing new has landed in any of its streams since. It is -1 by default, which
keeps everything. A pipeline, asz collect or asz server, removes at the end of a pass, after the
send. A session’s files go together, every landed file of it through its newest, never some of
them. A session keeps them while any of these holds:
- Nothing of it was ever sent, or tried to be:
push.statenames no owner for it. - Something landed for it within the retention.
- Its head round does not reach its newest landed file, or its parse failed this pass.
- With metrics derived, a landed file of it is not derived yet.
Sending is not waited for. A receiver that is down for longer than the retention misses the files removed meanwhile, and asz does not send them again. A scenario root keeps its own removal, below, and retention never runs over it.
Each removed file is replaced by its skeleton, <name>.sk in the same directory, written once and
read-only. A skeleton keeps every record and every part in its place,
with every field the index is built from, and none of the content: text, data and media go. The
index rebuilt from a skeleton is the index rebuilt from its file, so a conversation that becomes
active again continues the same chain, with the same rounds, as if nothing was removed. Its text
is kept only by the receiver it was sent to. A skeleton is never sent. Measured on 2026-10-13 on
one machine’s root, 7,235 landed files of 847.5 MB had skeletons of 29.6 MB, 3.5% of them: 3.5%
for transcripts, under 5% for workflow files, and 77% for child agents’ sidecars, which are mostly
identifiers. Its 16 provider body files of 133 KB had skeletons of 13.6 KB, 10% of them, 0.7 KB of
which is the segments each body’s manifest keeps.
Nothing else of the session changes: the index, the cursors, session.state and the chain stay. A
skeleton takes its file’s modification time, which the metrics grace period and the order sessions
are sent in read. A removed file’s metrics request goes once the receiver has it. A request that
was not sent stays, and goes on the first pass after it is sent while retention is on. A pass with
retention off does no retention work at all, beyond finishing a removal in progress. The file’s
receipt under metrics/ stays: a deriver whose metrics.state a power loss took back reads it to
derive the file as before.
A provider body landed after a removal is cut exactly as it would be with nothing removed. The cutter holds each removed body by its skeleton, which keeps its identity, its digest, its size, its depth, its place among the chain tips, the segments that name a part, a piece or an earlier body, and the digest of each of its pieces. So it cites the same pieces and compares a new request with the same tips, and a body it did not accept when it landed is not accepted from its skeleton either. Their bytes come from the session’s store:
<session-id>/provider/
tips/<generation>/<id>.gz every chain tip at that removal, whole
pieces/<sha256>.gz each removed piece a body landed since then cites
A removal keeps the bytes of every chain tip under its own generation. A collector keeps a removed
piece a new body cites, from that body, before the body lands, unless a body landed earlier cites it
already. A piece is named by the SHA-256 of what it holds. A tip is named by the SHA-256 of its
record’s id, <id> above, because an identical body under another id may be one that could not be
rebuilt, and must not be read as kept. Each file is checked against the SHA-256 of what it holds
when it is read. It is written through a temporary name and replaces a file an earlier attempt left,
and its directory and each one above it up to the session’s are synced, the errors checked: a later
sync that succeeds does not say an earlier attempt’s file reached the disk. A reader looks for a tip
in every generation the store holds, the newest first: while a removal runs, a body not yet deleted
can be built from a tip of the generation before. A tip that holds or cites a piece too large to
land cannot be rebuilt, so no removal keeps it, and a collector passes it over as a base with or
without a removal.
A removal is written so that a crash or a power loss at any point leaves nothing lost:
- The session’s provider bodies are read as a collector reads them, and the bytes of each chain tip that can be rebuilt are kept under this removal’s generation. A tip that holds or cites a piece too large to land is passed over. Any other tip that does not rebuild keeps the session’s files this pass, and its collectors go on landing.
removing.jsonrecords the removal: its generation, the newest sequence it removes, and its phase, and its directory is synced.- The skeleton of each landed file is written and its directory synced, and the error of that first sync is checked. A failed sync stops the removal. The next pass writes every skeleton again, since a later sync that succeeds does not say the data reached the disk. A skeleton takes its file’s modification time, and is synced again after it.
- The phase becomes
delete, andremoval.staterecords the removal’s generation. Each landed file is deleted once its skeleton reads whole and records the file’s digest; a skeleton that does not stops the removal and the file is kept. Then the metrics requests the receiver has go, as above, and every directory of the removed files, with the spool, is synced, so a deletion an earlier attempt made is durable too. - The store loses the tips of earlier generations and every piece: only bodies now removed were built from them. A body landed later that cites a removed piece keeps it again. The pieces stay when a provider body landed after the removed range, which only a power loss that brought back the intent of a finished removal leaves.
removing.jsonis deleted, and the session directory is synced, the error checked, so a power loss never brings the intent back after a collector landed into the session again.
Every pass of a pipeline first finishes a removal that an earlier one left in progress, before
anything lands, whatever storage.retention says now. Until a removal is finished, no collector
lands into its session: each finds the session busy. While both a landed file and its skeleton
are there, every reader reads the landed file, and one that finds the file gone after it listed it
reads the skeleton. The two read as the same index. The page keeps what it read of a conversation
for its chain head and the generation in removal.state, and keeps nothing while a removal is in
progress, so a page in another process than the pipeline draws the skeletons once the files are
removed.
A root with skeletons still verifies. asz verify checks lines and bytes through each skeleton,
binds each round to the digest each file had, which its skeleton recorded, and reports every
removed file with what the receiver confirmed of it, never as verified. asz show says a record
was removed. The page draws a removed step with no text, its content state removed. It does not
yet draw a provider body landed after a removal that is built from a removed tip or piece: the
page rebuilds a body from the landed files alone. asz verify rebuilds every body that is not
removed, with the store. While a removal is in progress, a body not yet deleted that no longer
rebuilds, because the removal deleted a body it is built from first, is counted and not reported:
the removal deletes it too. When a removal of the session starts, saves its generation or ends
while verify reads the bodies, verify reads them again, and after four reads that each raced one it
reports the check as inconclusive rather than as damage. asz repack refuses a session with skeletons or with a removal in
progress: a skeleton keeps no records to re-cut.
Removing by hand
The session directory is the unit of retention: removing it purges everything collected for that
session. Removing index/ costs a rebuild. Removing a conversation’s rounds loses the structure
only; a new parse rebuilds it from the landed files, and because a round carries no wall-clock
time, the same landed range and the same parser and policy versions reproduce the same bytes and
the same digest.
Remove a session directory whole, never only some of its landed files. A round names records by
landed sequence and row, so a round whose files are gone points at records nobody can read.
asz verify reports each such round with each missing sequence, and exits non-zero. For a stream
with an append cursor, it also reports the gap a lost file leaves in the stream. A lost first or
middle file leaves a gap in the lines and bytes of the records left. A lost last or only file
leaves the records short of the position the cursor holds, an end gap. So such a loss fails a
root with no rounds too, with two exceptions, where only a round that consumed the file shows it. A
lost file leaves no gap when another landed file holds the same records, as after an interrupted
pass. A cursor never travels, so in a root rebuilt from a push, a lost last file leaves no gap.
Which streams have an append cursor depends on what landed them. claude-code-local gives one to a
transcript and a workflow journal, and changes to the plugin’s change records.
claude-code-local reads a child agent’s sidecar, a workflow manifest and a workflow script whole,
with a snapshot cursor. So in a root it collected, a lost file of one of them is found only by the
round chain, and a root with no rounds does not show it. A scenario sd build gives each of them
an append cursor that counts records, so there such a loss is an end gap too.
The asz.view document is still served while the conversation has rounds. When a
round names a missing file, summary.state is incomplete, and summary.problems has one line
for each missing file in each round. A node that stood on a missing record has no text and no
content state. With no rounds there is no document. asz conversation refuses the conversation
with a no rounds error, and so does the request for the document behind the page.
A scenario root
asz removes a collected session on its own in one case only: a session a claude-code scenario
build wrote. Scenarios describes
the policy. A real Claude Code session is never removed. Only the build writes the marker a removal
needs, and the product configuration has no removal setting.
The build writes one marker for each session, DIR/_source/.asz-scenario/<session-id>.json, and a
pipeline reads it under the source_root of claude-code-local. The build writes it after every
other file of the session, so the marker proves the build finished writing the session. It names
the policy and lists every file the build wrote for the session, with its size and SHA-256. A
removal deletes only the files listed there, and only while each still holds the bytes the build
wrote. The build also creates _scenario/ in the storage root, the sign that a pipeline over this
root removes. Such a pipeline holds _scenario/.lock for as long as it runs, and a second one
waits. It could otherwise land a removed session again through a source it listed a moment
earlier, or parse evidence that is gone.
A pipeline removes a marked session at the end of a pass, after the send, when all of these hold:
- The adapters find exactly the files the marker lists, each still holding the bytes the build wrote, and neither adapter would land anything more of them.
- Every landed file of the session came from a file in the marker.
- Every landed file, every round and every spool file of the session is in
push.statewith the digest it has now, and none of them has arejectedline. push.statenames one receiver, the one this pipeline sends to.- The head round reaches the last landed file.
- With
metricson, every landed file is derived. - The marker’s policy allows it.
- Nothing keeps every marked session. Scenarios lists what does, such as a source directory where Claude Code keeps its own files, or a source directory discovery could not read.
The removal then goes in this order:
- The marker is rewritten to say
removing. From then on, a pass finishes the removal without deciding again, and a build refuses to write the session. - The source files the marker lists are deleted, then the empty directories named for the session. A project directory, which every session of a feed shares, is never deleted.
<root>/<session-id>/is renamed to_removed/<session-id>/, and deleted there._conversations/<session-id>/is renamed to_removed/<session-id>.chain/, and deleted there.- The spool files the session owns are deleted.
- The session’s lines in
push.state,metrics.stateand_provider/seen.stateare dropped. - The marker is deleted.
Each step comes where it does for a reason:
- The source goes first. The cursors live in
<root>/<session-id>/. A source that outlived them would be read again from byte 0, landed under new names and sent again, becausepush.stateis keyed by path. - The landed files go before the chain. With them gone, nothing parses the session again, and every round left is already sent. The other way round, a restart would parse the landed files into a new chain, and a round of it that differs from one already sent would be sent again.
- The state lines go last. A line is dropped only once its file is gone, so no file is sent or derived twice.
- The session directory and the chain directory each go in one rename. Deleting a session
directory in place takes one call per
file, and a walk in name order reaches
index/beforestreams/. A stop in the middle left a session with no index and some of its landed files. Parse then rebuilt the index from what was left and published a round of tombstones. A rename is one call. Before it the directory is whole, and after it nothing that lists sessions or chains finds any of it: the session listings pass over names that start with_, and_removed/is outside_conversations/. In a pipeline over a scenario root, the removal step at the end of every pass first deletes whatever a stopped removal left in_removed/. When_removedis a symbolic link, or not a directory, nothing is deleted through it and nothing is moved into it. The pass then removes no session, and says why.
Between the two renames the page still lists the conversation, because its rounds exist, and shows
it as a conversation whose landed files are gone, as above. Once the chain is gone too, the page
stops listing it, and the receiver holds the only copy. A test in tests/chain stops a removal at
every point it reaches, including after each source file and each spool file it deletes, then runs
the whole pipeline again as after a restart. Each time nothing is landed, derived, parsed or sent
again, the session ends fully removed, and _removed/ is empty.