Raxx · internal docs

internal · gated

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:

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.


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):**

  1. Credential model shim. Today's live Alpaca integration (trading_runtime.resolve_trading_credentials) uses a single shared platform-level credential pair selected by mode ("paper"/"live"); there is no per-user alpaca_live_connections-style row and no Velvet client in backend_v2 yet (those land in SC-3/#4219 and SC-4/#4220). The ABC signature is unchanged — every method still accepts velvet_token_id — because Tradier (#4221) is genuinely per-user OAuth from day one and must not be redesigned around a temporary Alpaca gap. AlpacaBrokerAdapter Pass 1 accepts velvet_token_id for interface conformance and ignores it, resolving credentials via the existing mode="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 before FLAG_MULTI_BROKER_ADAPTER flips on (§6, Dark phase), so there is no live exposure window.
  2. Equity order submission reuses existing options-order functions. No new live-order-submission function is needed: submit_options_order (1 leg) and submit_multileg_options_order (2/4 legs) in options_chain_service.py are generic over symbol type and already mode-aware. AlpacaBrokerAdapter.submit_order dispatches to one or the other by len(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-leg OrderSpec does not pick up options-only fields (contract multiplier, OCC symbol assumptions) when mapped.
  3. get_order_status / replace_order need two additive helpers. No live-mode single-order GET or PATCH exists today (fetch_alpaca_orders only lists; cancel_alpaca_order only deletes). Add fetch_alpaca_order_status(mode, order_id) and replace_alpaca_order(mode, order_id, patch) to trading_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:

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.


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).
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):

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.


8. Security / GDPR Checklist


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:

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


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: ProposedAccepted. 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=TRUE403 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