Skip to main content

car_proto/
lib.rs

1//! JSON-RPC 2.0 protocol types for CAR client-server communication.
2//!
3//! The protocol is bidirectional over WebSocket:
4//! - Client → Server: session.init, tools.register, proposal.submit, verify
5//! - Server → Client: tools.execute (callback for tool execution)
6//! - Server → Client: execution.event (notifications)
7
8pub mod approval_summary;
9mod canonical;
10
11pub use canonical::{canonical_json, canonical_sha256};
12
13/// Wire protocol version for the daemon JSON-RPC protocol. Bump ONLY on a
14/// backward-incompatible change to the request/response shapes or method
15/// semantics (NOT on every release — this is independent of the package
16/// semver). Client and server exchange this in the `server.handshake` RPC so
17/// version drift FAILS LOUD with a clear error instead of silently
18/// misbehaving or hanging.
19pub const PROTOCOL_VERSION: u32 = 3;
20
21/// Authenticated model-catalog content identity and snapshot reads.
22pub const MODELS_CATALOG_IDENTITY_CAPABILITY: &str = "models.catalog-identity.v1";
23
24/// Immutable model identity on every inference completion.
25pub const INFER_MODEL_IDENTITY_CAPABILITY: &str = "infer.model-identity.v1";
26
27/// Per-session cancellation of an active inference.
28pub const INFER_CANCEL_CAPABILITY: &str = "infer.cancel.v1";
29
30/// Relative deadline control for an active inference.
31pub const INFER_DEADLINE_CAPABILITY: &str = "infer.deadline.v1";
32
33/// Explicit bounded pagination for run list, replay, and live subscription.
34pub const RUNS_PAGINATION_CAPABILITY: &str = "runs.pagination.v1";
35
36/// Authenticated same-agent reclaim of an orphaned live run.
37pub const RUNS_RESUME_CAPABILITY: &str = "runs.resume.v1";
38
39/// Durable, authenticated cancellation of an active run.
40pub const RUNS_CANCEL_CAPABILITY: &str = "runs.cancel.v1";
41
42/// Runtime-observed state mutations returned by raw WebSocket tool callbacks.
43pub const TOOLS_CALLBACK_STATE_CAPABILITY: &str = "tools.callback-state.v1";
44
45/// Host-managed, schema-bound exact agent/tool approvals on WebSocket admission.
46pub const AGENT_TOOL_OVERRIDES_CAPABILITY: &str = "permissions.agent-tool-overrides.v1";
47
48/// In-app feedback (`feedback.compose_preview` / `submit` / `status` / `list`):
49/// the consent-previewed, redacted bug-report handoff into Parslee's intake.
50/// Optional — a client that does not negotiate it cannot spool reports or read
51/// submission summaries on that connection (the daemon refuses with the
52/// standard capability-mismatch error), mirroring every other gated surface.
53pub const FEEDBACK_CAPABILITY: &str = "feedback.v1";
54
55/// Capabilities implemented by this protocol version. Kept sorted so the
56/// handshake response is deterministic across clients and platforms.
57pub const SUPPORTED_CAPABILITIES: &[&str] = &[
58    AGENT_TOOL_OVERRIDES_CAPABILITY,
59    FEEDBACK_CAPABILITY,
60    INFER_CANCEL_CAPABILITY,
61    INFER_DEADLINE_CAPABILITY,
62    INFER_MODEL_IDENTITY_CAPABILITY,
63    MODELS_CATALOG_IDENTITY_CAPABILITY,
64    RUNS_CANCEL_CAPABILITY,
65    RUNS_PAGINATION_CAPABILITY,
66    RUNS_RESUME_CAPABILITY,
67    TOOLS_CALLBACK_STATE_CAPABILITY,
68];
69
70/// Capabilities every bundled v3 client requires from the daemon.
71pub const REQUIRED_CLIENT_CAPABILITIES: &[&str] = &[
72    INFER_MODEL_IDENTITY_CAPABILITY,
73    MODELS_CATALOG_IDENTITY_CAPABILITY,
74];
75
76/// JSON-RPC application error returned when a handshake-gated method is called
77/// before this WebSocket session has completed `server.handshake`.
78///
79/// `session.auth` is deliberately allowed before negotiation because an
80/// auth-enabled daemon requires it as the connection's first frame.
81pub const PROTOCOL_HANDSHAKE_REQUIRED_ERROR_CODE: i32 = -32005;
82
83/// JSON-RPC application error returned when `server.handshake` receives a
84/// client protocol version other than [`PROTOCOL_VERSION`].
85pub const PROTOCOL_VERSION_MISMATCH_ERROR_CODE: i32 = -32006;
86
87/// JSON-RPC application error returned when a client names an unsupported
88/// mandatory capability during `server.handshake`.
89pub const PROTOCOL_CAPABILITY_MISMATCH_ERROR_CODE: i32 = -32008;
90
91/// JSON-RPC application error returned when an inference request's optimistic
92/// model-catalog precondition does not match the snapshot bound by the daemon.
93/// The request is rejected before any provider or local worker dispatch.
94pub const CATALOG_PRECONDITION_MISMATCH_ERROR_CODE: i32 = -32009;
95/// JSON-RPC application error returned when a globally reserved run id /
96/// idempotency key belongs to another authenticated client.
97pub const RUN_OWNERSHIP_CONFLICT_ERROR_CODE: i32 = -32010;
98/// JSON-RPC application error returned when a durable run JSONL contains a
99/// malformed newline-terminated record. Partial records are never returned.
100pub const RUN_TRACE_CORRUPTION_ERROR_CODE: i32 = -32011;
101
102/// Stable message prefix paired with
103/// [`PROTOCOL_HANDSHAKE_REQUIRED_ERROR_CODE`]. Hosts may use the numeric code
104/// for typed handling and surface this text as an actionable fallback.
105pub const PROTOCOL_HANDSHAKE_REQUIRED_MESSAGE_PREFIX: &str = "protocol handshake required:";
106
107/// Stable message prefix paired with [`PROTOCOL_VERSION_MISMATCH_ERROR_CODE`].
108pub const PROTOCOL_VERSION_MISMATCH_MESSAGE_PREFIX: &str = "protocol version mismatch:";
109
110/// Stable prefix paired with [`PROTOCOL_CAPABILITY_MISMATCH_ERROR_CODE`].
111pub const PROTOCOL_CAPABILITY_MISMATCH_MESSAGE_PREFIX: &str = "protocol capability mismatch:";
112
113/// Stable prefix paired with [`CATALOG_PRECONDITION_MISMATCH_ERROR_CODE`].
114pub const CATALOG_PRECONDITION_MISMATCH_MESSAGE_PREFIX: &str = "catalog precondition mismatch:";
115/// Stable prefix paired with [`RUN_OWNERSHIP_CONFLICT_ERROR_CODE`].
116pub const RUN_OWNERSHIP_CONFLICT_MESSAGE_PREFIX: &str = "run ownership conflict:";
117/// Stable prefix paired with [`RUN_TRACE_CORRUPTION_ERROR_CODE`].
118pub const RUN_TRACE_CORRUPTION_MESSAGE_PREFIX: &str = "run trace corruption:";
119
120/// Negotiate supported required/optional capabilities. Unsupported mandatory
121/// entries are returned sorted and deduplicated; unsupported optional entries
122/// are ignored. A successful result is also sorted and deduplicated.
123pub fn negotiate_capabilities(
124    required: &[String],
125    optional: &[String],
126) -> Result<Vec<String>, Vec<String>> {
127    use std::collections::BTreeSet;
128
129    let supported: BTreeSet<&str> = SUPPORTED_CAPABILITIES.iter().copied().collect();
130    let missing: Vec<String> = required
131        .iter()
132        .filter(|capability| !supported.contains(capability.as_str()))
133        .cloned()
134        .collect::<BTreeSet<_>>()
135        .into_iter()
136        .collect();
137    if !missing.is_empty() {
138        return Err(missing);
139    }
140
141    Ok(required
142        .iter()
143        .chain(optional)
144        .filter(|capability| supported.contains(capability.as_str()))
145        .cloned()
146        .collect::<BTreeSet<_>>()
147        .into_iter()
148        .collect())
149}
150
151/// JSON-RPC application error returned when something in FRONT of the model
152/// declined the request's *content* — a managed gateway's content filter, a
153/// provider's moderation layer — rather than the model answering or the call
154/// crashing.
155///
156/// This is deliberately NOT `-32603 internal error`. A refusal is a
157/// deterministic ruling on that content, not a fault, and collapsing the two
158/// costs three different consumers (Parslee-ai/car#796):
159///
160/// - a **benchmark** can score a refusal as a refusal instead of counting it as
161///   a crash — an adversarial-safety suite drives this path on purpose, and it
162///   cannot measure anything if the blocked cases are indistinguishable from
163///   broken ones;
164/// - a **retry loop** stops instead of burning its budget re-sending a decision
165///   that will never change;
166/// - an **operator** can tell a content ruling from a misconfiguration.
167///
168/// The **numeric code is the contract.** [`CONTENT_REFUSED_MESSAGE_PREFIX`] is
169/// the paired fallback for consumers that only ever see the message text.
170pub const CONTENT_REFUSED_ERROR_CODE: i32 = -32007;
171
172/// Stable message prefix paired with [`CONTENT_REFUSED_ERROR_CODE`]. Consumers
173/// that only see the flattened message string (an FFI client rendering
174/// `"{code} {message}"`, a log line) can match on this prefix; anything that can
175/// read the JSON-RPC error object should match the code instead.
176pub const CONTENT_REFUSED_MESSAGE_PREFIX: &str = "content refused:";
177
178/// Typed terminal observation returned by `infer.cancel` and
179/// `infer.deadline`. Confirmation is deliberately evidence-bearing: only an
180/// exact backend acknowledgement may produce a `*_confirmed` variant.
181#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
182#[serde(rename_all = "snake_case")]
183pub enum InferenceControlStatus {
184    AlreadyTerminal,
185    CancelledConfirmed,
186    TerminationUnconfirmed,
187    DeadlineExceededConfirmed,
188    DeadlineExceededUnconfirmed,
189    Unknown,
190}
191
192#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
193pub struct InferenceControlResponse {
194    pub inference_id: String,
195    pub status: InferenceControlStatus,
196}
197
198pub mod daemon;
199pub use daemon::{method_accepts_host_authority, HOST_MANAGEMENT_METHODS};
200
201/// Compute a deterministic, content-derived run id (EPIC B / B7).
202///
203/// `runs.start` already treats a caller-supplied `idempotency_key` as the
204/// run id, so "same key → same run". This is the canonical way to *derive*
205/// that key from the run's content, so two independent devices (or a
206/// retried / replayed start) that issue the same logical run compute the
207/// **same** id without coordinating — the prerequisite for the multi-device
208/// idempotency keys and execution-lease fencing in B5.
209///
210/// The id is `run-<hex>` where `<hex>` is the first 32 hex chars of
211/// `SHA-256(agent_id ‖ "\x1f" ‖ intent ‖ "\x1f" ‖ salt)`. `salt`
212/// distinguishes otherwise-identical logical runs (e.g. a date bucket, a
213/// scheduler occurrence id, or a user-supplied nonce); pass `""` when the
214/// `(agent_id, intent)` pair alone identifies the run. Pure and stable
215/// across builds/platforms — pass the result as `runs.start`'s
216/// `idempotency_key`.
217pub fn deterministic_run_id(agent_id: &str, intent: &str, salt: &str) -> String {
218    use sha2::{Digest, Sha256};
219    let mut hasher = Sha256::new();
220    hasher.update(agent_id.as_bytes());
221    hasher.update(b"\x1f");
222    hasher.update(intent.as_bytes());
223    hasher.update(b"\x1f");
224    hasher.update(salt.as_bytes());
225    let digest = hasher.finalize();
226    let hex: String = digest.iter().take(16).map(|b| format!("{b:02x}")).collect();
227    format!("run-{hex}")
228}
229
230#[cfg(test)]
231mod run_id_tests {
232    use super::deterministic_run_id;
233
234    #[test]
235    fn same_inputs_same_id() {
236        let a = deterministic_run_id("agent-1", "summarize inbox", "2026-06-30");
237        let b = deterministic_run_id("agent-1", "summarize inbox", "2026-06-30");
238        assert_eq!(a, b, "deterministic: identical inputs → identical id");
239        assert!(a.starts_with("run-"));
240        assert_eq!(a.len(), 4 + 32);
241    }
242
243    #[test]
244    fn distinct_inputs_distinct_ids() {
245        let base = deterministic_run_id("agent-1", "intent", "s");
246        assert_ne!(base, deterministic_run_id("agent-2", "intent", "s"));
247        assert_ne!(base, deterministic_run_id("agent-1", "other", "s"));
248        assert_ne!(base, deterministic_run_id("agent-1", "intent", "s2"));
249    }
250
251    #[test]
252    fn no_field_separator_collision() {
253        // The 0x1f separator prevents ("ab","c") colliding with ("a","bc").
254        assert_ne!(
255            deterministic_run_id("ab", "c", ""),
256            deterministic_run_id("a", "bc", "")
257        );
258    }
259}
260
261#[cfg(test)]
262mod capability_negotiation_tests {
263    use super::*;
264
265    #[test]
266    fn protocol_v3_advertises_catalog_and_inference_identity() {
267        assert_eq!(PROTOCOL_VERSION, 3);
268        assert!(SUPPORTED_CAPABILITIES.contains(&MODELS_CATALOG_IDENTITY_CAPABILITY));
269        assert!(SUPPORTED_CAPABILITIES.contains(&INFER_MODEL_IDENTITY_CAPABILITY));
270        assert!(SUPPORTED_CAPABILITIES.contains(&INFER_CANCEL_CAPABILITY));
271        assert!(SUPPORTED_CAPABILITIES.contains(&INFER_DEADLINE_CAPABILITY));
272        assert!(!REQUIRED_CLIENT_CAPABILITIES.contains(&INFER_CANCEL_CAPABILITY));
273        assert!(!REQUIRED_CLIENT_CAPABILITIES.contains(&INFER_DEADLINE_CAPABILITY));
274    }
275
276    #[test]
277    fn callback_state_is_an_optional_v3_capability() {
278        assert!(SUPPORTED_CAPABILITIES.contains(&TOOLS_CALLBACK_STATE_CAPABILITY));
279        assert!(!REQUIRED_CLIENT_CAPABILITIES.contains(&TOOLS_CALLBACK_STATE_CAPABILITY));
280    }
281
282    #[test]
283    fn feedback_is_an_optional_v3_capability() {
284        assert!(SUPPORTED_CAPABILITIES.contains(&FEEDBACK_CAPABILITY));
285        assert!(!REQUIRED_CLIENT_CAPABILITIES.contains(&FEEDBACK_CAPABILITY));
286    }
287
288    #[test]
289    fn exact_agent_tool_overrides_are_an_optional_v3_capability() {
290        assert!(SUPPORTED_CAPABILITIES.contains(&AGENT_TOOL_OVERRIDES_CAPABILITY));
291        assert!(!REQUIRED_CLIENT_CAPABILITIES.contains(&AGENT_TOOL_OVERRIDES_CAPABILITY));
292    }
293
294    #[test]
295    fn run_resume_is_optional_strict_and_carries_no_caller_owner_identity() {
296        assert!(SUPPORTED_CAPABILITIES.contains(&RUNS_RESUME_CAPABILITY));
297        assert!(!REQUIRED_CLIENT_CAPABILITIES.contains(&RUNS_RESUME_CAPABILITY));
298        let request: RunResumeRequest =
299            serde_json::from_value(serde_json::json!({"run_id":"run-1"})).unwrap();
300        assert_eq!(request.run_id, "run-1");
301        assert_eq!(
302            serde_json::to_value(&request).unwrap(),
303            serde_json::json!({"run_id":"run-1"})
304        );
305
306        for caller_supplied_credential in [
307            serde_json::json!({"run_id":"run-1", "agent_id":"caller-controlled"}),
308            serde_json::json!({"run_id":"run-1", "client_id":"caller-controlled"}),
309            serde_json::json!({"run_id":"run-1", "idempotency_key":"caller-controlled"}),
310            serde_json::json!({"run_id":"run-1", "owner_token":"caller-controlled"}),
311        ] {
312            assert!(
313                serde_json::from_value::<RunResumeRequest>(caller_supplied_credential).is_err()
314            );
315        }
316
317        let response = RunResumeResponse {
318            run_id: "run-1".into(),
319            agent_id: "agent-1".into(),
320            client_id: "client-new".into(),
321            resumed_from_client_id: "client-old".into(),
322        };
323        assert_eq!(
324            serde_json::to_value(response).unwrap(),
325            serde_json::json!({
326                "run_id":"run-1",
327                "agent_id":"agent-1",
328                "client_id":"client-new",
329                "resumed_from_client_id":"client-old"
330            })
331        );
332    }
333
334    #[test]
335    fn inference_control_statuses_have_stable_typed_wire_names() {
336        let cases = [
337            (InferenceControlStatus::AlreadyTerminal, "already_terminal"),
338            (
339                InferenceControlStatus::CancelledConfirmed,
340                "cancelled_confirmed",
341            ),
342            (
343                InferenceControlStatus::TerminationUnconfirmed,
344                "termination_unconfirmed",
345            ),
346            (
347                InferenceControlStatus::DeadlineExceededConfirmed,
348                "deadline_exceeded_confirmed",
349            ),
350            (
351                InferenceControlStatus::DeadlineExceededUnconfirmed,
352                "deadline_exceeded_unconfirmed",
353            ),
354            (InferenceControlStatus::Unknown, "unknown"),
355        ];
356        for (status, expected) in cases {
357            assert_eq!(serde_json::to_value(status).unwrap(), expected);
358        }
359    }
360
361    #[test]
362    fn unknown_mandatory_capability_fails_loud() {
363        let error = negotiate_capabilities(
364            &["future.mandatory.v1".to_string()],
365            &[MODELS_CATALOG_IDENTITY_CAPABILITY.to_string()],
366        )
367        .expect_err("an unsupported mandatory capability must reject the handshake");
368
369        assert_eq!(error, vec!["future.mandatory.v1"]);
370    }
371
372    #[test]
373    fn negotiated_capabilities_are_sorted_deduplicated_and_optional_safe() {
374        let negotiated = negotiate_capabilities(
375            &[
376                INFER_MODEL_IDENTITY_CAPABILITY.to_string(),
377                MODELS_CATALOG_IDENTITY_CAPABILITY.to_string(),
378                INFER_MODEL_IDENTITY_CAPABILITY.to_string(),
379            ],
380            &[
381                "future.optional.v1".to_string(),
382                MODELS_CATALOG_IDENTITY_CAPABILITY.to_string(),
383            ],
384        )
385        .unwrap();
386
387        assert_eq!(
388            negotiated,
389            vec![
390                INFER_MODEL_IDENTITY_CAPABILITY.to_string(),
391                MODELS_CATALOG_IDENTITY_CAPABILITY.to_string(),
392            ]
393        );
394    }
395
396    #[test]
397    fn run_pagination_capability_is_negotiable() {
398        let capability = "runs.pagination.v1".to_string();
399        assert_eq!(
400            negotiate_capabilities(std::slice::from_ref(&capability), &[]),
401            Ok(vec![capability])
402        );
403    }
404
405    #[test]
406    fn run_page_requests_require_explicit_cursor_and_limit() {
407        assert!(serde_json::from_value::<RunListRequest>(serde_json::json!({
408            "agent_id": "agent-a"
409        }))
410        .is_err());
411        assert!(
412            serde_json::from_value::<RunGetTraceRequest>(serde_json::json!({
413                "run_id": "run-a"
414            }))
415            .is_err()
416        );
417        assert!(
418            serde_json::from_value::<RunSubscribeRequest>(serde_json::json!({
419                "run_id": "run-a"
420            }))
421            .is_err()
422        );
423    }
424}
425
426use car_ir::ActionProposal;
427use chrono::{DateTime, Utc};
428use serde::{Deserialize, Serialize};
429use serde_json::Value;
430use std::collections::HashMap;
431
432/// Tool definition sent by client during registration.
433///
434/// Mirrors the caller-settable fields of `car_ir::ToolSchema` over the wire so
435/// the validator, caching, and rate-limiting layers see the same values the
436/// in-process engine does. `ToolSchema.source` is deliberately absent: the
437/// runtime assigns `user_defined` to client registrations rather than trusting
438/// a caller to claim `builtin`. New optional fields are added with serde defaults so
439/// pre-v0.5.x clients (which only sent `name` / `description` /
440/// `parameters`) still parse cleanly.
441#[derive(Debug, Clone, Serialize, Deserialize)]
442pub struct ToolDefinition {
443    pub name: String,
444    #[serde(default)]
445    pub description: String,
446    /// JSON Schema for parameters. Empty object = schemaless (legacy
447    /// behavior — validator skips type checks).
448    #[serde(default)]
449    pub parameters: Value,
450    /// JSON Schema for return value (optional).
451    #[serde(default, skip_serializing_if = "Option::is_none")]
452    pub returns: Option<Value>,
453    /// Marks the tool as safe to cache/retry.
454    #[serde(default)]
455    pub idempotent: bool,
456    /// If set, results are cached with this TTL in seconds.
457    #[serde(default, skip_serializing_if = "Option::is_none")]
458    pub cache_ttl_secs: Option<u64>,
459    /// If set, rate-limited to this many calls per interval.
460    #[serde(default, skip_serializing_if = "Option::is_none")]
461    pub rate_limit: Option<ToolRateLimit>,
462}
463
464/// Mirror of `car_ir::ToolRateLimit` over the wire.
465#[derive(Debug, Clone, Serialize, Deserialize)]
466pub struct ToolRateLimit {
467    pub max_calls: u32,
468    pub interval_secs: f64,
469}
470
471// --- Client → Server requests ---
472
473/// Initialize a session.
474#[derive(Debug, Clone, Serialize, Deserialize)]
475pub struct SessionInitRequest {
476    pub client_id: String,
477    #[serde(default)]
478    pub tools: Vec<ToolDefinition>,
479    #[serde(default)]
480    pub policies: Vec<PolicyDefinition>,
481}
482
483/// Policy definition from client.
484#[derive(Debug, Clone, Serialize, Deserialize)]
485pub struct PolicyDefinition {
486    pub name: String,
487    pub rule: String, // deny_tool, deny_tool_param, require_state, etc.
488    #[serde(default)]
489    pub target: String,
490    #[serde(default)]
491    pub key: String,
492    #[serde(default)]
493    pub value: Value,
494    #[serde(default)]
495    pub pattern: String,
496}
497
498/// Submit a proposal for execution.
499#[derive(Debug, Clone, Serialize, Deserialize)]
500pub struct ProposalSubmitRequest {
501    pub proposal: ActionProposal,
502}
503
504/// Verify a proposal without executing.
505#[derive(Debug, Clone, Serialize, Deserialize)]
506pub struct VerifyRequest {
507    pub proposal: ActionProposal,
508    #[serde(default)]
509    pub initial_state: HashMap<String, Value>,
510}
511
512// --- Server → Client callbacks ---
513
514/// Server asks client to execute a tool.
515#[derive(Debug, Clone, Serialize, Deserialize)]
516pub struct ToolExecuteRequest {
517    pub action_id: String,
518    pub tool: String,
519    pub parameters: Value,
520    #[serde(default)]
521    pub timeout_ms: Option<u64>,
522    #[serde(default)]
523    pub attempt: u32,
524    /// Daemon-side callback-routing id (the JSON-RPC `id` of this
525    /// `tools.execute` request, e.g. `"cb-1"`), surfaced into the params so
526    /// the host can key a per-call abort registry on it. When this call is
527    /// reaped (the daemon's callback wait expires), the daemon emits a
528    /// `tools.cancel` notification carrying the SAME `request_id` so the host
529    /// kills the in-flight child instead of orphaning it (Parslee-ai/car#264).
530    ///
531    /// **Correlate by `request_id`, not `action_id`** — `action_id` is empty
532    /// for legacy `execute()` callers and is not unique across concurrent or
533    /// retried attempts. `#[serde(default)]` so pre-#264 hosts still parse the
534    /// payload (they just won't get the cancel correlation key).
535    #[serde(default)]
536    pub request_id: String,
537    /// The Runtime execution session this call belongs to, stamped by the
538    /// **daemon** rather than assembled by the client (Parslee-ai/car#904).
539    ///
540    /// Correlation was previously the host's problem, and the conventions
541    /// available for it are fragile in exactly the situation that needs them:
542    /// `action_id` is client-authored and explicitly not unique across
543    /// concurrent or retried attempts, and a submit-time map keyed on it
544    /// inherits that. An agent keeping per-mission receipts had to thread
545    /// identity through its own scheme, and the naive one (a process-global
546    /// run id) lets a later mission's artifact inherit an earlier mission's
547    /// receipts.
548    ///
549    /// The executor already had this value and threw it away — it reached
550    /// `execute_with_action_in_session` as an unused `_session_id` parameter.
551    /// Stamping it costs nothing and makes attribution server-side fact
552    /// instead of client-side convention.
553    ///
554    /// `None` for callers with no session: the legacy `execute()` path, and
555    /// in-process executors that never had one.
556    #[serde(default, skip_serializing_if = "Option::is_none")]
557    pub session_id: Option<String>,
558}
559
560/// Fire-and-forget `tools.cancel` notification (Parslee-ai/car#264).
561///
562/// Emitted server → client when a `tools.execute` callback is reaped (the
563/// daemon's per-call wait expired) so the host can abort the in-flight child
564/// (e.g. a `claude -p` / `codex exec` driven by `drive_cli`) instead of leaving
565/// it orphaned. A notification (no `id`, no response expected): the daemon has
566/// already given up on the call and is not waiting on the host's acknowledgment.
567///
568/// Correlation is by `request_id` (the `tools.execute` routing id), NOT
569/// `action_id` — see [`ToolExecuteRequest::request_id`].
570#[derive(Debug, Clone, Serialize, Deserialize)]
571pub struct ToolCancelRequest {
572    /// The reaped call's routing id — matches the `request_id` the host saw on
573    /// the originating `tools.execute`.
574    pub request_id: String,
575    /// The originating proposal `Action.id`, for host-side logging/telemetry.
576    /// May be empty (legacy `execute()` callers don't carry one).
577    #[serde(default)]
578    pub action_id: String,
579    /// Why the call was cancelled — currently always a callback-timeout reason
580    /// string. Advisory; the host should abort regardless of the reason.
581    #[serde(default)]
582    pub reason: String,
583}
584
585fn is_false(value: &bool) -> bool {
586    !*value
587}
588
589/// Client returns tool execution result.
590#[derive(Debug, Clone, Serialize, Deserialize)]
591pub struct ToolExecuteResponse {
592    pub action_id: String,
593    #[serde(default)]
594    pub output: Option<Value>,
595    #[serde(default)]
596    pub error: Option<String>,
597    /// The callback explicitly classified this error as unrecoverable.
598    ///
599    /// Additive and false by default so responses from older clients retain
600    /// their ordinary proposal-scoped failure behavior. This bit is execution
601    /// evidence, not [`car_ir::FailureBehavior`] policy, and is never inferred
602    /// from [`Self::error`] text.
603    #[serde(default, skip_serializing_if = "is_false")]
604    pub terminal: bool,
605}
606
607// --- Server → Client notifications ---
608
609/// Execution event notification (streaming).
610#[derive(Debug, Clone, Serialize, Deserialize)]
611pub struct ExecutionEvent {
612    pub kind: String, // matches EventKind values
613    #[serde(default)]
614    pub action_id: Option<String>,
615    #[serde(default)]
616    pub proposal_id: Option<String>,
617    #[serde(default)]
618    pub data: HashMap<String, Value>,
619}
620
621// --- Host UI protocol ---
622
623/// OS-host-visible agent status.
624#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
625#[serde(rename_all = "snake_case")]
626pub enum HostAgentStatus {
627    Idle,
628    Running,
629    WaitingForApproval,
630    Paused,
631    Completed,
632    Errored,
633    Stopped,
634}
635
636/// Host-visible display hints for an agent.
637#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
638pub struct HostAgentDisplay {
639    #[serde(default, skip_serializing_if = "Option::is_none")]
640    pub label: Option<String>,
641    #[serde(default, skip_serializing_if = "Option::is_none")]
642    pub icon: Option<String>,
643    #[serde(default, skip_serializing_if = "Option::is_none")]
644    pub accent: Option<String>,
645}
646
647/// Agent entry visible to menu bar, tray, or terminal host clients.
648#[derive(Debug, Clone, Serialize, Deserialize)]
649pub struct HostAgent {
650    pub id: String,
651    pub name: String,
652    #[serde(default)]
653    pub kind: String,
654    #[serde(default)]
655    pub capabilities: Vec<String>,
656    #[serde(default, skip_serializing_if = "Option::is_none")]
657    pub project: Option<String>,
658    #[serde(default, skip_serializing_if = "Option::is_none")]
659    pub session_id: Option<String>,
660    pub status: HostAgentStatus,
661    #[serde(default, skip_serializing_if = "Option::is_none")]
662    pub current_task: Option<String>,
663    #[serde(default, skip_serializing_if = "Option::is_none")]
664    pub pid: Option<u32>,
665    #[serde(default)]
666    pub display: HostAgentDisplay,
667    pub updated_at: DateTime<Utc>,
668    #[serde(default)]
669    pub metadata: Value,
670}
671
672/// Request to register an agent with the OS host surface.
673#[derive(Debug, Clone, Serialize, Deserialize)]
674pub struct RegisterHostAgentRequest {
675    #[serde(default, skip_serializing_if = "Option::is_none")]
676    pub id: Option<String>,
677    pub name: String,
678    #[serde(default)]
679    pub kind: String,
680    #[serde(default)]
681    pub capabilities: Vec<String>,
682    #[serde(default, skip_serializing_if = "Option::is_none")]
683    pub project: Option<String>,
684    #[serde(default, skip_serializing_if = "Option::is_none")]
685    pub pid: Option<u32>,
686    #[serde(default)]
687    pub display: HostAgentDisplay,
688    #[serde(default)]
689    pub metadata: Value,
690}
691
692/// Request to update an agent's host-visible status.
693#[derive(Debug, Clone, Serialize, Deserialize)]
694pub struct SetHostAgentStatusRequest {
695    pub agent_id: String,
696    pub status: HostAgentStatus,
697    #[serde(default, skip_serializing_if = "Option::is_none")]
698    pub current_task: Option<String>,
699    #[serde(default, skip_serializing_if = "Option::is_none")]
700    pub message: Option<String>,
701    #[serde(default)]
702    pub payload: Value,
703}
704
705#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
706#[serde(rename_all = "snake_case")]
707pub enum HostApprovalStatus {
708    Pending,
709    Resolved,
710}
711
712/// Approval request visible to the OS host surface.
713///
714/// `client_id` is the WS session that raised the approval. When
715/// `Some(x)`, only session `x` may call `host.resolve_approval` on
716/// it — added 2026-05 after a security audit found unrestricted
717/// resolve let one client approve another's pending request. When
718/// `None` the approval is system-raised (the high-risk-method
719/// approval gate uses this so the local UI session can resolve
720/// approvals raised by *other* sessions' dispatch attempts) and
721/// any authenticated session may resolve it.
722#[derive(Debug, Clone, Serialize, Deserialize)]
723pub struct HostApprovalRequest {
724    pub id: String,
725    #[serde(default, skip_serializing_if = "Option::is_none")]
726    pub agent_id: Option<String>,
727    #[serde(default, skip_serializing_if = "Option::is_none")]
728    pub client_id: Option<String>,
729    pub action: String,
730    pub details: Value,
731    #[serde(default)]
732    pub options: Vec<String>,
733    pub status: HostApprovalStatus,
734    pub created_at: DateTime<Utc>,
735    #[serde(default, skip_serializing_if = "Option::is_none")]
736    pub resolved_at: Option<DateTime<Utc>>,
737    #[serde(default, skip_serializing_if = "Option::is_none")]
738    pub resolution: Option<String>,
739}
740
741/// Request to create an approval prompt in the host surface.
742#[derive(Debug, Clone, Serialize, Deserialize)]
743pub struct CreateHostApprovalRequest {
744    /// The agent the approval is raised for.
745    ///
746    /// **Advisory from an agent-bound caller.** `host.request_approval` replaces
747    /// this with the session's authenticated agent binding when the session has
748    /// one, so an agent cannot attribute its request to another agent or leave
749    /// it unattributed. A client with no binding — the operator surface holding
750    /// the daemon-wide token — keeps what it sends, because raising an approval
751    /// on an agent's behalf is exactly what those clients do.
752    ///
753    /// The stamped value is what the requester-vs-resolver comparison in
754    /// `car_server_types::host::Resolver` reads, so its trustworthiness is the
755    /// check's trustworthiness.
756    #[serde(default, skip_serializing_if = "Option::is_none")]
757    pub agent_id: Option<String>,
758    pub action: String,
759    #[serde(default)]
760    pub details: Value,
761    #[serde(default)]
762    pub options: Vec<String>,
763    /// When `true`, the approval is created as system-level: it has
764    /// no `client_id` owner and any authenticated session may resolve
765    /// it. This is the right mode for agent-requested approvals where
766    /// "the user" (via CarHost or `car-host approve`) is the resolver,
767    /// not the requesting agent itself. The previous default (always
768    /// session-owned by the requester) locked the approval to the
769    /// agent's WS connection, which broke as soon as the agent
770    /// reconnected — the new session got a fresh client_id and could
771    /// no longer resolve its own pending approval, AND CarHost (a
772    /// different session) couldn't either.
773    ///
774    /// Defaults to `false` for backward compatibility: existing
775    /// callers that don't set this field keep the strict per-session
776    /// ownership semantics.
777    #[serde(default)]
778    pub system_level: bool,
779}
780
781/// Request to resolve an approval.
782#[derive(Debug, Clone, Serialize, Deserialize)]
783pub struct ResolveHostApprovalRequest {
784    pub approval_id: String,
785    pub resolution: String,
786}
787
788// --- iMessage approval-transport config surface (`messaging.*`) ---
789//
790// The host/local-auth-gated config channel for the iMessage approval
791// transport (Unit 3). These are the ONLY allowlist/config-mutation path
792// in the system; the daemon's WS handlers reject any caller that is not
793// `session.is_host` or presenting the per-launch local auth token. An
794// inbound iMessage carries neither, so it can never mutate config.
795
796/// Result of `messaging.config.get` / `messaging.config.set` — the
797/// current (or post-mutation) view of the transport config. The active
798/// pairing code is intentionally NOT echoed here (it is surfaced only via
799/// `messaging.pairing.status`, mirroring the local-UI-rooted invariant).
800#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
801pub struct MessagingConfigView {
802    /// Which channel this view describes (stable string key: `"imessage"` /
803    /// `"slack"`). Echoed so a per-channel round-trip can confirm WHICH channel
804    /// the flags belong to. Defaults to `"imessage"` for the back-compat
805    /// surface.
806    #[serde(default = "default_channel_key")]
807    pub channel: String,
808    /// Master opt-in flag (default `false`).
809    pub enabled: bool,
810    /// Approver handles permitted to resolve approvals over this channel.
811    pub allowlisted_handles: Vec<String>,
812    /// Whether a pairing is currently in flight (a code has been minted
813    /// and not yet consumed). The code value itself is not exposed here.
814    pub pairing_active: bool,
815}
816
817/// Back-compat default for `MessagingConfigView::channel` — iMessage.
818fn default_channel_key() -> String {
819    "imessage".to_string()
820}
821
822/// Params for `messaging.config.set`. All fields optional — only the
823/// supplied fields mutate (a `null`/absent field leaves that part of the
824/// config unchanged). `add_handles` / `remove_handles` apply after
825/// `allowlisted_handles` when both are present.
826#[derive(Debug, Clone, Default, Serialize, Deserialize)]
827pub struct MessagingConfigSetRequest {
828    /// Which channel this mutation targets (stable string key: `"imessage"` /
829    /// `"slack"`). **Absent ⇒ iMessage** — back-compat for the #403 surface and
830    /// bindings, which have no `channel` field. (The full FFI/doc parity for the
831    /// explicit `channel` field — `.d.ts`/`.pyi`/websocket-protocol — lands in
832    /// Unit 6; the server-side optional field is added here so the per-channel
833    /// WS round-trip works. The wire value stays a plain string tagged by
834    /// channel — no typed identity struct across FFI.)
835    #[serde(default, skip_serializing_if = "Option::is_none")]
836    pub channel: Option<String>,
837    /// When present, set the master opt-in flag.
838    #[serde(default, skip_serializing_if = "Option::is_none")]
839    pub enabled: Option<bool>,
840    /// When present, REPLACE the entire allowlist with these handles.
841    #[serde(default, skip_serializing_if = "Option::is_none")]
842    pub allowlisted_handles: Option<Vec<String>>,
843    /// When present, add each handle to the allowlist (idempotent).
844    #[serde(default)]
845    pub add_handles: Vec<String>,
846    /// When present, remove each handle from the allowlist (idempotent).
847    #[serde(default)]
848    pub remove_handles: Vec<String>,
849    /// Slack bot token (`xoxb-`) to provision. When BOTH `bot_token` and
850    /// `app_token` are present (Slack channel only), the daemon writes them to
851    /// the OS keychain (MC-9) and persists only a keychain *reference* into the
852    /// config — the bearer value never lands in `messaging.json` nor echoes back
853    /// in the response. This is a host-gated, write-only provisioning input; an
854    /// inbound message cannot reach this surface (MC-6). Absent ⇒ no token
855    /// change (back-compat for the enable/allowlist-only callers).
856    #[serde(default, skip_serializing_if = "Option::is_none")]
857    pub bot_token: Option<String>,
858    /// Slack app-level token (`xapp-`) to provision. See [`Self::bot_token`] —
859    /// both must be present to trigger provisioning.
860    #[serde(default, skip_serializing_if = "Option::is_none")]
861    pub app_token: Option<String>,
862    /// Slack post-channel id (`C0123…` / `D024…`) — the conversation the
863    /// outbound approval prompt posts into (Slack channel only). Unlike the
864    /// tokens this is CONFIGURATION, not a secret: the daemon persists it IN
865    /// `messaging.json` (host-gated), never the keychain. Set on the same
866    /// `messaging.config.set { channel: "slack", … }` call as the tokens.
867    /// Absent ⇒ no post-channel change (back-compat).
868    #[serde(default, skip_serializing_if = "Option::is_none")]
869    pub slack_channel: Option<String>,
870}
871
872/// Result of `messaging.pairing.start` — the freshly minted, high-entropy
873/// pairing code to display ONLY in the local UI, plus the post-mint view.
874#[derive(Debug, Clone, Serialize, Deserialize)]
875pub struct MessagingPairingStartResponse {
876    /// The minted pairing code. Shown only in local UI; the paired device
877    /// texts it back to prove control of its handle.
878    pub pairing_code: String,
879    /// Post-mint config view (`pairing_active` is now `true`).
880    pub config: MessagingConfigView,
881}
882
883/// Result of `messaging.pairing.status` — whether a pairing is in flight
884/// and, when so, the active code (host/local-auth gated read only, so the
885/// local UI can re-display the code after a reload).
886#[derive(Debug, Clone, Default, Serialize, Deserialize)]
887pub struct MessagingPairingStatusResponse {
888    /// Whether a pairing code is currently active.
889    pub pairing_active: bool,
890    /// The active pairing code, when one is in flight. Returned only over
891    /// the host/local-auth-gated surface — never over any inbound channel.
892    #[serde(default, skip_serializing_if = "Option::is_none")]
893    pub pairing_code: Option<String>,
894}
895
896/// Result of `messaging.status` — the real runtime liveness of a channel's
897/// approval transport, computed daemon-side (U2). The host UI renders a SINGLE
898/// readiness state from this object rather than re-deriving "is it on" from
899/// scattered permission widgets (the scatter that produced the confusing pane).
900///
901/// Readiness order (the pane resolves the FIRST failing condition):
902/// `enabled` → `watcher_running` → `fda_readable` → `paired` → Ready.
903/// `last_send_*` / `last_error` drive "last delivered" + a surfaced error.
904#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
905pub struct MessagingStatusView {
906    /// Which channel this status describes (`"imessage"` / `"slack"`).
907    #[serde(default = "default_channel_key")]
908    pub channel: String,
909    /// Master opt-in flag for this channel (condition 1).
910    pub enabled: bool,
911    /// Whether at least one handle is paired/allowlisted (condition 2).
912    pub paired: bool,
913    /// Whether this channel's watcher loop is currently spawned (condition 3 —
914    /// the invisible-restart fix; `true` once U1 has spawned it).
915    pub watcher_running: bool,
916    /// Whether the daemon can read the Messages database (Full Disk Access —
917    /// condition 4). Probed daemon-side (the daemon is the reader). For non-
918    /// iMessage channels this is `true` (no chat.db dependency).
919    pub fda_readable: bool,
920    /// Unix-epoch milliseconds of the most recent recorded send (success OR
921    /// failure). `None` until the first send. Drives "Last delivered: `<time>`".
922    #[serde(default, skip_serializing_if = "Option::is_none")]
923    pub last_send_at_ms: Option<i64>,
924    /// Whether the most recent recorded send succeeded. `None` until the first
925    /// send; `Some(false)` for a hard error OR a soft `sent:false`.
926    #[serde(default, skip_serializing_if = "Option::is_none")]
927    pub last_send_ok: Option<bool>,
928    /// The most recent send FAILURE reason (hard error or soft `sent:false`).
929    /// `None` when the last send succeeded or none has happened. Surfaced in the
930    /// pane so a swallowed failure becomes visible.
931    #[serde(default, skip_serializing_if = "Option::is_none")]
932    pub last_error: Option<String>,
933}
934
935/// Result of `messaging.test_send` — the synchronous outcome of the on-demand
936/// self-test (U4). `ok:true` means the labeled test message was delivered to
937/// the paired handle; `ok:false` carries an actionable `error` (channel off, no
938/// paired handle, Automation denied, recipient-not-found). The self-test mints
939/// NO approval/pairing mapping and resolves nothing.
940#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
941pub struct MessagingTestSendResponse {
942    /// Whether the test message was delivered.
943    pub ok: bool,
944    /// Actionable failure reason when `ok == false`; `None` on success.
945    #[serde(default, skip_serializing_if = "Option::is_none")]
946    pub error: Option<String>,
947}
948
949/// Host event emitted to subscribed OS host clients.
950#[derive(Debug, Clone, Serialize, Deserialize)]
951pub struct HostEvent {
952    pub id: String,
953    /// Monotonic daemon-local ordering token. A reconnect snapshot carries
954    /// the sequence it observed, so a client can reconcile it PER
955    /// CONVERSATION KEY: the snapshot's verdict stands for every key that no
956    /// live event with a strictly larger sequence has already spoken for on
957    /// that socket.
958    #[serde(default)]
959    pub sequence: u64,
960    pub timestamp: DateTime<Utc>,
961    pub kind: String,
962    #[serde(default, skip_serializing_if = "Option::is_none")]
963    pub agent_id: Option<String>,
964    pub message: String,
965    #[serde(default)]
966    pub payload: Value,
967}
968
969/// One browser wait returned by `host.subscribe.pending_signins`.
970#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
971pub struct BrowserSignInSnapshot {
972    pub conversation_id: String,
973    pub standing_session: bool,
974    pub message: String,
975}
976
977impl BrowserSignInSnapshot {
978    pub fn new(conversation_id: Option<&str>, message: impl Into<String>) -> Self {
979        Self {
980            conversation_id: conversation_id.unwrap_or("").to_string(),
981            standing_session: conversation_id.is_none(),
982            message: message.into(),
983        }
984    }
985}
986
987/// User-owned device registered by a native host app.
988///
989/// This is deliberately status/capability metadata, not a raw remote-exec
990/// surface. The daemon can tell the flagship assistant which personal devices
991/// are present and what consumer-safe surfaces they advertise; individual
992/// privacy-heavy capabilities still need dedicated, policy-gated RPCs.
993#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
994pub struct HostDevice {
995    pub id: String,
996    pub name: String,
997    pub platform: String,
998    #[serde(default)]
999    pub capabilities: Vec<String>,
1000    #[serde(default)]
1001    pub status: String,
1002    #[serde(default, skip_serializing_if = "Option::is_none")]
1003    pub session_id: Option<String>,
1004    pub updated_at: DateTime<Utc>,
1005    #[serde(default)]
1006    pub metadata: Value,
1007}
1008
1009#[derive(Debug, Clone, Serialize, Deserialize)]
1010pub struct RegisterHostDeviceRequest {
1011    #[serde(default, skip_serializing_if = "Option::is_none")]
1012    pub id: Option<String>,
1013    pub name: String,
1014    pub platform: String,
1015    #[serde(default)]
1016    pub capabilities: Vec<String>,
1017    #[serde(default)]
1018    pub status: Option<String>,
1019    #[serde(default)]
1020    pub metadata: Value,
1021}
1022
1023#[derive(Debug, Clone, Serialize, Deserialize)]
1024pub struct UpdateHostDeviceRequest {
1025    pub device_id: String,
1026    #[serde(default, skip_serializing_if = "Option::is_none")]
1027    pub name: Option<String>,
1028    #[serde(default, skip_serializing_if = "Option::is_none")]
1029    pub platform: Option<String>,
1030    #[serde(default, skip_serializing_if = "Option::is_none")]
1031    pub capabilities: Option<Vec<String>>,
1032    #[serde(default, skip_serializing_if = "Option::is_none")]
1033    pub status: Option<String>,
1034    #[serde(default, skip_serializing_if = "Option::is_none")]
1035    pub metadata: Option<Value>,
1036}
1037
1038/// Manifest-lock relationship for the daemon serving this WS.
1039/// Returned inside [`HostIdentity`].
1040#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1041#[serde(rename_all = "lowercase")]
1042pub enum HostManifestRole {
1043    /// This daemon holds the exclusive `<manifest>.lock` — agents.*
1044    /// mutations route through here.
1045    Owner,
1046    /// Another `car-server` on the host owns the lock; this daemon
1047    /// runs in observe-only mode (Parslee-ai/car-releases#44).
1048    Observer,
1049    /// No manifest is configured at all — `HOME` unset or the
1050    /// embedder didn't install one.
1051    None,
1052}
1053
1054/// Daemon-identifying metadata returned inside [`HostSnapshot`].
1055/// Lets multi-daemon hosts (`car-server install` plus an ad-hoc
1056/// eval daemon, etc.) tell which daemon they connected to and
1057/// whether THIS one owns the supervisor lock or is observe-only.
1058/// Closes the observability gap from Parslee-ai/car-releases#44.
1059///
1060/// Stable on the wire across the connection's lifetime — emitted
1061/// once on subscribe rather than as a periodic event because the
1062/// fields are immutable for the daemon's lifetime (the manifest
1063/// role flips only on daemon restart, which would close this WS
1064/// anyway).
1065#[derive(Debug, Clone, Serialize, Deserialize)]
1066pub struct HostIdentity {
1067    /// `CARGO_PKG_VERSION` from the daemon binary at build time.
1068    pub version: String,
1069    /// `std::process::id()` of the daemon — operators correlating
1070    /// log lines + `ps`/`lsof` output need this.
1071    pub pid: u32,
1072    /// Absolute path to the lifecycle-agent manifest this daemon
1073    /// supervises (or observes). `None` when no manifest is
1074    /// configured (`HOME` unset; embedder didn't install one).
1075    /// Lossy-encoded on non-UTF-8 paths — operators on path
1076    /// layouts that round-trip through this field should normalize
1077    /// upstream.
1078    pub manifest_path: Option<String>,
1079    pub manifest_role: HostManifestRole,
1080    /// Parslee cloud account bound to this daemon, when the local user
1081    /// has completed `car auth login`. This is advisory identity for
1082    /// cloud-backed features; the local WS auth token still gates access
1083    /// to the daemon process.
1084    #[serde(default, skip_serializing_if = "Option::is_none")]
1085    pub parslee: Option<ParsleeIdentity>,
1086}
1087
1088/// Parslee cloud identity associated with the local CAR user.
1089#[derive(Debug, Clone, Serialize, Deserialize)]
1090pub struct ParsleeIdentity {
1091    pub account_id: String,
1092    #[serde(default, skip_serializing_if = "Option::is_none")]
1093    pub email: Option<String>,
1094    #[serde(default, skip_serializing_if = "Option::is_none")]
1095    pub display_name: Option<String>,
1096    #[serde(default, skip_serializing_if = "Option::is_none")]
1097    pub active_organization: Option<String>,
1098    #[serde(default, skip_serializing_if = "Option::is_none")]
1099    pub organization_name: Option<String>,
1100}
1101
1102/// Snapshot returned by `host.subscribe`.
1103#[derive(Debug, Clone, Serialize, Deserialize)]
1104pub struct HostSnapshot {
1105    pub subscribed: bool,
1106    #[serde(default)]
1107    pub agents: Vec<HostAgent>,
1108    #[serde(default)]
1109    pub devices: Vec<HostDevice>,
1110    #[serde(default)]
1111    pub approvals: Vec<HostApprovalRequest>,
1112    #[serde(default)]
1113    pub events: Vec<HostEvent>,
1114    /// Authoritative browser waits at `event_sequence`. Clients replace
1115    /// local attention from this on reconnect unless they have already
1116    /// applied a live event with a larger sequence.
1117    #[serde(default)]
1118    pub pending_signins: Vec<BrowserSignInSnapshot>,
1119    #[serde(default)]
1120    pub event_sequence: u64,
1121    /// Daemon-identifying metadata — added 2026-05 to surface
1122    /// observe-only mode (Parslee-ai/car-releases#44) and let
1123    /// multi-daemon hosts tell which daemon they're talking to.
1124    #[serde(default, skip_serializing_if = "Option::is_none")]
1125    pub identity: Option<HostIdentity>,
1126}
1127
1128// --- Run lifecycle (agent run tracing, U1) ---
1129//
1130// A "run" is one `runs.start` / `runs.complete` bracket around an agent
1131// loop, identified by a durable `run_id` (a uuid) that is independent of
1132// the ephemeral, server-assigned WS `client_id`. U1 introduces only the
1133// run boundary + terminal-outcome carriers; the per-turn `RunTurn` /
1134// `CliOutcome` / `VerifierVerdict` model lands in U2.
1135//
1136// `RunStarted` and `RunEnded` are the two durable run records U1 emits.
1137// They serialize as tagged JSON (`{ "kind": "started", ... }` /
1138// `{ "kind": "ended", ... }`) so U2/U3 can extend the record set
1139// (adding a `Turn` variant) without breaking the on-wire shape.
1140
1141/// How a run reached its terminal state.
1142///
1143/// `Outcome` carries the harness-reported `AgentOutcome` from
1144/// `runs.complete`. `Incomplete` is written daemon-side when a harness
1145/// disconnects without ever reporting an outcome (past the short grace
1146/// window) — R5. It is deliberately distinct from any `OutcomeStatus`
1147/// so the dashboard can render "the harness vanished" differently from
1148/// "the agent gave up".
1149#[derive(Debug, Clone, Serialize, Deserialize)]
1150#[serde(tag = "kind", rename_all = "snake_case")]
1151pub enum RunTermination {
1152    /// The harness called `runs.complete` with a terminal outcome.
1153    Outcome {
1154        /// Convenience copy of `outcome.status`, surfaced top-level so
1155        /// list views can render the terminal banner without parsing
1156        /// the full `AgentOutcome`.
1157        status: car_ir::OutcomeStatus,
1158        outcome: car_ir::AgentOutcome,
1159    },
1160    /// The connection dropped mid-run with no `runs.complete` — the
1161    /// daemon wrote this marker. No `AgentOutcome` is available.
1162    Incomplete,
1163    /// CAR confirmed that all controlled work stopped and durably committed
1164    /// the caller's body-free cancellation identity.
1165    Cancelled {
1166        cancellation: RunCancellationIdentity,
1167    },
1168}
1169
1170/// Body-free identity durably bound into a cancelled terminal. The free-text
1171/// reason is deliberately represented only by its digest.
1172#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1173pub struct RunCancellationIdentity {
1174    pub receipt_version: u32,
1175    pub run_id: String,
1176    pub idempotency_key: String,
1177    pub reason_digest: String,
1178    pub principal: String,
1179    pub action_id: Option<String>,
1180    pub request_id: Option<String>,
1181}
1182
1183#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1184#[serde(rename_all = "snake_case")]
1185pub enum RunCancellationStatus {
1186    CancelledConfirmed,
1187    AlreadyTerminal,
1188    TerminationUnconfirmed,
1189}
1190
1191/// Strict `runs.cancel` request. Unknown fields are rejected to keep the
1192/// idempotency preimage closed and reviewable.
1193#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1194#[serde(deny_unknown_fields)]
1195pub struct RunCancelRequest {
1196    pub run_id: String,
1197    pub idempotency_key: String,
1198    pub reason: String,
1199}
1200
1201/// Deterministic, body-free cancellation receipt.
1202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1203pub struct RunCancelResponse {
1204    pub receipt_version: u32,
1205    pub run_id: String,
1206    pub idempotency_key: String,
1207    pub reason_digest: String,
1208    pub principal: String,
1209    pub status: RunCancellationStatus,
1210    pub terminal_digest: Option<String>,
1211    pub action_id: Option<String>,
1212    pub request_id: Option<String>,
1213    pub receipt_digest: String,
1214}
1215
1216/// First durable row for one cancellation key. It contains no reason/body.
1217#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1218pub struct RunCancellationRequested {
1219    pub receipt_version: u32,
1220    pub run_id: String,
1221    pub idempotency_key: String,
1222    pub reason_digest: String,
1223    pub principal: String,
1224    pub action_id: Option<String>,
1225    pub request_id: Option<String>,
1226}
1227
1228/// A run began — recorded when `runs.start` mints the `run_id`.
1229#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1230pub struct RunStarted {
1231    pub run_id: String,
1232    /// WebSocket connection that authenticated and opened this run. Optional
1233    /// only for replay compatibility with pre-0.51 rows.
1234    #[serde(default, skip_serializing_if = "Option::is_none")]
1235    pub client_id: Option<String>,
1236    /// The owning agent. Resolved from `session.auth {agent_id}`,
1237    /// `CAR_AGENT_ID`, or (one-shot fallback) a deterministic id
1238    /// synthesized from the agent's name.
1239    pub agent_id: String,
1240    /// What the agent was asked to do (free text from the harness).
1241    pub intent: String,
1242    /// The outcome the agent is steering toward, when supplied.
1243    #[serde(default, skip_serializing_if = "Option::is_none")]
1244    pub outcome_description: Option<String>,
1245    pub started_at: DateTime<Utc>,
1246}
1247
1248/// A run reached a terminal state — recorded on `runs.complete` (with a
1249/// reported outcome) or on a mid-run disconnect (as `Incomplete`).
1250#[derive(Debug, Clone, Serialize, Deserialize)]
1251pub struct RunEnded {
1252    pub run_id: String,
1253    /// Must match `RunStarted.client_id` for CAR-owned 0.51+ writes. Optional
1254    /// only for replay compatibility with historical rows.
1255    #[serde(default, skip_serializing_if = "Option::is_none")]
1256    pub client_id: Option<String>,
1257    pub agent_id: String,
1258    pub termination: RunTermination,
1259    /// Lowercase SHA-256 of the RFC 8785/JCS serialization of `termination`.
1260    /// This binds the terminal journal event, RPC acknowledgment, and durable
1261    /// row to one exact CAR run outcome; it is not an artifact/content digest.
1262    #[serde(default, skip_serializing_if = "Option::is_none")]
1263    pub completion_digest: Option<String>,
1264    pub ended_at: DateTime<Utc>,
1265}
1266
1267// --- Per-turn run trace (agent run tracing, U2) ---
1268//
1269// A "turn" is one submitted proposal (no inference chain-of-thought
1270// capture). The recorder (`car-server-core/src/run_trace.rs`) joins the
1271// submitted proposal's `actions[i]` to the resulting `ActionResult`s by
1272// `action_id` and emits one `RunTurn` per action, tagged with the
1273// session's current `run_id`. The recorder is tool-agnostic — it always
1274// records `tool` / `parameters` / `output` — and applies a thin, optional
1275// classifier for Bulldozer's `drive_cli` / `check_outcome` tools to fill
1276// `cli_outcome` / `verifier_verdict`. Those return-shape field names
1277// (`output_tail` / `exit_code` / `timed_out` / `passed`) are the agent's
1278// contract, not the runtime's; the classifier keys on them.
1279
1280/// How a CLI-driving action (e.g. Bulldozer's `drive_cli`) terminated.
1281///
1282/// `Exited { code }` is the normal case — the process ran and returned an
1283/// exit code. `Killed` is a signal death (the tool surfaced a `signal`
1284/// with no numeric `exit_code`). `Timeout` is the tool's own
1285/// `timed_out` flag. `KTD7`: this is one of the orthogonal, multi-valued
1286/// per-turn outcome fields — distinct from the run-level `OutcomeStatus`
1287/// — so a timed-out drive never gets mis-rendered as a run failure.
1288#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1289#[serde(tag = "kind", rename_all = "snake_case")]
1290pub enum CliOutcome {
1291    /// The process exited with a numeric code (0 = success).
1292    Exited { code: i64 },
1293    /// The process was killed by a signal (no numeric exit code).
1294    Killed,
1295    /// The tool reported `timed_out = true`.
1296    Timeout,
1297}
1298
1299/// The verifier verdict for a turn — Bulldozer's `check_outcome` result.
1300///
1301/// "Verifier" here is the agent's `check_outcome` tool result (its
1302/// `passed` field), NOT the runtime's static `verifyProposal` gate. A
1303/// turn with `Fail` is the healthy re-prod case (drove another turn),
1304/// not a run failure — KTD7 / R11. `NotRun` covers a turn that never
1305/// reached the verifier (a `drive_cli`-only turn, a timeout, or a
1306/// policy-rejected action).
1307#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1308#[serde(rename_all = "snake_case")]
1309pub enum VerifierVerdict {
1310    /// `check_outcome` returned `passed = true`.
1311    Pass,
1312    /// `check_outcome` returned `passed = false` (healthy re-prod — amber).
1313    Fail,
1314    /// The verifier did not run for this turn.
1315    NotRun,
1316}
1317
1318/// A policy rejection captured on a turn (R2 / R11). When an action is
1319/// `ActionStatus::Rejected`, the tool body never ran, so `cli_outcome`
1320/// is forced to `not-run` and the rejection is recorded here.
1321#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1322pub struct PolicyRejection {
1323    /// The rule that fired — the verbatim `ActionResult.error` string
1324    /// (e.g. `policy 'no-destructive': param 'prompt' matches 'rm -rf'`).
1325    pub rule: String,
1326    /// The blocked parameter name, best-effort extracted from the
1327    /// rejection reason (the `param '<name>'` token a `deny_tool_param`
1328    /// rejection carries). `None` when the reason has no param token.
1329    #[serde(default, skip_serializing_if = "Option::is_none")]
1330    pub param: Option<String>,
1331}
1332
1333/// One captured turn of a run — one action of one submitted proposal.
1334///
1335/// The recorder always fills `index` / `prompt` / `tool` / `parameters`
1336/// / `output` (tool-agnostic). `cli_outcome` / `verifier_verdict` /
1337/// `policy_rejected` are the thin Bulldozer classifier's enrichment and
1338/// are `None` / `NotRun` for any other tool. Multi-valued and orthogonal
1339/// to the run-level `OutcomeStatus` (KTD7).
1340#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
1341pub struct RunTurn {
1342    /// 0-based position of this action within the run's ordered turn
1343    /// stream (monotonic across proposals in the run).
1344    pub index: usize,
1345    /// The exact submitted proposal that produced this turn. Missing only
1346    /// for legacy or externally narrated turns that did not supply identity.
1347    #[serde(default, skip_serializing_if = "Option::is_none")]
1348    pub proposal_id: Option<String>,
1349    /// The exact submitted action that produced this turn. Missing only for
1350    /// legacy or externally narrated turns that did not supply identity.
1351    #[serde(default, skip_serializing_if = "Option::is_none")]
1352    pub action_id: Option<String>,
1353    /// Exact lifecycle status returned for this action. Missing when no
1354    /// matching result exists or for legacy/external turns that omit it.
1355    #[serde(default, skip_serializing_if = "Option::is_none")]
1356    pub action_status: Option<car_ir::ActionStatus>,
1357    /// Exact executor-reported duration for this action, in milliseconds.
1358    #[serde(default, skip_serializing_if = "Option::is_none")]
1359    pub action_duration_ms: Option<f64>,
1360    /// Exact executor result timestamp. This is a completion observation,
1361    /// not a synthesized start time.
1362    #[serde(default, skip_serializing_if = "Option::is_none")]
1363    pub action_completed_at: Option<DateTime<Utc>>,
1364    /// Exact predecessor action IDs from the executor's dependency graph.
1365    /// `Some([])` means a known root; `None` means dependency identity is
1366    /// unavailable (for example, a legacy journal entry).
1367    #[serde(default, skip_serializing_if = "Option::is_none")]
1368    pub depends_on: Option<Vec<String>>,
1369    /// The submitted action's state-dependency keys, verbatim. `Some([])`
1370    /// is explicitly known-empty; `None` means legacy/unknown metadata.
1371    #[serde(default, skip_serializing_if = "Option::is_none")]
1372    pub state_dependencies: Option<Vec<String>>,
1373    /// The prompt handed to the driven CLI — the action's `prompt`
1374    /// parameter when present (`drive_cli`). `None` for tools that take
1375    /// no `prompt`.
1376    #[serde(default, skip_serializing_if = "Option::is_none")]
1377    pub prompt: Option<String>,
1378    /// The tool name from the submitted action (`drive_cli`,
1379    /// `check_outcome`, or any other). `None` for non-`ToolCall` actions.
1380    #[serde(default, skip_serializing_if = "Option::is_none")]
1381    pub tool: Option<String>,
1382    /// The full action parameters as submitted — tool-agnostic capture so
1383    /// non-Bulldozer agents still get a usable trail.
1384    #[serde(default, skip_serializing_if = "Value::is_null")]
1385    pub parameters: Value,
1386    /// The tool's returned output value (the `ActionResult.output`).
1387    #[serde(default, skip_serializing_if = "Option::is_none")]
1388    pub output: Option<Value>,
1389    /// The classified CLI outcome for a `drive_cli` turn; `None` for
1390    /// non-driving tools.
1391    #[serde(default, skip_serializing_if = "Option::is_none")]
1392    pub cli_outcome: Option<CliOutcome>,
1393    /// The verifier verdict — `Pass`/`Fail` from a `check_outcome` turn,
1394    /// `NotRun` otherwise.
1395    pub verifier_verdict: VerifierVerdict,
1396    /// A policy rejection, when this action was `Rejected`.
1397    #[serde(default, skip_serializing_if = "Option::is_none")]
1398    pub policy_rejected: Option<PolicyRejection>,
1399}
1400
1401/// One durable run record. U1 ships `Started` / `Ended`; U2 adds the
1402/// per-turn `Turn` variant. Tagged on `record` so adding a variant is
1403/// forward-compatible on the wire.
1404#[derive(Debug, Clone, Serialize, Deserialize)]
1405#[serde(tag = "record", rename_all = "snake_case")]
1406pub enum RunRecord {
1407    Started(RunStarted),
1408    Ended(RunEnded),
1409    Turn(RunTurn),
1410    CancellationRequested(RunCancellationRequested),
1411    CancellationResult(RunCancelResponse),
1412}
1413
1414/// `runs.start` request params.
1415#[derive(Debug, Clone, Serialize, Deserialize)]
1416pub struct RunStartRequest {
1417    /// Owning agent id. Optional on the wire: when absent the daemon
1418    /// resolves from `session.auth {agent_id}`, then `CAR_AGENT_ID`,
1419    /// then falls back to a deterministic id synthesized from
1420    /// `agent_name`. With none of these available, `runs.start` is
1421    /// rejected.
1422    #[serde(default, skip_serializing_if = "Option::is_none")]
1423    pub agent_id: Option<String>,
1424    /// Agent display name — the one-shot fallback source for a
1425    /// deterministic `agent_id` when nothing else resolves.
1426    #[serde(default, skip_serializing_if = "Option::is_none")]
1427    pub agent_name: Option<String>,
1428    pub intent: String,
1429    #[serde(default, skip_serializing_if = "Option::is_none")]
1430    pub outcome_description: Option<String>,
1431    /// Optional caller-supplied run id for a globally reserved,
1432    /// same-WebSocket idempotent start. An exact retry on the connection that
1433    /// owns the live nonterminal run returns that binding. A terminal run, a
1434    /// run owned by another connection, or any durable occurrence from a
1435    /// prior connection is not adopted: `runs.start` returns JSON-RPC
1436    /// `-32010` (`run ownership conflict:`). After an unacknowledged start and
1437    /// disconnect, CAR releases the key only when no `RunStarted` boundary
1438    /// reached durable storage; otherwise it reconciles one historical
1439    /// `Incomplete` occurrence and keeps the original key owned. Absent ⇒ the
1440    /// daemon mints a fresh random `run_id`.
1441    #[serde(default, skip_serializing_if = "Option::is_none")]
1442    pub idempotency_key: Option<String>,
1443}
1444
1445/// `runs.start` response.
1446#[derive(Debug, Clone, Serialize, Deserialize)]
1447pub struct RunStartResponse {
1448    pub run_id: String,
1449    pub agent_id: String,
1450    /// Server-assigned authenticated WebSocket identity persisted on every
1451    /// active lifecycle journal event and durable boundary row.
1452    pub client_id: String,
1453}
1454
1455/// Strict `runs.resume` request. The caller supplies no owner identity or
1456/// secret: the daemon derives both from the authenticated WebSocket session.
1457#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1458#[serde(deny_unknown_fields)]
1459pub struct RunResumeRequest {
1460    pub run_id: String,
1461}
1462
1463/// `runs.resume` response naming the daemon-minted replacement socket and the
1464/// stale socket it atomically fenced.
1465#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1466pub struct RunResumeResponse {
1467    pub run_id: String,
1468    pub agent_id: String,
1469    pub client_id: String,
1470    pub resumed_from_client_id: String,
1471}
1472
1473/// `runs.complete` request params.
1474#[derive(Debug, Clone, Serialize, Deserialize)]
1475pub struct RunCompleteRequest {
1476    pub run_id: String,
1477    pub outcome: car_ir::AgentOutcome,
1478}
1479
1480/// `runs.complete` response.
1481#[derive(Debug, Clone, Serialize, Deserialize)]
1482pub struct RunCompleteResponse {
1483    pub run_id: String,
1484    pub ok: bool,
1485    /// Same canonical terminal digest persisted in `RunEnded` and emitted in
1486    /// the terminal `run_completed` journal event.
1487    pub completion_digest: String,
1488}
1489
1490/// `runs.record_turns` request params — a WS-only batch append of
1491/// client-narrated turns to a run the calling connection owns. The agent
1492/// builds full `RunTurn`s itself (out-of-pipeline capture: its work
1493/// happens inside its own subprocess, never through `proposal.submit`),
1494/// then pushes them here in batches. The daemon owns the turn `index`
1495/// (re-stamped under the `runs` lock — the client's `index` values are
1496/// ignored), the per-field/per-turn byte caps, the batch-size cap, and
1497/// the per-run turn ceiling; it appends through the same
1498/// `record_run_turns` path the proposal recorder uses, so persistence and
1499/// `runs.trace.event` fanout are identical. Turn content beyond size is
1500/// intentional pass-through — the daemon validates size and ownership,
1501/// never semantics.
1502#[derive(Debug, Clone, Serialize, Deserialize)]
1503pub struct RunRecordTurnsRequest {
1504    pub run_id: String,
1505    /// The batch of turns to append, in order. Each is appended as a
1506    /// [`RunRecord::Turn`]. On the wire a turn may omit `index` (the daemon
1507    /// owns it — re-stamped under the `runs` lock; any sent value is
1508    /// ignored) and `verifier_verdict` (defaults to `NotRun`). Must be
1509    /// non-empty.
1510    pub turns: Vec<RunTurn>,
1511}
1512
1513/// `runs.record_turns` response.
1514///
1515/// On a healthy append `ok` is `true`, `base_index` is the daemon-stamped
1516/// 0-based position of the FIRST turn in the batch, and `count` is the
1517/// number appended (the stamped indices are `base_index .. base_index +
1518/// count`). On a non-fatal rejection (`ok: false`) nothing is appended and
1519/// `dropped` carries the machine-readable reason the agent treats as
1520/// "stop sending for this run": `run_not_found` (no such run, or the
1521/// caller isn't entitled — uniform with the read path's not-found, never
1522/// an owner oracle), `run_terminal` (the run already reported / was swept
1523/// terminal), `run_turn_limit` (the per-run turn ceiling was reached), or
1524/// `turn_too_large` (a turn could not be bounded under the per-turn byte
1525/// cap even after its free-form fields were replaced — a misbehaving
1526/// client; the whole batch is dropped, never partially admitted).
1527#[derive(Debug, Clone, Serialize, Deserialize)]
1528pub struct RunRecordTurnsResponse {
1529    pub run_id: String,
1530    /// 0-based index of the first appended turn. `0` when nothing was
1531    /// appended (`ok: false`).
1532    pub base_index: usize,
1533    /// Number of turns appended. `0` when `ok: false`.
1534    pub count: usize,
1535    pub ok: bool,
1536    /// The machine-readable drop reason when `ok` is `false`; omitted on a
1537    /// healthy append.
1538    #[serde(default, skip_serializing_if = "Option::is_none")]
1539    pub dropped: Option<String>,
1540}
1541
1542// --- Live run-trace subscription (agent run tracing, U4) ---
1543//
1544// `runs.subscribe {run_id}` returns a snapshot of the run's turns so far
1545// plus a `cursor` (the count of records the snapshot covers), then the
1546// daemon pushes one `runs.trace.event` notification per record appended
1547// AFTER that cursor. The snapshot + the subscriber registration happen
1548// atomically under the same lock the recorder holds when it appends, so
1549// no record in the snapshot/register window is dropped (gap) or
1550// double-delivered (dup) — R7. The notification is WS-only (no FFI
1551// method); a CarHost re-issues `runs.subscribe {run_id}` after a
1552// reconnect and gap-fills via the cursor (R8). Authorization (R16):
1553// only the run's owning agent connection or the CarHost host-client may
1554// subscribe.
1555
1556/// Coarse live status of a run for the subscribe snapshot and each
1557/// `runs.trace.event`. Distinct from the run-level `OutcomeStatus`
1558/// carried inside a terminal `RunTermination::Outcome` — this is the
1559/// "is the run still open?" signal the live client folds into its view.
1560/// Mirrors `RunStore::RunStatus` but lives in `car-proto` so the wire
1561/// shape doesn't depend on the server-core crate.
1562#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1563#[serde(rename_all = "snake_case")]
1564pub enum RunLiveStatus {
1565    /// No terminal record yet — the run is still being written.
1566    InProgress,
1567    /// `runs.complete` reported a terminal `AgentOutcome`.
1568    Completed,
1569    /// The harness disconnected without reporting an outcome (R5).
1570    Incomplete,
1571    /// Cancellation was requested but CAR could not confirm controlled work
1572    /// stopped. The run is quarantined and remains nonterminal.
1573    CancellationPending,
1574    /// CAR durably confirmed cancellation.
1575    Cancelled,
1576}
1577
1578/// `runs.subscribe` request params.
1579#[derive(Debug, Clone, Serialize, Deserialize)]
1580pub struct RunSubscribeRequest {
1581    pub run_id: String,
1582    /// Zero-based turn cursor. Unlike `runs.get_trace`, lifecycle records do
1583    /// not advance this cursor.
1584    pub cursor: usize,
1585    /// Maximum turns returned by this catch-up page.
1586    pub limit: usize,
1587}
1588
1589/// `runs.subscribe` response — one bounded catch-up page before the live
1590/// `runs.trace.event` stream starts.
1591///
1592/// `turns` contains ordered `RunTurn` records beginning at `cursor`.
1593/// A partial page has `subscribed = false` and a `next_cursor`; the client
1594/// must request each continuation. The page that reaches `live_cursor`
1595/// atomically registers the subscriber and returns `subscribed = true`, so
1596/// turns appended at the catch-up boundary cannot be skipped. The
1597/// `RunStarted` data is already conveyed by `agent_id` + the request's
1598/// `run_id`, and the terminal disposition by `status`, so this response
1599/// carries turns only. Every record in `turns` is a `RunRecord::Turn`.
1600#[derive(Debug, Clone, Serialize, Deserialize)]
1601pub struct RunSubscribeResponse {
1602    pub run_id: String,
1603    pub agent_id: String,
1604    /// Ordered `RunRecord::Turn` records beginning at `cursor`.
1605    pub turns: Vec<RunRecord>,
1606    /// Echo of the requested turn cursor.
1607    pub cursor: usize,
1608    /// Echo of the applied page limit.
1609    pub limit: usize,
1610    /// Present only while more catch-up turns remain. The client must request
1611    /// that cursor before it can become live-subscribed.
1612    #[serde(default, skip_serializing_if = "Option::is_none")]
1613    pub next_cursor: Option<usize>,
1614    /// Exact turn-count boundary observed while this page was selected.
1615    pub live_cursor: usize,
1616    /// True only when the page reached `live_cursor` and registration happened
1617    /// atomically under the same run lock. Terminal traces use this as a
1618    /// caught-up marker but do not retain a future notification sink.
1619    pub subscribed: bool,
1620    pub status: RunLiveStatus,
1621}
1622
1623/// `runs.unsubscribe` request params.
1624#[derive(Debug, Clone, Serialize, Deserialize)]
1625pub struct RunUnsubscribeRequest {
1626    pub run_id: String,
1627}
1628
1629/// `runs.unsubscribe` response.
1630#[derive(Debug, Clone, Serialize, Deserialize)]
1631pub struct RunUnsubscribeResponse {
1632    pub run_id: String,
1633    /// `true` if a subscription for this `(connection, run_id)` existed
1634    /// and was removed; `false` if there was nothing to remove.
1635    pub removed: bool,
1636}
1637
1638/// One `runs.trace.event` server → client notification (agent run
1639/// tracing, U4). Pushed to every `(host_client, run_id)` subscriber.
1640///
1641/// `record` is the appended `RunRecord`:
1642/// - `Turn` — emitted after `record_run_turns` appends it to the run's
1643///   in-memory buffer, under the same lock that holds the snapshot/
1644///   register window closed (the gap/dup-free contract). `cursor` is the
1645///   run's turn count immediately AFTER this turn was appended (1-based),
1646///   so a subscriber at turn-cursor `n` expects the next `Turn` event's
1647///   `cursor` to be `n + 1` — a mismatch means a gap (re-subscribe, R8).
1648/// - `Started` — emitted on `runs.start`; a lifecycle marker. `cursor`
1649///   carries the run's current turn count (0 at start) and does not
1650///   advance the turn stream.
1651/// - `Ended` — emitted on `runs.complete` / disconnect-`Incomplete`;
1652///   carries the final turn count in `cursor` and the terminal `status`.
1653#[derive(Debug, Clone, Serialize, Deserialize)]
1654pub struct RunTraceEvent {
1655    pub run_id: String,
1656    pub agent_id: String,
1657    pub record: RunRecord,
1658    /// The run's turn count after this record was processed. Advances by
1659    /// one per `Turn`; unchanged on `Started`/`Ended`.
1660    pub cursor: usize,
1661    pub status: RunLiveStatus,
1662}
1663
1664// --- Replay read RPCs (agent run tracing, U5) ---
1665//
1666// `runs.list {agent_id}` and `runs.get_trace {run_id, cursor?}` are the
1667// WS-only replay reads CarHost uses to list an agent's runs and fetch a
1668// completed run's full trace. They read the disk store (`RunStore`), so
1669// they work after a daemon restart / `client_id` churn — `run_id` /
1670// `agent_id` are the durable keys. Both are authorization-gated (R16):
1671// `runs.list` authorizes the caller for `agent_id` first (the param is
1672// not a transparent key — an unentitled id is rejected, not enumerated);
1673// `runs.get_trace` resolves the run's owning `agent_id` from disk and
1674// authorizes against it.
1675//
1676// Only the *request* params are typed here. The responses are built
1677// inline in the handler (mirroring `agents.tail_log`'s `{ lines }` shape)
1678// because they carry the run-store's `RunSummary` type, which lives in
1679// `car-server-core` — `car-proto` must not depend on it. The exact JSON
1680// response shapes are documented in `docs/websocket-protocol.md` and
1681// asserted in `car-server-core/tests/run_trace_replay.rs`:
1682//
1683//   runs.list      → { agent_id, runs: [RunSummary] }   (newest-first)
1684//   runs.get_trace → { run_id, agent_id, records: [RunRecord], cursor }
1685//                    or { run_id, not_found: true } for an unknown run.
1686//
1687// `RunSummary` = { run_id, agent_id, intent, started_at, ended_at?,
1688// status, turn_count }; `RunRecord` is the tagged Started/Turn/Ended
1689// union defined above. The `cursor` echoes the request's `cursor` (0 when
1690// omitted) — the index `records` begins at, for paged fetches of large
1691// runs.
1692
1693/// `runs.list` request params — list an agent's runs newest-first.
1694#[derive(Debug, Clone, Serialize, Deserialize)]
1695pub struct RunListRequest {
1696    /// The agent whose runs to list. Authorization-gated (R16): the
1697    /// caller must own this agent (`session.auth {agent_id}`) or be the
1698    /// CarHost host-client. Not a transparent key — an unentitled id is
1699    /// rejected, never enumerated.
1700    pub agent_id: String,
1701    /// Opaque numeric keyset. `0` starts at the current newest row; a returned
1702    /// `next_cursor` resumes strictly after the prior page even if a new run is
1703    /// inserted at the head.
1704    pub cursor: usize,
1705    /// Maximum rows to return. Must be between 1 and the server hard maximum.
1706    pub limit: usize,
1707}
1708
1709/// `runs.get_trace` request params — fetch one bounded page of a run's ordered trace.
1710#[derive(Debug, Clone, Serialize, Deserialize)]
1711pub struct RunGetTraceRequest {
1712    /// The run to fetch. The owning `agent_id` is resolved from the disk
1713    /// store and the caller is authorized against it (R16).
1714    pub run_id: String,
1715    /// Optional start index into the run's ordered `RunRecord` stream —
1716    /// the first record returned. Omitted / `0` starts at the beginning;
1717    /// a non-zero cursor continues a large run from that offset. The
1718    /// response echoes the applied cursor.
1719    pub cursor: usize,
1720    /// Maximum records to return. Must be between 1 and the server hard maximum.
1721    pub limit: usize,
1722}
1723
1724// --- Response types ---
1725
1726#[derive(Debug, Clone, Serialize, Deserialize)]
1727pub struct SessionInitResponse {
1728    pub session_id: String,
1729    pub tools_registered: usize,
1730    pub policies_registered: usize,
1731}
1732
1733#[derive(Debug, Clone, Serialize, Deserialize)]
1734pub struct VerifyResponse {
1735    pub valid: bool,
1736    pub issues: Vec<VerifyIssueProto>,
1737    pub simulated_state: HashMap<String, Value>,
1738    /// Parallelizable execution batches (DAG levels), action IDs.
1739    /// Defaulted for backward-compatible deserialization of older
1740    /// daemons that omitted it.
1741    #[serde(default)]
1742    pub execution_levels: Vec<Vec<String>>,
1743    /// Undeclared write conflicts: (action1, action2, key).
1744    #[serde(default)]
1745    pub conflicts: Vec<(String, String, String)>,
1746    /// Evidence bundle: the verifier's declared scope — checks run,
1747    /// assumptions, untested regions, residual risks, coverage
1748    /// confidence (survey "Code as Agent Harness" §5.2.2). Carried as
1749    /// opaque JSON so car-proto stays decoupled from car-verify; shape
1750    /// mirrors `car_verify::VerificationEvidence`.
1751    #[serde(default)]
1752    pub evidence: Value,
1753}
1754
1755#[derive(Debug, Clone, Serialize, Deserialize)]
1756pub struct VerifyIssueProto {
1757    pub action_id: String,
1758    pub severity: String,
1759    pub message: String,
1760    /// Which kind of check produced the finding: `"decision_procedure"` |
1761    /// `"heuristic"` | `"sampled"` — the string form of
1762    /// `car_verify::EvidenceTier`, carried as a `String` so car-proto stays
1763    /// decoupled from car-verify (same reason `evidence` is an opaque `Value`).
1764    ///
1765    /// Orthogonal to `severity`, which says how bad the finding would be rather
1766    /// than how it was derived. Defaulted so a newer client can still
1767    /// deserialize an older daemon's response, where it arrives empty.
1768    #[serde(default)]
1769    pub tier: String,
1770}
1771
1772#[cfg(test)]
1773mod tests {
1774    use super::*;
1775
1776    #[test]
1777    fn tool_definition_roundtrip() {
1778        let td = ToolDefinition {
1779            name: "search".to_string(),
1780            description: "Search the web".to_string(),
1781            parameters: serde_json::json!({"type": "object", "properties": {"query": {"type": "string"}}}),
1782            returns: None,
1783            idempotent: false,
1784            cache_ttl_secs: None,
1785            rate_limit: None,
1786        };
1787        let json = serde_json::to_string(&td).unwrap();
1788        let rt: ToolDefinition = serde_json::from_str(&json).unwrap();
1789        assert_eq!(rt.name, "search");
1790    }
1791
1792    #[test]
1793    fn tool_definition_back_compat_pre_v05_clients() {
1794        // Pre-v0.5 clients only sent these three fields. The new
1795        // optional fields must default cleanly so the wire stays
1796        // backward-compatible.
1797        let legacy = r#"{"name":"read","description":"","parameters":{}}"#;
1798        let td: ToolDefinition = serde_json::from_str(legacy).unwrap();
1799        assert_eq!(td.name, "read");
1800        assert!(td.returns.is_none());
1801        assert!(!td.idempotent);
1802        assert!(td.cache_ttl_secs.is_none());
1803        assert!(td.rate_limit.is_none());
1804    }
1805
1806    #[test]
1807    fn tool_execute_request_roundtrip() {
1808        let req = ToolExecuteRequest {
1809            action_id: "a1".to_string(),
1810            tool: "search".to_string(),
1811            parameters: serde_json::json!({"query": "rust"}),
1812            timeout_ms: Some(5000),
1813            attempt: 1,
1814            request_id: "cb-7".to_string(),
1815            session_id: Some("sess-7".to_string()),
1816        };
1817        let json = serde_json::to_string(&req).unwrap();
1818        let rt: ToolExecuteRequest = serde_json::from_str(&json).unwrap();
1819        assert_eq!(rt.tool, "search");
1820        assert_eq!(rt.timeout_ms, Some(5000));
1821        assert_eq!(rt.request_id, "cb-7");
1822        assert_eq!(rt.session_id.as_deref(), Some("sess-7"));
1823    }
1824
1825    /// Parslee-ai/car#904 — the correlation field must be additive in both
1826    /// directions, because the daemon and the host upgrade independently.
1827    #[test]
1828    fn tool_execute_request_session_id_is_additive_both_ways() {
1829        // A pre-#904 daemon sends no `session_id`; a new host must still parse.
1830        let legacy =
1831            r#"{"action_id":"a1","tool":"x","parameters":{},"attempt":1,"request_id":"cb-1"}"#;
1832        let rt: ToolExecuteRequest = serde_json::from_str(legacy).unwrap();
1833        assert_eq!(rt.session_id, None);
1834
1835        // A sessionless caller must not put a null on the wire: `execute()` and
1836        // in-process executors legitimately have no session, and emitting
1837        // `"session_id": null` would make every such payload differ from the
1838        // pre-#904 shape for no gain.
1839        let sessionless = ToolExecuteRequest {
1840            action_id: "a1".to_string(),
1841            tool: "x".to_string(),
1842            parameters: serde_json::json!({}),
1843            timeout_ms: None,
1844            attempt: 1,
1845            request_id: "cb-1".to_string(),
1846            session_id: None,
1847        };
1848        let json = serde_json::to_string(&sessionless).unwrap();
1849        assert!(
1850            !json.contains("session_id"),
1851            "a sessionless call must omit the key entirely, got: {json}"
1852        );
1853    }
1854
1855    #[test]
1856    fn tool_execute_request_request_id_defaults_for_pre264_hosts() {
1857        // Pre-#264 payloads (no request_id) must still parse — the field
1858        // defaults to empty so an older host gets a usable callback, just
1859        // without the cancel correlation key.
1860        let legacy = r#"{"action_id":"a1","tool":"x","parameters":{},"attempt":1}"#;
1861        let rt: ToolExecuteRequest = serde_json::from_str(legacy).unwrap();
1862        assert_eq!(rt.request_id, "");
1863        assert_eq!(rt.tool, "x");
1864    }
1865
1866    #[test]
1867    fn tool_cancel_request_roundtrip() {
1868        let c = ToolCancelRequest {
1869            request_id: "cb-3".to_string(),
1870            action_id: "a2".to_string(),
1871            reason: "tool 'drive_cli' callback timed out (185s)".to_string(),
1872        };
1873        let json = serde_json::to_string(&c).unwrap();
1874        let rt: ToolCancelRequest = serde_json::from_str(&json).unwrap();
1875        assert_eq!(rt.request_id, "cb-3");
1876        assert_eq!(rt.action_id, "a2");
1877        assert!(rt.reason.contains("timed out"));
1878        // action_id + reason default when omitted (only request_id required).
1879        let minimal: ToolCancelRequest = serde_json::from_str(r#"{"request_id":"cb-9"}"#).unwrap();
1880        assert_eq!(minimal.request_id, "cb-9");
1881        assert_eq!(minimal.action_id, "");
1882        assert_eq!(minimal.reason, "");
1883    }
1884
1885    #[test]
1886    fn tool_execute_response_success() {
1887        let resp = ToolExecuteResponse {
1888            action_id: "a1".to_string(),
1889            output: Some(Value::from("results")),
1890            error: None,
1891            terminal: false,
1892        };
1893        let json = serde_json::to_string(&resp).unwrap();
1894        assert!(json.contains("results"));
1895        assert!(!json.contains("terminal"));
1896    }
1897
1898    #[test]
1899    fn tool_execute_response_error() {
1900        let resp = ToolExecuteResponse {
1901            action_id: "a1".to_string(),
1902            output: None,
1903            error: Some("timeout".to_string()),
1904            terminal: true,
1905        };
1906        let json = serde_json::to_string(&resp).unwrap();
1907        assert!(json.contains("timeout"));
1908        assert!(json.contains(r#""terminal":true"#));
1909
1910        let legacy: ToolExecuteResponse =
1911            serde_json::from_str(r#"{"action_id":"a1","error":"legacy callback failure"}"#)
1912                .unwrap();
1913        assert!(!legacy.terminal);
1914    }
1915
1916    #[test]
1917    fn session_init_request() {
1918        let req = SessionInitRequest {
1919            client_id: "client-1".to_string(),
1920            tools: vec![ToolDefinition {
1921                name: "read".to_string(),
1922                description: "Read file".to_string(),
1923                parameters: serde_json::json!({}),
1924                returns: None,
1925                idempotent: false,
1926                cache_ttl_secs: None,
1927                rate_limit: None,
1928            }],
1929            policies: vec![],
1930        };
1931        let json = serde_json::to_string(&req).unwrap();
1932        let rt: SessionInitRequest = serde_json::from_str(&json).unwrap();
1933        assert_eq!(rt.tools.len(), 1);
1934    }
1935
1936    #[test]
1937    fn verify_request() {
1938        let req = VerifyRequest {
1939            proposal: ActionProposal {
1940                id: "p1".to_string(),
1941                source: "test".to_string(),
1942                actions: vec![],
1943                timestamp: chrono::Utc::now(),
1944                context: HashMap::new(),
1945            },
1946            initial_state: [("x".to_string(), Value::from(1))].into(),
1947        };
1948        let json = serde_json::to_string(&req).unwrap();
1949        assert!(json.contains("p1"));
1950    }
1951
1952    #[test]
1953    fn run_start_request_resolves_optional_agent_id() {
1954        // The harness may omit agent_id (daemon resolves it) and may
1955        // supply agent_name as the one-shot fallback source.
1956        let wire = r#"{"intent":"ship the feature","agent_name":"Bulldozer"}"#;
1957        let req: RunStartRequest = serde_json::from_str(wire).unwrap();
1958        assert_eq!(req.intent, "ship the feature");
1959        assert_eq!(req.agent_id, None);
1960        assert_eq!(req.agent_name.as_deref(), Some("Bulldozer"));
1961        assert_eq!(req.outcome_description, None);
1962    }
1963
1964    #[test]
1965    fn run_record_started_ended_roundtrip() {
1966        let started = RunRecord::Started(RunStarted {
1967            run_id: "run-1".to_string(),
1968            client_id: Some("client-1".to_string()),
1969            agent_id: "agent-1".to_string(),
1970            intent: "do the thing".to_string(),
1971            outcome_description: Some("the thing is done".to_string()),
1972            started_at: chrono::Utc::now(),
1973        });
1974        let json = serde_json::to_string(&started).unwrap();
1975        // Tagged on `record` so U2 can add a `Turn` variant without
1976        // breaking the wire.
1977        assert!(json.contains("\"record\":\"started\""));
1978        let rt: RunRecord = serde_json::from_str(&json).unwrap();
1979        match rt {
1980            RunRecord::Started(s) => assert_eq!(s.run_id, "run-1"),
1981            other => panic!("expected Started, got {other:?}"),
1982        }
1983
1984        let ended = RunRecord::Ended(RunEnded {
1985            run_id: "run-1".to_string(),
1986            client_id: Some("client-1".to_string()),
1987            agent_id: "agent-1".to_string(),
1988            termination: RunTermination::Outcome {
1989                status: car_ir::OutcomeStatus::Success,
1990                outcome: car_ir::AgentOutcome::success("done"),
1991            },
1992            completion_digest: Some("abc123".to_string()),
1993            ended_at: chrono::Utc::now(),
1994        });
1995        let json = serde_json::to_string(&ended).unwrap();
1996        assert!(json.contains("\"record\":\"ended\""));
1997        assert!(json.contains("\"kind\":\"outcome\""));
1998        let rt: RunRecord = serde_json::from_str(&json).unwrap();
1999        match rt {
2000            RunRecord::Ended(e) => match e.termination {
2001                RunTermination::Outcome { status, .. } => {
2002                    assert_eq!(status, car_ir::OutcomeStatus::Success)
2003                }
2004                other => panic!("expected Outcome, got {other:?}"),
2005            },
2006            other => panic!("expected Ended, got {other:?}"),
2007        }
2008    }
2009
2010    #[test]
2011    fn run_termination_incomplete_serializes_distinctly() {
2012        let term = RunTermination::Incomplete;
2013        let json = serde_json::to_string(&term).unwrap();
2014        assert_eq!(json, r#"{"kind":"incomplete"}"#);
2015    }
2016
2017    #[test]
2018    fn historical_run_records_replay_without_client_binding_fields() {
2019        let started: RunRecord = serde_json::from_str(
2020            r#"{"record":"started","run_id":"old","agent_id":"agent","intent":"go","started_at":"2026-01-02T03:04:05Z"}"#,
2021        )
2022        .expect("historical started row");
2023        let ended: RunRecord = serde_json::from_str(
2024            r#"{"record":"ended","run_id":"old","agent_id":"agent","termination":{"kind":"incomplete"},"ended_at":"2026-01-02T03:05:05Z"}"#,
2025        )
2026        .expect("historical ended row");
2027
2028        match started {
2029            RunRecord::Started(row) => assert!(row.client_id.is_none()),
2030            other => panic!("expected started, got {other:?}"),
2031        }
2032        match ended {
2033            RunRecord::Ended(row) => {
2034                assert!(row.client_id.is_none());
2035                assert!(row.completion_digest.is_none());
2036            }
2037            other => panic!("expected ended, got {other:?}"),
2038        }
2039    }
2040
2041    #[test]
2042    fn cli_outcome_tagged_variants_roundtrip() {
2043        let exited = CliOutcome::Exited { code: 0 };
2044        let json = serde_json::to_string(&exited).unwrap();
2045        assert_eq!(json, r#"{"kind":"exited","code":0}"#);
2046        assert_eq!(serde_json::from_str::<CliOutcome>(&json).unwrap(), exited);
2047
2048        assert_eq!(
2049            serde_json::to_string(&CliOutcome::Killed).unwrap(),
2050            r#"{"kind":"killed"}"#
2051        );
2052        assert_eq!(
2053            serde_json::to_string(&CliOutcome::Timeout).unwrap(),
2054            r#"{"kind":"timeout"}"#
2055        );
2056        assert_eq!(
2057            serde_json::from_str::<CliOutcome>(r#"{"kind":"timeout"}"#).unwrap(),
2058            CliOutcome::Timeout
2059        );
2060    }
2061
2062    #[test]
2063    fn verifier_verdict_serializes_snake_case() {
2064        assert_eq!(
2065            serde_json::to_string(&VerifierVerdict::Pass).unwrap(),
2066            r#""pass""#
2067        );
2068        assert_eq!(
2069            serde_json::to_string(&VerifierVerdict::Fail).unwrap(),
2070            r#""fail""#
2071        );
2072        assert_eq!(
2073            serde_json::to_string(&VerifierVerdict::NotRun).unwrap(),
2074            r#""not_run""#
2075        );
2076        assert_eq!(
2077            serde_json::from_str::<VerifierVerdict>(r#""not_run""#).unwrap(),
2078            VerifierVerdict::NotRun
2079        );
2080    }
2081
2082    #[test]
2083    fn policy_rejection_omits_none_param() {
2084        let pr = PolicyRejection {
2085            rule: "policy 'x': denied".to_string(),
2086            param: None,
2087        };
2088        let json = serde_json::to_string(&pr).unwrap();
2089        assert!(
2090            !json.contains("param"),
2091            "None param must be omitted: {json}"
2092        );
2093        let with_param = PolicyRejection {
2094            rule: "policy 'x': param 'prompt' matches 'rm -rf'".to_string(),
2095            param: Some("prompt".to_string()),
2096        };
2097        let json = serde_json::to_string(&with_param).unwrap();
2098        assert!(json.contains("\"param\":\"prompt\""));
2099        assert_eq!(
2100            serde_json::from_str::<PolicyRejection>(&json).unwrap(),
2101            with_param
2102        );
2103    }
2104
2105    #[test]
2106    fn run_record_turn_variant_roundtrip() {
2107        // The `turn` variant must serialize under the same `record` tag as
2108        // Started/Ended so U3/U4 consume one ordered RunRecord stream.
2109        let turn = RunRecord::Turn(RunTurn {
2110            index: 0,
2111            proposal_id: None,
2112            action_id: None,
2113            action_status: None,
2114            action_duration_ms: None,
2115            action_completed_at: None,
2116            depends_on: None,
2117            state_dependencies: None,
2118            prompt: Some("make the test pass".to_string()),
2119            tool: Some("drive_cli".to_string()),
2120            parameters: serde_json::json!({ "cli": "claude", "prompt": "make the test pass" }),
2121            output: Some(serde_json::json!({ "exit_code": 0, "output_tail": "done" })),
2122            cli_outcome: Some(CliOutcome::Exited { code: 0 }),
2123            verifier_verdict: VerifierVerdict::NotRun,
2124            policy_rejected: None,
2125        });
2126        let json = serde_json::to_string(&turn).unwrap();
2127        assert!(
2128            json.contains("\"record\":\"turn\""),
2129            "turn must tag on `record`: {json}"
2130        );
2131        match serde_json::from_str::<RunRecord>(&json).unwrap() {
2132            RunRecord::Turn(t) => {
2133                assert_eq!(t.index, 0);
2134                assert_eq!(t.tool.as_deref(), Some("drive_cli"));
2135                assert_eq!(t.cli_outcome, Some(CliOutcome::Exited { code: 0 }));
2136                assert_eq!(t.verifier_verdict, VerifierVerdict::NotRun);
2137            }
2138            other => panic!("expected Turn, got {other:?}"),
2139        }
2140    }
2141
2142    #[test]
2143    fn run_turn_minimal_omits_optional_fields() {
2144        // A generic, non-Bulldozer turn: no prompt, no cli/verifier
2145        // classification, no rejection — only the always-present fields
2146        // serialize plus the required verifier_verdict.
2147        let turn = RunTurn {
2148            index: 3,
2149            proposal_id: None,
2150            action_id: None,
2151            action_status: None,
2152            action_duration_ms: None,
2153            action_completed_at: None,
2154            depends_on: None,
2155            state_dependencies: None,
2156            prompt: None,
2157            tool: Some("search".to_string()),
2158            parameters: serde_json::json!({ "query": "rust" }),
2159            output: Some(Value::from("results")),
2160            cli_outcome: None,
2161            verifier_verdict: VerifierVerdict::NotRun,
2162            policy_rejected: None,
2163        };
2164        let json = serde_json::to_string(&turn).unwrap();
2165        assert!(!json.contains("prompt"));
2166        assert!(!json.contains("cli_outcome"));
2167        assert!(!json.contains("policy_rejected"));
2168        assert!(json.contains("\"verifier_verdict\":\"not_run\""));
2169        let rt: RunTurn = serde_json::from_str(&json).unwrap();
2170        assert_eq!(rt, turn);
2171    }
2172
2173    #[test]
2174    fn run_live_status_roundtrip() {
2175        // snake_case wire form the live subscribe/event share with the
2176        // store's RunStatus.
2177        assert_eq!(
2178            serde_json::to_string(&RunLiveStatus::InProgress).unwrap(),
2179            "\"in_progress\""
2180        );
2181        assert_eq!(
2182            serde_json::from_str::<RunLiveStatus>("\"completed\"").unwrap(),
2183            RunLiveStatus::Completed
2184        );
2185        assert_eq!(
2186            serde_json::from_str::<RunLiveStatus>("\"incomplete\"").unwrap(),
2187            RunLiveStatus::Incomplete
2188        );
2189    }
2190
2191    #[test]
2192    fn run_trace_event_wraps_record_and_cursor() {
2193        // The live notification carries the appended record plus the
2194        // post-append turn cursor and the run's live status.
2195        let ev = RunTraceEvent {
2196            run_id: "run-1".to_string(),
2197            agent_id: "agent-a".to_string(),
2198            record: RunRecord::Turn(RunTurn {
2199                index: 4,
2200                proposal_id: None,
2201                action_id: None,
2202                action_status: None,
2203                action_duration_ms: None,
2204                action_completed_at: None,
2205                depends_on: None,
2206                state_dependencies: None,
2207                prompt: Some("fix it".to_string()),
2208                tool: Some("drive_cli".to_string()),
2209                parameters: serde_json::json!({ "prompt": "fix it" }),
2210                output: Some(serde_json::json!({ "exit_code": 0 })),
2211                cli_outcome: Some(CliOutcome::Exited { code: 0 }),
2212                verifier_verdict: VerifierVerdict::NotRun,
2213                policy_rejected: None,
2214            }),
2215            cursor: 5,
2216            status: RunLiveStatus::InProgress,
2217        };
2218        let json = serde_json::to_string(&ev).unwrap();
2219        let back: RunTraceEvent = serde_json::from_str(&json).unwrap();
2220        assert_eq!(back.run_id, "run-1");
2221        assert_eq!(back.cursor, 5);
2222        assert_eq!(back.status, RunLiveStatus::InProgress);
2223        match back.record {
2224            RunRecord::Turn(t) => assert_eq!(t.index, 4),
2225            other => panic!("expected Turn, got {other:?}"),
2226        }
2227    }
2228
2229    #[test]
2230    fn run_subscribe_response_turns_only_snapshot() {
2231        let resp = RunSubscribeResponse {
2232            run_id: "run-1".to_string(),
2233            agent_id: "agent-a".to_string(),
2234            turns: vec![RunRecord::Turn(RunTurn {
2235                index: 0,
2236                proposal_id: None,
2237                action_id: None,
2238                action_status: None,
2239                action_duration_ms: None,
2240                action_completed_at: None,
2241                depends_on: None,
2242                state_dependencies: None,
2243                prompt: None,
2244                tool: Some("drive_cli".to_string()),
2245                parameters: Value::Null,
2246                output: None,
2247                cli_outcome: None,
2248                verifier_verdict: VerifierVerdict::NotRun,
2249                policy_rejected: None,
2250            })],
2251            cursor: 0,
2252            limit: 100,
2253            next_cursor: None,
2254            live_cursor: 1,
2255            subscribed: true,
2256            status: RunLiveStatus::InProgress,
2257        };
2258        let json = serde_json::to_string(&resp).unwrap();
2259        let back: RunSubscribeResponse = serde_json::from_str(&json).unwrap();
2260        assert_eq!(back.cursor, 0);
2261        assert_eq!(back.live_cursor, 1);
2262        assert!(back.subscribed);
2263        assert_eq!(back.turns.len(), 1);
2264        assert!(matches!(back.turns[0], RunRecord::Turn(_)));
2265    }
2266
2267    // FIX 1: the canonical harness builds the outcome itself and sends
2268    // `{status, summary, evidence, metrics, tools_called}` with NO `timestamp`
2269    // and an EXTRA `tools_called` field. RunCompleteRequest.outcome must
2270    // deserialize this shape, otherwise `runs.complete` fails and the run is
2271    // never marked ended (recorded Incomplete).
2272    #[test]
2273    fn run_complete_request_accepts_harness_outcome_shape() {
2274        let req_json = serde_json::json!({
2275            "run_id": "r1",
2276            "outcome": {
2277                "status": "success",
2278                "summary": "Created file",
2279                "evidence": [],
2280                "metrics": {
2281                    "turns": 3,
2282                    "tool_calls": 3,
2283                    "actions_succeeded": 3,
2284                    "actions_failed": 0
2285                },
2286                "tools_called": ["drive_cli", "check_outcome", "finish"]
2287            }
2288        });
2289
2290        let req: RunCompleteRequest =
2291            serde_json::from_value(req_json).expect("harness outcome shape must deserialize");
2292        assert_eq!(req.run_id, "r1");
2293        assert_eq!(req.outcome.status, car_ir::OutcomeStatus::Success);
2294        assert_eq!(req.outcome.summary, "Created file");
2295        assert_eq!(req.outcome.metrics.turns, 3);
2296        assert_eq!(req.outcome.metrics.tool_calls, 3);
2297        assert_eq!(req.outcome.metrics.actions_succeeded, 3);
2298        assert_eq!(req.outcome.metrics.actions_failed, 0);
2299        // omitted metrics fields default to zero
2300        assert_eq!(req.outcome.metrics.duration_ms, 0.0);
2301        assert_eq!(req.outcome.metrics.retries, 0);
2302    }
2303
2304    #[test]
2305    fn run_cancel_contract_is_strict_and_capability_gated() {
2306        let negotiated = negotiate_capabilities(
2307            &[RUNS_CANCEL_CAPABILITY.to_string()],
2308            &[RUNS_PAGINATION_CAPABILITY.to_string()],
2309        )
2310        .unwrap();
2311        assert_eq!(
2312            negotiated,
2313            vec![
2314                RUNS_CANCEL_CAPABILITY.to_string(),
2315                RUNS_PAGINATION_CAPABILITY.to_string()
2316            ]
2317        );
2318        let request: RunCancelRequest = serde_json::from_value(serde_json::json!({
2319            "run_id":"run-1","idempotency_key":"cancel-1","reason":"operator stop"
2320        }))
2321        .unwrap();
2322        assert_eq!(request.reason, "operator stop");
2323        assert!(
2324            serde_json::from_value::<RunCancelRequest>(serde_json::json!({
2325                "run_id":"run-1","idempotency_key":"cancel-1","reason":"stop","extra":true
2326            }))
2327            .is_err()
2328        );
2329    }
2330
2331    #[test]
2332    fn cancelled_termination_and_body_free_records_round_trip() {
2333        let identity = RunCancellationIdentity {
2334            receipt_version: 1,
2335            run_id: "run-1".into(),
2336            idempotency_key: "cancel-1".into(),
2337            reason_digest: "a".repeat(64),
2338            principal: "agent:daily-continuity-newsroom".into(),
2339            action_id: Some("editor".into()),
2340            request_id: Some("cb-7".into()),
2341        };
2342        let ended = RunRecord::Ended(RunEnded {
2343            run_id: "run-1".into(),
2344            client_id: Some("client-1".into()),
2345            agent_id: "daily-continuity-newsroom".into(),
2346            termination: RunTermination::Cancelled {
2347                cancellation: identity,
2348            },
2349            completion_digest: Some("b".repeat(64)),
2350            ended_at: Utc::now(),
2351        });
2352        let json = serde_json::to_string(&ended).unwrap();
2353        assert!(json.contains("\"kind\":\"cancelled\""));
2354        assert!(!json.contains("operator stop"));
2355        assert!(matches!(
2356            serde_json::from_str::<RunRecord>(&json).unwrap(),
2357            RunRecord::Ended(RunEnded {
2358                termination: RunTermination::Cancelled { .. },
2359                ..
2360            })
2361        ));
2362    }
2363}