This page describes everything broca writes to disk, for modules that read broca's state directly rather than through its RPC surface. Everything here was read from source. Where this page and the code disagree, the code is right, and this page is the bug.
Section 7 says which items are stable contracts and which are internal. Section 8 lists the rules every external reader must follow. Read both before building on anything else here.
All state lives under one directory, the state root:
$BROCA_STATE_ROOTwhen that variable is set and non-empty;- otherwise
<directory of the subc connection file>/broca-state.
broca does not consult XDG_DATA_HOME.
<state root>/
wal/<addr>.wal one append-only WAL per session (section 2)
wal-durable/<addr>.wal live WALs selected for backup at first send
wal-ephemeral/<addr>.wal retained WALs, never backed up or folded
wal-archive/ sealed archive containers of cold WALs (section 3)
fold-<unix-ms>.ark
fold-<unix-ms>.journal
wal-archive-durable/ sealed containers of durable WALs only
run-index.db SQLite: run index, materialized transcript, (section 4)
admission checkpoints, usage export facts
caps.db SQLite: budget caps (section 4.6)
wal-manifest diagnostic address -> identity sidecar (section 2.7)
leases/ per-session lease files
catalog/fusiform-lkg.json last-known-good model catalog
logs/ daily log files
request-dumps/ debug request dumps
The WAL is the source of truth. Everything in run-index.db is derived from it, although
not all of it can be regenerated (section 4 says which parts cannot).
broca creates the WAL directory, WAL files and archive containers owner-only (0700
directories, 0600 files), and restricts run-index.db and its -wal/-shm sidecars to
0600 when it opens them. A reader must run as the same user.
Each session has one WAL file:
<state root>/wal/<addr>.wal
Durable sessions use the sibling wal-durable/ tree, and sessions with retention
use wal-ephemeral/ regardless of class. The first send freezes session_class;
the daemon's backup_classes setting (default ["head"]) chooses whether that
new session is durable. Existing live/archive placement is authoritative after
configuration changes. Readers resolve all three live trees before consulting
wal-archive/ and wal-archive-durable/; duplicate live copies are an error,
not a priority rule. Only the durable trees are captured by Engram, the backup service.
A session is identified by its bind triple (project_root, harness, session). addr
is FNV-1a 64-bit over the UTF-8 bytes of
project_root + "\x1f" + harness + "\x1f" + session
(the separator is U+001F, UNIT SEPARATOR, one byte), with offset basis
0xcbf29ce484222325 and prime 0x100000001b3, rendered as 16 lowercase hex digits,
zero-padded. Pinned example: ("/tmp/p", "runner", "s1") → 31a7840439280f40.
def addr(project_root, harness, session):
h = 0xcbf29ce484222325
for b in f"{project_root}\x1f{harness}\x1f{session}".encode():
h = ((h ^ b) * 0x100000001b3) & 0xFFFFFFFFFFFFFFFF
return f"{h:016x}"project_root is rendered as broca renders a path for display. A field containing U+001F
is rejected at bind, so the encoding is unambiguous. Changing any component of the triple
addresses a different file.
The filename is the only address. broca never checks the triple embedded in the WAL's
run_started records against the filename. A WAL copied or renamed to another address is
read as that address's session. So the embedded triple records where a session was born,
not where it lives now.
A WAL can be zero bytes long: a lineage opened before lineage frames existed and never run, for example. A present, empty file is a session with no records. It is not an error, and it counts as "live" for archive resolution (section 3.3).
Only files named <16 lowercase hex>.wal are WALs. While restoring an archived session,
broca writes a temporary .extract-<pid>-<n>.tmp file in its original live tree and renames it into
place; ignore anything else in the directory.
A WAL file is a sequence of frames with no file header. All integers are little-endian. Every frame has a fixed 53-byte header followed by its payload:
| Offset | Width | Field | Meaning |
|---|---|---|---|
| 0 | 4 | len |
u32, payload length in bytes. Never more than 64 MiB (67,108,864). |
| 4 | 1 | ver |
u8, frame kind (below). |
| 5 | 8 | seq |
u64, record sequence number. |
| 13 | 8 | fence |
u64, lease-tenure stamp of the writer. |
| 21 | 32 | digest |
SHA-256 over ver ‖ seq(8 bytes LE) ‖ fence(8 bytes LE) ‖ payload. |
| 53 | len |
payload |
See ver. |
len is not part of the digest. The frame's total size is 53 + len.
ver values in use today:
ver |
Frame | seq |
fence |
Payload |
|---|---|---|---|---|
| 1 | record | ≥ 1 | writer's lease tenure | One WalRecord as UTF-8 JSON (section 2.4). |
| 2 | lineage | always 0 | always 0 | Exactly 16 opaque bytes: the lineage id (section 2.3). len is always 16, so the frame is always 69 bytes. |
| 3 | gated record | ≥ 1 | writer's lease tenure | {"requires": [feature name, ...], "record": WalRecord} as UTF-8 JSON. |
Gated records (ver = 3). A record an older reader must not skip or misread is written
in a ver = 3 frame. requires lists the features it uses, as names like
hook-output/v1 ([a-z0-9-] then /v and a number), sorted. A reader checks every name
against its known-name table before it interprets record; if any is missing, it refuses
the whole WAL as NewerWriter (a newer build wrote it), which is neither corruption nor
a skip: nothing repairs, truncates or folds such a WAL. broca's table is printed by
ck-broca --wal-known-features. It holds plan-manifest/v1 (the frozen plan manifest on
run_started), scope/v1 (the frozen scope on run_started), steer-queue/v1 (the
steered record) and dispatch-module/v1 (module_id and schema_pin on
tool_dispatch_intent). A record with nothing to require is always written as ver = 1.
The encoder adds a record's own requirements itself, so a run_started with a scope or a
manifest, a tool_dispatch_intent with a module_id or a schema_pin, and every
steered record, can only be written as ver = 3.
Builds before the gate refuse ver = 3 as "unsupported WAL framing version 3".
The feature floor. Before the first frame that requires a feature, the writer adds the
feature to wal-feature-floor in the state root (next to wal/): one name per line, sorted,
written to a temporary file and renamed over the old one, and only ever grown. If the floor
cannot be raised the append is refused and nothing is written, so no gated frame is ever on
disk without the floor naming it. scripts/place-release.sh refuses to place a build whose
known-name table does not cover the floor.
Any other ver is corruption, and so is a ver = 2 frame with len ≠ 16, seq ≠ 0 or
fence ≠ 0. ver is the framing version, not the record schema version. The record
schema version (currently 2) is not written anywhere in the file.
Sequence numbers. Record frames are numbered from 1, contiguous and gapless within a file. Lineage frames do not consume a number. broca refuses a file whose record sequence does not start at 1, has a gap, or goes backwards.
fence is the lease epoch of the process that wrote the record. It exists only for
forensics. Nothing compares it or rejects an append because of it.
Tail and corruption rules (how broca decodes; readers should do the same):
- Fewer than 53 bytes left, or a header whose payload runs past end-of-file, is a torn tail: an append that was not finished (it crashed, or it is still being written). Stop there. It is not an error.
- A complete frame whose digest does not verify, whose JSON does not decode, or whose
veris unknown is corruption. broca fails loudly on it. - Only the writer, when it reopens the file, truncates a torn tail. Readers never do.
The lineage id is 16 random bytes, minted from the OS entropy source. Bits are set so it
looks like a version-4 UUID. Treat it as opaque. broca displays it in UUID form
(8-4-4-4-12 lowercase hex), as it appears in meta.lineage_id and in session.read.
- When it is minted: when a WAL file is created. The first frame of every file created by v0.3.64 or later is a lineage frame. A file created earlier gets its id at its first append after upgrade, when a lineage frame is written just before that record, so in such a file the first lineage frame is in the middle.
- Where it lives: in
ver = 2frames. Every append writes the record frame followed by a copy of the lineage frame, in one write. So a normal file alternates record and lineage frames, and its last complete frame is usually a lineage frame, not a record. - What it means: the same id means "same log, continued". A different id at the same address means the file was deleted and re-created. A file carrying two different ids is refused as spliced.
The payload of a ver = 1 frame is one JSON object, internally tagged by "type" (snake
case). Field rules in the tables below:
- absent means the key is left out when there is no value (
None/false/empty), and reads back as absent/false/empty when missing. Absent never means zero. - defaulted means the key is always written, and older records that lack it read as the stated default.
- required means the key is always present.
Identifier types are JSON strings (run_id, submission_id, batch_id, tool_call_id)
except step_id, which is a JSON number. session is the bind triple as an object:
{"project_root": str, "harness": str, "session": str}.
run_started: opens an episode (one run). Written atomically with its whole config
and input, and synced before the first provider call. A resumed run (after a crash or a
pause) does not write a new run_started; it continues under the same run_id.
| Field | Type | Rule | Meaning |
|---|---|---|---|
run_id |
string | required | |
session |
triple object | required | The bind triple at admission. |
project_id |
string | absent | Client-supplied, recorded verbatim, not validated. |
ts_ms |
u64 | absent | Wall-clock Unix ms at admission. Absent on records from before metering. |
config |
RunConfig object |
required | The request config frozen for this run (below). |
input |
array of Message |
required | The user input for this run. |
origin |
object tagged by kind |
defaulted {"kind":"fresh"} |
fresh, or seeded_from (import/fork) with origin_kind (import/fork), source_ref, optional origin_device_pseudonym (16 bytes), optional base_seq. |
submission_id |
string | absent | Set when the run consumed a queued submission. |
title |
string | absent | The human-readable title the session.send that opened this run carried (for a drained queued send, the title its queued record carried): 1-200 Unicode scalar values, not blank, no control characters. Informational only and ungated: never rendered into a provider request and not part of routing, cache keys or the request identity digest. Absent when the send carried none and on records written before it existed. |
scope |
{owner, ref, scope_epoch} |
absent | Must-understand, feature scope/v1. The scope the session was admitted under, as the subc daemon stamped it on the bind: owner is a principal object ({"kind":"reserved","module_id":…}), ref a string, scope_epoch a u64. Frozen at the session's first scoped episode and repeated on every later one. A record carrying it is written only in a ver = 3 frame requiring scope/v1; an unscoped record stays ver = 1. Written by admission after its owner check (the scope a send freezes or adopts, or the one the session already froze); the broca release before the one that first writes it already reads it, so rolling back one release still reads the session. |
manifest |
object | absent | Must-understand, feature plan-manifest/v1. What a session's first send fetched from its tool providers, frozen so every later episode and resume renders from it without fetching again: plan (the first session.send's plan member exactly as received, opaque), plan_identity (SHA-256 of the plan's RFC 8785 (JCS) bytes, 64 lowercase hex), composition_digest (64 lowercase hex, the digest the plan stated and the writer verified), fetches (array, one per provider fetched, sorted by provider id bytewise; each is flat: an answered fetch is {provider, catalog_digest, tools, text} with tools exactly as the provider answered (empty for a provider asked only for system text) and text the system text it answered, omitted when no system-text item was asked of it and "" when present and empty; an absent fetch is {provider, absent}, an optional text-only item that did not answer, with absent an open reason string such as timeout, unknown_module or refused:<code>; a fetch with both catalog_digest and absent does not decode, and one with neither decodes as a state from a later build and is never written), tools (array of Tool, as in RunConfig.tools, in the order offered to the model), system_text (the joined system text), system_digest, join_version (u32) and role_versions (object, role name to version string). The plan's own layout is never WAL schema, so the plan can grow without a WAL change. plan is opaque JSON stored as a JSON string holding the JSON text, so its key order, number spellings and escapes survive a decode exactly (an embedded value would not: run_started is decoded through serde's internally tagged buffer, which re-sorts keys and re-spells numbers). A record carrying it is written only in a ver = 3 frame requiring plan-manifest/v1. Written on the run_started of every episode of a session whose first send carried a fetch plan (the first episode freezes it; later ones repeat it, so episodes left after a fold still carry it); the WAL raises its feature floor to plan-manifest/v1 before the first such frame. The system texts are joined in plan order with "\n\n" under join_version 1, an empty text adding neither bytes nor a separator. |
RunConfig fields: model ({provider_module_id, model_id}), tools (array, order
significant), tool_choice, generation, response_format, context_epoch (u64),
renderer_version (u32), all required; provider_options, cache_tiers,
frozen_render_config (opaque JSON, absent when null); auth_selection (absent, below);
tools_were_omitted (bool, absent when false); budget, context_limit (u64),
work_class (string), variant (string), service_tier, context_management (all
absent); stop_when (array, absent when empty). frozen_render_config,
provider_options and cache_tiers are opaque provider blobs (see section 7). Note that
frozen_render_config can contain a project_id key of its own (Google Code Assist's
GCP project), which is not run_started.project_id.
run_finished: closes the episode (not the session: a later run_started can open
the next one). Synced before the caller sees the terminal.
| Field | Type | Rule | Meaning |
|---|---|---|---|
reason |
string | required | completed, max_steps, cancelled, interrupted, error, transform_unavailable. |
ts_ms |
u64 | absent | Wall-clock Unix ms when the terminal record was written. |
usage |
Usage |
required | The run's usage summed over its model steps (section 2.5). |
error |
ProviderError |
absent | Present only when reason is error or the terminal has a typed cause. |
retries_used |
u32 | defaulted 0 | Provider retries spent over the run, including retries of a step that then failed. |
ProviderError: class (transient, permanent, auth_required (older records may say
auth), context_overflow, provider_unavailable), message, and absent-when-unset
status (u16), retry_after_secs (float), provider_code, resets_at_ms (u64),
limit_window, account_ref, credential_id.
credential_id is the full credential id the run froze in auth_selection.credential_id
(with shape <method>:<provider>:<account>), stamped by the run on exactly the credential-scoped
terminals: billing_exhausted, account_limit_exhausted, and a transient rate limit (HTTP 429,
or an in-band rate_limit_error/rate_limit_exceeded). A rate-limit terminal also carries
provider_code: "rate_limited" in place of the provider's own code; WALs written before it
hold the raw code. Absent on every other error, on runs that froze no selection, and on records
written before the field; a reader that ignores it computes the same session.
run_paused: non-terminal. The run is waiting for re-authentication and resumes
under the same run_id. Always the last record of its episode while the pause lasts.
| Field | Type | Rule | Meaning |
|---|---|---|---|
run_id |
string | required | |
ts_ms |
u64 | absent | Wall-clock Unix ms of the pause. |
reason |
string | required | auth_required (the only value today). |
error |
ProviderError |
absent | The cause. |
run_config_refrozen: replaces the run's frozen render config after a pause at start.
Legal only before the first provider call.
| Field | Type | Rule |
|---|---|---|
renderer_version |
u32 | required |
ts_ms |
u64 | absent. Wall-clock ms when the refreeze was recorded. |
frozen_render_config |
opaque JSON | required |
run_auth_refrozen: replaces the run's frozen credential selection after
re-authentication. May appear more than once, and after provider calls.
| Field | Type | Rule |
|---|---|---|
ts_ms |
u64 | absent. Wall-clock ms when the refreeze was recorded. |
auth_selection |
AuthSelection |
required |
AuthSelection (also RunConfig.auth_selection): credential_id (required, the
<method>:<provider>[:<account>] routing key); absent-when-unset account_ref,
resolved_credential_id, and vault_request. vault_request is
{min_ttl_ms: i64|null (always written), force_refresh: bool (absent on get_scoped), op: "get"|"get_scoped"|<unknown string> (absent on older records, meaning "get")}.
account_ref is empty by construction for apikey and antigravity credentials, so key
spend on credential_id, never on account_ref.
step_started: written when a model step begins. step_id (u64, required). Carries
no time.
model_step_finished: the assembled assistant message of a completed step.
| Field | Type | Rule | Meaning |
|---|---|---|---|
step_id |
u64 | required | |
assistant_message |
AssistantMessage |
required | The full assistant output, including tool calls. |
usage |
Usage |
required | This step's usage (one provider call). |
finish_reason |
string | absent | stop, length, tool_calls, content_filter, error, other. |
retries_used |
u32 | defaulted 0 | Retries within this step. |
service_tier_used |
string | absent | Tier the provider reported serving, verbatim. Never filled from the requested tier. |
speed_used |
string | absent | Anthropic usage.speed, verbatim. |
turn_finished: step_id (u64). The turn (step plus its tool batch) is committed.
Synced.
budget_fired: a caller-supplied budget message was injected. run_id, kind
(nudge/finalize), threshold (u64), ordinal (u64), all required; caller_text
(absent).
| Record | Fields |
|---|---|
tool_batch_started |
batch_id, step_id, planned_calls (array of tool-call ids). Every planned call gets exactly one terminal. |
tool_dispatch_intent |
batch_id, tool_call_id, tool_name (absent; for display only), args (JSON), module_id (absent; the tool provider module the call was dispatched to), schema_pin (absent; {schema_digest, semantics}, the tool schema the call was dispatched against). Synced before the tool is dispatched. module_id and schema_pin are must-understand, feature dispatch-module/v1: an intent carrying either is a ver = 3 frame. Every new intent names module_id when the tool plane routes the call to a module; schema_pin is not written yet, because a session's frozen tools carry no schema digest. |
tool_no_dispatch |
batch_id, tool_call_id, reason: object tagged by kind: denied{reason}, validation_failed{detail}, unavailable{detail}, policy_blocked{detail}. The call never left the process. |
tool_result |
batch_id, result (ToolResult), outcome: completed, unknown (sent, no result: the side effect may have happened), interrupted, max_steps. Optional reason (string, absent by default, not must-understand): a typed reason name for a result broca wrote in place of a provider's. Today it is written only when the route a call was to use, opened under the run's scope, closed while the call was in flight. It then names what the daemon reported about the scope when asked with scope.describe (scope_ended, scope_not_live, scope_parent_ended, scope_agent_changed, scope_unverifiable), or the refusal code the daemon returned when broca tried to re-open the route; with completed the call was never sent (route_drained), with unknown it was sent and its result never came back. The outcome carries what replay acts on, so an older reader that ignores reason computes the same session. |
tool_batch_finished |
batch_id. Written only after every planned call has its terminal. |
approval_requested |
tool_call_id. |
approval_resolved |
tool_call_id, decision: object tagged by decision: allow, allow_narrowed{input}, deny{reason}. |
A tool_dispatch_intent with no tool_result after it is indeterminate: the call is never
re-sent. On resume broca writes a tool_result for it with outcome unknown, is_error: true
and text saying the call has no recorded result (and an interrupted result for any planned
call of that batch that never left the process), commits the batch, then seals the episode
interrupted with no model call. The next send renders the call paired with that result.
| Record | Fields |
|---|---|
queued |
submission_id, request (the caller's full SubmissionRequest), request_identity_digest (absent), principal (absent), send_id (absent), title (absent; the send's title, carried onto the run_started that drains it, informational like run_started.title), delivery (absent; "interrupt" for the message an interrupting send queued before cancelling the run it interrupted). A prompt queued while a run was active. |
retracted |
submission_id, failure (absent, {run_id, error: ProviderError} when the queued head could never be admitted). |
steered |
send_id (absent), request_identity_digest (absent), delivery (absent; "steer"), title (absent; the steering send's title, informational like run_started.title), input (the user message(s)), hooks (absent; reserved, never written yet). Must-understand, feature steer-queue/v1: always a ver = 3 frame. A message a caller added to the running turn. |
The pending queue is queued minus retracted minus those consumed by a run_started
carrying their submission_id. It drains every pending delivery: "interrupt" entry first,
in WAL order, then the plain entries in WAL order. queued.delivery is deliberately NOT gated:
a build that predates it drains everything in plain WAL order, so after a rollback an interrupt
runs behind the plain sends queued before it. Each message is still run exactly once; only the
order degrades.
A steered message is placed in the conversation at the first step boundary after its record:
the episode's next step_started (that step renders it, as a user message appended after
everything before it), or the episode's run_finished when no further step comes. It is never
placed between a tool call and its result. session.read shows it at that position, and not
before it is placed. Replay renders input exactly as written.
model_attempt_finished: one per provider HTTP attempt of a step (failed and cut
attempts included), written before that step's model_step_finished, or before the run's
terminal if the step failed. Written without its own fsync. Present from v0.3.122. Every
broca reader skips it: removing these records from a WAL gives the same session.
| Field | Type | Rule | Meaning |
|---|---|---|---|
step_id |
u64 | required | Same id as the step's model_step_finished. |
attempt |
u32 | required | 0-based, gapless within the step. |
request_body_len |
u64 | required | |
request_body_sha256 |
string | required | Lowercase hex. |
http_status |
u16 | absent | Absent when no status line arrived. |
content_type |
string | absent | |
response_body_len |
u64 | required | Raw bytes received (for a cut stream, the prefix). |
response_body_sha256 |
string | required | Lowercase hex over those bytes. |
response_body_cap |
u64 | absent | Present only when an error body was cut at a cap. |
outcome |
string | required | complete, eof_before_completion, chunk_error, idle_timeout, content_timeout, byte_limit, decode_error, rate_floor, cancelled, sealed_in_flight, http_error, provider_error, connect_failed, header_timeout, no_status. A later build may add values; decode as an open string. |
ts_ms |
u64 | required | Wall-clock Unix ms at attempt end. Informational only, never a join key. |
input_transformations |
array | absent | Provider's report of request changes, entries {type?, path?, reason?}. |
credential_refresh |
object | absent | {vault_op, min_ttl_ms?, force_refresh, record_version?} on an attempt re-sent after a 401 refresh. |
It does not carry usage.
resume_started: one per resume of an episode that has no durable run_finished,
written before anything else the resume writes. Every broca reader skips it and it never enters
a rendered request: removing these records from a WAL gives the same session. Builds that
predate it skip it as an unknown type.
| Field | Type | Rule | Meaning |
|---|---|---|---|
episode |
string | required | Run id of the episode being resumed. |
resumed_from_seq |
u64 | required | seq of the last durable record the resume read. |
replayed |
object | required | Counts rebuilt without calling anyone: messages (the whole reconstructed conversation), model_steps (durable steps of this episode, not requested again), tool_results (durable terminals of the open tool batch, reused as recorded). Absent keys read as 0; a later build may add keys. |
redone |
array | required | What the resume does again, entries tagged by kind: model_step{step_id} (no output of that step was durable), tool_dispatch{tool_call_id} (a planned call with no durable intent). A later build may add kinds. |
indeterminate |
array | required | Tool-call ids with an intent and no result, or an unknown outcome. Each gets an unknown tool_result if it has none, and is never re-sent. |
Since v0.3.120, a record
whose type the running build does not know is decoded as a placeholder and skipped
everywhere: replay, projection, the control stream, billing and export. This is what lets
a newer build add a record type while an older build can still read the session. broca only
adds a new record type if it is purely informational in this sense. A reader should do the
same: skip unknown types and never treat them as corruption. broca never writes the
placeholder back.
Usage is an object with five optional u64 keys:
| Key | Meaning |
|---|---|
input_tokens |
Fresh, non-cached input tokens. Disjoint from cached_input_tokens; do not subtract. |
cached_input_tokens |
Cache-read input tokens. |
cache_write_tokens |
Cache-creation tokens. |
output_tokens |
Output tokens, including reasoning. |
reasoning_tokens |
A subset of output_tokens; never add the two. |
Absent versus zero. A key is absent when the provider did not report that quantity. A
key with value 0 is a reported zero. {} means nothing was reported. When usages are
summed (a run total), a key stays absent only if no step reported it; if some steps
reported it, the sum covers those steps only, a lower bound.
Which records carry usage:
model_step_finished.usage: one step, one provider call.run_finished.usage: the run's accumulated usage.model_attempt_finishedcarries none. Nothing else in the WAL does.
Historical boundary. Builds before v0.3.103 wrote all five keys
on every usage, with an unreported quantity written as 0. The WAL record has no marker of
the build that wrote it, so a zero in a WAL record written before then may mean "not
reported". In the export lane the boundary is exact: facts with export_seq ≤ 122061
have all five keys always present; facts above it omit unreported keys.
OpenAI Responses cache writes. From the first release after v0.3.141, OpenAI Responses and ChatGPT/Codex steps carry cache_write_tokens, and their input_tokens excludes those written tokens (OpenAI counts writes inside its input_tokens). On earlier OpenAI Responses and Codex steps the key is absent because broca did not map it, not because OpenAI did not report it, and their input_tokens still includes any tokens written to cache. Chat-completions steps that report the cache_write_tokens spelling (metered OpenAI, OpenRouter) already carried the key before that release, but their input_tokens also still included the written tokens, so those tokens were counted twice; from the same release they are split out. The cache_creation_input_tokens spelling (Alibaba) is not split out.
- Open read-only and read to end-of-file. Stop at a torn tail (section 2.2); the writer may be in the middle of an append.
- Accept
ver = 2frames anywhere, including before the first record. Check that every lineage frame in the file carries the same 16 bytes. - Skip unknown record
types. - Refuse a
ver = 3frame whoserequiresnames a feature you do not know, and refuse the whole file, not just that record. Report it as a newer writer, never as corruption. The scripts underscripts/that read WAL frames (step-timing.py,find-finalize-leaks.py,live-runs.py) read aver = 3frame throughscripts/lib/wal_gate.py: they decode its record when they know every feature it requires (their table is empty today, so every gated frame is unknown to them, including broca'sscope/v1), and otherwise list the file asnewer-writer, use none of its records, and carry on with the rest of the corpus. - The file can change under you. Its writer truncates a torn tail when it reopens, the archive fold deletes cold files, and an archive restore re-creates them. If you cache byte offsets, key them on the file's identity (inode) and size and re-read from zero when either changes.
- broca refuses to read more than 1 GiB of one WAL in one pass. Production files are far smaller.
<state root>/wal-manifest is an append-only text file with one line per WAL
created by this code path:
<addr> <lineage id, UUID form> <project_root>\x1f<harness>\x1f<session>\n
(two spaces between fields). It is best effort: written without fsync, appended by every
daemon sharing the tree, never read by broca, and rebuildable (store-maintenance wal-manifest). Use it only to look up which triple an addr belonged to. Never treat it
as complete or as authoritative.
Cold WALs (by default, those whose newest run-level ts_ms is more than 14 days old) are
folded into archive containers, either by an operator or by the daemon's opt-in scheduler. Folding copies the
file's exact bytes into a container, seals and syncs it, then deletes the live file. An
append to an archived session restores the live file first.
<state root>/wal-archive/fold-<unix-ms>.ark the container
<state root>/wal-archive/fold-<unix-ms>.journal the fold pass's JSON-lines journal
<unix-ms> is written in canonical decimal (no leading zeros). Newer means a numerically
larger stamp: fold-10 is newer than fold-9. A pass that found nothing to fold leaves
only a journal. Journals are internal recovery state; readers ignore them.
All integers little-endian.
container := member* index trailer
member := header(64 bytes) payload(payload_len bytes)
header := "WALM"(4) format_version:u16 (=1) zero:u16
addr: 16 ASCII lowercase-hex bytes
payload_len:u64 payload_sha256:32 bytes
index := count:u32 entry*count
entry := addr: 16 ASCII bytes offset:u64 payload_len:u64 (32 bytes; offset of the member header)
trailer := index_len:u32 (= 4 + 32*count) "WALK"(4)
- A container is sealed only if its last four bytes are
WALK. An unsealed container belongs to an interrupted fold and must not be read. - A member's payload is the verbatim bytes of the WAL file at the time of the fold. Decode it with the frame layout in section 2.2.
- Verify
payload_sha256before using the bytes. - Each address appears at most once per container. The index is an optimization: members
are laid out back to back from offset 0, so walking the headers until the first
non-
WALMmagic finds every member when the index is damaged. - A sealed container is never modified.
- Compute
addr(section 2.1). - If
<addr>.walexists inwal/,wal-durable/orwal-ephemeral/(even with zero bytes), it is authoritative. Read it and stop. A live file always wins over any archive copy, which may be older. - Otherwise list
wal-archive/*.ark, order by numeric stamp, newest first, and for each container: skip it if it has disappeared since you listed it, skip it if it is unsealed, and otherwise lookaddrup. The first hit is the session. Two containers with the same stamp are an error. - No hit means the session does not exist on disk.
Folding and restoring can run while you read. If step 2 found no live file and step 3 found nothing, check the live path once more before concluding the session is absent.
broca writes two SQLite databases: run-index.db and caps.db. Both use
journal_mode=WAL, so recent commits sit in the -wal sidecar until SQLite checkpoints
them (see section 8).
run-index.db is written by one connection per daemon. Its schema is created and migrated
when the daemon opens it: CREATE TABLE IF NOT EXISTS, then ALTER TABLE ADD COLUMN for
any column an older file lacks. So on a long-lived file, columns appear in the order they
were added, not in the order below.
One row per run: resolves a run_id to its session and coarse state without scanning
WALs.
| Column | Type | Notes |
|---|---|---|
run_id |
TEXT PRIMARY KEY | |
session |
TEXT NOT NULL | The bind triple as JSON ({"project_root":…,"harness":…,"session":…}). Not the same encoding as meta.session. |
submission_id |
TEXT | Set when the run consumed a queued submission. |
request_identity_digest |
TEXT | Identity of a caller-supplied send id; NULL for server-minted ids and older rows. |
state |
TEXT NOT NULL | active, paused, completed, max_steps, cancelled, interrupted, error, transform_unavailable. |
usage_json |
TEXT | The run total as a Usage JSON object. Written at the terminal and only if the run completed at least one step. NULL while active/paused, for zero-step runs, and on older rows. |
final_step_finish_reason_json |
TEXT | JSON string of the last step's finish_reason, if one was observed. |
retries_used |
INTEGER NOT NULL DEFAULT 0 | |
state_changed_ms |
INTEGER | See section 5. NULL on rows written before the column existed (v0.3.24). |
title |
TEXT | The title the run's run_started records, written at admission; the first title written for a run is kept. NULL for a send without one and for runs admitted before the column existed. When this column is NULL, run.status answers from the run's run_started in the WAL. Never rebuilt: like every other column here it is maintained only by the actor. |
Writers. The session actor, not the projector:
- at admission, after the run's
run_startedis durable (INSERT … ON CONFLICT(run_id) DO UPDATE … WHERE state IN ('active','paused')): stateactive, usage NULL. A terminal row is never overwritten. - at the terminal or pause, after the WAL record is durable (
UPDATE … WHERE run_id): the terminal state orpaused, usage, finish reason, retries. - by repairs: the shutdown seal (
interrupted), the startup crash-orphan pass, andstore-maintenance reconcile-activeeach move a staleactiverow to the terminal its WAL already carries.
Each write is its own statement, awaited by the actor but best effort: a failure is logged
and the run continues. So a row can lag its WAL. A crash between the WAL terminal and the
index write leaves a finished run active until a repair pass runs. run_index is not
touched per step.
Source of truth? No. The WAL is. There is no full rebuild of this table: it is
maintained incrementally, and state_changed_ms records when broca wrote the row, which
cannot be recovered later.
To join run_index to meta, rebuild meta's key from the JSON:
json_extract(session,'$.project_root') || char(31) || json_extract(session,'$.harness') || char(31) || json_extract(session,'$.session'). A plain join on session returns zero
rows without an error.
One row per session: the materialized projection state.
| Column | Type | Notes |
|---|---|---|
session |
TEXT PRIMARY KEY | The triple joined by U+001F (the same string that is hashed into the WAL address). |
watermark_seq |
INTEGER NOT NULL | Last WAL seq folded into this projection. Advances only at turn boundaries. |
lineage_id |
TEXT | Lineage id (UUID form) of the WAL this was projected from. NULL on rows not reprojected since lineage ids existed. |
control_head_wal_seq, control_head_sub_index |
INTEGER | Control-stream cursor at the watermark. |
context_epoch |
INTEGER NOT NULL | Written as 0 and never updated. |
completed_steps |
INTEGER NOT NULL DEFAULT 0 | Model steps of the last episode through the watermark. |
usage_json |
TEXT NOT NULL DEFAULT '{}' | Usage summed over the last episode's steps through the watermark. |
retries_used |
INTEGER | Last episode, through the watermark. |
last_step_usage_json |
TEXT | The last step's Usage. |
last_step_finish_reason_json |
TEXT | The last step's finish reason. |
last_run_id |
TEXT | The last episode's run_id. |
last_episode_usage_json |
TEXT | The last episode's usage from the admission checkpoint (null when none). |
wal_anchor_file_id, wal_anchor_seq, wal_anchor_byte_end |
TEXT, INTEGER, INTEGER | Byte offset in the WAL just after watermark_seq, bound to the file's identity, so the next read can skip the prefix. |
prefix_len |
INTEGER | Number of message rows. |
admission_checkpoint_json |
TEXT | The admission checkpoint (below). |
last_title |
TEXT | The newest caller title (on a run_started, queued or steered, newest by WAL position) up to watermark_seq. A projection batch with no title keeps the stored one, so it is rebuilt by projecting the WAL like any other field. NULL when no projected send carried one. session.head overlays a title in the WAL past the watermark. |
Indexes: meta_page_usage_idx ON meta(session, last_episode_usage_json) and
meta_last_title_idx ON meta(session, last_title), each covering a column that was added after
the checkpoint blob so a read of it does not traverse the blob's overflow pages.
Admission checkpoints are not a separate table. They are the
admission_checkpoint_json column: a versioned envelope {"version":1,"checkpoint":{…}}
holding at_seq, the last episode (run id, effective config, input, disposition
running/paused/finished, usage, retries, budget state) and the pending submission
queue. A version the running build does not recognise is treated as missing, and the
checkpoint is rebuilt from the WAL.
The materialized transcript prefix.
| Column | Type | Notes |
|---|---|---|
session |
TEXT NOT NULL | Same encoding as meta.session. |
ord |
INTEGER NOT NULL | 0-based position in the reconstructed prompt. |
json |
TEXT NOT NULL | One serialized Message. |
Primary key (session, ord).
Writer for meta and message: the projector. After each committed tool turn the run
loop fsyncs the WAL and hands the durable head to a per-run projector task. The projector
runs asynchronously, concurrently with the next model step, and coalesces: if several
turns commit while it is busy, it projects once up to the newest. Each projection is one
SQLite transaction: insert the new message rows, then upsert the session's meta row,
including the whole checkpoint JSON. The run's terminal enqueues a final projection that is
drained before the lease is released. Runs with a tool-call budget skip the per-turn
projections and project only at the terminal. Imports and read paths also project: a read
that finds a different lineage_id in the WAL resets and reprojects the session.
A final model step with no tool calls is folded in only once the run_finished after it is
durable, and run_finished itself stays in the unprojected tail. So meta alone never
says whether the last episode has ended.
Source of truth? No. These tables are a rebuildable projection of the WAL. Deleting a session's rows costs only speed.
Usage facts for metering: one row per billing-homogeneous slice of a finished run.
| Column | Type | Notes |
|---|---|---|
export_seq |
INTEGER PRIMARY KEY AUTOINCREMENT | Insertion order. Increasing but not dense. |
fact_id |
TEXT NOT NULL UNIQUE | SHA-256 (hex) over the length-prefixed source instance id, triple, run id and the segment ordinal. Independent of usage and time. |
run_id |
TEXT NOT NULL | Indexed (export_facts_run_id). |
segment_json |
TEXT NOT NULL | The fact (below). |
Older files had a session column and a second fact_id index; both are dropped when the
database is opened.
segment_json fields: run_id, session (triple object), segment_ordinal,
fact_id, provider, model, auth_selection (credential id or null), account_ref,
resolved_credential_id, vault_request, work_class (these four absent when unset),
charge_basis (metered_api, subscription_included, unknown),
charge_basis_mapping_version, charge_basis_overrides ("none" or a 64-hex digest;
absent on older facts), usage_policy_version (absent unless the run used the
separate-reasoning normalizer), usage (Usage, section 2.5), usage_by_service_tier
(absent when no step reported a tier; buckets need not sum to usage), retries_used,
pricing_at_ms, pricing_at_precision, occurred_at_ms, occurred_at_precision,
terminal_reason (final slice only). New fields are only ever added.
A run gets a new slice at every run_config_refrozen and run_auth_refrozen. A slice's
usage is the sum of its model_step_finished usages.
Writers. After a run's terminal is durable and its run_index row is written, the
actor spawns a detached task that reads the session's WAL and inserts that run's facts with
INSERT OR IGNORE in one transaction. Only when a budget cap matches the session does the actor
instead wait for the export, before it admits the session's next run. At every daemon start, a catch-up pass exports any terminal
run_index run that has no facts. An existing fact_id is never overwritten: a
re-derivation that differs is logged and the stored row stays.
Source of truth? A projection of the WAL: the derivation is a pure function of the WAL
records. But the stored rows are what consumers have read and cursored past, and
re-deriving an old fact today can produce different bytes (a newer mapping version, added
fields) and a different export_seq. Treat the stored rows as the authoritative record
of spend.
export_steps, a per-step usage table added in v0.3.125, was removed in v0.3.127. Every
open runs DROP TABLE IF EXISTS export_steps. Rolling back to v0.3.125 or v0.3.126
recreates it empty. Per-step usage is in the WAL
(model_step_finished.usage).
A single row (id = 1) naming this export stream.
| Column | Type | Notes |
|---|---|---|
id |
INTEGER PRIMARY KEY CHECK (id = 1) | |
source_epoch |
INTEGER NOT NULL DEFAULT 1 | Written as 1 and never changed. |
source_instance_id |
TEXT NOT NULL | Minted once when the database is created (UUID-shaped). Part of every fact_id. |
high_water |
INTEGER NOT NULL DEFAULT 0 | MAX(export_seq) after the latest fact batch. |
Consumers read export_facts in run-index.db with SQLite mode=ro; the
usage.export RPC was removed in release 0.3.156. Incremental readers retain
(source_instance_id, source_epoch, export_seq) from this database. A new
source_instance_id means a different export database, and cursors from the old one must
be reset. sqlite_sequence also exists; SQLite maintains it because of AUTOINCREMENT.
Daemon-scoped budget caps and the spend applied against them. This is its own durability domain, not a projection of any WAL: it is the source of truth for caps.
| Column | Type |
|---|---|
cap_id |
TEXT PRIMARY KEY |
cap_version |
INTEGER NOT NULL |
scope_key |
TEXT NOT NULL |
project_root |
TEXT |
constraint_dim |
TEXT NOT NULL |
period |
TEXT NOT NULL |
period_bucket |
TEXT (added by migration) |
period_tz |
TEXT NOT NULL |
hard_cap_nanos |
INTEGER NOT NULL |
max_staleness_ms |
INTEGER NOT NULL |
replaces_cap_id |
TEXT |
spend_nanos |
INTEGER NOT NULL |
unresolved_count |
INTEGER NOT NULL |
watermark_json |
TEXT NOT NULL |
as_of_ms |
INTEGER NOT NULL |
active |
INTEGER NOT NULL |
replaced_by |
TEXT |
Written only by the cap.install and spend.delta operations, each in one transaction
with synchronous=FULL. as_of_ms comes from the caller's request, not from broca's clock.
This table is internal.
Every time value broca stores is wall-clock Unix milliseconds from the system clock of
the writing machine. None is monotonic, and none is used for ordering or identity. Order
comes from seq (in a WAL) and export_seq (in the export lane).
| Field | Set when | Updated? | Notes |
|---|---|---|---|
run_started.ts_ms |
The run is admitted and its run_started recorded. |
Never. A resume writes no new run_started. |
Absent on pre-metering records, or if the clock read failed. |
run_finished.ts_ms |
The terminal record is written. | Never. | |
run_paused.ts_ms |
The pause record is written. | Never. | |
run_config_refrozen.ts_ms, run_auth_refrozen.ts_ms |
The refreeze record is written. | Never. | |
model_attempt_finished.ts_ms |
A provider HTTP attempt ends. | Never. | Required. v0.3.122 and later. Informational: not a join key against provider logs. |
ProviderError.resets_at_ms |
Reported by the provider on an account-limit refusal. | n/a | When the account's quota refills. Not the time of any broca event. |
step_started, model_step_finished, every tool record, turn_finished, budget_fired,
queued, retracted and the approval records carry no time at all. The WAL's last
record usually has no ts_ms. The last timestamped record of a run in progress is usually
its latest model_attempt_finished (the end of the latest provider attempt) or, before any
attempt has finished, its run_started.
run_index.state_changed_ms is broca's wall clock at the moment broca writes the
run_index row:
- at admission (state
active), which is shortly afterrun_started.ts_ms; - again if the same
run_idis admitted again while its row isactiveorpaused(resuming a paused run, for example). This rewrites the value even if the state staysactive; - at the terminal or pause write, shortly after the WAL's
run_finished/run_paused; - when a repair (the shutdown seal, the crash-orphan pass,
reconcile-active) moves a staleactiverow to its terminal. The value is then the time of the repair, which can be long after the run ended.
It is not updated per step, per turn, or per tool call. It does not mean "last
activity", and it does not mean "when the run ended": for that, read run_finished.ts_ms
(or the export fact's occurred_at_ms). NULL means no time was recorded (rows written
before v0.3.24). It never means zero.
export_facts.segment_json:
occurred_at_ms: the run's terminal time, taken fromrun_finished.ts_ms(or from a trailingrun_paused.ts_mswhen the run never finished). Earlier slices of the same run are given the same terminal time. Absent when the WAL had no such timestamp (a closed, historical set of facts in the backfill bandexport_seq1..5473).occurred_at_precisionsaysterminaleven then, so testoccurred_at_msfor presence.pricing_at_ms: the time the slice's config was frozen (run_started.ts_msor the refreeze'sts_ms), falling back to the terminal time.pricing_at_precisionisexactfor the first case anddegradedfor the fallback.- Neither is the time the row was inserted. The catch-up pass can insert an old run's facts
late, so
export_seqorder is not time order.
cap_rows.as_of_ms comes from the caller (section 4.6). meta, message and
export_meta hold no timestamps.
Use the WAL file's mtime (stat of wal/<addr>.wal), for a run whose run_index.state
is active.
The session's writer task appends each record to the file as soon as the run produces it,
and fsyncs are batched separately, so the mtime moves at every step of progress: step start, each provider attempt's end, step
finish, every tool intent and result, turn finish, terminal. It costs one stat, needs no
parsing, and holds no lock.
The other candidates are weaker:
- The last record's
ts_ms. Most records carry none (section 5.1). The newest timestamp in an active run is usually amodel_attempt_finished, so it misses tool activity entirely: a run spending ten minutes in tool calls shows its last provider attempt. It also needs a full parse to the end of the file. session.read'shead. It is a position(wal_seq, sub_index)of the last durable control unit, not a time. You can detect that something happened by polling and comparing, but not when. It requires the RPC, not a disk read.run_index.state_changed_ms. Not updated during the run (section 5.2).
The mtime's limits: it does not move while a provider response is still streaming or while one tool call is running, since nothing is written until they finish. It is set to the restore time when an archived WAL is restored, and to the truncation time when the writer reopens a file with a torn tail. It measures recorded progress, not liveness. For a liveness question ("is anything happening right now"), combine it with the caller's own expectation of how long a step or a tool call may take.
| Question | Read |
|---|---|
| Spend, per run or per period | export_facts.segment_json (a stable contract). |
| Per-step usage or service tier | WAL model_step_finished. |
Run state by run_id |
run_index.state. Treat active/paused as possibly stale and confirm from the WAL if it matters. |
| When a run started / ended | WAL run_started.ts_ms / run_finished.ts_ms, or the fact's occurred_at_ms. |
| Last activity of an active run | WAL file mtime (section 5.3). |
| Which WAL is a session's | FNV-1a of the triple (section 2.1). |
| The transcript | WAL replay, or session.read. message is an internal cache. |
Stable means external readers may rely on it: changes are additive (new optional fields, new skippable record types), or come with a new version marker. Internal means it may change without notice.
| Item | Status | Why |
|---|---|---|
State-root layout: wal/, run-index.db paths |
Stable | The export lane's documented surface names the database path, and the WAL path is the session key. |
| WAL address: FNV-1a of the U+001F-joined triple, 16 hex digits | Stable | It is the filename of every existing WAL; a change would orphan every session. Pinned by a test against an independent implementation. |
WAL frame layout (len, ver, seq, fence, digest) and the ver 1/2 meanings |
Stable | Every existing WAL must stay readable. The lineage frame's bytes are pinned by a test. A new layout would need a new ver. |
| Lineage id: 16 opaque bytes, minted at creation, same id = same log | Stable | Exposed to consumers as lineage_id. |
seq semantics (1-based, gapless, per file) |
Stable | Part of the framing contract. |
fence values |
Internal | Forensic only; nothing gives the numbers a meaning. |
Record type names, and the fields listed in section 2.4 with their meanings |
Stable (additive) | Old WALs must replay forever, so an existing field cannot change meaning. New fields are optional and absent by default; new record types are added only if skippable. |
| Unknown record types are skipped | Stable | Required for rollback safety since v0.3.120. |
Opaque sub-objects: frozen_render_config, provider_options, cache_tiers, the inner shape of tools, generation, budget, context_management |
Internal | Provider- and loop-private. broca only needs them to round-trip. |
Usage keys and the absent-versus-zero rule |
Stable | Shared with the export lane contract. |
model_attempt_finished |
Stable (additive) | Informational; absent on WALs written before v0.3.122. |
resume_started |
Stable (additive) | Informational; absent on WALs written before it shipped. |
| Archive container format version 1 (section 3.2) and the live-first resolution rule | Stable | Sealed containers are immutable and must stay readable; the header carries a format version for any future layout. |
fold-*.journal contents |
Internal | Crash-recovery state of the fold pass. |
export_facts table and segment_json |
Stable | The documented metering contract. Facts are immutable; new fields are optional and change neither existing bytes nor fact_id. |
export_facts.export_seq as an increasing cursor |
Stable | Consumers cursor on it. It is not dense. |
export_meta |
Metering source identity | Direct readers use source_instance_id, source_epoch, and high_water to detect database replacement and track incremental reads. |
run_index |
Internal | An operational projection, not a declared contract. Its changes have so far been additive columns, but nothing promises that. The session column's JSON encoding, the state strings and state_changed_ms are the parts least likely to move. |
meta, message, admission_checkpoint_json |
Internal | Rebuildable caches. The checkpoint has its own version envelope, which broca may bump. |
caps.db |
Internal | Budget-cap implementation detail. |
wal-manifest, leases/, catalog/, logs/, request-dumps/ |
Internal | Diagnostics and process state. |
- Never write. Do not append to, truncate, rename or delete a WAL, archive container or journal. Do not "repair" a torn tail: the writer may be in the middle of an append. Open files read-only.
- Open SQLite read-only with
mode=ro, for examplefile:/…/run-index.db?mode=ro, orSQLITE_OPEN_READ_ONLY. Neverimmutable=1on the live database. It tells SQLite to ignore the-walsidecar, which silently hides every committed row not yet checkpointed, and the data just looks old. (immutable=1is only correct on a static copy that was checkpointed before it was copied.) Starting with v0.3.147,run-index.db-walandrun-index.db-shmpersist across broca restarts, so read-only database connections (mode=ro) can open while broca is down. - Keep read transactions short. A long-open read transaction stops SQLite from
checkpointing, and the daemon's
-walsidecar grows while it is open. - Prefer
export_factsover the raw WAL for spend. It already applies slicing at refreezes, charge basis, the credential labels and the absent-versus-zero rule. Read the WAL only for what the facts do not carry (per-step detail, attempts). - Decode usage keys as optional. Absent is not zero (section 2.5).
- Skip what you do not know. Unknown record
types, unknown JSON fields, unknown enum strings (outcome,reason,vault_request.op) are expected as broca grows. - Resolve sessions live-first (section 3.3), and tolerate files disappearing or reappearing between listing and opening.
- Treat
run_indexstates other than terminal ones as hints. Anactiverow can belong to a finished run until a repair pass runs, so confirm from the WAL when it matters.
RunStarted.scope can also carry optional agent_id and run_id. The latter
names the daemon-stamped agent run, not the broca episode's top-level run_id.
These scope members, including an agent identity alone, require the WAL reader
feature agent-run-scopes/v1; a reader without it refuses the gated frame rather
than losing ownership. Flow scopes retain their existing flow-scopes/v1 gate.
Ordinary and flow scopes with no new members retain their exact record bytes and
feature requirements, pinned before the new fields were introduced.
Replay, admission checkpoints, store projection, run.status, session.read,
archive readers, and diagnostic scripts use the WAL feature table. Reader-only
builds do not advertise the capability and refuse both fresh agent-run sends and
advancement of frozen agent-run sessions without appending WAL bytes.
The broca writer enables AGENT_RUN_SCOPES_WRITER_ENABLED and advertises the
agent-run-scopes/v1 daemon module capability together, promising run-scope admission.
Only reserved:basal, not the scope owner, may drive an
agent-run scope with both identifiers. Tool declarations and planned tool items
are refused before fetch/WAL admission. The frozen sender must remain Basal;
agent_id and run_id cannot change, and existing history cannot be adopted.
Basal still cannot read or affect sessions whose first sender is not Basal.
Usage-export labels are optional agent_id and agent_run_id, with ordinary
bytes and fact_id unchanged.