Developer documentation
Decision record format
Last reviewed 31 August 2026
All docs
Decision record format (open specification)#
Status: version 1 of the signature domain, record schema version 3. Published so that anyone can check a CAIN decision record without trusting CAIN, and so that other tools can produce and verify the same format. This page specifies the record, the digest and the signature. It does not describe how CAIN reaches a decision.
Why this exists#
A security dashboard asks you to trust what it shows. An auditor, an insurer or your own incident review needs more than that: proof that a record says today what it said when the action was decided. Every decision CAIN records is hashed and signed with Ed25519 when it is written, and the public key is published. With the three rules below, a record exported today can be checked years later, offline, by someone with no CAIN account and no CAIN code.
1. The record#
Export one decision exactly as stored:
curl -s https://cainstudio.online/fabric/decisions/<decision_id>/signed-record \ -H "X-API-Key: $CAIN_API_KEY" > record.json
The fields that the digest covers, in digest order:
| # | Field | Type in the record | Rule for the digest |
| 1 | decision_id | string | as is |
| 2 | tenant | string | as is |
| 3 | principal_id | string or null | null becomes the empty string |
| 4 | agent_id | string or null | null becomes the empty string |
| 5 | service | string or null | null becomes the empty string |
| 6 | path | string or null | null becomes the empty string |
| 7 | verdict | string | as is: ALLOWED, ALLOWED_DEGRADED, ALLOWED_WITH_DENIALS, REQUIRE_APPROVAL or BLOCKED |
| 8 | blocked | 0/1 or boolean | "1" if true, else "0" |
| 9 | enforcing | 0/1 or boolean | "1" if true, else "0" |
| 10 | stages | string | the stored JSON text byte for byte. Do not parse and re-serialise it |
| 11 | created_at | string | as is (ISO 8601, UTC) |
| 12 | chain_id | string or null | null becomes the empty string |
| 13 | parent_decision_id | string or null | null becomes the empty string |
| 14 | schema version | integer | the decimal string "3" |
| 15 | outcome | string or null | null becomes the empty string |
Three more fields carry the proof: digest (hex), record_key_id and record_signature (base64). schema_version must be 3 for this specification. Other fields in the export (state, halted, chain_depth) are informational and not covered.
2. The digest#
Join the 15 values above with the unit separator 0x1F, encode as UTF-8, and take SHA-256 in lowercase hex:
import hashlib
def digest(r):
parts = [r["decision_id"], r["tenant"], r.get("principal_id") or "", r.get("agent_id") or "",
r.get("service") or "", r.get("path") or "", r["verdict"],
"1" if r["blocked"] else "0", "1" if r["enforcing"] else "0",
r["stages"], r["created_at"], r.get("chain_id") or "", r.get("parent_decision_id") or "",
"3", r.get("outcome") or ""]
return hashlib.sha256("\x1f".join(parts).encode("utf-8")).hexdigest()
The recomputed digest must equal the record's digest.
3. The signature#
The signed message is the domain string, 0x1F, then the digest in hex, as UTF-8:
cain.fabric.decision-record.v1 0x1F <digest hex>
- Algorithm: Ed25519 (RFC 8032).
- Public key:
GET /fabric/decision-signing-keyon any of the three sites. No account is needed,
and the response is readable from any origin.
- Key id: the first 16 hex characters of SHA-256 over the raw 32-byte public key. It must equal the
record's record_key_id.
Fetch the key from a different site than the one that served the record. Serving a forged record together with a matching forged key would then require controlling both sites.
curl -s https://mcpgate.online/fabric/decision-signing-key
4. Verify with the reference verifier#
The reference verifier is one file. It imports no CAIN code and needs only Python 3.8+ and cryptography:
curl -sO https://cainstudio.online/proof/bundle/decision-signing-2026-09-27/verify_decision_record.py.txt mv verify_decision_record.py.txt verify_decision_record.py python3 verify_decision_record.py record.json --key https://mcpgate.online/fabric/decision-signing-key --self-test
It reports three checks (DIGEST, KEY, SIGNATURE). With --self-test it also runs two negative controls that must fail: 1. The verdict is changed on a copy of the record: the digest check fails. 2. The verdict is changed and the digest is recomputed, which is what someone who can write the database but does not hold the key would do: the signature check fails.
5. Test vector#
A real record from the hosted service, with its key, is published at /proof/bundle/decision-signing-2026-09-27/ (record.json, signing-key.json):
decision_id fd_b69b43052ff44051827576f7 verdict REQUIRE_APPROVAL digest e097129857d959b4a2a0d4b6314f3a0a1f45d3aa983f7b17654fa6c7bd0ef75c key_id 8fcdf85b4a675f0e public key FkHArqH0TyLOOqTFtDaJqUUWj8XhXF4EGmxkaaqoWFA= (base64, Ed25519)
An implementation of this specification is correct when it reproduces that digest and verifies that signature, and when it rejects both negative controls above.
6. What a valid record proves, and what it does not#
Proves: the record is byte-for-byte what was written at created_at by a holder of the signing key. Any later change to a covered field is detected, including a change made by someone who can write the database and recompute the digest.
Does not prove:
- That the decision was *correct*. The record shows what was decided, not whether it should have been.
- Anything against the operator of the gateway host. Root on that host holds both the key and the
database, so this is not third-party notarisation.
- That a record exists. A deleted record leaves nothing to verify. Completeness needs an append-only
external log, which is not part of version 1.
For decisions ordered by the consensus cluster, GET /fabric/decisions/<id>/integrity also reports a consensus_anchor: a quorum-signed commitment to the record, checked against signatures from the cluster's replicas rather than this one key.
7. Versioning#
The signature domain (cain.fabric.decision-record.v1) and the schema version change together whenever the covered fields change. A verifier must refuse versions it does not implement rather than guess. Records written under an earlier version stay verifiable under the rules of that version.
Feedback and independent implementations: security@cainstudio.online.