DESIGN.md: Exchange8:RFQ — Detailed Design Document
1. Scope & Context
Owns
- The RFQ engine — the full RFQ lifecycle
Open → Quoting → Quoted → Traded | Expired | Rejected: request routing, quote collection with windows and lateness, hit binding, last-look parking/accept/reject with per-(LP, reason) counters, and the sweep. - The streaming-quote side channel — per-(LP, pair) live two-way books, newest-binds hit semantics, withdrawal.
- The anonymized RFQ tape (
RfqTape) and the last-look analytics scorecard (LastLookAnalytics). - FX pair/tenor master extension (
rfq.fx_pair,rfq.fx_tenor) — the pair metadata (pip size, big figure, settlement convention, NDF flag,quote_type, cut-off) that rides on top ofrefdata.security. - Per-currency exposure records (
rfq.currency_exposure). - The outbox producer (
rfq.outbox, notify channelrfq_outbox_changed) and the audit journal (rfq.audit_journal) for the same writes.
Does NOT own (delegates)
- The CLOB order book for FX pairs traded as limit orders — Exchange8:ME
(pairs with
quote_type ∈ {clob, clob+rfq}are served there). - Client/LP FIX 4.4 and WebSocket session transports and FIX codec — Exchange8:Gateway (sessions are connections; the engine consumes their events).
- The currency-exposure limit policy decisions — Exchange8:Risk (this service records net position per currency/value date; limit evaluation is Risk's).
- Position ledger, novation and settlement of FX trades — Exchange8:Clearing / Exchange8:Settlement / Bank8:Ledger.
- Instrument security master, currency catalog — Exchange8:RefData
(
refdata.security,refdata.currency; read via declared grants). - Public market-data fan-out — Exchange8:MD / Exchange8:VFeeds (the tape is published by RFQ; consumer-facing feeds are theirs).
- Identity, M2M registration, Cron schedules — Sentin8:Auth / Sentin8:Cron.
System context
Container view
2. Runtime Architecture
The binary (src/main.rs) is a Sentin8 Shell assembly:
Sentin8Shell::new("exchange8:rfq", "EXCHANGE8_RFQ") with registration
printing (registration::print), state wiring that connects the MemBus
(sentin8::membus::ServiceBus::connect("exchange8:rfq", valkey_url)), and
with_router(extend_router) that mounts the Shield policy-admin API over
the shared HasPolicyAdmin accessor so the admin surface and the request
path evaluate the same policy cache. The global allocator is mimalloc,
covering every in-process task. Boot, DB pool, drain, outbox relay and audit
pusher are shell-provided.
The engine is the deterministic core: pure functions against an injected
now_us; the shell owns FIX/WS session I/O (with the session plumbing, bus
consumers and the sweep timer landing with the runtime wiring pass) and
turns engine events into MemBus publishes and DB writes.
Request family: RFQ create / broadcast
Request family: quote collect / compare
Request family: RFQ → order (trade) conversion
Request family: admin RFW / policy
3. Data Model
Schema-per-service: database exchange8, schema rfq, role
exchange8_rfq_service, DSN env EXCHANGE8_RFQ_DATABASE_URL. RLS
(ENABLE + FORCE) on every tenant table; policies require
sentin8_context_initialized() and env_id = sentin8_current_env_id()
(set via SELECT sentin8_set_tenant_context($1) on the same
connection/transaction as the queries). The role's search_path is
rfq, public; cross-schema access is exactly the declared SELECT grants on
refdata.security and refdata.currency (the FK targets are
intra-database and need no grants).
Per-table notes:
| Table | Visibility | Notes |
|---|---|---|
rfq.fx_pair | env-scoped, RLS FORCE | pair master over refdata.security (security_type 'Fx'); UNIQUE (env_id, base_currency, quote_currency); quote_type selects the serving engine; indexes on env_id |
rfq.fx_tenor | env-scoped, RLS FORCE | tenor catalog (SP = spot; forwards by calendar-days + modified-following rule); UNIQUE (env_id, code) |
rfq.rfq | env-scoped, RLS FORCE | lifecycle row; UNIQUE (env_id, rfq_id); state ∈ {open, quoting, quoted, traded, expired, rejected}; trade_id set on traded; index (env_id, state) for the sweep |
rfq.rfq_quote | env-scoped, RLS FORCE | LP responses; received_us engine-authoritative; late kills visibility; winning marks the chosen quote; index (env_id, rfq_id) |
rfq.rfq_reject | env-scoped, RLS FORCE | last-look/asymmetric counters by (env_id, lp_conn); audit + scorecard input |
rfq.lp_stream_quote | env-scoped, RLS FORCE | streaming pushes; UNIQUE (env_id, lp_conn, pair_id, ts); withdrawal keeps the row, stops binding; index (env_id, pair_id, lp_conn) |
rfq.currency_exposure | env-scoped, RLS FORCE | per-currency net position + limit, optional value-date bucket for forwards; UNIQUE (env_id, currency, value_date) |
rfq.outbox | env-scoped, RLS FORCE | DBX §6.7 producer table; partial index on relayed = false; pg_notify('rfq_outbox_changed') trigger on INSERT |
rfq.audit_journal | env-scoped, RLS FORCE | DBX §6.8 journal for POST /v1/audit; partial index on pushed = false |
Lifecycle
Streaming quotes have no RFQ lifecycle: a stream hit emits a trade bound to
rfq_id = 0. A pending last-look hit inside Quoted is parked (visible only
as the engine's pending_hits set) until accept, reject or deadline sweep.
4. API Surface
Grouped by audience. The authorization gate for every handler surface is the
8-step platform pipeline — bearer verify → tenant resolve → feature gate →
Shield (scope-mirror, enforced, action exchange8:rfq:quote) → RLS
context → execute → audit. The HTTP router mounts GET /v1/health,
/openapi.json and the Shield policy-admin API only; the RFQ flow arrives
via FIX 4.4 QuoteReq / WS session protocols, and the registered error codes
are exchange8_rfq_not_found (404), exchange8_rfq_quote_late (422) and
exchange8_rfq_hit_expired (422).
| Audience | Surface | Gate | Emits |
|---|---|---|---|
| Client session (FIX 4.4 QuoteReq / WS) | RFQ request, quote responses, hit, streaming quote view | exchange8:rfq:quote (scope-mirror) + pair entitlement | exchange8:rfq:rfq:* lifecycle events |
| LP session | Quote responses, last look, streaming pushes/withdraws | exchange8:rfq:quote + LP entitlement | exchange8:rfq:quote:*, exchange8:rfq:stream:* |
| Market data (entitled feeds) | Anonymized tape (MemBus family rfq.event, newest-wins) | session entitlement; exchange8:rfq:stream:read | tape entries (no identities) |
| LP scorecard (admin) | Last-look analytics snapshot | exchange8:rfq:rfq:read | — |
| Admin (RFW) | Pages rfqs, pairs, lp-sessions (/v1/admin/ui/{page} + POST /v1/admin/{resource}) | exchange8:rfq:rfq:read / :write / exchange8:fx:pairs:admin | — |
| Admin | Shield policy-admin API (exchange8:rfq:policy:read / :write, role exchange8:rfq:admin) | scope-mirror + admin scopes | sentin8:sys:policy family |
| Ops | GET /v1/health, /openapi.json | unauthenticated (health) | — |
Declared admin scopes (runtime-registered via vertical enroll): role
exchange8:admin → exchange8:rfq:rfq:read, exchange8:rfq:rfq:write,
exchange8:rfq:quote:write, exchange8:rfq:stream:read,
exchange8:fx:pairs:admin; role exchange8:rfq:admin →
exchange8:rfq:policy:read, exchange8:rfq:policy:write.
Event families (Sentin8:Bus, exchange8:rfq:{resource}:{operation}):
rfq:requested, rfq:quoted, rfq:traded, rfq:expired,
rfq:rejected (LP last-look), stream:quote, stream:withdrawn; MemBus
families rfq.quote (streaming LP quotes, session-private) and rfq.event
(anonymized tape).
Multi-party event chain (outbox → Bus → trigger):
5. Cross-Cutting Concerns
- Authentication — Sentin8:Auth is the only issuer. Session participants
(clients, LPs) authenticate to Exchange8:Gateway and act through connections;
service workloads use cert-bound M2M
client_credentialstokens (DCR-registered per workload:exchange8:rfqandrfq(the service's own client),exchange8:rfq-policy/rfq-policy(Shield hot-reload + stat),rfq-audit(journal pusher),exchange8-rfq-tls(DB-free host resolve)). DCR is fail-closed: only workloads insentin8_service_workloads(seeded bysentin8-cli vertical enroll) can register. - Authorization — the
scope-mirrorpolicy set (origin='platform',mode='enforced') permits each declared action (handler step-5 target:exchange8:rfq:quote) for callers holding the matching scope or the platform-admin scopes (sentin8:sys:admin,sentin8:sys:org:admin,sentin8:sys:env:admin). No-set → deny (fail-closed); shadow is the emergency one-UPDATE rollback lever only; M2Mtoken_tpnever bypasses policy; a tenant forbid beats the mirror permit. - Tenant resolution + RLS — every table has
ENABLE+FORCE ROW LEVEL SECURITYwith policiessentin8_context_initialized() AND env_id = sentin8_current_env_id(); context is set withSELECT sentin8_set_tenant_context(...)(set_config(..., true), transaction-local) on the same connection as the data statements — the async helper must be awaited. Explicitenv_id = $Npredicates are defense-in-depth; RLS is the backstop; roleexchange8_rfq_servicecarries noBYPASSRLS. - Idempotency — bus produce:
idempotency_key= outbox row id atPOST /v1/produce; RFQ identity: UNIQUE(env_id, rfq_id)plus the in-process engine state (a duplicate quote for a terminal RFQ is dropped, a duplicate hit cannot re-trade aTradedRFQ); stream quotes are newest-wins so replays self-supersede; hit/last-look answers are engine transitions, not additive writes. - Rate limiting / bot protection — ingress tier is the platform edge (Sentin8:Proxy); session-level rate policies ride the Shield policy surface (per-tenant sets), not bespoke code.
- Audit journaling — every state transition writes
rfq.audit_journal(same transaction where possible); the pusher drains to SysPOST /v1/audit(sentin8:sys:audit:write); a Sys outage retries without loss. - Drain —
sentin8::shellgraceful drain on upgrade: HTTP stops accepting, in-flight sessions finish, the relay/journal pusher checkpoint unrelayed/unpushed rows, and Sentin8:Captain'supgrade-instanceswaps the binary (rolling, zero-downtime). In-flight RFQs are short-lived by nature — an engine restart expiresOpen/QuotingRFQs (their lifecycle is measured in milliseconds) and streaming books re-sync from LP pushes. - TLS — terminated in-process by
sentin8-tlsbehind Sentin8:Proxy (single ingress, SNIexchange8-rfq.sys.ops…); theexchange8-rfq-tlsworkload resolves host context DB-free viaGET /v1/resolve/hostname(sentin8:sys:instances:read). Post-quantum only; no classical fallback. - Allocator — mimalloc global allocator covers HTTP, session drivers, relay, journal pusher and the sweep timer alike.
6. Failure Modes & Recovery
Recovery sequence (crash between domain write and relay):
| Failure | Behavior |
|---|---|
| LP session down | RFQ routes to the remaining entitled LPs; expired if none respond |
| LP late (past quote window) | quote flagged late + rfq_quote.late=true, exchange8_rfq_quote_late semantics; never shown |
| Hit during last-look reject | client receives rfq:rejected with reason; rfq_reject row persisted; no trade |
| LP never answers last look | sweep binds the trade at the quoted price past the deadline (asymmetric) and counts late_last_look |
| Engine restart | Open/Quoting RFQs expire (short-lived by nature); streaming quote books re-sync from LP pushes |
| Relay outage | outbox rows retry with backoff; idempotency_key makes replay safe |
| Sys outage | audit journal retries, never drops |
7. Concurrency & Performance Model
- Task layout — one HTTP acceptor; session drivers (one per client/LP
connection) feed the engine; one sweep timer (windows + last-look
deadlines + pending-hit binding); one relay task (LISTEN
rfq_outbox_changed+ periodic unrelayed poll); one audit pusher. The engine is single-threaded in-process state — all RFQ decisions serialize through it, so no shared-mutex contention exists on the hot path. - Pool sizing — the shared
sentin8_config::pg_pool::connectcaps connections (default 3, overridable viaSENTIN8_PG_POOL_MAX) so ~15 vertical services cannot exhaust PostgreSQL. Relay and journal use the same pool; context is set transaction-locally per statement set. - Hot paths — quote ingest and hit binding are pure in-memory engine calls (sub-millisecond, no DB); persistence lands per transition. Caches: nothing hot-cached beyond the Shield policy engine set (hot-reload consumer keeps loaded sets fresh).
- Streaming model — MemBus is newest-wins: a superseded quote ceases to exist, so backpressure cannot propagate stale prices; slow consumers see the newest state, never a stale queue replay.
- Backpressure — the outbox partial index bounds the relay scan; unrelayed rows retry with backoff so a Bus outage does not spin; the drain path checkpoints before shutdown.
8. Invariants, Insecure-Defaults Rejected, Unsafe Rationale
Invariants (always true):
- A late quote is recorded, never shown to the client, and never wins or
binds — visibility and binding are both keyed on
late = false. - Engine receive timestamps are authoritative; LP-supplied times are advisory and never decide window membership.
- LP-declared validity is capped at receipt time plus
MAX_VALID_WINDOW_US(5 s) — no LP quote carries unbounded validity. - A hit binds only at the quoted deal leg — the price is exact-equality matched; no price invention, averaging or tolerance band.
- A hit past the quoted
valid_untilexpires the RFQ and cannot trade. - A last-look rejection always produces
rfq:rejected, no trade, and a persistedrfq_rejectrow; an accept (within or past the deadline) always trades at the quoted price — asymmetry is deliberate. - Quote fairness: one request, one simultaneous fan-out to every entitled LP; LP entitlement is per pair and evaluated at request time.
- Every streaming trade draws its id from the engine's single trade-id
counter with
rfq_id = 0; RFQ trades consume the same counter. - Rejected and LateQuote events are never tapeable; the tape never carries LP or client identity, and one-sided quotes never appear on it.
- A domain state change and its
rfq.outbox(and wherever possiblerfq.audit_journal) row are committed in the same transaction; tenant context is set on the same connection and awaited. - No role carries
BYPASSRLS; cross-schema access is exactly the declared SELECT grants onrefdata.securityandrefdata.currency.
Insecure defaults rejected:
danger_accept_invalid_certs/ hand-rolled CA loading — TLS is in-process viasentin8-tls(post-quantum, root CA in the OS trust store).- LP-supplied timing as truth — a client-configurable clock is a fairness attack; engine stamps decide windows.
- Raw RFQ events on a public feed — rejects LP/client identity leaks (front-running, quote sniping); the anonymous projection replaces them.
- Unbounded LP-declared validity — rejected; the engine caps it at ingest.
- Direct cross-schema writes / blanket schema grants — declared SELECT-only grants are the exact cross-service surface; everything else is membus/bus/owner-API.
- Static superuser service DSN — the service role is unprivileged,
non-bypass, database-
exchange8-only.
No unsafe blocks exist in this service; the unsafe-rationale section is
vacuously satisfied. Soundness-relevant safety rests on the invariants
above (exact hit matching, late non-binding, engine-time authority,
same-connection context).
9. Testing Strategy
- Unit layer — the engine's pure core owns the domain matrix: routing and entitlement expansion, quote window + lateness, validity capping, hit leg/price matching, last-look parking/accept/reject/deadline binding and per(LP, reason) counters, streaming newest-binds + withdraw + one-sided legs, and tape anonymization (identity leak assertions, one-sided and identity-bearing events never tapeable) with analytics scoring.
- Conformance layer —
conformance.rsreplays the five deterministic design scenarios on a scripted clock through the real engine; these are the deployment-portable checks every environment honors. - Policy layer — proves the real seeded
scope-mirrortext evaluates fail-closed, respectsmode/origin, isolates sets perenv_id, keeps M2M parity, lets platform-admin scopes pass, lets a tenant forbid or role-grant decide, covers every handler action, and dry-runs clean at enroll. - SQL matrix — live PostgreSQL via the non-bypass
exchange8_proberole: RLS enforcement (no context → 0 rows, insert without context → rejected, own env → own rows only, cross env → 0 rows) and FORCE RLS + policy coverage for every table the shippedinit.sqldeclares. Skips silently when the DB is unreachable so test runs stay hermetic. - Deliberately not tested here — platform TLS/RLS plumbing itself, Sentin8:Bus delivery guarantees, Shield engine internals: covered by the platform's own test strategy docs.
- E2E — the vertical's
e2e-tssuite drives the real UI through the BFF only (no direct SQL or backend calls), so admin surfaces are exercised as a human would.
10. Open Questions
- The FIX 4.4 QuoteReq/WS session transports, bus consumers, the sweep timer and the MemBus publish path are declared by the design but not wired in this binary — the HTTP router carries health + Shield policy-admin only, and no domain endpoint/handler mounts exist. Wiring lands with the runtime-wiring pass mirroring the sibling services.
- RFW pages (
rfqs,pairs,lp-sessions) are registered but their UI/data handlers (/v1/admin/ui/{page},/v1/admin/{resource}) are not mounted in this binary. rfq.outboxemission (event types per lifecycle transition, stream families,event_triggersregistrations invertical-registry.json) is designed by the DBX pattern but not yet written by a handler path.rfq_id/trade-id batch leases from the platform sequence facility: the engine consumes injected counters today; lease-at-startup wiring is the vertical id-substrate pattern (data-model design §2a) and is outstanding.- The tape SSE publisher surface and session-entitlement model for
streaming quotes (
exchange8:rfq:stream:readconsumers) — the endpoint contract for RFQ is not fixed yet. - The
rfq.currency_exposureupdate path and the interplay with Exchange8:Risk's per-currency limit decisions (who shifts net positions, when) is a risk-design decision; only the table and limit columns exist today. - Pair
quote_typerouting between Exchange8:ME (CLOB) and this engine perfx_pair.quote_typeis designed at the vertical level but not resolved in a runtime router here. - Forward tenor pricing (value-date derivation from
fx_tenor.days+rule) affects quote semantics on tenor code beyondSP; the forward pricing curve (deposit rates / forward points source) is not designed on this service's surface yet.
Design attribution: exchange8:rfq and its underlying concepts were designed by Hamid Jahanzad Hamid@Jahanzad.com.