Audit Trail and Compliance for Reconciliation Permalink to this section
↑ Part of Core Architecture & Data Mapping for Reconciliation.
A reconciliation pipeline that produces the right answer is not, by itself, defensible. When an external auditor or an internal controls reviewer asks why a specific invoice line moved from exception to matched on a specific date, “the code did it correctly” is not an answer — they need the exact prior state, the exact new state, who or what triggered the transition, under which reason code, and at what UTC instant. If that provenance lives only in a mutable status column that gets overwritten on every update, the question is unanswerable the moment the row changes a second time. Every trace of the decision that mattered is gone.
This page treats auditability as a first-class architectural property, not a logging afterthought bolted on after the fact. The design choice that makes reconciliation decisions provable is the same one that underlies the matching and reconciliation algorithms that produce those decisions in the first place: never destroy history. A state transition is recorded as an immutable, append-only event; the “current state” is a materialized view derived by replaying those events, never an independently mutated field. That single discipline is what turns a reconciliation system from something an auditor has to trust into something an auditor can verify.
Core Concept & Decision Criteria Permalink to this section
The failure mode this page exists to prevent is the in-place status update: a reconciliation row carries a status column, a matching job flips it from pending to matched, an override job later flips it to manually_cleared, and each write physically overwrites the prior value. The database can tell you what the row is; it cannot tell you what the row was, who changed it, or why — unless a separate audit mechanism captured that at write time, and even then a mutable log can itself be edited or truncated. For SOX Section 404 internal-controls purposes, this is disqualifying: the control objective is not “the ledger is currently correct,” it is “every change to the ledger is attributable and irreversible after the fact.”
The alternative is an append-only event log where every state transition is its own row, keyed by the same canonical mapping identifier used everywhere else in the pipeline, and the table itself is architected so that UPDATE and DELETE are not just discouraged by convention but structurally impossible for the roles that write to it. “Current state” becomes a query — the latest event per canonical key — rather than a stored fact, which means the full transition history is always present and never has to be reconstructed from backups.
| Decision axis | Mutable in-place status | Append-only audit event log |
|---|---|---|
| Write semantics | UPDATE overwrites prior value |
INSERT only; prior rows untouched |
| Historical visibility | Only current value survives | Full transition history queryable |
| Attribution | Requires a separate, often optional, audit table | Every row carries actor, role, reason code by construction |
| Tamper evidence | None — an UPDATE leaves no trace of what it replaced |
Hash-chained sequence makes silent edits detectable |
| Replay / reconstruction | Not possible without external backups | Current state is always a deterministic replay of events |
| SOX control mapping | Weak — control relies on trusting the application layer | Strong — control is enforced at the storage/grant layer |
| Segregation of duties | Bolted on in application logic, easily bypassed | Enforceable in the schema (actor_id <> approver_id) |
| Storage growth | Flat | Grows with transition volume; requires retention policy |
| Query cost for “current state” | O(1) — direct read | O(n) worst case, mitigated by a materialized latest-event view |
The controls a SOX audit actually tests map directly onto specific fields in the event schema, which is the reason the schema cannot be an afterthought: canonical_key satisfies traceability to the underlying transaction, prior_state/new_state satisfy completeness of the transition, reason_code satisfies the requirement that a judgment call be classified rather than free-text, actor_id/actor_role satisfy attribution, event_ts in UTC satisfies chronological ordering across time zones, and approver_id (populated only on override paths) satisfies segregation of duties. The detailed column-level schema, retention, and indexing strategy is covered in Designing a SOX-Compliant Audit Log Schema; the storage-engine mechanics that make the table physically append-only are covered in Append-Only Ledger Design in PostgreSQL. This page focuses on the writer contract and the operational discipline that sits between them.
Implementation Permalink to this section
The audit writer is the single choke point every state transition must pass through — the matching engine, the exception-resolution workflow, and any manual override all call the same function rather than writing to the state table directly. Each event is hash-chained to the previous event for its canonical key, so a prior_event_hash mismatch on verification proves the chain was altered out of band:
import hashlib
import logging
from dataclasses import dataclass
from datetime import datetime, timezone
from enum import Enum
from typing import Optional
import psycopg
from pydantic import BaseModel, field_validator
logger = logging.getLogger("recon.audit")
class ReasonCode(str, Enum):
"""Closed taxonomy — free-text reasons are not auditable."""
AUTO_MATCH_EXACT = "auto_match_exact"
AUTO_MATCH_TOLERANCE = "auto_match_tolerance"
ROUTED_TO_EXCEPTION = "routed_to_exception"
MANUAL_OVERRIDE = "manual_override"
DUAL_CONTROL_APPROVAL = "dual_control_approval"
REVERSAL = "reversal"
class AuditEvent(BaseModel):
"""One immutable state transition for a single canonical key."""
canonical_key: str
prior_state: Optional[str]
new_state: str
reason_code: ReasonCode
actor_id: str
actor_role: str
approver_id: Optional[str] = None
event_ts: datetime
@field_validator("event_ts")
@classmethod
def must_be_utc(cls, v: datetime) -> datetime:
if v.tzinfo is None or v.utcoffset() != timezone.utc.utcoffset(v):
raise ValueError(f"event_ts must be timezone-aware UTC, got {v!r}")
return v
@dataclass
class SegregationViolation(Exception):
"""Raised when a single identity both proposes and approves an override."""
canonical_key: str
actor_id: str
def enforce_segregation_of_duties(event: AuditEvent) -> None:
"""Overrides and dual-control approvals must carry a distinct approver."""
requires_approver = event.reason_code in (
ReasonCode.MANUAL_OVERRIDE,
ReasonCode.DUAL_CONTROL_APPROVAL,
)
if requires_approver and event.approver_id == event.actor_id:
logger.error(
"segregation_violation key=%s actor=%s reason=%s",
event.canonical_key, event.actor_id, event.reason_code.value,
)
raise SegregationViolation(event.canonical_key, event.actor_id)
def compute_event_hash(prior_hash: Optional[str], event: AuditEvent) -> str:
"""Chain each event to the prior event for its canonical key."""
basis = "|".join([
prior_hash or "GENESIS",
event.canonical_key,
event.prior_state or "",
event.new_state,
event.reason_code.value,
event.actor_id,
event.event_ts.isoformat(),
])
return hashlib.sha256(basis.encode("utf-8")).hexdigest()
def write_audit_event(
conn: psycopg.Connection, event: AuditEvent, prior_hash: Optional[str]
) -> str:
"""Append one event; never UPDATE or DELETE a prior row."""
enforce_segregation_of_duties(event)
event_hash = compute_event_hash(prior_hash, event)
with conn.cursor() as cur:
cur.execute(
"""
INSERT INTO reconciliation_audit_event
(canonical_key, prior_state, new_state, reason_code,
actor_id, actor_role, approver_id, event_ts,
prior_event_hash, event_hash)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
""",
(
event.canonical_key, event.prior_state, event.new_state,
event.reason_code.value, event.actor_id, event.actor_role,
event.approver_id, event.event_ts, prior_hash, event_hash,
),
)
conn.commit()
logger.info(
"audit_appended key=%s %s->%s reason=%s actor=%s hash=%s",
event.canonical_key, event.prior_state, event.new_state,
event.reason_code.value, event.actor_id, event_hash[:12],
)
return event_hash
The corresponding storage layer must physically prevent the mutation this writer never performs. The table grants INSERT and SELECT only to the application role; a trigger blocks UPDATE and DELETE even for a role that later gains those grants by mistake, so a schema privilege error is never the only line of defense:
CREATE TABLE reconciliation_audit_event (
event_id BIGSERIAL PRIMARY KEY,
canonical_key TEXT NOT NULL,
prior_state TEXT,
new_state TEXT NOT NULL,
reason_code TEXT NOT NULL,
actor_id TEXT NOT NULL,
actor_role TEXT NOT NULL,
approver_id TEXT,
event_ts TIMESTAMPTZ NOT NULL,
prior_event_hash CHAR(64),
event_hash CHAR(64) NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT clock_timestamp(),
CONSTRAINT actor_not_approver
CHECK (approver_id IS NULL OR approver_id <> actor_id)
);
CREATE INDEX idx_audit_canonical_key_ts
ON reconciliation_audit_event (canonical_key, event_ts);
-- Structural append-only enforcement: no role can UPDATE or DELETE,
-- regardless of what grants it later accumulates.
CREATE OR REPLACE FUNCTION reject_audit_mutation() RETURNS trigger AS $$
BEGIN
RAISE EXCEPTION 'reconciliation_audit_event is append-only: % denied on event_id %',
TG_OP, OLD.event_id;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_audit_no_update
BEFORE UPDATE ON reconciliation_audit_event
FOR EACH ROW EXECUTE FUNCTION reject_audit_mutation();
CREATE TRIGGER trg_audit_no_delete
BEFORE DELETE ON reconciliation_audit_event
FOR EACH ROW EXECUTE FUNCTION reject_audit_mutation();
REVOKE UPDATE, DELETE ON reconciliation_audit_event FROM PUBLIC;
GRANT INSERT, SELECT ON reconciliation_audit_event TO recon_writer;
Both layers matter independently. The Python-side enforce_segregation_of_duties check stops an override from being proposed by the same identity that approves it; the SQL CHECK constraint stops it from ever being stored that way even if an application bug or a direct database session bypasses the writer. Which identity is even permitted to hold the actor_role values that can write MANUAL_OVERRIDE or DUAL_CONTROL_APPROVAL events is a role-based access question, governed by the controls in Data Security Boundaries for Procurement Systems and specifically by Implementing Role-Based Access for Supply Chain Data Pipelines, which defines the role hierarchy the actor_role field is populated from.
Configuration & Threshold Calibration Permalink to this section
The audit event schema is fixed by control requirements, but several operational parameters around it need calibration per environment and per regulatory posture:
| Parameter | Recommended value | Rationale |
|---|---|---|
reason_code taxonomy size |
6–15 closed values | Enough granularity for auditors to distinguish causes; too many collapses back into free text |
hash_algorithm |
SHA-256 | Collision resistance auditors and external attestors already accept |
dual_control_threshold |
Override affecting > tolerance-band variance or above a dollar floor | Below the floor, single-actor overrides are proportionate to risk |
retention_period |
7 years (US SOX) or per local statute | Audit events must outlive the fiscal periods they attest to |
clock_skew_tolerance |
0 — reject, don’t clamp | A clamped timestamp silently misrepresents when a decision was made |
chain_verification_interval |
Every batch close, plus a nightly full-table pass | Detects a broken hash chain before it reaches an external audit |
replay_divergence_alert |
Any mismatch between materialized state and replayed state | Signals the materialized view fell out of sync with the log |
event_ts is the parameter most teams get wrong. The writer above rejects any non-UTC timestamp outright rather than converting it, because a converted timestamp hides the ambiguity of what “local” meant at capture time — a plant in one time zone and a shared-services center in another must produce audit events that sort identically regardless of where the actor sat. This mirrors the same UTC-normalization discipline used for Timezone Normalization for Global Supply Chains, applied here to the audit dimension instead of the transactional one.
dual_control_threshold should never be a single global dollar figure copied from a different control framework. Calibrate it against the same tolerance bands that drive matching decisions in Setting Quantity and Price Tolerance Windows — an override that merely re-asserts what tolerance matching would have accepted anyway carries less risk than one that forces a match outside the band, and the approval requirement should scale accordingly.
Orchestration & Integration Permalink to this section
The audit writer sits downstream of every stage that can change a canonical key’s state — ingestion normalization, tiered matching, exception resolution, and manual override — and upstream of nothing except the materialized current-state view. It is not a side effect bolted onto those stages; it is the only mechanism by which state legitimately changes. A matching job does not “set status to matched” and separately “log an audit event” as two operations that can drift out of sync; it calls write_audit_event, and the materialized view’s next refresh reflects the new latest event. This ordering guarantee is what prevents the class of bug where a status column and its audit trail disagree because one write succeeded and the other did not.
Every stage that emits an event carries the same canonical_key used across the core architecture and data mapping layer, so a single key joins cleanly from raw ingestion through matching through audit without a translation step. Rows routed to the exception queue by the matching tier — covered in depth by the exception handling and discrepancy resolution section — write a routed_to_exception event on entry and, on resolution, a second event carrying whichever terminal reason code applies; the exception queue’s own state is therefore also fully replayable from this same log rather than tracked separately.
Health of the audit pipeline itself is an observability concern, not a compliance-only one. Chain-verification failures, a growing gap between materialized-view refresh time and the latest appended event, or an unexpected spike in manual_override events relative to auto-matched volume are exactly the kind of leading indicators the monitoring and alerting tier should surface before they become an audit finding. Wire the verification job’s pass/fail status and the override-rate ratio into that same alerting surface rather than building a parallel one.
Debugging & Pipeline Recovery Permalink to this section
Because the audit log is append-only, recovery never means “fix the row” — it means either replay from the log or escalate, and knowing which one applies depends on where the divergence sits.
- Materialized state disagrees with the event log: rebuild the current-state view by replaying all events for the affected
canonical_keyinevent_tsorder and recomputing the terminal state. If the replay produces a different value than what the materialized view currently shows, the view refresh — not the log — is the defective component; the log itself was never touched. - Hash-chain verification fails: walk the chain for the affected canonical key and locate the first
event_hashthat does not matchSHA256(prior_hash || event). Everything before that point is provably intact; everything after it is suspect. This is a containment and escalation event, not a routine bug — do not attempt to “repair” the chain by recomputing hashes forward, since that would erase the evidence that something was altered. - Segregation-of-duties violation raised in production: confirm the role mapping in the identity provider matches what
actor_roleexpects. ASegregationViolationfiring on a legitimate approval usually means a role was granted to the wrong identity upstream, in the same role-based access layer that defines who may hold override authority — fix the role assignment, then resubmit the transition as a new event; never bypass the check. - Clock skew rejections at ingestion: an actor’s system clock drifting from UTC will surface as a wall of
must_be_utcvalidation errors. Correct the source clock and replay the rejected transitions as new events with corrected timestamps; do not backdate a corrected event to match when the transition was originally attempted, since theevent_tsmust reflect when the row was actually appended. - Retention conflicts with a legal hold: a table configured to purge events past
retention_periodmust exempt any canonical key under active hold. Treat this as a query-time filter on the purge job, not a schema change, so the append-only guarantee is never weakened to accommodate an exception.
Every recovery path above ends in the same place: a new event is appended, or an anomaly is escalated. Nothing in a correctly designed audit pipeline is ever edited in place, which is precisely the property being verified.
FAQ Permalink to this section
Why not just add a created_at and updated_by column to the existing status table instead of a separate event log? Permalink to this section
Because those two columns only ever describe the most recent change. The moment a second update happens, the identity and reason for the first change are gone — overwritten, not archived. An auditor testing a specific historical transition has no way to reconstruct it. An append-only event log keeps every transition as its own row, so the full sequence is always queryable, not just the latest state.
Does hash-chaining the event log require a blockchain or distributed ledger? Permalink to this section
No. Hash chaining here is a single-writer, single-database integrity check — each event’s hash incorporates the previous event’s hash for the same canonical key, so any out-of-band edit breaks the chain at a detectable point. That property does not require consensus, distribution, or a token; a BEFORE UPDATE/BEFORE DELETE trigger plus a periodic verification job delivers the same tamper-evidence for a fraction of the operational complexity.
What counts as a valid reason code, and who decides? Permalink to this section
A valid reason code is any value in the closed taxonomy owned by the controls team, not whatever string an engineer types during an incident. Codes like auto_match_exact or manual_override map directly to control tests an auditor runs; free-text reasons do not. Changing the taxonomy itself should go through the same change-control process as a schema migration, since auditors will re-test against whatever taxonomy was in effect for the period under review.
Can a single person both propose and approve an override in an emergency? Permalink to this section
No — that is exactly the scenario segregation of duties exists to prevent, and the actor_not_approver check enforces it at both the application layer and the database constraint layer. In a genuine emergency where no second approver is reachable, the correct path is a documented emergency-access procedure that still produces two distinct identities in the log (even if one is a break-glass account with its own review trail), not a bypass of the check.
How do you prove to an external auditor that the audit log itself has not been tampered with? Permalink to this section
Run the chain-verification job against the full table and produce its pass result as evidence, then let the auditor independently recompute a sample of hashes from raw event data rather than trusting your job’s output alone. Because event_hash is a deterministic function of the row’s own fields plus the prior hash, anyone with read access to the table can recompute and confirm it without needing write access or trusting the application that wrote it.