What this is about
A template is a controlled document: it changes only through a new, reviewed state, and every report can be traced back to exactly that state. For this, every language variant has a chain. That is a list of entries that only grows. Every entry records one step: submitted, approved, rejected, published, superseded, retired.
The states
flowchart LR
D["Draft"] -->|submit| R["IN_REVIEW<br/>in review"]
D -->|"submit,<br/>no approval stages"| P
R -->|"last stage<br/>approved"| P["PUBLISHED<br/>published"]
R -->|reject| X["REJECTED<br/>rejected"]
P -->|"next revision<br/>published"| S["SUPERSEDED<br/>superseded"]
P -->|retire| T["RETIRED<br/>retired"]
S -.->|"roll back:<br/>new revision"| P
T -.->|"roll back:<br/>new revision"| P
| State | Meaning |
|---|---|
IN_REVIEW | submitted, running through the approval stages |
PUBLISHED | the revision of the variant that renders. There is at most one |
SUPERSEDED | was published, was replaced by a later one |
REJECTED | rejected in review. A new attempt is a new revision |
RETIRED | was published, was withdrawn without a successor |
The draft is not a state of a revision. It sits next to the chain and only becomes a revision when you submit it.
The actions
When: the draft is ready
-
1Client→CRMS
POST /crms/variants/{variant}/submitwith{"reason": "…"} -
2CRMSchecks the write role of the scope and the reason
-
3CRMSis there no draft? → 409
template-draft-missing -
4CRMSlets the renderer parse the source. Syntax error → 422
template-source-rejectedwithviolations -
5CRMSis another revision already in review? → 409
template-review-pending -
6CRMS→Databaseappends an entry
REVISION. If the scope has no approval stages,PUBLISHEDfollows right awayResult: new revision,IN_REVIEWorPUBLISHED
When: a revision is in review
POST /crms/revisions/{revision}/approve. Whoever has the role of the current stage approves. After the last stage, the revision is published. All rules are in Approval chains.
Result: entry APPROVED, after the last stage also PUBLISHED
When: a revision in review is not right
POST /crms/revisions/{revision}/reject with a reason. Here the reason is always required. Whoever rejects needs the role of the current stage. The draft stays; you keep working in it and submit again.
Result: entry REJECTED, the revision stays rejected
When: an earlier version should apply again
POST /crms/variants/{variant}/rollback with {"target": "<revision>", "reason": "…"}. The target must be SUPERSEDED or RETIRED. CRMS creates a new revision with the old content and publishes it at once, without approval stages: the content was approved before.
Result: new revision with rollbackOf, PUBLISHED. If there was a published one so far, it becomes SUPERSEDED
When: the variant should not render at all anymore
POST /crms/variants/{variant}/retire with a reason. The published revision becomes RETIRED, and the variant has no published revision anymore. Without a published revision, CRMS answers 409 template-not-published.
Result: entry RETIRED, answer 204
Submit, roll back and retire need the write role of the scope, see How a template is built. Who may approve and reject is set by the approval chain, see Approval chains.
Continuing from an old state
POST /crms/revisions/{revision}/draft with {"expectedVersion": 3} copies the content of any revision into the draft of its variant. This way you continue after a rejection or from an older state without typing everything again.
The reason
A reason is a short text saying why something happens. It goes into the chain.
| Action | Reason required? | What happens without a reason |
|---|---|---|
| Reject | always | 422 template-reason-required |
| Submit, roll back, retire | yes (default) | 422 template-reason-required |
| Submit, roll back, retire | switched off | The action runs, the entry has no reason |
| Approve | – | An approval has no reason, it has a meaning (see Approval chains) |
You switch the requirement off with CODAMAI_CRMS_TEMPLATE_REASON_REQUIRED=false, in the hub with the field templateReasonRequired. A reason has at most 2000 characters, otherwise template-reason-too-long|2000.
The chain and its hashes
A hash is a fingerprint: a short string that changes completely as soon as one character of the content changes. CRMS uses SHA-256 and writes a hash as sha-256:<hex>.
- The content hash of a revision is the hash of its source. Fields and assets are in the
REVISIONentry with their own hashes. - The hash of an entry covers everything the entry says, plus the content hash of the revision and the hash of the entry before it.
Because every entry includes the previous one, a changed, deleted or reordered row stands out. And because an approval also carries the content hash, it binds exactly the content that was approved.
GET /crms/variants/{variant}/trail[
{
"sequence": 1, ← position, without gaps
"type": "REVISION",
"revision": "0b7e…",
"actor": "anna", ← from the token
"at": "2026-10-17T08:30:00.123456Z",
"reason": "New logo",
"attributes": { "number": 1, "scope": "TENANT",
"locale": "de", "fields": [ … ],
"assets": [ … ], "approval": { … }, … },
"contentHash": "sha-256:9f86…",
"hash": "sha-256:41c2…"
},
{
"sequence": 2,
"type": "APPROVED",
…
"previousHash": "sha-256:41c2…", ← hash of entry 1
"hash": "sha-256:a07d…"
}
]| Call | Effect |
|---|---|
GET /crms/variants/{variant}/revisions | all revisions with state, number, author, reason, hash, stages and approvers, oldest first |
GET /crms/variants/{variant}/trail | all entries of the chain, with every hash, to recompute it |
POST /crms/variants/{variant}/verify | CRMS recomputes the chain. Answer {"intact": true} or {"intact": false, "brokenAt": 4} with the first entry that does not match |
GET /crms/revisions/{revision}/content | source, fields and described assets of a revision |
Every entry also goes into the journal of the tenant, for system templates into the platform’s. So the proof also lives outside the template tables. More in The hash chains.