If you cannot re-run a lending decision from last March and get the same answer, you do not have an audit trail. You have a log file.
A merchant asks why their credit line was declined in March. Compliance wants the answer by Friday. The engineer who wrote the underwriting rules left in June, the rules have been edited eleven times since, and the bureau response was never stored. All that remains is a row with status = 'declined'.
This is the normal state of lending backends that grew from a prototype. Underwriting starts as an if block in a service. It becomes a config file, then a feature-flagged config file, then a set of rules that product managers adjust directly. Each step is reasonable. Together they destroy your ability to explain a past decision.
A decision is a function of inputs and rules, and you are storing neither
Most schemas store the outcome. Some store the score. Almost none store the exact inputs the rules saw, or the exact version of the rules that ran. Both are required to reproduce a decision.
The gap matters more now than it did three years ago. The CJEU's SCHUFA ruling (C-634/21, December 2023) held that producing a credit score can itself count as an automated decision under GDPR Article 22 when a lender relies on it heavily. For natural persons, including sole traders, that brings explanation and human-review obligations. The EU AI Act lists creditworthiness assessment of natural persons as a high-risk use case in Annex III. And DORA (Regulation 2022/2554, applicable since January 2025) pushes financial entities and their ICT providers toward demonstrable control over change management and incident reconstruction.
None of these regulations prescribe a schema. All of them are easier to satisfy if any past decision can be reconstructed on demand.
What we see when lending teams hit this wall
In the fintech backends we have worked on or reviewed, the failure mode is consistent. Rules live in three places: code, a database table that ops edits, and a third-party decisioning tool. Nobody can say which version ran for a given application. Bureau and bank-data responses are fetched at decision time and discarded after the score is computed. When a regulator, an auditor or a funding partner asks for reconstruction, an engineer spends days piecing it together from application logs, and the answer is "probably this."
The second symptom shows up in engineering velocity. Teams become afraid to change rules because they cannot tell what a change would have done to historical applications. A rules change is shipped on a Friday with a prayer instead of a diff.
Both symptoms have the same cause, and the fix is not exotic.
Make the evaluator pure, snapshot the inputs, version the rules
Three decisions give you replayability. None of them need a new platform. PostgreSQL and a disciplined module boundary are enough.
First, the evaluator is a pure function. It takes a rule spec and an inputs object and returns an outcome with reason codes. No clock reads, no HTTP calls, no database access, no randomness. Everything the decision needs arrives as an argument.
export interface DecisionInputs {
applicationId: string;
evaluatedAt: string; // passed in, never Date.now()
applicant: ApplicantFacts;
bureau: BureauSnapshot; // stored response, not a live call
bankData: BankDataSnapshot;
}
export function evaluate(spec: RuleSpec, inputs: DecisionInputs): Decision {
const reasons: ReasonCode[] = [];
for (const rule of spec.rules) {
const hit = rule.test(inputs);
if (hit) reasons.push(rule.reason);
}
return {
outcome: reasons.some(r => r.blocking) ? 'declined' : 'approved',
reasons,
};
}
The side effects happen before the call. An orchestrating NestJS service fetches the bureau data and bank data, writes them to storage, then hands the snapshot to evaluate. The orchestration is messy and allowed to be. The evaluator is not.
Second, rules are immutable, content-addressed versions. A rule change creates a new row. Nothing is edited in place.
CREATE TABLE rule_versions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL,
version int NOT NULL,
content_hash text NOT NULL, -- sha256 of the canonical spec
spec jsonb NOT NULL,
created_by text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (name, version)
);
CREATE TABLE decisions (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
application_id uuid NOT NULL,
rule_version_id uuid NOT NULL REFERENCES rule_versions(id),
inputs jsonb NOT NULL, -- full snapshot, incl. bureau response
outcome text NOT NULL,
reasons jsonb NOT NULL,
decided_at timestamptz NOT NULL DEFAULT now()
);
REVOKE UPDATE, DELETE ON decisions, rule_versions FROM app_role;
The REVOKE is the point. An audit trail the application can rewrite is a suggestion. Add a trigger that rejects updates for the roles that still hold the privilege, and keep the migration role separate from the runtime role.
If rules must be code rather than data, the equivalent is a git SHA stored on every decision row, plus a build that refuses to deploy rule changes without it.
Third, retention is a decision made explicitly. Storing full bureau responses indefinitely conflicts with GDPR storage limitation. Pick a retention window per data class with your DPO, encrypt the snapshot column, and delete on schedule. The decision row can keep its hash of the inputs after the payload expires, which proves integrity even when replay is no longer possible.
Replay turns rule changes from a gamble into a diff
Once decisions are replayable, you get something more useful than compliance: a test suite built from production history.
Before a new rule version ships, run it against the last few thousand stored inputs and compare outcomes to what actually happened:
$ pnpm replay --candidate underwriting@v14 --baseline underwriting@v13 --last 5000
outcomes changed: 212 / 5000 (4.2%)
approved -> declined: 31
declined -> approved: 181
top reason codes driving change: CASHFLOW_VOLATILITY (140), SECTOR_CAP (52)
A product manager can see that a "small tweak" would have flipped 181 declines, and decide whether that is the intent. Risk gets a number before release instead of a surprise after it. The same harness works as a regression test in CI: pin a golden set of inputs and fail the build if the outcomes change without an explicit approval.
One caveat to state plainly. Replay reproduces the rules engine, not the world. If a decision depends on a model, pin the model artifact and its version as part of the rule spec. If it depends on an external call, the snapshot is the record and the live source is irrelevant.
Where a small team goes wrong
Retrofitting is cheap when the service already has a clean seam between data fetching and rules. It gets expensive when rules are tangled with I/O, because the real work is untangling them. Retrofits tend to stall at this point, since the feature roadmap does not wait.
This is the kind of work where an embedded engineer with backend depth in PostgreSQL and TypeScript pays off. It is unglamorous, it touches the core of the product, and it needs someone who will stay long enough to see the replay harness adopted by the team that owns the rules. We hire engineers for that specific profile and embed them in the client's team, so the refactor is owned by someone who knows the codebase, not delivered as a document.
Key Takeaways
- Store the inputs and the rule version with every decision. The outcome alone cannot be explained later.
- Keep the evaluator pure. Fetch external data first, snapshot it, then evaluate.
- Make rule versions immutable and revoke UPDATE and DELETE from the runtime database role.
- Decide retention per data class up front, so replayability does not collide with GDPR storage limitation.
- Use replay against historical inputs as the release gate for every rules change.
SifrVentures builds dedicated engineering teams for tech companies. Based in Berlin.
Draft created 2026-10-22 by the CTO agent. Pending partner review before publishing as a LinkedIn article.
SifrVentures builds dedicated engineering teams for tech companies. Based in Berlin. Learn how we work | Read more on our blog
Top comments (0)