How the workspace is put together and where each responsibility lives. This describes the code as it is; for how to run it see the README, operations, shared authority and paper trading.
Three published crates plus example packages, all on the workspace version
(0.2.0, Rust 2024 edition, MSRV 1.89, MIT OR Apache-2.0):
| Crate | Role | Key dependencies |
|---|---|---|
rustyschwab-types |
Wire types only. No I/O, no async. | serde, serde_with, rust_decimal, chrono; optional schemars |
rustyschwab |
The SDK: OAuth, REST client, streaming client, durable execution state, paper engine, remote backends. | tokio, reqwest (rustls), tokio-tungstenite, rusqlite (bundled), governor, tracing, metrics, chacha20poly1305, keyring, secrecy |
rustyschwab-mcp |
MCP server over the SDK, plus the operator, authority, gateway and paper CLIs. | turbomcp 4 (alpha), axum, jsonwebtoken, metrics-exporter-prometheus |
examples/ holds workspace packages (oauth-flow, auth_test,
comprehensive, streaming-examples, enterprise-bench); run them with
cargo run -p <package>, not cargo run --example.
┌───────────────────────── rustyschwab-mcp ──────────────────────────┐
│ server.rs (40 tools) policy.rs network.rs identity.rs redact.rs │
│ backend.rs (live | paper) stream_cache.rs status.rs │
│ projection.rs portfolio.rs order_status.rs journal.rs │
│ performance.rs │
│ guidance.rs (1 resource, 1 prompt) authority.rs (gateway) │
│ bins: admin/authority/remote/paper/paper-verify/eval │
└───────────────┬─────────────────────────────────┬──────────────────┘
│ SchwabClient / StreamClient │ TradingBackend / PaperBroker
┌────────────────────────────────┴─────────────────────────────────┴──────────────────┐
│ rustyschwab │
│ auth/ client.rs streaming/ execution.rs coordination.rs risk.rs │
│ transport/ retry.rs reconciliation.rs recovery.rs readiness.rs │
│ circuit_breaker.rs request_budget.rs storage.rs (SQLite) audit_delivery.rs │
│ broker.rs (TradingBackend) paper/ remote.rs security.rs metrics.rs telemetry.rs │
└──────────────────────────────────────────┬──────────────────────────────────────────┘
│
┌──────────────────────────────────────────┴──────────────────────────────────────────┐
│ rustyschwab-types: market_data trading (+ builder, spec) accounts streaming (+ payload) │
└─────────────────────────────────────────────────────────────────────────────────────┘
One module per Schwab API area: market_data, trading, accounts,
streaming, common. Design rules:
- Strict out, tolerant in. Types the SDK sends (
trading::OrderRequest,OrderRequestLeg,OrderRequestInstrument) usedeny_unknown_fieldsand exactDecimalprices/quantities. Types Schwab returns (Order,Account, quotes, stream payloads, …) accept unknown fields and usef64where Schwab emits numbers. That is deliberate, not a shortcut: Schwab sends JSON numbers,serde_jsonparses them tof64before anyDecimaldeserializer runs, andrust_decimalthen re-parses the shortest round-trip string, so aDecimalresponse field would carry exactly the same value. Turning onserde_json'sarbitrary_precisionto change that would replace numbers insideserde_json::Valuewith private maps and breakcanonical_jsonintent digests. Prices becomeDecimalwhere they enter exact accounting (paper::feedconverts viato_string().parse()). Decimalis re-exported (rustyschwab_types::Decimal, also reachable asrustyschwab::types::Decimal) so callers do not depend onrust_decimaldirectly. Decimal fields deserialize from strings or numbers and serialize as strings.- Order construction lives in
trading::builder(constructors such asOrderRequest::limit,one_cancels_other,bracket;OptionSymbolfor the OSI contract format) andtrading::spec(OrderSpec, a JSON composition keyed bykindthat expands to the sameOrderRequest).OrderRequest::validatechecks structure before anything leaves the process. See ORDERS.md. - Streaming payloads arrive with numbered fields;
streaming::payloadholds the per-service field tables and typedStreamPayloadvariants (DataItem::decode). Enum spellings for the Trader API were verified against Schwab's documentation and the Schwabdev 3.0.5 reference client (see the CHANGELOG "wire-shape audit" entries). - The
schemarsfeature derivesJsonSchemaon the input types so the MCP crate can publish tool schemas without hand-written JSON.
config::SchwabConfig bundles OAuthConfig, ClientConfig (base URL,
timeout, retry, rate limit, circuit breaker) and an optional StreamConfig;
SchwabConfig::from_env reads the SCHWAB_* variables listed in
.env.example. validate() runs on construction.
auth::AuthManager owns the OAuth authorization-code flow, the token set
and refresh. Tokens are SecretStrings held in a TokenSet; persistence
goes through the TokenStore trait with three backends
(FileTokenStore 0600 JSON, EncryptedFileTokenStore ChaCha20-Poly1305,
KeychainTokenStore via keyring). Refresh is serialized across processes
with a lock file next to the token file, and a rejected access token is
refreshed once and retried by the client. The optional callback-server
feature adds an axum/rustls HTTPS listener that captures the redirect
(capture_callback / SCHWAB_CAPTURE_CALLBACK); without it authorize
returns the URL and the caller passes the code to exchange_code. PKCE
(S256) is implemented but off by default because Schwab's public flow does
not use it.
security.rs is the encryption, key derivation and file-permission code
used by the stores; security_windows.rs applies an owner/SYSTEM DACL on
Windows.
client::SchwabClient is a cheap-to-clone Arc handle. Every endpoint
method is a thin wrapper that names its operation (op label for metrics)
and calls execute_resilient, which runs one logical call as:
ensure_live_state— the durable state file must not belong to a paper run (meta.execution_mode).- Fetch the access token from
AuthManager. CircuitBreaker::call(default: opens after 5 failures, half-open after 30 s; 429s show the broker is answering and never count). Reads and cancellations run insideRetryPolicy::execute(default 3 retries, 100 ms → 10 s backoff with jitter,Retry-Afterhonoured up to 5 minutes); placements and replacements are never retried — an ambiguous outcome becomesError::SubmissionUnknowncarrying the attempt ID. They also take the credential's execution lease, waiting for another in-flight submission rather than failing.- Each attempt:
RequestBudget::admit(in-processgovernorlimiter + rolling per-minute window + shared quota through theCoordinator, keyed by app key and API origin; default 2 req/s, burst 10), then for submissions the durable operator halt,RiskProvider::revalidate(fenced by a coordination lease when supplied) and the in-process submission gate, then the transport call. - A 401 on a read triggers one
refresh_rejected_tokenand one replay. - The call is bounded by
ClientConfig.timeout(default 30 s; a silent connection fails after 10 s) and recorded inschwab_http_requests_total/schwab_http_request_duration_seconds. The deadline starts after the first token fetch and, for a submission, once its turn at the execution lease comes (the wait has its own bound of the same length); the lease is released outside it, so an answer that arrived is kept. A submission with under 5 s (or half the timeout) left is refused unsent.
transport::HttpTransport wraps reqwest; request_for_location handles
Schwab's 201 + Location order responses.
submit_order_request (behind place_order, replace_order and their
_fenced variants) validates the request, assigns a tag if absent, and
walks the execution journal:
OrderJournal::begin_once ─► RiskProvider::reserve ─► LeaseSession (execution:<quota key>)
│ │
│ transport (never replayed)
▼ ▼
finish(Rejected) on definite failure finish(Accepted{order_id}) / SubmissionUnknown
Pending intents survive crashes and block new submissions for that account
until reconciled. execution::ExecutionStore is the trait; OrderJournal
is the local implementation over storage::StateDatabase, a SQLite file in
WAL mode (<tokens_file>.orders.store/state.sqlite3; a pre-0.2 JSON journal
at <tokens_file>.orders.json is imported once). Tables: submissions,
audit, consumed_approvals, used_order_tags, accounts, orders,
executions, leases, quota, quota_config, approvals,
reservations, intents, audit_anchor, meta, schema_migrations.
Migrations, online backup and restore live in storage.rs. audit rows
form a SHA-256 hash chain (audit_chain.rs, checked by
OrderJournal::verify_audit and rustyschwab-admin verify-audit).
Around the journal:
coordination::Coordinator— renewable ownership leases with epochs and the shared request quota. The local implementation is the same SQLite file, so several processes on one host share limits and fences.risk::RiskProvider— pre-trade checks with durable reservations;PortfolioRiskenforces per-account open-quantity/notional/position caps from a JSON policy and counts external open orders. Its optionalmax_daily_losstrips the durable operator halt (execution::trip_halt) once the reconciled baseline's day loss exceeds it, at the next reservation and, throughobserve_baseline, at each reconciliation; the paper engine applies the same cap to its bid-marked equity at every event.reconciliation— matches broker orders and activity to journal intents by unique tag, records generation-stamped account snapshots, and treats absence as inconclusive (never as rejection).recovery::RecoveryWorker— restartable per-account reconciliation loop that publishesRecoveryStatus;readiness::Readinessreports what local evidence says about reads and trading being safe.audit_delivery— batches theaudittable to an external sink with acknowledged, replay-safe delivery.
broker::TradingBackend is the trait the MCP gateway and authority dispatch
through: place_order_fenced, replace_order_fenced, cancel_order,
readiness, operator halt. SchwabClient implements it as "live".
paper::PaperBroker implements it as an isolated long-equity simulator
(paper/engine.rs execution and accounting, paper/model.rs versioned
inputs and reports, paper/feed.rs optional read-only REST quote/calendar
polling, paper/synthetic.rs quotes derived from OHLCV candles under the
declared candle-bbo-v1 model for backtesting). Paper state is its own
SQLite file; a live client refuses to open it. remote::RemoteBackend implements ExecutionStore, Coordinator and
RiskProvider over authenticated HTTPS to a rustyschwab-authority process so
workers on other hosts share one journal without holding broker credentials.
streaming::StreamClient speaks Schwab's streamer protocol over
transport::WebSocketTransport. It logs in with a StreamerIdentity taken
from GET /userPreference, exposes typed subscribe_*/unsubscribe_*
helpers for all 13 services, and delivers StreamMessages through a
tracked bounded queue (ChannelKind::Bounded(4096) by default):
- Every queued message carries the connection generation; the
MessageReceiveronly yields messages from the current, ready generation and interleavesStreamMessage::State(StreamStatus { ready, generation, lost_messages })whenever that changes, so consumers re-baseline instead of consuming stale data. - Overflow is never silent: it bumps
lost_messages, flipsreadyto false and incrementsschwab_stream_dropped_messages_total. The socket stays up: frames are dropped until the consumer drains half the queue, then delivery resumes under a new generation, so consumers re-baseline as after a reconnect without another LOGIN. - Heartbeat: ping every 20 s, reconnect if no pong within 30 s. Reconnect backoff 2 s → 128 s with ±25% jitter, unlimited by default. Subscriptions are restored on reconnect and only confirmed once Schwab acknowledges them.
- Field selection per service:
set_service_fieldsfor later subscriptions,view(Schwab'sVIEW) for live ones. Frames are capped at 16 MiB and decode toStreamPayloadthrough the field tables inrustyschwab-types.
The SDK emits tracing spans/events and metrics facade calls only; both
are no-ops until the host installs a subscriber and a recorder.
metrics.rs is the closed catalog of every metric name, label set and
help text (describe() registers them), and telemetry.rs has tracing
initialisation helpers. Errors (error.rs) are a single Error enum with
ErrorKind, http_status(), is_retryable() and a serializable
ErrorEnvelope for transports.
server::SchwabMcp is one #[turbomcp::server] impl (turbomcp 4, serving
the 2025-06-18 and 2025-11-25 session revisions and the stateless
2026-07-28 one through the same handlers) with 40 #[tool]
methods in category order: market data (12, including the get_candles
and get_option_contracts projections), accounts (6, including the
get_positions projection and get_portfolio_summary), orders read (6,
including get_order_status, get_trade_journal and get_performance), orders write (build_order,
check_order, preview_order, place_order, cancel_order,
replace_order), transactions (2), streaming control and cached reads (7),
server (get_server_status).
Each tool carries explicit read-only/destructive/idempotent/open-world hints;
tests/tool_inventory.rs pins the list. Tools hold a backend::Backend
rather than a SchwabClient: Live(Arc<SchwabClient>) or
Paper { broker: Arc<PaperBroker>, market_data: Option<Arc<SchwabClient>> }.
The backend answers account, order and quote reads and routes mutations to
its venue; in paper mode the venue's PaperOrder, PaperFill, snapshot
and quote are rendered in Schwab's shapes and parsed back through the
Schwab types, so a paper read can never carry a shape a live one could
not, and everything the venue does not model (preview_order,
transactions, user preferences, ACCT_ACTIVITY) is refused as
not_permitted. Backend::market_data(what) is the Schwab client for
market data and the stream: the live client, or the optional read-only
one, which the venue never routes an order through. Backend::source()
labels every read and mutation the backend served (schwab_rest or
paper). server::Switches carries the two write switches, read once at
startup, so a server built without the environment cannot trade.
build_order expands an
OrderSpec locally so the operator-approved intent and the JSON sent to
Schwab are the same shape. Responses pass through redact.rs, which drops
streamer credentials and identity fields and masks account numbers unless
SCHWAB_MCP_REDACT_PII=0. stream_cache.rs keeps per-service snapshots
keyed by field name with explicit freshness and a bounded activity log, and
reconciles them from REST after a reconnect (schwab_stream_recovery_seconds).
status.rs assembles get_server_status from the backend's local evidence
(mode, trading switches, operator halt, readiness, request budget,
circuit, stream, the paper venue's clock and session) plus one cached read
of Schwab's equity calendar per US-Eastern day; AuthorizedServer calls
ServerStatus::scope_to on the policy-gated transports, which adds the
principal section and withholds the halt's reason (operator text, and from
the daily-loss control an account's equity, loss and cap) from a principal
that is not granted every account, and puts the mode in
serverInfo.title. projection.rs turns a price history, an option chain or an
account into compact column/row pages: rows are sorted by a typed key
(candle time, expiration|strike|C/P, symbol#ordinal), a page stops at
the caller's limit or the SCHWAB_MCP_RESPONSE_BUDGET_BYTES budget, and
nextCursor is the last key so re-reading Schwab between pages never
skips or repeats a row. The raw tools keep Schwab's shape. portfolio.rs
composes get_portfolio_summary from two backend reads (orders from the
last 59 days, inside Schwab's 60-day list horizon, then the account with
positions) and the
execution journal: typed balances, P&L summed over positions, the first
get_positions page, the non-terminal orders (OrderStatus::is_terminal,
pinned to reconciliation's SQL) with children flattened, the count of
unanswered submissions and, in paper mode, the venue's own ledger; the
account read is essential, the others degrade to a null section plus a
warning. order_status.rs reduces one Order to
get_order_status's report (terminal flag, filled and remaining
quantities, quantity-weighted average fill price overall and per leg from
the FILL executions, latest update time, replacing and child orders) and
implements the bounded wait: one get_order read per second until the
order is terminal or wait_ms (capped at 30 s) elapses. Its leg projection
is shared with the portfolio summary's working-order rows. journal.rs
validates the write tools' optional rationale (journal-only text, excluded
from the intent digest), records how each audited dispatch ended as
structured evidence (Dispatch: the venue order id, or the failure code)
and composes get_trade_journal: the principal's audit records for one
account joined, by order id or by tag, to one backend read of the orders
entered within the window, paged newest first. Every tool body runs through
contract::run, which boxes it once so the macro-generated dispatcher's
future and stack frame stay small however many tools there are.
.env,tracingto stderr (stdout is the MCP channel), optional Prometheus listener atSCHWAB_METRICS_BIND.server::Switches::from_env, then the backend. Live:SchwabConfig::from_env→SchwabClient; ifSCHWAB_MCP_ALLOW_TRADING=1the process also requiresSCHWAB_RISK_POLICY_FILEandSCHWAB_RECOVERY_ACCOUNTS, and aRecoveryWorkerruns for those accounts. Paper (SCHWAB_MCP_PAPER_CONFIG+SCHWAB_MCP_PAPER_STATE):PaperBroker::openover the venue's config and state, reading no Schwab environment unlessSCHWAB_APP_KEYis set, which adds the read-only market-data client; the venue's per-account limits stand in for the risk file and no recovery worker runs.SCHWAB_HEALTH_BIND(loopback only) serves/livezand/readyz, whose body carriesmode.- Transport from
SCHWAB_MCP_TRANSPORT:stdiowithout a policy file and without trading/cancel enabled runs the plainSchwabMcp(local trusted host, read-only tools).- Otherwise
SCHWAB_MCP_POLICY_FILEis mandatory. It is a list ofpolicy::PrincipalPolicyentries; each becomes anAuthorizedServer. Stdio picks one bySCHWAB_MCP_PRINCIPAL;http/websocketbindSCHWAB_MCP_BIND(loopback unlessSCHWAB_MCP_ALLOW_REMOTE=1behind a TLS proxy).
- SIGTERM/Ctrl-C calls
begin_drain(no new submissions) and gives open transports 30 s to finish.
policy.rs—PrincipalPolicynames the tools, accounts, quantity and notional caps, option multipliers and approval requirement for one principal.authorize(name, args)runs before dispatch: it parsesargs.orderasOrderRequest, computes exposure (rejecting anything it cannot bound, such as market or stop children; a multi-leg option strategy counts itsdefined_risk, the most it can lose at expiration) and, whenrequire_approvalis set, demands a consumed single-use approval ofintent_digest(name, args)(SHA-256 over canonically sorted JSON, with a validorderfirst normalized to itsOrderRequestwire form so the digest binds the order Schwab would receive, not one JSON spelling of it).authorizeis the first denial ofreview, which keeps them all.AuthorizedServerwrapsSchwabMcpas anMcpHandler, writes the principal's audit journal and hands write tools to aTradingBackend. When that single denial is the approval and the submission asked forwait_ms,await_approvalpolls the same durable store for up toMAX_APPROVAL_WAIT_MSand judges again; it holds for no other denial, grants nothing itself, and runs beforebegin_once, so a hold that ends unapproved or is cancelled leaves no audit record and no order.guardrails.rs— the principal's limits beyond the per-order caps.allowed_symbols/denied_symbolsare part ofreview(an option matches by its underlying).price_collar_bpsreads the venue's market (TradingBackend::touch) once the policy passes, an approval aside, and again after an approval hold, refusing a limit priced that far through the far touch.max_daily_orders/max_daily_notionalare judged oncebegin_onceopens the audit record, when the principal has no other submission in flight, from the day's audit records (OrderJournal::records_since); a refusal there is a journaledrisk_deniedthat gives its approval back. The authority gateway applies the same three.preflight.rs—check_order: the samereview, the collar and the daily budget (with what it has used), plus the trading switch andBackend::submission_admission(operator halt, recovery suspension or the paper venue draining), reported together as aVerdictwithout dispatching or auditing.AuthorizedServerevaluates it under the principal with durable approvals merged in; the unauthorized stdio path evaluates it without a policy.guidance.rs— theschwab://order-specresource (theOrderSpecgrammar,guidance/order_spec.md) and thepropose_tradeprompt, rendered for anAudience: the tools the session holds, less the write tools the switches keep off, and the mode.AuthorizedServerlists and serves them only to principals holding the tools they are about, and the prompt only for the principal's accounts.network.rs— one turbomcp dispatcher serves every principal over Streamable HTTP (/mcp) and WebSocket.Credentials, turbomcp'sHttpAuthenticatorseam, checks the bearer token on every HTTP request and at the WebSocket upgrade (constant-time comparison of its SHA-256 against the policy, oridentity::JwtVerifierfor short-lived RS256 tokens fromSCHWAB_MCP_IDENTITY_FILE) and names the principal, its credential's expiry and the policy generation in the request identity.PrincipalRouterserves each request with that principal'sAuthorizedServer, refusing an expired credential or one checked under a replaced policy. The policy file is re-read every 2 s; any change, or an invalid file, retires every earlier credential.authority.rsandbin/rustyschwab-authority.rs— the shared transactional authority:/v1/backendfor privileged SDK workers (RemoteBackend),/v1/orders+/v1/leasefor trading identities that must not hold broker credentials,/v1/operatorfor verified approvals, halts and revocations. Single writer over its own SQLite file; default bind127.0.0.1:8766.bin/rustyschwab-admin.rs— trusted local operator commands (digest, approve, halt, resume, audit export) that act on the state file directly and are deliberately not MCP tools.bin/rustyschwab-remote.rssubmits one typed request to the authority with a protected token file.bin/rustyschwab-paper.rsandbin/rustyschwab-paper-verify.rs— deterministic replay (paced with--pace-mswhen a served venue should advance in virtual time), synthetic quote generation from price history, and transcript verification for the paper engine (paper_replay.rsis the shared command decoder).eval.rsandbin/rustyschwab-eval.rs— model evaluation over the paper venue.Scenariofixes a seeded venue, a principal policy and a task;Fixture::openbuilds them under a private directory behind anAuthorizedServer;Recordingis theMcpHandlerthat appends each call to a JSONL transcript before dispatching it;scorereplays aTranscriptthrough a fresh fixture and reduces the contract codes and venue orders to named checks plus the harness invariant (unauthorized_executions). No model is embedded.
REST read: tool or SDK call → execute_resilient → token → circuit
breaker → retry loop → budget admit → HttpTransport → typed response →
(MCP only) redaction.
Order through MCP over the network: bearer/JWT auth → principal router →
PrincipalPolicy::authorize (caps, approval digest) → TradingBackend:: place_order_fenced with a coordination lease → journal begin_once → risk
reserve → single transport attempt → journal finish → audit event →
OrderSubmitResponse { order_id, location } or SubmissionUnknown (never
auto-resubmitted; reconcile first).
Stream recovery: transport drop → generation bump and ready=false
(State message to consumers) → reconnect with backoff → re-login →
re-subscribe → acknowledgements confirm → ready=true; the MCP cache
additionally re-baselines each service from REST and reports the recovery
time.
CI (.github/workflows/ci.yml) runs cargo fmt --all -- --check,
cargo clippy --workspace --all-targets --all-features --locked -- -D warnings,
cargo doc --workspace --no-deps --all-features --locked,
cargo build --workspace --all-targets --locked,
cargo test --workspace --all-targets --all-features --locked,
cargo test --workspace --doc --all-features --locked, the SDK's feature
combinations (no default features, native-tls alone with no rustls in its
graph, callback-server alone), cargo package for the three crates,
cargo deny check advisories bans licenses sources (deny.toml),
actionlint (with shellcheck) on the workflows, an MSRV
(1.89) build, cargo-semver-checks, and the stream soak harness
(examples/enterprise_bench + tools/soak.py). qualification.yml runs the
full test suite on Linux, macOS and Windows plus longer soaks; release.yml
checks the tag against the crate version and runs the tests, then builds the
binaries for Linux (x86_64), macOS (aarch64) and Windows (x86_64) with
cargo auditable, checks each with cargo audit bin, and publishes them per
target with SHA-256 sums, a CycloneDX SBOM and build provenance attestation.
Property tests use proptest;
HTTP mocking uses wiremock. Evidence from local qualification runs is
written to docs/audit/, which is git-ignored; the hosted workflows upload
the same files as artifacts.
Everything above runs against mocks. The one gate that talks to Schwab is
the live conformance harness, crates/rustyschwab/tests/live_conformance.rs:
with SCHWAB_LIVE_CONFORMANCE=1 and real credentials it GETs every
read-only endpoint through SchwabClient::get_json, proves the typed
binding still decodes the body, and diffs the body's shape (key sets and
scalar types, never values) against tests/fixtures/conformance/*.shape.json.
A decode error or a scalar changing type fails; keys appearing or vanishing
warn, because Schwab omits absent fields and a sample is data-dependent.
SCHWAB_LIVE_CONFORMANCE=record merges the live shapes into the fixtures.
qualification.yml runs it weekly when the repository has
SCHWAB_APP_KEY, SCHWAB_APP_SECRET and SCHWAB_TOKENS_JSON secrets and
uploads live-conformance.json; without them the job is skipped, and the
harness's own request → decode → diff → record cycle is still covered by a
wiremock test in the same file.
| Task | Start at |
|---|---|
| Add or fix a REST endpoint | crates/rustyschwab/src/client.rs (one method per endpoint) and the response type in crates/rustyschwab-types/src/ |
| Change an order shape or add a constructor | crates/rustyschwab-types/src/trading.rs, trading/builder.rs, trading/spec.rs; docs/ORDERS.md |
| Add a streaming service or field | crates/rustyschwab-types/src/streaming/payload.rs field tables, then StreamClient::subscribe_* |
| Add an MCP tool | crates/rustyschwab-mcp/src/server.rs, then tests/tool_inventory.rs; policy-gated tools also need policy.rs |
| Change what a principal may do | crates/rustyschwab-mcp/src/policy.rs; runtime policy JSON documented in docs/OPERATIONS.md |
| Durable state, migrations, backup | crates/rustyschwab/src/storage.rs, execution.rs |
| Metrics or alerts | crates/rustyschwab/src/metrics.rs, crates/rustyschwab-mcp/src/metrics.rs, ops/ dashboards and rules |
| Paper engine behaviour | crates/rustyschwab/src/paper/, docs/PAPER_TRADING.md, docs/PAPER_LIMITATIONS.md |
| Add a model-evaluation scenario or check | crates/rustyschwab-mcp/src/eval.rs (Scenario, Fixture::open, checks), then tests/eval_harness.rs; docs/MCP_LLM_INTEGRATION.md#model-evaluation |