Transaction Science open standards monorepo. Each subdirectory is a self-contained reference protocol with its own README and licence. - openpay/ Apache-2.0 payment-acceptance stack (Rust, 26 crates) - smart-byte/ CC-BY-4.0 content-addressed value carrier (spec) - eoc/ CC-BY-4.0 energy-optimised compute (spec) The wire format and the right to fork are public. The optional hosted services are the offer; the protocols are not.
14 KiB
Phase 5 — op-rails-a2a Complete (Core Rails)
Status: Draft v0.5 Date: 2026-05-17
What shipped
op-rails-a2a: account-to-account / instant-payment rails. Three production-grade
drivers — FedNow, PIX, SEPA Instant (RT1 + TIPS) — behind one A2aAcquirer
trait that mirrors the CardAcquirer pattern from Phase 4.
This is where the economic differentiation lives. Cards through Hyperswitch cost 2.5–3.5% per transaction; A2A rails cost cents flat. Phase 5 is the cost-unlock.
Crate layout
crates/op-rails-a2a/
├── Cargo.toml # default features = fednow + pix + sepa-instant
├── src/
│ ├── lib.rs # crate root, re-exports, feature gating
│ ├── acquirer.rs # A2aAcquirer trait, A2aDecision, A2aStatus, ParticipantId
│ ├── error.rs # sealed Error: Transport, RailRejected, Iso20022, UnknownStatus,
│ │ # UnsupportedMethod, UnsupportedA2aKey, CurrencyMismatch, Signing,
│ │ # Core, DriverValidation
│ ├── signer.rs # Signer trait + NoOpSigner (operator plugs in HSM)
│ ├── xml_common.rs # shared pacs.002 parser, decimal formatter, XML escaping
│ ├── fednow/
│ │ ├── mod.rs
│ │ ├── mq.rs # MqChannel trait, MqMessage / MqResponse types
│ │ ├── client.rs # FedNowMqClient + FedNowApiClient (FedLine VPN REST)
│ │ ├── status_map.rs # ISO TxSts → A2aStatus, ExternalStatusReason1Code subset
│ │ └── xml.rs # FedNow pacs.008.001.08 emitter
│ ├── pix/
│ │ ├── mod.rs
│ │ ├── client.rs # PixClient: mTLS + OAuth (RFC 8705) + Signer + local emitter
│ │ └── status_map.rs # ISO TxSts + Bacen ACCC
│ └── sepa_instant/
│ ├── mod.rs
│ ├── client.rs # SepaInstantClient: backend enum (Rt1, Tips) + local emitter
│ └── status_map.rs
├── tests/
│ └── conformance.rs # cross-rail vector parsing + agreement checks
└── vectors/
├── fednow_pacs008_outbound.xml
├── pacs002_acsc.xml
├── pacs002_rjct_ac03.xml
└── sepa_instant_pacs008_outbound.xml
Verified ground truth (sources fetched live during construction)
FedNow
| Claim | Source |
|---|---|
| Message transport is IBM MQ over FedLine Direct or FedLine Advantage | FedNow Service Guide to FedLine Connectivity |
| Service Operating Procedures v3.2 are current as of June 2025 | frbservices.org, June 24, 2025 release |
| Every participant has an Authorized Connection Profile (ACP) | Service Operating Procedures v3.2 §Connection |
Profile identifier is pacs.008.001.08 |
FedNow MyStandards profile |
| Status codes are ACTC, ACSC, RJCT, PDNG | ISO 20022 pacs.002.001.10 schema |
| ABA RTN is the participant identifier (US clearing system "USABA") | FedNow ISO 20022 Implementation Guide |
| API access requires FRB-issued certificates over FedLine VPN | Connectivity Guide §APIs |
PIX
| Claim | Source |
|---|---|
| Transport is HTTPS to ICOM with mandatory mTLS | Bacen Manual de Iniciação v2.6.3 §3.1, items 2 + 9 |
| OAuth 2.0 + client-cert-bound tokens per RFC 8705 §3 | Manual de Iniciação §3.1 item 2c |
| Webhooks also require mTLS | Manual §3.1 item 9 |
| ISPB is the participant identifier — 8 digits, Bacen-assigned | bacen/pix-api §Direct Participant |
| Homologation: 20K tx / 10 min SPI, 1K key/sec DICT | bacen/pix-api homologation tests |
| AWS reference architecture uses CloudHSM as mandatory signing path | github.com/aws-samples/pix-proxy-samples |
| Currency is BRL only at rail level | Bacen SPI regulation |
SEPA Instant (RT1 + TIPS)
| Claim | Source |
|---|---|
| EBA Clearing RT1 has 94 participants | ebaclearing.eu/services-instant-payments/rt1 |
| Message is pacs.008.001.08 — single tx per message, no bulk | cpg.de §SCT Inst pacs.008 |
Mandatory LocalInstrument.Code = "INST" |
EPC SCT Inst Interbank IG 2019 v1.0 |
Mandatory ServiceLevel.Code = "SEPA" |
EPC SCT Inst Interbank IG 2019 v1.0 |
NbOfTxs must be 1 |
EBA RT1 + TIPS validation rules |
| EUR only at scheme level | EPC SCT Inst Interbank IG 2019 v1.0 |
| 10s SLA → 5/7/9s sub-timelines coming per 2025 SEPA Reg amendment | ECB TIPS CR 0087-SYS |
| Status codes ACCP (optional) and RJCT (mandatory) | cpg.de §pacs.002 in SCT Inst |
| 22 November 2026 structured-only postal address deadline | ECB TIPS alignment to EPC 2025 v1.2 |
Architecture: one trait, three rails
trait A2aAcquirer: Send + Sync {
fn name(&self) -> &'static str;
fn submit_credit_transfer(&self, req: &CreditTransferReq) -> Result<A2aDecision>;
fn query_status(&self, req: &StatusQueryReq) -> Result<A2aDecision>;
}
The orchestrator routes purely on op_core::PaymentMethod::A2a(A2aKey::*). A
UsAch keypair gets the FedNow driver; a Pix key gets the PIX driver; an
Iban gets the SEPA Instant driver. Same pattern as CardAcquirer from Phase 4.
Adding UPI (Phase 5.1) is a new upi/ module that implements the same trait.
UPI uses a different message family (NPCI's own format, not ISO 20022 pacs.008),
so the driver internals diverge, but the public API is identical.
A2aStatus normalization
| Status | Meaning | funds_moved | is_retryable | is_failure | needs_polling |
|---|---|---|---|---|---|
Settled |
ACSC — final settlement | ✓ | |||
Accepted |
ACCP / ACTC — accepted, may not be final | ✓ | |||
InProgress |
ACSP — settlement in flight | ✓ | |||
Pending |
PDNG — waiting | ✓ | |||
Rejected |
RJCT — definitive refusal | ✓ | |||
Transient |
network / timeout — retry-safe | ✓ | |||
OperationalError |
auth / quota / schema fail | ✓ |
Unknown status codes return Error::UnknownStatus(s) rather than guessing — same
discipline as Phase 4's Hyperswitch driver. If FedNow or Bacen introduces a new
status code, we get a loud error rather than silently misclassifying funds.
Design decisions
1. Shared XML utilities live in xml_common, profile-specific emitters live in each rail
Originally I built format_money, xml_escape, and parse_pacs002 inside
fednow/xml.rs and tried to call them from PIX and SEPA. That made PIX and SEPA
silently depend on the fednow feature flag. Extracted to a feature-independent
xml_common.rs. PIX and SEPA still emit their own profile-specific XML bodies
because the schemas diverge in non-trivial ways (USABA vs BRSPB vs BICFI;
Othr/Id vs IBAN; FedNow has no LclInstrm, SEPA requires it).
2. BuiltCreditTransfer<P> is constructed only by the builder
I initially wrote a transmute_profile helper to convert
BuiltCreditTransfer<Pix> → BuiltCreditTransfer<FedNow> so PIX could reuse
the FedNow XML emitter. This would not have compiled — _profile: PhantomData<P>
is a private field in op-iso20022. The fix is the right design anyway: each
rail emits its own XML. Caught at design-review time, not at cargo build time
(no Rust toolchain in sandbox), via reading the actual op-iso20022 source.
3. MQ transport is operator-supplied via MqChannel
IBM MQ is heavy, licensed, and deployment-specific. We define a trait the
operator implements over their existing MQ client / bridge / sidecar. OpenPay
itself embeds no MQ client. FedNowMqClient::new(Arc<dyn MqChannel>, sender_rtn)
is the seam.
4. PIX signer is operator-supplied via Signer
The private key MUST live in an HSM (AWS CloudHSM, on-prem nCipher, etc.).
OpenPay never sees the private key. The Signer trait returns signature bytes
that the PIX client puts in the x-signature header; key_id() goes in
x-signature-key-id. NoOpSigner ships for tests.
5. mTLS configuration lives on the ureq::Agent
Each rail's client takes an operator-constructed ureq::Agent. The operator
configures client certificates on the agent before passing it in. We expose
new_unsecured constructors for tests; production must use new with a
properly-configured agent.
6. Each driver is independently feature-gated
default = ["fednow", "pix", "sepa-instant"]. A deployment that only does
US-domestic flows can compile with --no-default-features --features fednow
and drop the PIX and SEPA dependencies. The conformance tests gate themselves
accordingly so partial-feature builds still pass.
Test coverage
| Module | Tests | What's covered |
|---|---|---|
acquirer.rs |
5 | A2aStatus classification invariants, ParticipantId extraction |
signer.rs |
2 | NoOpSigner round-trip, dyn-compatibility |
xml_common.rs |
15 | Money formatting (5 cases incl. negative), XML escaping, parsing 5 pacs.002 scenarios |
fednow/mq.rs |
2 | MqChannel trait round-trip, dyn-compatibility |
fednow/status_map.rs |
7 | All 5 ISO codes mapped; reason code recognition; unknown errors |
fednow/xml.rs |
3 | Emitter contains required fields, XML escapes special chars, omits empty remittance |
fednow/client.rs |
8 | ACSC → Settled, RJCT → Rejected with reason, USD-only, ABA-only, MQ envelope metadata, payload carries fields, query_status deferred, REST URL construction |
pix/status_map.rs |
4 | ACSC, ACCC (PIX-specific), RJCT, unknown error |
pix/client.rs |
8 | ACSC happy path, BRL-only enforcement, ISPB-only, malformed ISPB, 401 → RailRejected, status query, ISPB validation, base64 RFC 4648 vectors |
sepa_instant/status_map.rs |
4 | ACCP → Accepted, ACSC → Settled, RJCT, unknown |
sepa_instant/client.rs |
8 | RT1 ACCP, TIPS ACSC, EUR-only, BIC-only, short BIC, payload contains <Cd>INST</Cd> + <SvcLvl>SEPA</SvcLvl>, BIC validation, backend names |
tests/conformance.rs |
7 | Vector parsing, cross-rail status agreement, profile sentinels distinguish FedNow vs SEPA outputs |
| Phase 5 total | 73 | |
| Cumulative Phases 1–5 | 289 |
Independently verified by Python before being asserted in Rust
format_moneydecimals: 12345 USD → "123.45", 100 → "1.00", 500 JPY → "500", 5 USD → "0.05", 0 USD → "0.00", 1 USD → "0.01", -250 USD → "-2.50". All seven edge cases cross-checked.- FedNow pacs.008.001.08 minimum field set matches FedNow MyStandards profile (Document namespace, GrpHdr with NbOfTxs=1 + SttlmMtd=CLRG, CdtTrfTxInf with PmtId / IntrBkSttlmAmt / ChrgBr=SLEV / ClrSysId=USABA on both agents).
- SEPA SCT Inst payload includes mandatory
LclInstrm.Cd = INSTandSvcLvl.Cd = SEPAper EPC SCT Inst IG 2019 v1.0, single transaction per message (RT1/TIPS reject bulk), uses<BICFI>and<IBAN>rather than USABA/Othr/Id. - pacs.002 parser extracts
TxSts,OrgnlUETR,OrgnlEndToEndId, reasonCd, andAddtlInffrom sample bodies of both ACSC and RJCT shapes.
Bugs caught and fixed during construction (visible work)
-
Wrong
op-iso20022API mental model. Initially called the builder as.amount(&str, &str).debtor_agent("string")etc. Reading the actual Phase 2 source revealed the real surface:.amount(Money).debtor(PaymentMethod),.debtor_agent(PartyIdentification), returningResult<BuiltCreditTransfer<P>>. Rewrote all three drivers to match. -
transmute_profilewould not have compiled. Tried to constructBuiltCreditTransfer<FedNow>from outsideop-iso20022to share an emitter. The_profile: PhantomData<P>field is private. Caught by reading the source. Refactored each rail to emit its own XML directly. -
Cross-feature dependency hidden in module path. PIX and SEPA were calling
crate::fednow::xml::parse_pacs002— fine whenfednowis on, broken when it's off. Extracted shared helpers to a feature-independentxml_commonmodule. PIX and SEPA now depend only onxml_common. -
Duplicate test definitions. When extracting
xml_common, the originalformat_money,xml_escape,parse_pacs002,extract_first_tagtests stayed insidefednow/xml.rsreferencing items that had moved. Deleted the nine duplicates; the 15xml_commontests now cover them more thoroughly (including a negative-amount case). -
Duplicate
use op_core::Money;after a partialstr_replace. Cleaned up. -
Error::Transportreference in deleted test region. The tests that referenced it were removed alongside the duplicate parsers they tested.
What's NOT in this phase (explicitly deferred)
- UPI driver. UPI uses NPCI's own message family (not ISO 20022 pacs.008) and a different transport (NPCI's gateway, not MQ or HTTPS-pacs). Designing it correctly is its own phase, not a footnote here. → Phase 5.1.
- RTP (The Clearing House). Uses pacs.008.001.08 like FedNow but with TCH's
IPF endpoint and
USRTPclearing system instead ofUSABA. → Phase 5.2. - OCT Inst (one-leg-out instant transfers) on RT1. → Phase 5.3.
- R-transactions / recalls (camt.056 + pacs.004 for SEPA, equivalents for PIX MED 2.0). → Phase 5.4.
- Live integration tests. The
live-sandboxfeature flag exists; tests behind it need real certificates and rail credentials. The httpmock-based tests cover the entire wire shape without network. - Structured postal addresses. Required by the 22 November 2026 SEPA deadline. Phase 5.5 before that date.
- Async migration. Once
async fnindyn Traitstabilizes cleanly we'll migrateA2aAcquireralong withCardAcquirer.
Next: Phase 6 — op-fraud
On-device fraud scoring with Burn + ort. The signal here is that fraud detection runs locally on the merchant device, not in a remote API call. This is what makes FedNow viable at scale — banks won't accept instant credit transfers without instant fraud screening, and a 200ms round-trip to a SaaS fraud API breaks the latency envelope. We build the scoring layer in Rust, ship it as a shared library, and bind it through the FFI layer (Phases 8–10).