A systems architecture treatment of the immutability boundary in regulated payment systems.
Author: Kuldeep Singh — Solutions Architect & Staff Engineer · AWS SAA-C03
Domain: Payment gateways, settlement systems, and regulated fintech compliance (PCI-DSS, RBI, NACH, ISO 20022)
Scope: A generalized decision framework distilled from building audit/compliance layers for high-volume payment platforms — the "we" below refers to that class of work, not one single named system.
TL;DR
The practical resolution to the framing above: split every event into one of two tiers by evidentiary weight, not by service or data type — then enforce that split structurally, not by convention:
- Tier 1 (immutable, long-retained): money-movement state transitions, consent/mandate events, access to sensitive data. Append-only, hash-chained, delivered via a transactional outbox so a slow audit pipeline can never block a payment and can never silently drop a record.
- Tier 2 (operational, short-retained): everything else — request traces, debug logs, health checks. Mutable, sampled, aggressively expired, because unnecessary retention of this tier is pure liability with no offsetting benefit.
Everything below is the reasoning, the failure modes, and the working implementation behind that split.
The trade-off nobody frames correctly
Most engineering conversations about audit trails start in the wrong place: "How much should we log?" That's a storage question, and storage is the cheapest resource in the entire stack. The real question sits one layer up, and it's the one that should drive every downstream decision:
What is your organization willing to be unable to prove, six months from now, when a regulator, a bank partner, or a customer disputes what happened?
That reframe changes the entire design surface. Audit trail architecture is a liability decision that happens to be implemented in logging infrastructure. This is the concrete lesson from building audit layers for payments platforms that have to simultaneously satisfy PCI-DSS v4.0 Requirement 10 (log and monitor all access to system components and cardholder data), BBPS operational guidelines, and NACH mandate lifecycle rules, where a single missing record in a mandate-revocation trail isn't a bug ticket. It's a compliance finding with a named owner and a remediation deadline attached to it.
Get the framing wrong and you optimize the wrong axis: either you drown in undifferentiated data with no forensic value, or you ship a lean system that quietly can't answer the one question that matters at exactly the moment a dispute — a UPI complaint routed through NPCI's UDIR, a card-network chargeback, or an RBI audit sample pull — surfaces it.
Reframing it as a distributed systems problem, not a logging problem
It's worth being precise about what an audit trail actually is, architecturally: a durability and ordering guarantee layered on top of a system that was not, in most cases, originally designed around that guarantee. That's the same shape of problem as the CAP-theorem trade-offs any staff engineer already reasons about daily — except here, the "correctness" property isn't application consistency, it's non-repudiation: the guarantee that a recorded fact cannot be later disputed, altered, or shown never to have existed.
Three properties matter more than raw completeness:
- Durability under partial failure. The audit event must survive the failure of any single component in the write path — including the database itself, mid-transaction — without ever being silently dropped.
- Ordering per entity. For a given transaction or mandate, the sequence of state transitions must be reconstructable exactly as it happened, even if the events arrive at the durable store out of wall-clock order due to network or queueing delays.
- Exactly-once effect, not exactly-once delivery. True exactly-once delivery is famously unachievable in a distributed system with independent failure domains. What you actually need — and what's achievable — is at-least-once delivery with idempotent application, so a retried or duplicated audit event never corrupts the record, even though the transport underneath may redeliver it.
This is why the transactional outbox pattern, not a direct write from application code to the audit store, is the correct default: it converts an audit trail from "best-effort logging" into a durability guarantee backed by the same ACID transaction as the business state change itself.
Two failure modes, both expensive, both familiar
Log everything. Complete forensic capability on paper. In practice, at real transaction volume, you get:
- Query latency that degrades as the table or index grows — degrading precisely when you need fast answers, which is during an incident, never on a quiet Tuesday
- A data protection liability surface expanding with every field captured "just in case" — OTPs, full card numbers, raw KYC documents sitting in debug-level application logs because nobody scoped "log everything" down to the field level. This is exactly the class of finding that shows up in a PCI-DSS v4.0 Requirement 3 (protect stored account data) gap assessment
- Collapsed signal-to-noise for the person investigating something real. A settlement state transition buried under ten thousand load-balancer health checks and Celery heartbeat entries
Log only what's "necessary." Cheaper, cleaner, faster to query — until an audit surfaces a gap. The failure is asymmetric in a way engineers consistently underestimate: you don't discover you under-logged until the exact moment you need the record most, and by then it's unrecoverable. There is no "let me go back and log that" during a live dispute. NACH mandate disputes routinely reduce to "show the exact consent capture event, with timestamp, channel, and IP, for this specific mandate ID" — and if that wasn't captured as an immutable record at the time it happened, no amount of retroactive engineering effort recovers it.
Both failure modes share a root cause: treating "audit trail" as one homogeneous concern, implemented with one pipeline, at one retention policy, classified by gut feel under deadline pressure. It isn't one concern, and it can't be classified that way at scale.
The resolution: split by evidentiary weight, enforced structurally
The model that survived contact with real audits separates logs by what they need to prove, not by which service emitted them, and critically, not by an individual engineer's judgment call at the moment of instrumentation.
Business transaction (payment state change)
│
same DB transaction
│
▼
Classified at the EVENT SCHEMA level, not the service:
money movement / consent / sensitive access? → Tier 1
everything else (traces, debug, health checks) → Tier 2
┌──────────────────────────┐ ┌───────────────────────────┐
│ TIER 1 — evidentiary │ │ TIER 2 — operational │
│ audit.transaction_events│ │ CloudWatch Logs / │
│ append-only, hash- │ │ OpenSearch │
│ chained, delivered=false│ │ mutable, sampled │
└────────────┬─────────────┘ └─────────────┬─────────────┘
│ drain worker / CDC (WAL) │ 14–30 day
▼ │ retention
┌──────────────────────────┐ ▼
│ S3 + Object Lock │ deleted on
│ (Compliance mode) │ schedule, no
│ 5–10yr regulatory │ exceptions
│ retention, DLQ on fail │
└──────────────────────────┘
Tier 1 — Immutable, long-retained, evidentiary
This tier answers "what happened, when, and who authorized it" in a way that holds up outside your own systems — to a regulator, an auditor, a bank partner's risk team, or a court, none of whom accept "trust our engineering team" as an answer.
What belongs here:
- State transitions on money movement. Every status change on a transaction — initiated → authorized → settled → reconciled → failed/reversed — with actor, timestamp, and triggering event. The message standard depends on the rail, and audit events should carry whatever identifiers that rail actually issues: UPI and NACH run on NPCI's own APIs and file formats, not ISO 20022, so the audit event needs the UPI transaction reference (RRN) or NACH UMRN, not a pacs.008 field that was never generated. On the legs that do use ISO 20022 — RTGS and cross-border settlement (pacs.008 for credit transfers, pacs.002/004 for status and returns) — the audit event should carry enough of the message envelope — end-to-end ID, transaction ID, settlement method — to reconstruct that leg's lifecycle from the audit store alone, without a round trip to the message queue that may have already rotated the message out.
- Consent and authorization events. NACH mandate creation and revocation (the bank-mandate rail, identified by its UMRN) and UPI Autopay consent (a separate, UPI-native e-mandate mechanism with its own registration flow) are two distinct frameworks, not one — both need auditing, but don't collapse them into a single generic "mandate" event without keeping the rail identifiable. The same applies to delegated authority actions: captured with the exact payload the user consented to, not a paraphrase generated after the fact.
- Access to sensitive data. Who read a KYC document, who queried a customer's full Permanent Account Number (India's tax-ID PAN — not to be confused with a card's Primary Account Number, below), who exported a customer PII report, and the business justification tied to that access.
Design constraints, in order of how often teams get them wrong:
1. Append-only, tamper-evident storage — enforced at the grant level, not by convention. No UPDATE, no DELETE, ever, for the application's runtime role. A separate, MFA-gated, break-glass role holds anything higher, and every use of it is itself an audit event.
-- Enforce append-only at the grant level, not by convention or code review
CREATE SCHEMA audit;
CREATE TABLE audit.transaction_events (
id BIGSERIAL PRIMARY KEY,
event_id UUID NOT NULL DEFAULT gen_random_uuid(), -- external-facing correlation ID (never the internal serial id)
transaction_id UUID NOT NULL, -- assumes Payment.id is itself a UUID PK, common in payment systems to avoid enumerable IDs
event_type TEXT NOT NULL, -- e.g. PAYMENT_STATE_TRANSITION
from_state TEXT,
to_state TEXT NOT NULL,
actor_id TEXT NOT NULL,
actor_type TEXT NOT NULL, -- USER | SERVICE | SCHEDULED_JOB
schema_version SMALLINT NOT NULL DEFAULT 1,
payload_hash TEXT NOT NULL, -- SHA-256 of the canonical payload
payload JSONB NOT NULL,
idempotency_key TEXT NOT NULL, -- the gateway's own attempt/callback ID — rarely a UUID in practice
prev_event_hash TEXT, -- hash-chain: commits to the prior event
occurred_at TIMESTAMPTZ NOT NULL, -- business time, set by the domain event
recorded_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- system time, never conflated with occurred_at
delivered BOOLEAN NOT NULL DEFAULT false, -- outbox flag: has this row shipped to the durable archive?
delivery_attempts SMALLINT NOT NULL DEFAULT 0, -- delivery bookkeeping only, never touches the evidentiary columns
next_attempt_at TIMESTAMPTZ NOT NULL DEFAULT now(),
dead_lettered BOOLEAN NOT NULL DEFAULT false -- delivery gave up; the row itself is still intact and durable
);
CREATE INDEX idx_audit_undelivered
ON audit.transaction_events (next_attempt_at) WHERE NOT delivered;
CREATE UNIQUE INDEX idx_audit_idempotency
ON audit.transaction_events (idempotency_key);
-- The application role that records events gets INSERT/SELECT only —
-- it can never modify or delete a row once written.
REVOKE UPDATE, DELETE ON audit.transaction_events FROM app_runtime_role;
GRANT INSERT, SELECT ON audit.transaction_events TO app_runtime_role;
-- The drain worker gets a separate, narrower role: it may only flip its
-- own delivery-bookkeeping columns, never the evidentiary payload, hash,
-- or chain — a compromised drain worker still can't rewrite the record.
REVOKE ALL ON audit.transaction_events FROM drain_worker_role;
GRANT SELECT ON audit.transaction_events TO drain_worker_role;
GRANT UPDATE (delivered, delivery_attempts, next_attempt_at, dead_lettered)
ON audit.transaction_events TO drain_worker_role;
CREATE OR REPLACE FUNCTION audit.chain_hash() RETURNS TRIGGER AS $$
DECLARE
prior TEXT;
BEGIN
SELECT payload_hash INTO prior
FROM audit.transaction_events
WHERE transaction_id = NEW.transaction_id
ORDER BY id DESC LIMIT 1;
NEW.prev_event_hash := COALESCE(prior, 'GENESIS');
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_chain_hash
BEFORE INSERT ON audit.transaction_events
FOR EACH ROW EXECUTE FUNCTION audit.chain_hash();
The hash-chain isn't cryptographic theater — it's the mechanism that lets you prove nothing was retroactively edited, which is the actual question an auditor asks. On DynamoDB, the equivalent is Streams enabled on the table with a downstream Lambda computing the chain hash, paired with S3 Object Lock in Compliance mode for the durable copy — a mode even the AWS account root cannot override before the retention date expires.
The trigger has a real race condition worth calling out explicitly: under Postgres's default READ COMMITTED isolation, two concurrent inserts for the same transaction_id — a gateway retrying a webhook, two workers processing the same payment — can each read the same "prior" row before either commits, and both compute the same prev_event_hash. The chain forks instead of extending, and nothing in the trigger itself catches that. The fix belongs in the application, not the trigger: serialize writes per transaction_id with a row lock on the parent payment before the state transition, so a second concurrent writer blocks until the first commits and only then sees its hash. That's what select_for_update() is doing in the Django snippet below.
This has to be a rule that binds every writer into this table, not a habit local to one function. The consent-event writer and the sensitive-data-access writer mentioned earlier are different code paths — likely different services entirely — and if either inserts for the same transaction_id without taking the identical lock first, the fork risk just moves to whichever writer skipped it. Leaving this as "remember to lock before you insert" at each call site is exactly the kind of convention that erodes; it belongs behind a single record_audit_event(transaction_id, ...) entry point that takes the lock internally, so no caller can forget it — the same reasoning as the shared audit SDK argued for later in this post, applied one layer earlier.
Note the delivered column: the outbox here isn't a separate shadow table duplicating the payload. It's this same append-only table, with a few extra bookkeeping columns tracking whether and when the row has shipped to the durable archive. The write path is a single INSERT — the state change, the audit record, and the outbox entry are all the same transaction, because they're the same row.
This is also why the drain worker needs its own database role, distinct from both the application's insert-only role and the MFA-gated break-glass role: it has to flip delivered, delivery_attempts, next_attempt_at, and dead_lettered on rows that already exist, which means it needs UPDATE — but granting it table-wide UPDATE would let a compromised delivery pipeline rewrite payload or payload_hash and quietly defeat the whole tamper-evidence guarantee. Postgres's column-level grants solve this directly: drain_worker_role can update its four bookkeeping columns and nothing else. It can move an event from undelivered to delivered, or to dead-lettered; it cannot touch what the event says happened.
Deduplication runs on a dedicated idempotency_key, deliberately not on (transaction_id, event_type, to_state). A transaction can legitimately hit the same to_state more than once — a payment that fails, gets retried by the gateway, and fails again is two distinct, real events, and both deserve a row. Keying uniqueness off the state triple would silently swallow the second failure as a duplicate insert conflict, which is worse than logging nothing: it looks successful while quietly losing a record. The idempotency_key is generated once per real-world attempt — typically the upstream gateway's own callback or request ID for that specific attempt — so only a genuine redelivery of the same upstream event collides, never two different attempts that happen to land in the same state.
2. Retention driven by regulation, written into policy, enforced by infrastructure — not left as a database default. Don't reach for PCI-DSS here: its own audit-log retention requirement (Req. 10.5.1) is a minimum of 12 months, with 3 months immediately available — nowhere near what payment platforms actually need. It's RBI record-keeping rules and PMLA that push retention for transaction and consent records out to 5–10 years depending on record class. That number comes from compliance and legal, gets written into a policy document with a version history, and is enforced by S3 Lifecycle rules and Object Lock retention dates. It should never be something an engineer can quietly shorten during an infra cost review eighteen months later — which means the retention configuration itself should require the same change-control process as a production database migration.
Retention isn't the only non-negotiable RBI imposes here — where the data lives matters just as much as how long. RBI's data localization requirement (Storage of Payment System Data, 2018) mandates that the entire data flow of a payment transaction be stored exclusively within India, with only a narrow allowance for foreign-leg processing that must re-import within 24 hours. That constrains every infrastructure choice downstream of it: S3 buckets, database instances, and any DR replica all have to stay in-country. It's easy to design a technically correct Tier 1 pipeline that quietly fails this requirement the moment "durable" or "cross-region" gets treated as a generic AWS decision instead of a jurisdiction-scoped one.
3. Schema stability and explicit versioning. These records get read by people outside engineering, sometimes years after they were written. The schema_version field in the table above exists so that a compliance officer pulling a five-year-old record never depends on your current deserialization code being backward-compatible with a schema you evolved three times since.
4. Delivery that cannot silently drop events, but also cannot block the money movement itself. This is where teams most commonly get the design wrong on the first attempt: making the audit write synchronous with the business transaction, then watching a slow or unavailable audit store take down payment processing. The transactional outbox is the fix — the state change and the audit record commit atomically in the same database transaction; a separate async worker, or a CDC pipeline reading the database's own write-ahead log, publishes the event onward. A downstream failure in the audit pipeline delays delivery. It never loses the record, and it never blocks the payment.
from django.db import transaction
from django.utils import timezone
import hashlib
import json
@transaction.atomic
def transition_payment_state(payment_id, new_state, actor, gateway_attempt_id):
payment = Payment.objects.select_for_update().get(pk=payment_id) # serializes concurrent writers for this payment
old_state = payment.status
payment.status = new_state
payment.save(update_fields=["status", "updated_at"])
payload = build_immutable_payload(payment, old_state, new_state, actor)
payload_hash = hashlib.sha256(
json.dumps(payload, sort_keys=True).encode()
).hexdigest()
AuditTransactionEvent.objects.create(
transaction_id=payment.id,
event_type="PAYMENT_STATE_TRANSITION",
actor_id=actor.id,
actor_type="USER" if actor.is_authenticated else "SERVICE",
from_state=old_state,
to_state=new_state,
payload=payload,
payload_hash=payload_hash,
idempotency_key=gateway_attempt_id,
occurred_at=timezone.now(),
)
The drain worker is deliberately a separate service, not another Django/Celery process, because the delivery loop is I/O-bound, benefits from cheap concurrent fan-out to multiple downstream sinks, and needs to run as a lean, independently-scaled sidecar rather than compete for the same worker pool as business logic:
// Go: drain worker for audit.transaction_events. Reads rows where
// delivered = false AND next_attempt_at <= now() (idx_audit_undelivered),
// publishes each to the immutable sink (Kinesis Firehose -> S3 with Object
// Lock), retries with exponential backoff, and routes permanent failures to
// a DLQ instead of dropping them silently. It runs as drain_worker_role,
// which can only flip its own delivery bookkeeping columns on the row
// that's already durable — never the payload, hash, or chain.
func (w *DrainWorker) drainBatch(ctx context.Context) error {
events, err := w.repo.FetchUndelivered(ctx, batchSize)
if err != nil {
return fmt.Errorf("fetch undelivered: %w", err)
}
for _, evt := range events {
err := w.sink.Publish(ctx, evt)
switch {
case err == nil:
w.repo.MarkDelivered(ctx, evt.ID) // sets delivered = true
case evt.DeliveryAttempts >= maxAttempts:
w.dlq.Send(ctx, evt)
w.repo.MarkDeadLettered(ctx, evt.ID) // sets dead_lettered = true
w.metrics.IncrDeadLettered(evt.EventType) // paging alarm, not a backlog item
default:
// sets delivery_attempts += 1, next_attempt_at = now() + backoff(...)
w.repo.IncrementAttempt(ctx, evt.ID, backoff(evt.DeliveryAttempts))
}
}
return nil
}
A dead-lettered event on a money-movement or consent transaction should page a human within minutes, not surface in a weekly backlog review. Treat IncrDeadLettered on a Tier 1 event type as equivalent in severity to a failed payment itself — because to a regulator, an unprovable payment and a failed one carry similar weight.
Tier 2 — Operational, short-retained, disposable
Debug traces, request/response logs, routine API calls, health checks, rate-limiter decisions — high volume, high value for the next 72 hours, effectively zero forensic value six months out.
- Retention measured in days to a few weeks — 14 to 30 days is typical for request-level traces; longer only for aggregated metrics, never for raw payloads
- Free to be mutable, sampled, or rolled up — you don't need every health check, you need the pattern of health checks over time
- Cheaper, higher-throughput storage: CloudWatch Logs with an explicit retention policy (not the default "never expire," which is how a log group quietly becomes a five-figure monthly line item), or OpenSearch with Index Lifecycle Management rolling old indices to cold storage or deleting them outright
- No compliance obligation to preserve it — which means aggressive deletion is not just permitted, it's the responsible default. Every extra day of retained debug data with request bodies in it is a day of unnecessary breach exposure with zero offsetting compliance value. More near-miss data exposure incidents trace back to an over-verbose Tier 2 debug log than to the actual audit trail — because nobody treats "just a debug log" with the access control rigor they'd apply to Tier 1 by default.
The discipline that's hardest to hold under deadline pressure: resisting the urge to promote a Tier 2 event into Tier 1 "just in case." That instinct feels responsible in the moment, and each individual promotion feels locally justified. The aggregate of a hundred such decisions is exactly how a system drifts back to "log everything," with all the cost and liability that implies, one well-intentioned exception at a time. If an event has no named regulatory or contractual reason to be immutable, it belongs in Tier 2 — full stop, no exceptions carved out under time pressure.
Enforcing the boundary at organizational scale
Two-tier storage is the easy 20% of this problem. The hard 80%, especially across a fleet of independently-deployed services owned by different teams, is making the classification hold consistently — without relying on every engineer making the correct judgment call under a release deadline, and without it degrading as the org grows past the size where any one person can review every PR.
-
Classify at the event schema level, never the service level. Don't say "the payments service writes to Tier 1." A single payments service emits both
PAYMENT_STATE_TRANSITIONevents (Tier 1) andPAYMENT_API_REQUESTtraces (Tier 2). Tag the event type and route on that tag, so the classification travels with the data rather than living implicitly in which team owns the emitting code. -
Enforce with a shared library, not a wiki page. Across a fleet of independently-deployed services, a documented convention erodes within a quarter — someone joins, doesn't read the internal doc, and calls
logger.info()on a mandate-revocation event because it's the fastest path to green CI. A shared internal SDK —audit.record_immutable(event)for Tier 1,log.debug(...)for Tier 2, backed by different transport and storage by construction — removes the judgment call from the individual entirely. We layered a CI lint rule on top: any code path mutating a model tagged@auditedthat doesn't also call the audit SDK in the same transaction fails the build, not the code review. -
Maintain an audit event registry as a first-class artifact, reviewed like an API contract. Every new Tier 1 event type gets a short RFC: what it proves, which regulation or contract requires it, what fields it carries, and its retention class. This is the artifact a compliance team reviews once a quarter instead of auditing raw code — and it's what makes a PCI-DSS or RBI assessment go from "auditors reading your source code" to "auditors reviewing a registry," which is a materially faster and less adversarial conversation.
-
Treat PII minimization as part of the schema, not an afterthought. For Tier 1 events, capture references — transaction ID, masked card PAN (Primary Account Number — first 6 / last 4, per PCI-DSS masking requirements), hashed identifiers — rather than raw sensitive fields, wherever the reference is sufficient to reconstruct the event and re-fetch the sensitive detail from its system of record under proper access control if genuinely needed later. This is usually the exact difference between an audit trail that satisfies a PCI-DSS assessment and one that becomes a finding in it, because you've created a second, less-controlled copy of account data that the assessment now has to scope in.
-
Give the immutable store its own read path, independent of the primary application's health. If a compliance officer has to file a ticket asking an engineer to grep production logs to answer a dispute, the architecture has already failed its own design goal. Tier 1 data needs its own read API or dashboard, its own IAM-scoped access — read-only for engineering, write-only for the delivery pipeline — and ideally its own on-call rotation that doesn't depend on the primary application being up, because the moment you most need to pull an audit record is often during, or immediately after, a primary-system incident.
Mapping the tiers onto AWS infrastructure
| Concern | Tier 1 (immutable) | Tier 2 (operational) |
|---|---|---|
| Primary storage | Append-only Postgres (grant-restricted) or DynamoDB with Streams | CloudWatch Logs, OpenSearch |
| Durable archive | S3 with Object Lock (Compliance mode), SSE-KMS, versioning on | S3 Standard-IA / Glacier for cold rollup only, if kept at all |
| Retention enforcement | S3 Lifecycle + Object Lock retention date; DB grants block deletion entirely | Log group retention policy (14–30 days); ILM rollover/delete |
| Delivery pipeline | Transactional outbox → CDC (Debezium on Postgres WAL, or DynamoDB Streams) → Kinesis Firehose → S3, DLQ via SQS | Direct async logging via CloudWatch agent; sampling acceptable |
| Encryption | Envelope encryption via KMS, separate CMK per data class (payment / PII / auth events), rotation and access logged in CloudTrail | Standard at-rest encryption; no key separation required |
| Access control | Three distinct roles: insert-only for the app, column-scoped update-only (bookkeeping columns) for the drain worker, MFA-gated break-glass for anything higher; engineering gets read-only | Broad engineering access via standard ops roles |
| DR / multi-region | Replication across ap-south-1 (Mumbai) and ap-south-2 (Hyderabad) — India-only, per RBI data localization; "cross-region" cannot mean any AWS region here. Covers only rows already drained; a row still undelivered in the primary Postgres instance exists in one region only, so an in-country Postgres replica (or a tight delivery SLA that keeps the undelivered backlog small) is the real DR lever, not S3 replication alone | Best-effort; a lost debug log during a regional failover is an acceptable loss |
| Query surface | Purpose-built internal API or compliance dashboard, Athena over the S3 archive for historical queries | CloudWatch Insights / Kibana, ad hoc |
The specific tools matter less than what the table encodes: once the Tier 1/Tier 2 classification is made, the infrastructure choices mostly fall out of it. The hard design work happens upstream, at the classification boundary — not in choosing between DynamoDB and Postgres, and not in the DR row either, which is often the row teams skip until the first regional incident makes it non-optional.
A rough cost model, because "storage is cheap" needs a number attached
At real volume, the two tiers diverge sharply, and the divergence is the entire point of the split:
- Tier 2 at high volume, undifferentiated: a service doing meaningful request volume with verbose debug logging can easily produce tens of GB/day in raw log volume. At CloudWatch Logs ingestion + storage pricing, undifferentiated, unmanaged retention on this tier is where teams see log-group costs balloon into a five-figure monthly line item — for data that has no value past a few weeks.
- Tier 1, correctly scoped: the actual set of money-movement, consent, and sensitive-access events is orders of magnitude smaller in volume than raw request traffic — because it's one record per meaningful business event, not one record per HTTP call. Structured JSONB payloads in the hundreds-of-bytes-to-low-KB range, held for years in S3 with Object Lock, is a cost profile measured in tens to low hundreds of dollars per month even at meaningful transaction volume, not a scaling liability.
The financial argument for the split is almost as strong as the compliance one: undifferentiated retention makes you pay premium, hot-storage prices for data that's 95% disposable, while the 5% that actually needs to survive years gets no special handling at all. Tiering isn't just risk management — it's the cheaper architecture.
What actually breaks in production — and what each failure teaches
- Clock skew across services writing to the same audit trail. If
occurred_atis set inconsistently by different emitting services, events for the same entity can appear out of causal order in the trail. The fix is unglamorous but non-negotiable: NTP-synced infrastructure everywhere, and strict separation ofoccurred_at(business time, set by the domain event) fromrecorded_at(system time, set by the audit writer) — never conflate the two, and never let a UI display one when it means the other. - Partial reconciliation gaps. A settlement reconciliation batch from a bank partner arrives, and only some line items successfully update transaction state before a downstream failure interrupts the batch. Without an idempotent, replayable outbox, you get audit events that don't match the eventual settled state. The
idempotency_keyon every audit event — one per real-world attempt, not per resulting state — makes replay of the batch safe: reprocessing the same settlement file is a no-op on rows already recorded, while genuinely new line items still get written, turning a partial-batch failure into a routine retry instead of a data-integrity incident. - "Necessary" scope creep discovered only during an audit sample. An auditor pulls ten random transactions and asks for the full consent chain on one — and you discover the consent capture event was written to Tier 2 by mistake eighteen months ago, before the shared SDK existed, and it's already aged out. This is the strongest real-world argument for CI-enforced classification over a style-guide convention: the cost of a misclassification isn't discovered until it's already unrecoverable, which means the enforcement has to happen at write time, not at audit time.
- Silent success on a degraded delivery pipeline. An outbox drain worker that retries forever without a DLQ and without alerting looks healthy on every dashboard that only tracks "no errors thrown," while a growing backlog of undelivered Tier 1 events sits unnoticed. The DLQ and its paging alarm aren't a nice-to-have — they're what converts an invisible failure mode into a visible one before it becomes an audit gap. That backlog size is a DR metric as much as an ops one: every row sitting in it exists in one region only, so the bigger it grows, the bigger the window in which a regional outage would make a record genuinely unrecoverable, not just delayed.
A short decision framework
When a new event type is proposed, three questions decide its tier — answerable by anyone on the team, not just whoever designed the original system, which is the whole point of making this a registry-driven process rather than tribal knowledge:
- Would a regulator, auditor, or dispute process ever ask for this specific record, tied to a specific entity, months after the fact? If yes → Tier 1.
- Does this event represent money moving, consent being given or withdrawn, or sensitive data being accessed? If yes → Tier 1, regardless of how low-risk it looks in isolation.
- If this record didn't exist tomorrow, would anything beyond today's debugging session be harmed? If no → Tier 2, retained accordingly and deleted on schedule without exception.
Closing thought
Every regulated fintech system eventually gets asked a version of the same question — by an RBI audit, a bank partner's risk team, or a customer's chargeback claim: prove it. The systems that answer well aren't the ones that logged the most. They're the ones that decided, deliberately and in advance, exactly which facts they were structurally incapable of losing — and built the storage, the delivery guarantees, the access controls, and the organizational process around that decision, rather than around convenience or a well-meaning engineer's judgment call under deadline pressure. Everything else — CloudWatch versus OpenSearch, Postgres versus DynamoDB, this CDC tool versus that one — is a storage optimization problem with a well-understood solution space. The tiering decision is architecture. It belongs at the design table before the first line of logging code gets written, not retrofitted after the first audit finding.
References
- PCI Security Standards Council — PCI-DSS v4.0 requirements for logging/monitoring access (Req. 10) and protecting stored account data (Req. 3)
- ISO 20022 — message scheme definitions, including the
pacs(payments clearing and settlement) family used on RTGS and cross-border settlement legs referenced above (UPI and NACH use NPCI's own formats, not ISO 20022) - Reserve Bank of India — regulatory guidelines on payment aggregator/payment gateway compliance, record retention, and data localization (Storage of Payment System Data)
- NPCI — NACH mandate lifecycle and UPI operational guidelines
- AWS S3 Object Lock — WORM storage used for the durable Tier 1 archive
- Pat Helland — Life Beyond Distributed Transactions: An Apostate's Opinion (2007) — the foundational argument for at-least-once delivery with idempotent application over unachievable exactly-once delivery
Need an Audit Trail That Survives a Real Audit?
If your platform is scaling toward RBI, PCI-DSS, or NACH scope and your logging strategy hasn't been through this classification exercise yet, let's talk. I help fintech teams design immutable audit architecture — outbox pipelines, retention policy, and access controls — before the first compliance finding forces the rebuild.