ADR-0139 — Multi-Broker Platform Support for v1 Live Trading
Status: Accepted (2026-08-06 — see §9 and §13 for the ruling that settled the
open shape question; §5 Migration C and §14 for the 2026-08-06 SC-4/#4220 ruling
correcting the Velvet table shape and language tier; ADR-0052 promotion tracked
separately, see "Revisit when")
Date: 2026-07-13 UTC
Deciders: Kristerpher (operator); software-architect (design)
Scope: Raptor (backend_v2/), Velvet (token lifecycle), Antlers (frontend/raxx-next/),
onboarding wizard (BrokerConnectView)
Supersedes (in part): ADR-0052 (expands interface contract), ADR-0014 (extends
token table pattern to all Tier A brokers)
Extends: ADR-0109 (BYOB tier model), ADR-0052 (adapter interface pattern)
Refs: Epic #495, Epic #4216, Issue #4220 (SC-4), Research doc #2627 (BLR), parent
card driving this design
1. Context
Raxx enters v1 live trading after the MBT paper-beta soak. The operator requirement is a minimum of two active trading platforms at v1-live launch:
- Alpaca — the only real integration today. Approximately 429 code references across
alpaca_integration.py,alpaca_market_data.py, andalpaca_market_data_service.py. All live-execution references are wired directly into trading routes with no adapter interface. ADR-0052 proposed theBrokerAdapterABC pattern but did not fill the full interface contract or provide the migration path. - SnapTrade — intended BYOB aggregator, currently stubbed (five references, none functional). Unlocks IBKR, Schwab, E*TRADE, and dozens of additional brokers via a single aggregator contract; Raxx never holds the downstream broker credential.
- A named second direct broker — required by operator. Tradier is the recommended choice (see §4 for full evaluation; operator may override to tastytrade).
ADR-0109 established the broker tier model (Tier A direct, Tier B aggregator, Tier C read-only), the Velvet token storage extension, and the phased rollout roadmap but deferred the v1-live implementation specifics — the interface contract, the Alpaca migration path, and the platform-selection sub-step in the onboarding wizard — to this ADR.
Paper trading (MBT, ADR-0108) is permanently broker-independent. This ADR governs the live execution path only.
2. Invariants
These constraints are non-negotiable. Any sub-card that conflicts with them must surface the conflict before work begins.
- No stored credentials (ADR-0002): broker OAuth tokens and API keys are the documented exception (ADR-0009/0014). They must be envelope-encrypted in Velvet; no plaintext column exists in any table. Every additional Tier A broker inherits this posture exactly.
- Passkeys only (ADR-0001): step-up WebAuthn is required on every live order submission regardless of broker tier.
- Broker is plumbing (
feedback_no_backend_branding): customer copy never surfaces broker vendor names in generic instructions. "Connect your brokerage account" — not "Connect to Tradier." - Deterministic execution (
feedback_deterministic_execution_ai_augments): the order-firing path is rule-based. Changing the adapter beneath it does not change execution semantics. The adapter translates; it does not decide. - Paper-first gating: live execution requires paper-profitable-for-N-cycles (MBT)
or an explicit audited override via
FLAG_LIVE_TRADING_GATE_OVERRIDE. This gate applies to every broker and every tier. It reads MBT cycle history, not the broker. - Audit trail: every state change touching money, permissions, or data access
writes an
audit_logrow. Every live order submission and every broker connection lifecycle event (connect, disconnect, token-rotate, kill-switch activation) is audited. The pre-submit audit row (§3.7 step 5) must always be closeable from the normal return path of the call it precedes — not only from an exception handler. See §9 for how this constrains error signaling on the order-management methods. - Kill-switches exist before the feature ships:
BYOB_LIVE_DISABLED=1(all BYOB live orders) andBYOB_BROKER_<SLUG>_DISABLED=1(per-broker) must be present in Raptor before any non-Alpaca adapter reaches production.
3. Decision
3.1 Three platforms for v1-live
| Platform | Tier | Auth mechanism | Current state |
|---|---|---|---|
| Alpaca | Tier A direct | OAuth 2.0 (ADR-0014) | Exists; needs adapter wrap |
| Tradier | Tier A direct | OAuth 2.0 (3-legged; same pattern as Alpaca) | New |
| SnapTrade | Tier B aggregator | SnapTrade connection ID; downstream broker token held by SnapTrade | Stub → complete (Phase 2) |
Tradier is the recommended second direct broker. See §4 for evaluation against tastytrade. The operator may override; the interface contract below applies unchanged to either choice.
SnapTrade is Phase 2 (post Tradier soak). Sub-cards are filed now so
card-groomer can sequence them against the SnapTrade contract timeline.
3.2 BrokerAdapter interface contract
The interface expands ADR-0052's stub to cover the full live-execution surface, with explicit support for multi-leg options orders required by Raxx's iron-condor and spread workflows.
All adapters implement this contract. Location: backend_v2/api/services/broker/base.py.
# Stub types only — feature-developer chooses concrete library and dataclass shapes.
class BrokerAdapter(ABC):
# ── Identity ─────────────────────────────────────────────────────────────
broker_id: ClassVar[str] # slug: "alpaca" | "tradier" | "snaptrade"
display_name: ClassVar[str] # brand name for Settings detail view only
supports_options: ClassVar[bool] # True: Alpaca, Tradier; check per-broker for SnapTrade
# ── Auth / connection lifecycle ───────────────────────────────────────────
@abstractmethod
def get_auth_url(self, user_id: str, redirect_uri: str) -> str: ...
# Returns the OAuth redirect URL the user follows to authorize Raxx.
# For SnapTrade: returns the SnapTrade-hosted connection widget URL.
# A broker whose Pass-1 adapter has no real per-user OAuth flow yet (see §3.4
# implementation note) raises NotImplementedError — never a silent stub URL.
@abstractmethod
def exchange_code(self, code: str, state: str) -> ConnectionArtifact: ...
# Exchanges the OAuth authorization code for a token artifact.
# Returns an opaque artifact that Velvet stores.
# Raptor never persists the plaintext token after this call returns.
@abstractmethod
def revoke(self, velvet_token_id: str) -> None: ...
# Calls the upstream revoke endpoint, then marks the Velvet row revoked_at.
# Must be idempotent — safe to call on an already-revoked token.
# Must never silently no-op and report success: if a Pass-1 adapter has no
# per-user connection to revoke against yet, it raises NotImplementedError
# rather than returning None as if a revocation occurred.
# ── Account state ─────────────────────────────────────────────────────────
@abstractmethod
def get_account(self, velvet_token_id: str) -> AccountState: ...
# Returns: cash, buying_power, account_id, currency, account_type.
@abstractmethod
def get_positions(self, velvet_token_id: str) -> list[Position]: ...
# Returns open positions: symbol, qty, avg_cost, market_value, asset_class.
# ── Order management ──────────────────────────────────────────────────────
# Per §9: broker-domain outcomes (the broker could not fulfil this order,
# rejected it, is unavailable, etc.) are returned via `error_code` on the
# result object below — never raised. Only programmer/precondition errors
# (invalid OrderSpec, unregistered broker slug, no active connection) are
# raised, and those are detected before the broker is ever called.
@abstractmethod
def submit_order(self, velvet_token_id: str, order: OrderSpec) -> OrderResult: ...
@abstractmethod
def cancel_order(self, velvet_token_id: str, order_id: str) -> CancelResult: ...
@abstractmethod
def replace_order(
self, velvet_token_id: str, order_id: str, patch: OrderPatch,
) -> OrderResult: ...
@abstractmethod
def get_order_status(self, velvet_token_id: str, order_id: str) -> OrderStatus: ...
# ── Market context ────────────────────────────────────────────────────────
@abstractmethod
def is_market_open(self) -> bool: ...
# Adapter may delegate to the shared MarketDataHub (Alpaca data account)
# for market hours rather than calling the broker API separately.
@abstractmethod
def health_check(self) -> HealthResult: ...
# Returns {ok: bool, latency_ms: int, message: str}.
# Feeds the status-page surface poller ([ADR-0030](https://internal-docs.raxx.app/architecture/adr/0030-status-state-machine.html)/0121) without custom-casing
# each broker.
3.3 Options order support (multi-leg)
OrderSpec is the cross-broker order description. It must support single-leg and
multi-leg options. This is the primary reason Tradier is preferred at Tier A — its
REST API handles multi-leg options natively in a single call.
@dataclass
class Leg:
symbol: str # OCC option symbol (e.g. "SPY230120C00400000") or equity ticker
action: str # "buy_to_open" | "sell_to_open" | "buy_to_close" | "sell_to_close"
qty: int
@dataclass
class OrderSpec:
order_type: str # "market" | "limit" | "net_debit" | "net_credit"
tif: str # "day" | "gtc" | "ioc"
legs: list[Leg] # 1 leg = single; 2 legs = vertical/spread; 4 legs = iron condor
limit_price: Decimal | None
# Each adapter translates this to the broker's native multi-leg format.
# For SnapTrade Tier B: if the downstream broker does not support multi-leg
# via SnapTrade, the adapter returns OrderResult(error_code=UNSUPPORTED_ORDER)
# — never raises, never silently splits a multi-leg order into single-leg
# submissions. See §9 (2026-08-06 ruling) — this superseded the original
# "raises UnsupportedOrderError" text; the exception class name is retained
# for internal pre-translation validation only, not as a broker-domain
# signaling mechanism.
# Broker-domain outcome codes — returned via OrderResult.error_code, never raised.
class OrderErrorCode(str, Enum):
UNSUPPORTED_ORDER = "unsupported_order" # broker/tier can't fulfil this OrderSpec shape
BROKER_REJECTED = "broker_rejected" # hard reject from the broker (bad symbol, market closed, etc.)
INSUFFICIENT_FUNDS = "insufficient_funds"
RATE_LIMITED = "rate_limited"
BROKER_UNAVAILABLE = "broker_unavailable" # upstream broker API down/timeout
UNKNOWN = "unknown" # unmapped broker error; raw preserved in .raw
3.4 Alpaca migration path (incremental, no big-bang rewrite)
The 429 Alpaca references must not be broken by a single migration. The approach is adapter-wraps-existing, proven in three passes, each independently shippable:
Pass 1 — Wrap (SC-2): Create AlpacaBrokerAdapter(BrokerAdapter) in
backend_v2/api/services/broker/adapters/alpaca.py. It delegates to the existing
alpaca_integration.py functions unchanged. Register in BrokerAdapterRegistry.
Trading routes that submit orders call registry.get_adapter_for_user(user_id).
No existing function in alpaca_integration.py is deleted or modified.
Pass 2 — Route (SC-2, same card): Replace direct imports of
alpaca_integration.py in live-execution routes (trading.py, options.py) with
registry calls. The underlying functions stay untouched. Paper-trading paths are
untouched (MBT, not Alpaca).
Pass 3 — Internalize (SC-tech-debt, filed separately, not a v1-live blocker):
Move Alpaca-specific logic from alpaca_integration.py into the adapter methods.
Only after Pass 2 has soaked in staging.
BrokerAdapterRegistry is a dict-backed service locator populated at app startup,
consistent with the existing Flask pattern. No DI container is introduced.
**Implementation note — SC-2 Pass 1 clarifications (ruling 2026-08-06, adjudicating
4218 comment 5200954867):**
- Credential model shim. Today's live Alpaca integration
(
trading_runtime.resolve_trading_credentials) uses a single shared platform-level credential pair selected bymode("paper"/"live"); there is no per-useralpaca_live_connections-style row and no Velvet client inbackend_v2yet (those land in SC-3/#4219 and SC-4/#4220). The ABC signature is unchanged — every method still acceptsvelvet_token_id— because Tradier (#4221) is genuinely per-user OAuth from day one and must not be redesigned around a temporary Alpaca gap.AlpacaBrokerAdapterPass 1 acceptsvelvet_token_idfor interface conformance and ignores it, resolving credentials via the existingmode="live"path instead. This must be stated explicitly in the class docstring, with a# TODO(SC-3/SC-4)marker, not left implicit. Nothing calls this in production beforeFLAG_MULTI_BROKER_ADAPTERflips on (§6, Dark phase), so there is no live exposure window. - Equity order submission reuses existing options-order functions. No new
live-order-submission function is needed:
submit_options_order(1 leg) andsubmit_multileg_options_order(2/4 legs) inoptions_chain_service.pyare generic over symbol type and already mode-aware.AlpacaBrokerAdapter.submit_orderdispatches to one or the other bylen(order.legs)— this is translation, not new business logic, consistent with §3.4's "delegates to existing functions unchanged." In scope for SC-2; not deferred. Adapter-level tests must assert an equity 1-legOrderSpecdoes not pick up options-only fields (contract multiplier, OCC symbol assumptions) when mapped. get_order_status/replace_orderneed two additive helpers. No live-mode single-order GET or PATCH exists today (fetch_alpaca_ordersonly lists;cancel_alpaca_orderonly deletes). Addfetch_alpaca_order_status(mode, order_id)andreplace_alpaca_order(mode, order_id, patch)totrading_runtime.py, mirroring the existing functions' shape exactly. Additive only — no existing function in that file is modified. In scope for SC-2.
3.5 Per-user credential storage
ADR-0014 established alpaca_live_connections for Alpaca. This ADR generalizes it
to a single broker_live_connections table covering all Tier A and Tier B brokers.
Velvet holds the ciphertext; Raptor holds the reference ID (velvet_token_id).
-- Raptor-side reference table (token ciphertext lives in Velvet)
CREATE TABLE broker_live_connections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
broker_slug TEXT NOT NULL,
broker_tier TEXT NOT NULL CHECK (broker_tier IN ('tier_a','tier_b','tier_c')),
velvet_token_id UUID NOT NULL,
scopes TEXT NOT NULL DEFAULT '',
account_id_at_broker TEXT NULL,
connection_state TEXT NOT NULL DEFAULT 'active'
CHECK (connection_state IN ('active','needs_reauth','revoked','suspended')),
connected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ NULL,
revoked_at TIMESTAMPTZ NULL
);
CREATE UNIQUE INDEX broker_live_connections_active_uniq
ON broker_live_connections (user_id, broker_slug) WHERE revoked_at IS NULL;
-- One active connection per broker per user in v1.
-- Relaxed in Phase 4+ for multi-broker households ([ADR-0109](https://internal-docs.raxx.app/architecture/adr/0109-byob-roadmap.html) §4).
alpaca_live_connections rows (ADR-0014) are migrated to this table as part of SC-3.
Existing encrypted Alpaca tokens in Velvet are backfilled with broker_slug = 'alpaca'
and broker_tier = 'tier_a' after the Velvet schema extension (SC-4) ships.
3.6 BrokerConnectView extension
BrokerConnectView.tsx currently presents only a paper | live binary choice.
Its BrokerMode type and onNext({ mode }) contract are preserved.
When the user selects live, a new BrokerPlatformSelectView sub-step is rendered.
It presents only the brokers available in the user's subscription tier:
- Free / Pro: Alpaca only.
- Pro+: Alpaca + Tradier (GA); SnapTrade-sourced list post-Phase-2.
Copy rules (invariant): Broker brand names appear in the platform-select list items only. The wizard headline and body copy remain generic ("Connect your brokerage account"). SnapTrade is never mentioned; its downstream brokers appear by their own brand names in the aggregator-powered list. The aggregator is invisible to the user at every UX layer.
3.7 Live-vs-paper gating
The paper-first gate reads MBT cycle history. It is broker-agnostic: a user graduating from MBT paper to live Tradier follows the same logic as a user graduating to live Alpaca. The adapter layer is not involved until the gate clears.
Two paths through the gate:
1. Standard: N profitable MBT cycles (configurable via PAPER_GRAD_CYCLES,
default 5).
2. Override: operator sets FLAG_LIVE_TRADING_GATE_OVERRIDE=true per-user via
the console. Override is audited (actor, reason, timestamp). Requires step-up
WebAuthn from the operator performing the override.
Live order submission call sequence (in this order, no reordering permitted):
1. Kill-switch check (BYOB_LIVE_DISABLED, BYOB_BROKER_<SLUG>_DISABLED).
2. Paper-first gate state verified (live check against DB; not trusted from session).
3. Trade-window compliance check (existing trade_window_compliance_enforcement.py).
4. Step-up WebAuthn token verified.
5. Pre-submit audit_log row written (actor, broker_slug, order_spec, timestamp).
6. Adapter submit_order() called.
7. audit_log row updated with broker-issued order ID and OrderResult.status /
error_code. Because broker-domain failures return through OrderResult
rather than raising (§9), step 7 always runs on the normal return path — the
route layer does not need a separate except branch to close out the pre-submit
audit row for a broker-side rejection.
4. Second Direct Broker: Tradier Recommended
Tradier
| Criterion | Assessment |
|---|---|
| Options | Full multi-leg via REST API (equity + options in one endpoint). Required for iron-condor workflow. |
| Sandbox | Public sandbox at sandbox.tradier.com. No funded account required. Available immediately. |
| OAuth | Standard 3-legged OAuth 2.0 auth-code flow. AlpacaBrokerAdapter is the template. |
| API maturity | Production since 2012. Stable and well-documented. |
| Developer TOS | Public developer program. BLR review required before production (OQ-1). |
| FINRA / SIPC | Yes — options levels 1-4. |
| Integration effort | ~2 engineering weeks (adapter + OAuth + options order mapping + sandbox QA). |
Tastytrade (alternative, not recommended for v1-live)
| Criterion | Assessment |
|---|---|
| Options | Excellent — built for options traders, multi-leg native. |
| Sandbox | Partner program required (api.support@tastytrade.com). Approval adds 2-4 weeks before engineering can start. |
| OAuth | OAuth 2.0 — same once access is granted. |
| Developer TOS | BLR review required before production (same scope as Alpaca #932). |
| Integration effort | ~3 engineering weeks (partner approval + adapter, same methods). |
Recommendation: Tradier. The self-serve sandbox eliminates the 2-4 week partnership-approval gate that blocks tastytrade from starting. The OAuth and options-order API patterns are virtually identical to Alpaca, minimizing novelty. Tastytrade remains a strong Phase 3 Tier A candidate; it is not ruled out.
Hard regulatory dependency (OQ-1): BLR must review Tradier's developer API agreement under the same scope as Alpaca #932 before Tradier orders go to production. The Tradier staging/sandbox sub-cards can proceed; the production-deploy sub-card (SC-7) is blocked on BLR sign-off.
5. Migrations
Migration A — broker_live_connections table
-- File: backend_v2/alembic/versions/0040_broker_live_connections.py
-- POSTGRES-ONLY
CREATE TABLE broker_live_connections (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
broker_slug TEXT NOT NULL,
broker_tier TEXT NOT NULL CHECK (broker_tier IN ('tier_a','tier_b','tier_c')),
velvet_token_id UUID NOT NULL,
scopes TEXT NOT NULL DEFAULT '',
account_id_at_broker TEXT NULL,
connection_state TEXT NOT NULL DEFAULT 'active'
CHECK (connection_state IN ('active','needs_reauth','revoked','suspended')),
connected_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_used_at TIMESTAMPTZ NULL,
revoked_at TIMESTAMPTZ NULL
);
CREATE UNIQUE INDEX broker_live_connections_active_uniq
ON broker_live_connections (user_id, broker_slug) WHERE revoked_at IS NULL;
CREATE INDEX broker_live_connections_user_id ON broker_live_connections (user_id);
CREATE INDEX broker_live_connections_velvet_id ON broker_live_connections (velvet_token_id);
Rollback: DROP TABLE broker_live_connections; — safe until Alpaca row migration
(Migration B) ships.
Migration B — Alpaca row migration (SC-3, after adapter wrap is stable)
-- File: backend_v2/alembic/versions/0041_alpaca_rows_to_broker_live_connections.py
-- POSTGRES-ONLY
INSERT INTO broker_live_connections (
id, user_id, broker_slug, broker_tier, velvet_token_id,
scopes, connection_state, connected_at, last_used_at, revoked_at
)
SELECT
id, user_id, 'alpaca', 'tier_a', id,
-- velvet_token_id uses the same UUID as the alpaca_live_connections row
-- until Velvet SC-4 backfill adds the canonical Velvet token UUID.
scopes, CASE WHEN revoked_at IS NULL THEN 'active' ELSE 'revoked' END,
issued_at, last_used_at, revoked_at
FROM alpaca_live_connections;
-- alpaca_live_connections retained (not dropped) for 1 sprint verification window.
-- Separate follow-on migration drops the old table.
Rollback: Repoint AlpacaBrokerAdapter to alpaca_live_connections directly
(one config switch); broker_live_connections Alpaca rows can be truncated safely
during the verification window.
Migration C — broker_credentials table, Velvet (SC-4, delegated to Velvet sub-card)
Ruling 2026-08-06 (adjudication: PM + QA + software-architect, deciding voice
software-architect), settling feature-developer's #4220 pre-code stop (comment
5202638514) — see §14. This section is corrected in place; the original text
("add three columns to Velvet's tokens table") described a table that does not
exist. Velvet's only tables today are rotation_jobs and rotation_job_consumers
(internal secret-rotation lifecycle — see velvet/db/README.md); its /tokens*
routes (velvet/routes/tokens.py) proxy Infisical live for Raxx's own ops secrets
(AWS/Cloudflare/Heroku/Postmark keys) and hold no local row per token. Neither is the
right shape for a per-customer broker OAuth credential. SC-4 creates a new table,
broker_credentials, rather than altering an Infisical-metadata cache.
-- File: velvet/db/migrations/004_create_broker_credentials.sql
-- Plain idempotent SQL per velvet/db/README.md conventions (not Alembic — Velvet
-- migrations are lexicographically-numbered .sql files applied by
-- `python -m velvet.db.migrate`).
CREATE TABLE IF NOT EXISTS broker_credentials (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
-- ^ this is the value Raptor stores as broker_live_connections.velvet_token_id
user_id UUID NOT NULL,
-- No FK: Velvet and Raptor are separate services/databases. Referential
-- integrity to users.id is Raptor's responsibility; Velvet indexes user_id
-- only to serve the DSR purge call below.
broker_slug TEXT NOT NULL,
broker_tier TEXT NOT NULL CHECK (broker_tier IN ('tier_a','tier_b','tier_c')),
no_order_write BOOLEAN NOT NULL DEFAULT FALSE,
scopes TEXT NOT NULL DEFAULT '',
access_token_ciphertext BYTEA NOT NULL,
access_token_iv BYTEA NOT NULL,
access_token_wrapped_dek BYTEA NOT NULL,
kms_key_id TEXT NOT NULL,
refresh_token_ciphertext BYTEA NULL,
refresh_token_iv BYTEA NULL,
refresh_token_wrapped_dek BYTEA NULL,
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NULL,
last_used_at TIMESTAMPTZ NULL,
needs_reauth BOOLEAN NOT NULL DEFAULT FALSE,
revoked_at TIMESTAMPTZ NULL,
CHECK (length(access_token_ciphertext) > 0)
);
CREATE INDEX IF NOT EXISTS broker_credentials_user_id ON broker_credentials (user_id);
Column pattern (envelope encryption: per-row DEK, wrapped by a KMS key) is carried
forward unchanged from ADR-0014's alpaca_live_connections sketch — the architectural
change this ADR makes is where that ciphertext lives: ADR-0014 assumed Raptor
encrypted and held the ciphertext directly; this ADR moves ciphertext custody into
Velvet (§3.5 — "Velvet holds the ciphertext; Raptor holds the reference ID"), and
Velvet performs the envelope encrypt/decrypt, not Raptor. kms_key_id references an
AWS KMS key ARN read from SSM (VELVET_BROKER_TOKEN_KMS_KEY_ARN) — this is
AWS-resident workload config, not an Infisical vendor secret, consistent with Velvet's
existing split (PG job-state + SSM passwords + Infisical vendor tokens).
tier_c added to the CHECK constraint (both here and in §3.5/§5 Migration A's
broker_live_connections.broker_tier, widened in this same amendment) — the original
text restricted broker_tier to ('tier_a','tier_b'), but no_order_write is
explicitly for Tier C read-only connections (per ADR-0109's tier model) and the
constraint must allow the value it's designed to gate.
Backfill: no-op in Pass 1 (SC-4). The original AC ("existing Alpaca token rows
get broker_slug='alpaca'...") assumed pre-existing rows in a tokens table.
There are none: SC-2 (#4218, merged) explicitly ignores velvet_token_id and resolves
Alpaca credentials via the existing shared-platform-credential mode="live" path
(trading_runtime.resolve_trading_credentials) — no per-user Alpaca OAuth/token-issuance
path exists yet (unscoped in SC-1/SC-2/SC-3). broker_credentials therefore ships
empty; the "backfill" deliverable is satisfied vacuously (0 rows). Real per-user
Alpaca rows land when a real Alpaca per-user OAuth flow is built — tracked in
"Revisit when" below, not part of SC-4.
Velvet API additions (new blueprint velvet/routes/broker_tokens.py, separate from
the existing Infisical-proxy tokens_bp; same VELVET_API_KEY bearer auth model as
tokens.py, M1 stub, per #912 hardening tracked separately):
POST /broker-tokens— Raptor calls this once, immediately afterBrokerAdapter.exchange_code()returns aConnectionArtifact, with the plaintext token(s) in the request body over TLS. Velvet envelope-encrypts and persists a row; returns{id}(the value Raptor stores asvelvet_token_id). Raptor never persists the plaintext after this call returns (§3.2, §7).GET /broker-tokens/{id}— returns{id, broker_slug, broker_tier, no_order_write, connection_state, issued_at, expires_at, last_used_at}— no plaintext value. This is the corrected home for the original AC "broker_slug/broker_tierreturned inGET /tokens/{id}" — the route is/broker-tokens/{id}, not/tokens/{id}(that path stays reserved for Infisical-backed ops secrets).GET /broker-tokens/{id}/value?intent=read|order_write(requiredintentparam) — returns the decrypted access token. This is whereE_TOKEN_READ_ONLYattaches — see the dedicated ruling immediately below. Never cached, mirrors the existing/tokens/{name}/value"never cache plaintext" rule.POST /broker-tokens/{id}/revoke— setsrevoked_at; idempotent.DELETE /broker-tokens/{id}— hard purge. Used by the DSR erasure flow (see note below); distinct from/revoke(which is a connection-lifecycle state change, not deletion).
E_TOKEN_READ_ONLY enforcement point — ruling on the second follow-on question.
No write_order() endpoint exists in Velvet, and none should be built for this: Velvet
does not parse OrderSpec or know anything about order semantics — that would leak
trading-domain knowledge into the credential-custody service. Enforcement attaches to
the existing credential-fetch call, GET /broker-tokens/{id}/value, via a required
intent query param with two allowed values: read and order_write (400 if
missing or unrecognized — no silent default). Velvet returns 403 {"error":
"E_TOKEN_READ_ONLY"} when intent=order_write and the row's no_order_write=TRUE;
intent=read is always permitted regardless of no_order_write (a Tier C read-only
connection must still be able to fetch positions/account state — only order-write use
is blocked). intent=order_write with no_order_write=FALSE succeeds normally.
Every BrokerAdapter method that will submit/cancel/replace an order must pass
intent=order_write on its Velvet credential-fetch call; get_account/
get_positions/get_order_status/health_check pass intent=read. This is
enforced at the Velvet service layer independent of Raptor application logic — exactly
the independence the original card AC asked for — without inventing an order-domain
endpoint Velvet has no business owning. The Raptor-side Velvet client that makes these
calls is built in a later sub-card (not SC-4); this ADR fixes the contract now so that
client is built against the corrected shape from day one.
Cross-service GDPR erasure note (corrects an implicit assumption in §8). §8 reads
"CASCADE DELETE from users.id propagates to broker_live_connections. Corresponding
Velvet row is revoked and purged" — a DB-level CASCADE only reaches
broker_live_connections (same Postgres instance as users). Velvet is a separate
service with its own database; there is no cross-database FK. The user-deletion DSR
job in Raptor must explicitly call DELETE /broker-tokens/{id} for every
broker_live_connections row belonging to the deleted user, as a synchronous (or
reliably-retried) step in the same erasure job — not a database trigger. This is a
required deliverable of the sub-card that builds the DSR erasure job (tracked in the
epic, not SC-4 itself, since SC-4 ships the table and routes DSR depends on).
6. Rollout Plan
| Phase | Flag | Scope | Duration |
|---|---|---|---|
| Dark | FLAG_MULTI_BROKER_ADAPTER=off |
SC-1 through SC-4 merged; adapter registry not activated in routes | During remaining beta soak |
| Alpha | FLAG_MULTI_BROKER_ADAPTER=on (operator only) |
Tradier live orders available to operator in staging; Alpaca default unaffected | 2 weeks |
| Beta | FLAG_TRADIER_LIVE=on (selected Pro+ users) |
Tradier live orders to hand-picked beta cohort; Alpaca default unaffected | 2 weeks minimum |
| GA | FLAG_TRADIER_LIVE=on (all eligible users) |
Tradier available to all Pro+ users | — |
| SnapTrade | FLAG_SNAPTRADE_LIVE=on |
SnapTrade integration promoted after GA soak + contract signed (OQ-3) | TBD |
Kill-switch: BYOB_BROKER_TRADIER_DISABLED=1 reverts Tradier orders to an error
without touching Alpaca. BYOB_LIVE_DISABLED=1 disables all BYOB live orders.
MBT paper trading is unaffected by all kill-switches.
7. Security Considerations
All live order submission paths are security-critical surfaces because real money moves.
- Step-up WebAuthn on every live submit. The adapter call is gated; a request
that reaches
submit_order()without a verified step-up token raisesStepUpRequiredupstream and never reaches the adapter. - No credential in Raptor process memory longer than the request.
velvet_token_idis the only credential handle Raptor holds. Velvet decrypts and injects the token per request; Raptor never caches plaintext between requests. - Per-broker rate limit enforcement. Each adapter enforces upstream rate limits
before calling the broker API. Breaches are logged to
audit_logand surface via the adapter'shealth_check()to the status-page poller. - BLR TOS review is a hard pre-production gate for every new broker. No Tier A
adapter reaches production until BLR has signed off on that broker's developer TOS.
This gate is enforced via
blockedlabel on the production-deploy sub-card for each broker. - SnapTrade API key is server-side only. The SnapTrade API key (
SNAPTRADE_API_KEY) is in the secret store, rotatable without redeploy. Users never interact with SnapTrade directly; SnapTrade's OAuth flow for downstream brokers is mediated by SnapTrade's hosted widget, not by Raptor.
8. Security / GDPR Checklist
- PII collected: broker account ID (at broker), connection timestamps, order IDs. Position snapshots are fetched live per request; not cached in Raxx beyond the request scope. Audit log records order intent and broker-issued order ID — not position-level PII beyond what an audit trail requires.
- Retention period:
broker_live_connectionsrows: active connection duration plus 90 days after revocation (for dispute resolution). Audit log: 7 years (ADR-0003). Velvet token rows:revoked_atset on disconnect; purged after 90-day window. - Deletion on DSR: CASCADE DELETE from
users.idpropagates tobroker_live_connections. Corresponding Velvet row is revoked and purged. For SnapTrade connections: Raxx calls the SnapTrade delete-connection API before removing the Velvet row. Audit trail rows retain hashed actor ID (no reversible PII, per ADR-0003). - Audit trail: Every
submit_order,cancel_order,replace_order,revoke, andconnection_statechange writes toaudit_logviacustomer_audit_writer_service.py. Order-affecting rows participate in the KMS HMAC chain (SC-A11/SC-A14). - Stored credentials: All tokens in Velvet only — envelope-encrypted, no plaintext column. SnapTrade connection ID encrypted at rest for consistency (not itself secret). No credential in any file that ships with the repo or in any logged environment variable.
- Breach notification path: Token-exposure breach is financial PII. 72-hour notification to supervisory authority (GDPR Art. 33) and affected users (Art. 34) via the ADR-0003 path. SnapTrade breach: SnapTrade notifies Raxx under their DPA; Raxx re-notifies within 72 hours from notification receipt.
- Secrets location and rotation:
TRADIER_CLIENT_ID,TRADIER_CLIENT_SECRET,SNAPTRADE_API_KEYin Infisical secret store. All rotatable without redeploy. Velvet rotation handler perbroker_slug. - Kill-switch:
BYOB_LIVE_DISABLED=1(all BYOB live orders) andBYOB_BROKER_<SLUG>_DISABLED=1(per-broker). MBT paper trading is unaffected.
9. Language Choice Rationale
This ADR does not introduce a new service. All new modules are additions to existing services:
Service: Raptor (modified, not new) Language tier: - [x] Tier 2 — Python: broker adapter modules, registry, migration.
Broker adapter modules in Raptor do not trigger Tier 1 promotion criteria: broker API
round-trips are 100-500ms regardless of the adapter implementation language (no
sub-5ms p99 budget applies here); no cryptographic key material is handled in the
adapter (Velvet holds all ciphertext); throughput at v1 post-launch scale does not
stress a Python adapter. The credential-handling hot path is already Tier 1 (Velvet,
Rust, operator-designated). Reference: docs/architecture/language-tier-policy.md.
API contract portability (Tier 2): The BrokerAdapter ABC method signatures are
portable to a future Rust/C++ implementation without redesign.
Ruling — 2026-08-06 (adjudication: PM + QA + software-architect, deciding voice
software-architect), settling a QA finding on PR #4403 / #4217:
The original text of this section read "these [Python exception types in error paths] should be codified as enum error codes ... to preserve language neutrality" — worded as a future-port nicety. That is corrected here: the shape is binding now, for a reason independent of any future Rust/C++ port.
The distinction that matters is when an error is known, not what language reads it:
- Precondition / programmer errors —
InvalidOrderSpecError(badOrderSpecshape),UnknownBrokerError(unregistered slug),NoBrokerConfiguredError(user has no active connection) — are all detected before any broker is called and before the §3.7 step-5 pre-submitaudit_logrow exists to reconcile. Raising Python exceptions for these is unchanged and correct; there is nothing to port around because these never cross the adapter boundary into broker-domain data. - Broker-domain outcomes — the broker rejected the order, doesn't support the
requested shape, is unavailable, or the account has insufficient funds — happen
after the pre-submit audit row is written (§3.7 step 5) and must be
reflected in it (§3.7 step 7, "audit trail" invariant in §2). Signaling these via
a raised exception (as the merged
UnsupportedOrderErrordoes today) means the audit-row-closing code at step 7 needs a matchingexceptbranch, on every route, for every adapter, forever — one missed branch and a money-moving action's audit row is left open. Signaling them viaOrderResult.error_code(anOrderErrorCodeenum, added in §3.3) means step 7 runs unconditionally on the normal return path. This is an audit-trail-invariant argument, not a portability-only one; it would hold even if Raptor stayed Python forever.
The future-port portability benefit is real but secondary: enum values survive a language port with no interface redesign, where a raised exception class hierarchy does not.
Binding scope: submit_order, cancel_order, replace_order, get_order_status
signal broker-domain failures via error_code on the returned dataclass — never by
raising. UnsupportedOrderError is retained as a class (harmless, may still be used
internally by an adapter for its own pre-translation validation) but is no longer
part of the adapter-boundary contract; nothing at the BrokerAdapter ABC boundary
raises it. This must land as a fix-forward patch to the merged SC-1 scaffold
(types.py, base.py) before SC-2 (#4218) implements AlpacaBrokerAdapter's order
methods, so the first concrete adapter is built against the corrected contract
rather than reworked afterward. See the follow-up card spec in the #4216 epic
comment (2026-08-06) for the precise patch; software-architect specs it,
feature-developer/conductor implements it.
Antlers (BrokerPlatformSelectView): Tier 2 — TypeScript/Next.js, consistent
with all Antlers components.
Velvet (SC-4, broker_credentials table + broker_tokens blueprint): Tier 2 —
Python. Corrected 2026-08-06 (see §14) — this line previously said "Tier 1 — Rust,
operator-designated," which described Velvet's future target tier, not its current
implementation. velvet/ in this repo is entirely Python/Flask/SQLAlchemy today (zero
.rs files); every existing migration (velvet/db/migrations/001-003_*.sql) is plain
SQL run by python -m velvet.db.migrate. Per docs/architecture/language-tier-policy.md
§1: Velvet is a named Tier 1 candidate (C-1: secret rotation, key-material
distribution) — "Python v1 ships first, Tier 1 rewrite follows" is the policy's own
language for it, and it is listed under "Current Tier 2 services" until that
promotion happens. SC-4 builds in the current Python service, following every other
velvet/db/migrations/*.sql file's conventions (plain idempotent SQL, pytest, not
Rust unit tests). A future Tier 1 Rust rewrite of Velvet — if and when the operator
promotes it per the language-tier-policy.md §3 promotion-decision flow — inherits
broker_credentials under the standard parallel-implementation contract (§4 of that
policy: OpenAPI spec + behavioral parity suite before rewrite start). This ADR does
not itself trigger or block that promotion; it is out of scope here.
10. Alternatives Considered
SnapTrade as the only integration (no named direct broker)
Rejected. Operator explicitly required "both — aggregator + a named direct broker." Additionally, multi-leg options support via SnapTrade is downstream-broker-dependent and cannot be guaranteed for the brokers most relevant to iron-condor traders. Tradier Tier A gives Raxx a reliable, Raxx-controlled options order path.
Tastytrade as the second direct broker instead of Tradier
Not rejected as a future target. Deferred for v1-live because the partner-program approval gate (2-4 weeks to obtain sandbox access) delays the start of engineering work. Tradier's self-serve sandbox allows work to start immediately after BLR TOS review clears. Tastytrade is filed as open question OQ-2.
Big-bang refactor: rewrite all 429 Alpaca references at once
Rejected. A single large PR touching trading.py, options.py, trading_runtime.py,
historical_bars_service.py, and related routes simultaneously creates high regression
risk across paper trading, market data, and live-execution paths. The incremental
adapter-wraps-existing approach (§3.4) preserves all existing behavior through each
pass and allows each pass to be reviewed, tested, and soaked independently.
Extend alpaca_integration.py with Tradier branches
Rejected per ADR-0052 — the file grows unbounded with each new broker and cannot be
tested cleanly. Adapter isolation is required for independent testing and for the
health_check() feed to the status page.
Fidelity as the second direct broker
Rejected. Fidelity's direct API (WIX / FDX per ADR-0050/0123) is enterprise-gated and access approval is slow and uncertain. Fidelity is available via SnapTrade (Tier B) once Phase 2 ships. Tradier unblocks work now.
Keep exceptions for broker-domain order outcomes (status quo from PR #4403)
Rejected 2026-08-06. Considered as option (b) in the adjudication (treat §9 as advisory/future-port-only, accept the exception pattern as v1-canonical). Rejected because the audit-trail invariant argument in §9 is independent of language portability — it applies to the current, Python-only, v1 system. Accepting the exception pattern here would have let it hard code across Tradier (#4221), SnapTrade (#4224), and the compliance wrapper (#4226) before anyone revisited it.
11. Open Questions (operator decisions required)
OQ-1 (hard blocker for SC-7 — Tradier production deploy):
BLR must review Tradier's developer API agreement and third-party platform terms under
the same scope as the Alpaca TOS review (#932) before Tradier orders can reach
production. BLR engagement must be initiated in parallel with this ADR. The SC-7
sub-card carries the blocked label until BLR sign-off is received.
OQ-2 (Phase 3 input, not a v1-live blocker): After Tradier GA soak: does the operator want tastytrade as a second named Tier A direct broker, or defer tastytrade to the SnapTrade Tier B integration (which may route tastytrade accounts naturally via the aggregator)?
OQ-3 (hard blocker for SC-8, SC-9 — SnapTrade integration): SnapTrade contract and pricing tier. SnapTrade sub-cards are blocked until contract is signed. Operator action: initiate SnapTrade contract discussion. Last known pricing from BLR research doc #2627: ~$2/user/month for real-time + trading access.
OQ-4 (Tradier scope, affects SC-5 acceptance criteria): Tradier supports extended-hours orders and certain margin products. For v1-live, are these in scope, or should the adapter restrict to regular-hours equity and options orders only? Recommendation: restrict to regular-hours in v1; extended-hours is a follow-on tech card.
12. References
- [ADR-0002](https://internal-docs.raxx.app/architecture/adr/0002-no-stored-credentials.html) — no stored credentials invariant
- [ADR-0009](https://internal-docs.raxx.app/architecture/adr/0009-oauth-token-posture.html) / [ADR-0014](https://internal-docs.raxx.app/architecture/adr/0014-alpaca-scope-reframe.html) — token exception + Alpaca scope (extended by this ADR)
- [ADR-0013](https://internal-docs.raxx.app/architecture/adr/0013-mbt-paper-trading-engine.html) / [ADR-0108](https://internal-docs.raxx.app/architecture/adr/0108-mbt-engine-design.html) — MBT permanent paper layer
- [ADR-0052](https://internal-docs.raxx.app/architecture/adr/0052-broker-adapter-interface.html) —
BrokerAdapterABC; this ADR expands the interface contract - [ADR-0109](https://internal-docs.raxx.app/architecture/adr/0109-byob-roadmap.html) — BYOB tier model; this ADR selects v1-live platforms from that roadmap
- Epic #495 — Hybrid broker model
- Epic #4216 — v1 Live Trading: Multi-Broker Platform Support (sub-card chain for this ADR)
- Research doc #2627 — broker inventory + regulatory analysis (BLR)
docs/architecture/multi-tenant-alpaca.md— Alpaca market-data account (Surface 1, shared)- Project memory:
project_byob_hybrid_strategy,feedback_no_backend_branding,feedback_deterministic_execution_ai_augments
13. Adjudication record (2026-08-06)
Adjudication team: product-manager (ratifies at next standup), qa-agent (finding
raised on record), software-architect (deciding architectural voice). No legal
dimension identified — BLR pass waived for this consensus, consistent with the
operator's ship-fast posture (ADR-0020).
Ruling on §9 (error-code binding question): Hybrid — see §9 above for the full
rationale and binding scope. Precondition/programmer exceptions unchanged; broker-
domain order outcomes move to OrderResult.error_code. Fix-forward patch to the
merged SC-1 scaffold specced (not implemented by software-architect) as a follow-up
card, sequenced before SC-2 (#4218)'s order-method implementation.
Ruling on #4218 comment 5200954867 (points 1-3): All three of feature-developer's proposed approaches are accepted as the v1 shape, with two tightenings — see §3.2 and §3.4 implementation note. Full detail posted to the #4216 epic comment thread (2026-08-06).
Status bump: Proposed → Accepted. The shape questions that kept this ADR from
being fully settled (§9 error-signaling contract; SC-2's credential-model shim) are
resolved by this ruling. ADR-0052's own status is tracked separately (see "Revisit
when" below); it does not gate this ADR's status.
14. Adjudication record (2026-08-06, ruling 2) — SC-4 (#4220) pre-code stop
Adjudication team: product-manager (ratifies at next standup), qa-agent (finding on
record from #4220), software-architect (deciding architectural voice). No legal
dimension — BLR pass waived, consistent with the ADR-0020 ship-fast posture already
applied to ruling 1 (§13) on this same ADR.
Ruling on language/service target: SC-4 is implemented in this repo's existing
Python Velvet service — not a separate, not-yet-existing Rust Velvet. §9's Velvet line
is corrected in place (see §9 above) to match docs/architecture/language-tier-policy.md,
which already carried the accurate framing ("Python v1 ships first, Tier 1 rewrite
follows") that this ADR's §9 had drifted from. feature-developer proceeds with a
plain SQL migration + pytest, matching velvet/db/migrations/001-003_*.sql.
Ruling on table shape: No tokens table exists to extend. SC-4 creates a new
table, broker_credentials, in Velvet — not an ALTER TABLE against the Infisical-proxy
metadata cache behind /tokens*. Full schema, routes, and the intent-based
E_TOKEN_READ_ONLY enforcement point are specified in §5 Migration C above (rewritten
in this same amendment).
Ruling on Alpaca backfill: No-op in Pass 1. SC-2 (#4218, merged) never wrote a
per-user Velvet-stored Alpaca token — it resolves credentials via the existing
shared-platform-credential path and explicitly ignores velvet_token_id. broker_credentials
ships empty; there is nothing to backfill until a real per-user Alpaca OAuth path
exists (tracked in "Revisit when," not SC-4's scope).
Ruling on E_TOKEN_READ_ONLY enforcement: No write_order() endpoint is built.
Enforcement attaches to GET /broker-tokens/{id}/value via a required intent param
(read | order_write); order_write + no_order_write=TRUE → 403
E_TOKEN_READ_ONLY. Full rationale in §5 Migration C above.
Scope-change flag for PM re-scoping: SC-4 as originally carded ("3 ALTER TABLE
columns + a 403 check," size:s, ~1 day) is materially smaller than what this ruling
specifies (new table + envelope encryption + 5 new routes + intent-gated enforcement
+ a purge endpoint DSR will depend on). Recommend the card is re-sized (size:s →
size:m at minimum) and re-titled before card-groomer/feature-developer resume;
flagged on #4220 and cross-referenced on epic #4216.
Revisit when
- BLR Tradier TOS review completes — OQ-1 resolved, SC-7 unblocked.
- SnapTrade contract signed — OQ-3 resolved, SC-8 and SC-9 unblocked.
- Tradier GA soak completes — assess tastytrade Tier A promotion (OQ-2).
broker_live_connectionsUNIQUE constraint needs relaxing for multi-broker households (Phase 4+ per ADR-0109 §4).- ADR-0052 status updated from Proposed to Accepted once SC-1 ships.
- Any SnapTrade-routed broker reaches >500 active connections — assess Tier A direct promotion cost/benefit.
- A real per-user Alpaca OAuth/token-issuance path is built —
broker_credentialsgets its first Alpaca rows at that point; SC-4's backfill is a no-op until then (§5 Migration C, §14 ruling 2026-08-06). - Velvet hits a Tier 1 promotion threshold (
docs/architecture/language-tier-policy.md§1/§2) — promotion review is filed there, not amended into this ADR. - SC-3/SC-4 land — revisit the SC-2 credential-model shim (§3.4 implementation note
point 1);
AlpacaBrokerAdaptershould stop ignoringvelvet_token_idonce a real per-user Alpaca connection row exists.