Skip to main content

codewhale_protocol/
fleet.rs

1//! Agent Fleet control-plane protocol types.
2//!
3//! These types define the durable, serializable contract between the fleet
4//! manager, workers, CLI/TUI surfaces, and the Runtime API. They are
5//! intentionally additive: existing runtime-event consumers ignore unknown
6//! fields and are unaffected by fleet extensions.
7//!
8//! See:
9//! - <https://github.com/Hmbown/CodeWhale/issues/3154> (Agent Fleet control plane)
10//! - <https://github.com/Hmbown/CodeWhale/issues/3096> (Runtime API sub-agent direction)
11
12use std::collections::BTreeMap;
13use std::path::PathBuf;
14
15use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
16use serde_json::Value;
17
18use super::Status;
19
20pub const FLEET_PROTOCOL_VERSION: &str = "0.1.0";
21
22/// Globally unique identifier for a fleet run.
23#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
24pub struct FleetRunId(pub String);
25
26impl From<String> for FleetRunId {
27    fn from(value: String) -> Self {
28        Self(value)
29    }
30}
31
32impl From<&str> for FleetRunId {
33    fn from(value: &str) -> Self {
34        Self(value.to_string())
35    }
36}
37
38/// Top-level fleet run handle.
39#[derive(Debug, Clone, Serialize, Deserialize)]
40pub struct FleetRun {
41    pub id: FleetRunId,
42    pub name: String,
43    pub status: FleetRunStatus,
44    /// Explicit execution target selected by the managed client.
45    ///
46    /// Older CLI-created runs predate target selection and therefore omit
47    /// this field. Runtime API creation always persists it and currently
48    /// accepts only [`FleetRuntimeTarget::ThisComputer`].
49    #[serde(default, skip_serializing_if = "Option::is_none")]
50    pub target: Option<FleetRuntimeTarget>,
51    /// Named Workflow descriptor that owns this Fleet run.
52    ///
53    /// The durable task specs below remain the executable source of truth;
54    /// this descriptor keeps the product identity and scheduling policy
55    /// inspectable without smuggling them through labels.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub workflow: Option<FleetWorkflowDescriptor>,
58    /// Canonical named roles declared for the run.
59    #[serde(default, skip_serializing_if = "Vec::is_empty")]
60    pub roles: Vec<String>,
61    /// Maximum number of workers the manager may drive concurrently.
62    ///
63    /// Older ledgers omit this field; callers fall back to the persisted
64    /// worker roster when resuming those runs.
65    #[serde(default, skip_serializing_if = "Option::is_none")]
66    pub max_workers: Option<usize>,
67    #[serde(default)]
68    pub task_specs: Vec<FleetTaskSpec>,
69    #[serde(default)]
70    pub worker_specs: Vec<FleetWorkerSpec>,
71    #[serde(default)]
72    pub labels: BTreeMap<String, String>,
73    /// Legacy replay-only execution policy from pre-0.9.11 ledgers.
74    ///
75    /// New Fleet runs reject this field: Fleet selects identity, while the
76    /// Runtime owns trust, secrets, approvals, sandboxing, and tool authority.
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub security_policy: Option<FleetSecurityPolicy>,
79    pub created_at: String,
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub updated_at: Option<String>,
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub completed_at: Option<String>,
84}
85
86/// Product-level Runtime target for a managed Fleet run.
87///
88/// The enum intentionally names unsupported targets as contract values so a
89/// client receives a precise capability refusal instead of silently falling
90/// back to local execution.
91#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
92#[serde(rename_all = "snake_case")]
93pub enum FleetRuntimeTarget {
94    ThisComputer,
95    AnotherComputer,
96    Cloud,
97}
98
99/// Scheduling shape currently executable by the durable Fleet manager.
100///
101/// Fleet tasks are independent queue entries today, so only parallel
102/// workflows are advertised. Sequence/pipeline support must not be accepted
103/// until dependencies are durable in the Fleet ledger.
104#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
105#[serde(rename_all = "snake_case")]
106pub enum FleetWorkflowKind {
107    Parallel,
108}
109
110/// Durable identity for the Workflow that coordinates a Fleet run.
111#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
112pub struct FleetWorkflowDescriptor {
113    pub id: String,
114    pub kind: FleetWorkflowKind,
115}
116
117/// One privacy-bounded durable event exposed to managed Fleet clients.
118///
119/// `cursor` is an opaque stable digest of the underlying ledger transition.
120/// Clients persist it and send it back on reconnect; they must not parse it.
121/// Worker-local sequence numbers remain available separately because they are
122/// monotonic only within one `(worker, task)` lifecycle, not across a run.
123#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
124pub struct FleetRuntimeEvent {
125    pub cursor: String,
126    pub event: String,
127    pub run_id: FleetRunId,
128    #[serde(default, skip_serializing_if = "Option::is_none")]
129    pub worker_id: Option<String>,
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub task_id: Option<String>,
132    #[serde(default, skip_serializing_if = "Option::is_none")]
133    pub timestamp: Option<String>,
134    #[serde(default, skip_serializing_if = "Option::is_none")]
135    pub worker_seq: Option<u64>,
136    #[serde(default)]
137    pub payload: Value,
138}
139
140/// Bounded durable replay page.
141#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
142pub struct FleetEventReplay {
143    pub run_id: FleetRunId,
144    pub events: Vec<FleetRuntimeEvent>,
145    #[serde(default)]
146    pub has_more: bool,
147    /// True when a no-cursor request returned only the newest bounded tail.
148    #[serde(default)]
149    pub history_truncated: bool,
150    #[serde(default, skip_serializing_if = "Option::is_none")]
151    pub next_cursor: Option<String>,
152}
153
154/// Lifecycle status for an entire fleet run.
155#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
156#[serde(rename_all = "snake_case")]
157pub enum FleetRunStatus {
158    Pending,
159    Queued,
160    Running,
161    Paused,
162    Completed,
163    Failed,
164    Cancelled,
165}
166
167impl Status for FleetRunStatus {
168    fn is_terminal(&self) -> bool {
169        matches!(self, Self::Completed | Self::Failed | Self::Cancelled)
170    }
171    fn is_active(&self) -> bool {
172        matches!(self, Self::Pending | Self::Queued | Self::Running)
173    }
174    fn is_paused(&self) -> bool {
175        matches!(self, Self::Paused)
176    }
177}
178
179/// Specification of a single unit of work within a run.
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct FleetTaskSpec {
182    pub id: String,
183    pub name: String,
184    #[serde(skip_serializing_if = "Option::is_none")]
185    pub description: Option<String>,
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub objective: Option<String>,
188    pub instructions: String,
189    #[serde(skip_serializing_if = "Option::is_none")]
190    pub worker: Option<FleetTaskWorkerProfile>,
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub workspace: Option<FleetWorkspaceRequirements>,
193    #[serde(default)]
194    #[serde(skip_serializing_if = "Vec::is_empty")]
195    pub input_files: Vec<PathBuf>,
196    #[serde(default)]
197    #[serde(skip_serializing_if = "Vec::is_empty")]
198    pub context: Vec<String>,
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub budget: Option<FleetTaskBudget>,
201    #[serde(default)]
202    #[serde(skip_serializing_if = "Vec::is_empty")]
203    pub tags: Vec<String>,
204    #[serde(default)]
205    pub expected_artifacts: Vec<FleetArtifactKind>,
206    #[serde(skip_serializing_if = "Option::is_none")]
207    pub scorer: Option<FleetScorerSpec>,
208    #[serde(skip_serializing_if = "Option::is_none")]
209    pub retry_policy: Option<FleetRetryPolicy>,
210    #[serde(skip_serializing_if = "Option::is_none")]
211    pub alert_policy: Option<FleetAlertPolicy>,
212    #[serde(default)]
213    pub timeout_seconds: Option<u64>,
214    #[serde(default)]
215    pub metadata: BTreeMap<String, Value>,
216}
217
218/// Worker role and tool expectations for a task.
219#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
220pub struct FleetTaskWorkerProfile {
221    /// Bounded human selector for one Fleet member.
222    ///
223    /// Accepts member id/name, semantic role, model id/display name, or an
224    /// explicit `route:<provider>/<model>`. `profile` is accepted as a shorter
225    /// authoring alias. Resolution and permission narrowing happen in the Fleet
226    /// runtime layer.
227    #[serde(default, alias = "profile", skip_serializing_if = "Option::is_none")]
228    pub agent_profile: Option<String>,
229    #[serde(skip_serializing_if = "Option::is_none")]
230    pub role: Option<String>,
231    /// Fleet loadout intent such as `auto`, `fast`, or `review`.
232    ///
233    /// This is not a concrete provider/model selection; route resolution owns
234    /// the executable provider/model/wire-model decision.
235    #[serde(default, skip_serializing_if = "Option::is_none")]
236    pub loadout: Option<String>,
237    /// Fleet model class hint such as `strong`, `balanced`, or `fast`.
238    #[serde(default, skip_serializing_if = "Option::is_none")]
239    pub model_class: Option<String>,
240    /// Optional explicit model id for this worker.
241    ///
242    /// Task-level model overrides are visible authoring data. They apply only
243    /// when the selected member does not pin an exact provider/model route;
244    /// conflicting overrides of an exact member route are rejected.
245    #[serde(default, skip_serializing_if = "Option::is_none")]
246    pub model: Option<String>,
247    #[serde(skip_serializing_if = "Option::is_none")]
248    pub tool_profile: Option<String>,
249    #[serde(default)]
250    #[serde(skip_serializing_if = "Vec::is_empty")]
251    pub tools: Vec<String>,
252    #[serde(default)]
253    #[serde(skip_serializing_if = "Vec::is_empty")]
254    pub capabilities: Vec<String>,
255}
256
257/// Workspace and environment constraints needed before a task starts.
258#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
259pub struct FleetWorkspaceRequirements {
260    #[serde(skip_serializing_if = "Option::is_none")]
261    pub root: Option<PathBuf>,
262    #[serde(default)]
263    #[serde(skip_serializing_if = "Vec::is_empty")]
264    pub required_files: Vec<PathBuf>,
265    #[serde(default)]
266    #[serde(skip_serializing_if = "Vec::is_empty")]
267    pub writable_paths: Vec<PathBuf>,
268    #[serde(skip_serializing_if = "Option::is_none")]
269    pub environment: Option<FleetEnvironmentRequirements>,
270}
271
272/// Environment variables a task requires or may pass through to workers.
273#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
274pub struct FleetEnvironmentRequirements {
275    #[serde(default)]
276    #[serde(skip_serializing_if = "Vec::is_empty")]
277    pub required: Vec<String>,
278    #[serde(default)]
279    #[serde(skip_serializing_if = "Vec::is_empty")]
280    pub allowlist: Vec<String>,
281}
282
283/// Budget limits for a task.
284#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)]
285pub struct FleetTaskBudget {
286    #[serde(skip_serializing_if = "Option::is_none")]
287    pub max_tokens: Option<u64>,
288    /// Maximum model turns. `None` and `Some(0)` both mean unbounded.
289    #[serde(skip_serializing_if = "Option::is_none")]
290    pub max_steps: Option<u32>,
291    #[serde(skip_serializing_if = "Option::is_none")]
292    pub max_tool_calls: Option<u32>,
293    #[serde(skip_serializing_if = "Option::is_none")]
294    pub max_seconds: Option<u64>,
295}
296
297/// Reference to an artifact produced or consumed by a task.
298#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
299pub struct FleetArtifactRef {
300    pub kind: FleetArtifactKind,
301    pub path: PathBuf,
302    #[serde(skip_serializing_if = "Option::is_none")]
303    pub checksum: Option<String>,
304    #[serde(skip_serializing_if = "Option::is_none")]
305    pub mime_type: Option<String>,
306    #[serde(default)]
307    pub size_bytes: Option<u64>,
308}
309
310/// Kind of artifact a task may produce or consume.
311#[derive(Debug, Clone, PartialEq, Eq)]
312pub enum FleetArtifactKind {
313    Log,
314    Patch,
315    TestResult,
316    Report,
317    Checkpoint,
318    Receipt,
319    Other(String),
320}
321
322impl FleetArtifactKind {
323    fn as_wire_str(&self) -> &str {
324        match self {
325            Self::Log => "log",
326            Self::Patch => "patch",
327            Self::TestResult => "test_result",
328            Self::Report => "report",
329            Self::Checkpoint => "checkpoint",
330            Self::Receipt => "receipt",
331            Self::Other(kind) => kind.as_str(),
332        }
333    }
334
335    fn from_wire_str(value: &str) -> Self {
336        match value {
337            "log" => Self::Log,
338            "patch" => Self::Patch,
339            "test_result" => Self::TestResult,
340            "report" => Self::Report,
341            "checkpoint" => Self::Checkpoint,
342            "receipt" => Self::Receipt,
343            other => Self::Other(other.to_string()),
344        }
345    }
346}
347
348impl Serialize for FleetArtifactKind {
349    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
350    where
351        S: Serializer,
352    {
353        serializer.serialize_str(self.as_wire_str())
354    }
355}
356
357impl<'de> Deserialize<'de> for FleetArtifactKind {
358    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
359    where
360        D: Deserializer<'de>,
361    {
362        let value = String::deserialize(deserializer)?;
363        Ok(Self::from_wire_str(&value))
364    }
365}
366
367/// Scoring rule used to verify a task result.
368#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
369#[serde(tag = "kind", rename_all = "snake_case")]
370pub enum FleetScorerSpec {
371    ExitCode,
372    FileExists {
373        path: PathBuf,
374    },
375    RegexMatch {
376        path: PathBuf,
377        pattern: String,
378    },
379    JsonPath {
380        path: PathBuf,
381        expression: String,
382    },
383    Command {
384        command: String,
385        #[serde(default)]
386        args: Vec<String>,
387    },
388    CodeWhaleVerifierPrompt {
389        prompt: String,
390    },
391    Manual,
392}
393
394/// Worker specification.
395#[derive(Debug, Clone, Serialize, Deserialize)]
396pub struct FleetWorkerSpec {
397    pub id: String,
398    pub name: String,
399    pub host: FleetHostSpec,
400    /// Legacy replay-only host trust label. New runs reject author-supplied
401    /// values and derive execution authority from Runtime policy instead.
402    #[serde(default)]
403    #[serde(skip_serializing_if = "Option::is_none")]
404    pub trust_level: Option<FleetTrustLevel>,
405    #[serde(default)]
406    pub labels: BTreeMap<String, String>,
407    #[serde(default)]
408    pub capabilities: Vec<String>,
409    #[serde(skip_serializing_if = "Option::is_none")]
410    pub max_concurrent_tasks: Option<usize>,
411}
412
413/// Host on which a worker runs.
414#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
415#[serde(tag = "kind", rename_all = "snake_case")]
416pub enum FleetHostSpec {
417    Local,
418    Ssh {
419        host: String,
420        #[serde(skip_serializing_if = "Option::is_none")]
421        port: Option<u16>,
422        #[serde(skip_serializing_if = "Option::is_none")]
423        user: Option<String>,
424        #[serde(skip_serializing_if = "Option::is_none")]
425        identity: Option<PathBuf>,
426        /// Known hosts file for host-key verification.
427        #[serde(skip_serializing_if = "Option::is_none")]
428        known_hosts: Option<PathBuf>,
429        /// Expected host key fingerprint (SHA256:...) for key pinning.
430        /// When set, the connection is only trusted if the server's
431        /// host key matches this fingerprint exactly.
432        #[serde(skip_serializing_if = "Option::is_none")]
433        host_key_fingerprint: Option<String>,
434        #[serde(skip_serializing_if = "Option::is_none")]
435        working_directory: Option<PathBuf>,
436        #[serde(default)]
437        #[serde(skip_serializing_if = "Vec::is_empty")]
438        env_allowlist: Vec<String>,
439        #[serde(skip_serializing_if = "Option::is_none")]
440        codewhale_binary: Option<String>,
441    },
442    #[serde(alias = "container")]
443    #[serde(alias = "Container")]
444    Docker {
445        image: String,
446        #[serde(default)]
447        args: Vec<String>,
448    },
449}
450
451// ── Legacy Runtime-policy wire compatibility ───────────────────────────────
452
453/// Legacy trust classification retained only to deserialize old Fleet ledgers.
454///
455/// It is not Fleet identity and new run creation rejects it. Current authority
456/// comes from live Runtime policy; these helper predicates describe the old
457/// wire vocabulary only.
458#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Default)]
459#[serde(rename_all = "snake_case")]
460pub enum FleetTrustLevel {
461    /// Fully isolated: no network, no secrets, no writes outside `.codewhale/fleet/`.
462    /// Suitable for untrusted code review, community PR checks, or third-party tool runs.
463    #[default]
464    Sandbox = 0,
465    /// Local-only worker with access to the workspace and configured secrets.
466    /// Default for local workers. May read repo files but writes are gated.
467    Local = 1,
468    /// Worker on a known remote host with verified identity and a bounded
469    /// set of explicitly granted capabilities. Requires SSH host-key
470    /// verification or equivalent attestation.
471    #[serde(alias = "remote-verified", alias = "remoteVerified")]
472    RemoteVerified = 2,
473    /// Fully trusted worker (e.g. operator's own machine, CI runner).
474    /// Has access to all configured secrets and may perform any action the
475    /// operator can. Reserved for dogfood smoke and operator-owned machines.
476    Operator = 3,
477}
478
479impl FleetTrustLevel {
480    /// Whether this trust level is allowed to access provider secrets.
481    #[must_use]
482    pub fn may_access_secrets(&self) -> bool {
483        matches!(self, Self::Operator | Self::RemoteVerified | Self::Local)
484    }
485
486    /// Whether this trust level is allowed to write outside `.codewhale/fleet/`.
487    #[must_use]
488    pub fn may_write_workspace(&self) -> bool {
489        matches!(self, Self::Operator | Self::Local)
490    }
491
492    /// Whether this trust level is allowed network access.
493    #[must_use]
494    pub fn may_access_network(&self) -> bool {
495        matches!(self, Self::Operator | Self::RemoteVerified | Self::Local)
496    }
497}
498
499/// Legacy Runtime execution-policy envelope retained for ledger replay.
500///
501/// This type is accepted while reading older protocol data but is rejected for
502/// new Fleet runs. Fleet membership/selection never grants trust, secrets, or
503/// capabilities; the Runtime derives those from its live policy boundary.
504#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
505pub struct FleetSecurityPolicy {
506    /// Default trust level for workers that don't declare one explicitly.
507    #[serde(default)]
508    pub default_trust_level: FleetTrustLevel,
509    /// Secret refs that workers may resolve. An empty list means no secrets
510    /// are available. Each entry is a key name, not a value.
511    #[serde(default)]
512    #[serde(skip_serializing_if = "Vec::is_empty")]
513    pub allowed_secrets: Vec<FleetSecretRef>,
514    /// Capability grants for workers in this run.
515    #[serde(default)]
516    #[serde(skip_serializing_if = "Vec::is_empty")]
517    pub capability_grants: Vec<FleetCapabilityGrant>,
518    /// Maximum trust level any worker in this run may have, even if the
519    /// worker spec requests higher. Defaults to Operator (no ceiling).
520    #[serde(default = "default_max_trust_level")]
521    pub max_trust_level: FleetTrustLevel,
522    /// Require identity verification for remote workers. When true, SSH
523    /// workers must pass host-key verification before being trusted at
524    /// RemoteVerified level; unverified remotes stay at Sandbox.
525    #[serde(default)]
526    pub require_identity_verification: bool,
527    /// Allow conservative parallel execution of read-only tools (#2983).
528    /// When true, workers may batch independent read-only tool calls
529    /// (reads, searches, greps) into concurrent turns. Disabled by default
530    /// to avoid overwhelming providers or hitting rate limits.
531    #[serde(default)]
532    pub allow_parallel_reads: bool,
533}
534
535fn default_max_trust_level() -> FleetTrustLevel {
536    FleetTrustLevel::Operator
537}
538
539impl Default for FleetSecurityPolicy {
540    fn default() -> Self {
541        Self {
542            default_trust_level: FleetTrustLevel::Sandbox,
543            allowed_secrets: Vec::new(),
544            capability_grants: Vec::new(),
545            max_trust_level: FleetTrustLevel::Operator,
546            require_identity_verification: false,
547            allow_parallel_reads: false,
548        }
549    }
550}
551
552/// A reference to a secret that should be resolved at runtime, never
553/// serialized as a plaintext value.
554///
555/// Secret refs appear in task specs, alert configs, and worker definitions.
556/// The actual secret value is resolved by the fleet manager from the
557/// secrets backend (OS keyring, environment, or file store) just before
558/// the worker starts.
559#[derive(Debug, Clone, Serialize, PartialEq, Eq, Hash, PartialOrd, Ord)]
560pub struct FleetSecretRef {
561    /// The secret key name (e.g. `"CODEWHALE_API_KEY"`, `"GH_TOKEN"`).
562    pub key: String,
563    /// Optional source hint for resolution order.
564    /// - `"env"` — resolve from environment variable
565    /// - `"keyring"` — resolve from OS keyring
566    /// - `"file"` — resolve from `~/.codewhale/secrets/`
567    /// - absent / null — try all sources in default order
568    #[serde(skip_serializing_if = "Option::is_none")]
569    pub source: Option<String>,
570}
571
572impl FleetSecretRef {
573    /// Create a secret ref from a key name with default resolution.
574    #[must_use]
575    pub fn new(key: impl Into<String>) -> Self {
576        Self {
577            key: key.into(),
578            source: None,
579        }
580    }
581
582    /// Create a secret ref with an explicit source.
583    #[must_use]
584    pub fn with_source(key: impl Into<String>, source: impl Into<String>) -> Self {
585        Self {
586            key: key.into(),
587            source: Some(source.into()),
588        }
589    }
590
591    /// Redacted display form for logging. Shows the key name and source
592    /// but never the resolved value.
593    #[must_use]
594    pub fn redacted(&self) -> String {
595        match &self.source {
596            Some(src) => format!("<secret:{}.{}>", src, self.key),
597            None => format!("<secret:{}>", self.key),
598        }
599    }
600}
601
602impl std::fmt::Display for FleetSecretRef {
603    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
604        write!(f, "{}", self.redacted())
605    }
606}
607
608impl From<&str> for FleetSecretRef {
609    fn from(key: &str) -> Self {
610        Self::new(key)
611    }
612}
613
614impl From<String> for FleetSecretRef {
615    fn from(key: String) -> Self {
616        Self::new(key)
617    }
618}
619
620impl<'de> Deserialize<'de> for FleetSecretRef {
621    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
622    where
623        D: Deserializer<'de>,
624    {
625        #[derive(Deserialize)]
626        #[serde(untagged)]
627        enum SecretRefWire {
628            Key(String),
629            Structured {
630                key: String,
631                #[serde(default)]
632                source: Option<String>,
633            },
634        }
635
636        match SecretRefWire::deserialize(deserializer)? {
637            SecretRefWire::Key(key) if !key.trim().is_empty() => Ok(FleetSecretRef::new(key)),
638            SecretRefWire::Key(_) => Err(de::Error::custom("secret ref key cannot be empty")),
639            SecretRefWire::Structured { key, source } if !key.trim().is_empty() => {
640                Ok(FleetSecretRef { key, source })
641            }
642            SecretRefWire::Structured { .. } => {
643                Err(de::Error::custom("secret ref key cannot be empty"))
644            }
645        }
646    }
647}
648
649/// How a worker authenticates to the fleet manager.
650#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
651#[serde(tag = "method", rename_all = "snake_case")]
652pub enum FleetWorkerAuth {
653    /// No authentication (local workers share the same uid).
654    None,
655    /// SSH key-based authentication with host-key verification.
656    SshKey {
657        /// Path to the SSH identity file (may be a FleetSecretRef in JSON
658        /// as `{"key": "...", "source": "file"}`).
659        identity: PathBuf,
660        /// Known hosts file for host-key verification.
661        #[serde(skip_serializing_if = "Option::is_none")]
662        known_hosts: Option<PathBuf>,
663        /// Expected host key fingerprint for pinning.
664        #[serde(skip_serializing_if = "Option::is_none")]
665        host_key_fingerprint: Option<String>,
666        /// SSH user for the connection.
667        #[serde(skip_serializing_if = "Option::is_none")]
668        user: Option<String>,
669    },
670    /// Token-based authentication for remote workers behind a fleet proxy.
671    Token {
672        /// Reference to the token secret.
673        token_ref: FleetSecretRef,
674    },
675    /// mTLS certificate-based authentication.
676    Mtls {
677        /// Path to the client certificate.
678        cert_path: PathBuf,
679        /// Reference to the private key secret.
680        key_ref: FleetSecretRef,
681    },
682}
683
684/// A capability grant that explicitly authorizes a worker to perform
685/// a specific class of action.
686///
687/// By default, new workers get no grants (least privilege). Grants are
688/// additive: a worker's effective capabilities are the union of its
689/// trust-level defaults plus any explicit grants.
690#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
691pub struct FleetCapabilityGrant {
692    /// The capability being granted (e.g. `"network"`, `"git-push"`,
693    /// `"provider-secrets"`, `"release"`).
694    pub capability: String,
695    /// Optional scope limiting the grant (e.g. `"github.com"` for network,
696    /// `"crates/tui/**"` for file writes).
697    #[serde(skip_serializing_if = "Option::is_none")]
698    pub scope: Option<String>,
699    /// Optional justification for the grant (audit trail).
700    #[serde(skip_serializing_if = "Option::is_none")]
701    pub reason: Option<String>,
702}
703
704/// Runtime status of a worker.
705#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
706#[serde(rename_all = "snake_case")]
707pub enum FleetWorkerStatus {
708    Unknown,
709    Online,
710    Busy,
711    Offline,
712    Unhealthy,
713    Draining,
714    Retired,
715}
716
717impl Status for FleetWorkerStatus {
718    fn is_terminal(&self) -> bool {
719        matches!(self, Self::Retired)
720    }
721    fn is_active(&self) -> bool {
722        matches!(self, Self::Online | Self::Busy)
723    }
724    fn is_paused(&self) -> bool {
725        false
726    }
727}
728
729/// Durable inbox entry: a task waiting to be leased to a worker.
730#[derive(Debug, Clone, Serialize, Deserialize)]
731pub struct FleetInboxEntry {
732    pub run_id: FleetRunId,
733    pub task_id: String,
734    pub priority: i32,
735    pub enqueued_at: String,
736    #[serde(default)]
737    pub lease_deadline: Option<String>,
738    #[serde(default)]
739    pub attempts: u32,
740}
741
742/// Worker event envelope.
743#[derive(Debug, Clone, Serialize, Deserialize)]
744pub struct FleetWorkerEvent {
745    pub seq: u64,
746    pub run_id: FleetRunId,
747    pub worker_id: String,
748    pub task_id: String,
749    pub timestamp: String,
750    #[serde(flatten)]
751    pub payload: FleetWorkerEventPayload,
752    #[serde(default)]
753    #[serde(skip_serializing_if = "BTreeMap::is_empty")]
754    pub extra: BTreeMap<String, Value>,
755}
756
757/// Union of all worker event payloads.
758#[derive(Debug, Clone, Serialize, Deserialize)]
759#[serde(tag = "state", rename_all = "snake_case")]
760pub enum FleetWorkerEventPayload {
761    Queued,
762    Leased {
763        #[serde(skip_serializing_if = "Option::is_none")]
764        lease_expires_at: Option<String>,
765    },
766    Starting,
767    Running,
768    ModelWait {
769        #[serde(skip_serializing_if = "Option::is_none")]
770        model: Option<String>,
771    },
772    RunningTool {
773        tool: String,
774        #[serde(skip_serializing_if = "Option::is_none")]
775        call_id: Option<String>,
776    },
777    /// Typed receipt emitted by a Workflow running inside this worker.
778    WorkflowEvent {
779        /// Inner Workflow run id. Named distinctly from the outer Fleet run id
780        /// because payloads are flattened into `FleetWorkerEvent`.
781        workflow_run_id: String,
782        event: Value,
783    },
784    Heartbeat {
785        #[serde(default)]
786        #[serde(skip_serializing_if = "Option::is_none")]
787        cpu_percent: Option<f32>,
788        #[serde(default)]
789        #[serde(skip_serializing_if = "Option::is_none")]
790        memory_mb: Option<u64>,
791    },
792    Artifact(FleetArtifactRef),
793    Completed {
794        #[serde(default)]
795        #[serde(skip_serializing_if = "Option::is_none")]
796        exit_code: Option<i32>,
797        #[serde(skip_serializing_if = "Option::is_none")]
798        summary: Option<String>,
799    },
800    Failed {
801        reason: String,
802        #[serde(default)]
803        recoverable: bool,
804    },
805    Cancelled {
806        #[serde(skip_serializing_if = "Option::is_none")]
807        cancelled_by: Option<String>,
808    },
809    Interrupted {
810        #[serde(skip_serializing_if = "Option::is_none")]
811        signal: Option<String>,
812    },
813    Stale {
814        #[serde(skip_serializing_if = "Option::is_none")]
815        last_heartbeat_at: Option<String>,
816    },
817    Restarted {
818        #[serde(default)]
819        restart_count: u32,
820    },
821    Escalated {
822        channel: String,
823        #[serde(skip_serializing_if = "Option::is_none")]
824        alert_id: Option<String>,
825    },
826}
827
828/// Retry policy for a task or worker.
829#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
830pub struct FleetRetryPolicy {
831    #[serde(default = "default_retry_max_attempts")]
832    pub max_attempts: u32,
833    #[serde(default = "default_retry_initial_backoff_seconds")]
834    pub initial_backoff_seconds: u64,
835    #[serde(default = "default_retry_max_backoff_seconds")]
836    pub max_backoff_seconds: u64,
837    #[serde(default = "default_retry_backoff_multiplier")]
838    pub backoff_multiplier: u32,
839}
840
841impl Default for FleetRetryPolicy {
842    fn default() -> Self {
843        Self {
844            max_attempts: 3,
845            initial_backoff_seconds: 5,
846            max_backoff_seconds: 300,
847            backoff_multiplier: 2,
848        }
849    }
850}
851
852fn default_retry_max_attempts() -> u32 {
853    FleetRetryPolicy::default().max_attempts
854}
855
856fn default_retry_initial_backoff_seconds() -> u64 {
857    FleetRetryPolicy::default().initial_backoff_seconds
858}
859
860fn default_retry_max_backoff_seconds() -> u64 {
861    FleetRetryPolicy::default().max_backoff_seconds
862}
863
864fn default_retry_backoff_multiplier() -> u32 {
865    FleetRetryPolicy::default().backoff_multiplier
866}
867
868/// Alert/escalation policy attached to a task or run.
869#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
870pub struct FleetAlertPolicy {
871    #[serde(default)]
872    #[serde(skip_serializing_if = "Vec::is_empty")]
873    pub events: Vec<FleetAlertEventClass>,
874    #[serde(default)]
875    pub channels: Vec<FleetAlertChannel>,
876    #[serde(default)]
877    pub after_attempts: Option<u32>,
878    #[serde(default)]
879    pub after_minutes_stale: Option<u64>,
880}
881
882#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash)]
883#[serde(rename_all = "snake_case")]
884pub enum FleetAlertEventClass {
885    Stale,
886    RestartExhausted,
887    NeedsHuman,
888    BudgetExceeded,
889    VerifierFailed,
890    RunCompleted,
891}
892
893#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
894#[serde(tag = "kind", rename_all = "snake_case")]
895pub enum FleetAlertChannel {
896    Slack {
897        /// Webhook URL, resolved from a secret ref or inline.
898        #[serde(flatten)]
899        webhook: FleetAlertEndpoint,
900    },
901    Webhook {
902        #[serde(flatten)]
903        endpoint: FleetAlertEndpoint,
904    },
905    #[serde(alias = "pager_duty")]
906    #[serde(alias = "pagerduty")]
907    PagerDuty {
908        routing_key: String,
909        severity: String,
910    },
911}
912
913/// An alert channel endpoint, supporting both inline URLs and secret refs.
914///
915/// For Slack and generic webhook channels, the URL may be provided directly
916/// or as a secret reference resolved at send time. When both `url` and
917/// `url_ref` are present, `url_ref` takes precedence after resolution.
918#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
919pub struct FleetAlertEndpoint {
920    /// Inline URL (plaintext; only for non-sensitive endpoints).
921    #[serde(
922        alias = "webhook_url",
923        alias = "endpoint_url",
924        skip_serializing_if = "Option::is_none"
925    )]
926    pub url: Option<String>,
927    /// Reference to a secret containing the webhook URL.
928    #[serde(
929        alias = "webhook_url_ref",
930        alias = "webhook_ref",
931        alias = "url_secret_ref",
932        skip_serializing_if = "Option::is_none"
933    )]
934    pub url_ref: Option<FleetSecretRef>,
935    /// Optional HMAC secret for webhook payload signing, as a secret ref.
936    #[serde(
937        alias = "secret",
938        alias = "webhook_secret",
939        alias = "signing_secret",
940        skip_serializing_if = "Option::is_none"
941    )]
942    pub secret_ref: Option<FleetSecretRef>,
943}
944
945impl FleetAlertEndpoint {
946    /// Create an inline URL endpoint (for non-sensitive use).
947    #[must_use]
948    pub fn inline(url: impl Into<String>) -> Self {
949        Self {
950            url: Some(url.into()),
951            url_ref: None,
952            secret_ref: None,
953        }
954    }
955
956    /// Create a secret-backed URL endpoint.
957    #[must_use]
958    pub fn from_secret(url_ref: FleetSecretRef) -> Self {
959        Self {
960            url: None,
961            url_ref: Some(url_ref),
962            secret_ref: None,
963        }
964    }
965
966    /// Redacted display form for logging.
967    #[must_use]
968    pub fn redacted(&self) -> String {
969        self.url_ref
970            .as_ref()
971            .map_or_else(|| "<inline-url>".to_string(), |r| r.redacted())
972    }
973}
974
975/// Resolved-route detail persisted on a [`FleetReceipt`] (#3154).
976///
977/// This is an additive, *plain-strings* snapshot of the route a fleet worker
978/// resolved to. It deliberately does NOT depend on any `codewhale-config` route
979/// type so the protocol crate stays free of the route model.
980///
981/// CRITICAL no-secrets invariant: this struct carries ONLY non-sensitive route
982/// shape — provider id/kind, model ids, wire protocol, role/loadout/model-class
983/// intent, reasoning tier when known, and deterministic intent sources. It
984/// must NEVER hold a credential, API key, bearer token, or a base URL that
985/// embeds credentials. There is intentionally no field that could carry a
986/// secret.
987#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
988pub struct FleetResolvedRoute {
989    /// Resolved provider canonical id (e.g. `"deepseek"`).
990    pub provider_id: String,
991    /// Exact configured provider-table id when the worker used one.
992    ///
993    /// This is intentionally additive to `provider_id`: literal
994    /// `[providers.custom]` resolves to `Some("custom")`, while the legacy
995    /// idless root custom route resolves to `None`. Keeping the distinction
996    /// prevents a receipt from silently collapsing two different credential
997    /// and endpoint authorities into the same generic `custom` label.
998    #[serde(default, skip_serializing_if = "Option::is_none")]
999    pub provider_exact_id: Option<String>,
1000    /// Resolved provider kind (e.g. `"deepseek"`).
1001    pub provider_kind: String,
1002    /// Canonical, provider-agnostic model identity, when known.
1003    #[serde(default, skip_serializing_if = "Option::is_none")]
1004    pub canonical_model: Option<String>,
1005    /// Provider-owned wire model id placed on the request.
1006    pub wire_model_id: String,
1007    /// Selected wire protocol (e.g. `"chat_completions"`).
1008    pub protocol: String,
1009    /// Effective Fleet role intent, when one applied.
1010    #[serde(default, skip_serializing_if = "Option::is_none")]
1011    pub role: Option<String>,
1012    /// Effective Fleet loadout intent, when one applied.
1013    #[serde(default, skip_serializing_if = "Option::is_none")]
1014    pub loadout: Option<String>,
1015    /// Original task-level model-class intent, when authored separately from
1016    /// `loadout`. Profile `model_class_hint` is normalized into `loadout`.
1017    #[serde(default, skip_serializing_if = "Option::is_none")]
1018    pub model_class: Option<String>,
1019    /// Runtime model-route seam used by sub-agent routing (`inherit`, `faster`,
1020    /// `auto`, or `fixed`).
1021    #[serde(default, skip_serializing_if = "Option::is_none")]
1022    pub model_route: Option<String>,
1023    /// Concrete reasoning tier, when it is known by the route resolver path.
1024    #[serde(default, skip_serializing_if = "Option::is_none")]
1025    pub reasoning_effort: Option<String>,
1026    /// Deterministic source for the effective role intent.
1027    #[serde(default, skip_serializing_if = "Option::is_none")]
1028    pub role_source: Option<String>,
1029    /// Deterministic source for the effective loadout intent.
1030    #[serde(default, skip_serializing_if = "Option::is_none")]
1031    pub loadout_source: Option<String>,
1032    /// Deterministic source for the model-class hint, when present.
1033    #[serde(default, skip_serializing_if = "Option::is_none")]
1034    pub model_class_source: Option<String>,
1035    /// Deterministic source for the model selector used by the resolver.
1036    #[serde(default, skip_serializing_if = "Option::is_none")]
1037    pub model_source: Option<String>,
1038    /// How the route was produced (e.g. `"resolver"`).
1039    pub source: String,
1040}
1041
1042/// Effective worker authority persisted on a [`FleetReceipt`] (#3211).
1043///
1044/// This is a non-secret snapshot of the already-computed runtime profile. It
1045/// records what the worker was allowed to do; it does not grant permissions and
1046/// does not carry credentials, sandbox paths, or provider endpoints.
1047#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1048pub struct FleetEffectivePermissions {
1049    /// Whether the worker profile may modify workspace files.
1050    pub write: bool,
1051    /// Whether the worker profile may use network-capable tools.
1052    pub network: bool,
1053    /// Shell posture (`none`, `read_only`, or `full`).
1054    pub shell: String,
1055    /// Tool-surface posture (`inherit` or `explicit`).
1056    pub tool_scope: String,
1057    /// Explicit tool names when `tool_scope` is `explicit`.
1058    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1059    pub tools: Vec<String>,
1060    /// Whether the worker is intended to run detached/background.
1061    pub background: bool,
1062    /// Remaining nested-delegation budget after parent intersection/hardening.
1063    pub max_spawn_depth: u32,
1064    /// Roster profile id that contributed to this worker, when any.
1065    #[serde(default, skip_serializing_if = "Option::is_none")]
1066    pub profile_id: Option<String>,
1067    /// Roster layer for `profile_id` (`built_in`, `config`, or `workspace`).
1068    #[serde(default, skip_serializing_if = "Option::is_none")]
1069    pub profile_origin: Option<String>,
1070    /// How this snapshot was produced (e.g. `"worker_runtime_profile"`).
1071    pub source: String,
1072}
1073
1074/// Receipt produced when a task completes verification.
1075#[derive(Debug, Clone, Serialize, Deserialize)]
1076pub struct FleetReceipt {
1077    pub run_id: FleetRunId,
1078    pub task_id: String,
1079    pub worker_id: String,
1080    /// Durable lease generation that produced this receipt.
1081    ///
1082    /// Optional for backward compatibility with receipts written before Fleet
1083    /// attempts were fenced explicitly.
1084    #[serde(default, skip_serializing_if = "Option::is_none")]
1085    pub attempt: Option<u32>,
1086    /// Sequence of the terminal worker event finalized with this receipt.
1087    ///
1088    /// Optional so older ledger records remain replayable.
1089    #[serde(default, skip_serializing_if = "Option::is_none")]
1090    pub terminal_seq: Option<u64>,
1091    pub completed_at: String,
1092    pub result: FleetTaskResult,
1093    #[serde(skip_serializing_if = "Option::is_none")]
1094    pub failure_kind: Option<FleetTaskFailureKind>,
1095    #[serde(default)]
1096    pub artifacts: Vec<FleetArtifactRef>,
1097    #[serde(default)]
1098    pub score: Option<FleetScore>,
1099    /// Resolved-route snapshot for this task (#3154).
1100    ///
1101    /// `#[serde(default)]` keeps older ledgers (written before this field
1102    /// existed) deserializable.
1103    #[serde(default, skip_serializing_if = "Option::is_none")]
1104    pub resolved_route: Option<FleetResolvedRoute>,
1105    /// Effective worker authority for this task (#3211).
1106    #[serde(default, skip_serializing_if = "Option::is_none")]
1107    pub effective_permissions: Option<FleetEffectivePermissions>,
1108}
1109
1110#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1111#[serde(rename_all = "snake_case")]
1112pub enum FleetTaskResult {
1113    Pass,
1114    Partial,
1115    Fail,
1116    Skip,
1117    Timeout,
1118}
1119
1120/// Source category for a failed task receipt.
1121#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1122#[serde(rename_all = "snake_case")]
1123pub enum FleetTaskFailureKind {
1124    Transport,
1125    Task,
1126    Verifier,
1127}
1128
1129#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
1130pub struct FleetScore {
1131    pub value: f64,
1132    #[serde(skip_serializing_if = "Option::is_none")]
1133    pub max: Option<f64>,
1134    #[serde(skip_serializing_if = "Option::is_none")]
1135    pub notes: Option<String>,
1136}
1137
1138#[cfg(test)]
1139mod tests {
1140    use super::*;
1141
1142    #[test]
1143    fn fleet_run_round_trip() {
1144        let run = FleetRun {
1145            id: FleetRunId::from("run-001"),
1146            name: "dogfood smoke".to_string(),
1147            status: FleetRunStatus::Running,
1148            target: Some(FleetRuntimeTarget::ThisComputer),
1149            workflow: Some(FleetWorkflowDescriptor {
1150                id: "release-checks".to_string(),
1151                kind: FleetWorkflowKind::Parallel,
1152            }),
1153            roles: vec!["release-checker".to_string()],
1154            max_workers: Some(1),
1155            task_specs: vec![FleetTaskSpec {
1156                id: "task-1".to_string(),
1157                name: "lint".to_string(),
1158                description: None,
1159                objective: Some("Keep the workspace lint-clean".to_string()),
1160                instructions: "run cargo clippy".to_string(),
1161                worker: Some(FleetTaskWorkerProfile {
1162                    agent_profile: None,
1163                    role: Some("release-checker".to_string()),
1164                    loadout: None,
1165                    model_class: None,
1166                    model: None,
1167                    tool_profile: Some("read-only".to_string()),
1168                    tools: vec!["cargo".to_string()],
1169                    capabilities: vec!["rust".to_string()],
1170                }),
1171                workspace: Some(FleetWorkspaceRequirements {
1172                    root: Some(PathBuf::from(".")),
1173                    required_files: vec![PathBuf::from("Cargo.toml")],
1174                    writable_paths: vec![],
1175                    environment: Some(FleetEnvironmentRequirements {
1176                        required: vec!["PATH".to_string()],
1177                        allowlist: vec!["RUST_LOG".to_string()],
1178                    }),
1179                }),
1180                input_files: vec![PathBuf::from("crates/tui/src/main.rs")],
1181                context: vec!["release gate".to_string()],
1182                budget: Some(FleetTaskBudget {
1183                    max_tokens: Some(8000),
1184                    max_steps: Some(0),
1185                    max_tool_calls: Some(20),
1186                    max_seconds: Some(300),
1187                }),
1188                tags: vec!["release".to_string()],
1189                expected_artifacts: vec![FleetArtifactKind::Log],
1190                scorer: Some(FleetScorerSpec::ExitCode),
1191                retry_policy: Some(FleetRetryPolicy::default()),
1192                alert_policy: None,
1193                timeout_seconds: Some(300),
1194                metadata: BTreeMap::new(),
1195            }],
1196            worker_specs: vec![],
1197            labels: BTreeMap::new(),
1198            security_policy: None,
1199            created_at: "2026-06-12T17:00:00Z".to_string(),
1200            updated_at: None,
1201            completed_at: None,
1202        };
1203        let json = serde_json::to_string(&run).unwrap();
1204        let back: FleetRun = serde_json::from_str(&json).unwrap();
1205        assert_eq!(back.id, run.id);
1206        assert_eq!(back.status, FleetRunStatus::Running);
1207        assert_eq!(back.target, Some(FleetRuntimeTarget::ThisComputer));
1208        assert_eq!(back.roles, vec!["release-checker"]);
1209        assert_eq!(
1210            back.workflow.as_ref().map(|workflow| workflow.id.as_str()),
1211            Some("release-checks")
1212        );
1213        assert_eq!(back.task_specs.len(), 1);
1214        assert_eq!(
1215            back.task_specs[0].budget.as_ref().unwrap().max_steps,
1216            Some(0)
1217        );
1218        assert_eq!(
1219            back.task_specs[0].worker.as_ref().unwrap().role.as_deref(),
1220            Some("release-checker")
1221        );
1222        assert_eq!(
1223            back.task_specs[0]
1224                .workspace
1225                .as_ref()
1226                .unwrap()
1227                .required_files,
1228            vec![PathBuf::from("Cargo.toml")]
1229        );
1230    }
1231
1232    #[test]
1233    fn worker_profile_carries_agent_profile_and_loadout_intent() {
1234        let json = r#"{
1235            "profile": "adversarial_reviewer",
1236            "role": "reviewer",
1237            "loadout": "auto",
1238            "model_class": "balanced",
1239            "model": "deepseek-v4-pro",
1240            "tool_profile": "read-only",
1241            "tools": ["read_file"],
1242            "capabilities": ["rust"]
1243        }"#;
1244
1245        let profile: FleetTaskWorkerProfile = serde_json::from_str(json).unwrap();
1246
1247        assert_eq!(
1248            profile.agent_profile.as_deref(),
1249            Some("adversarial_reviewer")
1250        );
1251        assert_eq!(profile.role.as_deref(), Some("reviewer"));
1252        assert_eq!(profile.loadout.as_deref(), Some("auto"));
1253        assert_eq!(profile.model_class.as_deref(), Some("balanced"));
1254        assert_eq!(profile.model.as_deref(), Some("deepseek-v4-pro"));
1255        assert_eq!(profile.tool_profile.as_deref(), Some("read-only"));
1256
1257        let serialized = serde_json::to_value(&profile).unwrap();
1258        assert_eq!(serialized["agent_profile"], "adversarial_reviewer");
1259        assert_eq!(serialized["model"], "deepseek-v4-pro");
1260        assert!(serialized.get("profile").is_none());
1261    }
1262
1263    #[test]
1264    fn worker_event_lifecycle_round_trip() {
1265        let events = vec![
1266            FleetWorkerEvent {
1267                seq: 1,
1268                run_id: FleetRunId::from("run-002"),
1269                worker_id: "worker-a".to_string(),
1270                task_id: "task-1".to_string(),
1271                timestamp: "2026-06-12T17:01:00Z".to_string(),
1272                payload: FleetWorkerEventPayload::Queued,
1273                extra: BTreeMap::new(),
1274            },
1275            FleetWorkerEvent {
1276                seq: 2,
1277                run_id: FleetRunId::from("run-002"),
1278                worker_id: "worker-a".to_string(),
1279                task_id: "task-1".to_string(),
1280                timestamp: "2026-06-12T17:01:05Z".to_string(),
1281                payload: FleetWorkerEventPayload::RunningTool {
1282                    tool: "bash".to_string(),
1283                    call_id: Some("call-1".to_string()),
1284                },
1285                extra: BTreeMap::new(),
1286            },
1287            FleetWorkerEvent {
1288                seq: 3,
1289                run_id: FleetRunId::from("run-002"),
1290                worker_id: "worker-a".to_string(),
1291                task_id: "task-1".to_string(),
1292                timestamp: "2026-06-12T17:02:00Z".to_string(),
1293                payload: FleetWorkerEventPayload::Completed {
1294                    exit_code: Some(0),
1295                    summary: Some("ok".to_string()),
1296                },
1297                extra: BTreeMap::new(),
1298            },
1299        ];
1300        let json = serde_json::to_string(&events).unwrap();
1301        let back: Vec<FleetWorkerEvent> = serde_json::from_str(&json).unwrap();
1302        assert_eq!(back.len(), 3);
1303        assert!(matches!(back[0].payload, FleetWorkerEventPayload::Queued));
1304        assert!(matches!(
1305            back[2].payload,
1306            FleetWorkerEventPayload::Completed { .. }
1307        ));
1308    }
1309
1310    #[test]
1311    fn workflow_receipt_round_trip_keeps_outer_and_inner_run_ids_distinct() {
1312        let event = FleetWorkerEvent {
1313            seq: 3,
1314            run_id: FleetRunId::from("fleet-run-1"),
1315            worker_id: "worker-a".to_string(),
1316            task_id: "task-1".to_string(),
1317            timestamp: "2026-07-10T00:00:00Z".to_string(),
1318            payload: FleetWorkerEventPayload::WorkflowEvent {
1319                workflow_run_id: "workflow_1".to_string(),
1320                event: serde_json::json!({"type": "task_completed"}),
1321            },
1322            extra: BTreeMap::new(),
1323        };
1324        let value = serde_json::to_value(&event).unwrap();
1325        assert_eq!(value["run_id"], "fleet-run-1");
1326        assert_eq!(value["workflow_run_id"], "workflow_1");
1327        let back: FleetWorkerEvent = serde_json::from_value(value).unwrap();
1328        assert!(matches!(
1329            back.payload,
1330            FleetWorkerEventPayload::WorkflowEvent {
1331                workflow_run_id,
1332                ref event,
1333            } if workflow_run_id == "workflow_1" && event["type"] == "task_completed"
1334        ));
1335    }
1336
1337    #[test]
1338    fn alert_policy_round_trip() {
1339        let policy = FleetAlertPolicy {
1340            events: vec![FleetAlertEventClass::Stale],
1341            channels: vec![FleetAlertChannel::Slack {
1342                webhook: FleetAlertEndpoint::inline("https://hooks.slack.com/test"),
1343            }],
1344            after_attempts: Some(2),
1345            after_minutes_stale: Some(10),
1346        };
1347        let json = serde_json::to_string(&policy).unwrap();
1348        assert!(json.contains("\"events\":[\"stale\"]"));
1349        assert!(json.contains("\"kind\":\"slack\""));
1350        let back: FleetAlertPolicy = serde_json::from_str(&json).unwrap();
1351        assert_eq!(back.events, vec![FleetAlertEventClass::Stale]);
1352        assert_eq!(back.after_attempts, Some(2));
1353    }
1354
1355    #[test]
1356    fn artifact_other_kind_round_trip() {
1357        let artifact = FleetArtifactRef {
1358            kind: FleetArtifactKind::Other("coverage.xml".to_string()),
1359            path: PathBuf::from("/tmp/coverage.xml"),
1360            checksum: Some("sha256:abc".to_string()),
1361            mime_type: Some("application/xml".to_string()),
1362            size_bytes: Some(1024),
1363        };
1364        let json = serde_json::to_string(&artifact).unwrap();
1365        let back: FleetArtifactRef = serde_json::from_str(&json).unwrap();
1366        assert_eq!(back.kind, artifact.kind);
1367        assert_eq!(back.size_bytes, Some(1024));
1368    }
1369
1370    #[test]
1371    fn ssh_host_spec_accepts_minimal_legacy_json() {
1372        let json = r#"{"kind":"ssh","host":"builder.example.test"}"#;
1373        let host: FleetHostSpec = serde_json::from_str(json).unwrap();
1374
1375        match host {
1376            FleetHostSpec::Ssh {
1377                host,
1378                port,
1379                user,
1380                identity,
1381                known_hosts,
1382                host_key_fingerprint,
1383                working_directory,
1384                env_allowlist,
1385                codewhale_binary,
1386            } => {
1387                assert_eq!(host, "builder.example.test");
1388                assert_eq!(port, None);
1389                assert_eq!(user, None);
1390                assert_eq!(identity, None);
1391                assert_eq!(known_hosts, None);
1392                assert_eq!(host_key_fingerprint, None);
1393                assert_eq!(working_directory, None);
1394                assert!(env_allowlist.is_empty());
1395                assert_eq!(codewhale_binary, None);
1396            }
1397            other => panic!("expected ssh host spec, got {other:?}"),
1398        }
1399    }
1400
1401    #[test]
1402    fn artifact_kind_uses_flat_string_json() {
1403        let known = serde_json::to_string(&FleetArtifactKind::TestResult).unwrap();
1404        assert_eq!(known, "\"test_result\"");
1405
1406        let custom =
1407            serde_json::to_string(&FleetArtifactKind::Other("coverage.xml".to_string())).unwrap();
1408        assert_eq!(custom, "\"coverage.xml\"");
1409
1410        let parsed: FleetArtifactKind = serde_json::from_str("\"coverage.xml\"").unwrap();
1411        assert_eq!(parsed, FleetArtifactKind::Other("coverage.xml".to_string()));
1412    }
1413
1414    #[test]
1415    fn retry_policy_missing_fields_use_nonzero_defaults() {
1416        let policy: FleetRetryPolicy = serde_json::from_value(serde_json::json!({})).unwrap();
1417        assert_eq!(policy, FleetRetryPolicy::default());
1418
1419        let policy: FleetRetryPolicy =
1420            serde_json::from_value(serde_json::json!({"max_attempts": 5})).unwrap();
1421        assert_eq!(policy.max_attempts, 5);
1422        assert_eq!(
1423            policy.initial_backoff_seconds,
1424            FleetRetryPolicy::default().initial_backoff_seconds
1425        );
1426        assert_eq!(
1427            policy.max_backoff_seconds,
1428            FleetRetryPolicy::default().max_backoff_seconds
1429        );
1430        assert_eq!(
1431            policy.backoff_multiplier,
1432            FleetRetryPolicy::default().backoff_multiplier
1433        );
1434    }
1435
1436    #[test]
1437    fn sparse_worker_events_omit_absent_optional_fields() {
1438        let heartbeat = FleetWorkerEventPayload::Heartbeat {
1439            cpu_percent: None,
1440            memory_mb: None,
1441        };
1442        let heartbeat_json = serde_json::to_value(&heartbeat).unwrap();
1443        assert_eq!(heartbeat_json, serde_json::json!({"state": "heartbeat"}));
1444
1445        let completed = FleetWorkerEventPayload::Completed {
1446            exit_code: None,
1447            summary: None,
1448        };
1449        let completed_json = serde_json::to_value(&completed).unwrap();
1450        assert_eq!(completed_json, serde_json::json!({"state": "completed"}));
1451    }
1452
1453    #[test]
1454    fn receipt_round_trip() {
1455        let receipt = FleetReceipt {
1456            run_id: FleetRunId::from("run-003"),
1457            task_id: "task-1".to_string(),
1458            worker_id: "worker-b".to_string(),
1459            attempt: Some(2),
1460            terminal_seq: Some(7),
1461            completed_at: "2026-06-12T17:03:00Z".to_string(),
1462            result: FleetTaskResult::Pass,
1463            failure_kind: None,
1464            artifacts: vec![],
1465            score: Some(FleetScore {
1466                value: 0.95,
1467                max: Some(1.0),
1468                notes: None,
1469            }),
1470            resolved_route: None,
1471            effective_permissions: None,
1472        };
1473        let json = serde_json::to_string(&receipt).unwrap();
1474        let back: FleetReceipt = serde_json::from_str(&json).unwrap();
1475        assert_eq!(back.result, FleetTaskResult::Pass);
1476        assert_eq!(back.score.as_ref().unwrap().value, 0.95);
1477        assert_eq!(back.attempt, Some(2));
1478        assert_eq!(back.terminal_seq, Some(7));
1479    }
1480
1481    #[test]
1482    fn partial_receipt_records_failure_source_when_needed() {
1483        let receipt = FleetReceipt {
1484            run_id: FleetRunId::from("run-004"),
1485            task_id: "task-2".to_string(),
1486            worker_id: "worker-c".to_string(),
1487            attempt: None,
1488            terminal_seq: None,
1489            completed_at: "2026-06-12T17:04:00Z".to_string(),
1490            result: FleetTaskResult::Partial,
1491            failure_kind: Some(FleetTaskFailureKind::Verifier),
1492            artifacts: vec![],
1493            score: Some(FleetScore {
1494                value: 0.5,
1495                max: Some(1.0),
1496                notes: Some("manual verification required".to_string()),
1497            }),
1498            resolved_route: None,
1499            effective_permissions: None,
1500        };
1501
1502        let json = serde_json::to_string(&receipt).unwrap();
1503        assert!(json.contains("\"result\":\"partial\""));
1504        assert!(json.contains("\"failure_kind\":\"verifier\""));
1505        let back: FleetReceipt = serde_json::from_str(&json).unwrap();
1506        assert_eq!(back.result, FleetTaskResult::Partial);
1507        assert_eq!(back.failure_kind, Some(FleetTaskFailureKind::Verifier));
1508    }
1509
1510    #[test]
1511    fn ssh_host_spec_with_key_pinning_round_trip() {
1512        let spec = FleetHostSpec::Ssh {
1513            host: "builder.trusted.example.com".to_string(),
1514            port: Some(22),
1515            user: Some("codewhale".to_string()),
1516            identity: Some(PathBuf::from("~/.ssh/codewhale_fleet")),
1517            known_hosts: Some(PathBuf::from("~/.ssh/known_hosts")),
1518            host_key_fingerprint: Some("SHA256:aLGqZo1M6c...".to_string()),
1519            working_directory: Some(PathBuf::from("/srv/codewhale/work")),
1520            env_allowlist: vec!["CODEWHALE_PROFILE".to_string()],
1521            codewhale_binary: Some("/usr/local/bin/codewhale".to_string()),
1522        };
1523        let json = serde_json::to_string_pretty(&spec).unwrap();
1524        assert!(json.contains("\"known_hosts\""));
1525        assert!(json.contains("\"host_key_fingerprint\""));
1526        assert!(json.contains("SHA256:aLGqZo1M6c..."));
1527
1528        let back: FleetHostSpec = serde_json::from_str(&json).unwrap();
1529        match back {
1530            FleetHostSpec::Ssh {
1531                host,
1532                known_hosts,
1533                host_key_fingerprint,
1534                ..
1535            } => {
1536                assert_eq!(host, "builder.trusted.example.com");
1537                assert_eq!(known_hosts, Some(PathBuf::from("~/.ssh/known_hosts")));
1538                assert_eq!(
1539                    host_key_fingerprint,
1540                    Some("SHA256:aLGqZo1M6c...".to_string())
1541                );
1542            }
1543            other => panic!("expected ssh host spec, got {other:?}"),
1544        }
1545    }
1546
1547    #[test]
1548    fn secret_ref_redacted_never_exposes_value() {
1549        let ref_ = FleetSecretRef::new("DEEPSEEK_API_KEY");
1550        let redacted = ref_.redacted();
1551        assert!(redacted.contains("DEEPSEEK_API_KEY"));
1552        assert!(!redacted.contains("sk-"));
1553        assert!(redacted.contains("<secret:"));
1554
1555        let ref_ = FleetSecretRef::with_source("GH_TOKEN", "env");
1556        let redacted = ref_.redacted();
1557        assert!(redacted.contains("env.GH_TOKEN"));
1558        assert!(!redacted.contains("ghp_"));
1559    }
1560
1561    #[test]
1562    fn alert_endpoint_from_secret_round_trip() {
1563        let endpoint = FleetAlertEndpoint::from_secret(FleetSecretRef::new("SLACK_WEBHOOK"));
1564        let json = serde_json::to_string(&endpoint).unwrap();
1565        assert!(json.contains("SLACK_WEBHOOK"));
1566        assert!(!json.contains("hooks.slack.com"));
1567
1568        let back: FleetAlertEndpoint = serde_json::from_str(&json).unwrap();
1569        assert_eq!(back.url_ref.as_ref().unwrap().key, "SLACK_WEBHOOK");
1570        assert_eq!(back.url, None);
1571    }
1572
1573    #[test]
1574    fn secret_ref_accepts_legacy_string_wire_shape() {
1575        let ref_: FleetSecretRef = serde_json::from_str(r#""CODEWHALE_FLEET_TOKEN""#).unwrap();
1576        assert_eq!(ref_, FleetSecretRef::new("CODEWHALE_FLEET_TOKEN"));
1577
1578        let ref_: FleetSecretRef =
1579            serde_json::from_str(r#"{"key":"GH_TOKEN","source":"env"}"#).unwrap();
1580        assert_eq!(ref_, FleetSecretRef::with_source("GH_TOKEN", "env"));
1581    }
1582
1583    #[test]
1584    fn trust_level_accepts_hyphenated_remote_verified() {
1585        let trust: FleetTrustLevel = serde_json::from_str(r#""remote-verified""#).unwrap();
1586        assert_eq!(trust, FleetTrustLevel::RemoteVerified);
1587
1588        let canonical = serde_json::to_string(&trust).unwrap();
1589        assert_eq!(canonical, r#""remote_verified""#);
1590    }
1591
1592    #[test]
1593    fn alert_channel_accepts_legacy_webhook_fields() {
1594        let channel: FleetAlertChannel = serde_json::from_str(
1595            r#"{
1596                "kind": "slack",
1597                "webhook_url": "https://hooks.slack.com/test",
1598                "secret": "SLACK_SIGNING_SECRET"
1599            }"#,
1600        )
1601        .unwrap();
1602
1603        match channel {
1604            FleetAlertChannel::Slack { webhook } => {
1605                assert_eq!(webhook.url.as_deref(), Some("https://hooks.slack.com/test"));
1606                assert_eq!(
1607                    webhook.secret_ref,
1608                    Some(FleetSecretRef::new("SLACK_SIGNING_SECRET"))
1609                );
1610            }
1611            other => panic!("expected slack channel, got {other:?}"),
1612        }
1613    }
1614
1615    #[test]
1616    fn security_policy_defaults_are_conservative() {
1617        let policy = FleetSecurityPolicy::default();
1618        assert_eq!(policy.default_trust_level, FleetTrustLevel::Sandbox);
1619        assert!(policy.allowed_secrets.is_empty());
1620        assert!(policy.capability_grants.is_empty());
1621        assert_eq!(policy.max_trust_level, FleetTrustLevel::Operator);
1622        assert!(!policy.require_identity_verification);
1623    }
1624
1625    #[test]
1626    fn trust_level_ordinal_reflects_privilege() {
1627        assert!(FleetTrustLevel::Operator > FleetTrustLevel::RemoteVerified);
1628        assert!(FleetTrustLevel::RemoteVerified > FleetTrustLevel::Local);
1629        assert!(FleetTrustLevel::Local > FleetTrustLevel::Sandbox);
1630
1631        assert!(FleetTrustLevel::Operator.may_access_secrets());
1632        assert!(!FleetTrustLevel::Sandbox.may_access_secrets());
1633        assert!(!FleetTrustLevel::Sandbox.may_write_workspace());
1634        assert!(FleetTrustLevel::Operator.may_write_workspace());
1635    }
1636
1637    fn sample_receipt_with_route() -> FleetReceipt {
1638        FleetReceipt {
1639            run_id: FleetRunId::from("run-route"),
1640            task_id: "task-route".to_string(),
1641            worker_id: "worker-route".to_string(),
1642            attempt: Some(1),
1643            terminal_seq: Some(4),
1644            completed_at: "2026-06-23T00:00:00Z".to_string(),
1645            result: FleetTaskResult::Pass,
1646            failure_kind: None,
1647            artifacts: vec![],
1648            score: None,
1649            resolved_route: Some(FleetResolvedRoute {
1650                provider_id: "deepseek".to_string(),
1651                provider_exact_id: None,
1652                provider_kind: "deepseek".to_string(),
1653                canonical_model: Some("deepseek-v4-pro".to_string()),
1654                wire_model_id: "deepseek-v4-pro".to_string(),
1655                protocol: "chat_completions".to_string(),
1656                role: Some("builder".to_string()),
1657                loadout: Some("auto".to_string()),
1658                model_class: Some("balanced".to_string()),
1659                model_route: Some("auto".to_string()),
1660                reasoning_effort: Some("high".to_string()),
1661                role_source: Some("task.role".to_string()),
1662                loadout_source: Some("task.loadout".to_string()),
1663                model_class_source: Some("task.model_class".to_string()),
1664                model_source: Some("task.model".to_string()),
1665                source: "resolver".to_string(),
1666            }),
1667            effective_permissions: Some(FleetEffectivePermissions {
1668                write: true,
1669                network: true,
1670                shell: "full".to_string(),
1671                tool_scope: "explicit".to_string(),
1672                tools: vec!["read_file".to_string(), "apply_patch".to_string()],
1673                background: true,
1674                max_spawn_depth: 2,
1675                profile_id: Some("builder".to_string()),
1676                profile_origin: Some("built_in".to_string()),
1677                source: "worker_runtime_profile".to_string(),
1678            }),
1679        }
1680    }
1681
1682    #[test]
1683    fn fleet_resolved_route_round_trips() {
1684        let receipt = sample_receipt_with_route();
1685        let json = serde_json::to_string(&receipt).unwrap();
1686        let back: FleetReceipt = serde_json::from_str(&json).unwrap();
1687        assert_eq!(back.resolved_route, receipt.resolved_route);
1688        assert_eq!(back.effective_permissions, receipt.effective_permissions);
1689        let route = back.resolved_route.unwrap();
1690        assert_eq!(route.provider_id, "deepseek");
1691        assert_eq!(route.wire_model_id, "deepseek-v4-pro");
1692        assert_eq!(route.protocol, "chat_completions");
1693        assert_eq!(route.role.as_deref(), Some("builder"));
1694        assert_eq!(route.loadout.as_deref(), Some("auto"));
1695        assert_eq!(route.model_class.as_deref(), Some("balanced"));
1696        assert_eq!(route.model_route.as_deref(), Some("auto"));
1697        assert_eq!(route.reasoning_effort.as_deref(), Some("high"));
1698        assert_eq!(route.role_source.as_deref(), Some("task.role"));
1699        assert_eq!(route.loadout_source.as_deref(), Some("task.loadout"));
1700        assert_eq!(
1701            route.model_class_source.as_deref(),
1702            Some("task.model_class")
1703        );
1704        assert_eq!(route.model_source.as_deref(), Some("task.model"));
1705        assert_eq!(route.source, "resolver");
1706
1707        let permissions = back
1708            .effective_permissions
1709            .expect("effective permissions should round-trip");
1710        assert!(permissions.write);
1711        assert!(permissions.network);
1712        assert_eq!(permissions.shell, "full");
1713        assert_eq!(permissions.tool_scope, "explicit");
1714        assert_eq!(
1715            permissions.tools,
1716            vec!["read_file".to_string(), "apply_patch".to_string()]
1717        );
1718        assert!(permissions.background);
1719        assert_eq!(permissions.max_spawn_depth, 2);
1720        assert_eq!(permissions.profile_id.as_deref(), Some("builder"));
1721        assert_eq!(permissions.profile_origin.as_deref(), Some("built_in"));
1722        assert_eq!(permissions.source, "worker_runtime_profile");
1723    }
1724
1725    #[test]
1726    fn fleet_receipt_without_resolved_route_still_deserializes() {
1727        // An old ledger receipt JSON written before #3154 has no
1728        // `resolved_route` key; `#[serde(default)]` must keep it readable.
1729        let legacy = r#"{
1730            "run_id": "run-legacy",
1731            "task_id": "task-legacy",
1732            "worker_id": "worker-legacy",
1733            "completed_at": "2026-06-01T00:00:00Z",
1734            "result": "pass",
1735            "artifacts": [],
1736            "score": null
1737        }"#;
1738        let receipt: FleetReceipt = serde_json::from_str(legacy).unwrap();
1739        assert_eq!(receipt.task_id, "task-legacy");
1740        assert!(receipt.resolved_route.is_none());
1741        assert!(receipt.attempt.is_none());
1742        assert!(receipt.terminal_seq.is_none());
1743    }
1744
1745    #[test]
1746    fn fleet_resolved_route_legacy_shape_still_deserializes() {
1747        let legacy = r#"{
1748            "run_id": "run-route",
1749            "task_id": "task-route",
1750            "worker_id": "worker-route",
1751            "completed_at": "2026-06-23T00:00:00Z",
1752            "result": "pass",
1753            "artifacts": [],
1754            "score": null,
1755            "resolved_route": {
1756                "provider_id": "deepseek",
1757                "provider_kind": "deepseek",
1758                "canonical_model": "deepseek-v4-pro",
1759                "wire_model_id": "deepseek-v4-pro",
1760                "protocol": "chat_completions",
1761                "role": "builder",
1762                "loadout": "fast",
1763                "source": "resolver"
1764            }
1765        }"#;
1766
1767        let receipt: FleetReceipt = serde_json::from_str(legacy).unwrap();
1768        let route = receipt.resolved_route.expect("legacy route should parse");
1769        assert_eq!(route.source, "resolver");
1770        assert_eq!(route.role.as_deref(), Some("builder"));
1771        assert_eq!(route.loadout.as_deref(), Some("fast"));
1772        assert_eq!(route.model_class, None);
1773        assert_eq!(route.model_route, None);
1774        assert_eq!(route.reasoning_effort, None);
1775        assert_eq!(route.role_source, None);
1776        assert_eq!(route.loadout_source, None);
1777        assert_eq!(route.model_class_source, None);
1778        assert_eq!(route.model_source, None);
1779    }
1780
1781    #[test]
1782    fn fleet_resolved_route_serialization_carries_no_secrets() {
1783        let receipt = sample_receipt_with_route();
1784        // Scan the serialized resolved-route object: this is the field whose
1785        // no-secrets invariant we are asserting. Scoping to the route value
1786        // avoids false positives from unrelated envelope ids (e.g. a task id
1787        // such as "task-foo" innocently contains the substring "sk-").
1788        let route_json = serde_json::to_string(receipt.resolved_route.as_ref().unwrap()).unwrap();
1789        assert_no_secret_markers(&route_json);
1790        // The envelope as a whole must also stay credential-free.
1791        let receipt_json = serde_json::to_string(&receipt).unwrap();
1792        for needle in SECRET_KEY_MARKERS {
1793            assert!(
1794                !receipt_json.to_ascii_lowercase().contains(needle),
1795                "receipt JSON must not contain secret-key marker {needle:?}: {receipt_json}"
1796            );
1797        }
1798    }
1799
1800    /// Substrings that indicate a leaked credential field/value. These are
1801    /// deliberately specific so legitimate ids/model names do not trip them.
1802    const SECRET_KEY_MARKERS: &[&str] = &[
1803        "api_key",
1804        "apikey",
1805        "api-key",
1806        "authorization",
1807        "bearer ",
1808        "auth_token",
1809        "auth-token",
1810        "password",
1811        "credential",
1812        "sk-ant-",
1813        "sk-proj-",
1814        "sk-or-",
1815        "secret",
1816    ];
1817
1818    fn assert_no_secret_markers(json: &str) {
1819        let haystack = json.to_ascii_lowercase();
1820        for needle in SECRET_KEY_MARKERS {
1821            assert!(
1822                !haystack.contains(needle),
1823                "resolved-route JSON must not contain secret marker {needle:?}: {json}"
1824            );
1825        }
1826    }
1827}