Coreal.
Book a working session →
← INSIGHTS·ENGINEERINGdecision-journalevent-sourcingreplay

Decision journals for regulated runtime: event sourcing, replay, and what a regulator actually queries.

The decision journal sits behind every Coreal deployment, satisfies DORA Art. 5, BCBS 239 lineage, AMLA evidence, and EU AI Act Art. 12 logging from a single source. The engineering deep-dive: schema, event-sourcing pattern, replay engine, retention strategy, what a regulator actually queries.

I
I. Kovalenko
Head of Engineering · Coreal
17 Jun, 202613 min

The single artefact

We mention the decision journal in almost every essay we publish. Bank Wave-1 KYC field note: "every decision journaled". BCBS 239 + DORA + AMLA piece: "shared infrastructure across three regimes". EU AI Act piece: "Article 12 logging requirements". DORA Art. 28 evidence pack piece: "audit trail per ICT-third-party decision". Buyer's checklist: "Q8 — what's your retention?"

For all the references, we haven't written a single engineering piece on what the decision journal actually is, how it's built, what the schema looks like, how the replay engine works, and what a regulator actually queries against it. This essay fixes that gap.

Audience: staff engineers, architects, and CTOs evaluating Coreal or building a similar pattern themselves. It is a technical essay. It assumes familiarity with event sourcing as a pattern.


What a decision journal is, and what it isn't

A decision journal is not an application log. Application logs are debug-focused, freely structured, retained for weeks. A decision journal is not an audit log in the traditional sense either — traditional audit logs capture access events ("user X accessed resource Y at time T") but rarely capture rationale or context.

A decision journal is a first-class, immutable, append-only record of every material decision the runtime makes, with sufficient context to reconstruct why the decision was made and to replay it deterministically. Decisions are events; the event store is the journal.

Material decisions for a regulated runtime include:

  • KYC tier decisions (approve / step-up / decline)
  • Sanctions screening dispositions (clear / hold / refer)
  • AML transaction-monitoring case outcomes
  • Credit scoring outputs (where applicable)
  • Payment authorisations (approve / decline / queue)
  • Risk-rule firings (rule X fired, with disposition Y)
  • Configuration changes (rule updates, threshold changes)
  • Human reviewer interventions (override, approval gate sign-off)
  • AI model invocations (model M called with input I, output O)
  • Workflow stage transitions (case moved from state A to B)

The journal does NOT include:

  • Pure application internals (RPC calls between services, database reads, cache hits)
  • Performance metrics
  • High-volume non-decision events (transaction firehose for non-flagged transactions)

Distinguishing these matters. Application logs are gigabytes per day. Decision journals are megabytes per day — they retain the consequential events, not the firehose. The retention/replay/audit cost scales accordingly.


Schema

Every event in the journal conforms to a base schema:

type DecisionEvent = {
  // Identity
  id: string;                    // ULID, sortable by time
  occurred_at: string;           // ISO 8601 UTC, microsecond resolution
  recorded_at: string;           // when we persisted (often === occurred_at, sometimes later for clock skew)

  // Context
  tenant_id: string;             // multi-tenant isolation
  service: string;               // which Coreal service emitted (kyc, risk, payment-orch, etc.)
  version: string;               // service version + config version hash
  trace_id: string;              // distributed trace correlation
  causation_id: string | null;   // which prior event caused this one (for chains)
  correlation_id: string;        // which business workflow this belongs to (KYC case, payment, etc.)

  // The decision
  type: string;                  // 'kyc.tier_decision', 'aml.case_outcome', etc.
  subject: {                     // what entity the decision concerns
    kind: 'customer' | 'transaction' | 'case' | 'configuration';
    id: string;
  };
  inputs: {                      // hashed inputs used for the decision
    hash: string;                // SHA-256 of canonicalised inputs
    schema_version: string;
    // Specific input data is stored separately, referenced by hash, for retention/redaction
  };
  output: {
    decision: string;            // 'approve' | 'decline' | 'step-up' etc.
    reason_code: string;         // structured code from a controlled vocabulary
    reason_text?: string;        // human-readable rationale
    confidence?: number;         // 0..1 for AI/ML decisions
    rule_chain?: string[];       // which rules fired, in order
  };

  // Actor
  actor: {
    kind: 'system' | 'human' | 'agent';
    id: string;                  // service-id, user-id, agent-id
    role?: string;               // human's role (reviewer, supervisor, etc.)
  };

  // Provenance (where data came from)
  data_sources: Array<{
    source: string;              // 'BSS:event-bus', 'T24:read-replica', 'guidewire:datahub', etc.
    schema_version: string;
    fetched_at: string;
  }>;

  // Lineage (only for transformations)
  upstream_event_ids: string[];  // events this one derives from
};

Three design decisions worth noting:

1. Inputs are hashed, not stored inline. The journal stores a SHA-256 hash of canonicalised inputs. The actual input data lives in a separate, retention-managed store, referenced by hash. This lets us:

  • Keep journal size predictable (each event is < 4KB regardless of input size)
  • Apply different retention policies (input data may need to be redacted under GDPR; the journal entry remains)
  • Replay decisions by re-fetching the input via its hash

2. Causation_id explicitly captures decision chains. A chain like "transaction arrives → risk engine evaluates → rule fires → case opened → reviewer approves → payment authorised" is five events linked by causation_id chain. The replay engine can walk the chain and reconstruct the entire flow.

3. Schema versioning is event-level, not journal-level. When the schema of an event type evolves, we don't migrate old events. New events use new schema; the replay engine handles both. This is standard event-sourcing practice, but worth calling out because it matters for 7-year retention.


Storage

The journal is implemented as an event store with three layers:

Hot store (Postgres, 90 days). New events land in a Postgres table partitioned by month. This handles real-time write throughput (5-15k events/sec sustained for a tier-1 bank deployment, with bursts to 50k/sec). Indexes on correlation_id, subject.id, type, occurred_at. Hot store is for replay queries that need response time ≤ 100ms.

Warm store (Postgres + S3, 90 days – 18 months). Older partitions are detached from the live indexed set but remain in Postgres for slower-but-still-online queries. Materialised views aggregate by entity for faster recent-history queries. Response time ≤ 2 seconds.

Cold store (S3 + Parquet, 18 months – 7 years). Events older than 18 months are exported to S3 as monthly Parquet files. Queries against cold storage use Athena or DuckDB. Response time 30-120 seconds. Cost is dominated by storage at this tier; even multi-TB journals cost under €500/month at this resolution.

The 7-year retention threshold is set by EU financial regulation (the maximum mandated retention is 7 years for most artefacts; some are 10 years for tax-related). We retain 7 years uniformly and don't try to optimise the gradient.

Encryption at rest is applied at all three tiers. Customer data hash-references include a customer-tenant-key-derived salt, so cross-tenant correlation is impossible without tenant key access.


Write path

Events arrive from many sources: KYC orchestrator, risk engine, payment orchestrator, BPM workflow engine, AI orchestrator, human reviewers. The write path:

1. Service emits event (in-process)
2. Event passed to local DecisionJournalWriter
   - Adds tenant_id, service, version, trace_id
   - Validates against type-specific schema
   - Canonicalises inputs, computes SHA-256 hash
3. Writer batches up to 100 events or 50ms (whichever first)
4. Batch posted to journal-ingest service via mTLS gRPC
5. Ingest service:
   - Re-validates schema
   - Computes recorded_at (server timestamp)
   - Persists to Postgres via append-only insert
   - Returns acknowledgement
6. Writer confirms persistence to the caller

The write path is synchronous from the caller's perspective (the service waits for ack before considering the decision committed) but uses batching to amortise the round-trip cost. P50 write latency at a tier-1 bank deployment is 12-18ms; P95 is 28ms; P99 is 80ms (the long tail dominated by occasional Postgres checkpoint pressure during batch writes).

The cost of this synchronous design: every material decision waits for journal acknowledgement. The benefit: if the journal commit fails, the decision is not made — the runtime is fail-stop on logging failure, not fail-open. This is a deliberate trade-off. Regulators view "we lost the audit trail" as a major incident; we'd rather block the customer transaction than lose the trail.


Idempotency at the boundary

Idempotency is the property that retrying an operation produces the same result. In a decision journal, idempotency means: if a decision is computed twice (e.g., due to a retry, a network failure, or a partial commit), the journal contains one event, not two.

We implement idempotency via deterministic event IDs:

event_id = ulid_from_hash(
  service_name + ":" +
  correlation_id + ":" +
  subject_id + ":" +
  decision_type + ":" +
  input_hash
)

The ULID's timestamp prefix comes from the deterministic-hash component (rather than wall-clock), so retries of the same logical decision produce the same ID. The ingest service uses INSERT ... ON CONFLICT DO NOTHING (Postgres parlance) — duplicates are silently dropped at the storage layer.

This pattern is critical for the runtime correctness. Without it, a retry after partial failure could write two events for the same decision; the journal would lie about the runtime's history. With it, the journal is exactly-once even when the surrounding system has at-least-once delivery.


Replay engine

Replay is what makes the journal a regulator-readable artefact, not just a log. The replay engine answers questions like:

  • "What was the decision rationale for customer X's KYC approval on 2026-04-12?"
  • "Show me every decision involving rule R-447 in the last 30 days."
  • "Reconstruct case C-78421 — every decision, every input, every reviewer intervention."
  • "What would the system have decided if customer X's KYC was re-run today with the same inputs?" (this is the "what-if" replay)

The replay engine is a query layer over the event store, with two modes:

Read replay (deterministic, ~ms-scale). Given an event ID or a correlation ID, walk the causation chain forward and backward to reconstruct the full decision narrative. Each event in the chain is loaded; inputs are re-fetched via hash; the structure is rendered as a regulator-readable timeline.

Re-execution replay (re-runs the decision, ~seconds). For "what-if" or "what-would-have-happened" queries, the replay engine takes the historical inputs (referenced via hash), invokes the relevant service version (Coreal services are versioned), and re-runs the decision. Outputs are compared to the historical outputs; differences are highlighted. This is heavier — it requires the historical service version to be available in a sandboxed environment — but it's the gold standard for incident investigation and AI drift detection.

The re-execution replay is what makes the journal work for EU AI Act Article 14 (human oversight) compliance. An AI decision's rationale isn't always self-evident from the inputs; re-executing with diagnostic logging on shows the reasoning chain.


What a regulator actually queries

In practice, EU national competent authority audits in 2025-26 have asked the following query types most often:

Q1: Sample compliance. "Show us 50 randomly selected KYC decisions from the last quarter. For each, the inputs, the decision, the reviewer (if any), the rationale." The replay engine answers this in 4-8 seconds for the whole 50-decision batch.

Q2: Incident trace. "Customer A complained about a wrongful sanctions hit on date B. Show us the full decision history for this customer, with every sanctions screen, every disposition, every reviewer action." Walks the customer's correlation_id-linked event chain. 2-6 seconds.

Q3: Rule audit. "Rule R-447 fired 2,847 times in March. Show us a sample of 100 firings with disposition." Indexed by rule_chain. 1-3 seconds.

Q4: Configuration history. "When was risk threshold T changed, by whom, with what supporting rationale? Show all changes in the last 12 months." Indexed by subject.kind='configuration'. 2-4 seconds.

Q5: Model drift. "We're investigating whether the credit scoring model's behaviour has drifted. Show distribution of decisions over the last 12 months with month-over-month deltas." Aggregation query across the event store. 8-15 seconds for a tier-1 bank's volume.

Q6: Re-execution sample. "Take 20 KYC decisions from Q3 and re-run them with the current pipeline. Where do they differ?" Re-execution replay; longer (typically 5-15 minutes for a batch).

We export the results of each query as both human-readable PDF (for the regulator's file) and structured JSON (for the regulator's automated analysis tools). The export format is one of the artefacts in the DORA Art. 28 evidence pack.


Compliance overlap (revisited)

The piece BCBS 239 + DORA + AMLA: one evidence pack covered the high-level overlap. From the journal engineering side, the overlap shows up as:

DORA Art. 5 (ICT risk framework): the journal IS the ICT risk-decision record.

DORA Art. 17-18 (incident management): ICT incidents are events in the journal with a specific type. Incident timeline reconstruction = walk the correlation chain.

BCBS 239 Principle 3, 6 (lineage): the data_sources field on every event captures lineage. Provenance is a first-class field, not a separate documentation effort.

AMLA Art. 13 (CDD records): customer-due-diligence decisions (KYC tier, EDD outcome, periodic review) are events. The CDD file = the entity's event history.

EU AI Act Art. 12 (logging): AI model invocations are events. Input hashes + output + reason chain + reviewer overrides. All present.

MiFID II Art. 16 (org requirements): investment-service decisions can be journaled the same way. Best-execution decisions, suitability assessments — same shape.

One infrastructure. Six regulatory regimes. The journal pays for itself the first time the bank's compliance team doesn't have to dig through three separate systems to assemble an evidence pack.


Failure modes (the honest list)

The journal architecture works in production at scale, but it's not free. Three failure modes worth documenting:

1. Journal commit failure blocks the runtime. Because we treat the journal as a hard commit gate, a journal-side outage stops the runtime. Twice in 2024 we had brief Postgres performance issues that propagated to the runtime as elevated latency. Both times the issue was within SLA, but it's a structural risk: if the journal goes down hard, customer transactions stop.

The mitigation: the journal-ingest service runs in multi-region active-active with synchronous replication. Failure of a single region degrades performance but does not stop the runtime. Failure of two regions simultaneously is an actual outage; we have not had one.

2. Schema migration during incident is painful. When you discover during an incident that you need an additional field that wasn't being captured, you can't retroactively add it to historical events. We add the field for new events, but the historical part of the incident analysis remains incomplete.

The mitigation: design event schemas with extra "future-proofing" fields (typed JSONB blob for service-specific metadata, schema_version field for clean evolution). We over-engineer the schemas. Cost is small; benefit is real.

3. Replay re-execution requires keeping old service versions deployable. For re-execution replay to work, we need to be able to deploy historical service versions in a sandboxed environment. This means storing container images, dependency manifests, and configuration snapshots for 7 years.

The mitigation: we package services as immutable container images and keep them in a long-term registry. Dependency manifests are pinned. Configuration is fully versioned (and journaled). The cost of this infrastructure is real but not large — about €200/month of storage cost for our entire service portfolio.


What you build if you don't have us

If you're building this pattern in-house rather than buying it, the core engineering effort is:

  • Event store with hot/warm/cold tiers — 2-3 senior engineers, 4-6 months for a usable v1.
  • Replay engine (read replay only) — 1-2 engineers, 2-3 months.
  • Schema discipline (event taxonomies, controlled vocabularies for reason codes, etc.) — ongoing, 1 engineer-month-equivalent indefinitely.
  • Re-execution replay — significantly harder; 1-2 engineers for 4-6 months on top.

You can ship a usable v1 in 6 months with 4-5 senior engineers. You can ship a regulator-grade v2 in 18-24 months. The cost is real; the question is whether you'd rather build it once for your stack or use a stack where it's built in.

For Coreal partners it's built in. Wave-1 deployments inherit the journal from day one. Wave-2 extensions add new event types but don't require new infrastructure. By Wave-3 the journal is the single source of truth that BCBS 239, DORA, AMLA, AI Act and MiFID II audits all draw from — and the bank's compliance team has migrated from "assembling evidence packs" to "querying the journal".

That migration is what regulated-runtime governance actually looks like in production. The journal isn't a feature; it's the spine of the runtime.


Engineering essay. Schema, latency numbers and storage tiers reflect our current production deployment as of mid-2026. Specific numbers vary with workload, but the pattern is stable. For the architectural foundation, see /platform. For the regulatory framing, see BCBS 239 + DORA + AMLA: one evidence pack. For a design review of your existing journal infrastructure, book a working session →.

RELATED · BY TOPIC
← Back to all notesBook a working session →