diff --git a/.gitignore b/.gitignore
index 5bc574b..0eee271 100644
--- a/.gitignore
+++ b/.gitignore
@@ -28,3 +28,6 @@ llm_cache/
# Local Claude Code agent state (worktrees, transcripts)
/.claude/
/.claire/
+
+# local evidence/run artifacts
+.boruna/
diff --git a/AGENTS.md b/AGENTS.md
index ba37b24..4eb497a 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -47,6 +47,7 @@ The server communicates over JSON-RPC stdio. All tools return structured JSON wi
| `boruna_capability_list` | List the frozen 1.0 capability set with `capability_set_hash` |
| `boruna_policy_validate` | Validate a `Policy` JSON document; returns typed `error_kind` on rejection |
| `boruna_symbols` | Extract top-level symbols (fns/records/enums) from `.ax` source → exact typed signatures, capabilities, requires/ensures arity |
+| `boruna_run_sealed` | Compile + run `.ax`, replay-verify the execution, return a verifiable record (result, `replay_verified`, capability calls, event log, SHA-256 seal digest) |
## Agent-native CLI surfaces
diff --git a/CHANGELOG.md b/CHANGELOG.md
index c2fd561..c722d18 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,54 @@ Versioning follows [Semantic Versioning](https://semver.org/).
## [Unreleased]
+## [3.1.0] — 2026-07-18
+
+Additive feature release — no breaking changes. Deepens Boruna's two moats:
+**verifiable/auditable evidence** (standards interop, compliance reporting,
+observability export, sealed contract/guard verdicts) and **agent authoring**
+(exact-signature lookup, a run-and-seal execution cell, an agent corpus). Ideas
+were mined from adjacent tooling (Temporal/LangGraph/Langfuse/Credo AI/SLSA/
+in-toto/Sigstore) and from the `agentlanguages.dev` peer catalogue, then mapped
+onto Boruna's determinism + evidence model.
+
+### Added
+
+- **In-toto + DSSE attestation** — `boruna evidence attest
` emits the
+ bundle as an in-toto Statement (`predicateType https://boruna.dev/runtime-provenance/v1`)
+ wrapped in a DSSE envelope signed with the bundle's ed25519 key; `--verify`
+ checks it. Makes runtime-execution provenance consumable by the supply-chain
+ ecosystem (`cosign`, `in-toto-verify`). Additive — the native bundle is unchanged.
+ Predicate schema: `docs/spec/runtime-provenance-predicate-1.0.md`.
+- **Compliance-mapping report** — `boruna evidence report --framework eu-ai-act|nist|iso42001`
+ verifies a bundle, then maps its contents to the specific obligation each helps
+ satisfy (EU AI Act Art. 12/19/26, NIST AI RMF, ISO/IEC 42001), honestly flagging
+ gaps. A technical mapping, not a certificate of compliance.
+- **OpenTelemetry export** — `boruna evidence otel ` emits the run as OTLP/JSON
+ spans (no SDK dep, no network) with tamper-evidence attributes
+ (`boruna.bundle_hash`, `audit_log_hash`, `signature.keyid`) and `gen_ai.*` spans
+ for `llm.*` calls, so a run surfaces in any OTel backend while linking back to a
+ verifiable record.
+- **Sealed contract + guard verdicts** — `requires`/`ensures` contract checks now
+ record a `ContractCheck` event (pass and fail) into the hash-chained evidence log.
+ New `__builtin_guard(value, passed, label)` runs a deterministic output check,
+ traps fail-closed on violation, and seals the verdict — so "the guardrail ran on
+ this model output and returned this verdict" becomes a replayable, tamper-evident fact.
+- **`std-guard` standard library** (14th lib) — pure, deterministic output validators
+ (length/range/allow-list/ban-list/refusal-heuristic/json-shape).
+- **MCP tools** (now 14) — `boruna_symbols` (exact typed signatures for `.ax` source)
+ and `boruna_run_sealed` (compile + run + replay-verify → a verifiable execution record).
+- **Quickfix-coverage CI gate** — every auto-fixable diagnostic must ship a repair
+ strategy or be explicitly allow-listed.
+- **Agent corpus & docs** — `llms.txt`, an `.ax` teaching primer, a static agent
+ portal manifest, an evidence threat model, and a runtime-execution-provenance
+ positioning doc.
+
+### Fixed
+
+- **`docs/reference/ax-language.md` syntax drift** — corrected to the real grammar
+ (records use `type`, enum variants are unit or single-payload, match arms use bare
+ variant names), verified with `boruna lang check`.
+
## [3.0.0] — 2026-07-18
Removes the entire HTTP / serving / distributed-execution layer. Boruna is now a
diff --git a/CLAUDE.md b/CLAUDE.md
index 503d379..4785fa9 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -107,7 +107,7 @@ Note: directory paths still use original names (crates/llmbc, crates/llmc, etc.)
- **boruna-framework** (dir: crates/llmfw) — Framework layer enforcing the App protocol (Elm architecture: init/update/view). `AppValidator`, `AppRuntime`, `TestHarness`, `PolicySet`, state machine diffing.
- **boruna-effect** (dir: crates/llm-effect) — Token-optimized LLM integration: prompt building, context management, caching, normalization, capability gating for LLM calls.
- **boruna-cli** (dir: crates/llmvm-cli) — CLI binary (`boruna`). Subcommands: compile, run, trace, replay, inspect, ast, framework, lang, trace2tests, template, workflow, evidence.
-- **boruna-mcp** (dir: crates/boruna-mcp) — MCP server binary (`boruna-mcp`). Exposes 13 tools over JSON-RPC stdio for AI coding agents. Built on rmcp v0.16.
+- **boruna-mcp** (dir: crates/boruna-mcp) — MCP server binary (`boruna-mcp`). Exposes 14 tools over JSON-RPC stdio for AI coding agents. Built on rmcp v0.16.
### Supporting Crates
@@ -122,8 +122,8 @@ Note: directory paths still use original names (crates/llmbc, crates/llmc, etc.)
### Standard Libraries (libs/)
-13 deterministic libraries, each with `package.ax.json` and `src/core.ax`:
-std-ui, std-forms, std-authz, std-http, std-db, std-sync, std-validation, std-routing, std-storage, std-notifications, std-testing (all 1.0-stable as of v1.2.0), plus std-llm and std-json (1.0-stable as of v1.3.0). All 13 are 1.0-stable.
+14 deterministic libraries, each with `package.ax.json` and `src/core.ax`:
+std-ui, std-forms, std-authz, std-http, std-db, std-sync, std-validation, std-routing, std-storage, std-notifications, std-testing (all 1.0-stable as of v1.2.0), plus std-llm and std-json (1.0-stable as of v1.3.0), plus std-guard (deterministic output validators). The original 13 are 1.0-stable.
All are pure-functional (no hidden side effects). Libraries needing capabilities declare them in their manifest (e.g., std-http requires `net.fetch`, std-db requires `db.query`).
@@ -152,6 +152,7 @@ MCP (Model Context Protocol) server that exposes Boruna's toolchain to AI coding
| `boruna_capability_list` | Report the capability-set identity hash for `.ax` source |
| `boruna_policy_validate` | Validate a policy definition (strict validator) |
| `boruna_symbols` | Extract top-level symbols (fns/records/enums) from `.ax` source → exact typed signatures, capabilities, requires/ensures arity |
+| `boruna_run_sealed` | Compile + run `.ax`, replay-verify the execution, return a verifiable record (result, `replay_verified`, capability calls, event log, SHA-256 seal digest). Not a signed bundle — that's the workflow path |
### IDE Configuration
diff --git a/Cargo.lock b/Cargo.lock
index 83c1957..edfadff 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -289,7 +289,7 @@ checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c"
[[package]]
name = "boruna-benches"
-version = "3.0.0"
+version = "3.1.0"
dependencies = [
"boruna-bytecode",
"boruna-compiler",
@@ -302,7 +302,7 @@ dependencies = [
[[package]]
name = "boruna-bytecode"
-version = "3.0.0"
+version = "3.1.0"
dependencies = [
"serde",
"serde_json",
@@ -312,7 +312,7 @@ dependencies = [
[[package]]
name = "boruna-cli"
-version = "3.0.0"
+version = "3.1.0"
dependencies = [
"boruna-bytecode",
"boruna-compiler",
@@ -331,7 +331,7 @@ dependencies = [
[[package]]
name = "boruna-compiler"
-version = "3.0.0"
+version = "3.1.0"
dependencies = [
"boruna-bytecode",
"boruna-vm",
@@ -345,7 +345,7 @@ dependencies = [
[[package]]
name = "boruna-effect"
-version = "3.0.0"
+version = "3.1.0"
dependencies = [
"boruna-bytecode",
"serde",
@@ -356,7 +356,7 @@ dependencies = [
[[package]]
name = "boruna-framework"
-version = "3.0.0"
+version = "3.1.0"
dependencies = [
"boruna-bytecode",
"boruna-compiler",
@@ -369,7 +369,7 @@ dependencies = [
[[package]]
name = "boruna-lsp"
-version = "3.0.0"
+version = "3.1.0"
dependencies = [
"boruna-compiler",
"boruna-tooling",
@@ -381,7 +381,7 @@ dependencies = [
[[package]]
name = "boruna-mcp"
-version = "3.0.0"
+version = "3.1.0"
dependencies = [
"boruna-bytecode",
"boruna-compiler",
@@ -395,13 +395,14 @@ dependencies = [
"schemars 1.2.1",
"serde",
"serde_json",
+ "sha2",
"tempfile",
"tokio",
]
[[package]]
name = "boruna-orchestrator"
-version = "3.0.0"
+version = "3.1.0"
dependencies = [
"aes-gcm",
"base64 0.22.1",
@@ -432,7 +433,7 @@ dependencies = [
[[package]]
name = "boruna-pkg"
-version = "3.0.0"
+version = "3.1.0"
dependencies = [
"boruna-bytecode",
"boruna-compiler",
@@ -445,7 +446,7 @@ dependencies = [
[[package]]
name = "boruna-tooling"
-version = "3.0.0"
+version = "3.1.0"
dependencies = [
"boruna-bytecode",
"boruna-compiler",
@@ -462,7 +463,7 @@ dependencies = [
[[package]]
name = "boruna-vm"
-version = "3.0.0"
+version = "3.1.0"
dependencies = [
"boruna-bytecode",
"opentelemetry",
diff --git a/Cargo.toml b/Cargo.toml
index ddd0584..9da926e 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -16,7 +16,7 @@ members = [
]
[workspace.package]
-version = "3.0.0"
+version = "3.1.0"
edition = "2021"
[workspace.dependencies]
diff --git a/README.md b/README.md
index e32e0c9..f28d237 100644
--- a/README.md
+++ b/README.md
@@ -31,9 +31,9 @@ This makes Boruna suited for teams building AI workflows that touch regulated da
- **Diagnostics, auto-repair, and migration** — `boruna lang check`, `boruna lang repair`, `boruna migrate` for `.ax` files and bundle/workflow upgrades
- **`boruna new`** — interactive scaffold for new workflows from templates
- **33 built-in functions** — string (12), list (7), and map (7) operations plus type conversions and debug builtins (`__builtin_string_*`, `__builtin_list_*`, `__builtin_map_*`, …) available in every `.ax` file without imports
-- **Import resolution** — `import "std-name"` inlines `libs//src/core.ax` at compile time; all 13 stdlib packages are 1.0-stable
+- **Import resolution** — `import "std-name"` inlines `libs//src/core.ax` at compile time; 14 stdlib packages (the original 13 are 1.0-stable)
- **Four formal versioned specifications** — `.ax` language 1.0, bytecode 1.0, evidence bundle format 1.0, workflow DAG schema 1.0 (all under [`docs/spec/`](./docs/spec/))
-- **MCP server** — exposes 13 tools for AI coding agent integration (Claude Code, Cursor, Codex)
+- **MCP server** — exposes 14 tools for AI coding agent integration (Claude Code, Cursor, Codex)
## What Boruna is not
diff --git a/crates/boruna-mcp/Cargo.toml b/crates/boruna-mcp/Cargo.toml
index 6c89cca..053da0c 100644
--- a/crates/boruna-mcp/Cargo.toml
+++ b/crates/boruna-mcp/Cargo.toml
@@ -23,3 +23,4 @@ schemars = "1.0"
clap = { workspace = true }
tempfile = "3"
jsonschema = { version = "0.30", default-features = false }
+sha2 = "0.10"
diff --git a/crates/boruna-mcp/src/server.rs b/crates/boruna-mcp/src/server.rs
index bab3b81..4db1c53 100644
--- a/crates/boruna-mcp/src/server.rs
+++ b/crates/boruna-mcp/src/server.rs
@@ -87,6 +87,21 @@ struct RunLimitsParams {
max_memory_mb: Option,
}
+#[derive(Serialize, Deserialize, JsonSchema)]
+struct RunSealedParams {
+ /// The .ax source code to run and seal
+ source: String,
+ /// Capability policy — SAME shape as `boruna_run`. Either the string
+ /// shorthand "allow-all" / "deny-all" (default: "allow-all") or a Policy
+ /// object (see docs/reference/policy-schema.md). Invalid values return
+ /// success=false with error_kind="invalid_policy" or a policy.* kind.
+ #[serde(default)]
+ policy: Option,
+ /// Maximum execution steps (default: 10000000). Deterministic ceiling,
+ /// applied to both the original and the replay run.
+ max_steps: Option,
+}
+
#[derive(Serialize, Deserialize, JsonSchema)]
struct SymbolsParams {
/// The .ax source code to extract top-level symbols from
@@ -334,6 +349,27 @@ impl BorunaMcpServer {
Ok(CallToolResult::success(vec![Content::text(result)]))
}
+ // ── Run-and-Seal Tool ──
+
+ #[tool(
+ description = "Compile and execute .ax source, then return a VERIFIABLE execution record — not just the result. The run is executed once to capture the VM EventLog (capability calls/results, actor events, UI emits, and requires/ensures contract checks), then RE-EXECUTED a second time with the recorded capability results fed back through a replay handler; the two logs are compared with ReplayEngine::verify_full. The response carries `replay_verified` (true only when every event recurs identically), `result`, `steps`, an ordered `capability_calls` list, the full `event_log`, and `event_log_sha256` — a SHA-256 digest of the canonical log that acts as a stable seal handle. `policy` uses the SAME shape as boruna_run. IMPORTANT: the 'seal' here is a replay-verified event log, NOT a signed evidence bundle — a signed, hash-chained bundle is a workflow-directory artifact produced by the orchestrator (`boruna workflow run --record` / `boruna evidence verify`); the response documents this in seal.note. Use this to call Boruna as a deterministic, auditable execution cell from an external agent framework. Domain errors (compile/parse failures, runtime_error, capability_denied, invalid_policy) are returned as success=false JSON."
+ )]
+ async fn boruna_run_sealed(
+ &self,
+ Parameters(params): Parameters,
+ ) -> Result {
+ validate_source(¶ms.source)?;
+ let source = params.source;
+ let policy = params.policy;
+ let max_steps = params.max_steps.unwrap_or(10_000_000);
+ let result = tokio::task::spawn_blocking(move || {
+ tools::sealed::run_sealed(&source, policy.as_ref(), max_steps)
+ })
+ .await
+ .map_err(|e| McpError::internal_error(format!("task join error: {e}"), None))?;
+ Ok(CallToolResult::success(vec![Content::text(result)]))
+ }
+
// ── Diagnostics Tools ──
#[tool(
diff --git a/crates/boruna-mcp/src/tools/mod.rs b/crates/boruna-mcp/src/tools/mod.rs
index 5e9aef8..af488ba 100644
--- a/crates/boruna-mcp/src/tools/mod.rs
+++ b/crates/boruna-mcp/src/tools/mod.rs
@@ -4,6 +4,7 @@ pub mod compile;
pub mod framework;
pub mod policy;
pub mod run;
+pub mod sealed;
pub mod symbols;
pub mod template;
pub mod workflow;
@@ -121,6 +122,27 @@ mod protocol_version_tests {
assert_protocol_version(&out, "run compile failure");
}
+ // ── run_sealed ──
+
+ #[test]
+ fn run_sealed_success_carries_protocol_version() {
+ let out = sealed::run_sealed("fn main() -> Int { 1 + 2 }\n", None, 1_000_000);
+ assert_protocol_version(&out, "run_sealed success");
+ }
+
+ #[test]
+ fn run_sealed_compile_failure_carries_protocol_version() {
+ let out = sealed::run_sealed("@@@ not valid", None, 1_000_000);
+ assert_protocol_version(&out, "run_sealed compile failure");
+ }
+
+ #[test]
+ fn run_sealed_invalid_policy_carries_protocol_version() {
+ let bad = serde_json::json!(42);
+ let out = sealed::run_sealed("fn main() -> Int { 1 }\n", Some(&bad), 1_000_000);
+ assert_protocol_version(&out, "run_sealed invalid_policy");
+ }
+
// ── check / repair ──
#[test]
diff --git a/crates/boruna-mcp/src/tools/run.rs b/crates/boruna-mcp/src/tools/run.rs
index 0ad71d4..11d338f 100644
--- a/crates/boruna-mcp/src/tools/run.rs
+++ b/crates/boruna-mcp/src/tools/run.rs
@@ -600,7 +600,7 @@ pub(crate) fn parse_policy(value: Option<&JsonValue>) -> Result serde_json::Value {
+pub(crate) fn format_value(value: &Value) -> serde_json::Value {
match value {
Value::Int(n) => serde_json::json!(n),
Value::Float(f) => serde_json::json!(f),
diff --git a/crates/boruna-mcp/src/tools/sealed.rs b/crates/boruna-mcp/src/tools/sealed.rs
new file mode 100644
index 0000000..038e050
--- /dev/null
+++ b/crates/boruna-mcp/src/tools/sealed.rs
@@ -0,0 +1,449 @@
+//! `boruna_run_sealed` — run an `.ax` program and return a **verifiable
+//! execution record**, not just the result value.
+//!
+//! ## What "sealed" means here (honest scope)
+//!
+//! A full, signed, hash-chained **evidence bundle** is a workflow-directory
+//! artifact produced by the orchestrator (`boruna workflow run --record` →
+//! `boruna evidence verify`). It requires a workflow definition, a
+//! DataStore, and a filesystem output directory — none of which exist at the
+//! scope of a single stateless MCP `source`-string call.
+//!
+//! What a single MCP run *can* honestly produce is the strongest artifact the
+//! run actually generates: the VM's [`boruna_vm::replay::EventLog`] plus a **deterministic
+//! replay proof**. This tool:
+//!
+//! 1. compiles + runs the source under the requested policy, capturing the
+//! original [`boruna_vm::replay::EventLog`] (every capability call/result, actor event, UI
+//! emit, and `requires`/`ensures` contract check);
+//! 2. re-executes the same module a second time, feeding the recorded
+//! capability results back through a [`ReplayHandler`] so the replay is
+//! hermetic (no re-invocation of side effects);
+//! 3. compares the two logs with [`ReplayEngine::verify_full`] — every event
+//! must recur in the same order with identical payloads;
+//! 4. returns `replay_verified`, the full event log, an ordered list of
+//! capability calls, and a SHA-256 digest of the canonical event log that
+//! acts as a stable **seal handle** a caller can pin.
+//!
+//! So the seal here is a **replay-verified event log**, deliberately *not* a
+//! signed bundle. The response says so in its `seal.kind` /`seal.note` fields
+//! and points at the workflow path for callers who need the full bundle. We
+//! never fabricate a bundle.
+
+use boruna_bytecode::Value;
+use boruna_vm::capability_gateway::{CapabilityGateway, ReplayHandler};
+use boruna_vm::error::VmError;
+use boruna_vm::replay::{Event, ReplayEngine, ReplayResult};
+use boruna_vm::vm::Vm;
+use serde_json::Value as JsonValue;
+use sha2::{Digest, Sha256};
+
+use super::TOOL_RESPONSE_PROTOCOL_VERSION;
+use crate::tools::run::{format_value, parse_policy};
+
+/// Cap on the number of events embedded in the `event_log` array and the
+/// `capability_calls` array, mirroring `run.rs`'s `TRACE_LIMIT`. The SHA-256
+/// digest is always computed over the FULL log, so truncating the embedded
+/// view never weakens the seal — a caller can still detect tampering via the
+/// digest even when the array is clipped for transport.
+const EVENT_LOG_LIMIT: usize = 1000;
+
+/// Human-facing description of the seal semantics. Kept in one place so the
+/// response and the docs stay in lockstep.
+const SEAL_NOTE: &str = "This seal is a deterministic replay proof over the VM EventLog \
+ (ReplayEngine::verify_full), not a signed evidence bundle. A signed, hash-chained \
+ evidence bundle is a workflow-directory artifact produced by the orchestrator — see \
+ `boruna workflow run --record` and `boruna evidence verify`.";
+
+/// Compile, run, replay-verify, and seal `.ax` source.
+///
+/// `policy` uses the exact same shape [`boruna_run`](crate::tools::run) accepts
+/// (`None` → allow-all, `"allow-all"`/`"deny-all"` shorthand, or a strict
+/// Policy object). `max_steps` is the deterministic execution ceiling applied
+/// to BOTH the original and the replay run.
+///
+/// Domain failures follow the crate convention — returned as a successful tool
+/// response with `success: false` and a stable `error_kind`:
+/// - compile failure → the compiler's own error JSON (`error_kind: "parse_error"` etc.)
+/// - `invalid_policy` / `policy.*` → policy parse/validation failure
+/// - `capability_denied` → the run hit a `CapabilityDenied`/budget error
+/// (the denied capability name is reflected in `capability`)
+/// - `runtime_error` → any other VM error
+pub fn run_sealed(source: &str, policy: Option<&JsonValue>, max_steps: u64) -> String {
+ // Compile once; clone the module for the replay run so both executions
+ // start from a bit-identical program.
+ let module = match boruna_compiler::compile("module", source) {
+ Ok(m) => m,
+ Err(e) => return compile_error_json(&e),
+ };
+
+ // Resolve policy through the SAME parser boruna_run uses.
+ let gw_policy = match parse_policy(policy) {
+ Ok(p) => p,
+ Err(err) => {
+ return serde_json::json!({
+ "success": false,
+ "protocol_version": TOOL_RESPONSE_PROTOCOL_VERSION,
+ "error_kind": err.error_kind,
+ "message": err.message,
+ })
+ .to_string();
+ }
+ };
+
+ // ── Original run ──
+ let gateway = CapabilityGateway::new(gw_policy.clone());
+ let mut vm = Vm::new(module.clone(), gateway);
+ vm.set_max_steps(max_steps);
+ let value = match vm.run() {
+ Ok(v) => v,
+ Err(e) => return vm_error_json(&e, vm.step_count()),
+ };
+ let original_log = vm.event_log().clone();
+
+ // ── Replay run ──
+ // Feed the recorded capability RESULTS back through a ReplayHandler so the
+ // second execution never touches a real side effect — it must reproduce
+ // the identical event sequence purely from the recorded outcomes.
+ let recorded: Vec = original_log.capability_results();
+ let replay_gateway =
+ CapabilityGateway::with_handler(gw_policy, Box::new(ReplayHandler::new(recorded)));
+ let mut replay_vm = Vm::new(module, replay_gateway);
+ replay_vm.set_max_steps(max_steps);
+
+ let (replay_verified, replay_divergence): (bool, Option) = match replay_vm.run() {
+ Ok(_) => match ReplayEngine::verify_full(&original_log, replay_vm.event_log()) {
+ ReplayResult::Identical => (true, None),
+ ReplayResult::Diverged { reason } => (false, Some(reason)),
+ },
+ // A replay that itself errors (e.g. the recorded log was exhausted
+ // because the replay diverged onto a path with more capability calls)
+ // is a genuine divergence — surface it, don't claim verified.
+ Err(e) => (false, Some(format!("replay execution failed: {e}"))),
+ };
+
+ // ── Build the sealed artifact ──
+ let events = original_log.events();
+ let event_count = events.len();
+
+ // Digest over the FULL canonical log (never the truncated view).
+ let canonical = original_log.to_json().unwrap_or_default();
+ let digest = sha256_hex(canonical.as_bytes());
+
+ let truncated = event_count > EVENT_LOG_LIMIT;
+ let embedded: Vec = events
+ .iter()
+ .take(EVENT_LOG_LIMIT)
+ .map(event_json)
+ .collect();
+
+ let capability_calls: Vec = events
+ .iter()
+ .filter_map(cap_call_json)
+ .take(EVENT_LOG_LIMIT)
+ .collect();
+
+ let json = serde_json::json!({
+ "success": true,
+ "protocol_version": TOOL_RESPONSE_PROTOCOL_VERSION,
+ "result": format_value(&value),
+ "steps": vm.step_count(),
+ "replay_verified": replay_verified,
+ "replay_divergence_reason": replay_divergence,
+ "event_count": event_count,
+ "capability_calls": capability_calls,
+ "event_log": {
+ "version": original_log.version(),
+ "events": embedded,
+ "truncated": truncated,
+ },
+ "event_log_sha256": digest,
+ "seal": {
+ "kind": "replay-verified-event-log",
+ "verified": replay_verified,
+ "digest_alg": "sha256",
+ "digest": digest,
+ "note": SEAL_NOTE,
+ },
+ });
+
+ serde_json::to_string_pretty(&json).unwrap_or_else(|_| "{}".into())
+}
+
+/// Map a compile-time [`CompileError`] to this tool's typed domain-error
+/// envelope. Unlike `boruna_run` (which reuses `compile::compile_error_json`'s
+/// `errors[]`/`code` shape), `boruna_run_sealed` speaks a flat `error_kind`
+/// taxonomy consistently across every failure path — `parse_error` for
+/// lex/parse failures (mirroring `boruna_symbols`), `compile_error` for
+/// type/codegen failures that occur before any execution.
+fn compile_error_json(err: &boruna_compiler::CompileError) -> String {
+ use boruna_compiler::CompileError;
+ let (kind, message, line, col) = match err {
+ CompileError::Lexer { line, col, msg } => {
+ ("parse_error", msg.clone(), Some(*line), Some(*col))
+ }
+ CompileError::Parse { line, msg } => ("parse_error", msg.clone(), Some(*line), None),
+ CompileError::Type(msg) | CompileError::Codegen(msg) => {
+ ("compile_error", msg.clone(), None, None)
+ }
+ };
+ serde_json::json!({
+ "success": false,
+ "protocol_version": TOOL_RESPONSE_PROTOCOL_VERSION,
+ "error_kind": kind,
+ "error": message,
+ "line": line,
+ "col": col,
+ })
+ .to_string()
+}
+
+/// Map a terminal [`VmError`] to the crate's domain-error envelope.
+///
+/// `CapabilityDenied` / `CapabilityBudgetExceeded` get their own
+/// `capability_denied` kind (with the offending capability reflected) so a
+/// caller running under a restrictive policy can distinguish a policy block
+/// from an ordinary runtime fault. Everything else stays `runtime_error`,
+/// matching `boruna_run`.
+fn vm_error_json(err: &VmError, steps: u64) -> String {
+ match err {
+ VmError::CapabilityDenied(cap) | VmError::CapabilityBudgetExceeded(cap) => {
+ serde_json::json!({
+ "success": false,
+ "protocol_version": TOOL_RESPONSE_PROTOCOL_VERSION,
+ "error_kind": "capability_denied",
+ "capability": cap.name(),
+ "message": format!("{err}"),
+ "steps": steps,
+ })
+ .to_string()
+ }
+ _ => serde_json::json!({
+ "success": false,
+ "protocol_version": TOOL_RESPONSE_PROTOCOL_VERSION,
+ "error_kind": "runtime_error",
+ "message": format!("{err}"),
+ "steps": steps,
+ })
+ .to_string(),
+ }
+}
+
+/// Render one [`Event`] as JSON. Capability args/results and payloads reuse
+/// `format_value` so the shapes match `boruna_run`'s `result`.
+fn event_json(event: &Event) -> serde_json::Value {
+ match event {
+ Event::CapCall { capability, args } => serde_json::json!({
+ "event": "cap_call",
+ "capability": capability,
+ "args": args.iter().map(format_value).collect::>(),
+ }),
+ Event::CapResult { capability, result } => serde_json::json!({
+ "event": "cap_result",
+ "capability": capability,
+ "result": format_value(result),
+ }),
+ Event::ActorSpawn { actor_id, function } => serde_json::json!({
+ "event": "actor_spawn",
+ "actor_id": actor_id,
+ "function": function,
+ }),
+ Event::MessageSend { from, to, payload } => serde_json::json!({
+ "event": "message_send",
+ "from": from,
+ "to": to,
+ "payload": format_value(payload),
+ }),
+ Event::MessageReceive { actor_id, payload } => serde_json::json!({
+ "event": "message_receive",
+ "actor_id": actor_id,
+ "payload": format_value(payload),
+ }),
+ Event::UiEmit { tree } => serde_json::json!({
+ "event": "ui_emit",
+ "tree": format_value(tree),
+ }),
+ Event::SchedulerTick {
+ round,
+ active_actor,
+ } => serde_json::json!({
+ "event": "scheduler_tick",
+ "round": round,
+ "active_actor": active_actor,
+ }),
+ Event::ContractCheck {
+ function,
+ kind,
+ index,
+ passed,
+ } => serde_json::json!({
+ "event": "contract_check",
+ "function": function,
+ "kind": kind,
+ "index": index,
+ "passed": passed,
+ }),
+ }
+}
+
+/// Project only the `CapCall` events into the `capability_calls` summary.
+fn cap_call_json(event: &Event) -> Option {
+ match event {
+ Event::CapCall { capability, args } => Some(serde_json::json!({
+ "capability": capability,
+ "args": args.iter().map(format_value).collect::>(),
+ })),
+ _ => None,
+ }
+}
+
+fn sha256_hex(bytes: &[u8]) -> String {
+ let mut hasher = Sha256::new();
+ hasher.update(bytes);
+ let digest = hasher.finalize();
+ let mut out = String::with_capacity(digest.len() * 2);
+ for b in digest {
+ use std::fmt::Write;
+ let _ = write!(out, "{b:02x}");
+ }
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use serde_json::{json, Value};
+
+ fn parse(s: &str) -> Value {
+ serde_json::from_str(s).expect("valid JSON")
+ }
+
+ const PURE_SOURCE: &str = "fn main() -> Int {\n 1 + 2\n}\n";
+
+ // A program that makes a capability call at the surface level. `step_input`
+ // is the one builtin that compiles to `Op::CapCall`; under the MockHandler
+ // it deterministically returns an empty string, so the run records a
+ // CapCall + CapResult and the replay reproduces them exactly.
+ const CAP_SOURCE: &str = r#"
+fn main() -> Int !{step.input} {
+ let _x: String = step_input("upstream")
+ 7
+}
+"#;
+
+ #[test]
+ fn pure_run_is_success_and_replay_verified() {
+ let out = run_sealed(PURE_SOURCE, None, 1_000_000);
+ let v = parse(&out);
+ assert_eq!(v["success"], true, "output: {out}");
+ assert_eq!(v["result"], json!(3));
+ assert_eq!(v["replay_verified"], true);
+ assert!(v["replay_divergence_reason"].is_null());
+ // A pure program logs no events; the seal is still well-formed.
+ assert_eq!(v["event_count"], 0);
+ assert_eq!(v["capability_calls"].as_array().unwrap().len(), 0);
+ assert_eq!(v["seal"]["kind"], "replay-verified-event-log");
+ assert_eq!(v["seal"]["verified"], true);
+ // Digest is a 64-char hex string and is echoed in both places.
+ let digest = v["event_log_sha256"].as_str().unwrap();
+ assert_eq!(digest.len(), 64);
+ assert!(digest.chars().all(|c| c.is_ascii_hexdigit()));
+ assert_eq!(v["seal"]["digest"], v["event_log_sha256"]);
+ }
+
+ #[test]
+ fn capability_run_records_and_verifies() {
+ let out = run_sealed(CAP_SOURCE, None, 1_000_000);
+ let v = parse(&out);
+ assert_eq!(v["success"], true, "output: {out}");
+ assert_eq!(v["result"], json!(7));
+ assert_eq!(v["replay_verified"], true, "output: {out}");
+
+ // The run recorded a step.input CapCall + its CapResult (2 events).
+ assert_eq!(v["event_count"], 2);
+ let caps = v["capability_calls"].as_array().unwrap();
+ assert_eq!(caps.len(), 1);
+ assert_eq!(caps[0]["capability"], "step.input");
+
+ // The embedded event log carries the call and the result.
+ let events = v["event_log"]["events"].as_array().unwrap();
+ assert_eq!(events.len(), 2);
+ assert_eq!(events[0]["event"], "cap_call");
+ assert_eq!(events[0]["capability"], "step.input");
+ assert_eq!(events[1]["event"], "cap_result");
+ assert_eq!(v["event_log"]["truncated"], false);
+ }
+
+ #[test]
+ fn runtime_error_is_success_false() {
+ // list_get out of bounds → a runtime trap (not a compile error).
+ let src = r#"
+fn main() -> Int {
+ let xs: List = [1, 2, 3]
+ list_get(xs, 99)
+}
+"#;
+ let out = run_sealed(src, None, 1_000_000);
+ let v = parse(&out);
+ assert_eq!(v["success"], false, "output: {out}");
+ assert_eq!(v["error_kind"], "runtime_error");
+ assert!(v["message"].is_string());
+ }
+
+ #[test]
+ fn parse_error_is_success_false_parse_error() {
+ let out = run_sealed("@@@ not valid .ax", None, 1_000_000);
+ let v = parse(&out);
+ assert_eq!(v["success"], false, "output: {out}");
+ assert_eq!(v["error_kind"], "parse_error");
+ }
+
+ #[test]
+ fn capability_denied_is_reflected() {
+ // deny-all blocks the step.input capability → the denial surfaces
+ // with a typed error_kind and the offending capability name.
+ let out = run_sealed(CAP_SOURCE, Some(&json!("deny-all")), 1_000_000);
+ let v = parse(&out);
+ assert_eq!(v["success"], false, "output: {out}");
+ assert_eq!(v["error_kind"], "capability_denied");
+ assert_eq!(v["capability"], "step.input");
+ assert!(v["message"].as_str().unwrap().contains("capability denied"));
+ }
+
+ #[test]
+ fn invalid_policy_is_reflected() {
+ let bad = json!(42);
+ let out = run_sealed(PURE_SOURCE, Some(&bad), 1_000_000);
+ let v = parse(&out);
+ assert_eq!(v["success"], false, "output: {out}");
+ assert_eq!(v["error_kind"], "invalid_policy");
+ }
+
+ #[test]
+ fn seal_note_documents_bundle_boundary() {
+ // The honesty contract: the response must state that this is a replay
+ // proof, not a signed bundle, and point at the workflow path.
+ let out = run_sealed(PURE_SOURCE, None, 1_000_000);
+ let v = parse(&out);
+ let note = v["seal"]["note"].as_str().unwrap();
+ assert!(note.contains("not a signed evidence bundle"));
+ assert!(note.contains("workflow"));
+ }
+
+ #[test]
+ fn every_response_carries_protocol_version() {
+ for out in [
+ run_sealed(PURE_SOURCE, None, 1_000_000),
+ run_sealed("@@@", None, 1_000_000),
+ run_sealed(CAP_SOURCE, Some(&json!("deny-all")), 1_000_000),
+ run_sealed(PURE_SOURCE, Some(&json!(42)), 1_000_000),
+ ] {
+ let v = parse(&out);
+ assert_eq!(
+ v["protocol_version"],
+ json!(TOOL_RESPONSE_PROTOCOL_VERSION),
+ "missing protocol_version in: {out}"
+ );
+ }
+ }
+}
diff --git a/crates/llmbc/src/opcode.rs b/crates/llmbc/src/opcode.rs
index 05fce8d..5112e6b 100644
--- a/crates/llmbc/src/opcode.rs
+++ b/crates/llmbc/src/opcode.rs
@@ -95,6 +95,20 @@ pub enum Op {
index: u32,
},
+ /// Guard-and-seal an output value against a deterministic check.
+ ///
+ /// Stack layout (top → bottom): `[label, passed, value]`. Pops the
+ /// `label` (String) and the `passed` (Bool) verdict, leaving `value`
+ /// on the stack unchanged so the guard is transparent on the happy
+ /// path. The VM seals the verdict into the evidence trail as a
+ /// `ContractCheck` event with `kind: "output"` (for BOTH outcomes),
+ /// then, if `passed` is falsy, traps fail-closed with
+ /// `VmError::ContractViolation` carrying the label. This proves "the
+ /// guardrail ran on this value and returned this verdict" for a
+ /// sealed run. Emitted only by codegen for the `__builtin_guard`
+ /// builtin.
+ GuardSeal,
+
/// Capability call: cap_id, arg_count. Args on stack.
CapCall(u32, u8),
@@ -294,6 +308,7 @@ impl Op {
Op::ReceiveMsg => 0x11,
Op::Assert { .. } => 0x12,
Op::CapCall(_, _) => 0x13,
+ Op::GuardSeal => 0x15,
Op::Add => 0x20,
Op::Sub => 0x21,
Op::Mul => 0x22,
diff --git a/crates/llmc/src/codegen.rs b/crates/llmc/src/codegen.rs
index 2852a1e..8070801 100644
--- a/crates/llmc/src/codegen.rs
+++ b/crates/llmc/src/codegen.rs
@@ -586,6 +586,19 @@ impl Emitter {
fe.code.push(Op::DebugMsg);
return Ok(());
}
+ // guard-and-seal: run a deterministic boolean
+ // check on a value, fail closed on false, and seal
+ // the verdict into the evidence trail as an
+ // `output` ContractCheck. Stack the VM expects
+ // (top → bottom): [label, passed, value] — so emit
+ // value, then passed, then label.
+ "__builtin_guard" if args.len() == 3 => {
+ self.emit_expr(&args[0], fe)?; // value
+ self.emit_expr(&args[1], fe)?; // passed
+ self.emit_expr(&args[2], fe)?; // label
+ fe.code.push(Op::GuardSeal);
+ return Ok(());
+ }
// 0.3-S14: builtin `step_input(name)` reads a
// workflow step's resolved upstream output.
// Emits `Op::CapCall(StepInput, 1)` which
diff --git a/crates/llmc/src/tests.rs b/crates/llmc/src/tests.rs
index f6fd735..c18cbe8 100644
--- a/crates/llmc/src/tests.rs
+++ b/crates/llmc/src/tests.rs
@@ -270,6 +270,53 @@ mod tests {
assert_eq!(run_source(src), Value::Int(7));
}
+ #[test]
+ fn test_guard_builtin_emits_guard_seal_opcode() {
+ use boruna_bytecode::Op;
+ let module = compile(
+ "m",
+ "fn main() -> Int { __builtin_guard(42, true, \"ok\") }",
+ )
+ .unwrap();
+ let main = module.functions.iter().find(|f| f.name == "main").unwrap();
+ assert!(
+ main.code.iter().any(|op| matches!(op, Op::GuardSeal)),
+ "expected a GuardSeal opcode from __builtin_guard"
+ );
+ }
+
+ #[test]
+ fn test_guard_builtin_pass_returns_value_and_seals() {
+ use boruna_vm::replay::Event;
+ let src = "fn main() -> Int { __builtin_guard(42, true, \"json-shape\") }";
+ let module = compile("test", src).expect("compile");
+ let gateway = CapabilityGateway::new(Policy::allow_all());
+ let mut vm = Vm::new(module, gateway);
+ assert_eq!(vm.run().unwrap(), Value::Int(42));
+ assert!(vm.event_log().events().iter().any(|e| matches!(
+ e,
+ Event::ContractCheck { function, kind, passed, .. }
+ if function == "json-shape" && kind == "output" && *passed
+ )));
+ }
+
+ #[test]
+ fn test_guard_builtin_fail_traps_closed() {
+ use boruna_vm::replay::Event;
+ use boruna_vm::VmError;
+ let src = "fn main() -> Int { __builtin_guard(42, false, \"json-shape\") }";
+ let module = compile("test", src).expect("compile");
+ let gateway = CapabilityGateway::new(Policy::allow_all());
+ let mut vm = Vm::new(module, gateway);
+ let err = vm.run().expect_err("false guard must trap");
+ assert!(matches!(err, VmError::ContractViolation { .. }));
+ // The failed verdict is sealed even though the run trapped.
+ assert!(vm.event_log().events().iter().any(|e| matches!(
+ e,
+ Event::ContractCheck { kind, passed, .. } if kind == "output" && !*passed
+ )));
+ }
+
#[test]
fn test_e2e_for_loop_sums_list() {
let src = "fn main() -> Int {\n let mut total = 0\n for x in [1, 2, 3, 4, 5] {\n total = total + x\n }\n total\n}";
diff --git a/crates/llmc/src/typeck.rs b/crates/llmc/src/typeck.rs
index b4b5752..b3971af 100644
--- a/crates/llmc/src/typeck.rs
+++ b/crates/llmc/src/typeck.rs
@@ -88,6 +88,11 @@ impl TypeChecker {
// See docs/spec/bytecode-1.0.md §4 (Debug, DebugMsg).
functions.insert("__builtin_debug".to_string(), 1);
functions.insert("__builtin_debug_msg".to_string(), 2);
+ // guard-and-seal: `__builtin_guard(value, passed, label)` runs a
+ // deterministic boolean check on a value, fails closed on false,
+ // and seals the verdict into the evidence trail. Returns `value`
+ // unchanged on pass. Compiles to `Op::GuardSeal`.
+ functions.insert("__builtin_guard".to_string(), 3);
// 0.3-S14: read a workflow step's resolved input value at
// runtime. Compiles to `Op::CapCall(StepInput, 1)` which
// dispatches through the gateway's StepInputHandler. Returns
diff --git a/crates/llmvm-cli/src/main.rs b/crates/llmvm-cli/src/main.rs
index 291ed8a..30d0df2 100644
--- a/crates/llmvm-cli/src/main.rs
+++ b/crates/llmvm-cli/src/main.rs
@@ -952,6 +952,70 @@ enum EvidenceCommand {
#[arg(long)]
json: bool,
},
+ /// Emit (or verify) an in-toto Statement + DSSE envelope for the
+ /// bundle's runtime provenance, for interop with the supply-chain
+ /// ecosystem (`cosign verify-blob`, `in-toto-verify`). Additive —
+ /// does NOT touch the native bundle format. Writes
+ /// `attestation.intoto.dsse.json` into the bundle directory.
+ Attest {
+ /// Evidence bundle directory (must contain `manifest.json`).
+ dir: PathBuf,
+ /// Verify the existing `attestation.intoto.dsse.json` instead of
+ /// producing one. Checks the DSSE ed25519 signature over the PAE.
+ #[arg(long)]
+ verify: bool,
+ /// ed25519 signing seed (32 bytes as 64 hex chars) used to sign
+ /// the DSSE PAE. This is the SAME key machinery as manifest
+ /// signing — supply the same seed you signed the bundle with.
+ /// Falls back to `BORUNA_BUNDLE_SIGNING_KEY`. Required (only)
+ /// when producing an attestation.
+ #[arg(long, value_name = "HEX")]
+ signing_key: Option,
+ /// With `--verify`: pin the trusted ed25519 public key (64 hex
+ /// chars). A valid signature MUST be made by this key, else
+ /// verification fails. Without a pin, a self-consistent
+ /// signature is accepted.
+ #[arg(long, value_name = "HEX")]
+ verify_key: Option,
+ /// Output path for the DSSE envelope. Defaults to
+ /// `/attestation.intoto.dsse.json`.
+ #[arg(long)]
+ output: Option,
+ },
+ /// Generate a human-readable COMPLIANCE evidence-mapping report that
+ /// maps a bundle's actual contents to the specific regulatory
+ /// obligation each one helps satisfy. Verifies the bundle first and
+ /// stamps the verdict at the top; a tampered/unverifiable bundle
+ /// produces a report that says so loudly. This is a technical mapping,
+ /// NOT a certificate of compliance.
+ Report {
+ /// Evidence bundle directory.
+ dir: PathBuf,
+ /// Regulatory framework to map against.
+ #[arg(long, value_name = "FRAMEWORK")]
+ framework: String,
+ /// Output rendering: `md` (default) or `html`.
+ #[arg(long, value_name = "FORMAT", default_value = "md")]
+ format: String,
+ },
+ /// Export the bundle's execution as OpenTelemetry spans in OTLP/JSON —
+ /// the file format any OTel collector ingests. No SDK dependency, no
+ /// network: emit the document and POST it to a collector (or pipe it
+ /// through the `otlpjson` file receiver) to surface the run in Jaeger,
+ /// Tempo, Honeycomb, Datadog, etc.
+ ///
+ /// The root span carries tamper-evidence attributes (`boruna.bundle_hash`,
+ /// `boruna.audit_log_hash`, `boruna.signature.keyid`) so a span in a
+ /// tracing backend links back to a record verifiable with
+ /// `boruna evidence verify`. `llm.*` capability calls are emitted as
+ /// `gen_ai.*` spans (OTel GenAI semantic conventions).
+ Otel {
+ /// Evidence bundle directory.
+ dir: PathBuf,
+ /// Write the OTLP/JSON document to this file instead of stdout.
+ #[arg(long, value_name = "FILE")]
+ out: Option,
+ },
}
#[derive(Subcommand)]
@@ -4108,6 +4172,109 @@ fn run_evidence(
} => {
evidence_diff::evidence_diff(&bundle_a, &bundle_b, json)?;
}
+ EvidenceCommand::Attest {
+ dir,
+ verify,
+ signing_key,
+ verify_key,
+ output,
+ } => {
+ run_evidence_attest(dir, verify, signing_key, verify_key, output)?;
+ }
+ EvidenceCommand::Report {
+ dir,
+ framework,
+ format,
+ } => {
+ use boruna_orchestrator::audit::report::{
+ generate_report, ComplianceFramework, ReportFormat,
+ };
+ let framework = ComplianceFramework::parse(&framework)?;
+ let format = ReportFormat::parse(&format)?;
+ let report = generate_report(&dir, framework, format)?;
+ println!("{report}");
+ }
+ EvidenceCommand::Otel { dir, out } => {
+ let doc = boruna_orchestrator::audit::otel::bundle_to_otlp_json(&dir)?;
+ match out {
+ Some(path) => {
+ fs::write(&path, &doc)
+ .map_err(|e| format!("cannot write {}: {e}", path.display()))?;
+ println!("OTLP/JSON spans written to {}", path.display());
+ }
+ None => println!("{doc}"),
+ }
+ }
+ }
+ Ok(())
+}
+
+/// `boruna evidence attest ` — emit or verify an in-toto Statement
+/// wrapped in a DSSE envelope for a bundle's runtime provenance.
+/// Additive interop: reads the (plaintext) manifest, reuses the existing
+/// ed25519 signing key, and writes `attestation.intoto.dsse.json`. With
+/// `--verify`, it checks the DSSE signature over the PAE instead.
+fn run_evidence_attest(
+ dir: PathBuf,
+ verify: bool,
+ signing_key: Option,
+ verify_key: Option,
+ output: Option,
+) -> Result<(), Box> {
+ use boruna_orchestrator::audit::attestation::{
+ attest, parse_seed_hex, verify_envelope, DsseEnvelope,
+ };
+ use boruna_orchestrator::audit::evidence::BundleManifest;
+
+ let out_path = output.unwrap_or_else(|| dir.join("attestation.intoto.dsse.json"));
+
+ if verify {
+ let raw = fs::read_to_string(&out_path)
+ .map_err(|e| format!("cannot read {}: {e}", out_path.display()))?;
+ let envelope: DsseEnvelope =
+ serde_json::from_str(&raw).map_err(|e| format!("invalid DSSE envelope: {e}"))?;
+ match verify_envelope(&envelope, verify_key.as_deref()) {
+ Ok(stmt) => {
+ println!("attestation is VALID");
+ println!(" predicateType: {}", stmt.predicate_type);
+ println!(" subjects: {}", stmt.subject.len());
+ println!(
+ " invocationId: {}",
+ stmt.predicate.run_details.metadata.invocation_id
+ );
+ }
+ Err(e) => {
+ eprintln!("attestation INVALID: {e}");
+ process::exit(1);
+ }
+ }
+ return Ok(());
+ }
+
+ // Produce mode: read the manifest and sign a fresh envelope.
+ let manifest_json = fs::read_to_string(dir.join("manifest.json"))
+ .map_err(|e| format!("cannot read manifest.json: {e}"))?;
+ let manifest: BundleManifest =
+ serde_json::from_str(&manifest_json).map_err(|e| format!("invalid manifest.json: {e}"))?;
+
+ let seed_hex = signing_key
+ .or_else(|| std::env::var("BORUNA_BUNDLE_SIGNING_KEY").ok())
+ .ok_or_else(|| {
+ "no signing key: pass --signing-key <64-hex> or set BORUNA_BUNDLE_SIGNING_KEY"
+ .to_string()
+ })?;
+ let seed = parse_seed_hex(&seed_hex).map_err(|e| e.to_string())?;
+
+ let envelope = attest(&manifest, env!("CARGO_PKG_VERSION"), &seed)
+ .map_err(|e| format!("attestation failed: {e}"))?;
+ let json = serde_json::to_string_pretty(&envelope)
+ .map_err(|e| format!("cannot serialize envelope: {e}"))?;
+ fs::write(&out_path, json).map_err(|e| format!("cannot write {}: {e}", out_path.display()))?;
+
+ println!("attestation written to {}", out_path.display());
+ println!(" payloadType: {}", envelope.payload_type);
+ if let Some(sig) = envelope.signatures.first() {
+ println!(" keyid: {}", sig.keyid);
}
Ok(())
}
diff --git a/crates/llmvm/src/replay.rs b/crates/llmvm/src/replay.rs
index f32f31e..0184471 100644
--- a/crates/llmvm/src/replay.rs
+++ b/crates/llmvm/src/replay.rs
@@ -48,6 +48,12 @@ pub enum Event {
/// exactly which one failed (`passed: false`, immediately before the
/// run traps with `VmError::ContractViolation`). `kind` is
/// `"requires"`/`"ensures"`; `index` is the 0-based clause position.
+ ///
+ /// Also reused by the guard-and-seal builtin (`Op::GuardSeal`) with
+ /// `kind: "output"`, `function` = the guard label, and `index: 0` —
+ /// so a fail-closed output check on an (e.g. model-produced) value
+ /// seals its verdict through the same evidence path without a new
+ /// event variant or format bump.
ContractCheck {
function: String,
kind: String,
@@ -153,6 +159,22 @@ impl EventLog {
});
}
+ /// Record that a guard-and-seal output check was evaluated. Reuses
+ /// the `ContractCheck` event with `kind: "output"` (so no new event
+ /// variant, no format version bump, and the orchestrator's evidence
+ /// bundle seals it transparently). `label` is the guard's name,
+ /// carried in the `function` field; `index` is always 0 (output
+ /// checks are standalone, not part of a numbered clause list). Called
+ /// by the VM at every `Op::GuardSeal` site for both pass and fail.
+ pub fn log_output_check(&mut self, label: &str, passed: bool) {
+ self.events.push(Event::ContractCheck {
+ function: label.to_string(),
+ kind: "output".to_string(),
+ index: 0,
+ passed,
+ });
+ }
+
pub fn events(&self) -> &[Event] {
&self.events
}
diff --git a/crates/llmvm/src/tests.rs b/crates/llmvm/src/tests.rs
index 20d13d4..b0389f3 100644
--- a/crates/llmvm/src/tests.rs
+++ b/crates/llmvm/src/tests.rs
@@ -778,6 +778,138 @@ mod tests {
);
}
+ #[test]
+ fn test_guard_seal_pass_returns_value_and_seals() {
+ // __builtin_guard(value, true, "json-shape") returns `value`
+ // unchanged AND seals an output ContractCheck{passed:true}.
+ // Stack the VM expects (top → bottom): [label, passed, value].
+ let module = simple_module(
+ vec![
+ Op::PushConst(0), // value
+ Op::PushConst(1), // passed = true
+ Op::PushConst(2), // label
+ Op::GuardSeal,
+ Op::Ret,
+ ],
+ vec![
+ Value::Int(42),
+ Value::Bool(true),
+ Value::String("json-shape".into()),
+ ],
+ );
+ let gateway = CapabilityGateway::new(Policy::allow_all());
+ let mut vm = Vm::new(module, gateway);
+ // Transparent on the happy path: the guarded value flows through.
+ assert_eq!(vm.run().unwrap(), Value::Int(42));
+
+ let checks: Vec<_> = vm
+ .event_log()
+ .events()
+ .iter()
+ .filter(|e| matches!(e, Event::ContractCheck { .. }))
+ .collect();
+ assert_eq!(checks.len(), 1);
+ match checks[0] {
+ Event::ContractCheck {
+ function,
+ kind,
+ index,
+ passed,
+ } => {
+ assert_eq!(function, "json-shape");
+ assert_eq!(kind, "output");
+ assert_eq!(*index, 0);
+ assert!(*passed);
+ }
+ other => panic!("expected ContractCheck, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn test_guard_seal_fail_traps_and_seals() {
+ // __builtin_guard(value, false, "json-shape") traps fail-closed
+ // with VmError::ContractViolation AND still seals the verdict as
+ // an output ContractCheck{passed:false}.
+ let module = simple_module(
+ vec![
+ Op::PushConst(0), // value
+ Op::PushConst(1), // passed = false
+ Op::PushConst(2), // label
+ Op::GuardSeal,
+ Op::Ret,
+ ],
+ vec![
+ Value::Int(42),
+ Value::Bool(false),
+ Value::String("json-shape".into()),
+ ],
+ );
+ let gateway = CapabilityGateway::new(Policy::allow_all());
+ let mut vm = Vm::new(module, gateway);
+ let err = vm.run().expect_err("failing output guard must trap");
+ assert!(matches!(err, VmError::ContractViolation { .. }));
+
+ // Even though the run trapped, the failed check is sealed.
+ let checks: Vec<_> = vm
+ .event_log()
+ .events()
+ .iter()
+ .filter(|e| matches!(e, Event::ContractCheck { .. }))
+ .collect();
+ assert_eq!(checks.len(), 1);
+ match checks[0] {
+ Event::ContractCheck {
+ function,
+ kind,
+ passed,
+ ..
+ } => {
+ assert_eq!(function, "json-shape");
+ assert_eq!(kind, "output");
+ assert!(!*passed);
+ }
+ other => panic!("expected ContractCheck, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn test_guard_seal_replay_determinism() {
+ // A run containing guard-and-seal output events must
+ // verify_full-match a re-run of the identical module.
+ let make_module = || {
+ simple_module(
+ vec![
+ Op::PushConst(0), // value
+ Op::PushConst(1), // passed = true
+ Op::PushConst(2), // label
+ Op::GuardSeal,
+ Op::Ret,
+ ],
+ vec![
+ Value::Int(7),
+ Value::Bool(true),
+ Value::String("output-shape".into()),
+ ],
+ )
+ };
+
+ let mut vm1 = Vm::new(make_module(), CapabilityGateway::new(Policy::allow_all()));
+ vm1.run().unwrap();
+ let mut vm2 = Vm::new(make_module(), CapabilityGateway::new(Policy::allow_all()));
+ vm2.run().unwrap();
+
+ assert!(vm1.event_log().events().iter().any(|e| matches!(
+ e,
+ Event::ContractCheck { kind, .. } if kind == "output"
+ )));
+
+ let result = ReplayEngine::verify_full(vm1.event_log(), vm2.event_log());
+ assert!(
+ matches!(result, ReplayResult::Identical),
+ "output guard events must replay identically: {result:?}"
+ );
+ }
+
#[test]
fn test_event_log_explicit_v1_still_reads() {
// Backward compat: an explicit version-1 log (no ContractCheck)
diff --git a/crates/llmvm/src/vm.rs b/crates/llmvm/src/vm.rs
index 7175062..3d43cae 100644
--- a/crates/llmvm/src/vm.rs
+++ b/crates/llmvm/src/vm.rs
@@ -628,6 +628,29 @@ impl Vm {
});
}
}
+ Op::GuardSeal => {
+ // Stack (top → bottom): [label, passed, value]. Pop the
+ // label and the boolean verdict; leave `value` on the
+ // stack so the guard is transparent on pass.
+ let label_val = self.pop()?;
+ let label = match label_val {
+ Value::String(s) => s,
+ other => format!("{other}"),
+ };
+ let passed = self.pop()?.is_truthy();
+ // Seal the verdict for BOTH outcomes: a passing run
+ // proves the guardrail ran and approved this value; a
+ // failing run pins the exact guard that rejected it,
+ // right before the fail-closed trap below.
+ self.event_log.log_output_check(&label, passed);
+ if !passed {
+ return Err(VmError::ContractViolation {
+ message: format!("output guard `{label}` failed"),
+ counterexample: Vec::new(),
+ });
+ }
+ // `value` remains on the stack as the guard's result.
+ }
Op::CapCall(cap_id, arg_count) => {
let cap =
Capability::from_id(cap_id).ok_or(VmError::UnknownCapability(cap_id))?;
diff --git a/docs/README.md b/docs/README.md
index 80b0f5f..ce77ea5 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -12,6 +12,8 @@ Core ideas that make Boruna work:
- [Determinism](./concepts/determinism.md) — why same inputs → same outputs, and how it's enforced
- [Capabilities](./concepts/capabilities.md) — declaring and gating side effects
- [Evidence Bundles](./concepts/evidence-bundles.md) — tamper-evident audit logs and replay
+- [Evidence Bundle Threat Model](./concepts/threat-model.md) — what a bundle proves and does not prove (tamper-evidence vs. tamper-proofing vs. non-repudiation)
+- [Runtime Execution Provenance](./concepts/runtime-execution-provenance.md) — the provenance category Boruna occupies vs. SLSA, in-toto, Sigstore, C2PA, TEE attestation
- [Bundle Storage](./concepts/bundle-storage.md) — local + remote (S3/GCS/Azure) destinations for evidence bundles
## Guides
diff --git a/docs/concepts/runtime-execution-provenance.md b/docs/concepts/runtime-execution-provenance.md
new file mode 100644
index 0000000..184716f
--- /dev/null
+++ b/docs/concepts/runtime-execution-provenance.md
@@ -0,0 +1,132 @@
+# Runtime Execution Provenance
+
+Boruna occupies a provenance category that the established supply-chain
+and content-authenticity standards leave largely unoccupied:
+**runtime execution provenance** — an attested, tamper-evident record of
+what a *specific execution* actually did.
+
+The claim this category makes is narrow and concrete:
+
+> *This specific run executed these steps, made these policy-gated
+> capability calls, under this policy, transforming these recorded inputs
+> into these recorded outputs.*
+
+That is a statement about an **execution trace**, not about a build, an
+artifact, a signing event, a media file, or a booted code image. The
+distinction matters because the standards people reach for by reflex all
+answer a *different* question.
+
+---
+
+## 1. The provenance landscape
+
+Each of these standards is good at what it targets. None of them targets
+the execution trace of a particular run.
+
+| Standard / mechanism | What it attests | What it leaves unoccupied |
+|----------------------|-----------------|---------------------------|
+| **SLSA** | **Build** provenance: that an artifact was produced by a particular build system from particular sources, following a particular process. | Says nothing about what happens when the built thing later *runs*. A SLSA-attested binary can still do anything at runtime. |
+| **in-toto** | **Supply-chain step metadata**: that each link in a defined software supply chain was performed by an authorized party on declared materials/products. | Models the *pipeline that assembles software*, not the *runtime behavior of a workflow execution*. The "steps" are build/release steps, not gated capability calls made during one run. |
+| **Sigstore / Rekor** | **Signing events over artifacts**: that a given artifact digest was signed by a given identity, witnessed in an append-only transparency log. | Attests the *existence of a signature* over a blob at a time — not *what an execution did*. Rekor witnesses that something was signed, not that a run made these calls under this policy. |
+| **C2PA** | **Content provenance**: the capture/edit history and origin of a media asset (image, audio, video). | Concerned with the lineage of *content*, not with the *execution* of a program or workflow. |
+| **TEE remote attestation** | **Code identity**: which enclave/image was measured and booted on attested hardware. | Attests *what code was loaded and the platform it ran on* — not the *trace of what that code then did* (which steps, which capability calls, which inputs→outputs). |
+
+Read down the right-hand column: build provenance, supply-chain step
+metadata, signing events, content lineage, and code identity are all
+covered — and the *runtime execution trace* falls through the gap between
+them. Boruna's evidence bundle is aimed squarely at that gap.
+
+These standards are complementary, not competitors. A mature deployment
+might use SLSA for the binary, TEE attestation for the platform, and
+Boruna for the execution trace — each answering the question it is built
+to answer.
+
+---
+
+## 2. What Boruna attests
+
+The evidence bundle records the run itself. The load-bearing components
+(see [Evidence Bundles](./evidence-bundles.md) and
+`orchestrator/src/audit/evidence.rs`) map directly onto the category
+claim:
+
+- **These steps executed** — the hash-chained `audit_log`, whose entries
+ record step start/completion and capability calls in order, with each
+ entry chained to the previous (`entry_hash = SHA-256(prev_hash ||
+ event_json)`).
+- **Under this policy** — `policy.json` and its `policy_hash`; the policy
+ snapshot that gated the run is sealed alongside the trace, so a verifier
+ sees the exact rules in force.
+- **Made these gated capability calls** — capability calls appear in the
+ audit log / event stream; the optional `model_invoking_steps.json`
+ additionally records which steps transitively reached an LLM
+ capability, so an auditor can see which steps touched a model without
+ re-analyzing sources.
+- **Transforming these inputs into these outputs** — per-step outputs
+ under `outputs//.json`, each SHA-256-checksummed in
+ `file_checksums` and thereby committed to by `bundle_hash`.
+- **With declared purpose** — the optional `intents.json` records the
+ per-step declared intent (what each step was *authorized* to do),
+ captured as replay-verified evidence alongside what it actually did.
+
+All of these are covered by the same integrity contract: on-disk
+checksums, an unbroken audit-log chain, and a `bundle_hash` over the
+manifest. The record is therefore **tamper-evident** in exactly the sense
+defined in the [Evidence Bundle Threat Model](./threat-model.md) — and,
+as that document is careful to state, tamper-evidence is not
+tamper-proofing, and a sealed trace attests *what was recorded*, not that
+the producer recorded honestly.
+
+---
+
+## 3. Interoperability with the standards
+
+Occupying a distinct category does not mean living apart from the
+ecosystem. The intent is for a Boruna execution record to slot into
+existing supply-chain and attestation tooling rather than replace it.
+
+To that end, an **in-toto / DSSE emission** is available via
+`boruna evidence attest `: it exports the bundle's core
+attestation (run identity, `workflow_hash`, `policy_hash`,
+`audit_log_hash`, output digests) as an in-toto Statement
+(`predicateType: https://boruna.dev/runtime-provenance/v1`) wrapped in a
+DSSE envelope signed with the bundle's ed25519 key. That makes the
+execution-provenance claim consumable by the same tooling that already
+ingests SLSA and in-toto attestations — for example, a transparency log
+or a policy engine that gates on attestations. The predicate schema is
+specified in `docs/spec/runtime-provenance-predicate-1.0.md`.
+
+> **Status note.** The in-toto/DSSE emitter is implemented
+> (`boruna evidence attest`, `--verify` to check the envelope). It is
+> **additive** — the native, authoritative format remains the evidence
+> bundle described in `docs/spec/evidence-bundle-1.0.md`. Live
+> interoperability with a specific `cosign`/`in-toto-verify` binary is
+> spec-conformant but not yet end-to-end verified (the DSSE `keyid` is a
+> raw-hex ed25519 key that a consumer must bridge to PEM SPKI); see the
+> predicate spec's compatibility notes.
+
+The relationship is layered, not overlapping:
+
+- **SLSA / in-toto** attest how the software (and, via DSSE, other
+ attestations) was produced and assembled.
+- **Sigstore / Rekor** can witness signatures — including, once emitted,
+ a DSSE-wrapped Boruna attestation — in an append-only log.
+- **TEE attestation** can vouch for the platform and code image that ran
+ the Boruna engine.
+- **Boruna** attests the execution trace that happened on top of all of
+ the above.
+
+Each layer roots a different claim; together they compose into a story
+that no single standard tells alone.
+
+---
+
+## 4. See also
+
+- [Evidence Bundles](./evidence-bundles.md) — the concrete artifact that
+ carries the execution-provenance record.
+- [Evidence Bundle Threat Model](./threat-model.md) — precisely what the
+ record proves and does not prove (tamper-evidence vs. tamper-proofing
+ vs. non-repudiation).
+- `docs/spec/evidence-bundle-1.0.md` — the normative on-disk format and
+ integrity contract.
diff --git a/docs/concepts/threat-model.md b/docs/concepts/threat-model.md
new file mode 100644
index 0000000..1cd55f5
--- /dev/null
+++ b/docs/concepts/threat-model.md
@@ -0,0 +1,148 @@
+# Evidence Bundle Threat Model
+
+This document states, honestly and without marketing, what an evidence
+bundle proves and — just as important — what it does **not** prove. It
+is written in the SLSA spirit: enumerate the threats, name the concrete
+mitigation, and name the residual gap that remains after the mitigation.
+
+If you take one sentence away, take this:
+
+> An evidence bundle lets you prove that **the record was not altered
+> after it was sealed**, and that the record is **internally consistent
+> under replay**. It does not, and cannot, prove that the record was
+> **true when it was written**.
+
+Everything below is an elaboration of that single distinction.
+
+---
+
+## 1. Three properties people conflate
+
+Compliance conversations routinely blur three different guarantees.
+Boruna delivers the first, delivers the second only under stated
+conditions, and deliberately does not claim the third.
+
+| Property | Plain-English meaning | Does Boruna provide it? |
+|----------|-----------------------|-------------------------|
+| **Tamper-evidence** | If someone changes the sealed record, a verifier can *detect* the change. | Yes — this is the core guarantee. |
+| **Tamper-proofing** | Changing the sealed record is *impossible*. | No. Nothing on a general-purpose filesystem is tamper-proof; a holder of the bytes can always rewrite them. Boruna makes tampering *detectable*, not *impossible*. |
+| **Non-repudiation** | The party who produced the record cannot later deny producing it. | Partial, and only with a signed bundle under a pinned key (see §4). Even then it attests *who sealed the bytes*, not *whether the bytes are true*. |
+
+Keep these separate. Most overclaiming comes from quietly upgrading
+"tamper-evident" to "tamper-proof", or from treating a signature as
+proof of *truth* rather than proof of *origin*.
+
+---
+
+## 2. What a bundle actually contains
+
+A sealed bundle (`orchestrator/src/audit/evidence.rs`, `BundleManifest`)
+carries, at minimum:
+
+- `run_id`, `workflow_name`, `workflow_hash`, `policy_hash`
+- `audit_log_hash` — the head of a hash-chained event log
+- `file_checksums` — SHA-256 of every component file (workflow, policy,
+ audit log, env fingerprint, per-step outputs, and optional
+ `intents.json` / `model_invoking_steps.json`)
+- `env_fingerprint` — OS / arch / Boruna version, **self-reported**
+- `bundle_hash` — SHA-256 over the manifest itself (excluding
+ `bundle_hash` and `signature`), which therefore commits to every
+ `file_checksums` entry and the `audit_log_hash`
+- optional `encryption` — AES-256-GCM envelope metadata
+- optional `signature` — an ed25519 signature over `bundle_hash`
+
+The integrity contract enforced by `verify_bundle`
+(`orchestrator/src/audit/verify.rs`): every file's on-disk SHA-256 must
+match `file_checksums`; the audit-log chain must be unbroken
+(`entry_hash = SHA-256(prev_hash || event_json)`); the chain head must
+equal `audit_log_hash`; and all required components must be present. For
+the full on-disk contract see
+[Evidence Bundles](./evidence-bundles.md) and the format spec at
+`docs/spec/evidence-bundle-1.0.md`.
+
+---
+
+## 3. Threats and mitigations
+
+| Threat | What Boruna does | Residual gap |
+|--------|------------------|--------------|
+| **A third party edits a bundle file after it was sealed.** | The hash chain plus `file_checksums` plus `bundle_hash` make a naive edit detectable: any changed byte fails its SHA-256 check, and any spliced/removed audit entry breaks the chain. `verify_bundle` reports the failing check. | A *naive* edit is caught by plain `verify`. A *motivated* attacker who holds the whole bundle can rewrite the file **and** recompute every checksum **and** recompute `bundle_hash` so the bundle is internally self-consistent — this defeats plain `verify` (documented in-code as the "F1 weakness"). Closing it requires an **external anchor** or a **signature under a pinned key** — see §4. Tamper-*evidence*, not tamper-*proofing*. |
+| **The recorder/producer is malicious and seals a FALSE record at write time.** | Nothing. The bundle faithfully seals whatever the producer fed it. A signature (if present) attests *which key sealed these bytes* — the producer's identity — not that the sealed facts are true. | **Not prevented, by construction.** Garbage-in is sealed as faithfully as truth-in. Evidence bundles are a *tamper-evidence* mechanism, not a *truth oracle*. Detecting a lying producer requires controls outside the bundle (independent corroboration, dual control over the recorder, a trusted execution environment — see §5). |
+| **The signing key is compromised.** | With a valid key an attacker can forge or backdate the entire bundle, sign it, and it will verify under that key — hash-chaining is single-writer and provides no defense once the writer's key is held. Pinning a `trusted_pubkey` at verify time limits acceptance to a specific key, so a *different* attacker key is rejected. | If the *legitimate* key itself is stolen, pinning does not help — the forged bundle carries the pinned key. There is no revocation, no key rotation history, and no witnessed record of *when* a signature was made. Mitigation direction (not yet implemented): anchoring signatures in an append-only **transparency log** and/or **keyless, identity-bound signing**, so a signature is bound to a witnessed moment and a verifiable identity rather than to a long-lived secret. |
+| **Backdating — sealing a record now but claiming it was produced earlier.** | The manifest carries `started_at` / `completed_at` / `created_at` timestamps, but these are **self-reported wall-clock values written by the producer**. Nothing external witnesses them. | **No trusted timestamp today.** A producer (or a key holder) can set these fields to any value. Mitigation direction (not yet implemented): anchoring the `bundle_hash` in an external append-only log (e.g. a Rekor-style transparency log) at seal time, so the *earliest-existence* time of the bundle is witnessed by a third party rather than asserted by the producer. |
+| **Non-determinism, especially LLM calls, undermines "reproducibility".** | Replay re-executes the workflow against the **recorded** capability results: LLM calls, HTTP fetches, and other effects return their captured responses instead of hitting live services, and `--verify` checks that the replay reproduces the same output hashes. This proves the recorded run is *internally consistent* — the recorded inputs deterministically produce the recorded outputs. | Replay proves reproducibility **given the recorded capability results** — it does **not** prove that the model (or any external service) would return the same thing if called again live. A non-deterministic model is captured, not tamed: the bundle pins *what the model said this time*, not *what the model will say next time*. Do not read a passing replay as "the model is deterministic." |
+| **The environment fingerprint is forged.** | `env_fingerprint.json` records OS, architecture, and Boruna version, and it is checksummed and covered by `bundle_hash` like every other file — so it cannot be changed *after* sealing without detection. | The fingerprint is **self-reported by the recording process, not hardware-attested.** A malicious or misconfigured producer can write any values it likes *at seal time*; the integrity check only proves those values were not altered afterward, not that they were true. Mitigation direction (not yet implemented): **TEE remote attestation**, binding the fingerprint to a hardware root of trust that attests the actual code image and platform that ran. |
+
+---
+
+## 4. Why "detects tampering" needs a footnote
+
+Plain `boruna evidence verify` gives you **internal** consistency: it
+recomputes every checksum and the chain and confirms they agree with the
+manifest. That catches accidental corruption and unsophisticated edits.
+
+It does **not**, by itself, catch an attacker who controls the whole
+bundle, because that attacker can make the manifest agree with their
+forgery. Two independent, composable checks close this gap; neither is on
+by default, and each roots trust in something the attacker does not
+control:
+
+1. **External anchor** (`--expected-bundle-hash` /
+ `expected_bundle_hash`). You record the `bundle_hash` out-of-band at
+ seal time — in a separate system the attacker cannot rewrite — and
+ supply it at verify time. Verification then requires the recomputed
+ hash to equal *your* anchor, not the manifest's self-reported one. A
+ forged-but-self-consistent bundle fails because its recomputed hash no
+ longer matches the anchor you kept. This is what makes a plaintext
+ bundle genuinely tamper-evident against a motivated attacker.
+
+2. **ed25519 signature under a pinned key** (`--verify-key` /
+ `trusted_pubkey`, optionally `require_signature`). The producer signs
+ `bundle_hash` with an ed25519 key; the verifier pins the *expected*
+ public key. Trust is rooted in the pinned key: a bundle re-signed with
+ any other key is rejected as `signature_untrusted_key`. Without
+ pinning, a signature proves only that *some* key signed — an attacker
+ can substitute their own.
+
+The signature's meaning is precise: it attests **which key sealed these
+bytes**. That is an origin/authenticity claim about the producer, not a
+truth claim about the content (contrast the malicious-producer row in
+§3). Non-repudiation follows only to the extent that the key is bound to
+an accountable identity and is not shared — conditions the bundle format
+cannot enforce on its own.
+
+---
+
+## 5. Mitigation directions (not yet implemented)
+
+The residual gaps in §3 are real. The honest position is that they are
+*known* and have *known* remedies on the roadmap, none of which ship
+today:
+
+- **Transparency-log anchoring** (Rekor-style): witness the `bundle_hash`
+ in an external append-only log at seal time, giving a third-party-
+ attested earliest-existence timestamp and defeating silent backdating.
+- **Keyless / identity-bound signing**: bind a signature to a verifiable
+ workload identity for a short-lived credential, reducing the blast
+ radius of a stolen long-lived key.
+- **TEE remote attestation**: replace the self-reported environment
+ fingerprint with a hardware-attested measurement of the code image and
+ platform that actually executed.
+
+Until these land, treat the corresponding claims conservatively: a bundle
+proves post-seal integrity and internal replay-consistency, anchored or
+signed bundles additionally prove origin against a chosen root of trust,
+and *nothing in the bundle* proves the producer was honest or that the
+timestamps are true.
+
+---
+
+## 6. See also
+
+- [Evidence Bundles](./evidence-bundles.md) — on-disk layout, hash chain,
+ and the `verify` / `inspect` / replay workflow.
+- [Runtime Execution Provenance](./runtime-execution-provenance.md) — the
+ provenance category Boruna occupies, and how it relates to SLSA,
+ in-toto, Sigstore, C2PA, and TEE attestation.
+- `docs/spec/evidence-bundle-1.0.md` — the normative format and integrity
+ contract.
diff --git a/docs/spec/runtime-provenance-predicate-1.0.md b/docs/spec/runtime-provenance-predicate-1.0.md
new file mode 100644
index 0000000..9db4c63
--- /dev/null
+++ b/docs/spec/runtime-provenance-predicate-1.0.md
@@ -0,0 +1,214 @@
+# Boruna Runtime-Provenance Predicate 1.0
+
+`predicateType: https://boruna.dev/runtime-provenance/v1`
+
+This spec defines an **interop** view of a Boruna evidence bundle: the same
+provenance the native bundle already records (`manifest.json`), re-emitted as a
+standard [in-toto Statement](https://github.com/in-toto/attestation) wrapped in a
+[DSSE](https://github.com/secure-systems-lab/dsse) envelope so that off-the-shelf
+supply-chain tooling (`cosign verify-blob`, `in-toto-verify`) can consume Boruna
+evidence.
+
+It is **additive and non-breaking**. Producing an attestation does not modify the
+bundle, its `manifest.json`, its `bundle_hash`, or the existing
+`evidence verify` path. See `docs/spec/evidence-bundle-1.0.md` for the native
+format that remains the source of truth.
+
+The producer/verifier lives in `orchestrator/src/audit/attestation.rs`; the CLI
+surface is `boruna evidence attest`.
+
+---
+
+## 1. Artifacts
+
+Two nested artifacts are produced from a finalized `BundleManifest`:
+
+1. an **in-toto Statement v1**, and
+2. a **DSSE envelope** wrapping that Statement, signed with the SAME ed25519 key
+ used for manifest signing (`EvidenceBundleBuilder::with_signing_key`). No new
+ keypair is introduced; the DSSE `keyid` is the lowercase-hex ed25519 public
+ key, identical to `manifest.signature.public_key`.
+
+The envelope is written to `/attestation.intoto.dsse.json`.
+
+---
+
+## 2. Statement shape
+
+```jsonc
+{
+ "_type": "https://in-toto.io/Statement/v1",
+ "subject": [
+ { "name": "", "digest": { "sha256": "" } },
+ // ... one per manifest.file_checksums entry ...
+ { "name": "boruna-bundle:", "digest": { "sha256": "" } }
+ ],
+ "predicateType": "https://boruna.dev/runtime-provenance/v1",
+ "predicate": { /* see §3 */ }
+}
+```
+
+### Subjects
+
+`subject[]` is the set of artifacts this attestation makes claims about:
+
+- **Every component file** the manifest checksums — reusing
+ `manifest.file_checksums` verbatim (name → `sha256`). These include
+ `workflow.json`, `policy.json`, `audit_log.json`, `env_fingerprint.json`,
+ per-step `outputs//.json`, and any optional components
+ (`intents.json`, `model_invoking_steps.json`, `event_log.json`).
+- **The bundle itself**, as a synthetic subject `boruna-bundle:` whose
+ `sha256` is the manifest's `bundle_hash`. (`bundle_hash` is the SHA-256 of the
+ canonicalized manifest, so it is a digest, not a file — but it lets a verifier
+ bind the whole bundle by a single value.)
+
+`file_checksums` is a `BTreeMap`, so subjects are emitted in sorted-name order —
+the Statement bytes are deterministic for a given manifest.
+
+---
+
+## 3. Predicate schema
+
+The predicate borrows the shape of
+[SLSA Provenance v1](https://slsa.dev/provenance/v1)'s `buildDefinition` /
+`runDetails`, mapping the existing manifest fields:
+
+```jsonc
+{
+ "buildDefinition": {
+ "buildType": "https://boruna.dev/workflow-run/v1",
+ "externalParameters": {
+ "workflowName": "",
+ "workflowHash": "",
+ "policyHash": ""
+ },
+ "internalParameters": {
+ "borunaVersion": "",
+ "envFingerprint": { /* manifest.env_fingerprint, verbatim */ }
+ }
+ },
+ "runDetails": {
+ "builder": { "id": "https://boruna.dev/boruna@" },
+ "metadata": {
+ "invocationId": "",
+ "startedOn": "",
+ "finishedOn": ""
+ },
+ "byproducts": {
+ "auditLogHash": "",
+ "bundleHash": ""
+ }
+ }
+}
+```
+
+### Field mapping (manifest → predicate)
+
+| Manifest field | Predicate location |
+|-----------------------|------------------------------------------------------|
+| `workflow_name` | `buildDefinition.externalParameters.workflowName` |
+| `workflow_hash` | `buildDefinition.externalParameters.workflowHash` |
+| `policy_hash` | `buildDefinition.externalParameters.policyHash` |
+| `env_fingerprint` | `buildDefinition.internalParameters.envFingerprint` |
+| (build version) | `buildDefinition.internalParameters.borunaVersion` |
+| `run_id` | `runDetails.metadata.invocationId` |
+| `started_at` | `runDetails.metadata.startedOn` |
+| `completed_at` | `runDetails.metadata.finishedOn` |
+| `audit_log_hash` | `runDetails.byproducts.auditLogHash` |
+| `bundle_hash` | `runDetails.byproducts.bundleHash` |
+
+**Capability set and contract-check results** are not manifest struct fields —
+they live in bundle *components* (`event_log.json` carries `ContractCheck`
+events; `model_invoking_steps.json` lists steps that reached an `llm.*`
+capability). Those components appear as **subjects** (by SHA-256) rather than
+being inlined into the predicate, so the attestation still binds them without
+duplicating or re-parsing their contents. A consumer that wants contract-check
+detail dereferences the `event_log.json` subject and reads it from the bundle.
+
+All maps are `BTreeMap` and struct fields serialize in declaration order, so the
+Statement is byte-stable: same run → same manifest → same Statement bytes → same
+signature.
+
+---
+
+## 4. DSSE envelope
+
+```jsonc
+{
+ "payload": "",
+ "payloadType": "application/vnd.in-toto+json",
+ "signatures": [
+ { "sig": "", "keyid": "" }
+ ]
+}
+```
+
+The signature is an ed25519 signature over the DSSE **Pre-Authentication
+Encoding (PAE)** of `(payloadType, payload)`:
+
+```
+PAE(type, body) = "DSSEv1" SP LEN(type) SP type SP LEN(body) SP body
+```
+
+where `SP` is a single ASCII space (`0x20`) and `LEN` is the ASCII-decimal byte
+length. `type` is `application/vnd.in-toto+json` and `body` is the **raw**
+Statement JSON bytes (the pre-base64 bytes), not the base64 text.
+
+**Known vector** (from the DSSE spec's worked example, asserted in
+`attestation.rs` tests):
+
+```
+PAE("http://example.com/HelloWorld", "hello world")
+ = "DSSEv1 29 http://example.com/HelloWorld 11 hello world"
+```
+
+The `payloadType` is bound into the signed bytes, so a signature over one payload
+type cannot be replayed under another.
+
+---
+
+## 5. CLI
+
+```bash
+# Produce: sign the manifest's provenance into a DSSE envelope.
+# Reuses the SAME ed25519 seed you signed the bundle with.
+boruna evidence attest --signing-key <64-hex-seed>
+# (or set BORUNA_BUNDLE_SIGNING_KEY instead of --signing-key)
+# → writes /attestation.intoto.dsse.json
+
+# Verify: check the DSSE signature over the PAE.
+boruna evidence attest --verify
+# Optionally pin the trusted signer key:
+boruna evidence attest --verify --verify-key <64-hex-pubkey>
+```
+
+`--verify` exits non-zero on any failure (bad signature, mutated payload, wrong
+`payloadType`, or a pinned key that made no valid signature).
+
+---
+
+## 6. Ecosystem compatibility
+
+The envelope is a standard DSSE envelope with `payloadType`
+`application/vnd.in-toto+json` and a standard in-toto Statement payload, so it is
+structurally consumable by the wider ecosystem:
+
+- **`cosign verify-blob-attestation`** consumes a DSSE envelope + a public key and
+ verifies the ed25519 signature over the PAE. Export the signer's public key in
+ PEM form (the DSSE `keyid` here is the raw 32-byte ed25519 public key as hex;
+ cosign expects a PEM `PUBLIC KEY`, so wrap the key in SubjectPublicKeyInfo DER →
+ PEM before handing it to cosign). The signature algorithm (ed25519 over PAE)
+ and envelope layout match what cosign verifies.
+- **`in-toto-verify` / the in-toto attestation validators** parse the Statement
+ (`_type`, `subject`, `predicateType`, `predicate`) directly; the predicate is a
+ custom type, so policy is expressed against `predicateType ==
+ https://boruna.dev/runtime-provenance/v1` and the fields in §3.
+
+**Honest caveat.** The bytes and algorithms follow the DSSE and in-toto specs, and
+Boruna verifies its own envelopes end-to-end (round-trip, PAE known-vector, and
+tamper tests). Full black-box interop with a specific `cosign` / `in-toto`
+*release* — including the exact public-key PEM/DER encoding each tool wants and
+any tool-specific envelope expectations — has **not** been exercised against those
+binaries in this change; treat cross-tool verification as "spec-conformant,
+pending a live `cosign`/`in-toto-verify` integration check." The key-encoding
+bridge (raw hex ed25519 → SPKI PEM) is the most likely point of friction.
diff --git a/examples/adapters/README.md b/examples/adapters/README.md
new file mode 100644
index 0000000..fc82b49
--- /dev/null
+++ b/examples/adapters/README.md
@@ -0,0 +1,128 @@
+# Boruna as an auditable execution cell
+
+These adapters show how to call Boruna from an external agent framework
+(LangGraph, Temporal, OpenAI Agents SDK, …) as a **deterministic, auditable
+execution cell**: you hand Boruna an `.ax` program, it runs it under a
+capability policy, and it hands back not just the result but a **verifiable
+execution record**.
+
+The tool that makes this work is **`boruna_run_sealed`** on Boruna's MCP
+server. It runs the program, captures the VM's `EventLog`, re-executes it a
+second time feeding the recorded capability results back, and compares the two
+logs (`ReplayEngine::verify_full`). You get:
+
+| field | meaning |
+|-------|---------|
+| `result` | the program's return value |
+| `replay_verified` | `true` only if the second run reproduced every event identically |
+| `capability_calls` | ordered list of capability calls the run made |
+| `event_log` | the full event log (capability calls/results, actor events, UI emits, contract checks) |
+| `event_log_sha256` | SHA-256 of the canonical event log — a stable **seal handle** to pin |
+| `steps` | deterministic step count |
+
+## What "sealed" means (and does not)
+
+The seal here is a **replay-verified event log**, *not* a signed evidence
+bundle. That is deliberate and honest:
+
+- A single MCP call is a stateless `source`-string invocation. It has no
+ workflow definition, no data store, and no output directory.
+- A full **signed, hash-chained evidence bundle** is a workflow-directory
+ artifact produced by the **orchestrator**, not by a single run. Produce one
+ with:
+
+ ```bash
+ boruna workflow run --policy allow-all --record
+ boruna evidence verify
+ ```
+
+`boruna_run_sealed` returns the strongest artifact a single run genuinely
+produces — a deterministic replay proof plus a digest — and says so in its
+`seal.note`. It never fabricates a bundle. If you need the signed bundle, use
+the workflow path above.
+
+## Registering the MCP server
+
+Boruna ships an MCP server binary, `boruna-mcp`, speaking JSON-RPC over stdio.
+
+`.mcp.json` (Claude Code) or equivalent:
+
+```json
+{
+ "mcpServers": {
+ "boruna": {
+ "command": "cargo",
+ "args": ["run", "--bin", "boruna-mcp", "--manifest-path", "/path/to/ai-lang/Cargo.toml"],
+ "env": {}
+ }
+ }
+}
+```
+
+Or, with the binary on `PATH`, just `command: "boruna-mcp"`.
+
+## Calling the tool
+
+Arguments:
+
+```jsonc
+{
+ "source": "fn main() -> Int { 2 + 40 }\n", // required, .ax source (≤ 1 MB)
+ "policy": "allow-all", // optional: "allow-all" | "deny-all" | policy object
+ "max_steps": 10000000 // optional deterministic ceiling
+}
+```
+
+Success response (abridged):
+
+```jsonc
+{
+ "success": true,
+ "protocol_version": 1,
+ "result": 42,
+ "steps": 6,
+ "replay_verified": true,
+ "replay_divergence_reason": null,
+ "event_count": 0,
+ "capability_calls": [],
+ "event_log": { "version": 2, "events": [], "truncated": false },
+ "event_log_sha256": "5df6e0e2761359d30a8275058e299fcc0381534545f55cf43e41983f5d4c9456",
+ "seal": {
+ "kind": "replay-verified-event-log",
+ "verified": true,
+ "digest_alg": "sha256",
+ "digest": "5df6…9456",
+ "note": "This seal is a deterministic replay proof over the VM EventLog … not a signed evidence bundle …"
+ }
+}
+```
+
+Domain errors come back as `success: false` with a stable `error_kind`
+(`parse_error`, `runtime_error`, `capability_denied`, `invalid_policy`, …) —
+they are returned as successful tool responses, not MCP transport errors, so
+callers branch on the JSON.
+
+## The adapters
+
+- **`langgraph_node.py`** — a LangGraph node that calls `boruna_run_sealed` and
+ writes `{result, replay_verified}` into graph state so downstream nodes can
+ branch on reproducibility.
+- **`temporal_activity.py`** — a Temporal **Activity** wrapping the same call
+ (external I/O belongs in an Activity, not in deterministic workflow code),
+ returning a small result the workflow can persist or assert on.
+
+Both mark their MCP-client plumbing (`call_mcp_tool`) as **pseudo-code** — wire
+it to your MCP client of choice (e.g. the `mcp` Python SDK's stdio client, or
+LangChain's MCP adapters). Everything else is real.
+
+### CLI fallback
+
+If you would rather not run the MCP server, the CLI covers the run itself
+(without the replay-verified envelope):
+
+```bash
+boruna run program.ax --policy allow-all
+```
+
+For the full audited path with a signed bundle, use `boruna workflow run
+--record` + `boruna evidence verify` as shown above.
diff --git a/examples/adapters/langgraph_node.py b/examples/adapters/langgraph_node.py
new file mode 100644
index 0000000..486a4cc
--- /dev/null
+++ b/examples/adapters/langgraph_node.py
@@ -0,0 +1,106 @@
+"""LangGraph node that runs an .ax program through Boruna as a deterministic,
+auditable execution cell and writes the verified result back into graph state.
+
+The node calls the `boruna_run_sealed` MCP tool. Boruna compiles + runs the
+`.ax` source, replays it, and returns a verifiable execution record:
+`{ result, replay_verified, event_log_sha256, event_log, ... }`. We surface
+`result` and `replay_verified` into the graph so downstream nodes (or a human
+gate) can branch on whether the run is reproducible.
+
+Status: illustrative. The MCP plumbing (`call_mcp_tool`) is PSEUDO-CODE — wire
+it to your MCP client of choice (e.g. the `mcp` Python SDK's stdio client, or
+LangChain's MCP adapters). Everything else is real, runnable LangGraph.
+
+Register the server (see README.md in this dir):
+ boruna-mcp # JSON-RPC over stdio
+or, from a checkout:
+ cargo run --bin boruna-mcp
+"""
+
+from __future__ import annotations
+
+import json
+from typing import Any, Optional, TypedDict
+
+from langgraph.graph import END, START, StateGraph
+
+
+class GraphState(TypedDict, total=False):
+ # Input: the .ax program to execute.
+ ax_source: str
+ # Capability policy — same shape boruna_run/boruna_run_sealed accept:
+ # "allow-all", "deny-all", or a policy object. Optional; defaults allow-all.
+ policy: Any
+ # Outputs written by the boruna node:
+ result: Any
+ replay_verified: bool
+ event_log_sha256: Optional[str]
+ error: Optional[str]
+
+
+# ---------------------------------------------------------------------------
+# PSEUDO-CODE: replace with your MCP client call. The contract is:
+# tool "boruna_run_sealed", args {source, policy?, max_steps?}
+# -> returns a JSON *string* (Boruna tools return text content).
+# ---------------------------------------------------------------------------
+def call_mcp_tool(tool: str, arguments: dict) -> str: # pragma: no cover
+ raise NotImplementedError(
+ "Wire this to your MCP client. Example with the `mcp` SDK stdio client:\n"
+ " async with stdio_client(StdioServerParameters(command='boruna-mcp')) as (r, w):\n"
+ " async with ClientSession(r, w) as session:\n"
+ " await session.initialize()\n"
+ " res = await session.call_tool(tool, arguments)\n"
+ " return res.content[0].text\n"
+ )
+
+
+def boruna_sealed_node(state: GraphState) -> GraphState:
+ """Run state['ax_source'] through Boruna and record the verified outcome."""
+ raw = call_mcp_tool(
+ "boruna_run_sealed",
+ {
+ "source": state["ax_source"],
+ "policy": state.get("policy", "allow-all"),
+ },
+ )
+ payload = json.loads(raw)
+
+ if not payload.get("success", False):
+ # Domain errors (parse/runtime/capability_denied/invalid_policy) come
+ # back as success=false — surface, don't raise, so the graph can route.
+ return {
+ "result": None,
+ "replay_verified": False,
+ "event_log_sha256": None,
+ "error": f"{payload.get('error_kind')}: {payload.get('message')}",
+ }
+
+ return {
+ "result": payload["result"],
+ "replay_verified": payload["replay_verified"],
+ "event_log_sha256": payload.get("event_log_sha256"),
+ "error": None,
+ }
+
+
+def build_graph():
+ g = StateGraph(GraphState)
+ g.add_node("boruna", boruna_sealed_node)
+ g.add_edge(START, "boruna")
+ g.add_edge("boruna", END)
+ return g.compile()
+
+
+if __name__ == "__main__": # pragma: no cover
+ graph = build_graph()
+ out = graph.invoke(
+ {
+ "ax_source": "fn main() -> Int {\n 2 + 40\n}\n",
+ "policy": "allow-all",
+ }
+ )
+ # Only trust the result if the run reproduced deterministically.
+ if out["replay_verified"]:
+ print("verified result:", out["result"], "seal:", out["event_log_sha256"])
+ else:
+ print("UNVERIFIED / error:", out.get("error"))
diff --git a/examples/adapters/temporal_activity.py b/examples/adapters/temporal_activity.py
new file mode 100644
index 0000000..8d1cc41
--- /dev/null
+++ b/examples/adapters/temporal_activity.py
@@ -0,0 +1,93 @@
+"""Temporal Activity wrapping Boruna as a deterministic, auditable execution cell.
+
+Why an Activity and not Workflow code: Temporal *workflow* code must itself be
+deterministic and may not do I/O, so calling an external process/MCP server
+belongs in an **Activity**. The nice property here is that Boruna's own
+execution is deterministic and replay-verified, so the Activity returns a
+`replay_verified` flag plus a `event_log_sha256` seal the workflow can persist
+or assert on.
+
+The Activity calls the `boruna_run_sealed` MCP tool (or, equivalently, the
+`boruna run` CLI). It returns a small dataclass the workflow can store.
+
+Status: illustrative. `call_mcp_tool` is PSEUDO-CODE — wire it to your MCP
+client. The Temporal activity/worker scaffolding is real (temporalio SDK).
+"""
+
+from __future__ import annotations
+
+import json
+from dataclasses import dataclass
+from typing import Any, Optional
+
+from temporalio import activity
+
+
+@dataclass
+class SealedRunRequest:
+ ax_source: str
+ policy: Any = "allow-all" # "allow-all" | "deny-all" | policy object
+ max_steps: int = 10_000_000
+
+
+@dataclass
+class SealedRunResult:
+ success: bool
+ result: Any
+ replay_verified: bool
+ event_log_sha256: Optional[str]
+ error: Optional[str]
+
+
+# ---------------------------------------------------------------------------
+# PSEUDO-CODE: replace with your MCP client call, OR shell out to the CLI:
+# subprocess.run(["boruna", "run", path, "--policy", "allow-all"], ...)
+# The MCP tool returns richer data (replay_verified + seal), so it is preferred.
+# ---------------------------------------------------------------------------
+def call_mcp_tool(tool: str, arguments: dict) -> str: # pragma: no cover
+ raise NotImplementedError("Wire this to your MCP client — see README.md")
+
+
+@activity.defn
+async def run_boruna_sealed(req: SealedRunRequest) -> SealedRunResult:
+ raw = call_mcp_tool(
+ "boruna_run_sealed",
+ {"source": req.ax_source, "policy": req.policy, "max_steps": req.max_steps},
+ )
+ payload = json.loads(raw)
+
+ if not payload.get("success", False):
+ return SealedRunResult(
+ success=False,
+ result=None,
+ replay_verified=False,
+ event_log_sha256=None,
+ error=f"{payload.get('error_kind')}: {payload.get('message')}",
+ )
+
+ return SealedRunResult(
+ success=True,
+ result=payload["result"],
+ replay_verified=payload["replay_verified"],
+ event_log_sha256=payload.get("event_log_sha256"),
+ error=None,
+ )
+
+
+# --- Example workflow using the activity (illustrative) --------------------
+# from datetime import timedelta
+# from temporalio import workflow
+#
+# @workflow.defn
+# class DeterministicCellWorkflow:
+# @workflow.run
+# async def run(self, ax_source: str) -> SealedRunResult:
+# res = await workflow.execute_activity(
+# run_boruna_sealed,
+# SealedRunRequest(ax_source=ax_source),
+# start_to_close_timeout=timedelta(seconds=30),
+# )
+# # Fail the workflow if the cell did not reproduce deterministically.
+# if res.success and not res.replay_verified:
+# raise workflow.ApplicationError("boruna run was not replay-verified")
+# return res
diff --git a/libs/std-guard/package.ax.json b/libs/std-guard/package.ax.json
new file mode 100644
index 0000000..08a2e48
--- /dev/null
+++ b/libs/std-guard/package.ax.json
@@ -0,0 +1,8 @@
+{
+ "name": "std.guard",
+ "version": "1.0.0",
+ "description": "Deterministic output-content validators for guarding effects on model/LLM output",
+ "dependencies": {},
+ "required_capabilities": [],
+ "exposed_modules": ["core"]
+}
diff --git a/libs/std-guard/src/core.ax b/libs/std-guard/src/core.ax
new file mode 100644
index 0000000..f2e3fbf
--- /dev/null
+++ b/libs/std-guard/src/core.ax
@@ -0,0 +1,200 @@
+// std.guard — Deterministic output-content validators.
+//
+// Boruna gates ACTIONS via capabilities, but by default trusts the CONTENT
+// a model/LLM produces. std.guard provides pure, deterministic checks a
+// workflow can run on a string BEFORE letting it drive an effect — mirroring
+// the deterministic (non-ML) validators from Guardrails AI.
+//
+// Every function here is pure and deterministic: same input -> same output,
+// no capabilities, no side effects. All string matching is exact/structural;
+// there is no regex and no ML classification.
+
+// A richer result carrier for callers that want a reason string alongside the
+// boolean verdict (mirrors std.validation's ValidationResult shape).
+type GuardResult { passed: Bool, reason: String }
+
+fn guard_pass() -> GuardResult {
+ GuardResult { passed: true, reason: "" }
+}
+
+fn guard_fail(reason: String) -> GuardResult {
+ GuardResult { passed: false, reason: reason }
+}
+
+// Wrap a raw boolean verdict into a GuardResult with an explanatory reason
+// used only when the check fails.
+fn to_guard(passed: Bool, reason: String) -> GuardResult {
+ if passed {
+ guard_pass()
+ } else {
+ guard_fail(reason)
+ }
+}
+
+// ── Length / emptiness ──
+
+// Non-empty after trimming surrounding whitespace. Whitespace-only strings
+// are treated as empty, since a blank model answer should not drive an effect.
+fn is_non_empty(s: String) -> Bool {
+ __builtin_string_len(__builtin_string_trim(s)) > 0
+}
+
+fn min_length(s: String, n: Int) -> Bool {
+ __builtin_string_len(s) >= n
+}
+
+fn max_length(s: String, n: Int) -> Bool {
+ __builtin_string_len(s) <= n
+}
+
+fn length_between(s: String, lo: Int, hi: Int) -> Bool {
+ let len: Int = __builtin_string_len(s)
+ len >= lo && len <= hi
+}
+
+// ── Numeric range (parse-then-compare; false on parse failure) ──
+
+// Parse s as an integer and confirm lo <= value <= hi. Any non-integer input
+// (empty, floats, words) fails the guard rather than throwing.
+fn is_in_range(s: String, lo: Int, hi: Int) -> Bool {
+ match __builtin_int_parse(s) {
+ Some(n) => n >= lo && n <= hi,
+ None => false,
+ }
+}
+
+// Float variant of is_in_range. Non-numeric input fails the guard.
+fn is_in_range_float(s: String, lo: Float, hi: Float) -> Bool {
+ match __builtin_float_parse(s) {
+ Some(f) => f >= lo && f <= hi,
+ None => false,
+ }
+}
+
+// ── Structural string matchers (exact, case-sensitive) ──
+
+fn matches_prefix(s: String, prefix: String) -> Bool {
+ __builtin_string_starts_with(s, prefix)
+}
+
+fn matches_suffix(s: String, suffix: String) -> Bool {
+ __builtin_string_ends_with(s, suffix)
+}
+
+fn matches_contains(s: String, needle: String) -> Bool {
+ __builtin_string_contains(s, needle)
+}
+
+// ── Allow-list / ban-list ──
+
+// True when target appears exactly in items. Linear scan (no loops-free
+// requirement: uses a bounded while like std.json).
+fn list_contains_str(items: List, target: String) -> Bool {
+ let n: Int = list_len(items)
+ let i: Int = 0
+ let found: Bool = false
+ while i < n {
+ let item: String = list_get(items, i)
+ found = found || (item == target)
+ i = i + 1
+ }
+ found
+}
+
+// Allow-list: s must be one of the permitted options (exact match).
+fn is_one_of(s: String, options: List) -> Bool {
+ list_contains_str(options, s)
+}
+
+// Ban-list: s must NOT be one of the banned values (exact match).
+fn not_in_banlist(s: String, banned: List) -> Bool {
+ if list_contains_str(banned, s) {
+ false
+ } else {
+ true
+ }
+}
+
+// ── Content heuristics ──
+
+// Case-insensitive substring test: does haystack contain needle ignoring case?
+fn contains_ci(haystack: String, needle: String) -> Bool {
+ let h: String = __builtin_string_to_lower(haystack)
+ let n: String = __builtin_string_to_lower(needle)
+ __builtin_string_contains(h, n)
+}
+
+// Heuristic: does the output read like a model refusal / boilerplate apology
+// rather than a real answer? Case-insensitive substring match against a fixed
+// phrase set. This is a heuristic, not a classifier: it can miss novel
+// phrasings (false negatives) and can fire on legitimate text that quotes a
+// refusal (false positives). Use it as a cheap pre-filter, not a proof.
+fn looks_like_refusal(s: String) -> Bool {
+ let lo: String = __builtin_string_to_lower(s)
+ __builtin_string_contains(lo, "i cannot")
+ || __builtin_string_contains(lo, "i can't")
+ || __builtin_string_contains(lo, "i'm sorry")
+ || __builtin_string_contains(lo, "i am sorry")
+ || __builtin_string_contains(lo, "as an ai")
+ || __builtin_string_contains(lo, "i am unable")
+ || __builtin_string_contains(lo, "i'm unable")
+ || __builtin_string_contains(lo, "cannot assist")
+ || __builtin_string_contains(lo, "cannot help with")
+ || __builtin_string_contains(lo, "unable to help")
+}
+
+// Cheap STRUCTURAL check that s could be JSON: after trimming, it starts with
+// `{` and ends with `}`, or starts with `[` and ends with `]`. This does NOT
+// parse or validate JSON — it will accept malformed input like "{ bad" -> no,
+// (that ends with 'd'), but WILL accept "{not valid json}" and reject a valid
+// bare JSON number/string/`true`. Treat a true result as "worth trying to
+// parse", never as "this is valid JSON". For real validation, parse it.
+fn is_probably_json(s: String) -> Bool {
+ let t: String = __builtin_string_trim(s)
+ let n: Int = __builtin_string_len(t)
+ if n < 2 {
+ false
+ } else {
+ let is_obj: Bool = __builtin_string_starts_with(t, "{") && __builtin_string_ends_with(t, "}")
+ let is_arr: Bool = __builtin_string_starts_with(t, "[") && __builtin_string_ends_with(t, "]")
+ is_obj || is_arr
+ }
+}
+
+fn main() -> Int {
+ // Exercise every validator with at least one true and one false case.
+ let allow: List = ["approve", "reject"]
+ let ban: List = ["DROP TABLE"]
+
+ let checks_true: Bool = is_non_empty("hello")
+ && min_length("hello", 3)
+ && max_length("hi", 5)
+ && length_between("abc", 1, 5)
+ && is_in_range("42", 0, 100)
+ && is_in_range_float("2.5", 0.0, 10.0)
+ && matches_prefix("prefix-body", "prefix-")
+ && matches_suffix("body.json", ".json")
+ && matches_contains("needle in haystack", "needle")
+ && is_one_of("approve", allow)
+ && not_in_banlist("safe value", ban)
+ && contains_ci("HELLO World", "hello")
+ && looks_like_refusal("I'm sorry, I cannot do that")
+ && is_probably_json("{\"ok\": true}")
+
+ let checks_false: Bool = is_non_empty(" ")
+ || min_length("hi", 5)
+ || max_length("toolong", 3)
+ || is_in_range("not-a-number", 0, 100)
+ || is_in_range_float("abc", 0.0, 10.0)
+ || is_one_of("nope", allow)
+ || (not_in_banlist("DROP TABLE", ban) == false && false)
+ || looks_like_refusal("Here is your answer: 42")
+ || is_probably_json("just some prose")
+
+ // checks_true must hold and checks_false must not; collapse to 0.
+ if checks_true && (checks_false == false) {
+ 0
+ } else {
+ 1
+ }
+}
diff --git a/orchestrator/src/audit/attestation.rs b/orchestrator/src/audit/attestation.rs
new file mode 100644
index 0000000..9239695
--- /dev/null
+++ b/orchestrator/src/audit/attestation.rs
@@ -0,0 +1,653 @@
+//! Interop layer: emit an evidence bundle's provenance ALSO as a
+//! standard in-toto Statement wrapped in a DSSE envelope, so Boruna
+//! evidence is consumable by the supply-chain ecosystem (`cosign
+//! verify-blob`, `in-toto-verify`) WITHOUT changing the native bundle
+//! format. This is purely ADDITIVE — nothing here touches the manifest,
+//! its `bundle_hash`, or the existing verify path.
+//!
+//! Two artifacts are produced from a finalized [`BundleManifest`]:
+//!
+//! 1. an **in-toto Statement v1** — `_type`,
+//! `subject[]` (the run's component files by SHA-256, plus the bundle
+//! itself keyed by `bundle_hash`), `predicateType`
+//! (`https://boruna.dev/runtime-provenance/v1`), and a `predicate`
+//! that maps the manifest fields into a SLSA-provenance-shaped
+//! `buildDefinition` / `runDetails` structure; and
+//! 2. a **DSSE envelope** wrapping that Statement, whose signature is an
+//! ed25519 signature over the DSSE **PAE** pre-authentication
+//! encoding of `(payloadType, payload)`.
+//!
+//! The signing key is the SAME ed25519 key used for manifest signing
+//! (`EvidenceBundleBuilder::with_signing_key`); the `keyid` is the hex
+//! public key, matching `ManifestSignature.public_key`. No new keypair
+//! is introduced.
+//!
+//! Determinism: the Statement is serialized with canonical (sorted-key /
+//! fixed field-order) JSON via `serde_json`, and those exact bytes are
+//! what get base64-encoded into the payload and signed. Same run →
+//! same manifest → same Statement bytes → same PAE → same signature.
+//!
+//! See `docs/spec/runtime-provenance-predicate-1.0.md` for the predicate
+//! schema and a worked `cosign` / `in-toto-verify` example.
+
+use base64::Engine as _;
+use serde::{Deserialize, Serialize};
+use std::collections::BTreeMap;
+
+use crate::audit::evidence::BundleManifest;
+use crate::audit::fingerprint::EnvFingerprint;
+
+/// in-toto Statement `_type` for the v1 spec.
+pub const STATEMENT_TYPE: &str = "https://in-toto.io/Statement/v1";
+
+/// Boruna's runtime-provenance predicate type (versioned).
+pub const PREDICATE_TYPE: &str = "https://boruna.dev/runtime-provenance/v1";
+
+/// DSSE payload type for an in-toto Statement, per the in-toto spec.
+pub const DSSE_PAYLOAD_TYPE: &str = "application/vnd.in-toto+json";
+
+/// Build type recorded in the predicate's `buildDefinition.buildType`.
+pub const BUILD_TYPE: &str = "https://boruna.dev/workflow-run/v1";
+
+/// Errors from building or verifying an attestation.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub enum AttestError {
+ /// The supplied signing seed was not 64 hex chars / 32 bytes.
+ BadSigningKey(String),
+ /// A DSSE artifact could not be (de)serialized.
+ Serialization(String),
+ /// The DSSE payload was not valid base64.
+ BadPayloadBase64(String),
+ /// The envelope's `payloadType` was not the expected in-toto type.
+ UnexpectedPayloadType { found: String },
+ /// A signature's `sig` or `keyid` was malformed hex/base64.
+ BadSignatureEncoding(String),
+ /// The envelope had no signatures to verify.
+ NoSignatures,
+ /// ed25519 verification failed over the PAE for every signature.
+ SignatureInvalid,
+ /// A trusted key was pinned but no signature was made by it.
+ UntrustedKey { pinned: String },
+}
+
+impl std::fmt::Display for AttestError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ AttestError::BadSigningKey(m) => write!(f, "invalid signing key: {m}"),
+ AttestError::Serialization(m) => write!(f, "attestation serialization failed: {m}"),
+ AttestError::BadPayloadBase64(m) => write!(f, "DSSE payload is not valid base64: {m}"),
+ AttestError::UnexpectedPayloadType { found } => write!(
+ f,
+ "unexpected DSSE payloadType {found:?} (expected {DSSE_PAYLOAD_TYPE:?})"
+ ),
+ AttestError::BadSignatureEncoding(m) => write!(f, "bad signature encoding: {m}"),
+ AttestError::NoSignatures => write!(f, "DSSE envelope has no signatures"),
+ AttestError::SignatureInvalid => {
+ write!(f, "ed25519 signature does not verify over the DSSE PAE")
+ }
+ AttestError::UntrustedKey { pinned } => {
+ write!(f, "no signature was made by the pinned key {pinned}")
+ }
+ }
+ }
+}
+
+impl std::error::Error for AttestError {}
+
+/// A single in-toto `subject`: a named artifact by digest.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct Subject {
+ pub name: String,
+ /// Digest algorithm → lowercase-hex digest. Always contains
+ /// `sha256`. `BTreeMap` for deterministic key ordering.
+ pub digest: BTreeMap,
+}
+
+/// SLSA-shaped `buildDefinition` half of the predicate.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct BuildDefinition {
+ #[serde(rename = "buildType")]
+ pub build_type: String,
+ /// Operator-facing inputs that identify the run: the workflow and
+ /// policy the run was bound to (by content hash) plus the workflow
+ /// name.
+ #[serde(rename = "externalParameters")]
+ pub external_parameters: BTreeMap,
+ /// Environment fingerprint captured at finalize time.
+ #[serde(rename = "internalParameters")]
+ pub internal_parameters: InternalParameters,
+}
+
+/// Internal (platform-captured) parameters for the run.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct InternalParameters {
+ #[serde(rename = "borunaVersion")]
+ pub boruna_version: String,
+ #[serde(rename = "envFingerprint")]
+ pub env_fingerprint: EnvFingerprint,
+}
+
+/// SLSA-shaped `runDetails` half of the predicate.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct RunDetails {
+ pub builder: Builder,
+ pub metadata: RunMetadata,
+ /// Non-artifact outputs of the run that are still evidence: the
+ /// hash-chained audit-log hash and the native bundle hash.
+ /// `BTreeMap` for deterministic ordering.
+ pub byproducts: BTreeMap,
+}
+
+/// Identifies the builder (the Boruna engine) that produced the run.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct Builder {
+ pub id: String,
+}
+
+/// Run invocation metadata (SLSA `runDetails.metadata` shape).
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct RunMetadata {
+ #[serde(rename = "invocationId")]
+ pub invocation_id: String,
+ #[serde(rename = "startedOn")]
+ pub started_on: String,
+ #[serde(rename = "finishedOn")]
+ pub finished_on: String,
+}
+
+/// The `predicate` body: a documented mapping of the manifest fields.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct RuntimeProvenancePredicate {
+ #[serde(rename = "buildDefinition")]
+ pub build_definition: BuildDefinition,
+ #[serde(rename = "runDetails")]
+ pub run_details: RunDetails,
+}
+
+/// A full in-toto Statement (v1).
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct InTotoStatement {
+ #[serde(rename = "_type")]
+ pub type_: String,
+ pub subject: Vec,
+ #[serde(rename = "predicateType")]
+ pub predicate_type: String,
+ pub predicate: RuntimeProvenancePredicate,
+}
+
+/// One DSSE signature.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct DsseSignature {
+ /// base64(ed25519 signature over `PAE(payloadType, payload)`).
+ pub sig: String,
+ /// Lowercase-hex ed25519 public key (matches
+ /// `ManifestSignature.public_key`).
+ pub keyid: String,
+}
+
+/// A DSSE envelope wrapping a base64 in-toto Statement payload.
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct DsseEnvelope {
+ /// base64(canonical Statement JSON bytes).
+ pub payload: String,
+ #[serde(rename = "payloadType")]
+ pub payload_type: String,
+ pub signatures: Vec,
+}
+
+/// DSSE Pre-Authentication Encoding of `(payload_type, payload)`.
+///
+/// `PAE(type, body) = "DSSEv1" SP LEN(type) SP type SP LEN(body) SP body`
+/// where `SP` is a single ASCII space and `LEN` is the ASCII-decimal
+/// byte length. This is the exact string that gets signed — it binds
+/// the payload type into the signature so a signature over one type
+/// cannot be replayed as another. See the DSSE spec (`protocol.md`).
+pub fn pae(payload_type: &str, payload: &[u8]) -> Vec {
+ let mut out = Vec::with_capacity(payload.len() + payload_type.len() + 32);
+ out.extend_from_slice(b"DSSEv1 ");
+ out.extend_from_slice(payload_type.len().to_string().as_bytes());
+ out.push(b' ');
+ out.extend_from_slice(payload_type.as_bytes());
+ out.push(b' ');
+ out.extend_from_slice(payload.len().to_string().as_bytes());
+ out.push(b' ');
+ out.extend_from_slice(payload);
+ out
+}
+
+/// Build the in-toto Statement for a finalized bundle manifest.
+///
+/// `subject[]` is every component file the manifest checksums (by its
+/// SHA-256), plus a synthetic subject for the bundle itself keyed by
+/// `bundle_hash`. The predicate maps the manifest's provenance fields
+/// into a SLSA-shaped structure. Fully deterministic: `file_checksums`
+/// is a `BTreeMap` so subjects come out in sorted-name order.
+pub fn statement_from_manifest(manifest: &BundleManifest, boruna_version: &str) -> InTotoStatement {
+ let mut subject: Vec = manifest
+ .file_checksums
+ .iter()
+ .map(|(name, sha)| {
+ let mut digest = BTreeMap::new();
+ digest.insert("sha256".to_string(), sha.clone());
+ Subject {
+ name: name.clone(),
+ digest,
+ }
+ })
+ .collect();
+ // The bundle itself as a subject, keyed by its manifest bundle_hash.
+ {
+ let mut digest = BTreeMap::new();
+ digest.insert("sha256".to_string(), manifest.bundle_hash.clone());
+ subject.push(Subject {
+ name: format!("boruna-bundle:{}", manifest.run_id),
+ digest,
+ });
+ }
+
+ let mut external_parameters = BTreeMap::new();
+ external_parameters.insert("workflowName".to_string(), manifest.workflow_name.clone());
+ external_parameters.insert("workflowHash".to_string(), manifest.workflow_hash.clone());
+ external_parameters.insert("policyHash".to_string(), manifest.policy_hash.clone());
+
+ let mut byproducts = BTreeMap::new();
+ byproducts.insert("auditLogHash".to_string(), manifest.audit_log_hash.clone());
+ byproducts.insert("bundleHash".to_string(), manifest.bundle_hash.clone());
+
+ InTotoStatement {
+ type_: STATEMENT_TYPE.to_string(),
+ subject,
+ predicate_type: PREDICATE_TYPE.to_string(),
+ predicate: RuntimeProvenancePredicate {
+ build_definition: BuildDefinition {
+ build_type: BUILD_TYPE.to_string(),
+ external_parameters,
+ internal_parameters: InternalParameters {
+ boruna_version: boruna_version.to_string(),
+ env_fingerprint: manifest.env_fingerprint.clone(),
+ },
+ },
+ run_details: RunDetails {
+ builder: Builder {
+ id: format!("https://boruna.dev/boruna@{boruna_version}"),
+ },
+ metadata: RunMetadata {
+ invocation_id: manifest.run_id.clone(),
+ started_on: manifest.started_at.clone(),
+ finished_on: manifest.completed_at.clone(),
+ },
+ byproducts,
+ },
+ },
+ }
+}
+
+/// Serialize a Statement to its canonical (deterministic) JSON bytes.
+///
+/// `serde_json` emits struct fields in declaration order and `BTreeMap`
+/// keys in sorted order, so the output is byte-stable for a given
+/// Statement — exactly what the DSSE payload/signature require. These
+/// bytes (not a re-serialization) are what get base64-encoded into the
+/// payload and fed through the PAE.
+pub fn statement_to_canonical_bytes(statement: &InTotoStatement) -> Result, AttestError> {
+ serde_json::to_vec(statement).map_err(|e| AttestError::Serialization(e.to_string()))
+}
+
+/// Parse a 32-byte ed25519 signing seed from 64 hex chars.
+pub fn parse_seed_hex(hex: &str) -> Result<[u8; 32], AttestError> {
+ let hex = hex.trim();
+ if hex.len() != 64 {
+ return Err(AttestError::BadSigningKey(format!(
+ "expected 64 hex chars (32 bytes), got {}",
+ hex.len()
+ )));
+ }
+ let mut out = [0u8; 32];
+ for (i, chunk) in hex.as_bytes().chunks(2).enumerate() {
+ let s = std::str::from_utf8(chunk)
+ .map_err(|_| AttestError::BadSigningKey("non-utf8".to_string()))?;
+ out[i] = u8::from_str_radix(s, 16)
+ .map_err(|_| AttestError::BadSigningKey("non-hex digit".to_string()))?;
+ }
+ Ok(out)
+}
+
+/// Produce a signed DSSE envelope for a manifest, using the ed25519 key
+/// derived from `signing_seed` (the SAME key machinery as manifest
+/// signing). The signature is over `PAE(payloadType, payload)`; the
+/// `keyid` is the hex public key.
+pub fn attest(
+ manifest: &BundleManifest,
+ boruna_version: &str,
+ signing_seed: &[u8; 32],
+) -> Result {
+ let statement = statement_from_manifest(manifest, boruna_version);
+ let payload_bytes = statement_to_canonical_bytes(&statement)?;
+ sign_statement_bytes(&payload_bytes, signing_seed)
+}
+
+/// Sign already-serialized Statement bytes into a DSSE envelope. Split
+/// out from [`attest`] so tests can exercise the PAE/signature path
+/// against known payload bytes.
+pub fn sign_statement_bytes(
+ payload_bytes: &[u8],
+ signing_seed: &[u8; 32],
+) -> Result {
+ use ed25519_dalek::Signer;
+ let sk = ed25519_dalek::SigningKey::from_bytes(signing_seed);
+ let to_sign = pae(DSSE_PAYLOAD_TYPE, payload_bytes);
+ let sig = sk.sign(&to_sign);
+
+ let b64 = base64::engine::general_purpose::STANDARD;
+ Ok(DsseEnvelope {
+ payload: b64.encode(payload_bytes),
+ payload_type: DSSE_PAYLOAD_TYPE.to_string(),
+ signatures: vec![DsseSignature {
+ sig: b64.encode(sig.to_bytes()),
+ keyid: to_hex(sk.verifying_key().as_bytes()),
+ }],
+ })
+}
+
+/// Verify a DSSE envelope: check the `payloadType`, then verify each
+/// signature's ed25519 sig over `PAE(payloadType, payload)` using the
+/// signature's own `keyid` as the public key. Returns the decoded
+/// Statement on success.
+///
+/// When `trusted_pubkey` is `Some`, verification additionally requires
+/// that at least one VALID signature was made by that pinned key —
+/// otherwise an attacker who re-signs a mutated payload with their own
+/// key would pass. Without a pin, a valid self-consistent signature is
+/// accepted (the caller vouches for the key out of band, e.g. via the
+/// manifest's `signature.public_key`).
+pub fn verify_envelope(
+ envelope: &DsseEnvelope,
+ trusted_pubkey: Option<&str>,
+) -> Result {
+ if envelope.payload_type != DSSE_PAYLOAD_TYPE {
+ return Err(AttestError::UnexpectedPayloadType {
+ found: envelope.payload_type.clone(),
+ });
+ }
+ if envelope.signatures.is_empty() {
+ return Err(AttestError::NoSignatures);
+ }
+
+ let b64 = base64::engine::general_purpose::STANDARD;
+ let payload_bytes = b64
+ .decode(envelope.payload.as_bytes())
+ .map_err(|e| AttestError::BadPayloadBase64(e.to_string()))?;
+ let to_verify = pae(&envelope.payload_type, &payload_bytes);
+
+ let mut any_valid = false;
+ let mut pinned_valid = false;
+ for s in &envelope.signatures {
+ let pk_bytes = match decode_hex_array::<32>(&s.keyid) {
+ Ok(b) => b,
+ Err(_) => continue,
+ };
+ let sig_bytes = match b64.decode(s.sig.as_bytes()) {
+ Ok(b) if b.len() == 64 => {
+ let mut arr = [0u8; 64];
+ arr.copy_from_slice(&b);
+ arr
+ }
+ _ => continue,
+ };
+ let vk = match ed25519_dalek::VerifyingKey::from_bytes(&pk_bytes) {
+ Ok(vk) => vk,
+ Err(_) => continue,
+ };
+ let signature = ed25519_dalek::Signature::from_bytes(&sig_bytes);
+ use ed25519_dalek::Verifier;
+ if vk.verify(&to_verify, &signature).is_ok() {
+ any_valid = true;
+ if let Some(pin) = trusted_pubkey {
+ if pin.eq_ignore_ascii_case(&s.keyid) {
+ pinned_valid = true;
+ }
+ }
+ }
+ }
+
+ if !any_valid {
+ return Err(AttestError::SignatureInvalid);
+ }
+ if let Some(pin) = trusted_pubkey {
+ if !pinned_valid {
+ return Err(AttestError::UntrustedKey {
+ pinned: pin.to_string(),
+ });
+ }
+ }
+
+ let statement: InTotoStatement = serde_json::from_slice(&payload_bytes)
+ .map_err(|e| AttestError::Serialization(e.to_string()))?;
+ Ok(statement)
+}
+
+/// Lowercase-hex encode bytes.
+fn to_hex(bytes: &[u8]) -> String {
+ use std::fmt::Write;
+ let mut s = String::with_capacity(bytes.len() * 2);
+ for b in bytes {
+ let _ = write!(s, "{b:02x}");
+ }
+ s
+}
+
+/// Decode a fixed-length hex string into `[u8; N]`.
+fn decode_hex_array(hex: &str) -> Result<[u8; N], String> {
+ if hex.len() != N * 2 {
+ return Err(format!("expected {} hex chars, got {}", N * 2, hex.len()));
+ }
+ let mut out = [0u8; N];
+ for (i, chunk) in hex.as_bytes().chunks(2).enumerate() {
+ let s = std::str::from_utf8(chunk).map_err(|_| "non-utf8".to_string())?;
+ out[i] = u8::from_str_radix(s, 16).map_err(|_| "non-hex digit".to_string())?;
+ }
+ Ok(out)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::audit::evidence::EvidenceBundleBuilder;
+ use crate::audit::log::{AuditEvent, AuditLog};
+ use std::path::Path;
+
+ fn signing_seed(base: u8) -> [u8; 32] {
+ let mut s = [0u8; 32];
+ for (i, b) in s.iter_mut().enumerate() {
+ *b = base.wrapping_add((i as u8).wrapping_mul(7));
+ }
+ s
+ }
+
+ fn pubkey_hex(seed: &[u8; 32]) -> String {
+ let sk = ed25519_dalek::SigningKey::from_bytes(seed);
+ to_hex(sk.verifying_key().as_bytes())
+ }
+
+ fn build_manifest(dir: &Path) -> BundleManifest {
+ let mut builder = EvidenceBundleBuilder::new(dir, "run-attest-001", "attest-test").unwrap();
+ builder.add_workflow_def(r#"{"name":"test"}"#).unwrap();
+ builder.add_policy(r#"{"default_allow":true}"#).unwrap();
+ builder
+ .add_step_output("s1", "result", r#"{"value":1}"#)
+ .unwrap();
+ let mut audit = AuditLog::new();
+ audit.append(AuditEvent::WorkflowStarted {
+ workflow_hash: "abc".into(),
+ policy_hash: "def".into(),
+ });
+ audit.append(AuditEvent::WorkflowCompleted {
+ result_hash: "res".into(),
+ total_duration_ms: 7,
+ });
+ builder.finalize(&audit).unwrap()
+ }
+
+ #[test]
+ fn pae_matches_dsse_spec_known_vector() {
+ // From the DSSE spec (protocol.md) worked example:
+ // payloadType = "http://example.com/HelloWorld"
+ // payload = "hello world"
+ // PAE = "DSSEv1 29 http://example.com/HelloWorld 11 hello world"
+ let got = pae("http://example.com/HelloWorld", b"hello world");
+ assert_eq!(
+ String::from_utf8(got).unwrap(),
+ "DSSEv1 29 http://example.com/HelloWorld 11 hello world"
+ );
+ }
+
+ #[test]
+ fn pae_binds_payload_type_and_length() {
+ // Empty payload still encodes the "0" length token.
+ assert_eq!(
+ String::from_utf8(pae("application/vnd.in-toto+json", b"")).unwrap(),
+ "DSSEv1 28 application/vnd.in-toto+json 0 "
+ );
+ }
+
+ #[test]
+ fn statement_has_correct_types_and_subject_digests() {
+ let dir = tempfile::tempdir().unwrap();
+ let manifest = build_manifest(dir.path());
+ let stmt = statement_from_manifest(&manifest, "9.9.9");
+
+ assert_eq!(stmt.type_, STATEMENT_TYPE);
+ assert_eq!(stmt.predicate_type, PREDICATE_TYPE);
+
+ // Every manifest file checksum appears as a subject sha256.
+ for (name, sha) in &manifest.file_checksums {
+ let subj = stmt
+ .subject
+ .iter()
+ .find(|s| &s.name == name)
+ .unwrap_or_else(|| panic!("missing subject {name}"));
+ assert_eq!(subj.digest.get("sha256"), Some(sha));
+ }
+ // Plus the synthetic bundle subject keyed by bundle_hash.
+ let bundle_subj = stmt
+ .subject
+ .iter()
+ .find(|s| s.name == "boruna-bundle:run-attest-001")
+ .expect("bundle subject present");
+ assert_eq!(
+ bundle_subj.digest.get("sha256"),
+ Some(&manifest.bundle_hash)
+ );
+
+ // Predicate maps the manifest fields.
+ let ext = &stmt.predicate.build_definition.external_parameters;
+ assert_eq!(ext.get("workflowHash"), Some(&manifest.workflow_hash));
+ assert_eq!(ext.get("policyHash"), Some(&manifest.policy_hash));
+ assert_eq!(
+ stmt.predicate.run_details.byproducts.get("auditLogHash"),
+ Some(&manifest.audit_log_hash)
+ );
+ assert_eq!(
+ stmt.predicate.run_details.metadata.invocation_id,
+ manifest.run_id
+ );
+ }
+
+ #[test]
+ fn statement_round_trips_through_json() {
+ let dir = tempfile::tempdir().unwrap();
+ let manifest = build_manifest(dir.path());
+ let stmt = statement_from_manifest(&manifest, "1.2.3");
+ let bytes = statement_to_canonical_bytes(&stmt).unwrap();
+ let back: InTotoStatement = serde_json::from_slice(&bytes).unwrap();
+ assert_eq!(stmt, back);
+ }
+
+ #[test]
+ fn statement_bytes_are_deterministic() {
+ // Same manifest → identical canonical bytes (→ identical sig).
+ let dir = tempfile::tempdir().unwrap();
+ let manifest = build_manifest(dir.path());
+ let a = statement_to_canonical_bytes(&statement_from_manifest(&manifest, "1.0.0")).unwrap();
+ let b = statement_to_canonical_bytes(&statement_from_manifest(&manifest, "1.0.0")).unwrap();
+ assert_eq!(a, b);
+ }
+
+ #[test]
+ fn attest_then_verify_passes() {
+ let dir = tempfile::tempdir().unwrap();
+ let manifest = build_manifest(dir.path());
+ let seed = signing_seed(3);
+ let env = attest(&manifest, "1.0.0", &seed).unwrap();
+
+ assert_eq!(env.payload_type, DSSE_PAYLOAD_TYPE);
+ assert_eq!(env.signatures.len(), 1);
+ assert_eq!(env.signatures[0].keyid, pubkey_hex(&seed));
+
+ // Unpinned verify passes and returns the Statement.
+ let stmt = verify_envelope(&env, None).unwrap();
+ assert_eq!(stmt.type_, STATEMENT_TYPE);
+
+ // Pinned to the correct key passes.
+ let pk = pubkey_hex(&seed);
+ let stmt2 = verify_envelope(&env, Some(&pk)).unwrap();
+ assert_eq!(stmt2, stmt);
+ }
+
+ #[test]
+ fn verify_fails_on_mutated_payload() {
+ let dir = tempfile::tempdir().unwrap();
+ let manifest = build_manifest(dir.path());
+ let seed = signing_seed(3);
+ let mut env = attest(&manifest, "1.0.0", &seed).unwrap();
+
+ // Mutate the payload: decode, tamper a byte, re-encode. The
+ // signature is over the ORIGINAL payload's PAE, so it must fail.
+ let b64 = base64::engine::general_purpose::STANDARD;
+ let mut raw = b64.decode(env.payload.as_bytes()).unwrap();
+ // Flip a byte well inside the JSON body.
+ raw[10] ^= 0xFF;
+ env.payload = b64.encode(&raw);
+
+ let err = verify_envelope(&env, None).unwrap_err();
+ assert_eq!(err, AttestError::SignatureInvalid);
+ }
+
+ #[test]
+ fn verify_fails_on_wrong_pinned_key() {
+ let dir = tempfile::tempdir().unwrap();
+ let manifest = build_manifest(dir.path());
+ let seed = signing_seed(3);
+ let env = attest(&manifest, "1.0.0", &seed).unwrap();
+
+ let wrong = pubkey_hex(&signing_seed(42));
+ let err = verify_envelope(&env, Some(&wrong)).unwrap_err();
+ assert_eq!(err, AttestError::UntrustedKey { pinned: wrong });
+ }
+
+ #[test]
+ fn verify_rejects_wrong_payload_type() {
+ let dir = tempfile::tempdir().unwrap();
+ let manifest = build_manifest(dir.path());
+ let seed = signing_seed(3);
+ let mut env = attest(&manifest, "1.0.0", &seed).unwrap();
+ env.payload_type = "application/json".to_string();
+ let err = verify_envelope(&env, None).unwrap_err();
+ assert!(matches!(err, AttestError::UnexpectedPayloadType { .. }));
+ }
+
+ #[test]
+ fn parse_seed_hex_roundtrip_and_errors() {
+ let seed = signing_seed(5);
+ let hex = to_hex(&seed);
+ assert_eq!(parse_seed_hex(&hex).unwrap(), seed);
+ assert!(matches!(
+ parse_seed_hex("abc"),
+ Err(AttestError::BadSigningKey(_))
+ ));
+ assert!(matches!(
+ parse_seed_hex(&"zz".repeat(32)),
+ Err(AttestError::BadSigningKey(_))
+ ));
+ }
+}
diff --git a/orchestrator/src/audit/fingerprint.rs b/orchestrator/src/audit/fingerprint.rs
index 1f987a3..d5e9f5d 100644
--- a/orchestrator/src/audit/fingerprint.rs
+++ b/orchestrator/src/audit/fingerprint.rs
@@ -1,7 +1,7 @@
use serde::{Deserialize, Serialize};
/// Environment fingerprint captured at runtime (no secrets).
-#[derive(Debug, Clone, Serialize, Deserialize)]
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct EnvFingerprint {
pub boruna_version: String,
pub rust_version: String,
diff --git a/orchestrator/src/audit/mod.rs b/orchestrator/src/audit/mod.rs
index 291865e..61fe3d3 100644
--- a/orchestrator/src/audit/mod.rs
+++ b/orchestrator/src/audit/mod.rs
@@ -1,7 +1,10 @@
+pub mod attestation;
pub mod encryption;
pub mod evidence;
pub mod fingerprint;
pub mod log;
+pub mod otel;
+pub mod report;
pub mod rotate;
pub mod storage;
#[cfg(feature = "azure")]
@@ -19,6 +22,7 @@ pub use encryption::{
pub use evidence::*;
pub use fingerprint::*;
pub use log::*;
+pub use report::{generate_report, ComplianceFramework, ReportFormat};
pub use verify::*;
/// Evidence bundle format version emitted by the current build.
diff --git a/orchestrator/src/audit/otel.rs b/orchestrator/src/audit/otel.rs
new file mode 100644
index 0000000..48ca02c
--- /dev/null
+++ b/orchestrator/src/audit/otel.rs
@@ -0,0 +1,794 @@
+//! OpenTelemetry (OTLP/JSON) export of an evidence bundle's execution.
+//!
+//! Turns a sealed run into a set of OpenTelemetry spans encoded in the
+//! OTLP/JSON wire format — the exact shape any OTel collector ingests via
+//! its OTLP/HTTP receiver. This is deliberately a **file emitter**, not an
+//! SDK exporter: no `opentelemetry` crate, no async runtime, no network.
+//! It keeps Boruna v3.0 local-only while still letting a run show up in the
+//! observability stack a buyer already runs (Jaeger, Tempo, Honeycomb,
+//! Datadog, …) simply by POSTing the emitted document, or piping it through
+//! the collector's `otlpjson` file receiver.
+//!
+//! ## Why this matters: Boruna as the NOTARIZED upstream
+//!
+//! CRITICAL — the whole point of this exporter is the tamper-evidence
+//! carried on the **root span's attributes**:
+//! * `boruna.bundle_hash` — SHA-256 over the manifest (file checksums +
+//! audit_log_hash). Recomputable by `boruna evidence verify`.
+//! * `boruna.audit_log_hash` — head of the hash-chained audit log.
+//! * `boruna.signature.keyid` — ed25519 public key (hex) that signed
+//! `bundle_hash`, when the bundle is signed.
+//!
+//! A span in a buyer's tracing backend therefore links back to a
+//! independently verifiable record: anyone can take these attributes,
+//! re-run `evidence verify` against the bundle, and prove the trace was
+//! produced by an untampered run. Boruna is the notarized source feeding
+//! the observability graph — not just another emitter of unattested spans.
+//!
+//! ## Determinism
+//!
+//! Wall clocks and RNGs are unavailable in the deterministic core, and the
+//! export must be byte-stable (same bundle → same bytes). So:
+//! * trace/span IDs are derived from `sha256(run_id [":" index])` — never
+//! random. The 16-byte trace id and 8-byte span ids are stable slices
+//! of those digests.
+//! * span start/end times are ordinal: a base nanosecond anchor (parsed
+//! from the manifest's `started_at`, or 0 if unparseable) plus the
+//! event index. They encode ordering, not measured latency.
+
+use serde::Serialize;
+use sha2::{Digest, Sha256};
+use std::path::Path;
+
+use crate::audit::evidence::BundleManifest;
+use boruna_vm::replay::{Event, EventLog};
+
+/// Errors emitting an OTLP/JSON document from a bundle.
+#[derive(Debug, thiserror::Error)]
+pub enum OtelExportError {
+ #[error("cannot read {file}: {source}")]
+ Io {
+ file: String,
+ source: std::io::Error,
+ },
+ #[error("invalid manifest.json: {0}")]
+ BadManifest(serde_json::Error),
+ #[error("invalid event_log.json: {0}")]
+ BadEventLog(String),
+ #[error("serialization failed: {0}")]
+ Serialize(serde_json::Error),
+}
+
+/// OTLP/JSON `TracesData` document.
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct TracesData {
+ resource_spans: Vec,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct ResourceSpans {
+ resource: Resource,
+ scope_spans: Vec,
+}
+
+#[derive(Serialize)]
+struct Resource {
+ attributes: Vec,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct ScopeSpans {
+ scope: Scope,
+ spans: Vec,
+}
+
+#[derive(Serialize)]
+struct Scope {
+ name: String,
+ version: String,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct Span {
+ trace_id: String,
+ span_id: String,
+ /// Empty string for the root span (OTLP convention).
+ parent_span_id: String,
+ name: String,
+ /// SPAN_KIND_INTERNAL = 1, SPAN_KIND_CLIENT = 3.
+ kind: u32,
+ start_time_unix_nano: String,
+ end_time_unix_nano: String,
+ attributes: Vec,
+ events: Vec,
+ status: Status,
+}
+
+#[derive(Serialize)]
+#[serde(rename_all = "camelCase")]
+struct SpanEvent {
+ time_unix_nano: String,
+ name: String,
+ attributes: Vec,
+}
+
+#[derive(Serialize)]
+struct Status {
+ /// STATUS_CODE_UNSET = 0, OK = 1, ERROR = 2.
+ code: u32,
+}
+
+#[derive(Serialize)]
+struct KeyValue {
+ key: String,
+ value: AnyValue,
+}
+
+/// OTLP `AnyValue` oneof, JSON-encoded. int64 is a string per proto3 JSON.
+#[derive(Serialize, Default)]
+#[serde(rename_all = "camelCase")]
+struct AnyValue {
+ #[serde(skip_serializing_if = "Option::is_none")]
+ string_value: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ bool_value: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ int_value: Option,
+}
+
+fn str_val(s: impl Into) -> AnyValue {
+ AnyValue {
+ string_value: Some(s.into()),
+ ..Default::default()
+ }
+}
+fn bool_val(b: bool) -> AnyValue {
+ AnyValue {
+ bool_value: Some(b),
+ ..Default::default()
+ }
+}
+fn int_val(i: u64) -> AnyValue {
+ AnyValue {
+ int_value: Some(i.to_string()),
+ ..Default::default()
+ }
+}
+fn kv(key: &str, value: AnyValue) -> KeyValue {
+ KeyValue {
+ key: key.to_string(),
+ value,
+ }
+}
+
+/// Read `bundle_dir` and emit its execution as an OTLP/JSON traces
+/// document (pretty-printed, deterministic bytes).
+///
+/// Root span: `boruna.run` carrying the run identity + tamper-evidence
+/// attributes drawn from `manifest.json`. Child spans: one per VM event
+/// in `event_log.json` (when present) — `llm.*` capability calls become
+/// `gen_ai.*` spans (GenAI semantic conventions), other effects become
+/// `boruna.capability` / `boruna.*` spans. `ContractCheck` events are
+/// recorded as span events on the root span.
+pub fn bundle_to_otlp_json(bundle_dir: &Path) -> Result {
+ let manifest_path = bundle_dir.join("manifest.json");
+ let manifest_json =
+ std::fs::read_to_string(&manifest_path).map_err(|e| OtelExportError::Io {
+ file: "manifest.json".to_string(),
+ source: e,
+ })?;
+ let manifest: BundleManifest =
+ serde_json::from_str(&manifest_json).map_err(OtelExportError::BadManifest)?;
+
+ // event_log.json is optional: a bundle sealed without a VM event log
+ // (e.g. `evidence create` from a persisted run) still exports a root
+ // span carrying the tamper-evidence anchor.
+ let event_log = match std::fs::read_to_string(bundle_dir.join("event_log.json")) {
+ Ok(s) => Some(EventLog::from_json(&s).map_err(OtelExportError::BadEventLog)?),
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
+ Err(e) => {
+ return Err(OtelExportError::Io {
+ file: "event_log.json".to_string(),
+ source: e,
+ })
+ }
+ };
+
+ let doc = build_traces(&manifest, event_log.as_ref());
+ serde_json::to_string_pretty(&doc).map_err(OtelExportError::Serialize)
+}
+
+/// Base nanosecond anchor for ordinal span times: the manifest's
+/// `started_at` parsed as RFC3339, or 0 when it can't be parsed.
+fn base_nanos(manifest: &BundleManifest) -> u64 {
+ chrono::DateTime::parse_from_rfc3339(&manifest.started_at)
+ .ok()
+ .and_then(|dt| dt.timestamp_nanos_opt())
+ .map(|n| n.max(0) as u64)
+ .unwrap_or(0)
+}
+
+fn build_traces(manifest: &BundleManifest, event_log: Option<&EventLog>) -> TracesData {
+ let trace_id = trace_id_from(&manifest.run_id);
+ let root_span_id = span_id_from(&manifest.run_id, "root");
+ let base = base_nanos(manifest);
+
+ let events = event_log.map(|l| l.events()).unwrap_or(&[]);
+ // A CapResult consumed into its matching CapCall span does not get a
+ // span of its own — track which indices were folded in.
+ let mut consumed = vec![false; events.len()];
+
+ // ContractCheck events fold onto the root span as span events; child
+ // spans come from every other VM event.
+ let mut root_events: Vec = Vec::new();
+ let mut child_spans: Vec = Vec::new();
+
+ for (i, ev) in events.iter().enumerate() {
+ if consumed[i] {
+ continue;
+ }
+ match ev {
+ Event::ContractCheck {
+ function,
+ kind,
+ index,
+ passed,
+ } => {
+ root_events.push(SpanEvent {
+ time_unix_nano: (base + i as u64).to_string(),
+ name: "boruna.contract_check".to_string(),
+ attributes: vec![
+ kv("boruna.contract.function", str_val(function.clone())),
+ kv("boruna.contract.kind", str_val(kind.clone())),
+ kv("boruna.contract.index", int_val(*index as u64)),
+ kv("boruna.contract.passed", bool_val(*passed)),
+ ],
+ });
+ }
+ Event::CapCall { capability, args } => {
+ // Fold the next matching, not-yet-consumed CapResult into
+ // this span so one operation = one span.
+ let result_idx = events
+ .iter()
+ .enumerate()
+ .skip(i + 1)
+ .find_map(|(j, e)| match e {
+ Event::CapResult { capability: c, .. }
+ if c == capability && !consumed[j] =>
+ {
+ Some(j)
+ }
+ _ => None,
+ });
+ let mut passed = true;
+ if let Some(j) = result_idx {
+ consumed[j] = true;
+ if let Event::CapResult { result, .. } = &events[j] {
+ // A Value::Err result marks the operation failed.
+ passed = !matches!(result, boruna_bytecode::Value::Err(_));
+ }
+ }
+ child_spans.push(capability_span(
+ &trace_id,
+ &root_span_id,
+ &manifest.run_id,
+ i,
+ base,
+ capability,
+ args.len(),
+ passed,
+ ));
+ }
+ Event::CapResult { capability, result } => {
+ // An unpaired CapResult (no preceding CapCall) still gets a
+ // span so the trace loses nothing.
+ let passed = !matches!(result, boruna_bytecode::Value::Err(_));
+ child_spans.push(generic_span(
+ &trace_id,
+ &root_span_id,
+ &manifest.run_id,
+ i,
+ base,
+ "boruna.capability_result",
+ vec![kv("boruna.capability", str_val(capability.clone()))],
+ passed,
+ ));
+ }
+ Event::ActorSpawn { actor_id, function } => {
+ child_spans.push(generic_span(
+ &trace_id,
+ &root_span_id,
+ &manifest.run_id,
+ i,
+ base,
+ "boruna.actor_spawn",
+ vec![
+ kv("boruna.actor.id", int_val(*actor_id)),
+ kv("boruna.actor.function", str_val(function.clone())),
+ ],
+ true,
+ ));
+ }
+ Event::MessageSend { from, to, .. } => {
+ child_spans.push(generic_span(
+ &trace_id,
+ &root_span_id,
+ &manifest.run_id,
+ i,
+ base,
+ "boruna.message_send",
+ vec![
+ kv("boruna.message.from", int_val(*from)),
+ kv("boruna.message.to", int_val(*to)),
+ ],
+ true,
+ ));
+ }
+ Event::MessageReceive { actor_id, .. } => {
+ child_spans.push(generic_span(
+ &trace_id,
+ &root_span_id,
+ &manifest.run_id,
+ i,
+ base,
+ "boruna.message_receive",
+ vec![kv("boruna.actor.id", int_val(*actor_id))],
+ true,
+ ));
+ }
+ Event::UiEmit { .. } => {
+ child_spans.push(generic_span(
+ &trace_id,
+ &root_span_id,
+ &manifest.run_id,
+ i,
+ base,
+ "boruna.ui_emit",
+ vec![],
+ true,
+ ));
+ }
+ Event::SchedulerTick {
+ round,
+ active_actor,
+ } => {
+ child_spans.push(generic_span(
+ &trace_id,
+ &root_span_id,
+ &manifest.run_id,
+ i,
+ base,
+ "boruna.scheduler_tick",
+ vec![
+ kv("boruna.scheduler.round", int_val(*round)),
+ kv("boruna.scheduler.active_actor", int_val(*active_actor)),
+ ],
+ true,
+ ));
+ }
+ }
+ }
+
+ let root = Span {
+ trace_id,
+ span_id: root_span_id,
+ parent_span_id: String::new(),
+ name: "boruna.run".to_string(),
+ kind: 1, // INTERNAL
+ start_time_unix_nano: base.to_string(),
+ // Root ends after the last event so children nest inside it.
+ end_time_unix_nano: (base + events.len() as u64 + 1).to_string(),
+ attributes: root_attributes(manifest),
+ events: root_events,
+ status: Status { code: 1 },
+ };
+
+ // Root first, then children (their parentSpanId points back at root).
+ let mut spans = Vec::with_capacity(1 + child_spans.len());
+ spans.push(root);
+ spans.extend(child_spans);
+
+ let version = env!("CARGO_PKG_VERSION").to_string();
+ TracesData {
+ resource_spans: vec![ResourceSpans {
+ resource: Resource {
+ attributes: vec![
+ kv("service.name", str_val("boruna")),
+ kv("service.version", str_val(version.clone())),
+ kv("boruna.run_id", str_val(manifest.run_id.clone())),
+ ],
+ },
+ scope_spans: vec![ScopeSpans {
+ scope: Scope {
+ name: "boruna.evidence".to_string(),
+ version,
+ },
+ spans,
+ }],
+ }],
+ }
+}
+
+/// Tamper-evidence + identity attributes on the root span. This is the
+/// span that links the trace back to a verifiable record — see the module
+/// docs. `boruna.signature.keyid` is emitted only for signed bundles.
+fn root_attributes(manifest: &BundleManifest) -> Vec {
+ let mut attrs = vec![
+ kv("boruna.run_id", str_val(manifest.run_id.clone())),
+ kv(
+ "boruna.workflow_name",
+ str_val(manifest.workflow_name.clone()),
+ ),
+ kv(
+ "boruna.workflow_hash",
+ str_val(manifest.workflow_hash.clone()),
+ ),
+ kv("boruna.policy_hash", str_val(manifest.policy_hash.clone())),
+ // --- tamper-evidence: recomputable/verifiable by `evidence verify` ---
+ kv("boruna.bundle_hash", str_val(manifest.bundle_hash.clone())),
+ kv(
+ "boruna.audit_log_hash",
+ str_val(manifest.audit_log_hash.clone()),
+ ),
+ ];
+ if let Some(sig) = &manifest.signature {
+ attrs.push(kv(
+ "boruna.signature.algorithm",
+ str_val(sig.algorithm.clone()),
+ ));
+ // The public key is the key id a verifier pins with
+ // `evidence verify --verify-key`.
+ attrs.push(kv(
+ "boruna.signature.keyid",
+ str_val(sig.public_key.clone()),
+ ));
+ }
+ attrs
+}
+
+/// A capability-call span. `llm.*` capabilities map to the OTel GenAI
+/// semantic conventions (`gen_ai.*`); everything else is a generic
+/// `boruna.capability` span.
+#[allow(clippy::too_many_arguments)]
+fn capability_span(
+ trace_id: &str,
+ parent: &str,
+ run_id: &str,
+ index: usize,
+ base: u64,
+ capability: &str,
+ args_count: usize,
+ passed: bool,
+) -> Span {
+ let mut attributes = vec![
+ kv("boruna.capability", str_val(capability.to_string())),
+ kv("boruna.capability.args_count", int_val(args_count as u64)),
+ ];
+ let (name, kind) = if let Some(op) = gen_ai_operation(capability) {
+ // GenAI semantic conventions: gen_ai.system + gen_ai.operation.name.
+ attributes.push(kv("gen_ai.system", str_val("boruna")));
+ attributes.push(kv("gen_ai.operation.name", str_val(op.clone())));
+ (format!("gen_ai.{op}"), 3u32) // CLIENT
+ } else {
+ ("boruna.capability".to_string(), 1u32) // INTERNAL
+ };
+ Span {
+ trace_id: trace_id.to_string(),
+ span_id: span_id_from(run_id, &index.to_string()),
+ parent_span_id: parent.to_string(),
+ name,
+ kind,
+ start_time_unix_nano: (base + index as u64).to_string(),
+ end_time_unix_nano: (base + index as u64 + 1).to_string(),
+ attributes,
+ events: Vec::new(),
+ status: Status {
+ code: if passed { 1 } else { 2 },
+ },
+ }
+}
+
+#[allow(clippy::too_many_arguments)]
+fn generic_span(
+ trace_id: &str,
+ parent: &str,
+ run_id: &str,
+ index: usize,
+ base: u64,
+ name: &str,
+ attributes: Vec,
+ passed: bool,
+) -> Span {
+ Span {
+ trace_id: trace_id.to_string(),
+ span_id: span_id_from(run_id, &index.to_string()),
+ parent_span_id: parent.to_string(),
+ name: name.to_string(),
+ kind: 1, // INTERNAL
+ start_time_unix_nano: (base + index as u64).to_string(),
+ end_time_unix_nano: (base + index as u64 + 1).to_string(),
+ attributes,
+ events: Vec::new(),
+ status: Status {
+ code: if passed { 1 } else { 2 },
+ },
+ }
+}
+
+/// Map an `llm.*` capability to a GenAI `gen_ai.operation.name`. Returns
+/// `None` for non-LLM capabilities. The operation is normalized toward the
+/// GenAI convention's vocabulary where the suffix is recognizable, else the
+/// raw suffix is passed through.
+fn gen_ai_operation(capability: &str) -> Option {
+ let suffix = capability.strip_prefix("llm.")?;
+ let op = match suffix {
+ "complete" | "completion" | "completions" => "text_completion",
+ // `llm.call` is Boruna's single generic LLM capability; map it to
+ // the convention's most common operation.
+ "chat" | "chat_completion" | "call" => "chat",
+ "embed" | "embedding" | "embeddings" => "embeddings",
+ "" => "chat",
+ other => other,
+ };
+ Some(op.to_string())
+}
+
+/// 16-byte (32 hex) trace id derived from the run id. Deterministic, never
+/// random — see module docs.
+fn trace_id_from(run_id: &str) -> String {
+ let digest = Sha256::digest(run_id.as_bytes());
+ to_hex(&digest[..16])
+}
+
+/// 8-byte (16 hex) span id derived from `run_id` + a per-span tag (the
+/// event index, or "root"). Deterministic.
+fn span_id_from(run_id: &str, tag: &str) -> String {
+ let mut hasher = Sha256::new();
+ hasher.update(run_id.as_bytes());
+ hasher.update(b":");
+ hasher.update(tag.as_bytes());
+ let digest = hasher.finalize();
+ to_hex(&digest[..8])
+}
+
+fn to_hex(bytes: &[u8]) -> String {
+ use std::fmt::Write;
+ let mut s = String::with_capacity(bytes.len() * 2);
+ for b in bytes {
+ let _ = write!(s, "{b:02x}");
+ }
+ s
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::audit::evidence::EvidenceBundleBuilder;
+ use crate::audit::log::AuditLog;
+ use boruna_bytecode::{Capability, ContractKind, Value};
+ use boruna_vm::replay::EventLog;
+
+ /// Build a bundle on disk carrying a VM event log with a mix of
+ /// events, including an `llm.*` capability call. Returns the bundle
+ /// directory path (inside `dir`).
+ fn make_bundle(dir: &Path, run_id: &str) -> std::path::PathBuf {
+ let mut log = EventLog::new();
+ // an LLM capability call + result → should become a gen_ai span
+ log.log_cap_call(&Capability::LlmCall, &[Value::String("prompt".into())]);
+ log.log_cap_result(&Capability::LlmCall, &Value::String("answer".into()));
+ // a non-LLM capability → generic boruna.capability span
+ log.log_cap_call(
+ &Capability::NetFetch,
+ &[Value::String("https://example.com".into())],
+ );
+ log.log_cap_result(&Capability::NetFetch, &Value::String("".into()));
+ let event_log_json = log.to_json().unwrap();
+
+ let mut builder = EvidenceBundleBuilder::new(dir, run_id, "otel-wf").unwrap();
+ builder.add_workflow_def(r#"{"name":"otel"}"#).unwrap();
+ builder.add_policy(r#"{"default_allow":true}"#).unwrap();
+ builder.add_file("event_log.json", &event_log_json).unwrap();
+
+ let mut audit = AuditLog::new();
+ audit.append(crate::audit::log::AuditEvent::WorkflowStarted {
+ workflow_hash: "wfh".into(),
+ policy_hash: "plh".into(),
+ });
+ audit.append(crate::audit::log::AuditEvent::WorkflowCompleted {
+ result_hash: "res".into(),
+ total_duration_ms: 5,
+ });
+ builder.finalize(&audit).unwrap();
+ dir.join(run_id)
+ }
+
+ fn parse(json: &str) -> serde_json::Value {
+ serde_json::from_str(json).unwrap()
+ }
+
+ /// Collect (name, attributes-map) for every span in the document.
+ fn spans(doc: &serde_json::Value) -> Vec {
+ doc["resourceSpans"][0]["scopeSpans"][0]["spans"]
+ .as_array()
+ .unwrap()
+ .clone()
+ }
+
+ fn attr<'a>(span: &'a serde_json::Value, key: &str) -> Option<&'a serde_json::Value> {
+ span["attributes"]
+ .as_array()?
+ .iter()
+ .find(|kv| kv["key"] == key)
+ .map(|kv| &kv["value"])
+ }
+
+ #[test]
+ fn root_span_carries_identity_and_tamper_evidence() {
+ let dir = tempfile::tempdir().unwrap();
+ let bundle = make_bundle(dir.path(), "run-otel-001");
+ let out = bundle_to_otlp_json(&bundle).unwrap();
+ let doc = parse(&out);
+ let spans = spans(&doc);
+
+ let root = &spans[0];
+ assert_eq!(root["name"], "boruna.run");
+ assert_eq!(root["parentSpanId"], "");
+ // trace id is 32 hex chars, span id 16 hex chars
+ assert_eq!(root["traceId"].as_str().unwrap().len(), 32);
+ assert_eq!(root["spanId"].as_str().unwrap().len(), 16);
+
+ // run_id present
+ assert_eq!(
+ attr(root, "boruna.run_id").unwrap()["stringValue"],
+ "run-otel-001"
+ );
+ // the tamper-evidence anchors are present and non-empty
+ let bundle_hash = attr(root, "boruna.bundle_hash").unwrap()["stringValue"]
+ .as_str()
+ .unwrap()
+ .to_string();
+ let audit_hash = attr(root, "boruna.audit_log_hash").unwrap()["stringValue"]
+ .as_str()
+ .unwrap()
+ .to_string();
+ assert_eq!(bundle_hash.len(), 64);
+ assert_eq!(audit_hash.len(), 64);
+
+ // and they match the manifest exactly (the span links back to a
+ // verifiable record).
+ let manifest: BundleManifest =
+ serde_json::from_str(&std::fs::read_to_string(bundle.join("manifest.json")).unwrap())
+ .unwrap();
+ assert_eq!(bundle_hash, manifest.bundle_hash);
+ assert_eq!(audit_hash, manifest.audit_log_hash);
+ }
+
+ #[test]
+ fn capability_events_produce_child_spans() {
+ let dir = tempfile::tempdir().unwrap();
+ let bundle = make_bundle(dir.path(), "run-otel-002");
+ let doc = parse(&bundle_to_otlp_json(&bundle).unwrap());
+ let spans = spans(&doc);
+
+ // root + 2 capability spans (each CapCall folds its CapResult).
+ assert_eq!(spans.len(), 3, "expected root + 2 capability spans");
+
+ // every child points at the root
+ let root_id = spans[0]["spanId"].as_str().unwrap();
+ for child in &spans[1..] {
+ assert_eq!(child["parentSpanId"], root_id);
+ }
+
+ // the non-LLM call is a generic boruna.capability span
+ let net = spans
+ .iter()
+ .find(|s| {
+ attr(s, "boruna.capability").map(|v| v["stringValue"] == "net.fetch") == Some(true)
+ })
+ .unwrap();
+ assert_eq!(net["name"], "boruna.capability");
+ }
+
+ #[test]
+ fn llm_capability_produces_gen_ai_span() {
+ let dir = tempfile::tempdir().unwrap();
+ let bundle = make_bundle(dir.path(), "run-otel-003");
+ let doc = parse(&bundle_to_otlp_json(&bundle).unwrap());
+ let spans = spans(&doc);
+
+ let genai = spans
+ .iter()
+ .find(|s| s["name"].as_str().map(|n| n.starts_with("gen_ai.")) == Some(true))
+ .expect("an llm.* call must produce a gen_ai.* span");
+
+ assert_eq!(genai["name"], "gen_ai.chat");
+ assert_eq!(
+ attr(genai, "gen_ai.operation.name").unwrap()["stringValue"],
+ "chat"
+ );
+ assert_eq!(
+ attr(genai, "gen_ai.system").unwrap()["stringValue"],
+ "boruna"
+ );
+ // GenAI spans are CLIENT kind
+ assert_eq!(genai["kind"], 3);
+ }
+
+ #[test]
+ fn contract_checks_become_root_span_events() {
+ // Build a bundle whose event log includes a ContractCheck.
+ let dir = tempfile::tempdir().unwrap();
+ let mut log = EventLog::new();
+ log.log_contract_check("main", ContractKind::Requires, 0, true);
+ let mut builder = EvidenceBundleBuilder::new(dir.path(), "run-otel-004", "wf").unwrap();
+ builder
+ .add_file("event_log.json", &log.to_json().unwrap())
+ .unwrap();
+ builder.finalize(&AuditLog::new()).unwrap();
+
+ let doc = parse(&bundle_to_otlp_json(&dir.path().join("run-otel-004")).unwrap());
+ let root = &spans(&doc)[0];
+ let events = root["events"].as_array().unwrap();
+ assert_eq!(events.len(), 1);
+ assert_eq!(events[0]["name"], "boruna.contract_check");
+ let a = &events[0]["attributes"];
+ let function = a
+ .as_array()
+ .unwrap()
+ .iter()
+ .find(|kv| kv["key"] == "boruna.contract.function")
+ .unwrap();
+ assert_eq!(function["value"]["stringValue"], "main");
+ }
+
+ #[test]
+ fn signed_bundle_emits_signature_keyid() {
+ let dir = tempfile::tempdir().unwrap();
+ let mut log = EventLog::new();
+ log.log_cap_call(&Capability::LlmCall, &[]);
+ let mut builder = EvidenceBundleBuilder::new(dir.path(), "run-otel-005", "wf")
+ .unwrap()
+ .with_signing_key(&[7u8; 32]);
+ builder
+ .add_file("event_log.json", &log.to_json().unwrap())
+ .unwrap();
+ builder.finalize(&AuditLog::new()).unwrap();
+
+ let doc = parse(&bundle_to_otlp_json(&dir.path().join("run-otel-005")).unwrap());
+ let root = &spans(&doc)[0];
+ let keyid = attr(root, "boruna.signature.keyid").expect("signed bundle emits keyid");
+ assert_eq!(keyid["stringValue"].as_str().unwrap().len(), 64);
+ assert_eq!(
+ attr(root, "boruna.signature.algorithm").unwrap()["stringValue"],
+ "ed25519"
+ );
+ }
+
+ #[test]
+ fn output_is_deterministic() {
+ let dir = tempfile::tempdir().unwrap();
+ let bundle = make_bundle(dir.path(), "run-otel-006");
+ let a = bundle_to_otlp_json(&bundle).unwrap();
+ let b = bundle_to_otlp_json(&bundle).unwrap();
+ assert_eq!(a, b, "same bundle must export byte-identical OTLP/JSON");
+ }
+
+ #[test]
+ fn bundle_without_event_log_still_exports_root() {
+ // No event_log.json → root span only, still carrying the anchors.
+ let dir = tempfile::tempdir().unwrap();
+ let mut builder = EvidenceBundleBuilder::new(dir.path(), "run-otel-007", "wf").unwrap();
+ builder.add_workflow_def(r#"{"name":"x"}"#).unwrap();
+ builder.finalize(&AuditLog::new()).unwrap();
+
+ let doc = parse(&bundle_to_otlp_json(&dir.path().join("run-otel-007")).unwrap());
+ let spans = spans(&doc);
+ assert_eq!(spans.len(), 1);
+ assert_eq!(spans[0]["name"], "boruna.run");
+ assert!(attr(&spans[0], "boruna.bundle_hash").is_some());
+ }
+}
diff --git a/orchestrator/src/audit/report.rs b/orchestrator/src/audit/report.rs
new file mode 100644
index 0000000..a321580
--- /dev/null
+++ b/orchestrator/src/audit/report.rs
@@ -0,0 +1,885 @@
+//! Compliance evidence-mapping reports (`boruna evidence report`).
+//!
+//! Turns the machine-facts already inside an evidence bundle (run id,
+//! hash-chained audit log, policy/workflow hashes, env fingerprint,
+//! signature) into a HUMAN-READABLE mapping from each present artifact
+//! to the specific regulatory obligation it helps satisfy.
+//!
+//! This is deliberately NOT a certificate of compliance. It is a
+//! *technical mapping*: "here is the record, and here is the obligation
+//! text it speaks to." Obligations the bundle does NOT cover are flagged
+//! loudly (e.g. retention metadata, which the bundle format does not
+//! carry). The report ALWAYS runs `verify_bundle` first — a mapping over
+//! a tampered or unverifiable bundle is worse than useless, so the
+//! verification verdict is stamped at the top and any failure is shown
+//! prominently.
+
+use std::path::Path;
+
+use crate::audit::evidence::BundleManifest;
+use crate::audit::log::{AuditEvent, AuditLog};
+use crate::audit::verify::verify_bundle;
+
+/// Regulatory framework the report maps evidence against.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ComplianceFramework {
+ /// EU AI Act (Regulation (EU) 2024/1689) record-keeping obligations.
+ EuAiAct,
+ /// NIST AI Risk Management Framework 1.0.
+ Nist,
+ /// ISO/IEC 42001:2023 AI management system.
+ Iso42001,
+}
+
+impl ComplianceFramework {
+ /// Parse the CLI `--framework` value.
+ pub fn parse(s: &str) -> Result {
+ match s.trim().to_ascii_lowercase().as_str() {
+ "eu-ai-act" | "eu_ai_act" | "euaiact" => Ok(ComplianceFramework::EuAiAct),
+ "nist" | "nist-ai-rmf" => Ok(ComplianceFramework::Nist),
+ "iso42001" | "iso-42001" | "iso" => Ok(ComplianceFramework::Iso42001),
+ other => Err(format!(
+ "unknown framework {other:?} (expected: eu-ai-act | nist | iso42001)"
+ )),
+ }
+ }
+
+ fn title(self) -> &'static str {
+ match self {
+ ComplianceFramework::EuAiAct => "EU AI Act (Regulation (EU) 2024/1689)",
+ ComplianceFramework::Nist => "NIST AI Risk Management Framework 1.0",
+ ComplianceFramework::Iso42001 => "ISO/IEC 42001:2023",
+ }
+ }
+}
+
+/// Output rendering for a report.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum ReportFormat {
+ Markdown,
+ Html,
+}
+
+impl ReportFormat {
+ /// Parse the CLI `--format` value.
+ pub fn parse(s: &str) -> Result {
+ match s.trim().to_ascii_lowercase().as_str() {
+ "md" | "markdown" => Ok(ReportFormat::Markdown),
+ "html" => Ok(ReportFormat::Html),
+ other => Err(format!("unknown format {other:?} (expected: md | html)")),
+ }
+ }
+}
+
+/// How well the recorded evidence speaks to an obligation.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum Coverage {
+ /// The bundle provides a record that directly satisfies this obligation.
+ Provided,
+ /// The bundle covers part of the obligation; the rest is out of scope
+ /// for what a sealed run-artifact can attest.
+ Partial,
+ /// The bundle carries no evidence for this obligation.
+ NotCovered,
+ /// The obligation does not apply to this particular run.
+ NotApplicable,
+}
+
+impl Coverage {
+ fn label(self) -> &'static str {
+ match self {
+ Coverage::Provided => "EVIDENCE PROVIDED",
+ Coverage::Partial => "PARTIAL",
+ Coverage::NotCovered => "NOT COVERED",
+ Coverage::NotApplicable => "NOT APPLICABLE",
+ }
+ }
+}
+
+/// One obligation row: the regulatory reference, what the bundle provides
+/// for it (with the actual run's hashes), and — critically — any gap.
+struct Obligation {
+ reference: String,
+ title: String,
+ coverage: Coverage,
+ provides: String,
+ gap: Option,
+}
+
+/// Facts distilled from a bundle's manifest + audit log, used to build the
+/// obligation mapping. Absent components are recorded as `false`/`None` so
+/// the mapping can flag them honestly.
+struct BundleFacts {
+ run_id: String,
+ workflow_name: String,
+ workflow_hash: String,
+ policy_hash: String,
+ audit_log_hash: String,
+ bundle_hash: String,
+ started_at: String,
+ completed_at: String,
+ env_summary: String,
+ signed: bool,
+ signer_pubkey: Option,
+ encrypted: bool,
+ has_workflow: bool,
+ has_policy: bool,
+ has_outputs: bool,
+ has_intents: bool,
+ has_model_invocations: bool,
+ /// `None` when the audit log could not be parsed (e.g. encrypted and
+ /// undecryptable in report context).
+ audit_event_count: Option,
+ /// Human-readable approval-gate (human-oversight) records, if any.
+ approvals: Vec,
+}
+
+impl BundleFacts {
+ fn from_bundle(bundle_dir: &Path, manifest: &BundleManifest) -> Self {
+ let (audit_event_count, approvals) = read_audit_facts(bundle_dir);
+ let env = &manifest.env_fingerprint;
+ BundleFacts {
+ run_id: manifest.run_id.clone(),
+ workflow_name: manifest.workflow_name.clone(),
+ workflow_hash: manifest.workflow_hash.clone(),
+ policy_hash: manifest.policy_hash.clone(),
+ audit_log_hash: manifest.audit_log_hash.clone(),
+ bundle_hash: manifest.bundle_hash.clone(),
+ started_at: manifest.started_at.clone(),
+ completed_at: manifest.completed_at.clone(),
+ env_summary: format!(
+ "{} on {}/{}, boruna {}",
+ env.rust_version, env.os, env.arch, env.boruna_version
+ ),
+ signed: manifest.signature.is_some(),
+ signer_pubkey: manifest.signature.as_ref().map(|s| s.public_key.clone()),
+ encrypted: manifest.encryption.is_some(),
+ has_workflow: bundle_dir.join("workflow.json").exists(),
+ has_policy: bundle_dir.join("policy.json").exists(),
+ has_outputs: bundle_dir.join("outputs").is_dir(),
+ has_intents: bundle_dir.join("intents.json").exists(),
+ has_model_invocations: bundle_dir.join("model_invoking_steps.json").exists(),
+ audit_event_count,
+ approvals,
+ }
+ }
+}
+
+/// Read the audit log for the event count and any approval-gate records.
+/// Degrades gracefully: on any read/parse failure returns `(None, [])`.
+fn read_audit_facts(bundle_dir: &Path) -> (Option, Vec) {
+ let raw = match std::fs::read_to_string(bundle_dir.join("audit_log.json")) {
+ Ok(s) => s,
+ Err(_) => return (None, Vec::new()),
+ };
+ let log = match AuditLog::from_json(&raw) {
+ Ok(l) => l,
+ Err(_) => return (None, Vec::new()),
+ };
+ let mut approvals = Vec::new();
+ for entry in log.entries() {
+ match &entry.event {
+ AuditEvent::ApprovalRequested { step_id, role } => {
+ approvals.push(format!(
+ "step `{step_id}`: approval requested from role `{role}`"
+ ));
+ }
+ AuditEvent::ApprovalGranted { step_id, approver } => {
+ approvals.push(format!(
+ "step `{step_id}`: approval GRANTED by `{approver}`"
+ ));
+ }
+ AuditEvent::ApprovalDenied { step_id, reason } => {
+ approvals.push(format!("step `{step_id}`: approval DENIED ({reason})"));
+ }
+ _ => {}
+ }
+ }
+ (Some(log.entries().len()), approvals)
+}
+
+/// Generate a compliance evidence-mapping report for a bundle.
+///
+/// The bundle is VERIFIED first; the verdict (and any errors) is stamped
+/// at the top of the report. A tampered/unverifiable bundle still produces
+/// a report — one that says so loudly — so an auditor is never handed a
+/// clean-looking mapping over broken evidence.
+///
+/// Returns `Err` only when the manifest itself cannot be read/parsed (there
+/// is then nothing to map); a bundle that merely fails verification returns
+/// `Ok` with the failure surfaced in the report body.
+pub fn generate_report(
+ bundle_dir: &Path,
+ framework: ComplianceFramework,
+ format: ReportFormat,
+) -> Result {
+ let manifest_path = bundle_dir.join("manifest.json");
+ let manifest_json = std::fs::read_to_string(&manifest_path)
+ .map_err(|e| format!("cannot read manifest.json: {e}"))?;
+ let manifest: BundleManifest =
+ serde_json::from_str(&manifest_json).map_err(|e| format!("invalid manifest.json: {e}"))?;
+
+ let verdict = verify_bundle(bundle_dir);
+ let facts = BundleFacts::from_bundle(bundle_dir, &manifest);
+ let obligations = match framework {
+ ComplianceFramework::EuAiAct => eu_ai_act_obligations(&facts),
+ ComplianceFramework::Nist => nist_obligations(&facts),
+ ComplianceFramework::Iso42001 => iso42001_obligations(&facts),
+ };
+
+ Ok(match format {
+ ReportFormat::Markdown => render_markdown(framework, &facts, &verdict, &obligations),
+ ReportFormat::Html => render_html(framework, &facts, &verdict, &obligations),
+ })
+}
+
+// ---- Obligation catalogues ------------------------------------------------
+
+fn eu_ai_act_obligations(f: &BundleFacts) -> Vec {
+ let mut out = Vec::new();
+
+ // Art. 12(2)(a-c) — automatic recording of events (logging).
+ let events = f
+ .audit_event_count
+ .map(|n| n.to_string())
+ .unwrap_or_else(|| "unavailable".to_string());
+ out.push(Obligation {
+ reference: "Art. 12(2)(a–c)".to_string(),
+ title: "Automatic recording of events (logging) over the system's lifetime".to_string(),
+ coverage: if f.audit_event_count.is_some() {
+ Coverage::Provided
+ } else {
+ Coverage::NotCovered
+ },
+ provides: format!(
+ "`audit_log.json` — a hash-chained (SHA-256) event log with {events} entries, \
+ head hash `audit_log_hash = {}`. Each entry chains the previous entry's hash, so \
+ any insertion, deletion, or edit is detectable. This is the automatically generated \
+ record of events over the run's lifetime.",
+ f.audit_log_hash
+ ),
+ gap: if f.audit_event_count.is_none() {
+ Some(
+ "The audit log could not be read (bundle may be encrypted); the event record \
+ cannot be summarised without the decryption key."
+ .to_string(),
+ )
+ } else {
+ None
+ },
+ });
+
+ // Art. 12(3) — Annex III logging content.
+ let period_ok = !f.started_at.is_empty() && !f.completed_at.is_empty();
+ let persons_ok = !f.approvals.is_empty();
+ let all_ok = period_ok && f.has_outputs && persons_ok;
+ let mut sub = Vec::new();
+ sub.push(format!(
+ "- period of each use: recorded as `started_at = {}` .. `completed_at = {}` [{}]",
+ f.started_at,
+ f.completed_at,
+ if period_ok { "PROVIDED" } else { "MISSING" }
+ ));
+ sub.push(format!(
+ "- input data / records checked against: per-step outputs under `outputs/` [{}]",
+ if f.has_outputs {
+ "PROVIDED"
+ } else {
+ "NOT PRESENT"
+ }
+ ));
+ sub.push(format!(
+ "- natural persons verifying results: approval-gate records [{}]",
+ if persons_ok {
+ "PROVIDED"
+ } else {
+ "NONE RECORDED"
+ }
+ ));
+ out.push(Obligation {
+ reference: "Art. 12(3)".to_string(),
+ title: "Logging content for Annex III high-risk systems".to_string(),
+ coverage: if all_ok {
+ Coverage::Provided
+ } else {
+ Coverage::Partial
+ },
+ provides: sub.join("\n"),
+ gap: if all_ok {
+ None
+ } else {
+ Some(
+ "Not every Annex III logging item is present in this run. Items marked MISSING / \
+ NONE RECORDED are either not applicable to a fully-automated run (no human \
+ verifier) or must be supplied by the deploying system — the bundle attests only \
+ what the run actually recorded."
+ .to_string(),
+ )
+ },
+ });
+
+ // Art. 19 + Art. 26(6) — retention >= 6 months. The bundle format
+ // carries NO retention metadata, so this is always flagged.
+ out.push(Obligation {
+ reference: "Art. 19 & Art. 26(6)".to_string(),
+ title: "Automatic logs kept / retained for at least 6 months".to_string(),
+ coverage: Coverage::NotCovered,
+ provides: "The bundle is a sealed, tamper-evident snapshot but declares no retention \
+ period or lifecycle policy."
+ .to_string(),
+ gap: Some(
+ "retention policy: NOT DECLARED — Art. 19 (provider) and Art. 26(6) (deployer) \
+ require the automatically generated logs to be retained for a period appropriate to \
+ the intended purpose, and at least 6 months unless other law provides otherwise. \
+ Retention must be enforced by the operator's storage/lifecycle controls; it is NOT \
+ attested by this evidence bundle."
+ .to_string(),
+ ),
+ });
+
+ // Art. 14 — human oversight (approval-gate records).
+ if f.approvals.is_empty() {
+ out.push(Obligation {
+ reference: "Art. 14".to_string(),
+ title: "Human oversight".to_string(),
+ coverage: Coverage::NotApplicable,
+ provides: "No approval-gate (human-oversight) events were recorded in this run's \
+ audit log."
+ .to_string(),
+ gap: Some(
+ "If this system is subject to Art. 14 human-oversight requirements, the workflow \
+ did not record a human approval gate. A fully-automated run cannot evidence \
+ human oversight — add an approval step to capture it."
+ .to_string(),
+ ),
+ });
+ } else {
+ out.push(Obligation {
+ reference: "Art. 14".to_string(),
+ title: "Human oversight".to_string(),
+ coverage: Coverage::Provided,
+ provides: format!(
+ "Approval-gate records in the audit log evidence human oversight:\n{}",
+ f.approvals
+ .iter()
+ .map(|a| format!("- {a}"))
+ .collect::>()
+ .join("\n")
+ ),
+ gap: None,
+ });
+ }
+
+ out
+}
+
+fn nist_obligations(f: &BundleFacts) -> Vec {
+ let mut out = Vec::new();
+
+ let signer = f
+ .signer_pubkey
+ .as_deref()
+ .map(|k| format!(", ed25519-signed by `{k}`"))
+ .unwrap_or_default();
+ out.push(Obligation {
+ reference: "MEASURE 2.x (2.8 / 2.9 / 2.11)".to_string(),
+ title: "Traceability, provenance, and the ability to inspect/audit AI system behaviour"
+ .to_string(),
+ coverage: Coverage::Provided,
+ provides: format!(
+ "The bundle is a replayable provenance record: `bundle_hash = {}`{}, workflow hash \
+ `{}`, audit head `{}`, and an environment fingerprint ({}). Per-file SHA-256 \
+ checksums plus the hash-chained log let a third party re-inspect exactly what ran \
+ and confirm nothing changed.",
+ f.bundle_hash, signer, f.workflow_hash, f.audit_log_hash, f.env_summary
+ ),
+ gap: None,
+ });
+
+ out.push(Obligation {
+ reference: "MANAGE 2.x".to_string(),
+ title: "Documented policy / capability controls governing the system".to_string(),
+ coverage: if f.has_policy {
+ Coverage::Provided
+ } else {
+ Coverage::NotCovered
+ },
+ provides: if f.has_policy {
+ format!(
+ "`policy.json` captures the capability policy in force during the run \
+ (`policy_hash = {}`); capability decisions are recorded in the audit log.",
+ f.policy_hash
+ )
+ } else {
+ "No `policy.json` component is present in this bundle.".to_string()
+ },
+ gap: if f.has_policy {
+ None
+ } else {
+ Some(
+ "policy record: NOT PRESENT — MANAGE expects the governing controls to be \
+ documented; this bundle carries no policy snapshot."
+ .to_string(),
+ )
+ },
+ });
+
+ out
+}
+
+fn iso42001_obligations(f: &BundleFacts) -> Vec {
+ let mut out = Vec::new();
+
+ let integrity = if f.signed {
+ "sealed with a bundle hash AND an ed25519 signature"
+ } else {
+ "sealed with a bundle hash (unsigned)"
+ };
+ out.push(Obligation {
+ reference: "Clause 7.5 / 8.1".to_string(),
+ title: "Control of documented information & operational records".to_string(),
+ coverage: if f.signed {
+ Coverage::Provided
+ } else {
+ Coverage::Partial
+ },
+ provides: format!(
+ "The evidence bundle for run `{}` (workflow `{}`) is a controlled record: {}, \
+ `bundle_hash = {}`. Its contents are protected against unintended alteration by \
+ per-file checksums and the hash-chained audit log.{}",
+ f.run_id,
+ f.workflow_name,
+ integrity,
+ f.bundle_hash,
+ if f.encrypted {
+ " Contents are additionally encrypted at rest."
+ } else {
+ ""
+ }
+ ),
+ gap: if f.signed {
+ None
+ } else {
+ Some(
+ "The record is tamper-EVIDENT (hash-chained) but UNSIGNED — integrity rests on \
+ an out-of-band anchor of `bundle_hash`. Sign the bundle (ed25519) to root record \
+ integrity in an operator key rather than external anchoring."
+ .to_string(),
+ )
+ },
+ });
+
+ out
+}
+
+// ---- Rendering ------------------------------------------------------------
+
+const DISCLAIMER: &str = "This is a TECHNICAL EVIDENCE-MAPPING report, NOT a certificate of \
+compliance and NOT legal attestation. It maps artifacts present in a Boruna evidence bundle to the \
+regulatory obligations they help evidence; it does not assess whether the deploying organisation \
+meets those obligations. Determinism guarantees reproducibility GIVEN the recorded effects \
+(captured capability results) — it does NOT prove reproducibility of any underlying AI model's \
+outputs. Obligations flagged NOT COVERED / NOT DECLARED require controls outside this bundle. \
+Consult qualified counsel for a compliance determination.";
+
+fn render_markdown(
+ framework: ComplianceFramework,
+ f: &BundleFacts,
+ verdict: &crate::audit::verify::VerifyResult,
+ obligations: &[Obligation],
+) -> String {
+ let mut s = String::new();
+ s.push_str(&format!(
+ "# Compliance Evidence Mapping — {}\n\n",
+ framework.title()
+ ));
+ s.push_str(&format!("- **Run ID:** `{}`\n", f.run_id));
+ s.push_str(&format!("- **Workflow:** `{}`\n", f.workflow_name));
+ s.push_str(&format!("- **Bundle hash:** `{}`\n", f.bundle_hash));
+ s.push_str(&format!("- **Audit log hash:** `{}`\n", f.audit_log_hash));
+ s.push_str(&format!(
+ "- **Run window:** `{}` .. `{}`\n",
+ f.started_at, f.completed_at
+ ));
+ s.push_str(&format!("- **Environment:** {}\n", f.env_summary));
+ s.push_str(&format!(
+ "- **Signature:** {}\n",
+ match &f.signer_pubkey {
+ Some(k) => format!("ed25519 `{k}`"),
+ None => "unsigned".to_string(),
+ }
+ ));
+ s.push('\n');
+
+ // Verification banner — loud on failure.
+ if verdict.valid {
+ s.push_str(
+ "## Verification: PASSED\n\nThe bundle passed integrity verification \
+ (`verify_bundle`): checksums, hash chain, and required files are intact.\n\n",
+ );
+ } else {
+ s.push_str("## Verification: FAILED\n\n");
+ s.push_str(
+ "> WARNING: This bundle did NOT pass integrity verification. The mapping below is \
+ over EVIDENCE THAT CANNOT BE TRUSTED. Do not rely on it until the errors are \
+ resolved.\n\n",
+ );
+ for e in &verdict.errors {
+ s.push_str(&format!("- `{e}`\n"));
+ }
+ s.push('\n');
+ }
+
+ s.push_str("## Disclaimer\n\n");
+ s.push_str(DISCLAIMER);
+ s.push_str("\n\n");
+
+ s.push_str("## Obligation mapping\n\n");
+ for o in obligations {
+ s.push_str(&format!(
+ "### {} — {}\n\n**Status:** {}\n\n**Evidence provided:**\n\n{}\n\n",
+ o.reference,
+ o.title,
+ o.coverage.label(),
+ o.provides
+ ));
+ if let Some(gap) = &o.gap {
+ s.push_str(&format!("**Gap / not covered:** {gap}\n\n"));
+ }
+ }
+
+ s.push_str("---\n\n");
+ s.push_str(&format!(
+ "_Generated by `boruna evidence report` over bundle `{}`. Components observed: \
+ workflow={}, policy={}, outputs={}, intents={}, model_invoking_steps={}._\n",
+ f.run_id,
+ f.has_workflow,
+ f.has_policy,
+ f.has_outputs,
+ f.has_intents,
+ f.has_model_invocations
+ ));
+ s
+}
+
+fn render_html(
+ framework: ComplianceFramework,
+ f: &BundleFacts,
+ verdict: &crate::audit::verify::VerifyResult,
+ obligations: &[Obligation],
+) -> String {
+ let mut body = String::new();
+ body.push_str(&format!(
+ "Compliance Evidence Mapping — {}
\n",
+ esc(framework.title())
+ ));
+ body.push_str("\n");
+
+ if verdict.valid {
+ body.push_str(
+ "Verification: PASSED
The bundle passed \
+ integrity verification (checksums, hash chain, required files intact).
\n",
+ );
+ } else {
+ body.push_str(
+ "Verification: FAILED
WARNING: \
+ This bundle did NOT pass integrity verification. The mapping below is over \
+ evidence that cannot be trusted.
\n",
+ );
+ for e in &verdict.errors {
+ body.push_str(&format!("{} \n", esc(e)));
+ }
+ body.push_str("
\n");
+ }
+
+ body.push_str(&format!(
+ "\n",
+ esc(DISCLAIMER)
+ ));
+
+ body.push_str("Obligation mapping
\n");
+ for o in obligations {
+ let cls = match o.coverage {
+ Coverage::Provided => "provided",
+ Coverage::Partial => "partial",
+ Coverage::NotCovered => "notcovered",
+ Coverage::NotApplicable => "na",
+ };
+ body.push_str(&format!(
+ "\n{} — {}
\n\
+ Status: {}
\n\
+ {}
\n",
+ cls,
+ esc(&o.reference),
+ esc(&o.title),
+ o.coverage.label(),
+ esc_multiline(&o.provides)
+ ));
+ if let Some(gap) = &o.gap {
+ body.push_str(&format!(
+ "Gap / not covered: {}
\n",
+ esc(gap)
+ ));
+ }
+ body.push_str("\n");
+ }
+
+ format!(
+ "\n\n\n\n\
+ Compliance Evidence Mapping — {}\n\n\
+ \n\n{}\n\n",
+ esc(&f.run_id),
+ HTML_STYLE,
+ body
+ )
+}
+
+const HTML_STYLE: &str = "body{font-family:system-ui,-apple-system,sans-serif;max-width:52rem;\
+margin:2rem auto;padding:0 1rem;line-height:1.5;color:#1a1a1a}\
+code{background:#f2f2f2;padding:.1em .3em;border-radius:3px;font-size:.9em;word-break:break-all}\
+.verify.pass{border-left:4px solid #2e7d32;background:#edf7ed;padding:.5rem 1rem}\
+.verify.fail{border-left:4px solid #c62828;background:#fdecea;padding:.5rem 1rem}\
+.disclaimer{border:1px solid #999;background:#fafafa;padding:.5rem 1rem;font-size:.9em}\
+section.ob{border:1px solid #ddd;border-radius:6px;padding:.5rem 1rem;margin:1rem 0}\
+section.ob.notcovered{border-color:#c62828}section.ob.partial{border-color:#f9a825}\
+section.ob.provided{border-color:#2e7d32}.status strong{text-transform:uppercase}\
+.gap{color:#a11}";
+
+/// HTML-escape a string for safe interpolation into element content.
+fn esc(s: &str) -> String {
+ s.replace('&', "&")
+ .replace('<', "<")
+ .replace('>', ">")
+}
+
+/// Like `esc`, but turns newlines into `
` so multi-line `provides`
+/// text keeps its line breaks in HTML.
+fn esc_multiline(s: &str) -> String {
+ esc(s).replace('\n', "
\n")
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::audit::evidence::EvidenceBundleBuilder;
+ use crate::audit::log::{AuditEvent, AuditLog};
+ use std::path::Path;
+
+ /// Build a plaintext bundle (no retention metadata, no approvals) that
+ /// verifies cleanly. Mirrors the constructions in verify.rs tests.
+ fn build_bundle(dir: &Path) -> BundleManifest {
+ let mut builder = EvidenceBundleBuilder::new(dir, "run-report-001", "report-wf").unwrap();
+ builder.add_workflow_def(r#"{"name":"report"}"#).unwrap();
+ builder.add_policy(r#"{"default_allow":true}"#).unwrap();
+ builder
+ .add_step_output("s1", "result", r#"{"value":1}"#)
+ .unwrap();
+
+ let mut audit = AuditLog::new();
+ audit.append(AuditEvent::WorkflowStarted {
+ workflow_hash: "abc".into(),
+ policy_hash: "def".into(),
+ });
+ audit.append(AuditEvent::StepCompleted {
+ step_id: "s1".into(),
+ output_hash: "out".into(),
+ duration_ms: 5,
+ });
+ audit.append(AuditEvent::WorkflowCompleted {
+ result_hash: "res".into(),
+ total_duration_ms: 6,
+ });
+ builder.finalize(&audit).unwrap()
+ }
+
+ #[test]
+ fn framework_and_format_parse() {
+ assert_eq!(
+ ComplianceFramework::parse("eu-ai-act").unwrap(),
+ ComplianceFramework::EuAiAct
+ );
+ assert_eq!(
+ ComplianceFramework::parse("NIST").unwrap(),
+ ComplianceFramework::Nist
+ );
+ assert_eq!(
+ ComplianceFramework::parse("iso42001").unwrap(),
+ ComplianceFramework::Iso42001
+ );
+ assert!(ComplianceFramework::parse("gdpr").is_err());
+ assert_eq!(ReportFormat::parse("md").unwrap(), ReportFormat::Markdown);
+ assert_eq!(ReportFormat::parse("HTML").unwrap(), ReportFormat::Html);
+ assert!(ReportFormat::parse("pdf").is_err());
+ }
+
+ #[test]
+ fn eu_ai_act_report_maps_real_facts_and_flags_retention() {
+ let dir = tempfile::tempdir().unwrap();
+ let manifest = build_bundle(dir.path());
+ let bundle_dir = dir.path().join("run-report-001");
+
+ let report = generate_report(
+ &bundle_dir,
+ ComplianceFramework::EuAiAct,
+ ReportFormat::Markdown,
+ )
+ .unwrap();
+
+ // Verified clean bundle.
+ assert!(
+ report.contains("Verification: PASSED"),
+ "expected PASSED, got:\n{report}"
+ );
+ // The report cites the bundle's REAL identifiers.
+ assert!(report.contains("run-report-001"), "missing run_id");
+ assert!(
+ report.contains(&manifest.audit_log_hash),
+ "missing real audit_log_hash"
+ );
+ // Names the record-keeping article.
+ assert!(report.contains("Art. 12"), "missing Art. 12");
+ // Flags the missing retention policy against Art. 19.
+ assert!(
+ report.contains("NOT DECLARED") && report.contains("Art. 19"),
+ "retention gap not flagged:\n{report}"
+ );
+ // Honest about being a mapping, not a certificate.
+ assert!(
+ report.contains("NOT a certificate of compliance"),
+ "disclaimer missing"
+ );
+ }
+
+ #[test]
+ fn tampered_bundle_report_says_verification_failed() {
+ let dir = tempfile::tempdir().unwrap();
+ build_bundle(dir.path());
+ let bundle_dir = dir.path().join("run-report-001");
+
+ // Tamper a covered file: manifest still parses, but verify fails on
+ // the checksum mismatch.
+ std::fs::write(bundle_dir.join("workflow.json"), r#"{"name":"EVIL"}"#).unwrap();
+
+ let report = generate_report(
+ &bundle_dir,
+ ComplianceFramework::EuAiAct,
+ ReportFormat::Markdown,
+ )
+ .unwrap();
+
+ assert!(
+ report.contains("Verification: FAILED"),
+ "tampered bundle must report FAILED:\n{report}"
+ );
+ assert!(
+ report.contains("checksum mismatch"),
+ "expected the checksum error surfaced in the report"
+ );
+ assert!(
+ report.contains("cannot be trusted") || report.contains("CANNOT BE TRUSTED"),
+ "expected a loud untrusted-evidence warning"
+ );
+ }
+
+ #[test]
+ fn nist_and_iso_reports_render() {
+ let dir = tempfile::tempdir().unwrap();
+ build_bundle(dir.path());
+ let bundle_dir = dir.path().join("run-report-001");
+
+ let nist = generate_report(
+ &bundle_dir,
+ ComplianceFramework::Nist,
+ ReportFormat::Markdown,
+ )
+ .unwrap();
+ assert!(nist.contains("MEASURE 2"), "NIST MEASURE mapping missing");
+ assert!(nist.contains("MANAGE"), "NIST MANAGE mapping missing");
+
+ let iso = generate_report(
+ &bundle_dir,
+ ComplianceFramework::Iso42001,
+ ReportFormat::Html,
+ )
+ .unwrap();
+ assert!(iso.starts_with(""), "HTML doctype missing");
+ assert!(iso.contains("Clause 7.5"), "ISO clause mapping missing");
+ assert!(iso.contains("run-report-001"), "run_id missing in HTML");
+ }
+
+ #[test]
+ fn report_maps_human_oversight_when_approvals_present() {
+ let dir = tempfile::tempdir().unwrap();
+ let mut builder =
+ EvidenceBundleBuilder::new(dir.path(), "run-report-appr", "appr-wf").unwrap();
+ builder.add_workflow_def(r#"{"name":"appr"}"#).unwrap();
+ builder.add_policy(r#"{"default_allow":true}"#).unwrap();
+ let mut audit = AuditLog::new();
+ audit.append(AuditEvent::ApprovalRequested {
+ step_id: "review".into(),
+ role: "compliance-officer".into(),
+ });
+ audit.append(AuditEvent::ApprovalGranted {
+ step_id: "review".into(),
+ approver: "alice".into(),
+ });
+ builder.finalize(&audit).unwrap();
+ let bundle_dir = dir.path().join("run-report-appr");
+
+ let report = generate_report(
+ &bundle_dir,
+ ComplianceFramework::EuAiAct,
+ ReportFormat::Markdown,
+ )
+ .unwrap();
+ // Art. 14 human oversight is now evidenced, not N/A.
+ assert!(report.contains("Art. 14"));
+ assert!(
+ report.contains("GRANTED by `alice`"),
+ "approval record not surfaced:\n{report}"
+ );
+ }
+
+ #[test]
+ fn missing_manifest_is_an_error() {
+ let dir = tempfile::tempdir().unwrap();
+ let err = generate_report(
+ dir.path(),
+ ComplianceFramework::EuAiAct,
+ ReportFormat::Markdown,
+ )
+ .unwrap_err();
+ assert!(err.contains("manifest.json"), "got: {err}");
+ }
+}
diff --git a/tooling/src/stdlib/mod.rs b/tooling/src/stdlib/mod.rs
index a79967a..ebe5cd8 100644
--- a/tooling/src/stdlib/mod.rs
+++ b/tooling/src/stdlib/mod.rs
@@ -238,6 +238,19 @@ mod tests {
assert_eq!(result, 0);
}
+ #[test]
+ fn test_std_guard_compiles() {
+ let src = load_library_source(&libs_dir(), "std-guard").unwrap();
+ assert!(verify_compiles(&src).is_ok());
+ }
+
+ #[test]
+ fn test_std_guard_runs() {
+ let src = load_library_source(&libs_dir(), "std-guard").unwrap();
+ let result = run_library(&src).unwrap();
+ assert_eq!(result, 0); // all true-cases hold, all false-cases fail
+ }
+
// ── Determinism Tests ──
#[test]
@@ -299,4 +312,10 @@ mod tests {
let src = load_library_source(&libs_dir(), "std-json").unwrap();
assert!(verify_determinism(&src).is_ok());
}
+
+ #[test]
+ fn test_std_guard_determinism() {
+ let src = load_library_source(&libs_dir(), "std-guard").unwrap();
+ assert!(verify_determinism(&src).is_ok());
+ }
}
diff --git a/tooling/src/tests.rs b/tooling/src/tests.rs
index 5cfc268..0fc7a8b 100644
--- a/tooling/src/tests.rs
+++ b/tooling/src/tests.rs
@@ -429,6 +429,7 @@ fn test_stdlib_all_compile_and_run() {
"std-storage",
"std-notifications",
"std-testing",
+ "std-guard",
];
for name in &lib_names {
let src = stdlib::load_library_source(&libs_dir, name)
@@ -452,6 +453,7 @@ fn test_stdlib_determinism() {
"std-db",
"std-notifications",
"std-testing",
+ "std-guard",
];
for name in &lib_names {
let src = stdlib::load_library_source(&libs_dir, name)