Scenarios
A scenario is a short YAML description of a conversation, in the model’s own words: a person’s
input, a call with its fragments, a tool and its result, a child agent, a context reset. asz scenario build turns it into the input a real session leaves behind, so the ordinary commands
collect and parse it. asz scenario check runs it as a test.
The .sf side is never mocked. A scenario produces evidence; the ordinary parser produces the
rounds. That is what makes a scenario both a fixture generator and a conformance test: built as
the runtime’s own files and collected through its adapter, or landed directly as Session Data, it
must fold to the same conversation.
Generate, then load, parse and export
build only generates. It writes the input and DIR/asz.yaml, whose storage root is DIR and
whose adapter source is DIR/_source, then stops; the ordinary commands do the rest, each one
inspectable on disk before the next.
asz scenario build FILE --format claude-code --out DIR # generate the runtime's files, under DIR/_source
asz collect -once -config DIR/asz.yaml # load: the real adapter lands them as .sd
asz parse -config DIR/asz.yaml # parse: the real parser writes the .sf rounds
asz verify -config DIR/asz.yaml # every digest and every chain
asz view -config DIR/asz.yaml # the page, on 127.0.0.1:8787
asz push -once -config DIR/asz.yaml # export: every file to an OTLP receiver
With --format sd the Session Data is landed by the build itself, so the collect step is skipped
and the rest is the same. To push, name the receiver in DIR/asz.yaml; build leaves the block
there, commented:
export:
otlp:
protocol: grpc
endpoint: 127.0.0.1:11800
The pushed records say where they came from. A claude-code build is landed by the Claude Code
adapter and is attributed to Claude Code; an sd build carries the mock/1 dialect and is
attributed to Mock Agent, so a receiver never lists an invented conversation as a real one.
A demo corpus is one scenario repeated: --repeat 20 builds twenty sessions end to end on the
clock, the first with the scenario’s own id and the rest counted up from it, and --at 2026-06-01T09:00:00Z --scale 60 puts them on a day rather than in a burst. build refuses a
DIR/asz.yaml it did not write, so a directory holds one configuration; what a person appends to
it, such as the export block, is kept across builds.
| Flag | Meaning |
|---|---|
--format |
claude-code or sd |
--out |
the directory; it ends as a storage root |
--at |
the base time, RFC 3339, or now (default) |
--scale |
multiplies every delta; 60 turns a scenario typed in seconds into minutes |
--interval |
overrides the scenario’s interval |
--repeat N |
N sessions end to end on the clock, each with its own id |
--through NAME |
only the steps up to the checkpoint NAME |
With a fixed --at, every file is identical on every run.
The scenario
session: mock-build-and-check # optional; default derived from the steps
title: build and check # optional
interval: 1s # the gap between steps, in every stream
steps:
- input: run the build # a person's message: opens a run and a talk
- inject: {type: skill_listing, text: "skills: 1"}
after: 100ms # this step's own delta since the previous one
- call: # one provider call, in fragments
thinking: unavailable # a reasoning part with no text; any other value is the text
text: Building now.
tool: {id: t1, name: Bash, input: {command: make build}, result: {text: build succeeded, after: 800ms}}
usage: {in: 2, out: 50, cache_read: 900, cache_write: 100}
checkpoint: built # a point a test can stop at
- call:
agent: # a child agent: the call, its acknowledgement, the child's own stream
name: checker
prompt: check the tests
after: 1s # the child's first record, relative to the call
steps: [{call: {text: Tests pass.}}]
notify: true # the runtime reports the child finished: a second run
- call: {text: Build passed and tests are green.}
- error: "API Error: Connection lost mid-response." # an assistant-role message the client made
- reset: {summary: "Summary: the build was run and checked."}
Every step is exactly one of these, plus an optional after, checkpoint and lost:
| Step | What it writes |
|---|---|
input |
a person’s message; trigger: external; opens a run and a talk |
queued: {text, mode} |
input that exists only as a queued attachment; mode is prompt or task-notification |
inject: {type, text} |
material the harness put into context, of any type |
call |
fragments in this order: thinking, text, then one of tool, agent, skill, workflow; usage on every fragment; the last carries the stop reason |
result: {of, text, after, failed, string} |
a tool result arriving on its own, for a tool whose call gave none |
error |
a synthetic message |
reset: {summary} |
a context reset and its summary, the summary timestamped before the boundary as the runtime writes it |
replay: N |
the last N main-stream records re-emitted with their run rewritten |
system: {subtype, fields} |
a system record of any subtype |
A tool without a result is an unfinished tool. A skill: {name, agent, steps} is a fork whose
child is announced only in the parent’s result. A workflow: {name, children: [{name, prompt, steps}]} starts children as one batch, with a journal, a manifest and a script. Ids are stable and
the same in every format: the step’s position names its records, a tool is tool/<id>, a run is
<step>-cycle, a child stream’s id is derived from its name.
Records the original lost
lost: true on a step says the original file does not hold what the step wrote: a person trimmed
the transcript, or the write never reached the disk. The step still happened. The clock and the
ids move as if the records were there, and the record after them still names them as its parent,
so the loss shows where it shows in a real corpus: in references the assembler cannot resolve. On
a result inside a tool, only the result is lost and the call stays. On an agent, a skill or
a workflow child, the child’s file never reached the collector, nor its meta file, while the
parent’s records about the child stay.
steps:
- call: {text: Reading it., tool: {name: Read, result: {text: "root: ./data", lost: true}}}
- call: {text: Checking the port., tool: {name: Bash}}
lost: true # the call is gone; the result below is not
- result: {of: s2-tool, text: "8787 LISTEN"}
- call: {text: Asking the helper., agent: {name: helper, lost: true, steps: [{call: {text: Done.}}]}}
Both formats leave the same records out. The collector numbers the lines it finds, so the landed
data verifies and the chain is intact, and the document stays verified: nothing asz holds is
damaged, and the incompleteness is in the evidence. What a check sees is the unresolved list, a
tool_result for the first step, a tool_use for the second, a child_stream for the third.
tests/scenarios/lost-records.yaml covers every kind of loss.
Check
asz scenario check FILE [--format claude-code|sd|all] [--out DIR] [--at TIME] [--scale FACTOR]
A checkpoint may also say what a person deleted from the storage root. lose names landed files
by what they hold, a stream or a run and a kind, since the two formats land the same files in a
different order, and the runner deletes them after the checkpoint’s parse, once a round has bound
to them. Every check from there on runs over the damaged root: the structure survives, because it
lives in the rounds, the text of the lost file is gone, the document says incomplete and names
the round and the sequence, asz verify reports the same, and the session goes on into later
rounds. Three properties cannot hold on such a root and are set off with a reason: a re-cut root
holds only what is on disk, a source line of the lost file has no landed record, and re-deriving
from the landed files changes the fold. tests/scenarios/lost-file.yaml is the example, and its
loss travels over the wire like anything else: the root rebuilt from the push reports exactly what
the pushed root reports.
For each format, and at each checkpoint in order, check builds through the checkpoint, collects
when the format needs it, parses, and compares the fold with the expectation file beside the
scenario, NAME.expect.yaml. At the end it runs the properties every chain must have, and with
all, the default, it compares the formats’ folds with each other. It exits non-zero on any
failure and keeps its directory when one is given.
checkpoints:
built: # named in the scenario
rounds: 1
kinds: {tool: 1, llm.call: 1}
nodes:
tool/t1: {refs: 2, attrs: {result: available, timing: unavailable}}
final: # the end of the scenario
rounds: 2
delta: true # the round written here is a delta
talks_on: {main: 2, checker: 1} # a stream may be named by its scenario name
runs_in: {talk/main/s1-cycle: 2}
relations: {starts: 1, reports: 1}
unresolved: {open: 0, resolved: 0}
unresolved_kinds: {tool_result: none}
session: {from: +0s, to: +11.1s} # the session node's range, as deltas from --at
view: {state: verified, problems: 0, talks: 3, files: 6, first_talk: {label: run the build, runs: 2}}
verify: {problems: 0} # what asz verify reports over the root
helped:
lose: [{stream: checker, kind: transcript}] # deleted from the root after this checkpoint's parse
view: {state: incomplete, problems: 1}
verify: {problems: 1}
properties: # all on unless set false
reproducible: true
fold_equals_parse: true
immutable_rounds: true
bundle: true
header_matches_fold: true
records_well_formed: true
repack_keeps_structure: true
recollect_idempotent: true # runtime formats only
every_line_a_record: true # runtime formats only
discovery_ignores_noise: true # runtime formats only
cross_format: true
records_match: true
push_follows_the_wire: true
view_covers_the_session: true
parse:
max_round_bytes: 0 # a parse setting, when the scenario needs one
push:
kinds: [transcript, agent_meta, journal, workflow_manifest, workflow_script, round] # kinds the push must carry
Only what is written is checked. The properties are: two parses of the same landed files write the same rounds; folding every round equals one full parse; rounds verify, link and are not writable; the landed files and rounds are self-sufficient without index and state; the head round’s header says what the fold holds; a parse with no new evidence writes nothing; every landed record carries only the fields the format states a purpose for; a repack under the smallest budget keeps every record and the whole structure; and, for a runtime format, a second collect lands nothing, every source line becomes one landed record, and discovery passes over the noise the writer plants beside the session. Across formats, the folds must agree, and so must the landed records themselves, field by field: the runtime’s adapter and the sd writer must land the same evidence from the same scenario, which is what makes a scenario a conformance test for an adapter.
The document is checked too: at the end of every scenario, view_covers_the_session holds the
asz.view document to the whole session: every round, verified; every landed file with its digest
as on disk; every talk, run and step of the fold in a tree; the session’s own range; and a verified
state. A scenario with checkpoints is the multi-round case: three-rounds lands and parses in
three stages, from the start to the first checkpoint, from there to the second, and from there to
the end, so three rounds sit over landed files cut at each stage, and the final document must
cover the session as one parse would.
The push is checked too. Every scenario, in both formats, is pushed to a receiver in the test,
over gRPC and then over HTTP, one file per request, and every request is compared with the tables of
Export over OpenTelemetry: the resource and the scope, one record per
file with the file’s bytes and digest, the attributes a landed file carries and the ones only a
round carries, the record time range and the list attributes, and the stamp a receiver bounds a
read on. A refused request must leave every file for the next pass, a second pass must send
nothing, and writing every body back to its path must give a root that verifies and folds the
same. push.kinds names the file kinds a scenario’s push must carry; all-kinds.yaml names all
six.
The project’s own tests are scenarios under tests/scenarios/, one property of assembly each,
run in both formats by go test ./tests/.