Skip to main content

zero_testkit/
mock_engine.rs

1//! Axum-based mock of the engine's FastAPI surface.
2//!
3//! Mirrors the JSON shapes emitted by `engine/zero/server.py` for the
4//! endpoints the CLI actually calls. Missing endpoints return 404 so
5//! tests fail loud when a new call is added without a mock.
6//!
7//! Usage in tests:
8//!
9//! ```no_run
10//! # use zero_testkit::mock_engine::MockEngine;
11//! # async fn run() -> anyhow::Result<()> {
12//! let mock = MockEngine::spawn().await?;
13//! let base = mock.base_url();
14//! // … construct an HttpClient against `base` and exercise it …
15//! mock.shutdown().await;
16//! # Ok(())
17//! # }
18//! ```
19
20use std::collections::BTreeMap;
21use std::net::SocketAddr;
22use std::sync::Arc;
23use std::time::Duration;
24
25use axum::Router;
26use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
27use axum::extract::{Request, State};
28use axum::http::StatusCode;
29use axum::middleware::{self, Next};
30use axum::response::{IntoResponse, Json};
31use axum::routing::{get, post};
32use parking_lot::Mutex;
33use serde_json::json;
34use tokio::net::TcpListener;
35use tokio::sync::oneshot;
36use tokio::task::JoinHandle;
37
38/// Overrides the test can inject to simulate engine states.
39#[allow(clippy::struct_excessive_bools)] // flags are independent
40#[derive(Debug, Default, Clone)]
41pub struct Overrides {
42    /// Force `/health` status to `"degraded"` with a custom component.
43    pub degrade_health: bool,
44    /// Return 503 on `/health` (simulate overloaded engine).
45    pub health_503: bool,
46    /// Return 401 on every typed `GET` endpoint other than `/` and
47    /// `/health`. Exercises [`HttpClient`]'s auth-error mapping
48    /// path (`HttpError::Unauthorized`). The version probe and
49    /// health surface stay open because the CLI uses them during
50    /// doctor runs before auth is wired.
51    pub force_unauthorized: bool,
52    /// Return 404 on every typed `GET` endpoint (same scope as
53    /// [`Self::force_unauthorized`]). Tests [`HttpError::NotFound`]
54    /// mapping and the client's missing-endpoint log behavior.
55    pub force_not_found: bool,
56    /// Return 500 on every typed `GET` endpoint. Non-retryable —
57    /// asserts that the client does **not** double-call on a
58    /// server error that isn't 502/503/504.
59    pub force_server_error: bool,
60    /// Return a degenerate-but-valid 200 on `/evaluate/{coin}`
61    /// — the JSON decodes, but `layers` is empty and `direction`
62    /// is absent. Exercises the dispatcher's empty-verdict guard
63    /// (`evaluate_cmd` must emit an alert + dismiss stale overlays
64    /// instead of opening an empty verdict card). Does not affect
65    /// any other endpoint.
66    pub force_empty_evaluation: bool,
67    /// Return `{}` (HTTP 200) on `/regime`. Matches a real
68    /// production failure mode where the engine exposes the
69    /// endpoint but never populates a payload. The dispatcher
70    /// must alert the operator instead of rendering a row of
71    /// em-dashes that looks like data.
72    pub force_empty_regime: bool,
73    /// Return `{"error": "<msg>"}` (HTTP 200) on `/regime`. Matches
74    /// the engine's "coin not found" envelope on `?coin=...`.
75    /// The dispatcher must surface the embedded error as an alert.
76    pub force_regime_error_envelope: bool,
77    /// Return 404 on `/approaching`. Matches older engine builds
78    /// that predate the endpoint. The dispatcher must detect the
79    /// `NotFound` and emit an explanatory alert instead of the
80    /// raw `"not found: /approaching"` error-display.
81    pub force_approaching_not_found: bool,
82    /// Return a `/risk` payload where `account_value > peak_equity`
83    /// (mathematically impossible by definition — peak is monotonic
84    /// max of equity). Mirrors a real production drift where the
85    /// engine kept writing `risk.json` with a stale equity number
86    /// that no live code path refreshed while the portfolio snapshot
87    /// was fresh. The dispatcher must surface the contradiction
88    /// instead of rendering a confident (but wrong) drawdown percent.
89    pub force_stale_risk_equity: bool,
90    /// How many further requests should respond with a transient
91    /// 503 before the real handler runs. Decremented atomically
92    /// per matched request. Used to verify the retry-once policy
93    /// (`HttpClient::get_json`) recovers when the first attempt
94    /// fails with 503 and the second succeeds. Setting this to
95    /// `>= 2` lets the test observe the retry limit: after one
96    /// retry, the second failure surfaces as `HttpError::Status`.
97    pub transient_fail_count: u32,
98    /// How many further requests should respond with a 429 Too Many
99    /// Requests before the real handler runs. Sister field to
100    /// [`Self::transient_fail_count`]; separated so a test can
101    /// pin engine-429 behavior without also exercising the
102    /// transient-retry code path.
103    ///
104    /// When this fires, the response body is empty and the
105    /// `Retry-After` header carries [`Self::rate_limit_retry_after`]
106    /// (or a sensible default when unset). The CLI's `HttpClient`
107    /// parses the header, refunds the local bucket, and surfaces
108    /// `HttpError::RateBudgetExhausted { origin: Engine429, .. }`.
109    pub rate_limit_count: u32,
110    /// Value placed in the `Retry-After` header on every injected
111    /// 429. Accepts any string the real engine might emit — plain
112    /// integer seconds (`"30"`) or an RFC-7231 IMF-fixdate
113    /// (`"Fri, 31 Dec 1999 23:59:59 GMT"`). When `None` (default),
114    /// the header carries `"1"` so tests inspecting the client's
115    /// parsed duration see an unambiguous 1 s rather than having
116    /// to reason about a missing header's fallback.
117    pub rate_limit_retry_after: Option<String>,
118    /// Custom version string for `GET /`.
119    pub version: Option<String>,
120    /// Cause the `/ws` handler to immediately close the connection
121    /// after accepting the upgrade, exercising the subscriber's
122    /// reconnect path. Resets to `false` automatically after one
123    /// drop so a test can: set → wait for drop → unset → verify
124    /// reconnect succeeds.
125    pub ws_drop_once: bool,
126    /// Operator-state label the `/operator/state` endpoint reports.
127    /// Defaults to `"steady"` when unset. Valid values match
128    /// `zero_operator_state::Label` snake-case: `fresh`, `steady`,
129    /// `elevated`, `tilt`, `fatigued`, `recovery`.
130    pub operator_label: Option<String>,
131    /// Monotonic version bumped on each change to `operator_label`
132    /// in tests that want to exercise the widget's version-skip
133    /// logic. Auto-increments when tests flip the label via
134    /// `with_overrides`.
135    pub operator_version: u64,
136    /// Engine-side `/auto/toggle` state the mock echoes back to the
137    /// next `POST /auto/toggle` caller. Tests asserting the "engine
138    /// refuses the flip" path pre-set this to a value that differs
139    /// from the request, so the response `state` reflects the
140    /// engine's truth rather than the caller's wish. `None` means
141    /// "mirror the request" (happy path).
142    pub auto_toggle_echo_state: Option<bool>,
143    /// Optional `reason` string the mock returns alongside
144    /// `auto_toggle_echo_state` — used to pin the refusal path
145    /// (e.g. `"operator state is TILT"`). Emitted verbatim.
146    pub auto_toggle_reason: Option<String>,
147    /// When set, every `POST /execute` and `POST /auto/toggle`
148    /// response carries `"simulated": true` regardless of the
149    /// `X-Zero-Mode` header the client sent. Used by tests that
150    /// want to assert the client surfaces the engine's truth
151    /// rather than locally inferring paper from its own `--paper`
152    /// flag. In production the engine flips this based on the
153    /// inbound header; the mock lets tests drive either path.
154    pub force_simulated: bool,
155    /// When set, `POST /execute` and `POST /auto/toggle` return
156    /// a single 503. Verifies that no silent retry fires on the
157    /// no-retry POST surface — the caller sees exactly one
158    /// upstream request with a typed `HttpError::Status` back.
159    pub post_transient_fail: bool,
160    /// When set, `POST /execute` returns 500. Same intent as
161    /// [`Self::post_transient_fail`] but for a non-retryable
162    /// status; belt-and-suspenders against any future change to
163    /// `is_retryable` accidentally catching the 500 family.
164    pub post_server_error: bool,
165}
166
167/// Shared state for the mock axum app.
168#[derive(Debug, Clone)]
169pub struct AppState {
170    pub overrides: Arc<Mutex<Overrides>>,
171    /// Every body the mock has received on `POST /operator/events`,
172    /// captured as the raw decoded JSON in arrival order. Tests that
173    /// exercise the `/rate`, `/break`, etc. rewires assert on this
174    /// to confirm the typed-event serialization actually reached the
175    /// wire. Kept as `serde_json::Value` rather than the typed
176    /// `zero_operator_state::Event` so the mock does not pre-validate
177    /// — the engine's real behavior (400 on bad shapes) is already
178    /// covered by the Python-side integration test.
179    pub received_events: Arc<Mutex<Vec<serde_json::Value>>>,
180    /// Every `(headers-snapshot, body)` pair the mock has received on
181    /// `POST /execute`, in arrival order. Tests assert on the headers
182    /// (especially `x-zero-mode` and `x-idempotency-key`) and the
183    /// body shape (typed `ExecuteRequest` round-trip via `serde_json`).
184    /// Captured as `(BTreeMap<String, String>, Value)` so both the
185    /// test and the stored data are trivially cloneable / printable
186    /// when an assertion fails.
187    pub received_executes: Arc<Mutex<Vec<CapturedPost>>>,
188    /// Every `(headers-snapshot, body)` pair the mock has received on
189    /// `POST /auto/toggle`. Same shape as [`Self::received_executes`].
190    pub received_auto_toggles: Arc<Mutex<Vec<CapturedPost>>>,
191    /// Every live control endpoint path the mock has received, in order.
192    pub received_live_controls: Arc<Mutex<Vec<String>>>,
193}
194
195/// A snapshot of one POST the mock captured — headers (lowercased
196/// names, string values) plus a parsed-JSON body. Structured so a
197/// failing assertion in a test prints the full payload rather than
198/// a byte vector the human has to decode by hand.
199#[derive(Debug, Clone)]
200pub struct CapturedPost {
201    /// Header name → value, keys lowercased. Only the headers the
202    /// mock actually inspects are captured (see
203    /// [`capture_relevant_headers`]); a test that wants to assert a
204    /// custom header adds it to the capture list in one place.
205    pub headers: std::collections::BTreeMap<String, String>,
206    /// Parsed JSON body. When the client sends malformed JSON the
207    /// handler short-circuits with 400 before capturing, so this is
208    /// always a valid `Value`.
209    pub body: serde_json::Value,
210}
211
212impl AppState {
213    fn new() -> Self {
214        Self {
215            overrides: Arc::new(Mutex::new(Overrides::default())),
216            received_events: Arc::new(Mutex::new(Vec::new())),
217            received_executes: Arc::new(Mutex::new(Vec::new())),
218            received_auto_toggles: Arc::new(Mutex::new(Vec::new())),
219            received_live_controls: Arc::new(Mutex::new(Vec::new())),
220        }
221    }
222}
223
224/// A running mock. Holds the listening address and a shutdown
225/// handle; automatically aborts on drop as a safety net, but prefer
226/// explicit `.shutdown()` so the port is reclaimable immediately.
227#[derive(Debug)]
228pub struct MockEngine {
229    addr: SocketAddr,
230    state: AppState,
231    shutdown: Option<oneshot::Sender<()>>,
232    handle: Option<JoinHandle<()>>,
233}
234
235impl MockEngine {
236    /// Bind to `127.0.0.1:0`, return the running mock.
237    pub async fn spawn() -> anyhow::Result<Self> {
238        let state = AppState::new();
239        let app = router(state.clone()).with_state(state.clone());
240        let listener = TcpListener::bind("127.0.0.1:0").await?;
241        let addr = listener.local_addr()?;
242        let (tx, rx) = oneshot::channel::<()>();
243
244        let handle = tokio::spawn(async move {
245            let _ = axum::serve(listener, app)
246                .with_graceful_shutdown(async move {
247                    let _ = rx.await;
248                })
249                .await;
250        });
251
252        // Brief pause so the server is ready to accept connections
253        // before the first request. Keeps tests flake-free without a
254        // full readiness handshake.
255        tokio::time::sleep(Duration::from_millis(10)).await;
256
257        Ok(Self {
258            addr,
259            state,
260            shutdown: Some(tx),
261            handle: Some(handle),
262        })
263    }
264
265    #[must_use]
266    pub fn addr(&self) -> SocketAddr {
267        self.addr
268    }
269
270    #[must_use]
271    pub fn base_url(&self) -> String {
272        format!("http://{}", self.addr)
273    }
274
275    /// `ws://…/ws` URL for the subscriber to connect to.
276    #[must_use]
277    pub fn ws_url(&self) -> String {
278        format!("ws://{}/ws", self.addr)
279    }
280
281    pub fn with_overrides(&self, mutate: impl FnOnce(&mut Overrides)) {
282        let mut o = self.state.overrides.lock();
283        mutate(&mut o);
284    }
285
286    pub async fn shutdown(mut self) {
287        if let Some(tx) = self.shutdown.take() {
288            let _ = tx.send(());
289        }
290        if let Some(handle) = self.handle.take() {
291            let _ = handle.await;
292        }
293    }
294}
295
296impl Drop for MockEngine {
297    fn drop(&mut self) {
298        if let Some(tx) = self.shutdown.take() {
299            let _ = tx.send(());
300        }
301        if let Some(handle) = &self.handle {
302            handle.abort();
303        }
304    }
305}
306
307fn router(shared: AppState) -> Router<AppState> {
308    // Typed endpoints run behind a failure-injection layer so tests
309    // can exercise auth / not-found / transient-503 paths without
310    // having to mutate each handler. `/`, `/health`, and `/ws` stay
311    // outside the layer: the version probe + health surface are
312    // unauthenticated by design, and the ws handler has its own
313    // drop-once hook (`ws_drop_once`).
314    //
315    // The middleware captures `shared` at construction time — the
316    // same `AppState` the rest of the router is given via
317    // `.with_state()`. Because `AppState`'s interior is `Arc<Mutex<…>>`,
318    // mutations from the test side are visible to both.
319    let typed = Router::new()
320        .route("/v2/status", get(v2_status))
321        .route("/positions", get(positions))
322        .route("/risk", get(risk))
323        .route("/regime", get(regime))
324        .route("/brief", get(brief))
325        .route("/evaluate/:coin", get(evaluate))
326        .route("/pulse", get(pulse))
327        .route("/approaching", get(approaching))
328        .route("/rejections", get(rejections))
329        .route("/hl/status", get(hl_status))
330        .route("/hl/account", get(hl_account))
331        .route("/hl/reconcile", get(hl_reconcile))
332        .route("/immune", get(immune))
333        .route("/live/cockpit", get(live_cockpit))
334        .route("/live/certification", get(live_certification))
335        .route("/live/evidence", get(live_evidence))
336        .route("/live/canary-policy", get(live_canary_policy))
337        .route("/runtime/parity", get(runtime_parity))
338        .route("/live/receipts", get(live_receipts))
339        .route("/live/preflight", get(live_preflight))
340        .route("/market/quote", get(market_quote))
341        .route("/operator/state", get(operator_state))
342        .route("/operator/events", post(operator_events))
343        .route("/execute", post(execute))
344        .route("/auto/toggle", post(auto_toggle))
345        .route("/live/heartbeat", post(live_heartbeat))
346        .route("/live/pause", post(live_pause))
347        .route("/live/resume", post(live_resume))
348        .route("/live/kill", post(live_kill))
349        .route("/live/flatten", post(live_flatten))
350        .layer(middleware::from_fn_with_state(shared, inject_failures));
351
352    Router::new()
353        .route("/", get(root))
354        .route("/health", get(health))
355        .route("/ws", get(ws_handler))
356        .merge(typed)
357}
358
359/// Middleware that consults [`Overrides`] before every typed
360/// request and short-circuits with a synthetic failure when any
361/// injection is active. Priorities (most-to-least specific):
362///
363/// 1. `force_unauthorized` → 401. This comes first because
364///    "your token is wrong" must beat "rate limited" on the
365///    operator's screen.
366/// 2. `force_not_found` → 404. Runs before `force_server_error`
367///    so tests can express "this engine doesn't serve that
368///    endpoint" cleanly.
369/// 3. `rate_limit_count > 0` → 429 with `Retry-After`, decrement.
370///    Ahead of the transient-503 rule: a test that sets both
371///    wants to see the 429 path first (the CLI client does not
372///    retry 429, so the two injections are never meant to
373///    cascade). Decrement is atomic under the overrides lock.
374/// 4. `transient_fail_count > 0` → 503, decrement. This is the
375///    retry-policy probe; the decrement happens atomically
376///    under the overrides lock so a concurrent test can't
377///    double-spend the budget.
378/// 5. `force_server_error` → 500. Non-retryable per the
379///    client's policy; verifies the client does **not**
380///    re-call on this status.
381async fn inject_failures(
382    State(s): State<AppState>,
383    req: Request,
384    next: Next,
385) -> axum::response::Response {
386    let action = {
387        let mut o = s.overrides.lock();
388        if o.force_unauthorized {
389            InjectAction::Unauthorized
390        } else if o.force_not_found {
391            InjectAction::NotFound
392        } else if o.rate_limit_count > 0 {
393            o.rate_limit_count -= 1;
394            InjectAction::RateLimited(o.rate_limit_retry_after.clone())
395        } else if o.transient_fail_count > 0 {
396            o.transient_fail_count -= 1;
397            InjectAction::Transient
398        } else if o.force_server_error {
399            InjectAction::ServerError
400        } else {
401            InjectAction::Pass
402        }
403    };
404    match action {
405        InjectAction::Unauthorized => (StatusCode::UNAUTHORIZED, "missing token").into_response(),
406        InjectAction::NotFound => (StatusCode::NOT_FOUND, "unknown endpoint").into_response(),
407        InjectAction::RateLimited(header) => {
408            // Default to `"1"` so a test inspecting the client's
409            // parsed duration observes an unambiguous 1 s. A real
410            // engine would carry a real wall-clock budget here.
411            let retry_after = header.unwrap_or_else(|| "1".to_string());
412            let mut resp = (StatusCode::TOO_MANY_REQUESTS, "slow down").into_response();
413            if let Ok(v) = retry_after.parse() {
414                resp.headers_mut()
415                    .insert(axum::http::header::RETRY_AFTER, v);
416            }
417            resp
418        }
419        InjectAction::Transient => (StatusCode::SERVICE_UNAVAILABLE, "retry me").into_response(),
420        InjectAction::ServerError => {
421            (StatusCode::INTERNAL_SERVER_ERROR, "unexpected").into_response()
422        }
423        InjectAction::Pass => next.run(req).await,
424    }
425}
426
427#[derive(Debug, Clone)]
428enum InjectAction {
429    Pass,
430    Unauthorized,
431    NotFound,
432    RateLimited(Option<String>),
433    Transient,
434    ServerError,
435}
436
437async fn root(State(s): State<AppState>) -> impl IntoResponse {
438    let version = s
439        .overrides
440        .lock()
441        .version
442        .clone()
443        .unwrap_or_else(|| "1.2.3-mock".to_string());
444    Json(json!({
445        "name": "ZERO OS",
446        "version": version,
447        "status": "running",
448        "ts": chrono_utc_now_iso(),
449    }))
450}
451
452async fn health(State(s): State<AppState>) -> Response {
453    let o = s.overrides.lock().clone();
454    if o.health_503 {
455        return (StatusCode::SERVICE_UNAVAILABLE, "overloaded").into_response();
456    }
457    let status = if o.degrade_health { "degraded" } else { "ok" };
458    Json(json!({
459        "status": status,
460        "components": {
461            "controller": {"status": "healthy", "last_seen": chrono_utc_now_iso(), "age_s": 1.1},
462            "market_data": {"status": "healthy", "last_seen": chrono_utc_now_iso(), "age_s": 0.4},
463        },
464        "dependencies": {"hyperliquid": "healthy", "llm": "healthy"},
465        "circuit_breakers": {},
466        "risk": {
467            "account_value": 10_000.0,
468            "drawdown_pct": 0.8,
469            "halted": false,
470        },
471        "ws_connections": 0,
472    }))
473    .into_response()
474}
475
476// ─── M1 HTTP breadth endpoints ─────────────────────────────────────
477
478async fn v2_status() -> Json<serde_json::Value> {
479    // Real engine shape (see `zero-engine-client/tests/fixtures/v2_status.json`):
480    // confidence/market/positions/today are nested sub-objects;
481    // `regime` lives under `market.regime`, `engine_confidence`
482    // under `confidence.score` (0..=100 integer, not 0..=1 float).
483    Json(json!({
484        "confidence": {"score": 72, "level": "high"},
485        "market": {
486            "regime": "TREND_LONG confirmed across majors.",
487            "health": 0.954,
488            "signal": "stable",
489            "prediction": "stable",
490            "fear_greed": 54,
491            "coins_tradeable": 30
492        },
493        "positions": {"open": 2, "unrealized_pnl": 34.12, "equity": 10_034.12},
494        "today": {"trades": 24, "wins": 15, "pnl": -3.95, "streak": -3, "sizing_mult": 0.7},
495        "approaching": [],
496        "blind_spots": [],
497        "alert": null,
498        "recovery": {
499            "status": "recovered",
500            "source": "journal",
501            "durable": true,
502            "journal_path": "/data/decisions.jsonl",
503            "decisions_recovered": 24,
504            "fills_recovered": 17,
505            "rejections_recovered": 7,
506            "positions_recovered": 2,
507            "last_decision_at": "2026-05-01T00:00:00Z",
508            "current_decisions": 24,
509            "current_fills": 17,
510            "current_rejections": 7,
511            "current_positions": 2
512        },
513        "ts": chrono_utc_now_iso(),
514    }))
515}
516
517async fn positions() -> Json<serde_json::Value> {
518    Json(json!({
519        "positions": [
520            {
521                "symbol": "BTC",
522                "side": "long",
523                "size": 0.42,
524                "entry": 64_120.5,
525                "mark": 64_480.0,
526                "unrealized_pnl": 151.13,
527                "unrealized_r": 0.82,
528                "stop": 63_800.0,
529                "target": 65_400.0,
530                "lens_id": "alpha_v3",
531                "age_s": 1_824.0
532            },
533            {
534                "symbol": "ETH",
535                "side": "short",
536                "size": 1.2,
537                "entry": 3_120.0,
538                "mark": 3_098.0,
539                "unrealized_pnl": 26.4,
540                "unrealized_r": 0.31,
541                "stop": 3_160.0,
542                "target": 3_010.0,
543                "lens_id": "beta_v1",
544                "age_s": 421.0
545            }
546        ],
547        "account_value": 10_034.12,
548        "total_unrealized_pnl": 177.53
549    }))
550}
551
552async fn risk(State(s): State<AppState>) -> Json<serde_json::Value> {
553    let o = s.overrides.lock().clone();
554    // Stale-equity-field path: production engines have been observed
555    // to carry an `account_value` in `risk.json` that has drifted
556    // above `peak_equity`, which is impossible by definition (peak
557    // is a monotonic max of account_value). The dispatcher must
558    // flag the cross-field contradiction instead of passing the
559    // fake drawdown percent through. We mirror the real numbers
560    // from the reported incident so the tested error text is the
561    // one operators will see.
562    if o.force_stale_risk_equity {
563        return Json(json!({
564            "account_value": 638.488_706,       // stale (higher)
565            "updated_at": chrono_utc_now_iso(),
566            "daily_pnl_usd": -3.312,
567            "daily_loss_usd": 4.1261,
568            "per_runner": {},
569            "global_halt": false,
570            "daily_loss_since": chrono_utc_now_iso(),
571            "halted": false,
572            "halt_reason": null,
573            "halt_until": null,
574            "stop_failure_halt": false,
575            "open_count": 0,
576            "drawdown_pct": 0.22,               // computed against a stale peak
577            "peak_equity": 577.338_628,         // actual peak
578            "peak_equity_30d": 577.34,
579            "last_drawdown_alert_pct": 20,
580            "capital_floor_hit": false
581        }));
582    }
583    // Real engine shape (see `zero-engine-client/tests/fixtures/risk.json`):
584    // `account_value`, `daily_pnl_usd` / `daily_loss_usd` (dollars,
585    // not percent), `halted` / `global_halt` / `stop_failure_halt`
586    // (booleans), `open_count`, `peak_equity`. The legacy
587    // `kill_all` / `exposure_pct` / `daily_loss_pct` names were
588    // invented by the old mock; the live engine does not emit
589    // them.
590    Json(json!({
591        "account_value": 10_034.12,
592        "updated_at": chrono_utc_now_iso(),
593        "daily_pnl_usd": 34.12,
594        "daily_loss_usd": 4.1261,
595        "per_runner": {},
596        "global_halt": false,
597        "daily_loss_since": chrono_utc_now_iso(),
598        "halted": false,
599        "halt_reason": null,
600        "halt_until": null,
601        "stop_failure_halt": false,
602        "open_count": 2,
603        "drawdown_pct": 0.8,
604        "peak_equity": 10_100.0,
605        "peak_equity_30d": 10_100.0,
606        "last_drawdown_alert_pct": 20,
607        "capital_floor_hit": false
608    }))
609}
610
611async fn regime(State(s): State<AppState>) -> Json<serde_json::Value> {
612    let o = s.overrides.lock().clone();
613    // Engine-error envelope: a 200 that carries `{"error": "..."}`
614    // instead of a regime payload. Lets tests pin the dispatcher's
615    // "don't render em-dashes on an error envelope" contract.
616    if o.force_regime_error_envelope {
617        return Json(json!({"error": "coin not found"}));
618    }
619    // Empty-body path: engine exposes the endpoint but has no
620    // regime reading. Lets tests pin the "alert instead of
621    // em-dashes" contract.
622    if o.force_empty_regime {
623        return Json(json!({}));
624    }
625    Json(json!({
626        "regime": "TREND_LONG",
627        "confidence": 0.81,
628        "trending_long": 7,
629        "trending_short": 2,
630        "choppy": 3
631    }))
632}
633
634async fn brief() -> Json<serde_json::Value> {
635    // Real engine shape (see `zero-engine-client/tests/fixtures/brief.json`):
636    // fear_greed, open_positions, positions list, recent_signals,
637    // approaching, last_cycle object. No headline/summary strings.
638    Json(json!({
639        "timestamp": chrono_utc_now_iso(),
640        "fear_greed": 54,
641        "open_positions": 2,
642        "positions": [
643            {
644                "symbol": "BTC",
645                "side": "long",
646                "size": 0.42,
647                "entry": 64_120.5,
648                "mark": 64_480.0,
649                "unrealized_pnl": 151.13,
650                "unrealized_r": 0.82
651            }
652        ],
653        "recent_signals": [
654            {"coin": "BTC", "kind": "signal", "message": "edge_floor cleared"}
655        ],
656        "approaching": [
657            {"coin": "AVAX", "direction": "long", "distance_to_gate": 0.04}
658        ],
659        "last_cycle": {
660            "regime": "TREND_LONG",
661            "signals_evaluated": 30,
662            "actions_taken": 2
663        }
664    }))
665}
666
667async fn evaluate(
668    State(s): State<AppState>,
669    axum::extract::Path(coin): axum::extract::Path<String>,
670) -> Json<serde_json::Value> {
671    // Degenerate-200 path, off by default. When
672    // `force_empty_evaluation` is set, return a body that decodes
673    // as `Evaluation` but has no `layers` and no `direction` —
674    // the exact shape that tricked the real engine into rendering
675    // an empty verdict card. Lets tests lock in the dispatcher's
676    // "empty verdict → alert + dismiss, don't open overlay" contract
677    // without having to mock a half-crashed engine end-to-end.
678    if s.overrides.lock().force_empty_evaluation {
679        return Json(json!({
680            "coin": coin,
681            "layers": [],
682            "data_fresh": true,
683            "timestamp": chrono_utc_now_iso()
684        }));
685    }
686    // Real engine shape (see `zero-engine-client/tests/fixtures/evaluate_sol.json`):
687    // a flat object with `layers: [{layer, passed, value, detail}]`,
688    // `direction` ("LONG" | "SHORT" | "NONE"), `conviction`,
689    // `consensus`, `regime`, `data_fresh`, `timestamp`. The legacy
690    // mock's `verdict` / `gates` / `rationale` were never emitted
691    // by the live engine.
692    Json(json!({
693        "coin": coin,
694        "price": 85.48,
695        "consensus": 10,
696        "conviction": 0.64,
697        "direction": "NONE",
698        "regime": "random_quiet",
699        "layers": [
700            {"layer": "layer_0", "passed": true,  "value": "random_quiet", "detail": "regime=random_quiet"},
701            {"layer": "layer_1", "passed": true,  "value": {"agree": 0, "oppose": 0}, "detail": "technical neutral"},
702            {"layer": "layer_2", "passed": false, "value": 1.25e-05, "detail": "funding_rate below threshold"}
703        ],
704        "data_fresh": true,
705        "timestamp": chrono_utc_now_iso()
706    }))
707}
708
709async fn pulse() -> Json<serde_json::Value> {
710    // Real engine shape (see `zero-engine-client/tests/fixtures/pulse.json`):
711    // `{ events: [...], count, timestamp }`. The `Pulse` struct
712    // aliases both `pulse` and `events`, so either key works on
713    // the wire; we emit the real one.
714    Json(json!({
715        "events": [
716            {"kind": "signal",    "coin": "BTC", "message": "edge_floor cleared",      "ts": chrono_utc_now_iso(), "severity": "info"},
717            {"kind": "rejection", "coin": "SOL", "message": "stage2 HOLD on volume",   "ts": chrono_utc_now_iso(), "severity": "info"}
718        ],
719        "count": 2,
720        "timestamp": chrono_utc_now_iso()
721    }))
722}
723
724async fn approaching(State(s): State<AppState>) -> axum::response::Response {
725    // Simulate older engine builds that predate `/approaching`.
726    // The real production engine at api.getzero.dev returns
727    // `{"detail": "Not Found"}` on this path, so we mirror that
728    // body shape so the client's error-mapping stays honest.
729    if s.overrides.lock().force_approaching_not_found {
730        return (StatusCode::NOT_FOUND, Json(json!({"detail": "Not Found"}))).into_response();
731    }
732    Json(json!({
733        "approaching": [
734            {"coin": "AVAX", "direction": "long", "distance_to_gate": 0.04, "gate": "edge_floor", "ts": chrono_utc_now_iso()},
735            {"coin": "LINK", "direction": "short", "distance_to_gate": 0.07, "gate": "stage2", "ts": chrono_utc_now_iso()}
736        ]
737    }))
738    .into_response()
739}
740
741async fn rejections() -> Json<serde_json::Value> {
742    Json(json!({
743        "rejections": [
744            {"coin": "SOL", "direction": "long", "stage": "stage2", "reason": "volume below threshold", "ts": chrono_utc_now_iso()}
745        ]
746    }))
747}
748
749async fn hl_status(
750    axum::extract::Query(query): axum::extract::Query<BTreeMap<String, String>>,
751) -> Json<serde_json::Value> {
752    let mids = match query.get("symbol").map(String::as_str) {
753        Some("BTC") => json!({"BTC": 40500.0}),
754        Some("ETH") => json!({"ETH": 2850.0}),
755        Some(symbol) => json!({symbol: 100.0}),
756        None => json!({"BTC": 40500.0, "ETH": 2850.0}),
757    };
758    Json(json!({
759        "enabled": true,
760        "exchange": "hyperliquid",
761        "endpoint": "https://api.hyperliquid.xyz/info",
762        "coins": 2,
763        "mids": mids,
764        "secrets_required": false
765    }))
766}
767
768async fn hl_account() -> Json<serde_json::Value> {
769    Json(json!({
770        "schema_version": "zero.hl_account.v1",
771        "exchange": "hyperliquid",
772        "user": "0x0000...0000",
773        "as_of": chrono_utc_now_iso(),
774        "account_value": 10_000.0,
775        "margin_used": 25.0,
776        "withdrawable": 9_975.0,
777        "positions": [
778            {
779                "symbol": "BTC",
780                "side": "long",
781                "quantity": 0.01,
782                "entry_price": 50_000.0,
783                "position_value": 500.0,
784                "unrealized_pnl": 10.0,
785                "margin_used": 25.0
786            }
787        ],
788        "open_orders": [{"coin": "BTC", "oid": 123}],
789        "counts": {"positions": 1, "open_orders": 1}
790    }))
791}
792
793async fn hl_reconcile() -> Json<serde_json::Value> {
794    Json(json!({
795        "schema_version": "zero.reconciliation.v1",
796        "exchange": "hyperliquid",
797        "status": "ok",
798        "risk_increasing_allowed": true,
799        "reason": "local runtime and Hyperliquid account state are reconciled",
800        "as_of": chrono_utc_now_iso(),
801        "stale_after_s": 10,
802        "local": {"positions": [], "open_positions": 0},
803        "exchange_state": {"positions": [], "open_positions": 0},
804        "drifts": []
805    }))
806}
807
808async fn live_certification() -> Json<serde_json::Value> {
809    Json(json!({
810        "schema_version": "zero.live_certification.v1",
811        "mode": "dry_run",
812        "passed": true,
813        "live_start_certified": true,
814        "summary": {
815            "total": 10,
816            "passed": 10,
817            "failed": 0,
818            "exchange": "fake",
819            "secrets_required": false,
820            "orders_placed_live": 0
821        },
822        "drills": [
823            {
824                "name": "heartbeat_arms_dead_man",
825                "status": "pass",
826                "note": "exchange dead-man heartbeat must be accepted before risk can increase",
827                "evidence": {"heartbeat_ok": true}
828            },
829            {
830                "name": "exchange_submit_outage_fails_closed_without_retry",
831                "status": "pass",
832                "note": "exchange submit failures must become auditable refused records and must not retry",
833                "evidence": {"exchange_attempts": 1}
834            }
835        ],
836        "evidence_requirements": ["live_preflight packet", "hl_reconcile packet"]
837    }))
838}
839
840async fn live_evidence() -> Json<serde_json::Value> {
841    Json(json!({
842        "schema_version": "zero.live_evidence.v1",
843        "generated_at": chrono_utc_now_iso(),
844        "mode": "paper",
845        "live_mode": "refused",
846        "ready": false,
847        "risk_increasing_allowed": false,
848        "operator_context": mock_operator_context(),
849        "summary": {
850            "artifacts": 9,
851            "preflight_ready": false,
852            "controls_ready": true,
853            "certification_passed": true,
854            "live_start_certified": true,
855            "live_receipts_total": 0,
856            "live_receipts_accepted": 0,
857            "reconciliation_status": "ok",
858            "immune_risk_increasing_allowed": false,
859            "live_records_total": 0,
860            "live_records_accepted": 0,
861            "deployment_heartbeat_status": "paper_only"
862        },
863        "artifacts": [
864            {"name": "live_preflight", "schema_version": "zero.live_preflight.v1", "status": "refused", "hash": "sha256:1111111111111111111111111111111111111111111111111111111111111111", "included": "hash_only"},
865            {"name": "live_cockpit", "schema_version": "zero.live_cockpit.v1", "status": "refused", "hash": "sha256:2222222222222222222222222222222222222222222222222222222222222222", "included": "hash_only"},
866            {"name": "live_execution_receipts", "schema_version": "zero.live_execution_receipts.v1", "status": "empty", "hash": "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", "included": "hash_only"},
867            {"name": "hl_reconcile", "schema_version": "zero.reconciliation.v1", "status": "ok", "hash": "sha256:3333333333333333333333333333333333333333333333333333333333333333", "included": "hash_only"},
868            {"name": "immune", "schema_version": "zero.immune.v1", "status": "blocked", "hash": "sha256:4444444444444444444444444444444444444444444444444444444444444444", "included": "hash_only"},
869            {"name": "live_certification", "schema_version": "zero.live_certification.v1", "status": "pass", "hash": "sha256:5555555555555555555555555555555555555555555555555555555555555555", "included": "hash_only"},
870            {"name": "audit_export", "schema_version": "zero.audit.v1", "status": "captured", "hash": "sha256:6666666666666666666666666666666666666666666666666666666666666666", "included": "hash_only"},
871            {"name": "deployment_claim", "schema_version": "zero.deployment.claim.v1", "status": "captured", "hash": "sha256:7777777777777777777777777777777777777777777777777777777777777777", "included": "hash_only"},
872            {"name": "deployment_heartbeat", "schema_version": "zero.deployment.heartbeat.v1", "status": "paper_only", "hash": "sha256:8888888888888888888888888888888888888888888888888888888888888888", "included": "hash_only"}
873        ],
874        "canary_rule": {
875            "tiny_capital_only": true,
876            "operator_owned_custody": true,
877            "requires_external_exchange_records": true,
878            "risk_reducing_actions_required": ["/pause-entries", "/flatten-all", "/kill"],
879            "default_public_runtime_places_live_orders": false
880        },
881        "privacy": {
882            "contains_exchange_credentials": false,
883            "contains_wallet_material": false,
884            "contains_raw_decisions": false,
885            "contains_trace_tokens": false,
886            "contains_idempotency_tokens": false,
887            "contains_private_notes": false
888        },
889        "evidence_hash": "sha256:9999999999999999999999999999999999999999999999999999999999999999",
890        "signature": {
891            "status": "unsigned_local",
892            "algorithm": null,
893            "signature": null,
894            "signer": "mock-runtime",
895            "signed_evidence_hash": "sha256:9999999999999999999999999999999999999999999999999999999999999999",
896            "key_material_included": false
897        }
898    }))
899}
900
901async fn live_canary_policy() -> Json<serde_json::Value> {
902    Json(json!({
903        "schema_version": "zero.live_canary_policy.v1",
904        "policy_version": "zero.live_canary_policy.public.v1",
905        "generated_at": chrono_utc_now_iso(),
906        "mode": "refusal",
907        "summary": {
908            "ready_for_canary": false,
909            "policy_armed": false,
910            "live_order_attempted": true,
911            "live_order_accepted": false,
912            "receipts_accepted": 0,
913            "exchange_evidence_attached": true,
914            "publishable_canary_evidence": false,
915            "refusal_evidence_qualified": true,
916            "qualified": true,
917            "next_step": "keep_public_claim_at_refusal_proof"
918        },
919        "policy": {
920            "default_state": "disarmed",
921            "arm_requires": [
922                "ready live preflight",
923                "risk-increasing cockpit allowance",
924                "passing dry-run live certification",
925                "operator-owned custody",
926                "exact live-risk confirmation phrase"
927            ],
928            "disarm_after": [
929                "canary attempt completed",
930                "pause captured",
931                "flatten captured",
932                "kill captured",
933                "evidence exported",
934                "operator report written"
935            ],
936            "launch_window_seconds": 300,
937            "tiny_capital_only": true,
938            "requires_exchange_evidence_for_accepted_receipts": true,
939            "required_evidence": ["live_preflight", "live_cockpit", "live_certification", "live_receipts", "exchange_evidence"]
940        },
941        "phases": [
942            {
943                "name": "readiness",
944                "status": "blocked",
945                "detail": "live gates are not ready for risk-increasing canary mode",
946                "preflight_ready": false,
947                "controls_ready": true,
948                "cockpit_risk_increasing_allowed": false,
949                "certification_passed": true
950            },
951            {
952                "name": "policy_arm",
953                "status": "disarmed",
954                "detail": "policy remains disarmed outside ready canary mode",
955                "mode": "refusal",
956                "requires_explicit_confirmation": true
957            },
958            {
959                "name": "qualification",
960                "status": "pass",
961                "detail": "refusal-mode bundle qualifies as fail-closed public proof, not live trading proof",
962                "publishable_canary_evidence": false,
963                "refusal_evidence_qualified": true,
964                "exchange_evidence_attached": true
965            }
966        ],
967        "recommendation": {
968            "action": "keep_public_claim_at_refusal_proof",
969            "risk_direction": "none",
970            "reason": "fail-closed evidence is valid but does not prove live execution"
971        },
972        "operator_context": mock_operator_context(),
973        "request": {
974            "mode": "refusal",
975            "source": "mock"
976        },
977        "privacy": {
978            "contains_exchange_credentials": false,
979            "contains_wallet_material": false,
980            "contains_raw_exchange_order_ids": false,
981            "contains_raw_client_order_ids": false,
982            "contains_idempotency_tokens": false,
983            "contains_confirmation_phrase": false,
984            "contains_private_notes": false
985        }
986    }))
987}
988
989async fn runtime_parity() -> Json<serde_json::Value> {
990    Json(json!({
991        "schema_version": "zero.runtime.production_parity.v1",
992        "available": true,
993        "ok": true,
994        "mode": "production-parity",
995        "source": "bundled-paper-scenario",
996        "generated_at": chrono_utc_now_iso(),
997        "cycles_requested": 4,
998        "cycles_run": 4,
999        "paper_only": true,
1000        "places_live_orders": false,
1001        "paper": {
1002            "decisions": 4,
1003            "fills": 2,
1004            "rejections": 2,
1005            "open_positions": 1
1006        },
1007        "live_shadow": {
1008            "mode": "disabled-fail-closed",
1009            "accepted": 0,
1010            "refused": 4,
1011            "adapter_orders_placed": 0,
1012            "records": []
1013        },
1014        "feedback": {
1015            "schema_version": "zero.runtime.feedback.v1",
1016            "cycles": 4,
1017            "sample_size": 4,
1018            "fills": 2,
1019            "rejections": 2,
1020            "rejection_rate": 0.5,
1021            "by_rejection_reason": {"order notional exceeds limit": 2},
1022            "by_rejection_symbol": {"ETH": 1, "SOL": 1},
1023            "items": []
1024        },
1025        "certification": {
1026            "schema_version": "zero.live_certification.v1",
1027            "mode": "dry_run",
1028            "passed": true,
1029            "live_start_certified": true,
1030            "summary": {
1031                "total": 10,
1032                "passed": 10,
1033                "failed": 0,
1034                "orders_placed_live": 0
1035            },
1036            "drills": [],
1037            "evidence_requirements": ["operator-owned live canary evidence for live claims"]
1038        },
1039        "checks": {
1040            "paper_boundary": true,
1041            "phase_order": true,
1042            "live_shadow_fail_closed": true,
1043            "live_adapter_no_orders": true,
1044            "operator_owned_canary_required": true
1045        },
1046        "claim_boundary": {
1047            "production_ooda_parity": true,
1048            "live_trading_claimed": false,
1049            "operator_owned_canary_required_for_live_claim": true,
1050            "protected_live_code_evolution_allowed": false,
1051            "remote_push_allowed": false
1052        }
1053    }))
1054}
1055
1056async fn live_receipts() -> Json<serde_json::Value> {
1057    Json(json!({
1058        "schema_version": "zero.live_execution_receipts.v1",
1059        "generated_at": chrono_utc_now_iso(),
1060        "mode": "paper",
1061        "operator_context": mock_operator_context(),
1062        "summary": {
1063            "total": 0,
1064            "accepted": 0,
1065            "refused": 0,
1066            "exchange_error": 0,
1067            "status": "empty"
1068        },
1069        "receipts": [],
1070        "privacy": {
1071            "contains_exchange_credentials": false,
1072            "contains_wallet_material": false,
1073            "contains_raw_venue_ack_payload": false,
1074            "contains_trace_tokens": false,
1075            "contains_idempotency_tokens": false,
1076            "contains_private_notes": false
1077        },
1078        "receipts_hash": "sha256:cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd"
1079    }))
1080}
1081
1082async fn immune() -> Json<serde_json::Value> {
1083    Json(json!({
1084        "schema_version": "zero.immune.v1",
1085        "generated_at": chrono_utc_now_iso(),
1086        "mode": "paper",
1087        "risk_increasing_allowed": false,
1088        "summary": {"total": 3, "open": 2, "closed": 1, "warning": 0, "risk_blocking": 2},
1089        "breakers": [
1090            {
1091                "name": "stale_market_data",
1092                "status": "closed",
1093                "blocks_risk": false,
1094                "severity": "info",
1095                "reason": "market data fresh",
1096                "evidence": {"age_s": 0.1}
1097            },
1098            {
1099                "name": "dead_man",
1100                "status": "open",
1101                "blocks_risk": true,
1102                "severity": "critical",
1103                "reason": "live executor not configured",
1104                "evidence": {"configured": false}
1105            },
1106            {
1107                "name": "reconciliation",
1108                "status": "open",
1109                "blocks_risk": true,
1110                "severity": "critical",
1111                "reason": "account reconciliation unavailable",
1112                "evidence": {"status": "missing"}
1113            }
1114        ]
1115    }))
1116}
1117
1118async fn live_cockpit() -> Json<serde_json::Value> {
1119    Json(json!({
1120        "schema_version": "zero.live_cockpit.v1",
1121        "generated_at": chrono_utc_now_iso(),
1122        "mode": "paper",
1123        "live_mode": "refused",
1124        "ready": false,
1125        "controls_ready": true,
1126        "risk_increasing_allowed": false,
1127        "next_action": "fix preflight check live_executor: mock has no live executor",
1128        "operator_context": mock_operator_context(),
1129        "access_policy": {
1130            "identity_required_for_live_controls": true,
1131            "default_scope": "local-private",
1132            "header_overrides": [
1133                "X-Zero-Operator-Id",
1134                "X-Zero-Operator-Handle",
1135                "X-Zero-Operator-Role",
1136                "X-Zero-Operator-Scope"
1137            ]
1138        },
1139        "preflight": {
1140            "schema_version": "zero.live_preflight.v1",
1141            "ready": false,
1142            "live_mode": "refused",
1143            "controls_ready": true,
1144            "summary": {"total": 9, "passed": 8, "failed": 1},
1145            "failed_checks": [
1146                {"name": "live_executor", "status": "fail", "note": "mock has no live executor"}
1147            ]
1148        },
1149        "immune": {
1150            "schema_version": "zero.immune.v1",
1151            "risk_increasing_allowed": false,
1152            "summary": {"total": 3, "open": 2, "closed": 1, "warning": 0, "risk_blocking": 2},
1153            "open_breakers": [
1154                {
1155                    "name": "dead_man",
1156                    "status": "open",
1157                    "blocks_risk": true,
1158                    "severity": "critical",
1159                    "reason": "live executor not configured",
1160                    "evidence": {"configured": false}
1161                },
1162                {
1163                    "name": "reconciliation",
1164                    "status": "open",
1165                    "blocks_risk": true,
1166                    "severity": "critical",
1167                    "reason": "account reconciliation unavailable",
1168                    "evidence": {"status": "missing"}
1169                }
1170            ]
1171        },
1172        "reconciliation": {
1173            "schema_version": "zero.reconciliation.v1",
1174            "status": "ok",
1175            "risk_increasing_allowed": true,
1176            "reason": "local runtime and Hyperliquid account state are reconciled",
1177            "drifts": 0
1178        },
1179        "certification": {
1180            "schema_version": "zero.live_certification.v1",
1181            "mode": "dry_run",
1182            "passed": true,
1183            "live_start_certified": true,
1184            "summary": {"total": 10, "passed": 10, "failed": 0, "orders_placed_live": 0},
1185            "failed_drills": []
1186        },
1187        "heartbeat": {
1188            "configured": false,
1189            "expired": true,
1190            "last_heartbeat_at": null,
1191            "timeout_s": null
1192        },
1193        "live_records": {
1194            "total": 0,
1195            "accepted": 0,
1196            "refused": 0,
1197            "exchange_error": 0,
1198            "recent": []
1199        },
1200        "operator_actions": {
1201            "risk_reducing": ["/pause-entries", "/kill", "/flatten-all"],
1202            "risk_increasing": ["/resume-entries"],
1203            "read_only": ["/live-cockpit", "/live-certify", "/immune", "/hl-reconcile"],
1204            "recent": []
1205        }
1206    }))
1207}
1208
1209async fn live_preflight() -> Json<serde_json::Value> {
1210    Json(json!({
1211        "schema_version": "zero.live_preflight.v1",
1212        "generated_at": chrono_utc_now_iso(),
1213        "exchange": "hyperliquid",
1214        "mode": "paper",
1215        "ready": false,
1216        "live_mode": "refused",
1217        "controls_ready": true,
1218        "checks": [
1219            {"name": "live_executor", "status": "fail", "note": "mock has no live executor"},
1220            {"name": "wallet_address", "status": "ok", "note": "0x0000...0000"},
1221            {"name": "api_private_key", "status": "ok", "note": "0x0000...0000"},
1222            {"name": "account_read", "status": "ok", "note": "clearinghouseState read ok"},
1223            {"name": "reconciliation", "status": "ok", "note": "local runtime and Hyperliquid account state are reconciled", "status_code": "ok"},
1224            {"name": "dry_run_order", "status": "ok", "note": "buy 0.001 BTC validates locally"},
1225            {"name": "journal", "status": "ok", "note": "append-only decision journal configured"},
1226            {"name": "risk_limits", "status": "ok", "note": "max_notional_usd=1000 max_position_usd=5000"},
1227            {"name": "emergency_controls", "status": "ok", "note": "kill switch armed"}
1228        ]
1229    }))
1230}
1231
1232async fn market_quote(
1233    axum::extract::Query(query): axum::extract::Query<BTreeMap<String, String>>,
1234) -> Json<serde_json::Value> {
1235    let symbol = query
1236        .get("symbol")
1237        .map_or_else(|| "BTC".to_string(), |s| s.to_uppercase());
1238    let price = match symbol.as_str() {
1239        "BTC" => 40500.0,
1240        "ETH" => 2850.0,
1241        _ => 100.0,
1242    };
1243    Json(json!({
1244        "symbol": symbol,
1245        "price": price,
1246        "source": "paper:static",
1247        "as_of": chrono_utc_now_iso(),
1248        "mode": "paper",
1249        "live": false
1250    }))
1251}
1252
1253// ─── /operator/state — behavioral classifier snapshot ─────────────
1254
1255async fn operator_state(State(s): State<AppState>) -> Json<serde_json::Value> {
1256    let (label, version) = {
1257        let o = s.overrides.lock();
1258        (
1259            o.operator_label
1260                .clone()
1261                .unwrap_or_else(|| "steady".to_string()),
1262            o.operator_version,
1263        )
1264    };
1265    let friction = match label.as_str() {
1266        "elevated" | "fatigued" => "l1",
1267        "tilt" => "l2",
1268        _ => "l0",
1269    };
1270    // Minimal vector — every numeric component defaults to zero.
1271    // Tests that care about classifier internals can build their
1272    // own payload by hitting the endpoint directly with reqwest.
1273    Json(json!({
1274        "label": label,
1275        "friction": friction,
1276        "vector": {
1277            "velocity": {"last_1h": 0, "last_4h": 0, "last_24h": 0, "baseline_1h": null},
1278            "deviation": {
1279                "overrides_last_10": 0, "verdicts_last_10": 0,
1280                "overrides_last_50": 0, "verdicts_last_50": 0,
1281            },
1282            "session": {"active_duration_ms": 0, "longest_focus_ms": 0, "since_last_break_ms": 0},
1283            "loss_reaction": {
1284                "median_last_10_ms": 0, "fastest_session_ms": 0, "baseline_ms": null,
1285            },
1286            "re_entry": {"within_15m": 0, "within_30m": 0, "within_2h": 0},
1287            "sleep_proxy": {"hours_since_rest_ended": null},
1288            "on_break": false,
1289        },
1290        "as_of": chrono_utc_now_iso(),
1291        "version": version,
1292    }))
1293}
1294
1295// ─── POST /operator/events — one-way ingress for classifier events ───
1296//
1297// Mirrors the real engine's contract enough to let the CLI-side
1298// rewires (`/rate`, `/break`) be exercised end-to-end: accept either
1299// a single event object or a `{"events": [...]}` batch, record each
1300// decoded body into `received_events` for test assertions, and reply
1301// with `{"accepted": N, "snapshot": <Snapshot>}` using the same
1302// `/operator/state` snapshot shape so the CLI's `post_operator_event`
1303// deserializer does not need a separate test fixture.
1304//
1305// Validation is minimal on purpose — the mock is not the engine. A
1306// request whose JSON does not deserialize at all falls through to
1307// 400; malformed event *shapes* still succeed here because the
1308// engine-side integration tests (Python `test_operator_state_endpoints`)
1309// own the per-field rejection paths, and duplicating them would mean
1310// the Rust side starts drifting from the Python contract.
1311
1312async fn operator_events(
1313    State(s): State<AppState>,
1314    body: String,
1315) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
1316    let parsed: serde_json::Value = serde_json::from_str(&body)
1317        .map_err(|e| (StatusCode::BAD_REQUEST, format!("{{\"error\":\"{e}\"}}")))?;
1318
1319    let events: Vec<serde_json::Value> = match &parsed {
1320        serde_json::Value::Object(map) if map.contains_key("events") => {
1321            map["events"].as_array().cloned().unwrap_or_default()
1322        }
1323        serde_json::Value::Array(arr) => arr.clone(),
1324        serde_json::Value::Object(_) => vec![parsed.clone()],
1325        _ => {
1326            return Err((
1327                StatusCode::BAD_REQUEST,
1328                "{\"error\":\"body must be an object or array\"}".to_string(),
1329            ));
1330        }
1331    };
1332
1333    {
1334        let mut log = s.received_events.lock();
1335        for ev in &events {
1336            log.push(ev.clone());
1337        }
1338    }
1339
1340    // Reply with a fresh snapshot; reuse the `operator_state` logic
1341    // so the post-accept snapshot the CLI sees is identical to what
1342    // a subsequent GET would return.
1343    let Json(snapshot) = operator_state(State(s)).await;
1344    Ok(Json(json!({
1345        "accepted": events.len(),
1346        "snapshot": snapshot,
1347    })))
1348}
1349
1350impl MockEngine {
1351    /// Snapshot of every `POST /operator/events` body the mock has
1352    /// received, in arrival order. Returned by clone so a later POST
1353    /// cannot mutate a value the test is inspecting.
1354    #[must_use]
1355    pub fn received_operator_events(&self) -> Vec<serde_json::Value> {
1356        self.state.received_events.lock().clone()
1357    }
1358
1359    /// Snapshot of every `POST /execute` (headers + body) the mock
1360    /// has received, in arrival order. Tests inspect the captured
1361    /// `x-zero-mode` and `x-idempotency-key` headers here.
1362    #[must_use]
1363    pub fn received_executes(&self) -> Vec<CapturedPost> {
1364        self.state.received_executes.lock().clone()
1365    }
1366
1367    /// Snapshot of every `POST /auto/toggle` (headers + body) the
1368    /// mock has received, in arrival order. Sister to
1369    /// [`Self::received_executes`].
1370    #[must_use]
1371    pub fn received_auto_toggles(&self) -> Vec<CapturedPost> {
1372        self.state.received_auto_toggles.lock().clone()
1373    }
1374
1375    /// Snapshot of every live control endpoint path the mock has received.
1376    #[must_use]
1377    pub fn received_live_controls(&self) -> Vec<String> {
1378        self.state.received_live_controls.lock().clone()
1379    }
1380}
1381
1382fn capture_live_control(s: &AppState, path: &str) {
1383    s.received_live_controls.lock().push(path.to_string());
1384}
1385
1386async fn live_heartbeat(State(s): State<AppState>) -> Json<serde_json::Value> {
1387    capture_live_control(&s, "/live/heartbeat");
1388    Json(json!({
1389        "ok": true,
1390        "action": "heartbeat",
1391        "risk_direction": "neutral",
1392        "operator_context": mock_operator_context(),
1393        "as_of": chrono_utc_now_iso(),
1394        "dead_man_timeout_s": 30,
1395        "exchange_dead_man": {"ok": true}
1396    }))
1397}
1398
1399async fn live_pause(State(s): State<AppState>) -> Json<serde_json::Value> {
1400    capture_live_control(&s, "/live/pause");
1401    Json(json!({
1402        "ok": true,
1403        "state": "paused",
1404        "action": "pause_entries",
1405        "risk_direction": "reduces",
1406        "operator_context": mock_operator_context(),
1407        "as_of": chrono_utc_now_iso()
1408    }))
1409}
1410
1411async fn live_resume(State(s): State<AppState>) -> Json<serde_json::Value> {
1412    capture_live_control(&s, "/live/resume");
1413    Json(json!({
1414        "ok": true,
1415        "state": "running",
1416        "action": "resume_entries",
1417        "risk_direction": "increases",
1418        "operator_context": mock_operator_context(),
1419        "as_of": chrono_utc_now_iso()
1420    }))
1421}
1422
1423async fn live_kill(State(s): State<AppState>) -> Json<serde_json::Value> {
1424    capture_live_control(&s, "/live/kill");
1425    Json(json!({
1426        "ok": true,
1427        "state": "killed",
1428        "action": "kill",
1429        "risk_direction": "reduces",
1430        "operator_context": mock_operator_context(),
1431        "as_of": chrono_utc_now_iso(),
1432        "exchange_cancel": {"ok": true, "cancelled": 2}
1433    }))
1434}
1435
1436async fn live_flatten(State(s): State<AppState>) -> Json<serde_json::Value> {
1437    capture_live_control(&s, "/live/flatten");
1438    Json(json!({
1439        "ok": true,
1440        "action": "flatten_all",
1441        "risk_direction": "reduces",
1442        "operator_context": mock_operator_context(),
1443        "orders": [
1444            {"accepted": true, "coin": "BTC", "side": "sell", "size": 0.42, "reason": "submitted"}
1445        ]
1446    }))
1447}
1448
1449// ─── /execute (POST) ───────────────────────────────────────────────
1450//
1451// M2_PLAN §7 — mock surface for the composition-change endpoint.
1452// The handler is deliberately dumb: accept any syntactically-valid
1453// JSON body, capture it + the headers the CLI must populate, and
1454// echo back a realistic response. Overrides drive the 5xx / 500 /
1455// simulated paths for tests that pin the no-retry rule and the
1456// paper-mode discriminator.
1457
1458// `side` / `size` mirror the wire shape of the live `/execute`
1459// handler; renaming either to appease `similar_names` would break
1460// the visual parity with the engine-side Python counterpart.
1461#[allow(clippy::similar_names)]
1462async fn execute(
1463    State(s): State<AppState>,
1464    headers: axum::http::HeaderMap,
1465    body: String,
1466) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
1467    // Injection paths must short-circuit *before* body capture so a
1468    // test that sets `post_transient_fail` sees no capture — the
1469    // CLI's no-retry rule means the server observes one request,
1470    // returns one 503, and the client does not re-send.
1471    {
1472        let o = s.overrides.lock();
1473        if o.post_transient_fail {
1474            return Err((StatusCode::SERVICE_UNAVAILABLE, "retry me".into()));
1475        }
1476        if o.post_server_error {
1477            return Err((StatusCode::INTERNAL_SERVER_ERROR, "boom".into()));
1478        }
1479    }
1480
1481    let parsed: serde_json::Value = serde_json::from_str(&body)
1482        .map_err(|e| (StatusCode::BAD_REQUEST, format!("{{\"error\":\"{e}\"}}")))?;
1483
1484    let captured = CapturedPost {
1485        headers: capture_relevant_headers(&headers),
1486        body: parsed.clone(),
1487    };
1488    s.received_executes.lock().push(captured);
1489
1490    // The engine asserts `simulated` based on the inbound
1491    // `X-Zero-Mode` header (or the launch-time default when the
1492    // header is absent); the mock mirrors that so tests can pin
1493    // both paths via the header alone.
1494    let mode_header = headers
1495        .get("x-zero-mode")
1496        .and_then(|v| v.to_str().ok())
1497        .unwrap_or("");
1498    let force_sim = s.overrides.lock().force_simulated;
1499    let simulated = force_sim || mode_header.eq_ignore_ascii_case("paper");
1500
1501    // Echo the inbound coin/side/size so the test can round-trip
1502    // the typed `ExecuteRequest` through the wire and see its
1503    // shape arrive in the typed `ExecuteResponse`.
1504    let coin = parsed.get("coin").cloned().unwrap_or(json!("BTC"));
1505    let side = parsed.get("side").cloned().unwrap_or(json!("buy"));
1506    let size = parsed.get("size").cloned().unwrap_or(json!(0.0));
1507    let key = parsed
1508        .get("idempotency_key")
1509        .and_then(|v| v.as_str())
1510        .unwrap_or_default();
1511    // `fill_id` is deterministic for paper fills, randomized on
1512    // live. The mock substitutes the idempotency key so tests can
1513    // assert the key made the round trip from request to response.
1514    let fill_id = if simulated {
1515        format!("paper-{key}")
1516    } else {
1517        format!("live-{key}")
1518    };
1519
1520    Ok(Json(json!({
1521        "accepted": true,
1522        "simulated": simulated,
1523        "fill_id": fill_id,
1524        "coin": coin,
1525        "side": side,
1526        "size": size,
1527        "request_hash": "sha256:abababababababababababababababababababababababababababababababab",
1528        "receipt_hash": "sha256:bcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbcbc",
1529    })))
1530}
1531
1532// ─── /auto/toggle (POST) ───────────────────────────────────────────
1533
1534async fn auto_toggle(
1535    State(s): State<AppState>,
1536    headers: axum::http::HeaderMap,
1537    body: String,
1538) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
1539    {
1540        let o = s.overrides.lock();
1541        if o.post_transient_fail {
1542            return Err((StatusCode::SERVICE_UNAVAILABLE, "retry me".into()));
1543        }
1544        if o.post_server_error {
1545            return Err((StatusCode::INTERNAL_SERVER_ERROR, "boom".into()));
1546        }
1547    }
1548
1549    let parsed: serde_json::Value = serde_json::from_str(&body)
1550        .map_err(|e| (StatusCode::BAD_REQUEST, format!("{{\"error\":\"{e}\"}}")))?;
1551
1552    let captured = CapturedPost {
1553        headers: capture_relevant_headers(&headers),
1554        body: parsed.clone(),
1555    };
1556    s.received_auto_toggles.lock().push(captured);
1557
1558    let requested = parsed
1559        .get("enabled")
1560        .and_then(serde_json::Value::as_bool)
1561        .unwrap_or(false);
1562    let (echo, reason) = {
1563        let o = s.overrides.lock();
1564        (o.auto_toggle_echo_state, o.auto_toggle_reason.clone())
1565    };
1566    let actual = echo.unwrap_or(requested);
1567    let state_str = if actual { "on" } else { "off" };
1568
1569    let mode_header = headers
1570        .get("x-zero-mode")
1571        .and_then(|v| v.to_str().ok())
1572        .unwrap_or("");
1573    let force_sim = s.overrides.lock().force_simulated;
1574    let simulated = force_sim || mode_header.eq_ignore_ascii_case("paper");
1575
1576    let mut resp = serde_json::Map::new();
1577    resp.insert("state".into(), json!(state_str));
1578    resp.insert("simulated".into(), json!(simulated));
1579    if let Some(r) = reason {
1580        resp.insert("reason".into(), json!(r));
1581    }
1582    Ok(Json(serde_json::Value::Object(resp)))
1583}
1584
1585/// Capture exactly the headers tests assert on (lowercased names →
1586/// string values). Intentionally narrow so the captured blob is
1587/// tractable to `assert_eq!` against; headers the tests don't care
1588/// about (user-agent, accept, content-length) are dropped.
1589fn capture_relevant_headers(
1590    headers: &axum::http::HeaderMap,
1591) -> std::collections::BTreeMap<String, String> {
1592    const RELEVANT: &[&str] = &[
1593        "x-zero-mode",
1594        "x-idempotency-key",
1595        "content-type",
1596        "authorization",
1597    ];
1598    let mut out = std::collections::BTreeMap::new();
1599    for name in RELEVANT {
1600        if let Some(v) = headers.get(*name)
1601            && let Ok(s) = v.to_str()
1602        {
1603            out.insert((*name).to_string(), s.to_string());
1604        }
1605    }
1606    out
1607}
1608
1609// ─── /ws — push surface for EngineState ────────────────────────────
1610
1611async fn ws_handler(ws: WebSocketUpgrade, State(s): State<AppState>) -> Response {
1612    ws.on_upgrade(move |socket| handle_ws(socket, s))
1613}
1614
1615async fn handle_ws(mut socket: WebSocket, s: AppState) {
1616    // If the test asked us to drop the connection on accept, take
1617    // that flag (consuming it) and close immediately so the
1618    // subscriber exercises its reconnect path. Note the explicit
1619    // scope on the mutex guard — parking_lot's guard is !Send, so
1620    // it must not live across the `.await` below.
1621    let should_drop = {
1622        let mut o = s.overrides.lock();
1623        if o.ws_drop_once {
1624            o.ws_drop_once = false;
1625            true
1626        } else {
1627            false
1628        }
1629    };
1630    if should_drop {
1631        let _ = socket.close().await;
1632        return;
1633    }
1634
1635    // Canonical test fixture sequence. Order matters: heartbeat
1636    // first so any subscriber waiting on `last_heartbeat` unblocks,
1637    // then state-carrying events.
1638    let events = [
1639        json!({"event": "heartbeat", "ts": now_iso(), "data": {}}),
1640        json!({
1641            "event": "v2_status",
1642            "ts": now_iso(),
1643            "data": {
1644                "confidence": {"score": 72, "level": "high"},
1645                "market": {
1646                    "regime": "TREND_LONG",
1647                    "health": 0.954,
1648                    "fear_greed": 54,
1649                    "coins_tradeable": 30
1650                },
1651                "positions": {"open": 2, "unrealized_pnl": 34.12, "equity": 10_034.12},
1652                "today": {"trades": 24, "wins": 15, "pnl": -3.95},
1653                "approaching": [],
1654                "blind_spots": []
1655            }
1656        }),
1657        json!({
1658            "event": "positions_update",
1659            "ts": now_iso(),
1660            "data": {
1661                "positions": [
1662                    {"symbol": "BTC", "side": "long", "size": 0.42, "entry": 64_120.5,
1663                     "mark": 64_480.0, "unrealized_pnl": 151.13, "unrealized_r": 0.82}
1664                ],
1665                "account_value": 10_034.12,
1666                "total_unrealized_pnl": 151.13
1667            }
1668        }),
1669        json!({
1670            "event": "risk_update",
1671            "ts": now_iso(),
1672            "data": {
1673                "account_value": 10_034.12,
1674                "drawdown_pct": 0.8,
1675                "halted": false,
1676                "global_halt": false,
1677                "stop_failure_halt": false,
1678                "daily_pnl_usd": 34.12,
1679                "daily_loss_usd": 20.0,
1680                "peak_equity": 10_100.0,
1681                "open_count": 2
1682            }
1683        }),
1684        json!({
1685            "event": "regime_update",
1686            "ts": now_iso(),
1687            "data": {"regime": "TREND_LONG", "confidence": 0.81}
1688        }),
1689    ];
1690
1691    for ev in events {
1692        if socket.send(Message::Text(ev.to_string())).await.is_err() {
1693            return;
1694        }
1695    }
1696
1697    // Remain connected until the client disconnects or asks us to
1698    // close. Periodic heartbeats keep the freshness clock alive in
1699    // longer-running tests.
1700    let mut ticker = tokio::time::interval(Duration::from_millis(250));
1701    ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
1702    loop {
1703        tokio::select! {
1704            _ = ticker.tick() => {
1705                let hb = json!({"event": "heartbeat", "ts": now_iso(), "data": {}});
1706                if socket.send(Message::Text(hb.to_string())).await.is_err() {
1707                    return;
1708                }
1709            }
1710            msg = socket.recv() => {
1711                match msg {
1712                    Some(Ok(Message::Close(_)) | Err(_)) | None => return,
1713                    _ => {}
1714                }
1715            }
1716        }
1717    }
1718}
1719
1720fn now_iso() -> String {
1721    use std::time::{SystemTime, UNIX_EPOCH};
1722    let secs = SystemTime::now()
1723        .duration_since(UNIX_EPOCH)
1724        .map_or(0, |d| d.as_secs());
1725    // RFC-3339 stub good enough for the subscriber's parser; the
1726    // subscriber falls back to `Utc::now()` when parsing fails.
1727    format!("2026-01-01T00:00:{:02}Z", secs % 60)
1728}
1729
1730/// Alias kept tight so response bodies in this module stay single-line.
1731type Response = axum::response::Response;
1732
1733fn chrono_utc_now_iso() -> String {
1734    use std::time::{SystemTime, UNIX_EPOCH};
1735    let secs = SystemTime::now()
1736        .duration_since(UNIX_EPOCH)
1737        .map(|d| d.as_secs())
1738        .unwrap_or(0);
1739    // Cheap ISO-ish timestamp good enough for test fixtures; no
1740    // dependency on `chrono` inside this crate.
1741    format!("1970-01-01T00:00:{:02}Z", secs % 60)
1742}
1743
1744fn mock_operator_context() -> serde_json::Value {
1745    json!({
1746        "schema_version": "zero.operator_context.v1",
1747        "operator_id": "mock-operator",
1748        "handle": "mock-operator",
1749        "role": "owner",
1750        "scope": "local-private",
1751        "source": "mock"
1752    })
1753}