Evidence Integrity Specification
Status: stable · Version: 1.12 · Scope: the signed evidence record produced by Talon.
This is the normative specification for how a Talon evidence record is serialized, signed, and verified. It is written so that a third party can independently verify a Talon record — or reproduce a signature — from this document alone, without reading the Go source.
The normative source of the record shape is the Evidence struct in
internal/evidence/store.go; the signing and
verification primitives are in
internal/evidence/signature.go. Where this
document and the source disagree, the source is authoritative — please file an issue so
the spec can be corrected.
Integrity, not correctness. A valid signature proves that the record was signed with the deployment's configured key and has not been modified since signing. It does not prove that the policy decision, model response, tool result, or operator configuration was correct. See LIMITATIONS.md.
1. Overview
record (signature = "") ──serialize──▶ canonical bytes ──HMAC-SHA256(key)──▶ mac
│
signature = "hmac-sha256:" + hex(mac)
Signing happens once, at record creation (Store.Store in
internal/evidence/store.go). Verification recomputes
the signature from the stored record and compares it in constant time
(Store.VerifyRecord). The signing key never leaves the operator's deployment.
2. Record fields
A record is a single JSON object. Every field listed below is covered by the signature. Fields marked always are always present; fields marked optional are omitted when they hold their zero value (see §3.3).
Top-level fields, in serialization order (this order is significant — see §3.2):
| # | JSON key | Type | Presence |
|---|---|---|---|
| 1 | id | string | always |
| 2 | correlation_id | string | always |
| 3 | session_id | string | optional |
| 4 | stage | string | optional |
| 5 | candidate_index | number | optional |
| 6 | judge_score | number | optional |
| 7 | selected | bool | optional |
| 8 | timestamp | string (RFC 3339) | always |
| 9 | tenant_id | string | always |
| 10 | agent_id | string | always |
| 11 | team | string | optional |
| 12 | invocation_type | string | always |
| 13 | request_source_id | string | optional |
| 14 | policy_decision | object | always |
| 15 | classification | object | always |
| 16 | attachment_scan | object | optional |
| 17 | tool_governance | object | optional |
| 18 | execution | object | always |
| 19 | model_routing_rationale | string | optional |
| 20 | secrets_accessed | array(string) | optional |
| 21 | upstream_auth_mode | string | optional |
| 22 | upstream_key_source | string | optional |
| 23 | upstream_key_fingerprint | string | optional |
| 24 | gateway_annotations | array(string) | optional |
| 25 | memory_writes | array(object) | optional |
| 26 | memory_reads | array(object) | optional |
| 27 | audit_trail | object | always |
| 28 | compliance | object | always |
| 29 | agent_reasoning | string | optional |
| 30 | agent_verified | bool | optional |
| 31 | observation_mode_override | bool | optional — LEGACY, no longer written since #442; retained at the same position for verification of historical records |
| 32 | shadow_violations | array(object) | optional — LEGACY, no longer written since #442; retained at the same position for verification of historical records |
| 33 | status | string | optional |
| 34 | failure_reason | string | optional |
| 35 | signature | string | always |
| 36 | routing_decision | object | optional |
| 37 | cache_hit | bool | optional |
| 38 | cache_entry_id | string | optional |
| 39 | cache_similarity | number | optional |
| 40 | cost_saved | number | optional |
| 41 | plan_review | object | optional |
| 42 | retry_attempt | string | optional |
| 43 | explanations | array(object) | optional |
| 44 | plan_id | string | optional |
| 45 | graph_run_id | string | optional |
| 46 | data_flow | object | optional |
| 47 | egress_decision | object | optional |
| 48 | failover | object | optional |
| 49 | orchestration | object | optional |
| 50 | session_budget | object | optional |
| 51 | cost_budget | object | optional |
| 52 | workload_identity | object | optional |
| 53 | enforcement | object | optional |
| 54 | action_lifecycle | object | optional |
Nested objects (policy_decision, classification, execution, audit_trail,
compliance, and the optional objects) follow the same encoding rules recursively; their
field order and omitempty behavior are defined by their Go structs in
internal/evidence/store.go. The audit-critical
nested fields are:
policy_decision:allowed(bool),action(string),reasons(array, optional),policy_version(string), andpolicy_digests(object, optional; gateway decisions) — SHA-256 digests of theorganization,agent,provider, andeffectivepolicy components, so an auditor can prove which exact policy state produced the decision (#266). Part of the signed payload.classification:input_tier,output_tier(numbers),pii_detected(array, optional),pii_redacted(bool), and optional output-scan fields. The optionalscannerobject identifies the scan engine behind the verdict:engine(detector identity, e.g.talon-regexor the configured external engine name),type(regex|presidio|http|llm), and optionalversion,scan_duration_ms, andfailure(adapter failure kind —timeout/transport/status/decode/validation— when a scan failed: a fail-closed block for preventive actions, an incomplete observation forwarn). Entity types only; never raw PII text or raw engine errors. The optionaltool_contentobject (spec 1.5, #212) records the observation-only PII scan of tool-related request content (scanned,has_pii,entity_types,entity_count); detection is evidence-only and never influences allow/deny or redaction in this version. The optionalresponse_scanobject (spec 1.10, #476), appended aftertool_content, states how the response-side PII control was applied:action(warn|redact|block),enforcement(post_delivery_observation— a streamed response delivered as it arrived and scanned afterwards;observation— a non-streaming response scanned before the write but never altered;preventive— held until the verdict and possibly redacted or withheld), optionalstreamed(bool),status(complete|incomplete), optionalincomplete_reason(capture_limit_exceeded|upstream_error|client_cancelled|scanner_unavailable|no_text_content), and optionalbytes_observed/capture_limit(numbers, streamed observations only). It is omitted when no response scan ran (allow).output_pii_detected/output_pii_typeskeep describing what was found; withstatus: incompletetheir absence means "not fully observed", never "clean".execution:model_used(string),cost(number),tokens(object),duration_ms(number), plus optional fields.tokenscarriesinputandoutputalways, plus optionalcache_read/cache_writeprompt-cache token counts (spec 1.7, #196). Optionalpricing_basisrecords howcostwas derived (table|cache_fallback_input_rate|default_estimate) andpricing_knownis true only when the model was priced from the table.audit_trail: SHA-256input_hash/output_hashcontent digests.compliance:frameworks(array) anddata_location.status(optional): the generic outcome of the recorded invocation, one ofqueued|running|completed|failed|terminated|blocked|denied|unknown|cancelled. An empty or omittedstatusmeanscompleted: historical records were written without the field and keep that meaning; verifiers and consumers must preserve it. Writers of any non-final or ambiguous outcome set the value explicitly.unknown(#458) means the governed effect may or may not have happened and no trustworthy outcome exists — it is nevercompleted;cancelledmeans the operation closed before any effect (rejected, expired or invalidated approval).action_lifecyclerecords setstatuson every transition (queuedwhile authorized or awaiting approval,runningwhile an attempt executes, then the terminal value mirrored fromaction_lifecycle.operation_status).data_flow(optional):detector(string, optional) anditems(array of objects linking classified data sources to destinations). Each item carriessource,source_detail(optional),tier,entity_types(sorted array, optional),entity_count(optional),value_digests(sorted array of per-request salted SHA-256 prefixes, optional — never raw values),entity_attributions(optional array of compact attribution objects:type,field_path(optional),start(optional),end(optional),attributes(optional)),disposition, and adestinationobject (kind,name,model,endpoint,region; the last three optional).egress_decision(optional): outcome of the gateway egress policy (data tier × destination). Fields:tier(number),provider(string),region(string, optional),decision("allow"or"deny"),matched_rule(string, optional — e.g.tier_2:allowed_regionsordefault_action), andreason(string, optional — machine code such asegress_tier_destination_disallowed). Present only when an egress policy is configured for the agent; recorded for allowed and denied requests so the control's execution can be evidenced.failover(optional): provider fallback-chain context (#191). Present when error-driven failover produced a failed-attempt record, a fallback decision, or a fail-closed outcome. Fields are defined by theFailoverContextGo struct; see the failover verifier (talon audit verify --failover). Spec 1.9 adds the optionalretryfield at the struct's end (#139): the same-provider retry ordinal of a failed attempt (0/absent = first attempt, 1 = first retry) — additive per the nested append rule.orchestration(optional): client-asserted coding-orchestration identity (#194). Fields:session_id,agent_id,parent_agent_id,client(adapter-detected:"claude-code","codex", or"generic"),session_source(client_asserted|vendor_asserted|synthetic), andprovenance(always"client_asserted"in this version). Attribution metadata only — as trustworthy as the agent whose key was presented; never a policy input. All fields optional/omitempty.session_budget(optional, spec 1.8, #198): the numbers asession_budget_exceededgateway deny was decided on. Fields:limit(the agent'smax_session_costat evaluation time),spent(accumulated session spend the deny rule saw),estimate(the pre-request estimate added to spend). Present only on session-budget deny records — every such deny is enforced with zero provider dispatch (#442); historical records from the removed shadow posture carry the reason in the legacyshadow_violations(#442) field instead. Appended afterorchestrationper the §2 append rule.cost_budget(optional, spec 1.9, #144): the agent/org budget-window context a cost-control event was decided on. Fields:period("daily"|"monthly"),limit(the BINDING cap — the tightest of the per-agent resolved cap and the org ceiling, the same denominator enforcement uses),spent(window spend at evaluation time),estimate(number, optional — the pre-request estimate; deny records only),threshold_pct(number, optional — the crossed warning threshold;budget_thresholdrecords only). Present on two record kinds: budget hard-stop denials (budget_exceededreasons), and warning-threshold crossing records withinvocation_type: "budget_threshold"— a signed fact written ONCE per crossing per budget window (record classoperator_event, so it never counts as request traffic). Appended aftersession_budgetper the §2 append rule; pre-1.9 record signatures remain valid.workload_identity(optional, spec 1.11, #457/#482): the outcome of verified workload-identity federation at ingress. Fields:status(verified|failed|absent|asserted—assertedmarks imported/asserted identity facts Talon never verified),runtime(configured verification source, e.g.openshell),auth_method(jwt_eddsa),issuer,subject,principal_id,audience,binding(agent_config— the principal → use-case binding came from the agent's own trusted configuration),failure_code(stableworkload_identity_*code),verified_at(RFC 3339). Safe facts only: never the raw credential, signature or key material. Appended aftercost_budgetper the §2 append rule.enforcement(optional, spec 1.11, #146/#482): who owned the prevention/observation boundary for this record. Fields:mechanism(intercept|delegate|verify),boundary(talon|external_runtime),decision_authority(talon|external_runtime),provenance— literal enforcement provenance:talon_enforced(Talon owned the boundary and observed the outcome),delegated_expected(Talon decided and returned the verdict through a configured runtime hook; enforcement expected, not observed),external_asserted(unsigned/unverified external statement, e.g. an operator-imported log),external_verified(cryptographically or authoritatively verified receipt),client_asserted— optionaldecision_returned_via(the configured channel Talon returned a delegated decision through), optionalruntimeobject (type,id,policy_ref,reference,request_id— safe references to the external runtime, never tokens), and optionalreceiptobject (kind,digest,verified,detail) describing an externally produced fact Talon consumed;verifiedis true only when Talon cryptographically verified the receipt itself. Absent means the legacy default: Talon intercepted on its own gateway/MCP boundary (talon_enforced). A valid Talon signature proves Talon recorded the claim, not that an external runtime enforced it. Appended afterworkload_identity.action_lifecycle(optional, spec 1.12, #458): one transition of a governed action operation (invocation typeaction_lifecycle, record classaction_event). Every record of one operation shares the operation's resource id ascorrelation_id. Fields:event(operation_established|operation_conflict|approval_requested|approval_decided|attempt_claimed|attempt_armed|attempt_completed|authorization_refused),sequence(operation- local, gap-free, 1 = establishing record), the exact identity (operation_id,operation_ref,action,digest,schema_digest,policy_digest,binding_profile,execution_profile,destination_id,identity_source), the separate state axes (verdict,matched_rule_id,operation_status,approval_id,approval_status,approver_groups,approval_expires_at), the authenticated reviewer (reviewer_principal,reviewer_group,decision_reason— never a display name from a request), and attempt facts (attempt_id,attempt_ordinal,attempt_status,idempotency_key,dispatch_boundary,dispatch_armed(durable pre-effect marker: intent, never observation),request_writtenandresponse_observed(the dispatcher's own observations, recorded only at completion and false after a crash),http_status,result_provenance=observed(response matched the trusted success contract) |unknown|not_dispatched,outcome_code,outcome_ref,refusal_code; reviewer factsreviewer_principal(principal id),reviewer_tenant,reviewer_subject,reviewer_group,reviewer_credential_id,reviewer_credential_version; identity factsdefinition_digestandprojection_digest). These answer distinct questions on purpose: who decided (verdict), whether a human decided (approval axis), whether Talon armed a dispatch (dispatch_armed), what Talon observed (request_written,response_observed), and how the result is known (result_provenance); none of them is an enforcement-provenance value. Raw arguments never appear; the digest and the reviewer projection are the permanent record. Beyond per-record signatures,talon audit verify --operation <id>checks lifecycle consistency (order, sequence, constant digests, approval before claim, claim before arm before completion, observed write and response for a succeeded attempt, no attempt after a closed operation, reviewer tenant equal to the operation tenant) so individually valid records cannot describe an impossible lifecycle. Appended afterenforcement.
3. Canonical serialization
The canonical byte sequence is the JSON encoding of the record with the signature field
set to the empty string "". It is produced by Go's encoding/json.Marshal; a faithful
re-implementation in any language must reproduce the following rules byte-for-byte.
3.1 Object form
- A single JSON object, UTF-8 encoded.
- No insignificant whitespace between tokens (no spaces after
:or,). - No trailing newline (the encoder is
json.Marshal, not a streamingEncoder).
3.2 Field order
Object members appear in the struct declaration order shown in §2, not
alphabetical order. Go's encoding/json emits struct fields in declaration order; any
re-implementation must use the same fixed order. (There are no Go map fields at the top
level, so ordering is fully deterministic.)
3.3 omitempty rules
A field tagged ,omitempty is omitted entirely when it holds its Go zero value:
"" for strings, 0 for numbers, false for booleans, and null/length 0 for
pointers, slices, and maps. Note: Go's omitempty does not omit empty structs, so
the always-present object fields (e.g. policy_decision) are emitted even when their
sub-fields are zero.
The signature field is not tagged omitempty. In the canonical (pre-signing) form
it is therefore always present and serialized as "signature":"".
3.4 String, number, and time encoding
- Strings use standard JSON escaping, with Go's default HTML escaping enabled:
<→\u003c,>→\u003e,&→\u0026, and U+2028 / U+2029 →\u2028/\u2029. A re-implementation must apply the same escaping or signatures will not match. - Timestamps (
timestamp) are RFC 3339 / ISO 8601 with up to nanosecond precision, the output of Go'stime.Time.MarshalJSON(e.g.2026-06-02T21:15:02.123456789Z). Trailing-zero fractional digits are trimmed. - Numbers use Go's default
encoding/jsonformatting (integers without a decimal point; floats in the shortest round-trippable form).
4. Signing procedure
Given the canonical bytes C from §3 and the resolved key
K from §6:
- Compute
mac = HMAC-SHA256(K, C)(RFC 2104, SHA-256). - Encode
macas lowercase hexadecimal (64 hex characters). - The signature string is the literal prefix
hmac-sha256:followed by that hex string, e.g.hmac-sha256:9f86d081884c7d65.... - Set the record's
signaturefield to this string and persist the record.
See Signer.Sign in internal/evidence/signature.go.
5. Verification procedure
Given a stored record R whose signature field holds S:
- Save
S, then setR.signature = "". - Recompute the canonical bytes
C'fromRper §3. - Compute
expected = "hmac-sha256:" + hex(HMAC-SHA256(K, C')). - The record is valid iff
expected == S, compared in constant time (hmac.Equalover the full prefixed strings). RestoreR.signature = S.
Any post-signing modification to any field — timestamp, cost, PII findings, policy
decision, etc. — changes C' and causes verification to fail. See Store.VerifyRecord in
internal/evidence/store.go.
CLI
talon audit verify <evidence-id> # verify one record from the live store
talon audit verify --file export.json # verify a signed export offline
The file verifier reports total / valid / invalid / missing-signature / unparseable counts and exits non-zero if any record fails. See Evidence store and the compliance export runbook.
6. Key resolution
The signing key is supplied via TALON_SIGNING_KEY (or configuration). It is interpreted
by resolveSigningKey in
internal/evidence/signature.go:
- If the value is 64 or more characters, even length, and all hexadecimal, it is hex-decoded to raw bytes, which must be at least 32 bytes.
- Otherwise the value's raw UTF-8 bytes are used directly, and must be at least 32 bytes.
The same K is used for signing and verification (symmetric HMAC). Custody, rotation, and
backup of the key are operator responsibilities; see LIMITATIONS.md.
7. Reproducibility test
The round-trip property — that following this spec independently produces a signature the
verifier accepts, and that tampering is detected — is asserted by
TestEvidenceIntegritySpecRoundTrip in
internal/evidence/integrity_spec_test.go.
It serializes a record per §3, signs it per
§4, verifies it with an independently constructed signer and with
Store.VerifyRecord, and confirms that mutating a field invalidates the signature.
8. Changelog
-
1.12 — added optional top-level field
action_lifecycle(#458), appended afterenforcement, plus invocation typeaction_lifecycle(record classaction_event): the per-transition signed record of one governed action operation, with a lifecycle verifier over the chain. Additive and backward-compatible per the append rule. The top-levelstatusvocabulary gainedunknownandcancelled(values only — no canonical-form change; empty/omitted still meanscompleted). -
1.11 — added optional top-level fields
workload_identity(#457) andenforcement(#146), appended aftercost_budget, for the OpenShell reference composition (#482): verified external workload identity facts, and who owned the prevention boundary (talonvsexternal_runtime,intercept/delegate/verify,observed, safe runtime references, unverified receipts). Added invocation typeexternal_runtime_event(record classexternal_event) for imported containment facts. Additive and backward-compatible: records that omit the fields keep identical canonical bytes and verify unchanged; a pre-1.11 verifier drops the unknown members on parse and therefore cannot verify records that carry them — use a 1.11 verifier for new records. -
1.10 — added optional nested field
classification.response_scan(#476): how the response-side PII control was applied (action,enforcement,streamed,status,incomplete_reason,bytes_observed,capture_limit), so a post-delivery observation of a streamedwarnresponse is machine-distinguishable from a preventive redaction or block, and an incomplete observation from a clean scan. Appended aftertool_contentper the §2 append rule. Additive and backward-compatible: records that omit the field keep identical canonical bytes and verify unchanged (fixtures underinternal/evidence/testdata/pre_response_scan/); use a 1.10 verifier for records that carry it. -
1.9 (unchanged bytes, #442) — fields 31
observation_mode_overrideand 32shadow_violationsare now LEGACY: the live non-enforcing postures that (#442) produced them were removed, so new records never populate them and themode_change/proxy_shadow_violationrecord kinds are no longer written. Not a breaking change and no version bump: both fields keep their positions andomitemptyrules, canonical bytes are identical for every record with and without them, and historical records that carry them verify unchanged with the same verifier (fixtures underinternal/evidence/testdata/legacy_posture/). -
1.9 — added optional top-level field
cost_budget(#144) and the optional nested fieldfailover.retry(#139, same-provider retry ordinal, appended at the end ofFailoverContext): the structured{period, limit, spent, estimate?, threshold_pct?}an agent/org budget decision was made on, carried by budget hard-stop denials and by the newbudget_thresholdwarning-crossing records (invocation typebudget_threshold, record classoperator_event— one signed fact per crossing per budget window). Appended aftersession_budgetper the §2 append rule. Additive and backward-compatible: records that omit the field keep identical canonical bytes and verify unchanged; use a 1.9 verifier for records that carry it. -
1.8 — added optional top-level field
session_budget(#198): the structured{limit, spent, estimate}asession_budget_exceededgateway deny was decided on. Appended afterorchestrationper the §2 append rule. Additive and backward-compatible: records that omit the field keep identical canonical bytes and verify unchanged; use a 1.8 verifier for records that carry it. -
1.7 — added optional nested fields
execution.tokens.cache_read/execution.tokens.cache_write(prompt-cache token counts) andexecution.pricing_basis/execution.pricing_known(how the cost was derived), #196. Additive and backward-compatible: records that omit them keep identical canonical bytes and verify unchanged; use a 1.7 verifier for records that carry them. -
1.6 — added optional nested field
orchestration(#194): client-asserted coding-orchestration identity (session_id,agent_id,parent_agent_id,client,session_source,provenance) observed on gateway requests. Attribution metadata only, markedprovenance: "client_asserted"; never a policy input in this version. Additive and backward-compatible: records that omit the field keep identical canonical bytes and verify unchanged; use a 1.6 verifier for records that carry it. -
1.5 — added optional nested field
classification.tool_content(#212): the observation-only PII scan of tool-related request content (tool_use inputs, tool_result outputs, function-call arguments) —scanned,has_pii,entity_types,entity_count. Detection is evidence-only: it never influences allow/deny or redaction in this version. Additive and backward-compatible: records that omit the field keep identical canonical bytes and verify unchanged; use a 1.5 verifier for records that carry it. -
1.4 — added optional nested field
classification.scanner(external EntityScanner adapter support, #181): scan engine identity, type, declared version, scan duration, and failure kind on fail-closed blocks. Additive and backward-compatible: records that omit the field keep identical canonical bytes and verify unchanged. -
1.3 — added optional nested field
data_flow.items[].entity_attributions(compact field-path + span attribution, no raw values). This is additive and backward-compatible: records that omit the field keep identical canonical bytes and verify unchanged. -
1.2 — added optional top-level field
egress_decision(#47), appended afterdata_flow. Records signed under spec 1.0/1.1 verify unchanged (the field is omitted when absent, so their canonical bytes are identical). The established additive-field caveat applies: a verifier built against an earlier spec drops the unknownegress_decisionmember on parse and therefore cannot verify records that carry it — use a 1.2 verifier for new records. -
1.1 — added optional top-level field
data_flow(#46), appended aftergraph_run_id. Records signed under spec 1.0 verify unchanged (the field is omitted when absent, so their canonical bytes are identical). Note the established caveat for every additive field: a verifier built against spec 1.0 drops the unknowndata_flowmember on parse and therefore cannot verify records that carry it — use a 1.1 verifier for new records. -
1.0 — initial version.
9. Limitations
- HMAC is symmetric: anyone holding the signing key can produce valid signatures. The signature attests integrity under the operator's key custody, not third-party non-repudiation. Asymmetric signing is out of scope for this version.
- The signature does not bind the record to a specific host or instance beyond the shared key, and it does not attest the correctness of the decision it records.