What this is about
A hash chain is a list in which every entry contains the hash of the entry before it. If you change an entry, its hash no longer matches the next entry. The chain is broken at that point, and every verification finds it.
CRMS keeps two kinds of such chains:
- The object chain: one per template variant and one per document chain. It holds the history of exactly one object.
- The journal: one per tenant and one for the system. It holds every event relevant as evidence, for all objects of that tenant.
How the chains fit together
flowchart LR
subgraph O["Object chain (one document chain)"]
R1["Revision 1"] --> R2["Revision 2"] --> H["Legal hold set"]
end
subgraph J["Journal of the tenant"]
J1["template-published"] --> J2["document-stored"] --> J3["document-stored"] --> J4["document-legal-hold-set"]
end
R1 -. "hash" .-> J2
R2 -. "hash" .-> J3
H -. "hash" .-> J4
J4 -. "head" .-> A["Anchor with timestamp"]
Every journal entry carries the hash of the object chain entry it reports. So whoever wants to change a revision has to rewrite its object chain and the journal. And the journal is anchored outside, see Timestamps and anchors.
What a hash links
Both chains follow the same rule:
hash_n = SHA-256( canonical(metadata_n) ‖ content_hash_n ‖ hash_n-1 )
- canonical means: the metadata is written as JSON following RFC 8785. The same data always gives the same bytes, whatever order the fields were in.
- content_hash is the hash of the document bytes for a document revision. In the journal it is the hash of the reported object chain entry.
- hash_n-1 is the hash of the predecessor. The first entry of a chain has none and uses all zero bytes instead.
‖means: appended one after the other. The two hashes enter as raw bytes, not as text.
The algorithm is written in front of every hash (sha-256:…). Anyone who holds the metadata, the content and the chain can recompute every hash, with nothing but a JSON canonicalizer and SHA-256.
What goes into an entry of a document chain
Metadata of an entry (canonicalized, then hashed){
"chain": "5d0c…", ← the chain
"sequence": 2, ← position, from 1, without gaps
"type": "REVISION", ← REVISION, LEGAL_HOLD_SET, LEGAL_HOLD_RELEASED, ERASED
"revision": "7c1e…", ← the revision concerned
"actor": "u-3f2a…", ← who caused it
"at": "2026-10-17T09:12:44.123456Z",
"reason": null, ← reason as a key, for hold and erasure
"attributes": { "number": 2, "template": "audit-report", "payloadHash": "sha-256:…", … }
}Every entry carries the content hash of the revision it is about. So an erasure or a legal hold is also bound firmly to exactly one document.
A document chain knows four kinds of entries:
| Kind | Meaning |
|---|---|
REVISION | a final document was stored as the next revision |
LEGAL_HOLD_SET | a legal hold was set on the chain |
LEGAL_HOLD_RELEASED | the legal hold was released |
ERASED | the bytes of a revision were deleted and its key destroyed |
Whether a revision is erased or a hold applies is not stored by CRMS in a field next to it. CRMS reads the state from the entries. What is not recorded there does not apply.
What the journal records
Every journal is a chain of its own. An entry names the kind, the object concerned, the user and the time:
| Kind | Object concerned | When |
|---|---|---|
template-revision | template-variant:<id> | a template revision was submitted |
template-approved | template-variant:<id> | an approval stage was granted |
template-rejected | template-variant:<id> | a revision was rejected |
template-published | template-variant:<id> | a revision was published |
template-superseded | template-variant:<id> | the published revision was replaced |
template-retired | template-variant:<id> | the published revision was withdrawn without a successor |
document-stored | document-chain:<id> | a final document was stored |
document-legal-hold-set | document-chain:<id> | a legal hold was set |
document-legal-hold-released | document-chain:<id> | a legal hold was released |
document-erased | document-chain:<id> | a revision was erased |
document-key-destroyed | document-key:<id> | a document key was destroyed |
document-read | document-chain:<id> | a final document was read, only with CODAMAI_CRMS_AUDIT_READS=true |
How the template entries come about is described in Life cycle of a template.
Where the chains are kept
| What | Where |
|---|---|
| journal of a tenant | in the tenant’s database |
journal of the system (key system) | in the system database |
| document chains | with the journal of their owner |
All tables are only added to, never changed: entries are added, none is overwritten or deleted. Only the journal’s head, which holds the last number and the last hash, is moved forward. More in Database.
Writing at the same time
Two operations in the same tenant write into the journal one after the other. For this, CRMS locks the journal’s head until the transaction ends. If an operation rolls back, its journal entry goes with it.
| What meets | What happens |
|---|---|
| two writers in an existing journal | the second waits and writes afterwards |
| two writers create the same new journal at the same moment | one gets 409 journal-concurrent-change and can retry |
| two writers append to the same document chain at the same time | one gets 409 document-concurrent-change and can retry |
A chain never forks.
What verifying a chain finds
Verification recomputes every hash from the first entry and compares it with the stored one.
| Tampering | found by |
|---|---|
| one byte of an entry changed | the chain itself |
| an entry removed or the order swapped | the chain itself |
| the journal’s last entry removed | the comparison with the head |
| the journal consistently recomputed from some point, head included | only the anchor with timestamp |