« Phase 12 · Warmup · Track Overview
Lab 01 — The Integration Fabric
The problem
The agent has decided. Identity, policy and the action gateway have all said yes. Now the decision has to reach a mainframe.
Between here and there:
- The payment must be expressed in ISO 20022, where a missing element is not a validation error but a rejected file, and where a JPY amount parsed with two decimal places is a payment a hundred times too large.
- The rail you choose determines whether the payment can be recalled at all — and that determines whether a human had to approve it.
- The database write and the event publish are two systems, and a crash between them leaves the bank and the platform disagreeing about what happened.
- Downstream will receive the event more than once, because at-least-once is the only delivery guarantee anyone can actually offer.
- Someone will add a field to the event schema, and whether that breaks production depends on which side deploys first.
- And at the end of the day, two independent records have to agree — or somebody has to own the difference.
You build all of it.
What you build
| # | Component | What it does |
|---|---|---|
| 1 | MessageIdentifier, BUSINESS_AREAS | reading pain.001.001.09 — the map, not the details |
| 2 | to_minor, from_minor, currency_exponent | integer minor units via Decimal, with the non-2-digit currencies |
| 3 | valid_iban, valid_bic | mod-97 and ISO 9362 |
| 4 | Pain001Parser | fourteen rejection codes, every problem at once, each naming its element |
| 5 | Rail, Finality, choose_rail | finality as a scheme rule, and a rail choice that explains itself |
| 6 | DualWriteStore | the problem, demonstrated — deliberately broken |
| 7 | Outbox, OutboxRelay | the fix, with the at-least-once republish it honestly produces |
| 8 | MessageLog, ConsumerGroup | partitions, offsets, lag, and where ordering holds |
| 9 | IdempotentConsumer | exactly-once effects under duplicate delivery |
| 10 | check_backward/forward, SchemaRegistry | compatibility mode as deploy order |
| 11 | DataProductContract, DataProduct | schema + quality + freshness, checked rather than asserted |
| 12 | reconcile | two records, compared; a break is a finding |
Key concepts
| Concept | Where | Why it matters |
|---|---|---|
| The message name is the map | MessageIdentifier | pain = customer→bank, pacs = bank→bank, camt = statements |
| The namespace pins the version | Pain001Parser.parse | a .09 parser must not silently accept a .03 document |
| Integer minor units | to_minor | int(1.15 * 100) is 114, silently, on every payment |
Decimal, never float | to_minor | binary floats cannot represent 0.1, and money is base 10 |
| Not every currency has 2 digits | CURRENCY_EXPONENT | JPY has 0, KWD has 3; assuming 2 is a 100× error |
| Refuse extra precision | to_minor | rounding a payment amount silently is a daily reconciliation break |
| Every rejection at once | parse | one error per round trip, in a bank, is one error per day |
| Rejections name the element | Rejection | CdtTrfTxInf[1]/Amt versus "invalid message" |
| Cross-field checks | CS01, CS02 | NbOfTxs and CtrlSum are what catch a truncated file |
Duplicate EndToEndId | DU01 | downstream keys idempotency on it; a duplicate pays twice |
| Settlement dominates the cut-off | finality_at | an instant payment is final at once, despite having no cut-off |
| Finality is a scheme rule | Finality | irreversible is not an opinion about difficulty |
| The rail choice explains itself | choose_rail | an agent must be able to say why, because it drives approval |
| The dual-write problem has no ordering fix | DualWriteStore | DB-then-publish loses; publish-then-DB invents |
| One transaction, two rows | Outbox.transact | both writes go to the same database, so atomicity covers them |
| The outbox is at-least-once | run_once | it promises no lost events, not no duplicates |
| Derived partitioning | partition_for | hash() is salted per process; ordering would break on restart |
| Ordering is per partition | MessageLog | there is no global order, and asking for one costs the parallelism |
| Commit after processing | ConsumerGroup.commit | commit first and a crash is at-most-once |
| The offset is the next one | commit | off-by-one means re-reading forever or skipping |
seek is separate from commit | seek | a rewind must be deliberate |
| Exactly-once effects | IdempotentConsumer | exactly-once delivery is two-generals-impossible |
| The dedup key is content | key_of | an offset changes under a different partition assignment |
| Mode is deploy order | DEPLOY_ORDER | BACKWARD → consumers first; getting it backwards is a release outage |
| New required fields need defaults | check_backward | old data does not have them |
| A refused registration makes no version | register | otherwise version numbers lie about what was live |
| Freshness makes it a product | DataProductContract | a promise with a number can be breached, so someone can be told |
| A break is a finding | reconcile | it runs daily and somebody is accountable; that is the whole value |
Files
| File | Role |
|---|---|
| lab.py | your implementation |
| solution.py | reference; python solution.py runs a twelve-part worked session |
| test_lab.py | 144 tests |
| requirements.txt | pytest |
Run
pip install -r requirements.txt
pytest test_lab.py -v
LAB_MODULE=solution pytest test_lab.py -v
python solution.py
Success criteria
-
All 144 tests green against your
lab.py. -
to_minor("1.15", "USD") == 115— andto_minor("100", "JPY") == 100. - Extra decimal precision is refused, not rounded.
- An IBAN with one digit changed fails; a BIC with a digit in the institution code fails.
-
A
pain.001with six problems reports all six, sorted, each naming its element. - The wrong namespace is a rejection, not a silent parse.
-
NbOfTxsandCtrlSumdisagreeing with the transactions are both caught. -
A duplicate
EndToEndIdwithin one file is rejected. -
An instant payment is
FINALimmediately; a batch payment before cut-off isREVOCABLE. -
choose_railalways returns a non-empty reason, including when it returns no rail. - A dual-write crash leaves the row and loses the event.
- An outbox crash before commit leaves neither, and consumes no sequence number.
- A relay crash after publishing republishes — at-least-once, honestly.
- The same key always routes to the same partition, in this process and the next.
-
commitcannot rewind;seekcan. - Replaying the whole log five times produces one effect per event.
- A crash before commit re-delivers and does not re-effect.
- Each compatibility mode refuses exactly the right changes, and states its deploy order.
- A refused registration leaves the version count unchanged.
- A product fails on a missing field, a wrong type, a failed rule, or a stale clock.
-
reconcilereports mismatches and both directions of absence, sorted by key.
How this maps to the real stack
| This lab | The real thing | What we simplified |
|---|---|---|
Pain001Parser | an ISO 20022 library + the scheme's own usage guidelines | one message, a subset of elements; no XSD, no CBPR+/HVPS+ market practice |
RAILS | UAEFTS, IPI, WPS, SWIFT gpi | invented cut-offs; real ones vary by currency, day and correspondent |
Outbox | Debezium outbox pattern, or a relay reading a table | no database; no polling interval, no ordering guarantees across aggregates |
MessageLog | Kafka, Azure Event Hubs | in-memory; no replication, no rebalancing, no retention, no compaction |
ConsumerGroup | a Kafka consumer group | no rebalancing — which is where most real bugs live |
IdempotentConsumer | a dedup table in Postgres/Redis with a TTL | an unbounded in-memory set |
SchemaRegistry | Confluent Schema Registry, Azure Schema Registry | no Avro/Protobuf, no serialization, no subject naming strategies |
DataProduct | a Databricks/Cloudera data product with Great Expectations or Soda | no storage, no lineage, no access control |
reconcile | a nightly recon job with a break-management workflow | no tolerance rules, no ageing, no break assignment |
Honest limits. The parser covers one message and a subset of its elements — real ISO 20022 validation is an XSD plus a market-practice guideline (CBPR+ for cross-border, HVPS+ for high value) that constrains what the schema permits, and the guideline is where most rejections come from. The consumer group has no rebalancing, which is the single richest source of real bugs: a partition moving between consumers mid-batch is how you get duplicate processing and lost commits, and it is exactly what makes the idempotent consumer non-optional. The dedup set is unbounded, so in production it needs a TTL and therefore a decision about how late a duplicate can arrive. The outbox has no polling interval, no batch size and no ordering guarantee across aggregates. And the rails are invented: real cut-offs vary by currency, by day, by correspondent bank, and by whether it is a Friday in the UAE.
Extensions
- Add rebalancing. Two consumers, one group, partitions reassigned mid-batch. Watch the duplicate processing appear, then confirm the idempotent consumer absorbs it. This is the exercise that makes the design click.
- Bound the dedup set. Add a TTL, then answer: how late can a duplicate arrive? The answer is the retention of your log, which makes it a capacity decision.
- Validate against the real XSD. Download the pain.001.001.09 schema and run
xmlschemaagainst your fixtures. Then read a CBPR+ guideline and count how many additional rules it imposes. - Add pacs.002. The status report that comes back. Now model the full lifecycle: initiated →
accepted → rejected → settled, and map each to a
Finality. - Compacted topics. Add log compaction keyed on the message key, and work out what it means for a consumer that joins late — it sees the latest state, not the history, which changes what your handler can assume.
- Transactional outbox with CDC. Replace the polling relay with a Debezium-shaped reader over a simulated transaction log, and handle the schema-drift problem it hands you.
- A data-product lineage graph. Record which products a product derives from, then answer
"what breaks if
agent.tracesis late?" (Phase 15). - Reconciliation with tolerance. Real recon allows a small tolerance and ages breaks. Add both, and discover that "how old is the oldest unresolved break" is the metric people actually watch.
Interview / resume bullets
- "Built the platform's payment-initiation path against ISO 20022, with a validator that reports every rejection at once and names the offending element — turning a six-problem file from six days of round trips into one."
- "Represented money as integer minor units with per-currency exponents, which removed an entire class of cross-border defect: JPY has no minor digits and KWD has three, and the two-decimal assumption is a hundred-fold error."
- "Mapped scheme finality onto the platform's side-effect classes, so an agent's autonomy is bounded by the rail it selects — an instant payment requires an approval that a pre-cut-off batch payment does not."
- "Eliminated the dual-write problem with a transactional outbox and idempotent consumers, giving exactly-once effects under at-least-once delivery without any exactly-once delivery claim."
- "Made schema compatibility mode an explicit deploy-order decision rather than a release-day discovery, and enforced it in the registry so an incompatible schema cannot be published."
- "Published the AI platform's traces, evaluations and outcomes as contract-bearing data products with freshness SLOs — applying to ourselves the discipline we asked of every upstream system."