# assurance

> Build a security assurance case: claims backed only by checks that really ran, recorded in a hash-chained ledger, then verified at a commit. Shield tooling, exit codes and failure modes.

Canonical: https://bmad-plus.rochetta.fr/docs/reference/cli-assurance/
Language: en
BMAD+ version: 0.23.1
Documentation reviewed: 2026-10-19
Author: Laurent Rochetta
License: MIT

BMAD+ documentation › [Reference](https://bmad-plus.rochetta.fr/docs/reference/) › assurance

`assurance` is the command behind the Shield pack’s security assurance case. A case lists claims about your system (for example “no production dependency has a known high-severity advisory”), gives an argument for each, and supports it with evidence. The only evidence BMAD+ accepts is a check it ran itself: a command, its exit code, a digest of its output and the digests of the files it wrote, appended to a hash-chained ledger. A statement, a document or a link is refused. You use it when someone, an auditor, a customer or your future self, asks you to show that controls hold at a given commit, and “we believe so” is not enough.

> **Warning · What a verdict is not** A supported case shows that the named checks passed at a commit. It does not certify the system and it is not an audit opinion; a qualified auditor decides what the evidence is worth. Also, `assurance run` executes the commands written in the case with the same trust as your own scripts: read a case from someone else before running it.

<a id="synopsis"></a>
## Synopsis

**In your project terminal**

```sh
npx bmad-plus assurance init <case-file>
npx bmad-plus assurance run <case-file> [--check <id>]... [--dir <path>] [--json]
npx bmad-plus assurance verify <case-file> [--ledger-head <sha256>] [--emit-check <file.json>] [--dir <path>] [--json]
```

**Actions**

| Action | What it does |
| --- | --- |
| `init` | Copies the Shield template to the case file you name. The file must be called `<id>.yaml`; the id is taken from the name. It never overwrites an existing file. |
| `run` | Executes the case’s checks (all, or the ones named with `--check`) and appends one record per check to the ledger. |
| `verify` | Judges every check on its latest run and every claim on its evidence, and prints the verdict. Runs nothing. |

**Options of assurance**

| Flag | Argument | Default | What it does |
| --- | --- | --- | --- |
| `-d, --directory` | `<path>` | current folder | Project folder. |
| `--dir` | `<path>` | `_bmad-output/assurance` | Evidence folder inside the project; the ledger lives in `<dir>/<case id>/runs.jsonl`. Relative, written with `/`. |
| `--check` | `<id>` | all checks | `run` only: run just this check. Repeat the flag for several. |
| `--ledger-head` | `<sha256>` | none | `verify` only: fail unless the ledger still holds the record with this digest (64 lowercase hexadecimal characters). |
| `--emit-check` | `<file>` | none | `verify` only: also write the verdict as a JSON check result for CI. The name must end in `.json` and must not be the case or its ledger. |
| `--json` | - | off | Print the result as JSON (`schemaVersion: 1`) instead of text. Errors become `{ "status": "error", "message": ... }`. |

<a id="case-file"></a>
## The case file

By convention a case lives at `_bmad/assurance/<id>.yaml`, but any project-relative path works as long as it contains no `..` and no symbolic link. `init` writes the Shield template, which is a complete working example (four checks, one top claim and four sub-claims). Replace every example with your own, commit the file, then run it. Here is the shape; unknown keys are refused.

**Shape of a case (shortened from the template)**

```yaml
schema: bmad-plus/assurance-case/1
id: release-security
title: Release security case
scope: >-
  The service at the commit being released.
freshness:
  maxAgeDays: 30
  sameCommit: true
checks:
  - id: unit-tests
    run: [npm, test]
    timeoutSeconds: 900
  - id: secret-scan
    run: [gitleaks, detect, --no-banner, --redact, --report-path, reports/gitleaks.json]
    artifacts: [reports/gitleaks.json]
claims:
  - id: C1
    claim: The release ships no committed credential and no failing control test.
    argument: >-
      Holds when both sub-claims hold.
  - id: C1.1
    parent: C1
    claim: No credential is committed in the repository history.
    argument: >-
      The scanner reads every reachable commit and exits non-zero on a finding.
    controls: [ISO27001:A.5.17, SOC2:CC6.1]
    evidence:
      - check: secret-scan
        shows: gitleaks finds no secret and writes its report.
  - id: C1.2
    parent: C1
    claim: The behaviour the controls rely on is covered by passing tests.
    argument: >-
      The suite exercises access checks and input validation.
    controls: [ISO27001:A.8.29]
    evidence:
      - check: unit-tests
        shows: The test suite passes at this commit.
```

**Keys of a case**

| Key | Rule |
| --- | --- |
| `schema` | Must be `bmad-plus/assurance-case/1`. |
| `id` | Lowercase letters, digits, dots and dashes, up to 64 characters. |
| `title`, `scope` | Free text, 1 to 4000 characters each. |
| `freshness.maxAgeDays` | Whole number of days from 1 to 366. Default 30. A run older than this is `stale`. |
| `freshness.sameCommit` | `true` (default) ties the evidence to the commit checked out and to a clean working tree. Set `false` only for a project outside git. |
| `checks[].id` | Unique id of the check. |
| `checks[].run` | The command as a list of separate arguments. No shell is involved. On Windows, programs such as npm are `.cmd` files and need `[cmd, /c, npm, ...]`. |
| `checks[].cwd` | Project-relative folder to run in. Default `.`. |
| `checks[].env` | Names of extra environment variables the check may see. By default it inherits only `PATH`, `HOME`, the temporary folders and a few Windows system variables. `BMAD_PLUS_ASSURANCE_KEY` can never be listed. |
| `checks[].timeoutSeconds` | Whole number from 1 to 21600. Default 600. |
| `checks[].expect.exitCode` | Exit code that counts as success, 0 to 255. Default 0. |
| `checks[].artifacts` | Project-relative files the command must write; each is hashed with the run. A file left unchanged by the run does not count as produced. |
| `claims[].id`, `claim`, `argument` | Required. The argument says why a passing check shows the claim. |
| `claims[].parent` | A claim declared earlier. A claim with no evidence must have at least one sub-claim, otherwise it is “only asserted” and the case does not load. |
| `claims[].controls` | Optional list of `FRAMEWORK:ID` references. Accepted frameworks: `ISO27001`, `SOC2`, `GDPR`, `EU-AI-Act`, `NIS2`, `NIST-800-53`. A mistyped or invented control is refused. |
| `claims[].evidence` | List of `{ check, shows }`. `check` must name a declared check. |

<a id="reads-writes"></a>
## What it reads and writes

- `init` writes one file: the case, at the path you give (parent folders are created).
- `run` reads the case, executes each selected check as a child process without a shell, and appends one line per check to `<dir>/<case id>/runs.jsonl`. While it works it holds `runs.jsonl.lock`. It reads git (`rev-parse`, `status`, `ls-files`) to record the commit and whether the tree was clean.
- `verify` reads the case, the ledger, git and the declared artifacts. It writes nothing except the optional `--emit-check` file.
- It opens no network connection itself. The commands you declare may do whatever they do; that is why the data-handling inventory lists `assurance-checks` as a flow that exists only when you invoke `run`.
- Refused everywhere: paths outside the project, and any path that passes through a symbolic link.

<a id="ledger"></a>
## The ledger and its key

Each record in `runs.jsonl` is one JSON line: the check id, a sequence number, the digest of the command, the commit (`revision`) and `dirty` (whether tracked changes or untracked, non-ignored files existed), start and end times, the exit code, whether it timed out, the output size and digest with a redacted excerpt of its last 2048 bytes, the digest of each declared artifact (or `null` when the run did not write it), the digest of the previous record, and its own `sha256`. Editing, reordering or removing a record before the last one breaks the chain, and `verify` then judges the whole case unsupported.

The chain alone only reveals careless edits: anyone who can write the file can append a record that verifies, or cut off the last records. Two things kept outside the ledger close that gap.

- The key. Set `BMAD_PLUS_ASSURANCE_KEY` (at least 32 characters) from a CI secret. `run` then adds an HMAC-SHA256 to every record and `verify` refuses any record the key does not authenticate. The key is never handed to a check. Use the same key for `run` and `verify`: a ledger written without the key is refused once the key is set.
- The head. `run` prints the digest of its last record. Keep it where the ledger’s writers cannot change it (the CI job log, the audit file) and pass it to `verify --ledger-head`. A ledger that no longer holds that record is refused, so removed runs are noticed.

<a id="verdict"></a>
## How the verdict is built

**Status of one check (from its latest run)**

| Status | Meaning |
| --- | --- |
| `passed` | Ran, did what the check expects, and is fresh. |
| `failed` | It did not start, timed out, exited with another code than expected, or an artifact was not produced by the run. |
| `stale` | It passed, but at another commit, on uncommitted changes or untracked files, with a command that has changed since, with an artifact that changed since, older than `maxAgeDays`, or with an end time in the future. |
| `missing` | It never ran. |

A claim is `supported` only when all its evidence checks are `passed` and all its sub-claims are `supported`; otherwise it is `unsupported`, with the reasons. The case is `supported` only when every claim is, the ledger is intact, any `--ledger-head` is still in it, and, with `sameCommit: true`, there is a commit and the working tree has no uncommitted changes or untracked files (the case’s own ledger folder and artifacts do not count). The verdict also lists `controls.supported` and `controls.unsupported`: the controls tagged on claims that have, or lack, executed evidence.

<a id="exit-codes"></a>
## Exit codes

**Exit codes by action**

| Action | Code | Meaning |
| --- | --- | --- |
| `init` | 0 | Case file written. |
| `run` | 0 | Every selected check ended as its check expects. |
| `run` | 1 | At least one selected check failed (did not start, timed out, wrong exit code, or a declared artifact not produced). The records are written anyway. |
| `verify` | 0 | The case is `supported`. |
| `verify` | 1 | The case is `unsupported`. |
| any | 3 | The command could not do its job: unknown action, case missing or invalid, bad key or head, ledger not intact on `run`, lock held, check not declared, `--emit-check` refused. Printed as `assurance: <message>` on the error stream. |

<a id="real-output"></a>
## What the output looks like

The three transcripts below are composed from the message templates in the source, with illustrative values (the paths, timings and digests are made up). They are marked as simulated for that reason.

**Start a case, then run it** (Simulated)

```text
$ npx bmad-plus assurance init _bmad/assurance/release-security.yaml
_bmad/assurance/release-security.yaml: case release-security started from the Shield template.
Replace every example claim and check with those of the project, commit it, then run it.
$ npx bmad-plus assurance run _bmad/assurance/release-security.yaml
/home/me/app/_bmad-output/assurance/release-security/runs.jsonl
  passed unit-tests               exit 0 in 41.3 s
  passed dependency-audit         exit 0 in 6.8 s
  passed secret-scan              exit 0 in 3.1 s
  passed review-gate              exit 0 in 1.2 s
Recorded 4 run(s); ledger head 3f9c1b7e5a2d4c6e8b0a1d3f5c7e9b2a4d6f8a0c1e3b5d7f9a2c4e6b8d0f1a3c.
Keep the head outside the ledger and verify with bmad-plus assurance verify _bmad/assurance/release-security.yaml --ledger-head 3f9c1b7e5a2d4c6e8b0a1d3f5c7e9b2a4d6f8a0c1e3b5d7f9a2c4e6b8d0f1a3c.
```

Illustrative transcript, not captured from a real run. Source: tools/cli/commands/assurance.js (initAction, runAction message templates)

**Verify at the same commit** (Simulated)

```text
$ npx bmad-plus assurance verify _bmad/assurance/release-security.yaml --ledger-head 3f9c1b7e5a2d4c6e8b0a1d3f5c7e9b2a4d6f8a0c1e3b5d7f9a2c4e6b8d0f1a3c --emit-check ci/assurance.json
release-security: supported — 5/5 claim(s) supported, 4 recorded run(s)
  controls with executed evidence: ISO27001:A.8.8, NIST-800-53:RA-5, ISO27001:A.5.17, SOC2:CC6.1, ISO27001:A.8.32, SOC2:CC8.1, ISO27001:A.8.29
  check: /home/me/app/ci/assurance.json
```

Illustrative transcript, not captured from a real run. Source: tools/cli/commands/assurance.js (verifyAction message template)

**Verify after the code moved on** (Simulated)

```text
$ npx bmad-plus assurance verify _bmad/assurance/release-security.yaml
release-security: unsupported — 3/5 claim(s) supported, 4 recorded run(s)
  stale   unit-tests: it ran at 1a2b3c4d5e6f, not at 9f8e7d6c5b4a
  unsupported claim C1: sub-claim C1.4 is unsupported
  unsupported claim C1.4: evidence unit-tests is stale
  controls with executed evidence: ISO27001:A.8.8, NIST-800-53:RA-5, ISO27001:A.5.17, SOC2:CC6.1, ISO27001:A.8.32, SOC2:CC8.1
# Exit code 1. Run the stale check again at the new commit, then verify.
```

Illustrative transcript, not captured from a real run. Source: tools/cli/commands/assurance.js (verifyAction), tools/cli/lib/assurance.js (judgeCheck reasons)

With `--emit-check`, the file written is a JSON object with the schema `bmad-plus/assurance-check/1`: the status, the revision, the claims with their reasons, the controls, and a `github` entry (`name`, `conclusion` of `success` or `failure`, a title such as `3/5 claims supported` and a Markdown summary table) that a CI step can post as a check run. It holds no timestamp, so the same verdict produces the same file.

<a id="errors"></a>
## Errors and what to do

**Messages printed after assurance: (exit code 3)**

| Message | Cause | What to do |
| --- | --- | --- |
| `unknown action "x" (init, run, verify)` | The first argument is not one of the three actions. | Use `init`, `run` or `verify`. |
| `no assurance case at <path>` | The case file does not exist. | Check the path, or create it with `init`. |
| `<path> already exists` | `init` never overwrites. | Pick another name or edit the existing case. |
| `case file: name it <id>.yaml ...` | The file name is not `<id>.yaml` with a valid id. | Use lowercase letters, digits, dots and dashes in the id. |
| `<case>: claim X is only asserted; support it with evidence from a check or with sub-claims` | A claim has neither evidence nor sub-claims. | Add `evidence` naming a check, or sub-claims with `parent`. |
| `evidence must name a check that runs (check: <id>); a statement, document or link is only asserted` | An evidence item is not `{ check, shows }`. | Write a check whose command shows the claim, and point to it. |
| `BMAD_PLUS_ASSURANCE_KEY must hold at least 32 characters` | The key is set but too short. | Generate a longer secret, or unset the variable. |
| `the ledger <file> is not intact (record N ...)` | `run` found an edited, reordered or unauthenticated record (for example written before the key was set). | Keep the file for inspection and record into another `--dir`. |
| `another run holds <file>.lock; delete it only if no run is in progress` | A run is active, or one was interrupted. | Wait; if none is running, delete the lock file. |
| `check X is not declared in <case>` | `--check` names an unknown check. | Use an id from the `checks` list. |
| `the ledger head must be a record digest of 64 lowercase hexadecimal characters` | `--ledger-head` is malformed. | Copy the head exactly as `run` printed it. |
| `--emit-check writes a .json file` / `--emit-check must not overwrite the case or its ledger` | Bad output name. | Choose a `.json` path that is neither the case nor the ledger. |
| `<path> passes through a symbolic link` / `must be a relative path inside the project, written with /` | The path leaves the project or crosses a link. | Use a normal relative path with `/` separators. |

<a id="verdict-reasons"></a>
### Reasons that make verify exit 1

- `the ledger is not intact: ...` or `... is not authenticated by BMAD_PLUS_ASSURANCE_KEY`: a record was altered, or the key does not match. Rebuild the ledger by running the checks again into a fresh `--dir`.
- `the ledger no longer holds the head ... it was anchored to`: records were removed or the ledger replaced.
- `the working tree has uncommitted changes or untracked files`: commit, then run and verify again. The evidence describes the commit, not your working tree.
- `there is no commit to bind the evidence to`: the folder is not a git repository with a commit. Set `freshness.sameCommit: false` only for a project outside git.
- A check is `missing`, `failed` or `stale`: the line `<status> <check>: <reasons>` says why. Run that check again at the current commit.

<a id="examples"></a>
## Examples

<a id="example-cli-assurance-start-case"></a>
### Have Shield build and run a case for a release

You are about to release and a customer asked for evidence that security controls hold at that exact commit. The working tree is committed.

**In your AI assistant**

```text
Shield, start an assurance case for this release. Keep each claim small enough that one command shows it, tag the claims with the controls they support, then run the checks at this commit and verify the case. Tell me which claims are not supported and why.
```

**What you should get** Shield runs `bmad-plus assurance init _bmad/assurance/<id>.yaml`, replaces the template’s example claims and checks with ones for your project (one top claim, sub-claims each backed by a command that exits non-zero when the claim fails), and asks you to commit the file. It then runs `assurance run`, keeps the printed ledger head, and runs `assurance verify --ledger-head <head> --emit-check ...`. It reports each unsupported claim with the reason printed by `verify` and lists the controls with executed evidence. It states whether a key and a head were used; without them it says the evidence is recorded, not tamper-proof. It never calls an unsupported claim met.

<a id="example-cli-assurance-explain-verdict"></a>
### Explain an unsupported verdict

A CI job failed on `assurance verify`, and the case was green last week.

**In your AI assistant**

```text
Shield, verify the release-security case and explain every unsupported claim. Do not re-run anything until you have told me what is stale and why.
```

**What you should get** Shield runs `bmad-plus assurance verify _bmad/assurance/release-security.yaml` (read-only) and reads the output: the status line, each non-passed check with its reasons (another commit, uncommitted changes, a changed command or artifact, older than `maxAgeDays`), and each unsupported claim. It explains them in plain words, tells you which checks to run again with `assurance run --check <id>`, and waits for your go-ahead before executing anything, because `run` executes the commands written in the case.

**See also**

- [CLI overview](https://bmad-plus.rochetta.fr/docs/reference/cli-overview/)
- [ai-register: the other Shield evidence file](https://bmad-plus.rochetta.fr/docs/reference/cli-ai-register/)
- [review: its gate can be a check of a case](https://bmad-plus.rochetta.fr/docs/reference/cli-review/)
- [Troubleshooting](https://bmad-plus.rochetta.fr/docs/faq/troubleshooting/)

[BMAD+ documentation](https://bmad-plus.rochetta.fr/docs/) · [Reference](https://bmad-plus.rochetta.fr/docs/reference/)

[Source on GitHub](https://github.com/lrochetta/BMAD-PLUS)
