Reviewer runbook

You have been named the product-security reviewer for a release. This page takes you from an untouched evidence pack to a signed-off one, in the order you will actually do it, and shows what an auditor sees afterwards.

Every command on this page is executed against a real evidence pack in CI, so a recipe here cannot rot into something that no longer runs.

Two things are true of everything below:

  • SBOMFlow observed; you decide. Nothing the engine writes is a review decision. Statuses like needs_review or under_investigation mean nobody has decided yet — never this is fine.
  • Your decisions live in files, not in the engine. reviews.json, vulnerability_reviews.json, waivers.json and approvals.json are the only things that can change a finding's disposition or unblock a gate.

1. See what needs you#

bash
sbomflow review ./evidence

The queue separates three questions you answer differently:

Queue sectionThe questionWhere your answer lands
Evidence awaiting reviewIs this observed signal really evidence?reviews.json
Advisory findings awaiting triageDoes this vulnerability affect our product?vulnerability_reviews.json
Unresolved gapsWhat evidence is missing entirely?reviews.json (or a waiver)

On a busy release, narrow it:

bash
sbomflow review ./evidence --filter "severity = high"

If a decision file cannot be read#

A file presented as reviewer decisions is never skipped in silence. Before the queue is built, every candidate is enumerated and classified into exactly three states — read, not supplied, or rejected — and only the middle one is the ordinary case of "you have not recorded anything here yet".

A rejected file makes review exit 2 and name it:

text
warning: 1 of 2 file(s) presented as reviewer decisions were NOT applied; this
review result is incomplete.
  reviews: reviews.json [7f8b039016cadaf0] — malformed_document

Every count carries the number of candidates examined, because "0 rejected" out of four files examined and "0 rejected" out of none examined read identically and mean opposite things.

review-queue.json is still written first, carrying decision_intake with complete: false and one bounded record per rejected file — a reason code, a digest where the bytes were readable, and a stable path token, never the contents of your file and never a path from the machine that ran it. Recording a new decision is refused while a supplied file cannot be read, because writing into a set SBOMFlow could not read risks superseding a decision nobody could see.

sbomflow next-actions ./evidence reports the same accounting without gating, so you can see what is wrong before you fix it. Nothing is repaired, guessed, or regenerated: SBOMFlow will not infer what a reviewer meant.

Where your decision files are looked for#

Two places, and decision_intake.sources[] says which one each candidate came from. pack is the canonical name beside the evidence pack — reviews.json, vulnerability_reviews.json, waivers.json, approvals.json, the files sbomflow review and sbomflow approve write. target is applied-decisions/, where analyze records the decision inputs its gate actually applied when they came in on some other path: a vulnerability_reviews: declared in your sbomflow.yaml beside the target, or an explicit --vulnerability-reviews.

If you declare your decision files in the config there is nothing beside the pack, and the review queue used to disagree with the gate about the same decision — the gate applied your not_affected, the queue asked you to triage it. The queue now reads the recorded copy and marks the row decided_elsewhere: target, so the decision is applied and you are told that the file to edit is not the one beside the pack.

That second look is taken only when the canonical file is provably absent. A file that is there and will not open is a rejection, reported as one; it is never replaced by the recorded copy, because that would answer a question about your document with different bytes.

A reason code says what to do next, and they do not share a repair:

ReasonWhat it meansWhat to do
unreadable, not_a_regular_file, escapes_containment, changed_during_readThe bytes were never seenFix the access, not the file
undecodable, malformed_document, too_large, too_deep, duplicate_decision_keyThe bytes were read and are not this documentRepair or regenerate the file at its source
unknown_decision_kindIt is a valid decision file of another family — waivers where reviews were expectedSupply it on the path that family is read from
unsupported_schema_versionIt was written by a build whose rules this one does not shareUpgrade the tool, or re-export with this build — never edit the file
product_identity_mismatchThe release record beside it names a different productPoint at the right output directory

The last three describe a file with nothing wrong with it, which is why none of them tells you to fix your JSON.

If two files disagree#

Two decision files can both be read and still record different decisions for one key. Nothing was lost, and neither side was applied over the other:

text
Reviewer decision inputs: UNSETTLED — 1 decision key(s) are decided differently
by two files that were both read. SBOMFlow resolved neither; a named human must
say which applies.
  reviews: key [3f1c8a20d94b7e55] decided by reviews, vulnerability_reviews

This is deliberately not the sentence used for a file that could not be read — both of these opened perfectly, so there is no access to repair and no corruption to fix. SBOMFlow never picks between two recorded decisions, and the key travels as a token so the record stays safe to share.

Waivers and approvals are decision files too#

A waiver and an approval exist only because a human recorded them, so waivers.json and approvals.json are enumerated and classified alongside reviews.json and vulnerability_reviews.json — four candidates, one accounting. "You recorded no waivers" and "you recorded waivers this run could not read" are different sentences with different repairs, and only one of them means a reviewer's recorded gate exception is missing from everything above it.

2. Understand one item before deciding it#

Never decide from the queue line alone. Ask the engine what it saw and why:

bash
sbomflow explain ./evidence --gap <gap-id>
sbomflow explain ./evidence --finding <finding-id>

explain prints the requirement, the official reference, the source file the signal came from, and how the advisory matched the component. For a finding, the match is three lines — Match basis, Match confidence and Range evaluation — so a low-confidence, flagged-for-verification range match no longer reads like a purl-exact one. A finding whose pack recorded no basis says unknown on those same lines rather than dropping them: an absent explanation and a confident one must not look alike. They record how the match was made, never whether it is correct, and they change no status, no ordering and no gate.

A finding explanation also carries Human disposition: — what a person recorded about this finding, and what the reviewer boundary did with it:

text
Human disposition: under_investigation (not recorded) by alice@example.com at 2026-01-02T03:04:05Z; effect on gate: downgraded
  recorded not_affected (code_not_reachable), downgraded because not_affected requires a valid CISA justification

effect on gate is one of three words. suppressed means the boundary accepted the decision, so the finding no longer counts as a blocker for the vulnerability policies. downgraded means a suppressing decision was recorded and refused — the finding is read as under_investigation and stays visible, and the recorded status and the rejected justification are printed on the next line so you can go back to that decision rather than make a second one. none means the recorded status is not one that suppresses (affected / under_investigation) at all.

The line is resolved through the same rule the release gate uses, and it reports the decision document this run applied (applied-decisions/, falling back to the sibling only when no recorded copy exists). It is not a gate verdict: when a decision names a component version this release no longer ships, a further line says so, because a run enforcing fail_on_recheck_needed reads such a decision as no longer suppressing. A decision file that is absent, one that is present and unreadable, and one that was read and records no decision covering this finding are three different sentences, never folded into one.

The same refusal is written on the three other surfaces that show the finding, so you meet it wherever you are reading rather than only on the one command that takes a finding id:

  • sbomflow explain ./evidence --gate lists Reviewer decisions refused (N) beside Reviewer-suppressed vulnerabilities — the decisions that did suppress and the ones that did not, together, because a release surface that shows only the first answers the question wrongly. A decision file that is present and unreadable reports UNKNOWN there rather than an empty list.
  • sbomflow review-view ./evidence puts a downgraded: … badge in the row's Context column, next to the Effective VEX value it explains.
  • assessment-report.html prints the reason under the Effective VEX cell of the reviewer-workflow triage table.

All four quote the same sentence, so no surface can describe one refusal differently from another. Every one of them reports: the status the gate applied, the queue placement, the counts and the exit codes are unchanged, and your decision file is never edited.

If a scan warning is bothering you, it can explain that too — and list the codes this run actually produced:

bash
sbomflow explain ./evidence --warning

Before you spend time on individual items, check whether the release is even blocked, and by what:

bash
sbomflow explain ./evidence --gate

AI entities in the review#

If the release contains AI components, explain will talk you through one of them the same way it talks you through a finding: pass --ai-entity with the entity's id — sbomflow explain ./evidence --ai-entity ID — and it prints what was observed, what was declared, where each fact came from, and what is being asked of a reviewer. The ids come from ai-evidence.json in the same output directory.

Three things are worth knowing before you act on what it prints:

  • Everything here is an observation, never an assessment. SBOMFlow does not run, load, evaluate or score a model, does not watch runtime behaviour, and never says an AI system is safe, adequate or compliant.
  • A declared-versus-observed disagreement is surfaced, never resolved. If a run was given a declaration with --ai-manifest, the declaration does not overwrite what was found; the conflict is put in front of you. A run given no declaration says plainly that nothing was compared, rather than implying agreement.
  • AI-aware release controls are off by default. When you enforce one and it cannot run, it blocks with its own reason code — an enforced control never contributes a pass because its input was missing.

3. Claim the item (coordination, not a decision)#

On a team, say you have picked something up so two people do not triage the same CVE:

bash
sbomflow review ./evidence --start secure_boot_configured --as product-security --reviewer you@example.com

This records in_review in review-claims.json. It is bookkeeping only: it changes no status, unblocks no gate, and is not evidence of anything.

4. Decide the evidence items#

Three verbs, and they mean different things. Always pass --reviewer — an unattributed decision is recorded, but it warns, and an auditor will ask who made it.

bash
sbomflow review ./evidence --accept secure_boot_configured --reviewer you@example.com --note "Verified against the signed manifest" --evidence-ref "PR-123"
bash
sbomflow review ./evidence --needs-more-evidence update_mechanism_documented --reviewer you@example.com --note "Need the signed-update procedure, not the marketing page"
bash
sbomflow review ./evidence --reject secure_boot_configured --reviewer you@example.com --note "Signal came from a stale build config"

--evidence-ref is where you point at the thing that convinced you: a PR, a test run, a document. It is the difference between a decision an auditor can follow and one they have to take on trust.

An acceptance expires, and its date has to be a real one#

An acceptance is dated. Its reviewed_at starts the freshness window (scan.evidence_valid_days, 90 by default), after which the item is reported is_stale and an enforced fail_on_stale_evidence policy blocks on it. That is the point: a decision made long enough ago stops counting as a current one.

If you hand-write a reviews file rather than using sbomflow review, two dates are refused rather than honoured, because neither can start a window:

  • one that is not an ISO-8601 timestamp (warning evidence_reviewed_at_unparseable);
  • one that names an instant later than the run itself — almost always a mistyped year (warning evidence_reviewed_at_in_future).

Both keep the acceptance on record and both fall back to the run's own observation time, so the item ages normally instead of being frozen fresh. Each warning names the evidence key and the exact value it could not use, so scan-warnings.json tells you which date to correct — run sbomflow help warning <code> for the full explanation, offline. An empty reviewed_at has always been treated this way and is not a problem.

5. Triage a finding#

A vulnerability finding needs a VEX status: affected, fixed, not_affected, or under_investigation.

bash
sbomflow review ./evidence --triage <finding-id> --status not_affected --justification vulnerable_code_not_present --reviewer you@example.com --note "TLS stack is compiled out of this image"

not_affected is the one that gets audited#

not_affected is a claim about your product, and SBOMFlow will not let you make it casually. Running --status not_affected without --justification does not quietly downgrade — it refuses, exits non-zero, and prints the five valid CISA categories:

error: 'not_affected' requires a valid CISA justification. Valid: component_not_present,
vulnerable_code_not_present, vulnerable_code_not_in_execute_path,
vulnerable_code_cannot_be_controlled_by_adversary, inline_mitigations_already_exist.

A category on its own is not an explanation. Start from the scaffold and write the real one:

bash
sbomflow review ./evidence --template vulnerable_code_not_present

The scaffold tells you what a reader will need — which function or module, which version removed it, and how you confirmed that. Nothing is ever submitted anywhere; this is text for the humans who read your evidence.

Supplying your own reachability evidence#

If your own analysis of a finding lives in a document — a manufacturer analysis packet, a call-path study, a test report — you can attach it to the run so it travels with the evidence, is hashed as provenance, and appears beside the finding a reviewer is looking at:

bash
sbomflow analyze ./my-product --output ./evidence --reachability-evidence ./reachability_inputs.json

The same file can be named in sbomflow.yaml as reachability_evidence:.

Attaching evidence records a fact; it decides nothing. It sets no VEX status, satisfies no evidence item, and never relaxes a release-gate decision — the same gate blocks with it and without it. It does not make a finding count as reachable either: it neither loosens a gate nor tightens one. Every entry is recorded as review_status: "unreviewed", because only a recorded human decision (sbomflow review) can change a status. What it changes is what a reviewer sees: the finding's reachability row gains a manual-evidence reference naming your document.

The file shape

The root may take any one of three forms. Use whichever matches how you keep the evidence:

  • {"evidence": [ … ]} — a list of entries, each naming what it is about.
  • {"vulnerabilities": {"CVE-…": { … }}} — keyed by advisory id.
  • {"components": {"pkg:pypi/name@1.2.3": { … }}} — keyed by package URL, or by a plain component name when it is not a package URL.

Every entry accepts the same fields, all optional:

FieldMeaning
idYour own stable id for the entry. Without one, a deterministic REACH-MANUAL-… id is derived from what the entry points at and from its position among the entries in the file, so adding, removing or reordering entries changes the derived id of every entry that shifts. Set your own id whenever you need the id to survive an edit — it is the key this entry is recorded under.
vulnerability_idsAdvisory ids this evidence is about.
component_purlsPackage URLs this evidence is about.
component_namesPlain component names, for components with no package URL.
supplied_filesThe documents themselves.
noteFree text for the reviewer.
reviewerWho prepared the packet. Recorded as provenance — it is not a review decision.
reviewed_atWhen they prepared it. Same: provenance, not a decision.

supplied_files entries are plain strings, not objects. A relative path is resolved relative to the inputs file (not the scanned product and not your working directory), so keeping the packets next to it is the simplest layout. Each file is SHA-256 hashed and listed in artifact-manifest.json under the category reachability_evidence_input. A file that is not found is recorded as missing and warned about (missing_reachability_evidence_input_file) — never silently dropped — and a file the run never finds contributes nothing. A file that is there but cannot be read — a mode change, an I/O error — is a different fact and is recorded as its own status, unreadable (unreadable_reachability_evidence_input_file). It too contributes nothing: a file that could not be hashed carries no integrity anchor for a reference to point at. Neither case stops the run, and neither stops the other packets in the same file from being hashed.

The inputs file itself is treated the same way. If it is missing, unreadable (unreadable_reachability_evidence_inputs) or unparseable, the run says so and carries on without it — no manufacturer evidence is applied, and nothing is assumed in its place.

A worked example, complete, for a packet stored beside the inputs file:

json
{
  "evidence": [
    {
      "id": "REACH-TLS-001",
      "vulnerability_ids": ["CVE-2024-12345"],
      "component_purls": ["pkg:pypi/requests@2.31.0"],
      "component_names": ["requests"],
      "supplied_files": ["reachability/requests-analysis.md"],
      "note": "Call-path study: the affected parser is never entered on this image.",
      "reviewer": "product-security@example.com",
      "reviewed_at": "2026-06-16T12:00:00+00:00"
    }
  ]
}

That packet is now attached to the finding. To act on it, record the decision separately with sbomflow review --triage (above) and its CISA justification. That is the reviewer boundary doing its job: the document is your analysis, the triage is your decision, and SBOMFlow will not turn one into the other.

6. Waive what you cannot fix yet#

A waiver is an honest, time-boxed statement that you know about a finding and are shipping anyway. The reason and the expiry are mandatory, because a waiver without an end date is just a silenced alarm:

bash
sbomflow review ./evidence --waive-finding <finding-id> --reason "Mitigated by network isolation; upstream fix lands in 2.5.0" --expires-at 2026-12-31 --reviewer you@example.com

A waiver excuses that finding, on that release, until that date. It is not a VEX status and it does not claim the product is unaffected.

If a later scan no longer contains the waived finding or gap, the decision is preserved as history but becomes UNAPPLIED: artifact validation names it in a note and states that it suppresses nothing. This is the expected result after a real remediation, not a broken pack. Malformed waiver JSON, unknown scopes and blank or malformed targets remain validation errors; only a well-formed decision whose former target is absent receives the historical-note treatment.

A row SBOMFlow cannot read as a decision at all — one with no scope it recognises, no target key, or a value that is not an entry — is kept and reported, never removed. It stays in waivers.json under the record key you wrote it with, it never suppresses anything, and every run that reads the file raises waiver_entry_unreadable naming the record key and which half of the identity is missing. Recording, renewing or revoking an unrelated waiver will not delete it: the file the writer produces can never be smaller than the file it read. Repair the row by giving it a scope (gap, vulnerability, license or ai) and a target, or remove it deliberately — SBOMFlow will not decide that for you.

The analyze run summary and sbomflow explain <output-dir> --gate name such a row too, and they say only what is actually known about it: it names nothing this run could match, so what it was meant to waive is unknown. That is deliberately a different report from a row whose target is known and whose bound could not be read — that one waives nothing and leaves a named item still blocking. The two need different repairs, so they are never folded into one sentence; see Troubleshooting.

Two rows for one record#

Scope spellings are aliases of one another: gap, evidence_gap and evidence-gap are one scope, as are vulnerability, vuln, cve and finding, and license and licence. So two reviewers using two accepted spellings for the same target write two rows that mean one record — as do two rows whose entry body names a scope and key different from the record key they are filed under. Only one of them can be the decision for that record.

Every colliding row is well formed. None of them is expired, refused or unreadable by being named here, and this is not the case above. The applied row is chosen by ordering the colliding record keys as written and taking the first. That rule is deliberately content-free — SBOMFlow never picks the longer or the shorter expiry, because choosing between two reviewers' exceptions is not its decision — and it means the same set of rows produces the same gate outcome whatever order the file holds them in.

Every run that reads the file raises waiver_record_key_collision naming the record key, every member with the scope spelling and approver it was written with, and which member applied; release-gate.json records the same under waiver_status.collisions, with the members that did not apply under discarded. Reconcile the rows into one and the report goes away.

What a write does to them

Recording, renewing or revoking any waiver leaves every contested row on disk. A write rebuilds waivers.json from what it read, and a contested record is written back under the record keys you authored rather than under the single canonical key the rows fold onto — because that canonical key is normally one of the two rows' own keys (gap:X beside evidence-gap:X folds onto gap:X), and writing the applied row there would replace one reviewer's exception with another's. No new record key is ever minted to make room: an identity nobody wrote is not something SBOMFlow will put in your file.

So an exception recorded for an unrelated gap does not touch the contested rows at all. They come back byte-for-byte as you wrote them, and the next run raises the same waiver_record_key_collision, with the same members, the same scope spellings and the same applied row. A row that did not apply is never removed to make the report quiet — the report and the rows go together, or a run that told you a record was contested would be followed by one that told you it was clean.

Recording, renewing or revoking the contested record itself updates the member that applies, in place, under the record key it was already filed under. You get a changed row, not a third one, and the row that did not apply is left exactly as its author wrote it. SBOMFlow will not merge the two for you: deciding which exception stands is a decision about the product, and that one is yours.

One shape cannot be kept whole, and the run does not pretend otherwise: a row filed under a record key that names a different target than its own entry body, where that record key is also another row's identity. There is no key left that holds every row, and inventing one is off the table, so the write behaves as it did before — the member that did not apply is dropped. Repair a mis-filed record key (make the key and the body agree) before it meets a contested record.

7. Approve the release#

Approvals are per-role, and separation of duties is enforced:

bash
sbomflow approve ./evidence --role product-security --reviewer alice@example.com --author bob@example.com

Check where the release stands against the roles it requires:

bash
sbomflow approve ./evidence --require-role product-security --require-role release-manager

If the declared release author tries to approve their own release, SBOMFlow refuses and exits non-zero:

error: separation of duties: approver 'bob@example.com' is the declared release
author and cannot approve their own release.

The second rule is refused the same way. If someone who already holds one of the required roles approves a second one, the approval is not recorded — a single person covering two required roles leaves the approvals permanently incomplete, so recording it would only defer the failure to the gate:

error: separation of duties: approver 'alice@example.com' already covers required
role(s) product-security on this release, and one reviewer may not cover two
required roles, so 'release-manager' would leave the approvals permanently
incomplete. Have a different person approve 'release-manager', or revoke the other
role first (--revoke --role product-security --reviewer alice@example.com).

This rule is judged against the roles you declared — --require-role, or a policy profile's required_approval_roles. Two required roles cannot be double-covered if no role is required, so an approve run that declares none records the approval and reports no violation. Supply the same roles you intend to gate on.

Not every violation can be refused at the moment of approval — a release author can be declared after the approval that conflicts with them, for instance — so when a violation does survive into the ledger, the ledger says so. approvals.json then carries a separation_of_duties block naming the roles it was evaluated against and the violations it observed, and the approve confirmation line names that field beside its sod_violations= count:

  status now: satisfied=1 missing=1 sod_violations=1 override_active=False complete=False
  recorded in the ledger as separation_of_duties.separation_of_duties_violations (1 entry)

That block is a record, not a decision. Every surface that reports approvals — the status display, release-gate.json, the HTML report — re-derives the violations from the recorded approvals themselves, so editing the block changes nothing: it cannot clear a violation that is really there, and it cannot create one that is not. Revoking the conflicting approval is what clears it, and the next write refreshes the block whether or not you repeat --require-role. A ledger written by runs that never declared a required role carries no block at all, because there was no denominator to evaluate "two required roles" against.

--reviewer is mandatory when you approve a role, and when you record an --override. Both separation-of-duties rules work by comparing identities, so an approval that names nobody could not be held to either of them — one person could satisfy every required role, and the declared author could self-approve, just by leaving --reviewer off. An emergency override is wider still: it lifts every unmet role at once, so an unattributed one is a release-wide bypass with no answer to "who broke the glass?".

SBOMFlow now refuses to record either without a named reviewer, and one already sitting in a ledger carries no authority: an unattributed approval satisfies no role (the role reads MISSING, the record is still shown as [UNATTRIBUTED — satisfies no role], never deleted) and an unattributed override suppresses nothing (the status display flags it; the enforced gate keeps blocking). Re-record with a named reviewer to clear either one.

--revoke is the one action that stays recordable without a reviewer, because it withdraws a sign-off: an unattributed revocation can only make the gate stricter, never laxer. Note what --reviewer does there, because it does two things: it names who is revoking and it selects which approvals are revoked. With it, only that reviewer's approval for the role is withdrawn; without it, every active approval for the role is. So a record that names nobody can only be retired by the unfiltered form — retire it before you re-record the proper sign-off, not after (recovery playbooks, PB-08).

A release manager on leave? Someone else can approve as their delegate, which always requires a mandatory expiry — delegation is deliberately time-boxed, never open-ended:

bash
sbomflow approve ./evidence --role product-security --reviewer carol@example.com \
  --delegate-of alice@example.com --expires-at 2026-08-01T00:00:00Z

The status report shows exactly who acted: product-security by carol@example.com (delegate of alice@example.com). It satisfies the role under the same rules as any approval — separation of duties still applies (a delegate can't be the author or cover a second role), and it reopens the role automatically the moment it expires. Delegating on behalf of someone who is themselves currently delegating is refused: delegation is one hop only, so a report can always trace a chain back to a real approver.

What min_approvals actually counts. A min_approvals quorum counts distinct humans, regardless of which role each one signed. It is not a count of roles. Two different people approving the same single role satisfy min_approvals: 2:

bash
sbomflow approve ./evidence --role product-security --reviewer alice@example.com
sbomflow approve ./evidence --role product-security --reviewer bob@example.com
# min_approvals: 2 is now met — two humans, one role.

This is deliberate, and the two rules compose rather than overlap: the quorum asks "how many people signed?", and separation of duties is what enforces role spread — one reviewer covering two required roles counts once toward the quorum and additionally raises a separation-of-duties violation. So if what you want is "two different roles signed off", require those roles with required_approval_role; min_approvals alone will not express it.

What an approval is tied to. Every recorded approval also stores the evidence pack and gate policy it was granted against, read from the release's own artifacts rather than from anything you type. That is provenance by default and changes nothing. Set fail_on_approval_binding_mismatch: true in your policy file and it becomes enforcement: an approval then satisfies its role only for the release it was granted on, and the gate blocks — naming which role, whose sign-off, and whether the evidence or the policy differed — if a ledger is reused across a re-run, a different build variant, or a policy edited after the fact.

This is the strictest knob SBOMFlow ships, deliberately: a green gate that says "approvals complete" because a sign-off carried over from a different pack is the closest thing the tool can produce to a forged human decision. Two consequences worth knowing before you turn it on:

  • Re-approving is a human act. Nothing is ever re-bound automatically, so a new analyze run needs a new sign-off.
  • Approvals recorded before this existed carry no binding, and an approval that cannot be placed is treated as unanswerable rather than acceptable — so the first release you enforce it on will ask for fresh sign-offs.

An emergency --override still works, and still lists exactly what it overrode.

8. Digest the session for standup#

Instead of reading audit-log.jsonl by eye, ask for a summary of what was just decided:

bash
sbomflow review ./evidence --digest

Read-only — it records nothing, and refuses over a tampered audit chain like every other review write. It groups the decisions you recorded above (accepted/rejected/needs-more-evidence, findings triaged, waivers, approvals) into a Markdown summary with event_hash references, prints it, and writes session-digest.md next to the evidence. Add --since <timestamp> to narrow it to just this session when the audit log spans more than one.

9. What the auditor sees afterwards#

Three surfaces, produced from the decisions you just recorded:

bash
sbomflow review-view ./evidence

reviewer.html — a static, no-JavaScript console showing every item, who decided it, when, and on what basis.

bash
sbomflow bundle ./evidence

evidence-bundle.html / evidence-bundle.json — the portable handoff. It composes existing artifacts; it adds no authority and concludes nothing.

And audit-log.jsonl — an append-only, hash-chained record of every decision event, including the ones you later changed your mind about. Each event carries an event_hash linking it to the one before, so a decision cannot be quietly removed from the history after the fact.

Changing a decision#

Re-triaging a finding does not erase the decision it replaces. The decision file keeps the current decision under reviews, and moves the one it replaced into a superseded list — the full record, verbatim, with superseded_by and superseded_at:

json
"superseded": [
  {
    "section": "reviews",
    "key": "CVE-2026-0001",
    "decision": {
      "status": "not_affected",
      "justification": "vulnerable_code_not_present",
      "reviewer": "alice@corp.com",
      "note": "Audited: built without the affected applet",
      "evidence_ref": "build-config.h:L42"
    },
    "superseded_by": "bob@corp.com",
    "superseded_at": "2026-07-02T00:00:00Z"
  }
]

This matters because the audit log records a triage's verdict — status, justification, reviewer — but not your free-text note or your evidence_ref. The decision file is the only place those live. Before supersession existed they were overwritten and gone: the two things an auditor most wants — why you said not_affected, and which bytes support it — could not be recovered from any artifact. A decision is now reversible through supersession, never deletion. --bulk behaves identically; it is the operation most capable of destroying rationale at scale.

Re-recording the same decision changes nothing and adds no history entry — an idempotent re-run must not look like a reviewer changing their mind.

What SBOMFlow will never do for you#

  • It will never set not_affected or fixed on your behalf. Only a review file does that.
  • It will never treat an observed signal as accepted evidence.
  • It will never file, transmit, or sign a regulatory report. CRA Article 14 output is an unsigned draft, marked submitted: false.
  • It will never tell you the product is compliant or conformant. The reports are engineering gap assessments, not legal conclusions.
  • It will never certify an AI system, score a model, or decide whether an AI system is safe. It will never pick a winner between what you declared and what it observed.

If a finding is genuinely exploitable in your product and you cannot fix it before release, that is an escalation to your release owner — not a status you can pick in this tool.

Next#