Event sourcing
Opt-in event sourcing, per content type
A design plan. Steps 1 and 2 below are implemented; steps 3 to 5 are not, and the open decisions
at the end are settled in DECISIONS.md rather than here.
What has landed: every content event now carries the fields a rebuild needs, including Sensitivity,
ContentScheduled and ContentSensitivityChanged; and every write path goes through
IContentWriter. The prerequisite gap described under "the events are not complete enough yet" is
therefore closed, and the section is kept because the reasoning still explains why the shape is what
it is.
What is being proposed
A content type declares once, at creation, whether its content is event sourced. That choice cannot be changed afterwards.
- Not event sourced (today's behaviour, and the default): the
Contentdocument is the source of truth. Events are still emitted for workflow, integration and audit, but nothing rebuilds state from them. - Event sourced: the stream is the source of truth. The
Contentdocument still exists and is still what everything reads, but it is produced by a projection and can be discarded and rebuilt.
Per content type, not per document. A per-document choice would mean two documents of the same type answering the same query with different consistency guarantees, and no way to reason about a type as a whole.
The idea that keeps this small
The read model does not change. Only its provenance does.
Everything downstream reads Content documents: the Delivery API, the admin UI, search and
SearchText, the sitemap, public feeds. If event-sourced types produced a different shape, or no
document at all, every one of those forks into two code paths.
So in both modes a Content document is written, in the same transaction, with the same shape. The
difference is who owns it:
| Source of truth | Who writes the document | Can it be rebuilt? | |
|---|---|---|---|
| Default | the document | the write handler | no |
| Event sourced | the stream | an inline projection | yes |
That last column is the whole feature. If the document cannot be deleted and rebuilt from the stream, the type is not event sourced, whatever the flag says.
Prerequisite: the events are not complete enough yet
This is a gate, not a task. Per-type event sourcing cannot ship until it is closed, because flipping a type today would silently lose data on the first rebuild.
Three fields never reach an event
Content has 11 properties. Three of them are on no event at all:
| Field | Carried by |
|---|---|
Id, ContentType, Data, Status, SearchText |
ContentCreated |
Data, SearchText |
ContentUpdated |
Status |
ContentStatusChanged |
CreatedAt, UpdatedAt |
derivable from the event timestamp |
LastModifiedBy |
CreatedBy / UpdatedBy |
Sensitivity |
nothing |
ScheduledPublishAt |
nothing |
ScheduledUnpublishAt |
nothing |
Sensitivity is the serious one. It drives field-level redaction, so a rebuild that loses it does
not produce a broken record, it produces a record that looks fine and is readable by people who
should not see it. That is a security regression that no test asserting "the document came back"
would catch.
Two write paths emit no event at all
| Path | What it writes | Emits an event? |
|---|---|---|
Content/Create/Endpoint.cs |
the document | yes |
Content/Update/Endpoint.cs |
the document | yes |
Content/ChangeStatus/Endpoint.cs |
the document | yes |
Content/History/RollbackEndpoint.cs |
the document | yes |
Content/Schedule/Endpoint.cs |
the document | no |
Workflows/Actions/CreateTaskAction.cs |
a whole new document | no |
The workflow action is the more dangerous of the two, because it creates content rather than amending it. An event-sourced type whose documents can be created by a path that appends nothing has streams that do not exist for documents that do.
What closing the gate requires
ContentScheduledandContentSensitivityChangedevents, or additional fields on the existing ones with upcasters for the old shapes.- Every one of the six write paths appends. Enforced by a test that fails when a new write path is added without one, not by review.
- A test that maps every settable property of
Contentto the event that carries it, and fails when a property is added without one. Same shape as the descriptor-property-count guard in the Mapsicle work: it exists to fail on a future change, not to describe today.
Where the decision lives
One place. The routing decision must not appear in six endpoints.
That is the exact failure the Mapsicle conversion cascade had: the same rule written three times, and the copies drifted until two defects lived in all of them and a third lived in one. Six copies of "is this type event sourced" would drift the same way, and the drift would be invisible because both branches produce a valid-looking document.
Features/Content/*/Endpoint.cs ─┐
Workflows/Actions/*.cs ─┼─→ IContentWriter ─┬─→ document mode: Store(content), append event
┘ └─→ stream mode: Append(event) → inline projection
CLAUDE.md says not to add a service reflexively. This one is justified on its own terms: the logic
is genuinely shared across six call sites and complex enough to test alone.
The endpoints keep their slice structure and lose the persistence detail. They ask for a content change to be recorded; they do not decide how.
The projection
A Marten SingleStreamProjection<Content>, stream identified by the content Id.
Inline, not async. This is effectively forced rather than chosen:
- The admin UI saves and immediately reads. Async projection means read-after-write staleness, which presents as "my edit did not save" to an editor.
- An inline projection failure fails the write, which is what you want: better a failed save than a stream and a document that disagree.
- The async daemon is registered
Solo, which processes on a single node. A multi-instance deployment would needHotCold, so async is not currently available at scale anyway.
The cost is that every write now does two things in one transaction. That is the price of the feature and it should be stated in the docs, not discovered.
Constraints
These are the things that make the feature harder than it looks. Each one needs a decision before implementation, not during.
1. The flag is immutable, and immutability must be enforced
Not documented. Enforced, with a test.
Why it cannot be turned on later. There is no history to rebuild from. You would have to synthesise a genesis event from the current document, and the stream would then assert a history that did not happen. Every audit answer derived from it would be wrong in a way nobody can detect.
Why it cannot be turned off later. The stream is the record. Turning it off discards history that callers may be relying on for audit or compliance, and the discarding is unrecoverable.
Enforcement:
- The content type update endpoint rejects any request that changes the flag, with a message that says why rather than "invalid request".
- A test asserts the rejection, and a paired test asserts that an update which does not touch the flag still succeeds. Otherwise "reject everything" passes.
2. Delete-and-recreate is a hole in that immutability
Nothing stops someone deleting a content type and recreating it with the same name and the opposite flag. Existing documents and streams then belong to a type whose rules have changed.
Decide one of:
- Content type names are never reusable after deletion.
- Deleting a content type with existing content is refused.
- Recreation inherits the original flag, which means deletion is a soft delete that remembers it.
This needs answering. It is the obvious way around the constraint and someone will find it.
3. Right to erasure conflicts with an immutable stream
A CMS stores user-defined schemas. Those will contain personal data, whatever the documentation suggests. An immutable event stream and a legal obligation to erase are in direct conflict.
Options, in increasing cost:
- Document the limitation. Event-sourced types are unsuitable for personal data. Cheapest, and it will be ignored by someone.
- Tombstone and rebuild. Erasure appends a redaction event and rebuilds; the payload remains in earlier events, so this is not true erasure.
- Crypto-shredding. Event payloads encrypted per subject; erasure throws away the key. Real erasure, and the largest change.
This must be decided before the first person stores personal data in an event-sourced type, not after. It is not reversible once data exists.
4. Event schema evolution becomes load-bearing
While the document is authoritative, an event shape change is cosmetic. Once the stream is the source of truth, an event shape change breaks rebuild for every stream ever written.
ContentCreated and ContentUpdated already carry [Obsolete] constructors, so the shape is
already moving. Upcasters are required from the first event-sourced type, not added when it first
hurts.
5. Storage grows without bound, and rebuild time grows with it
Every update appends forever. There is no compaction without snapshotting. Two consequences worth stating in the documentation rather than leaving to be discovered:
- A high-churn content type is a poor candidate. A type edited hundreds of times a day accumulates faster than its value justifies.
- Rebuild is an operation with a duration. At some stream count it stops being something that fits in a deploy window, and that point arrives without warning.
Snapshotting is the mitigation, and it is a later feature, not part of this one.
6. Tenancy correctness is a security property here
Events.TenancyStyle is Conjoined, so events are tenant-partitioned. A rebuild must therefore
rebuild per tenant and must not read across tenants.
A rebuild that crossed tenants would write one tenant's content into another's documents, which is a data breach rather than a bug. This needs an explicit test with two tenants, not an assumption that Marten handles it.
7. Concurrency semantics differ between the two modes
Document mode is last-write-wins. Stream mode can use Marten's expected-version checking to reject a write based on a stale read.
That is a behaviour difference between two types in the same API, so decide deliberately:
- Match document mode, and accept last-write-wins for consistency of behaviour.
- Use expected-version, and accept that event-sourced types can return a conflict that other types never return.
Either is defensible. What is not defensible is arriving at one by accident.
8. The workflow action is easy to forget
CreateTaskAction creates content outside the Content feature slices. It has to go through the same
writer. A test should create content through a workflow action against an event-sourced type and
assert a stream exists.
How you know it works
One test decides whether the feature is real:
- Create content of an event-sourced type. Update it, change its status, schedule it, set its sensitivity.
- Delete every
Contentdocument for that type, leaving only the streams. - Rebuild the projection.
- Assert the documents come back identical across all eleven properties, including
Sensitivityand both scheduling fields.
If that fails, the type is not event sourced regardless of the flag.
Then the paired control, so the assertion is not vacuous: run the same test against a non-event-sourced type and assert the documents do not come back. A rebuild that appears to restore both modes means something else is writing the documents and the test is proving nothing.
Also worth having:
- Every settable property of
Contentis carried by some event. Fails when a property is added. - Every write path appends. Fails when a write path is added.
- The flag cannot be changed, with a paired test that unrelated updates still succeed.
- Two tenants, rebuild, no crossing.
Suggested order
Each step is shippable and leaves the system working.
- Close the event gap. The three fields and the two silent write paths, plus the two guard tests. Ships on its own merit: it fixes the audit trail whether or not this feature happens.
- Route every write through one writer, still document mode only. No behaviour change, and it should be provable by the existing suite staying green.
- Add the flag, defaulting to false, immutable, with the rejection test. Still nothing reads it.
- Add the projection and the stream path, gated behind the flag. Now the acceptance test above can be written.
- Documentation: what the choice means, that it is permanent, and the personal-data limitation.
Steps 1 and 2 are worth doing regardless of whether this feature ships, which makes them safe to start before the constraints above are all decided.
Open decisions
All four are answered. They were one-way doors, which is why they were listed here rather than left to the implementation.
- Delete-and-recreate of a content type: the original flag is inherited. The policy is keyed by the type name and written once, so recreating a name cannot arrive at the opposite answer. Decided in #230.
- Personal data: refuse the combination. An event-sourced type may not hold non-Public fields.
Decided in #230, and consistent with D9 in
DECISIONS.md, where erasure is a delete rather than a tombstone or a shred. - Concurrency: expected-version with a 409, for event-sourced types only. Decided in #230.
- History stays exposed. The event records stay internal. What separates them is a mapping layer, not the absence of an endpoint, and a deployment can turn history off. Decided 30 Aug 2026, before #331.
On decision 4
The question as originally written offered two options, expose the history or keep the stream internal, and treated them as opposites. They are not, and the code already shows why.
GET /api/contents/{id}/history exists today, and it already reads the event stream through
FetchStreamAsync. It does not expose the event records. It maps ContentCreated and
ContentUpdated onto VersionResponse, a DTO the endpoint owns, and applies the same
document- and field-level sensitivity that Get and List apply. So the exposure the question
worried about was never on the table: a mapping layer sits between the stream and the wire, and it
is that layer, not the absence of the endpoint, that leaves the event shapes free to evolve.
This matters because it is the same separation IContentWriter gives the write path. Events are an
internal representation on both sides. Adding a field to ContentUpdated changes what the mapper
reads and nothing a client sees.
So the rule to hold is narrower than "no history", and it is worth stating as a rule because the
tempting shortcut breaks it: an endpoint may read events and must not return them. Serialising
an event record straight to the wire, or returning IEvent and letting the serializer decide, is
what would freeze the shapes under CLAUDE.md section 6.
The config flag. History:Enabled, defaulting to on. It was decided here and has not been
built: nothing reads that setting, and GET /api/contents/{id}/history answers in every
deployment. What follows is the reasoning for it. It does not exist to protect the event
shapes, because the mapping already does that and a flag could not: under section 6 the surface
freezes when it ships, not when it is switched on, so any deployment that could turn it on makes the
shape a commitment. It exists for a different and better reason. History serves prior values,
including ones since corrected, and a field that is Public today was not necessarily Public when an
old version was written. Sensitivity is applied from the content's current schema, so a field
reclassified as Sensitive is filtered out of old versions correctly, but a deployment holding
personal data may still want the whole surface gone rather than reasoning about that. Turning it off
is the answer to a question a privacy reviewer will ask, and the same fail-closed shape as
Erasure:Mode in D9.
A gap this uncovered. The mapper handles two of the five content events, and returns null for
the rest. ContentStatusChanged, ContentScheduled and ContentSensitivityChanged are silently
absent, so publishing a document never appears in its own history. That is a defect in document mode
today, not something #331 introduces, and it is filed separately.