AI Research Library

Event-Sourced Case State: Reconstruct the Case, Not the Transcript

Rajesh Ranjan Mahapatra

A consequential case is replayable only when its log preserves the causal commitments linking intent, evidence, authority, action and verified outcome. Conversation history and database mutations alone cannot establish that chain.

© 2026 Rajesh Ranjan Mahapatra. 33 min read

Event-Sourced Case State: Reconstruct the Case, Not the Transcript

A consequential case is replayable only when its log preserves the causal commitments linking intent, evidence, authority, action and verified outcome. Conversation history and database mutations alone cannot establish that chain.

TLDR

  1. Case state should be a projection of typed domain events when an organisation must later explain how a consequential outcome came to exist, not merely what the latest row contains.
  2. The replay mechanism is a deterministic fold over an ordered stream that records intent, identity, evidence versions, decisions, authority, requested actions, effect receipts and independent outcome observations.
  3. Replay must rebuild state without calling external systems. Commands, acknowledgements and readbacks are separate events, so a timeout becomes an explicit unknown outcome rather than permission to repeat an effect.
  4. Event sourcing fails when event meaning drifts, external effects lack identifiers or readback, privacy rules forbid indefinite retention, or the chosen case boundary mixes several independently changing aggregates.
  5. Use the pattern only when causal reconstruction changes a real decision. For point-in-time row history, temporal tables or change data capture are often simpler and more honest.

The row that could not answer the complaint

Imagine two hardship cases. At 10:30 on Friday, both case tables contain the same fields: arrangement_status = approved, collections_suppressed = true, and review_owner = A17. A dashboard cannot distinguish them. A point-in-time query cannot distinguish them. Yet only one case is defensible.

In the first case, a customer requested help, a verified income document entered the evidence set, a model proposed an arrangement, a caseworker approved it under a named policy, the case system accepted a suppression command, and a separate readback confirmed that collections had stopped. In the second, an old recommendation was retried after an API timeout. The database row eventually reached the same values, but nobody can tell whether the first request succeeded, whether the retry duplicated an effect, or which evidence and authority governed the change.

The difference is not the final state. It is the causal path by which that state acquired institutional meaning. A snapshot can answer “what values are stored?” It cannot reliably answer “who intended this, what did they know, who was allowed to decide, what was sent, what actually happened, and what remains unresolved?”

The core answer is to persist the case as a sequence of causal commitments and treat every convenient state view as replaceable. The log is not a transcript of everything said. It is the smallest ordered record from which a reviewer can reconstruct the case, explain each material transition and identify any gap between command and outcome.

This question is narrower than the companion article on governed context reconstruction. That article asks what evidence should be assembled for a decision now. This one asks what must survive after the decision so the case can be replayed later without inventing history or repeating effects.

Identical state, incompatible histories Two causal paths converge on the same final database fields. The upper path preserves evidence, authorisation and outcome verification. The lower path contains a stale recommendation, timeout and unverified retry. History A: defensible intent verifiedevidence humanauthority effectreceipt History B: ambiguous staleproposal timeout blindretry noreadback same row approved suppressed owner=A17 different claim Snapshots preserve values. Case ledgers preserve legitimacy and uncertainty.
Figure 1. Identical state, incompatible histories. Illustrative. Both paths end with the same fields. Only the upper path carries a reviewable chain from evidence and authority to a verified outcome.

Part I

A snapshot is a projection, not the case

Event sourcing begins with a reversal. In an ordinary state-based design, the row or document is primary and history is supporting material. In an event-sourced design, the ordered events are primary and the current state is calculated from them. Martin Fowler's foundational account describes storing every change as an event and reconstructing past states from the sequence. He also makes the stronger point that an audit log alone is not event sourcing: domain changes must be initiated through the events themselves.1

For a case, that reversal matters because a case is more than an entity's latest properties. It is a bounded institutional undertaking with a subject, purpose, evidence, decisions, duties and effects. The same customer can have several cases. The same document can support several cases. The same external action can affect more than one system. A case stream therefore needs a deliberate boundary, usually one material decision journey or obligation, rather than a convenient database table.

The state at sequence n can be written as:

Here S₀ is the known initial state, each eᵢ is an ordered event, and Fᵥ is the versioned reducer that interprets those events. Adding an event changes the projection. Changing the reducer version can also change it, which is why event evolution is part of the audit claim rather than a later implementation detail.

Thought experiment: which half can you lose?

Take a complete case system and make two copies. In Copy A, delete every read model, dashboard row and cached summary, but retain the event stream and reducer versions. In Copy B, retain every current row and snapshot, but delete the events. Which copy can recover?

Copy A can rebuild its projections. It may take time, and a broken reducer may delay the work, but the information needed to derive state remains. Copy B cannot reconstruct why its values exist. No algorithm can recover a missing approval, evidence version or failed action from the final boolean alone. Replayability is an information property before it is a database feature.

This experiment also shows why “we log everything” is weak assurance. Logs often record technical activity after the application has already mutated state. They may omit rejected commands, authority decisions, evidence versions or the difference between an accepted request and an observed effect. A verbose log can still be causally incomplete.

Event strata reconstructing case state Seven horizontal strata represent intent, evidence, decision, authority, action, effect and outcome events. A vertical replay cursor folds them into progressively richer case projections. The case is layered history; state is the cross-section Intent and identity Evidence and observed world state Proposal and decision Authority and policy Typed action request Effect acknowledgement or unknown outcome Independent readback and recovery replay cursor Projected state purpose: hardship evidence: reviewed decision: approved authority: valid action: requested effect: accepted outcome: verified rebuildable view
Figure 2. Event strata reconstructing case state. Conceptual and mandatory signature figure. The layers are typed commitments, not screen activity. The current state is one cross-section through the ordered stream.
Published evidence

An empirical study of 19 event-sourced systems, based on interviews with 25 engineers, found genuine benefits alongside recurring liabilities: schema evolution, projection rebuilding, privacy, tooling and a steep learning curve.2 That evidence argues against treating the pattern as a default.

Part II

The argument in one passChart · flow
flowchart TD
  A["The row that could not answer<br/>the complaint"] --> B["A snapshot is a projection,<br/>not the case"]
  B --> C["The smallest replayable record<br/>is causally closed"]
  C --> D["Replay must reconstruct state<br/>without repeating effects"]
  D -. "re-test" .-> A
  classDef first fill:#0a1424,stroke:#B96D00,stroke-width:2px,color:#e6f3ff
  classDef step fill:#0a1424,stroke:#3f7da0,stroke-width:1px,color:#e6f3ff
  class A first
  class B,C,D step
A structural route through Event-Sourced Case State: Reconstruct the Case, Not the Transcript, from The row that could not answer the… to Source ledger. Reading use: follow the relation, then test it against the surrounding evidence and limits.

The smallest replayable record is causally closed

“Store every event” sounds precise until a team asks what counts as an event. Mouse clicks, database updates, model tokens, tool calls, policy evaluations and customer statements are all occurrences. Recording all of them would be expensive and still might omit the one relation that matters. Minimality therefore cannot mean the fewest bytes or the smallest number of rows.

A case log is minimally replayable when it is closed under the questions that a material transition creates. For every consequential state change, the stream must let a reviewer answer five questions without consulting mutable prose: why did the case act, what evidence did it rely on, who decided under which authority, what effect was requested, and what outcome was independently observed?

Those questions expand into ten distinct control objects: intent, identity, world state, context, reasoning, authority, action, evidence, outcome, and release or recovery. Some objects may share an event, but they should not be collapsed conceptually. A model proposal is not an approval. An API acknowledgement is not a verified business outcome. A document hash is not the document's truth. A conversation is not the world state.

An event as a causal specimen A cutaway diagram shows an event with outer integrity metadata, causal links, actor and time, typed business payload, and references to evidence, authority and effects. One event needs more than a timestamp and payload event Integrity envelope event id · sequence previous hash · schema Causal placement case · correlation causation · supersession Responsibility and time principal · role · purpose occurred · recorded · valid Typed business payload a fact, decision or effect named in domain language External references evidence version and hash authority, receipt, readback The payload says what happened. Surrounding fields explain why it can be trusted and replayed.
Figure 3. An event as a causal specimen. Practitioner synthesis. Transport metadata, business meaning and provenance have different jobs. A CloudEvents-compatible envelope can carry the record, but application extensions must supply the case-specific causal fields.

Seven event strata

A practical case stream can use seven strata. They are not mandatory class names. They are a test for missing meaning.

StratumWhat it preservesTypical eventReplay question
Intent and identitySubject, requestor, purpose and case boundaryCaseOpenedWhy did this undertaking begin, and for whom?
Evidence and world stateAccepted source version, hash, valid time and verification statusEvidenceAcceptedWhat was knowable and permitted at the decision point?
Proposal and decisionModel proposal, human disposition, calculations and cited evidenceDecisionProposedWhat judgement was made, and from which material?
AuthorityPrincipal, role, policy version, limits and confirmationDecisionAuthorisedWho had the right to commit the case?
ActionTyped contract, parameters, preconditions and idempotency keyActionRequestedWhat exact effect was requested?
Effect evidenceRemote acknowledgement, receipt, timeout or rejectionEffectAcknowledgedWhat did the target system report?
Outcome and recoveryIndependent readback, postcondition, compensation or escalationOutcomeObservedWhat business state now exists, and what remains unresolved?

A compact replayability test

The fold equation is necessary but insufficient. Almost any ordered log can be folded into something. A stronger test asks whether a stream E and reducer F satisfy three conditions together:

Deterministic means the same retained stream, initial state and reducer version yield the same projection. Closed means each material transition can resolve the intent, evidence, decision, authority, action and outcome relations that it depends on, or mark the missing relation unresolved. Isolated means projection replay cannot call an effectful adapter. A change to any term changes the assurance claim. Determinism without closure reproduces an unexplained state. Closure without isolation may duplicate a live effect. Isolation without the other two safely rebuilds an unreliable view.

This is a semantic test rather than a storage-product feature. A relational table of typed events can pass it. A specialised event store can fail it if the events are technical mutations, reducer behaviour is nondeterministic or replay reaches a live tool. Hashes, sequence checks and signatures support integrity, but none proves that the right causal relations were recorded.

Minimal worked example: one approval, one effect

Consider a synthetic request for 24-hour read access to a restricted report. CaseOpened records the employee, requestor and declared purpose. EvidenceAccepted references the current role assignment and completed training record. DecisionAuthorised records a named approver, the applicable access policy and an expiry no later than the requested period.

The authority kernel emits ActionRequested with the report identifier, read-only scope, expiry, precondition and one idempotency key. The access service returns EffectAcknowledged with its grant identifier. A separate entitlement query produces OutcomeObserved, showing the exact scope and expiry now active. Folding those events yields an active, time-bounded grant and a closed evidence chain.

Change only the last event. Suppose the readback finds no grant. The command receipt remains true, but the business outcome is contradicted. Replay must project OUTCOME_CONTRADICTED or an equivalent recovery state, not infer success from the acknowledgement. The example is small enough to inspect by hand, yet it contains the decisive separation between requested effect, target receipt and verified postcondition.

References, not copies, for durable evidence

The event stream should usually retain evidence identifiers, immutable versions, content hashes and access classifications rather than duplicate every document. That keeps the stream compact and avoids turning it into an uncontrolled archive of personal data. Replay then resolves a protected evidential copy or versioned source. Where the source can change without history, a retained evidential snapshot is necessary, otherwise “replay” silently consults today's document.

W3C PROV provides a useful general vocabulary here. It separates entities, activities and agents, then links use, generation, derivation and responsibility.3 A case event can map evidence to an entity, a decision or action to an activity, and a person, model or service to an agent. The mapping helps interoperability, but it does not decide which business transitions are material. That remains a domain design decision.

CloudEvents solves a different problem. Its specification defines a vendor-neutral event format and requires core context such as id, source, specversion and type.4 Those fields help routing and tooling. They do not, by themselves, record delegated identity, evidence version, policy authority, idempotency or observed outcome. A standard envelope makes events portable; it does not make a case replayable.

The causal closure test Five questions form a closed ring around a replayable case: why it started, what was believed, who authorised, what was requested, and what was verified. A gap in outcome verification breaks the ring. Replayability is closure under five questions replayable case Why did itstart? What wasbelieved? Whoauthorised? What wasrequested? What wasverified? A broken segment need not invalidate the case. It must remain visible as unresolved state.
Figure 4. The causal closure test. Conceptual. Each material transition creates questions that the stream must close. Missing outcome evidence should produce an explicit unresolved state, not a fabricated success.

Thought experiment: change one causal feature

Hold the final case state constant and change only the evidence version. In History A, the decision cites a verified income statement valid on the decision date. In History B, it cites an older statement that had been superseded. The projected status can remain “approved” in both histories, yet the second decision may require review.

Now restore the evidence and change only authority. The same recommendation is approved by a principal whose delegation expired an hour earlier. Again, the row remains unchanged. The legal or operational standing may not. These interventions expose why source version and authority belong inside the causal record rather than in separate logs joined during an incident.

The event should carry both occurred time and recorded time when delayed knowledge matters. A document may be valid from Monday but accepted into the case on Thursday. Replay of Wednesday's decision must use what was recorded by Wednesday, while a retrospective truth analysis may ask what was valid on Wednesday. Conflating the two can make a reasonable historic decision look negligent or a negligent one look reasonable.

Case boundaries and partial order

A case rarely owns every object it touches. A hardship undertaking may involve a customer, one or more accounts, documents, a payment arrangement and correspondence. Each object has its own authoritative history and may change concurrently. Forcing all of those changes into one global sequence creates a comforting but false story: timestamp precision is not causal order.

The case stream needs a strict local revision for commitments made by the case. Cross-system facts should be linked through identifiers, observed revisions and causal parents. An event recorded late is appended at the current case revision while retaining when its subject matter occurred and became valid. The projection can then answer two different questions: what the case believed after revision 18, and what was later learned to have been true at the time. It should not insert a late event into old storage positions and rewrite the audit surface.

Use total order only for transitions that compete for the same case commitment; use explicit causation for everything else. This keeps optimistic concurrency meaningful without pretending one clock can settle relationships among independently operated systems. It also exposes a design error early: when almost every external event must block the case stream, the aggregate boundary is probably too broad.

Practitioner synthesis

The proposed minimal envelope has four layers: ordered integrity, causal placement, responsible principal and time, plus a typed payload with external evidence or receipt references. A hash chain can reveal alteration, but only the domain rules can reject a perfectly hashed sequence that is causally nonsensical.

Part III

Replay must reconstruct state without repeating effects

The word replay hides three different operations. The first is projection replay: read recorded events and rebuild derived state. The second is decision replay: run a new or historic decision procedure against a frozen evidence set to compare what it would propose. The third is effect replay: send the original commands again. Only the first is safe by default.

Projection replay should be pure. Given the same initial state, ordered events and reducer version, it should produce the same projection without network calls, clock reads, random values or model inference. Temporal's durable execution documentation makes the same separation operationally: workflow history is replayed to restore execution state, while nondeterministic work and external calls live outside the replay path as activities or recorded side effects.5

Decision replay is valuable but needs a label. Re-running a model today does not reproduce what the model proposed then unless the exact model release, prompt, tool results and stochastic choices are available. Often the right goal is weaker: preserve the historic proposal artifact and use a new model only for counterfactual analysis. The comparison can reveal drift, but it should not overwrite the original case.

Effect replay should be prohibited unless the target contract is explicitly idempotent and the recovery plan has established the current external state. Rebuilding a projection must never send a letter, move money, update a limit or close a case.

Three meanings of replay Three tracks distinguish safe state projection, sandboxed counterfactual decision replay, and prohibited default effect re-execution. Three operations share one dangerous word Projection replay events → deterministic fold → state default Decision replay frozen evidence → old or new procedure → comparison sandbox Effect replay historic command → live target → possible duplicate effect stop The event stream records effects. It does not grant permission to reproduce them.
Figure 5. Three meanings of replay. Conceptual. Projection replay is deterministic state reconstruction. Decision replay is a labelled experiment. Effect replay is a live operation and requires a fresh authority decision.

The unknown outcome is first-class state

The most revealing failure occurs between a command and its response. The authority kernel sends suppress_collections(case-7, true). The target commits the update, but the network connection fails before the response arrives. The caller knows the request was sent and does not know whether the effect occurred. Retrying may be correct or may produce a duplicate, depending on the target.

Many systems collapse this condition into “error” and let generic retry logic proceed. An event-sourced case should record it as an unknown outcome. The action event keeps the idempotency key and requested postcondition. A later event can record the remote receipt, a readback, a compensation or an escalation. Until then, the projection says unresolved.

Exactly-once claims do not remove this boundary. Kafka's own design documentation separates publication durability from processing guarantees and warns that exactly-once claims require careful scope reading.6 A message can be written exactly once to a topic while a consumer still calls a non-transactional external API twice. Broker semantics cannot manufacture idempotency in another system.

Where an API supports idempotency keys, retries become safer because the target can recognise the same logical request. Stripe's public API, for example, stores the first result for an idempotency key and returns it to matching retries.7 This is an implementation example, not a universal guarantee. The case still needs the key, parameters, receipt and readback because idempotency windows expire and parameter mismatches must fail visibly.

Unknown outcome between command and readback A timeline shows an action request, a remote commit, a network timeout, a risky blind retry branch, and a safe readback branch using the same idempotency key. A timeout creates uncertainty, not evidence of failure requestkey K remote commitunseen timeoutresponse lost UNKNOWN OUTCOME blind retry new key duplicate risk readback or retry key K verified state Record the gap, preserve the key, observe the postcondition, then decide recovery.
Figure 6. Unknown outcome is a state, not an error string. Synthetic timeline. The safe branch uses a stable logical action identity and independent observation before deciding whether any further effect is authorised.

Worked scenario: a four-system hardship case

Worked scenario

A synthetic hardship case starts in a portal, gathers evidence from a document service, receives a recommendation from a model, requires a human approval, and changes collections treatment in a case system. The case stream does not copy each system's full history. It binds the decisive objects.

CaseOpened records the customer subject, requestor, purpose and case correlation. EvidenceAccepted names the verified income document, its immutable version, content hash and valid period. DecisionProposed records the proposed arrangement and the evidence identifiers it cites. The reasoning trace may be retained as a separate artifact with a hash; it is not treated as authority.

DecisionAuthorised records the approving principal, delegated role, policy version and disposition. Only then may the deterministic kernel emit ActionRequested with a typed contract, preconditions, parameters and idempotency key. The target returns an EffectAcknowledged receipt. A read-only query then produces OutcomeObserved, confirming that collections are suppressed in the authoritative case system.

If the acknowledgement is missing, the action remains pending or unknown. If readback contradicts the receipt, the stream preserves both. If a later correction changes a document, the old event is not rewritten. A new correction event points to the superseded item, and a new decision decides whether remediation is required. Corrections extend history; they do not edit the evidence that explains the original decision.

Three-ledger braid for a production-shaped case Three parallel tracks show the case event stream, versioned evidence objects, and external effect receipts. Links connect events to evidence and observed outcomes without copying every source into the case log. A replayable case binds three histories Case stream Evidence store External effects caseopened evidenceaccepted decisionauthorised actionrequested income v3hash H₃ policy 4.2effective T proposalartifact P receiptR-5001 readbackversion 8912 recoveryif needed Case events hold causal links; source systems retain authoritative detail.
Figure 7. Three-ledger braid for a production-shaped case. Synthetic architecture. The case stream binds evidence and effect histories through immutable identifiers, versions and hashes rather than pretending one database owns every truth.

Snapshots accelerate replay; they do not replace evidence

A long-lived case may contain thousands of events, so rebuilding every projection from the first event can be wasteful. A snapshot is a legitimate optimisation when it records the case revision, reducer version and stream head it represents. Replay verifies that checkpoint, loads the derived state, then folds only later events. If verification fails, the snapshot is discarded and reconstruction starts from an earlier trusted checkpoint.

The danger begins when a snapshot becomes the only retained explanation. It contains answers selected by one reducer, not the evidence and causal links from which those answers were derived. A new regulatory question, correction rule or projection may need information that the old snapshot omitted. Treating it as the primary record therefore recreates the weakness event sourcing was meant to remove.

A snapshot is a cache with a proof obligation. It should be reproducible from retained events, carry no independent authority, and be safe to delete. Production tests should periodically rebuild selected cases without snapshots and compare decision-relevant fields, unresolved outcomes and causal references. That exercise detects reducer drift and corrupt checkpoints before an incident makes reconstruction urgent.

Executable artefact: semantic replay verifier

The companion Python artefact uses only the standard library. It creates an append-only, hash-linked synthetic stream, validates ordering and event integrity, then folds the events into case state. The positive path ends with a verified outcome. The negative path is deliberately hash-valid but reuses an idempotency key with a materially different command, so semantic replay rejects it.

The example demonstrates three separations. First, the reducer performs no external calls. Second, the hash chain detects alteration but does not prove business validity. Third, cross-event invariants such as “an action needs an authorised decision” and “an idempotency key cannot name two different effects” are part of replay assurance.

Expected outputsynthetic data
POSITIVE: events=7 status=OUTCOME_VERIFIED unresolved=0 head=c30ed347adb8
NEGATIVE: rejected: idempotency key case-125-A:suppress:v2 reused with different action payload
Full runnable Python implementation and positive/negative cases
#!/usr/bin/env python3
"""Minimal event-sourced case ledger with semantic replay checks.

The example uses only Python's standard library and synthetic data.
It demonstrates two properties:
1. State is reconstructed by folding typed events without calling external systems.
2. A hash-valid log can still be semantically invalid, so replay checks causal rules too.

For production cryptographic interchange, replace this pedagogical canonical JSON
function with a conformant RFC 8785 implementation and use managed signing keys.
"""
from __future__ import annotations

from dataclasses import dataclass, field, asdict
from hashlib import sha256
import json
from typing import Any, Iterable


ALLOWED_TYPES = {
    "CaseOpened",
    "EvidenceAccepted",
    "DecisionProposed",
    "DecisionAuthorised",
    "ActionRequested",
    "EffectAcknowledged",
    "OutcomeObserved",
    "CorrectionRecorded",
}


class ReplayError(ValueError):
    """Raised when a ledger cannot support a sound replay."""


def canonical_json(value: Any) -> str:
    """Return deterministic JSON for this demonstration (not full RFC 8785)."""
    return json.dumps(
        value,
        ensure_ascii=False,
        sort_keys=True,
        separators=(",", ":"),
        allow_nan=False,
    )


def digest(value: Any) -> str:
    return sha256(canonical_json(value).encode("utf-8")).hexdigest()


@dataclass(frozen=True)
class Event:
    event_id: str
    case_id: str
    sequence: int
    event_type: str
    occurred_at: str
    recorded_at: str
    actor: dict[str, str]
    causation_id: str | None
    correlation_id: str
    payload: dict[str, Any]
    previous_hash: str
    event_hash: str = ""

    def unsigned(self) -> dict[str, Any]:
        data = asdict(self)
        data.pop("event_hash")
        return data

    def sealed(self) -> "Event":
        object.__setattr__(self, "event_hash", digest(self.unsigned()))
        return self


@dataclass
class CaseState:
    case_id: str
    status: str = "NEW"
    intent: dict[str, Any] | None = None
    evidence: dict[str, dict[str, Any]] = field(default_factory=dict)
    proposed_decisions: dict[str, dict[str, Any]] = field(default_factory=dict)
    authorised_decisions: set[str] = field(default_factory=set)
    actions: dict[str, dict[str, Any]] = field(default_factory=dict)
    effects: dict[str, dict[str, Any]] = field(default_factory=dict)
    outcomes: dict[str, dict[str, Any]] = field(default_factory=dict)
    corrections: list[dict[str, Any]] = field(default_factory=list)
    last_sequence: int = -1
    head_hash: str = "GENESIS"

    @property
    def unresolved_actions(self) -> list[str]:
        return sorted(set(self.actions) - set(self.outcomes))


class LedgerBuilder:
    """Build a correctly chained synthetic ledger."""

    def __init__(self, case_id: str, correlation_id: str) -> None:
        self.case_id = case_id
        self.correlation_id = correlation_id
        self._events: list[Event] = []

    def add(
        self,
        event_id: str,
        event_type: str,
        actor: dict[str, str],
        payload: dict[str, Any],
        occurred_at: str,
        *,
        causation_id: str | None = None,
        recorded_at: str | None = None,
    ) -> "LedgerBuilder":
        previous_hash = self._events[-1].event_hash if self._events else "GENESIS"
        event = Event(
            event_id=event_id,
            case_id=self.case_id,
            sequence=len(self._events),
            event_type=event_type,
            occurred_at=occurred_at,
            recorded_at=recorded_at or occurred_at,
            actor=actor,
            causation_id=causation_id,
            correlation_id=self.correlation_id,
            payload=payload,
            previous_hash=previous_hash,
        ).sealed()
        self._events.append(event)
        return self

    def events(self) -> list[Event]:
        return list(self._events)


def replay(events: Iterable[Event]) -> CaseState:
    """Validate and fold a case stream. This function performs no external effects."""
    stream = list(events)
    if not stream:
        raise ReplayError("empty stream")

    state = CaseState(case_id=stream[0].case_id)
    seen_event_ids: set[str] = set()
    idempotency_payloads: dict[str, str] = {}

    for expected_sequence, event in enumerate(stream):
        if event.case_id != state.case_id:
            raise ReplayError(f"mixed case stream at sequence {event.sequence}")
        if event.sequence != expected_sequence:
            raise ReplayError(
                f"sequence gap: expected {expected_sequence}, got {event.sequence}"
            )
        if event.event_id in seen_event_ids:
            raise ReplayError(f"duplicate event_id {event.event_id}")
        if event.event_type not in ALLOWED_TYPES:
            raise ReplayError(f"unknown event type {event.event_type}")
        if event.previous_hash != state.head_hash:
            raise ReplayError(f"broken hash link at sequence {event.sequence}")
        if digest(event.unsigned()) != event.event_hash:
            raise ReplayError(f"event hash mismatch at sequence {event.sequence}")
        if event.event_type != "CaseOpened" and not event.causation_id:
            raise ReplayError(f"{event.event_type} lacks causation_id")
        if event.causation_id is not None and event.causation_id not in seen_event_ids:
            raise ReplayError(
                f"{event.event_type} cites unseen cause {event.causation_id}"
            )

        p = event.payload
        if event.event_type == "CaseOpened":
            if state.status != "NEW":
                raise ReplayError("case can be opened only once")
            state.status = "OPEN"
            state.intent = p

        elif event.event_type == "EvidenceAccepted":
            evidence_id = require(p, "evidence_id", event)
            if evidence_id in state.evidence:
                raise ReplayError(f"duplicate evidence_id {evidence_id}; use a correction")
            require(p, "source_version", event)
            require(p, "content_hash", event)
            state.evidence[evidence_id] = p

        elif event.event_type == "DecisionProposed":
            decision_id = require(p, "decision_id", event)
            if decision_id in state.proposed_decisions:
                raise ReplayError(f"duplicate decision_id {decision_id}")
            evidence_ids = p.get("evidence_ids", [])
            missing = sorted(set(evidence_ids) - set(state.evidence))
            if missing:
                raise ReplayError(
                    f"decision {decision_id} cites missing evidence {missing}"
                )
            state.proposed_decisions[decision_id] = p

        elif event.event_type == "DecisionAuthorised":
            decision_id = require(p, "decision_id", event)
            if decision_id not in state.proposed_decisions:
                raise ReplayError(f"authorisation lacks proposal {decision_id}")
            require(p, "authority_ref", event)
            require(p, "policy_version", event)
            state.authorised_decisions.add(decision_id)

        elif event.event_type == "ActionRequested":
            action_id = require(p, "action_id", event)
            if action_id in state.actions:
                raise ReplayError(f"duplicate action_id {action_id}")
            decision_id = require(p, "decision_id", event)
            key = require(p, "idempotency_key", event)
            require(p, "expected_postcondition", event)
            if decision_id not in state.authorised_decisions:
                raise ReplayError(
                    f"action {action_id} lacks authorised decision {decision_id}"
                )
            action_fingerprint = digest(
                {k: v for k, v in p.items() if k != "idempotency_key"}
            )
            if key in idempotency_payloads:
                if idempotency_payloads[key] != action_fingerprint:
                    raise ReplayError(
                        f"idempotency key {key} reused with different action payload"
                    )
                raise ReplayError(f"duplicate action request for idempotency key {key}")
            idempotency_payloads[key] = action_fingerprint
            state.actions[action_id] = p
            state.status = "ACTION_PENDING"

        elif event.event_type == "EffectAcknowledged":
            action_id = require(p, "action_id", event)
            if action_id not in state.actions:
                raise ReplayError(f"effect acknowledgement lacks action {action_id}")
            require(p, "effect_receipt_id", event)
            state.effects[action_id] = p
            state.status = "EFFECT_UNVERIFIED"

        elif event.event_type == "OutcomeObserved":
            action_id = require(p, "action_id", event)
            if action_id not in state.actions:
                raise ReplayError(f"outcome lacks action {action_id}")
            require(p, "readback_source", event)
            observed = require(p, "observed_state", event)
            expected = state.actions[action_id]["expected_postcondition"]
            if not isinstance(expected, dict) or not isinstance(observed, dict):
                raise ReplayError("postcondition and observed_state must be objects")
            state.outcomes[action_id] = p
            state.status = (
                "OUTCOME_VERIFIED"
                if all(observed.get(key) == value for key, value in expected.items())
                else "OUTCOME_CONTRADICTED"
            )

        elif event.event_type == "CorrectionRecorded":
            supersedes = require(p, "supersedes_event_id", event)
            if supersedes not in seen_event_ids:
                raise ReplayError(f"correction targets unknown event {supersedes}")
            state.corrections.append(p)

        seen_event_ids.add(event.event_id)
        state.last_sequence = event.sequence
        state.head_hash = event.event_hash

    return state


def require(payload: dict[str, Any], field_name: str, event: Event) -> Any:
    if field_name not in payload or payload[field_name] in (None, ""):
        raise ReplayError(
            f"{event.event_type} at sequence {event.sequence} lacks {field_name}"
        )
    return payload[field_name]


def positive_case() -> list[Event]:
    human = {"principal_id": "user-104", "role": "caseworker"}
    approver = {"principal_id": "approver-22", "role": "approver"}
    model = {"principal_id": "model-router-2", "role": "model_proposer"}
    kernel = {"principal_id": "authority-kernel", "role": "executor"}
    observer = {"principal_id": "case-readback", "role": "observer"}

    return (
        LedgerBuilder("case-125-A", "corr-125-A")
        .add(
            "e0",
            "CaseOpened",
            human,
            {
                "intent_id": "intent-1",
                "purpose": "assess hardship request",
                "subject_ref": "customer-synthetic-7",
            },
            "2026-08-01T09:00:00Z",
        )
        .add(
            "e1",
            "EvidenceAccepted",
            human,
            {
                "evidence_id": "ev-income-1",
                "source_ref": "document-store/doc-77",
                "source_version": "3",
                "content_hash": "sha256:synthetic-income-v3",
                "valid_time": "2026-07-01/2026-08-31",
                "verification": "reviewed",
            },
            "2026-08-01T09:08:00Z",
            causation_id="e0",
        )
        .add(
            "e2",
            "DecisionProposed",
            model,
            {
                "decision_id": "decision-1",
                "proposal": "approve_arrangement",
                "evidence_ids": ["ev-income-1"],
                "reasoning_artifact_hash": "sha256:synthetic-proposal",
                "model_release": "synthetic-model-r2",
            },
            "2026-08-01T09:09:00Z",
            causation_id="e1",
        )
        .add(
            "e3",
            "DecisionAuthorised",
            approver,
            {
                "decision_id": "decision-1",
                "authority_ref": "role-policy/hardship-approver",
                "policy_version": "HARDSHIP-4.2",
                "disposition": "approved",
            },
            "2026-08-01T09:14:00Z",
            causation_id="e2",
        )
        .add(
            "e4",
            "ActionRequested",
            kernel,
            {
                "action_id": "action-1",
                "decision_id": "decision-1",
                "contract": "suppress_collections:v2",
                "parameters": {"case_id": "case-125-A", "enabled": True},
                "expected_postcondition": {"collections_suppressed": True},
                "idempotency_key": "case-125-A:suppress:v2",
            },
            "2026-08-01T09:15:00Z",
            causation_id="e3",
        )
        .add(
            "e5",
            "EffectAcknowledged",
            kernel,
            {
                "action_id": "action-1",
                "effect_receipt_id": "receipt-5001",
                "remote_status": "accepted",
            },
            "2026-08-01T09:15:01Z",
            causation_id="e4",
        )
        .add(
            "e6",
            "OutcomeObserved",
            observer,
            {
                "action_id": "action-1",
                "readback_source": "case-system/case-125-A",
                "observed_state": {"collections_suppressed": True},
                "observation_version": "8912",
            },
            "2026-08-01T09:15:04Z",
            causation_id="e5",
        )
        .events()
    )


def negative_case() -> list[Event]:
    events = positive_case()[:5]
    builder = LedgerBuilder("case-125-A", "corr-125-A-negative")
    for e in events:
        builder.add(
            e.event_id,
            e.event_type,
            e.actor,
            e.payload,
            e.occurred_at,
            causation_id=e.causation_id,
            recorded_at=e.recorded_at,
        )
    # Same idempotency key, materially different command. The hash chain is valid,
    # but the semantic replay must reject it.
    builder.add(
        "e5-conflict",
        "ActionRequested",
        {"principal_id": "authority-kernel", "role": "executor"},
        {
            "action_id": "action-2",
            "decision_id": "decision-1",
            "contract": "suppress_collections:v2",
            "parameters": {"case_id": "case-125-A", "enabled": False},
            "expected_postcondition": {"collections_suppressed": False},
            "idempotency_key": "case-125-A:suppress:v2",
        },
        "2026-08-01T09:15:02Z",
        causation_id="e4",
    )
    return builder.events()


def main() -> None:
    good = replay(positive_case())
    print(
        "POSITIVE:",
        f"events={good.last_sequence + 1}",
        f"status={good.status}",
        f"unresolved={len(good.unresolved_actions)}",
        f"head={good.head_hash[:12]}",
    )

    try:
        replay(negative_case())
    except ReplayError as exc:
        print("NEGATIVE: rejected:", exc)
    else:
        raise SystemExit("negative case unexpectedly passed")


if __name__ == "__main__":
    main()
Replayable case event JSON Schema
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://rajeshrmahapatra.com/schemas/case-event.schema.json",
  "title": "Replayable case event envelope",
  "description": "A transport-neutral envelope for ordered, typed case events. Domain-specific payload and cross-event causal rules require additional validation.",
  "type": "object",
  "additionalProperties": false,
  "required": [
    "event_id",
    "case_id",
    "sequence",
    "event_type",
    "occurred_at",
    "recorded_at",
    "actor",
    "correlation_id",
    "payload",
    "previous_hash",
    "event_hash"
  ],
  "properties": {
    "event_id": {"type": "string", "minLength": 1},
    "case_id": {"type": "string", "minLength": 1},
    "sequence": {"type": "integer", "minimum": 0},
    "event_type": {
      "type": "string",
      "enum": [
        "CaseOpened",
        "EvidenceAccepted",
        "DecisionProposed",
        "DecisionAuthorised",
        "ActionRequested",
        "EffectAcknowledged",
        "OutcomeObserved",
        "CorrectionRecorded"
      ]
    },
    "occurred_at": {"type": "string", "format": "date-time"},
    "recorded_at": {"type": "string", "format": "date-time"},
    "actor": {"$ref": "#/$defs/actor"},
    "causation_id": {"type": ["string", "null"]},
    "correlation_id": {"type": "string", "minLength": 1},
    "payload": {"type": "object"},
    "previous_hash": {"type": "string", "minLength": 1},
    "event_hash": {"type": "string", "pattern": "^[0-9a-f]{64}$"}
  },
  "$defs": {
    "actor": {
      "type": "object",
      "additionalProperties": false,
      "required": ["principal_id", "role"],
      "properties": {
        "principal_id": {"type": "string", "minLength": 1},
        "role": {
          "type": "string",
          "enum": [
            "requestor",
            "caseworker",
            "model_proposer",
            "approver",
            "executor",
            "observer",
            "system"
          ]
        }
      }
    }
  }
}

For production cryptographic interchange, the script's compact JSON serialiser should be replaced with a conformant canonicalisation implementation. RFC 8785 defines a deterministic JSON representation suitable for repeatable hashing and signing.8 Managed keys, retention controls and independent verification remain separate requirements.

Part IV

The negative control: when a temporal table is enough

A useful architecture argument must contain a case where its preferred mechanism is unnecessary. Consider an internal reference table whose only material question is, “what value did this field contain at 14:00?” There is no independent approval, no evidence pack, no external effect and no need to distinguish proposal from execution. SQL Server system-versioned temporal tables automatically retain prior row versions and support point-in-time reconstruction.9 That mechanism may satisfy the requirement with far less domain machinery.

Change data capture can also be sufficient when consumers need reliable data-change propagation rather than domain replay. The outbox pattern addresses the dangerous dual write between local state and an emitted message by committing the business update and outbox record in one transaction, then publishing asynchronously.10 It solves transport consistency. It does not automatically preserve why a decision was legitimate.

The distinction is the question being answered. Temporal history says how stored values changed. CDC says which database mutations occurred. Workflow history says how an orchestrator advanced. Domain events say which business transitions occurred. A full case ledger links those transitions to evidence, authority and outcomes. Choose the least powerful mechanism that answers the required reconstruction question.

MechanismStrongest question answeredWhat it usually lacksUse when
Current-state tableWhat is true now?Historic pathChanges are disposable and fully reversible
Temporal tableWhat did this row contain at time T?Intent, evidence and authority semanticsPoint-in-time forensics is sufficient
CDC plus outboxWhich committed data changes were published?Business-level causal completenessReliable integration is the main burden
Workflow event historyHow did this orchestrated execution progress?External business truth unless explicitly observedDurable process recovery is primary
Event-sourced caseWhy does this case state exist, and what remains unresolved?Facts never observed by any participating systemEvidence, authority and effects must be reconstructed together
Architecture choice and replay confidence surface A phase diagram places mechanisms by need for point-in-time state on the vertical axis and need to explain evidence, authority and effects on the horizontal axis. Event-sourced case state occupies the upper-right region beyond both thresholds. Event sourcing belongs beyond both thresholds Need to explain evidence, authority and external effect → Need historic state reconstruction → currenttable temporaltable CDC +outbox workflowhistory event-sourcedcase below threshold: complexity exceeds evidence need Illustrative boundary. Local obligations, volumes and incident costs determine the actual frontier.
Figure 8. Architecture choice and replay confidence surface. Illustrative, not measured. The upper-right region requires both historical reconstruction and causal explanation. Below that frontier, simpler mechanisms should win.

Counterexample: the complete transcript that still cannot replay

Suppose a team retains every chat message, model token, tool request, HTTP response and database statement for a case. The record is append-only and hash-linked. It still omits the evidence version accepted for the decision, the approver's effective delegation and an independent observation of the external postcondition. The volume is enormous; the causal closure is poor.

During review, the model's prose says the action was approved, an HTTP 200 suggests a service accepted something, and the final table shows the desired value. None establishes that the authorised principal approved this exact effect against the evidence then applicable, or that the remote business state resulted from this request. A later operator may have changed it manually.

This is the negative control for indiscriminate logging. Completeness of activity is not completeness of explanation. Adding more technical exhaust cannot repair missing domain commitments. The remedy is to promote a small number of typed decision, authority, action and outcome events, while retaining detailed traces separately for diagnosis.

Where the mechanism breaks

Event sourcing cannot recover facts that no trusted component observed. If a letter was printed but the print service emits no receipt and no downstream readback exists, the log can preserve uncertainty only. It cannot prove delivery. The same boundary applies to real-world outcomes such as whether a customer understood advice.

Semantic drift is the next boundary. Events live longer than application code. Renaming a field is easy; changing what an event means can corrupt replay. The empirical study of event-sourced systems found several evolution tactics, including versioned events, upcasting and copy-and-transform, with no cost-free option.2 Reducer versions and migration tests belong in the release gate.

Privacy can conflict with immutability. Personal data should not be copied into every event merely because storage is cheap. Use identifiers, protected evidential stores, encryption boundaries and retention policies. Some domains require cryptographic erasure or event redaction schemes. Any such operation changes what “immutable” means and must be explicit in the assurance case.

Finally, a badly chosen case boundary causes false ordering. One stream may include several entities that change independently, producing contention and accidental coupling. Several streams may split one indivisible obligation, making the causal chain difficult to join. Optimistic concurrency can prevent two writers from appending against the same stale revision, as event-store implementations expose through expected-version checks.11 It cannot decide the correct aggregate boundary.

Explicit failure boundary

The pattern ceases to provide defensible replay when any material transition lacks a stable domain meaning, responsible principal, causal parent or observable postcondition. At that point the system has an append-only narrative, not a replayable case.

The replayability decision instrument

Before adopting the pattern, take one historic case and attempt this eight-step test. A “no” is not an automatic rejection. It identifies the control the architecture would need to add.

  1. Boundary: Can the team name one case identity whose events form a coherent material undertaking?
  2. Ordering: Can concurrent writers detect a stale stream revision without imposing a false global order?
  3. Meaning: Does each event describe an enduring domain fact, decision or effect rather than an implementation detail?
  4. Evidence: Can every material decision resolve the exact source versions and validity known at that time?
  5. Authority: Can the stream distinguish requestor, model proposer, approver, executor and observer?
  6. Effects: Does every consequential command carry a stable idempotency key, typed postcondition and target receipt?
  7. Outcome: Can an independent observer confirm success, contradiction or unknown outcome without repeating the action?
  8. Evolution: Can old events be replayed under tested reducer versions while privacy and retention obligations remain enforceable?

A practical release test follows: delete the projection database, isolate every action adapter, replay one representative case, and compare the rebuilt state with an independently retained expected projection. Then inject a duplicate command, missing evidence version, expired authority and contradictory readback. The desired result is rejection or unresolved state, never silent repair.

Architecture decision

Adopt event-sourced case state when three conditions hold together: a case lasts long enough for narrative drift, a material outcome must be explained through evidence and authority, and external effects can be identified and observed. Otherwise prefer temporal state, CDC or durable workflow history.

Open hypothesis

For compound AI systems, the smallest useful case event set will usually be much smaller than the full model trace but larger than the business transaction log. The proposed seven strata predict that audit quality improves most when outcome observations and authority events are added, not when more reasoning tokens are retained. This needs comparative evaluation.

How the claim changes under pressureChart · state
stateDiagram-v2
  [*] --> Claim
  state "Thought experiment: which half<br/>can you lose?" as Claim
  state "Seven event strata" as Test
  state "A compact replayability test" as Revision
  Claim --> Test: expose mechanism
  Test --> Decision: evidence survives
  Test --> Revision: rival survives
  Revision --> Claim: narrow or rebuild
  Decision --> [*]
The revision loop begins with Thought experiment, which half can you… and keeps counterexamples inside the reasoning path. Reading use: follow the relation, then test it against the surrounding evidence and limits.

Compact glossary

Case stream
The ordered events for one bounded undertaking, decision journey or obligation.
Projection
A replaceable state view derived by folding events through a reducer.
Decision receipt
A durable record of proposal, evidence, authority and disposition at a decision point.
Effect receipt
A target-system acknowledgement that a command was accepted or processed, distinct from outcome proof.
Readback
An independent observation of the authoritative postcondition after an action.
Unknown outcome
A state in which a command may have taken effect but available evidence cannot yet establish success or failure.
Upcasting
Transforming an older event representation into a newer in-memory form during replay without rewriting the stored event.

Store the commitments, then derive the case

A case becomes defensible when its durable record follows the causal burden of its actions. Intent explains why work began. Evidence and world-state observations explain what was knowable. A proposal records judgement without pretending to confer power. Authority identifies who could commit the institution. A typed action records the exact requested effect. Receipts and readbacks distinguish accepted commands from verified outcomes. Recovery events keep uncertainty visible until it is resolved.

The architecture changes when the event log becomes the primary record of commitments and the case table becomes a disposable projection. That change does not require storing every token or technical message. It requires preserving the few events that close the questions created by consequential state transitions.

The pattern earns its cost only where those questions matter. For simple point-in-time history, use a temporal table. For reliable data propagation, use CDC and an outbox. For durable orchestration, use workflow history. For a long-running case whose outcome must be justified through evidence, identity, authority and effect, use an event-sourced case ledger with explicit unknown outcomes and replay that never re-executes live actions.

The release criterion is concrete: rebuild one case from an empty projection store, with action adapters disabled, and recover the same decision-relevant state, causal links and unresolved gaps. When that works under failure injection, the organisation has more than an audit trail. It has a case it can actually reconstruct.

The evidence conversationChart · sequence
sequenceDiagram
  participant Q as Question
  participant M as Mechanism
  participant E as Evidence
  participant D as Decision
  Q->>M: The row that could not answer the complaint
  M->>E: Thought experiment, which half can you lose?
  E-->>M: compare rival explanations
  E->>D: Source ledger
  D-->>Q: revise scope or act
Question, mechanism, evidence and decision exchange the burdens developed in Event-Sourced Case State: Reconstruct the Case, Not the Transcript. Reading use: follow the relation, then test it against the surrounding evidence and limits.

Source ledger

  1. Seminal practitioner source
    Martin Fowler, “Event Sourcing”. Defines state reconstruction from ordered domain events and distinguishes the pattern from a mere audit log.
  2. Primary research
    Overeem et al., “An Empirical Characterization of Event Sourced Systems and Their Schema Evolution”. Interview study covering 19 systems and 25 engineers, with documented benefits and liabilities.
  3. Official standard
    W3C PROV-O. Provides entity, activity, agent and responsibility relations for provenance chains.
  4. Official specification
    CloudEvents specification. Standardises a portable event envelope and core context attributes.
  5. Official implementation documentation
    Temporal event history and workflow determinism guidance. Illustrate deterministic replay and separation of external activities from the replay path.
  6. Official implementation documentation
    Apache Kafka design and delivery semantics. Delimits publication, consumption and exactly-once claims.
  7. Official API documentation
    Stripe idempotent requests. Concrete example of stable request identity and matching retry results.
  8. Authoritative technical specification
    RFC 8785, JSON Canonicalization Scheme. Defines deterministic JSON suitable for repeatable hashing and signing.
  9. Official implementation documentation
    SQL Server system-versioned temporal tables. Negative-control mechanism for row history and point-in-time reconstruction.
  10. Official implementation documentation
    Debezium Outbox Event Router. Addresses consistency between local state changes and asynchronously published events.
  11. Official implementation documentation
    KurrentDB appending events. Shows expected stream revisions for optimistic concurrency.
  12. Current official architecture guidance
    Azure Architecture Center, Event Sourcing pattern. Explicitly frames event sourcing as a complex trade-off rather than a default.
  13. Recent primary research signal
    Nakajima, “The Log is the Agent”. A 2026 preprint proposing deterministic event-log projections for auditable, forkable agent systems; treated here as a research signal, not enterprise validation.
  14. Recent primary research signal
    Souza et al., “PROV-AGENT”. Extends W3C PROV for agentic workflow provenance and evaluates provenance queries across heterogeneous environments.