1use 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#[allow(clippy::struct_excessive_bools)] #[derive(Debug, Default, Clone)]
41pub struct Overrides {
42 pub degrade_health: bool,
44 pub health_503: bool,
46 pub force_unauthorized: bool,
52 pub force_not_found: bool,
56 pub force_server_error: bool,
60 pub force_empty_evaluation: bool,
67 pub force_empty_regime: bool,
73 pub force_regime_error_envelope: bool,
77 pub force_approaching_not_found: bool,
82 pub force_stale_risk_equity: bool,
90 pub transient_fail_count: u32,
98 pub rate_limit_count: u32,
110 pub rate_limit_retry_after: Option<String>,
118 pub version: Option<String>,
120 pub ws_drop_once: bool,
126 pub operator_label: Option<String>,
131 pub operator_version: u64,
136 pub auto_toggle_echo_state: Option<bool>,
143 pub auto_toggle_reason: Option<String>,
147 pub force_simulated: bool,
155 pub post_transient_fail: bool,
160 pub post_server_error: bool,
165}
166
167#[derive(Debug, Clone)]
169pub struct AppState {
170 pub overrides: Arc<Mutex<Overrides>>,
171 pub received_events: Arc<Mutex<Vec<serde_json::Value>>>,
180 pub received_executes: Arc<Mutex<Vec<CapturedPost>>>,
188 pub received_auto_toggles: Arc<Mutex<Vec<CapturedPost>>>,
191 pub received_live_controls: Arc<Mutex<Vec<String>>>,
193}
194
195#[derive(Debug, Clone)]
200pub struct CapturedPost {
201 pub headers: std::collections::BTreeMap<String, String>,
206 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#[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 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 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 #[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 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
359async 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 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
476async fn v2_status() -> Json<serde_json::Value> {
479 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 if o.force_stale_risk_equity {
563 return Json(json!({
564 "account_value": 638.488_706, "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, "peak_equity": 577.338_628, "peak_equity_30d": 577.34,
579 "last_drawdown_alert_pct": 20,
580 "capital_floor_hit": false
581 }));
582 }
583 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 if o.force_regime_error_envelope {
617 return Json(json!({"error": "coin not found"}));
618 }
619 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 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 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 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 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 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
1253async 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 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
1295async 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 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 #[must_use]
1355 pub fn received_operator_events(&self) -> Vec<serde_json::Value> {
1356 self.state.received_events.lock().clone()
1357 }
1358
1359 #[must_use]
1363 pub fn received_executes(&self) -> Vec<CapturedPost> {
1364 self.state.received_executes.lock().clone()
1365 }
1366
1367 #[must_use]
1371 pub fn received_auto_toggles(&self) -> Vec<CapturedPost> {
1372 self.state.received_auto_toggles.lock().clone()
1373 }
1374
1375 #[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#[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 {
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 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 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 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
1532async 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
1585fn 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
1609async 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 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 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 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 format!("2026-01-01T00:00:{:02}Z", secs % 60)
1728}
1729
1730type 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 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}