An eight-service, three-language simulation of a payment switch: participant banks and PSPs submit ISO 20022 payment messages, the switch authenticates, risk-scores, routes, ledgers, and settles them, and a response goes back to the caller.
This README is the single source of truth for the repo — architecture, message
flow, topics, ports, sequence diagrams, how to run it, and an honest list of
what's fixed vs. still open. (FINAL_ARCHITECTURE.md, referenced by an earlier
version of this file, doesn't exist — consider this file its replacement.)
Companion documents:
INSTALL_AND_VERIFY.md— full step-by-step install, run, and feature-by-feature verification walkthrough.GAPS_VS_REAL_WORLD.md— what it would actually take for this to operate as a licensed, global payment switch (regulatory, legal, and operational gaps — not code bugs).
Contents: 1. Service map · 2. Request flow · 3. Kafka topics · 4. Running it · 5. Known gaps · 6. Fixed this review · 7. Verified consistent · 8. Repo layout · 9. Sequence diagrams
| Service | Language | Port | Metrics | Role |
|---|---|---|---|---|
| edge-gateway | NestJS | 8443 | 9091 | mTLS/HTTPS participant interface, ISO 20022 ingestion |
| core-switch | Go | 8080 | 9097 | Idempotency, transaction state, routing table, participant registry |
| elixir-orchestrator | Elixir/OTP | 4000 | 9093 | Saga: Auth → Risk → Route → Ledger, one GenServer per transaction |
| auth-service | Go | 8081 | 9098 | Participant limit checks (per-txn + daily caps) |
| risk-service | Go | 8082 | 9100 | Velocity + amount risk scoring (APPROVE/SOFT_DECLINE/HARD_DECLINE) |
| go-ledger | Go | 8083 | 9094 | Append-only double-entry ledger (Postgres) |
| go-settlement | Go | 8084 | 9095 | Async multilateral netting + RTGS batch settlement |
| go-hsm | Go | 8085 | 9096 | PKCS#11 sign / verify / MAC (SoftHSM2 in dev) |
Each service also exposes /health, /ready, and /metrics (Prometheus).
auth-service and risk-service are intentionally dependency-free (Go stdlib
only) — see §5 for why, and §6 for what they actually do.
The most important thing to know about this codebase: the saga is strictly sequential, not fan-out/fan-in. Auth, Risk, and Route run one after another, and Ledger only runs after the destination bank has confirmed the transaction. Settlement is asynchronous and happens after the customer already has a response.
Participant (bank app / PSP app / merchant POS / biller)
| HTTPS + mTLS, ISO 20022 payload
v
edge-gateway :8443 --[Kafka: switch.inbound]--> core-switch :8080
(dedupe / idempotency check)
|
[Kafka: switch.orchestrate]
v
elixir-orchestrator :4000
one TxnProcess GenServer per transaction
|
Step 1: AuthStep
- HSM signature verify -> go-hsm :8085 POST /api/v1/verify
- Participant limit check -> auth-service :8081 POST /v1/auth/verify
Step 2: RiskStep
- Velocity / amount score -> risk-service :8082 POST /v1/risk/score
Step 3: RouteStep
- Resolve destination -> core-switch :8080 POST /api/v1/routing/resolve
- Dispatch -> Kafka partner.requests -> destination bank -> Kafka partner.responses
Step 4: LedgerStep (only runs after partner responds SUCCESS)
- Double-entry write -> go-ledger :8083 POST /api/v1/entries
|
on saga success, two things fire in parallel:
-----------------------------------------------------------------
| |
v v
Kafka switch.outbound Kafka settlement.events
-> edge-gateway -> HTTP response to caller -> go-settlement :8084
(async netting -> RTGS)
Any saga step failure triggers compensation in reverse order (each step's
compensate/1), not a raw error bubble-up.
| Topic | Producer | Consumer(s) | Consumer group |
|---|---|---|---|
switch.inbound |
edge-gateway | core-switch | core-switch |
switch.inbound.dlq |
core-switch | none wired up | - |
switch.orchestrate |
core-switch | elixir-orchestrator | orchestrator |
partner.requests |
elixir-orchestrator | destination bank / partner adapter | - |
partner.responses |
partner adapter | elixir-orchestrator | orchestrator-partner-resp |
switch.outbound |
elixir-orchestrator | edge-gateway | edge-gateway-group |
settlement.events |
elixir-orchestrator | go-settlement | settlement-engine |
tx.ledger.append |
nothing publishes to this | go-ledger | ledger-service |
tx.audit |
all services | none (write-only audit trail) | - |
All 9 topics are created consistently by infrastructure/create-topics.sh and checked
by verify-all.sh. Two of them are dead weight, not bugs:
tx.ledger.append: go-ledger has a full consumer for it, but nothing publishes to it —LedgerStepwrites synchronously over HTTP instead. Harmless, just unused.switch.inbound.dlq: created and written to on failure, but nothing currently consumes/alerts on it — worth wiring up a dead-letter monitor eventually.
# Start the full stack
docker-compose -f infrastructure/docker-compose.yml up -d
# Wait for all services to report healthy (~60s)
sleep 60
# Sanity-check every service and topic
bash verify-all.sh
# Send a test payment (no X-Signature — a non-empty one triggers real HSM
# verification against a per-participant key that won't exist yet; see the
# full install/verify guide for how to generate one and sign for real)
curl -X POST http://localhost:8443/v1/gateway/message \
-H "Content-Type: application/json" \
-H "X-Participant-ID: HDFC" \
-H "X-Nonce: $(uuidgen)" \
-H "X-Timestamp: $(date +%s%3N)" \
-d '{
"messageType": "payment.request",
"payload": "<pain.001/>",
"data": {
"idempotencyKey": "e2e-001",
"amount": 150000,
"currency": "INR",
"debtorAccount": "1234567890",
"creditorAccount": "0987654321",
"debtorBank": "HDFC",
"creditorBank": "SBI"
}
}'Before any of this will work, see the "required setup" and "critical gap" items below — as committed, the repo won't build (missing lockfiles) and a submitted transaction won't complete the saga (missing auth/risk handlers).
| Tool | URL |
|---|---|
| Jaeger (traces) | http://localhost:16686 |
| Prometheus | http://localhost:9099 |
| Grafana | http://localhost:3000 |
Previously, AuthStep's participant-limit check and all of RiskStep called
AUTH_SERVICE_URL/RISK_SERVICE_URL, both pointed at http://core-switch:8080 —
a service with no handler for either route. Every call 404'd and the saga halted
on every transaction.
This is now fixed the way elixir-orchestrator/ARCHITECTURE.md always intended:
two real, standalone services — auth-service and risk-service — added
to the compose stack, with AUTH_SERVICE_URL/RISK_SERVICE_URL pointed at them
instead of core-switch. No changes were needed on the orchestrator side — its
calls to /v1/auth/verify, /v1/risk/score, and /v1/risk/decrement already
matched exactly once something was actually listening. See §6 for what each one does.
None of the five backend services ship a lockfile, but every Dockerfile expects one:
core-switch,go-ledger,go-settlement,go-hsm— nogo.sum, but eachDockerfiledoesCOPY go.mod go.sum ./.elixir-orchestrator— nomix.lock, but itsDockerfiledoesCOPY mix.exs mix.lock ./.
docker build will fail immediately on all five with "file not found." Run
go mod tidy in each Go service and mix deps.get in the orchestrator (needs normal
internet access) once, commit the resulting lockfiles, and this goes away permanently.
ps-e2e(the Go integration test module) also has nogo.sum— same fix.switch.inbound.dlqhas no consumer/alerting wired up yet.- edge-gateway has 65 remaining
npm auditfindings (28 moderate, 34 high, 3 low) in@nestjs/*,@opentelemetry/*, andmulter— all in packages actually used in the code (unlike the two criticals already removed, see §6 item 10). Fixing these means a major-version bump (npm audit fix --force), which is a real upgrade to plan/test rather than a safe drop-in — not done here.
These were found and corrected in this repo (not just documented):
- The saga couldn't complete a single transaction.
AuthStepandRiskStepcalled anauth-service/risk-servicethat didn't exist anywhere in the repo — every call 404'd against core-switch, which was standing in for both. Built two real services instead of patching around it:auth-service(POST /v1/auth/verify) — checks a submitted amount against a per-participant table of per-transaction and rolling-daily limits (seeded with HDFC/SBI/ICICI/AXIS plus a conservative default for unknown participants), returns 403 with a reason on suspension/limit breach, 200 with the remaining daily headroom otherwise.risk-service(POST /v1/risk/score,POST /v1/risk/decrement) — scores each transaction from two signals: a 60-second sliding-window velocity count persource_account, and an amount tier. Combined score maps toAPPROVE(score < 0.7),SOFT_DECLINE(0.7–0.9), orHARD_DECLINE(≥ 0.9) — matchingRiskStep's expected decision strings exactly./v1/risk/decrementundoes the last velocity hit for compensation.- Both are dependency-free (Go stdlib only,
net/http/encoding/json— no external module needed at all), specifically so they don't inherit the missing-go.sumproblem described in §5 below, and so I could actually compile and functionally test them in this environment rather than just writing code that looked plausible. Both were run and hit with real requests before being wired in. - Wired into
docker-compose.ymlon ports 8081/8082, with health checks, and added to the orchestrator'sdepends_on. Zero changes needed to the Elixir saga code — its calls already matched these exactly once something real was listening.
- edge-gateway wouldn't
npm install—@opentelemetry/api@^1.8.0conflicted with@opentelemetry/sdk-node@0.48.0's peer requirement (<1.8.0). Pinned to^1.7.0. - edge-gateway was missing dependencies —
rediswas imported in code but never declared (onlyiorediswas);@types/expresswas missing too. Both added. - edge-gateway/Dockerfile never copied
package-lock.jsoninto the build context before runningnpm ci. Fixed. - docker-compose port collision —
core-switch's metrics port was mapped to the same host port as Kafka's broker port (9092:9090vs. Kafka's9092:9092), which would prevent both containers from starting. Remapped core-switch to9097:9090. - Every healthcheck in the stack was broken.
core-switch,go-ledger, andgo-settlementrun ondistroless/staticimages (no shell, nowget— theCMD-SHELL wget ...healthchecks couldn't even execute).hsm-connectorandorchestratorrun ondebian-slimbut never hadwgetinstalled. Sinceedge-gateway,orchestrator, andsettlement-engineall depend on these viacondition: service_healthy,docker-compose upwould have hung indefinitely waiting for containers that could never report healthy. Fixed by adding a tiny stdlib-only Go healthcheck binary to each distroless image (exec-form, no shell needed) and addingwgetto the two debian-slim images. - edge-gateway's own health endpoint was unreachable —
app.setGlobalPrefix('v1')had no exclusions, so/healthand/readyonly existed at/v1/health//v1/ready, while every healthcheck andverify-all.shhit the unprefixed path (404, always). Fixed withexclude: ['health', 'ready']. - Go source formatting — all four original Go services were unformatted; ran
gofmt -wacross all of them (confirmed viagofmt -dthese were purely cosmetic, not syntax errors). - Repo cleanup — removed a large number of stray empty directories left over from
a brace-expansion mistake (e.g.
go-ledger/internal/{config,domain,...}as a literal directory name) scattered across four services, plus a staleFINCH_POOL_PATCH.exleftover note inelixir-orchestrator(the fix it described was already correctly applied inapplication.ex). - Two critical CVEs, both dead code.
npm auditon edge-gateway flagged 67 vulnerabilities; the two critical ones werexml-crypto(signature verification bypass / spoofing —GHSA-...) andxmldom(multiple XML injection advisories, no fix available). Checked whether either is actually reachable: neither is imported anywhere insrc/— both were declared inpackage.jsonbut never used (signature verification actually goes throughgo-hsm, not a local XML library). Removed both entirely rather than chasing a fix for code that doesn't run — regenerated the lockfile and confirmed the build and CVE count both come out clean (67 → 65, both criticals gone). The remaining 65 (28 moderate, 34 high, 3 low) are in@nestjs/*,@opentelemetry/*, andmulter— all actually used, all require a major-version bump to fix (npm audit fix --force, breaking changes), and weren't force-upgraded here since that's a real upgrade to plan and test, not a drop-in patch. Worth doing as a follow-up, particularly the@nestjs/coreinjection advisory and themulterDoS advisories. iorediswas dead weight — declared as a dependency but never imported anywhere insrc/(the code that actually talks to Redis,replay.prevention.ts, uses theredispackage). Removed it; one less thing to patch, one less thing to be confused about next time someone reads this codebase.ledger-redis(port 6380) had zero monitoring. There are two Redis instances in this stack —redis:6379(core-switch idempotency, edge-gateway replay prevention) andledger-redis:6380(go-ledger's balance cache) — but only oneredis-exporterexisted, pointed at the first. The second was completely dark to Prometheus. Added a second exporter (ledger-redis-exporter) and a matching scrape job.- Two of three Prometheus alert-rule files were silently being dropped.
docker-compose.ymlmountedgo-ledger/monitoring/alerts,go-settlement/monitoring/alerts, andgo-hsm/monitoring/alertsall as directory volumes to the same container path (/etc/prometheus/alerts). Docker doesn't merge directory mounts to an identical target — only the last one in the list (go-hsm's) actually appeared in the container, soledger_alerts.ymlandsettlement_alerts.ymlwere never loaded despite looking correctly configured. Switched all three to file-level mounts (each.ymlto its own path within the same target directory), which Docker does support side by side. - Grafana had no datasource at all. It ran with anonymous access enabled
but no provisioning and no volume — opening it showed an empty instance,
and any datasource added by hand through the UI wouldn't have survived a
container restart anyway (nothing was persisting it). Added
infrastructure/grafana/provisioning/datasources/datasources.yml(Prometheus + Jaeger, both auto-configured on boot) and mounted it in. verify-all.sh's Kafka checks would fail for most people, for two compounding reasons. It calledkafka-topics.sh/kafka-consumer-groups.shas if they were installed on the host — most machines don't have the Confluent CLI tools lying around, and this was never listed as a prerequisite anywhere. And even with them installed, Kafka only advertised itself askafka:9092(KAFKA_ADVERTISED_LISTENERS) — a host-side client would get past the initial bootstrap tolocalhost:9092but fail on every real request once it followed up against an address that only resolves inside the docker network. Fixed both:verify-all.shnow runs the CLI tools inside the kafka container itself (docker compose exec kafka ..., no host install needed), and Kafka now has a genuine second listener (localhost:29092) for anyone who does want to point their own local tools directly at the broker.otel-collectorexposed a port nothing was listening on.docker-compose.ymlpublished13133(the conventional OTel Collector health-check port), butcollector.ymlnever actually configured thehealth_checkextension — same "port exposed, nothing behind it" pattern as the earlierauth-service/risk-servicemetrics-port bug. Added the extension so the port is real. Deliberately did not add a DockerHEALTHCHECKfor this one, though — theotel/opentelemetry-collector-contribimage is minimal (likely no shell, same as the distroless Go images), and adding aCMD-SHELLprobe without being able to verify that first would risk reintroducing exactly the broken-healthcheck bug from item 6.curl http://localhost:13133from the host now genuinely works if you want to check it by hand.
settlement.eventsmessage contract: the Elixir producer's fields match Go'sSettlementEventstruct tag-for-tag.- All 9 Kafka topic names and all 6 consumer group names match exactly across
create-topics.sh,verify-all.sh, and every service's actual code — no drift. - HSM caller identities (
edge-gateway,core-switch,elixir-orchestrator,settlement-engine) matchALLOWED_CALLERSin docker-compose exactly. RouteStep-> core-switch (/api/v1/routing/resolve) andLedgerStep-> go-ledger (/api/v1/entries,/api/v1/entries/reverse) and the HSM signature-verify call (/api/v1/verify) all match their target service's actually-registered routes.AuthStep-> auth-service (/v1/auth/verify) andRiskStep-> risk-service (/v1/risk/score,/v1/risk/decrement) — actually built, compiled, and hit with real requests (approve / per-txn-limit reject / unknown-participant / velocity escalation to SOFT_DECLINE and HARD_DECLINE) before being wired into compose.- No other duplicate host ports or Dockerfile path/COPY mismatches found.
- Every DB connection env var (
POSTGRES_HOST/PORT/USER/PASSWORD/DB) thatcore-switch,go-ledger, andgo-settlementread matches what's actually set indocker-compose.yml, and matches the right Postgres instance (postgresfor core-switch;ledger-postgres, shared, for go-ledger + go-settlement). - Every
REDIS_ADDRS/REDIS_HOSTenv var read bycore-switch,go-ledger, andedge-gatewaymatches what's set, and each points at the right one of the two Redis instances (redis:6379vs.ledger-redis:6380). - The OTel collector's own config (
infrastructure/otel/collector.yml) exports tojaeger:14250, matching Jaeger's actual exposed port; all 6 services that reference theotel-envanchor (correctly excluding the 2 dependency-free new services) actually initialize an OTel exporter in their own code (internal/observability/tracer.goin each Go service). kafka-setup's broker address, the migration files each Postgres container mounts, and every alert-rule file Prometheus expects to find, all actually exist on disk at the paths referenced.
edge-gateway/ NestJS - mTLS ingress, ISO 20022 -> internal format
core-switch/ Go - idempotency, routing table, participant registry
elixir-orchestrator/ Elixir - saga (Auth -> Risk -> Route -> Ledger)
auth-service/ Go - participant limit checks (stdlib only, no deps)
risk-service/ Go - velocity + amount risk scoring (stdlib only, no deps)
go-ledger/ Go - double-entry ledger (Postgres)
go-settlement/ Go - async netting + RTGS batch settlement
go-hsm/ Go - PKCS#11 signing/verification (SoftHSM2 in dev)
ps-e2e/ Go - end-to-end integration tests
infrastructure/ docker-compose.yml, Kafka topic setup, Prometheus config,
Grafana datasource provisioning, OTel collector config
docs/sequence-diagrams/ Mermaid source for every flow diagrammed in §9
verify-all.sh Post-startup smoke test across every service and topic
Each service also has its own ARCHITECTURE.md with service-specific design notes
(circuit breakers, connection pooling, retry policy, etc.) — this file is the
cross-service picture.
Mermaid source for each — renders automatically on GitHub, or paste into
https://mermaid.live to view/edit. The same source files live standalone at
docs/sequence-diagrams/*.mermaid.
sequenceDiagram
autonumber
participant P as Participant Bank/PSP
participant EG as edge-gateway
participant K as Kafka
participant CS as core-switch
participant OR as orchestrator
participant HSM as go-hsm
participant AU as auth-service
participant RI as risk-service
participant DB as Destination Bank
participant LE as go-ledger
participant SE as go-settlement
P->>EG: POST /v1/gateway/message (ISO 20022)
EG->>K: publish switch.inbound
K->>CS: consume switch.inbound
CS->>CS: idempotency check (dedupe on key)
CS->>K: publish switch.orchestrate
K->>OR: consume switch.orchestrate
Note over OR: one TxnProcess GenServer per transaction
rect rgb(240, 248, 255)
Note over OR,HSM: Step 1 — AuthStep
OR->>HSM: POST /api/v1/verify (signature)
HSM-->>OR: 200 valid
OR->>AU: POST /v1/auth/verify (participant limit check)
AU-->>OR: 200 approved, remaining_daily_limit
end
rect rgb(255, 250, 240)
Note over OR,RI: Step 2 — RiskStep
OR->>RI: POST /v1/risk/score
RI-->>OR: 200 {score, decision: APPROVE}
end
rect rgb(240, 255, 240)
Note over OR,DB: Step 3 — RouteStep
OR->>CS: POST /api/v1/routing/resolve
CS-->>OR: destination route
OR->>K: publish partner.requests
K->>DB: deliver payment request
DB->>K: publish partner.responses (SUCCESS)
K->>OR: consume partner.responses
end
rect rgb(255, 240, 245)
Note over OR,LE: Step 4 — LedgerStep (only after partner SUCCESS)
OR->>LE: POST /api/v1/entries (double-entry write)
LE-->>OR: 200 posted
end
par response to customer
OR->>K: publish switch.outbound
K->>EG: consume switch.outbound
EG-->>P: HTTP 200 {correlation_id, status: ACCEPTED}
and async settlement (does not block the customer response)
OR->>K: publish settlement.events
K->>SE: consume settlement.events
SE->>SE: update net position (per participant pair, currency)
end
sequenceDiagram
autonumber
participant P as Participant Bank/PSP
participant EG as edge-gateway
participant K as Kafka
participant OR as orchestrator
participant AU as auth-service
participant RI as risk-service
P->>EG: POST /v1/gateway/message
EG->>K: publish switch.inbound
K->>OR: (via core-switch → switch.orchestrate)
alt Auth step rejects (limit exceeded / suspended)
OR->>AU: POST /v1/auth/verify
AU-->>OR: 403 {approved: false, reason}
Note over OR: no prior steps to compensate — saga halts immediately
OR->>K: publish switch.outbound (DECLINED, reason)
K->>EG: consume switch.outbound
EG-->>P: HTTP 200 {status: DECLINED, reason}
else Auth approves, Risk hard-declines
OR->>AU: POST /v1/auth/verify
AU-->>OR: 200 approved
OR->>RI: POST /v1/risk/score
RI-->>OR: 200 {score >= 0.9, decision: HARD_DECLINE}
OR->>RI: POST /v1/risk/decrement (compensate: undo velocity hit)
RI-->>OR: 200
OR->>K: publish switch.outbound (DECLINED, reason)
K->>EG: consume switch.outbound
EG-->>P: HTTP 200 {status: DECLINED, reason}
else Destination bank rejects at Route step
Note over OR: Auth + Risk already passed
OR->>OR: RouteStep dispatches, partner.responses = FAILURE
OR->>RI: POST /v1/risk/decrement (compensate)
Note over OR: LedgerStep never runs — no entry to reverse
OR->>K: publish switch.outbound (DECLINED, reason: partner rejected)
K->>EG: consume switch.outbound
EG-->>P: HTTP 200 {status: DECLINED, reason}
end
Note: AuthStep.compensate/1 is a documented no-op ("Auth is read-only — no
side-effects to undo") — but auth-service does increment a rolling daily
usage counter on approval, and there's no corresponding decrement endpoint.
A transaction that passes Auth but fails later (Risk/Route/Ledger) currently
consumes that participant's daily limit headroom permanently. Small, real gap
— flagged here rather than fixed, since fixing it means adding a genuine
/v1/auth/decrement endpoint and deciding the right semantics for it.
sequenceDiagram
autonumber
participant P as Participant Bank/PSP
participant EG as edge-gateway
participant CS as core-switch
Note over P,CS: First submission
P->>EG: POST /v1/gateway/message (idempotencyKey = X)
EG->>CS: publish switch.inbound
CS->>CS: lookup idempotencyKey X → not found
CS->>CS: store X → PROCESSING, forward to orchestrator
CS-->>EG: 202 accepted
EG-->>P: HTTP 200 {correlation_id, status: ACCEPTED}
Note over P,CS: Retry with the same idempotency key (network timeout, client retry, etc.)
P->>EG: POST /v1/gateway/message (idempotencyKey = X, identical payload)
EG->>CS: publish switch.inbound
CS->>CS: lookup idempotencyKey X → found (already processed/processing)
CS-->>EG: original result — NOT reprocessed, no second orchestrator run
EG-->>P: HTTP 200 — same correlation_id and status as the first call
sequenceDiagram
autonumber
participant Op as Operator (one-time setup)
participant HSM as go-hsm
participant EG as edge-gateway
participant P as Participant Bank/PSP
participant OR as orchestrator (AuthStep)
Note over Op,HSM: One-time: generate a signing/verify key for a participant
Op->>HSM: POST /api/v1/keys/generate<br/>{id: "HDFC:verify-key", algorithm, purpose}
HSM-->>Op: 200 key created
Note over P,HSM: Participant signs their payload before sending
P->>HSM: POST /api/v1/sign<br/>{key_id, payload, algorithm: ECDSA-SHA256}
HSM-->>P: 200 {signature}
P->>EG: POST /v1/gateway/message<br/>headers: X-Signature = signature
EG->>OR: (forwarded through switch.inbound → switch.orchestrate)
Note over OR,HSM: AuthStep — Step A, signature verification
OR->>HSM: POST /api/v1/verify<br/>{key_id, payload, signature}
alt signature valid
HSM-->>OR: 200 {valid: true}
Note over OR: proceeds to Step B (participant limit check)
else signature invalid / wrong key
HSM-->>OR: 200 {valid: false} or 401
OR->>OR: saga halts — request rejected
end
sequenceDiagram
autonumber
participant OR as orchestrator
participant K as Kafka
participant SE as go-settlement
participant PG as ledger-postgres (shared with go-ledger)
participant Op as Operator / scheduler
Note over OR,PG: Continuous — one event per completed transaction
loop for every transaction that reaches LedgerStep successfully
OR->>K: publish settlement.events<br/>{source_bank, destination_bank, amount, currency, ...}
K->>SE: consume settlement.events (group: settlement-engine)
SE->>PG: update running net position<br/>(per participant pair, per currency)
end
Note over Op,SE: Separately — a settlement cycle is triggered on a schedule or manually
Op->>SE: POST /api/v1/settlement/trigger {cycle_type: T0, currency}
SE->>PG: read current net positions for currency
SE->>SE: compute multilateral net obligations per participant
Note over SE: In this repo, this step is simulated —<br/>see GAPS_VS_REAL_WORLD.md §3 for what real RTGS<br/>connectivity and settlement-finality guarantees require
SE->>PG: mark included positions as settled for this cycle
SE-->>Op: 200 {cycle_id, positions_settled, net_amounts}
