Skip to main content

terminal_commander_ipc/
protocol.rs

1// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
2// Copyright 2026 The Terminal Commander Authors
3
4//! IPC wire protocol (TC37).
5//!
6//! Length-prefixed JSON frames. Every frame begins with a 4-byte
7//! big-endian `u32` payload length, followed by exactly that many
8//! bytes of UTF-8 JSON. Frames larger than [`MAX_FRAME_BYTES`] are
9//! rejected before the payload is decoded.
10//!
11//! Method set at TC37 is deliberately tiny:
12//! - `system_discover` — version + capabilities + tool list.
13//! - `health` — daemon liveness ping.
14//! - `policy_status` — active profile + the daemon-side caps.
15//! - `self_check` — re-run the bounded TC36 self-check report.
16//!
17//! No `command_*`, no `bucket_*`, no `event_context`, no
18//! `file_read_*` — those land in TC38 (process wiring), TC39
19//! (bucket/event daemon API), and TC41 (MCP tool surface). TC37
20//! deliberately ships a minimal, safe method set so the transport
21//! lock-in does not race ahead of policy / wiring goals.
22//!
23//! Source-status: live (TC37).
24
25use std::path::PathBuf;
26use std::time::Duration;
27
28use serde::{Deserialize, Serialize};
29use terminal_commander_core::{
30    ActivationScope, BucketConfig, BucketId, EventId, JobId, RuleDefinition, RuleStatus, SessionId,
31    Severity, SignalEvent, SourceStream,
32};
33
34/// US2 (FR-011): a non-blocking hint that a curated rule pack exists
35/// for the tool being started but is not active. Surfaced on
36/// command-start responses so an agent can self-serve the comb rules.
37///
38/// This is advisory only: it changes no behavior and activates
39/// nothing (constitution VII). The agent acts on it by calling
40/// `registry_import_pack` (named in `action`).
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
42pub struct PackAvailableHint {
43    /// Always `"pack_available"`. A discriminant for forward-compat.
44    pub kind: String,
45    /// The recognized pack id (e.g. `"docker"`, `"git"`).
46    pub pack: String,
47    /// Always `"registry_import_pack"`: the tool to call to act on the
48    /// hint.
49    pub action: String,
50}
51
52impl PackAvailableHint {
53    /// Build a `pack_available` hint for the named pack.
54    #[must_use]
55    pub fn for_pack(pack: impl Into<String>) -> Self {
56        Self {
57            kind: "pack_available".to_owned(),
58            pack: pack.into(),
59            action: "registry_import_pack".to_owned(),
60        }
61    }
62}
63
64/// Bounded response shape. Carries identifiers and counters, never
65/// raw stdout/stderr.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct CommandStartResponse {
68    pub job_id: JobId,
69    pub bucket_id: BucketId,
70    pub probe_id: terminal_commander_core::ProbeId,
71    /// Initial bucket cursor: clients pass this to `bucket_events_since`.
72    pub cursor: u64,
73    /// US2 (FR-011): optional hint that a curated pack exists for this
74    /// tool but is not active. Omitted (None) when the tool is
75    /// unrecognized or its pack is already active. Advisory only.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub hint: Option<PackAvailableHint>,
78}
79
80/// No-silence exit receipt (TCE-ERG-1).
81///
82/// Present ONLY when a finished process command produced ZERO
83/// rule-driven events. This is the one sanctioned exception to "TC
84/// never returns raw output": a bounded, truthful tail so a zero-rule
85/// command does not read as breakage.
86///
87/// PTY and file-watch jobs never produce a receipt: it is built solely in the
88/// combed lifecycle waiter, and the other lanes have no binding in the command
89/// runtime's live map, so this field stays `None` for them. A tail therefore
90/// cannot include secret-prompt input.
91///
92/// An earlier version of this comment claimed `handle_command_status` routed
93/// PTY job ids to `UnknownJob`. It never did -- the job ledger is shared across
94/// lanes -- and that gap is the defect spec 004 fixes by routing a status read
95/// to the runtime that owns the job. The no-secret-leak conclusion above holds
96/// on its own terms, independent of that routing.
97#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct CommandReceipt {
99    pub exit_code: Option<i32>,
100    /// Frames the command produced that no rule matched, i.e. lines
101    /// the agent would otherwise have scrolled. `frames_total` for a
102    /// zero-rule run.
103    pub lines_suppressed: u64,
104    /// Last N frame texts (oldest first), byte-capped.
105    pub tail: Vec<String>,
106    /// True when the ring evicted earlier frames; the tail may omit
107    /// the start of output.
108    pub tail_incomplete: bool,
109}
110
111/// How a reported outcome was established (spec 004 FR-006).
112///
113/// Present on EVERY [`CommandStatusResponse`], never conditionally omitted, so
114/// the normal and degraded shapes cannot drift apart. Constitution VII requires
115/// public diagnostics to use closed typed codes rather than free text, which is
116/// why this is a fixed enum and not a note field.
117///
118/// ## "lost" is deliberately NOT a value here
119///
120/// A job the daemon recorded starting and never recorded finishing has no
121/// outcome to report, so it is not a status payload at all: it is delivered as
122/// a typed [`IpcErrorCode::JobLost`] error. An earlier draft carried a `Lost`
123/// variant and the agent-facing docs promised it as a field value, but nothing
124/// ever constructed it -- an agent branching on `outcome_trust == "lost"` would
125/// have waited forever for a value that could not arrive (found by all three
126/// reviewers of this branch). Every variant below has a live construction site.
127#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
128#[serde(rename_all = "snake_case")]
129pub enum OutcomeTrust {
130    /// The daemon witnessed this outcome live. Every counter is a real
131    /// observation.
132    #[default]
133    Observed,
134    /// Read back from the durable receipt after the in-memory job was gone.
135    /// `state` and `exit_code` are truthful; the counters are the values
136    /// captured when the job finished. Receipts written before the evidence
137    /// migration carry no counters, and that absence is reported honestly.
138    Reconstructed,
139    /// Ended by daemon shutdown or stale replacement rather than reaching its
140    /// own conclusion. Reported with lifecycle state `Cancelled` and no exit
141    /// code -- deliberately NOT a failure, and deliberately not a new
142    /// [`JobState`](terminal_commander_core::JobState) variant (spec D1).
143    Abandoned,
144}
145
146/// Bounded status shape. Counters + final exit state only.
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct CommandStatusResponse {
149    pub job_id: JobId,
150    pub bucket_id: BucketId,
151    pub probe_id: terminal_commander_core::ProbeId,
152    pub state: terminal_commander_core::JobState,
153    pub frames_total: u64,
154    pub frames_stdout: u64,
155    pub frames_stderr: u64,
156    pub bytes_total: u64,
157    pub events_emitted: u64,
158    #[serde(default)]
159    pub frames_suppressed: u64,
160    #[serde(default)]
161    pub frames_suppressed_progress: u64,
162    #[serde(default)]
163    pub frames_suppressed_dedupe: u64,
164    pub exit_code: Option<i32>,
165    pub signal: Option<String>,
166    pub duration_ms: Option<u64>,
167    /// No-silence receipt; `Some` only for a finished process command
168    /// with zero rule-driven events. See [`CommandReceipt`].
169    pub receipt: Option<CommandReceipt>,
170    /// TC-B3 (FR-027): `true` when this status was reconstructed from a
171    /// PERSISTED job receipt because the in-memory job was gone (a daemon
172    /// restart happened since the job ran).
173    ///
174    /// Retained as the backward-compatible alias for "this outcome was NOT
175    /// observed live", i.e. `outcome_trust != OutcomeTrust::Observed`. It is
176    /// therefore `true` for both `Reconstructed` and `Abandoned`, both of which
177    /// are read back from the durable receipt. Derive it from `outcome_trust`
178    /// rather than setting the two independently, so they cannot drift.
179    ///
180    /// NOTE: an earlier version of this comment said the live counters are
181    /// "zero because the in-memory probe metrics did not survive". That is no
182    /// longer true -- spec 004 persists the evidence a live observer would have
183    /// had, so a reconstructed status carries real counters. Only receipts
184    /// written before that migration lack them.
185    #[serde(default)]
186    pub restarted: bool,
187    /// How this outcome was established. See [`OutcomeTrust`]. Defaults to
188    /// `Observed` so a payload from an older daemon decodes unchanged.
189    #[serde(default)]
190    pub outcome_trust: OutcomeTrust,
191}
192
193/// Params for `command_stop` (TC-3): force-kill a running combed
194/// command by `job_id`.
195///
196/// Mirrors [`PtyCommandStopParams`] for the non-PTY runtime.
197#[derive(Debug, Clone, Serialize, Deserialize)]
198pub struct CommandStopParams {
199    pub job_id: JobId,
200}
201
202/// Bounded response for `command_stop` (TC-3).
203///
204/// Mirrors [`PtyCommandStopResponse`] minus the PTY-only counters
205/// (`stdin_bytes_written`, `secret_prompts_total`), which have no
206/// meaning for a non-interactive combed command.
207#[derive(Debug, Clone, Serialize, Deserialize)]
208pub struct CommandStopResponse {
209    pub job_id: JobId,
210    pub bucket_id: BucketId,
211    pub frames_total: u64,
212    pub events_emitted: u64,
213    pub bytes_total: u64,
214}
215
216/// Hard cap on a complete frame (length prefix + payload). Anything
217/// above this is rejected without payload parse.
218///
219/// 256 KiB is large enough for the JSON shapes ahead (e.g. a full
220/// `bucket_events_since` response capped at 10000 events ~ 25 bytes
221/// per event minimum overhead) and small enough that one malicious
222/// client cannot exhaust daemon memory by streaming a giant frame.
223/// Bucket / event-context responses still carry their own per-call
224/// caps; this is the transport-layer envelope cap.
225pub const MAX_FRAME_BYTES: usize = 256 * 1024;
226
227/// Soft cap on the request side.
228///
229/// Applied before the request is dispatched. Currently identical to
230/// [`MAX_FRAME_BYTES`]; kept as a named constant so future tools can
231/// raise / lower it independently from response sizing.
232pub const MAX_REQUEST_BYTES: usize = MAX_FRAME_BYTES;
233
234/// Soft cap on the response side. Matches the frame cap today.
235pub const MAX_RESPONSE_BYTES: usize = MAX_FRAME_BYTES;
236
237/// Wire correlation id.
238///
239/// The client picks; the server echoes. Used to distinguish responses
240/// on a multiplexed connection. Today the connection is request /
241/// response one-at-a-time; the field future-proofs the protocol.
242pub type CorrelationId = u64;
243
244/// Top-level request envelope.
245#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct RequestEnvelope {
247    pub correlation_id: CorrelationId,
248    pub request: IpcRequest,
249}
250
251/// Top-level response envelope.
252#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct ResponseEnvelope {
254    pub correlation_id: CorrelationId,
255    pub result: IpcResult,
256}
257
258/// Maximum wait timeout the daemon will accept for `bucket_wait`.
259/// Requests above this are clamped at the dispatcher.
260pub const MAX_BUCKET_WAIT_MS: u64 = 30_000;
261/// Default wait timeout when the client omits one.
262pub const DEFAULT_BUCKET_WAIT_MS: u64 = 5_000;
263/// Hard cap on events returned by `bucket_events_since` /
264/// `bucket_wait`. Mirrors the codebase `MAX_READ_LIMIT`.
265pub const MAX_BUCKET_READ_LIMIT: usize = 10_000;
266/// Default events-per-call when the client omits a limit.
267pub const DEFAULT_BUCKET_READ_LIMIT: usize = 200;
268/// Hard cap on context-window frames returned by `event_context`.
269pub const MAX_CONTEXT_FRAMES: u32 = 1024;
270/// Default `before` count when the client omits one.
271pub const DEFAULT_CONTEXT_BEFORE: u32 = 5;
272/// Default `after` count when the client omits one.
273pub const DEFAULT_CONTEXT_AFTER: u32 = 5;
274/// Hard cap on event_context payload bytes. Mirrors the per-ring
275/// `max_bytes` cap; the dispatcher clamps oversize values.
276pub const MAX_CONTEXT_BYTES: usize = 64 * 1024;
277
278/// Maximum number of subscriptions open at once in the in-memory registry.
279///
280/// Opening beyond this returns
281/// [`IpcErrorCode::SubscriptionLimitExceeded`]; the caller frees a
282/// slot via `subscription_close` and retries. (Subscriptions design,
283/// Phase 1, Task 7.)
284pub const MAX_SUBSCRIPTIONS: usize = 64;
285/// Hard cap on in-scope buckets a single subscription scans per pull.
286///
287/// The `list_bucket_ids()` ∩ side-table scan is bounded by this; over-cap
288/// is flagged `truncated`. (Subscriptions design §1 "Routing scan is
289/// bounded".)
290pub const MAX_BUCKETS_PER_SUBSCRIPTION: usize = 200;
291
292/// Hard cap on events returned by one `subscription_pull`. The caller's
293/// `max` is clamped to this; the combined events+liveness response stays
294/// under [`MAX_FRAME_BYTES`]. (Subscriptions design §3 step 8.)
295pub const MAX_PULL_EVENTS: usize = 50;
296/// Default `subscription_pull` timeout when the caller omits `timeout_ms`.
297pub const DEFAULT_PULL_TIMEOUT_MS: u64 = 5_000;
298/// Hard cap on a `subscription_pull` timeout.
299///
300/// Strictly below the unix `DRAIN_CEILING` (10 s) so a blocked pull returns
301/// its normal empty+liveness at its own timeout before a graceful drain
302/// would abort it. (Subscriptions design §3 "Timeout reconciliation".)
303pub const MAX_PULL_TIMEOUT_MS: u64 = 8_000;
304
305/// Method-typed request union.
306///
307/// Method names are namespaced `<domain>_<verb>` to match the MCP tool
308/// names; the rmcp adapter maps each daemon-backed tool 1:1 to a method.
309/// 50 IPC methods are live, including the `audit_since` read surface and the
310/// supervisor-only `quiesce_for_replace` verb.
311/// The full rmcp catalogue exposes 51 granular tools (see
312/// `docs/mcp/TOOL_CONTROL_SURFACE.md` §2); the compact MCP surface instead
313/// advertises five facade tools, gated by `TC_SURFACE=compact`, that forward
314/// to the same IPC methods.
315#[derive(Debug, Clone, Serialize, Deserialize)]
316#[serde(tag = "method", content = "params", rename_all = "snake_case")]
317pub enum IpcRequest {
318    /// Get daemon version, MCP spec revision, callable method list.
319    SystemDiscover,
320    /// Liveness ping. Returns the daemon uptime in seconds.
321    Health,
322    /// Report active policy profile + the configured per-call caps.
323    PolicyStatus,
324    /// Re-run the TC36 self-check; returns the report as text.
325    SelfCheck,
326    /// Cursor-based bucket read. Bounded by `MAX_BUCKET_READ_LIMIT`.
327    BucketEventsSince(BucketEventsSinceParams),
328    /// Realtime wait. Returns heartbeat on timeout, never raw text.
329    BucketWait(BucketWaitParams),
330    /// Bounded summary (counters + severity histogram).
331    BucketSummary(BucketSummaryParams),
332    /// Bounded context window around the event's source pointer.
333    /// Resolved by `(bucket_id, event_id)`; the daemon walks the
334    /// bucket to find the matching event, then resolves
335    /// `(probe_id, pointer.frame_id)` against the context ring.
336    EventContext(EventContextParams),
337    /// Start a non-PTY argv command. Bounded metadata response only;
338    /// never returns raw stdout/stderr. Shell-bridge guard applies.
339    CommandStartCombed(CommandStartParams),
340    /// Lifecycle + counters lookup for a previously started command.
341    CommandStatus(CommandStatusParams),
342    /// Force-kill a running combed command by `job_id` (TC-3). Bounded
343    /// metadata response; never returns raw output.
344    CommandStop(CommandStopParams),
345    /// Rule-free bounded read of a job's captured output tail (F1).
346    CommandOutputTail(CommandOutputTailParams),
347    /// Start a shell-lane command (TC49): run ONE shell line through the
348    /// comb pipeline behind the `allow_shell` capability. Denied by
349    /// default; the wire carries `shell_line` ONLY, never a cap flag.
350    /// Bounded metadata response (reuses [`CommandStartResponse`]);
351    /// never returns raw stdout/stderr.
352    ShellExec(ShellExecParams),
353    /// FTS-backed search over persisted rule definitions.
354    RegistrySearch(RegistrySearchParams),
355    /// Fetch a specific rule definition by id and optional version.
356    RegistryGet(RegistryGetParams),
357    /// Insert a new (rule_id, version+1) row from a validated
358    /// definition. Existing versions are immutable.
359    RegistryUpsert(RegistryUpsertParams),
360    /// Evaluate a rule against bounded sample texts and return the
361    /// emitted draft shapes. Read-only; never persisted.
362    RegistryTest(RegistryTestParams),
363    /// Mark `(rule_id, version)` as active in the in-memory
364    /// activation registry AND record the persistent activation row.
365    RegistryActivate(RegistryActivateParams),
366    /// Import an embedded rule pack by name; optionally promote its
367    /// rules to Active and activate them in one call.
368    RegistryImportPack(RegistryImportPackParams),
369    /// Remove `(rule_id, version)` from the active set and close
370    /// the persistent activation row.
371    RegistryDeactivate(RegistryDeactivateParams),
372    /// US2 (FR-011): deactivate an entire seed pack or an explicit list
373    /// of rule ids in ONE call, under one explicit scope, reporting
374    /// per-rule outcomes. The single-rule [`RegistryDeactivate`] wire
375    /// contract is untouched.
376    RegistryDeactivateBulk(RegistryDeactivateBulkParams),
377    /// Snapshot of every currently-active `(rule_id, version)`. Bounded
378    /// by [`MAX_LIST_LIMIT`] / the request `limit`.
379    RegistryListActive(ListLimitParams),
380    /// US2 (FR-007): suggest candidate parsing rules from bounded
381    /// output samples using PURE heuristics. Returns DRAFT proposals +
382    /// a confidence label + the explicit next-step loop. NEVER
383    /// activates or persists a rule (FR-008 / constitution VII).
384    /// Read-only: a blind retry recomputes the same deterministic
385    /// proposals, so it is idempotent.
386    RegistrySuggestFromSamples(RegistrySuggestFromSamplesParams),
387    /// Bounded line/byte window read of a regular file. Never
388    /// returns the whole file; the daemon clamps the window.
389    FileReadWindow(FileReadWindowParams),
390    /// Bounded substring/regex search over one file. Returns
391    /// structured match pointers + short snippets only.
392    FileSearch(FileSearchParams),
393    /// Bounded single-level listing of one directory (US3). Returns
394    /// name/kind/size/mtime per entry, dirs-first deterministic order,
395    /// capped with a truthful truncation flag. Same read-path policy gate
396    /// as `file_read_window`. Symlinks/reparse points reported by kind,
397    /// never followed.
398    FileListDir(FileListDirParams),
399    /// Write UTF-8 content to a single regular file (TC22 A3).
400    /// MUTATING + NON-idempotent: a blind retry would double-write.
401    /// The daemon policy-gates the canonical target against
402    /// `paths.write_allow`, audits BEFORE the write, bounds the content
403    /// size, and writes atomically (temp file + rename).
404    FileWrite(FileWriteParams),
405    /// Start a daemon-owned file probe that emits structured signal
406    /// events into a bucket as the file is appended to. Never
407    /// streams raw file content.
408    FileWatchStart(FileWatchStartParams),
409    /// Stop a previously-started file watch by `watch_id`.
410    FileWatchStop(FileWatchStopParams),
411    /// Snapshot of every currently-live file watch.
412    FileWatchList,
413    /// Start an interactive PTY argv command. Bounded metadata
414    /// response only; never returns raw screen buffer.
415    PtyCommandStart(PtyCommandStartParams),
416    /// Write bounded stdin bytes to a running PTY job. Returns
417    /// `SecretInputDenied` while a secret prompt is active.
418    PtyCommandWriteStdin(PtyCommandWriteStdinParams),
419    /// Stop a previously started PTY job by `job_id`.
420    PtyCommandStop(PtyCommandStopParams),
421    /// Snapshot of every currently-live PTY job.
422    PtyCommandList,
423    /// Start a persistent shell session (P1 / TC50): a long-lived
424    /// login-shell PTY behind the `allow_session` capability. Denied by
425    /// default; policy-checked + audited before spawn. Bounded metadata
426    /// response (`session_id`/`bucket_id`/`state`); never raw output.
427    ShellSessionStart(ShellSessionStartParams),
428    /// Send ONE line to a live session shell and read back the combed
429    /// signals it produced. Output is read from the session bucket; never
430    /// a raw stream. A send to a non-live session fails loudly.
431    ShellSessionExec(ShellSessionExecParams),
432    /// Query a session's lifecycle state, current cwd, and bounded env
433    /// snapshot.
434    ShellSessionStatus(ShellSessionStatusParams),
435    /// Stop a session (graceful then forced) and report the terminal
436    /// state.
437    ShellSessionStop(ShellSessionStopParams),
438    /// Snapshot of every currently-live session.
439    ShellSessionList,
440    /// Persist a session's cwd + bounded env as a restorable workspace
441    /// snapshot (SQLite). Read-once of session state + a DB write.
442    WorkspaceSnapshotCreate(WorkspaceSnapshotCreateParams),
443    /// Restore a workspace snapshot's cwd/env into a (live) session.
444    WorkspaceSnapshotApply(WorkspaceSnapshotApplyParams),
445    /// TC45: bounded aggregate snapshot across every daemon runtime
446    /// (command, file watch, PTY) plus active rule scopes and
447    /// bucket counters. Read-only. Each of its three vecs is bounded
448    /// INDEPENDENTLY by [`MAX_LIST_LIMIT`] / the request `limit`.
449    RuntimeState(ListLimitParams),
450    /// TC45: flat list of every live probe across all runtimes.
451    /// Read-only. Bounded by [`MAX_LIST_LIMIT`] / the request `limit`.
452    ProbeList(ListLimitParams),
453    /// TC45: bounded lookup for one probe by id. Returns
454    /// `UnknownProbe` if no runtime knows the id.
455    ProbeStatus(ProbeStatusParams),
456    /// Cursor-based read of the persistent audit log. Read-only;
457    /// bounded by [`MAX_AUDIT_READ_LIMIT`]. Read failure surfaces
458    /// [`IpcErrorCode::Internal`] (the closed error set is not widened
459    /// for this method).
460    AuditSince(AuditSinceParams),
461    /// Open a predicate-routed subscription. Mints a fresh opaque
462    /// `sub_id` with its own independent offsets (consumer isolation).
463    /// Initial offsets for already-in-scope buckets are their current
464    /// tail (from-now for a late open). (Subscriptions §4.)
465    SubscriptionOpen(SubscriptionOpenParams),
466    /// Multiplexed, lossless pull over an open subscription. Returns
467    /// bounded, source-tagged events + per-source liveness. Idle returns
468    /// SUCCESS empty+liveness, never an error; unknown/expired `sub_id`
469    /// returns [`IpcErrorCode::UnknownSubscription`]. Blocks up to the
470    /// (clamped) timeout. (Subscriptions §3.)
471    SubscriptionPull(SubscriptionPullParams),
472    /// Bounded snapshot of every open subscription. (Subscriptions §6.)
473    SubscriptionList(SubscriptionListParams),
474    /// Close a subscription, freeing its registry slot. (Subscriptions §4.)
475    SubscriptionClose(SubscriptionCloseParams),
476    /// Reposition one bucket's offset for a subscription (explicit re-read).
477    /// The requested seq is clamped to the bucket's live range. (Subscriptions
478    /// §3 seek.)
479    SubscriptionSeek(SubscriptionSeekParams),
480    /// Request a graceful shutdown. The daemon ACKs immediately
481    /// (`ShutdownAck`), stops accepting new connections, drains in-flight
482    /// requests, removes its pidfile, and exits 0. New connections during the
483    /// drain receive `ShuttingDown` (retryable).
484    Shutdown,
485    /// spec 004 FR-013: ask a daemon that is about to be REPLACED to durably
486    /// record its in-flight jobs as abandoned before it is killed.
487    ///
488    /// The replacer cannot do this itself: the in-flight set lives in the
489    /// outgoing daemon's memory, not on disk. So the outgoing daemon writes its
490    /// own records, and the replacer waits a bounded beat before hard-killing
491    /// as it does today.
492    ///
493    /// Strictly weaker than [`Self::Shutdown`]: spawns nothing, kills nothing,
494    /// exits nothing. It only writes rows about the daemon's own jobs, so it
495    /// carries the same authorization posture -- protected by the local-only
496    /// endpoint and attested peer identity, not by a capability flag.
497    ///
498    /// Quiescing MUST NEVER be able to block a replacement: a timeout, an
499    /// error, or an older daemon that does not know this verb all fall back to
500    /// current behaviour.
501    QuiesceForReplace,
502}
503
504impl IpcRequest {
505    /// Whether this RPC is safe to blindly re-send after a transport
506    /// failure (the daemon pipe/socket dropped mid-call so the client
507    /// never learned the outcome).
508    ///
509    /// Governing rule: return `false` for any RPC whose retry could
510    /// create or duplicate a server-side resource, mint a fresh id, or
511    /// advance server-held state; return `true` only for pure bounded
512    /// reads and idempotent-effect repositioning. When in doubt, return
513    /// `false` -- a missed retry is an error the caller can re-issue
514    /// deliberately, but a silent double-effect cannot be undone.
515    ///
516    /// The match is EXHAUSTIVE (no wildcard arm) so that adding a new
517    /// `IpcRequest` variant fails to compile until it is deliberately
518    /// classified here.
519    #[must_use]
520    pub const fn is_idempotent(&self) -> bool {
521        match self {
522            // Mutating / unsafe to blind-retry: each one creates or
523            // duplicates server-side state, mints a fresh id, or advances
524            // a server-held offset.
525            Self::CommandStartCombed(_)
526            // Shell-lane start (TC49): spawns a fresh `[shell,"-lc",line]`
527            // child + mints a job/bucket exactly like CommandStartCombed,
528            // so a blind retry double-spawns. Non-idempotent.
529            | Self::ShellExec(_)
530            // Force-kill: fires a one-shot cancel + sets the job terminal.
531            // A blind re-send is a harmless no-op on an already-terminal job,
532            // but it is a server-side state MUTATION, so it is classified
533            // non-idempotent alongside the other Command*/Pty* mutators.
534            | Self::CommandStop(_)
535            | Self::PtyCommandStart(_)
536            | Self::PtyCommandWriteStdin(_)
537            | Self::PtyCommandStop(_)
538            // Session lane (P1 / TC50): start spawns a fresh session
539            // shell + mints ids; exec writes stdin + advances the read
540            // cursor server-side; stop fires a one-shot cancel; the
541            // workspace snapshot create/apply mutate persisted state or
542            // re-inject cwd/env into the session shell. All non-idempotent
543            // alongside the PTY mutators.
544            | Self::ShellSessionStart(_)
545            | Self::ShellSessionExec(_)
546            | Self::ShellSessionStop(_)
547            | Self::WorkspaceSnapshotCreate(_)
548            | Self::WorkspaceSnapshotApply(_)
549            | Self::RegistryUpsert(_)
550            | Self::RegistryActivate(_)
551            | Self::RegistryDeactivate(_)
552            // Bulk deactivate mutates the active set (durable rows +
553            // in-memory authority) exactly like the single-rule form; a
554            // blind retry re-closes already-closed rows (harmless no-op)
555            // but is still a server-side mutation, so it groups with the
556            // other registry mutators.
557            | Self::RegistryDeactivateBulk(_)
558            | Self::RegistryImportPack(_)
559            // File WRITE (TC22 A3): creates or overwrites a file on disk.
560            // A blind retry double-writes (or re-truncates) the target, so
561            // it MUST be non-idempotent -- the BACKLOG P0.1 client self-heal
562            // can never auto-retry a write. Classified MUTATING alongside
563            // command_start / file_watch_start, NOT with the read-only
564            // file_read_window / file_search.
565            | Self::FileWrite(_)
566            | Self::FileWatchStart(_)
567            | Self::FileWatchStop(_)
568            // Mints a fresh sub_id + a registry slot; a blind retry leaks
569            // a slot and can trip SubscriptionLimitExceeded.
570            | Self::SubscriptionOpen(_)
571            // Frees a slot; conservative non-retry.
572            | Self::SubscriptionClose(_)
573            // NOT a safe read: per-consumer offsets are advanced and
574            // committed SERVER-SIDE inside the pull (the drain advances
575            // offsets and `commit` persists them, subscriptions/pull.rs
576            // commit sites at 543/633) BEFORE the response is serialized.
577            // A lost-then-
578            // retried pull restarts from the already-advanced offset, so
579            // the previously-drained events are silently dropped --
580            // converting the documented lossless pull into a lossy one.
581            // Contrast BucketWait, which is client-cursor-driven and fully
582            // replayable.
583            | Self::SubscriptionPull(_)
584            | Self::Shutdown
585            // spec 004 FR-013. The write itself is idempotent (INSERT OR
586            // REPLACE of the same terminal row), but it is still a server-side
587            // mutation and the caller is about to kill this daemon anyway --
588            // a blind retry buys nothing. Grouped conservatively with Shutdown.
589            | Self::QuiesceForReplace => false,
590
591            // Pure bounded reads + idempotent-effect repositioning: a
592            // retry observes state without changing it (or re-applies the
593            // same absolute reposition).
594            Self::Health
595            | Self::SystemDiscover
596            | Self::PolicyStatus
597            | Self::SelfCheck
598            | Self::CommandStatus(_)
599            | Self::CommandOutputTail(_)
600            | Self::BucketWait(_)
601            | Self::BucketEventsSince(_)
602            | Self::BucketSummary(_)
603            | Self::EventContext(_)
604            | Self::RuntimeState(_)
605            | Self::ProbeList(_)
606            | Self::ProbeStatus(_)
607            | Self::PtyCommandList
608            // Session read-only lookups: pure bounded reads, replayable.
609            | Self::ShellSessionStatus(_)
610            | Self::ShellSessionList
611            | Self::FileReadWindow(_)
612            | Self::FileSearch(_)
613            // Directory listing (US3): a pure bounded read, replayable and
614            // side-effect free, so it groups with the file read/search reads.
615            | Self::FileListDir(_)
616            | Self::FileWatchList
617            | Self::RegistrySearch(_)
618            | Self::RegistryGet(_)
619            | Self::RegistryTest(_)
620            | Self::RegistryListActive(_)
621            // Suggestion is a pure deterministic heuristic over the
622            // supplied samples: a retry recomputes the identical
623            // proposal set and never activates/persists anything.
624            | Self::RegistrySuggestFromSamples(_)
625            | Self::SubscriptionList(_)
626            // Set-position (absolute clamped offset), not advance-position,
627            // so a re-send re-applies the same reposition. Caveat: the
628            // clamp target is live state (head_seq/tail_seq), so a retry
629            // after bucket eviction may clamp to a later position than the
630            // first attempt -- identical to the hazard of any deliberate
631            // re-seek, hence still idempotent in shape.
632            | Self::SubscriptionSeek(_)
633            | Self::AuditSince(_) => true,
634        }
635    }
636}
637
638/// Success / error union. Success carries a typed payload per method;
639/// error carries a structured code + message.
640///
641/// Boxing the `Ok` payload would change neither the JSON wire form
642/// nor the public Rust API (serde renders `Box<T>` as `T`), so the
643/// large-variant lint is suppressed in favor of keeping the
644/// pattern-matched shape that every dispatcher and test uses.
645#[derive(Debug, Clone, Serialize, Deserialize)]
646#[serde(rename_all = "snake_case", tag = "kind")]
647#[allow(clippy::large_enum_variant)]
648pub enum IpcResult {
649    Ok { response: IpcResponse },
650    Err { error: IpcError },
651}
652
653/// Method-typed success union. Each variant matches one
654/// [`IpcRequest`] variant.
655#[derive(Debug, Clone, Serialize, Deserialize)]
656#[serde(rename_all = "snake_case", tag = "method")]
657pub enum IpcResponse {
658    SystemDiscover(DiscoverResponse),
659    Health {
660        uptime_secs: u64,
661        /// Seconds since the last real IPC request. Optional for
662        /// backward compat: a legacy daemon omits it; clients treat
663        /// absence as unknown.
664        #[serde(default)]
665        idle_secs: Option<u64>,
666        /// The responding daemon's own compile-time crate version
667        /// (`env!("CARGO_PKG_VERSION")`). Lets a client assert WHICH
668        /// build is live. `#[serde(default)]` keeps back-compat: a
669        /// legacy daemon omits it, so an empty string means "unknown".
670        #[serde(default)]
671        version: String,
672    },
673    PolicyStatus(PolicyStatusResponse),
674    SelfCheck(SelfCheckResponse),
675    BucketEventsSince(BucketEventsSinceResponse),
676    BucketWait(BucketWaitResponse),
677    BucketSummary(BucketSummaryResponse),
678    EventContext(EventContextResponse),
679    CommandStartCombed(CommandStartResponse),
680    CommandStatus(CommandStatusResponse),
681    CommandStop(CommandStopResponse),
682    CommandOutputTail(CommandOutputTailResponse),
683    RegistrySearch(RegistrySearchResponse),
684    RegistryGet(RegistryGetResponse),
685    RegistryUpsert(RegistryUpsertResponse),
686    RegistryTest(RegistryTestResponse),
687    RegistryActivate(RegistryActivateResponse),
688    RegistryImportPack(RegistryImportPackResponse),
689    RegistryDeactivate(RegistryDeactivateResponse),
690    RegistryDeactivateBulk(RegistryDeactivateBulkResponse),
691    RegistryListActive(RegistryListActiveResponse),
692    RegistrySuggestFromSamples(RegistrySuggestFromSamplesResponse),
693    FileReadWindow(FileReadWindowResponse),
694    FileSearch(FileSearchResponse),
695    FileListDir(FileListDirResponse),
696    FileWrite(FileWriteResponse),
697    FileWatchStart(FileWatchStartResponse),
698    FileWatchStop(FileWatchStopResponse),
699    FileWatchList(FileWatchListResponse),
700    PtyCommandStart(PtyCommandStartResponse),
701    PtyCommandWriteStdin(PtyCommandWriteStdinResponse),
702    PtyCommandStop(PtyCommandStopResponse),
703    PtyCommandList(PtyCommandListResponse),
704    ShellSessionStart(ShellSessionStartResponse),
705    ShellSessionExec(ShellSessionExecResponse),
706    ShellSessionStatus(ShellSessionStatusResponse),
707    ShellSessionStop(ShellSessionStopResponse),
708    ShellSessionList(ShellSessionListResponse),
709    WorkspaceSnapshotCreate(WorkspaceSnapshotCreateResponse),
710    WorkspaceSnapshotApply(WorkspaceSnapshotApplyResponse),
711    RuntimeState(RuntimeStateResponse),
712    ProbeList(ProbeListResponse),
713    ProbeStatus(ProbeStatusResponse),
714    AuditSince(AuditSinceResponse),
715    SubscriptionOpen(SubscriptionOpenResponse),
716    SubscriptionPull(SubscriptionPullResponse),
717    SubscriptionList(SubscriptionListResponse),
718    SubscriptionClose(SubscriptionCloseResponse),
719    SubscriptionSeek(SubscriptionSeekResponse),
720    /// Ack for `Shutdown`. `draining=true` once the daemon has stopped accepting
721    /// new connections and begun draining.
722    ShutdownAck {
723        draining: bool,
724    },
725    /// ACK for [`IpcRequest::QuiesceForReplace`]: how many in-flight jobs were
726    /// recorded as abandoned. Zero is a normal answer (an idle daemon).
727    QuiesceAck {
728        recorded: u32,
729    },
730}
731
732/// Evidence-backed host program probe used by `system_discover`.
733#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
734pub struct ProgramProbe {
735    pub name: String,
736    pub available: bool,
737    #[serde(default, skip_serializing_if = "Option::is_none")]
738    pub path: Option<String>,
739    #[serde(default, skip_serializing_if = "Option::is_none")]
740    pub version: Option<String>,
741    pub evidence: String,
742    pub version_status: String,
743    #[serde(default)]
744    pub execution_status: String,
745}
746
747/// Terminal/session markers inherited by the daemon process.
748#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
749pub struct TerminalProbe {
750    pub kind: String,
751    pub evidence: String,
752    #[serde(default, skip_serializing_if = "Option::is_none")]
753    pub name: Option<String>,
754    #[serde(default, skip_serializing_if = "Option::is_none")]
755    pub version: Option<String>,
756    #[serde(default, skip_serializing_if = "Option::is_none")]
757    pub interactive: Option<bool>,
758    pub ci: bool,
759}
760
761/// Bounded WSL availability details on Windows; unavailable elsewhere.
762#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
763pub struct WslProbe {
764    pub available: bool,
765    pub status: String,
766    #[serde(default, skip_serializing_if = "Option::is_none")]
767    pub version: Option<String>,
768    #[serde(default, skip_serializing_if = "Vec::is_empty")]
769    pub distributions: Vec<String>,
770    #[serde(default, skip_serializing_if = "Option::is_none")]
771    pub default_shell: Option<String>,
772    #[serde(default)]
773    pub execution_status: String,
774}
775
776/// A verified way to cross from the daemon host into a command interpreter.
777#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
778pub struct AccessRoute {
779    pub route_id: String,
780    pub rank: u16,
781    pub kind: String,
782    pub family: String,
783    pub executable: String,
784    pub argv_template: Vec<String>,
785    #[serde(default, skip_serializing_if = "Option::is_none")]
786    pub version: Option<String>,
787    pub evidence: String,
788}
789
790/// Bounded, evidence-backed execution environment snapshot.
791#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
792pub struct HostEnvironment {
793    pub os: String,
794    pub arch: String,
795    pub terminal: TerminalProbe,
796    pub shells: Vec<ProgramProbe>,
797    pub tools: Vec<ProgramProbe>,
798    pub wsl: WslProbe,
799    #[serde(default, skip_serializing_if = "Vec::is_empty")]
800    pub access_routes: Vec<AccessRoute>,
801    #[serde(default, skip_serializing_if = "Option::is_none")]
802    pub beachhead: Option<AccessRoute>,
803    #[serde(default, skip_serializing_if = "Option::is_none")]
804    pub preferred_shell: Option<String>,
805    pub discovery_ms: u64,
806}
807
808/// `system_discover` payload. Mirrors the contract laid out in
809/// `docs/mcp/TOOL_CONTROL_SURFACE.md`. The advertised method list is
810/// tied to the dispatcher's actual handler set.
811#[derive(Debug, Clone, Serialize, Deserialize)]
812pub struct DiscoverResponse {
813    pub version: String,
814    pub mcp_spec: String,
815    pub policy_profile: String,
816    pub methods: Vec<String>,
817    /// Host evidence is additive so newer adapters can still decode older
818    /// daemon responses as an empty/unknown snapshot.
819    #[serde(default)]
820    pub environment: Box<HostEnvironment>,
821}
822
823/// The four resolved per-call capabilities (POLICY.md section 4.1).
824///
825/// Surfaced on [`PolicyStatusResponse`] so an operator can see the ACTIVE caps
826/// -- including those preset ON by `full_access` -- without reading TOML.
827/// These are the values the policy engine actually evaluates against (base
828/// profile `||` `full_access` preset), never the raw config.
829// 4 independent opt-in capability flags; a bitfield/enum would hurt the wire/serde surface
830#[allow(clippy::struct_excessive_bools)]
831#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
832pub struct PolicyCapsView {
833    /// Gates the `shell_exec` lane (TC49).
834    pub allow_shell: bool,
835    /// Gates the `shell_session_*` lane (TC50; not yet live).
836    pub allow_session: bool,
837    /// Gates the Wave-4 privileged helper (not yet live).
838    pub allow_privileged: bool,
839    /// Gates remote federation / `target_id` (Wave 5; not yet live).
840    pub allow_remote: bool,
841}
842
843/// `policy_status` payload.
844#[derive(Debug, Clone, Serialize, Deserialize)]
845pub struct PolicyStatusResponse {
846    pub profile: String,
847    pub commands_deny_count: usize,
848    pub default_deny_path_suffix_count: usize,
849    /// Per-call file_read_window cap (from `LimitsSection`, clamped
850    /// at config load to the codebase hard cap).
851    pub file_window_bytes: usize,
852    /// Per-call bucket-read cap.
853    pub bucket_read_limit: usize,
854    /// Resolved per-call capabilities (POLICY.md section 4.1). Exposes the
855    /// caps the engine evaluates against -- so `full_access` (all preset ON)
856    /// and a base profile + `[policy.caps] allow_shell = true` both show the
857    /// active set, with no opaque "full_access magic".
858    pub caps: PolicyCapsView,
859}
860
861/// `self_check` payload.
862#[derive(Debug, Clone, Serialize, Deserialize)]
863pub struct SelfCheckResponse {
864    pub report: String,
865    pub failures: u32,
866}
867
868/// Structured error code. Closed set. Adding a variant requires a
869/// goal-file amendment.
870#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
871#[serde(rename_all = "snake_case")]
872pub enum IpcErrorCode {
873    /// Frame exceeded [`MAX_FRAME_BYTES`].
874    FrameTooLarge,
875    /// Payload was not valid UTF-8 JSON.
876    MalformedJson,
877    /// Payload decoded but didn't match the wire schema.
878    SchemaMismatch,
879    /// Method not recognized.
880    UnknownMethod,
881    /// Policy engine denied the request.
882    PolicyDenied,
883    /// Daemon-internal error while handling the request.
884    Internal,
885    /// Peer credential check failed; connection refused.
886    PeerCredentialFailure,
887    /// Platform does not support UDS (Windows native).
888    UnsupportedPlatform,
889    /// The requested bucket does not exist.
890    BucketNotFound,
891    /// The requested event id was not found in the bucket.
892    EventNotFound,
893    /// The cursor is invalid (e.g. far above the current tail).
894    InvalidCursor,
895    /// `argv[0]` basename matches the shell-bridge deny list.
896    /// `command_start_combed` is not a shell entry point.
897    ShellInterpreterDenied,
898    /// F7: the program named in `argv[0]` does not exist (the OS spawn
899    /// returned `ErrorKind::NotFound`). A CALLER-fixable command attempt
900    /// (typo / wrong PATH / missing binary), NOT a daemon or transport
901    /// fault. Surfaced as a structured `program_not_found` receipt at the
902    /// MCP boundary (`invalid_params`, carrying `error_kind` + `argv0`)
903    /// instead of an opaque `Internal` error so the agent corrects its
904    /// argv and keeps routing through Terminal Commander. Distinct from
905    /// every other spawn failure, which stays `Internal`.
906    ProgramNotFound,
907    /// argv shape is invalid (empty, too long, or item too large).
908    ArgvInvalid,
909    /// `command_status` was called with a job id the daemon does not
910    /// know.
911    UnknownJob,
912    /// `command_status` was called with a job id the daemon durably recorded
913    /// STARTING but never recorded finishing -- the daemon died before the
914    /// terminal transition. Distinct from [`Self::UnknownJob`], which means no
915    /// durable record exists at all. Never accompanied by a terminal outcome.
916    ///
917    /// Deliberately an error code rather than a lifecycle state: "the daemon
918    /// lost the thread" is not a job lifecycle state, and an unknown error code
919    /// fails closed for older clients.
920    JobLost,
921    /// `registry_get` / `registry_test` / `registry_activate` /
922    /// `registry_deactivate` referenced a `(rule_id, version?)` the
923    /// daemon does not know.
924    RuleNotFound,
925    /// `registry_upsert` or `registry_test` payload failed rule
926    /// validation (empty id, bad regex, kind/keywords mismatch,
927    /// etc.).
928    RuleInvalid,
929    /// `registry_activate` / `registry_deactivate` was issued with
930    /// a scope value the daemon cannot resolve to a live entity
931    /// (unknown bucket / job / probe id) or with a malformed scope
932    /// payload. The activation is NOT silently widened to Global.
933    ScopeInvalid,
934    /// `file_*` request referenced a path the policy engine rejected
935    /// (default-deny suffix or future per-profile path policy).
936    PathDenied,
937    /// `file_*` request referenced a path that does not exist on
938    /// disk OR is not a regular file (directories rejected here so
939    /// TC43 does not balloon into directory probe expansion).
940    FileNotFound,
941    /// `file_read_window` / `file_search` detected non-UTF-8 bytes
942    /// in the requested window. Binary content is rejected with a
943    /// typed code instead of streaming bytes to the LLM.
944    FileBinary,
945    /// Request exceeds a bounded cap (line count, byte count, glob
946    /// breadth, search result count). The dispatcher clamps where
947    /// safe; payloads that cannot be clamped surface this code.
948    OversizedRequest,
949    /// `file_watch_stop` referenced a watch id the daemon does not
950    /// know.
951    UnknownWatch,
952    /// `pty_command_write_stdin` was issued while the target PTY job
953    /// has an active secret prompt. The LLM input MUST NOT be
954    /// written. TC44 contract: no automatic password entry, no
955    /// LLM-supplied password forwarding.
956    SecretInputDenied,
957    /// `probe_status` referenced a probe id the daemon does not
958    /// know across any of its runtimes.
959    UnknownProbe,
960    /// `registry_activate` referenced a rule whose status is not
961    /// runtime-eligible (Draft / Deprecated / Tombstoned). Activating
962    /// a non-Active rule would silently bind a definition the sifter
963    /// runtime then rejects at command-start time with
964    /// `SifterError::NotActive`, blocking every newly-started command
965    /// in scope. The activation is refused up front with the remedy
966    /// in the message (promote the rule to status=Active and re-upsert)
967    /// rather than poisoning the scope. See the agent-ergonomics chain.
968    RuleNotActive,
969    /// `subscription_pull`/`subscription_close` referenced a `sub_id` the
970    /// daemon does not know (unknown or reset by a daemon restart). Caller
971    /// re-opens. Approved goal-file amendment 2026-06-02.
972    UnknownSubscription,
973    /// `subscription_open` exceeded the max-subscriptions cap. Caller frees
974    /// a slot (subscription_close) and retries. Approved 2026-06-02.
975    SubscriptionLimitExceeded,
976    /// `shell_session_*` referenced a `session_id` the daemon does not
977    /// know (never started, already reaped, or reset by a daemon
978    /// restart). P1 / TC50 (omni spec 001).
979    UnknownSession,
980    /// `shell_session_exec` (or a snapshot apply) targeted a session that
981    /// is not in the `Live` state. The terminal-state guard refuses the
982    /// send loudly instead of hanging on a dead shell. P1 / TC50.
983    SessionNotLive,
984    /// `shell_session_start` was refused because the configured
985    /// `max_sessions` cap is already reached. Caller stops a session and
986    /// retries. P1 / TC50.
987    SessionLimitExceeded,
988    /// Returned to a new request that arrives while the daemon is draining for
989    /// shutdown. Retryable: the client should cold-spawn a fresh daemon.
990    ShuttingDown,
991}
992
993/// Structured error payload.
994#[derive(Debug, Clone, Serialize, Deserialize)]
995pub struct IpcError {
996    pub code: IpcErrorCode,
997    pub message: String,
998    /// F7: the offending `argv[0]` for an [`IpcErrorCode::ProgramNotFound`]
999    /// error, carried as a TYPED field rather than recovered by parsing the
1000    /// human-readable `message`. The message still names the program for logs
1001    /// and humans, but this field is the authoritative source the MCP boundary
1002    /// reads to populate the `argv0` data key -- so the receipt survives any
1003    /// wording change to `message` (including apostrophes in the program name,
1004    /// which the old prose quote-count parse could not handle). Additive and
1005    /// `serde(default)`: omitted from the wire when `None`, so every other
1006    /// error (and any pre-F7 client payload) round-trips unchanged.
1007    #[serde(default, skip_serializing_if = "Option::is_none")]
1008    pub argv0: Option<String>,
1009    /// F14: the MCP tool name for an [`IpcErrorCode::UnsupportedPlatform`]
1010    /// error, carried as a TYPED field so the MCP boundary can name the
1011    /// caller-routable tool (e.g. `shell_session_start`) in its structured
1012    /// `unsupported_platform` receipt without parsing the human-readable
1013    /// `message`. An unsupported platform is a caller-ROUTABLE fact (route to
1014    /// WSL / a different tool), not a server fault, so the receipt must say
1015    /// WHICH tool is unavailable here. Additive and `serde(default)`: omitted
1016    /// from the wire when `None`, so every other error round-trips unchanged.
1017    #[serde(default, skip_serializing_if = "Option::is_none")]
1018    pub tool: Option<String>,
1019}
1020
1021impl IpcError {
1022    /// Marker lead-in for CLIENT-SIDE transport failures (connect, write,
1023    /// read, request timeout, correlation mismatch). These never carry a
1024    /// daemon-authored payload -- they fail before, or instead of, a decoded
1025    /// response -- so the marker lets a caller distinguish "could not reach
1026    /// the daemon" (recoverable: re-ensure + retry, then surface a clean
1027    /// `daemon_unavailable` envelope) from a daemon-RETURNED [`IpcErrorCode`]
1028    /// (a real, caller-actionable fault). The marker is a human-readable
1029    /// prefix on an otherwise-`Internal` error so the rendered message stays
1030    /// meaningful while [`IpcError::is_transport`] can detect it without
1031    /// fragile substring scanning of OS error text. The daemon NEVER
1032    /// constructs transport errors, so its `Internal` errors are never
1033    /// misclassified.
1034    pub const TRANSPORT_PREFIX: &'static str = "transport: ";
1035
1036    /// Constructor.
1037    #[must_use]
1038    pub fn new(code: IpcErrorCode, message: impl Into<String>) -> Self {
1039        Self {
1040            code,
1041            message: message.into(),
1042            argv0: None,
1043            tool: None,
1044        }
1045    }
1046
1047    /// F7: construct a [`IpcErrorCode::ProgramNotFound`] error that carries the
1048    /// offending `argv[0]` as a TYPED field. The `message` is still
1049    /// human/log-facing (and may name the program however it likes), but the
1050    /// `argv0` field -- not the prose -- is the authoritative value the MCP
1051    /// boundary reads into the structured `program_not_found` receipt.
1052    #[must_use]
1053    pub fn program_not_found(argv0: impl Into<String>, message: impl Into<String>) -> Self {
1054        Self {
1055            code: IpcErrorCode::ProgramNotFound,
1056            message: message.into(),
1057            argv0: Some(argv0.into()),
1058            tool: None,
1059        }
1060    }
1061
1062    /// F14: construct an [`IpcErrorCode::UnsupportedPlatform`] error that
1063    /// carries the offending MCP `tool` name as a TYPED field. The `message`
1064    /// stays human/log-facing, but the `tool` field -- not the prose -- is the
1065    /// authoritative value the MCP boundary reads into the structured
1066    /// `unsupported_platform` receipt. An unsupported platform is a
1067    /// caller-ROUTABLE fact (route to WSL / a different tool), so naming the
1068    /// unavailable tool keeps the agent reasoning instead of abandoning TC.
1069    #[must_use]
1070    pub fn unsupported_platform(tool: &str, message: impl Into<String>) -> Self {
1071        Self {
1072            code: IpcErrorCode::UnsupportedPlatform,
1073            message: message.into(),
1074            argv0: None,
1075            tool: Some(tool.to_owned()),
1076        }
1077    }
1078
1079    /// Construct a client-side TRANSPORT failure: an [`IpcErrorCode::Internal`]
1080    /// error tagged with [`Self::TRANSPORT_PREFIX`] so [`Self::is_transport`]
1081    /// recognizes it. Use this only for failures to REACH or COMMUNICATE with
1082    /// the daemon (connect / write / read / timeout / correlation mismatch),
1083    /// never for a daemon-returned error.
1084    #[must_use]
1085    pub fn transport(message: impl AsRef<str>) -> Self {
1086        Self {
1087            code: IpcErrorCode::Internal,
1088            message: format!("{}{}", Self::TRANSPORT_PREFIX, message.as_ref()),
1089            argv0: None,
1090            tool: None,
1091        }
1092    }
1093
1094    /// True when this is a client-side transport failure (see
1095    /// [`Self::transport`]). Distinguishes "could not reach the daemon" from a
1096    /// daemon-returned error so the MCP adapter can self-heal + retry and then
1097    /// surface a clean `daemon_unavailable` envelope instead of a raw
1098    /// `internal_error` (-32603) that trains agents to abandon the tool.
1099    #[must_use]
1100    pub fn is_transport(&self) -> bool {
1101        self.code == IpcErrorCode::Internal && self.message.starts_with(Self::TRANSPORT_PREFIX)
1102    }
1103}
1104
1105/// Parameters for `bucket_events_since`.
1106#[derive(Debug, Clone, Serialize, Deserialize)]
1107pub struct BucketEventsSinceParams {
1108    pub bucket_id: BucketId,
1109    pub cursor: u64,
1110    /// Optional minimum severity. Omitted = `trace` (no filter).
1111    #[serde(default, skip_serializing_if = "Option::is_none")]
1112    pub severity_min: Option<terminal_commander_core::Severity>,
1113    /// Optional exact-match kind filter.
1114    #[serde(default, skip_serializing_if = "Option::is_none")]
1115    pub kind_filter: Option<String>,
1116    /// Result count cap. Clamped to `MAX_BUCKET_READ_LIMIT` at the
1117    /// dispatcher. Omitted = `DEFAULT_BUCKET_READ_LIMIT`.
1118    #[serde(default, skip_serializing_if = "Option::is_none")]
1119    pub limit: Option<usize>,
1120}
1121
1122/// Response shape for `bucket_events_since` / `bucket_wait`.
1123#[derive(Debug, Clone, Serialize, Deserialize)]
1124pub struct BucketEventsSinceResponse {
1125    pub bucket_id: BucketId,
1126    pub cursor_in: u64,
1127    pub next_cursor: u64,
1128    pub has_more: bool,
1129    pub dropped_count: u64,
1130    pub events: Vec<SignalEvent>,
1131}
1132
1133/// Parameters for `bucket_wait`.
1134#[derive(Debug, Clone, Serialize, Deserialize)]
1135pub struct BucketWaitParams {
1136    pub bucket_id: BucketId,
1137    pub cursor: u64,
1138    #[serde(default, skip_serializing_if = "Option::is_none")]
1139    pub severity_min: Option<terminal_commander_core::Severity>,
1140    #[serde(default, skip_serializing_if = "Option::is_none")]
1141    pub kind_filter: Option<String>,
1142    #[serde(default, skip_serializing_if = "Option::is_none")]
1143    pub limit: Option<usize>,
1144    /// Maximum wait in milliseconds. Clamped to `MAX_BUCKET_WAIT_MS`.
1145    /// Omitted = `DEFAULT_BUCKET_WAIT_MS`.
1146    #[serde(default, skip_serializing_if = "Option::is_none")]
1147    pub timeout_ms: Option<u64>,
1148}
1149
1150impl BucketWaitParams {
1151    /// Resolve the effective `Duration`, clamping to the hard cap.
1152    #[must_use]
1153    pub fn timeout(&self) -> Duration {
1154        let raw = self.timeout_ms.unwrap_or(DEFAULT_BUCKET_WAIT_MS);
1155        Duration::from_millis(raw.min(MAX_BUCKET_WAIT_MS))
1156    }
1157}
1158
1159/// Response shape for `bucket_wait`. Identical to
1160/// `BucketEventsSinceResponse` plus a `heartbeat` flag.
1161#[derive(Debug, Clone, Serialize, Deserialize)]
1162pub struct BucketWaitResponse {
1163    pub bucket_id: BucketId,
1164    pub cursor_in: u64,
1165    pub next_cursor: u64,
1166    /// `true` when the wait timed out and no matching events
1167    /// arrived. The `events` array MUST be empty in that case.
1168    pub heartbeat: bool,
1169    pub dropped_count: u64,
1170    pub events: Vec<SignalEvent>,
1171}
1172
1173/// Parameters for `bucket_summary`.
1174#[derive(Debug, Clone, Serialize, Deserialize)]
1175pub struct BucketSummaryParams {
1176    pub bucket_id: BucketId,
1177}
1178
1179/// Response shape for `bucket_summary`. Counters only; never raw
1180/// stream content.
1181#[derive(Debug, Clone, Serialize, Deserialize)]
1182pub struct BucketSummaryResponse {
1183    pub bucket_id: BucketId,
1184    pub head_seq: u64,
1185    pub tail_seq: u64,
1186    pub event_count: u64,
1187    pub dropped_count: u64,
1188    /// Per-severity histogram (trace / debug / info / low / medium /
1189    /// high / critical), in wire-stable order.
1190    pub by_severity: SeverityHistogram,
1191}
1192
1193/// Wire-stable severity histogram. Independent of the in-memory
1194/// `BucketSummary` so the protocol locks the field order.
1195#[derive(Debug, Clone, Copy, Default, Serialize, Deserialize)]
1196pub struct SeverityHistogram {
1197    pub trace: u64,
1198    pub debug: u64,
1199    pub info: u64,
1200    pub low: u64,
1201    pub medium: u64,
1202    pub high: u64,
1203    pub critical: u64,
1204}
1205
1206/// Parameters for `event_context`. Resolves the event's source
1207/// pointer and returns bounded context around that frame.
1208#[derive(Debug, Clone, Serialize, Deserialize)]
1209pub struct EventContextParams {
1210    /// NOW OPTIONAL (US5 / FR-040). Supplied: exactly today's
1211    /// single-bucket resolution (event absent from that bucket =
1212    /// `EventNotFound`, so a contradicting `bucket_id` is an error, never
1213    /// silently ignored). Absent: the daemon resolves the owning bucket
1214    /// by scanning in-scope buckets for the globally-unique `event_id`.
1215    #[serde(default, skip_serializing_if = "Option::is_none")]
1216    pub bucket_id: Option<BucketId>,
1217    pub event_id: EventId,
1218    /// Frames to include BEFORE the anchor. Clamped to
1219    /// `MAX_CONTEXT_FRAMES`. Omitted = `DEFAULT_CONTEXT_BEFORE`.
1220    #[serde(default, skip_serializing_if = "Option::is_none")]
1221    pub before: Option<u32>,
1222    /// Frames to include AFTER the anchor. Clamped to
1223    /// `MAX_CONTEXT_FRAMES`. Omitted = `DEFAULT_CONTEXT_AFTER`.
1224    #[serde(default, skip_serializing_if = "Option::is_none")]
1225    pub after: Option<u32>,
1226    /// Hard byte cap on the response. Clamped to
1227    /// `MAX_CONTEXT_BYTES`. Omitted = `MAX_CONTEXT_BYTES`.
1228    #[serde(default, skip_serializing_if = "Option::is_none")]
1229    pub max_bytes: Option<usize>,
1230}
1231
1232/// One context frame on the wire. Same shape as `core::ContextLine`
1233/// but kept inside the IPC module so the protocol owns its serde
1234/// surface.
1235#[derive(Debug, Clone, Serialize, Deserialize)]
1236pub struct IpcContextFrame {
1237    pub probe_id: terminal_commander_core::ProbeId,
1238    pub frame_id: terminal_commander_core::FrameId,
1239    pub stream: terminal_commander_core::SourceStream,
1240    pub line: Option<u64>,
1241    pub text: String,
1242}
1243
1244/// Reasons a context window may be empty or partial.
1245#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
1246#[serde(rename_all = "snake_case")]
1247pub enum ContextUnavailableReason {
1248    /// Severity below Medium: event carries no pointer by design.
1249    NoPointer,
1250    /// Event carried a `pointer_unavailable_reason` instead of a
1251    /// pointer (synthetic lifecycle events).
1252    SyntheticEvent,
1253    /// Anchor frame already evicted from the ring.
1254    AnchorEvicted,
1255    /// Probe id not found in the context-ring manager.
1256    UnknownProbe,
1257}
1258
1259/// Response shape for `event_context`.
1260#[derive(Debug, Clone, Serialize, Deserialize)]
1261pub struct EventContextResponse {
1262    pub bucket_id: BucketId,
1263    pub event_id: EventId,
1264    /// `true` when the anchor frame was no longer in the ring at
1265    /// resolution time.
1266    pub anchor_missing: bool,
1267    /// Set when the daemon could not produce a window for a
1268    /// non-error reason (severity below threshold, synthetic event,
1269    /// etc.). When set, `frames` is empty.
1270    #[serde(default, skip_serializing_if = "Option::is_none")]
1271    pub unavailable_reason: Option<ContextUnavailableReason>,
1272    /// Echo of the event's `pointer_unavailable_reason` if the event
1273    /// carried one. Never raw stream content.
1274    #[serde(default, skip_serializing_if = "Option::is_none")]
1275    pub pointer_unavailable_reason: Option<String>,
1276    /// Bounded window. Empty when `anchor_missing` or
1277    /// `unavailable_reason` is set.
1278    pub frames: Vec<IpcContextFrame>,
1279    /// Reported by the underlying ring; helps clients reason about
1280    /// truncation.
1281    pub total_bytes: usize,
1282    pub truncated: bool,
1283}
1284
1285/// Maximum number of explicit env entries on `command_start_combed`.
1286/// Symmetric with `MAX_ARGV_ITEMS`; protects the wire path from
1287/// accidental fan-out via env.
1288pub const MAX_COMMAND_ENV_ITEMS: usize = 256;
1289/// Maximum number of inline rules accepted on `command_start_combed`.
1290/// Hot rule binding is TC42 territory; TC41 only accepts the empty
1291/// default unless the operator passes a small per-call list.
1292pub const MAX_COMMAND_INLINE_RULES: usize = 64;
1293/// Maximum grace window before forced terminate. Clamped at the
1294/// dispatcher.
1295pub const MAX_COMMAND_GRACE_MS: u64 = 60_000;
1296
1297/// Serde default for `bool` fields that default to `true` (e.g.
1298/// `CommandStartParams::strip_ansi`). A bare `#[serde(default)]` would
1299/// yield `false`, inverting the intended TC-B1 default; this helper keeps
1300/// an omitted field meaning "strip on".
1301const fn default_true() -> bool {
1302    true
1303}
1304
1305/// Wire shape for `command_start_combed`. Mirrors the daemon's
1306/// `CommandStartRequest` but uses millis instead of `Duration` so the
1307/// JSON form stays human-readable.
1308#[derive(Debug, Clone, Serialize, Deserialize)]
1309pub struct CommandStartParams {
1310    /// Target environment (default local parent).
1311    #[serde(default, skip_serializing_if = "Option::is_none")]
1312    pub environment: Option<terminal_commander_core::EnvironmentSpec>,
1313    /// argv. `argv[0]` is the program; rest are passed verbatim.
1314    /// Shell-string passthrough is forbidden; `argv[0]` matching the
1315    /// shell-bridge deny list is rejected before the policy gate.
1316    pub argv: Vec<String>,
1317    /// Working directory. Optional; resolves against the daemon's
1318    /// own cwd when None.
1319    #[serde(default, skip_serializing_if = "Option::is_none")]
1320    pub cwd: Option<PathBuf>,
1321    /// Explicit environment for the child. Empty means inherit.
1322    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1323    pub env: Vec<(String, String)>,
1324    /// Bucket configuration override (max_events / TTL). Defaults
1325    /// applied if None.
1326    #[serde(default, skip_serializing_if = "Option::is_none")]
1327    pub bucket_config: Option<BucketConfig>,
1328    /// Optional inline rule set. Empty means use the daemon's empty
1329    /// sifter (no events emitted). Hot rule binding lives in TC42.
1330    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1331    pub rules: Vec<RuleDefinition>,
1332    /// Grace window between graceful and forced terminate, in
1333    /// milliseconds. Clamped to `MAX_COMMAND_GRACE_MS`.
1334    #[serde(default, skip_serializing_if = "Option::is_none")]
1335    pub grace_ms: Option<u64>,
1336    /// Optional per-bucket tag for subscription routing (Phase 3).
1337    #[serde(default, skip_serializing_if = "Option::is_none")]
1338    pub tag: Option<String>,
1339    /// Strip ANSI/CSI/OSC escapes before sifter matching and in emitted
1340    /// summaries (TC-B1, FR-026). RAW bytes are always preserved in the
1341    /// frame store; this affects ONLY what the sifter sees and echoes.
1342    /// Defaults to `true`. Additive and non-breaking: old clients omit it
1343    /// and decode via the `default_true` serde default.
1344    #[serde(default = "default_true")]
1345    pub strip_ansi: bool,
1346    /// Optional in-flight dedup hint (TC-2). A client that MAY re-send
1347    /// the same logical start (e.g. a transport retry of a mutating
1348    /// `command_start_combed`) SHOULD send the SAME nonce on every
1349    /// re-send; the daemon collapses an in-flight duplicate to the SAME
1350    /// `(job_id, bucket_id)` instead of spawning twice. Two DISTINCT
1351    /// logical starts MUST use distinct nonces (or none). Additive and
1352    /// non-breaking: old clients omit it and decode via serde default;
1353    /// the daemon then falls back to a very short peer-scoped
1354    /// signature window. This is NOT a server-honored idempotency-key
1355    /// protocol (no TTL store, no envelope change) -- just an in-flight
1356    /// collapse hint.
1357    #[serde(default, skip_serializing_if = "Option::is_none")]
1358    pub dedup_nonce: Option<String>,
1359}
1360
1361impl CommandStartParams {
1362    /// Resolve the effective grace `Duration`, clamping to the cap.
1363    #[must_use]
1364    pub fn grace(&self) -> Option<Duration> {
1365        self.grace_ms
1366            .map(|ms| Duration::from_millis(ms.min(MAX_COMMAND_GRACE_MS)))
1367    }
1368}
1369
1370/// Wire shape for `shell_exec` (TC49).
1371///
1372/// The shell lane runs ONE shell
1373/// line (pipelines / compounds / redirects) through the comb pipeline
1374/// behind the `allow_shell` capability. Mirrors the daemon's
1375/// `ShellExecRequest`; carries the dedicated `shell_line` ONLY — there
1376/// is NO capability flag on the wire (caps are config/TOML, never
1377/// MCP-flippable). Denied by default.
1378///
1379/// `wait_ms` is deliberately ABSENT here: like `command_start_combed`,
1380/// the bounded-wait control is an MCP-layer concern (`McpShellExecParams`
1381/// strips it before building the IPC start), never forwarded into the
1382/// IPC start params.
1383#[derive(Debug, Clone, Serialize, Deserialize)]
1384pub struct ShellExecParams {
1385    /// The shell line to run. Becomes `argv[2]` of `[shell, "-lc",
1386    /// shell_line]`; bounded by the daemon's `MAX_SHELL_LINE_BYTES`.
1387    pub shell_line: String,
1388    /// Interpreter override. `None` -> the daemon's `default_shell`.
1389    #[serde(default, skip_serializing_if = "Option::is_none")]
1390    pub shell: Option<String>,
1391    /// Working directory for the spawned child. `None` inherits the
1392    /// daemon's cwd (the policy gate may still reject paths outside the
1393    /// project root on containment profiles).
1394    #[serde(default, skip_serializing_if = "Option::is_none")]
1395    pub cwd: Option<PathBuf>,
1396    /// Explicit environment for the child. Empty means inherit.
1397    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1398    pub env: Vec<(String, String)>,
1399    /// Optional inline rule set to comb this job's output. Empty means
1400    /// the daemon's empty sifter (no events emitted).
1401    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1402    pub rules: Vec<RuleDefinition>,
1403    /// Bucket configuration override (max_events / TTL). Defaults
1404    /// applied if None.
1405    #[serde(default, skip_serializing_if = "Option::is_none")]
1406    pub bucket_config: Option<BucketConfig>,
1407    /// Optional per-bucket tag for subscription routing.
1408    #[serde(default, skip_serializing_if = "Option::is_none")]
1409    pub tag: Option<String>,
1410}
1411
1412/// Wire shape for `command_status`. Carries just the job id.
1413#[derive(Debug, Clone, Serialize, Deserialize)]
1414pub struct CommandStatusParams {
1415    pub job_id: JobId,
1416}
1417
1418/// Wire shape for `command_output_tail` (F1). Rule-free bounded read
1419/// of a job's captured output. Caps enforced server-side.
1420#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1421pub struct CommandOutputTailParams {
1422    pub job_id: JobId,
1423    #[serde(default = "default_tail_lines")]
1424    pub max_lines: u32,
1425    #[serde(default = "default_tail_bytes")]
1426    pub max_bytes: u32,
1427    /// Strip ANSI escape sequences from the returned rendering. The frame
1428    /// store remains raw. Omitted/false preserves the historical wire shape.
1429    #[serde(default)]
1430    pub strip_ansi: bool,
1431}
1432
1433const fn default_tail_lines() -> u32 {
1434    50
1435}
1436const fn default_tail_bytes() -> u32 {
1437    65_536
1438}
1439
1440/// Response for `command_output_tail`.
1441///
1442/// Bounded; never returns the full raw stream. `truncated_lines` is
1443/// true when the ring held more frames than `max_lines` (after
1444/// server-side clamping). `truncated_bytes` is true when the byte cap
1445/// was hit before the line cap.
1446#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1447pub struct CommandOutputTailResponse {
1448    pub job_id: JobId,
1449    pub lines: Vec<String>,
1450    pub returned_lines: u32,
1451    pub truncated_lines: bool,
1452    pub truncated_bytes: bool,
1453    pub evicted_frames: u64,
1454}
1455
1456/// Maximum number of lines returned by `command_output_tail`.
1457pub const MAX_TAIL_LINES: usize = 200;
1458/// Maximum bytes returned by `command_output_tail`.
1459pub const MAX_TAIL_BYTES: usize = 65_536;
1460
1461/// Maximum hits returned by `registry_search` in a single call.
1462pub const MAX_REGISTRY_SEARCH_LIMIT: usize = 200;
1463/// Default hits returned when the caller omits a limit.
1464pub const DEFAULT_REGISTRY_SEARCH_LIMIT: usize = 50;
1465/// Maximum number of samples accepted by `registry_test`.
1466pub const MAX_REGISTRY_TEST_SAMPLES: usize = 32;
1467/// Maximum size of a single sample text. Mirrors the sifter's
1468/// per-frame cap; bytes above this are truncated before evaluation.
1469pub const MAX_REGISTRY_TEST_SAMPLE_BYTES: usize = 8192;
1470
1471/// Maximum sample lines accepted by `registry_suggest_from_samples`.
1472///
1473/// US2 / FR-007. Lines beyond this are ignored before the heuristics
1474/// run so a huge sample set cannot blow the bounded-output budget.
1475pub const MAX_SUGGEST_SAMPLES: usize = 200;
1476/// Maximum bytes inspected per suggestion sample line.
1477pub const MAX_SUGGEST_SAMPLE_BYTES: usize = 4096;
1478/// Hard cap on the number of proposed rules returned by
1479/// `registry_suggest_from_samples`, regardless of the caller's
1480/// `max_rules`.
1481pub const MAX_SUGGEST_PROPOSED_RULES: usize = 8;
1482
1483/// `registry_search` parameters.
1484#[derive(Debug, Clone, Serialize, Deserialize)]
1485pub struct RegistrySearchParams {
1486    /// FTS5 query string. Operator-supplied; the daemon performs no
1487    /// rewriting beyond what SQLite's FTS5 layer enforces.
1488    pub query: String,
1489    /// Result cap. Clamped to `MAX_REGISTRY_SEARCH_LIMIT`.
1490    #[serde(default, skip_serializing_if = "Option::is_none")]
1491    pub limit: Option<usize>,
1492}
1493
1494/// One hit returned by `registry_search`.
1495#[derive(Debug, Clone, Serialize, Deserialize)]
1496pub struct RegistrySearchHit {
1497    pub rule_id: String,
1498    pub version: u32,
1499    pub event_kind: String,
1500    pub summary_template: String,
1501    pub tags: Vec<String>,
1502    pub severity: Severity,
1503    pub status: RuleStatus,
1504}
1505
1506#[derive(Debug, Clone, Serialize, Deserialize)]
1507pub struct RegistrySearchResponse {
1508    pub hits: Vec<RegistrySearchHit>,
1509}
1510
1511/// `registry_get` parameters. If `version` is `None`, the daemon
1512/// returns the latest stored version.
1513#[derive(Debug, Clone, Serialize, Deserialize)]
1514pub struct RegistryGetParams {
1515    pub rule_id: String,
1516    #[serde(default, skip_serializing_if = "Option::is_none")]
1517    pub version: Option<u32>,
1518}
1519
1520#[derive(Debug, Clone, Serialize, Deserialize)]
1521pub struct RegistryGetResponse {
1522    pub definition: RuleDefinition,
1523}
1524
1525/// `registry_upsert` parameters. The daemon validates the definition,
1526/// assigns the next version, and persists an immutable row.
1527#[derive(Debug, Clone, Serialize, Deserialize)]
1528pub struct RegistryUpsertParams {
1529    pub definition: RuleDefinition,
1530}
1531
1532#[derive(Debug, Clone, Serialize, Deserialize)]
1533pub struct RegistryUpsertResponse {
1534    pub rule_id: String,
1535    pub version: u32,
1536}
1537
1538/// A single bounded sample for `registry_test`.
1539#[derive(Debug, Clone, Serialize, Deserialize)]
1540pub struct RegistryTestSample {
1541    /// Sample text. Bytes above `MAX_REGISTRY_TEST_SAMPLE_BYTES` are
1542    /// truncated by the daemon before evaluation; the dropped byte
1543    /// count surfaces in the response.
1544    pub text: String,
1545    /// Stream tag used to drive `rule.stream` filtering. Defaults to
1546    /// `stdout` so an operator does not have to set it for simple
1547    /// keyword tests.
1548    #[serde(default, skip_serializing_if = "Option::is_none")]
1549    pub stream: Option<SourceStream>,
1550}
1551
1552#[derive(Debug, Clone, Serialize, Deserialize)]
1553pub struct RegistryTestParams {
1554    pub rule_id: String,
1555    #[serde(default, skip_serializing_if = "Option::is_none")]
1556    pub version: Option<u32>,
1557    pub samples: Vec<RegistryTestSample>,
1558}
1559
1560/// One match produced by `registry_test`. Bounded by design:
1561/// captures are projected to a flat `BTreeMap<String, String>` so
1562/// the response never carries arbitrary deeply-nested JSON.
1563#[derive(Debug, Clone, Serialize, Deserialize)]
1564pub struct RegistryTestMatch {
1565    pub sample_index: usize,
1566    pub severity: Severity,
1567    pub kind: String,
1568    pub summary: String,
1569    pub captures: std::collections::BTreeMap<String, String>,
1570}
1571
1572#[derive(Debug, Clone, Serialize, Deserialize)]
1573pub struct RegistryTestResponse {
1574    pub matches: Vec<RegistryTestMatch>,
1575    /// Bytes dropped by per-sample truncation. Helps the operator
1576    /// reason about why a tail-anchored regex did not fire.
1577    pub truncated_bytes: u32,
1578    /// F8b (trust): `sample_index` values whose text the rule's regex
1579    /// WOULD match, but whose stream the rule's `stream` filter
1580    /// excludes -- so the rule produced no match for a reason invisible
1581    /// in the sample text alone. Empty when no sample is a stream
1582    /// mismatch. Additive: omitted from the wire when empty so older
1583    /// clients keep the historical shape.
1584    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1585    pub stream_mismatches: Vec<usize>,
1586}
1587
1588/// `registry_suggest_from_samples` parameters (US2 / FR-007).
1589///
1590/// PURE heuristic suggestion: the daemon runs deterministic line-shape
1591/// detectors over `samples` and returns DRAFT rule proposals. It NEVER
1592/// activates or persists anything (FR-008 / constitution VII).
1593#[derive(Debug, Clone, Serialize, Deserialize)]
1594pub struct RegistrySuggestFromSamplesParams {
1595    /// Raw output sample lines to analyze. Capped at
1596    /// [`MAX_SUGGEST_SAMPLES`]; per-line bytes capped at
1597    /// [`MAX_SUGGEST_SAMPLE_BYTES`].
1598    pub samples: Vec<String>,
1599    /// Optional free-text hint describing the tool/intent. Advisory
1600    /// only; the heuristics are deterministic and ignore it for
1601    /// matching (it is echoed back for the caller's context).
1602    #[serde(default, skip_serializing_if = "Option::is_none")]
1603    pub intent: Option<String>,
1604    /// Optional cap on proposals. Clamped to
1605    /// [`MAX_SUGGEST_PROPOSED_RULES`].
1606    #[serde(default, skip_serializing_if = "Option::is_none")]
1607    pub max_rules: Option<u32>,
1608}
1609
1610/// `registry_suggest_from_samples` response (US2 / FR-007).
1611///
1612/// `proposed_rules` are DRAFT [`RuleDefinition`]s. They are NOT active
1613/// and NOT persisted: the caller must run the explicit
1614/// `registry_test` -> `registry_upsert` -> `registry_activate` loop
1615/// named in `next_steps`.
1616#[derive(Debug, Clone, Serialize, Deserialize)]
1617pub struct RegistrySuggestFromSamplesResponse {
1618    /// Candidate DRAFT rules. May be empty for low-signal input.
1619    pub proposed_rules: Vec<RuleDefinition>,
1620    /// Always `"heuristic"`. The suggestions are deterministic
1621    /// line-shape heuristics, never an ML score.
1622    pub confidence: String,
1623    /// The explicit, ordered activation loop the caller MUST follow to
1624    /// make any proposal live. Constant by design.
1625    pub next_steps: Vec<String>,
1626    /// Human-readable explanation of what was (or was not) detected.
1627    /// For empty/low-signal input this explains why no rule was
1628    /// proposed instead of fabricating one.
1629    pub explanation: String,
1630}
1631
1632/// `registry_activate` parameters.
1633///
1634/// The optional `scope` field (TC42c) selects which live stream(s) the
1635/// activation reaches. Omitted scope deserializes to
1636/// [`ActivationScope::Global`], preserving TC42/TC42b wire compatibility.
1637#[derive(Debug, Clone, Serialize, Deserialize)]
1638pub struct RegistryActivateParams {
1639    pub rule_id: String,
1640    #[serde(default, skip_serializing_if = "Option::is_none")]
1641    pub version: Option<u32>,
1642    #[serde(default, skip_serializing_if = "Option::is_none")]
1643    pub scope: Option<ActivationScope>,
1644}
1645
1646#[derive(Debug, Clone, Serialize, Deserialize)]
1647pub struct RegistryActivateResponse {
1648    pub rule_id: String,
1649    pub version: u32,
1650    /// `true` when the rule was already active under this scope
1651    /// before this call. The activation row is still persisted in
1652    /// either case so the audit trail records the operator intent.
1653    pub was_already_active: bool,
1654    /// Echo of the scope that was applied. Always populated so
1655    /// pre-TC42c clients can ignore the field and post-TC42c clients
1656    /// can verify their request.
1657    pub scope: ActivationScope,
1658    /// Number of live jobs whose sifter was rebound by this call.
1659    /// Zero is valid (e.g. no commands running, or no live job
1660    /// matched the scope).
1661    pub jobs_rebound: u32,
1662    /// Other versions of the SAME rule id that were active under this
1663    /// scope and were closed by this activation (S5 activate-supersedes:
1664    /// version stacking within one scope fires duplicate events per
1665    /// frame, so activating vN deactivates the rest). Empty when nothing
1666    /// was superseded. `serde(default)` keeps pre-S5 payloads decodable.
1667    #[serde(default)]
1668    pub superseded_versions: Vec<u32>,
1669}
1670
1671/// Import a named, embedded rule pack into the registry.
1672///
1673/// When `activate` is true, `scope` is REQUIRED and every imported
1674/// rule is promoted to Active and activated in that scope -- one call
1675/// for "give me expert signals for X". When false, rules import at
1676/// their on-disk status (the vetting path) and nothing is activated.
1677#[derive(Debug, Clone, Serialize, Deserialize)]
1678pub struct RegistryImportPackParams {
1679    pub pack: String,
1680    #[serde(default)]
1681    pub activate: bool,
1682    #[serde(default, skip_serializing_if = "Option::is_none")]
1683    pub scope: Option<ActivationScope>,
1684}
1685
1686#[derive(Debug, Clone, Serialize, Deserialize)]
1687pub struct RegistryImportPackResponse {
1688    pub pack: String,
1689    pub imported: Vec<String>,
1690    pub skipped: Vec<String>,
1691    /// Rules that imported AND activated successfully. Only the rules
1692    /// whose activation completed appear here; a rule listed in
1693    /// `imported` but missing from both `activated` and `failed` means
1694    /// `activate` was false (nothing was activated).
1695    pub activated: Vec<String>,
1696    /// Partial-success channel (M7): rules that imported and were
1697    /// promoted to Active but whose *activation* failed mid-loop. Each
1698    /// entry carries the rule id + a human-readable reason. This is an
1699    /// ADDITIVE field: it serializes only when non-empty, so the
1700    /// all-success wire shape is unchanged and older clients that do
1701    /// not know the field still deserialize. A non-empty `failed` is
1702    /// still a SUCCESSFUL response (no IPC error code) -- the caller
1703    /// inspects `failed` to learn which rules need a retry rather than
1704    /// receiving a bare error that hides the rules that did activate.
1705    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1706    pub failed: Vec<RegistryImportFailure>,
1707}
1708
1709/// One rule whose activation failed during a partial-success import.
1710///
1711/// The rule WAS imported (and promoted to Active in the store) by
1712/// `registry_import_pack`; only the in-memory/durable activation step
1713/// failed. `reason` is the typed IPC error message surfaced to the
1714/// caller so it can decide whether to retry that single rule.
1715#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1716pub struct RegistryImportFailure {
1717    pub rule_id: String,
1718    pub reason: String,
1719}
1720
1721/// `registry_deactivate` parameters. Scope follows the same default
1722/// rule as `registry_activate`.
1723#[derive(Debug, Clone, Serialize, Deserialize)]
1724pub struct RegistryDeactivateParams {
1725    pub rule_id: String,
1726    pub version: u32,
1727    #[serde(default, skip_serializing_if = "Option::is_none")]
1728    pub scope: Option<ActivationScope>,
1729}
1730
1731#[derive(Debug, Clone, Serialize, Deserialize)]
1732pub struct RegistryDeactivateResponse {
1733    pub rule_id: String,
1734    pub version: u32,
1735    /// `false` when the rule was not in the in-memory active set
1736    /// for this scope (e.g. operator deactivated something already
1737    /// inactive). The daemon still attempts to close the persistent
1738    /// row.
1739    pub was_deactivated: bool,
1740    /// Echo of the scope that was applied.
1741    pub scope: ActivationScope,
1742    /// Number of live jobs whose sifter was rebound by this call.
1743    pub jobs_rebound: u32,
1744}
1745
1746/// `registry_deactivate_bulk` parameters (US2 / FR-011).
1747///
1748/// Deactivate an entire seed pack OR an explicit list of rule ids in ONE
1749/// call, under exactly ONE scope. Exactly one of `pack` / `rule_ids`
1750/// must be present; the daemon rejects zero or both with a teaching
1751/// error. The single-rule [`RegistryDeactivateParams`] wire contract is
1752/// untouched.
1753#[derive(Debug, Clone, Serialize, Deserialize)]
1754pub struct RegistryDeactivateBulkParams {
1755    /// Selector 1: deactivate every member of this seed pack.
1756    #[serde(default, skip_serializing_if = "Option::is_none")]
1757    pub pack: Option<String>,
1758    /// Selector 2: deactivate these rule ids.
1759    #[serde(default, skip_serializing_if = "Option::is_none")]
1760    pub rule_ids: Option<Vec<String>>,
1761    /// Required. ONE scope per call.
1762    pub scope: ActivationScope,
1763}
1764
1765/// The disposition of one rule in a bulk deactivate (US2 / FR-011).
1766///
1767/// Partial success is the NORMAL shape: `not_active` and `unknown_rule`
1768/// are reported per-rule, never as a call-level error.
1769#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
1770#[serde(rename_all = "snake_case")]
1771pub enum BulkOutcomeKind {
1772    /// At least one active version under the scope was closed.
1773    Deactivated,
1774    /// Known rule, but nothing was open under this scope.
1775    NotActive,
1776    /// Rule id not in the registry (or not a member of the named pack).
1777    UnknownRule,
1778}
1779
1780/// One per-rule outcome entry in a bulk deactivate response.
1781#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1782pub struct BulkDeactivateOutcome {
1783    pub rule_id: String,
1784    /// The version acted on; `None` for `unknown_rule`.
1785    #[serde(default, skip_serializing_if = "Option::is_none")]
1786    pub version: Option<u32>,
1787    pub outcome: BulkOutcomeKind,
1788}
1789
1790#[derive(Debug, Clone, Serialize, Deserialize)]
1791pub struct RegistryDeactivateBulkResponse {
1792    /// One entry per requested rule, ALWAYS, in request order (pack
1793    /// order = pack-file order).
1794    pub outcomes: Vec<BulkDeactivateOutcome>,
1795    /// Live jobs rebound ONCE after the whole loop.
1796    pub jobs_rebound: u64,
1797}
1798
1799/// One entry in `registry_list_active`. Carries the scope the rule
1800/// is bound to so a rule active under several scopes appears once
1801/// per scope.
1802#[derive(Debug, Clone, Serialize, Deserialize)]
1803pub struct RegistryActiveEntry {
1804    pub rule_id: String,
1805    pub version: u32,
1806    pub severity: Severity,
1807    pub event_kind: String,
1808    pub tags: Vec<String>,
1809    pub scope: ActivationScope,
1810}
1811
1812#[derive(Debug, Clone, Serialize, Deserialize)]
1813pub struct RegistryListActiveResponse {
1814    pub entries: Vec<RegistryActiveEntry>,
1815    /// More active entries existed than `entries` carries (bounded by
1816    /// [`MAX_LIST_LIMIT`] / the request `limit`).
1817    #[serde(default)]
1818    pub truncated: bool,
1819}
1820
1821// =====================================================================
1822// TC43: file probe surface.
1823//
1824// Three families:
1825//   - `file_read_window` — bounded line/byte window read of one file.
1826//   - `file_search`       — bounded substring/regex match over one file.
1827//   - `file_watch_*`      — daemon-owned follow-mode FileProbe attached
1828//                           to a bucket so scoped rules emit signal.
1829// =====================================================================
1830
1831/// Hard cap on lines returned by `file_read_window` in a single call.
1832pub const MAX_FILE_READ_LINES: u32 = 2_000;
1833/// Default lines when the caller omits a limit.
1834pub const DEFAULT_FILE_READ_LINES: u32 = 200;
1835/// Hard cap on bytes returned by `file_read_window`. Same envelope cap
1836/// shape as the bucket / event-context payloads.
1837pub const MAX_FILE_READ_BYTES: usize = 64 * 1024;
1838/// Default `file_read_window` byte cap.
1839pub const DEFAULT_FILE_READ_BYTES: usize = MAX_FILE_READ_BYTES;
1840/// Hard cap on matches returned by `file_search` in a single call.
1841pub const MAX_FILE_SEARCH_MATCHES: u32 = 500;
1842/// Default `file_search` match cap.
1843pub const DEFAULT_FILE_SEARCH_MATCHES: u32 = 100;
1844/// Hard cap on snippet bytes returned per `file_search` match.
1845pub const MAX_FILE_SEARCH_SNIPPET_BYTES: usize = 512;
1846/// Default snippet bytes per match.
1847pub const DEFAULT_FILE_SEARCH_SNIPPET_BYTES: usize = 240;
1848/// Hard cap on bytes scanned by a single `file_search` call. Protects
1849/// the daemon from a request that asks to search a gigabyte file.
1850pub const MAX_FILE_SEARCH_SCAN_BYTES: u64 = 16 * 1024 * 1024;
1851/// Hard cap on directory entries visited by one recursive `file_search`.
1852/// This bounds trees containing many empty directories or tiny files even
1853/// when the byte and match caps would not stop the walk.
1854pub const MAX_FILE_SEARCH_ENTRIES: u64 = 20_000;
1855/// Hard cap on entries returned by `file_list_dir` in a single call.
1856///
1857/// (US3 FR-020.) Bounds a single-level directory listing the same way the
1858/// read-window / search lanes bound their output: a directory with more than
1859/// this many entries is returned truncation-flagged with the true total, never
1860/// silently partial (Constitution III).
1861pub const MAX_FILE_LIST_ENTRIES: usize = 500;
1862/// Default `file_list_dir` entry cap when the caller omits `max_entries`.
1863pub const DEFAULT_FILE_LIST_ENTRIES: usize = 200;
1864/// Hard cap on the content size accepted by a single `file_write` call.
1865///
1866/// (TC22 A3.) Bounds the request the same way the read window / search
1867/// scan budgets bound their lanes: a write larger than this is rejected
1868/// with [`IpcErrorCode::OversizedRequest`] before any filesystem touch,
1869/// so a single tool call can never be coerced into writing an unbounded
1870/// blob.
1871///
1872/// MUST stay comfortably below [`MAX_FRAME_BYTES`] (256 KiB): the content
1873/// is carried inline in the request frame as escaped JSON, so the cap is
1874/// 192 KiB to leave ~64 KiB headroom for the path, field names, and worst-
1875/// case JSON string escaping. This guarantees the dedicated
1876/// `OversizedRequest` verdict fires at the handler, rather than a generic
1877/// transport `FrameTooLarge`, giving the caller an actionable, lane-specific
1878/// error. (Larger files are written as multiple bounded calls.)
1879pub const MAX_FILE_WRITE_BYTES: usize = 192 * 1024;
1880
1881/// `file_read_window` parameters.
1882///
1883/// Either `start_line` (1-based) drives a line-window read or
1884/// `start_byte` drives a byte-window read. If both are omitted the
1885/// daemon reads from line 1.
1886#[derive(Debug, Clone, Serialize, Deserialize)]
1887pub struct FileReadWindowParams {
1888    pub path: std::path::PathBuf,
1889    #[serde(default, skip_serializing_if = "Option::is_none")]
1890    pub start_line: Option<u64>,
1891    #[serde(default, skip_serializing_if = "Option::is_none")]
1892    pub max_lines: Option<u32>,
1893    #[serde(default, skip_serializing_if = "Option::is_none")]
1894    pub max_bytes: Option<usize>,
1895}
1896
1897/// One line returned by `file_read_window`.
1898#[derive(Debug, Clone, Serialize, Deserialize)]
1899pub struct FileLine {
1900    /// 1-based line number within the file.
1901    pub line: u64,
1902    /// Byte offset where this line begins. Useful for follow-up
1903    /// reads / context windows.
1904    pub byte_offset: u64,
1905    pub text: String,
1906}
1907
1908#[derive(Debug, Clone, Serialize, Deserialize)]
1909pub struct FileReadWindowResponse {
1910    pub path: std::path::PathBuf,
1911    pub lines: Vec<FileLine>,
1912    /// File size in bytes at read time.
1913    pub file_bytes: u64,
1914    /// `true` when the response was clamped by line / byte cap.
1915    pub truncated: bool,
1916    /// First byte offset past the last line returned. Lets the
1917    /// caller compute a follow-up window without rereading.
1918    pub next_byte_offset: u64,
1919}
1920
1921/// `file_search` parameters.
1922#[derive(Debug, Clone, Serialize, Deserialize)]
1923pub struct FileSearchParams {
1924    pub path: std::path::PathBuf,
1925    /// Substring to find. Required.
1926    pub query: String,
1927    /// Case-insensitive match. Defaults to false.
1928    #[serde(default, skip_serializing_if = "Option::is_none")]
1929    pub case_insensitive: Option<bool>,
1930    /// Hard cap on returned matches. Clamped to
1931    /// [`MAX_FILE_SEARCH_MATCHES`]. Omitted = [`DEFAULT_FILE_SEARCH_MATCHES`].
1932    #[serde(default, skip_serializing_if = "Option::is_none")]
1933    pub max_matches: Option<u32>,
1934    /// Hard cap on snippet bytes per match. Clamped to
1935    /// [`MAX_FILE_SEARCH_SNIPPET_BYTES`]. Omitted =
1936    /// [`DEFAULT_FILE_SEARCH_SNIPPET_BYTES`].
1937    #[serde(default, skip_serializing_if = "Option::is_none")]
1938    pub max_snippet_bytes: Option<usize>,
1939}
1940
1941/// One `file_search` match. Bounded shape: never the whole line, never
1942/// arbitrary bytes — `snippet` is capped at `max_snippet_bytes`.
1943#[derive(Debug, Clone, Serialize, Deserialize)]
1944pub struct FileSearchMatch {
1945    /// Canonical owning file for a directory search. Omitted for the legacy
1946    /// single-file shape, whose response-level `path` already identifies it.
1947    #[serde(default, skip_serializing_if = "Option::is_none")]
1948    pub path: Option<std::path::PathBuf>,
1949    /// 1-based line number.
1950    pub line: u64,
1951    /// Byte offset of the matching position within the file.
1952    pub byte_offset: u64,
1953    /// Bounded text snippet around the match. Replaced with the
1954    /// owning line, truncated to `max_snippet_bytes`. Never raw
1955    /// stream bytes; always UTF-8.
1956    pub snippet: String,
1957}
1958
1959#[derive(Debug, Clone, Serialize, Deserialize)]
1960pub struct FileSearchResponse {
1961    pub path: std::path::PathBuf,
1962    pub matches: Vec<FileSearchMatch>,
1963    /// `true` when the search hit the per-call cap or the scan-bytes
1964    /// budget before completing.
1965    pub truncated: bool,
1966    /// Bytes actually scanned (may be lower than file size when the
1967    /// scan-bytes budget tripped first).
1968    pub bytes_scanned: u64,
1969    /// Directory-search diagnostic: regular files successfully scanned.
1970    /// Omitted for the legacy single-file response shape.
1971    #[serde(default, skip_serializing_if = "Option::is_none")]
1972    pub files_scanned: Option<u64>,
1973    /// Directory-search diagnostic: entries skipped because they were binary,
1974    /// unreadable, policy-denied, symlinks/reparse points, special files, or
1975    /// version-control metadata directories.
1976    /// Omitted for the legacy single-file response shape.
1977    #[serde(default, skip_serializing_if = "Option::is_none")]
1978    pub entries_skipped: Option<u64>,
1979}
1980
1981/// Kind of a single directory entry, from `symlink_metadata` (never
1982/// followed): a symlink is reported as `symlink` regardless of its target.
1983#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
1984#[serde(rename_all = "snake_case")]
1985pub enum DirEntryKind {
1986    File,
1987    Dir,
1988    Symlink,
1989}
1990
1991/// One entry returned by `file_list_dir` (US3). The discovery unit of the
1992/// files facade: a single-level entry, never recursed into and, for a
1993/// symlink/reparse point, never followed.
1994#[derive(Debug, Clone, Serialize, Deserialize)]
1995pub struct DirEntry {
1996    /// Entry file name only (no path component).
1997    pub name: String,
1998    /// `file` / `dir` / `symlink`, taken from `symlink_metadata`.
1999    pub kind: DirEntryKind,
2000    /// Size in bytes for regular files only; omitted for dirs/symlinks and
2001    /// when the entry vanished between enumeration and stat.
2002    #[serde(default, skip_serializing_if = "Option::is_none")]
2003    pub size_bytes: Option<u64>,
2004    /// Modification time in milliseconds since the Unix epoch; omitted when
2005    /// unavailable (stat race or platform without an mtime).
2006    #[serde(default, skip_serializing_if = "Option::is_none")]
2007    pub mtime_ms: Option<i64>,
2008}
2009
2010/// `file_list_dir` parameters (US3 FR-020).
2011///
2012/// Single-level listing of one directory. Absolute path required (the daemon
2013/// has no workspace root); gated by the SAME read-path policy as
2014/// `file_read_window` (FR-021).
2015#[derive(Debug, Clone, Serialize, Deserialize)]
2016pub struct FileListDirParams {
2017    /// Absolute path of the directory to list.
2018    pub path: String,
2019    /// Cap on returned entries; clamped to
2020    /// `[1, MAX_FILE_LIST_ENTRIES]`, default `DEFAULT_FILE_LIST_ENTRIES`.
2021    #[serde(default, skip_serializing_if = "Option::is_none")]
2022    pub max_entries: Option<u32>,
2023}
2024
2025#[derive(Debug, Clone, Serialize, Deserialize)]
2026pub struct FileListDirResponse {
2027    /// Canonicalized directory that was listed.
2028    pub path: String,
2029    /// Sorted: dirs first, then files/symlinks together; each group
2030    /// lexicographic by `name`.
2031    pub entries: Vec<DirEntry>,
2032    /// Total entries present in the directory (`>= entries.len()`).
2033    pub total_entries: u64,
2034    /// `true` iff `total_entries > entries.len()` (the cap clamped the list).
2035    pub truncated: bool,
2036}
2037
2038/// `file_write` parameters (TC22 A3).
2039///
2040/// Writes `content` to `path` as a single UTF-8 regular file. The daemon
2041/// canonicalizes the PARENT directory (the target file need not exist
2042/// yet), policy-gates the canonical target against `paths.write_allow`,
2043/// audits BEFORE the write, bounds `content` to [`MAX_FILE_WRITE_BYTES`],
2044/// and writes ATOMICALLY (temp file in the same dir + rename) so a partial
2045/// or torn write can never be observed.
2046#[derive(Debug, Clone, Serialize, Deserialize)]
2047pub struct FileWriteParams {
2048    /// Absolute path to the target file. Absolute is required: the daemon
2049    /// has no workspace root, so a relative path is rejected rather than
2050    /// resolved against the daemon's working directory.
2051    pub path: std::path::PathBuf,
2052    /// UTF-8 content to write. Bounded by [`MAX_FILE_WRITE_BYTES`]; an
2053    /// oversize payload is rejected before any filesystem touch.
2054    pub content: String,
2055    /// Create missing parent directories WITHIN an allowed path. The
2056    /// parent must still pass policy: `create_dirs` never widens the
2057    /// allow-list, it only saves a separate mkdir for a path the policy
2058    /// already permits. Defaults to false.
2059    #[serde(default)]
2060    pub create_dirs: bool,
2061    /// Append `content` to the target instead of replacing it. Same policy
2062    /// gate (`FileWrite`), same [`MAX_FILE_WRITE_BYTES`] cap per call, same
2063    /// missing-file creation semantics. Defaults to false (full replace).
2064    ///
2065    /// Integrity (honest): the original content is never modified and racing
2066    /// appends never interleave (OS append-mode offset atomicity). All-or-
2067    /// nothing is NOT promised: a mid-write I/O failure can leave a partial
2068    /// append and surfaces as an error.
2069    #[serde(default)]
2070    pub append: bool,
2071}
2072
2073#[derive(Debug, Clone, Serialize, Deserialize)]
2074pub struct FileWriteResponse {
2075    /// Canonical path that was written.
2076    pub path: std::path::PathBuf,
2077    /// Number of content bytes written.
2078    pub bytes_written: u64,
2079}
2080
2081/// `file_watch_start` parameters.
2082#[derive(Debug, Clone, Serialize, Deserialize)]
2083pub struct FileWatchStartParams {
2084    pub path: std::path::PathBuf,
2085    /// Optional bucket config (max_events / TTL). Defaults applied
2086    /// if None.
2087    #[serde(default, skip_serializing_if = "Option::is_none")]
2088    pub bucket_config: Option<BucketConfig>,
2089    /// Optional inline rule set bound to this watch only. Empty
2090    /// means the per-job set is whatever scoped activations the
2091    /// registry resolves for the watch's `(bucket_id, watch_id,
2092    /// probe_id)` triple.
2093    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2094    pub rules: Vec<RuleDefinition>,
2095    /// Follow from end (skip existing content) or from beginning.
2096    /// Defaults to follow-end (typical "tail -F" semantics).
2097    #[serde(default, skip_serializing_if = "Option::is_none")]
2098    pub follow_from_beginning: Option<bool>,
2099    /// Optional per-bucket tag for subscription routing (Phase 3).
2100    #[serde(default, skip_serializing_if = "Option::is_none")]
2101    pub tag: Option<String>,
2102}
2103
2104#[derive(Debug, Clone, Serialize, Deserialize)]
2105pub struct FileWatchStartResponse {
2106    /// Opaque watch identifier. The LLM uses this with
2107    /// `file_watch_stop`. Wire form is a JobId so scoped activation
2108    /// can target a single watch via `ActivationScope::Job { job_id }`.
2109    pub watch_id: JobId,
2110    pub bucket_id: BucketId,
2111    pub probe_id: terminal_commander_core::ProbeId,
2112    pub cursor: u64,
2113}
2114
2115#[derive(Debug, Clone, Serialize, Deserialize)]
2116pub struct FileWatchStopParams {
2117    pub watch_id: JobId,
2118}
2119
2120#[derive(Debug, Clone, Serialize, Deserialize)]
2121pub struct FileWatchStopResponse {
2122    pub watch_id: JobId,
2123    pub bucket_id: BucketId,
2124    pub frames_total: u64,
2125    pub events_emitted: u64,
2126    pub bytes_total: u64,
2127}
2128
2129#[derive(Debug, Clone, Serialize, Deserialize)]
2130pub struct FileWatchListEntry {
2131    pub watch_id: JobId,
2132    pub bucket_id: BucketId,
2133    pub probe_id: terminal_commander_core::ProbeId,
2134    pub path: std::path::PathBuf,
2135    pub frames_total: u64,
2136    pub events_emitted: u64,
2137    pub bytes_total: u64,
2138}
2139
2140#[derive(Debug, Clone, Serialize, Deserialize)]
2141pub struct FileWatchListResponse {
2142    pub entries: Vec<FileWatchListEntry>,
2143}
2144
2145// =====================================================================
2146// TC44: PTY command surface.
2147// =====================================================================
2148
2149/// Hard cap on argv items for a PTY command. Matches `MAX_ARGV_ITEMS`
2150/// from `command.rs` so the two surfaces stay symmetric.
2151pub const MAX_PTY_ARGV_ITEMS: usize = 256;
2152/// Hard cap on bytes accepted in one `pty_command_write_stdin` call.
2153/// Mirrors `MAX_PTY_STDIN_BYTES` from `crates/probes::pty`.
2154pub const MAX_PTY_STDIN_BYTES: usize = 4096;
2155
2156#[derive(Debug, Clone, Serialize, Deserialize)]
2157pub struct PtyCommandStartParams {
2158    #[serde(default, skip_serializing_if = "Option::is_none")]
2159    pub environment: Option<terminal_commander_core::EnvironmentSpec>,
2160    pub argv: Vec<String>,
2161    #[serde(default, skip_serializing_if = "Option::is_none")]
2162    pub cwd: Option<std::path::PathBuf>,
2163    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2164    pub env: Vec<(String, String)>,
2165    #[serde(default, skip_serializing_if = "Option::is_none")]
2166    pub bucket_config: Option<BucketConfig>,
2167    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2168    pub rules: Vec<RuleDefinition>,
2169    #[serde(default, skip_serializing_if = "Option::is_none")]
2170    pub rows: Option<u16>,
2171    #[serde(default, skip_serializing_if = "Option::is_none")]
2172    pub cols: Option<u16>,
2173    /// Optional per-bucket tag for subscription routing (Phase 3).
2174    #[serde(default, skip_serializing_if = "Option::is_none")]
2175    pub tag: Option<String>,
2176}
2177
2178#[derive(Debug, Clone, Serialize, Deserialize)]
2179pub struct PtyCommandStartResponse {
2180    pub job_id: JobId,
2181    pub bucket_id: BucketId,
2182    pub probe_id: terminal_commander_core::ProbeId,
2183    pub cursor: u64,
2184}
2185
2186#[derive(Debug, Clone, Serialize, Deserialize)]
2187pub struct PtyCommandWriteStdinParams {
2188    pub job_id: JobId,
2189    /// Bytes to write. Capped at `MAX_PTY_STDIN_BYTES`. Sent as a
2190    /// JSON string; non-UTF-8 input must be base64-pre-encoded by the
2191    /// caller (TC44 surface accepts UTF-8 only).
2192    pub bytes: String,
2193    /// NEW (US5 / FR-041): bucket cursor to read the settle window from
2194    /// (default `0` = the PTY job bucket head). Only meaningful with
2195    /// `wait_ms`.
2196    #[serde(default, skip_serializing_if = "Option::is_none")]
2197    pub cursor: Option<u64>,
2198    /// NEW (US5 / FR-041): bounded settle window (ms) to wait for combed
2199    /// signals AFTER the write, clamped server-side like the
2200    /// `shell_session_exec` settle window. Absent = immediate return
2201    /// (today's byte-identical behavior).
2202    #[serde(default, skip_serializing_if = "Option::is_none")]
2203    pub wait_ms: Option<u64>,
2204}
2205
2206#[derive(Debug, Clone, Serialize, Deserialize)]
2207pub struct PtyCommandWriteStdinResponse {
2208    pub job_id: JobId,
2209    pub bytes_written: u64,
2210    /// Echoes the post-write secret-prompt-active flag so the LLM
2211    /// can avoid a follow-up write that would also be rejected.
2212    pub secret_prompt_active: bool,
2213    /// NEW (US5 / FR-041): the following combed-batch fields are present
2214    /// ONLY when `wait_ms` was supplied on the request. A no-wait
2215    /// response omits every one of them, serializing byte-identically to
2216    /// the pre-US5 shape.
2217    #[serde(default, skip_serializing_if = "Option::is_none")]
2218    pub cursor_in: Option<u64>,
2219    #[serde(default, skip_serializing_if = "Option::is_none")]
2220    pub next_cursor: Option<u64>,
2221    #[serde(default, skip_serializing_if = "Option::is_none")]
2222    pub has_more: Option<bool>,
2223    #[serde(default, skip_serializing_if = "Option::is_none")]
2224    pub dropped_count: Option<u64>,
2225    #[serde(default, skip_serializing_if = "Option::is_none")]
2226    pub events: Option<Vec<SignalEvent>>,
2227}
2228
2229#[derive(Debug, Clone, Serialize, Deserialize)]
2230pub struct PtyCommandStopParams {
2231    pub job_id: JobId,
2232}
2233
2234#[derive(Debug, Clone, Serialize, Deserialize)]
2235pub struct PtyCommandStopResponse {
2236    pub job_id: JobId,
2237    pub bucket_id: BucketId,
2238    pub frames_total: u64,
2239    pub events_emitted: u64,
2240    pub bytes_total: u64,
2241    pub stdin_bytes_written: u64,
2242    pub secret_prompts_total: u64,
2243}
2244
2245#[derive(Debug, Clone, Serialize, Deserialize)]
2246pub struct PtyCommandListEntry {
2247    pub job_id: JobId,
2248    pub bucket_id: BucketId,
2249    pub probe_id: terminal_commander_core::ProbeId,
2250    pub argv: Vec<String>,
2251    pub frames_total: u64,
2252    pub events_emitted: u64,
2253    pub bytes_total: u64,
2254    pub stdin_bytes_written: u64,
2255    pub secret_prompts_total: u64,
2256    pub secret_prompt_active: bool,
2257}
2258
2259#[derive(Debug, Clone, Serialize, Deserialize)]
2260pub struct PtyCommandListResponse {
2261    pub entries: Vec<PtyCommandListEntry>,
2262}
2263
2264// =====================================================================
2265// P1 (TC50): persistent shell sessions + workspace snapshots.
2266//
2267// A session is a long-lived login-shell PTY job: sticky cwd/env come
2268// for free from the persistent shell process. The wire mirrors the
2269// PTY surface in style. Session output is ALWAYS combed (read from the
2270// session bucket via cursor); the wire never carries a raw stream.
2271// Session start is gated by `PolicyAction::SessionStart` behind the
2272// `allow_session` capability (default deny) and audited before spawn.
2273// =====================================================================
2274
2275/// Maximum byte length of a single `shell_session_exec` line.
2276///
2277/// A session line is written to the shell PTY as `line + "\n"`. Bounded
2278/// well under the PTY stdin cap (`MAX_PTY_STDIN_BYTES`) so the appended
2279/// newline can never push a max-length line over the probe's write cap.
2280pub const MAX_SESSION_LINE_BYTES: usize = 4000;
2281
2282/// Maximum number of `(key, value)` pairs returned in a session/snapshot
2283/// bounded env snapshot. Keeps the status/list/snapshot responses small.
2284pub const MAX_SESSION_ENV_ITEMS: usize = 256;
2285
2286/// Lifecycle state of a shell session.
2287///
2288/// Mirrors the data-model `Starting | Live | Exited | Failed` set. `Live`
2289/// is the only state in which `shell_session_exec` is accepted; a send to
2290/// any other state fails loudly (terminal-state guard), never hangs.
2291#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2292#[serde(rename_all = "snake_case")]
2293pub enum SessionState {
2294    Starting,
2295    Live,
2296    Exited,
2297    Failed,
2298}
2299
2300/// `shell_session_start` request.
2301///
2302/// `shell` is the interpreter override (`None` -> the daemon's default
2303/// login shell); argv[0] is NOT a user-chosen interpreter on this lane (it
2304/// is assembled by the daemon), so the argv shell-interpreter guard is
2305/// intentionally skipped here and the `SessionStart` cap gates instead.
2306#[derive(Debug, Clone, Serialize, Deserialize)]
2307pub struct ShellSessionStartParams {
2308    /// Interpreter override. `None` -> the daemon's default login shell.
2309    #[serde(default, skip_serializing_if = "Option::is_none")]
2310    pub shell: Option<String>,
2311    /// Initial working directory for the session shell. `None` inherits
2312    /// the daemon's cwd (the policy gate still applies on containment
2313    /// profiles).
2314    #[serde(default, skip_serializing_if = "Option::is_none")]
2315    pub cwd: Option<PathBuf>,
2316    /// Environment overlay applied to the session shell. Bounded by
2317    /// [`MAX_SESSION_ENV_ITEMS`]. Empty = inherit unchanged.
2318    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2319    pub env: Vec<(String, String)>,
2320    /// Optional inline rules bound to the session bucket so the session's
2321    /// combed output emits structured signals. Empty = the daemon's empty
2322    /// sifter (only lifecycle events appear).
2323    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2324    pub rules: Vec<RuleDefinition>,
2325    /// Bucket configuration override (max_events / TTL). Defaults applied
2326    /// if `None`.
2327    #[serde(default, skip_serializing_if = "Option::is_none")]
2328    pub bucket_config: Option<BucketConfig>,
2329    /// Optional per-bucket tag for subscription routing.
2330    #[serde(default, skip_serializing_if = "Option::is_none")]
2331    pub tag: Option<String>,
2332}
2333
2334/// `shell_session_start` response: the stable session id, its signal
2335/// bucket, and the initial lifecycle state.
2336#[derive(Debug, Clone, Serialize, Deserialize)]
2337pub struct ShellSessionStartResponse {
2338    pub session_id: SessionId,
2339    pub bucket_id: BucketId,
2340    pub state: SessionState,
2341}
2342
2343/// `shell_session_exec` request: send ONE line to a live session shell.
2344///
2345/// The line is bounded by [`MAX_SESSION_LINE_BYTES`]; a trailing newline
2346/// is appended by the daemon (do NOT include it). Output is read back as
2347/// combed signals from the session bucket via the cursor.
2348#[derive(Debug, Clone, Serialize, Deserialize)]
2349pub struct ShellSessionExecParams {
2350    pub session_id: SessionId,
2351    /// The shell line to run (no trailing newline). Bounded by
2352    /// [`MAX_SESSION_LINE_BYTES`].
2353    pub line: String,
2354    /// Cursor into the session bucket to read combed signals from. Omit /
2355    /// `0` to read from the bucket head. The response returns the next
2356    /// cursor for the following exec.
2357    #[serde(default)]
2358    pub cursor: u64,
2359    /// Bounded wait (ms) for combed signals to appear after the line is
2360    /// written, clamped server-side to [`MAX_BUCKET_WAIT_MS`]. Omit for
2361    /// the default settle window.
2362    #[serde(default, skip_serializing_if = "Option::is_none")]
2363    pub wait_ms: Option<u64>,
2364}
2365
2366/// `shell_session_exec` response: combed signals appended to the session
2367/// bucket after the line ran, plus the next cursor. Never a raw stream.
2368#[derive(Debug, Clone, Serialize, Deserialize)]
2369pub struct ShellSessionExecResponse {
2370    pub session_id: SessionId,
2371    pub bucket_id: BucketId,
2372    pub bytes_written: u64,
2373    pub cursor_in: u64,
2374    pub next_cursor: u64,
2375    pub has_more: bool,
2376    pub dropped_count: u64,
2377    pub events: Vec<SignalEvent>,
2378}
2379
2380/// `shell_session_status` request.
2381#[derive(Debug, Clone, Serialize, Deserialize)]
2382pub struct ShellSessionStatusParams {
2383    pub session_id: SessionId,
2384}
2385
2386/// `shell_session_status` response: lifecycle state, current cwd, a
2387/// bounded env snapshot, and the last-active timestamp (epoch seconds).
2388#[derive(Debug, Clone, Serialize, Deserialize)]
2389pub struct ShellSessionStatusResponse {
2390    pub session_id: SessionId,
2391    pub bucket_id: BucketId,
2392    pub state: SessionState,
2393    /// Best-known current working directory. Tracked from the requested
2394    /// start cwd and from `cd`-shaped session lines (see SHELL_SESSION
2395    /// runtime docs); a value the daemon has not observed reports the
2396    /// start cwd. Bounded.
2397    pub cwd: Option<String>,
2398    /// Bounded env snapshot captured at start (overlay entries), capped at
2399    /// [`MAX_SESSION_ENV_ITEMS`].
2400    pub env_snapshot: Vec<(String, String)>,
2401    /// Seconds since the unix epoch of the last exec/status touch.
2402    pub last_active_at: u64,
2403}
2404
2405/// `shell_session_stop` request.
2406#[derive(Debug, Clone, Serialize, Deserialize)]
2407pub struct ShellSessionStopParams {
2408    pub session_id: SessionId,
2409}
2410
2411/// `shell_session_stop` response. The session shell is terminated
2412/// (graceful then forced) and the state moves to [`SessionState::Exited`].
2413#[derive(Debug, Clone, Serialize, Deserialize)]
2414pub struct ShellSessionStopResponse {
2415    pub session_id: SessionId,
2416    pub state: SessionState,
2417    /// Short bounded reason for the terminal transition (e.g. "stopped"
2418    /// or "already terminal").
2419    pub terminal_reason: String,
2420}
2421
2422/// One entry in `shell_session_list`.
2423#[derive(Debug, Clone, Serialize, Deserialize)]
2424pub struct ShellSessionListEntry {
2425    pub session_id: SessionId,
2426    pub bucket_id: BucketId,
2427    pub state: SessionState,
2428    pub cwd: Option<String>,
2429    pub last_active_at: u64,
2430}
2431
2432/// `shell_session_list` response: a bounded snapshot of live sessions.
2433#[derive(Debug, Clone, Serialize, Deserialize)]
2434pub struct ShellSessionListResponse {
2435    pub sessions: Vec<ShellSessionListEntry>,
2436}
2437
2438/// `workspace_snapshot_create` request: persist the current cwd + bounded
2439/// env of a session as a restorable workspace snapshot.
2440#[derive(Debug, Clone, Serialize, Deserialize)]
2441pub struct WorkspaceSnapshotCreateParams {
2442    pub session_id: SessionId,
2443    /// Optional human-friendly label stored alongside the snapshot.
2444    #[serde(default, skip_serializing_if = "Option::is_none")]
2445    pub name: Option<String>,
2446}
2447
2448/// `workspace_snapshot_create` response: the new snapshot id.
2449#[derive(Debug, Clone, Serialize, Deserialize)]
2450pub struct WorkspaceSnapshotCreateResponse {
2451    pub snapshot_id: String,
2452}
2453
2454/// `workspace_snapshot_apply` request: restore a snapshot's cwd/env into
2455/// the given (live) session.
2456#[derive(Debug, Clone, Serialize, Deserialize)]
2457pub struct WorkspaceSnapshotApplyParams {
2458    pub snapshot_id: String,
2459    pub session_id: SessionId,
2460}
2461
2462/// `workspace_snapshot_apply` response: the restored cwd echoed back.
2463#[derive(Debug, Clone, Serialize, Deserialize)]
2464pub struct WorkspaceSnapshotApplyResponse {
2465    pub applied: bool,
2466    pub session_id: SessionId,
2467    pub cwd: Option<String>,
2468}
2469
2470// =====================================================================
2471// TC45: aggregate runtime view (read-only).
2472//
2473// `runtime_state`, `probe_list`, and `probe_status` surface the union
2474// of `CommandRuntime::live_jobs`, `WatchRuntime::list`, and
2475// `PtyRuntime::list` (when cfg(unix)) plus bucket counters and the
2476// scoped activation snapshot. No new spawn / cancel / mutation
2477// capability. No raw stream content.
2478// =====================================================================
2479
2480/// Default + hard cap on each list/snapshot vec (subscriptions §6).
2481///
2482/// `runtime_state` bounds its THREE vecs (probes, buckets, active_rules)
2483/// INDEPENDENTLY by this; `probe_list` / `registry_list_active` bound their
2484/// single vec by it. Over-cap sets the per-vec `truncated` flag.
2485pub const MAX_LIST_LIMIT: usize = 500;
2486
2487/// Closed set of probe kinds surfaced by `probe_list` / `probe_status`.
2488#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
2489#[serde(rename_all = "snake_case")]
2490pub enum ProbeKind {
2491    Command,
2492    FileWatch,
2493    Pty,
2494}
2495
2496/// Single authoritative liveness union for a probe / subscription source.
2497///
2498/// Derived from the job ledger (NOT from live-map presence: command
2499/// bindings linger in the live map after exit, so presence is not
2500/// "running"). Reused by `ProbeListEntry.liveness` and the subscription
2501/// `SourceLiveness`. See subscriptions spec MUST-ADD #3.
2502///
2503/// `JobState -> Liveness` mapping (command kind):
2504/// `Starting -> Starting`, `Running -> Running`,
2505/// `Exited -> Exited{code}`, `Failed -> Failed{code,signal}`,
2506/// `Cancelled -> Cancelled` (cancel sets signal `"CANCELLED"` and MUST
2507/// NOT be folded into `Failed`). File-watch / PTY report `Running`
2508/// while present (no exit-code concept on the live-map path).
2509/// `Dropped{count}` carries a bucket's `dropped_count` when surfacing
2510/// bucket-level lag (not used by per-probe derivation today).
2511#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2512#[serde(rename_all = "snake_case", tag = "state")]
2513pub enum Liveness {
2514    /// Probe registered, process not yet observed running.
2515    Starting,
2516    /// Probe is live (process running, or watch/PTY present).
2517    Running,
2518    /// Process exited cleanly (code 0, no signal).
2519    Exited { code: i32 },
2520    /// Process exited non-zero or was killed by a signal.
2521    Failed {
2522        code: Option<i32>,
2523        signal: Option<String>,
2524    },
2525    /// Operator-initiated kill before exit (JobState::Cancelled).
2526    Cancelled,
2527    /// Probe stopped without an exit code (watch/PTY removal).
2528    Stopped,
2529    /// Bucket-level lag: `count` events were dropped (eviction).
2530    Dropped { count: u64 },
2531}
2532
2533impl Default for Liveness {
2534    /// Backstop for `#[serde(default)]` on `ProbeListEntry.liveness`
2535    /// when decoding an older payload that predates the field. A
2536    /// present-but-unannotated probe is treated as `Running`.
2537    fn default() -> Self {
2538        Self::Running
2539    }
2540}
2541
2542/// One row in `probe_list` / `runtime_state.probes`.
2543///
2544/// Bounded; never carries raw stream content. `argv` is the
2545/// bounded argv passed at spawn time (for FileWatch this is
2546/// `["file_watch:<path>"]`, matching how `WatchRuntime` registers
2547/// the JobConfig today; for PTY this is the original argv).
2548#[derive(Debug, Clone, Serialize, Deserialize)]
2549pub struct ProbeListEntry {
2550    pub kind: ProbeKind,
2551    pub job_id: JobId,
2552    pub bucket_id: BucketId,
2553    pub probe_id: terminal_commander_core::ProbeId,
2554    pub frames_total: u64,
2555    pub events_emitted: u64,
2556    #[serde(default)]
2557    pub frames_suppressed: u64,
2558    #[serde(default)]
2559    pub frames_suppressed_progress: u64,
2560    #[serde(default)]
2561    pub frames_suppressed_dedupe: u64,
2562    /// PTY only — surfaces `PtyProbeMetrics::secret_prompts_total`.
2563    /// Other kinds return 0.
2564    pub secret_prompts_total: u64,
2565    /// PTY only — current secret-prompt flag from the probe.
2566    /// Other kinds return `false`.
2567    pub secret_prompt_active: bool,
2568    /// File-watch only — surfaces the watched path. Other kinds
2569    /// return None.
2570    pub path: Option<std::path::PathBuf>,
2571    /// Per-source liveness. Command probes derive this from the job
2572    /// ledger (`command.status(job).state`) — NOT from live-map
2573    /// presence, which lingers after exit. File-watch and PTY probes
2574    /// report `Running` while present. `#[serde(default)]` so older
2575    /// payloads (pre-liveness) decode as `Running`.
2576    #[serde(default)]
2577    pub liveness: Liveness,
2578    /// Optional per-bucket tag lifted from the bucket source. `#[serde(default,
2579    /// skip_serializing_if)]` keeps the wire additive: old payloads decode as None,
2580    /// old daemons omit it, new clients see None.
2581    #[serde(default, skip_serializing_if = "Option::is_none")]
2582    pub tag: Option<String>,
2583    /// Bounded, REDACTED argv head (program + up to 2 tokens), with secret spans
2584    /// masked to `<redacted>` and each item truncated to 128 bytes. None when the
2585    /// source kind carries no argv. Additive: `#[serde(default, skip_serializing_if)]`.
2586    #[serde(default, skip_serializing_if = "Option::is_none")]
2587    pub argv_head: Option<Vec<String>>,
2588}
2589
2590/// Bucket-level counters surfaced by `runtime_state.buckets`.
2591#[derive(Debug, Clone, Serialize, Deserialize)]
2592pub struct RuntimeBucketSummary {
2593    pub bucket_id: BucketId,
2594    pub head_seq: u64,
2595    pub tail_seq: u64,
2596    pub event_count: u64,
2597    /// Backpressure indicator: events dropped by the bucket's
2598    /// retention policy. Already tracked by `BucketSummary`
2599    /// (TC07); surfaced here in the aggregate view.
2600    pub dropped_count: u64,
2601}
2602
2603/// One active scoped registry binding surfaced by
2604/// `runtime_state.active_rules`.
2605#[derive(Debug, Clone, Serialize, Deserialize)]
2606pub struct RuntimeActiveRule {
2607    pub rule_id: String,
2608    pub version: u32,
2609    pub event_kind: String,
2610    pub scope: terminal_commander_core::ActivationScope,
2611}
2612
2613/// Optional per-call `limit` for the single-vec list snapshots
2614/// (`probe_list`, `registry_list_active`) and `runtime_state` (applied
2615/// independently to each of its three vecs). Clamped to [`MAX_LIST_LIMIT`].
2616#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2617pub struct ListLimitParams {
2618    /// Max rows per vec. Clamped to [`MAX_LIST_LIMIT`]. Omitted =
2619    /// [`MAX_LIST_LIMIT`].
2620    #[serde(default, skip_serializing_if = "Option::is_none")]
2621    pub limit: Option<usize>,
2622}
2623
2624#[derive(Debug, Clone, Serialize, Deserialize)]
2625pub struct RuntimeStateResponse {
2626    pub command_jobs: u32,
2627    pub pty_jobs: u32,
2628    pub file_watches: u32,
2629    pub bucket_count: u32,
2630    pub active_rules_count: u32,
2631    pub probes: Vec<ProbeListEntry>,
2632    pub buckets: Vec<RuntimeBucketSummary>,
2633    pub active_rules: Vec<RuntimeActiveRule>,
2634    /// More probes existed than `probes` carries (bounded independently
2635    /// by [`MAX_LIST_LIMIT`] / the request `limit`).
2636    #[serde(default)]
2637    pub probes_truncated: bool,
2638    /// More buckets existed than `buckets` carries.
2639    #[serde(default)]
2640    pub buckets_truncated: bool,
2641    /// More active rules existed than `active_rules` carries.
2642    #[serde(default)]
2643    pub active_rules_truncated: bool,
2644}
2645
2646#[derive(Debug, Clone, Serialize, Deserialize)]
2647pub struct ProbeListResponse {
2648    pub probes: Vec<ProbeListEntry>,
2649    /// More probes existed than `probes` carries (bounded by
2650    /// [`MAX_LIST_LIMIT`] / the request `limit`).
2651    #[serde(default)]
2652    pub truncated: bool,
2653}
2654
2655#[derive(Debug, Clone, Serialize, Deserialize)]
2656pub struct ProbeStatusParams {
2657    pub probe_id: terminal_commander_core::ProbeId,
2658}
2659
2660#[derive(Debug, Clone, Serialize, Deserialize)]
2661pub struct ProbeStatusResponse {
2662    pub probe: ProbeListEntry,
2663}
2664
2665// =====================================================================
2666// P4: audit-log read surface (read-only).
2667//
2668// `audit_since` exposes a cursor-paged, bounded view of the persistent
2669// audit log. The protocol owns its serde surface: [`AuditRowWire`]
2670// MIRRORS `terminal_commander_store::AuditRow` the same way
2671// [`SeverityHistogram`] mirrors its in-memory type. The wire form does
2672// NOT couple to store internals — `timestamp` is carried as an RFC3339
2673// string so the protocol crate needs no `time` dependency.
2674// =====================================================================
2675
2676/// Hard cap on rows returned by `audit_since` in a single call.
2677/// Mirrors `terminal_commander_store::MAX_AUDIT_READ_LIMIT`. The daemon
2678/// dispatcher clamps oversize / omitted limits to this value.
2679pub const MAX_AUDIT_READ_LIMIT: usize = 10_000;
2680/// Default rows returned when the caller omits a limit. Mirrors
2681/// `terminal_commander_store::DEFAULT_AUDIT_READ_LIMIT`.
2682pub const DEFAULT_AUDIT_READ_LIMIT: usize = 200;
2683
2684/// Parameters for `audit_since`. Reads rows strictly after `cursor`
2685/// (i.e. `audit_id > cursor`), ordered ascending by `audit_id`.
2686#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2687pub struct AuditSinceParams {
2688    /// Return rows with `audit_id > cursor`. `0` reads from the start.
2689    pub cursor: u64,
2690    /// Optional exact-match action filter (e.g. `"registry_activate"`).
2691    #[serde(default, skip_serializing_if = "Option::is_none")]
2692    pub action_filter: Option<String>,
2693    /// Optional exact-match decision filter (e.g. `"info"`, `"error"`).
2694    #[serde(default, skip_serializing_if = "Option::is_none")]
2695    pub decision_filter: Option<String>,
2696    /// Result count cap. Clamped to [`MAX_AUDIT_READ_LIMIT`] at the
2697    /// dispatcher. Omitted = [`DEFAULT_AUDIT_READ_LIMIT`].
2698    #[serde(default, skip_serializing_if = "Option::is_none")]
2699    pub limit: Option<usize>,
2700}
2701
2702/// One audit row on the wire.
2703///
2704/// MIRRORS `terminal_commander_store::AuditRow` field-for-field; the
2705/// daemon maps the in-memory row to this shape (a unit test guards the
2706/// mapping against drift). `timestamp` is the row's RFC3339 string —
2707/// the same encoding the store persists — so the protocol owns its
2708/// serde surface without a `time` dependency.
2709#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2710pub struct AuditRowWire {
2711    pub audit_id: u64,
2712    /// RFC3339 timestamp string.
2713    pub timestamp: String,
2714    pub action: String,
2715    pub subject: String,
2716    pub decision: String,
2717    #[serde(default, skip_serializing_if = "Option::is_none")]
2718    pub profile: Option<String>,
2719    #[serde(default, skip_serializing_if = "Option::is_none")]
2720    pub reason: Option<String>,
2721    #[serde(default, skip_serializing_if = "Option::is_none")]
2722    pub actor: Option<String>,
2723    #[serde(default, skip_serializing_if = "Option::is_none")]
2724    pub metadata_json: Option<String>,
2725}
2726
2727/// Response shape for `audit_since`. Bounded by
2728/// [`MAX_AUDIT_READ_LIMIT`]; carries the next cursor so a client can
2729/// page forward without re-reading.
2730#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2731pub struct AuditSinceResponse {
2732    /// Echo of the requested cursor.
2733    pub cursor_in: u64,
2734    /// `audit_id` of the last returned row, or `cursor_in` when empty.
2735    /// Pass as the next call's `cursor` to page forward.
2736    pub next_cursor: u64,
2737    pub rows: Vec<AuditRowWire>,
2738}
2739
2740// =====================================================================
2741// Subscriptions (Phase 1): predicate-routed, multiplexed event consumer.
2742//
2743// Wire types for `subscription_open/pull/list/close`. The daemon holds
2744// the opaque per-open `sub_id` + server-advanced offsets; the wire form
2745// carries the predicate, the source-tagged events, and per-source
2746// liveness. See `docs/superpowers/specs/2026-06-02-subscriptions-design.md`
2747// §4, §6.
2748// =====================================================================
2749
2750/// Per-bucket routing selector on the wire. Mirrors the daemon-internal
2751/// `SourceSel`. `all` auto-includes future matching buckets; the fixed
2752/// variants are a closed set of ids.
2753#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2754#[serde(rename_all = "snake_case", tag = "kind")]
2755pub enum SubscriptionSourceSel {
2756    /// Every bucket; future buckets auto-join on the next routing rebuild.
2757    All,
2758    /// A fixed set of owning jobs.
2759    Jobs { jobs: Vec<JobId> },
2760    /// A fixed set of bucket ids.
2761    Buckets { buckets: Vec<BucketId> },
2762    /// A fixed set of owning probes.
2763    Probes {
2764        probes: Vec<terminal_commander_core::ProbeId>,
2765    },
2766}
2767
2768/// The wire predicate. All fields optional; AND semantics. `severity_min`
2769/// and `kind` are per-EVENT filters; `sources` is per-BUCKET routing.
2770#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2771pub struct SubscriptionPredicate {
2772    /// Minimum severity (per-EVENT). Omitted = `trace` (no filter).
2773    #[serde(default, skip_serializing_if = "Option::is_none")]
2774    pub severity_min: Option<Severity>,
2775    /// Event-kind allowlist (per-EVENT). Omitted = any kind.
2776    #[serde(default, skip_serializing_if = "Option::is_none")]
2777    pub kind: Option<Vec<String>>,
2778    /// Per-BUCKET routing selector. Omitted = `all`.
2779    #[serde(default = "default_source_sel")]
2780    pub sources: SubscriptionSourceSel,
2781    /// Per-BUCKET tag AND-filter. Omitted = ignore the tag dimension.
2782    #[serde(default, skip_serializing_if = "Option::is_none")]
2783    pub tag: Option<String>,
2784}
2785
2786/// Default routing selector (`all`) when `sources` is omitted on the wire.
2787const fn default_source_sel() -> SubscriptionSourceSel {
2788    SubscriptionSourceSel::All
2789}
2790
2791/// One source-tagged event delivered by `subscription_pull`.
2792///
2793/// Reuses the existing [`SignalEvent`] wire shape (the same type
2794/// `bucket_events_since` returns), plus its provenance so a multiplexed
2795/// consumer can attribute it without juggling N cursors.
2796#[derive(Debug, Clone, Serialize, Deserialize)]
2797pub struct SubscriptionEvent {
2798    /// Bucket the event was read from.
2799    pub bucket_id: BucketId,
2800    /// Owning job, if the bucket's source recorded one.
2801    #[serde(default, skip_serializing_if = "Option::is_none")]
2802    pub job_id: Option<JobId>,
2803    /// The event's per-bucket sequence number (provenance echo).
2804    pub seq: u64,
2805    /// The matched signal event.
2806    pub event: SignalEvent,
2807}
2808
2809/// Per-source liveness entry returned with every pull (including idle).
2810#[derive(Debug, Clone, Serialize, Deserialize)]
2811pub struct SourceLiveness {
2812    /// The in-scope bucket this entry describes.
2813    pub bucket_id: BucketId,
2814    /// Owning job, if recorded.
2815    #[serde(default, skip_serializing_if = "Option::is_none")]
2816    pub job_id: Option<JobId>,
2817    /// Owning probe, if recorded.
2818    #[serde(default, skip_serializing_if = "Option::is_none")]
2819    pub probe_id: Option<terminal_commander_core::ProbeId>,
2820    /// Process / probe liveness (the single authoritative union).
2821    pub liveness: Liveness,
2822}
2823
2824/// One row in `subscription_list`. Bounded; the predicate hash lets an
2825/// agent recognize an equivalent predicate without coupling cursors.
2826#[derive(Debug, Clone, Serialize, Deserialize)]
2827pub struct SubscriptionSummary {
2828    /// Opaque per-open handle.
2829    pub sub_id: String,
2830    /// Stable hash of the normalized predicate (decimal string).
2831    pub predicate_hash: String,
2832    /// Number of buckets this subscription currently tracks an offset for.
2833    pub source_count: u32,
2834    /// Milliseconds since the Unix epoch at open.
2835    pub created_at_ms: u64,
2836    /// Milliseconds since the Unix epoch of the last pull, if any.
2837    #[serde(default, skip_serializing_if = "Option::is_none")]
2838    pub last_pull_at_ms: Option<u64>,
2839}
2840
2841/// `subscription_open` parameters.
2842#[derive(Debug, Clone, Serialize, Deserialize)]
2843pub struct SubscriptionOpenParams {
2844    pub predicate: SubscriptionPredicate,
2845}
2846
2847/// `subscription_open` response. `boot_id` lets a looping agent detect a
2848/// restart (registry + buckets + offsets reset together).
2849#[derive(Debug, Clone, Serialize, Deserialize)]
2850pub struct SubscriptionOpenResponse {
2851    /// Opaque per-open handle (uuid string).
2852    pub sub_id: String,
2853    /// This daemon's per-boot id (uuid string).
2854    pub boot_id: String,
2855    /// Stable hash of the normalized predicate (decimal string).
2856    pub predicate_hash: String,
2857    /// Milliseconds since the Unix epoch at open.
2858    pub created_at_ms: u64,
2859    /// Number of already-in-scope sources matched at open.
2860    pub matched_sources: u32,
2861}
2862
2863/// `subscription_pull` parameters.
2864#[derive(Debug, Clone, Serialize, Deserialize)]
2865pub struct SubscriptionPullParams {
2866    pub sub_id: String,
2867    /// Max events to return. Clamped to [`MAX_PULL_EVENTS`]. Omitted =
2868    /// [`MAX_PULL_EVENTS`].
2869    #[serde(default, skip_serializing_if = "Option::is_none")]
2870    pub max: Option<usize>,
2871    /// Blocking timeout. Clamped to `[1, MAX_PULL_TIMEOUT_MS]`. Omitted =
2872    /// [`DEFAULT_PULL_TIMEOUT_MS`].
2873    #[serde(default, skip_serializing_if = "Option::is_none")]
2874    pub timeout_ms: Option<u64>,
2875    /// When true, `liveness` in the response carries only entries whose state
2876    /// changed since the last pull (full snapshot on a subscription's first
2877    /// pull and after any seek). Default false = today's full-array behavior,
2878    /// byte-identical for existing callers.
2879    #[serde(default)]
2880    pub liveness_delta: bool,
2881}
2882
2883/// `subscription_pull` response. Idle = empty `events` + liveness (never
2884/// an error). No `next_state` in Phase 1.
2885#[derive(Debug, Clone, Serialize, Deserialize)]
2886pub struct SubscriptionPullResponse {
2887    pub events: Vec<SubscriptionEvent>,
2888    pub liveness: Vec<SourceLiveness>,
2889    /// Any in-scope bucket dropped events under us (eviction lag).
2890    pub lagged: bool,
2891    /// The routing scan hit [`MAX_BUCKETS_PER_SUBSCRIPTION`].
2892    pub truncated: bool,
2893}
2894
2895/// `subscription_list` parameters. Bounded by an optional `limit`.
2896#[derive(Debug, Clone, Default, Serialize, Deserialize)]
2897pub struct SubscriptionListParams {
2898    /// Max rows to return. Clamped to [`MAX_SUBSCRIPTIONS`]. Omitted =
2899    /// [`MAX_SUBSCRIPTIONS`].
2900    #[serde(default, skip_serializing_if = "Option::is_none")]
2901    pub limit: Option<usize>,
2902}
2903
2904/// `subscription_list` response. Bounded; `truncated` set if more
2905/// subscriptions exist than were returned.
2906#[derive(Debug, Clone, Serialize, Deserialize)]
2907pub struct SubscriptionListResponse {
2908    pub subscriptions: Vec<SubscriptionSummary>,
2909    pub truncated: bool,
2910}
2911
2912/// `subscription_close` parameters.
2913#[derive(Debug, Clone, Serialize, Deserialize)]
2914pub struct SubscriptionCloseParams {
2915    pub sub_id: String,
2916}
2917
2918/// `subscription_close` response. `closed=false` when the `sub_id` was
2919/// already unknown (idempotent close).
2920#[derive(Debug, Clone, Serialize, Deserialize)]
2921pub struct SubscriptionCloseResponse {
2922    pub closed: bool,
2923}
2924
2925/// `subscription_seek` parameters.
2926///
2927/// Reposition ONE bucket's offset for an existing subscription (explicit
2928/// re-read). The requested `seq` is clamped to the bucket's live range; it is
2929/// never an error. (Subscriptions §3 seek.)
2930#[derive(Debug, Clone, Serialize, Deserialize)]
2931pub struct SubscriptionSeekParams {
2932    /// Opaque handle from `subscription_open`.
2933    pub sub_id: String,
2934    /// Bucket to reposition within.
2935    pub bucket_id: BucketId,
2936    /// Requested re-read position. Clamped to
2937    /// `[head_seq.saturating_sub(1), tail_seq]`; never an error.
2938    pub seq: u64,
2939}
2940
2941/// `subscription_seek` response.
2942#[derive(Debug, Clone, Serialize, Deserialize)]
2943pub struct SubscriptionSeekResponse {
2944    /// The offset actually stored after clamping.
2945    pub clamped_seq: u64,
2946    /// True when the requested seq was below `head_seq.saturating_sub(1)`
2947    /// (the requested events were already evicted).
2948    pub lagged: bool,
2949}
2950
2951/// Serialize an envelope to a length-prefixed wire frame. Returns
2952/// the bytes ready to write to the socket. Rejects payloads larger
2953/// than [`MAX_FRAME_BYTES`].
2954pub fn encode_frame<T: Serialize>(value: &T) -> Result<Vec<u8>, IpcError> {
2955    let json = serde_json::to_vec(value)
2956        .map_err(|e| IpcError::new(IpcErrorCode::Internal, format!("serialize: {e}")))?;
2957    if json.len() > MAX_FRAME_BYTES {
2958        return Err(IpcError::new(
2959            IpcErrorCode::FrameTooLarge,
2960            format!(
2961                "payload {} bytes > MAX_FRAME_BYTES {MAX_FRAME_BYTES}",
2962                json.len()
2963            ),
2964        ));
2965    }
2966    let len_u32 = u32::try_from(json.len())
2967        .map_err(|_| IpcError::new(IpcErrorCode::Internal, "len overflow"))?;
2968    let mut out = Vec::with_capacity(4 + json.len());
2969    out.extend_from_slice(&len_u32.to_be_bytes());
2970    out.extend_from_slice(&json);
2971    Ok(out)
2972}
2973
2974/// Deserialize the JSON portion of a frame (length-prefix stripped
2975/// by the caller). The caller has already validated that
2976/// `payload.len() <= MAX_FRAME_BYTES`.
2977pub fn decode_payload<T: for<'de> Deserialize<'de>>(payload: &[u8]) -> Result<T, IpcError> {
2978    serde_json::from_slice::<T>(payload)
2979        .map_err(|e| IpcError::new(IpcErrorCode::MalformedJson, format!("decode: {e}")))
2980}
2981
2982#[cfg(test)]
2983mod tests {
2984    use super::*;
2985
2986    #[test]
2987    fn transport_error_is_classified_and_daemon_internal_is_not() {
2988        // A client-side transport failure carries the marker and is recognized.
2989        let t = IpcError::transport("connect: os error 2");
2990        assert_eq!(t.code, IpcErrorCode::Internal);
2991        assert!(
2992            t.is_transport(),
2993            "a transport() error must classify as transport"
2994        );
2995        assert!(
2996            t.message.starts_with(IpcError::TRANSPORT_PREFIX),
2997            "transport message keeps a human-readable marker prefix"
2998        );
2999        assert!(
3000            t.message.contains("connect: os error 2"),
3001            "the underlying cause is preserved in the message"
3002        );
3003
3004        // A daemon-RETURNED Internal error (no marker) must NOT classify as
3005        // transport, so it keeps the real internal_error mapping at the MCP edge.
3006        let daemon_internal = IpcError::new(IpcErrorCode::Internal, "open: permission denied");
3007        assert!(
3008            !daemon_internal.is_transport(),
3009            "a daemon-returned Internal error must NOT be misread as transport"
3010        );
3011
3012        // A caller-fixable code is never transport regardless of message text.
3013        let fixable = IpcError::new(IpcErrorCode::PathDenied, "transport: not really");
3014        assert!(
3015            !fixable.is_transport(),
3016            "only Internal-coded marker-prefixed errors are transport"
3017        );
3018    }
3019
3020    #[test]
3021    fn transport_error_survives_wire_round_trip() {
3022        // The marker rides in the message, so a transport error serialized and
3023        // decoded over the wire is still recognized (defensive: the daemon never
3024        // sends one, but the classifier must be robust if it ever did).
3025        let t = IpcError::transport("pipe connect: os error 2");
3026        let json = serde_json::to_string(&t).unwrap();
3027        let back: IpcError = serde_json::from_str(&json).unwrap();
3028        assert!(back.is_transport());
3029    }
3030
3031    #[test]
3032    fn legacy_discover_without_environment_decodes_as_unknown() {
3033        let json = r#"{
3034            "version":"0.1.0",
3035            "mcp_spec":"2025-11-25",
3036            "policy_profile":"default",
3037            "methods":["system_discover"]
3038        }"#;
3039        let decoded: DiscoverResponse = serde_json::from_str(json).unwrap();
3040        assert_eq!(*decoded.environment, HostEnvironment::default());
3041    }
3042
3043    /// F7: the typed `argv0` field is the authoritative carrier for a
3044    /// `ProgramNotFound` error and must survive a serde round-trip exactly,
3045    /// independent of the human-readable `message`. The MCP boundary reads
3046    /// THIS field (not the prose), so the round-trip is the contract.
3047    #[test]
3048    fn program_not_found_argv0_survives_wire_round_trip() {
3049        // A program name with an embedded apostrophe -- the exact case the old
3050        // prose quote-count parse could not recover. The typed field carries it
3051        // verbatim regardless of how the message is worded.
3052        let e = IpcError::program_not_found("my'prog", "program not found: 'my'prog'.");
3053        assert_eq!(e.code, IpcErrorCode::ProgramNotFound);
3054        assert_eq!(e.argv0.as_deref(), Some("my'prog"));
3055
3056        let json = serde_json::to_string(&e).unwrap();
3057        let back: IpcError = serde_json::from_str(&json).unwrap();
3058        assert_eq!(
3059            back.argv0.as_deref(),
3060            Some("my'prog"),
3061            "the typed argv0 must survive serialization verbatim"
3062        );
3063        assert_eq!(back.code, IpcErrorCode::ProgramNotFound);
3064        assert_eq!(back.message, e.message);
3065    }
3066
3067    /// F7: `argv0` is additive and `skip_serializing_if = "Option::is_none"`,
3068    /// so a non-ProgramNotFound error (the common case) must OMIT the `argv0`
3069    /// key from the wire entirely -- no key, not `null`. This pins the
3070    /// skip_serializing_if contract so every other error round-trips unchanged
3071    /// and pre-F7 clients see no new field.
3072    #[test]
3073    fn argv0_key_is_omitted_from_json_when_none() {
3074        let e = IpcError::new(IpcErrorCode::PathDenied, "nope");
3075        assert!(e.argv0.is_none());
3076        let value: serde_json::Value = serde_json::to_value(&e).unwrap();
3077        assert!(
3078            value.get("argv0").is_none(),
3079            "the argv0 key must be absent (not null) when None; got: {value}"
3080        );
3081        // And the typical constructors default it to None.
3082        assert!(IpcError::transport("x").argv0.is_none());
3083    }
3084
3085    // Source-status: test-only. TC-2: the dedup_nonce field is additive and
3086    // serde(default), so an OLD client payload that omits it must still
3087    // decode, and a payload carrying Some(nonce) must round-trip unchanged.
3088    #[test]
3089    fn command_start_params_dedup_nonce_is_optional_and_round_trips() {
3090        // Old-client payload: no dedup_nonce key at all. Must decode via the
3091        // serde default to None (additive, non-breaking).
3092        let old_payload = r#"{"argv":["true"]}"#;
3093        let decoded: CommandStartParams =
3094            serde_json::from_str(old_payload).expect("old payload without dedup_nonce must decode");
3095        assert_eq!(
3096            decoded.dedup_nonce, None,
3097            "absent dedup_nonce must default to None"
3098        );
3099
3100        // Present nonce: serialize -> decode preserves it exactly.
3101        let with_nonce = CommandStartParams {
3102            environment: None,
3103            argv: vec!["true".to_owned()],
3104            cwd: None,
3105            env: vec![],
3106            bucket_config: None,
3107            rules: vec![],
3108            grace_ms: None,
3109            tag: None,
3110            dedup_nonce: Some("mcp-1234-7".to_owned()),
3111            strip_ansi: true,
3112        };
3113        let json = serde_json::to_string(&with_nonce).expect("serialize ok");
3114        assert!(
3115            json.contains("dedup_nonce"),
3116            "a Some(nonce) must serialize the field; json: {json}"
3117        );
3118        let back: CommandStartParams = serde_json::from_str(&json).expect("decode ok");
3119        assert_eq!(back.dedup_nonce, Some("mcp-1234-7".to_owned()));
3120
3121        // skip_serializing_if: a None nonce must NOT appear on the wire, so an
3122        // old daemon never sees an unexpected key.
3123        let without = CommandStartParams {
3124            dedup_nonce: None,
3125            strip_ansi: true,
3126            ..with_nonce
3127        };
3128        let json_none = serde_json::to_string(&without).expect("serialize ok");
3129        assert!(
3130            !json_none.contains("dedup_nonce"),
3131            "a None nonce must be omitted from the wire; json: {json_none}"
3132        );
3133    }
3134
3135    // TC-4 Phase 4a: the ProbeListEntry `tag` and `argv_head` fields are
3136    // additive and `#[serde(default, skip_serializing_if)]`. An OLD payload
3137    // that omits both must decode (fields default to None), and a daemon that
3138    // has neither set must omit both keys from the wire (skip_serializing_if).
3139    #[test]
3140    fn probe_list_entry_tag_and_argv_head_are_optional_and_additive() {
3141        use terminal_commander_core::{BucketId, JobId, ProbeId};
3142
3143        // Build a fully-populated entry, then serialize it and strip the new
3144        // keys to reconstruct an OLD-daemon payload deterministically (the typed
3145        // ids serialize as `<prefix>_<hex>`, not raw UUIDs, so we cannot hand-
3146        // write the wire form). This also exercises the live wire prefixes.
3147        let populated = ProbeListEntry {
3148            kind: ProbeKind::Command,
3149            job_id: JobId::new(),
3150            bucket_id: BucketId::new(),
3151            probe_id: ProbeId::new(),
3152            frames_total: 0,
3153            events_emitted: 0,
3154            frames_suppressed: 0,
3155            frames_suppressed_progress: 0,
3156            frames_suppressed_dedupe: 0,
3157            secret_prompts_total: 0,
3158            secret_prompt_active: false,
3159            path: None,
3160            liveness: Liveness::default(),
3161            tag: Some("prod".to_owned()),
3162            argv_head: Some(vec!["curl".to_owned(), "<redacted>".to_owned()]),
3163        };
3164
3165        // Present values round-trip exactly.
3166        let json_full = serde_json::to_string(&populated).expect("encode populated");
3167        let back: ProbeListEntry =
3168            serde_json::from_str(&json_full).expect("populated payload round-trips");
3169        assert_eq!(back.tag.as_deref(), Some("prod"));
3170        assert_eq!(
3171            back.argv_head,
3172            Some(vec!["curl".to_owned(), "<redacted>".to_owned()])
3173        );
3174
3175        // skip_serializing_if: a None tag/argv_head must NOT appear on the wire,
3176        // so an old daemon and a new client agree on the encoded shape.
3177        let none_entry = ProbeListEntry {
3178            tag: None,
3179            argv_head: None,
3180            ..populated
3181        };
3182        let json_none = serde_json::to_string(&none_entry).expect("encode none");
3183        assert!(
3184            !json_none.contains("\"tag\""),
3185            "None tag must be omitted from the wire (skip_serializing_if)"
3186        );
3187        assert!(
3188            !json_none.contains("\"argv_head\""),
3189            "None argv_head must be omitted from the wire (skip_serializing_if)"
3190        );
3191
3192        // Old-daemon payload shape: the encoded none_entry already omits both
3193        // keys, so decoding it proves an absent tag/argv_head defaults to None.
3194        let decoded: ProbeListEntry = serde_json::from_str(&json_none)
3195            .expect("old payload without tag/argv_head must decode");
3196        assert_eq!(decoded.tag, None, "absent tag must default to None");
3197        assert_eq!(
3198            decoded.argv_head, None,
3199            "absent argv_head must default to None"
3200        );
3201    }
3202
3203    // Source-status: test-only. Verifies the retry-safety classification on
3204    // `IpcRequest::is_idempotent`, which gates the MCP daemon-client retry so a
3205    // transport-failed MUTATING RPC (e.g. a >5s-spawning CommandStartCombed) is
3206    // never blindly re-sent. The list below is exhaustive over the enum; the
3207    // `is_idempotent` match itself is wildcard-free so a new variant forces a
3208    // deliberate classification before this test can even be updated.
3209    #[test]
3210    #[allow(clippy::too_many_lines)] // one table row per IpcRequest variant
3211    fn is_idempotent_classifies_every_request_variant() {
3212        use terminal_commander_core::{JobId, RuleDefinition};
3213
3214        // A minimal valid RuleDefinition for the two rule-carrying registry
3215        // variants. Deserialized rather than hand-built so this test stays
3216        // decoupled from the full struct shape (it only needs *a* value).
3217        fn sample_rule() -> RuleDefinition {
3218            serde_json::from_str(
3219                r#"{
3220                    "id": "r",
3221                    "version": 1,
3222                    "kind": "keyword",
3223                    "severity": "low",
3224                    "event_kind": "k",
3225                    "keywords": ["x"],
3226                    "summary_template": "s"
3227                }"#,
3228            )
3229            .expect("sample rule deserializes")
3230        }
3231
3232        let predicate = SubscriptionPredicate {
3233            severity_min: None,
3234            kind: None,
3235            sources: SubscriptionSourceSel::All,
3236            tag: None,
3237        };
3238
3239        // (variant, expected is_idempotent). MUTATING = false (unsafe to blind
3240        // re-send), READ / idempotent-reposition = true.
3241        let cases: Vec<(IpcRequest, bool)> = vec![
3242            // ---- mutating: must be false ----
3243            (
3244                IpcRequest::CommandStartCombed(CommandStartParams {
3245                    environment: None,
3246                    argv: vec!["x".to_owned()],
3247                    cwd: None,
3248                    env: vec![],
3249                    bucket_config: None,
3250                    rules: vec![],
3251                    grace_ms: None,
3252                    tag: None,
3253                    dedup_nonce: None,
3254                    strip_ansi: true,
3255                }),
3256                false,
3257            ),
3258            (
3259                IpcRequest::ShellExec(ShellExecParams {
3260                    shell_line: "echo a | wc -c".to_owned(),
3261                    shell: None,
3262                    cwd: None,
3263                    env: vec![],
3264                    rules: vec![],
3265                    bucket_config: None,
3266                    tag: None,
3267                }),
3268                false,
3269            ),
3270            (
3271                IpcRequest::PtyCommandStart(PtyCommandStartParams {
3272                    environment: None,
3273                    argv: vec!["x".to_owned()],
3274                    cwd: None,
3275                    env: vec![],
3276                    bucket_config: None,
3277                    rules: vec![],
3278                    rows: None,
3279                    cols: None,
3280                    tag: None,
3281                }),
3282                false,
3283            ),
3284            (
3285                IpcRequest::PtyCommandWriteStdin(PtyCommandWriteStdinParams {
3286                    job_id: JobId::new(),
3287                    bytes: "x".to_owned(),
3288                    cursor: None,
3289                    wait_ms: None,
3290                }),
3291                false,
3292            ),
3293            (
3294                IpcRequest::PtyCommandStop(PtyCommandStopParams {
3295                    job_id: JobId::new(),
3296                }),
3297                false,
3298            ),
3299            (
3300                IpcRequest::CommandStop(CommandStopParams {
3301                    job_id: JobId::new(),
3302                }),
3303                false,
3304            ),
3305            (
3306                IpcRequest::RegistryUpsert(RegistryUpsertParams {
3307                    definition: sample_rule(),
3308                }),
3309                false,
3310            ),
3311            (
3312                IpcRequest::RegistryActivate(RegistryActivateParams {
3313                    rule_id: "r".to_owned(),
3314                    version: None,
3315                    scope: None,
3316                }),
3317                false,
3318            ),
3319            (
3320                IpcRequest::RegistryDeactivate(RegistryDeactivateParams {
3321                    rule_id: "r".to_owned(),
3322                    version: 1,
3323                    scope: None,
3324                }),
3325                false,
3326            ),
3327            (
3328                IpcRequest::RegistryImportPack(RegistryImportPackParams {
3329                    pack: "p".to_owned(),
3330                    activate: false,
3331                    scope: None,
3332                }),
3333                false,
3334            ),
3335            (
3336                // File WRITE (TC22 A3): MUTATING -- a blind retry double-writes,
3337                // so is_idempotent MUST be false. Classified with the other
3338                // file-state mutators, NOT with FileReadWindow / FileSearch.
3339                IpcRequest::FileWrite(FileWriteParams {
3340                    path: "x".into(),
3341                    content: "data".to_owned(),
3342                    create_dirs: false,
3343                    append: false,
3344                }),
3345                false,
3346            ),
3347            (
3348                IpcRequest::FileWatchStart(FileWatchStartParams {
3349                    path: "x".into(),
3350                    bucket_config: None,
3351                    rules: vec![],
3352                    follow_from_beginning: None,
3353                    tag: None,
3354                }),
3355                false,
3356            ),
3357            (
3358                IpcRequest::FileWatchStop(FileWatchStopParams {
3359                    watch_id: JobId::new(),
3360                }),
3361                false,
3362            ),
3363            (
3364                IpcRequest::SubscriptionOpen(SubscriptionOpenParams {
3365                    predicate: predicate.clone(),
3366                }),
3367                false,
3368            ),
3369            (
3370                IpcRequest::SubscriptionClose(SubscriptionCloseParams {
3371                    sub_id: "s".to_owned(),
3372                }),
3373                false,
3374            ),
3375            (
3376                IpcRequest::SubscriptionPull(SubscriptionPullParams {
3377                    sub_id: "s".to_owned(),
3378                    max: None,
3379                    timeout_ms: None,
3380                    liveness_delta: false,
3381                }),
3382                false,
3383            ),
3384            (IpcRequest::Shutdown, false),
3385            // ---- reads / idempotent repositioning: must be true ----
3386            (IpcRequest::Health, true),
3387            (IpcRequest::SystemDiscover, true),
3388            (IpcRequest::PolicyStatus, true),
3389            (IpcRequest::SelfCheck, true),
3390            (
3391                IpcRequest::CommandStatus(CommandStatusParams {
3392                    job_id: JobId::new(),
3393                }),
3394                true,
3395            ),
3396            (
3397                IpcRequest::CommandOutputTail(CommandOutputTailParams {
3398                    job_id: JobId::new(),
3399                    max_lines: 10,
3400                    max_bytes: 1024,
3401                    strip_ansi: false,
3402                }),
3403                true,
3404            ),
3405            (
3406                IpcRequest::BucketWait(BucketWaitParams {
3407                    bucket_id: BucketId::new(),
3408                    cursor: 0,
3409                    severity_min: None,
3410                    kind_filter: None,
3411                    limit: None,
3412                    timeout_ms: None,
3413                }),
3414                true,
3415            ),
3416            (
3417                IpcRequest::BucketEventsSince(BucketEventsSinceParams {
3418                    bucket_id: BucketId::new(),
3419                    cursor: 0,
3420                    severity_min: None,
3421                    kind_filter: None,
3422                    limit: None,
3423                }),
3424                true,
3425            ),
3426            (
3427                IpcRequest::BucketSummary(BucketSummaryParams {
3428                    bucket_id: BucketId::new(),
3429                }),
3430                true,
3431            ),
3432            (
3433                IpcRequest::EventContext(EventContextParams {
3434                    bucket_id: Some(BucketId::new()),
3435                    event_id: EventId::new(),
3436                    before: None,
3437                    after: None,
3438                    max_bytes: None,
3439                }),
3440                true,
3441            ),
3442            (
3443                IpcRequest::RuntimeState(ListLimitParams { limit: None }),
3444                true,
3445            ),
3446            (IpcRequest::ProbeList(ListLimitParams { limit: None }), true),
3447            (
3448                IpcRequest::ProbeStatus(ProbeStatusParams {
3449                    probe_id: terminal_commander_core::ProbeId::new(),
3450                }),
3451                true,
3452            ),
3453            (IpcRequest::PtyCommandList, true),
3454            (
3455                IpcRequest::FileReadWindow(FileReadWindowParams {
3456                    path: "x".into(),
3457                    start_line: None,
3458                    max_lines: None,
3459                    max_bytes: None,
3460                }),
3461                true,
3462            ),
3463            (
3464                IpcRequest::FileSearch(FileSearchParams {
3465                    path: "x".into(),
3466                    query: "q".to_owned(),
3467                    case_insensitive: None,
3468                    max_matches: None,
3469                    max_snippet_bytes: None,
3470                }),
3471                true,
3472            ),
3473            (
3474                // Directory listing (US3): a pure bounded read, replayable.
3475                IpcRequest::FileListDir(FileListDirParams {
3476                    path: "/x".to_owned(),
3477                    max_entries: None,
3478                }),
3479                true,
3480            ),
3481            (IpcRequest::FileWatchList, true),
3482            (
3483                IpcRequest::RegistrySearch(RegistrySearchParams {
3484                    query: "q".to_owned(),
3485                    limit: None,
3486                }),
3487                true,
3488            ),
3489            (
3490                IpcRequest::RegistryGet(RegistryGetParams {
3491                    rule_id: "r".to_owned(),
3492                    version: None,
3493                }),
3494                true,
3495            ),
3496            (
3497                IpcRequest::RegistryTest(RegistryTestParams {
3498                    rule_id: "r".to_owned(),
3499                    version: None,
3500                    samples: vec![],
3501                }),
3502                true,
3503            ),
3504            (
3505                IpcRequest::RegistryListActive(ListLimitParams { limit: None }),
3506                true,
3507            ),
3508            (
3509                IpcRequest::SubscriptionList(SubscriptionListParams { limit: None }),
3510                true,
3511            ),
3512            (
3513                IpcRequest::SubscriptionSeek(SubscriptionSeekParams {
3514                    sub_id: "s".to_owned(),
3515                    bucket_id: BucketId::new(),
3516                    seq: 0,
3517                }),
3518                true,
3519            ),
3520            (
3521                IpcRequest::AuditSince(AuditSinceParams {
3522                    cursor: 0,
3523                    action_filter: None,
3524                    decision_filter: None,
3525                    limit: None,
3526                }),
3527                true,
3528            ),
3529        ];
3530
3531        for (req, expected) in &cases {
3532            assert_eq!(
3533                req.is_idempotent(),
3534                *expected,
3535                "is_idempotent classification wrong for {req:?}"
3536            );
3537        }
3538
3539        // Spot-check the two highest-harm classifications explicitly so a
3540        // regression names the exact defect.
3541        assert!(
3542            !IpcRequest::CommandStartCombed(CommandStartParams {
3543                environment: None,
3544                argv: vec!["sleep".to_owned(), "10".to_owned()],
3545                cwd: None,
3546                env: vec![],
3547                bucket_config: None,
3548                rules: vec![],
3549                grace_ms: None,
3550                tag: None,
3551                dedup_nonce: None,
3552                strip_ansi: true,
3553            })
3554            .is_idempotent(),
3555            "CommandStartCombed must be non-idempotent: a blind retry double-spawns"
3556        );
3557        assert!(
3558            !IpcRequest::SubscriptionPull(SubscriptionPullParams {
3559                sub_id: "s".to_owned(),
3560                max: None,
3561                timeout_ms: None,
3562                liveness_delta: false,
3563            })
3564            .is_idempotent(),
3565            "SubscriptionPull must be non-idempotent: per-consumer offsets are \
3566             committed server-side inside the pull (subscriptions/pull.rs 543/633) \
3567             before the response, so a lost-then-retried pull drops already-drained \
3568             events -- it is NOT a replayable read like BucketWait"
3569        );
3570        assert!(
3571            !IpcRequest::SubscriptionOpen(SubscriptionOpenParams { predicate }).is_idempotent(),
3572            "SubscriptionOpen must be non-idempotent: a blind retry mints a second \
3573             sub_id + registry slot, leaking a slot and risking \
3574             SubscriptionLimitExceeded"
3575        );
3576    }
3577
3578    /// US3 (W1): the `file_list_dir` response wire shape is the pinned one --
3579    /// `DirEntryKind` renders snake_case (`file`/`dir`/`symlink`), and the
3580    /// per-entry `size_bytes`/`mtime_ms` optionals are omitted when `None`
3581    /// (`skip_serializing_if`). Round-trips back into the same variant.
3582    #[test]
3583    fn file_list_dir_response_wire_shape_is_pinned() {
3584        let resp = IpcResponse::FileListDir(FileListDirResponse {
3585            path: "/abs/dir".to_owned(),
3586            entries: vec![
3587                DirEntry {
3588                    name: "sub".to_owned(),
3589                    kind: DirEntryKind::Dir,
3590                    size_bytes: None,
3591                    mtime_ms: Some(1),
3592                },
3593                DirEntry {
3594                    name: "f.txt".to_owned(),
3595                    kind: DirEntryKind::File,
3596                    size_bytes: Some(3),
3597                    mtime_ms: None,
3598                },
3599                DirEntry {
3600                    name: "link".to_owned(),
3601                    kind: DirEntryKind::Symlink,
3602                    size_bytes: None,
3603                    mtime_ms: None,
3604                },
3605            ],
3606            total_entries: 3,
3607            truncated: false,
3608        });
3609        let json = serde_json::to_value(&resp).expect("serialize");
3610        assert_eq!(json["method"], "file_list_dir");
3611        // snake_case kinds.
3612        assert_eq!(json["entries"][0]["kind"], "dir");
3613        assert_eq!(json["entries"][1]["kind"], "file");
3614        assert_eq!(json["entries"][2]["kind"], "symlink");
3615        // Absent optionals are omitted, not rendered as null.
3616        assert!(
3617            json["entries"][0].get("size_bytes").is_none(),
3618            "dir omits size_bytes"
3619        );
3620        assert!(
3621            json["entries"][1].get("mtime_ms").is_none(),
3622            "None mtime_ms omitted"
3623        );
3624        assert_eq!(json["entries"][1]["size_bytes"], 3);
3625        assert_eq!(json["total_entries"], 3);
3626        assert_eq!(json["truncated"], false);
3627        // Round-trip.
3628        let back: IpcResponse = serde_json::from_value(json).expect("deserialize");
3629        assert!(matches!(back, IpcResponse::FileListDir(_)));
3630    }
3631
3632    #[test]
3633    fn encode_decode_envelope_round_trip() {
3634        let req = RequestEnvelope {
3635            correlation_id: 42,
3636            request: IpcRequest::SystemDiscover,
3637        };
3638        let frame = encode_frame(&req).unwrap();
3639        // 4-byte length + JSON
3640        assert!(frame.len() > 4);
3641        let len = u32::from_be_bytes([frame[0], frame[1], frame[2], frame[3]]) as usize;
3642        assert_eq!(len, frame.len() - 4);
3643        let back: RequestEnvelope = decode_payload(&frame[4..]).unwrap();
3644        assert_eq!(back.correlation_id, 42);
3645        assert!(matches!(back.request, IpcRequest::SystemDiscover));
3646    }
3647
3648    #[test]
3649    fn malformed_json_rejected_with_typed_code() {
3650        let bad = b"{not valid json";
3651        let err: IpcError = decode_payload::<RequestEnvelope>(bad).unwrap_err();
3652        assert_eq!(err.code, IpcErrorCode::MalformedJson);
3653    }
3654
3655    #[test]
3656    fn schema_mismatch_is_malformed_json_today() {
3657        // serde_json reports as a parse error; we surface that as
3658        // MalformedJson because the variant set is closed-set.
3659        let s = br#"{"correlation_id": 1, "request": {"method": "totally_bogus"}}"#;
3660        let err: IpcError = decode_payload::<RequestEnvelope>(s).unwrap_err();
3661        // Either MalformedJson or SchemaMismatch is acceptable; both
3662        // keep the bad payload out of the dispatcher.
3663        assert!(matches!(
3664            err.code,
3665            IpcErrorCode::MalformedJson | IpcErrorCode::SchemaMismatch
3666        ));
3667    }
3668
3669    #[test]
3670    fn frame_too_large_rejected_before_serialize_attempt() {
3671        // Construct an envelope that, once serialized, would exceed
3672        // MAX_FRAME_BYTES. Easiest way: a SelfCheck response with a
3673        // huge report string.
3674        let huge = "x".repeat(MAX_FRAME_BYTES + 1024);
3675        let env = ResponseEnvelope {
3676            correlation_id: 1,
3677            result: IpcResult::Ok {
3678                response: IpcResponse::SelfCheck(SelfCheckResponse {
3679                    report: huge,
3680                    failures: 0,
3681                }),
3682            },
3683        };
3684        let err = encode_frame(&env).unwrap_err();
3685        assert_eq!(err.code, IpcErrorCode::FrameTooLarge);
3686    }
3687
3688    #[test]
3689    fn subscription_error_codes_roundtrip_snake_case() {
3690        for (code, wire) in [
3691            (
3692                IpcErrorCode::UnknownSubscription,
3693                "\"unknown_subscription\"",
3694            ),
3695            (
3696                IpcErrorCode::SubscriptionLimitExceeded,
3697                "\"subscription_limit_exceeded\"",
3698            ),
3699        ] {
3700            let s = serde_json::to_string(&code).unwrap();
3701            assert_eq!(s, wire);
3702            let back: IpcErrorCode = serde_json::from_str(&s).unwrap();
3703            assert_eq!(back, code);
3704        }
3705    }
3706
3707    #[test]
3708    fn subscription_open_pair_round_trips_through_request_and_response() {
3709        let params = SubscriptionOpenParams {
3710            predicate: SubscriptionPredicate {
3711                severity_min: Some(Severity::High),
3712                kind: Some(vec!["error".to_owned(), "panic".to_owned()]),
3713                sources: SubscriptionSourceSel::Jobs {
3714                    jobs: vec![JobId::new()],
3715                },
3716                tag: None,
3717            },
3718        };
3719        let req = IpcRequest::SubscriptionOpen(params);
3720        let back: IpcRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
3721        assert!(matches!(back, IpcRequest::SubscriptionOpen(_)));
3722
3723        let resp = IpcResponse::SubscriptionOpen(SubscriptionOpenResponse {
3724            sub_id: "sub-1".to_owned(),
3725            boot_id: "boot-1".to_owned(),
3726            predicate_hash: "12345".to_owned(),
3727            created_at_ms: 1_700_000_000_000,
3728            matched_sources: 3,
3729        });
3730        let back: IpcResponse =
3731            serde_json::from_str(&serde_json::to_string(&resp).unwrap()).unwrap();
3732        match back {
3733            IpcResponse::SubscriptionOpen(r) => {
3734                assert_eq!(r.sub_id, "sub-1");
3735                assert_eq!(r.matched_sources, 3);
3736            }
3737            other => panic!("unexpected: {other:?}"),
3738        }
3739    }
3740
3741    #[test]
3742    fn subscription_predicate_defaults_sources_to_all_when_omitted() {
3743        let json = r#"{"severity_min":"high"}"#;
3744        let p: SubscriptionPredicate = serde_json::from_str(json).unwrap();
3745        assert_eq!(p.sources, SubscriptionSourceSel::All);
3746        assert_eq!(p.severity_min, Some(Severity::High));
3747        assert!(p.kind.is_none());
3748    }
3749
3750    #[test]
3751    fn subscription_pull_pair_round_trips_through_request_and_response() {
3752        let params = SubscriptionPullParams {
3753            sub_id: "sub-1".to_owned(),
3754            max: Some(25),
3755            timeout_ms: Some(3_000),
3756            liveness_delta: true,
3757        };
3758        let req = IpcRequest::SubscriptionPull(params);
3759        let back: IpcRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
3760        match back {
3761            IpcRequest::SubscriptionPull(p) => {
3762                assert_eq!(p.sub_id, "sub-1");
3763                assert_eq!(p.max, Some(25));
3764                assert_eq!(p.timeout_ms, Some(3_000));
3765                assert!(p.liveness_delta, "liveness_delta round-trips on the wire");
3766            }
3767            other => panic!("unexpected: {other:?}"),
3768        }
3769
3770        let resp = IpcResponse::SubscriptionPull(SubscriptionPullResponse {
3771            events: Vec::new(),
3772            liveness: vec![SourceLiveness {
3773                bucket_id: BucketId::new(),
3774                job_id: Some(JobId::new()),
3775                probe_id: None,
3776                liveness: Liveness::Exited { code: 0 },
3777            }],
3778            lagged: false,
3779            truncated: false,
3780        });
3781        let back: IpcResponse =
3782            serde_json::from_str(&serde_json::to_string(&resp).unwrap()).unwrap();
3783        match back {
3784            IpcResponse::SubscriptionPull(r) => {
3785                assert!(r.events.is_empty());
3786                assert_eq!(r.liveness.len(), 1);
3787                assert_eq!(r.liveness[0].liveness, Liveness::Exited { code: 0 });
3788            }
3789            other => panic!("unexpected: {other:?}"),
3790        }
3791    }
3792
3793    #[test]
3794    fn subscription_list_pair_round_trips_through_request_and_response() {
3795        let req = IpcRequest::SubscriptionList(SubscriptionListParams { limit: Some(10) });
3796        let back: IpcRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
3797        assert!(matches!(back, IpcRequest::SubscriptionList(_)));
3798
3799        let resp = IpcResponse::SubscriptionList(SubscriptionListResponse {
3800            subscriptions: vec![SubscriptionSummary {
3801                sub_id: "sub-1".to_owned(),
3802                predicate_hash: "9".to_owned(),
3803                source_count: 2,
3804                created_at_ms: 1,
3805                last_pull_at_ms: Some(2),
3806            }],
3807            truncated: true,
3808        });
3809        let back: IpcResponse =
3810            serde_json::from_str(&serde_json::to_string(&resp).unwrap()).unwrap();
3811        match back {
3812            IpcResponse::SubscriptionList(r) => {
3813                assert!(r.truncated);
3814                assert_eq!(r.subscriptions.len(), 1);
3815                assert_eq!(r.subscriptions[0].source_count, 2);
3816            }
3817            other => panic!("unexpected: {other:?}"),
3818        }
3819    }
3820
3821    #[test]
3822    fn subscription_close_pair_round_trips_through_request_and_response() {
3823        let req = IpcRequest::SubscriptionClose(SubscriptionCloseParams {
3824            sub_id: "sub-1".to_owned(),
3825        });
3826        let back: IpcRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
3827        assert!(matches!(back, IpcRequest::SubscriptionClose(_)));
3828
3829        let resp = IpcResponse::SubscriptionClose(SubscriptionCloseResponse { closed: true });
3830        let back: IpcResponse =
3831            serde_json::from_str(&serde_json::to_string(&resp).unwrap()).unwrap();
3832        match back {
3833            IpcResponse::SubscriptionClose(r) => assert!(r.closed),
3834            other => panic!("unexpected: {other:?}"),
3835        }
3836    }
3837
3838    #[test]
3839    fn subscription_seek_pair_round_trips_through_request_and_response() {
3840        let req = IpcRequest::SubscriptionSeek(SubscriptionSeekParams {
3841            sub_id: "sub-1".to_owned(),
3842            bucket_id: BucketId::new(),
3843            seq: 42,
3844        });
3845        let back: IpcRequest = serde_json::from_str(&serde_json::to_string(&req).unwrap()).unwrap();
3846        match back {
3847            IpcRequest::SubscriptionSeek(p) => assert_eq!(p.seq, 42),
3848            other => panic!("unexpected: {other:?}"),
3849        }
3850
3851        let resp = IpcResponse::SubscriptionSeek(SubscriptionSeekResponse {
3852            clamped_seq: 7,
3853            lagged: true,
3854        });
3855        let back: IpcResponse =
3856            serde_json::from_str(&serde_json::to_string(&resp).unwrap()).unwrap();
3857        match back {
3858            IpcResponse::SubscriptionSeek(r) => {
3859                assert_eq!(r.clamped_seq, 7);
3860                assert!(r.lagged);
3861            }
3862            other => panic!("unexpected: {other:?}"),
3863        }
3864    }
3865
3866    #[test]
3867    fn command_output_tail_params_round_trip() {
3868        let params = CommandOutputTailParams {
3869            job_id: JobId::new(),
3870            max_lines: 50,
3871            max_bytes: 65_536,
3872            strip_ansi: true,
3873        };
3874        let json = serde_json::to_string(&params).unwrap();
3875        let back: CommandOutputTailParams = serde_json::from_str(&json).unwrap();
3876        assert_eq!(back, params);
3877        // defaults kick in when fields are absent
3878        let minimal = format!(r#"{{"job_id":"{}"}}"#, params.job_id);
3879        let def: CommandOutputTailParams = serde_json::from_str(&minimal).unwrap();
3880        assert_eq!(def.max_lines, 50);
3881        assert_eq!(def.max_bytes, 65_536);
3882        assert!(!def.strip_ansi);
3883    }
3884
3885    #[test]
3886    fn response_envelope_err_round_trips() {
3887        let env = ResponseEnvelope {
3888            correlation_id: 7,
3889            result: IpcResult::Err {
3890                error: IpcError::new(IpcErrorCode::PolicyDenied, "nope"),
3891            },
3892        };
3893        let frame = encode_frame(&env).unwrap();
3894        let back: ResponseEnvelope = decode_payload(&frame[4..]).unwrap();
3895        match back.result {
3896            IpcResult::Err { error } => {
3897                assert_eq!(error.code, IpcErrorCode::PolicyDenied);
3898                assert_eq!(error.message, "nope");
3899            }
3900            IpcResult::Ok { .. } => panic!("expected err variant"),
3901        }
3902    }
3903
3904    #[test]
3905    fn shutdown_variants_serde_roundtrip() {
3906        // Request round-trips. `IpcRequest` does not derive `PartialEq`,
3907        // so compare the serialized forms instead of the values.
3908        let req = IpcRequest::Shutdown;
3909        let s = serde_json::to_string(&req).unwrap();
3910        let s2 = serde_json::to_string(&serde_json::from_str::<IpcRequest>(&s).unwrap()).unwrap();
3911        assert_eq!(s, s2);
3912
3913        // Response round-trips with the draining flag.
3914        let resp = IpcResponse::ShutdownAck { draining: true };
3915        let s = serde_json::to_string(&resp).unwrap();
3916        match serde_json::from_str::<IpcResponse>(&s).unwrap() {
3917            IpcResponse::ShutdownAck { draining } => assert!(draining),
3918            other => panic!("unexpected: {other:?}"),
3919        }
3920
3921        // The new error code serializes.
3922        let _ = serde_json::to_string(&IpcErrorCode::ShuttingDown).unwrap();
3923    }
3924
3925    #[test]
3926    fn audit_since_params_round_trip() {
3927        let full = AuditSinceParams {
3928            cursor: 7,
3929            action_filter: Some("registry_activate".to_owned()),
3930            decision_filter: Some("info".to_owned()),
3931            limit: Some(50),
3932        };
3933        let json = serde_json::to_string(&full).unwrap();
3934        let back: AuditSinceParams = serde_json::from_str(&json).unwrap();
3935        assert_eq!(back, full);
3936
3937        // Optional fields default to None and are omitted on the wire.
3938        let minimal = AuditSinceParams {
3939            cursor: 0,
3940            action_filter: None,
3941            decision_filter: None,
3942            limit: None,
3943        };
3944        let json = serde_json::to_string(&minimal).unwrap();
3945        assert_eq!(json, r#"{"cursor":0}"#);
3946        let back: AuditSinceParams = serde_json::from_str(&json).unwrap();
3947        assert_eq!(back, minimal);
3948    }
3949
3950    #[test]
3951    fn audit_since_response_round_trips_through_envelope() {
3952        let resp = AuditSinceResponse {
3953            cursor_in: 0,
3954            next_cursor: 2,
3955            rows: vec![
3956                AuditRowWire {
3957                    audit_id: 1,
3958                    timestamp: "2026-06-01T00:00:00Z".to_owned(),
3959                    action: "registry_activate".to_owned(),
3960                    subject: "peer".to_owned(),
3961                    decision: "info".to_owned(),
3962                    profile: Some("developer_local".to_owned()),
3963                    reason: None,
3964                    actor: Some("cli".to_owned()),
3965                    metadata_json: None,
3966                },
3967                AuditRowWire {
3968                    audit_id: 2,
3969                    timestamp: "2026-06-01T00:00:01Z".to_owned(),
3970                    action: "system_discover".to_owned(),
3971                    subject: "peer".to_owned(),
3972                    decision: "info".to_owned(),
3973                    profile: None,
3974                    reason: None,
3975                    actor: None,
3976                    metadata_json: None,
3977                },
3978            ],
3979        };
3980        let env = ResponseEnvelope {
3981            correlation_id: 9,
3982            result: IpcResult::Ok {
3983                response: IpcResponse::AuditSince(resp.clone()),
3984            },
3985        };
3986        let frame = encode_frame(&env).unwrap();
3987        let back: ResponseEnvelope = decode_payload(&frame[4..]).unwrap();
3988        match back.result {
3989            IpcResult::Ok {
3990                response: IpcResponse::AuditSince(r),
3991            } => assert_eq!(r, resp),
3992            other => panic!("unexpected: {other:?}"),
3993        }
3994    }
3995
3996    #[test]
3997    fn audit_since_request_round_trips_through_envelope() {
3998        let req = RequestEnvelope {
3999            correlation_id: 3,
4000            request: IpcRequest::AuditSince(AuditSinceParams {
4001                cursor: 0,
4002                action_filter: None,
4003                decision_filter: None,
4004                limit: Some(50),
4005            }),
4006        };
4007        let frame = encode_frame(&req).unwrap();
4008        let back: RequestEnvelope = decode_payload(&frame[4..]).unwrap();
4009        match back.request {
4010            IpcRequest::AuditSince(p) => {
4011                assert_eq!(p.cursor, 0);
4012                assert_eq!(p.limit, Some(50));
4013            }
4014            other => panic!("unexpected: {other:?}"),
4015        }
4016    }
4017}