Status: stable
Format version: 1.0
Reader contract: semver-like — 1.x is forward-compatible for any 1.0 reader; 2.x is breaking and MUST be rejected.
This document is the source of truth for the on-disk layout, integrity contract, and version semantics of evidence bundles emitted by boruna workflow run --record and validated by boruna evidence verify.
Every bundle declares its format in a top-level bundle.json file. Readers MUST gate on this field before reading any other content.
Bundle format_version |
1.0 reader | 1.x reader (x ≥ 1) | 2.x reader |
|---|---|---|---|
| missing / pre-1.0 | reject (legacy) | reject (legacy) | reject |
"1.0" |
accept | accept | reject |
"1.5" |
accept (forward-compat) | accept | reject |
"2.0" |
reject (incompatible major) | reject | accept |
Compatibility rules:
- Same major: unknown fields are ignored. A
1.0reader presented with a1.5bundle MUST treat it as valid and silently drop fields it doesn't recognize. - Different major: the wire format is allowed to change in incompatible ways. Readers MUST refuse to interpret content from a major they don't know.
- Missing
bundle.json: the bundle is pre-1.0 (legacy). Readers MUST reject and surface a hint pointing the user atboruna migrate evidence-bundle(planned, sprint W5-C).
This applies §1 of the project conventions: "reject at parse, don't silently override". A reader that silently accepts an unknown major would let a future bundle's content be misinterpreted as the format the reader expects.
{
"format_version": "1.0",
"boruna_version": "0.6.0",
"created_at": "2026-04-28T14:32:11.420Z",
"run_id": "run-2026-04-28-abc123",
"workflow_hash": "9c8a1f...",
"components": [
"audit_log.json",
"env_fingerprint.json",
"manifest.json",
"outputs/",
"policy.json",
"workflow.json"
]
}Field semantics:
| Field | Type | Required | Description |
|---|---|---|---|
format_version |
string | yes | Semver-like format version. The major component is the compat gate. |
boruna_version |
string | yes | Version of the Boruna binary that emitted the bundle (CARGO_PKG_VERSION). Diagnostic, not a compat gate. |
created_at |
string (RFC3339, UTC) | yes | Wall-clock time the bundle was finalized. |
run_id |
string | yes | Workflow run identifier, matches manifest.run_id. |
workflow_hash |
string (hex) | yes | SHA-256 of the workflow definition JSON, matches manifest.workflow_hash. |
components |
string[] | yes | Sorted list of component file/directory names actually present in this bundle. Trailing / denotes a directory. Diagnostic; readers MUST NOT rely on this list to drive parsing. |
encryption |
object | no | Additive in 1.x (sprint W6-B). When present in manifest.json, the bundle's file contents are AES-256-GCM envelope-encrypted. See §9. Absent → plaintext bundle (original 1.0 behavior). |
Future minor versions (1.x) may add optional fields. Readers MUST tolerate them.
<bundle-dir>/
├── bundle.json # version gate (this spec)
├── manifest.json # cryptographic manifest with file checksums + bundle_hash
├── workflow.json # snapshot of the workflow definition
├── policy.json # snapshot of the active policy
├── audit_log.json # hash-chained event log
├── env_fingerprint.json # OS / arch / boruna_version captured at run time
└── outputs/
└── <step_id>/
└── <output_name>.json # per-step JSON outputs (compact form)
bundle.json is the LAST file written during finalize. Every other component is written and (where applicable) parent-dir fsynced before bundle.json is committed via the same atomic-rename + parent-dir-fsync pattern used by workflow::data_flow::DataStore::store_output. Consequence: a reader observing bundle.json is guaranteed to observe a complete bundle.
| Component | Contract |
|---|---|
manifest.json |
BundleManifest (see orchestrator/src/audit/evidence.rs). Carries file_checksums: BTreeMap<filename, sha256> for every other file (excluding bundle.json and manifest.json itself). |
workflow.json |
The workflow definition as submitted, byte-for-byte. workflow_hash = sha256(workflow.json). |
policy.json |
The policy snapshot. policy_hash = sha256(policy.json). |
audit_log.json |
AuditLog JSON; chain integrity is independently verifiable via AuditLog::verify. |
env_fingerprint.json |
OS / arch / CARGO_PKG_VERSION of the recording binary. |
outputs/<step>/<name>.json |
Compact JSON; same bytes that DataStore::hash_value hashed and that the orchestrator's SQLite checkpoint persisted. sha256sum MUST match the output_hash recorded in the audit log. |
Independent of the format gate, verify_bundle enforces:
- Every entry in
manifest.file_checksumsmatches the SHA-256 of the on-disk file. audit_log.jsonparses as a validAuditLog, and every entry'sentry_hashissha256(prev_hash || event_json). The chain is broken iff any entry fails this check.audit_log.hash()(last entry'sentry_hash) equalsmanifest.audit_log_hash.- All required components from §3 are present.
A bundle that fails any of (1)–(4) is INVALID. The verify_bundle reader emits the failing checks but does not attempt to "repair" — that is reserved for boruna migrate.
| Feature | 1.0 reader | 1.x reader (x ≥ 1) |
|---|---|---|
Read 1.0 bundle |
yes | yes |
Read 1.x bundle (x > 0) |
yes (drops unknown fields) | yes |
| Read pre-1.0 / legacy bundle | no (reject + migration hint) | no |
Read 2.x bundle |
no | no |
Producers MUST emit the lowest format version that contains every field they need; this maximizes the population of readers that can consume the bundle.
The 0.5-S7 retro flagged the sidecar layout for output blob references as the leading driver of a future 1.1 minor bump. Rationale: large LLM step outputs are stored content-addressed (/api/runs/{id}/blobs/{hash}) since 0.5-S7. Bundles currently inline-resolve the bytes into outputs/<step>/result.json. A 1.1 bundle would optionally carry a blobs/<sha256> sidecar directory and rewrite outputs/<step>/result.json to a { "$blob_ref": "<hash>" } reference. A 1.0 reader would (correctly) reject the rewritten outputs JSON unless it understands $blob_ref; therefore the sidecar layout is a 1.x field-level extension rather than a parse-time break — provided producers continue to emit the inline form when no blobs are referenced. The compat matrix is preserved.
A 2.0 break — for example, switching from JSON to a binary-framed format — is not currently planned and would require an ADR plus a migration path through boruna migrate evidence-bundle.
Bundles produced by Boruna v0.5.0 and earlier do NOT carry bundle.json. The reader rejects them with:
unsupported evidence bundle format_version: found `missing bundle.json (legacy bundle from pre-1.0 release; use `boruna migrate evidence-bundle` to upgrade)`, expected major `1`
The boruna migrate evidence-bundle tool is planned for sprint W5-C. Until it ships, legacy bundles must be re-recorded against a current binary.
The optional encryption field in manifest.json carries the metadata
for AES-256-GCM envelope encryption (sprint W6-B). When absent, the
bundle is plaintext (the original 1.0 behavior). When present, file
contents in the bundle directory are AES-256-GCM ciphertext keyed off
a fresh per-bundle data-encryption key (DEK) which is itself wrapped
with an operator-supplied key-encryption-key (KEK).
Field shape:
| Field | Type | Required | Description |
|---|---|---|---|
algorithm |
string | yes | Locked to "aes-256-gcm" for 1.x |
kek_id |
string | yes | Operator-supplied identifier; bound as AAD into the wrapped DEK |
wrapped_dek |
base64 | yes | DEK encrypted with the KEK |
wrapped_dek_nonce |
base64 | yes | Nonce for the DEK wrap |
files |
string[] | no | Operational hint listing encrypted files; readers MUST NOT use this to drive parsing |
algorithm, kek_id, wrapped_dek, and wrapped_dek_nonce are
replay-verified — they live inside manifest.json and therefore
participate in bundle_hash. files is OPERATIONAL metadata (per
project convention §15): it is informational; the canonical set of
encrypted files is derivable from manifest.file_checksums and
tampering with files cannot bypass decryption (the verify loop
iterates file_checksums, not encryption.files).
Per-file nonces are deterministic SHA-256(filename)[..12]. This is
safe because the DEK is freshly generated per bundle and filenames are
unique within a bundle, so a (DEK, nonce) pair never repeats. Each
per-file ciphertext carries an AES-GCM authentication tag; tag failure
on read surfaces as evidence.cipher_tag_invalid.
A 1.x reader:
- MUST accept bundles WITHOUT
encryption(plaintext path; original 1.0 behavior). - MUST accept bundles WITH
encryptionif a KEK is available. - MUST reject
encryption.algorithmvalues other than"aes-256-gcm"withevidence.unsupported_algorithm. - MUST surface DEK-unwrap failure as
evidence.encryption_key_mismatchand a missing KEK asevidence.encryption_key_required. - MUST surface per-file tag mismatch as
evidence.cipher_tag_invalidand refuse to return decrypted bytes from the failing file. - MUST NOT log the unwrapped DEK at any severity.
Per project convention §1 ("reject at parse, don't silently override"), a reader MUST refuse to interpret a bundle when:
encryptionis present butalgorithmis anything other than"aes-256-gcm"→evidence.unsupported_algorithm.encryptionis present but a required field (kek_id,wrapped_dek,wrapped_dek_nonce) is missing or malformed (non-base64, wrong length) → reader-defined parse error.encryptionis present but no KEK has been supplied to the reader →evidence.encryption_key_required.
A 1.0 reader (pre-W6-B) that does not know the encryption field will
ignore it as an unknown field per §1 of this spec, then fail integrity
verification because the on-disk bytes hash to ciphertext rather than
plaintext referenced by manifest.file_checksums. Operators using a
1.0 reader against an encrypted bundle MUST upgrade to a 1.x reader
that understands encryption. This is the documented compat story per
§B.3 of the LTS contract (docs/lts.md).
- Implementation:
orchestrator/src/audit/evidence.rs(BundleJson,EvidenceBundleBuilder::finalize) - Reader gate:
orchestrator/src/audit/verify.rs(check_bundle_format,verify_bundle) - Constant:
orchestrator/src/audit/mod.rs(BUNDLE_FORMAT_VERSION) - Concept doc:
docs/concepts/evidence-bundles.md - CLI surface:
boruna evidence verify,boruna evidence inspect [--json]