Skip to main content

Evidence Integrity Specification

Status: stable · Version: 1.2 · 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 keyTypePresence
1idstringalways
2correlation_idstringalways
3session_idstringoptional
4stagestringoptional
5candidate_indexnumberoptional
6judge_scorenumberoptional
7selectedbooloptional
8timestampstring (RFC 3339)always
9tenant_idstringalways
10agent_idstringalways
11teamstringoptional
12invocation_typestringalways
13request_source_idstringoptional
14policy_decisionobjectalways
15classificationobjectalways
16attachment_scanobjectoptional
17tool_governanceobjectoptional
18executionobjectalways
19model_routing_rationalestringoptional
20secrets_accessedarray(string)optional
21upstream_auth_modestringoptional
22upstream_key_sourcestringoptional
23upstream_key_fingerprintstringoptional
24gateway_annotationsarray(string)optional
25memory_writesarray(object)optional
26memory_readsarray(object)optional
27audit_trailobjectalways
28complianceobjectalways
29agent_reasoningstringoptional
30agent_verifiedbooloptional
31observation_mode_overridebooloptional
32shadow_violationsarray(object)optional
33statusstringoptional
34failure_reasonstringoptional
35signaturestringalways
36routing_decisionobjectoptional
37cache_hitbooloptional
38cache_entry_idstringoptional
39cache_similaritynumberoptional
40cost_savednumberoptional
41plan_reviewobjectoptional
42retry_attemptstringoptional
43explanationsarray(object)optional
44plan_idstringoptional
45graph_run_idstringoptional
46data_flowobjectoptional
47egress_decisionobjectoptional

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).
  • classification: input_tier, output_tier (numbers), pii_detected (array, optional), pii_redacted (bool), and optional output-scan fields.
  • execution: model_used (string), cost (number), tokens (object), duration_ms (number), plus optional fields.
  • audit_trail: SHA-256 input_hash / output_hash content digests.
  • compliance: frameworks (array) and data_location.
  • data_flow (optional): detector (string, optional) and items (array of objects linking classified data sources to destinations). Each item carries source, 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), disposition, and a destination object (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_regions or default_action), and reason (string, optional — machine code such as egress_tier_destination_disallowed). Present only when an egress policy is configured for the caller; recorded for allowed and denied requests so the control's execution can be evidenced.

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 streaming Encoder).

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's time.Time.MarshalJSON (e.g. 2026-06-02T21:15:02.123456789Z). Trailing-zero fractional digits are trimmed.
  • Numbers use Go's default encoding/json formatting (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:

  1. Compute mac = HMAC-SHA256(K, C) (RFC 2104, SHA-256).
  2. Encode mac as lowercase hexadecimal (64 hex characters).
  3. The signature string is the literal prefix hmac-sha256: followed by that hex string, e.g. hmac-sha256:9f86d081884c7d65....
  4. Set the record's signature field 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:

  1. Save S, then set R.signature = "".
  2. Recompute the canonical bytes C' from R per §3.
  3. Compute expected = "hmac-sha256:" + hex(HMAC-SHA256(K, C')).
  4. The record is valid iff expected == S, compared in constant time (hmac.Equal over the full prefixed strings). Restore R.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.2 — added optional top-level field egress_decision (#47), appended after data_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 unknown egress_decision member 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 after graph_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 unknown data_flow member 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.