Skip to main content

khive_runtime/daemon/
wire_frames.rs

1use serde::{Deserialize, Serialize};
2#[cfg(unix)]
3use tokio::io::{AsyncReadExt, AsyncWriteExt};
4
5#[cfg(doc)]
6use super::PROTOCOL_VERSION;
7use super::{ConnectionCapSnapshot, DaemonLifecycleSnapshot, RecallLedgerSnapshot};
8#[cfg(unix)]
9use super::{DAEMON_LEXICAL_TIMEOUT_MARKER, MAX_FRAME_BYTES};
10
11/// Request frame sent from a client to the daemon.
12#[derive(Serialize, Deserialize, Default)]
13pub struct DaemonRequestFrame {
14    pub ops: String,
15    /// Parse and inspect the catalog without dispatch, identity, or storage access.
16    #[serde(default)]
17    pub plan: bool,
18    #[serde(skip_serializing_if = "Option::is_none")]
19    pub presentation: Option<String>,
20    #[serde(skip_serializing_if = "Option::is_none")]
21    pub presentation_per_op: Option<Vec<Option<String>>>,
22    /// The client's resolved storage/gate default namespace for this request.
23    ///
24    /// As of protocol version 3 (ADR-096) the daemon serves the request under
25    /// this namespace instead of rejecting on mismatch: a per-request
26    /// identity input, not a same-process-identity assertion.
27    pub namespace: String,
28    /// The client's resolved write-stamp / gate actor identity (ADR-057),
29    /// carried on the frame so the warm daemon stamps writes with the
30    /// *caller's* actor instead of its own baked `actor_id` (ADR-096). `None`
31    /// mints `ActorRef::anonymous()`, matching an unconfigured actor.
32    #[serde(default)]
33    pub actor_id: Option<String>,
34    /// Opaque process provenance resolved in the originating client process.
35    /// It is carried per request because a shared warm daemon's environment
36    /// does not identify the worker that submitted the operation. Protocol v4
37    /// makes this field part of dispatch semantics: a v3 daemon must reject the
38    /// request rather than execute it while silently discarding provenance.
39    #[serde(default, skip_serializing_if = "Option::is_none")]
40    pub process_ref: Option<String>,
41    /// The client's resolved extra read-visibility namespaces (ADR-007 Rule
42    /// 3b), carried on the frame so the warm daemon widens read scope to
43    /// match the caller's own configuration rather than its own baked
44    /// `visible_namespaces` (ADR-096). A non-`local` `actor_id` joins default
45    /// reads where the registry mints the token (ADR-007 Rev 4 Rule 3b), so
46    /// an empty list still includes that actor in default reads. Explicit
47    /// `namespace=` operations remain scoped to exactly that namespace.
48    #[serde(default)]
49    pub visible_namespaces: Vec<String>,
50    /// Fingerprint of the client's engine-coherence config: packs, db target,
51    /// embedders, backend routing, and construction-baked outbound policy.
52    /// Identity fields are carried separately in this frame. The daemon rejects
53    /// requests whose configuration differs, except when its extra-embedder set
54    /// is a superset of the client's and every other field matches. See
55    /// ADR-027 / ADR-049 / ADR-096.
56    #[serde(default)]
57    pub config_id: String,
58    /// IPC protocol version sent by the client. Pre-versioning clients omit
59    /// this field (deserializes to 0). The daemon compares against
60    /// [`PROTOCOL_VERSION`] and rejects mismatches with an explicit error.
61    #[serde(default)]
62    pub protocol_version: u32,
63    /// When `true`, the daemon returns an identity frame (ok=true, result=None)
64    /// immediately after identity validation — without calling the dispatcher.
65    /// Used by the client's under-lock recovery probe to confirm a daemon is
66    /// alive and identity-matching without dispatching any mutating verb.
67    /// Pre-probe clients omit this field (deserializes to false → normal dispatch).
68    #[serde(default)]
69    pub probe_only: bool,
70    /// When `true`, the daemon returns a point-in-time [`MetricsSnapshot`] of
71    /// its server-side gauges (a read-only measurement surface for the
72    /// load/perf harness) instead of dispatching any op. Handled before the
73    /// `config_id` equality reject: a gauge read is process-global and
74    /// namespace/config-agnostic, not a namespaced record operation.
75    /// READ-ONLY — this field is the only input the frame accepts for a
76    /// metrics request; there is no reset or mutation reachable over the
77    /// wire. Pre-metrics clients omit this field (deserializes to `false` →
78    /// normal dispatch, unaffected).
79    #[serde(default)]
80    pub metrics_only: bool,
81    /// Output format for this request (ADR-078). Forwarded to the daemon's
82    /// serialization seam. `None` means use the daemon's resolved default.
83    #[serde(default)]
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub format: Option<String>,
86    /// Per-operation output format overrides (ADR-078).
87    #[serde(default)]
88    #[serde(skip_serializing_if = "Option::is_none")]
89    pub format_per_op: Option<Vec<Option<String>>>,
90    /// Whether this request originated from the agent-facing MCP `request`
91    /// tool (the wire surface). When `true`, the daemon rejects
92    /// `Visibility::Subhandler` verbs: agents must not invoke internal
93    /// subhandlers. When `false` (the default, and the only value any
94    /// operator path sends), subhandlers are allowed: `kkernel exec` and
95    /// other in-process callers are trusted operator surfaces.
96    ///
97    /// This is the origin discriminator, not a daemon-vs-local one: operator
98    /// requests flow through the daemon by default too, so the gate cannot
99    /// key on transport.
100    #[serde(default)]
101    pub from_wire: bool,
102    /// Request-group correlation id (khive#948), echoed back unchanged on
103    /// [`DaemonResponseFrame::request_id`] and stamped into the dispatch's
104    /// audit event (`resource.request_id`) so a benchmark harness can join
105    /// its own pre-send sample to the server-side audit row for the same
106    /// request. Agent-facing MCP requests always carry one: the bridge keeps a
107    /// caller-supplied value or mints an opaque nonzero value when absent.
108    /// Operator-built/probe frames may still use `None`. Purely additive —
109    /// `#[serde(default)]` matches `metrics_only`/`format`/`format_per_op`
110    /// precedent, with no `PROTOCOL_VERSION` bump.
111    #[serde(default)]
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub request_id: Option<u64>,
114}
115
116/// A dispatch failure whose domain outcome remains available to the transport.
117#[derive(Debug, Clone)]
118pub struct DaemonDispatchError {
119    pub message: String,
120    pub error_detail: serde_json::Value,
121}
122
123/// Per-field container limit, asserted equal to the request parser's bound by MCP.
124pub const ERROR_DETAIL_NESTING_DEPTH_LIMIT: usize = 64;
125
126fn error_detail_value_within_limit(value: &serde_json::Value) -> bool {
127    let mut pending = vec![(value, 0_usize)];
128    while let Some((value, depth)) = pending.pop() {
129        match value {
130            serde_json::Value::Array(items) if depth < ERROR_DETAIL_NESTING_DEPTH_LIMIT => {
131                pending.extend(items.iter().map(|child| (child, depth + 1)));
132            }
133            serde_json::Value::Object(fields) if depth < ERROR_DETAIL_NESTING_DEPTH_LIMIT => {
134                pending.extend(fields.values().map(|child| (child, depth + 1)));
135            }
136            serde_json::Value::Array(_) | serde_json::Value::Object(_) => return false,
137            _ => {}
138        }
139    }
140    true
141}
142
143fn drop_error_detail_iteratively(value: serde_json::Value) {
144    let mut pending = vec![value];
145    while let Some(value) = pending.pop() {
146        match value {
147            serde_json::Value::Array(items) => pending.extend(items),
148            serde_json::Value::Object(fields) => pending.extend(fields.into_values()),
149            _ => {}
150        }
151    }
152}
153
154impl DaemonDispatchError {
155    /// Missing or unrecognized disposition from a legacy implementation is unknown.
156    pub fn new(message: impl Into<String>, error_detail: Option<serde_json::Value>) -> Self {
157        let message = message.into();
158        let mut fields = match error_detail {
159            Some(serde_json::Value::Object(fields)) => fields,
160            Some(data) => serde_json::Map::from_iter([("data".to_string(), data)]),
161            None => serde_json::Map::new(),
162        };
163        let disposition = match fields
164            .get("domain_disposition")
165            .and_then(serde_json::Value::as_str)
166        {
167            Some("committed") => crate::DomainDisposition::Committed,
168            Some("not_committed") => crate::DomainDisposition::NotCommitted,
169            _ => crate::DomainDisposition::Unknown,
170        };
171        if disposition != crate::DomainDisposition::Committed {
172            if let Some(result) = fields.remove("domain_result") {
173                drop_error_detail_iteratively(result);
174            }
175        }
176        let rejected: Vec<String> = fields
177            .iter()
178            .filter(|(_, value)| !error_detail_value_within_limit(value))
179            .map(|(name, _)| name.clone())
180            .collect();
181        let omitted_result = rejected.iter().any(|name| name == "domain_result");
182        let omitted_detail = !rejected.is_empty();
183        for name in rejected {
184            if let Some(value) = fields.remove(&name) {
185                drop_error_detail_iteratively(value);
186            }
187        }
188        let mut error_detail = serde_json::Value::Object(fields);
189        if error_detail["kind"].as_str().is_none() {
190            error_detail["kind"] = serde_json::json!("internal");
191        }
192        if error_detail["message"].as_str().is_none() {
193            error_detail["message"] = serde_json::json!(message);
194        }
195        error_detail["domain_disposition"] = serde_json::json!(disposition.as_str());
196        if omitted_detail {
197            error_detail["code"] = serde_json::json!(if omitted_result {
198                "result_too_deep"
199            } else {
200                "error_detail_too_deep"
201            });
202        }
203        Self {
204            message,
205            error_detail,
206        }
207    }
208}
209
210/// Response frame sent from the daemon back to a client.
211#[derive(Serialize, Deserialize, Debug)]
212pub struct DaemonResponseFrame {
213    pub ok: bool,
214    pub result: Option<String>,
215    pub error: Option<String>,
216    /// Additive failure metadata; legacy protocol-v4 peers still read `error` as text.
217    /// On a successful response, `{"lexical_timeout":true}` is a daemon-only
218    /// diagnostic that old clients ignore and new clients log locally.
219    #[serde(default, skip_serializing_if = "Option::is_none")]
220    pub error_detail: Option<serde_json::Value>,
221    pub namespace_mismatch: bool,
222    /// Set when the request's `config_id` does not match the daemon's. Like
223    /// `namespace_mismatch`, this signals the client to fall back to local
224    /// dispatch rather than execute under a different runtime/config.
225    #[serde(default)]
226    pub config_mismatch: bool,
227    /// The `config_id` the daemon dispatched under, echoed back so the client
228    /// can positively confirm the result came from a matching runtime. A
229    /// pre-`config_id` daemon omits this field (deserializes to `None`), which
230    /// the client treats as a mismatch and falls back to local dispatch — this
231    /// closes the upgrade window where a new restricted client could otherwise
232    /// trust a still-warm legacy daemon's broader registry.
233    #[serde(default)]
234    pub served_config_id: Option<String>,
235    /// Set when the client's `protocol_version` does not match the daemon's
236    /// [`PROTOCOL_VERSION`]. The client must treat this as a hard error and
237    /// surface the human-readable `error` field rather than falling back to
238    /// local dispatch (which would hide the version skew).
239    #[serde(default)]
240    pub version_mismatch: bool,
241    /// The daemon's [`PROTOCOL_VERSION`], echoed in error responses so the
242    /// client can include both sides in the diagnostic message. Pre-versioning
243    /// daemons omit this field (deserializes to 0).
244    #[serde(default)]
245    pub daemon_protocol_version: u32,
246    /// Populated when the request set `metrics_only: true`: a point-in-time
247    /// snapshot of the daemon's server-side gauges. `None` on every other
248    /// response, and on any response from a daemon that predates this field
249    /// (client-side back-compat via `#[serde(default)]`, matching
250    /// `served_config_id`'s upgrade-window handling above).
251    #[serde(default, skip_serializing_if = "Option::is_none")]
252    pub metrics: Option<MetricsSnapshot>,
253    /// Echo of the request's `request_id` (khive#948), present whenever the
254    /// frame that produced this response carried one — including on every
255    /// error/denied arm, not only success, so a client can join a failure
256    /// the same way it joins a success. `#[serde(default)]` so an older
257    /// daemon's response (predating this field) deserializes to `None`
258    /// rather than a parse error.
259    #[serde(default)]
260    pub request_id: Option<u64>,
261}
262
263/// Move the private dispatch signal out of the result before any client can
264/// observe it. Unmarked results retain their exact bytes. The marked result
265/// was serialized from a JSON Value by the MCP server, so reserializing after
266/// removal reproduces its public envelope. The marker's escaped frame cost
267/// equals `error_detail:{"lexical_timeout":true}`, keeping the server's exact
268/// frame-fit calculation valid after this move.
269#[cfg(unix)]
270pub(super) fn take_daemon_lexical_timeout_marker(
271    raw: String,
272) -> (String, Option<serde_json::Value>) {
273    if !raw.contains(DAEMON_LEXICAL_TIMEOUT_MARKER) {
274        return (raw, None);
275    }
276    let Ok(mut value) = serde_json::from_str::<serde_json::Value>(&raw) else {
277        return (raw, None);
278    };
279    let Some(fields) = value.as_object_mut() else {
280        return (raw, None);
281    };
282    if !fields
283        .get("results")
284        .is_some_and(serde_json::Value::is_array)
285    {
286        return (raw, None);
287    }
288    let Some(marker) = fields.remove(DAEMON_LEXICAL_TIMEOUT_MARKER) else {
289        return (raw, None);
290    };
291    let detail =
292        (marker.as_bool() == Some(true)).then(|| serde_json::json!({"lexical_timeout": true}));
293    (
294        serde_json::to_string(&value).expect("serde_json::Value is serializable"),
295        detail,
296    )
297}
298
299/// One checkpoint store in this daemon's fixed topology. IDs are process-local:
300/// `main` or `secondary:<index>` in dispatcher order. The basename is display-only,
301/// not an identity; no directory path is exposed. Restart/topology changes reset
302/// the interpretation of interval deltas.
303#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)]
304#[serde(default)]
305pub struct CheckpointStoreMetrics {
306    pub store_id: String,
307    pub role: String,
308    pub database: Option<String>,
309    #[serde(flatten)]
310    pub timing: khive_db::checkpoint::CheckpointTiming,
311}
312
313/// Point-in-time snapshot of the daemon's server-side gauges — the
314/// load/perf harness read-surface (measurement substrate, not a product feature).
315///
316/// Every field here is a **server-side** gauge reachable from `handle_conn`
317/// without any mutation: [`khive_storage::tx_registry`] (ADR-091 Plank 0,
318/// process-global singleton), the main pool's backend-keyed routine WAL
319/// sample and process TRUNCATE counters (`khive_db::checkpoint`), and the
320/// ADR-067 Component A write queue depth/latest writer-stage sample. There is
321/// no reset reachable through this type or through [`DaemonRequestFrame`] —
322/// gauges out, nothing in.
323#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq)]
324pub struct MetricsSnapshot {
325    /// Launch mode and the current lifecycle of this daemon incarnation.
326    #[serde(default, skip_serializing_if = "Option::is_none")]
327    pub lifecycle: Option<DaemonLifecycleSnapshot>,
328    /// Last-observed WAL page count from the periodic checkpoint tick.
329    /// `None` when the checkpoint task has never ticked in this process
330    /// (for example, an in-memory dispatcher with no pool, or a daemon that
331    /// just started and hasn't hit its first tick yet).
332    pub wal_pages: Option<u64>,
333    /// Logical frames present in the WAL at the main backend's most recent
334    /// periodic PASSIVE checkpoint. Kept separate from physical allocation.
335    #[serde(default)]
336    pub wal_log_frames: Option<u64>,
337    /// Frames backfilled by that same periodic PASSIVE pass.
338    #[serde(default)]
339    pub wal_checkpointed_frames: Option<u64>,
340    /// Logical frames still pending after that pass (`log - checkpointed`).
341    #[serde(default)]
342    pub wal_pending_frames: Option<u64>,
343    /// Physical `-wal` sidecar bytes captured at the same routine tick.
344    #[serde(default)]
345    pub wal_physical_bytes: Option<u64>,
346    /// Wall-clock timestamp of the routine WAL sample, for staleness checks.
347    #[serde(default)]
348    pub wal_observed_at_unix_ms: Option<u64>,
349    /// Cumulative actual routine PASSIVE calls for each store this daemon
350    /// checkpoints. Microseconds preserve sub-millisecond costs; max is since
351    /// process start, not an interval maximum. Counters saturate rather than wrap.
352    #[serde(default)]
353    pub wal_checkpoint_stores: Vec<CheckpointStoreMetrics>,
354    /// Total WAL TRUNCATE escalation attempts (ADR-091 Plank 2) made in this
355    /// process's lifetime, regardless of whether they succeeded in reclaiming
356    /// pages.
357    pub wal_truncate_attempts: u64,
358    /// Current consecutive-failure count for TRUNCATE attempts that failed to
359    /// bring the WAL back below `warn_pages`; resets to 0 the next time an
360    /// attempt clears it.
361    pub wal_truncate_consecutive_failures: u64,
362    /// Total checkpoint ticks skipped because the dedicated checkpoint
363    /// connection was unavailable (ADR-091 checkpoint-pressure telemetry),
364    /// across this process's lifetime. `#[serde(default)]` so an older client
365    /// decoding a newer daemon's snapshot (or vice versa) does not fail.
366    #[serde(default)]
367    pub wal_checkpoint_skipped_ticks: u64,
368    /// Current consecutive-skip run length; 0 once the next tick is observed.
369    #[serde(default)]
370    pub wal_checkpoint_consecutive_skips: u64,
371    /// WAL page count last known at the time of the most recent skip, if any
372    /// skip has occurred yet in this process.
373    #[serde(default)]
374    pub wal_checkpoint_last_skip_wal_pages: Option<u64>,
375    /// Age, in microseconds, of the oldest currently-open transaction
376    /// registry entry (ADR-091 Plank 0). `None` when no transaction is
377    /// currently open.
378    pub oldest_pinned_tx_micros: Option<u64>,
379    /// Diagnostic label of the oldest currently-open transaction registry
380    /// entry, if any and if it was registered with one.
381    pub oldest_pinned_tx_label: Option<String>,
382    /// Number of currently open transaction registry entries.
383    pub open_tx_count: usize,
384    /// Current write-queue backlog depth (ADR-067 Component A): requests
385    /// enqueued but not yet accepted by the `WriterTask` drain loop. `None`
386    /// unless the write queue is enabled (`KHIVE_WRITE_QUEUE=1`) and a
387    /// file-backed pool is available.
388    pub write_queue_depth: Option<usize>,
389    /// The write queue's configured bounded capacity
390    /// (`PoolConfig::write_queue_capacity`), gated the same as
391    /// `write_queue_depth`.
392    pub write_queue_capacity: Option<usize>,
393    /// Latest completed writer-task span: bounded-channel admission/backlog.
394    #[serde(default)]
395    pub write_last_queue_wait_micros: Option<u64>,
396    /// Latest completed writer-task span: `BEGIN IMMEDIATE` acquisition.
397    #[serde(default)]
398    pub write_last_transaction_acquire_micros: Option<u64>,
399    /// Latest completed writer-task span: application transaction body.
400    #[serde(default)]
401    pub write_last_body_micros: Option<u64>,
402    /// Latest completed writer-task span: SQLite COMMIT/fsync phase.
403    #[serde(default)]
404    pub write_last_commit_micros: Option<u64>,
405    /// Whole latest writer-task request span, retained for compatibility and
406    /// comparison with the decomposed stages.
407    #[serde(default)]
408    pub write_last_total_micros: Option<u64>,
409    /// Wall-clock timestamp of the writer-stage sample.
410    #[serde(default)]
411    pub write_last_observed_at_unix_ms: Option<u64>,
412    /// Connection cap of the daemon socket this snapshot was served from.
413    /// `None` from a daemon that predates the cap and when no listener owns
414    /// the connection.
415    #[serde(default, skip_serializing_if = "Option::is_none")]
416    pub connections: Option<ConnectionCapSnapshot>,
417    /// Bounds and counts of the best-effort recall serve-ledger tasks.
418    /// `None` from a daemon that predates the bound.
419    #[serde(default, skip_serializing_if = "Option::is_none")]
420    pub recall_ledger: Option<RecallLedgerSnapshot>,
421}
422
423// ── framing ───────────────────────────────────────────────────────────────────
424
425/// Read one length-prefixed frame (4-byte BE u32 length + JSON bytes).
426#[cfg(unix)]
427pub async fn read_frame<R>(stream: &mut R) -> std::io::Result<Vec<u8>>
428where
429    R: tokio::io::AsyncRead + Unpin,
430{
431    let mut len_buf = [0u8; 4];
432    stream.read_exact(&mut len_buf).await?;
433    let len = u32::from_be_bytes(len_buf) as usize;
434    if len > MAX_FRAME_BYTES {
435        return Err(std::io::Error::new(
436            std::io::ErrorKind::InvalidData,
437            format!("daemon frame of {len} bytes exceeds {MAX_FRAME_BYTES} cap"),
438        ));
439    }
440    let mut buf = vec![0u8; len];
441    stream.read_exact(&mut buf).await?;
442    Ok(buf)
443}
444
445#[cfg(unix)]
446fn initial_frame_timeout_error() -> std::io::Error {
447    std::io::Error::new(
448        std::io::ErrorKind::TimedOut,
449        "daemon initial request frame read timed out",
450    )
451}
452
453#[cfg(unix)]
454pub(super) async fn read_initial_frame<R>(
455    stream: &mut R,
456    deadline: tokio::time::Instant,
457) -> std::io::Result<Vec<u8>>
458where
459    R: tokio::io::AsyncRead + Unpin,
460{
461    // Tokio polls the inner future before checking its timer. A frame already
462    // buffered when a delayed connection task first runs would otherwise pass
463    // even though its acceptance-time deadline has expired.
464    if tokio::time::Instant::now() >= deadline {
465        return Err(initial_frame_timeout_error());
466    }
467    let raw = tokio::time::timeout_at(deadline, read_frame(stream))
468        .await
469        .map_err(|_| initial_frame_timeout_error())??;
470    if tokio::time::Instant::now() >= deadline {
471        return Err(initial_frame_timeout_error());
472    }
473    Ok(raw)
474}
475
476/// Write one length-prefixed frame.
477#[cfg(unix)]
478pub async fn write_frame<W>(stream: &mut W, payload: &[u8]) -> std::io::Result<()>
479where
480    W: tokio::io::AsyncWrite + Unpin,
481{
482    if payload.len() > MAX_FRAME_BYTES {
483        return Err(std::io::Error::new(
484            std::io::ErrorKind::InvalidData,
485            format!(
486                "daemon frame of {} bytes exceeds {MAX_FRAME_BYTES} cap",
487                payload.len()
488            ),
489        ));
490    }
491    let len = (payload.len() as u32).to_be_bytes();
492    stream.write_all(&len).await?;
493    stream.write_all(payload).await?;
494    stream.flush().await?;
495    Ok(())
496}