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/.