Skip to content

Multi-step checks (sequences)

A check is a fan-out: one request, sent as every identity it names. Some authorization bugs cannot be written that way, because proving them needs state that only exists between requests:

  • an id the target returns when an object is created, which a later request has to use;
  • an object provisioned as the victim during the run, rather than one seeded before it;
  • a write whose effect only a later read can show. A cross-tenant DELETE that answers 403 and removes the row anyway looks correct to any check that reads only the attacker's response.

A sequence is the ordered form. It is a list of requests, each sent as exactly one named identity, each declaring a status expectation, each optionally naming values to carry forward to the requests after it. Sequences are declared in the same configuration file as checks and run as part of veridor run.

Sequences write to the target

A sequence that creates and deletes objects is for systems you own. Point it only at an environment you can recreate: a per-run container, a seeded test tenant, a preview deploy. Never at production, and never at a shared staging system holding data anyone relies on. See write safety below.

A sequence you can run

No file in fixtures/ declares a sequence. The demo server, scripts/demo_server.py, carries a stateful notes API for exactly this case, at /api/v2/notes, which nothing up front provisions:

  • POST /api/v2/notes creates a note for the caller's tenant and answers 201 with {"id": …, "tenant": …} and a Location header;
  • GET /api/v2/notes/<id> returns the note, including its provenance field, to its own tenant and 404 to another tenant, in every mode;
  • DELETE /api/v2/notes/<id> answers 204 to its own tenant and 403 to another tenant. With --vulnerable, that 403 is sent and the note is deleted anyway. With --vulnerable --noisy-write, the other tenant gets 200 and nothing is deleted.

Save this as veridor.notes.json. The tokens and markers are the demo server's own fixed values.

{
  "version": 2,
  "target": {"base_url": "http://127.0.0.1:8765", "timeout_seconds": 3},
  "identities": {
    "owner": {
      "headers": {"Authorization": "Bearer owner-token"},
      "marker": "vdr-Xk7Qm2Ls9Rt4Vb8Nc3Zp6Wf1"
    },
    "outsider": {
      "headers": {"Authorization": "Bearer outsider-token"},
      "marker": "vdr-Bq3Wm8Ke5Tz1Yx7Rn2Hd4Vs6",
      "marker_probe": {"method": "GET", "path": "/api/invoices/inv-200"}
    }
  },
  "sequences": [
    {
      "id": "note-cross-tenant-delete",
      "name": "Note cross-tenant delete",
      "steps": [
        {"role": "setup", "as": "owner", "name": "owner creates a note",
         "request": {"method": "POST", "path": "/api/v2/notes",
                     "json": {"title": "fixture", "provenance": "${marker:owner}"}},
         "expect": {"status": [201]},
         "capture": {"note_id": "$.id"}},

        {"role": "setup", "as": "owner", "name": "owner reads it back",
         "request": {"method": "GET", "path": "/api/v2/notes/${note_id}"},
         "expect": {"status": [200], "contains_marker": "owner"}},

        {"role": "assert", "as": "outsider", "name": "outsider reads it",
         "request": {"method": "GET", "path": "/api/v2/notes/${note_id}"},
         "expect": {"status": [404], "excludes_marker": "owner"}},

        {"role": "assert", "as": "outsider", "name": "outsider deletes it",
         "request": {"method": "DELETE", "path": "/api/v2/notes/${note_id}"},
         "expect": {"status": [403, 404]}},

        {"role": "assert", "as": "owner", "name": "owner still has it",
         "request": {"method": "GET", "path": "/api/v2/notes/${note_id}"},
         "expect": {"status": [200], "contains_marker": "owner"}}
      ],
      "teardown": [
        {"as": "owner", "name": "owner removes the note",
         "request": {"method": "DELETE", "path": "/api/v2/notes/${note_id}"},
         "expect": {"status": [204, 404]}}
      ]
    }
  ]
}

Read it top to bottom. The owner creates a note with its own marker planted in the body, and Veridor keeps the returned id as note_id. The owner reads the note back, which proves the marker is retrievable at that path. The outsider then tries to read it and to delete it. Last, the owner reads it again: the marker must still be there. Teardown deletes the note whatever happened before.

The outsider identity has a marker_probe because Veridor sends no fabricated credential inside a sequence. Without some proof that the outsider's token works, a 403 from the outsider is equally explained by a token that expired. See liveness below.

The boundary holds

With the demo server running in its normal mode (python3 scripts/demo_server.py):

veridor run --config veridor.notes.json
veridor: write mode -- up to 3 mutating request(s) (DELETE, POST) will be issued against 127.0.0.1. Do not run this against production. Pass --read-only to refuse instead, before anything is sent.
veridor: liveness controls -- 2 sequence assert step(s) accept only denial statuses (GET /api/v2/notes/${note_id}, DELETE /api/v2/notes/${note_id}), and Veridor sends no credential it fabricated inside a sequence. …
Veridor run 20261110T043618Z-dacdb8: 0/0 probes matched their declaration, 0 vulnerable, 0 unexpected denial, 0 inconclusive
Target: http://127.0.0.1:8765
Regression diff: 0 new, 0 resolved, 0 unchanged
Evidence: 0 confirmed, 0 suspected, 0 ungraded, 0 escalated since the baseline
Sequences: 1 executed, 5/5 steps matched their declaration, 0 aborted, 0 teardown failure(s)
  - note-cross-tenant-delete: completed (teardown: clean)
…

Exit code 0. The first line reads 0/0 probes because sequence steps are not check probes; the Sequences: line is where they are counted.

The write crosses the boundary and the response hides it

Restart the demo server with --vulnerable and run the same command. The outsider's DELETE still answers 403, the status the configuration allows, so step 3 matches. The note is gone regardless, and step 4 shows it:

Veridor run 20261110T043550Z-8239ce: 0/0 probes matched their declaration, 1 vulnerable, 0 unexpected denial, 0 inconclusive
Target: http://127.0.0.1:8765
Regression diff: 1 new, 0 resolved, 0 unchanged
Evidence: 1 confirmed, 0 suspected, 0 ungraded, 0 escalated since the baseline
Sequences: 1 executed, 4/5 steps matched their declaration, 0 aborted, 0 teardown failure(s)
  - note-cross-tenant-delete: completed (teardown: clean)
…
- [vulnerable (confirmed)] note-cross-tenant-delete / owner: Identity 'owner' received HTTP 404 (declared: one of [200]). 'owner''s own marker was not observed, and step 3 issued DELETE as 'outsider' against the same path this step reads. 'owner''s marker was observed at step 1 of this sequence and is absent now.
    Confirm your declaration that this identity's data must persist. If it holds, the write issued by the earlier step in this sequence took effect across the boundary -- enforce authorization on that write path. If the earlier identity is entitled to modify this object, correct the configuration instead.

Exit code 1. The finding is VERIDOR-EFFECT-001, graded confirmed because step 1 of the same sequence observed the owner's marker at that path. Teardown is clean because it accepts 404, which is what deleting an already-deleted note returns.

The response claims a write that did not happen

With --vulnerable --noisy-write, the outsider's DELETE answers 200 and deletes nothing. Step 3 fails its declaration, and step 4, the owner's read of the same path, shows the note survived:

- [vulnerable] note-cross-tenant-delete / outsider: Identity 'outsider' received HTTP 200 (declared: one of [403, 404]). A later step in this sequence observed 'owner''s data still present after this request, so this response did not take effect on the object under test.

Exit code 1. The verdict is not softened: a target that answers 200 to an unauthorized DELETE still fails the declaration. The finding's evidence carries "write_effect": {"verified_by_step": 4, "state": "no_effect_observed"} so triage can see what the later read established.

The schema, key by key

sequences is a top-level list, a sibling of checks. It requires "version": 2; a configuration may declare checks, sequences, or both.

veridor: sequences requires configuration version 2

A configuration with no sequences key runs exactly as it did before: the same run document, the same fingerprints, the same exit code.

Sequence

Key Required What it does
id yes Non-empty, held to the same character set as a check id, and unique across checks and sequences
name no Used in finding titles; defaults to id
steps yes A non-empty list of steps, run in order
teardown no A list of teardown entries, run after the steps on every code path

Step

Key Required What it does
role yes setup or assert. No default
as yes The one identity this request is sent as. Must name a declared identity
request yes method, path, headers, json, body, as for a check's request
expect yes status (an integer or a non-empty list of integers from 100 to 599), and optionally one of contains_marker or excludes_marker
capture no An object mapping capture names to extractors
name no A label recorded with the step

role has no default because both defaults are wrong. Defaulting to setup would turn a forgotten role on the attacker's request into scaffolding: a green run with the boundary never tested. Defaulting to assert would report a 500 on a provisioning POST as vulnerable.

veridor: sequence 'note-cross-tenant-delete' step 3 requires role 'setup' or 'assert'

contains_marker and excludes_marker follow the check rules: not both on one step, the named identity must exist and declare a marker, and a step cannot exclude the marker of the identity it runs as. A step does not need a same-check control for excludes_marker; how that is licensed at run time is under markers inside a sequence.

Teardown entry

Key Required What it does
as yes The identity the cleanup request is sent as
request yes As for a step
expect no status only. Without it, any status the entry draws counts as matched
name no A label recorded with the entry

A teardown entry cannot declare role, capture, contains_marker or excludes_marker, and cannot plant a marker. It may use any value an earlier step captures.

An unrecognised key at the sequence, step or teardown-entry level loads as an annotation and is ignored, as elsewhere in the file. A key that is a near miss of a real one, or whose name contains marker (my_marker_note), is refused with exit 2. Here a step declares captur beside its capture:

veridor: sequence 'note-cross-tenant-delete' step 0 has unsupported keys: captur (did you mean one of as, capture, expect, name, request, role?) Rename it, or start it with '_' if it is an annotation.

A key starting with _ is never judged.

Renaming a step's only capture to captur stops at a different error first. With no step capturing note_id, ${note_id} in the later steps is read as an environment variable:

veridor: environment variable 'note_id' is not set

Caps

Each is a refusal at load time, with no override flag:

Cap Limit
sequences per configuration 64
steps per sequence 12
teardown entries per sequence 6
captures per step 8
${…} interpolation sites per step or teardown entry 16
captured value length 512 characters

Capturing a value

A step's capture reads values out of that step's response and keeps them, under the names you give, for the steps and teardown entries after it.

"capture": {"note_id": "$.id", "note_url": "header:Location"}

There are two extractor forms and no others.

Form Reads Example
$ followed by .key and [index] segments A value from a JSON response body $.id, $.data.items[0].id
header:Name A response header, matched case-insensitively header:Location

A JSON extractor has no wildcards, filters or recursive descent. Keys are letters, digits, _ and -, starting with a letter or _; indexes are non-negative integers without leading zeros. An extractor outside this grammar is refused at load time:

veridor: sequence 'note-cross-tenant-delete' step 0 capture 'note_id' is not a valid extractor: '$..id'

There is deliberately no regex source and no transform. A regex over a response body is an unreviewable mini-language inside a configuration, it lets a body the target controls choose which bytes enter a later authenticated request, and a backtracking regex over target-controlled input can stall the runner.

What a captured value can be

A capture resolves only when all of these hold. Anything else is a capture miss, and the reason is recorded:

Reason Means
not_json A JSON extractor, and the response Content-Type was not application/json or …+json
body_too_large The body was truncated, so it is not a whole JSON document
malformed_json The body did not parse
path_not_found A key or index on the path was missing
unsupported_type The value was a float, a boolean, null, an object or a list. Only strings and integers are captured
header_absent The header was not in the response
ambiguous_header The header arrived more than once with different values. Veridor does not guess which one the target meant
unsafe_value Empty, longer than 512 characters, non-ASCII, or containing a space or a control character. The value goes onto the wire in a path or a header, where a \r is a second request
step_not_executed An earlier capture on the same step missed, or the step was skipped

A miss stops the sequence. The steps after it are skipped, never sent with missing values.

Capture names

Capture names are identifiers: [A-Za-z_][A-Za-z0-9_]*. Within one sequence they are single-assignment; two steps capturing the same name is a load error. run_id is reserved. A step can only use a value an earlier step captures:

veridor: sequence 'note-cross-tenant-delete' step 1 references '${note_id}', which no earlier step captures; it is captured by step 4, which runs later

A capture name shares its ${NAME} spelling with environment substitution, so a capture name that is also set as an environment variable is refused rather than silently resolved from the environment:

veridor: capture name 'note_id' collides with an environment variable of the same name; rename the capture

Step captures are a different mechanism from session captures, which read login responses and are written ${capture.<name>}.

Where ${…} may appear

Token Resolves to Allowed in
${name} A value an earlier step captured the path, header values, and string values in json or body
${run_id} This run's id the path and the body; not a header
${marker:identity} That identity's declared marker a request body only, on a setup step, for the step's own identity

A token is never allowed in a header name or in a JSON object key. A ${ that does not form one of these tokens is a load error rather than literal text on the wire.

Substitution is textual, with one typing rule: a JSON string that is exactly one capture token keeps the type the value was captured with. So {"note_id": "${note_id}"} sends an integer when the target returned an integer.

A path may begin with a capture, for example "path": "${note_url}" with a capture of header:Location. The path rules for checks (request) still apply, and the resolved path is checked again after substitution. A step whose resolved path leaves the base URL's origin or path, carries a fragment, or contains a . or .. segment is not sent, and the sequence stops with path_invalid.

Markers inside a sequence

${marker:owner} is the only way to plant a marker from a sequence. A marker literal pasted anywhere into a sequence's requests is a load error, as it is in a check: a marker Veridor sent itself and then found cannot prove a leak.

A step that plants a marker:

  • must be a setup step and must plant the marker of the identity it runs as;
  • cannot declare contains_marker or excludes_marker. Its response is never scanned, because a target that echoes the created object back in its 201 would otherwise "find" the marker it was just sent.

The same rule holds for captured values at run time. If a captured value carries a configured marker, for example because the target echoed the planted body back into a field that was captured, a later step that would send that capture is not issued, and the sequence reports inconclusive with marker_reflection_blocked.

An excludes_marker on an assert step needs something that shows the marker is live. Veridor takes the first that applies:

  1. An earlier step in this sequence, sent as that identity, observed its own marker with contains_marker, in a response not served from a cache (recorded as sequence_prior_step). In the example, step 1 licenses step 2.
  2. The identity's marker_probe (recorded as identity_probe), unless this sequence plants that identity's marker. A probe read an object that existed before the run, not the one the sequence just created.
  3. Nothing (recorded as none). A matched status on such a step, or on one whose marker_probe did not verify, is reported as VERIDOR-ORACLE-001, inconclusive, rather than as a pass.

How a sequence is graded

Setup steps are preconditions

A setup step establishes state. It never produces a vulnerability. When a setup step does not match its declaration — a status outside status, a declared contains_marker not observed, or a capture miss — the sequence stops. The remaining steps are skipped, and one VERIDOR-SEQ-001 finding is reported, inconclusive. A sequence that cannot establish its own preconditions is never reported as safe and never as vulnerable.

- [inconclusive] note-cross-tenant-delete / owner: Sequence 'note-cross-tenant-delete' could not be completed. Step 0 (setup, as 'owner', POST /api/v2/notes) did not yield capture 'note_id' (path_not_found), so the state the later steps depend on was never established. Steps 1 (setup), 2 (assert), 3 (assert), 4 (assert) were not executed. No conclusion about authorization was reached: this is neither a pass nor a failure of the declared expectations.

That run, with the capture changed to $.note.id, exits 3: not one assert step was answered, and a run that measured nothing does not exit 0.

Assert steps are the measurements

An assert step is graded like one identity's probe in a check, using the same verdicts and evidence grades:

Rule Verdict When
VERIDOR-AUTHZ-001 vulnerable or unexpected_denial The status was outside status. confirmed when the response also carried the excludes_marker identity's marker; otherwise suspected or ungraded
VERIDOR-LEAK-001 vulnerable, confirmed The status matched, and the response carried the excludes_marker identity's marker
VERIDOR-EFFECT-001 vulnerable The step reads its own identity's data with contains_marker, the marker is absent, and an earlier step that reached the target sent a method other than GET, HEAD or OPTIONS, as a different identity, to the same resolved path. confirmed when an earlier step of this sequence observed that identity's marker; suspected when none did, because absent now may mean never there
VERIDOR-ORACLE-001 inconclusive The status matched but a declared marker could not be verified: contains_marker not observed, or excludes_marker with no live control or an unreadable response
VERIDOR-EXEC-001 inconclusive The request failed or was not sent, or drew a 429, a 5xx or a 407 outside its declared statuses with no marker observed. None of those is an answer from the authorization layer
VERIDOR-SESSION-001 inconclusive The step's identity uses a session and drew 401 or 407, which is also what an ended session looks like
VERIDOR-CACHE-001 inconclusive The response announced it came from a cache and the step declares a marker expectation

VERIDOR-EFFECT-001 is the reason sequences exist. The earlier write's status is deliberately not part of the rule: a 403 that deletes the row anyway is exactly the defect it catches.

Unlike a refusal elsewhere, the owner's 404 in an effect finding is graded vulnerable, not unexpected_denial: here the refusal is the damage.

When an assert step sends a method other than GET, HEAD or OPTIONS and draws a VERIDOR-AUTHZ-001, Veridor looks for a later assert step on the same resolved path, sent as a different identity, that declares contains_marker for its own identity. If one exists, the finding's evidence gains a write_effect object with verified_by_step and a state:

state Means
effect_observed The later read produced a VERIDOR-EFFECT-001
no_effect_observed The later read found the marker still present. The message says the write did not take effect; the verdict is unchanged
not_verified The later read was a cached response, or did not find the marker present

An assert step that is answered with 429, 5xx or 407, a step whose request fails, and a capture miss on an assert step also stop the sequence; later steps are skipped.

Why a sequence stopped

The run document records abort_reason from a closed set:

abort_reason Means
setup_failed A setup step's status or contains_marker did not match
capture_missed A capture did not resolve
execution_error A request failed, or a session was not established, so the step was not sent
path_invalid A path became invalid only after substitution, so the step was not sent
marker_reflection_blocked A captured value carried a configured marker
marker_check_incomplete A captured value could not be checked for a marker
step_inconclusive An assert step was answered with 429, 5xx or 407
session_not_live A setup step for a session identity drew 401 or 407
response_from_cache A setup step with contains_marker was served from a cache

Teardown

Every teardown entry is attempted on every code path Veridor can see: a normal finish, a setup step that failed, a step that errored, an unexpected exception, Ctrl-C (exit 130) and SIGTERM (exit 143). Teardown runs per sequence, right after that sequence's steps, so an interrupt only owes cleanup to the sequence in flight. SIGKILL cannot be caught, so a SIGKILL or a runner eviction can leave fixtures behind.

Four rules hold:

  • Teardown never changes a verdict. Its results create no findings, enter no probe count and contribute to no fingerprint. Grading uses records that were complete before cleanup started.
  • Teardown never masks a finding. A finding always wins the exit code.
  • Every entry is attempted. One failure does not stop the entries after it.
  • Teardown is loud. Every failure and skip prints to stderr. Under GitHub Actions (GITHUB_ACTIONS set) it is also emitted as a ::warning annotation.

An entry that references a capture that never resolved is skipped, never sent with the literal ${…} text. An entry whose identity's session was not established is skipped too; teardown does not log in again. Teardown has its own per-request timeout, target.teardown_timeout_seconds, which defaults to timeout_seconds (see target).

The outcome is recorded per sequence as teardown_state:

teardown_state Means
clean Every entry was sent and drew a status its expect allows
failed An entry errored or drew a status outside its expect
incomplete Nothing failed, but at least one entry was skipped. A skipped entry still counts toward teardown failure(s) on the Sequences: line and in summary.teardown_failures
not_required The sequence declares no teardown
aborted The run was interrupted

A teardown that declares "status": [200] against the demo, which answers 204:

veridor: teardown entry 0 of sequence 'note-cross-tenant-delete' (DELETE http://127.0.0.1:8765/api/v2/notes/${note_id}, as 'owner') did not complete cleanly: executed -- HTTP 204. The target may hold a fixture this run created.
…
Sequences: 1 executed, 5/5 steps matched their declaration, 0 aborted, 1 teardown failure(s)
  - note-cross-tenant-delete: completed (teardown: failed)

That run exits 0: an unclean cleanup is reported everywhere but does not fail the security gate on its own. Add --require-clean-teardown to exit 3 instead:

veridor: teardown did not complete cleanly for note-cross-tenant-delete; the target may hold fixtures this run created

Declare expect statuses that cover an object already gone. In the example, [204, 404] keeps teardown clean when the vulnerable target deleted the note before teardown did.

A capture miss can orphan what the setup step created

A step's captures are read after its request was answered, so a setup POST that created an object and then missed its capture has already written. Every teardown entry that needs that capture is then skipped (teardown: incomplete). In the capture-miss run above, the demo server had created the note before the miss, and no request removed it. Recreate the environment, or clean it up by other means.

Write safety

A run that will send a POST, PUT, PATCH or DELETE prints one banner on stderr before its first request:

veridor: write mode -- up to 3 mutating request(s) (DELETE, POST) will be issued against 127.0.0.1. Do not run this against production. Pass --read-only to refuse instead, before anything is sent.

The run document then records a mutation block:

"mutation": {"verbs": ["DELETE", "POST"], "requests_issued": 3}

verbs lists the mutating methods the configuration declares, not the ones that were sent. requests_issued counts the mutating step, teardown, check and marker-probe requests that were attempted rather than skipped; a request that errored, including a refused connection, still counts. The block is present whenever the configuration declares a mutating request, even if nothing reached the target, and absent only for a configuration that declares none. In the capture-miss run above, only the POST was sent, and the block reads {"verbs": ["DELETE", "POST"], "requests_issued": 1}.

--read-only refuses the whole configuration, before any request, if any declaration uses a method outside GET, HEAD and OPTIONS:

veridor run --config veridor.notes.json --read-only
veridor: --read-only refuses POST in sequence 'note-cross-tenant-delete' step 0, and 2 other mutating declaration(s). All 3 are listed below (verbs: DELETE, POST); nothing has been sent, and together they declare 3 request(s) -- more under `mutate`, which re-issues each one per probe:
  POST in sequence 'note-cross-tenant-delete' step 0
  DELETE in sequence 'note-cross-tenant-delete' step 3
  DELETE in sequence 'note-cross-tenant-delete' teardown entry 0

Exit code 2. If losing an object in the target would matter to anyone, use --read-only.

Two limits, stated plainly:

  • If the attacker's own request writes and succeeds — that being the vulnerability — the resulting change belongs to the attack, not to the fixture. Veridor does not try to undo it.
  • Sequences run strictly one after another, and so do their steps. There is no interleaved fixture state.

More on running against a target you can write to is in safety posture.

Liveness: a denial inside a sequence

For a GET, HEAD or OPTIONS check whose expectation accepts only denial statuses, run sends a fabricated credential to tell a refusal apart from a dead token (the liveness controls). It never does so inside a sequence, and says so before the first request.

So an assert step that accepts only denial statuses, sent as an identity with a credential header, counts as unproven when some identity was served in the same sequence and nothing else in the run proves this identity's credential live. Remove the marker_probe from the example's outsider and run it again:

Unproven denials: 2 expectation(s) held against a denial that a credential Veridor fabricated received too, or where no fabricated credential was sent to compare with, so they did not establish who was denied.
  - sequence note-cross-tenant-delete step 2 / outsider: 'outsider' received HTTP 404 on step 2 (GET) of sequence 'note-cross-tenant-delete'. Veridor does not send a credential it fabricated inside a sequence, so nothing here shows the target recognised 'outsider''s credential. The step accepts only denial statuses, so it is satisfied by a boundary that refuses this identity AND by a credential that expired or was mistyped. This step does not distinguish them.
    Give 'outsider' a marker_probe naming something it SHOULD be served, or declare a check or a step where it is served and an identity with no credential is refused on the same request (a GET check also gets a fabricated credential that must be refused). Either proves the credential live and this disclosure disappears.
…

Steps 2 and 3 are both listed: the outsider's read and its delete.

The run reports this and exits 0. With --require-complete the same run exits 3 (reason liveness_unproven). The fix is a marker_probe on that identity naming something it should be served, as in the example, or a check where it is served and an identity with no credential is refused on the same request.

What is reported

On the console

A run that executes sequences prints a Sequences: line with the number of sequences executed, steps matched out of steps recorded, sequences aborted and teardown failures, then one line per sequence with its outcome (completed or aborted), its teardown_state, and, when it stopped, the step and abort_reason. Findings from steps are listed with the other findings, as <sequence id> / <identity>.

In the run document

The run document gains a sequences list, one record per sequence:

Field Holds
sequence_id, name From the configuration
outcome completed or aborted
aborted_at_step, abort_reason null unless the sequence stopped
steps Per step: index, as, role, name, request (method and URL), response, error, state (matched, mismatched, inconclusive, errored or skipped), captures (name, extractor, whether it resolved and the miss reason), marker_planted, marker_scanned
teardown Per entry: index, as, request, status, matched, state, error
teardown_state As above

summary gains sequences, sequence_steps, sequence_steps_matched, sequences_aborted and teardown_failures. Each step finding carries step_index and an evidence.sequence object with the sequence id, step index, role, the sequence's outcome and the indexes of skipped steps.

Captured values are never written down. The URL recorded for a step or a teardown entry is the configuration template with its ${…} tokens intact (http://127.0.0.1:8765/api/v2/notes/${note_id}), not the resolved URL. The captured values live in memory for one sequence and are dropped when it ends. Error text that might contain one has each captured value of four characters or more replaced with ${name}.

Fingerprints come from the template too. A step finding's id is computed from the sequence id, the identity, the method, the path as written, the body as written, the declared statuses and the step index. The ids are stable across runs even though the captured ids differ every run, so a baseline keeps working.

Exit codes for a run with sequences

Code When
0 No finding the gate fails on. An unclean teardown or an unproven denial alone stays 0 unless its flag is set. With --fail-on none, a run where no assert step was answered, or where a session identity failed to log in, also exits 0
1 A step produced a finding the gate fails on
2 The configuration was refused at load time, or --read-only refused it. Nothing was sent
3 No finding, but not one assert step was answered; or a session identity failed to log in for a reason that will repeat (a refused credential, not a 429 or 5xx), with no flag needed; or a teardown was unclean with --require-clean-teardown; or, with --require-complete, a denial was unproven or a probe or step came back inconclusive
130, 143 Interrupted by SIGINT or SIGTERM. Teardown for the sequence in flight runs first. A run stopped before its probes finish writes no run document

The full table is in exit codes.

What a sequence is not

There are no conditionals, no loops, no arithmetic, no expressions, no transforms and no retries, and none are planned. The only state a sequence carries is a flat, single-assignment map of names to strings and integers, which never touches disk. A configuration nobody can review is not a security tool.

veridor mutate mutates checks only. The sequences of a version-2 configuration are counted as unmutated_sequences and named in its report header, so its score never silently covers them.