BDR Gate Systems

Architecture

Django 5 LTS, PostgreSQL 16, Redis and Celery, deployed in ap-south-1. The interesting parts are not the components but three decisions about where guarantees live.

1. State transitions are enforced three times

Each entity — the physical bar, the depository receipt, the loan — has a declared lifecycle: a list of states and the edges between them. That single list drives three independent enforcement points.

LayerWhat it catches
Service guard The ordinary path, with an error message an operator can act on.
Database trigger Anything that bypasses the service: raw SQL, a management shell, a future bug. The trigger is generated from the same edge list, so the database and the application cannot describe different machines.
Audit record Every refusal is written to the chain, so “show me a blocked state jump” is answerable from records rather than from logs that rotate.

A CHECK constraint cannot do the second job, because it cannot see the row’s previous value, and the whole question is which edge is being traversed. A BEFORE UPDATE trigger can, and does.

2. Multi-party flows are sagas, not distributed transactions

Settlement and loan origination each touch systems we do not control. Both are persisted sequences of steps, each with a named compensation, executed forward and unwound in reverse on failure.

ForwardCompensation
Reserve fundsRelease the reservation
Lock the receiptUnlock it
Transfer at the depositoryReverse the transfer
Commit paymentReverse the payment

The ordering is the design: the cheapest reversible action happens first, so a late failure never leaves an expensive one standing.

The in-doubt case

A call that times out leaves the remote state genuinely unknown — the instruction may have been applied. The saga does not guess. It marks the step in doubt, runs compensation anyway because every compensation is idempotent, and escalates to a human.

Compensating a step we hold no reference for would otherwise silently skip it, leaving a real transfer standing after a settlement we recorded as unwound. So the saga asks the counterparty what our idempotency key produced. Any real integration must offer that lookup; it is on our partner questionnaire for exactly that reason.

3. The audit log is append-only, chained and signed

Each record carries the hash of its predecessor and a signature over the pair of its sequence number and its own hash. Editing a row breaks its hash; deleting one breaks the sequence and the following link; repairing both still fails the signature, because the signing key is not held by the application.

Three enforcement points again: the model refuses updates and deletes, UPDATE and DELETE are revoked on the table for the application role, and the chain makes any rewrite that gets past both detectable. Verification is an endpoint you run yourself.

Refusals outlive the thing they refused

A denial recorded inside the transaction that is failing would roll back with it — leaving no trace of the refusal. Recording it on a second connection inside the same thread deadlocks against the lock the failing transaction already holds.

So refusals are staged with a lock-free write and folded into the chain once the transaction has ended. They are durable immediately and chained moments later: never lost, and never able to deadlock the operation they were refusing.

Integration boundaries

IIDI, IIBX, the banking unit and the price oracle each sit behind a protocol with two implementations: a stateful local simulator with injectable failure modes, and a real adapter written once the counterparty provides their schema. The domain never learns which it is talking to, and adopting a real specification is a class change rather than a rewrite.

The simulators are not mocks. They hold real state, honour the idempotency contract, and can be told to fail in specific ways — including succeeding and then losing the response, which is the failure the compensation logic exists for. A mock that always succeeds proves the happy path and nothing else.