trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
//! Daemon domain error type.
//!
//! Why: the HTTP handlers previously returned bare `StatusCode`s, which buries
//! the *reason* a request failed at the call site and forces every handler to
//! repeat the `.ok_or(StatusCode::NOT_FOUND)` boilerplate. A single domain
//! error enum lets the domain services (in `services/`) speak in terms of what
//! went wrong — a missing session, a blocked tool call — while one
//! `IntoResponse` impl maps each variant to the right HTTP status. Business
//! logic stays HTTP-agnostic; the transport mapping lives in one place.
//! What: [`DaemonError`] enumerates every way a daemon request can fail, with a
//! `thiserror`-derived `Display`, an `axum::IntoResponse` that picks the status
//! code and renders a `{ "error": <message> }` body, and — since #6288 — a
//! `From<DaemonError> for RpcError` that picks the JSON-RPC error code for the
//! same failure on the Unix socket. Both transports read the SAME variant, so a
//! route served over HTTP and over the socket cannot disagree about what went
//! wrong.
//! Test: `error_status_codes_map` asserts the variant → status mapping;
//! `rpc_error_codes_track_http_statuses` asserts the variant → code mapping.

use axum::Json;
use axum::http::StatusCode;
use axum::response::{IntoResponse, Response};
use thiserror::Error;
use trusty_common::uds::server::{CODE_INTERNAL_ERROR, CODE_INVALID_PARAMS, RpcError};

// ---- JSON-RPC error codes for the daemon's non-standard failure kinds -------
//
// Why these are declared here rather than in `trusty_common::uds::server::wire`:
// that module owns the codes JSON-RPC itself reserves (-32700..-32600) plus the
// two streaming codes #6286 added. Everything below is a DOMAIN failure kind
// with no JSON-RPC counterpart, so it lives beside the enum it describes.
//
// The numbers are not arbitrary. -32004 and -32005 are already spent by
// `trusty-analyze` (`service::events::CODE_NOT_FOUND`, `CODE_DEADLINE_EXCEEDED`)
// and -32010/-32011 by `trusty-common`'s streaming pair, so #6288 takes free
// slots around them and reuses -32004 for the SAME meaning — a later
// consolidation into `trusty-common` is then a move, not a renumbering.
// Test: `rpc_error_codes_track_http_statuses`.

/// HTTP 404 over the socket — the same code `trusty-analyze` uses.
pub const CODE_NOT_FOUND: i64 = -32004;

/// HTTP 503 over the socket: a capability this daemon is not configured for.
pub const CODE_UNAVAILABLE: i64 = -32002;

/// HTTP 403 over the socket: a guard rejected the request.
pub const CODE_FORBIDDEN: i64 = -32003;

/// HTTP 422 over the socket: well-formed but un-fulfillable.
pub const CODE_UNPROCESSABLE: i64 = -32006;

/// HTTP 409 over the socket: the request conflicts with the record's state.
pub const CODE_CONFLICT: i64 = -32009;

/// HTTP 502 over the socket: an upstream this daemon called answered badly.
///
/// #6288: distinct from [`CODE_UNAVAILABLE`], which says the daemon has no
/// provider configured at all. This one says a configured provider was called
/// and the call failed, so the caller's retry advice differs.
pub const CODE_UPSTREAM_FAILED: i64 = -32007;

/// HTTP 410 over the socket: the addressed resource existed and is now gone.
///
/// #6288: the peer bus's instance-bypass failure ([`BusError::InstanceGone`])
/// is the only route in this daemon that distinguishes "gone" from "never
/// existed", and DOC-60 §4 requires the sender to be able to tell them apart
/// without parsing a message — so the distinction has to survive the socket.
///
/// [`BusError::InstanceGone`]: crate::daemon::bus::BusError::InstanceGone
pub const CODE_GONE: i64 = -32008;

/// A resume refused because the workspace directory is gone (#6288 slice 4).
///
/// Why its own code rather than plain [`CODE_UNPROCESSABLE`]: on HTTP the two
/// 422 resume classes are told apart by an `x-trusty-resume-reason` header, and
/// they need DIFFERENT operator remedies — a vanished workspace is safe to
/// `tm session delete --force`, a vanished pane is not, because a live sibling
/// tmux window may still hold work. The socket has no headers, so the
/// discriminant becomes the code rather than being dropped or smuggled into the
/// message. See `managed_routes::resume_error`.
pub const CODE_WORKSPACE_GONE: i64 = -32023;

/// A resume refused because the recorded tmux pane is gone (#6288 slice 4). See
/// [`CODE_WORKSPACE_GONE`] for why the two 422 classes carry distinct codes.
pub const CODE_PANE_GONE: i64 = -32024;

/// The JSON-RPC code an HTTP status projects onto (#6288).
///
/// Why: slice 2 wrote this table inside `From<DaemonError> for RpcError`, and
/// slice 4's route bodies — which report a status without ever building a
/// [`DaemonError`] — needed the identical mapping. Two copies of it would let
/// the same status answer two different codes depending on which family served
/// the route, which is the exact drift the shared-body rule exists to prevent.
/// Extracting it leaves ONE table with two callers.
/// What: takes a raw `u16` rather than a `StatusCode` so callers under
/// `daemon::rpc` can use it without importing an HTTP type — the acceptance bar
/// for slice 4 is that no HTTP type appears there.
/// Test: `rpc_error_codes_track_http_statuses`,
/// `status_to_rpc_code_maps_each_refusal_class`.
pub fn rpc_code_for_status(status: u16) -> i64 {
    match status {
        400 => CODE_INVALID_PARAMS,
        403 => CODE_FORBIDDEN,
        404 => CODE_NOT_FOUND,
        409 => CODE_CONFLICT,
        422 => CODE_UNPROCESSABLE,
        // #6288 slice 5: the manager surface is the first to report "a
        // configured provider answered badly", so 502 stops falling through to
        // 500. See `DaemonError::UpstreamFailed`.
        502 => CODE_UPSTREAM_FAILED,
        503 => CODE_UNAVAILABLE,
        // Everything left maps to 500, and a caller could not have sent any of
        // them differently.
        _ => CODE_INTERNAL_ERROR,
    }
}

/// A failure surfaced by a daemon domain service or request handler.
///
/// Why: domain services must report *why* an operation failed without knowing
/// they are behind HTTP; a typed enum keeps the failure self-describing and
/// lets `?` propagate it cleanly from a service into a handler.
/// What: one variant per failure mode the daemon can produce; each carries the
/// context needed to render an operator-facing message.
/// Test: `error_status_codes_map`, `error_messages_include_context`.
#[derive(Debug, Error)]
pub enum DaemonError {
    /// No session matched the supplied id or friendly name.
    #[error("session not found: {id}")]
    SessionNotFound {
        /// The id or name that failed to resolve.
        id: String,
    },

    /// The session exists but is in a state that forbids the operation.
    #[error("session not active: {id} (status: {status})")]
    SessionNotActive {
        /// The session id or name.
        id: String,
        /// The session's current status, lowercased.
        status: String,
    },

    /// The overseer halted the request before it could proceed.
    #[error("overseer blocked: {reason}")]
    OverseerBlocked {
        /// The overseer's stated reason for the block.
        reason: String,
    },

    /// A tmux operation could not be completed.
    #[error("tmux unavailable: {0}")]
    TmuxUnavailable(String),

    /// The request body or parameters were malformed.
    #[error("invalid request: {0}")]
    InvalidRequest(String),

    /// No checkpoint matched the supplied id.
    #[error("checkpoint not found: {id}")]
    CheckpointNotFound {
        /// The checkpoint id that failed to resolve.
        id: String,
    },

    /// No Deliverable matched the supplied id (within the named project).
    ///
    /// Why: the Deliverable CRUD routes (#2378) need a 404 distinct from the
    /// session/checkpoint not-founds so the error message names the right
    /// resource.
    /// What: carries the id (or malformed id string); maps to HTTP 404.
    /// Test: `error_status_codes_map`, and the deliverable route tests.
    #[error("deliverable not found: {id}")]
    DeliverableNotFound {
        /// The Deliverable id that failed to resolve.
        id: String,
    },

    /// No Milestone matched the supplied id (within the named project).
    #[error("milestone not found: {id}")]
    MilestoneNotFound {
        /// The Milestone id that failed to resolve.
        id: String,
    },

    /// No registered project matches the given `repo_url`, so a Deliverable
    /// id cannot be scoped against it (#2379 review MEDIUM).
    ///
    /// Why: distinct from [`DeliverableNotFound`](Self::DeliverableNotFound) —
    /// the Deliverable itself may well exist; what's missing is the PROJECT
    /// identity to scope it against. Returning `DeliverableNotFound` here
    /// would misleadingly imply the id itself was bad, when the actual gap is
    /// "no project is registered for this repo at all".
    /// What: carries the `repo_url` that failed to resolve; maps to HTTP 404
    /// (there is genuinely no project resource for this identity).
    /// Test: `error_status_codes_map`,
    /// `validate_deliverable_scope_unknown_project_is_404`, and
    /// `project_not_found_message_redacts_the_url_credentials`.
    // #9124: the message reaches the HTTP body and the log; never a token.
    #[error(
        "no registered project matches repo_url {:?}; cannot scope deliverable",
        crate::core::remote_url_redact::redact_stored_url(.repo_url)
    )]
    ProjectNotFoundForRepoUrl {
        /// The `repo_url` that did not resolve to any registered project.
        repo_url: crate::core::remote_url_redact::RedactedUrl,
    },

    /// A requested Deliverable status change is not a legal transition (#2380).
    ///
    /// Why: the §10.3 state machine rejects illegal `set-status` requests (e.g.
    /// `proposed → complete`). A dedicated variant lets the error body name the
    /// legal next states so the caller can self-correct, and maps to 409 Conflict
    /// (the requested change conflicts with the record's current state) — the same
    /// class as [`SessionNotActive`](Self::SessionNotActive).
    /// What: carries the `from`/`to` labels and the legal `allowed` next states;
    /// [`into_response`](Self::into_response) surfaces `allowed` as a JSON array.
    /// Test: `error_status_codes_map`, `invalid_transition_body_lists_allowed`.
    #[error(
        "invalid status transition {from} \u{2192} {to}; legal next states from {from}: [{}]",
        allowed.join(", ")
    )]
    InvalidTransition {
        /// The Deliverable's current status.
        from: String,
        /// The requested (rejected) target status.
        to: String,
        /// The states that would have been legal transitions from `from`.
        allowed: Vec<String>,
    },

    /// A bot pairing code was wrong or had expired.
    #[error("pair code invalid or expired")]
    InvalidPairCode,

    /// An unexpected internal failure (IO, serialization, ...).
    #[error("internal error: {0}")]
    Internal(String),

    /// A requested capability is not configured on this daemon.
    #[error("service unavailable: {0}")]
    ServiceUnavailable(String),

    /// The request was rejected by a security guard (e.g. the cross-origin CSRF
    /// guard on the browser-facing, action-capable chat endpoint).
    ///
    /// Why: distinct from [`OverseerBlocked`](Self::OverseerBlocked) (which is a
    /// policy decision about a *session*), this is a transport-level rejection of
    /// the *request itself* — it never reached business logic. A dedicated variant
    /// keeps the operator message self-describing and maps cleanly to 403.
    /// What: carries a human-readable reason; maps to HTTP 403 Forbidden.
    /// Test: `error_status_codes_map`, and the origin-guard handler tests.
    #[error("forbidden: {0}")]
    Forbidden(String),

    /// A resource of a kind with no dedicated variant above was not found.
    ///
    /// Why (#6288): the `/claude-config/*` routes 404 on an unknown
    /// recommendation id and an unknown profile name. Both used to be a bare
    /// `StatusCode::NOT_FOUND` returned straight from the handler, which
    /// carried no message. Sharing one body between HTTP and RPC means the
    /// failure has to be a typed value, and neither resource is a session, a
    /// checkpoint, a deliverable, or a milestone.
    /// What: carries the operator-facing message; maps to HTTP 404 and to
    /// [`CODE_NOT_FOUND`](crate::daemon::error::CODE_NOT_FOUND) on the socket.
    /// Test: `error_status_codes_map`, `rpc_error_codes_track_http_statuses`.
    #[error("not found: {0}")]
    NotFound(String),

    /// The request is syntactically valid but semantically un-fulfillable.
    ///
    /// Why: a precondition the daemon cannot satisfy (e.g. `POST /sessions`
    /// in spawn mode when the `claude` binary is missing from `PATH`) is not
    /// a malformed request (`400`) and not a missing resource (`404`) — it
    /// maps to HTTP 422 Unprocessable Entity, which exactly describes
    /// "well-formed but unprocessable".
    /// What: carries a human-readable reason.
    /// Test: `error_status_codes_map`, `spawn_session_without_claude_returns_422`.
    #[error("unprocessable: {0}")]
    Unprocessable(String),

    /// An upstream service this daemon called answered badly.
    ///
    /// Why (#6288 slice 5): the L3 manager's digest, chat, and route-task
    /// routes call a configured inference provider and return HTTP 502 when
    /// that call errors or returns an empty body. That is a different fact
    /// from [`ServiceUnavailable`](Self::ServiceUnavailable) (503, "no provider
    /// is configured"), and a caller's correct reaction differs: retry a 502,
    /// configure a provider for a 503. Before this variant the distinction
    /// lived only in the handlers' own `StatusCode` literals, which a shared
    /// transport-neutral body cannot carry — so serving those routes over the
    /// socket needed the class to become a value.
    /// What: carries the operator-facing reason; maps to HTTP 502 and to
    /// [`CODE_UPSTREAM_FAILED`] on the socket.
    /// Test: `error_status_codes_map`, `rpc_error_codes_track_http_statuses`.
    #[error("upstream failed: {0}")]
    UpstreamFailed(String),
}

impl DaemonError {
    /// The HTTP status this error maps to.
    ///
    /// Why: the `IntoResponse` impl and any caller inspecting the error (tests,
    /// the MCP backend) need the status without re-deriving the mapping.
    /// What: returns the canonical status per variant.
    /// Test: `error_status_codes_map`.
    pub fn status(&self) -> StatusCode {
        match self {
            Self::SessionNotFound { .. }
            | Self::CheckpointNotFound { .. }
            | Self::DeliverableNotFound { .. }
            | Self::MilestoneNotFound { .. }
            | Self::ProjectNotFoundForRepoUrl { .. }
            | Self::NotFound(_) => StatusCode::NOT_FOUND,
            Self::SessionNotActive { .. } | Self::InvalidTransition { .. } => StatusCode::CONFLICT,
            Self::OverseerBlocked { .. } | Self::Forbidden(_) => StatusCode::FORBIDDEN,
            Self::InvalidRequest(_) | Self::InvalidPairCode => StatusCode::BAD_REQUEST,
            Self::TmuxUnavailable(_) | Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
            Self::ServiceUnavailable(_) => StatusCode::SERVICE_UNAVAILABLE,
            Self::Unprocessable(_) => StatusCode::UNPROCESSABLE_ENTITY,
            Self::UpstreamFailed(_) => StatusCode::BAD_GATEWAY,
        }
    }

    /// The message WITHOUT the variant's `Display` prefix.
    ///
    /// Why (#6288 slice 5): several routes moving onto the socket answer HTTP
    /// with a bare plain-text body — `project foo not found`, not
    /// `{"error":"not found: project foo not found"}`. Their bodies now live in
    /// a shared `*_op` that reports a [`DaemonError`], and the HTTP delegator
    /// has to reproduce the old body byte for byte, which `Display` cannot do:
    /// it prepends the variant label. This returns just the payload, so a
    /// delegator can render `(status, detail)` and leave the wire unchanged.
    /// What: the carried string for the single-field variants; `to_string()`
    /// for the structured ones, which have no single payload to return.
    /// Test: `detail_drops_the_display_prefix`.
    pub fn detail(&self) -> String {
        match self {
            Self::TmuxUnavailable(m)
            | Self::InvalidRequest(m)
            | Self::Internal(m)
            | Self::ServiceUnavailable(m)
            | Self::Forbidden(m)
            | Self::NotFound(m)
            | Self::Unprocessable(m)
            | Self::UpstreamFailed(m) => m.clone(),
            other => other.to_string(),
        }
    }
}

/// Render a [`DaemonError`] as an HTTP response.
///
/// Why: axum handlers return `Result<_, DaemonError>`; this is the single seam
/// that turns a domain failure into a status code plus a JSON error body.
/// What: emits `(status, Json({ "error": <message> }))`.
/// Test: exercised indirectly by every handler test that asserts an error
/// status; the mapping itself is unit-tested in `error_status_codes_map`.
impl IntoResponse for DaemonError {
    fn into_response(self) -> Response {
        let status = self.status();
        // #2380: an invalid transition surfaces the legal next states as a
        // structured `allowed_next` array so the caller can self-correct without
        // parsing the message string.
        let body = match &self {
            Self::InvalidTransition { from, to, allowed } => Json(serde_json::json!({
                "error": self.to_string(),
                "from": from,
                "to": to,
                "allowed_next": allowed,
            })),
            _ => Json(serde_json::json!({ "error": self.to_string() })),
        };
        (status, body).into_response()
    }
}

/// Map a daemon failure onto the JSON-RPC error frame a socket client reads.
///
/// Why (#6288): `RpcRouter::typed` takes `Result<Resp, RpcError>`, and every
/// route this slice moves onto the socket already reports its failures as a
/// [`DaemonError`]. Without this conversion each RPC registration would spell
/// its own status-to-code table, and the HTTP status and the RPC code would be
/// free to drift apart for the same variant.
/// What: derives the code from [`DaemonError::status`], so the code is a pure
/// function of the status the HTTP transport would have sent. The message
/// crosses verbatim — it is the same string the HTTP body's `error` field
/// carries, which is what makes a parity assertion meaningful.
/// Test: `rpc_error_codes_track_http_statuses`;
/// `rpc_tmux_snapshot_unknown_session_reports_a_coded_error` drives one arm
/// through a real failure.
impl From<DaemonError> for RpcError {
    fn from(e: DaemonError) -> Self {
        // #6288 slice 4: the table moved to `rpc_code_for_status` so the route
        // bodies that report a status without building a `DaemonError` share it.
        RpcError::new(rpc_code_for_status(e.status().as_u16()), e.to_string())
    }
}

impl From<std::io::Error> for DaemonError {
    fn from(e: std::io::Error) -> Self {
        Self::Internal(e.to_string())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn error_status_codes_map() {
        // Each variant must map to the documented HTTP status.
        assert_eq!(
            DaemonError::SessionNotFound { id: "x".into() }.status(),
            StatusCode::NOT_FOUND
        );
        assert_eq!(
            DaemonError::CheckpointNotFound { id: "x".into() }.status(),
            StatusCode::NOT_FOUND
        );
        assert_eq!(
            DaemonError::SessionNotActive {
                id: "x".into(),
                status: "stopped".into(),
            }
            .status(),
            StatusCode::CONFLICT
        );
        assert_eq!(
            DaemonError::OverseerBlocked { reason: "x".into() }.status(),
            StatusCode::FORBIDDEN
        );
        assert_eq!(
            DaemonError::InvalidRequest("x".into()).status(),
            StatusCode::BAD_REQUEST
        );
        assert_eq!(
            DaemonError::InvalidPairCode.status(),
            StatusCode::BAD_REQUEST
        );
        assert_eq!(
            DaemonError::TmuxUnavailable("x".into()).status(),
            StatusCode::INTERNAL_SERVER_ERROR
        );
        assert_eq!(
            DaemonError::Internal("x".into()).status(),
            StatusCode::INTERNAL_SERVER_ERROR
        );
        assert_eq!(
            DaemonError::ServiceUnavailable("x".into()).status(),
            StatusCode::SERVICE_UNAVAILABLE
        );
        assert_eq!(
            DaemonError::Forbidden("x".into()).status(),
            StatusCode::FORBIDDEN
        );
        assert_eq!(
            DaemonError::Unprocessable("x".into()).status(),
            StatusCode::UNPROCESSABLE_ENTITY
        );
        // #6288: 502 is the manager surface's "a configured provider answered
        // badly", distinct from the 503 no-provider case above it.
        assert_eq!(
            DaemonError::UpstreamFailed("x".into()).status(),
            StatusCode::BAD_GATEWAY
        );
        assert_eq!(
            DaemonError::DeliverableNotFound { id: "x".into() }.status(),
            StatusCode::NOT_FOUND
        );
        assert_eq!(
            DaemonError::MilestoneNotFound { id: "x".into() }.status(),
            StatusCode::NOT_FOUND
        );
        assert_eq!(
            DaemonError::ProjectNotFoundForRepoUrl {
                repo_url: "x".into()
            }
            .status(),
            StatusCode::NOT_FOUND
        );
        assert_eq!(
            DaemonError::InvalidTransition {
                from: "proposed".into(),
                to: "complete".into(),
                allowed: vec!["in-progress".into()],
            }
            .status(),
            StatusCode::CONFLICT
        );
    }

    /// Why (#6288): the socket and HTTP must never disagree about what a failure
    /// was. The conversion derives the code FROM the status, so this pins that
    /// derivation — a variant whose status changes moves its RPC code with it,
    /// and a variant silently falling into the catch-all `internal` arm shows up
    /// here.
    /// Test: this function IS the test.
    #[test]
    fn rpc_error_codes_track_http_statuses() {
        let cases: Vec<(DaemonError, i64)> = vec![
            (
                DaemonError::SessionNotFound { id: "x".into() },
                CODE_NOT_FOUND,
            ),
            (DaemonError::NotFound("x".into()), CODE_NOT_FOUND),
            (
                DaemonError::CheckpointNotFound { id: "x".into() },
                CODE_NOT_FOUND,
            ),
            (DaemonError::InvalidRequest("x".into()), CODE_INVALID_PARAMS),
            (DaemonError::Forbidden("x".into()), CODE_FORBIDDEN),
            (
                DaemonError::SessionNotActive {
                    id: "x".into(),
                    status: "stopped".into(),
                },
                CODE_CONFLICT,
            ),
            (DaemonError::Unprocessable("x".into()), CODE_UNPROCESSABLE),
            (
                DaemonError::UpstreamFailed("x".into()),
                CODE_UPSTREAM_FAILED,
            ),
            (
                DaemonError::ServiceUnavailable("x".into()),
                CODE_UNAVAILABLE,
            ),
            (DaemonError::Internal("x".into()), CODE_INTERNAL_ERROR),
            (
                DaemonError::TmuxUnavailable("x".into()),
                CODE_INTERNAL_ERROR,
            ),
        ];

        for (err, expected) in cases {
            let message = err.to_string();
            let rpc: RpcError = err.into();
            assert_eq!(rpc.code, expected, "wrong code for {message:?}");
            assert_eq!(
                rpc.message, message,
                "the RPC message must be the HTTP body's message verbatim"
            );
        }
    }

    /// Why: the #6288 variant is new, so its 404 mapping needs its own
    /// assertion rather than riding on the pre-existing table above.
    /// Test: this function IS the test.
    #[test]
    fn not_found_variant_is_404() {
        assert_eq!(
            DaemonError::NotFound("profile `nope`".into()).status(),
            StatusCode::NOT_FOUND
        );
    }

    #[test]
    fn invalid_transition_message_lists_allowed() {
        // The Display must name the from-state, to-state, and legal next states
        // so an operator reading the plain error string can self-correct.
        let e = DaemonError::InvalidTransition {
            from: "proposed".into(),
            to: "complete".into(),
            allowed: vec!["in-progress".into()],
        };
        let msg = e.to_string();
        assert!(msg.contains("proposed"), "{msg}");
        assert!(msg.contains("complete"), "{msg}");
        assert!(msg.contains("in-progress"), "{msg}");
    }

    /// Why (#6288): a plain-text route's HTTP body is reproduced from
    /// `detail`, so a prefix leaking back in silently changes that wire.
    /// Test: this function IS the test.
    #[test]
    fn detail_drops_the_display_prefix() {
        let e = DaemonError::NotFound("project foo not found".into());
        assert_eq!(e.to_string(), "not found: project foo not found");
        assert_eq!(e.detail(), "project foo not found");

        // A structured variant has no single payload, so it falls back to
        // `Display` rather than losing its fields.
        let e = DaemonError::SessionNotFound { id: "abc".into() };
        assert_eq!(e.detail(), e.to_string());
    }

    #[test]
    fn error_messages_include_context() {
        // The `Display` impl must surface the contextual fields so an operator
        // reading the JSON error body can tell what failed.
        let e = DaemonError::SessionNotFound {
            id: "tmpm-blue-fox".into(),
        };
        assert!(e.to_string().contains("tmpm-blue-fox"));

        let e = DaemonError::SessionNotActive {
            id: "abc".into(),
            status: "stopped".into(),
        };
        assert!(e.to_string().contains("abc"));
        assert!(e.to_string().contains("stopped"));
    }

    #[test]
    fn project_not_found_message_names_repo_url_not_deliverable() {
        // #2379 review MEDIUM: the message must talk about the missing
        // PROJECT, never imply the deliverable id itself was bad.
        let e = DaemonError::ProjectNotFoundForRepoUrl {
            repo_url: "/local/path/checkout".into(),
        };
        let msg = e.to_string();
        assert!(msg.contains("/local/path/checkout"), "{msg}");
        assert!(msg.contains("project"), "{msg}");
        assert!(
            !msg.contains("deliverable not found"),
            "must not read as a deliverable-not-found error: {msg}"
        );
    }

    /// #9124 delta critic: the message is the 404 body and a log line, so a
    /// credentialed `repo_url` is shown redacted.
    #[test]
    fn project_not_found_message_redacts_the_url_credentials() {
        const TOKEN: &str = "ghp_9124DaemonErrorSyntheticToken0";
        let e = DaemonError::ProjectNotFoundForRepoUrl {
            repo_url: format!("https://octo:{TOKEN}@github.com/acme/widget.git").into(),
        };
        // #9124: `Debug` redacts too; the field keeps the raw value.
        assert!(!format!("{e:?}").contains(TOKEN), "the token reached Debug");
        let msg = e.to_string();
        assert!(!msg.contains(TOKEN), "the token reached the message");
        assert!(
            msg.contains("https://***@github.com/acme/widget.git"),
            "{msg}"
        );
        // #9259: a quote in the password does not end the userinfo.
        let e = DaemonError::ProjectNotFoundForRepoUrl {
            repo_url: "https://octo:pa'ss9259@github.com/acme/widget.git".into(),
        };
        for text in [e.to_string(), format!("{e:?}")] {
            assert!(!text.contains("ss9259"), "the password reached {text}");
            assert!(text.contains("https://***@github.com/acme/widget.git"));
        }
    }
}