Bundles, disclosure & verification
An evidence bundle is everything a relying party needs to check a set of records offline: the records and their Signed Statements, an RFC 6962 inclusion proof for each against the newest included tree head, the tree heads with any witness receipts, and consistency proofs between consecutive heads so the heads are shown to be one append-only history.
Building a bundle
Section titled “Building a bundle”From the console (Compliance → Evidence Ledger) or the admin API, which delegates to the core:
curl -X POST http://localhost:5050/v1/admin/evidence/bundles \ -H "Authorization: Bearer $ADMIN_TOKEN" -H "Content-Type: application/json" \ -d '{ "selector": { "ai_system_id": "rg-claims-agent", "from": "2026-09-01T00:00:00Z", "to": "2026-09-19T00:00:00Z" }, "audience": "Example Audit LLP", "closure_depth": 1, "framework_id": "eu-ai-act" }'| Selector | Selection | Meaning |
|---|---|---|
{"record_ids": [...]} |
producer-selected |
a hand-picked list — the verifier is told so |
{"root_task_id": "..."} |
contiguous |
every record of one run |
{"ai_system_id": "...", "from": "...", "to": "..."} |
contiguous |
every record of a system in a window |
closure_depth (0–8, default 1) follows chain parents that many hops. The core forces a
tree head first if the newest selected record is not yet covered, so every proof is against
a signed head. Limits: 10 000 records including closure, 50 MB. Bodies never enter records,
so payloads: "all" is refused (409 payloads_unavailable) rather than silently degraded.
The bundle, member by member
Section titled “The bundle, member by member”ai.brutor/evidence-bundle/v1:
{ "manifest": { "kind": "ai.brutor/evidence-bundle", "version": 1, "bundle_id", "root_record_id"?, "selector", "produced_at", "issuer", "log_id", "kids": [...], "framework_id"?, "catalog_version"?, "completeness": { "closure_depth", "records_mode": "complete" | "declared_incomplete", "selection": "contiguous" | "producer-selected", "payloads_mode": "none", "suppressed_fields": [], "missing": [record_id...] }, "verification": { "self_report": { "records": n, "proofs": n, "heads": n } } }, "records": [ { "record": {...}, "statement_b64": "...", "seq": n } ], "proofs": [ { "record_id", "leaf_index", "tree_size", "path": [hex...] } ], // against tree_heads[-1] "tree_heads": [ { "statement_b64", "transparent_statement_b64"?, "receipts": [ { "kind", "receipt_b64", "grade" } ] } ], "consistency":[ { "from_size", "to_size", "path": [hex...] } ], // between consecutive heads "prunes": [...], // declared prunes overlapping the window "disclosures":{}, // revealed preimages (none in v1) "extensions": { "ai.brutor/evidence-report/v1": {...} }, // digest-covered, uninterpreted by others "countersignatures": [] } // excluded from the digest| Member | Purpose |
|---|---|
manifest.selector, selection |
what was asked for, and whether it is a contiguous range or a producer’s choice |
manifest.completeness.missing |
chain parents neither included nor followed. Missing is listed, never dropped; a non-empty list forces records_mode: "declared_incomplete" |
records[] |
each record with its Signed Statement and log sequence |
proofs[] |
one inclusion proof per record against the newest head |
tree_heads[] |
the first head covering each record plus the newest, with receipts and the Transparent Statement (receipts at label 394) when witnessed |
consistency[] |
RFC 6962 consistency proofs between consecutive included heads |
prunes[] |
declared prunes in the window — gaps with their reason |
extensions |
e.g. an Evidence Report’s rows |
countersignatures[] |
later signatures (an auditor’s) that must not change the identity the producer sealed |
Bundle digest = hex(SHA-256(JCS(bundle minus "countersignatures"))). The core seals
it as an ai.brutor.bundle record, so the digest a verifier compares against is itself in
the log.
Three completeness claims, reported separately
Section titled “Three completeness claims, reported separately”- Graph closure — every chain parent is included or listed in
missing(records_mode, checked byclosure_parent_dropped/records_mode_mismatch). - Interval coverage —
selection: "contiguous"states that every record of the run or window was selected;producer-selectedstates that it was not, and the verifier is told so. - Per-record membership — each record is in the tenant log at the newest head (inclusion proof).
Each head’s receipts carry their own grade, so a bundle is only as witnessed as its heads; until a receipt verifies, the claims above are the producer’s own. None of them proves there was no action the gateway never saw: integrity is not completeness. Scope is a human-signed census.
Disclosure records
Section titled “Disclosure records”Producing a bundle for a named audience seals a disclosure: an ai.brutor.disclosure
record (the audience enters only as a digest) and a log entry of kind disclosure.
completeness is contiguous for a range and always producer-selected for an
explicit list. Viewing is not disclosing — only a bundle built with an audience
creates one.
An answer to an auditor or regulator is exactly one of three things:
| Answer | What the platform does today |
|---|---|
| record | a bundle; with an audience, the disclosure is sealed into the tenant log |
| refusal | a request that cannot be met honestly is refused, not degraded — e.g. payloads: "all" → 409 payloads_unavailable |
| absence | no record matches the selector → 404 no_records; an absence is an answer, not silence |
Only the record answer is sealed into the log today; record refusals and absences you give to an authority in your own correspondence.
List disclosures with GET /v1/admin/evidence/disclosures (Evidence Ledger →
Disclosures).
Verifying
Section titled “Verifying”Integrity can be checked by anyone. Record semantics are checked by Brutor’s published rules, which a third party can reimplement from this page and the vector corpus.
Everything cryptographic is a published standard:
- Parse
statement_b64as a COSE_Sign1 (RFC 9052). Check the Ed25519 signature under a key from the public key document that you pinned out-of-band. - Check the payload equals SHA-256 of the RFC 8785 bytes of the record without
record_id(RFC 9995 hash envelope, label 258 = −16). - Compute the leaf
SHA-256(0x00 ‖ statement bytes)and check its RFC 6962 inclusion proof against the newest head’sroot. - Verify each head’s signature and payload, and the consistency proofs between heads.
- Verify each scitt receipt as an RFC 9942 COSE Receipt over the head statement, under
the witness’s pinned key; RFC 3161 tokens with
openssl ts -verify.
The corpus ships vectors/interop/verify_with_scitt_cose.py, which does exactly this with
the upstream scitt-cose Python package — nothing from Brutor — and optionally the Go
verifier.
brutor-verify bundle bundle.json --keys brutor-evidence-keys.json \ --expected-digest aa9732219419604f55ea7ef1ad14637a1abef288d25eeb757b62f852af9f7539 \ --witness-key 3f09ef99ddf662818513adac94d6e220186225f5d99ce94ca7263a4bbaf08699 --jsonbrutor-verify bundle <bundle.json> [--keys well-known.json]... [--key HEX]... [--expected-digest HEX] [--witness-key HEX]... [--tsa-pin PEM-FILE|HEX]... [--json]brutor-verify record <record.json> [--statement FILE | --statement-b64 B64] [--store FILE] [keys] [--json]brutor-verify statement <FILE | --b64 B64> [--record FILE] [keys] [--json]brutor-verify head <FILE | --b64 B64> [--previous FILE | --previous-b64 B64] [--consistency HEX,...] [keys] [--json]brutor-verify receipt <FILE | --b64 B64> (--statement FILE | --statement-b64 B64) [--kind scitt|rfc3161] [--pin HEX|PEM-FILE] [--json]Binary inputs may be raw bytes or base64 text. A bundle API response that wraps a bundle
is accepted as-is. Exit status: 0 no error finding, 1 at least one error finding,
2 usage or I/O error. Without --keys/--key, signatures are checked under the key each
statement names and the report says so (kid_not_pinned) — a self-consistency check, not
an attribution.
The report (--json) is {artefact, ok, exit_code, checks[{name, outcome: ok|failed|skipped, detail}], findings[{code, severity, detail, record_id?, rule?}], facts{…}}. It never says
more than the checks it names — no “verified” or “compliant” verdict; the relying party
draws conclusions.
No clock, no network, no randomness: the same inputs give the same report on every
platform. Build it from brutor-gateway-core:
cargo build -p brutor-verify --release. Release binaries are published per platform with
their SHA-256 in the release notes.
The offline verifier page is the same Rust verifier compiled to WebAssembly. Paste or drop a bundle, record, statement, head or receipt, and a keys document. Nothing leaves the page: no server, no network calls, no analytics — and it works opened from disk. The console’s Evidence Ledger links every bundle there as a permalink.
POST /v1/admin/evidence/verify (a record) and POST /v1/admin/evidence/bundles/verify
on the Control Plane delegate to the core’s POST /v1/evidence/verify/* family — see the
API reference. An unreachable core is reported as a
finding, never as a pass. Useful inside your own estate; an outside party should use their
own tooling.
The semantic rules
Section titled “The semantic rules”Fixed order, structured result, never throws, no clock, no network, no model. Only
error findings gate ok; warning and info never do.
| # | Rule | Finding codes (severity) |
|---|---|---|
| 1 | Structural — object, schema, required members (schema action_id kind operator system timestamp verdict), types, digest hygiene (no floats, safe integers), only admitted members, no never-enters member at any depth |
not_an_object, unsupported_schema, missing_required_field, field_not_string, field_not_bool, field_not_array, block_not_object, record_id_malformed, unadmitted_field, never_enters_member, kind_invalid, approver_invalid, float_in_digest_field, unsafe_integer_in_digest_field, not_canonicalizable, reference_malformed (error) |
| 2 | Identity — recompute record_id |
record_id_mismatch, record_id_uncomputable (error) |
| 3 | Confirmed-effect binding — confirmed needs a response_digest; planned carries none |
confirmed_without_response, planned_with_digests (error) |
| 4 | Verdict / effect orthogonality — a never-dispatching verdict must derive not_applicable |
verdict_effect_conflict (error) |
| 5 | Attestation presence — a dispatched effect carries attestation |
attestation_missing (error), attestation_on_planned (info) |
| 6 | Chain — parent present in the store, well-formed, relation set, not duplicated as a reference; concurrent supersedes |
chain_parent_missing, chain_parent_malformed, reference_duplicates_chain_parent (error); chain_check_store_level, concurrent_supersedes (info) |
| 7 | Claims never above derived — reserved; v1 records carry no claimed modes | — |
| 8 | Unknown vocabulary — a verdict, decision, effect status or relation this verifier does not know | unknown_vocabulary_value (info — never grades up) |
| + | Defensive honesty — human_disposed: true with a non-human approver (the builder already refuses to sign such a record) |
dishonest_human_disposed (warning) |
Bundle finding codes
Section titled “Bundle finding codes”| Code | Severity | Meaning |
|---|---|---|
bundle_not_object |
error | not a JSON object |
bundle_digest_mismatch |
error | computed digest ≠ the expected (sealed) digest |
digest_uncomputable |
error | the bundle cannot be canonicalised |
manifest_invalid |
error | missing manifest, or wrong kind / version |
records_mode_mismatch |
error | records_mode contradicts missing |
payloads_mode_unsupported |
error | v1 bundles carry no payloads |
kid_not_pinned |
warning | no trusted keys supplied |
structure_invalid |
error | a member has the wrong shape |
untrusted_kid |
error | a statement’s key is not in the trust set |
head_invalid, head_log_mismatch |
error | a head fails its signature or payload checks, or names another log |
transparent_statement_mismatch |
error | the Transparent Statement is not the head statement plus receipts |
heads_not_ascending |
error | heads out of order |
consistency_missing, consistency_invalid |
error | consecutive heads not proven to be one history |
duplicate_record |
error | a record appears twice |
record_semantic_error |
error | a record fails a semantic rule above |
record_statement_invalid, statement_payload_mismatch |
error | bad signature, or the statement does not commit to this record |
proof_for_unknown_record, proof_wrong_head, leaf_index_mismatch, proof_invalid, proof_missing |
error | inclusion-proof failures |
closure_parent_dropped |
error | a chain parent is neither included nor listed as missing |
missing_listed_but_present |
warning | listed as missing but included |
record_not_in_selector |
error | an explicit-id bundle contains a record neither selected nor within the closure |
self_report_mismatch |
warning | the manifest’s counts disagree with the contents |
brutor-verify adds: input_invalid, kid_not_pinned, untrusted_kid, kid_retired
(info), kid_invalid, statement_malformed, signature_invalid,
statement_payload_mismatch, head_malformed, head_signature_invalid,
head_consistency_invalid, previous_head_invalid, head_log_mismatch,
head_not_consistent, consistency_undetermined, receipt_invalid, receipt_unpinned
(warning — not evidence of forgery), receipt_not_checked (info), receipt_kind_unknown.

