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}