trusty-common 0.56.0

Shared utilities and provider-agnostic streaming chat (ChatProvider, OllamaProvider, OpenRouter, tool-use) for trusty-* projects
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
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
//! The one way anything outside trusty-memory calls the running daemon (#6286).
//!
//! Why: this module used to resolve an address — `TRUSTY_MEMORY_URL`, else the
//! `http_addr` discovery file, else a guaranteed-dead placeholder — and POST a
//! JSON-RPC envelope to `{base}/rpc`. ADR-0032 retired that listener: since
//! #6286 trusty-memory binds one hardened Unix socket at the path
//! [`crate::daemon_socket_path`] derives, writes no discovery file, and speaks
//! the framed JSON-RPC envelope [`crate::uds`] defines. There is no address to
//! discover, no port to walk, and nothing for a stale file to disagree with.
//!
//! What: [`crate::memory_rpc::call_memory_tool`] derives the socket, writes one frame, reads one
//! back, and returns the envelope's `result`.
//! [`crate::memory_rpc::call_memory_tool_at`] takes the
//! socket explicitly, for a caller that resolved it once and threads it through
//! (catch-up's `CatchupOptions::memory_socket`) or a test pointing at a daemon
//! it started itself.
//!
//! **This module is now what the monitor TUI's client and trusty-agents'
//! `TrustyMemoryClient` call too.** Both were independent REST clients against
//! `/api/v1/*` routes, and the doc comment here used to say so and disclaim
//! reusing them. #6286 folded both onto this function: the routes they targeted
//! no longer exist, and re-deriving a second socket client for each would be
//! the drift the workspace's common-entry-point rule exists to prevent.
//!
//! **This is the request/response half only.** `memory.chat` answers in many
//! frames; a caller that wants it uses
//! [`crate::uds::send_framed_stream_request_capped`] directly, because a stream
//! is not something these signatures can return.
//!
//! Test: `call_memory_tool_at_reports_a_dead_socket_rather_than_hanging`,
//! `resolve_memory_socket_honours_the_env_override`,
//! `resolve_memory_socket_or_unreachable_falls_back`.

use std::path::{Path, PathBuf};
use std::time::Duration;

use anyhow::{Context, Result, anyhow};
use serde_json::{Value, json};

use crate::uds::send_framed_request_capped;
use crate::uds::server::RpcResponse;

/// Environment variable that pins the daemon's socket path explicitly.
///
/// Why: it replaces `TRUSTY_MEMORY_URL`, which named a base URL there is no
/// longer a listener for. The affordance it provided is still wanted — a test
/// rig or a CI job points a client at a daemon it started on a temp path — and
/// the alternative, `TRUSTY_DATA_DIR_OVERRIDE`, is process-global and would
/// redirect every other trusty-* client in the same process along with this one.
///
/// What: the literal env var name `TRUSTY_MEMORY_SOCKET`, read as a path.
/// Test: `resolve_memory_socket_honours_the_env_override`.
pub const TRUSTY_MEMORY_SOCKET_ENV: &str = "TRUSTY_MEMORY_SOCKET";

/// The app name trusty-memory derives its socket path under.
///
/// Matches the daemon's own `daemon_socket_path("trusty-memory")` call, which
/// is why caller and daemon compute the same path with nothing published
/// between them.
const MEMORY_APP_NAME: &str = "trusty-memory";

/// Largest frame this client reads or writes, in bytes.
///
/// Why not [`crate::uds::MAX_FRAME_BYTES`] (8 MiB): the daemon's own budget is
/// 32 MiB (`trusty_memory::transport::uds::MAX_FRAME_BYTES`), sized for whole-
/// palace KG dumps and 500-row activity pages, and the budget is symmetric —
/// a client that kept the shared default would refuse frames the daemon
/// considers legal, which only moves which end reports the failure.
///
/// **This is a second copy of the daemon's figure, and deliberately so.**
/// `trusty-common` is below `trusty-memory` in the dependency graph, so it
/// cannot import the constant. `memory_rpc_frame_budget_matches_the_daemon` in
/// `trusty-memory/tests/uds_consumer_contract.rs` is what keeps them equal.
pub const MAX_FRAME_BYTES: u64 = 32 * 1024 * 1024;

/// Default budget for one call.
///
/// Why 5 seconds: it is the timeout the retired `reqwest` client carried, kept
/// so this migration changes the transport and not what a slow daemon looks
/// like to a caller. A caller with different needs passes its own through
/// [`call_memory_tool_at_with_timeout`] — the monitor TUI polls on a 3-second
/// tick, and a bulk import wants far longer than either.
pub const DEFAULT_TIMEOUT: Duration = Duration::from_secs(5);

/// The JSON-RPC code trusty-memory answers when the thing asked for is absent.
///
/// Why it is duplicated here: `trusty-common` is below `trusty-memory` in the
/// dependency graph, so `trusty_memory::transport::api_error::CODE_NOT_FOUND`
/// cannot be imported. The value is pinned by
/// `memory_rpc_not_found_code_matches_the_daemon` in
/// `trusty-memory/tests/uds_consumer_contract.rs`.
pub const CODE_NOT_FOUND: i64 = -32004;

/// The daemon answered, and what it answered was an error.
///
/// Why a typed error rather than a formatted string: a caller sometimes has to
/// tell one refusal from another. trusty-agents' memory backend reads a drawer
/// out of a palace that may never have been created, and "no such palace" has
/// to be a clean empty result while a transport failure or an internal error
/// has to propagate — the same distinction its REST predecessor drew from a 404
/// status. Carrying it in the error means [`call_memory_tool_at`] keeps its
/// `Result<Value>` signature and only the callers that care pay for it, via
/// `anyhow::Error::downcast_ref`.
///
/// Test: `call_memory_tool_at_reports_a_dead_socket_rather_than_hanging` covers
/// the transport half; the code itself is pinned against the daemon by
/// `memory_rpc_not_found_code_matches_the_daemon` in
/// `trusty-memory/tests/uds_consumer_contract.rs`, and trusty-agents'
/// `get_and_delete_are_clean_when_absent` covers the caller that reads it.
#[derive(Debug, thiserror::Error)]
#[error("{method} failed: {message} ({code})")]
// #9288: non-exhaustive, so the next added field is not another break.
#[non_exhaustive]
pub struct MemoryRpcError {
    /// The method that was called.
    pub method: String,
    /// The daemon's own JSON-RPC error code.
    pub code: i64,
    /// The daemon's own message.
    pub message: String,
    /// The error object's JSON-RPC `data` member, verbatim (#9288).
    ///
    /// Why: it used to be dropped here, so a refusal that carried structured
    /// detail reached every consumer as code and message only.
    /// Test: `memory_rpc_error_keeps_the_data_member`.
    pub data: Option<Value>,
}

impl MemoryRpcError {
    /// Did the daemon say the thing asked for does not exist?
    pub fn is_not_found(&self) -> bool {
        self.code == CODE_NOT_FOUND
    }
}

/// The `status` a trusty-memory `memory.health` answer reports (#7685).
///
/// Why: a daemon that answers its health call is not necessarily one that can
/// write. trusty-memory's handler reports `"wedged"` from the worker pool's
/// oldest in-flight age on its cheap path, and `"degraded"` when a deep probe's
/// round trip fails, so a consumer that stops at "the call returned" reads a
/// stuck palace as healthy. This reads only that reported string — it does not
/// itself inspect thread or lock state, so it cannot tell a stuck write lock
/// apart from any other cause of a slow operation; that deeper observation is
/// tracked on #4001. `trusty-common` sits below `trusty-memory` in the
/// dependency graph, so the strings cannot be imported from the producer; this is
/// the one client-side reading of them, pinned against the real handler by
/// `shared_client_reaches_the_health_method_consumers_dial_by_literal` in
/// `trusty-memory/tests/uds_consumer_contract.rs`.
/// What: [`Self::Ok`], [`Self::Degraded`] and [`Self::Wedged`] for the three
/// strings the handler emits; [`Self::Unrecognised`] for any other string and
/// [`Self::Missing`] for a body with no string `status` at all. Only `Ok` is
/// healthy — see [`Self::is_ok`].
/// Test: `health_status_reads_every_handler_string`,
/// `health_status_fails_closed_on_an_unexpected_body`.
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum MemoryHealthStatus {
    /// `"ok"` — the daemon reports itself healthy.
    Ok,
    /// `"degraded"` — a deep probe's remember/recall round trip failed.
    Degraded,
    /// `"wedged"` — the oldest in-flight palace operation outlived its bound.
    Wedged,
    /// A `status` string this client does not know.
    Unrecognised(String),
    /// No string `status` field — a non-object body, or an object without one.
    Missing,
}

impl MemoryHealthStatus {
    /// Read the `status` out of a `memory.health` result.
    pub fn from_health_body(body: &Value) -> Self {
        match body.get("status").and_then(Value::as_str) {
            Some("ok") => Self::Ok,
            Some("degraded") => Self::Degraded,
            Some("wedged") => Self::Wedged,
            Some(other) => Self::Unrecognised(other.to_string()),
            None => Self::Missing,
        }
    }

    /// Whether the daemon reported itself healthy. Everything but `Ok` is not.
    pub fn is_ok(&self) -> bool {
        matches!(self, Self::Ok)
    }
}

/// A path nothing can be serving, for a caller that must never see an error.
///
/// Why a path under a directory that cannot exist rather than an empty one: a
/// dial against it is refused by the kernel immediately, which is what makes
/// the fail-open callers below fail fast instead of waiting out a budget.
const UNREACHABLE_PLACEHOLDER: &str = "/nonexistent/trusty-memory/trusty-memory.sock";

/// Resolve the socket the trusty-memory daemon binds.
///
/// # Errors
///
/// When the data directory cannot be resolved or created — an operator-fixable
/// condition (permissions, a `TRUSTY_DATA_DIR_OVERRIDE` pointing somewhere
/// unusable), distinct from "the daemon is not running", which this function
/// cannot and does not report.
///
/// Test: `resolve_memory_socket_honours_the_env_override`.
pub fn resolve_memory_socket() -> Result<PathBuf> {
    if let Ok(raw) = std::env::var(TRUSTY_MEMORY_SOCKET_ENV) {
        let trimmed = raw.trim();
        if !trimmed.is_empty() {
            return Ok(PathBuf::from(trimmed));
        }
    }
    crate::daemon_addr::daemon_socket_path(MEMORY_APP_NAME)
}

/// Fail-open variant of [`resolve_memory_socket`].
///
/// Why: catch-up, identity seeding, and the TUI health poller all degrade
/// gracefully when trusty-memory is unreachable rather than aborting their
/// caller. Keeping the "give me *a* path, even a dead one" policy here means it
/// is one decision rather than an `unwrap_or_else` at every call site.
///
/// What: delegates to [`resolve_memory_socket`]; on `Err`, warns on stderr and
/// returns [`UNREACHABLE_PLACEHOLDER`].
///
/// Test: `resolve_memory_socket_or_unreachable_falls_back`.
pub fn resolve_memory_socket_or_unreachable() -> PathBuf {
    resolve_memory_socket().unwrap_or_else(|e| {
        eprintln!("trusty-memory: {e}");
        PathBuf::from(UNREACHABLE_PLACEHOLDER)
    })
}

/// Call one method on the running daemon and return its `result`.
///
/// # Errors
///
/// When the socket cannot be resolved or dialled — which is what "the daemon is
/// not running" looks like — or when the daemon answers with a JSON-RPC error,
/// whose message and code are carried through so the caller reports the reason
/// it was given rather than a generic failure.
pub async fn call_memory_tool(method: &str, params: Value) -> Result<Value> {
    let socket = resolve_memory_socket()?;
    call_memory_tool_at(&socket, method, params).await
}

/// [`call_memory_tool`] against an already-resolved socket.
///
/// Why: catch-up resolves once into `CatchupOptions::memory_socket` and threads
/// it through, and a test drives a daemon on a temp path. Re-resolving per call
/// would make both impossible without mutating process-global state.
///
/// # Errors
///
/// As [`call_memory_tool`].
///
/// Test: `call_memory_tool_at_reports_a_dead_socket_rather_than_hanging`.
pub async fn call_memory_tool_at(socket: &Path, method: &str, params: Value) -> Result<Value> {
    call_memory_tool_at_with_timeout(socket, method, params, DEFAULT_TIMEOUT).await
}

/// [`call_memory_tool_at`] with an explicit budget.
///
/// # Errors
///
/// As [`call_memory_tool`].
pub async fn call_memory_tool_at_with_timeout(
    socket: &Path,
    method: &str,
    params: Value,
    timeout: Duration,
) -> Result<Value> {
    let request = json!({
        "jsonrpc": "2.0",
        "id": 1,
        "method": method,
        "params": params,
    });

    let response: RpcResponse =
        send_framed_request_capped(socket, &request, timeout, MAX_FRAME_BYTES)
            .await
            .with_context(|| {
                format!(
                    "call {method} on the trusty-memory daemon at {}",
                    socket.display()
                )
            })?;

    match (response.result, response.error) {
        (Some(result), _) => Ok(result),
        (None, Some(e)) => Err(anyhow::Error::new(MemoryRpcError {
            method: method.to_string(),
            code: e.code,
            message: e.message,
            // #9288: carried through rather than dropped.
            data: e.data,
        })),
        // The daemon's own contract is that exactly one of the two is present.
        (None, None) => Err(anyhow!(
            "{method} answered with neither a result nor an error"
        )),
    }
}

/// Is anything serving the daemon's socket?
///
/// Why a bare connect rather than a `memory.health` call: the question is
/// whether the endpoint is live. A daemon that is up but degraded must not be
/// reported absent and then spawned on top of itself.
pub async fn memory_daemon_is_serving(socket: &Path, timeout: Duration) -> bool {
    crate::uds::socket_is_serving(socket, timeout).await
}

/// The method a client calls to learn the daemon's wire protocol (#9288).
///
/// Why here and not in trusty-memory: the daemon depends on this crate, so it
/// registers this same constant and the name cannot drift between the two.
pub const METHOD_PROTOCOL: &str = "memory.protocol";

/// The daemon protocol versions this client can talk to (#9288).
///
/// Why: ADR-0066 keeps the daemon socket out of the 1.x contract, so the wire
/// can change between releases. trusty-memory reports one monotonic integer
/// (`trusty_memory::transport::methods::protocol::PROTOCOL_VERSION`, the
/// ADR-0007 pattern), bumped only on a change an older client cannot read. A
/// daemon outside this range is refused with
/// [`MemoryProtocolError::Unsupported`] rather than misparsed.
/// What: the end moves with the daemon's bump in the same change; the start
/// moves when this client drops an old daemon. The daemon's version is held
/// inside this range by `memory_rpc_protocol_range_accepts_the_daemon` in
/// `trusty-memory/tests/uds_consumer_contract.rs`.
/// Test: `protocol_check_refuses_an_out_of_range_daemon_with_a_named_error`.
pub const SUPPORTED_MEMORY_PROTOCOLS: std::ops::RangeInclusive<u64> = 1..=1;

/// How long a successful protocol check is reused for one socket (#9288).
///
/// Why: a long-lived client (the stdio bridge, trusty-agents) calls many times,
/// and a daemon can be restarted under it into another release. Ten seconds
/// bounds that window; a refused or failed check is never cached.
const PROTOCOL_RECHECK_INTERVAL: Duration = Duration::from_secs(10);

/// The answer to [`METHOD_PROTOCOL`] — the one shape both ends share (#9288).
///
/// Why one struct: the daemon serialises it and this client deserialises it,
/// so the field names cannot drift. The shape is frozen for every protocol
/// version; a later version may add fields, never rename or remove these.
/// Test: `protocol_check_accepts_a_daemon_in_the_supported_range`.
#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[non_exhaustive]
pub struct MemoryProtocolInfo {
    /// The daemon's wire protocol version.
    pub protocol_version: u64,
    /// The daemon's crate version, for an operator reading a refusal.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub daemon_version: Option<String>,
}

impl MemoryProtocolInfo {
    /// The answer a daemon at `protocol_version` sends.
    pub fn new(protocol_version: u64, daemon_version: Option<String>) -> Self {
        Self {
            protocol_version,
            daemon_version,
        }
    }
}

/// The protocol a daemon that predates the handshake speaks (#9288).
const PRE_HANDSHAKE_PROTOCOL: u64 = 1;

/// The verdict for a daemon that answered [`METHOD_PROTOCOL`] with
/// method-not-found, given the versions this client supports (#9288).
///
/// Why: such a daemon speaks protocol 1. It is callable only while this
/// client still supports protocol 1; once the range moves past it, the daemon
/// is refused like any other out-of-range one.
/// Test: `a_pre_handshake_daemon_is_refused_once_protocol_1_is_unsupported`.
fn pre_handshake_verdict(
    socket: &Path,
    supported: &std::ops::RangeInclusive<u64>,
) -> Result<MemoryProtocol, MemoryProtocolError> {
    if supported.contains(&PRE_HANDSHAKE_PROTOCOL) {
        return Ok(MemoryProtocol::PreHandshake);
    }
    Err(MemoryProtocolError::Unsupported {
        socket: socket.to_path_buf(),
        daemon: PRE_HANDSHAKE_PROTOCOL,
        daemon_version: "older than protocol 1".to_string(),
        min: *supported.start(),
        max: *supported.end(),
    })
}

/// What a protocol check concluded about a daemon that may be called (#9288).
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum MemoryProtocol {
    /// The daemon reported a version inside [`SUPPORTED_MEMORY_PROTOCOLS`].
    Supported(MemoryProtocolInfo),
    /// The daemon predates the handshake: it answered [`METHOD_PROTOCOL`] with
    /// method-not-found. See [`check_memory_protocol_at`] for why this is
    /// callable.
    PreHandshake,
}

/// Why a protocol check refused the daemon (#9288).
///
/// Why typed: a caller must tell "this daemon speaks another protocol" (restart
/// it) from "the check could not run" (it is down or broken), and neither may
/// ever read as compatible.
/// Test: `protocol_check_refuses_an_out_of_range_daemon_with_a_named_error`,
/// `protocol_check_fails_closed_when_the_query_fails`.
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum MemoryProtocolError {
    /// The daemon speaks a protocol version this client does not support.
    #[error(
        "unsupported trusty-memory protocol: the daemon at {socket} (version {daemon_version}) \
         speaks protocol {daemon}, this client supports {min}..={max}; restart the daemon so \
         it runs the installed release"
    )]
    Unsupported {
        /// The socket that was checked.
        socket: PathBuf,
        /// The protocol version the daemon reported.
        daemon: u64,
        /// The daemon's crate version, or `unknown`.
        daemon_version: String,
        /// The oldest version this client supports.
        min: u64,
        /// The newest version this client supports.
        max: u64,
    },
    /// The daemon answered the handshake with a body this client cannot read.
    #[error("the trusty-memory daemon at {socket} answered {METHOD_PROTOCOL} unreadably: {reason}")]
    MalformedHandshake {
        /// The socket that was checked.
        socket: PathBuf,
        /// What was wrong with the body.
        reason: String,
    },
    /// The handshake itself failed: transport, timeout, or a refusal other
    /// than method-not-found.
    #[error("the trusty-memory protocol check against {socket} failed: {cause}")]
    HandshakeFailed {
        /// The socket that was checked.
        socket: PathBuf,
        /// The underlying failure, with its whole cause chain.
        cause: String,
    },
}

/// Ask the daemon at `socket` for its protocol version, uncached (#9288).
///
/// Why: a first-party client must refuse a daemon from another release by
/// name, instead of misparsing its replies.
/// What: calls [`METHOD_PROTOCOL`] and reads [`MemoryProtocolInfo`]. A version
/// in [`SUPPORTED_MEMORY_PROTOCOLS`] is [`MemoryProtocol::Supported`]; any
/// other version is [`MemoryProtocolError::Unsupported`]; an unreadable body is
/// `MalformedHandshake`; every other failure is `HandshakeFailed`.
///
/// **A daemon that predates the handshake is callable, by explicit rule.** It
/// answers method-not-found (`-32601`), and that one answer maps to
/// [`MemoryProtocol::PreHandshake`] rather than an error. Why: clients install
/// before the running daemon restarts, so refusing here would fail every call
/// for the whole rolling-upgrade window. Protocol 1 is the wire such a daemon
/// already speaks, so the arm holds only while [`SUPPORTED_MEMORY_PROTOCOLS`]
/// contains 1; past that it is `Unsupported`. No other error code takes it.
///
/// # Errors
///
/// [`MemoryProtocolError`], one variant per cause above.
///
/// Test: `protocol_check_accepts_a_daemon_in_the_supported_range`,
/// `protocol_check_refuses_an_out_of_range_daemon_with_a_named_error`,
/// `protocol_check_reads_a_pre_handshake_daemon_as_pre_handshake`,
/// `protocol_check_fails_closed_when_the_query_fails`,
/// `a_pre_handshake_daemon_is_refused_once_protocol_1_is_unsupported`.
pub async fn check_memory_protocol_at(
    socket: &Path,
    timeout: Duration,
) -> Result<MemoryProtocol, MemoryProtocolError> {
    let body =
        match call_memory_tool_at_with_timeout(socket, METHOD_PROTOCOL, json!({}), timeout).await {
            Ok(body) => body,
            Err(e) => {
                // #9288: method-not-found, and only it, means "predates the
                // handshake". Every other failure is refused below.
                let pre_handshake = e
                    .downcast_ref::<MemoryRpcError>()
                    .is_some_and(|rpc| rpc.code == crate::uds::server::CODE_METHOD_NOT_FOUND);
                if pre_handshake {
                    return pre_handshake_verdict(socket, &SUPPORTED_MEMORY_PROTOCOLS);
                }
                return Err(MemoryProtocolError::HandshakeFailed {
                    socket: socket.to_path_buf(),
                    cause: format!("{e:#}"),
                });
            }
        };
    let info: MemoryProtocolInfo =
        serde_json::from_value(body).map_err(|e| MemoryProtocolError::MalformedHandshake {
            socket: socket.to_path_buf(),
            reason: e.to_string(),
        })?;
    if !SUPPORTED_MEMORY_PROTOCOLS.contains(&info.protocol_version) {
        return Err(MemoryProtocolError::Unsupported {
            socket: socket.to_path_buf(),
            daemon: info.protocol_version,
            daemon_version: info.daemon_version.unwrap_or_else(|| "unknown".to_string()),
            min: *SUPPORTED_MEMORY_PROTOCOLS.start(),
            max: *SUPPORTED_MEMORY_PROTOCOLS.end(),
        });
    }
    Ok(MemoryProtocol::Supported(info))
}

/// The process-wide cache of callable verdicts, keyed by socket.
type ProtocolCache = std::collections::HashMap<PathBuf, (std::time::Instant, MemoryProtocol)>;

fn protocol_cache() -> &'static std::sync::Mutex<ProtocolCache> {
    static CACHE: std::sync::OnceLock<std::sync::Mutex<ProtocolCache>> = std::sync::OnceLock::new();
    CACHE.get_or_init(Default::default)
}

/// [`check_memory_protocol_at`], reusing a callable verdict briefly (#9288).
///
/// Why: this is the gate a client runs before its calls. Re-asking on every
/// call would double each round trip; never re-asking would miss a daemon
/// restarted into another release.
/// What: returns a callable verdict younger than [`PROTOCOL_RECHECK_INTERVAL`]
/// for this socket; otherwise checks, and caches only a callable verdict. A
/// refusal is never cached, so a restarted daemon heals on the next call. A
/// pre-handshake daemon is reported once per process — see
/// [`warn_pre_handshake_once`].
///
/// # Errors
///
/// As [`check_memory_protocol_at`].
///
/// Test: `an_unsupported_verdict_is_not_cached`,
/// `a_pre_handshake_daemon_is_called_and_warned_about_once_per_process`.
pub async fn ensure_memory_protocol_at(
    socket: &Path,
    timeout: Duration,
) -> Result<MemoryProtocol, MemoryProtocolError> {
    let cached = protocol_cache()
        .lock()
        .unwrap_or_else(|e| e.into_inner())
        .get(socket)
        .filter(|(at, _)| at.elapsed() < PROTOCOL_RECHECK_INTERVAL)
        .map(|(_, verdict)| verdict.clone());
    if let Some(verdict) = cached {
        return Ok(verdict);
    }
    let verdict = check_memory_protocol_at(socket, timeout).await?;
    if verdict == MemoryProtocol::PreHandshake {
        warn_pre_handshake_once(socket, timeout).await;
    }
    protocol_cache()
        .lock()
        .unwrap_or_else(|e| e.into_inner())
        .insert(
            socket.to_path_buf(),
            (std::time::Instant::now(), verdict.clone()),
        );
    Ok(verdict)
}

/// How many pre-handshake warnings this process has emitted: 0 or 1.
static PRE_HANDSHAKE_WARNINGS: std::sync::atomic::AtomicUsize =
    std::sync::atomic::AtomicUsize::new(0);

/// Report a daemon that predates the handshake, once per process (#9288).
///
/// Why: the call goes ahead, so the operator needs to learn the daemon wants a
/// restart — once, not on every call of a long-lived client.
/// What: the first caller in the process asks `memory.health` for the
/// daemon's crate version (a pre-handshake daemon cannot report it through
/// [`METHOD_PROTOCOL`]) and writes one warning to stderr and to the log. Later
/// callers do nothing.
/// Test: `a_pre_handshake_daemon_is_called_and_warned_about_once_per_process`.
async fn warn_pre_handshake_once(socket: &Path, timeout: Duration) {
    use std::sync::atomic::Ordering;
    if PRE_HANDSHAKE_WARNINGS
        .compare_exchange(0, 1, Ordering::SeqCst, Ordering::SeqCst)
        .is_err()
    {
        return;
    }
    let version = call_memory_tool_at_with_timeout(socket, "memory.health", json!({}), timeout)
        .await
        .ok()
        .and_then(|body| {
            body.get("version")
                .and_then(Value::as_str)
                .map(str::to_string)
        });
    let daemon = match version {
        Some(v) => format!("the trusty-memory daemon {v}"),
        None => "a trusty-memory daemon older than protocol 1".to_string(),
    };
    let warning = format!(
        "{daemon} at {} predates the protocol handshake (#9288); calling it as protocol 1. \
         Restart the trusty-memory daemon to pick up the protocol handshake.",
        socket.display()
    );
    eprintln!("trusty-memory: {warning}");
    tracing::warn!("{warning}");
}

#[cfg(test)]
#[path = "memory_rpc_protocol_tests.rs"]
mod protocol_tests;

#[cfg(test)]
mod tests {
    use super::*;
    // Reuse the crate-wide env-mutation lock (`data_dir::ENV_LOCK`) rather than
    // a module-local one: several test modules mutate `TRUSTY_DATA_DIR_OVERRIDE`,
    // and cargo runs tests in the same process across files, so a separate
    // lock would not prevent the race.
    use crate::data_dir::ENV_LOCK;

    /// Why: the override is the only way a test rig or a CI job can point this
    /// client at a daemon it started itself without redirecting every other
    /// trusty-* client in the process through `TRUSTY_DATA_DIR_OVERRIDE`.
    /// Test: itself.
    #[test]
    fn resolve_memory_socket_honours_the_env_override() {
        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        unsafe {
            std::env::set_var(TRUSTY_MEMORY_SOCKET_ENV, "/tmp/example-memory.sock");
        }
        let resolved = resolve_memory_socket();
        unsafe {
            std::env::remove_var(TRUSTY_MEMORY_SOCKET_ENV);
        }
        assert_eq!(
            resolved.expect("an override always resolves"),
            PathBuf::from("/tmp/example-memory.sock")
        );
    }

    /// Why: `resolve_memory_socket` errs only when the data directory cannot be
    /// resolved or created, and the fail-open callers must get a dead path
    /// rather than an error they would have to handle.
    /// What: points `TRUSTY_DATA_DIR_OVERRIDE` at a path under a file, which no
    /// directory can be created beneath.
    /// Test: itself.
    #[test]
    fn resolve_memory_socket_or_unreachable_falls_back() {
        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
        let tmp = tempfile::NamedTempFile::new().expect("temp file");
        unsafe {
            std::env::remove_var(TRUSTY_MEMORY_SOCKET_ENV);
            std::env::set_var(
                crate::data_dir::DATA_DIR_OVERRIDE_ENV,
                tmp.path().join("under-a-file"),
            );
        }
        let resolved = resolve_memory_socket_or_unreachable();
        unsafe {
            std::env::remove_var(crate::data_dir::DATA_DIR_OVERRIDE_ENV);
        }
        assert_eq!(resolved, PathBuf::from(UNREACHABLE_PLACEHOLDER));
    }

    /// Why (#7685): the three strings trusty-memory's health handler emits must
    /// each map to their own variant, and only `"ok"` may read as healthy.
    /// Test: itself.
    #[test]
    fn health_status_reads_every_handler_string() {
        for (status, expected, healthy) in [
            ("ok", MemoryHealthStatus::Ok, true),
            ("degraded", MemoryHealthStatus::Degraded, false),
            ("wedged", MemoryHealthStatus::Wedged, false),
        ] {
            let read = MemoryHealthStatus::from_health_body(&json!({ "status": status }));
            assert_eq!(read, expected, "status {status}");
            assert_eq!(read.is_ok(), healthy, "status {status}");
        }
    }

    /// Why (#7685): a body this client cannot read is not evidence of health.
    /// Test: itself.
    #[test]
    fn health_status_fails_closed_on_an_unexpected_body() {
        for (body, expected) in [
            (
                json!({ "status": "warming" }),
                MemoryHealthStatus::Unrecognised("warming".to_string()),
            ),
            (json!({ "version": "1.0" }), MemoryHealthStatus::Missing),
            (json!({ "status": 1 }), MemoryHealthStatus::Missing),
            (json!("not a health body"), MemoryHealthStatus::Missing),
        ] {
            let read = MemoryHealthStatus::from_health_body(&body);
            assert_eq!(read, expected, "body {body}");
            assert!(!read.is_ok(), "body {body} must not read as healthy");
        }
    }

    /// Why: every fail-open caller degrades on "the daemon is not running", and
    /// has to learn that promptly rather than by waiting out a timeout. A dial
    /// against an absent socket is refused by the kernel, so the error must
    /// arrive well inside the budget.
    /// Test: itself.
    #[tokio::test]
    async fn call_memory_tool_at_reports_a_dead_socket_rather_than_hanging() {
        let tmp = tempfile::tempdir().expect("tempdir");
        let started = std::time::Instant::now();
        let result = call_memory_tool_at(
            &tmp.path().join("absent.sock"),
            "memory_list",
            json!({ "palace": "p" }),
        )
        .await;
        assert!(result.is_err(), "an absent socket cannot answer");
        assert!(
            started.elapsed() < DEFAULT_TIMEOUT,
            "a refused dial must not wait out the budget: {:?}",
            started.elapsed()
        );
    }
}