Evidence / For assessors

What an assessor is actually handed.

The claim is that evidence is a by-product of governing rather than a report assembled afterwards. This page is that claim made checkable: what the bundle contains, how to verify it without trusting us, and what it does and does not establish.

Verifiable offline Tamper-evident by construction Not a certification

CONTENTS

What is in the bundle.

Signed evidence bundles are available per deployment and are not default-on. Configuration and verification are explicit steps, and a deployment that has not configured them does not produce one.

Element
What an assessor checks
Governance events
Agent ID, session ID, tool name, policy decision, timestamp, latency. No prompts, no completions, no PII.
Hash chain
Each entry is chained to the last, so a removed or altered entry does not verify. SHA-256 throughout. Each entry stores entry_hash = SHA-256(prev_hash + entry_json), where entry_json is the entry in a fixed field order with the two chain fields removed from their own input, and the first entry chains from an empty string. Every row also carries a sequence number; against Postgres the prior hash and the next sequence are read inside the appending transaction under a cross-replica advisory lock, so concurrent writers cannot fork the chain. Two hash layouts exist: the original does not cover account_id or sequence_number, the current one does, and a verification reports how many legacy rows it walked — a chain that passes over legacy rows says nothing about which tenant those rows belonged to. Note what this is: the platform audit chain. A separate per-tenant decision ledger with the same prev_hash / entry_hash shape exists as database schema only, with no write path behind it.
Merkle root
Two different constructions carry this name, and this page will not claim one of them for both. A gateway decision batch gets a real binary tree: each leaf is SHA-256 over one record’s canonical bytes, pairs are concatenated and hashed, an odd node is promoted unchanged to the next level rather than duplicated, and the process repeats to a single hex root. The window is one batch — 100 records or a 5-second flush, whichever comes first, per gateway — and the manifest’s from_ts and to_ts are the first and last record timestamps in it. A loop evidence bundle reuses the same manifest envelope but sets merkle_root to a flat SHA-256 content hash over the whole canonical bundle body. Same field, no tree.
Signature
Ed25519. There are two distinct signatures rather than one over everything. Each decision record is signed individually over its canonical JSON — fixed field order, sig omitted — covering decision_id, ts, org_id, gateway_id, bundle_hash, input_hash, decision, effect, stage, kid and seq. The batch manifest is signed separately over its own canonical bytes: batch_id, from_ts, to_ts, record_count, merkle_root, kid. So the manifest signature covers the root, and the root is what binds the records to it. A loop bundle signs that same envelope, where the root is the bundle’s content hash, so there the signature does cover the whole emitted body except the signing envelope itself. Distribution: every signature names a kid, and the public halves are served as a keyring — public material only, each key carrying ACTIVE, PREVIOUS or RETIRED status and a not-before / not-after window — from the control plane’s gateway-keys endpoint; a gateway that generates its own key registers the public half back. There is no JWKS endpoint and no out-of-band publication, which is precisely why third-party offline verification is not yet available.
Policy version
Every decision record carries bundle_hash: the SHA-256 over the canonical body of the policy bundle in force at that gateway when the decision was made — the org, kill switches, resolved bindings, active exceptions, OPA policies, the SLM contract and the behavioural thresholds, with every slice sorted before hashing so the value is order-independent. The bundle header alongside it carries a monotonic sequence and an epoch and is itself Ed25519-signed, and the same hash is what the drift check compares. Because bundle_hash sits inside the record’s canonical signing bytes, it cannot be changed after the fact without breaking that record’s signature. What it does not do is name the individual rule: it identifies the policy state by content, and resolving it back to readable policy needs the control plane that issued it.
Manifest
There is no archive and no directory layout — these are JSON documents, not a tarball, and the page should not imply otherwise. The batch manifest is a single object: batch_id, from_ts, to_ts, record_count, merkle_root, kid, sig. A loop evidence bundle is one document declaring schema_version: loops.evidence.v1, with a content-addressed bundle_id, generated_at, the account, the loop, an explicit machine-readable honesty verdict, then runs, events, stop_conditions and shadow_findings, a counts block and the signing envelope. counts.truncated is set when any collection hit its cap, so an omission is disclosed rather than silent. The compliance report export is a different artifact: one file in PDF, JSON, CSV or HTML, and it is unsigned — a report, not the sealed record.

VERIFICATION

Checking it without trusting the vendor.

The property that matters is that verification does not require us to be online, honest, or still in business.

Obtain the bundle

Export covering the period under assessment. State the shape of this honestly: there is no single command that emits one sealed bundle for a date range. Three things exist, and only the first two are signed.

# sealed batch manifests over a period # both parameters are required, both RFC 3339 GET /api/v1/orgs/{org_id}/evidence/manifests?from={rfc3339}&to={rfc3339} GET /api/v1/orgs/{org_id}/evidence/manifests/{batch_id} # one loop's evidence bundle — no period parameter # the covered window is derived from the rows and reported in its own manifest GET /api/v1/loops/{id}/evidence-bundle # compliance report — takes a date range, but the output is UNSIGNED POST /api/v1/compliance-export/jobs {"date_range_from": "...", "date_range_to": "...", "format": "pdf"} GET /api/v1/compliance-export/jobs/{id}/download

Verify the signature

Confirm the bundle was produced by the deployment it claims to come from, and has not been altered since. The endpoint re-derives the Merkle root from the records you supply, compares it to the manifest, then checks the manifest’s own Ed25519 signature against that gateway’s keyring. A manifest can be checked without its records, and then merkle_verified deliberately stays false. With no keyring wired it answers 503 rather than a comforting valid: true.

POST /api/v1/orgs/{org_id}/evidence/verify {"records": [ ... ], "manifest": { ... }} # success {"valid": true, "checked": 100, "merkle_verified": true} # failure — every failing record is named, not just the first {"valid": false, "checked": 100, "errors": [ "record_index=41 decision_id=...: ED25519 signature verification failed for kid ...", "merkle_root_mismatch: recomputed=... manifest=...", "record_count_mismatch: manifest=100 supplied=97" ]} # nothing could be verified — never dressed up as a pass 503 {"error": "verification unavailable: no keyring resolver configured"}

Can a standard tool do it? The signature itself is plain Ed25519 over canonical JSON whose field order is fixed and documented, so any Ed25519 implementation can check it once you hold the public keyring. But no standalone verifier ships, and the keyring is served by the control plane — so nothing completes the check today without a GovernorAI component present.

Verify the chain

Confirm no entry has been removed or edited. This is the check that makes the record worth reading: an append-only log that anyone can silently edit is a report, not evidence. The walk starts from an empty genesis hash and stops at the first row it cannot verify, naming that row by id in first_broken. There are two failure shapes: a prev_hash that does not match the running hash, which is what a removed or reordered entry looks like, and an entry_hash that matches neither hash layout, which is what an edited entry looks like.

governor compliance audit-verify --from {rfc3339} --to {rfc3339} # success [OK] Audit chain integrity verified Entries verified: N # broken — the failing row is named [ERROR] Audit chain integrity BROKEN Entries checked: N First broken: ENTRY_ID Error: entry ENTRY_ID: prev_hash mismatch (expected ..., got ...) Error: entry ENTRY_ID: entry_hash mismatch (stored ... matches neither the v1 nor the v2 layout) # intact, but older rows predate tenant-bound hashing [WARN] Audit chain verified, but tenant attribution is NOT bound for all entries # nothing was verified — this is never printed as a pass [INFO] Audit chain verification not performed (chain_not_configured)

Two limits stated before an assessor finds them. An empty chain verifies, which is not evidence of integrity. And a truncated tail is invisible to this check by construction — it validates the entries it is handed and has no independent notion of where the chain should end, so detecting truncation needs a head anchored outside the database, which is not shipped.

Map decisions to controls

Trace a specific governed action to the control it evidences, and to the policy version in force at the time.

action an agent invokes a tool at the gateway and the call is evaluated against the policy bundle in force event one signed evidence record decision_id · ts · org_id · gateway_id · bundle_hash input_hash · kid · seq decision (allow | deny | pause) effect (audit | enforce | deny) stage (pre | post) plus the matching hash-chained audit row control NIST AI RMF MANAGE-2.2 "Mechanisms for AI incident response" artifact GET /api/v1/compliance-modules/frameworks/nist_ai_rmf MANAGE-2.2 → coverage_status: partial partial_evidence capability: kill_switch evidence_kind: kill_switch_config capability: audit_log evidence_kind: audit_log manual_attestation incident_response_plan (the documented plan, from you)

Read the last block carefully, because it is where the chain genuinely breaks. The mapping artifact maps a control to a capability — kill switch, audit log, policy engine, RBAC, approval workflow — and declares whether that coverage is automatic, partial or manual attestation. Nothing maps a control to an individual decision: no field on an evidence record, an audit entry or a manifest names a control, and no code joins the two. So an assessor is shown the control’s declared coverage and the events produced by the capability it names, and a person makes the join. That is a mapping of controls to mechanisms, not of controls to decisions, and calling it the second thing would be the overstatement this page exists to avoid.

External offline verification — a third party verifying without any GovernorAI component present — is roadmap, not shipped. Say so on this page rather than letting an assessor discover it during fieldwork.

LIMITS

What this does not establish.

  • It is not a certification. GovernorAI holds none on your behalf and issues none — a certification is granted by an auditor or certification body against their own assessment.
  • It evidences governed actions. An action taken on a path with no seam is not in the bundle, and the bundle cannot tell you it is missing.
  • All six assurance domains have an implemented evaluator; measurability is computed from how your deployment is wired. An unmeasurable domain reports not_assessed, which is a stated gap rather than a pass.
Honesty note

The value of this page to an assessor is entirely in the commands and their real output, and three of them are worth reading twice. There is no single export that seals one bundle for a date range. merkle_root names a real binary tree on a gateway batch and a flat content hash on a loop bundle, and the page says which is which rather than letting the stronger word cover both. And the control mapping reaches a capability, not a decision — the last step of the join is made by a person.

Continue