Record chain
The canonical form, fields, hash links and signatures of an evidence/v1 record, how a verifier walks the chain, and why denied actions are recorded.
evidence/v1 (spec 1.0.0). The records are written by the proprietary runtime; any verifier can read them.A record is the runner's signed statement that an action happened, under which decision, with which outcome. The chain proves those statements are in order and unaltered under a key you trust.
When to read this page
Read it when you write or review a verifier, when you need to explain to a security team why a chain cannot be quietly edited, or when a chain_broken or record_invalid finding needs a diagnosis. Operators who only run numi evidence verify can start at Offline verification.
Mechanism
Every action a change takes, including lifecycle transitions and refused actions, becomes one record. The record is written by the runner identity. The agent that did the work does not write, edit or sign its own account. Three properties tie the records into a chain:
- Each record names the hash of the previous record (
previous_record_hash), absent only atsequence1. - Each record carries its own
record_hash, the SHA-256 of its canonical bytes withoutrecord_hashandsignature. - Each record is signed with Ed25519 over a context string and a digest that includes the chain fields, so a record cannot be moved to another position or another change.
record 1 --hash--> record 2 --hash--> record 3 ... record n (ChangeClosed.v1) --> seal (optional)
| | |
signed signed signed one runner key, pinned by the verifierA verifier does not take any of this on faith. It re-derives record_hash from the bytes as stored, compares it with the stored value, follows the link to the previous record, and only then checks the signature against the key it was told to trust.
One real record
This is record 2 of the conformance fixture closed-author: the runner moving the change from new to planning. The fixture is a generated golden export with fixed test keys, not a real engineering run. The line is shown exactly as stored, in canonical form.
{"action":"ChangeTransitioned.v1","action_outcome":"succeeded","actor_kind":"runner","actor_principal":"runner-1","canon_version":2,"change_id":"chg_01010101010101010101010101010101","ev_id":"ev_02020202020202020202020202020202","lifecycle":{"author_run_id":"run_09090909090909090909090909090909","from":"new","row_version":1,"to":"planning"},"policy_bundle_digest":"sha256:95d78bff6f47b59c8787f8a980af18faa3ca6d34401348abf4afc6f8642ba2de","previous_record_hash":"sha256:badc6909e742411775874470cfd60d09ec13e2d81018d3f74027ca1145276067","profile_digest":"sha256:1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0","record_hash":"sha256:ca2bbf4b81dcd943e8c2630b8cadb01e19d0b6743eb80a721c7cbc1b9c70865e","risk_class":"READ","sequence":2,"signature":"p2fWkY5ME54o0d7/iu0F8XGp9ZViGYKh3dUUceXjYuTgOd8J9wY98ZtzU5oiPEwHIrZwSIibNTxQ3W3vMgFqAQ==","signing_key_id":"sha256:34750f98bd59fcfc946da45aaabe933be154a4b5094e1c4abf42866505f3c97e","timestamp":"2026-11-01T12:00:02Z"}Note what is absent: no inputs, no executor_id. In canonical form an empty field is not written at all, so the record is exactly the fields that carry information.
Exact fields
The JSON Schema is schemas/evidence/v1/record.schema.json. The keys, with the rules a verifier enforces:
| Key | Presence | Rule |
|---|---|---|
ev_id | required | ev_ and 32 lowercase hexadecimal characters |
change_id | required | chg_ and 32 lowercase hexadecimal characters |
sequence | required | integer, 1 to 2^53-1 |
action | required | a capability port.verb, or a versioned lifecycle event such as ChangeClosed.v1; at most 128 bytes |
actor_principal | required | 1 to 256 bytes of printable ASCII, no leading or trailing space |
actor_kind | required | author-agent, reviewer-agent, release-pipeline, human-approver, runner, break-glass; broker or egress-proxy on a receipt record only |
actor_run_id | optional | run_ and 32 hex; required for author-agent and reviewer-agent |
policy_decision_id | optional | dec_ and 32 hex; required on a capability record |
grant_id | optional | grant_ and 32 hex; required on a capability record unless it was denied |
risk_class | required | READ, LOW, MEDIUM, HIGH or PRIVILEGED |
profile_digest, policy_bundle_digest | required | the profile and policy bundle in force, as digests |
plan_digest, executor_id, model_id | optional | the plan, executor and model the action ran under |
sandbox_image_digest, sandbox_profile | optional, together | set exactly together; process, container, gvisor or microvm |
inputs, outputs | optional | artifacts {ref, sha256}, at most 1024 each. Digests, never content |
action_outcome | required | succeeded, partial, failed or denied |
corrects_record_hash | optional | names an earlier record this one corrects |
previous_record_hash | optional | absent exactly at sequence 1 |
record_hash, signature, signing_key_id | required in an export | see below |
timestamp | required | RFC 3339 UTC |
canon_version | required | 2 |
lifecycle | optional | {from, to, row_version, author_run_id?, max_repair_attempts?} on lifecycle events with outcome succeeded only |
Three details matter for people who build tooling:
- The four identities appear as
actor_kind:author-agent,reviewer-agent,release-pipelineandhuman-approver. The verifier checks the field's form. The separation of duties itself is enforced by policy before the action; the chain is where you read that it held. inputsandoutputsname artifacts by reference and digest. The chain holds no diff content, no log text and no credentials.- A record that has an
executor_idhas bothsandbox_profileandsandbox_image_digest. An action cannot be attributed to an executor without saying where it ran.
Canonical form, digests and signatures
Every digest and signature is computed over bytes, so the bytes are specified. The canonical form of a record is deterministic JSON, and it is not RFC 8785 (JCS): key order and number rules differ.
| Rule | Consequence |
|---|---|
| Members sorted by the UTF-8 bytes of the key | Two producers cannot disagree on order |
| No whitespace outside strings, no trailing newline | The line is the record |
| Integers only: no sign, leading zero, fraction or exponent | 1.0 and 01 are refused |
| An empty field is absent | "inputs":[] is not canonical |
Enums are names, bytes are padded base64, timestamps are UTC with Z | One spelling for every value |
Digest is sha256: and 64 lowercase hex | Uppercase hex is not a digest |
A verifier reads each document strictly: no duplicate or unknown keys, no case-variant keys, types exactly as defined, and finally the decoded model written back in canonical form must equal the input byte for byte. That last step refuses every non-canonical spelling at once, which is why a record cannot be altered by whitespace or key tricks and still pass.
record_hash = sha256( canonical(record without record_hash and signature) )
signing_digest = sha256( canonical(record without signature) )
domain_tag = "numinance.evidence.v1.Record"
signature = Ed25519( runner_key, domain_tag || signing_digest )The signature covers sequence, previous_record_hash and record_hash, so it binds position as well as content. A signature made in one context never verifies in another: the attestation, the manifest and the checkpoint each have their own context string. A key id is the digest of the raw 32-byte public key.
How a verifier walks the chain
A verifier is told the change (from summary.json) and walks records.ndjson from the first line. For record n it applies these rules, and the first failure ends the walk:
| Step | Failure code |
|---|---|
| The line decodes strictly and satisfies the record validity rules | record_invalid |
record_hash is present, the chain has not already ended, change_id matches, ev_id is new | chain_broken |
sequence equals n, previous_record_hash equals the hash of record n-1, and re-derived record_hash equals the stored one | chain_broken |
The signature verifies under the key named by signing_key_id, which must be a trusted key | signature_invalid, signer_untrusted |
Limits: a line is at most 3 MiB, the file at most 256 MiB, and a chain at most 32768 records (too_many_records). A chain ends at ChangeClosed.v1, ChangeAbandoned.v1 or ChangeRolledBack.v1. Only an optional seal record, EvidenceSealed.v1, may follow, with exactly one output (the checkpoint reference) and no inputs. A chain whose last record is neither terminal nor a seal "may be a prefix of a longer chain", and the verifier reports it as incomplete instead of passing it.
Denied actions are records
Policy is evaluated before every privileged action, outside the model. When policy refuses, the runner still writes a record, with action_outcome of denied. A denied capability record carries its policy_decision_id but may omit grant_id. This is the denial from the closed-basic fixture: the runner was refused scm.merge_pull_request.
{"action":"scm.merge_pull_request","action_outcome":"denied","actor_kind":"runner","actor_principal":"runner-1","actor_run_id":"run_03030303030303030303030303030303","canon_version":2,"change_id":"chg_01010101010101010101010101010101","ev_id":"ev_03030303030303030303030303030303","grant_id":"grant_03030303030303030303030303030303","policy_bundle_digest":"sha256:95d78bff6f47b59c8787f8a980af18faa3ca6d34401348abf4afc6f8642ba2de","policy_decision_id":"dec_03030303030303030303030303030303","previous_record_hash":"sha256:144c4c24bc7c5825b4dfd423b754df5ad8b7f0c4a746bbacdb8a997f2504b656","profile_digest":"sha256:1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0","record_hash":"sha256:571265f468fcd1ec01661959ad4e53b196a7c8275965d313aeaf302a943da296","risk_class":"READ","sequence":3,"signature":"xVUEZLD31a/yOX6EzJmq49HX+7OdE9k8YlxnIkkNypegICpsWx+RwVNFV5FkGdvcK0BtPwZgmBjLzbKsSpiHCw==","signing_key_id":"sha256:34750f98bd59fcfc946da45aaabe933be154a4b5094e1c4abf42866505f3c97e","timestamp":"2026-11-01T12:00:03Z"}The summary counts them: that export's summary.json has "denied":1. The denial matters because a history that lists only successes cannot show that the boundary held. Here the boundary is visible: the attempt, the decision that refused it, and the next record in the chain.
Corrections instead of edits
Nothing is updated or deleted. A correction is a new record whose corrects_record_hash names an earlier record. A verifier checks only that the named record exists earlier in the chain. It does not interpret the correction.
Failure modes and safeguards
| Symptom | Finding | What it usually means |
|---|---|---|
| A record edited after signing | chain_broken, with the message "a record does not match its record_hash" | The bytes changed; the real example is in Offline verification |
| A record removed or reordered | chain_broken | Sequence or link no longer matches |
| A record signed by an unexpected key | signer_untrusted | The key is not one you pinned; see Trust levels |
| Non-canonical spelling (key order, escapes, number form) | record_invalid | A producer bug or tampering; a verifier never "fixes" the line |
| A record after a terminal record other than a seal | chain_broken | The chain was extended after it ended |
What the chain does not say: it holds no clock beyond each record's timestamp, and timestamps are not checked against each other or against real time. It proves these records, in this order, were signed by the pinned runner key. It does not by itself prove they are the whole chain. Completeness rests on the terminal record, the signed manifest and the transparency log; see Anchoring and receipts and Threat model and limits.
Evidence
How a Numinance change becomes a signed, hash-linked record chain that an independent party can verify offline, and what that verification does and does not prove.
Export format
The files in an evidence/v1 export directory, what each one binds, how absent parts are declared, and the rules a verifier applies when it reads the directory as untrusted input.