Recovery playbooks
Things go wrong in the middle of real release work: a laptop dies between a reviewer's decision and its audit record, a disk hiccup truncates a file, an old evidence pack meets a newer SBOMFlow. This page is the operator's map for those moments — one diagnose → recover path per failure, using only commands that already exist.
Every tested playbook below is exercised end-to-end by the offline test suite: the failure state is really created, the documented diagnosis commands are really run, and the documented recovery is really verified. A recipe here cannot rot into something that no longer works.
Two rules hold everywhere on this page:
- The engine never guesses. Recovery completes only steps whose outcome is provable from recorded digests; anything ambiguous is retained and named for a human. Nothing here creates a reviewer decision, accepts evidence, or claims conformity.
- Exit codes are the contract.
0healthy,1an enforced gate said no,2usage/input,4integrity or structural failure,5needs attention. See exit codes andsbomflow help exit <code>offline.
| Situation | Playbook |
|---|---|
| Interrupted decision transaction | PB-01 |
| Corrupt or truncated decision journal | PB-02 |
| Audit-chain mismatch | PB-03 |
| Stale decision or expired waiver | PB-04 |
| Stale vulnerability snapshot | PB-05 |
| Unsupported schema version | PB-06 |
| Evidence gap for a requirement | PB-07 |
| Failed approval quorum | PB-08 |
| Unverified signature or unauthorized signer | PB-09 |
| Invalid bundle content | PB-10 |
| Missing or unreadable evidence-store index | PB-12 |
| Run killed mid-write (stale lock, missing outputs) | PB-14 |
| Broken post-market projection | PB-11 |
Attach diagnostics to any bug report with the sanitized, offline support bundle (config shape and warning counts, never your source or findings). It also carries a privacy-safe transaction_summary — crash-recovery journal counts by phase, plus unreadable and recovery_required tallies, and never a target id, reviewer identity, timestamp, or free-text detail — so a decision-workflow bug report shows whether interrupted transactions are present without leaking what they were about:
sbomflow support-bundle ./evidence --yesPB-01 — Interrupted decision transaction#
Symptom. A decision command (triage, evidence review, waiver, approval, claim) crashed or was killed mid-write. Every consequential decision runs as a crash-recoverable transaction: a write-ahead journal is prepared, the decision file is written, the audit event is appended, and only then is the journal completed and removed. A crash leaves a discoverable journal instead of a silently half-recorded decision.
Diagnose. List the journals, then classify each against the real state of the decision file and the audit log:
sbomflow decisions transactions --store ./evidence
sbomflow decisions verify-transactions --store ./evidenceverify-transactions exits 5 when anything needs a human, 0 when the store is clean. Each row's status comes from a registered, closed vocabulary (complete, recoverable, manual_intervention_required, state_mismatch, audit_mismatch, corrupt, unsupported, in_flight) — see the machine-readable reference below — so CI can branch on --json status instead of parsing prose. A recoverable row means the decision landed but its audit event did not (or nothing was applied and the attempt can be safely aborted). Narrow large stores with --operation-type, --target, or --status.
While a relevant prior transaction is in an unsafe state, a new decision on that target is refused fail-closed with the stable code E022 ("an unsafe prior transaction must be resolved first") — distinct from the generic review-refusal E021 — so a pipeline can tell the two apart. Resolve the named prior transaction (below), then re-run.
Recover. Target-scoped and dry-run first, exactly as printed by the diagnosis:
sbomflow decisions recover --store ./evidence --operation-id <operation-id> --dry-run
sbomflow decisions recover --store ./evidence --operation-id <operation-id>Recovery completes only the provably missing step: the audit event is reconstructed from the recorded decision and verified against the digest bound before the crash, then appended exactly once (the event carries recovered: true). An attempt that never applied is aborted clean. You do not have to run this eagerly — the next decision write on the same target heals it automatically first.
Never hand-edit or hand-delete a journal that verify-transactions can still classify. It is the only record linking an applied decision to its pending audit event.
PB-02 — Corrupt or truncated decision journal#
Symptom. A journal file itself is unreadable — torn by a crash mid-write, or damaged on disk. Because an unreadable journal's target cannot be trusted, all new decision writes on that store are refused (fail closed) until a human resolves it; the refusal message points back here.
Diagnose.
sbomflow decisions verify-transactions --store ./evidence
sbomflow decisions transactions --store ./evidence --status corrupt
sbomflow decisions recover --store ./evidence --operation-id <operation-id>The corrupt row names the exact journal file and the parse error. Asking recover about that operation id answers corrupt at exit 4, repeats the parse error and names the quarantine command below — it never reports the journal as absent or as possibly already complete, because it read nothing that could tell it so. Rows can also be manual_intervention_required, state_mismatch, or audit_mismatch — readable journals whose recorded digests contradict the observed state. Those are retained in place with the mismatch named; the engine will not auto-repair by guessing which side is right, and quarantine refuses them, because they still have a recovery path.
Recover. This is a manual-intervention path by design — the decision to discard is yours; the tool only makes carrying it out auditable:
- Confirm the parts the engine can verify are intact — the decision files and the hash-chained audit log:
``bash sbomflow validate ./evidence ``
- Investigate how the journal was damaged (crash timing, disk, permissions), and preserve a copy of the unreadable file with your incident notes.
- Only after that investigation, quarantine the unreadable journal:
``bash sbomflow decisions quarantine --store ./evidence \ --operation-id <operation-id> --reviewer you@example.com \ --reason "torn by a full disk during the overnight run" ``
The bytes are moved, never deleted: they are preserved under .decision-txn/quarantine/ and the command prints the journal name, the byte count and the sha256 of exactly what it moved. The discard is appended to the run's hash-chained audit log as decision_journal_quarantined, carrying that same digest and the reviewer who did it — so a quarantine that could not be recorded does not happen (an absent audit log, or a broken chain, refuses at exit 4 and leaves the journal where it is). The event carries no transaction envelope: the operation it names was never completed, and nothing about a quarantine recovers a decision.
Re-run verify-transactions to confirm the store accepts decisions again. Its listing keeps stating how many journals were quarantined out of the store, so an empty row set is never read as "every operation finished".
- Re-issue the affected human decision so the store carries a complete, audited record of it:
``bash sbomflow review ./evidence --triage <finding-id> --status under_investigation --reviewer you@example.com ``
Never let the engine (or a script) guess: an unreadable journal is evidence about an interrupted human action, not noise to be cleaned on sight.
PB-03 — Audit-chain mismatch#
Symptom. sbomflow validate (or any decision command, which refuses to run over a broken chain) reports that the audit log's hash chain does not verify. Exit code 4, error E043.
Diagnose.
sbomflow validate ./evidenceValidation localizes the break instead of just declaring it: a defect confined to malformed JSON on the final line with every prior entry chained intact is reported as consistent with a truncated/incomplete tail (a crash mid-write), NOT mid-chain tampering — while altered event content, a broken link, or a stripped chain is reported at its exact entry position as tampering. The distinction is the whole diagnosis: a torn tail is an interrupted write; a mid-chain break means a recorded entry changed after the fact.
sbomflow validate --json carries this localization structurally as an additive audit_chain_break block (same sbomflow-validate-report-v1 schema): a classification (torn_tail / unchained / tampering), the raw break_kind (link_mismatch / content_mismatch / malformed_hash / not_an_event / unchained), and the 1-based break_entry. It is present only when there is a break, so a clean report is unchanged. CI can branch on the classification without parsing the prose problem line.
Recover. Treat a mid-chain break as tampering until explained:
- Do not record anything new into the directory — SBOMFlow already refuses.
- Restore
audit-log.jsonlfrom your last known-good copy (the evidence store, or an archived reviewer bundle: the bundle records the chain head and entry count as an external anchor, andsbomflow store verifyre-checks stored releases), then re-runsbomflow validateto confirm. - Document the incident: when the break was found, what the localization said, which copy you restored from. The chain proves that something changed, not who changed it — see the honest limits in security & privacy.
Never silently re-chain or hand-repair hashes. A rewritten-but-consistent chain is exactly what the anchor comparison exists to catch.
PB-04 — Stale decision or expired waiver#
Symptom. A reviewer decision was recorded against an earlier state of the product (the component's version has changed since), or a time-boxed gate waiver has lapsed. Neither is auto-invalidated or auto-renewed: the record stands as history and the surfaces below prompt a human to look again.
Diagnose. The review queue marks prior decisions that need re-confirming (prior decision … component version changed since; re-confirm), the gate output lists waivers by status, and the freshness horizon answers "what lapses next?" in one dated report:
sbomflow review ./evidence
sbomflow report freshness ./evidenceAn expired waiver appears in release-gate.json under waiver_status.expired (with negative days_until_expiry) and on the horizon as kind: waiver, status: expired — and the item it covered blocks the gate again, which is the point of an expiry.
Recover. Both paths are recorded human actions with history preserved:
- Stale decision: re-review it. A new decision on the same finding is a first-class supersession — a distinct audited operation that records the exact predecessor it replaces; the earlier decision remains in the file and the audit log.
- Expired waiver: renew or let it lapse. A renewal needs a strictly later expiry and a fresh reason (silently re-waiving with the stale reason is refused), and the previous term is kept in the waiver's renewal history:
sbomflow review ./evidence --renew-waiver-finding <waived-finding-id> --reason "risk re-assessed; new expiry approved by security review" --expires-at 2027-06-30T00:00:00Z --reviewer you@example.comThen re-run the gate so the release reflects the renewal:
sbomflow analyze . --output ./evidence --waivers ./evidence/waivers.jsonNever auto-renew, and never edit a recorded decision in place — the lapse-then-human-renewal loop is what makes a waiver an exception rather than a permanent hole.
PB-05 — Stale vulnerability snapshot#
Symptom. Findings look clean but the advisory data they were matched against is old. SBOMFlow makes this visible instead of letting run time masquerade as freshness: every run's vuln-source-health.json records, per source, whether it was usable (state) and how old its data is (freshness), against a documented per-source staleness threshold.
Diagnose. Read vuln-source-health.json in the output directory: a snapshot past its threshold reports state: stale / freshness: stale with its age in days, and the run raises the cataloged stale_vulnerability_data warning naming the source (see the warning catalog). A source with no readable date reports freshness: unknown — never fresh. To make CI fail on it, promote the warning: --strict=stale_vulnerability_data exits 5 after all artifacts are written.
Recover. Supply newer data — staleness is never "fixed" by re-running:
- Offline (preferred): point the run at a newer pinned snapshot:
``bash sbomflow analyze . --output ./evidence --kev-file kev.json ``
(the same applies to --nvd-file, --epss-file, and --osv-snapshot).
Network — opt-in
Explicitly online: sbomflow vulndb update refreshes the local snapshot
store over the network. It runs only when you invoke it — no analyze/audit
path fetches anything on its own. See
security & privacy for the full network policy.
Never treat the run timestamp as data freshness, and never block on staleness by default — a stale source was genuinely consulted; its age is a reported fact for you to act on.
PB-06 — Unsupported schema version#
Symptom. An output directory produced by an older SBOMFlow carries artifact schema versions the current tooling has since evolved past.
Diagnose. Name the family and version of every artifact, then dry-run the migration:
sbomflow upgrade inspect ./evidence
sbomflow upgrade plan ./evidenceinspect prints family=… version=… per artifact; plan reports exactly which files would change, from which version to which, flagging any lossy step. plan also names on stderr every file it could not read or parse, so an unreadable artifact is never hidden behind a 0 artifact(s) would change line. Neither writes anything.
Recover. Migrate to a copy — never in place — and validate it:
sbomflow upgrade migrate-copy ./evidence --dest ./evidence-migrated
sbomflow upgrade validate ./evidence(point upgrade validate at the migrated directory). The source directory is untouched, and the migrated copy carries upgrade-manifest.json — the receipt recording every applied step, the original content hashes, and any lossy-step warnings. Migrations transform representation only: they never invent evidence, reviewer decisions, or authority.
Never hand-edit artifacts to a newer shape — an edit invalidates recorded digests and turns a schema question into an integrity question (PB-03).
PB-07 — Evidence gap for a requirement#
Symptom. A CRA requirement in scope for your product class has no observed evidence behind it. Gaps are first-class records, not absences: each carries a stable gap_id, the requirement, the signal that was not observed, and a concrete recommendation.
Diagnose. The gap register lives in evidence-pack.json under evidence_gaps, cross-referenced per requirement in cra-coverage.json (gap_ids). Enforce it and read the reasons:
sbomflow analyze . --output ./evidence --fail-on-gaps
sbomflow explain ./evidence --gate
sbomflow explain ./evidence --gap <gap-id>A blocked run exits 1 with reason code GATE_EVIDENCE_GAPS listing the gap ids; explain --gap prints the requirement, the official reference, and what would satisfy the signal.
Recover. Close the gap with real inputs, then let a human accept:
- Supply the evidence the recommendation names (for example, a monitored
SECURITY.mddisclosure policy in the product tree), or record manufacturer-supplied Annex I input files, and re-run the analysis — the gap disappears from the register only because the signal is now observed. - A human reviews the new evidence item; observation is never acceptance:
``bash sbomflow review ./evidence --accept vulnerability_disclosure_policy --reviewer you@example.com ``
- If the evidence genuinely cannot land before the release, a reviewer may record a time-boxed waiver with a reason and expiry (see PB-04) — visible in the gate output, never a resolution of the gap.
Never infer acceptance. See observed vs reviewed — the engine observes; humans decide.
PB-08 — Failed approval quorum#
Symptom. The release gate blocks because required sign-off roles are not each covered by an active, attributed approval (revoked and expired approvals do not count; separation of duties refuses self-approval of one's own release).
Diagnose. The gate names the policy and every unmet role, machine-readably (reason_code: GATE_MISSING_APPROVALS in release-gate.json, the unmet roles in its ids) and human-readably:
sbomflow analyze . --output ./evidence --require-role product-security --fail-on-missing-approvals
sbomflow explain ./evidence --gateRecover. Obtain the attributed approval from the named role — there is no actorless way to satisfy a quorum:
sbomflow approve ./evidence --role product-security --reviewer approver@example.com
sbomflow analyze . --output ./evidence --require-role product-security --fail-on-missing-approvals --approvals ./evidence/approvals.jsonThe re-run exits 0 once every required role carries an active approval. Each grant, revocation, and override is a crash-consistent, audited operation (PB-01 covers an interrupted one).
A role can also read MISSING while approvals.json clearly holds a record for it. That happens when the record has no reviewer: an approval that names nobody cannot be held to either separation-of-duties rule, so it satisfies no role. The status display marks it [UNATTRIBUTED — satisfies no role]; the record is kept exactly as written and nothing is deleted.
Retire it first, then re-record the sign-off — that order matters. On a revocation, --reviewer does two jobs: it names who is revoking, and it selects which records are revoked. A record that names nobody cannot be selected by name, so the only command that reaches it is a revocation with no --reviewer — and that one revokes every active approval for the role, including a good one you have just recorded. Look at what the role holds, retire, then re-approve:
sbomflow approve ./evidence --require-role product-security
sbomflow approve ./evidence --revoke --role product-security --reason "unattributed; carries no authority"
sbomflow approve ./evidence --role product-security --reviewer approver@example.comThe revoked record stays in the ledger as marked history, and the re-recorded sign-off satisfies the role. If you are content to leave the unattributed record where it is, the last command alone is enough — that record grants nothing whether you retire it or not.
An emergency override you can see in the ledger may be suppressing nothing. An override recorded without --reviewer names no human, and an override is the widest authority this command records — it suppresses every unmet role at once — so an unattributed one carries no authority at all. The status display flags it (UNATTRIBUTED emergency override(s) recorded) and the enforced gate keeps blocking. There is nothing to retire, because it grants nothing; if the exception still stands, record it attributed:
sbomflow approve ./evidence --override --reviewer you@org --reason "line down; CISO unreachable" --expires-at <expires-at><expires-at> is a real ISO-8601 instant that has not yet passed — an override is mandatorily time-boxed, and one recorded with an expiry already in the past is accepted, exits 0, and still suppresses nothing (the confirmation line says override_active=False). Check that line, not the exit code.
Never fake quorum by editing the ledger. Every record that grants or suppresses release authority must name a human — an approval and an emergency --override are both refused without --reviewer, and both are inert if one already sits unattributed in a ledger written before that rule. An override is a recorded, attributed, time-boxed exception that lets the gate proceed while still listing every missing role; it is a deliberate, named escape hatch, not an anonymous one.
PB-09 — Unverified signature or unauthorized signer (deferred)#
Status: deferred — this playbook is not yet published; it lands, tested, with the signature-verification surface it covers.
Until then: treat any bundle whose verification fails as untrusted, keep it for investigation, and confirm you hold the right public key before retrying. sbomflow verify-bundle exits 3 on a failed signature and never "partially trusts" a bundle.
PB-10 — Invalid bundle content (deferred)#
Status: deferred — as for PB-09; the content-dimension playbook lands, tested, with it.
Until then: a failed content check means the bundle no longer matches its manifest. Re-export from the canonical output directory and investigate the mismatch; never pass a bundle whose extra or altered files are hidden.
PB-12 — Rebuild a missing or unreadable store index#
Diagnose. Keep the store unchanged and inspect its owned release packs:
sbomflow store --store ./.sbomflow-store verify --jsonverify refuses here rather than reporting. It walks the index entries, and release directories on disk are only ever counted as orphans — so with no index to walk it would check nothing and report the store intact. Instead it exits non-zero and names which of three states it found: absent (no index file at that path), unreadable (an index is there and its bytes would not come back — permissions, or an I/O error) or malformed (bytes came back and are not a usable index). An index that was read and honestly records zero releases is a fourth state, empty, and verifies normally.
store retain, store empty-trash and store reissue-plan refuse over the same three states, for the same reason: each folds its verdict over the index entries, so an index nobody could enumerate produced "nothing to retain", "no release is held" and "nothing needs reissuing" — answers indistinguishable from a healthy store. store reindex below is the command that repairs it.
store query and store timeline refuse over those three states too. They write nothing, but they are what you reach for to ask what do we hold and what did we decide about this CVE — and over a store that lost its index both answered "nothing", which is the answer you would get from a store that genuinely holds nothing.
store list now says when there is no store, but it is still not the integrity check. For a store whose index.json is missing it prints an empty list and exits 0 — it shares its reader with the cross-release context analyze builds, and that must keep working on a store directory nobody has added to yet — but it prints a note to stderr first:
note: no store at ./.sbomflow-store (index.json absent) — this is not the same
as a store that holds nothing; any packs on disk were not enumerated. Rebuild
the record with `sbomflow store --store ./.sbomflow-store reindex`, or check the
--store path.So an absent index no longer renders exactly like a store that records zero releases. store verify remains the command that establishes whether a store is intact, and it is the one to run here. (store list does refuse an index that is present and unusable.) store query and store timeline still print their ordinary empty answers over an absent index and are unchanged.
If index.json is missing but releases/<product>/<release>/ still contains canonical packs, rebuild only from those bytes:
sbomflow store --store ./.sbomflow-store reindex --as-of 2026-07-21T12:00:00Z --json
sbomflow store --store ./.sbomflow-store verify --jsonreindex is offline and serialized with every other store mutation. It preserves readable retention tombstones, warns once for each unreadable pack, and skips rather than fabricates any release it cannot prove. Releases stored only by reference cannot be reconstructed from store-owned bytes and are named as warnings; re-add those from their trusted source output.
A preservation hold is never rebuilt away. A hold is a decision a named human recorded and it exists nowhere but the index, so a rebuild carries every hold forward, and a held release whose pack could not be rebuilt keeps its index record verbatim and is named in warnings — a missing pack under hold is a finding, not a tidy-up. Only store release-hold lifts a hold.
An index that will not parse — or whose bytes will not come back — is refused, not rebuilt over. Its bytes are the only record of the holds it carries, so reindex exits 2 (E051) and writes nothing, for a malformed index and an unreadable one alike. Repair the file, or move it aside deliberately and re-run:
mv ./.sbomflow-store/index.json ./.sbomflow-store/index.json.unreadable
sbomflow store --store ./.sbomflow-store reindex --as-of 2026-07-21T12:00:00Z --jsonRead prior_index_state in the report. A rebuild with no prior index writes an index in which no release is held — because none could be read, not because none was set. The report says which: read (holds carried forward), absent (nothing to carry, see prior_index_note), or empty. Re-place any hold that still applies with store set-hold.
PB-13 — Move a store to a new machine (or restore one)#
Symptom. The evidence store has been copied to a new laptop, restored from a backup, or handed to a colleague — and store verify says everything is fine. It may not be. A release added with store add --no-copy is held by reference: the store records its identity but keeps no copy of the bytes. After a move, those external directories are simply gone, and plain verify reports them as by_reference, which reads far too much like "ok".
Before you move it. Record the whole-store seal — the anchor that catches a release lost in transit even though every survivor still hashes correctly:
sbomflow store --store ./.sbomflow-store verify --jsonKeep that output (its seal block is the anchor) as store-seal-before.json.
After you move it. Ask the transfer question, not the same-machine one:
sbomflow store --store ./.sbomflow-store verify --strictAdd --expected-seal store-seal-before.json to compare against the anchor you recorded above.
--strict turns an unreadable by-reference release into a failure (exit 4, state by_reference_missing) instead of a state. --expected-seal compares the set of indexed releases against what you recorded, so a dropped release fails even when nothing that survived is corrupt.
If the referenced trees moved too, point at their new home. Recorded paths are scrubbed of local layout (an out-of-store output is stored as outside-scan-root/<name>), so verification has to be told where to look:
sbomflow store --store ./.sbomflow-store verify --strict --reference-root ./evidence--reference-root changes only where verification looks. No recorded path is rewritten, no identity is re-minted, and nothing is re-adopted: a tree at the new location that does not hash to the recorded identity fails as hash_mismatch, exactly as it would have on the original machine.
Transfer the pinned vulnerability snapshot too. The store does not contain it — a snapshot lives in its own directory and is referenced by identity. A restored store without its snapshot still verifies releases, but re-running a gate against the same advisory data needs the snapshot copied across as well; see PB-05.
Not built, deliberately. SBOMFlow has no backup or replication daemon and no hosted store. Moving evidence is a copy plus these checks; anything that silently re-adopted or re-created releases would be inventing history.
PB-14 — Run killed mid-write#
Symptom. A run was interrupted — the machine died, CI cancelled the job, the container was evicted — and the output directory still has files in it. It is not obvious whether what is on disk is a finished run or the wreckage of one, and a .sbomflow.lock is sitting there.
What the engine knows. The run lock is removed when a run finishes, so a lock left in an output directory means one of exactly two things, and neither of them is "a finished run": a run is writing that directory right now, or a run died holding it. validate reports it and tells those apart rather than guessing:
sbomflow validate ./evidenceExit 4, with a problem line naming .sbomflow.lock:
- is held by pid … on … — a run is writing the directory now. Nothing is broken; wait for it and validate again. What you read meanwhile may change.
- appears STALE — the holder is provably gone, or the lock is past the staleness horizon. What is on disk is what an interrupted run left behind.
- its bytes could not be read at all — the lock is present and unreadable. That is a statement about access, not about the holder: it does not say the run finished, and it does not say it died.
The same run also lists any artifact this run recorded as still owed when it was interrupted, as requested artifact missing: <name> — evidence-bundle.zip for an audit --zip killed after the evidence pack was sealed but before the archive was written.
Diagnose. The environment view names the same lock and its staleness:
sbomflow doctor .Recover. There is nothing to repair inside the pack: an interrupted run is re-run, not patched.
- Confirm no run is actually active — check CI, and check for a live SBOMFlow process on the host named in the problem line.
- Re-run the interrupted command with
--break-stale-lock. It reports the stale holder it removed and then proceeds; a lock is never removed silently, and never byvalidate. - Validate again. Exit
0with no lock line means the directory now holds one complete run.
Never do this. Do not delete .sbomflow.lock by hand while a run might be live — the lock is the only thing stopping two runs from interleaving writes into one pack. And do not treat a partly written directory as evidence: SBOMFlow does not resume a killed run, so the artifacts of one are superseded by the re-run, never merged with it.
PB-11 — Broken post-market projection (deferred)#
Status: deferred — the post-market case workflow is experimental; its recovery playbook ships when that surface stabilizes.
Until then: case projections are rebuilt from the canonical hash-chained case journals, never edited in place.
Machine-readable status and failure reference#
A design partner wiring SBOMFlow into CI needs to branch on structured signals, not prose. Every value below is a stable, registered part of the contract; each is transcribed from behaviour the engine already implements (it never asserts more than the command does).
verify-transactions statuses (registered vocabulary; --json status):
| Status | Needs a human? | Meaning |
|---|---|---|
complete | no | Terminal and consistent — completed, or cleanly aborted with nothing applied. |
in_flight | no | A live writer holds the operation lease — retrying, not a crash. |
recoverable | yes | A safe recovery step remains — recover completes the audit link or aborts an unapplied attempt. |
manual_intervention_required | yes | Observed state cannot be reconciled automatically — retained for a human. |
state_mismatch | yes | The recorded decision digest contradicts the observed decision state. |
audit_mismatch | yes | The intended audit event is absent or its payload conflicts with the record. |
corrupt | yes | The journal file itself is unreadable — the target cannot be trusted (fail closed). |
unsupported | yes | The journal's operation type is not verifiable by this engine version yet. |
verify-transactions exits 5 if any row needs a human, 0 otherwise (a --status/--target/--operation-type filter narrows the view but never the exit-code health signal).
Error codes on a blocked decision write:
E021— a domain refusal of this operation (immutable prior decision, invalid justification, missing role…).E022— an earlier transaction on the same target is not in a safe state yet (recoverable/corrupt/unsupported/state_mismatch/audit_mismatch); nothing was written. Resolve it, then re-run. Both exit2; see the error reference.
validate --json → audit_chain_break (additive; present only on a break): classification ∈ {torn_tail, unchained, tampering}, break_kind ∈ {link_mismatch, content_mismatch, malformed_hash, not_an_event, unchained}, and a 1-based break_entry.
support-bundle → transaction_summary (privacy-safe): total, by_phase counts, unreadable, and recovery_required — counts and closed vocabularies only, never a target id, reviewer identity, timestamp, or free-text detail.
See also#
- Troubleshooting — symptom-first index for everything that is not a recovery scenario.
- Reviewer runbook — the normal-path decision workflow these playbooks return you to.
- Exit codes and the error reference — the stable contract every command above follows.
- CI runbook — branching on reason codes in pipelines.