content-block-manager: 9. Use domain events to record edition history
Date: 2026-01-23
Status: Accepted
Context
The Content Block Manager currently relies on the Edition::HasAuditTrail concern and the Version model to
generate the history timeline for a block.
This approach records a callback when a non-draft edition is updated. To display the timeline, the system inspects
the Version and attempts to derive context by looking at the associated Edition (version.item).
The Problem
This architecture assumes the Edition's current state reflects the state at the time of the versioning. This causes two major issues:
-
Mutable State vs. Immutable History: Relying on
version.item(the current record) is brittle. For example, if an edition is "Scheduled" but then edited to be "Published" immediately, thescheduled_publicationtimestamp is cleared on the Edition. This causes the timeline history for the "Scheduled" event to error, as the data it relies on no longer exists. -
Loss of Intermediate Context: We cannot accurately track multiple cycles of the same state. If a block goes
through multiple rounds of 2i or Factcheck, the
Versiononly points to the final outcome stored on the Edition, losing the history of previous approvals or rejections.
Currently, we attempt to workaround this with logic like:
def review_outcome
return unless version.state == "awaiting_factcheck"
# This relies on the current edition state, which may have changed since
# the version was created
skipped_or_performed = version.item.review_skipped ? "skipped" : "performed"
"2i review #{skipped_or_performed}"
end
Decision
We will introduce a new DomainEvent model to explicitly record significant actions in an Edition's lifecycle. This
decouples the historical record (what happened and when) from the current state (what the edition looks like now).
Schema
A DomainEvent will capture the specific action (name) and a snapshot of relevant context (metadata) at the exact
moment the event occurred.
erDiagram
domain_events {
int id PK
int user_id
int edition_id FK
int document_id FK
string name
json metadata
datetime created_at
int version_id FK
}
editions {}
versions {}
domain_events ||--}o editions : "belongs to"
domain_events |o--|| versions : "(optionally) belongs to"
-
name: A machine-readable string describing the action in the format$object.$noun.$verb. For example, when sending to review,namewill beedition.draft.sent_to_review, and when creating a new draft,namewill beedition.draft.created -
metadata: A JSON payload containing snapshot data relevant to that specific event (see Metadata shape). -
version_id: An optional link. This allows us to maintain the existingVersionfunctionality for tracking field-level data changes (diffs), while theDomainEventhandles the semantic timeline.
Metadata shape
The shape of metadata will depend on the action that has taken place. For example - when a review has been performed:
{
"review": {
"performed_by": {
"name": "Nigel Smith",
"email": "nigel@example.com"
},
"recorded_by": {
"name": "Ian Editor",
"email": "editor@example.com"
},
"recorded_at": "2026-01-29 13:29"
}
}
Ian is the logged in user, who has recorded that Nigel performed the 2i review.
And when a fact check has been skipped:
{
"fact_check": {
"skipped_by": {
"name": "Ian Editor",
"email": "editor@example.com"
},
"skipped_at": "2026-01-29 13:29"
}
}
Ian has recorded that he's skipped the fact check step.
Additionally, when an Event is tied to a Version, we should also record the previous state and the new state. For example, when sending to review, the metadata should include:
{
"previous_state": "draft_complete",
"new_state": "awaiting_review"
}
Usage Example
When a fact-check is performed, we create an DomainEvent:
-
Name:
factcheck_performed -
Metadata:
{ "performed_by": "Jane Doe" }
The UI can then render the description using I18n and the frozen metadata, without querying the Edition:
# Pseudo-code
I18n.t(
"events.edition.fact_check.performed",
user: event.metadata["review"]["performed_by"]["name"],
date: event.created_at
)
# => "Factcheck performed on 23 Jan by Jane Doe"
Consequences
Implementation
We will update the application to generate DomainEvent records for the following actions:
| action | event name | associated version's state |
|---|---|---|
| Brand new block created | document.draft.created |
- |
| New edition created for existing block | edition.draft.created |
- |
| Draft deleted | edition.draft.deleted |
-> deleted
|
| Draft completed | edition.draft.completed |
draft -> draft_complete
|
| Draft sent to review | edition.draft.sent_to_review |
-> awaiting_review
|
| Review skipped | edition.draft.review_skipped |
- |
| Review performed | edition.draft.review_performed |
- |
| Draft sent to fact-check | edition.draft.sent_to_factcheck |
-> awaiting_factcheck
|
| Fact-check skipped | edition.draft.fact_check_skipped |
- |
| Fact-check performed | edition.draft.fact_check_performed |
- |
| Draft scheuled for publication | edition.draft.scheduled |
-> scheduled
|
| Scheduled draft published | edition.schedule.executed |
- |
| Draft block publihsed | edition.draft.published |
-> published
|
We will also modify the has_audit_trail concern to generate a DomainEvent alongside the existing Version when an
edition is saved.
Migration & Backfill
- Existing
Editions must be backfilled. We will write a migration script to generateDomainEventrecords based on existingVersionhistory. -
Note: Perfect fidelity may not be possible for all historical actions where metadata was previously lost
(e.g., overwritten 2i outcomes), but we will approximate based on available
Versiondata.
Presentation
- The
TimelineItemComponentwill be updated to read from theDomainEventstream rather thanVersionrecords. - During the transition period, we may need to support both sources until the backfill is verified.