CodamAIDocs
Topicdone

The two hash chains

Every object has its own chain, every tenant its journal: what a hash links, what the journal records, and what verification finds.

Variants
object chain of a template variantobject chain of a documentjournal of a tenantjournal of the system

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.

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

What is hashed
Metadata of an entry (canonicalized, then hashed)
Metadata
{
  "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:

KindMeaning
REVISIONa final document was stored as the next revision
LEGAL_HOLD_SETa legal hold was set on the chain
LEGAL_HOLD_RELEASEDthe legal hold was released
ERASEDthe 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:

KindObject concernedWhen
template-revisiontemplate-variant:<id>a template revision was submitted
template-approvedtemplate-variant:<id>an approval stage was granted
template-rejectedtemplate-variant:<id>a revision was rejected
template-publishedtemplate-variant:<id>a revision was published
template-supersededtemplate-variant:<id>the published revision was replaced
template-retiredtemplate-variant:<id>the published revision was withdrawn without a successor
document-storeddocument-chain:<id>a final document was stored
document-legal-hold-setdocument-chain:<id>a legal hold was set
document-legal-hold-releaseddocument-chain:<id>a legal hold was released
document-eraseddocument-chain:<id>a revision was erased
document-key-destroyeddocument-key:<id>a document key was destroyed
document-readdocument-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

WhatWhere
journal of a tenantin the tenant’s database
journal of the system (key system)in the system database
document chainswith 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 meetsWhat happens
two writers in an existing journalthe second waits and writes afterwards
two writers create the same new journal at the same momentone gets 409 journal-concurrent-change and can retry
two writers append to the same document chain at the same timeone 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.

Tamperingfound by
one byte of an entry changedthe chain itself
an entry removed or the order swappedthe chain itself
the journal’s last entry removedthe comparison with the head
the journal consistently recomputed from some point, head includedonly the anchor with timestamp

Pitfalls

Next

Search