Skip to main content

agentd/config/v2/
mod.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! The **agentd settings document** — one nested document (YAML or JSON;
3//! several files merge in order) whose every path is also `AGENTD_<PATH>` /
4//! `AGENT_<PATH>` / `<PATH>` and `--<path>`. This module holds the typed
5//! [`Settings`], its JSON Schema ([`schema::schema`]), the load pipeline
6//! (files → env → flags → typed → validated), the flat **alias** table
7//! (`--instruction`, `--intelligence`, `--model`, `--mcp`, …), the
8//! `agentd --instruction X` **sugar**, schema **detection**, and the reload
9//! partition (which paths only a restart can change).
10//!
11//! Layering: `built-in < files < env < flags`. Files compose with
12//! JSON-Merge-Patch semantics; env sets a path (lists and maps are replaced,
13//! never merged); flags apply in argument order — a generic `--<path>` SETS,
14//! while a named repeatable alias (`--mcp`, `--a2a-peer`) ADDS to its list.
15
16pub mod schema;
17
18use super::file::{self, Format};
19use super::paths::{self, Binding};
20use super::{ConfigError, usage};
21use serde::{Deserialize, Serialize};
22use serde_json::{Map, Value, json};
23use std::collections::{BTreeMap, HashMap};
24use std::fmt;
25use std::path::{Path, PathBuf};
26use std::time::Duration;
27
28// ---------------------------------------------------------------------------
29// Scalars
30// ---------------------------------------------------------------------------
31
32/// A duration deserialized from `"10m"` / `"500ms"` / bare seconds (string or
33/// integer). Displays in the same string form.
34#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
35pub struct Dur(pub Duration);
36
37impl fmt::Debug for Dur {
38    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
39        write!(f, "{:?}", self.0)
40    }
41}
42
43impl<'de> Deserialize<'de> for Dur {
44    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
45        #[derive(Deserialize)]
46        #[serde(untagged)]
47        enum Raw {
48            Secs(u64),
49            Text(String),
50        }
51        match Raw::deserialize(d)? {
52            Raw::Secs(s) => Ok(Dur(Duration::from_secs(s))),
53            Raw::Text(t) => super::parse_duration(&t)
54                .map(Dur)
55                .map_err(serde::de::Error::custom),
56        }
57    }
58}
59
60/// A credential-bearing string. From a FILE it must be a `{{secret:…}}` /
61/// `{{secret-file:…}}` reference — validation over the file document enforces
62/// that, so a config document never carries a live credential — while an
63/// env/flag value may be inline. `Debug` never shows the contents.
64#[derive(Clone, PartialEq, Eq, Deserialize, Default)]
65pub struct Secret(pub String);
66
67impl fmt::Debug for Secret {
68    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69        f.write_str("***")
70    }
71}
72
73/// `all` | `none` | an explicit list.
74#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
75#[serde(untagged)]
76pub enum ToolSelect {
77    Keyword(SelectKeyword),
78    List(Vec<String>),
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
82#[serde(rename_all = "lowercase")]
83pub enum SelectKeyword {
84    All,
85    None,
86}
87
88impl Default for ToolSelect {
89    fn default() -> Self {
90        ToolSelect::Keyword(SelectKeyword::All)
91    }
92}
93
94impl ToolSelect {
95    pub fn allows(&self, name: &str) -> bool {
96        match self {
97            ToolSelect::Keyword(SelectKeyword::All) => true,
98            ToolSelect::Keyword(SelectKeyword::None) => false,
99            ToolSelect::List(l) => l.iter().any(|n| n == name),
100        }
101    }
102}
103
104fn string_or_list<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<String>, D::Error> {
105    #[derive(Deserialize)]
106    #[serde(untagged)]
107    enum Raw {
108        List(Vec<String>),
109        One(String),
110    }
111    Ok(match Raw::deserialize(d)? {
112        Raw::List(l) => l,
113        Raw::One(s) => s
114            .split(',')
115            .map(str::trim)
116            .filter(|s| !s.is_empty())
117            .map(str::to_string)
118            .collect(),
119    })
120}
121
122// ---------------------------------------------------------------------------
123// The document
124// ---------------------------------------------------------------------------
125
126/// The typed v2 settings document. Every object is `deny_unknown_fields`;
127/// every section defaults so a minimal document (`agent.instruction` alone)
128/// is complete.
129#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
130#[serde(deny_unknown_fields, default)]
131pub struct Settings {
132    pub config_version: Option<String>,
133    /// Named durable event streams: `{name: {retention: {max_events,
134    /// max_age}}}`. A stream must be declared before an `emit` or `stream` node
135    /// may reference it — an undeclared name is refused at startup rather than
136    /// silently creating a stream nothing retains.
137    #[serde(default)]
138    pub streams: BTreeMap<String, StreamCfg>,
139    /// The **service catalog**: the named external services this deployment may
140    /// use. Entries carry connection settings, one shared credential,
141    /// authoritative trifecta tags and a tool-surface ceiling; `mcp.servers`
142    /// entries reference them via `service:`. An absent catalog imposes no
143    /// constraints at all.
144    #[serde(default)]
145    pub services: BTreeMap<String, Service>,
146    /// Operator-defined constants, referenced anywhere in this document and in
147    /// workflow definitions as `{{config.NAME}}` (dotted paths reach into
148    /// nested values). The template prefix is `config.` and NOT `vars.`
149    /// because `vars.*` already names a RUN's own variables — two things
150    /// called `vars` in one template language would be a permanent trap.
151    ///
152    /// Substitution is fail-closed: a reference to an undefined name refuses
153    /// startup naming every unresolved reference at once. In workflows the
154    /// values fold in at LOAD time, so they participate in the definition hash
155    /// — a var change is a definition change, and in-flight runs stay pinned
156    /// to the definition they started with.
157    pub vars: BTreeMap<String, Value>,
158    pub agent: Agent,
159    pub intelligence: Intelligence,
160    pub mcp: Mcp,
161    pub tools: Tools,
162    pub store: Store,
163    pub memory: Memory,
164    pub context: Context,
165    pub knowledge: Knowledge,
166    pub search: Search,
167    pub skills: Skills,
168    /// Inline workflow definitions or `{name, file|uri}` references — kept as
169    /// raw documents here and typed by the workflow engine, so config loading
170    /// never has to know the node registry.
171    pub workflows: Vec<Value>,
172    pub limits: Limits,
173    pub lifecycle: Lifecycle,
174    /// Subagent templates + spawn policy: operator-declared definitions the
175    /// model may instantiate (filling declared `params` only), section-wide
176    /// defaults, and the freeform-spawn switch.
177    pub subagents: Subagents,
178    pub a2a: A2a,
179    /// The display-client surface: opt-in TUI/web-UI methods on the A2A
180    /// listener (the global `SubscribeToEvents` feed + interface read ops).
181    pub interface: Interface,
182    /// The inbound webhook HTTP surface: a dedicated listener for `webhook`
183    /// start nodes and `wait: {on: webhook}` callbacks.
184    pub webhooks: Webhooks,
185    /// The self-correcting goal watchdog: a periodic check of whether the
186    /// configured goal is achieved (or the agent is stuck).
187    pub goal: Option<Goal>,
188    pub observability: Observability,
189    pub security: Security,
190    /// Who work is done ON BEHALF OF, and what travels with it.
191    pub identity: Identity,
192}
193
194#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
195#[serde(deny_unknown_fields, default)]
196pub struct Agent {
197    pub name: Option<String>,
198    /// Static text, or a single-token URI a configured MCP server serves
199    /// (read + subscribed) — one field, with the shape deciding which.
200    pub instruction: Option<String>,
201    /// A **one-shot task** (`--prompt`). With no workflows configured this is
202    /// what the generated run executes, while `instruction` stays the standing
203    /// policy (it becomes the run's system prompt). Given alone, the prompt is
204    /// the whole job — `agentd --prompt "…" --intelligence …` runs it once and
205    /// exits with the answer on stdout.
206    pub prompt: Option<String>,
207    /// Skills defined by `:::skill` directives in the instruction — DERIVED
208    /// (never a config key): `Settings::from_document` extracts them, the
209    /// runtime feeds them to the catalogue. In the struct so a reload diff
210    /// sees an edited inline skill as an agent change.
211    #[serde(skip)]
212    pub inline_skills: Vec<crate::config::directives::InlineSkill>,
213    pub preflight: Preflight,
214    pub wake_on: Option<Vec<WakeEvent>>,
215    pub on_workflow_finished: OnWorkflowFinished,
216    pub tools: AgentTools,
217    pub max_parallel_turns: Option<u32>,
218    pub conversation_budget: Option<Budget>,
219    /// What `ask_human` does when NO human channel can answer — the interface
220    /// is disabled — and, for `auto`, when a gate times out unanswered
221    /// `fail` (default; the ask errors immediately), `wait` (park until the
222    /// ask timeout), or `auto` (an LLM judge answers on the operator's behalf,
223    /// conservatively, marked as auto).
224    pub ask_human_fallback: AskHumanFallback,
225    /// What a gate does when a human COULD answer.
226    ///
227    /// `ask_human_fallback` governs the case where nobody can answer;
228    /// this governs whether to ask at all. They are separate because they are
229    /// separate questions: "there is no channel" is a fact about deployment,
230    /// "do not interrupt me" is a policy about attention.
231    pub approval: Approval,
232}
233
234/// How much a person wants to be asked.
235///
236/// Runtime-settable, because the right answer changes with what the agent is
237/// doing: you supervise closely while it is somewhere unfamiliar and stop
238/// wanting to be asked once it is doing something you have watched it do
239/// twenty times.
240#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
241#[serde(rename_all = "lowercase")]
242pub enum Approval {
243    /// Ask a person and wait. The default: a gate exists because someone
244    /// wanted a decision, so the decision is theirs unless told otherwise.
245    #[default]
246    #[serde(alias = "await", alias = "human")]
247    Ask,
248    /// An LLM judge decides whether it is safe to proceed, conservatively, and
249    /// the answer is marked `via: auto` so nobody mistakes it for a person's.
250    Auto,
251    /// Take the recommendation without asking.
252    ///
253    /// Only usable when the ask CARRIES one — a `recommend` argument or a
254    /// schema `default`. With neither there is nothing to accept, and inventing
255    /// an answer would be worse than the interruption, so it degrades to
256    /// `auto` rather than guessing.
257    #[serde(alias = "accept_all", alias = "yes")]
258    Accept,
259}
260
261/// The `ask_human` fallback disposition.
262#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
263#[serde(rename_all = "lowercase")]
264pub enum AskHumanFallback {
265    /// Park the ask until its timeout (then it fails).
266    #[serde(alias = "pause", alias = "idle")]
267    Wait,
268    /// Error immediately — the caller (model / workflow policy) decides.
269    #[default]
270    #[serde(alias = "finish", alias = "stop")]
271    Fail,
272    /// An LLM judge answers on the operator's behalf (also fires when an
273    /// interface-served gate times out unanswered). `UNDECIDED` ⇒ fail.
274    Auto,
275}
276
277impl Agent {
278    /// The wake set used when the operator declares none: the events that
279    /// carry information the agent cannot get any other way. A finished
280    /// workflow is deliberately absent — success needs no attention.
281    pub fn wake_on(&self) -> Vec<WakeEvent> {
282        self.wake_on.clone().unwrap_or_else(|| {
283            vec![
284                WakeEvent::A2aMessage,
285                WakeEvent::HumanReply,
286                WakeEvent::SubagentResult,
287                WakeEvent::WorkflowFailed,
288            ]
289        })
290    }
291    pub fn max_parallel_turns(&self) -> u32 {
292        self.max_parallel_turns.unwrap_or(4)
293    }
294    /// Whether the instruction is a resource reference (a single-token URI).
295    pub fn instruction_is_uri(&self) -> bool {
296        self.instruction
297            .as_deref()
298            .is_some_and(looks_like_resource_uri)
299    }
300}
301
302/// `scheme://…` with no whitespace, and a scheme that is not a bare `http(s)`
303/// URL to a web page… — any `<alpha><alnum+.->://` single token counts; the
304/// registry decides which server serves it.
305pub fn looks_like_resource_uri(s: &str) -> bool {
306    let t = s.trim();
307    if t.contains(char::is_whitespace) {
308        return false;
309    }
310    let Some((scheme, rest)) = t.split_once("://") else {
311        return false;
312    };
313    !scheme.is_empty()
314        && scheme
315            .chars()
316            .next()
317            .is_some_and(|c| c.is_ascii_alphabetic())
318        && scheme
319            .chars()
320            .all(|c| c.is_ascii_alphanumeric() || matches!(c, '+' | '.' | '-'))
321        && !rest.is_empty()
322}
323
324#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
325#[serde(rename_all = "lowercase")]
326pub enum Preflight {
327    Never,
328    #[default]
329    Auto,
330    Always,
331}
332
333#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
334#[serde(rename_all = "snake_case")]
335pub enum WakeEvent {
336    A2aMessage,
337    HumanReply,
338    SubagentResult,
339    WorkflowFinished,
340    WorkflowFailed,
341    InstructionUpdated,
342    BudgetResumed,
343}
344
345#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
346#[serde(rename_all = "lowercase")]
347pub enum OnWorkflowFinished {
348    Ignore,
349    #[default]
350    Note,
351    Think,
352}
353
354#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
355#[serde(deny_unknown_fields, default)]
356pub struct AgentTools {
357    pub internal: ToolSelect,
358    pub mcp: ToolSelect,
359    pub code: ToolSelect,
360}
361
362#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
363#[serde(deny_unknown_fields, default)]
364pub struct Intelligence {
365    #[serde(deserialize_with = "string_or_list")]
366    pub endpoints: Vec<String>,
367    pub model: Option<String>,
368    /// The wire dialect: `openai` (default), `anthropic`, or
369    /// `bedrock` (native Amazon Bedrock Converse — pair with `auth: {kind: aws,
370    /// service: bedrock}`). Unset ⇒ OpenAI-compatible.
371    pub dialect: Option<String>,
372    pub token: Option<Secret>,
373    pub token_file: Option<String>,
374    pub headers: BTreeMap<String, String>,
375    /// A unified credential provider for the LLM endpoint — e.g. `oauth2`
376    /// device-login for an enterprise gateway. Obtained via
377    /// `agentd login intelligence`; the resolved bearer overrides `token`.
378    pub auth: Option<Auth>,
379    pub swap_policy: Option<String>,
380    pub structured_output: StructuredOutput,
381    pub budget: Budget,
382    pub pricing: BTreeMap<String, Pricing>,
383    pub timeout: Option<Dur>,
384    /// Named model TIERS. The model was one instance-global string, so
385    /// choosing a cheap model for a classify step and a frontier one for a
386    /// judgement call meant forking a subagent process just to change it —
387    /// and the breaker was per ENDPOINT, so a frontier and a cheap model
388    /// behind one gateway shared one breaker and one spend pool.
389    ///
390    /// A tier is NOT a second service catalog: `services:` already names
391    /// endpoints, auth, tags, rate and breaker, and restating those here would
392    /// be a parallel mechanism. A tier points AT a service and may only
393    /// narrow — it inherits that service's trifecta tags and can never declare
394    /// its own floor, so "make it cheaper" cannot quietly become a different
395    /// security decision.
396    pub models: BTreeMap<String, ModelTier>,
397    /// Which tier is used when nothing names one. Falls back to `model`.
398    pub default: Option<String>,
399    /// The tier preflight runs on. Preflight is a recurring fixed cost on
400    /// every inbound message, like compaction — it does not need the model
401    /// that answers.
402    pub preflight_model: Option<String>,
403}
404
405/// One named model tier.
406#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
407#[serde(deny_unknown_fields, default)]
408pub struct ModelTier {
409    /// The wire model name sent to the provider. Required.
410    pub model: Option<String>,
411    /// A `services:` entry of `kind: intelligence` supplying the endpoint,
412    /// auth and tags. Absent ⇒ the top-level `intelligence` endpoint.
413    pub service: Option<String>,
414    /// This model's context window, so compaction stops guessing from the
415    /// model NAME (a substring match that is wrong for every provider whose
416    /// naming does not happen to match).
417    pub window: Option<u64>,
418    /// The tier to fall back to when this one is unavailable or the budget is
419    /// squeezed — a degradation ladder that walks DOWN instead of failing.
420    pub fallback: Option<String>,
421    pub pricing: Option<Pricing>,
422}
423
424impl Intelligence {
425    /// Resolve a model reference to the wire model name.
426    ///
427    /// A reference is either a declared TIER name or a literal model string,
428    /// with the tier winning. That ordering is what lets `models:` be adopted
429    /// without rewriting every place a model is already named — an existing
430    /// literal keeps working, and a tier name takes over the moment one is
431    /// declared under that name.
432    pub fn wire_model(&self, reference: &str) -> String {
433        match self.models.get(reference).and_then(|t| t.model.clone()) {
434            Some(m) => m,
435            None => reference.to_string(),
436        }
437    }
438
439    /// The tier a reference names, if it names one.
440    pub fn tier(&self, reference: &str) -> Option<&ModelTier> {
441        self.models.get(reference)
442    }
443
444    /// The model reference used when nothing names one: `default` (a tier),
445    /// else `model` (a literal or a tier name).
446    pub fn default_reference(&self) -> Option<String> {
447        self.default.clone().or_else(|| self.model.clone())
448    }
449
450    /// Walk the fallback chain from `reference`, stopping at the first tier
451    /// with no fallback. Cycles are impossible because validation refuses
452    /// them; the bound here is belt-and-braces for a config that reached the
453    /// runtime some other way.
454    pub fn fallback_chain(&self, reference: &str) -> Vec<String> {
455        let mut out = Vec::new();
456        let mut cur = reference.to_string();
457        for _ in 0..8 {
458            let Some(next) = self.models.get(&cur).and_then(|t| t.fallback.clone()) else {
459                break;
460            };
461            if out.contains(&next) || next == reference {
462                break;
463            }
464            out.push(next.clone());
465            cur = next;
466        }
467        out
468    }
469
470    pub fn timeout(&self) -> Duration {
471        self.timeout.map(|d| d.0).unwrap_or(Duration::from_secs(60))
472    }
473    /// The comma-joined endpoint list URI the v1 intelligence client speaks.
474    pub fn endpoint_list(&self) -> Option<String> {
475        if self.endpoints.is_empty() {
476            None
477        } else {
478            Some(self.endpoints.join(","))
479        }
480    }
481}
482
483#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
484#[serde(rename_all = "snake_case")]
485pub enum StructuredOutput {
486    #[default]
487    Auto,
488    JsonSchema,
489    Tool,
490    Prompt,
491}
492
493#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
494#[serde(deny_unknown_fields, default)]
495pub struct Budget {
496    pub windows: Vec<BudgetWindow>,
497    pub lifetime_tokens: Option<u64>,
498    pub scope: Option<Vec<BudgetScope>>,
499    pub on_exhausted: BudgetTactic,
500    pub slow: Slow,
501    pub degrade: Degrade,
502    pub reserve: Reserve,
503}
504
505#[derive(Debug, Clone, Deserialize, PartialEq)]
506#[serde(deny_unknown_fields)]
507pub struct BudgetWindow {
508    pub per: WindowUnit,
509    #[serde(default)]
510    pub tokens: Option<u64>,
511    #[serde(default)]
512    pub requests: Option<u64>,
513    #[serde(default)]
514    pub reset: Option<String>,
515}
516
517#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
518#[serde(rename_all = "lowercase")]
519pub enum WindowUnit {
520    Second,
521    Minute,
522    Hour,
523    Day,
524    Week,
525}
526
527impl WindowUnit {
528    pub fn duration(self) -> Duration {
529        match self {
530            WindowUnit::Second => Duration::from_secs(1),
531            WindowUnit::Minute => Duration::from_secs(60),
532            WindowUnit::Hour => Duration::from_secs(3600),
533            WindowUnit::Day => Duration::from_secs(86_400),
534            WindowUnit::Week => Duration::from_secs(7 * 86_400),
535        }
536    }
537    /// Calendar windows reset at a wall-clock time; rolling windows are buckets.
538    pub fn is_calendar(self) -> bool {
539        matches!(self, WindowUnit::Day | WindowUnit::Week)
540    }
541}
542
543#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
544#[serde(rename_all = "lowercase")]
545pub enum BudgetScope {
546    Instance,
547    Run,
548    Conversation,
549    Principal,
550}
551
552#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
553#[serde(rename_all = "lowercase")]
554pub enum BudgetTactic {
555    #[default]
556    Wait,
557    Slow,
558    Degrade,
559    Refuse,
560    Fail,
561}
562
563#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
564#[serde(deny_unknown_fields, default)]
565pub struct Slow {
566    pub factor: Option<f64>,
567}
568
569#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
570#[serde(deny_unknown_fields, default)]
571pub struct Degrade {
572    pub model: Option<String>,
573}
574
575#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
576#[serde(deny_unknown_fields, default)]
577pub struct Reserve {
578    pub estimate: ReserveEstimate,
579    pub fixed: Option<u64>,
580}
581
582#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
583#[serde(rename_all = "lowercase")]
584pub enum ReserveEstimate {
585    #[default]
586    Context,
587    Fixed,
588    None,
589}
590
591#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
592#[serde(deny_unknown_fields, default)]
593pub struct Pricing {
594    pub input_per_1k: Option<f64>,
595    pub output_per_1k: Option<f64>,
596    pub currency: Option<String>,
597}
598
599#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
600#[serde(deny_unknown_fields, default)]
601pub struct Mcp {
602    pub servers: Vec<McpServer>,
603    pub default_timeout: Option<Dur>,
604}
605
606/// A **service catalog entry**: a named external service this deployment may
607/// use — connection settings, one shared credential, authoritative trifecta
608/// tags (a floor for any matching endpoint, not just referencing ones), and a
609/// tool-surface ceiling consumers can only narrow. The catalog itself dials
610/// nothing; `mcp.servers` entries reference it.
611#[derive(Debug, Clone, Deserialize, PartialEq)]
612#[serde(deny_unknown_fields)]
613pub struct Service {
614    #[serde(default)]
615    pub kind: ServiceKind,
616    pub endpoint: String,
617    #[serde(default)]
618    pub headers: BTreeMap<String, String>,
619    /// Authoritative trifecta tags: unioned into any consumer whose endpoint
620    /// matches this entry — referencing or inline, `open` or `closed` mode.
621    #[serde(default)]
622    pub tags: BTreeMap<String, Vec<String>>,
623    /// The CEILING: the widest advertised-tool surface any consumer may get.
624    /// A consumer `allow` pattern not subsumed by this list is refused.
625    #[serde(default)]
626    pub allow: Option<Vec<String>>,
627    #[serde(default)]
628    pub exclude: Vec<String>,
629    #[serde(default)]
630    pub auth: Option<Auth>,
631    /// Per-instance pacing toward the service (`<burst>/<per>`, e.g. `60/1m`),
632    /// shared by every consumer of the entry in this process.
633    #[serde(default)]
634    pub rate: Option<String>,
635    #[serde(default)]
636    pub timeout: Option<Dur>,
637    /// `kind: http` only — the METHOD ceiling for `http` steps against this
638    /// entry (`[GET, POST]`); absent = any method.
639    #[serde(default)]
640    pub methods: Option<Vec<String>>,
641    /// `kind: mcp` only — a default `breaker:` policy for `mcp.tool` steps
642    /// against this entry (same shape as the step field); a step's own
643    /// `breaker:` wins.
644    #[serde(default)]
645    pub breaker: Option<Value>,
646}
647
648/// Which outbound surface a catalog entry describes. Matching is
649/// KIND-FILTERED: an MCP dial only matches `mcp` entries, an `http` step only
650/// `http` entries, and so on, because one host may legitimately serve several
651/// kinds under different trust budgets.
652#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
653#[serde(rename_all = "lowercase")]
654pub enum ServiceKind {
655    #[default]
656    Mcp,
657    Intelligence,
658    Peer,
659    Http,
660}
661
662impl ServiceKind {
663    pub fn as_str(self) -> &'static str {
664        match self {
665            ServiceKind::Mcp => "mcp",
666            ServiceKind::Intelligence => "intelligence",
667            ServiceKind::Peer => "peer",
668            ServiceKind::Http => "http",
669        }
670    }
671}
672
673/// Resolve `service:` references against the catalog and apply the
674/// unconditional tag floor. Mutates `mcp.servers` in place — after this, every
675/// server carries its effective endpoint, auth, headers, admission lists and
676/// tag set, so validation, the trifecta gate and the runtime all judge the
677/// *outcome* rather than each re-deriving it. Must therefore run BEFORE
678/// validation. Returns the resolution errors, for aggregation with
679/// validation's.
680pub fn resolve_services(s: &mut Settings) -> Vec<String> {
681    let services = s.services.clone();
682    let mut errs = Vec::new();
683    for srv in &mut s.mcp.servers {
684        let Some(name) = srv.service.clone() else {
685            continue;
686        };
687        let Some(entry) = services.get(&name) else {
688            errs.push(format!(
689                "mcp server '{}' references unknown service '{name}' (services.{name} is not declared)",
690                srv.name
691            ));
692            continue;
693        };
694        // Consumers reference, never restate: connection settings live in the
695        // catalog only, so there is exactly one place to rotate a credential
696        // or repoint a host.
697        for (restated, what) in [
698            (!srv.endpoint.is_empty(), "endpoint"),
699            (srv.auth.is_some(), "auth"),
700            (srv.oauth.is_some(), "oauth"),
701            (!srv.headers.is_empty(), "headers"),
702        ] {
703            if restated {
704                errs.push(format!(
705                    "mcp server '{}' references service '{name}' and restates `{what}` — a referencing consumer inherits connection settings from the catalog",
706                    srv.name
707                ));
708            }
709        }
710        srv.endpoint = entry.endpoint.clone();
711        srv.auth = entry.auth.clone();
712        srv.headers = entry.headers.clone();
713        if srv.timeout.is_none() {
714            srv.timeout = entry.timeout;
715        }
716        // The ceiling: consumer `allow` may only narrow (every consumer
717        // pattern must be subsumed by some catalog pattern); absent consumer
718        // `allow` inherits the ceiling itself. `exclude` unions.
719        match (&entry.allow, &mut srv.allow) {
720            (Some(ceil), Some(mine)) => {
721                for p in mine.iter() {
722                    if !ceil.iter().any(|c| pattern_subsumes(p, c)) {
723                        errs.push(format!(
724                            "mcp server '{}': allow pattern '{p}' widens the ceiling of service '{name}' (catalog allow: {ceil:?})",
725                            srv.name
726                        ));
727                    }
728                }
729            }
730            (Some(ceil), mine @ None) => *mine = Some(ceil.clone()),
731            _ => {}
732        }
733        for e in &entry.exclude {
734            if !srv.exclude.contains(e) {
735                srv.exclude.push(e.clone());
736            }
737        }
738        union_tags(&mut srv.tags, &entry.tags);
739        srv.service_rate = entry.rate.clone();
740    }
741    // The unconditional tag floor: ANY server whose endpoint matches a catalog
742    // entry gets that entry's tags unioned in — whether it referenced the entry
743    // or spelled the URL out inline, and in either egress mode. Without this,
744    // restating a catalogued endpoint inline with weaker tags would launder
745    // away the trifecta tags the catalog declares. (The entry's pacing applies
746    // to matched inline consumers too, for the same reason.)
747    for srv in &mut s.mcp.servers {
748        if srv.endpoint.is_empty() {
749            continue;
750        }
751        if let Some((name, entry)) = service_match(&services, ServiceKind::Mcp, &srv.endpoint) {
752            union_tags(&mut srv.tags, &entry.tags);
753            if srv.service.is_none() {
754                srv.service = Some(name.clone());
755                srv.service_rate = entry.rate.clone();
756            }
757        }
758    }
759    // `a2a.peers[].service` references resolve the same way against
760    // `kind: peer` entries — inherit, never restate.
761    for peer in &mut s.a2a.peers {
762        let Some(name) = peer.service.clone() else {
763            continue;
764        };
765        let entry = match services.get(&name) {
766            Some(e) if e.kind == ServiceKind::Peer => e,
767            Some(e) => {
768                errs.push(format!(
769                    "a2a peer '{}' references service '{name}', which is `kind: {}` (a peer reference needs `kind: peer`)",
770                    peer.name,
771                    e.kind.as_str()
772                ));
773                continue;
774            }
775            None => {
776                errs.push(format!(
777                    "a2a peer '{}' references unknown service '{name}' (services.{name} is not declared)",
778                    peer.name
779                ));
780                continue;
781            }
782        };
783        for (restated, what) in [
784            (!peer.endpoint.is_empty(), "endpoint"),
785            (peer.auth.is_some(), "auth"),
786            (!peer.headers.is_empty(), "headers"),
787        ] {
788            if restated {
789                errs.push(format!(
790                    "a2a peer '{}' references service '{name}' and restates `{what}` — a referencing consumer inherits connection settings from the catalog",
791                    peer.name
792                ));
793            }
794        }
795        peer.endpoint = entry.endpoint.clone();
796        peer.auth = entry.auth.clone();
797        peer.headers = entry.headers.clone();
798    }
799    errs
800}
801
802/// Does catalog pattern `ceiling` cover consumer pattern `p`? Patterns are the
803/// registry's trailing-`*` globs. A literal ceiling covers only itself; a
804/// glob ceiling covers any pattern whose fixed prefix extends the ceiling's.
805fn pattern_subsumes(p: &str, ceiling: &str) -> bool {
806    match ceiling.strip_suffix('*') {
807        Some(prefix) => p.strip_suffix('*').unwrap_or(p).starts_with(prefix),
808        None => p == ceiling,
809    }
810}
811
812/// Union `from` into `into` (per tool-pattern key; tag lists dedup).
813fn union_tags(into: &mut BTreeMap<String, Vec<String>>, from: &BTreeMap<String, Vec<String>>) {
814    for (k, list) in from {
815        let slot = into.entry(k.clone()).or_default();
816        for t in list {
817            if !slot.contains(t) {
818                slot.push(t.clone());
819            }
820        }
821    }
822}
823
824/// Match a URL against the catalog's entries OF ONE KIND: scheme and authority
825/// must be equal (host case-insensitively), and the URL's path must extend the
826/// entry's path on a segment boundary — so `/v1` never matches `/v1betaX`.
827/// Returns the matching entry and refuses nothing; the caller decides what a
828/// non-match means (`Egress::Closed` refuses it).
829pub fn service_match<'a>(
830    services: &'a BTreeMap<String, Service>,
831    kind: ServiceKind,
832    url: &str,
833) -> Option<(&'a String, &'a Service)> {
834    let (scheme, authority, path) = split_url(url)?;
835    services.iter().find(|(_, e)| {
836        if e.kind != kind {
837            return false;
838        }
839        let Some((es, ea, ep)) = split_url(&e.endpoint) else {
840            return false;
841        };
842        scheme == es
843            && authority.eq_ignore_ascii_case(&ea)
844            && (ep.is_empty()
845                || ep == "/"
846                || path == ep
847                || (path.starts_with(&ep)
848                    && (ep.ends_with('/') || path.as_bytes().get(ep.len()) == Some(&b'/'))))
849    })
850}
851
852/// `scheme://authority/path` → (scheme, authority, path). `unix:` sockets
853/// have no authority; the socket path is the authority for matching purposes.
854fn split_url(url: &str) -> Option<(String, String, String)> {
855    if let Some(rest) = url
856        .strip_prefix("unix://")
857        .or_else(|| url.strip_prefix("unix:"))
858    {
859        return Some(("unix".into(), rest.to_string(), String::new()));
860    }
861    let (scheme, rest) = url.split_once("://")?;
862    let (authority, path) = match rest.split_once('/') {
863        Some((a, p)) => (a, format!("/{p}")),
864        None => (rest, String::new()),
865    };
866    Some((scheme.to_string(), authority.to_string(), path))
867}
868
869/// The dial-time egress check. `Open` always passes; `Closed` requires the URL
870/// to match a catalog entry of the surface's kind, so an uncatalogued host
871/// cannot be reached even if some other config path names it.
872pub fn egress_allows(
873    services: &BTreeMap<String, Service>,
874    egress: Egress,
875    kind: ServiceKind,
876    url: &str,
877) -> Result<(), String> {
878    if egress == Egress::Open || service_match(services, kind, url).is_some() {
879        return Ok(());
880    }
881    Err(format!(
882        "security.egress is `closed` and {url} matches no `kind: {}` services: catalog entry — catalog the endpoint to allow it",
883        kind.as_str()
884    ))
885}
886
887#[derive(Debug, Clone, Deserialize, PartialEq)]
888#[serde(deny_unknown_fields)]
889pub struct McpServer {
890    pub name: String,
891    /// Either a literal URL, or empty when `service:` references a catalog
892    /// entry — resolution fills it in before anything dials.
893    #[serde(default)]
894    pub endpoint: String,
895    /// Reference a `services:` catalog entry: inherit its connection settings
896    /// (restating `endpoint`/`auth`/`headers` here is refused) and narrow its
897    /// tool ceiling.
898    #[serde(default)]
899    pub service: Option<String>,
900    /// NOT a config key: the referenced entry's `rate:`, stamped by
901    /// resolution so `to_spec` can carry it to every process's pace registry.
902    #[serde(skip)]
903    pub service_rate: Option<String>,
904    #[serde(default)]
905    pub ns: Option<String>,
906    #[serde(default)]
907    pub headers: BTreeMap<String, String>,
908    #[serde(default)]
909    pub tags: BTreeMap<String, Vec<String>>,
910    /// Tool admission control, on the server's ADVERTISED names (before any
911    /// `ns` prefixing): with `allow`, only matching tools register; anything
912    /// matching `exclude` never registers, and exclude beats allow. Globs are
913    /// the registry's `pattern_matches` (trailing `*`).
914    #[serde(default)]
915    pub allow: Option<Vec<String>>,
916    #[serde(default)]
917    pub exclude: Vec<String>,
918    #[serde(default)]
919    pub aauth: Option<bool>,
920    #[serde(default)]
921    pub oauth: Option<McpOauth>,
922    /// A unified credential provider — `static` / `oauth2` (device login,
923    /// refresh) / `aws` / `spiffe`. Interactive providers obtain their token
924    /// via `agentd login mcp:<name>` and the daemon only reads the cached
925    /// token, so an unattended process never has to run a browser flow.
926    /// Coexists with the narrower `oauth` client-credentials shortcut.
927    #[serde(default)]
928    pub auth: Option<Auth>,
929    #[serde(default)]
930    pub timeout: Option<Dur>,
931}
932
933impl McpServer {
934    /// The flattened, deduplicated trifecta tag set across every tool-pattern
935    /// key. An unknown tag name is an error rather than a silent drop, since
936    /// a typo'd tag would otherwise read as "this server is untagged".
937    pub fn tag_set(&self) -> Result<Vec<crate::sec::scope::TrifectaTag>, String> {
938        let mut out = Vec::new();
939        for list in self.tags.values() {
940            for t in list {
941                let tag = crate::sec::scope::TrifectaTag::parse(t).ok_or_else(|| {
942                    format!("mcp server '{}' has unknown trifecta tag '{t}'", self.name)
943                })?;
944                if !out.contains(&tag) {
945                    out.push(tag);
946                }
947            }
948        }
949        Ok(out)
950    }
951
952    /// Lower to the runtime spec the MCP client and the spawn payload carry.
953    pub fn to_spec(&self) -> Result<super::McpServerSpec, String> {
954        Ok(super::McpServerSpec {
955            name: self.name.clone(),
956            endpoint: self.endpoint.clone(),
957            headers: self
958                .headers
959                .iter()
960                .map(|(k, v)| (k.clone(), v.clone()))
961                .collect(),
962            tags: self.tag_set()?,
963            aauth: self.aauth,
964            // The OAuth client-credentials config must be carried into the
965            // runtime spec: dropping it here would leave `mcp.servers[].oauth`
966            // configured but inert, and the dial would go out unauthenticated.
967            oauth: self.oauth.as_ref().map(|o| super::McpOauthSpec {
968                token_url: o.token_url.clone(),
969                client_id: o.client_id.clone(),
970                client_secret: o.client_secret.0.clone(),
971                scope: o.scope.clone(),
972            }),
973            auth: self.auth.as_ref().map(|a| a.to_spec()),
974            service: self.service.clone(),
975            rate: self.service_rate.clone(),
976        })
977    }
978}
979
980#[derive(Debug, Clone, Deserialize, PartialEq)]
981#[serde(deny_unknown_fields)]
982pub struct McpOauth {
983    pub token_url: String,
984    pub client_id: String,
985    pub client_secret: Secret,
986    #[serde(default)]
987    pub scope: Option<String>,
988}
989
990/// A unified per-endpoint authentication provider. A flat, `kind`-discriminated
991/// record: only the fields relevant to the chosen `kind` are set, and semantic
992/// validation is what enforces which of them are required — the type itself
993/// cannot, because every field is optional for some other kind.
994#[derive(Debug, Clone, Deserialize, PartialEq)]
995#[serde(deny_unknown_fields)]
996pub struct Auth {
997    pub kind: AuthKind,
998    // --- oauth2 / oidc ---
999    /// Issuer base URL for `.well-known` metadata discovery (RFC 8414 / OIDC).
1000    /// When set, it fills in whichever of the token / device-authorization /
1001    /// authorization endpoints the document leaves unset.
1002    #[serde(default)]
1003    pub issuer: Option<String>,
1004    #[serde(default)]
1005    pub token_url: Option<String>,
1006    #[serde(default)]
1007    pub device_authorization_url: Option<String>,
1008    #[serde(default)]
1009    pub authorization_url: Option<String>,
1010    #[serde(default)]
1011    pub client_id: Option<String>,
1012    /// A confidential client's secret (`{{secret:…}}`); omit for a public client
1013    /// (the device grant needs no secret).
1014    #[serde(default)]
1015    pub client_secret: Option<Secret>,
1016    /// `device` (default, interactive), `authorization_code`, or
1017    /// `client_credentials` (headless M2M).
1018    #[serde(default)]
1019    pub grant: Option<OAuthGrant>,
1020    #[serde(default)]
1021    pub scopes: Vec<String>,
1022    #[serde(default)]
1023    pub audience: Option<String>,
1024    // --- static ---
1025    /// A static bearer (`{{secret:…}}`) → `Authorization: Bearer …`.
1026    #[serde(default)]
1027    pub token: Option<Secret>,
1028    /// A static credential under an arbitrary header name (paired with `value`).
1029    #[serde(default)]
1030    pub header: Option<String>,
1031    #[serde(default)]
1032    pub value: Option<Secret>,
1033    // --- aws (SigV4) ---
1034    #[serde(default)]
1035    pub region: Option<String>,
1036    /// The AWS service to sign for (e.g. `bedrock`, `execute-api`).
1037    #[serde(default)]
1038    pub service: Option<String>,
1039    /// The credential source: `env` / `static`, `sso` (IAM Identity Center
1040    /// interactive login → temporary credentials), `imds` (the EC2 instance
1041    /// role) or `irsa` (the Kubernetes projected service-account token).
1042    /// Unset behaves as `env`.
1043    #[serde(default)]
1044    pub source: Option<String>,
1045    /// aws `source: sso` — the IAM Identity Center portal start URL, the account,
1046    /// and the permission-set role to assume (via `agentd login`).
1047    #[serde(default)]
1048    pub sso_start_url: Option<String>,
1049    #[serde(default)]
1050    pub account_id: Option<String>,
1051    #[serde(default)]
1052    pub role_name: Option<String>,
1053    // --- spiffe (workload identity) ---
1054    /// The SVID type: `jwt` (a rotating JWT-SVID bearer read from a file) or
1055    /// `x509` (an mTLS client identity rather than a request signer).
1056    #[serde(default)]
1057    pub svid: Option<String>,
1058    /// Path to the SPIRE-written JWT-SVID token file (re-read per request, so a
1059    /// rotation is picked up).
1060    #[serde(default)]
1061    pub jwt_svid_file: Option<String>,
1062    /// Paths to the X.509-SVID cert + key (for `svid: x509`).
1063    #[serde(default)]
1064    pub svid_file: Option<String>,
1065    #[serde(default)]
1066    pub key_file: Option<String>,
1067}
1068
1069impl Auth {
1070    /// Lower to the secret-free runtime [`AuthSpec`](super::AuthSpec) (spawn
1071    /// payload). Secrets stay as `{{secret:…}}` templates.
1072    pub fn to_spec(&self) -> super::AuthSpec {
1073        super::AuthSpec {
1074            kind: match self.kind {
1075                AuthKind::Static => "static",
1076                AuthKind::Oauth2 => "oauth2",
1077                AuthKind::Aws => "aws",
1078                AuthKind::Spiffe => "spiffe",
1079            }
1080            .to_string(),
1081            grant: self.grant.map(|g| {
1082                match g {
1083                    OAuthGrant::Device => "device",
1084                    OAuthGrant::AuthorizationCode => "authorization_code",
1085                    OAuthGrant::ClientCredentials => "client_credentials",
1086                }
1087                .to_string()
1088            }),
1089            issuer: self.issuer.clone(),
1090            token_url: self.token_url.clone(),
1091            device_authorization_url: self.device_authorization_url.clone(),
1092            authorization_url: self.authorization_url.clone(),
1093            client_id: self.client_id.clone(),
1094            client_secret: self.client_secret.as_ref().map(|s| s.0.clone()),
1095            scopes: self.scopes.clone(),
1096            audience: self.audience.clone(),
1097            token: self.token.as_ref().map(|s| s.0.clone()),
1098            header: self.header.clone(),
1099            value: self.value.as_ref().map(|s| s.0.clone()),
1100            region: self.region.clone(),
1101            service: self.service.clone(),
1102            source: self.source.clone(),
1103            sso_start_url: self.sso_start_url.clone(),
1104            account_id: self.account_id.clone(),
1105            role_name: self.role_name.clone(),
1106            svid: self.svid.clone(),
1107            jwt_svid_file: self.jwt_svid_file.clone(),
1108            svid_file: self.svid_file.clone(),
1109            key_file: self.key_file.clone(),
1110        }
1111    }
1112}
1113
1114/// The authentication provider family.
1115#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
1116#[serde(rename_all = "snake_case")]
1117pub enum AuthKind {
1118    /// A static bearer or named-header credential.
1119    Static,
1120    /// OAuth 2.1 / OIDC — device grant, authorization-code, or client-credentials.
1121    Oauth2,
1122    /// AWS Signature Version 4 — every request is SigV4-signed.
1123    Aws,
1124    /// SPIFFE/SPIRE workload identity — a JWT-SVID bearer, or X.509-SVID mTLS.
1125    Spiffe,
1126}
1127
1128/// The OAuth 2.1 grant type.
1129#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
1130#[serde(rename_all = "snake_case")]
1131pub enum OAuthGrant {
1132    /// RFC 8628 device authorization — the interactive default.
1133    Device,
1134    /// RFC 7636 authorization-code + PKCE (browser loopback).
1135    AuthorizationCode,
1136    /// The headless machine-to-machine grant.
1137    ClientCredentials,
1138}
1139
1140#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1141#[serde(deny_unknown_fields, default)]
1142pub struct Tools {
1143    pub disabled: Vec<String>,
1144    pub overrides: BTreeMap<String, ToolOverride>,
1145}
1146
1147#[derive(Debug, Clone, Deserialize, PartialEq)]
1148#[serde(deny_unknown_fields)]
1149pub struct ToolOverride {
1150    pub server: String,
1151    pub tool: String,
1152    #[serde(default)]
1153    pub args: Option<String>,
1154    #[serde(default)]
1155    pub result: Option<String>,
1156}
1157
1158#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1159#[serde(deny_unknown_fields, default)]
1160pub struct Store {
1161    pub kind: StoreKind,
1162    pub prefix: Option<String>,
1163    pub mcp: Option<StoreMcp>,
1164    pub http: Option<StoreHttp>,
1165    pub file: Option<StoreFile>,
1166    pub checkpoint: Checkpoint,
1167    pub durability: Durability,
1168    pub retention: Retention,
1169    pub on_error: StoreOnError,
1170    pub audit: bool,
1171    pub timeout: Option<Dur>,
1172    /// Refuse a durable write whose serialized state exceeds this many bytes.
1173    /// `None` (the default) is unbounded, which keeps today's behaviour for an
1174    /// operator who has not thought about it.
1175    ///
1176    /// Set it when the store's READ limit is lower than its write limit — an
1177    /// MCP store reached through a broker typically caps a tool RESULT well
1178    /// below its request body. Without the cap, agentd can write a checkpoint
1179    /// it cannot read back, and the failure lands on the next boot restore
1180    /// rather than on the write that caused it.
1181    pub max_value_bytes: Option<u64>,
1182}
1183
1184impl Store {
1185    pub fn prefix(&self) -> &str {
1186        self.prefix.as_deref().unwrap_or("agentd")
1187    }
1188}
1189
1190#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
1191#[serde(rename_all = "lowercase")]
1192pub enum StoreKind {
1193    Mcp,
1194    Http,
1195    /// The local filesystem: one file per key under a root directory,
1196    /// single-writer, and durable to whatever the filesystem is.
1197    File,
1198    Memory,
1199    #[default]
1200    None,
1201}
1202
1203/// `store.file`. The only setting is where the state lives; the adapter needs
1204/// nothing else, so the block itself is optional — `kind: file` with no block
1205/// resolves the root from the environment ([`file_store_root`]).
1206#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1207#[serde(deny_unknown_fields)]
1208pub struct StoreFile {
1209    #[serde(default)]
1210    pub path: Option<String>,
1211    /// Shed new work when the store's filesystem has less than this free
1212    /// (`256MB`, `1.5GiB`, plain bytes; `"0"` disables). Warn at twice it.
1213    /// Default 256MB: a checkpoint failure at ENOSPC HALTS the daemon, so with
1214    /// under a quarter-gig free, refusing new runs while draining the current
1215    /// ones is almost certainly what the operator would have chosen — and the
1216    /// alternative was choosing nothing and dying mid-write.
1217    #[serde(default)]
1218    pub min_free: Option<String>,
1219}
1220
1221/// One declared event stream.
1222#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1223#[serde(deny_unknown_fields, default)]
1224pub struct StreamCfg {
1225    pub retention: StreamRetention,
1226}
1227
1228/// Retention: whichever bound trims first. Neither set = the 10k default —
1229/// an unbounded stream on a disk the pressure system guards would be a
1230/// self-inflicted shed.
1231#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1232#[serde(deny_unknown_fields, default)]
1233pub struct StreamRetention {
1234    pub max_events: Option<u64>,
1235    pub max_age: Option<Dur>,
1236}
1237
1238impl StreamCfg {
1239    pub fn max_events(&self) -> u64 {
1240        self.retention.max_events.unwrap_or(10_000)
1241    }
1242    pub fn max_age_ms(&self) -> Option<u64> {
1243        self.retention.max_age.map(|d| d.0.as_millis() as u64)
1244    }
1245}
1246
1247/// The `file` store's root directory, first that applies:
1248/// `store.file.path`, `$AGENTD_STATE_DIR`, `$XDG_STATE_HOME/agentd/state`,
1249/// `$HOME/.local/state/agentd/state`, else the OS temp dir.
1250///
1251/// This is deliberately the same chain — and the same order — that
1252/// [`crate::auth::cache::default_dir`] uses for the credential cache, one
1253/// sibling over (`state` beside `creds`): an operator who has learned where
1254/// agentd keeps its tokens already knows where it keeps its state, and one
1255/// `XDG_STATE_HOME` moves both. Resolution lives here, next to the schema, so
1256/// the startup log, `--capabilities` and [`crate::store::open`] all name the
1257/// one directory instead of each re-deriving it.
1258///
1259/// The last resort is the OS temp dir: a store that is *there* survives a
1260/// process restart but not a reboot, which is why the runtime logs the
1261/// resolved path and whether it was defaulted, rather than letting a user
1262/// believe more durability than the filesystem actually delivers.
1263pub fn file_store_root(store: &Store) -> std::path::PathBuf {
1264    file_store_root_in(store, &|k| std::env::var_os(k))
1265}
1266
1267/// [`file_store_root`] with the environment injected. The chain is the part
1268/// worth testing and the process env is shared by every test in this binary,
1269/// so the lookup is a parameter — the same shape `unresolved_secret_ref` uses.
1270fn file_store_root_in(
1271    store: &Store,
1272    env: &dyn Fn(&str) -> Option<std::ffi::OsString>,
1273) -> std::path::PathBuf {
1274    use std::path::PathBuf;
1275    if let Some(p) = store.file.as_ref().and_then(|f| f.path.as_deref()) {
1276        return PathBuf::from(p);
1277    }
1278    if let Some(d) = env("AGENTD_STATE_DIR") {
1279        return PathBuf::from(d);
1280    }
1281    if let Some(d) = env("XDG_STATE_HOME") {
1282        return PathBuf::from(d).join("agentd").join("state");
1283    }
1284    if let Some(h) = env("HOME") {
1285        return PathBuf::from(h)
1286            .join(".local")
1287            .join("state")
1288            .join("agentd")
1289            .join("state");
1290    }
1291    std::env::temp_dir().join("agentd").join("state")
1292}
1293
1294#[derive(Debug, Clone, Deserialize, PartialEq)]
1295#[serde(deny_unknown_fields)]
1296pub struct StoreMcp {
1297    pub server: String,
1298    #[serde(default)]
1299    pub put: Option<StoreOp>,
1300    #[serde(default)]
1301    pub get: Option<StoreOp>,
1302    #[serde(default)]
1303    pub list: Option<StoreOp>,
1304    #[serde(default)]
1305    pub delete: Option<StoreOp>,
1306}
1307
1308#[derive(Debug, Clone, Deserialize, PartialEq)]
1309#[serde(deny_unknown_fields)]
1310pub struct StoreOp {
1311    pub tool: String,
1312    #[serde(default)]
1313    pub args: Option<String>,
1314    #[serde(default)]
1315    pub ok: Option<String>,
1316    #[serde(default)]
1317    pub conflict: Option<String>,
1318    #[serde(default)]
1319    pub value: Option<String>,
1320    #[serde(default)]
1321    pub keys: Option<String>,
1322}
1323
1324#[derive(Debug, Clone, Deserialize, PartialEq)]
1325#[serde(deny_unknown_fields)]
1326pub struct StoreHttp {
1327    pub base_url: String,
1328    #[serde(default)]
1329    pub headers: BTreeMap<String, String>,
1330    #[serde(default)]
1331    pub get: Option<HttpOp>,
1332    #[serde(default)]
1333    pub put: Option<HttpOp>,
1334    #[serde(default)]
1335    pub list: Option<HttpOp>,
1336    #[serde(default)]
1337    pub delete: Option<HttpOp>,
1338}
1339
1340#[derive(Debug, Clone, Deserialize, PartialEq)]
1341#[serde(deny_unknown_fields)]
1342pub struct HttpOp {
1343    #[serde(default)]
1344    pub method: Option<String>,
1345    pub url: String,
1346    #[serde(default)]
1347    pub body: Option<String>,
1348    #[serde(default)]
1349    pub value: Option<String>,
1350    #[serde(default)]
1351    pub keys: Option<String>,
1352    #[serde(default)]
1353    pub conflict_status: Option<u16>,
1354}
1355
1356#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1357#[serde(deny_unknown_fields, default)]
1358pub struct Checkpoint {
1359    pub debounce_ms: Option<u64>,
1360}
1361
1362/// What to keep once a run is over.
1363///
1364/// A long-lived instance accumulates one durable record per run forever. On a
1365/// laptop that is the difference between an agent that runs for a month and one
1366/// that fills a disk — and the store had no eviction at all, so "forever" was
1367/// literal.
1368#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1369#[serde(deny_unknown_fields, default)]
1370pub struct Retention {
1371    pub runs: RunRetention,
1372}
1373
1374#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1375#[serde(deny_unknown_fields, default)]
1376pub struct RunRetention {
1377    /// Keep at most this many terminal runs (newest first).
1378    pub keep_last: Option<u32>,
1379    /// Drop a terminal run older than this.
1380    pub ttl: Option<Dur>,
1381}
1382
1383#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1384#[serde(deny_unknown_fields, default)]
1385pub struct Durability {
1386    pub a2a: Option<DurabilityLevel>,
1387    pub steps: Option<DurabilityLevel>,
1388    /// The default durability CLASS for work (runs + subagent records):
1389    /// `durable` (the default — everything checkpoints and survives a
1390    /// restart) or `ephemeral` (nothing persists unless a workflow says
1391    /// `durable: true` / a spawn passes `durable: true` — the fast path for
1392    /// deployments that treat work as recomputable). The inbox, tasks,
1393    /// memory and credentials stay durable regardless.
1394    pub work: Option<WorkDurability>,
1395}
1396
1397#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
1398#[serde(rename_all = "lowercase")]
1399pub enum WorkDurability {
1400    Durable,
1401    Ephemeral,
1402}
1403
1404#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
1405#[serde(rename_all = "lowercase")]
1406pub enum DurabilityLevel {
1407    Strict,
1408    Eventual,
1409}
1410
1411#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
1412#[serde(rename_all = "lowercase")]
1413pub enum StoreOnError {
1414    #[default]
1415    Halt,
1416    Degrade,
1417}
1418
1419#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1420#[serde(deny_unknown_fields, default)]
1421pub struct Memory {
1422    pub max_value_bytes: Option<u64>,
1423    pub list_default_limit: Option<u64>,
1424}
1425
1426#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1427#[serde(deny_unknown_fields, default)]
1428pub struct Context {
1429    pub compact_at: Option<f64>,
1430    pub keep_last: Option<u32>,
1431    /// The model's context window in tokens (overrides the value inferred
1432    /// from `intelligence.model`) — the base of the compaction threshold.
1433    pub model_window: Option<u64>,
1434    pub plan: Plan,
1435    /// The system-prompt template. Unset = the built-in default,
1436    /// which `agentd --context-template` prints. Written in the small
1437    /// `{{#if}}` / `{{#each}}` language over the environment data; expressions
1438    /// are a path first and CEL second.
1439    pub template: Option<String>,
1440    /// Named alternates a node selects with `context: {template: <name>}` —
1441    /// e.g. a `minimal` template for extraction steps that need no
1442    /// environment.
1443    pub templates: BTreeMap<String, String>,
1444    /// Compaction's model-facing half: the summarizer prompt and (optionally)
1445    /// a cheaper model to run it on.
1446    pub summarize: Summarize,
1447}
1448
1449#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1450#[serde(deny_unknown_fields, default)]
1451pub struct Summarize {
1452    /// Override the summarizer's guidance. The JSON schema it must satisfy
1453    /// (`goals`/`decisions`/`open`/`facts`/`narrative`) is NOT yours to
1454    /// change — the summary is parsed back into the context, so a prompt that
1455    /// asks for another shape produces a refusal, not a nicer summary.
1456    pub prompt: Option<String>,
1457    /// Summarize on this model instead of the instance's. Compaction is a
1458    /// recurring fixed cost that rarely needs the frontier model.
1459    pub model: Option<String>,
1460}
1461
1462#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1463#[serde(deny_unknown_fields, default)]
1464pub struct Plan {
1465    pub max_items: Option<u32>,
1466}
1467
1468#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1469#[serde(deny_unknown_fields, default)]
1470pub struct Knowledge {
1471    pub server: Option<String>,
1472    pub auto_context: AutoContext,
1473}
1474
1475#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1476#[serde(deny_unknown_fields, default)]
1477pub struct AutoContext {
1478    pub on: AutoContextOn,
1479    pub top_k: Option<u32>,
1480    pub max_bytes: Option<u64>,
1481}
1482
1483#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
1484#[serde(rename_all = "lowercase")]
1485pub enum AutoContextOn {
1486    Turn,
1487    #[default]
1488    Never,
1489}
1490
1491#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1492#[serde(deny_unknown_fields, default)]
1493pub struct Search {
1494    pub server: Option<String>,
1495}
1496
1497#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1498#[serde(deny_unknown_fields, default)]
1499pub struct Skills {
1500    pub sources: Vec<SkillSource>,
1501    /// A LOCAL folder of skill files, beside the config rather than behind an
1502    /// MCP server. Skills are documents; requiring a server to serve a
1503    /// markdown file was the one place the "capability comes from a server"
1504    /// rule bought nothing — a skill grants no tool, it is prose the model
1505    /// reads. `skills/` beside the config is adopted automatically when this
1506    /// is unset and the folder has files in it.
1507    pub dir: Option<String>,
1508    pub reference_prefix: Option<String>,
1509    pub max_loaded: Option<u32>,
1510    pub max_bytes: Option<u64>,
1511}
1512
1513#[derive(Debug, Clone, Deserialize, PartialEq)]
1514#[serde(deny_unknown_fields)]
1515pub struct SkillSource {
1516    pub server: String,
1517    #[serde(default)]
1518    pub discover: Discover,
1519    #[serde(default)]
1520    pub filter: Option<String>,
1521}
1522
1523#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
1524#[serde(rename_all = "lowercase")]
1525pub enum Discover {
1526    Prompts,
1527    Resources,
1528    #[default]
1529    Auto,
1530}
1531
1532#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1533#[serde(deny_unknown_fields, default)]
1534pub struct Limits {
1535    pub max_runs: Option<u32>,
1536    pub run: RunLimits,
1537    pub subagents: SubagentLimits,
1538    pub inline_max_bytes: Option<u64>,
1539    pub step_timeout: Option<Dur>,
1540    pub workflow: WorkflowLimits,
1541    /// How many `message` hops may chain before a delivery is refused
1542    /// (default [`DEFAULT_MESSAGE_DEPTH`]). This is the fail-closed gate on
1543    /// message → turn → run → message, not a tuning knob: the loop it stops
1544    /// re-arms itself faster than pressure shedding can hold it, because
1545    /// shedding queues new turns while the chain keeps adding more.
1546    pub max_message_depth: Option<u32>,
1547}
1548
1549/// The default ceiling on chained `message` deliveries. Deep enough for real
1550/// delegation — a schedule waking the agent, which runs a workflow, which asks
1551/// a question back — and shallow enough that a runaway is caught in seconds.
1552pub const DEFAULT_MESSAGE_DEPTH: u32 = 8;
1553
1554impl Limits {
1555    pub fn message_depth(&self) -> u32 {
1556        self.max_message_depth.unwrap_or(DEFAULT_MESSAGE_DEPTH)
1557    }
1558}
1559
1560/// Ceilings a workflow definition is checked against at load time.
1561#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1562#[serde(deny_unknown_fields, default)]
1563pub struct WorkflowLimits {
1564    /// The most concurrent lanes a `foreach`/`batch` body may use. A definition
1565    /// asking for more is REFUSED at load rather than quietly clamped: silent
1566    /// clamping is how a workflow ends up running eight-wide while its author
1567    /// believes it runs fifty, and the whole point of the field whitelist is
1568    /// that a knob either does what it says or fails loudly.
1569    pub fan_out: Option<u32>,
1570}
1571
1572#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1573#[serde(deny_unknown_fields, default)]
1574pub struct RunLimits {
1575    pub steps: Option<u32>,
1576    pub tokens: Option<u64>,
1577    pub deadline: Option<Dur>,
1578}
1579
1580impl RunLimits {
1581    pub fn steps(&self) -> u32 {
1582        self.steps.unwrap_or(500)
1583    }
1584    pub fn tokens(&self) -> u64 {
1585        self.tokens.unwrap_or(2_000_000)
1586    }
1587    pub fn deadline(&self) -> Duration {
1588        self.deadline
1589            .map(|d| d.0)
1590            .unwrap_or(Duration::from_secs(3600))
1591    }
1592}
1593
1594#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1595#[serde(deny_unknown_fields, default)]
1596pub struct SubagentLimits {
1597    pub depth: Option<u32>,
1598    pub breadth: Option<u32>,
1599    pub total: Option<u32>,
1600    pub rate: Option<String>,
1601    /// Instance-tier children are far heavier than flat workers, so they carry
1602    /// their own, much tighter caps rather than sharing the ones above.
1603    /// Defaults: 2 live, 8 over the parent's lifetime, `4/1h`.
1604    pub instances: InstanceLimits,
1605}
1606
1607#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1608#[serde(deny_unknown_fields, default)]
1609pub struct InstanceLimits {
1610    pub breadth: Option<u32>,
1611    pub total: Option<u32>,
1612    pub rate: Option<String>,
1613}
1614
1615/// The `subagents:` section: what the model may spawn, and how.
1616#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1617#[serde(deny_unknown_fields, default)]
1618pub struct Subagents {
1619    /// `false` makes templates the ONLY spawn path — every child the model can
1620    /// create is a definition the operator reviewed. Default `true` (freeform
1621    /// flat-tier spawns keep working; there is no freeform INSTANCE spawn
1622    /// regardless).
1623    pub allow_freeform: Option<bool>,
1624    /// Applied to every spawn (flat and templated) unless overridden at the
1625    /// template or call site.
1626    pub defaults: SubagentDefaults,
1627    /// Named, operator-authored definitions. A template whose `instruction`
1628    /// carries no config-defining directives spawns a flat worker; one that
1629    /// defines machinery (`:::workflow`/`:::mcp`/`:::stream`/`:::config`/
1630    /// `:::tools`) spawns an instance-tier child. The tier follows from the
1631    /// text, so it is never declared twice and cannot disagree with itself.
1632    pub templates: BTreeMap<String, SubagentTemplate>,
1633}
1634
1635#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1636#[serde(deny_unknown_fields, default)]
1637pub struct SubagentDefaults {
1638    pub model: Option<String>,
1639    pub priority: Option<String>,
1640    pub mode: Option<String>,
1641    /// Default durability class for spawns (see `SubagentTemplate::durable`).
1642    pub durable: Option<bool>,
1643    /// The per-spawn limits object the `subagent` step takes (`max_tokens`,
1644    /// `deadline`, `memory`, `cpu`, `max_steps`) — kept raw; the spawn path
1645    /// parses it exactly like call-site limits.
1646    pub limits: Option<Value>,
1647}
1648
1649#[derive(Debug, Clone, Deserialize, PartialEq)]
1650#[serde(deny_unknown_fields)]
1651pub struct SubagentTemplate {
1652    /// A full instruction document. Directive extraction runs ONCE, at boot,
1653    /// on this operator-authored text; `params` fold in later at spawn as data
1654    /// and are never re-parsed for directives, so a param value cannot
1655    /// introduce machinery the operator never wrote.
1656    pub instruction: String,
1657    /// The ONLY holes the model may fill, schema-validated at spawn.
1658    #[serde(default)]
1659    pub params: BTreeMap<String, ParamSpec>,
1660    /// Flat tier only: narrowing grants from the parent's server/tool set.
1661    #[serde(default)]
1662    pub servers: Option<Vec<String>>,
1663    #[serde(default)]
1664    pub tools: Option<Vec<String>>,
1665    /// Flat tier: the full per-spawn limits object. Instance tier: OS caps
1666    /// only (`memory`, `cpu`) — token ceilings live in `budget`.
1667    #[serde(default)]
1668    pub limits: Option<Value>,
1669    #[serde(default)]
1670    pub mode: Option<String>,
1671    #[serde(default)]
1672    pub model: Option<String>,
1673    #[serde(default)]
1674    pub priority: Option<String>,
1675    #[serde(default)]
1676    pub skills: Option<Value>,
1677    #[serde(default)]
1678    pub context: Option<Value>,
1679    #[serde(default)]
1680    pub output_contract: Option<String>,
1681    #[serde(default)]
1682    pub output_schema: Option<Value>,
1683    /// Instance tier only: the child's own budget (a `Budget` document),
1684    /// enforced in-child with `on_exhausted: refuse`.
1685    #[serde(default)]
1686    pub budget: Option<Value>,
1687    /// Instance tier only: a lifetime after which the child retires
1688    /// gracefully rather than being killed.
1689    #[serde(default)]
1690    pub ttl: Option<Dur>,
1691    /// A signal name (templated over params) whose delivery IN THE CHILD
1692    /// retires it.
1693    #[serde(default)]
1694    pub until: Option<String>,
1695    /// One live child at a time; its A2A peer alias is the template name.
1696    #[serde(default)]
1697    pub singleton: bool,
1698    /// Durability class: `false` ⇒ the spawn's record is memory-only (and an
1699    /// instance child runs on a memory store — no restore-respawn). Absent ⇒
1700    /// the deployment default (`store.durability.work`).
1701    #[serde(default)]
1702    pub durable: Option<bool>,
1703    /// Instance tier, `mode: sync`: `{workflow: <name>}` — the spawn
1704    /// resolves when the CHILD's named workflow first completes,
1705    /// returning that run's output. Composed as a reporter workflow in the
1706    /// child; requires the parent to have an A2A listener.
1707    #[serde(default)]
1708    pub result: Option<Value>,
1709    /// Instance tier: child streams mirrored into the PARENT's same-named
1710    /// streams — each event forwarded over the socket and
1711    /// appended with source `instance:<handle>`. Requires the stream declared
1712    /// on BOTH sides and a parent A2A listener.
1713    #[serde(default)]
1714    pub mirror_streams: Option<Vec<String>>,
1715}
1716
1717/// One declared template parameter — the only hole a spawn may fill.
1718#[derive(Debug, Clone, Deserialize, PartialEq)]
1719#[serde(deny_unknown_fields)]
1720pub struct ParamSpec {
1721    /// `string` (default) | `number` | `integer` | `boolean`.
1722    #[serde(rename = "type", default)]
1723    pub kind: Option<String>,
1724    #[serde(default)]
1725    pub required: bool,
1726    #[serde(default)]
1727    pub default: Option<Value>,
1728    #[serde(rename = "enum", default)]
1729    pub one_of: Option<Vec<Value>>,
1730    #[serde(default)]
1731    pub description: Option<String>,
1732}
1733
1734#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1735#[serde(deny_unknown_fields, default)]
1736pub struct Lifecycle {
1737    pub run_until: RunUntil,
1738    pub idle_grace: Option<Dur>,
1739    pub drain_timeout: Option<Dur>,
1740    pub run_id: Option<String>,
1741    pub exit_code_map: BTreeMap<String, i32>,
1742    pub watch_config: bool,
1743    /// Delivery of this signal begins graceful shutdown — the retirement
1744    /// trigger a parent composes into an instance-tier child
1745    /// (`until:` on the template), and available to any daemon that should
1746    /// drain when a named signal arrives.
1747    pub until_signal: Option<String>,
1748}
1749
1750impl Lifecycle {
1751    pub fn drain_timeout(&self) -> Duration {
1752        self.drain_timeout
1753            .map(|d| d.0)
1754            .unwrap_or(Duration::from_secs(25))
1755    }
1756    pub fn idle_grace(&self) -> Duration {
1757        self.idle_grace
1758            .map(|d| d.0)
1759            .unwrap_or(Duration::from_secs(5))
1760    }
1761}
1762
1763#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Default)]
1764#[serde(rename_all = "lowercase")]
1765pub enum RunUntil {
1766    #[default]
1767    Auto,
1768    Idle,
1769    Drained,
1770}
1771
1772#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1773#[serde(deny_unknown_fields, default)]
1774pub struct Identity {
1775    /// Who autonomous work is done as. A schedule, webhook, stream or timer
1776    /// firing carries no principal, so "every effect names the human or the
1777    /// schedule that caused it" was false by construction — the attribution
1778    /// chain was dropped at the very first hop. Default `system`.
1779    pub autonomous_as: Option<String>,
1780    /// Labels stamped on autonomous work, the same way a principal's are.
1781    #[serde(default)]
1782    pub labels: BTreeMap<String, String>,
1783}
1784
1785impl Identity {
1786    pub fn autonomous_id(&self) -> &str {
1787        self.autonomous_as.as_deref().unwrap_or("system")
1788    }
1789}
1790
1791#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1792#[serde(deny_unknown_fields, default)]
1793pub struct A2a {
1794    pub listen: Option<String>,
1795    pub tls: A2aTls,
1796    pub bearer: Option<Secret>,
1797    pub principals: Vec<Principal>,
1798    pub peers: Vec<A2aPeer>,
1799    pub conversation_ttl: Option<Dur>,
1800    pub push: A2aPush,
1801}
1802
1803/// **Push notifications**: a caller registers a webhook and agentd POSTs its
1804/// task's updates there instead of holding a stream open.
1805///
1806/// Default-OFF, because the URL comes from the caller: every delivery is an
1807/// outbound request to an address a *peer* chose, which is the shape of an SSRF.
1808/// Enabling it says you are willing to make that request; `allow_private` says
1809/// you are willing to make it to a private or loopback address, which is a
1810/// separate and larger decision (a peer could otherwise reach agentd's own
1811/// surfaces, or a cloud metadata endpoint).
1812#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1813#[serde(deny_unknown_fields, default)]
1814pub struct A2aPush {
1815    /// Accept `CreateTaskPushNotificationConfig` and deliver on transitions.
1816    pub enabled: bool,
1817    /// Permit webhook targets on private / loopback addresses.
1818    pub allow_private: bool,
1819}
1820
1821#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1822#[serde(deny_unknown_fields, default)]
1823pub struct A2aTls {
1824    pub cert: Option<String>,
1825    pub key: Option<String>,
1826    pub client_ca: Option<String>,
1827}
1828
1829/// The **display-client interface**: the opt-in surface a thin TUI/web-UI
1830/// client rides — the global `SubscribeToEvents` feed and the
1831/// `interface.*`/debug read ops, served on the existing A2A listener (no new
1832/// socket). Default-OFF: with `enabled: false` those methods answer
1833/// UNSUPPORTED_OPERATION and the core A2A surface is byte-identical. `debug`
1834/// additionally exposes internals (conversation transcripts, per-step run
1835/// detail, the live log ring, audit records on the feed) — operator-grade
1836/// information; leave it off in production unless you need it. `origins` lets a
1837/// hosted web UI (a non-loopback browser origin) through the DNS-rebind guard
1838/// with CORS; loopback origins are always accepted.
1839#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1840#[serde(deny_unknown_fields, default)]
1841pub struct Interface {
1842    /// Serve the interface methods (`SubscribeToEvents`, `interface.info`, …).
1843    pub enabled: bool,
1844    /// Expose extra debug information (transcripts, run step detail, the log
1845    /// ring, audit feed events). Clients render their debug panes only when
1846    /// this is on. Runtime-togglable over the wire via `config.set` (operator).
1847    pub debug: bool,
1848    /// Extra allowed browser origins (`scheme://host[:port]`, exact match) for
1849    /// a hosted web UI. Loopback origins never need listing.
1850    pub origins: Vec<String>,
1851    /// What the display clients render in their chrome. The daemon decides,
1852    /// so every attached client renders the same layout.
1853    pub display: Display,
1854    /// Pairing-code login: a rotating short code shown to the operator that a
1855    /// client exchanges for a session token — the low-friction alternative to
1856    /// copying a bearer around.
1857    pub pairing: Pairing,
1858}
1859
1860/// The client-chrome layout: ordered item lists for the top (header) and
1861/// bottom (status bar) edges. `None` ⇒ the built-in default. Clients skip an
1862/// item they do not recognise instead of erroring, so a newer daemon can name
1863/// items an older client has never heard of. The vocabulary is
1864/// [`DISPLAY_ITEMS`].
1865#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1866#[serde(deny_unknown_fields, default)]
1867pub struct Display {
1868    pub top: Option<Vec<String>>,
1869    pub bottom: Option<Vec<String>>,
1870}
1871
1872/// The display items a client knows how to render.
1873pub const DISPLAY_ITEMS: &[&str] = &[
1874    "name",     // agent name (card)
1875    "version",  // agentd version
1876    "instance", // instance identity
1877    "model",    // intelligence.model
1878    "endpoint", // the endpoint the client dialed
1879    "conn",     // connection state (live/polling/error)
1880    "debug",    // the debug badge
1881    "draining", // the DRAINING notice
1882    "active",   // active task count
1883    "turns",    // counter
1884    "tokens",   // tokens in/out
1885    "tool_calls",
1886    "runs",          // run count
1887    "subagents",     // subagent count
1888    "conversations", // conversation count
1889    "screen",        // current screen name (tui)
1890    "keys",          // key hints (tui)
1891    "clock",         // local time
1892];
1893
1894/// Pairing-code login. The code is a 6-digit value derived
1895/// from a per-process random seed and the current 60-second window — shown
1896/// only to operators (`pairing.code`), verified with the previous window's
1897/// grace, rate-limited, and exchanged (`Pair`) for a high-entropy session
1898/// token that lives in memory until `ttl` (or restart).
1899#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1900#[serde(deny_unknown_fields, default)]
1901pub struct Pairing {
1902    pub enabled: bool,
1903    /// The role a paired session gets: `operator` (default — whoever can read
1904    /// the code can already see the operator console) or `user`.
1905    pub role: Option<Role>,
1906    /// Session-token lifetime (default 12h).
1907    pub ttl: Option<Dur>,
1908}
1909
1910/// The webhook inbound HTTP surface: a dedicated listener serving the
1911/// `webhook` start nodes and `wait: {on: webhook}` callbacks. Auth is **per
1912/// node** — each `webhook` declares its own verification — so one permissive
1913/// route cannot weaken the others; the listener-wide default set here applies
1914/// only to nodes that declare no `auth` of their own.
1915#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1916#[serde(deny_unknown_fields, default)]
1917pub struct Webhooks {
1918    /// `https://host:port` (loopback `http://` for dev). Required when any
1919    /// `webhook` start node or `wait: {on: webhook}` is used.
1920    pub listen: Option<String>,
1921    pub tls: A2aTls,
1922    /// A default auth applied to `webhook` nodes that declare none.
1923    pub default_auth: Option<WebhookAuth>,
1924}
1925
1926/// A webhook's inbound authentication. Best practice (and the default guidance)
1927/// is HMAC over the raw body; a required-header or bearer match are alternatives;
1928/// `none: true` is an explicit loopback-only dev opt-out.
1929#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1930#[serde(deny_unknown_fields, default)]
1931pub struct WebhookAuth {
1932    /// HMAC signature verification over the raw request body (GitHub/Stripe-style).
1933    pub hmac: Option<Hmac>,
1934    /// A shared bearer token (`Authorization: Bearer …`), constant-time matched.
1935    pub bearer: Option<Secret>,
1936    /// A required header exact-match (`{name, equals}`).
1937    pub header: Option<HeaderMatch>,
1938    /// Loopback-only, no auth (dev). Explicit opt-in.
1939    pub none: bool,
1940}
1941
1942#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1943#[serde(deny_unknown_fields, default)]
1944pub struct Hmac {
1945    pub secret: Option<Secret>,
1946    /// The header carrying the signature (default `X-Signature`).
1947    pub header: Option<String>,
1948    /// Digest algorithm — `sha256` (default; the only supported algorithm).
1949    pub algo: Option<String>,
1950    /// A prefix stripped before the constant-time hex compare (e.g. `sha256=`).
1951    pub prefix: Option<String>,
1952}
1953
1954#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1955#[serde(deny_unknown_fields, default)]
1956pub struct HeaderMatch {
1957    pub name: Option<String>,
1958    pub equals: Option<Secret>,
1959}
1960
1961/// The self-correcting goal watchdog. A supervisor-level periodic check of
1962/// whether the configured `statement` is achieved (or the agent is stuck),
1963/// with a configurable disposition. It runs beside the agent loop and never
1964/// blocks it, so a slow judge cannot stall real work.
1965#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1966#[serde(deny_unknown_fields, default)]
1967pub struct Goal {
1968    /// The goal in natural language (the LLM judge reads it).
1969    pub statement: Option<String>,
1970    pub check: GoalCheck,
1971    /// N consecutive no-progress checks ⇒ self-correct (default 3).
1972    pub stuck_after: Option<u32>,
1973    /// What to do when the goal is achieved (default: `finish`).
1974    pub on_achieved: Option<GoalAction>,
1975    /// What to do when stuck (default: `replan`).
1976    pub on_stuck: Option<GoalAction>,
1977}
1978
1979#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
1980#[serde(deny_unknown_fields, default)]
1981pub struct GoalCheck {
1982    /// The check cadence (default `5m`).
1983    pub every: Option<Dur>,
1984    /// An optional cheap CEL predicate over durable state, evaluated first.
1985    pub condition: Option<String>,
1986    /// `both` (default: CEL then LLM), `condition` (CEL only), or `agent` (LLM only).
1987    pub via: Option<String>,
1988}
1989
1990/// A goal disposition. Deserialized from a bare string
1991/// (`finish`/`idle`/`replan`/`escalate`) or `{ workflow: <name> }`.
1992#[derive(Debug, Clone, PartialEq)]
1993pub enum GoalAction {
1994    Finish,
1995    Idle,
1996    Replan,
1997    Escalate,
1998    Workflow(String),
1999}
2000
2001impl<'de> Deserialize<'de> for GoalAction {
2002    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
2003        use serde::de::Error;
2004        match Value::deserialize(d)? {
2005            Value::String(s) => match s.as_str() {
2006                "finish" => Ok(GoalAction::Finish),
2007                "idle" => Ok(GoalAction::Idle),
2008                "replan" => Ok(GoalAction::Replan),
2009                "escalate" => Ok(GoalAction::Escalate),
2010                other => Err(D::Error::custom(format!(
2011                    "unknown goal action '{other}' (want finish|idle|replan|escalate|{{workflow: <name>}})"
2012                ))),
2013            },
2014            Value::Object(m) => match m.get("workflow").and_then(Value::as_str) {
2015                Some(w) => Ok(GoalAction::Workflow(w.to_string())),
2016                None => Err(D::Error::custom(
2017                    "a goal action object must be { workflow: <name> }",
2018                )),
2019            },
2020            _ => Err(D::Error::custom(
2021                "a goal action must be a string or { workflow: <name> }",
2022            )),
2023        }
2024    }
2025}
2026
2027#[derive(Debug, Clone, Deserialize, PartialEq)]
2028#[serde(deny_unknown_fields)]
2029pub struct Principal {
2030    #[serde(rename = "match")]
2031    pub matcher: PrincipalMatch,
2032    pub role: Role,
2033    #[serde(default)]
2034    pub grants: Vec<String>,
2035    #[serde(default)]
2036    pub quotas: Option<Quotas>,
2037    /// Operator-declared attributes carried with everything this principal
2038    /// causes — into the run, the MCP `_meta` and the audit line.
2039    ///
2040    /// A CLOSED domain on purpose: these become durable governor scope keys
2041    /// and audit fields, and minting them from values arriving off the box is
2042    /// the same unbounded-cardinality hazard the metrics layer already bans
2043    /// for labels, relocated into the manifest.
2044    #[serde(default)]
2045    pub labels: BTreeMap<String, String>,
2046}
2047
2048#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2049#[serde(deny_unknown_fields, default)]
2050pub struct PrincipalMatch {
2051    pub san: Option<String>,
2052    pub sub: Option<String>,
2053    pub bearer_ref: Option<String>,
2054    pub aauth_agent: Option<String>,
2055    pub any: bool,
2056}
2057
2058#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2059#[serde(rename_all = "lowercase")]
2060pub enum Role {
2061    Operator,
2062    User,
2063    Agent,
2064    Anonymous,
2065}
2066
2067#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2068#[serde(deny_unknown_fields, default)]
2069pub struct Quotas {
2070    pub rate: Option<String>,
2071    pub budget: Option<Budget>,
2072}
2073
2074#[derive(Debug, Clone, Deserialize, PartialEq)]
2075#[serde(deny_unknown_fields)]
2076pub struct A2aPeer {
2077    pub name: String,
2078    /// Either a literal URL, or empty when `service:` references a
2079    /// `kind: peer` catalog entry — resolution fills it in before any dial.
2080    #[serde(default)]
2081    pub endpoint: String,
2082    /// Reference a `services:` entry of `kind: peer`: inherit its connection
2083    /// settings (restating `endpoint`/`auth`/`headers` here is refused).
2084    #[serde(default)]
2085    pub service: Option<String>,
2086    #[serde(default)]
2087    pub headers: BTreeMap<String, String>,
2088    #[serde(default)]
2089    pub client_cert: Option<String>,
2090    #[serde(default)]
2091    pub client_key: Option<String>,
2092    /// A unified credential provider for the peer — `static` / `oauth2`
2093    /// (device-login) / `spiffe` (jwt), each resolved to a bearer at dial
2094    /// time, or `aws`, which instead signs every request body individually.
2095    #[serde(default)]
2096    pub auth: Option<Auth>,
2097}
2098
2099#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2100#[serde(deny_unknown_fields, default)]
2101pub struct Observability {
2102    pub log_level: Option<String>,
2103    pub log_content: bool,
2104    pub otel: Otel,
2105    pub metrics_addr: Option<String>,
2106    pub health_file: Option<String>,
2107    pub report_file: Option<String>,
2108    pub events_ring: Option<u32>,
2109    pub audit: Audit,
2110    pub traceparent: Option<String>,
2111    /// Mirror a selected subset of the daemon's own event vocabulary onto a
2112    /// declared stream, so the runtime can react to itself: a tripped breaker,
2113    /// a shed admission or an unhealthy child becomes an ordinary start node.
2114    pub runtime_events: Option<RuntimeEvents>,
2115}
2116
2117/// Which of the daemon's own events reach a stream, and at what rate.
2118#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2119#[serde(deny_unknown_fields, default)]
2120pub struct RuntimeEvents {
2121    /// The declared stream events land on. Required.
2122    pub stream: Option<String>,
2123    /// Families taken in full (the segment before the first dot in an event
2124    /// name). Validated against the closed vocabulary at startup.
2125    pub include: Vec<String>,
2126    /// Families taken at a sampled rate. A high-rate family cannot share a
2127    /// list with a once-a-week one: `pressure.shed` arrives in storms exactly
2128    /// when the disk it would be written to is the thing under pressure.
2129    pub sampled: Vec<String>,
2130    /// How many events may queue between ticks before the tap starts dropping
2131    /// (and counting). Default [`DEFAULT_TAP_QUEUE`].
2132    pub queue: Option<u32>,
2133}
2134
2135/// The default bound on the runtime-event queue.
2136pub const DEFAULT_TAP_QUEUE: u32 = 512;
2137
2138impl RuntimeEvents {
2139    pub fn queue_cap(&self) -> usize {
2140        self.queue.unwrap_or(DEFAULT_TAP_QUEUE) as usize
2141    }
2142}
2143
2144#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2145#[serde(deny_unknown_fields, default)]
2146pub struct Otel {
2147    pub endpoint: Option<String>,
2148    pub traces: Option<bool>,
2149    pub metrics: Option<bool>,
2150    pub logs: Option<bool>,
2151}
2152
2153#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2154#[serde(deny_unknown_fields, default)]
2155pub struct Audit {
2156    pub sink: Option<Vec<AuditSink>>,
2157    /// The declared stream `sink: [stream]` appends to. Required with that
2158    /// sink; audit records are otherwise written and then unreadable, since
2159    /// `Kind::Audit` is deliberately not manifest-indexed.
2160    pub stream: Option<String>,
2161}
2162
2163#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
2164#[serde(rename_all = "lowercase")]
2165pub enum AuditSink {
2166    Log,
2167    Store,
2168    /// Append to a declared stream — the supported path off the box, and the
2169    /// one sink a workflow can consume: compliance evidence becomes a
2170    /// scheduled run that reads a window and ships it, with no evidence
2171    /// subsystem in the binary.
2172    Stream,
2173}
2174
2175#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2176#[serde(deny_unknown_fields, default)]
2177pub struct Security {
2178    pub allow_trifecta: bool,
2179    pub tls_ca: Option<String>,
2180    pub aauth: Option<AAuth>,
2181    pub cgroup: Cgroup,
2182    pub exec: Exec,
2183    pub workflows: WorkflowSecurity,
2184    /// `closed` ⇒ an outbound MCP dial whose URL matches no `services:`
2185    /// catalog entry is refused: at boot for configured servers, and at dial
2186    /// time for everything else, so a URL assembled at runtime is caught too.
2187    /// Default `open`.
2188    pub egress: Egress,
2189    /// Ordered verdicts on the tool CALL — by name, tag, caller, principal or
2190    /// arguments. First match wins; no match is allow.
2191    ///
2192    /// This is the only place an argument can be judged: grants are name
2193    /// patterns, so "delete anything outside /tmp" has no expression in them,
2194    /// and `agent.approval` only decides whether to honour a gate the MODEL
2195    /// asked for. It is also where the trifecta tags finally do work at
2196    /// runtime rather than only folding at startup.
2197    pub policies: Vec<Policy>,
2198}
2199
2200/// One rule: what it matches, and what happens.
2201#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2202#[serde(deny_unknown_fields, default)]
2203pub struct Policy {
2204    #[serde(rename = "match")]
2205    pub matcher: PolicyMatch,
2206    pub action: PolicyAction,
2207    /// The question put to a person for `action: ask`. Templated with
2208    /// `{{tool}}`, `{{caller}}` and `{{args}}`.
2209    pub question: Option<String>,
2210    /// What an unanswered `ask` becomes. Default `deny` — a gate nobody
2211    /// answered has not been approved.
2212    pub on_timeout: Option<PolicyAction>,
2213    pub timeout: Option<Dur>,
2214}
2215
2216#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2217#[serde(deny_unknown_fields, default)]
2218pub struct PolicyMatch {
2219    /// Tool-name glob (`fs.*`). Absent matches every tool.
2220    pub tool: Option<String>,
2221    /// Every listed trifecta tag must be present on the tool.
2222    pub tags: Vec<String>,
2223    /// Which callers this applies to: `root`, `workflow`, `subagent`.
2224    pub caller: Vec<PolicyCaller>,
2225    /// Principal-id glob, for calls carrying one.
2226    pub principal: Option<String>,
2227    /// A CEL predicate over `args`, `tool` and `caller`. Needs the `cel`
2228    /// feature; a build without it refuses the config rather than evaluating
2229    /// an argument guard to "no match".
2230    pub args: Option<String>,
2231}
2232
2233#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
2234#[serde(rename_all = "lowercase")]
2235pub enum PolicyCaller {
2236    Root,
2237    Workflow,
2238    Subagent,
2239}
2240
2241#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
2242#[serde(rename_all = "lowercase")]
2243pub enum PolicyAction {
2244    #[default]
2245    Allow,
2246    Deny,
2247    /// Suspend on a human gate; the answer decides. Rides the same deferred
2248    /// path `ask_human` and the `human` node already use, so a policy gate
2249    /// renders as an answerable row in every attached client and survives a
2250    /// restart.
2251    Ask,
2252    /// Refuse, but say plainly that the call was held rather than run. NOT a
2253    /// synthetic success: a schema-conformant fake result is reasoned over as
2254    /// real, and every later decision is then built on a fabricated
2255    /// observation — a strange thing for a fail-closed runtime to ship.
2256    Shadow,
2257}
2258
2259/// Whether outbound dials are confined to the service catalog.
2260#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
2261#[serde(rename_all = "lowercase")]
2262pub enum Egress {
2263    #[default]
2264    Open,
2265    Closed,
2266}
2267
2268/// Whether the agent may rewrite its own workflows.
2269#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2270#[serde(deny_unknown_fields, default)]
2271pub struct WorkflowSecurity {
2272    /// Refuse `workflow.create` / `.update` / `.delete` at runtime.
2273    ///
2274    /// Workflows are the agent's *standing instructions* — what it does when a
2275    /// schedule fires or a webhook lands, unattended. An agent that can rewrite
2276    /// them can quietly change what happens next time, and the change survives
2277    /// the conversation that caused it. Anywhere a definition is reviewed
2278    /// before it ships — a file in git, a config a deploy applies — self-update
2279    /// is not a feature, it is a hole in that review.
2280    ///
2281    /// Off by default, because the runtime-created workflow is a real workflow
2282    /// (`docs/workflows.md`); turn it on and definitions become read-only, from
2283    /// the config and the store, for everyone: the model, a subagent, and an
2284    /// operator over A2A alike. Loading is unaffected — the daemon still reads
2285    /// files, URLs and directories at startup.
2286    pub immutable: bool,
2287}
2288
2289/// The local command-runner controls. agentd's default posture is **no local
2290/// execution**, so this stays off unless an operator both builds with
2291/// `--features exec` AND sets `enabled: true` — two independent switches, so
2292/// neither a config mistake nor a stock binary can turn it on alone. Even then
2293/// it runs only allow-listed commands, in a confined directory, with a minimal
2294/// env. Without the local runner the `exec` tool is **mapping-only**: it can
2295/// be delegated off-box via `tools.overrides`. It carries the `sensitive` +
2296/// `egress` trifecta tags, so enabling it narrows what else the agent may
2297/// compose with.
2298#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2299#[serde(deny_unknown_fields, default)]
2300pub struct Exec {
2301    /// Enable a LOCAL runner. Requires the `exec` build feature too; default OFF.
2302    pub enabled: bool,
2303    /// Allow-listed command names (`argv[0]`); anything else is refused. Empty =
2304    /// deny all (so `enabled` alone runs nothing).
2305    pub allow: Vec<String>,
2306    /// The directory commands run in; a requested `cwd` must resolve inside it.
2307    pub workdir: Option<String>,
2308    /// Max wall-clock per command (a longer requested `timeout` is clamped). 30s.
2309    pub timeout: Option<Dur>,
2310    /// Cap on captured stdout+stderr bytes (default 1 MiB).
2311    pub max_output: Option<u64>,
2312    /// Environment variable NAMES passed through to the child (default none — a
2313    /// minimal env; the agent's own env/secrets are never inherited).
2314    pub env: Vec<String>,
2315}
2316
2317#[derive(Debug, Clone, Deserialize, PartialEq)]
2318#[serde(deny_unknown_fields)]
2319pub struct AAuth {
2320    pub provider: String,
2321    #[serde(default)]
2322    pub key_file: Option<String>,
2323    #[serde(default)]
2324    pub enroll_token: Option<Secret>,
2325    #[serde(default)]
2326    pub enroll_assertion_file: Option<String>,
2327    #[serde(default)]
2328    pub person_server: Option<String>,
2329}
2330
2331#[derive(Debug, Clone, Default, Deserialize, PartialEq)]
2332#[serde(deny_unknown_fields, default)]
2333pub struct Cgroup {
2334    pub spec: Option<String>,
2335    pub memory_max: Option<String>,
2336    pub pids_max: Option<String>,
2337}
2338
2339/// One `{{…}}` reference found by [`scan_references`].
2340#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
2341pub struct FoundRef {
2342    /// `secret` | `secret-file` | `config`.
2343    pub kind: &'static str,
2344    pub name: String,
2345    /// Where it sat (a dotted path into the document).
2346    pub at: String,
2347}
2348
2349/// Collect every `{{secret:…}}`, `{{secret-file:…}}` and `{{config.…}}`
2350/// reference in `value`, with where each sits.
2351///
2352/// This exists so a deployment can be checked in ONE pass: the alternative —
2353/// failing on whichever reference happens to be evaluated first — turns
2354/// configuring a new instance into a guessing game played one restart at a
2355/// time, entering values one failure per attempt.
2356pub fn scan_references(value: &Value, at: &str, out: &mut Vec<FoundRef>) {
2357    match value {
2358        Value::String(s) => {
2359            let mut rest = s.as_str();
2360            while let Some(open) = rest.find("{{") {
2361                let after = &rest[open + 2..];
2362                let Some(close) = after.find("}}") else { break };
2363                let token = after[..close].trim();
2364                if let Some(n) = token.strip_prefix("secret:") {
2365                    out.push(FoundRef {
2366                        kind: "secret",
2367                        name: n.trim().into(),
2368                        at: at.into(),
2369                    });
2370                } else if let Some(p) = token.strip_prefix("secret-file:") {
2371                    out.push(FoundRef {
2372                        kind: "secret-file",
2373                        name: p.trim().into(),
2374                        at: at.into(),
2375                    });
2376                } else if let Some(c) = token.strip_prefix("config.") {
2377                    out.push(FoundRef {
2378                        kind: "config",
2379                        name: c.trim().into(),
2380                        at: at.into(),
2381                    });
2382                }
2383                rest = &after[close + 2..];
2384            }
2385        }
2386        Value::Array(a) => {
2387            for (i, v) in a.iter().enumerate() {
2388                scan_references(v, &format!("{at}[{i}]"), out);
2389            }
2390        }
2391        Value::Object(o) => {
2392            for (k, v) in o {
2393                scan_references(v, &format!("{at}.{k}"), out);
2394            }
2395        }
2396        _ => {}
2397    }
2398}
2399
2400/// Every `hmac.algo` in a document, with where it sits.
2401///
2402/// A whole-document walk rather than a typed lookup, because the field appears
2403/// in two unrelated places — `webhooks.default_auth.hmac` and each webhook
2404/// node's own `auth.hmac` — and a workflow's steps are untyped `Value`s at this
2405/// point. A definition arriving from `file:`/`url:` is not in the document and
2406/// is caught at listener build instead.
2407pub fn hmac_algos(value: &Value, at: &str) -> Vec<(String, String)> {
2408    let mut out = Vec::new();
2409    fn walk(v: &Value, at: &str, out: &mut Vec<(String, String)>) {
2410        match v {
2411            Value::Object(o) => {
2412                for (k, child) in o {
2413                    if k == "hmac"
2414                        && let Some(a) = child.get("algo").and_then(Value::as_str)
2415                    {
2416                        out.push((format!("{at}.hmac.algo"), a.to_string()));
2417                    }
2418                    walk(child, &format!("{at}.{k}"), out);
2419                }
2420            }
2421            Value::Array(a) => {
2422                for (i, child) in a.iter().enumerate() {
2423                    walk(child, &format!("{at}[{i}]"), out);
2424                }
2425            }
2426            _ => {}
2427        }
2428    }
2429    walk(value, at, &mut out);
2430    out
2431}
2432
2433/// The references in `value` that would NOT resolve right now — secrets against
2434/// the environment (and any interactively-entered values), secret-files against
2435/// the filesystem, `config.*` against `vars`. One message per missing
2436/// reference, deduplicated, every location listed.
2437pub fn missing_references(value: &Value, at: &str, vars: &BTreeMap<String, Value>) -> Vec<String> {
2438    let mut found = Vec::new();
2439    scan_references(value, at, &mut found);
2440    let mut by_ref: BTreeMap<(&'static str, String), Vec<String>> = BTreeMap::new();
2441    for r in found {
2442        let missing = match r.kind {
2443            "secret" => !crate::sec::secret::secret_available(&r.name),
2444            "secret-file" => std::fs::metadata(&r.name).is_err(),
2445            "config" => {
2446                let mut parts = r.name.split('.');
2447                let mut cur = parts.next().and_then(|p| vars.get(p));
2448                for p in parts {
2449                    cur = cur.and_then(|v| v.get(p));
2450                }
2451                cur.is_none()
2452            }
2453            _ => false,
2454        };
2455        if missing {
2456            by_ref.entry((r.kind, r.name)).or_default().push(r.at);
2457        }
2458    }
2459    by_ref
2460        .into_iter()
2461        .map(|((kind, name), ats)| {
2462            let what = match kind {
2463                "secret" => format!("{{{{secret:{name}}}}} is not set in the environment"),
2464                "secret-file" => format!("{{{{secret-file:{name}}}}} is not readable"),
2465                _ => format!("config.{name} is not defined in vars"),
2466            };
2467            format!("{what} (referenced at {})", ats.join(", "))
2468        })
2469        .collect()
2470}
2471
2472/// Substitute `{{config.NAME}}` tokens in `value` from `vars`, appending every
2473/// unresolved reference to `errs` (with `at` naming where it sat).
2474///
2475/// A string that IS exactly one token takes the variable's typed value — a
2476/// number stays a number. A token embedded in a longer string is stringified
2477/// into place. There is no escape syntax: an unresolved reference is an error
2478/// rather than a literal, because a URL that still contains `{{config.region}}`
2479/// at runtime is a bug wherever it was headed.
2480pub fn substitute_config_vars(
2481    value: &mut Value,
2482    vars: &BTreeMap<String, Value>,
2483    at: &str,
2484    errs: &mut Vec<String>,
2485) {
2486    fn lookup<'a>(vars: &'a BTreeMap<String, Value>, path: &str) -> Option<&'a Value> {
2487        let mut parts = path.split('.');
2488        let mut cur = vars.get(parts.next()?)?;
2489        for p in parts {
2490            cur = cur.get(p)?;
2491        }
2492        Some(cur)
2493    }
2494    fn token_at(s: &str, from: usize) -> Option<(usize, usize, String)> {
2495        let start = s[from..].find("{{config.")? + from;
2496        let end = s[start..].find("}}")? + start + 2;
2497        let name = s[start + 9..end - 2].trim().to_string();
2498        Some((start, end, name))
2499    }
2500    match value {
2501        Value::String(s) => {
2502            // The whole string is one token: keep the value's TYPE.
2503            if let Some((0, end, name)) = token_at(s, 0)
2504                && end == s.len()
2505            {
2506                match lookup(vars, &name) {
2507                    Some(v) => *value = v.clone(),
2508                    None => errs.push(format!("{at}: config.{name} is not defined in vars")),
2509                }
2510                return;
2511            }
2512            let mut out = String::new();
2513            let mut pos = 0;
2514            while let Some((start, end, name)) = token_at(s, pos) {
2515                out.push_str(&s[pos..start]);
2516                match lookup(vars, &name) {
2517                    Some(Value::String(v)) => out.push_str(v),
2518                    Some(v) => out.push_str(&v.to_string()),
2519                    None => {
2520                        errs.push(format!("{at}: config.{name} is not defined in vars"));
2521                        out.push_str(&s[start..end]);
2522                    }
2523                }
2524                pos = end;
2525            }
2526            if pos > 0 {
2527                out.push_str(&s[pos..]);
2528                *s = out;
2529            }
2530        }
2531        Value::Array(a) => {
2532            for (i, v) in a.iter_mut().enumerate() {
2533                substitute_config_vars(v, vars, &format!("{at}[{i}]"), errs);
2534            }
2535        }
2536        Value::Object(o) => {
2537            for (k, v) in o.iter_mut() {
2538                substitute_config_vars(v, vars, &format!("{at}.{k}"), errs);
2539            }
2540        }
2541        _ => {}
2542    }
2543}
2544
2545impl Settings {
2546    /// Type a settings document. `source` names it in errors.
2547    ///
2548    /// `{{config.*}}` substitution happens here, before typing — so a var can
2549    /// sit anywhere a string can: an endpoint, a path, a header. The
2550    /// `workflows` array is deliberately left alone; workflow documents are
2551    /// substituted at LOAD time instead (`load_workflows`), where the ones
2552    /// arriving from files, URLs and directories can be treated identically to
2553    /// inline ones.
2554    pub fn from_document(mut doc: Value, source: &str) -> Result<Settings, String> {
2555        let vars: BTreeMap<String, Value> = doc
2556            .get("vars")
2557            .and_then(Value::as_object)
2558            .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
2559            .unwrap_or_default();
2560        let workflows = doc.as_object_mut().and_then(|o| o.remove("workflows"));
2561        let mut errs = Vec::new();
2562        substitute_config_vars(&mut doc, &vars, source, &mut errs);
2563        if let (Some(o), Some(w)) = (doc.as_object_mut(), workflows) {
2564            o.insert("workflows".into(), w);
2565        }
2566        if !errs.is_empty() {
2567            return Err(format!(
2568                "{} unresolved config var reference(s):\n  {}",
2569                errs.len(),
2570                errs.join("\n  ")
2571            ));
2572        }
2573        // Colon-fence directives in the instruction (operator-authored text —
2574        // this is the ONLY surface extraction runs on; conversation text is
2575        // never parsed). `:::workflow` bodies join `workflows:` exactly as
2576        // inline entries — same folding, validation, hashing, retirement —
2577        // and the model reads the CLEANED text, where each block became a
2578        // one-line note instead of machinery it might paraphrase. Extraction
2579        // runs BEFORE deserialization because the config-defining blocks
2580        // (`:::config`/`:::mcp`/`:::stream`/`:::tools`) contribute a fragment
2581        // that merges UNDER the explicit document — an instruction file alone
2582        // can define the whole agent, and an explicit key still wins.
2583        let mut extraction = None;
2584        if let Some(instr) = doc
2585            .get("agent")
2586            .and_then(|a| a.get("instruction"))
2587            .and_then(Value::as_str)
2588            .map(str::to_string)
2589            && !looks_like_resource_uri(&instr)
2590            && instr.lines().any(|l| l.starts_with(":::"))
2591        {
2592            match crate::config::directives::extract(&instr) {
2593                Ok(ex) => {
2594                    // (`{{config.*}}` inside a block already resolved: the doc
2595                    // passed substitute_config_vars with the instruction in it.
2596                    // A nameless block is refused by parse_workflow, the one
2597                    // authority on that message.)
2598                    if let Some(a) = doc.get_mut("agent").and_then(Value::as_object_mut) {
2599                        a.insert("instruction".into(), Value::String(ex.cleaned.clone()));
2600                    }
2601                    if let (Some(o), Value::Object(fragment)) =
2602                        (doc.as_object_mut(), ex.config.clone())
2603                    {
2604                        crate::config::directives::merge_missing(o, fragment, false);
2605                    }
2606                    extraction = Some(ex);
2607                }
2608                Err(errs) => {
2609                    return Err(format!(
2610                        "{source}: agent.instruction directives:
2611  {}",
2612                        errs.join(
2613                            "
2614  "
2615                        )
2616                    ));
2617                }
2618            }
2619        }
2620        let mut settings: Settings =
2621            serde_json::from_value(doc).map_err(|e| format!("{source} parse error: {e}"))?;
2622        if let Some(ex) = extraction {
2623            settings.agent.inline_skills = ex.skills;
2624            settings.workflows.extend(ex.workflows);
2625        }
2626        Ok(settings)
2627    }
2628
2629    /// The `agent.name` fallback chain: config › downward-API instance ›
2630    /// hostname › `agentd`.
2631    pub fn instance_name(&self) -> String {
2632        if let Some(n) = &self.agent.name {
2633            return n.clone();
2634        }
2635        let id =
2636            crate::identity::Identity::from_env(self.lifecycle.run_id.as_deref().unwrap_or(""));
2637        if let Some(inst) = id.instance.filter(|i| !i.trim().is_empty()) {
2638            return inst;
2639        }
2640        std::env::var("HOSTNAME")
2641            .ok()
2642            .filter(|h| !h.trim().is_empty())
2643            .unwrap_or_else(|| "agentd".to_string())
2644    }
2645
2646    /// Whether this instance OUTLIVES a single run — it serves A2A or webhooks,
2647    /// watches a goal, or owns a workflow with a long-lived start node
2648    /// (`loop`/`schedule`/`subscribe`/`signal`/`event`/`a2a`/`webhook`).
2649    ///
2650    /// This is the durability predicate: a job-shaped run can lose its state
2651    /// and simply be re-run, while an instance that keeps running cannot. Two
2652    /// callers need exactly the same answer — [`load`], which defaults such an
2653    /// instance to the file store, and [`validate`], which refuses an EXPLICIT
2654    /// `store.kind: none` here — so the predicate lives in one place instead of
2655    /// being spelled out twice and diverging.
2656    pub fn is_long_lived(&self) -> bool {
2657        self.a2a.listen.is_some()
2658            || self.webhooks.listen.is_some()
2659            || self.goal.is_some()
2660            || self.workflows.iter().any(workflow_is_long_lived)
2661    }
2662}
2663
2664// ---------------------------------------------------------------------------
2665// Detection
2666// ---------------------------------------------------------------------------
2667
2668/// Top-level keys only the settings document has. `limits` is deliberately
2669/// absent: it exists in both schemas, so it decides nothing. `intelligence`
2670/// is absent for a different reason — it is a STRING (the endpoint list) in
2671/// the flat schema but an OBJECT here, so [`detect`] judges it by shape.
2672pub const V2_KEYS: &[&str] = &[
2673    "agent",
2674    "store",
2675    "workflows",
2676    "tools",
2677    "a2a",
2678    "lifecycle",
2679    "observability",
2680    "security",
2681    "knowledge",
2682    "search",
2683    "skills",
2684    "memory",
2685    "context",
2686    "vars",
2687    "streams",
2688];
2689
2690/// v1 (flat) top-level keys.
2691pub const V1_KEYS: &[&str] = &[
2692    "intelligence_headers",
2693    "model_swap",
2694    "model",
2695    "max_tokens",
2696    "mcp_servers",
2697    "subscribe",
2698    "a2a_peers",
2699    "log_level",
2700];
2701
2702#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2703pub enum Detected {
2704    /// No document at all (no config files).
2705    Empty,
2706    /// The v1 flat schema.
2707    V1,
2708    /// The v2 nested schema.
2709    V2,
2710    /// Both key families present — refused.
2711    Mixed,
2712}
2713
2714/// Decide which schema a merged document speaks.
2715pub fn detect(doc: &Value) -> Detected {
2716    let Some(obj) = doc.as_object() else {
2717        return Detected::Empty;
2718    };
2719    if obj.is_empty() {
2720        return Detected::Empty;
2721    }
2722    let version = obj.get("config_version").and_then(Value::as_str);
2723    let intel_is_object = obj.get("intelligence").is_some_and(Value::is_object);
2724    let intel_is_string = obj.get("intelligence").is_some_and(Value::is_string);
2725    let has_v2 = version == Some(schema::CONFIG_VERSION)
2726        || intel_is_object
2727        || obj.keys().any(|k| V2_KEYS.contains(&k.as_str()));
2728    let has_v1 = intel_is_string
2729        || obj.keys().any(|k| V1_KEYS.contains(&k.as_str()))
2730        || matches!(version, Some(v) if v != schema::CONFIG_VERSION);
2731    match (has_v1, has_v2) {
2732        (true, true) => Detected::Mixed,
2733        (false, true) => Detected::V2,
2734        (true, false) => Detected::V1,
2735        // Only `config_version` absent + neither family (e.g. `{}` with a
2736        // comment) or `intelligence`… every key was matched above; anything
2737        // else is a v1 document for the v1 loader to judge.
2738        (false, false) => Detected::V1,
2739    }
2740}
2741
2742// ---------------------------------------------------------------------------
2743// Aliases
2744// ---------------------------------------------------------------------------
2745
2746/// How a named flag maps onto the document.
2747#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2748pub enum AliasKind {
2749    /// `--flag <value>` sets `path` (typed by the schema binding of `path`).
2750    Set,
2751    /// `--flag` (no value) sets `path` to `true`.
2752    SetTrue,
2753    /// `--flag <value>` appends a parsed element to the array at `path`.
2754    Append,
2755    /// `--flag <value>` reads the FILE at `<value>` and sets `path` to its text.
2756    SetFromFile,
2757    /// Handled by dedicated code (`--mcp-tags`, `--budget-exit-code`).
2758    Special,
2759}
2760
2761/// A named flag → document-path alias.
2762#[derive(Debug, Clone, Copy)]
2763pub struct Alias {
2764    pub flag: &'static str,
2765    pub path: &'static str,
2766    pub kind: AliasKind,
2767}
2768
2769/// The alias table. The order of entries is irrelevant — flags take effect in
2770/// the order they appear on the command line, not the order they are listed
2771/// here.
2772pub const ALIASES: &[Alias] = &[
2773    Alias {
2774        flag: "--instruction",
2775        path: "agent.instruction",
2776        kind: AliasKind::Set,
2777    },
2778    Alias {
2779        flag: "--instruction-file",
2780        path: "agent.instruction",
2781        kind: AliasKind::SetFromFile,
2782    },
2783    Alias {
2784        flag: "--prompt",
2785        path: "agent.prompt",
2786        kind: AliasKind::Set,
2787    },
2788    Alias {
2789        flag: "--prompt-file",
2790        path: "agent.prompt",
2791        kind: AliasKind::SetFromFile,
2792    },
2793    Alias {
2794        flag: "--intelligence",
2795        path: "intelligence.endpoints",
2796        kind: AliasKind::Set,
2797    },
2798    Alias {
2799        flag: "--intelligence-token",
2800        path: "intelligence.token",
2801        kind: AliasKind::Set,
2802    },
2803    Alias {
2804        flag: "--intelligence-token-file",
2805        path: "intelligence.token_file",
2806        kind: AliasKind::Set,
2807    },
2808    Alias {
2809        flag: "--model",
2810        path: "intelligence.model",
2811        kind: AliasKind::Set,
2812    },
2813    Alias {
2814        flag: "--model-swap",
2815        path: "intelligence.swap_policy",
2816        kind: AliasKind::Set,
2817    },
2818    Alias {
2819        flag: "--budget-tokens-lifetime",
2820        path: "intelligence.budget.lifetime_tokens",
2821        kind: AliasKind::Set,
2822    },
2823    Alias {
2824        flag: "--mcp",
2825        path: "mcp.servers",
2826        kind: AliasKind::Append,
2827    },
2828    Alias {
2829        flag: "--mcp-tags",
2830        path: "mcp.servers",
2831        kind: AliasKind::Special,
2832    },
2833    Alias {
2834        flag: "--a2a-peer",
2835        path: "a2a.peers",
2836        kind: AliasKind::Append,
2837    },
2838    Alias {
2839        flag: "--workflow",
2840        path: "workflows",
2841        kind: AliasKind::Append,
2842    },
2843    Alias {
2844        flag: "--max-steps",
2845        path: "limits.run.steps",
2846        kind: AliasKind::Set,
2847    },
2848    Alias {
2849        flag: "--max-tokens",
2850        path: "limits.run.tokens",
2851        kind: AliasKind::Set,
2852    },
2853    Alias {
2854        flag: "--deadline",
2855        path: "limits.run.deadline",
2856        kind: AliasKind::Set,
2857    },
2858    Alias {
2859        flag: "--max-depth",
2860        path: "limits.subagents.depth",
2861        kind: AliasKind::Set,
2862    },
2863    Alias {
2864        flag: "--run-id",
2865        path: "lifecycle.run_id",
2866        kind: AliasKind::Set,
2867    },
2868    Alias {
2869        flag: "--drain-timeout",
2870        path: "lifecycle.drain_timeout",
2871        kind: AliasKind::Set,
2872    },
2873    Alias {
2874        flag: "--watch-config",
2875        path: "lifecycle.watch_config",
2876        kind: AliasKind::SetTrue,
2877    },
2878    Alias {
2879        flag: "--budget-exit-code",
2880        path: "lifecycle.exit_code_map",
2881        kind: AliasKind::Special,
2882    },
2883    Alias {
2884        flag: "--listen",
2885        path: "a2a.listen",
2886        kind: AliasKind::Set,
2887    },
2888    Alias {
2889        flag: "--serve-mcp",
2890        path: "a2a.listen",
2891        kind: AliasKind::Set,
2892    },
2893    Alias {
2894        flag: "--serve-cert",
2895        path: "a2a.tls.cert",
2896        kind: AliasKind::Set,
2897    },
2898    Alias {
2899        flag: "--serve-key",
2900        path: "a2a.tls.key",
2901        kind: AliasKind::Set,
2902    },
2903    Alias {
2904        flag: "--serve-client-ca",
2905        path: "a2a.tls.client_ca",
2906        kind: AliasKind::Set,
2907    },
2908    Alias {
2909        flag: "--serve-bearer",
2910        path: "a2a.bearer",
2911        kind: AliasKind::Set,
2912    },
2913    Alias {
2914        flag: "--log-level",
2915        path: "observability.log_level",
2916        kind: AliasKind::Set,
2917    },
2918    Alias {
2919        flag: "--log-content",
2920        path: "observability.log_content",
2921        kind: AliasKind::SetTrue,
2922    },
2923    Alias {
2924        flag: "--metrics-addr",
2925        path: "observability.metrics_addr",
2926        kind: AliasKind::Set,
2927    },
2928    Alias {
2929        flag: "--health-file",
2930        path: "observability.health_file",
2931        kind: AliasKind::Set,
2932    },
2933    Alias {
2934        flag: "--report-file",
2935        path: "observability.report_file",
2936        kind: AliasKind::Set,
2937    },
2938    Alias {
2939        flag: "--events-ring",
2940        path: "observability.events_ring",
2941        kind: AliasKind::Set,
2942    },
2943    Alias {
2944        flag: "--traceparent",
2945        path: "observability.traceparent",
2946        kind: AliasKind::Set,
2947    },
2948    Alias {
2949        flag: "--allow-trifecta",
2950        path: "security.allow_trifecta",
2951        kind: AliasKind::SetTrue,
2952    },
2953    Alias {
2954        flag: "--tls-ca",
2955        path: "security.tls_ca",
2956        kind: AliasKind::Set,
2957    },
2958    Alias {
2959        flag: "--aauth-provider",
2960        path: "security.aauth.provider",
2961        kind: AliasKind::Set,
2962    },
2963    Alias {
2964        flag: "--aauth-key-file",
2965        path: "security.aauth.key_file",
2966        kind: AliasKind::Set,
2967    },
2968    Alias {
2969        flag: "--aauth-enroll-token",
2970        path: "security.aauth.enroll_token",
2971        kind: AliasKind::Set,
2972    },
2973    Alias {
2974        flag: "--aauth-enroll-assertion-file",
2975        path: "security.aauth.enroll_assertion_file",
2976        kind: AliasKind::Set,
2977    },
2978    Alias {
2979        flag: "--aauth-person-server",
2980        path: "security.aauth.person_server",
2981        kind: AliasKind::Set,
2982    },
2983    Alias {
2984        flag: "--cgroup",
2985        path: "security.cgroup.spec",
2986        kind: AliasKind::Set,
2987    },
2988    Alias {
2989        flag: "--cgroup-memory-max",
2990        path: "security.cgroup.memory_max",
2991        kind: AliasKind::Set,
2992    },
2993    Alias {
2994        flag: "--cgroup-pids-max",
2995        path: "security.cgroup.pids_max",
2996        kind: AliasKind::Set,
2997    },
2998];
2999
3000/// Short env names → document paths. The derived `AGENTD_<PATH>` names are the
3001/// primary surface; these are the shorter spellings a quickstart or a k8s
3002/// manifest can use instead. Branded (`AGENTD_`) and neutral (`AGENT_`)
3003/// prefixes both apply, as does the bare name.
3004pub const ENV_ALIASES: &[(&str, &str)] = &[
3005    ("INSTRUCTION", "agent.instruction"),
3006    ("PROMPT", "agent.prompt"),
3007    ("INTELLIGENCE", "intelligence.endpoints"),
3008    ("INTELLIGENCE_TOKEN", "intelligence.token"),
3009    ("INTELLIGENCE_TOKEN_FILE", "intelligence.token_file"),
3010    ("MODEL", "intelligence.model"),
3011    ("MODEL_SWAP", "intelligence.swap_policy"),
3012    ("BUDGET_TOKENS", "intelligence.budget.lifetime_tokens"),
3013    ("MAX_STEPS", "limits.run.steps"),
3014    ("MAX_TOKENS", "limits.run.tokens"),
3015    ("DEADLINE", "limits.run.deadline"),
3016    ("RUN_ID", "lifecycle.run_id"),
3017    ("DRAIN_TIMEOUT", "lifecycle.drain_timeout"),
3018    ("LOG_LEVEL", "observability.log_level"),
3019    ("LOG_CONTENT", "observability.log_content"),
3020    ("METRICS_ADDR", "observability.metrics_addr"),
3021    ("TRACEPARENT", "observability.traceparent"),
3022    ("SERVE_MCP", "a2a.listen"),
3023    ("SERVE_BEARER", "a2a.bearer"),
3024    ("TLS_CA", "security.tls_ca"),
3025    ("ALLOW_TRIFECTA", "security.allow_trifecta"),
3026    ("WATCH_CONFIG", "lifecycle.watch_config"),
3027];
3028
3029/// Flags agentd does not accept, each paired with the hint that replaces it.
3030/// Naming one fails the load with its hint, so a stale command line is a loud
3031/// error rather than a flag that is silently ignored.
3032pub const REMOVED_FLAGS: &[(&str, &str)] = &[
3033    (
3034        "--mode",
3035        "modes are gone: give the workflow a start node (`once` | `loop` | `schedule` | `subscribe` | `signal` | `event` | `a2a` | `manual`) and set `lifecycle.run_until` if needed",
3036    ),
3037    (
3038        "--subscribe",
3039        "use a `subscribe` start node: `{kind: subscribe, server: <name>, uri: <uri>}`",
3040    ),
3041    (
3042        "--continue",
3043        "use a `subscribe` start node with `deliver: wait` (or a warm subagent)",
3044    ),
3045    (
3046        "--interval",
3047        "use a `loop` start node with `interval`, or a `schedule` start node with `every`",
3048    ),
3049    ("--cron", "use a `schedule` start node with `cron`"),
3050    // Clustering has no replacement flag, deliberately: agentd owns no
3051    // coordination protocol. A fleet partitions upstream instead — one
3052    // subscription per replica, or the queue's own lease semantics called from
3053    // a workflow step (docs/scaling.md).
3054    (
3055        "--shard",
3056        "agentd does not partition work; give each replica its own subscription (docs/scaling.md)",
3057    ),
3058    (
3059        "--claim",
3060        "call the queue's own claim/lease tools from a workflow step (docs/scaling.md)",
3061    ),
3062    ("--claim-ttl", "it went with --claim"),
3063    ("--claim-renew-fraction", "it went with --claim"),
3064    (
3065        "--standby",
3066        "there is no standby pool; a worker replica is an ordinary instance with its own subscription",
3067    ),
3068    ("--assign-from", "it went with --standby"),
3069    (
3070        "--workflow-resume",
3071        "automatic: runs resume from the store on restart (`resume_policy` per workflow)",
3072    ),
3073    (
3074        "--workflow-resume-force",
3075        "set `resume_policy: force` on the workflow",
3076    ),
3077];
3078
3079// ---------------------------------------------------------------------------
3080// Load pipeline
3081// ---------------------------------------------------------------------------
3082
3083/// The result of a v2 load: the typed settings plus the documents they came
3084/// from (the merged FILE document is kept for secret-provenance validation and
3085/// for the reload diff).
3086#[derive(Debug, Clone)]
3087pub struct Loaded {
3088    pub settings: Settings,
3089    /// The effective document (files ← env ← flags), what `Settings` typed.
3090    pub doc: Value,
3091    /// The merged FILE layer alone (before env/flags).
3092    pub file_doc: Value,
3093    pub files: Vec<(String, Format)>,
3094    /// Every non-fatal advisory collected during load (surfaced by
3095    /// `--validate-config` and logged at startup).
3096    pub warnings: Vec<String>,
3097}
3098
3099/// What the loader was asked to do besides loading (short-circuits the CLI
3100/// handles).
3101#[derive(Debug, Clone, PartialEq, Eq)]
3102pub enum Ask {
3103    Run,
3104    Help,
3105    Version,
3106    Schema,
3107    WorkflowSchema,
3108    /// `--context-template`: print the built-in system-prompt template, so an
3109    /// override starts from a copy rather than a guess.
3110    ContextTemplate,
3111    Validate,
3112    Capabilities,
3113    /// `--login <target>`: complete the interactive OAuth device flow for a
3114    /// configured endpoint and cache the token.
3115    Login(String),
3116    /// `--logout <target>`: evict a cached credential.
3117    Logout(String),
3118}
3119
3120/// Probe the invocation without side effects: which schema the config files
3121/// speak (`Detected`), so `main` can route to the v2 runtime.
3122pub fn probe(args: &[String], env: &[(String, String)]) -> Result<Detected, ConfigError> {
3123    let env = super::debrand_env(env);
3124    let envmap: HashMap<&str, &str> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
3125    // A flag/env `config_version: "1"` selects the full runtime for a flag-only
3126    // invocation (`agentd --config-version 1 --instruction …`).
3127    let flag_v2 = args
3128        .windows(2)
3129        .any(|w| matches!(w[0].as_str(), "--config-version" | "--config_version") && w[1] == "1")
3130        || args
3131            .iter()
3132            .any(|a| a == "--config-version=1" || a == "--config_version=1")
3133        || envmap
3134            .get("AGENTD_CONFIG_VERSION")
3135            .or_else(|| envmap.get("CONFIG_VERSION"))
3136            .is_some_and(|v| *v == "1");
3137    let paths = super::config_paths_from_map(args, &envmap).paths;
3138    if paths.is_empty() {
3139        return Ok(if flag_v2 {
3140            Detected::V2
3141        } else {
3142            Detected::Empty
3143        });
3144    }
3145    let (doc, _) = file::read_documents_checked(&paths, &|_, _| Ok(())).map_err(usage)?;
3146    let d = detect(&doc);
3147    Ok(match (d, flag_v2) {
3148        (Detected::Empty, true) => Detected::V2,
3149        (Detected::V1, true) => Detected::Mixed,
3150        (d, _) => d,
3151    })
3152}
3153
3154/// Load, layer and validate a v2 document from `args` (excluding the program
3155/// name) and `env`. Returns `(Loaded, Ask)`; `Ask` tells the caller what the
3156/// invocation wants (`--help`, `--config-schema`, `--validate-config`, …).
3157/// Errors are `ConfigError::Usage` (exit 2), before any side effect.
3158pub fn load(args: &[String], env: &[(String, String)]) -> Result<(Loaded, Ask), ConfigError> {
3159    let env = super::debrand_env(env);
3160    let envmap: HashMap<&str, &str> = env.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
3161    let schema = schema::schema();
3162    let bindings = paths::bindings_of(&schema);
3163    let mut warnings = Vec::new();
3164
3165    // --- FILE layer: several files, later wins (JSON Merge Patch) ---
3166    let super::ConfigPaths {
3167        paths: config_paths,
3168        discovered,
3169        ambiguous,
3170    } = super::config_paths_from_map(args, &envmap);
3171    // Two spellings of ONE rung at once: refuse rather than pick. Whichever
3172    // agentd chose, somebody would be editing the other and wondering why
3173    // nothing changed. Only DISCOVERY is ambiguous this way — naming two files
3174    // that happen to be spelled `agentd.yml` and `agentd.yaml` (`--config
3175    // a/agentd.yml --config b/agentd.yaml`) states an order, so layering them
3176    // is legal. Ambiguity is per rung: `agentd.yml` beside `agentd.local.yml`
3177    // is the chain working as designed.
3178    if let Some(e) = ambiguous {
3179        return Err(usage(e));
3180    }
3181    let (file_doc, files) = if config_paths.is_empty() {
3182        (Value::Object(Map::new()), Vec::new())
3183    } else {
3184        file::read_documents_checked(&config_paths, &|doc, source| {
3185            // A v1/mixed file is judged after the merge (a clear migration
3186            // message); a v2 file is typed here so an unknown key names ITS file.
3187            match detect(doc) {
3188                Detected::V2 | Detected::Empty => {
3189                    Settings::from_document(doc.clone(), source).map(|_| ())
3190                }
3191                _ => Ok(()),
3192            }
3193        })
3194        .map_err(usage)?
3195    };
3196    match detect(&file_doc) {
3197        Detected::Mixed => {
3198            return Err(usage(
3199                "config file mixes legacy flat keys (model/subscribe/mcp_servers/…) with settings sections (agent/intelligence/…); \
3200                 migrate the legacy keys (docs/configuration.md §migration)"
3201                    .into(),
3202            ));
3203        }
3204        Detected::V1 => {
3205            return Err(usage(
3206                "config file speaks the retired flat schema; the loader needs `config_version: \"1\"` or settings sections (agent/intelligence/…)".into(),
3207            ));
3208        }
3209        _ => {}
3210    }
3211    // A DISCOVERED config governs an invocation that never named it: `cd` into a
3212    // repo you cloned, type `agentd --prompt …`, and that repo's `agentd.yml`
3213    // decides where your credentials go. Convenience is worth that only while the
3214    // file cannot RELAX a security control, so an unnamed file setting one is
3215    // exit 2 with the file and the setting named. An explicit `--config` keeps
3216    // its full power — naming the file IS the deliberate act, and that is the
3217    // whole distinction being drawn here.
3218    //
3219    // The rule covers the WHOLE chain, not its first rung: every file discovery
3220    // adopted was unnamed, so a machine-local overlay must not be able to relax
3221    // what the project file could not. The check reads the merged document for
3222    // exactly that reason — wherever the relaxation entered, it is refused.
3223    if discovered {
3224        let file = config_paths.join(", ");
3225        let file = file.as_str();
3226        if let Some((_, label)) = DISCOVERY_FORBIDDEN_RELAXATIONS
3227            .iter()
3228            .find(|(ptr, _)| file_doc.pointer(ptr).and_then(Value::as_bool) == Some(true))
3229        {
3230            return Err(usage(format!(
3231                "{file} was discovered, not named, and it sets {label}: a config found in the \
3232                 working directory may not relax a security control. Pass `--config {file}` if \
3233                 you meant to run under that file's grant."
3234            )));
3235        }
3236        // …and whatever else it wired that bears on security is named at startup
3237        // (option (c) of the containment): an adopted dotfile is never silent
3238        // about the endpoints, peers and powers it just chose for this process.
3239        let touched = discovered_security_settings(&file_doc);
3240        if !touched.is_empty() {
3241            warnings.push(format!(
3242                "adopted the discovered config {file} (no --config given); it sets {}",
3243                touched.join(", ")
3244            ));
3245        }
3246    }
3247    let mut doc = file_doc.clone();
3248
3249    // --- ENV layer: derived path names, then the short aliases. A path name
3250    // wins over an alias for the same field, since it names the field exactly
3251    // and cannot be a coincidence. ---
3252    let mut env_doc = Value::Object(Map::new());
3253    for (name, path) in ENV_ALIASES {
3254        let candidates = [
3255            format!("AGENTD_{name}"),
3256            format!("AGENT_{name}"),
3257            (*name).to_string(),
3258        ];
3259        if let Some(raw) = candidates.iter().find_map(|k| envmap.get(k.as_str())) {
3260            let binding = binding_for(&bindings, path)
3261                .ok_or_else(|| usage(format!("internal: alias path {path} not in schema")))?;
3262            let v = binding
3263                .coerce(raw)
3264                .map_err(|e| usage(format!("invalid {}: {e}", candidates[0])))?;
3265            paths::set_path(&mut env_doc, path, v);
3266        }
3267    }
3268    let (derived, _applied) = paths::env_document_in(&bindings, &envmap).map_err(usage)?;
3269    file::merge_into(&mut env_doc, derived);
3270    file::merge_into(&mut doc, env_doc);
3271
3272    // --- FLAG layer: aliases + generic path flags, in argument order ---
3273    let mut ask = Ask::Run;
3274    let mut mcp_tags: Vec<(String, Vec<String>)> = Vec::new();
3275    let mut it = args.iter().peekable();
3276    while let Some(arg) = it.next() {
3277        let a = arg.as_str();
3278        match a {
3279            "-h" | "--help" => ask = Ask::Help,
3280            "-V" | "--version" => ask = Ask::Version,
3281            "--config-schema" | "--config-schema=1" => ask = Ask::Schema,
3282            "--workflow-schema" => ask = Ask::WorkflowSchema,
3283            "--context-template" => ask = Ask::ContextTemplate,
3284            "--validate-config" => ask = Ask::Validate,
3285            "--capabilities" => ask = Ask::Capabilities,
3286            "--login" => {
3287                let t = it
3288                    .next()
3289                    .cloned()
3290                    .ok_or_else(|| usage("--login requires a target (e.g. mcp:<name>)".into()))?;
3291                ask = Ask::Login(t);
3292            }
3293            "--logout" => {
3294                let t = it
3295                    .next()
3296                    .cloned()
3297                    .ok_or_else(|| usage("--logout requires a target (e.g. mcp:<name>)".into()))?;
3298                ask = Ask::Logout(t);
3299            }
3300            "--config" | "-c" => {
3301                it.next(); // consumed by the FILE layer
3302            }
3303            // `--config=a.yaml` / `-c=a.yaml`: the FILE layer already took it.
3304            _ if matches!(
3305                crate::config::config_flag(a),
3306                crate::config::ConfigFlag::Inline(_)
3307            ) => {}
3308            _ => {
3309                if let Some((flag, hint)) = REMOVED_FLAGS.iter().find(|(f, _)| *f == a) {
3310                    return Err(usage(format!("{flag} was removed in agentd: {hint}")));
3311                }
3312                if let Some(alias) = ALIASES.iter().find(|al| al.flag == a) {
3313                    apply_alias(&mut doc, &bindings, alias, &mut it, &mut mcp_tags)?;
3314                    continue;
3315                }
3316                match paths::resolve_flag_in(&bindings, a).map_err(usage)? {
3317                    Some(target) => {
3318                        let raw = if matches!(target.value_kind(), paths::Kind::Boolean)
3319                            && !it.peek().is_some_and(|n| !n.starts_with("--"))
3320                        {
3321                            "true".to_string()
3322                        } else {
3323                            it.next()
3324                                .cloned()
3325                                .ok_or_else(|| usage(format!("{a} requires a value")))?
3326                        };
3327                        let value = paths::coerce(target.value_kind(), &raw)
3328                            .map_err(|e| usage(format!("invalid {a}: {e}")))?;
3329                        file::merge_into(&mut doc, target.document(value));
3330                    }
3331                    None => return Err(usage(format!("unknown argument: {a}"))),
3332                }
3333            }
3334        }
3335    }
3336    // `--mcp-tags name=tags` after every `--mcp` is known.
3337    for (name, tags) in mcp_tags {
3338        let Some(servers) = doc
3339            .pointer_mut("/mcp/servers")
3340            .and_then(Value::as_array_mut)
3341        else {
3342            return Err(usage(format!(
3343                "--mcp-tags references unknown server '{name}'"
3344            )));
3345        };
3346        match servers
3347            .iter_mut()
3348            .find(|s| s.get("name").and_then(Value::as_str) == Some(name.as_str()))
3349        {
3350            Some(s) => {
3351                s["tags"] = json!({ "*": tags });
3352            }
3353            None => {
3354                return Err(usage(format!(
3355                    "--mcp-tags references unknown server '{name}'"
3356                )));
3357            }
3358        }
3359    }
3360
3361    // --- conventional folders beside the config ---
3362    //
3363    // Runs BEFORE the instruction sugar on purpose: a project with a
3364    // `workflows/` folder has declared its machinery, and the sugar `main` loop
3365    // is for the case where nothing did.
3366    apply_default_folders(&mut doc, &config_dirs(&config_paths), &mut warnings);
3367
3368    // --- sugar: `agentd --instruction X` with no workflows ---
3369    if ask == Ask::Run || ask == Ask::Validate {
3370        apply_instruction_sugar(&mut doc);
3371    }
3372
3373    // --- env substitution: `${VAR}` / `${VAR:-default}` in any string value of
3374    //     the merged document (config + workflows), from the process env. Distinct
3375    //     from `{{secret:…}}` (which resolves a redacted credential). ---
3376    if let Err(e) = substitute_env(&mut doc, &envmap) {
3377        return Err(usage(e));
3378    }
3379
3380    // --- type + validate ---
3381    let mut settings = Settings::from_document(doc.clone(), "config").map_err(usage)?;
3382    // --- durability a laptop already satisfies ---
3383    //
3384    // A long-lived instance that names no store gets the FILE adapter: durable
3385    // to whatever filesystem it lands on, with the runtime logging exactly that
3386    // at startup (`store.file`) rather than implying more. Demanding a
3387    // coordination backend before the operator has run anything would make the
3388    // first honest deployment the hardest one.
3389    //
3390    // "Absent" is read off the effective DOCUMENT, not off `settings.store.kind`
3391    // — `StoreKind` derives `Default = None`, so the typed value cannot tell a
3392    // config that said nothing from one that said `none`. `doc` is the merged
3393    // file ← env ← flag layers, so `--store-kind none` / `AGENTD_STORE_KIND=none`
3394    // count as explicit exactly like the YAML key does. That distinction is the
3395    // whole point: an operator who WROTE `none` on a long-lived instance still
3396    // gets the diagnostic (validate, below), because silently overriding a
3397    // stated choice is worse than refusing to start.
3398    //
3399    // A one-shot instance is deliberately untouched and keeps `none`: a job that
3400    // suddenly began writing state to disk would surprise every existing user of
3401    // it, and re-running it is already the recovery story.
3402    //
3403    // "Explicit" has to mean any layer INCLUDING an instruction directive. A
3404    // `:::config` fragment declaring `store: {kind: memory}` is folded into
3405    // `settings` during typing and never written back into `doc`, so reading
3406    // the document alone judged it unstated and overrode it. That stayed hidden
3407    // only because `stream` was missing from the long-lived list this check
3408    // depends on: a stream-only agent was misclassified short-lived, so the
3409    // branch never ran. Fixing the classification exposed it — an agent defined
3410    // entirely in one markdown file, stating a memory store, silently got a
3411    // file store instead and then collided on the shared state directory.
3412    let store_stated =
3413        doc.pointer("/store/kind").is_some() || settings.store.kind != StoreKind::default();
3414    if !store_stated && settings.is_long_lived() {
3415        settings.store.kind = StoreKind::File;
3416    }
3417    // Resolve `service:` references and apply the tag floor BEFORE validation
3418    // and the trifecta gate, so both judge the effective servers rather than
3419    // the pre-resolution shorthand.
3420    let service_errors = resolve_services(&mut settings);
3421    let mut loaded = Loaded {
3422        settings,
3423        doc,
3424        file_doc,
3425        files,
3426        warnings: Vec::new(),
3427    };
3428    // `--prompt-missing`, before validation: the person is standing at a
3429    // terminal ready to supply what is missing, so ask FIRST and let the
3430    // validation that follows see the values — otherwise the aggregate error
3431    // below exits before a prompt could ever appear. Only in run mode: a
3432    // `--validate-config` must stay side-effect-free and report, not converse.
3433    if ask == Ask::Run && crate::config::prompt::prompt_missing_requested() {
3434        let mut found = Vec::new();
3435        scan_references(&loaded.doc, "config", &mut found);
3436        let mut names: Vec<String> = found
3437            .into_iter()
3438            .filter(|r| r.kind == "secret" && !crate::sec::secret::secret_available(&r.name))
3439            .map(|r| r.name)
3440            .collect();
3441        names.sort();
3442        names.dedup();
3443        for name in names {
3444            match crate::config::prompt::read_secret_from_tty(&format!("{name} (secret)")) {
3445                Ok(v) => crate::sec::secret::set_prompted(&name, v),
3446                // A failed prompt (no terminal, EOF) falls through to the
3447                // normal aggregate refusal below, which names what is missing.
3448                Err(_) => break,
3449            }
3450        }
3451    }
3452    let mut diags = validate(&loaded);
3453    diags.errors.splice(0..0, service_errors);
3454    warnings.extend(diags.warnings);
3455    loaded.warnings = warnings;
3456    if ask != Ask::Validate
3457        && ask != Ask::Help
3458        && ask != Ask::Version
3459        && ask != Ask::Schema
3460        && ask != Ask::WorkflowSchema
3461        && ask != Ask::ContextTemplate
3462        && !matches!(ask, Ask::Login(_) | Ask::Logout(_))
3463        && let Some(first) = diags.errors.first()
3464    {
3465        // ALL of them, not the first. Failing on whichever error happens to
3466        // sort first turns fixing a config into a loop of restart, read one
3467        // line, fix one thing — the aggregate report is the whole point of
3468        // validating everything up front.
3469        let msg = if diags.errors.len() == 1 {
3470            first.clone()
3471        } else {
3472            format!(
3473                "{} configuration errors:\n  - {}",
3474                diags.errors.len(),
3475                diags.errors.join("\n  - ")
3476            )
3477        };
3478        return Err(usage(msg));
3479    }
3480    if ask == Ask::Validate && !diags.errors.is_empty() {
3481        return Err(ConfigError::Validate(Err(diags
3482            .errors
3483            .iter()
3484            .map(|d| super::config_invalid_line(d))
3485            .collect::<Vec<_>>()
3486            .join("\n"))));
3487    }
3488    Ok((loaded, ask))
3489}
3490
3491/// The security controls a config file can **relax** — the two booleans that
3492/// widen what this process may do: lifting the lethal-trifecta refusal, and
3493/// turning on the local command runner. A file the operator NAMED may set
3494/// them; a file merely discovered in the working directory may not, so
3495/// stepping into a repository cannot silently grant its dotfile more power
3496/// than the operator asked for. Narrowing settings are deliberately absent:
3497/// a dotfile that takes power away needs no ceremony.
3498const DISCOVERY_FORBIDDEN_RELAXATIONS: [(&str, &str); 2] = [
3499    ("/security/allow_trifecta", "security.allow_trifecta"),
3500    ("/security/exec/enabled", "security.exec.enabled"),
3501];
3502
3503/// The settings that decide where this agent's credentials go, who may reach
3504/// it, and what it may call. A DISCOVERED config that sets any of them has them
3505/// named in a startup `config.warning`, so adopting a dotfile is visible in the
3506/// log rather than inferred from behaviour. Pointer + the dotted label to print:
3507/// NAMES only, never values — a value may be a `{{secret:…}}` template, and
3508/// the log is not the place to widen a credential's blast radius.
3509const DISCOVERY_SECURITY_SETTINGS: [(&str, &str); 12] = [
3510    ("/intelligence/endpoints", "intelligence.endpoints"),
3511    ("/intelligence/token", "intelligence.token"),
3512    ("/intelligence/token_file", "intelligence.token_file"),
3513    ("/intelligence/headers", "intelligence.headers"),
3514    ("/intelligence/auth", "intelligence.auth"),
3515    ("/mcp/servers", "mcp.servers"),
3516    ("/tools/overrides", "tools.overrides"),
3517    ("/store", "store"),
3518    ("/a2a/listen", "a2a.listen"),
3519    ("/a2a/peers", "a2a.peers"),
3520    ("/webhooks/listen", "webhooks.listen"),
3521    ("/security", "security"),
3522];
3523
3524/// Which of [`DISCOVERY_SECURITY_SETTINGS`] the file layer actually set, in
3525/// declaration order. An explicit `null` counts as unset, matching the merge
3526/// semantics where `null` removes a key.
3527fn discovered_security_settings(file_doc: &Value) -> Vec<&'static str> {
3528    DISCOVERY_SECURITY_SETTINGS
3529        .iter()
3530        .filter(|(ptr, _)| file_doc.pointer(ptr).is_some_and(|v| !v.is_null()))
3531        .map(|(_, label)| *label)
3532        .collect()
3533}
3534
3535fn binding_for<'a>(bindings: &'a [Binding], path: &str) -> Option<&'a Binding> {
3536    bindings.iter().find(|b| b.path == path)
3537}
3538
3539fn apply_alias(
3540    doc: &mut Value,
3541    bindings: &[Binding],
3542    alias: &Alias,
3543    it: &mut std::iter::Peekable<std::slice::Iter<'_, String>>,
3544    mcp_tags: &mut Vec<(String, Vec<String>)>,
3545) -> Result<(), ConfigError> {
3546    let mut take = || -> Result<String, ConfigError> {
3547        it.next()
3548            .cloned()
3549            .ok_or_else(|| usage(format!("{} requires a value", alias.flag)))
3550    };
3551    match alias.kind {
3552        AliasKind::Set => {
3553            let raw = take()?;
3554            let b = binding_for(bindings, alias.path).ok_or_else(|| {
3555                usage(format!("internal: alias path {} not in schema", alias.path))
3556            })?;
3557            let v = b
3558                .coerce(&raw)
3559                .map_err(|e| usage(format!("invalid {}: {e}", alias.flag)))?;
3560            let mut patch = Value::Object(Map::new());
3561            paths::set_path(&mut patch, alias.path, v);
3562            file::merge_into(doc, patch);
3563        }
3564        AliasKind::SetTrue => {
3565            let mut patch = Value::Object(Map::new());
3566            paths::set_path(&mut patch, alias.path, Value::Bool(true));
3567            file::merge_into(doc, patch);
3568        }
3569        AliasKind::SetFromFile => {
3570            let path = take()?;
3571            let text = super::read_file(&path)?;
3572            let mut patch = Value::Object(Map::new());
3573            paths::set_path(&mut patch, alias.path, Value::String(text));
3574            file::merge_into(doc, patch);
3575        }
3576        AliasKind::Append => {
3577            let raw = take()?;
3578            let element = match alias.flag {
3579                "--mcp" => {
3580                    let (name, endpoint) = raw
3581                        .split_once('=')
3582                        .ok_or_else(|| usage(format!("--mcp: want name=endpoint (got: {raw})")))?;
3583                    json!({ "name": name.trim(), "endpoint": endpoint.trim() })
3584                }
3585                "--a2a-peer" => {
3586                    let (name, endpoint) = raw.split_once('=').ok_or_else(|| {
3587                        usage(format!("--a2a-peer: want name=endpoint (got: {raw})"))
3588                    })?;
3589                    json!({ "name": name.trim(), "endpoint": endpoint.trim() })
3590                }
3591                "--workflow" => {
3592                    let name = std::path::Path::new(&raw)
3593                        .file_stem()
3594                        .and_then(|s| s.to_str())
3595                        .unwrap_or("workflow")
3596                        .to_string();
3597                    json!({ "name": name, "file": raw })
3598                }
3599                other => return Err(usage(format!("internal: no append rule for {other}"))),
3600            };
3601            append_at(doc, alias.path, element);
3602        }
3603        AliasKind::Special => match alias.flag {
3604            "--mcp-tags" => {
3605                let raw = take()?;
3606                let (name, tags) = raw
3607                    .split_once('=')
3608                    .ok_or_else(|| usage(format!("--mcp-tags: want name=tag,tag (got: {raw})")))?;
3609                mcp_tags.push((
3610                    name.trim().to_string(),
3611                    tags.split(',')
3612                        .map(str::trim)
3613                        .filter(|t| !t.is_empty())
3614                        .map(str::to_string)
3615                        .collect(),
3616                ));
3617            }
3618            "--budget-exit-code" => {
3619                let raw = take()?;
3620                let n: i64 = raw
3621                    .trim()
3622                    .parse()
3623                    .ok()
3624                    .filter(|n| (0..=255).contains(n))
3625                    .ok_or_else(|| {
3626                        usage(format!("invalid --budget-exit-code: {raw} (want 0..=255)"))
3627                    })?;
3628                let mut patch = Value::Object(Map::new());
3629                paths::set_path(
3630                    &mut patch,
3631                    "lifecycle.exit_code_map",
3632                    json!({ "3": n, "7": n }),
3633                );
3634                file::merge_into(doc, patch);
3635            }
3636            other => return Err(usage(format!("internal: no special rule for {other}"))),
3637        },
3638    }
3639    Ok(())
3640}
3641
3642/// Push `element` onto the array at dotted `path` (creating it).
3643fn append_at(doc: &mut Value, path: &str, element: Value) {
3644    let pointer = format!("/{}", path.replace('.', "/"));
3645    if doc.pointer(&pointer).is_none() {
3646        let mut patch = Value::Object(Map::new());
3647        paths::set_path(&mut patch, path, Value::Array(Vec::new()));
3648        file::merge_into(doc, patch);
3649    }
3650    if let Some(arr) = doc.pointer_mut(&pointer) {
3651        if !arr.is_array() {
3652            *arr = Value::Array(Vec::new());
3653        }
3654        arr.as_array_mut().expect("array").push(element);
3655    }
3656}
3657
3658/// Where conventional folders are looked for: beside each config file, MOST
3659/// SPECIFIC FIRST.
3660///
3661/// A single directory is not enough. `agentd -c ./agentd.yml -c /tmp/over.yml`
3662/// is an ordinary shape — a thin overlay that lives nowhere near the project —
3663/// and keying on the last file alone finds no folders and falls back to the
3664/// sugar `main` loop in silence, which is a worse outcome than any ordering
3665/// question. Keying on the FIRST is wrong too: the chain's first rung is
3666/// `~/.config/agentd`, where nobody keeps a project's workflows.
3667///
3668/// The working directory is a candidate ONLY when no config file was loaded at
3669/// all. Adding it unconditionally leaks: `agentd -c examples/voice/ears.yaml`
3670/// run from a repository root adopted that repository's own `./skills`, which
3671/// is exactly the "a stray file modified a run you spelled out" surprise the
3672/// whole discovery design refuses. Naming a config means the caller decided,
3673/// and that has to hold for the folders beside it too. Discovery does not lose
3674/// anything here — a discovered `./agentd.yml` has `.` as its parent, so the
3675/// working directory is already in the list when it should be.
3676fn config_dirs(paths: &[String]) -> Vec<PathBuf> {
3677    if paths.is_empty() {
3678        return vec![PathBuf::from(".")];
3679    }
3680    let mut out: Vec<PathBuf> = Vec::new();
3681    for p in paths.iter().rev() {
3682        // A path with no directory component — `-c agentd.yml` — names a file
3683        // in the working directory, so its directory IS the working directory.
3684        // Filtering the empty parent out instead dropped the project entirely
3685        // whenever the config was named relatively, which is the common way to
3686        // name it.
3687        let d = match Path::new(p).parent() {
3688            Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(),
3689            _ => PathBuf::from("."),
3690        };
3691        if !out.contains(&d) {
3692            out.push(d);
3693        }
3694    }
3695    out
3696}
3697
3698/// Files in `dir` with any of `exts`, sorted, empty when the directory is not
3699/// there. Sorted so a folder's load order is its filename order — the only
3700/// ordering an operator can see without reading the loader.
3701fn folder_files(dir: &Path, exts: &[&str]) -> Vec<PathBuf> {
3702    let Ok(rd) = std::fs::read_dir(dir) else {
3703        return Vec::new();
3704    };
3705    let mut out: Vec<PathBuf> = rd
3706        .flatten()
3707        .map(|e| e.path())
3708        .filter(|p| p.is_file())
3709        .filter(|p| {
3710            p.extension()
3711                .and_then(|e| e.to_str())
3712                .is_some_and(|e| exts.contains(&e))
3713        })
3714        .collect();
3715    out.sort();
3716    out
3717}
3718
3719/// Adopt the conventional folders beside the config — `workflows/`,
3720/// `subagents/`, `context/` — for the settings the operator did not write.
3721///
3722/// Two rules make these CONVENTIONS rather than declarations, and both matter:
3723///
3724/// 1. **Only when the setting is absent.** Someone who wrote `workflows:` has
3725///    decided, including writing an empty list to mean "none". A default that
3726///    appended to an explicit list would make the explicit one unreadable.
3727/// 2. **Only when the folder yields something.** A named `dir:` with no match
3728///    is exit 2, and rightly so — you asked for it by name. A default that did
3729///    the same would make agentd unrunnable in any directory that happens to
3730///    lack a `subagents/`, which is nearly all of them.
3731///
3732/// Injection happens on the merged DOCUMENT, so everything downstream —
3733/// validation, `{{config.*}}` folding, the definition hash, hot reload — sees
3734/// an ordinary explicit entry and needs no case for "came from a folder".
3735fn apply_default_folders(doc: &mut Value, dirs: &[PathBuf], warnings: &mut Vec<String>) {
3736    let Some(obj) = doc.as_object_mut() else {
3737        return;
3738    };
3739
3740    /// The first candidate directory whose `<dir>/<name>` satisfies `has`.
3741    fn find(dirs: &[PathBuf], name: &str, has: impl Fn(&Path) -> bool) -> Option<PathBuf> {
3742        dirs.iter().map(|d| d.join(name)).find(|p| has(p))
3743    }
3744
3745    // workflows/ — reuses the `dir:` entry the loader already expands, so the
3746    // glob, the sort and the naming stay one implementation rather than two.
3747    if !obj.contains_key("workflows")
3748        && let Some(d) = find(dirs, "workflows", |p| {
3749            !folder_files(p, &["yaml", "yml", "json"]).is_empty()
3750        })
3751    {
3752        obj.insert(
3753            "workflows".into(),
3754            json!([{"dir": d.to_string_lossy(), "glob": "*.yaml,*.yml,*.json"}]),
3755        );
3756    }
3757
3758    // skills/ — prose the model reads, so it needs no server. Either
3759    // `<name>.md` or the Agent Skill directory form `<name>/SKILL.md`.
3760    if obj.get("skills").and_then(|s| s.get("dir")).is_none()
3761        && let Some(d) = find(dirs, "skills", |p| {
3762            !folder_files(p, &["md"]).is_empty()
3763                || std::fs::read_dir(p)
3764                    .is_ok_and(|rd| rd.flatten().any(|e| e.path().join("SKILL.md").is_file()))
3765        })
3766        && let Some(sk) = obj
3767            .entry("skills")
3768            .or_insert_with(|| json!({}))
3769            .as_object_mut()
3770    {
3771        sk.insert("dir".into(), json!(d.to_string_lossy()));
3772    }
3773
3774    // subagents/ — one reviewed template per file, named by stem.
3775    if obj
3776        .get("subagents")
3777        .and_then(|s| s.get("templates"))
3778        .is_none()
3779        && let Some(d) = find(dirs, "subagents", |p| {
3780            !folder_files(p, &["yaml", "yml", "json"]).is_empty()
3781        })
3782    {
3783        let mut templates = Map::new();
3784        for path in folder_files(&d, &["yaml", "yml", "json"]) {
3785            let Some(name) = path.file_stem().and_then(|s| s.to_str()) else {
3786                continue;
3787            };
3788            match file::read_document(&path.to_string_lossy()) {
3789                Ok((v, _)) => {
3790                    templates.insert(name.to_string(), v);
3791                }
3792                Err(e) => warnings.push(format!("subagents template {}: {e}", path.display())),
3793            }
3794        }
3795        if !templates.is_empty()
3796            && let Some(sub) = obj
3797                .entry("subagents")
3798                .or_insert_with(|| json!({}))
3799                .as_object_mut()
3800        {
3801            sub.insert("templates".into(), Value::Object(templates));
3802        }
3803    }
3804
3805    // context/ — a prompt template is prose, so it is a file whose whole body
3806    // is the template and whose stem is the name a node selects with
3807    // `context: {template: <name>}`.
3808    if obj
3809        .get("context")
3810        .and_then(|c| c.get("templates"))
3811        .is_none()
3812        && let Some(d) = find(dirs, "context", |p| {
3813            !folder_files(p, &["md", "txt", "hbs"]).is_empty()
3814        })
3815    {
3816        let mut templates = Map::new();
3817        for path in folder_files(&d, &["md", "txt", "hbs"]) {
3818            let Some(name) = path.file_stem().and_then(|s| s.to_str()) else {
3819                continue;
3820            };
3821            match std::fs::read_to_string(&path) {
3822                Ok(text) => {
3823                    templates.insert(name.to_string(), Value::String(text));
3824                }
3825                Err(e) => warnings.push(format!("context template {}: {e}", path.display())),
3826            }
3827        }
3828        if !templates.is_empty()
3829            && let Some(ctx) = obj
3830                .entry("context")
3831                .or_insert_with(|| json!({}))
3832                .as_object_mut()
3833        {
3834            ctx.insert("templates".into(), Value::Object(templates));
3835        }
3836    }
3837}
3838
3839/// `agentd --instruction X` (or `agent.instruction` alone) with no workflows ⇒
3840/// the one-node workflow `once → agent → finish`.
3841///
3842/// A `--prompt` deliberately does NOT come here: a prompt is a **message to
3843/// the agent**, delivered into its root context at startup, not a canned
3844/// workflow step. That is what lets it set itself up — workflow-authoring
3845/// tools are root-scoped, so a prompt running as a step could never define the
3846/// loop/schedule it was asked for (`Caller::Workflow` vs `Caller::Root` in
3847/// the registry).
3848fn apply_instruction_sugar(doc: &mut Value) {
3849    let has_workflows = doc
3850        .pointer("/workflows")
3851        .and_then(Value::as_array)
3852        .is_some_and(|w| !w.is_empty());
3853    let nonblank = |p: &str| {
3854        doc.pointer(p)
3855            .and_then(Value::as_str)
3856            .is_some_and(|s| !s.trim().is_empty())
3857    };
3858    let has_instruction = nonblank("/agent/instruction");
3859    // An instruction that CARRIES a `:::workflow` directive has authored its
3860    // machinery explicitly — extraction (from_document) will add it to
3861    // `workflows:`, so generating a sugar `main` here would bolt a model loop
3862    // onto a config that declared none.
3863    let carries_workflow = doc
3864        .pointer("/agent/instruction")
3865        .and_then(Value::as_str)
3866        .is_some_and(|t| t.lines().any(|l| l.starts_with(":::workflow")));
3867    // A prompt runs as a root turn, so an instruction+prompt pair needs no
3868    // sugar workflow at all — the prompt IS the job.
3869    if has_workflows || carries_workflow || !has_instruction || nonblank("/agent/prompt") {
3870        return;
3871    }
3872    let work = json!({
3873        "kind": "agent",
3874        "depends_on": ["start"],
3875        "instruction": "{{env.instruction}}",
3876    });
3877    let mut patch = Value::Object(Map::new());
3878    paths::set_path(
3879        &mut patch,
3880        "workflows",
3881        json!([{
3882            "name": "main",
3883            "version": 3,
3884            "steps": {
3885                "start": { "kind": "once" },
3886                "work":  work,
3887                "done":  { "kind": "finish", "depends_on": ["work"], "status": "completed", "output": "{{steps.work.output}}" }
3888            }
3889        }]),
3890    );
3891    file::merge_into(doc, patch);
3892}
3893
3894/// Substitute `${VAR}` / `${VAR:-default}` references in **every string value**
3895/// of the merged document (config sections *and* inline workflows) from the
3896/// process environment. Braces are required — a bare `$VAR` (or a `$` not
3897/// followed by `{`) is left verbatim, and `$${` yields a literal `${` — so
3898/// shell snippets and prices survive untouched. An unset variable with no
3899/// default is a hard error (fail-closed). This is intentionally distinct from
3900/// `{{secret:NAME}}` / `{{secret-file:PATH}}` (which resolve a *redacted*
3901/// credential and are never echoed): `${VAR}` is for plain, loggable values
3902/// like hostnames, ports, and paths that differ per environment.
3903fn substitute_env(v: &mut Value, env: &HashMap<&str, &str>) -> Result<(), String> {
3904    match v {
3905        Value::String(s) => {
3906            if s.as_bytes().contains(&b'$') {
3907                *s = expand_env_str(s, env)?;
3908            }
3909            Ok(())
3910        }
3911        Value::Array(a) => a.iter_mut().try_for_each(|item| substitute_env(item, env)),
3912        Value::Object(m) => m.values_mut().try_for_each(|val| substitute_env(val, env)),
3913        _ => Ok(()),
3914    }
3915}
3916
3917/// Expand a single string's `${…}` references (see [`substitute_env`]).
3918fn expand_env_str(s: &str, env: &HashMap<&str, &str>) -> Result<String, String> {
3919    let mut out = String::with_capacity(s.len());
3920    let b = s.as_bytes();
3921    let mut i = 0;
3922    while i < b.len() {
3923        // `$` is ASCII and can never appear inside a multi-byte UTF-8 sequence,
3924        // so scanning for it byte-wise is safe; the fallthrough advances by whole
3925        // chars to keep every slice on a boundary.
3926        if b[i] == b'$' {
3927            if b.get(i + 1) == Some(&b'$') {
3928                out.push('$'); // `$$` -> literal `$`
3929                i += 2;
3930                continue;
3931            }
3932            if b.get(i + 1) == Some(&b'{') {
3933                let start = i + 2;
3934                let Some(rel) = s[start..].find('}') else {
3935                    return Err(format!("unterminated `${{` in config value {s:?}"));
3936                };
3937                let end = start + rel;
3938                let expr = &s[start..end];
3939                let (name, default) = match expr.split_once(":-") {
3940                    Some((n, d)) => (n.trim(), Some(d)),
3941                    None => (expr.trim(), None),
3942                };
3943                if name.is_empty() {
3944                    return Err(format!("empty `${{}}` reference in config value {s:?}"));
3945                }
3946                if !name.bytes().all(|c| c.is_ascii_alphanumeric() || c == b'_') {
3947                    return Err(format!(
3948                        "invalid environment variable name {name:?} in `${{{expr}}}`"
3949                    ));
3950                }
3951                match env.get(name) {
3952                    Some(val) => out.push_str(val),
3953                    None => match default {
3954                        Some(d) => out.push_str(d),
3955                        None => {
3956                            return Err(format!(
3957                                "environment variable ${{{name}}} is not set (referenced in config); \
3958                                 set it or write ${{{name}:-default}}"
3959                            ));
3960                        }
3961                    },
3962                }
3963                i = end + 1;
3964                continue;
3965            }
3966        }
3967        let ch = s[i..].chars().next().unwrap();
3968        out.push(ch);
3969        i += ch.len_utf8();
3970    }
3971    Ok(out)
3972}
3973
3974// ---------------------------------------------------------------------------
3975// Validation
3976// ---------------------------------------------------------------------------
3977
3978/// Collected diagnostics: `errors` fail the load (exit 2); `warnings` are
3979/// advisory (logged, printed by `--validate-config`).
3980#[derive(Debug, Default, Clone)]
3981pub struct Diagnostics {
3982    pub errors: Vec<String>,
3983    pub warnings: Vec<String>,
3984}
3985
3986/// Every check, collected (never fast-fails) so `--validate-config` reports
3987/// all problems at once. Pure.
3988/// Validate a unified `auth:` block — the fields each `kind`/`grant` requires,
3989/// and secret-freedom for credential fields. Returns error strings prefixed
3990/// with `ctx` (e.g. `mcp server 'github'`).
3991fn validate_auth_block(auth: &Auth, ctx: &str) -> Vec<String> {
3992    let mut out = Vec::new();
3993    // Credential fields must be `{{secret:…}}` references, never inline.
3994    for (field, s) in [
3995        ("client_secret", &auth.client_secret),
3996        ("token", &auth.token),
3997        ("value", &auth.value),
3998    ] {
3999        if let Some(sec) = s
4000            && !sec.0.trim().is_empty()
4001            && !crate::sec::secret::has_secret_ref(&sec.0)
4002        {
4003            out.push(format!(
4004                "{ctx}: auth.{field} carries an inline credential; use a {{{{secret:…}}}} reference"
4005            ));
4006        }
4007    }
4008    match auth.kind {
4009        AuthKind::Static => {
4010            let has_bearer = auth.token.is_some();
4011            let has_header = auth.header.is_some() && auth.value.is_some();
4012            if !has_bearer && !has_header {
4013                out.push(format!(
4014                    "{ctx}: auth.kind static needs `token` (a bearer) or `header` + `value`"
4015                ));
4016            }
4017        }
4018        AuthKind::Aws => {
4019            if auth.region.is_none() {
4020                out.push(format!("{ctx}: auth.kind aws needs `region`"));
4021            }
4022            if auth.service.is_none() {
4023                out.push(format!(
4024                    "{ctx}: auth.kind aws needs `service` (e.g. bedrock, execute-api)"
4025                ));
4026            }
4027            match auth.source.as_deref() {
4028                Some("sso") => {
4029                    if auth.sso_start_url.is_none()
4030                        || auth.account_id.is_none()
4031                        || auth.role_name.is_none()
4032                    {
4033                        out.push(format!(
4034                            "{ctx}: aws source sso needs `sso_start_url` + `account_id` + `role_name`"
4035                        ));
4036                    }
4037                }
4038                Some(src) if !matches!(src, "env" | "static" | "imds" | "irsa") => {
4039                    out.push(format!(
4040                        "{ctx}: auth.source '{src}' is not a known AWS source (env|static|imds|irsa|sso)"
4041                    ));
4042                }
4043                _ => {}
4044            }
4045        }
4046        AuthKind::Spiffe => match auth.svid.as_deref().unwrap_or("jwt") {
4047            "jwt" => {
4048                if auth.jwt_svid_file.is_none() {
4049                    out.push(format!(
4050                        "{ctx}: auth.kind spiffe (svid jwt) needs `jwt_svid_file`"
4051                    ));
4052                }
4053            }
4054            "x509" => {
4055                if auth.svid_file.is_none() || auth.key_file.is_none() {
4056                    out.push(format!(
4057                        "{ctx}: auth.kind spiffe (svid x509) needs `svid_file` + `key_file`"
4058                    ));
4059                }
4060            }
4061            other => out.push(format!("{ctx}: auth.svid '{other}' (want jwt|x509)")),
4062        },
4063        AuthKind::Oauth2 => {
4064            if auth.client_id.is_none() {
4065                out.push(format!("{ctx}: auth.kind oauth2 needs `client_id`"));
4066            }
4067            if auth.token_url.is_none() && auth.issuer.is_none() {
4068                out.push(format!(
4069                    "{ctx}: auth oauth2 needs `token_url` or `issuer` (for discovery)"
4070                ));
4071            }
4072            match auth.grant.unwrap_or(OAuthGrant::Device) {
4073                OAuthGrant::Device => {
4074                    if auth.device_authorization_url.is_none() && auth.issuer.is_none() {
4075                        out.push(format!(
4076                            "{ctx}: the device grant needs `device_authorization_url` or `issuer`"
4077                        ));
4078                    }
4079                }
4080                OAuthGrant::ClientCredentials => {
4081                    if auth.client_secret.is_none() {
4082                        out.push(format!(
4083                            "{ctx}: the client_credentials grant needs `client_secret`"
4084                        ));
4085                    }
4086                }
4087                OAuthGrant::AuthorizationCode => {
4088                    if auth.authorization_url.is_none() && auth.issuer.is_none() {
4089                        out.push(format!(
4090                            "{ctx}: the authorization_code grant needs `authorization_url` or `issuer`"
4091                        ));
4092                    }
4093                }
4094            }
4095        }
4096    }
4097    out
4098}
4099
4100/// Why a declared header value's `{{secret:NAME}}` / `{{secret-file:PATH}}` ref
4101/// does not resolve, or `None` when it does (or when the value carries no ref).
4102///
4103/// This is the security half of header validation, and it is not cosmetic: a
4104/// header whose ref does not resolve is a header that is **not sent**, so
4105/// without this check the process starts and dials the endpoint with no
4106/// credential at all. Validating before any side effect makes that exit 2 at
4107/// startup, naming the ref — the same rule, and the same resolver, the runtime
4108/// applies at the moment of use. The message names the ref and never the
4109/// resolved value, so a diagnostic cannot leak the credential.
4110fn unresolved_secret_ref(value: &str) -> Option<String> {
4111    if !crate::sec::secret::has_secret_ref(value) {
4112        return None;
4113    }
4114    // Interactively-entered values (`--prompt-missing`) count as resolvable:
4115    // by the time the runtime dereferences the ref, the prompted store answers.
4116    crate::sec::secret::refs_resolvable(value, &|k| {
4117        crate::sec::secret::prompted_of(k).or_else(|| std::env::var(k).ok())
4118    })
4119    .err()
4120}
4121
4122pub fn validate(loaded: &Loaded) -> Diagnostics {
4123    let s = &loaded.settings;
4124    let mut d = Diagnostics::default();
4125    let err = |d: &mut Diagnostics, m: String| d.errors.push(m);
4126
4127    // Every `{{secret:…}}` / `{{secret-file:…}}` / `{{config.…}}` in the whole
4128    // document, checked the way STARTUP checks it — the identical scan, so the
4129    // two cannot disagree.
4130    //
4131    // They did disagree, and that is the bug this closes. The scan already
4132    // existed and ran only at startup; validation checked credentials in
4133    // exactly one place, `intelligence.headers`, because the check rode along
4134    // with a lint that only header maps have. So `intelligence.token`,
4135    // `mcp.servers[].auth.token` and `a2a.principals[].match.bearer_ref` — the
4136    // idiomatic spellings — passed `--validate-config` and then exited 2 at
4137    // startup. A validator that green-lights a config the daemon refuses is
4138    // worse than no validator: it is the one tool whose whole purpose is to
4139    // move that failure before any side effect.
4140    for m in missing_references(&loaded.doc, "config", &s.vars) {
4141        err(&mut d, m);
4142    }
4143
4144    // An `auth.hmac.algo` the verifier does not implement. Refused at listener
4145    // build too, but catching it HERE is the point: this is the same
4146    // validate/startup divergence the reference scan above closes, and adding a
4147    // startup-only refusal would have re-opened it in a security field.
4148    for (at, algo) in hmac_algos(&loaded.doc, "config") {
4149        if !algo.eq_ignore_ascii_case("sha256") {
4150            err(
4151                &mut d,
4152                format!(
4153                    "{at} {algo:?} is not implemented — agentd computes HMAC-SHA256 only;                      use `algo: sha256` (or omit it) and have senders sign SHA-256"
4154                ),
4155            );
4156        }
4157    }
4158
4159    // config_version
4160    if let Some(v) = &s.config_version
4161        && v != schema::CONFIG_VERSION
4162    {
4163        err(
4164            &mut d,
4165            format!(
4166                "config_version must be \"{}\" (got {v:?})",
4167                schema::CONFIG_VERSION
4168            ),
4169        );
4170    }
4171
4172    // observability.runtime_events / audit.stream — both name a stream, and a
4173    // stream that is not declared can never be appended to. Refusing here
4174    // rather than at the first append keeps the failure at boot, where an
4175    // operator is looking, instead of inside a storm.
4176    if let Some(re) = &s.observability.runtime_events {
4177        match re.stream.as_deref() {
4178            None => err(
4179                &mut d,
4180                "observability.runtime_events: `stream` is required".into(),
4181            ),
4182            Some(name) if !s.streams.contains_key(name) => err(
4183                &mut d,
4184                format!(
4185                    "observability.runtime_events.stream: {name:?} is not declared (add it under `streams:`)"
4186                ),
4187            ),
4188            Some(_) => {}
4189        }
4190        if re.include.is_empty() && re.sampled.is_empty() {
4191            err(
4192                &mut d,
4193                "observability.runtime_events: name at least one family in `include` or `sampled`"
4194                    .into(),
4195            );
4196        }
4197        for f in re.include.iter().chain(re.sampled.iter()) {
4198            if !crate::obs::log::EVENT_FAMILIES.contains(&f.as_str()) {
4199                err(
4200                    &mut d,
4201                    format!(
4202                        "observability.runtime_events: unknown event family {f:?} (known: {})",
4203                        crate::obs::log::EVENT_FAMILIES.join(", ")
4204                    ),
4205                );
4206            }
4207        }
4208        for f in &re.sampled {
4209            if re.include.contains(f) {
4210                err(
4211                    &mut d,
4212                    format!(
4213                        "observability.runtime_events: family {f:?} is in both `include` and `sampled` — pick one"
4214                    ),
4215                );
4216            }
4217        }
4218    }
4219    if let Some(sinks) = &s.observability.audit.sink
4220        && sinks.iter().any(|x| matches!(x, AuditSink::Stream))
4221    {
4222        match s.observability.audit.stream.as_deref() {
4223            None => err(
4224                &mut d,
4225                "observability.audit: `sink: [stream]` needs `stream: <name>`".into(),
4226            ),
4227            Some(name) if !s.streams.contains_key(name) => err(
4228                &mut d,
4229                format!(
4230                    "observability.audit.stream: {name:?} is not declared (add it under `streams:`)"
4231                ),
4232            ),
4233            Some(_) => {}
4234        }
4235    }
4236
4237    // intelligence.models — a tier catalogue whose names are referenced from
4238    // several places, so a typo has to be a startup error rather than a
4239    // silent fall-through to "a literal model called `smal`".
4240    for (name, t) in &s.intelligence.models {
4241        let at = format!("intelligence.models.{name}");
4242        if t.model.as_deref().unwrap_or("").trim().is_empty() {
4243            err(&mut d, format!("{at}: `model` is required"));
4244        }
4245        if let Some(svc) = &t.service {
4246            match s.services.get(svc) {
4247                None => err(
4248                    &mut d,
4249                    format!("{at}.service: {svc:?} is not declared (add it under `services:`)"),
4250                ),
4251                Some(entry) if entry.kind != ServiceKind::Intelligence => err(
4252                    &mut d,
4253                    format!(
4254                        "{at}.service: {svc:?} is `kind: {}` — a model tier needs `kind: intelligence`",
4255                        entry.kind.as_str()
4256                    ),
4257                ),
4258                Some(_) => {}
4259            }
4260        }
4261        if let Some(f) = &t.fallback {
4262            if !s.intelligence.models.contains_key(f) {
4263                err(&mut d, format!("{at}.fallback: no model tier named {f:?}"));
4264            } else if f == name {
4265                err(
4266                    &mut d,
4267                    format!("{at}.fallback: a tier cannot fall back to itself"),
4268                );
4269            }
4270        }
4271    }
4272    // A degradation ladder that loops is a hang under exactly the conditions
4273    // it exists to survive.
4274    for name in s.intelligence.models.keys() {
4275        let mut seen = vec![name.clone()];
4276        let mut cur = name.clone();
4277        while let Some(next) = s
4278            .intelligence
4279            .models
4280            .get(&cur)
4281            .and_then(|t| t.fallback.clone())
4282        {
4283            if seen.contains(&next) {
4284                err(
4285                    &mut d,
4286                    format!(
4287                        "intelligence.models: fallback cycle {} -> {next}",
4288                        seen.join(" -> ")
4289                    ),
4290                );
4291                break;
4292            }
4293            seen.push(next.clone());
4294            cur = next;
4295        }
4296    }
4297    for (field, reference) in [
4298        ("intelligence.default", s.intelligence.default.as_ref()),
4299        (
4300            "intelligence.preflight_model",
4301            s.intelligence.preflight_model.as_ref(),
4302        ),
4303        (
4304            "context.summarize.model",
4305            s.context.summarize.model.as_ref(),
4306        ),
4307    ] {
4308        // These name a TIER. A literal here would work by accident today and
4309        // break the moment a tier of that name is declared, so require the
4310        // tier when a catalogue exists at all.
4311        if let Some(r) = reference
4312            && !s.intelligence.models.is_empty()
4313            && !s.intelligence.models.contains_key(r)
4314        {
4315            err(
4316                &mut d,
4317                format!(
4318                    "{field}: {r:?} is not a declared model tier (known: {})",
4319                    s.intelligence
4320                        .models
4321                        .keys()
4322                        .cloned()
4323                        .collect::<Vec<_>>()
4324                        .join(", ")
4325                ),
4326            );
4327        }
4328    }
4329
4330    // a2a.principals[].quotas — a limit that parses and is never checked for
4331    // shape is the same failure as one that is never enforced: the operator
4332    // believes they set a ceiling.
4333    for (i, p) in s.a2a.principals.iter().enumerate() {
4334        let Some(q) = &p.quotas else { continue };
4335        if let Some(r) = &q.rate
4336            && let Err(e) = crate::supervisor::tree::parse_rate(r)
4337        {
4338            err(&mut d, format!("a2a.principals[{i}].quotas.rate: {e}"));
4339        }
4340        if let Some(b) = &q.budget {
4341            validate_budget(b, &format!("a2a.principals[{i}].quotas.budget"), &mut d);
4342        }
4343    }
4344
4345    // security.policies — a security control has to fail loudly when it cannot
4346    // do what it says.
4347    for (i, p) in s.security.policies.iter().enumerate() {
4348        let at = format!("security.policies[{i}]");
4349        if let Some(expr) = &p.matcher.args {
4350            if !cfg!(feature = "cel") {
4351                err(
4352                    &mut d,
4353                    format!(
4354                        "{at}: `match.args` needs the `cel` feature; this build cannot evaluate an \
4355                         argument guard, and silently treating it as no-match would turn a deny \
4356                         into an allow"
4357                    ),
4358                );
4359            } else if let Err(e) =
4360                crate::cel::compile_check(expr.trim().trim_start_matches("CEL:").trim())
4361            {
4362                err(&mut d, format!("{at}: match.args: {e}"));
4363            }
4364        }
4365        for t in &p.matcher.tags {
4366            if !["untrusted_input", "sensitive", "egress"].contains(&t.as_str()) {
4367                err(
4368                    &mut d,
4369                    format!("{at}: unknown tag {t:?} (want untrusted_input|sensitive|egress)"),
4370                );
4371            }
4372        }
4373        if p.action != PolicyAction::Ask && (p.question.is_some() || p.on_timeout.is_some()) {
4374            err(
4375                &mut d,
4376                format!("{at}: `question`/`on_timeout` apply to `action: ask`"),
4377            );
4378        }
4379        if p.on_timeout == Some(PolicyAction::Ask) {
4380            err(
4381                &mut d,
4382                format!("{at}: `on_timeout: ask` would ask again forever"),
4383            );
4384        }
4385    }
4386
4387    // intelligence
4388    for e in &s.intelligence.endpoints {
4389        if let Err(e) = super::validate_intelligence_uri(e) {
4390            err(&mut d, e.to_string());
4391        }
4392    }
4393    if let Some(p) = &s.intelligence.swap_policy
4394        && super::SwapPolicy::parse(p).is_none()
4395    {
4396        err(
4397            &mut d,
4398            format!("intelligence.swap_policy: {p:?} (want finish-on-old|restart-turn)"),
4399        );
4400    }
4401    if s.intelligence.token.is_some() && s.intelligence.token_file.is_some() {
4402        d.warnings.push(
4403            "intelligence.token and intelligence.token_file are both set; the inline token wins"
4404                .into(),
4405        );
4406    }
4407    if let Some(auth) = &s.intelligence.auth {
4408        for e in validate_auth_block(auth, "intelligence") {
4409            err(&mut d, e);
4410        }
4411    }
4412    if let Some(dialect) = &s.intelligence.dialect {
4413        if crate::intel::client::Provider::from_dialect(Some(dialect)).is_none() {
4414            err(
4415                &mut d,
4416                format!("intelligence.dialect: {dialect:?} (want openai|anthropic|bedrock)"),
4417            );
4418        }
4419        // Native Bedrock authenticates by SigV4 — an `auth: {kind: aws}` is
4420        // required (creds come from env/imds/irsa/sso at dial time).
4421        if dialect == "bedrock"
4422            && !matches!(
4423                s.intelligence.auth.as_ref().map(|a| a.kind),
4424                Some(AuthKind::Aws)
4425            )
4426        {
4427            err(
4428                &mut d,
4429                "intelligence.dialect: bedrock requires intelligence.auth.kind = aws (SigV4)"
4430                    .into(),
4431            );
4432        }
4433    }
4434    validate_budget(&s.intelligence.budget, "intelligence.budget", &mut d);
4435    if let Some(b) = &s.agent.conversation_budget {
4436        validate_budget(b, "agent.conversation_budget", &mut d);
4437    }
4438    for (name, value) in &s.intelligence.headers {
4439        if super::is_secret_shaped_key(name) && !crate::sec::secret::has_secret_ref(value) {
4440            err(
4441                &mut d,
4442                format!(
4443                    "intelligence.headers['{name}'] looks like a credential but has an inline value; use {{{{secret:NAME}}}} / {{{{secret-file:PATH}}}}"
4444                ),
4445            );
4446        } else if let Some(e) = unresolved_secret_ref(value) {
4447            err(&mut d, format!("intelligence.headers['{name}']: {e}"));
4448        }
4449    }
4450
4451    // mcp servers
4452    let mut names = std::collections::HashSet::new();
4453    for srv in &s.mcp.servers {
4454        if srv.name.trim().is_empty() {
4455            err(&mut d, "mcp.servers[]: a server has an empty name".into());
4456        }
4457        if !names.insert(srv.name.as_str()) {
4458            err(
4459                &mut d,
4460                format!("mcp.servers[]: duplicate server name '{}'", srv.name),
4461            );
4462        }
4463        if srv.name == "code" {
4464            err(
4465                &mut d,
4466                "mcp.servers[]: the server name 'code' is reserved for code-registered tools"
4467                    .into(),
4468            );
4469        }
4470        if srv.endpoint.is_empty() {
4471            // A `service:` reference is filled by resolution (an unknown name
4472            // already errored there); a server with NEITHER is malformed.
4473            if srv.service.is_none() {
4474                err(
4475                    &mut d,
4476                    format!(
4477                        "mcp server '{}' needs an `endpoint` or a `service:` catalog reference",
4478                        srv.name
4479                    ),
4480                );
4481            }
4482        } else if let Err(e) = super::mcp_endpoint_scheme_ok(&srv.endpoint) {
4483            err(&mut d, format!("mcp server '{}': {e}", srv.name));
4484        }
4485        if let Err(e) = srv.tag_set() {
4486            err(&mut d, e);
4487        }
4488        for (h, v) in &srv.headers {
4489            if super::is_secret_shaped_key(h) && !crate::sec::secret::has_secret_ref(v) {
4490                err(
4491                    &mut d,
4492                    format!(
4493                        "mcp server '{}' header '{h}' looks like a credential but has an inline value; use a {{{{secret:…}}}} reference",
4494                        srv.name
4495                    ),
4496                );
4497            } else if let Some(e) = unresolved_secret_ref(v) {
4498                err(
4499                    &mut d,
4500                    format!("mcp server '{}' header '{h}': {e}", srv.name),
4501                );
4502            }
4503        }
4504        if let Some(auth) = &srv.auth {
4505            for e in validate_auth_block(auth, &format!("mcp server '{}'", srv.name)) {
4506                err(&mut d, e);
4507            }
4508        }
4509    }
4510    // services catalog
4511    for (name, svc) in &s.services {
4512        if name.is_empty()
4513            || !name
4514                .chars()
4515                .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
4516        {
4517            err(
4518                &mut d,
4519                format!("services: entry name '{name}' must be [a-zA-Z0-9_-]+"),
4520            );
4521        }
4522        // The endpoint scheme is judged by the entry's KIND: a peer speaks
4523        // A2A (https / loopback http / unix), everything else HTTPS-or-loopback.
4524        match svc.kind {
4525            ServiceKind::Peer => {
4526                if let Err(e) = crate::config::A2aEndpoint::parse(&svc.endpoint) {
4527                    err(&mut d, format!("services.{name}: {e}"));
4528                }
4529            }
4530            _ => {
4531                if let Err(e) = super::mcp_endpoint_scheme_ok(&svc.endpoint) {
4532                    err(&mut d, format!("services.{name}: {e}"));
4533                }
4534            }
4535        }
4536        // Kind-specific fields: the tool surface (allow/exclude/tags/breaker)
4537        // is `kind: mcp` vocabulary; `methods` is `kind: http` vocabulary.
4538        if svc.kind != ServiceKind::Mcp {
4539            for (set, what) in [
4540                (svc.allow.is_some(), "allow"),
4541                (!svc.exclude.is_empty(), "exclude"),
4542                (!svc.tags.is_empty(), "tags"),
4543                (svc.breaker.is_some(), "breaker"),
4544            ] {
4545                if set {
4546                    err(
4547                        &mut d,
4548                        format!(
4549                            "services.{name}: `{what}` applies to `kind: mcp` entries only (this entry is `kind: {}`)",
4550                            svc.kind.as_str()
4551                        ),
4552                    );
4553                }
4554            }
4555        }
4556        if svc.methods.is_some() && svc.kind != ServiceKind::Http {
4557            err(
4558                &mut d,
4559                format!(
4560                    "services.{name}: `methods` applies to `kind: http` entries only (this entry is `kind: {}`)",
4561                    svc.kind.as_str()
4562                ),
4563            );
4564        }
4565        if let Some(ms) = &svc.methods {
4566            for m in ms {
4567                if !matches!(
4568                    m.as_str(),
4569                    "GET" | "PUT" | "POST" | "DELETE" | "PATCH" | "HEAD"
4570                ) {
4571                    err(
4572                        &mut d,
4573                        format!(
4574                            "services.{name}.methods: unknown method '{m}' (want GET|PUT|POST|DELETE|PATCH|HEAD, uppercase)"
4575                        ),
4576                    );
4577                }
4578            }
4579        }
4580        if let Some(b) = &svc.breaker
4581            && crate::runtime::breaker::Config::of(Some(b)).is_none()
4582        {
4583            err(
4584                &mut d,
4585                format!(
4586                    "services.{name}.breaker: want {{failures: N>=1, cooldown: \"60s\"}} — both fields required"
4587                ),
4588            );
4589        }
4590        for list in svc.tags.values() {
4591            for t in list {
4592                if crate::sec::scope::TrifectaTag::parse(t).is_none() {
4593                    err(
4594                        &mut d,
4595                        format!("services.{name} has unknown trifecta tag '{t}'"),
4596                    );
4597                }
4598            }
4599        }
4600        for (h, v) in &svc.headers {
4601            if super::is_secret_shaped_key(h) && !crate::sec::secret::has_secret_ref(v) {
4602                err(
4603                    &mut d,
4604                    format!(
4605                        "services.{name} header '{h}' looks like a credential but has an inline value; use a {{{{secret:…}}}} reference"
4606                    ),
4607                );
4608            } else if let Some(e) = unresolved_secret_ref(v) {
4609                err(&mut d, format!("services.{name} header '{h}': {e}"));
4610            }
4611        }
4612        if let Some(auth) = &svc.auth {
4613            for e in validate_auth_block(auth, &format!("services.{name}")) {
4614                err(&mut d, e);
4615            }
4616        }
4617        if let Some(r) = &svc.rate
4618            && let Err(e) = crate::supervisor::tree::parse_rate(r)
4619        {
4620            err(&mut d, format!("services.{name}.rate: {e}"));
4621        }
4622    }
4623    // Matching must be unambiguous PER KIND: no entry's endpoint may itself
4624    // match another entry of the same kind (identical or prefix-comparable).
4625    // Different kinds on one host are legal — matching is kind-filtered.
4626    {
4627        let entries: Vec<(&String, &Service)> = s.services.iter().collect();
4628        for i in 0..entries.len() {
4629            for j in (i + 1)..entries.len() {
4630                if entries[i].1.kind != entries[j].1.kind {
4631                    continue;
4632                }
4633                let kind = entries[i].1.kind;
4634                let one = BTreeMap::from([(entries[i].0.clone(), entries[i].1.clone())]);
4635                let other = BTreeMap::from([(entries[j].0.clone(), entries[j].1.clone())]);
4636                if service_match(&one, kind, &entries[j].1.endpoint).is_some()
4637                    || service_match(&other, kind, &entries[i].1.endpoint).is_some()
4638                {
4639                    err(
4640                        &mut d,
4641                        format!(
4642                            "services.{} and services.{} have prefix-comparable endpoints of the same kind — URL matching must be unambiguous",
4643                            entries[i].0, entries[j].0
4644                        ),
4645                    );
4646                }
4647            }
4648        }
4649    }
4650    // Egress policy, over all four outbound surfaces: closed ⇒ every
4651    // configured dial must match a catalog entry of its own kind.
4652    if s.security.egress == Egress::Closed {
4653        let closed = |d: &mut Diagnostics, kind: ServiceKind, what: &str, url: &str| {
4654            if service_match(&s.services, kind, url).is_none() {
4655                d.errors.push(format!(
4656                    "security.egress is closed and {what} ({url}) matches no `kind: {}` services: catalog entry — catalog the endpoint to allow it",
4657                    kind.as_str()
4658                ));
4659            }
4660        };
4661        for srv in &s.mcp.servers {
4662            if !srv.endpoint.is_empty() {
4663                closed(
4664                    &mut d,
4665                    ServiceKind::Mcp,
4666                    &format!("mcp server '{}'", srv.name),
4667                    &srv.endpoint,
4668                );
4669            }
4670        }
4671        for e in &s.intelligence.endpoints {
4672            // `mock:` is the in-process test endpoint — no socket, no egress.
4673            if !e.starts_with("mock:") {
4674                closed(
4675                    &mut d,
4676                    ServiceKind::Intelligence,
4677                    "intelligence endpoint",
4678                    e,
4679                );
4680            }
4681        }
4682        for p in &s.a2a.peers {
4683            if !p.endpoint.is_empty() {
4684                closed(
4685                    &mut d,
4686                    ServiceKind::Peer,
4687                    &format!("a2a peer '{}'", p.name),
4688                    &p.endpoint,
4689                );
4690            }
4691        }
4692        if s.store.kind == StoreKind::Http
4693            && let Some(h) = &s.store.http
4694        {
4695            closed(
4696                &mut d,
4697                ServiceKind::Http,
4698                "store.http.base_url",
4699                &h.base_url,
4700            );
4701            // Ops that don't build on {base_url} dial their own literal hosts.
4702            for (opname, op) in [
4703                ("get", &h.get),
4704                ("put", &h.put),
4705                ("list", &h.list),
4706                ("delete", &h.delete),
4707            ] {
4708                if let Some(op) = op
4709                    && !op.url.starts_with("{base_url}")
4710                    && !op.url.contains("{{")
4711                {
4712                    closed(
4713                        &mut d,
4714                        ServiceKind::Http,
4715                        &format!("store.http.{opname}.url"),
4716                        &op.url,
4717                    );
4718                }
4719            }
4720        }
4721        for w in &s.workflows {
4722            if let Some(u) = w.get("url").and_then(Value::as_str) {
4723                closed(&mut d, ServiceKind::Http, "workflow reference url", u);
4724            }
4725            // Literal `http` step URLs are judged here; templated ones are
4726            // checked at execution (`step_http`'s runtime gate).
4727            if let Some(steps) = w.get("steps").and_then(Value::as_object) {
4728                for (sid, st) in steps {
4729                    if st.get("kind").and_then(Value::as_str) == Some("http")
4730                        && let Some(u) = st.get("url").and_then(Value::as_str)
4731                        && !u.contains("{{")
4732                    {
4733                        closed(&mut d, ServiceKind::Http, &format!("http step '{sid}'"), u);
4734                    }
4735                }
4736            }
4737        }
4738        // The one surface deliberately outside the policy: telemetry export.
4739        if s.observability.otel.endpoint.is_some() {
4740            d.warnings.push(
4741                "security.egress: closed does not cover observability.otel.endpoint (telemetry export is operator plumbing, not agent egress)".into(),
4742            );
4743        }
4744    }
4745    // Context templates are parsed at LOAD, so a malformed block or a typo'd
4746    // reference is a startup error rather than a prompt that renders wrong
4747    // once the agent is already running unattended.
4748    {
4749        let known: &[&str] = &[
4750            "instance",
4751            "instruction",
4752            "extra",
4753            "tools",
4754            "workflows",
4755            "services",
4756            "egress_closed",
4757            "streams",
4758            "templates",
4759            "skills",
4760            "peers",
4761            "signals",
4762            "memory",
4763        ];
4764        let mut check = |what: String, src: &str, is_default_slot: bool| {
4765            match crate::context::prompt::Template::parse(src) {
4766                Err(e) => err(&mut d, format!("{what}: {e}")),
4767                Ok(t) => {
4768                    for r in &t.roots {
4769                        if !known.contains(&r.as_str()) {
4770                            err(
4771                                &mut d,
4772                                format!(
4773                                    "{what}: unknown reference {{{{{r}}}}} (available: {})",
4774                                    known.join(", ")
4775                                ),
4776                            );
4777                        }
4778                    }
4779                    if t.needs_cel && !cfg!(feature = "cel") {
4780                        err(
4781                            &mut d,
4782                            format!(
4783                                "{what}: uses an expression, which needs the 'cel' build feature (bare paths work without it)"
4784                            ),
4785                        );
4786                    }
4787                    // Losing the standing policy is the failure that still
4788                    // looks like a working agent — say so, loudly.
4789                    if is_default_slot && !t.reads("instruction") {
4790                        d.warnings.push(format!(
4791                            "{what} never references {{{{instruction}}}} — this agent's standing policy will not reach the model"
4792                        ));
4793                    }
4794                }
4795            }
4796        };
4797        if let Some(src) = &s.context.template {
4798            check("context.template".into(), src, true);
4799        }
4800        for (name, src) in &s.context.templates {
4801            check(format!("context.templates.{name}"), src, false);
4802        }
4803    }
4804    // Subagent templates: extraction, tier resolution and the
4805    // instance-machinery checks all run here, so a bad template refuses the
4806    // PARENT's startup — naming the template — instead of failing at the first
4807    // spawn, long after the deploy.
4808    if let Err(errs) = crate::config::templates::compile_templates(s) {
4809        for e in errs {
4810            err(&mut d, e);
4811        }
4812    }
4813    if !s.subagents.templates.is_empty() && s.a2a.listen.is_none() {
4814        d.warnings.push(
4815            "subagents.templates are declared but a2a.listen is unset — instance-tier children get no `parent` peer (they cannot call home)".into(),
4816        );
4817    }
4818    let server_known = |n: &str| s.mcp.servers.iter().any(|x| x.name == n);
4819
4820    // tools
4821    for (name, ov) in &s.tools.overrides {
4822        if !server_known(&ov.server) {
4823            err(
4824                &mut d,
4825                format!(
4826                    "tools.overrides['{name}'] references undeclared MCP server '{}'",
4827                    ov.server
4828                ),
4829            );
4830        }
4831        if s.tools.disabled.iter().any(|x| x == name) {
4832            err(
4833                &mut d,
4834                format!("tool '{name}' is both disabled and overridden"),
4835            );
4836        }
4837        for (label, tpl) in [("args", &ov.args), ("result", &ov.result)] {
4838            if let Some(t) = tpl
4839                && let Some(expr) = t.strip_prefix("CEL:")
4840                && let Err(e) = crate::cel::compile_check(expr.trim())
4841            {
4842                err(&mut d, format!("tools.overrides['{name}'].{label}: {e}"));
4843            }
4844        }
4845    }
4846
4847    // store
4848    match s.store.kind {
4849        StoreKind::Mcp => match &s.store.mcp {
4850            None => err(&mut d, "store.kind is mcp but store.mcp is not set".into()),
4851            Some(m) => {
4852                if !server_known(&m.server) {
4853                    err(
4854                        &mut d,
4855                        format!(
4856                            "store.mcp.server '{}' is not a declared MCP server",
4857                            m.server
4858                        ),
4859                    );
4860                }
4861                for (label, op) in [
4862                    ("put", &m.put),
4863                    ("get", &m.get),
4864                    ("list", &m.list),
4865                    ("delete", &m.delete),
4866                ] {
4867                    if let Some(op) = op {
4868                        for (f, t) in [
4869                            ("args", &op.args),
4870                            ("ok", &op.ok),
4871                            ("conflict", &op.conflict),
4872                            ("value", &op.value),
4873                            ("keys", &op.keys),
4874                        ] {
4875                            if let Some(t) = t
4876                                && let Some(expr) = t.strip_prefix("CEL:")
4877                                && let Err(e) = crate::cel::compile_check(expr.trim())
4878                            {
4879                                err(&mut d, format!("store.mcp.{label}.{f}: {e}"));
4880                            }
4881                        }
4882                    }
4883                }
4884            }
4885        },
4886        StoreKind::Http => match &s.store.http {
4887            None => err(
4888                &mut d,
4889                "store.kind is http but store.http is not set".into(),
4890            ),
4891            Some(h) => {
4892                if !(h.base_url.starts_with("https://") || h.base_url.starts_with("http://")) {
4893                    err(
4894                        &mut d,
4895                        format!(
4896                            "store.http.base_url must be an http(s) URL (got {})",
4897                            h.base_url
4898                        ),
4899                    );
4900                }
4901                if h.get.is_none() || h.put.is_none() {
4902                    err(
4903                        &mut d,
4904                        "store.http needs at least `get` and `put` operations".into(),
4905                    );
4906                }
4907                for (name, v) in &h.headers {
4908                    if super::is_secret_shaped_key(name) && !crate::sec::secret::has_secret_ref(v) {
4909                        err(
4910                            &mut d,
4911                            format!(
4912                                "store.http.headers['{name}'] looks like a credential but has an inline value"
4913                            ),
4914                        );
4915                    } else if let Some(e) = unresolved_secret_ref(v) {
4916                        err(&mut d, format!("store.http.headers['{name}']: {e}"));
4917                    }
4918                }
4919            }
4920        },
4921        StoreKind::File => {
4922            // No block is required: `kind: file` alone resolves a root from
4923            // the environment. The one thing that cannot work is an
4924            // explicit empty path — it would resolve to the process's working
4925            // directory, so it is refused here rather than discovered as a
4926            // state directory nobody meant to create.
4927            if let Some(f) = &s.store.file
4928                && f.path.as_deref().is_some_and(|p| p.trim().is_empty())
4929            {
4930                err(
4931                    &mut d,
4932                    "store.file.path is empty — set a directory, or omit the field to use $AGENTD_STATE_DIR / $XDG_STATE_HOME/agentd/state".into(),
4933                );
4934            }
4935        }
4936        StoreKind::Memory => {
4937            d.warnings.push(
4938                "store.kind is memory: state does not survive the process (dev/test only)".into(),
4939            );
4940        }
4941        StoreKind::None => {
4942            // A job-shaped instance (one-shot workflows, no listener) may run
4943            // without a store — a crash simply re-runs it. Anything long-lived
4944            // MUST be durable: an A2A listener or a long-lived start node.
4945            //
4946            // Reaching here with a long-lived instance means the operator
4947            // WROTE `none`, because `load` defaults the absent case to the file
4948            // store. So the message says how to take that default back, not
4949            // just which backends exist.
4950            if s.is_long_lived() {
4951                err(&mut d, "store.kind is none but the instance is long-lived (serves A2A / webhooks / a goal watchdog / has a loop|schedule|subscribe|signal|event|a2a|webhook start node) — configure a durable store (store.kind: file | mcp | http), or drop store.kind to get the local file store by default".into());
4952            } else if !s.workflows.is_empty() {
4953                d.warnings.push("store.kind is none: this one-shot run is not durable (a crash re-runs it from scratch); set store.kind for durability".into());
4954            }
4955        }
4956    }
4957    // The mirror of the checks above: each adapter validates the block it needs,
4958    // so a block that belongs to an adapter that is not selected is dead config.
4959    // Silence would be the wrong answer — `store.file.path` set beside
4960    // `kind: mcp` reads like state on disk and is not — but so would refusing to
4961    // start, since the block does no harm; the operator is told it is ignored.
4962    if s.store.file.is_some() && s.store.kind != StoreKind::File {
4963        d.warnings.push(format!(
4964            "store.file is set but store.kind is {} — the file adapter is not in use and the block is ignored",
4965            // The Debug name lowercased is exactly the YAML spelling of the
4966            // variant (`serde(rename_all = "lowercase")`), so the warning
4967            // quotes back what the operator wrote.
4968            format!("{:?}", s.store.kind).to_lowercase()
4969        ));
4970    }
4971    if let Some(ms) = s.store.checkpoint.debounce_ms
4972        && ms > 60_000
4973    {
4974        d.warnings.push(format!(
4975            "store.checkpoint.debounce_ms is {ms} (> 60s): progress may lag far behind reality"
4976        ));
4977    }
4978
4979    // knowledge / search / skills servers
4980    if let Some(k) = &s.knowledge.server
4981        && !server_known(k)
4982    {
4983        err(
4984            &mut d,
4985            format!("knowledge.server '{k}' is not a declared MCP server"),
4986        );
4987    }
4988    if let Some(k) = &s.search.server
4989        && !server_known(k)
4990    {
4991        err(
4992            &mut d,
4993            format!("search.server '{k}' is not a declared MCP server"),
4994        );
4995    }
4996    for src in &s.skills.sources {
4997        if !server_known(&src.server) {
4998            err(
4999                &mut d,
5000                format!(
5001                    "skills.sources[] references undeclared MCP server '{}'",
5002                    src.server
5003                ),
5004            );
5005        }
5006    }
5007    if let Some(c) = s.context.compact_at
5008        && !(c > 0.0 && c <= 1.0)
5009    {
5010        err(
5011            &mut d,
5012            format!("context.compact_at must be in (0, 1] (got {c})"),
5013        );
5014    }
5015
5016    // workflows (the structural minimum only; full validation is the engine's)
5017    let mut wf_names = std::collections::HashSet::new();
5018    for (i, w) in s.workflows.iter().enumerate() {
5019        let Some(obj) = w.as_object() else {
5020            err(&mut d, format!("workflows[{i}] must be an object"));
5021            continue;
5022        };
5023        // A `{dir}` entry names no workflow: it expands into one per matching
5024        // file, and each file carries its own name. Requiring one here would
5025        // mean inventing a name for a set.
5026        if obj.contains_key("dir") {
5027            continue;
5028        }
5029        let name = obj.get("name").and_then(Value::as_str).unwrap_or("");
5030        if name.trim().is_empty() {
5031            err(&mut d, format!("workflows[{i}] has no name"));
5032        } else if !wf_names.insert(name.to_string()) {
5033            err(
5034                &mut d,
5035                format!("workflows[]: duplicate workflow name '{name}'"),
5036            );
5037        }
5038        // A `model:` on a node names a TIER once a catalogue exists. Catching
5039        // the typo here keeps it a startup error instead of a run that
5040        // silently asks the provider for a model called `smal`.
5041        if !s.intelligence.models.is_empty()
5042            && let Some(steps) = obj.get("steps").and_then(Value::as_object)
5043        {
5044            for (sid, st) in steps {
5045                let Some(m) = st.get("model").and_then(Value::as_str) else {
5046                    continue;
5047                };
5048                if !s.intelligence.models.contains_key(m) {
5049                    err(
5050                        &mut d,
5051                        format!(
5052                            "workflow '{name}' step '{sid}': model {m:?} is not a declared tier (known: {})",
5053                            s.intelligence
5054                                .models
5055                                .keys()
5056                                .cloned()
5057                                .collect::<Vec<_>>()
5058                                .join(", ")
5059                        ),
5060                    );
5061                }
5062            }
5063        }
5064        // One entry, one source. `dir` is not in this list because a dir entry
5065        // returned above — it names a SET, and each file it expands to gets
5066        // checked as its own entry.
5067        let sources = ["file", "uri", "url", "steps"]
5068            .iter()
5069            .filter(|k| obj.contains_key(**k))
5070            .count();
5071        if sources != 1 {
5072            err(
5073                &mut d,
5074                format!(
5075                    "workflows['{name}'] must have exactly one of file | uri | url | steps (dir is a separate entry shape)"
5076                ),
5077            );
5078        }
5079        // The reference is folded against `vars` before the check, because a
5080        // workflow entry's `{{config.*}}` is resolved at LOAD time (workflow
5081        // documents are deliberately excluded from the settings-wide
5082        // substitution so that inline, file, url and dir entries are all
5083        // treated alike). Checking the raw token here rejected a perfectly
5084        // good `file: "{{config.wf_file}}"` as "does not exist". An
5085        // UNRESOLVABLE var is not reported here — the load-time fold names it
5086        // once, and two messages for one typo is worse than one.
5087        if let Some(f) = obj.get("file").and_then(Value::as_str) {
5088            let mut folded = Value::String(f.to_string());
5089            let mut ignored = Vec::new();
5090            substitute_config_vars(&mut folded, &s.vars, "workflow entry", &mut ignored);
5091            if ignored.is_empty()
5092                && let Some(path) = folded.as_str()
5093                && !std::path::Path::new(path).exists()
5094            {
5095                err(
5096                    &mut d,
5097                    format!("workflows['{name}'].file {path:?} does not exist"),
5098                );
5099            }
5100        }
5101    }
5102
5103    // lifecycle
5104    for (k, v) in &s.lifecycle.exit_code_map {
5105        if k != "3" && k != "7" {
5106            err(
5107                &mut d,
5108                format!(
5109                    "lifecycle.exit_code_map: only the policy codes 3 and 7 are remappable (got key {k:?})"
5110                ),
5111            );
5112        }
5113        if !(0..=255).contains(v) {
5114            err(
5115                &mut d,
5116                format!("lifecycle.exit_code_map[{k}] must be 0..=255 (got {v})"),
5117            );
5118        }
5119    }
5120    if s.lifecycle.watch_config && loaded.files.is_empty() {
5121        err(
5122            &mut d,
5123            "lifecycle.watch_config requires a config file (--config / AGENTD_CONFIG)".into(),
5124        );
5125    }
5126
5127    // a2a
5128    if let Some(l) = &s.a2a.listen {
5129        match super::ServeTarget::parse(l) {
5130            Ok(super::ServeTarget::Http { bind, tls }) => {
5131                let loopback = crate::net::http::is_loopback_host(super::serve_host_of(&bind));
5132                if tls && (s.a2a.tls.cert.is_none() || s.a2a.tls.key.is_none()) {
5133                    err(
5134                        &mut d,
5135                        "a2a.listen is https:// but a2a.tls.cert / a2a.tls.key are not set".into(),
5136                    );
5137                }
5138                if !loopback
5139                    && s.a2a.tls.client_ca.is_none()
5140                    && s.a2a.bearer.is_none()
5141                    && !s.interface.pairing.enabled
5142                {
5143                    err(&mut d, "a2a.listen on a non-loopback address needs client auth: a2a.bearer, interface.pairing, or a2a.tls.client_ca (mTLS — then EVERY caller needs a client certificate, bearer-only and paired included)".into());
5144                }
5145                if !tls && !loopback {
5146                    err(
5147                        &mut d,
5148                        "a2a.listen plaintext http:// is allowed for loopback only; use https://"
5149                            .into(),
5150                    );
5151                }
5152            }
5153            Ok(super::ServeTarget::Unix { .. }) => {
5154                // The kernel is the authenticator: only same-uid (or root)
5155                // peers may connect, so TLS material is meaningless here and
5156                // configuring it is a sign of a misunderstood posture.
5157                if s.a2a.tls.cert.is_some()
5158                    || s.a2a.tls.key.is_some()
5159                    || s.a2a.tls.client_ca.is_some()
5160                {
5161                    err(
5162                        &mut d,
5163                        "a2a.listen is unix:// — the kernel authenticates peers (same-uid); a2a.tls does not apply and must be unset".into(),
5164                    );
5165                }
5166            }
5167            Err(e) => err(&mut d, format!("a2a.listen: {e}")),
5168        }
5169    }
5170
5171    // interface (the display-client surface — it rides the A2A listener)
5172    if s.interface.enabled && s.a2a.listen.is_none() {
5173        err(
5174            &mut d,
5175            "interface.enabled requires a2a.listen (the interface is served on the A2A listener)"
5176                .into(),
5177        );
5178    }
5179    if s.interface.debug && !s.interface.enabled {
5180        d.warnings
5181            .push("interface.debug has no effect while interface.enabled is false".into());
5182    }
5183    for o in &s.interface.origins {
5184        // An origin is `scheme://host[:port]` — no path, no trailing slash.
5185        let ok = o
5186            .split_once("://")
5187            .map(|(scheme, rest)| {
5188                matches!(scheme, "http" | "https") && !rest.is_empty() && !rest.contains('/')
5189            })
5190            .unwrap_or(false);
5191        if !ok {
5192            err(
5193                &mut d,
5194                format!(
5195                    "interface.origins: {o:?} is not an origin (want scheme://host[:port], no path)"
5196                ),
5197            );
5198        }
5199    }
5200    // Display items: unknown names are skipped by clients — warn, don't refuse
5201    // (forward compatibility across client versions).
5202    for (edge, items) in [
5203        ("top", &s.interface.display.top),
5204        ("bottom", &s.interface.display.bottom),
5205    ] {
5206        for item in items.iter().flatten() {
5207            // `memory:<key>` renders whatever a WORKFLOW wrote to that key —
5208            // the extension point that lets the status line show a branch, a PR
5209            // number or a deploy state without the daemon learning to compute
5210            // any of them. The key still has to be a legal memory key, so a
5211            // typo is caught here rather than silently never rendering.
5212            if let Some(key) = item.strip_prefix("memory:") {
5213                if key.is_empty() {
5214                    d.errors.push(format!(
5215                        "interface.display.{edge}: {item:?} names no memory key"
5216                    ));
5217                } else if let Err(e) = crate::context::memory::Memory::check_key(key) {
5218                    d.errors
5219                        .push(format!("interface.display.{edge}: {item:?}: {e}"));
5220                }
5221                continue;
5222            }
5223            if !DISPLAY_ITEMS.contains(&item.as_str()) {
5224                d.warnings.push(format!(
5225                    "interface.display.{edge}: unknown item {item:?} (clients skip it); known: {}, \
5226                     or memory:<key> for a value a workflow maintains",
5227                    DISPLAY_ITEMS.join(", ")
5228                ));
5229            }
5230        }
5231    }
5232    // Pairing-code login.
5233    if s.interface.pairing.enabled {
5234        if !s.interface.enabled {
5235            err(
5236                &mut d,
5237                "interface.pairing.enabled requires interface.enabled (pairing rides the interface surface)".into(),
5238            );
5239        }
5240        if let Some(role) = s.interface.pairing.role
5241            && !matches!(role, Role::Operator | Role::User)
5242        {
5243            err(
5244                &mut d,
5245                "interface.pairing.role must be operator or user".into(),
5246            );
5247        }
5248    }
5249
5250    // webhooks (the inbound HTTP surface)
5251    let uses_webhook = s.workflows.iter().any(workflow_uses_webhook);
5252    if uses_webhook && s.webhooks.listen.is_none() {
5253        err(&mut d, "a `webhook` node (start or wait) is used but webhooks.listen is not set — configure webhooks.listen (https://host:port)".into());
5254    }
5255    if let Some(l) = &s.webhooks.listen {
5256        match super::ServeTarget::parse(l) {
5257            Ok(super::ServeTarget::Unix { .. }) => {
5258                err(
5259                    &mut d,
5260                    "webhooks.listen does not support unix:// (webhooks are an external surface); use https://".into(),
5261                );
5262            }
5263            Ok(super::ServeTarget::Http { bind, tls }) => {
5264                let loopback = crate::net::http::is_loopback_host(super::serve_host_of(&bind));
5265                if tls && (s.webhooks.tls.cert.is_none() || s.webhooks.tls.key.is_none()) {
5266                    err(
5267                        &mut d,
5268                        "webhooks.listen is https:// but webhooks.tls.cert / webhooks.tls.key are not set"
5269                            .into(),
5270                    );
5271                }
5272                if !tls && !loopback {
5273                    err(
5274                        &mut d,
5275                        "webhooks.listen plaintext http:// is allowed for loopback only; use https://"
5276                            .into(),
5277                    );
5278                }
5279                // Symmetric with the `a2a.listen` refusal above: both are inbound
5280                // listeners that TRIGGER work, so a reachable one must authenticate
5281                // its callers — an open webhook route hands the agent's workflows to
5282                // anyone who can reach the port. Auth is resolved per route
5283                // (`runtime::webhooks::build_verify`: the node's own `auth`, else the
5284                // listener `default_auth`), so refuse only when a route would really
5285                // end up unverified — a listener whose every node signs is fine.
5286                // `none: true` is the documented loopback-only dev opt-out, not
5287                // authentication, so it does not buy an open public bind; the schema
5288                // offers no other way to ask for one, and this deliberately does not
5289                // invent one.
5290                if !loopback && !webhook_default_verifies(s.webhooks.default_auth.as_ref()) {
5291                    let mut open: Vec<String> = Vec::new();
5292                    let mut nodes = 0usize;
5293                    for w in &s.workflows {
5294                        let wf = w.get("name").and_then(Value::as_str).unwrap_or("?");
5295                        for (node, auth) in webhook_nodes(w) {
5296                            nodes += 1;
5297                            if !webhook_auth_verifies(auth) {
5298                                open.push(format!("{wf}/{node}"));
5299                            }
5300                        }
5301                    }
5302                    if !open.is_empty() {
5303                        err(
5304                            &mut d,
5305                            format!(
5306                                "webhooks.listen on a non-loopback address needs auth: set webhooks.default_auth (hmac, bearer or header), or give every `webhook` node its own `auth` (HMAC recommended) — unauthenticated: {}",
5307                                open.join(", ")
5308                            ),
5309                        );
5310                    } else if nodes == 0 {
5311                        // Nothing is reachable yet (every path answers 404), so this
5312                        // is not a live hole — but the next node added would be one.
5313                        d.warnings.push("webhooks.listen is non-loopback with no webhooks.default_auth — every webhook node must declare its own `auth` (HMAC recommended)".into());
5314                    }
5315                }
5316            }
5317            Err(e) => err(&mut d, format!("webhooks.listen: {e}")),
5318        }
5319    }
5320
5321    // goal watchdog
5322    if let Some(g) = &s.goal {
5323        let via = g.check.via.as_deref().unwrap_or("both");
5324        if via == "condition" && g.check.condition.is_none() {
5325            err(
5326                &mut d,
5327                "goal.check.via is 'condition' but goal.check.condition is not set".into(),
5328            );
5329        }
5330        for (label, act) in [("on_achieved", &g.on_achieved), ("on_stuck", &g.on_stuck)] {
5331            if let Some(GoalAction::Workflow(name)) = act
5332                && !s
5333                    .workflows
5334                    .iter()
5335                    .any(|w| w.get("name").and_then(Value::as_str) == Some(name.as_str()))
5336            {
5337                err(
5338                    &mut d,
5339                    format!(
5340                        "goal.{label} references workflow '{name}', which is not defined in workflows"
5341                    ),
5342                );
5343            }
5344        }
5345    }
5346
5347    let mut peer_names = std::collections::HashSet::new();
5348    for p in &s.a2a.peers {
5349        if !peer_names.insert(p.name.as_str()) {
5350            err(
5351                &mut d,
5352                format!("a2a.peers[]: duplicate peer name '{}'", p.name),
5353            );
5354        }
5355        let unix_peer = p.endpoint.starts_with("unix://") || p.endpoint.starts_with("unix:");
5356        if unix_peer && !cfg!(unix) {
5357            err(
5358                &mut d,
5359                format!("a2a peer '{}': unix:// endpoints are unix-only", p.name),
5360            );
5361        }
5362        if !unix_peer && !p.endpoint.starts_with("https://") && !p.endpoint.starts_with("http://") {
5363            err(
5364                &mut d,
5365                format!(
5366                    "a2a peer '{}': endpoint must be http(s):// (or unix:///path for a co-located peer)",
5367                    p.name
5368                ),
5369            );
5370        }
5371        if p.client_cert.is_some() != p.client_key.is_some() {
5372            err(
5373                &mut d,
5374                format!(
5375                    "a2a peer '{}': client_cert and client_key must be set together",
5376                    p.name
5377                ),
5378            );
5379        }
5380        if let Some(auth) = &p.auth {
5381            for e in validate_auth_block(auth, &format!("a2a peer '{}'", p.name)) {
5382                err(&mut d, e);
5383            }
5384            if auth.kind == AuthKind::Aws {
5385                err(
5386                    &mut d,
5387                    format!(
5388                        "a2a peer '{}': auth kind `aws` is not accepted for peers — use static, oauth2 or spiffe",
5389                        p.name
5390                    ),
5391                );
5392            }
5393        }
5394        for (h, v) in &p.headers {
5395            if super::is_secret_shaped_key(h) && !crate::sec::secret::has_secret_ref(v) {
5396                err(
5397                    &mut d,
5398                    format!(
5399                        "a2a peer '{}' header '{h}' looks like a credential but has an inline value",
5400                        p.name
5401                    ),
5402                );
5403            } else if let Some(e) = unresolved_secret_ref(v) {
5404                err(&mut d, format!("a2a peer '{}' header '{h}': {e}", p.name));
5405            }
5406        }
5407    }
5408    for (i, pr) in s.a2a.principals.iter().enumerate() {
5409        let m = &pr.matcher;
5410        if m.san.is_none()
5411            && m.sub.is_none()
5412            && m.bearer_ref.is_none()
5413            && m.aauth_agent.is_none()
5414            && !m.any
5415        {
5416            err(
5417                &mut d,
5418                format!(
5419                    "a2a.principals[{i}]: match needs one of san | sub | bearer_ref | aauth_agent | any"
5420                ),
5421            );
5422        }
5423        if m.any && pr.role == Role::Operator {
5424            err(
5425                &mut d,
5426                format!("a2a.principals[{i}]: `any` cannot grant the operator role"),
5427            );
5428        }
5429    }
5430
5431    // observability
5432    if let Some(l) = &s.observability.log_level
5433        && crate::obs::log::Level::parse(l).is_none()
5434    {
5435        err(
5436            &mut d,
5437            format!("observability.log_level: {l:?} (want trace|debug|info|warn|error)"),
5438        );
5439    }
5440
5441    // secrets provenance: the FILE layer must not carry inline secrets
5442    for m in secret_violations(&loaded.file_doc) {
5443        err(&mut d, m);
5444    }
5445    for f in &s.observability.audit.sink.clone().unwrap_or_default() {
5446        if *f == AuditSink::Store && s.store.kind == StoreKind::None {
5447            err(
5448                &mut d,
5449                "observability.audit.sink includes `store` but store.kind is none".into(),
5450            );
5451        }
5452    }
5453
5454    // trifecta over the root grant
5455    let mut tags = Vec::new();
5456    for srv in &s.mcp.servers {
5457        match srv.tag_set() {
5458            Ok(t) if t.is_empty() => tags.push(crate::sec::scope::TrifectaTag::UntrustedInput),
5459            Ok(t) => tags.extend(t),
5460            Err(_) => {}
5461        }
5462    }
5463    // The local command runner is a capability like any other, and it carries
5464    // the two heaviest legs: it can touch anything inside `workdir`
5465    // (`sensitive`) and it can talk to the network if the allow-list lets it
5466    // (`egress`). The registry tags it that way, but the registry is built
5467    // after validation — so the tags have to be contributed here, or enabling
5468    // `exec` next to an untrusted-input server assembles the whole trifecta
5469    // and starts anyway. Gated on the feature: without it `exec` is
5470    // mapping-only, and whichever MCP server provides it carries its own tags.
5471    #[cfg(feature = "exec")]
5472    if s.security.exec.enabled {
5473        tags.push(crate::sec::scope::TrifectaTag::Sensitive);
5474        tags.push(crate::sec::scope::TrifectaTag::Egress);
5475    }
5476    use crate::sec::scope::{TrifectaVerdict, check_trifecta};
5477    if check_trifecta(tags, s.security.allow_trifecta) == TrifectaVerdict::RefusedTrifecta {
5478        err(&mut d, "lethal-trifecta refused: the root grant wires untrusted_input + sensitive + egress into one agent; narrow the tags or set security.allow_trifecta (audited)".into());
5479    }
5480
5481    // Workflow definitions — the SAME strict parse the runtime runs at startup
5482    // (`load_workflows`). Without it, `--validate-config` passes a config that
5483    // then exits 2 on the first real start: a typo'd step field (`prompt:` on
5484    // an `agent` node) validated clean and failed in production, which is what
5485    // the pre-flight check exists to prevent. Reported after the structural
5486    // checks above so the more basic error still leads. `file:`/`uri:` refs
5487    // resolve at startup, so only inline definitions are checkable here.
5488    // `store.durability.{a2a,steps}` is parsed, published in the schema, and
5489    // surfaced in the manifest — and read by no writer. `eventual` therefore
5490    // promised a weaker-but-faster durability that was never implemented, on the
5491    // one guarantee agentd exists to make. Refusing the value is better than
5492    // honouring it: a durability dial nobody wired is a lie, and implementing it
5493    // would trade away the property the product is for. `strict` (the default)
5494    // is what the engine already does, so only a config that asked for the
5495    // unimplemented setting fails, and it fails saying so.
5496    for (path, level) in [
5497        ("store.durability.a2a", s.store.durability.a2a),
5498        ("store.durability.steps", s.store.durability.steps),
5499    ] {
5500        if level == Some(DurabilityLevel::Eventual) {
5501            d.errors.push(format!(
5502                "{path}: `eventual` is not implemented — every durable write is strict \
5503                 (checkpoint-before-effect). Remove the key; `strict` is the default and \
5504                 the only behaviour."
5505            ));
5506        }
5507    }
5508    // The reference preflight, aggregated: every secret, secret-file and config
5509    // var an INLINE workflow mentions, checked now and reported TOGETHER.
5510    // (Definitions arriving from files, URLs and directories get the same check
5511    // at startup, after they are fetched.) Failing on whichever reference
5512    // happens to be evaluated first turns configuring a deployment into a
5513    // guessing game played one restart at a time.
5514    for (i, w) in s.workflows.iter().enumerate() {
5515        if w.get("steps").is_some() {
5516            for msg in missing_references(w, &format!("workflows[{i}]"), &s.vars) {
5517                err(&mut d, msg);
5518            }
5519        }
5520    }
5521    for w in &s.workflows {
5522        // A reference — file, uri, url or dir — resolves at startup, so there
5523        // is nothing to parse here. Only inline definitions are checkable.
5524        if w.get("steps").is_none() {
5525            // A credential in a `headers` value must be a reference, the same
5526            // rule every other header in this config follows.
5527            if let Some(h) = w.get("headers").and_then(Value::as_object) {
5528                for (k, v) in h {
5529                    if let Some(val) = v.as_str()
5530                        && super::is_secret_shaped_key(k)
5531                        && !crate::sec::secret::has_secret_ref(val)
5532                    {
5533                        d.errors.push(format!(
5534                            "workflows: headers[{k:?}] looks like a credential — use {{{{secret:NAME}}}} rather than a literal"
5535                        ));
5536                    }
5537                }
5538            }
5539            continue;
5540        }
5541        if let Err(errs) = crate::engine::model::parse_workflow(w) {
5542            // The parser's messages already name the workflow and the step.
5543            d.errors.extend(errs);
5544        }
5545        // Fan-out is checked HERE rather than in the parser because the ceiling
5546        // is a config value the parser cannot see.
5547        let cap = s
5548            .limits
5549            .workflow
5550            .fan_out
5551            .unwrap_or(crate::engine::model::MAX_BATCH_PARALLEL as u32);
5552        let wname = w.get("name").and_then(Value::as_str).unwrap_or("?");
5553        if let Some(steps) = w.get("steps").and_then(Value::as_object) {
5554            for (sid, step) in steps {
5555                let want = step.get("parallel").and_then(Value::as_u64).or_else(|| {
5556                    step.get("batch")
5557                        .and_then(|b| b.get("parallel"))
5558                        .and_then(Value::as_u64)
5559                });
5560                if let Some(want) = want
5561                    && want > cap as u64
5562                {
5563                    d.errors.push(format!(
5564                        "workflow {wname:?} step {sid:?}: parallel {want} exceeds \
5565                         limits.workflow.fan_out ({cap}) — raise the limit or lower the step"
5566                    ));
5567                }
5568            }
5569        }
5570    }
5571    d
5572}
5573
5574/// Start-node kinds that keep an instance alive indefinitely. An instance
5575/// running one drains rather than finishing, so it needs a durable store: its
5576/// state has no re-run to fall back on.
5577/// Re-exported so existing callers keep a name to use; the judgement itself
5578/// lives in `engine::model` with the kind table, so it cannot drift from it.
5579pub use crate::engine::model::is_long_lived_start;
5580
5581/// Whether a raw workflow document has a long-lived start node.
5582pub fn workflow_is_long_lived(w: &Value) -> bool {
5583    w.get("steps")
5584        .and_then(Value::as_object)
5585        .is_some_and(|steps| {
5586            steps.values().any(|st| {
5587                st.get("kind")
5588                    .and_then(Value::as_str)
5589                    .is_some_and(is_long_lived_start)
5590            })
5591        })
5592}
5593
5594/// Whether a raw workflow document uses the inbound webhook surface — a
5595/// `webhook` start node, or a `wait: {on: webhook}` callback (either needs
5596/// `webhooks.listen`).
5597pub fn workflow_uses_webhook(w: &Value) -> bool {
5598    w.get("steps")
5599        .and_then(Value::as_object)
5600        .is_some_and(|steps| {
5601            steps.values().any(|st| {
5602                let kind = st.get("kind").and_then(Value::as_str);
5603                kind == Some("webhook")
5604                    || (matches!(kind, Some("wait") | Some("await"))
5605                        && st.get("on").and_then(Value::as_str) == Some("webhook"))
5606            })
5607        })
5608}
5609
5610/// The inbound-webhook routes a raw workflow document arms, as
5611/// `(node id, declared auth)`. Two shapes, matching what the listener reads: a
5612/// `webhook` start node carries its `auth` at the top level, while a
5613/// `wait: {on: webhook}` callback carries it under `webhook.auth`
5614/// (`runtime::webhooks::webhook_wait`).
5615fn webhook_nodes(w: &Value) -> Vec<(&str, Option<&Value>)> {
5616    let Some(steps) = w.get("steps").and_then(Value::as_object) else {
5617        return Vec::new();
5618    };
5619    steps
5620        .iter()
5621        .filter_map(|(id, st)| {
5622            let kind = st.get("kind").and_then(Value::as_str);
5623            if kind == Some("webhook") {
5624                Some((id.as_str(), st.get("auth")))
5625            } else if matches!(kind, Some("wait") | Some("await"))
5626                && st.get("on").and_then(Value::as_str) == Some("webhook")
5627            {
5628                Some((id.as_str(), st.get("webhook").and_then(|c| c.get("auth"))))
5629            } else {
5630                None
5631            }
5632        })
5633        .collect()
5634}
5635
5636/// Whether a node's declared `auth` actually verifies the caller. This mirrors
5637/// `runtime::webhooks::build_verify` INCLUDING its type tests — there a
5638/// non-object `hmac`/`header` or a non-string `bearer` is not a verifier and
5639/// falls through, so counting it as auth here would bless a route the listener
5640/// serves open. `none: true` short-circuits to `Verify::None`, so it is the
5641/// opposite of authentication.
5642fn webhook_auth_verifies(auth: Option<&Value>) -> bool {
5643    let Some(a) = auth else { return false };
5644    if a.get("none").and_then(Value::as_bool) == Some(true) {
5645        return false;
5646    }
5647    a.get("hmac").and_then(Value::as_object).is_some()
5648        || a.get("header").and_then(Value::as_object).is_some()
5649        || a.get("bearer").and_then(Value::as_str).is_some()
5650}
5651
5652/// The same question for the listener-wide `default_auth` (the typed twin,
5653/// `runtime::webhooks::build_verify_typed`): `none` wins over everything, and a
5654/// declared-but-incomplete verifier still counts — the listener refuses to spawn
5655/// on it, which fails closed.
5656fn webhook_default_verifies(d: Option<&WebhookAuth>) -> bool {
5657    d.is_some_and(|d| !d.none && (d.hmac.is_some() || d.bearer.is_some() || d.header.is_some()))
5658}
5659
5660fn validate_budget(b: &Budget, at: &str, d: &mut Diagnostics) {
5661    for (i, w) in b.windows.iter().enumerate() {
5662        if w.tokens.is_none() && w.requests.is_none() {
5663            d.errors
5664                .push(format!("{at}.windows[{i}]: set tokens and/or requests"));
5665        }
5666        if let Some(r) = &w.reset {
5667            // `HH:MMZ` is ASCII by construction, and the ASCII test must come
5668            // BEFORE the byte slices: `r.len()` is bytes, so a multi-byte char
5669            // (`0é:0Z` is six bytes) would otherwise make `r[..2]` land inside a
5670            // character and panic. A config error is exit 2, never a panic.
5671            let ok = r.len() == 6
5672                && r.is_ascii()
5673                && r.ends_with('Z')
5674                && r[..2].parse::<u32>().is_ok_and(|h| h < 24)
5675                && &r[2..3] == ":"
5676                && r[3..5].parse::<u32>().is_ok_and(|m| m < 60);
5677            if !ok {
5678                d.errors.push(format!(
5679                    "{at}.windows[{i}].reset must be HH:MMZ (got {r:?})"
5680                ));
5681            }
5682            if !w.per.is_calendar() {
5683                d.warnings.push(format!(
5684                    "{at}.windows[{i}].reset is only meaningful for day/week windows"
5685                ));
5686            }
5687        }
5688    }
5689    if let Some(f) = b.slow.factor
5690        && !(f > 0.0 && f <= 1.0)
5691    {
5692        d.errors
5693            .push(format!("{at}.slow.factor must be in (0, 1] (got {f})"));
5694    }
5695    if b.on_exhausted == BudgetTactic::Degrade && b.degrade.model.is_none() {
5696        d.errors.push(format!(
5697            "{at}.on_exhausted is degrade but {at}.degrade.model is not set"
5698        ));
5699    }
5700    if b.reserve.estimate == ReserveEstimate::Fixed && b.reserve.fixed.is_none() {
5701        d.errors.push(format!(
5702            "{at}.reserve.estimate is fixed but {at}.reserve.fixed is not set"
5703        ));
5704    }
5705}
5706
5707/// Secret-bearing paths that must be REFERENCES when they come from a file.
5708const FILE_SECRET_PATHS: &[&str] = &[
5709    "/intelligence/token",
5710    "/a2a/bearer",
5711    "/security/aauth/enroll_token",
5712];
5713
5714/// Inline (non-reference) credentials found in the FILE document.
5715fn secret_violations(file_doc: &Value) -> Vec<String> {
5716    let mut out = Vec::new();
5717    for p in FILE_SECRET_PATHS {
5718        if let Some(Value::String(v)) = file_doc.pointer(p)
5719            && !crate::sec::secret::has_secret_ref(v)
5720        {
5721            out.push(format!(
5722                "config file: {} carries an inline credential; use {{{{secret:NAME}}}} / {{{{secret-file:PATH}}}} (or set it from env/flag)",
5723                p.trim_start_matches('/').replace('/', ".")
5724            ));
5725        }
5726    }
5727    if let Some(servers) = file_doc.pointer("/mcp/servers").and_then(Value::as_array) {
5728        for s in servers {
5729            if let Some(Value::String(v)) = s.pointer("/oauth/client_secret")
5730                && !crate::sec::secret::has_secret_ref(v)
5731            {
5732                out.push(format!(
5733                    "config file: mcp server '{}' oauth.client_secret carries an inline credential; use a {{{{secret:…}}}} reference",
5734                    s.get("name").and_then(Value::as_str).unwrap_or("?")
5735                ));
5736            }
5737        }
5738    }
5739    out
5740}
5741
5742// ---------------------------------------------------------------------------
5743// Reload partition
5744// ---------------------------------------------------------------------------
5745
5746/// Restart-only path prefixes: a live reload whose effective document differs
5747/// under any of these is refused (`restart_required`).
5748pub const RESTART_ONLY_PATHS: &[&str] = &[
5749    "config_version",
5750    "agent.name",
5751    "store.kind",
5752    "store.prefix",
5753    "store.mcp",
5754    "store.http",
5755    // Moving the state directory under a running instance would strand every
5756    // key it has written, so it joins the other store paths as restart-only.
5757    "store.file",
5758    "lifecycle.run_until",
5759    "lifecycle.drain_timeout",
5760    "lifecycle.run_id",
5761    "lifecycle.exit_code_map",
5762    "lifecycle.watch_config",
5763    "a2a.listen",
5764    "a2a.tls",
5765    "a2a.bearer",
5766    // Arming the observation feed is a startup decision: the `SharedFeed` is
5767    // built only when `interface.enabled` was true at boot, so turning the
5768    // interface ON at runtime would pass every settings gate and still have no
5769    // feed to publish onto. (Turning it OFF does work, because those gates are
5770    // read live — but a knob that reloads in one direction only is worse than
5771    // one that plainly refuses.) Pairing state is likewise built at boot and
5772    // handed to the listener's `Auth`.
5773    "interface.enabled",
5774    "interface.pairing",
5775    // `store.max_value_bytes` rides the `Policy` built once at startup and
5776    // handed to the `Durable` layer, which the reload does not rebuild — the
5777    // same shape as the rest of `store.*`. Listed rather than silently
5778    // reloadable: a cap an operator believed they had raised, while writes
5779    // were still being refused at the old one, is exactly the lie this
5780    // partition exists to prevent.
5781    "store.max_value_bytes",
5782    // The webhook listener's SOCKET, not its rules: rebinding an address or
5783    // swapping a TLS identity needs a restart, while `webhooks.default_auth`
5784    // and the routes themselves (which live in `workflows[].steps[]`) are
5785    // rebuilt into the live handler on reload.
5786    "webhooks.listen",
5787    "webhooks.tls",
5788    "observability.otel",
5789    "observability.metrics_addr",
5790    "observability.health_file",
5791    "observability.events_ring",
5792    "observability.traceparent",
5793    "security",
5794];
5795
5796/// Every configuration path that a reload APPLIES, as the counterpart to
5797/// [`RESTART_ONLY_PATHS`].
5798///
5799/// The two lists together must cover the whole configuration surface, which
5800/// [`every_config_path_is_classified`] enforces against the generated schema.
5801/// That test is the point of this list: a config field that is in neither list
5802/// is not "probably fine", it is **unexamined** — and three shipped defects
5803/// (`a2a.principals`, the webhook routes, `interface.origins`) were all fields
5804/// nobody had classified. Each reported a successful reload and changed
5805/// nothing, because the value was copied into a long-lived structure at
5806/// startup that the reload never rebuilt.
5807///
5808/// Being listed here is a claim that the running daemon honours a change to
5809/// the path without a restart — either because it is read live from
5810/// `self.settings`, or because `reload.rs` rebuilds what caches it. It is NOT
5811/// a claim that a test proves it; the paths whose effect is proven end to end
5812/// are the ones a defect was found in. When you add a config field, add it to
5813/// one of the two lists, and prefer [`RESTART_ONLY_PATHS`] when unsure: a
5814/// refusal an operator can see beats an apply that lied.
5815pub const RELOADABLE_PATHS: &[&str] = &[
5816    "a2a.conversation_ttl",
5817    "a2a.peers",
5818    "a2a.principals",
5819    "a2a.push",
5820    "agent.approval",
5821    "agent.ask_human_fallback",
5822    "agent.conversation_budget",
5823    "agent.instruction",
5824    "agent.max_parallel_turns",
5825    "agent.on_workflow_finished",
5826    "agent.preflight",
5827    "agent.prompt",
5828    "agent.tools",
5829    "agent.wake_on",
5830    "context.compact_at",
5831    "context.keep_last",
5832    "context.model_window",
5833    "context.plan",
5834    "context.summarize",
5835    "context.template",
5836    "context.templates",
5837    "goal.check",
5838    "goal.on_achieved",
5839    "goal.on_stuck",
5840    "goal.statement",
5841    "goal.stuck_after",
5842    "identity.autonomous_as",
5843    "identity.labels",
5844    "intelligence.auth",
5845    "intelligence.budget",
5846    "intelligence.default",
5847    "intelligence.dialect",
5848    "intelligence.endpoints",
5849    "intelligence.headers",
5850    "intelligence.model",
5851    "intelligence.models",
5852    "intelligence.preflight_model",
5853    "intelligence.pricing",
5854    "intelligence.structured_output",
5855    "intelligence.swap_policy",
5856    "intelligence.timeout",
5857    "intelligence.token",
5858    "intelligence.token_file",
5859    "interface.debug",
5860    "interface.display",
5861    "interface.origins",
5862    "knowledge.auto_context",
5863    "knowledge.server",
5864    "lifecycle.idle_grace",
5865    "lifecycle.until_signal",
5866    "limits.inline_max_bytes",
5867    "limits.max_message_depth",
5868    "limits.max_runs",
5869    "limits.run",
5870    "limits.step_timeout",
5871    "limits.subagents",
5872    "limits.workflow",
5873    "mcp.default_timeout",
5874    "mcp.servers",
5875    "memory.list_default_limit",
5876    "memory.max_value_bytes",
5877    "observability.audit",
5878    "observability.log_content",
5879    "observability.log_level",
5880    "observability.report_file",
5881    "observability.runtime_events",
5882    "search.server",
5883    "services",
5884    "skills.dir",
5885    "skills.max_bytes",
5886    "skills.max_loaded",
5887    "skills.reference_prefix",
5888    "skills.sources",
5889    "store.audit",
5890    "store.checkpoint",
5891    "store.durability",
5892    "store.on_error",
5893    "store.retention",
5894    "store.timeout",
5895    "streams",
5896    "subagents.allow_freeform",
5897    "subagents.defaults",
5898    "subagents.templates",
5899    "tools.disabled",
5900    "tools.overrides",
5901    "vars",
5902    "webhooks.default_auth",
5903    "workflows.allow_private",
5904    "workflows.armed",
5905    "workflows.concurrency",
5906    "workflows.description",
5907    "workflows.dir",
5908    "workflows.durable",
5909    "workflows.file",
5910    "workflows.glob",
5911    "workflows.headers",
5912    "workflows.inputs",
5913    "workflows.key",
5914    "workflows.limits",
5915    "workflows.name",
5916    "workflows.outputs",
5917    "workflows.priority",
5918    "workflows.state",
5919    "workflows.steps",
5920    "workflows.timeout",
5921    "workflows.tool",
5922    "workflows.unload",
5923    "workflows.uri",
5924    "workflows.url",
5925    "workflows.version",
5926];
5927
5928/// The restart-only paths whose values differ between two effective documents.
5929pub fn restart_only_diff(running: &Value, candidate: &Value) -> Vec<String> {
5930    RESTART_ONLY_PATHS
5931        .iter()
5932        .filter(|p| {
5933            let ptr = format!("/{}", p.replace('.', "/"));
5934            running.pointer(&ptr) != candidate.pointer(&ptr)
5935        })
5936        .map(|p| (*p).to_string())
5937        .collect()
5938}
5939
5940/// The `--help` section for the v2 paths.
5941pub fn help_section() -> String {
5942    paths::help_section_in(&paths::bindings_of(&schema::schema()))
5943}
5944
5945/// The v2 `--help` text: usage, the alias flags, the removed flags, and every
5946/// config path (flag · env).
5947pub fn help_text() -> String {
5948    let mut out = format!(
5949        "agentd {ver} — a durable, workflow-driven agent (config schema v2)\n\
5950         \n\
5951         USAGE:\n\
5952         \x20 agentd --config <settings.yaml> [--config <overlay.yaml> …] [--<path> <value> …]\n\
5953         \x20 agentd --prompt <TEXT> --intelligence <URL>                    # one-shot: ask, answer, exit\n\
5954         \x20 agentd --instruction <TEXT> --intelligence <URL> [--mcp name=endpoint …]   # one-shot sugar\n\
5955         \x20 agentd tui|ui --config <settings.yaml> [--<path> <value> …]   # + a display client\n\
5956         \n\
5957         Every setting is a document path (YAML/JSON file, AGENTD_<PATH> env, --<path> flag);\n\
5958         several files merge in order (later wins). Precedence: built-in < files < env < flags.\n\
5959         \n\
5960         ALIASES (short spellings of paths):\n",
5961        ver = crate::VERSION
5962    );
5963    for a in ALIASES {
5964        let shape = match a.kind {
5965            AliasKind::Set | AliasKind::SetFromFile => "<value>",
5966            AliasKind::SetTrue => "",
5967            AliasKind::Append => "<value>  (adds one)",
5968            AliasKind::Special => "<value>",
5969        };
5970        out.push_str(&format!("  {:<32} {} → {}\n", a.flag, shape, a.path));
5971    }
5972    out.push_str(
5973        "\nSUBCOMMANDS (run the daemon with a display client attached):\n\
5974         \x20 tui                        + the terminal UI (fullscreen; --inline for in-place)\n\
5975         \x20 ui                         + the web UI, opened in a browser\n\
5976         \x20                            both need `interface.enabled: true`, which the\n\
5977         \x20                            subcommand sets for you; the client exits with the daemon.\n\
5978         \x20                            Detached instead: run `agentd -c …`, then `agentd-tui\n\
5979         \x20                            --endpoint <url>` (npm i -g @agentd-dev/cli).\n\
5980         \nCONTROL:\n\
5981         \x20 -c, --config <PATH>        a settings file (repeatable; `=` form too; or AGENT_CONFIG=a.yaml:b.yaml)\n\
5982         \x20 --validate-config          load+validate everything, print the verdict, exit 0/2\n\
5983         \x20 --config-schema            print the settings JSON Schema and exit\n\
5984         \x20 --context-template        print the built-in system-prompt template and exit\n\
5985     \x20 --workflow-schema          print the workflow JSON Schema + node registry and exit\n\
5986         \x20 --capabilities             print the capabilities manifest and exit\n\
5987         \x20 --login <target>           complete an OAuth device-login for an endpoint (e.g. mcp:<name>) and cache the token\n\
5988         \x20 --logout <target>          evict a cached credential\n\
5989         \x20 --prompt-missing           ask interactively (echo off, on /dev/tty) for each {{secret:NAME}} the startup preflight finds missing; refused without a controlling terminal\n\
5990         \x20 --env <FILE>               load a dotenv file into this process's environment (repeatable; real env wins, later files win)\n\
5991         \x20 -h, --help / -V, --version\n\
5992         \nREMOVED FLAGS:\n",
5993    );
5994    for (flag, hint) in REMOVED_FLAGS {
5995        out.push_str(&format!("  {flag:<32} {hint}\n"));
5996    }
5997    out.push('\n');
5998    out.push_str(&help_section());
5999    out
6000}
6001
6002#[cfg(test)]
6003mod tests {
6004    use super::*;
6005    use std::io::Write;
6006
6007    fn args(v: &[&str]) -> Vec<String> {
6008        v.iter().map(|s| s.to_string()).collect()
6009    }
6010
6011    fn write_tmp(contents: &str, ext: &str) -> tempfile::NamedTempFile {
6012        let mut f = tempfile::Builder::new()
6013            .suffix(&format!(".{ext}"))
6014            .tempfile()
6015            .unwrap();
6016        f.write_all(contents.as_bytes()).unwrap();
6017        f.flush().unwrap();
6018        f
6019    }
6020
6021    /// The environment a test config loads under.
6022    ///
6023    /// Also seeds the credential the shared `CATALOG` fixture references.
6024    /// Validation now runs the SAME whole-document reference scan startup runs,
6025    /// so a fixture naming `{{secret:BILLING}}` is refused unless that secret
6026    /// resolves — which is the point of the change, and makes these fixtures
6027    /// behave like a real deployment. Seeded through the prompted-values store
6028    /// rather than `std::env::set_var`: it is additive and never removes, so
6029    /// tests running in parallel cannot unset each other's secrets.
6030    fn base_env() -> Vec<(String, String)> {
6031        // Only the two the loaded fixtures reference. `SUB_WINDOW_TEST_UNSET`
6032        // is deliberately NOT seeded — a test asserts the missing case, and
6033        // seeding it would quietly delete that coverage.
6034        for name in ["BILLING", "PEER"] {
6035            crate::sec::secret::set_prompted(name, "test-value".into());
6036        }
6037        vec![(
6038            "AGENTD_INTELLIGENCE_ENDPOINTS".into(),
6039            "https://intel.example/v1".into(),
6040        )]
6041    }
6042
6043    // ---- schema ↔ struct agreement -----------------------------------------
6044
6045    /// serde's `deny_unknown_fields` error names the expected fields; that
6046    /// list IS the struct's field set — compare it with the schema properties
6047    /// at every object, so neither can diverge from the other unnoticed.
6048    fn struct_fields_at(doc_path: &str) -> Vec<String> {
6049        // Build a document that is empty except for a probe key at `doc_path`.
6050        let mut probe = Value::Object(Map::new());
6051        let path = if doc_path.is_empty() {
6052            "__probe__".to_string()
6053        } else {
6054            format!("{doc_path}.__probe__")
6055        };
6056        paths::set_path(&mut probe, &path, json!(1));
6057        let err = Settings::from_document(probe, "t").expect_err("probe must be rejected");
6058        // "… unknown field `__probe__`, expected one of `a`, `b`, `c` …" (or
6059        // "expected `a` or `b`" for two, "expected `a`" for one).
6060        let after = err.split("expected").nth(1).unwrap_or("");
6061        let mut out: Vec<String> = after
6062            .split('`')
6063            .skip(1)
6064            .step_by(2)
6065            .map(str::to_string)
6066            .collect();
6067        out.sort();
6068        out
6069    }
6070
6071    fn schema_props_at(schema: &Value, doc_path: &str) -> Vec<String> {
6072        let mut node = schema.clone();
6073        let defs = schema.get("$defs").cloned().unwrap_or(Value::Null);
6074        for seg in doc_path.split('.').filter(|s| !s.is_empty()) {
6075            let props = node.get("properties").cloned().unwrap_or(Value::Null);
6076            node = props.get(seg).cloned().unwrap_or(Value::Null);
6077            if let Some(r) = node.get("$ref").and_then(Value::as_str)
6078                && let Some(name) = r.strip_prefix("#/$defs/")
6079            {
6080                node = defs.get(name).cloned().unwrap_or(Value::Null);
6081            }
6082        }
6083        let mut out: Vec<String> = node
6084            .get("properties")
6085            .and_then(Value::as_object)
6086            .map(|m| m.keys().cloned().collect())
6087            .unwrap_or_default();
6088        out.sort();
6089        out
6090    }
6091
6092    /// The same agreement check for types that live INSIDE a collection —
6093    /// a map value (`services.<name>` → `Service`) and an array item
6094    /// (`a2a.peers[]` → `A2aPeer`).
6095    ///
6096    /// `schema_matches_struct_at_every_object` walks `properties` segment by
6097    /// segment, so it cannot reach through `additionalProperties` or `items`
6098    /// and never covered these two. That gap is exactly where the published
6099    /// schema drifted: `Service.kind` still said mcp-only against four kinds in
6100    /// the loader, `Service.methods` and `A2aPeer.service` were absent, and
6101    /// with `additionalProperties: false` that made our OWN shipped
6102    /// examples/voice/hands.yaml red in any editor honouring the schema.
6103    ///
6104    /// Note what this could NOT have caught: CI regenerates the published files
6105    /// from the binary and diffs, which proves the file matches the generator
6106    /// and says nothing about whether the generator matches the loader.
6107    #[test]
6108    fn schema_matches_struct_for_collection_item_types() {
6109        /// The struct's field set, via serde's `deny_unknown_fields` error on a
6110        /// probe document that reaches into the collection.
6111        fn fields_of(probe: Value) -> Vec<String> {
6112            let err = Settings::from_document(probe, "t").expect_err("probe must be rejected");
6113            let after = err.split("expected").nth(1).unwrap_or("");
6114            let mut out: Vec<String> = after
6115                .split('`')
6116                .skip(1)
6117                .step_by(2)
6118                .map(str::to_string)
6119                .collect();
6120            out.sort();
6121            out.dedup();
6122            out
6123        }
6124        fn def_props(schema: &Value, name: &str) -> Vec<String> {
6125            let mut out: Vec<String> = schema["$defs"][name]["properties"]
6126                .as_object()
6127                .map(|m| m.keys().cloned().collect())
6128                .unwrap_or_default();
6129            out.sort();
6130            out
6131        }
6132
6133        let schema = schema::schema();
6134        for (def, probe) in [
6135            (
6136                "Service",
6137                json!({"services": {"p": {"endpoint": "https://x", "__probe__": 1}}}),
6138            ),
6139            (
6140                "A2aPeer",
6141                json!({"a2a": {"peers": [{"name": "p", "endpoint": "https://x", "__probe__": 1}]}}),
6142            ),
6143        ] {
6144            assert_eq!(
6145                def_props(&schema, def),
6146                fields_of(probe),
6147                "schema/struct drift in $defs/{def}"
6148            );
6149        }
6150
6151        // And the enum, which a field-name comparison cannot see: every
6152        // ServiceKind variant must be offered, or a valid config reads as
6153        // invalid in an editor.
6154        assert_eq!(
6155            schema["$defs"]["Service"]["properties"]["kind"]["enum"],
6156            json!(["mcp", "intelligence", "peer", "http"]),
6157        );
6158    }
6159
6160    #[test]
6161    fn schema_matches_struct_at_every_object() {
6162        let schema = schema::schema();
6163        for path in [
6164            "",
6165            "agent",
6166            "agent.tools",
6167            "intelligence",
6168            "intelligence.auth",
6169            "intelligence.budget",
6170            "intelligence.budget.slow",
6171            "intelligence.budget.degrade",
6172            "intelligence.budget.reserve",
6173            "mcp",
6174            "tools",
6175            "store",
6176            "store.checkpoint",
6177            "store.durability",
6178            "memory",
6179            "context",
6180            "context.plan",
6181            "context.summarize",
6182            "knowledge",
6183            "knowledge.auto_context",
6184            "search",
6185            "skills",
6186            "limits",
6187            "limits.run",
6188            "limits.subagents",
6189            "limits.subagents.instances",
6190            "subagents",
6191            "subagents.defaults",
6192            "lifecycle",
6193            "a2a",
6194            "a2a.tls",
6195            "observability",
6196            "observability.otel",
6197            "observability.audit",
6198            "security",
6199            "security.cgroup",
6200            "security.exec",
6201        ] {
6202            let s = schema_props_at(&schema, path);
6203            let f = struct_fields_at(path);
6204            assert_eq!(s, f, "schema/struct drift at `{path}`");
6205        }
6206    }
6207
6208    #[test]
6209    fn every_schema_path_deserializes_a_sample() {
6210        // Every binding, given a kind-appropriate sample, must be accepted by
6211        // the typed Settings (proves the schema names real fields with the
6212        // right shapes — the paths mechanism depends on it).
6213        for b in paths::bindings_of(&schema::schema()) {
6214            let sample = match &b.kind {
6215                paths::Kind::String => match b.path.as_str() {
6216                    "config_version" => json!("2"),
6217                    _ => json!("x"),
6218                },
6219                paths::Kind::Integer => json!(1),
6220                paths::Kind::Number => json!(0.5),
6221                paths::Kind::Boolean => json!(true),
6222                paths::Kind::Enum(vs) => json!(vs[0]),
6223                paths::Kind::Array(item) => match (**item).clone() {
6224                    paths::Kind::Object => match b.path.as_str() {
6225                        "mcp.servers" => {
6226                            json!([{"name": "a", "endpoint": "https://a.example/mcp"}])
6227                        }
6228                        "workflows" => json!([{"name": "w", "steps": {}}]),
6229                        "a2a.principals" => json!([{"match": {"any": true}, "role": "user"}]),
6230                        "a2a.peers" => json!([{"name": "p", "endpoint": "https://p.example"}]),
6231                        "skills.sources" => json!([{"server": "s"}]),
6232                        "security.policies" => {
6233                            json!([{"match": {"tool": "fs.*"}, "action": "deny"}])
6234                        }
6235                        "intelligence.budget.windows" | "agent.conversation_budget.windows" => {
6236                            json!([{"per": "hour", "tokens": 1}])
6237                        }
6238                        other => panic!("no sample for object list {other}"),
6239                    },
6240                    paths::Kind::Enum(vs) => json!([vs[0]]),
6241                    _ => json!(["s"]),
6242                },
6243                paths::Kind::Object => match b.path.as_str() {
6244                    "intelligence.pricing" => json!({"m": {"input_per_1k": 1.0}}),
6245                    "intelligence.models" => json!({"small": {"model": "m-1"}}),
6246                    "tools.overrides" => json!({"memory.get": {"server": "s", "tool": "t"}}),
6247                    "store.mcp" => json!({"server": "s"}),
6248                    "streams" => json!({"orders": {"retention": {"max_events": 1}}}),
6249                    "services" => json!({"billing": {"endpoint": "https://b.example/mcp"}}),
6250                    "subagents.templates" => json!({"t": {"instruction": "do the thing"}}),
6251                    "subagents.defaults.limits" => json!({"max_tokens": 1000}),
6252                    "store.http" => json!({"base_url": "https://s"}),
6253                    "security.aauth" => json!({"provider": "https://apd"}),
6254                    "lifecycle.exit_code_map" => json!({"3": 0}),
6255                    _ => json!({"k": "v"}),
6256                },
6257                paths::Kind::Any => match b.path.as_str() {
6258                    "intelligence.endpoints" => json!("https://a,https://b"),
6259                    "goal.on_achieved" | "goal.on_stuck" => json!("finish"),
6260                    p if p.ends_with("timeout")
6261                        || p.ends_with("deadline")
6262                        || p.ends_with("_grace")
6263                        || p.ends_with("ttl")
6264                        || p.ends_with("every") =>
6265                    {
6266                        json!("10s")
6267                    }
6268                    p if p.starts_with("agent.tools.") => json!("all"),
6269                    _ => json!("x"),
6270                },
6271            };
6272            let mut doc = Value::Object(Map::new());
6273            paths::set_path(&mut doc, &b.path, sample);
6274            fill_required(&mut doc, &schema::schema(), &b.path);
6275            Settings::from_document(doc, "t")
6276                .unwrap_or_else(|e| panic!("path {} does not deserialize: {e}", b.path));
6277        }
6278    }
6279
6280    /// Along `path`, every schema object with `required` gets its required
6281    /// properties filled with a sample (so a lone leaf under `store.mcp` still
6282    /// types — the runtime validation reports the missing siblings instead).
6283    fn fill_required(doc: &mut Value, schema: &Value, path: &str) {
6284        let defs = schema.get("$defs").cloned().unwrap_or(Value::Null);
6285        let resolve = |v: &Value| -> Value {
6286            match v
6287                .get("$ref")
6288                .and_then(Value::as_str)
6289                .and_then(|r| r.strip_prefix("#/$defs/"))
6290            {
6291                Some(name) => defs.get(name).cloned().unwrap_or(Value::Null),
6292                None => v.clone(),
6293            }
6294        };
6295        let mut node = schema.clone();
6296        let mut prefix = String::new();
6297        let segs: Vec<&str> = path.split('.').collect();
6298        for (i, seg) in segs.iter().enumerate() {
6299            let props = node.get("properties").cloned().unwrap_or(Value::Null);
6300            node = resolve(&props.get(*seg).cloned().unwrap_or(Value::Null));
6301            prefix = if prefix.is_empty() {
6302                (*seg).to_string()
6303            } else {
6304                format!("{prefix}.{seg}")
6305            };
6306            if i + 1 == segs.len() {
6307                break;
6308            }
6309            if let Some(req) = node.get("required").and_then(Value::as_array) {
6310                let props = node.get("properties").cloned().unwrap_or(Value::Null);
6311                for r in req.iter().filter_map(Value::as_str) {
6312                    let p = format!("{prefix}.{r}");
6313                    if doc.pointer(&format!("/{}", p.replace('.', "/"))).is_none() {
6314                        // Honor an enum-typed required field (e.g. `auth.kind`) so
6315                        // the filled sample is a valid variant, not `"x"`.
6316                        let sample = match props
6317                            .get(r)
6318                            .and_then(|f| f.get("enum"))
6319                            .and_then(Value::as_array)
6320                            .filter(|a| !a.is_empty())
6321                        {
6322                            Some(vs) => vs[0].clone(),
6323                            None => match r {
6324                                "provider" | "base_url" | "url" => json!("https://x.example"),
6325                                _ => json!("x"),
6326                            },
6327                        };
6328                        paths::set_path(doc, &p, sample);
6329                    }
6330                }
6331            }
6332        }
6333    }
6334
6335    #[test]
6336    fn env_and_flag_names_derive_from_the_v2_paths() {
6337        let bs = paths::bindings_of(&schema::schema());
6338        let model = bs.iter().find(|b| b.path == "intelligence.model").unwrap();
6339        assert_eq!(model.env_names()[0], "AGENTD_INTELLIGENCE_MODEL");
6340        assert_eq!(model.env_names()[2], "INTELLIGENCE_MODEL");
6341        assert_eq!(model.flag(), "--intelligence-model");
6342        let steps = bs.iter().find(|b| b.path == "limits.run.steps").unwrap();
6343        assert_eq!(steps.env_names()[0], "AGENTD_LIMITS_RUN_STEPS");
6344        // Uniqueness of the derived names across the whole v2 schema.
6345        let mut seen = std::collections::HashSet::new();
6346        for b in &bs {
6347            assert!(seen.insert(b.flag()), "duplicate flag {}", b.flag());
6348        }
6349    }
6350
6351    // ---- detection ------------------------------------------------------------
6352
6353    #[test]
6354    fn detects_v1_v2_mixed_and_empty() {
6355        assert_eq!(detect(&json!({})), Detected::Empty);
6356        assert_eq!(detect(&json!({"model": "m"})), Detected::V1);
6357        assert_eq!(detect(&json!({"config_version": "1"})), Detected::V2);
6358        assert_eq!(
6359            detect(&json!({"agent": {"instruction": "x"}})),
6360            Detected::V2
6361        );
6362        assert_eq!(detect(&json!({"agent": {}, "model": "m"})), Detected::Mixed);
6363        assert_eq!(
6364            detect(&json!({"config_version": "1.0", "model": "m"})),
6365            Detected::V1
6366        );
6367        // `limits` is neutral; `intelligence` decides by shape.
6368        assert_eq!(
6369            detect(&json!({"model": "m", "limits": {"max_steps": 1}})),
6370            Detected::V1
6371        );
6372        assert_eq!(
6373            detect(&json!({"intelligence": "https://x", "limits": {}})),
6374            Detected::V1
6375        );
6376        assert_eq!(
6377            detect(&json!({"intelligence": {"model": "m"}, "limits": {}})),
6378            Detected::V2
6379        );
6380        assert_eq!(detect(&json!({"limits": {"max_steps": 1}})), Detected::V1);
6381    }
6382
6383    // ---- load: layering, aliases, sugar --------------------------------------
6384
6385    #[cfg(feature = "exec")]
6386    #[test]
6387    fn enabling_exec_next_to_untrusted_input_assembles_the_trifecta() {
6388        // `exec` is tagged sensitive+egress in the tool registry — but the
6389        // registry is built AFTER validation, so for a long time those tags
6390        // never reached the check and this config started happily. It is the
6391        // whole lethal trifecta: untrusted input, sensitive powers, an egress
6392        // path.
6393        let cfg = "config_version: \"1\"\nstore: {kind: memory}\n\
6394                   mcp:\n  servers:\n    - name: web\n      endpoint: https://mcp-web.internal/mcp\n      tags: {\"*\": [untrusted_input]}\n\
6395                   security:\n  exec: {enabled: true, workdir: /tmp, allow: [git]}\n";
6396        let f = write_tmp(cfg, "yaml");
6397        let e = load(
6398            &args(&["--config", f.path().to_str().unwrap(), "--validate-config"]),
6399            &base_env(),
6400        )
6401        .unwrap_err();
6402        assert!(format!("{e}").contains("lethal-trifecta refused"), "{e}");
6403
6404        // The documented override still lets an operator take the risk.
6405        load(
6406            &args(&[
6407                "--config",
6408                f.path().to_str().unwrap(),
6409                "--validate-config",
6410                "--allow-trifecta",
6411            ]),
6412            &base_env(),
6413        )
6414        .expect("--allow-trifecta is the escape hatch");
6415
6416        // exec WITHOUT an untrusted-input source is only two legs: still fine.
6417        let alone = write_tmp(
6418            "config_version: \"1\"\nstore: {kind: memory}\n\
6419             security:\n  exec: {enabled: true, workdir: /tmp, allow: [git]}\n",
6420            "yaml",
6421        );
6422        load(
6423            &args(&[
6424                "--config",
6425                alone.path().to_str().unwrap(),
6426                "--validate-config",
6427            ]),
6428            &base_env(),
6429        )
6430        .expect("two legs are not the trifecta");
6431    }
6432
6433    #[test]
6434    fn validate_config_catches_workflow_body_errors_the_runtime_would_refuse() {
6435        // The pre-flight check must not pass a config that then exits 2 on
6436        // the first real start: a validator that accepts what startup refuses
6437        // is worse than no validator, because it certifies the broken config.
6438        let f = write_tmp(
6439            "config_version: \"1\"\nstore: {kind: memory}\nworkflows:\n  - name: w\n    version: 3\n    steps:\n      s: {kind: once}\n      a: {kind: agent, depends_on: [s], prompt: \"typo — agent steps take `instruction`\"}\n      f: {kind: finish, depends_on: [a], status: completed}\n",
6440            "yaml",
6441        );
6442        let e = load(
6443            &args(&["--config", f.path().to_str().unwrap(), "--validate-config"]),
6444            &base_env(),
6445        )
6446        .unwrap_err();
6447        let msg = format!("{e}");
6448        assert!(msg.contains("unknown field"), "{msg}");
6449        assert!(msg.contains("prompt"), "{msg}");
6450        assert!(
6451            msg.contains("instruction"),
6452            "names the allowed fields: {msg}"
6453        );
6454
6455        // The same workflow, spelled correctly, still validates.
6456        let ok = write_tmp(
6457            "config_version: \"1\"\nstore: {kind: memory}\nworkflows:\n  - name: w\n    version: 3\n    steps:\n      s: {kind: once}\n      a: {kind: agent, depends_on: [s], instruction: \"do it\"}\n      f: {kind: finish, depends_on: [a], status: completed}\n",
6458            "yaml",
6459        );
6460        load(
6461            &args(&["--config", ok.path().to_str().unwrap(), "--validate-config"]),
6462            &base_env(),
6463        )
6464        .expect("a correct workflow validates");
6465    }
6466
6467    #[test]
6468    fn a_prompt_is_a_message_not_a_sugar_workflow() {
6469        // A prompt is delivered into the agent's ROOT context at startup, so
6470        // it authors no workflow — that is what gives it root-scoped tools and
6471        // lets it set the instance up (workflow.create) rather than only
6472        // answering a canned step.
6473        let (l, ask) = load(&args(&["--prompt", "do the thing"]), &base_env()).unwrap();
6474        assert_eq!(ask, Ask::Run);
6475        assert_eq!(l.settings.agent.prompt.as_deref(), Some("do the thing"));
6476        assert!(
6477            l.settings.workflows.is_empty(),
6478            "a prompt needs no workflow: {:?}",
6479            l.settings.workflows
6480        );
6481
6482        // An instruction alone still gets the one-shot sugar workflow…
6483        let (only_instr, _) = load(&args(&["--instruction", "be terse"]), &base_env()).unwrap();
6484        assert_eq!(only_instr.settings.workflows.len(), 1);
6485
6486        // …but a prompt alongside it means the prompt is the job: the
6487        // instruction stays standing policy, and no step is synthesized.
6488        let (both, _) = load(
6489            &args(&["--prompt", "do the thing", "--instruction", "be terse"]),
6490            &base_env(),
6491        )
6492        .unwrap();
6493        assert!(both.settings.workflows.is_empty());
6494        assert_eq!(both.settings.agent.instruction.as_deref(), Some("be terse"));
6495
6496        // The env spelling works too (12-factor).
6497        let mut env = base_env();
6498        env.push(("AGENTD_AGENT_PROMPT".into(), "from env".into()));
6499        let (from_env, _) = load(&args(&[]), &env).unwrap();
6500        assert_eq!(from_env.settings.agent.prompt.as_deref(), Some("from env"));
6501    }
6502
6503    #[test]
6504    fn minimal_instruction_run_gets_the_sugar_workflow() {
6505        let (l, ask) = load(&args(&["--instruction", "do it"]), &base_env()).unwrap();
6506        assert_eq!(ask, Ask::Run);
6507        assert_eq!(l.settings.agent.instruction.as_deref(), Some("do it"));
6508        assert_eq!(
6509            l.settings.intelligence.endpoints,
6510            vec!["https://intel.example/v1"]
6511        );
6512        assert_eq!(l.settings.workflows.len(), 1, "sugar workflow synthesized");
6513        assert_eq!(l.settings.workflows[0]["name"], json!("main"));
6514        assert_eq!(
6515            l.settings.workflows[0]["steps"]["start"]["kind"],
6516            json!("once")
6517        );
6518        // A one-shot job may run without a store — with a warning.
6519        assert!(
6520            l.warnings.iter().any(|w| w.contains("not durable")),
6521            "{:?}",
6522            l.warnings
6523        );
6524    }
6525
6526    #[test]
6527    fn a_long_lived_instance_defaults_to_the_file_store_but_an_explicit_none_is_refused() {
6528        // An A2A listener makes the instance long-lived ⇒ the file store.
6529        let (l, _) = load(
6530            &args(&[
6531                "--instruction",
6532                "x",
6533                "--a2a.listen",
6534                "http://127.0.0.1:8443",
6535            ]),
6536            &base_env(),
6537        )
6538        .unwrap();
6539        assert_eq!(l.settings.store.kind, StoreKind::File);
6540        // A long-lived start node ⇒ the same default.
6541        let f = write_tmp(
6542            "config_version: \"1\"\nworkflows:\n  - name: w\n    steps:\n      s: {kind: schedule, cron: \"* * * * *\"}\n      f: {kind: finish, depends_on: [s], status: completed}\n",
6543            "yaml",
6544        );
6545        let (l, _) = load(
6546            &args(&["--config", f.path().to_str().unwrap()]),
6547            &base_env(),
6548        )
6549        .unwrap();
6550        assert_eq!(l.settings.store.kind, StoreKind::File);
6551        // …but a STATED `none` is still refused: the default fills a silence,
6552        // it does not overrule an operator.
6553        let e = load(
6554            &args(&[
6555                "--config",
6556                f.path().to_str().unwrap(),
6557                "--store.kind",
6558                "none",
6559            ]),
6560            &base_env(),
6561        )
6562        .unwrap_err();
6563        assert!(format!("{e}").contains("long-lived"), "{e}");
6564        // A one-shot job keeps `none` — the default deliberately does not move
6565        // for the shape that can simply be re-run.
6566        let (l, _) = load(&args(&["--instruction", "x"]), &base_env()).unwrap();
6567        assert_eq!(l.settings.store.kind, StoreKind::None);
6568        // memory is accepted (with a warning).
6569        let (l, _) = load(
6570            &args(&["--instruction", "x", "--store.kind", "memory"]),
6571            &base_env(),
6572        )
6573        .unwrap();
6574        assert!(
6575            l.warnings.iter().any(|w| w.contains("memory")),
6576            "{:?}",
6577            l.warnings
6578        );
6579    }
6580
6581    // ---- env substitution: `${VAR}` / `${VAR:-default}` --------------------
6582
6583    #[test]
6584    fn expand_env_str_covers_the_forms() {
6585        let env: HashMap<&str, &str> = [("HOST", "db.internal"), ("PORT", "5432")]
6586            .into_iter()
6587            .collect();
6588        // A plain reference; multiple in one string.
6589        assert_eq!(
6590            expand_env_str("${HOST}:${PORT}", &env).unwrap(),
6591            "db.internal:5432"
6592        );
6593        // A default applies only when the variable is unset.
6594        assert_eq!(
6595            expand_env_str("${MISSING:-fallback}", &env).unwrap(),
6596            "fallback"
6597        );
6598        assert_eq!(
6599            expand_env_str("${HOST:-fallback}", &env).unwrap(),
6600            "db.internal"
6601        );
6602        // Braces are required: a bare `$VAR` and a lone `$` pass through.
6603        assert_eq!(
6604            expand_env_str("$HOST costs $5", &env).unwrap(),
6605            "$HOST costs $5"
6606        );
6607        // `$$` escapes to a literal `$` and does not open a reference.
6608        assert_eq!(expand_env_str("$${HOST}", &env).unwrap(), "${HOST}");
6609        // An unset variable with no default is a hard error (fail-closed).
6610        assert!(
6611            expand_env_str("${NOPE}", &env)
6612                .unwrap_err()
6613                .contains("NOPE")
6614        );
6615        // A malformed reference is rejected, not silently passed through.
6616        assert!(expand_env_str("${HOST", &env).is_err());
6617        assert!(expand_env_str("${bad-name}", &env).is_err());
6618    }
6619
6620    /// **Every configuration path is classified: restart-only, or reloadable.**
6621    ///
6622    /// This is the forcing function for the defect class that produced
6623    /// v1.3.3, v1.3.4 and v1.4.0: a config field whose value is captured into
6624    /// a long-lived structure at startup, which the reload never rebuilds and
6625    /// which nobody listed as restart-only either. The reload reports success;
6626    /// the daemon keeps its boot snapshot. `a2a.principals`, the webhook
6627    /// routes and `interface.origins` were all of exactly this shape, and the
6628    /// existing reload test could not see any of them, because it asserts what
6629    /// the reload REPORTED rather than what it did.
6630    ///
6631    /// What this proves is narrow and worth stating plainly: that a decision
6632    /// was RECORDED for every path, not that the decision is correct. Proving
6633    /// the effect needs an e2e per path (there are three, one per defect
6634    /// found). What it buys is that the next field cannot be added without
6635    /// someone answering the question — which is the step that was skipped
6636    /// each of the three times.
6637    ///
6638    /// The surface comes from the generated schema rather than a hand-written
6639    /// list, so it tracks the structs: `schema_matches_struct_for_collection_item_types`
6640    /// already pins the schema to the loader.
6641    #[test]
6642    fn every_config_path_is_classified() {
6643        let schema: Value = schema::schema();
6644        let defs = &schema["$defs"];
6645
6646        // `section` for a leaf/opaque section, `section.field` where the
6647        // section has a struct behind it — the granularity the two lists use.
6648        let mut paths: Vec<String> = Vec::new();
6649        for (section, node) in schema["properties"]
6650            .as_object()
6651            .expect("the schema has properties")
6652        {
6653            let target = node
6654                .get("$ref")
6655                .or_else(|| node.get("items").and_then(|i| i.get("$ref")))
6656                .and_then(Value::as_str)
6657                .and_then(|r| r.rsplit('/').next())
6658                .and_then(|name| defs.get(name))
6659                .unwrap_or(node);
6660            match target.get("properties").and_then(Value::as_object) {
6661                Some(fields) if !fields.is_empty() => {
6662                    paths.extend(fields.keys().map(|f| format!("{section}.{f}")));
6663                }
6664                _ => paths.push(section.clone()),
6665            }
6666        }
6667        assert!(
6668            paths.len() > 100,
6669            "the schema walk found only {} paths — it stopped resolving $refs",
6670            paths.len()
6671        );
6672
6673        let covered = |p: &str, list: &[&str]| {
6674            list.iter()
6675                .any(|e| p == *e || p.starts_with(&format!("{e}.")))
6676        };
6677        let unclassified: Vec<&String> = paths
6678            .iter()
6679            .filter(|p| !covered(p, RESTART_ONLY_PATHS) && !covered(p, RELOADABLE_PATHS))
6680            .collect();
6681        assert!(
6682            unclassified.is_empty(),
6683            "these config paths are in neither RESTART_ONLY_PATHS nor \
6684             RELOADABLE_PATHS, so nobody has said whether a reload applies them \
6685             — add each to one list (prefer restart-only when unsure): {unclassified:#?}"
6686        );
6687
6688        // And the reverse: a path listed as BOTH is a contradiction, and a
6689        // stale entry in either list (a field that was renamed or removed)
6690        // silently weakens the check above.
6691        let both: Vec<&&str> = RELOADABLE_PATHS
6692            .iter()
6693            .filter(|p| covered(p, RESTART_ONLY_PATHS))
6694            .collect();
6695        assert!(both.is_empty(), "classified as both: {both:?}");
6696        let stale: Vec<&&str> = RELOADABLE_PATHS
6697            .iter()
6698            .filter(|e| {
6699                !paths
6700                    .iter()
6701                    .any(|p| p == *e || p.starts_with(&format!("{e}.")))
6702            })
6703            .collect();
6704        assert!(
6705            stale.is_empty(),
6706            "RELOADABLE_PATHS names paths the schema does not have — renamed or \
6707             removed fields leave the coverage check weaker than it looks: {stale:?}"
6708        );
6709    }
6710
6711    /// The two paths that once reported a SUCCESSFUL reload and did nothing,
6712    /// were then made restart-only (v1.3.3), and now genuinely reload.
6713    ///
6714    /// What stays restart-only is the SOCKET, not the rules: rebinding an
6715    /// address or swapping a TLS identity still needs a restart. The rules —
6716    /// principal matchers, `default_auth`, the routes themselves — are rebuilt
6717    /// into the live listener. That the rebuild actually TAKES EFFECT is not
6718    /// provable here (this function only compares documents); the e2e
6719    /// `a_reload_rebuilds_principals_and_webhook_routes` proves the effect.
6720    #[test]
6721    fn principal_and_webhook_rules_reload_while_their_sockets_stay_restart_only() {
6722        let base = json!({
6723            "a2a": {"listen": "http://127.0.0.1:1", "principals": [
6724                {"match": {"any": true}, "role": "user", "labels": {"team": "alpha"}}]},
6725            "webhooks": {"listen": "http://127.0.0.1:2",
6726                         "default_auth": {"hmac": {"secret": "{{secret:S}}"}}},
6727            "agent": {"instruction": "before"},
6728        });
6729
6730        // A principal label edit reloads.
6731        let mut changed = base.clone();
6732        changed["a2a"]["principals"][0]["labels"]["team"] = json!("bravo");
6733        assert!(
6734            restart_only_diff(&base, &changed).is_empty(),
6735            "principal rules are rebuilt into the live resolver"
6736        );
6737
6738        // A rotated webhook secret reloads — the case that was a silent
6739        // security regression before any of this.
6740        let mut rotated = base.clone();
6741        rotated["webhooks"]["default_auth"]["hmac"]["secret"] = json!("{{secret:S2}}");
6742        assert!(
6743            restart_only_diff(&base, &rotated).is_empty(),
6744            "webhook auth is rebuilt into the live handler"
6745        );
6746
6747        // But moving the listener itself still demands a restart: the socket
6748        // is bound once, and a reload cannot rebind it.
6749        let mut moved = base.clone();
6750        moved["webhooks"]["listen"] = json!("http://127.0.0.1:3");
6751        assert_eq!(restart_only_diff(&base, &moved), ["webhooks.listen"]);
6752
6753        let mut retls = base.clone();
6754        retls["webhooks"]["tls"] = json!({"cert": "/c.pem", "key": "/k.pem"});
6755        assert_eq!(restart_only_diff(&base, &retls), ["webhooks.tls"]);
6756
6757        // And the ordinary reloadable half still reloads.
6758        let mut instr = base.clone();
6759        instr["agent"]["instruction"] = json!("after");
6760        assert!(restart_only_diff(&base, &instr).is_empty());
6761    }
6762
6763    #[test]
6764    fn config_vars_fold_typed_values_and_collect_every_miss() {
6765        let file = write_tmp(
6766            "config_version: \"1\"\n\
6767             vars:\n  region: eu-1\n  port: 8443\n  team:\n    name: platform\n\
6768             agent:\n  name: \"svc-{{config.region}}\"\n  instruction: serve\n  preflight: never\n\
6769             intelligence:\n  endpoints: [https://x/v1]\n  model: m\n\
6770             store:\n  kind: memory\n\
6771             limits:\n  step_timeout: \"{{config.port}}s\"\n\
6772             workflows:\n  - name: w\n    steps:\n\
6773             \x20     s: {kind: once}\n\
6774             \x20     c: {kind: http, depends_on: [s], url: \"https://api.{{config.region}}.example\", headers: {x-team: \"{{config.team.name}}\"}}\n\
6775             \x20     f: {kind: finish, depends_on: [c]}\n",
6776            "yaml",
6777        );
6778        let (l, _) = load(
6779            &args(&["--config", file.path().to_str().unwrap()]),
6780            &base_env(),
6781        )
6782        .unwrap();
6783        // Embedded token: stringified into place, in config values…
6784        assert_eq!(l.settings.agent.name.as_deref(), Some("svc-eu-1"));
6785        // A folded scalar keeps its place in typed config too.
6786        assert_eq!(
6787            l.settings
6788                .limits
6789                .step_timeout
6790                .as_ref()
6791                .map(|d| d.0.as_secs()),
6792            Some(8443)
6793        );
6794        // Workflow docs are deliberately NOT folded here: `load_workflows`
6795        // folds them (so URL/file/dir sources get the identical treatment and
6796        // the definition hash pins the RESOLVED doc) — the e2e proves that leg.
6797        let wf = &l.settings.workflows[0];
6798        assert_eq!(
6799            wf.pointer("/steps/c/url").and_then(Value::as_str),
6800            Some("https://api.{{config.region}}.example")
6801        );
6802
6803        // Every unresolved reference is reported, in ONE refusal.
6804        let bad = write_tmp(
6805            "config_version: \"1\"\n\
6806             vars:\n  set: yes\n\
6807             agent:\n  name: \"{{config.gone}}\"\n  instruction: serve\n  preflight: never\n\
6808             intelligence:\n  endpoints: [\"https://{{config.also_gone}}/v1\"]\n  model: m\n\
6809             store:\n  kind: memory\n",
6810            "yaml",
6811        );
6812        let err = load(
6813            &args(&["--config", bad.path().to_str().unwrap()]),
6814            &base_env(),
6815        )
6816        .err()
6817        .map(|e| e.to_string())
6818        .unwrap_or_default();
6819        assert!(err.contains("config.gone"), "{err}");
6820        assert!(err.contains("config.also_gone"), "{err}");
6821        assert!(
6822            err.contains("2 unresolved config var reference"),
6823            "all misses in one report: {err}"
6824        );
6825    }
6826
6827    #[test]
6828    fn missing_references_name_every_gap_with_its_locations() {
6829        let doc = serde_json::json!({
6830            "a": "{{secret:SUB_WINDOW_TEST_UNSET}}",
6831            "b": {"c": ["{{secret-file:/definitely/not/here}}", "{{config.gone}}"]},
6832            "d": "{{secret:SUB_WINDOW_TEST_UNSET}} again",
6833        });
6834        let vars: std::collections::BTreeMap<String, Value> =
6835            [("present".to_string(), serde_json::json!(1))].into();
6836        let missing = missing_references(&doc, "cfg", &vars);
6837        assert_eq!(missing.len(), 3, "{missing:?}");
6838        let all = missing.join("\n");
6839        assert!(
6840            all.contains("{{secret:SUB_WINDOW_TEST_UNSET}} is not set"),
6841            "{all}"
6842        );
6843        assert!(
6844            all.contains("cfg.a") && all.contains("cfg.d"),
6845            "both locations: {all}"
6846        );
6847        assert!(
6848            all.contains("{{secret-file:/definitely/not/here}} is not readable"),
6849            "{all}"
6850        );
6851        assert!(all.contains("config.gone is not defined in vars"), "{all}");
6852        // A resolvable reference is not noise.
6853        let ok = serde_json::json!({"x": "{{config.present}}"});
6854        assert!(missing_references(&ok, "cfg", &vars).is_empty());
6855    }
6856
6857    #[test]
6858    fn env_substitution_reaches_config_values_and_workflows() {
6859        let file = write_tmp(
6860            "config_version: \"1\"\n\
6861             agent:\n  name: ${SVC_NAME}\n  instruction: serve\n  preflight: never\n\
6862             intelligence:\n  endpoints: [https://x/v1]\n  model: m\n\
6863             store:\n  kind: memory\n\
6864             workflows:\n  - name: w\n    steps:\n\
6865             \x20     s: {kind: once}\n\
6866             \x20     c: {kind: http, depends_on: [s], url: \"https://api.${REGION:-us}.example/${SVC_NAME}\"}\n\
6867             \x20     f: {kind: finish, depends_on: [c]}\n",
6868            "yaml",
6869        );
6870        let mut env = base_env();
6871        env.push(("SVC_NAME".into(), "billing".into()));
6872        // REGION is deliberately unset -> the `:-us` default applies.
6873        let (l, _) = load(&args(&["--config", file.path().to_str().unwrap()]), &env).unwrap();
6874        // A plain config value is substituted.
6875        assert_eq!(
6876            l.settings.agent.name.as_deref(),
6877            Some("billing"),
6878            "the `${{SVC_NAME}}` in a config value was substituted"
6879        );
6880        // A value nested inside an inline workflow is substituted too, honouring
6881        // the `:-default` for the unset REGION and the set SVC_NAME.
6882        let url = l.settings.workflows[0]
6883            .pointer("/steps/c/url")
6884            .and_then(Value::as_str)
6885            .unwrap_or_default();
6886        assert_eq!(
6887            url, "https://api.us.example/billing",
6888            "the workflow value was substituted (default + set var)"
6889        );
6890    }
6891
6892    #[test]
6893    fn mcp_server_oauth_is_carried_to_the_runtime_spec() {
6894        // `mcp.servers[].oauth` must reach the runtime spec as a secret-free
6895        // template, or the connect path has nothing to build a signer from and
6896        // the configured client-credentials grant is inert.
6897        let s = McpServer {
6898            name: "gh".into(),
6899            endpoint: "https://mcp.example".into(),
6900            service: None,
6901            service_rate: None,
6902            ns: None,
6903            headers: BTreeMap::new(),
6904            tags: BTreeMap::new(),
6905            allow: None,
6906            exclude: Vec::new(),
6907            aauth: None,
6908            oauth: Some(McpOauth {
6909                token_url: "https://auth.example/token".into(),
6910                client_id: "cid".into(),
6911                client_secret: Secret("{{secret:CS}}".into()),
6912                scope: Some("mcp:read".into()),
6913            }),
6914            auth: None,
6915            timeout: None,
6916        };
6917        let spec = s.to_spec().unwrap();
6918        let o = spec.oauth.expect("oauth reaches the runtime spec");
6919        assert_eq!(o.token_url, "https://auth.example/token");
6920        assert_eq!(o.client_id, "cid");
6921        // The secret stays a template — never resolved into the spec/payload.
6922        assert_eq!(o.client_secret, "{{secret:CS}}");
6923        assert_eq!(o.scope.as_deref(), Some("mcp:read"));
6924    }
6925
6926    #[test]
6927    fn files_env_flags_layer_in_order_with_aliases() {
6928        let base = write_tmp(
6929            "config_version: \"1\"\nagent:\n  instruction: from-file\nintelligence:\n  endpoints: [https://file.example/v1]\n  model: file-model\nlimits:\n  run:\n    steps: 10\nstore: { kind: memory }\n",
6930            "yaml",
6931        );
6932        let over = write_tmp("intelligence:\n  model: over-model\n", "yml");
6933        let mut env = base_env();
6934        env.clear();
6935        env.push(("AGENTD_LIMITS_RUN_STEPS".into(), "20".into())); // derived path name
6936        env.push(("AGENT_MODEL".into(), "env-model".into())); // short alias
6937        env.push(("INSTRUCTION".into(), "env-instruction".into())); // bare alias
6938        let (l, _) = load(
6939            &args(&[
6940                "--config",
6941                base.path().to_str().unwrap(),
6942                "--config",
6943                over.path().to_str().unwrap(),
6944                "--max-steps",
6945                "30",
6946                "--mcp",
6947                "fs=https://fs.example/mcp",
6948                "--mcp-tags",
6949                "fs=sensitive",
6950                "--intelligence.headers.x-team",
6951                "ops",
6952            ]),
6953            &env,
6954        )
6955        .unwrap();
6956        let s = &l.settings;
6957        assert_eq!(
6958            s.agent.instruction.as_deref(),
6959            Some("env-instruction"),
6960            "env > file"
6961        );
6962        assert_eq!(
6963            s.intelligence.model.as_deref(),
6964            Some("env-model"),
6965            "env alias > later file"
6966        );
6967        assert_eq!(s.limits.run.steps(), 30, "flag alias > env");
6968        assert_eq!(s.mcp.servers.len(), 1);
6969        assert_eq!(s.mcp.servers[0].name, "fs");
6970        assert_eq!(s.mcp.servers[0].tags["*"], vec!["sensitive"]);
6971        assert_eq!(
6972            s.intelligence.headers.get("x-team").map(String::as_str),
6973            Some("ops")
6974        );
6975        assert_eq!(l.files.len(), 2);
6976        // A derived path name beats the short alias for the same field.
6977        let env2: Vec<(String, String)> = vec![
6978            ("AGENT_MODEL".into(), "legacy".into()),
6979            ("AGENTD_INTELLIGENCE_MODEL".into(), "path".into()),
6980            ("AGENTD_INTELLIGENCE_ENDPOINTS".into(), "https://i".into()),
6981        ];
6982        let (l2, _) = load(
6983            &args(&["--instruction", "x", "--store.kind", "memory"]),
6984            &env2,
6985        )
6986        .unwrap();
6987        assert_eq!(l2.settings.intelligence.model.as_deref(), Some("path"));
6988    }
6989
6990    #[test]
6991    fn removed_flags_name_their_replacement() {
6992        for (flag, _) in REMOVED_FLAGS {
6993            let e = load(&args(&[flag, "x"]), &base_env()).unwrap_err();
6994            assert!(format!("{e}").contains("removed in agentd"), "{flag}: {e}");
6995        }
6996        let e = load(&args(&["--mode", "reactive"]), &base_env()).unwrap_err();
6997        assert!(format!("{e}").contains("start node"), "{e}");
6998    }
6999
7000    #[test]
7001    fn mixed_and_v1_files_are_refused_by_the_v2_loader() {
7002        let mixed = write_tmp("agent: {instruction: x}\nmodel: m\n", "yaml");
7003        let e = load(
7004            &args(&["--config", mixed.path().to_str().unwrap()]),
7005            &base_env(),
7006        )
7007        .unwrap_err();
7008        assert!(format!("{e}").contains("mixes legacy flat keys"), "{e}");
7009        let v1 = write_tmp("model: m\n", "yaml");
7010        let e = load(
7011            &args(&["--config", v1.path().to_str().unwrap()]),
7012            &base_env(),
7013        )
7014        .unwrap_err();
7015        assert!(format!("{e}").contains("retired flat schema"), "{e}");
7016    }
7017
7018    #[test]
7019    fn budget_exit_code_and_instruction_file_aliases() {
7020        let f = write_tmp("read me from a file", "txt");
7021        let (l, _) = load(
7022            &args(&[
7023                "--instruction-file",
7024                f.path().to_str().unwrap(),
7025                "--budget-exit-code",
7026                "9",
7027                "--store.kind",
7028                "memory",
7029            ]),
7030            &base_env(),
7031        )
7032        .unwrap();
7033        assert_eq!(
7034            l.settings.agent.instruction.as_deref(),
7035            Some("read me from a file")
7036        );
7037        assert_eq!(l.settings.lifecycle.exit_code_map.get("3"), Some(&9));
7038        assert_eq!(l.settings.lifecycle.exit_code_map.get("7"), Some(&9));
7039    }
7040
7041    // ---- validation -----------------------------------------------------------
7042
7043    fn load_doc(yaml: &str) -> Result<Loaded, ConfigError> {
7044        let f = write_tmp(yaml, "yaml");
7045        load(&args(&["--config", f.path().to_str().unwrap()]), &[]).map(|(l, _)| l)
7046    }
7047
7048    #[test]
7049    fn validation_collects_the_document_rules() {
7050        // A file with an inline credential is refused; the same value from env is fine.
7051        let e = load_doc(
7052            "config_version: \"1\"\nintelligence:\n  endpoints: [https://i]\n  token: sk-inline\n",
7053        )
7054        .unwrap_err();
7055        assert!(format!("{e}").contains("inline credential"), "{e}");
7056        let (l, _) = load(
7057            &args(&[
7058                "--intelligence",
7059                "https://i",
7060                "--intelligence-token",
7061                "sk-inline",
7062            ]),
7063            &[],
7064        )
7065        .unwrap();
7066        assert_eq!(
7067            l.settings.intelligence.token.as_ref().map(|s| s.0.as_str()),
7068            Some("sk-inline")
7069        );
7070        assert!(
7071            !format!("{:?}", l.settings).contains("sk-inline"),
7072            "Debug redacts"
7073        );
7074
7075        // Undeclared servers referenced by tools/store/knowledge/skills: the
7076        // startup path fast-fails on the first problem (exit 2)…
7077        let e = load_doc(
7078            "config_version: \"1\"\nstore: {kind: mcp, mcp: {server: nope}}\nknowledge: {server: kb}\nskills: {sources: [{server: sk}]}\ntools: {overrides: {memory.get: {server: mem, tool: t}}, disabled: [memory.get]}\n",
7079        )
7080        .unwrap_err();
7081        assert!(matches!(e, ConfigError::Usage(_)), "{e}");
7082
7083        // --validate-config collects EVERYTHING.
7084        let f = write_tmp(
7085            "config_version: \"1\"\nstore: {kind: mcp, mcp: {server: nope}}\nknowledge: {server: kb}\nskills: {sources: [{server: sk}]}\ntools: {overrides: {memory.get: {server: mem, tool: t}}, disabled: [memory.get]}\nlifecycle: {exit_code_map: {\"4\": 300}}\n",
7086            "yaml",
7087        );
7088        let e = load(
7089            &args(&["--config", f.path().to_str().unwrap(), "--validate-config"]),
7090            &[],
7091        )
7092        .unwrap_err();
7093        let ConfigError::Validate(Err(lines)) = e else {
7094            panic!("expected a validate verdict, got {e:?}")
7095        };
7096        for needle in [
7097            "store.mcp.server 'nope'",
7098            "knowledge.server 'kb'",
7099            "skills.sources[]",
7100            "tools.overrides['memory.get']",
7101            "both disabled and overridden",
7102            "only the policy codes 3 and 7",
7103            "0..=255",
7104        ] {
7105            assert!(lines.contains(needle), "missing {needle} in:\n{lines}");
7106        }
7107
7108        // A2A listener rules.
7109        let e = load_doc("config_version: \"1\"\nstore: {kind: memory}\na2a: {listen: \"https://0.0.0.0:8443\"}\n").unwrap_err();
7110        assert!(format!("{e}").contains("a2a.tls.cert"), "{e}");
7111        let e = load_doc("config_version: \"1\"\nstore: {kind: memory}\na2a: {listen: \"http://0.0.0.0:8080\"}\n").unwrap_err();
7112        assert!(format!("{e}").contains("loopback"), "{e}");
7113        // Principals: `any` cannot be operator.
7114        let e = load_doc(
7115            "config_version: \"1\"\na2a: {principals: [{match: {any: true}, role: operator}]}\n",
7116        )
7117        .unwrap_err();
7118        assert!(format!("{e}").contains("operator role"), "{e}");
7119        // Budget rules.
7120        let e = load_doc("config_version: \"1\"\nintelligence: {budget: {windows: [{per: hour}], on_exhausted: degrade}}\n").unwrap_err();
7121        assert!(format!("{e}").contains("tokens and/or requests"), "{e}");
7122        // Trifecta over the root grant.
7123        let e = load_doc(
7124            "config_version: \"1\"\nmcp:\n  servers:\n    - {name: fs, endpoint: https://fs/mcp, tags: {\"*\": [untrusted_input, sensitive, egress]}}\n",
7125        )
7126        .unwrap_err();
7127        assert!(format!("{e}").contains("lethal-trifecta"), "{e}");
7128    }
7129
7130    #[test]
7131    fn restart_only_diff_names_changed_paths() {
7132        let a = json!({"agent": {"name": "x", "instruction": "i"}, "store": {"kind": "mcp"}, "a2a": {"listen": "https://l"}});
7133        let b = json!({"agent": {"name": "y", "instruction": "j"}, "store": {"kind": "mcp"}, "a2a": {"listen": "https://l"}});
7134        assert_eq!(restart_only_diff(&a, &b), vec!["agent.name".to_string()]);
7135        let c = json!({"agent": {"name": "x", "instruction": "changed"}, "store": {"kind": "mcp"}, "a2a": {"listen": "https://l"}});
7136        assert!(
7137            restart_only_diff(&a, &c).is_empty(),
7138            "instruction is reloadable"
7139        );
7140    }
7141
7142    #[test]
7143    fn duration_and_tool_select_scalars() {
7144        let s = Settings::from_document(
7145            json!({"limits": {"run": {"deadline": "90s"}, "step_timeout": 5}, "agent": {"tools": {"mcp": "none", "internal": ["memory.get"]}}}),
7146            "t",
7147        )
7148        .unwrap();
7149        assert_eq!(s.limits.run.deadline(), Duration::from_secs(90));
7150        assert_eq!(s.limits.step_timeout, Some(Dur(Duration::from_secs(5))));
7151        assert!(!s.agent.tools.mcp.allows("fs.read"));
7152        assert!(s.agent.tools.internal.allows("memory.get"));
7153        assert!(!s.agent.tools.internal.allows("finish"));
7154        assert!(s.agent.tools.code.allows("anything"));
7155        assert!(
7156            Settings::from_document(json!({"limits": {"run": {"deadline": "soon"}}}), "t").is_err()
7157        );
7158    }
7159
7160    #[test]
7161    fn instruction_config_directives_define_the_agent_and_explicit_keys_win() {
7162        let instr = ":::config\nlimits: {max_runs: 9}\n:::\n\
7163                     :::stream{name=orders}\nretention: {max_events: 50}\n:::\n\
7164                     :::mcp{name=fs}\nendpoint: \"https://fs.internal/mcp\"\nexclude: [\"delete_*\"]\n:::\n\
7165                     Do the work.";
7166        let s = Settings::from_document(
7167            json!({"agent": {"instruction": instr}, "limits": {"max_runs": 3}}),
7168            "t",
7169        )
7170        .unwrap();
7171        assert_eq!(
7172            s.limits.max_runs,
7173            Some(3),
7174            "an explicit key beats the fragment"
7175        );
7176        assert_eq!(
7177            s.streams.get("orders").map(|c| c.max_events()),
7178            Some(50),
7179            "the fragment fills what the config left unsaid"
7180        );
7181        let srv = s
7182            .mcp
7183            .servers
7184            .iter()
7185            .find(|m| m.name == "fs")
7186            .expect("declared");
7187        assert_eq!(srv.endpoint, "https://fs.internal/mcp");
7188        assert_eq!(srv.exclude, vec!["delete_*"]);
7189        let cleaned = s.agent.instruction.as_deref().unwrap();
7190        assert!(cleaned.contains("Do the work."));
7191        assert!(
7192            !cleaned.contains("endpoint"),
7193            "machinery never reaches the model"
7194        );
7195        // A fragment with a bogus section is refused by the SAME deserializer
7196        // that guards the config file — no parallel, laxer path.
7197        assert!(
7198            Settings::from_document(
7199                json!({"agent": {"instruction": ":::config\nnot_a_section: 1\n:::\nx"}}),
7200                "t"
7201            )
7202            .is_err()
7203        );
7204    }
7205
7206    // ---- the file store ------------------------------------------------------
7207
7208    #[test]
7209    fn file_store_root_walks_the_chain_in_order() {
7210        use std::ffi::OsString;
7211        use std::path::PathBuf;
7212        let env = |pairs: Vec<(&'static str, &'static str)>| {
7213            move |k: &str| -> Option<OsString> {
7214                pairs
7215                    .iter()
7216                    .find(|(n, _)| *n == k)
7217                    .map(|(_, v)| OsString::from(*v))
7218            }
7219        };
7220        let all = vec![
7221            ("AGENTD_STATE_DIR", "/state-dir"),
7222            ("XDG_STATE_HOME", "/xdg"),
7223            ("HOME", "/home/a"),
7224        ];
7225        let with_file = |path: Option<&str>| Store {
7226            file: Some(StoreFile {
7227                path: path.map(str::to_string),
7228                min_free: None,
7229            }),
7230            ..Store::default()
7231        };
7232
7233        // 1. store.file.path wins over every environment variable.
7234        assert_eq!(
7235            file_store_root_in(&with_file(Some("/var/lib/agentd")), &env(all.clone())),
7236            PathBuf::from("/var/lib/agentd")
7237        );
7238        // 2. $AGENTD_STATE_DIR is taken verbatim — an operator naming the
7239        //    directory does not get `agentd/state` appended to it.
7240        assert_eq!(
7241            file_store_root_in(&with_file(None), &env(all.clone())),
7242            PathBuf::from("/state-dir")
7243        );
7244        // 3. $XDG_STATE_HOME, with the agentd/state suffix (`creds` sibling).
7245        assert_eq!(
7246            file_store_root_in(&Store::default(), &env(all[1..].to_vec())),
7247            PathBuf::from("/xdg/agentd/state")
7248        );
7249        // 4. $HOME/.local/state/… — the XDG default spelled out.
7250        assert_eq!(
7251            file_store_root_in(&Store::default(), &env(all[2..].to_vec())),
7252            PathBuf::from("/home/a/.local/state/agentd/state")
7253        );
7254        // 5. Last resort: the OS temp dir (non-durable; the runtime says so).
7255        assert_eq!(
7256            file_store_root_in(&Store::default(), &env(vec![])),
7257            std::env::temp_dir().join("agentd").join("state")
7258        );
7259        // The chain is the credential cache's, one sibling over: same order,
7260        // same suffix shape, `state` where `creds` is.
7261        assert!(
7262            file_store_root_in(&Store::default(), &env(all[1..].to_vec()))
7263                .ends_with("agentd/state")
7264        );
7265    }
7266
7267    #[test]
7268    fn file_store_validation_diagnostics() {
7269        // `kind: file` needs no block at all.
7270        let l = load_doc("config_version: \"1\"\nstore: {kind: file}\n").unwrap();
7271        assert_eq!(l.settings.store.kind, StoreKind::File);
7272        assert!(validate(&l).errors.is_empty(), "{:?}", validate(&l).errors);
7273        // …and a long-lived instance is satisfied by it (no `store.kind is none`).
7274        let l = load_doc(
7275            "config_version: \"1\"\nstore: {kind: file, file: {path: /var/lib/agentd}}\na2a: {listen: \"http://127.0.0.1:8080\"}\n",
7276        )
7277        .unwrap();
7278        assert!(validate(&l).errors.is_empty(), "{:?}", validate(&l).errors);
7279        assert_eq!(
7280            file_store_root(&l.settings.store),
7281            std::path::PathBuf::from("/var/lib/agentd")
7282        );
7283
7284        // An explicitly empty path would resolve to the working directory.
7285        let e = load_doc("config_version: \"1\"\nstore: {kind: file, file: {path: \"\"}}\n")
7286            .unwrap_err();
7287        assert!(format!("{e}").contains("store.file.path is empty"), "{e}");
7288
7289        // A block belonging to an adapter that is not selected is dead config:
7290        // a warning (it is ignored), not a refusal (it does no harm).
7291        let l = load_doc(
7292            "config_version: \"1\"\nstore: {kind: memory, file: {path: /var/lib/agentd}}\n",
7293        )
7294        .unwrap();
7295        let d = validate(&l);
7296        assert!(d.errors.is_empty(), "{:?}", d.errors);
7297        assert!(
7298            d.warnings
7299                .iter()
7300                .any(|w| w.contains("store.file is set but store.kind is memory")),
7301            "{:?}",
7302            d.warnings
7303        );
7304        // No warning when the file adapter IS the selected one.
7305        let l =
7306            load_doc("config_version: \"1\"\nstore: {kind: file, file: {path: /var/lib/agentd}}\n")
7307                .unwrap();
7308        assert!(
7309            !validate(&l)
7310                .warnings
7311                .iter()
7312                .any(|w| w.contains("store.file")),
7313            "{:?}",
7314            validate(&l).warnings
7315        );
7316        // Changing the state directory under a running instance is restart-only.
7317        assert_eq!(
7318            restart_only_diff(
7319                &json!({"store": {"kind": "file", "file": {"path": "/a"}}}),
7320                &json!({"store": {"kind": "file", "file": {"path": "/b"}}})
7321            ),
7322            vec!["store.file".to_string()]
7323        );
7324    }
7325
7326    #[test]
7327    fn instruction_uri_detection() {
7328        assert!(looks_like_resource_uri("mcp://docs/agent-instruction"));
7329        assert!(looks_like_resource_uri("docs://agent"));
7330        assert!(!looks_like_resource_uri("You are a helpful agent."));
7331        assert!(!looks_like_resource_uri(
7332            "see https://x.example for details"
7333        ));
7334        assert!(!looks_like_resource_uri("://nope"));
7335    }
7336
7337    #[test]
7338    fn help_and_schema_asks_short_circuit_validation() {
7339        let (_, ask) = load(&args(&["--help"]), &[]).unwrap();
7340        assert_eq!(ask, Ask::Help);
7341        let (_, ask) = load(&args(&["--config-schema=1"]), &[]).unwrap();
7342        assert_eq!(ask, Ask::Schema);
7343        // `--workflow-schema` is a static, side-effect-free dump: it must resolve
7344        // even with no config file present (no intelligence endpoint, etc.).
7345        let (_, ask) = load(&args(&["--workflow-schema"]), &[]).unwrap();
7346        assert_eq!(ask, Ask::WorkflowSchema);
7347        assert!(help_section().contains("intelligence.model"));
7348    }
7349
7350    // ---- service catalog & egress policy -----------------------------------
7351
7352    const CATALOG: &str = "config_version: \"1\"\nstore: {kind: memory}\nservices:\n  billing:\n    endpoint: https://billing.example/mcp\n    auth: {kind: static, token: \"{{secret:BILLING}}\"}\n    headers: {X-Env: prod}\n    tags: {\"*\": [sensitive]}\n    allow: [charge_lookup, invoice_*]\n    exclude: [invoice_purge]\n  brain:\n    kind: intelligence\n    endpoint: https://intel.example/v1\n";
7353
7354    #[test]
7355    fn service_reference_inherits_and_narrows() {
7356        let f = write_tmp(
7357            &format!(
7358                "{CATALOG}mcp:\n  servers:\n    - {{name: money, service: billing, allow: [charge_lookup], ns: fin}}\n"
7359            ),
7360            "yaml",
7361        );
7362        let (loaded, _) = load(
7363            &args(&["--config", f.path().to_str().unwrap()]),
7364            &base_env(),
7365        )
7366        .unwrap();
7367        let s = &loaded.settings.mcp.servers[0];
7368        assert_eq!(s.endpoint, "https://billing.example/mcp", "inherited");
7369        assert!(s.auth.is_some(), "inherited auth");
7370        assert_eq!(s.headers["X-Env"], "prod", "inherited headers");
7371        assert_eq!(s.allow.as_deref(), Some(&["charge_lookup".to_string()][..]));
7372        assert_eq!(
7373            s.exclude,
7374            vec!["invoice_purge".to_string()],
7375            "exclude unions"
7376        );
7377        assert_eq!(s.tags["*"], vec!["sensitive"], "tag floor applied");
7378        assert_eq!(s.ns.as_deref(), Some("fin"), "consumer-local ns kept");
7379    }
7380
7381    #[test]
7382    fn service_reference_without_allow_inherits_the_ceiling() {
7383        let f = write_tmp(
7384            &format!("{CATALOG}mcp:\n  servers:\n    - {{name: money, service: billing}}\n"),
7385            "yaml",
7386        );
7387        let (loaded, _) = load(
7388            &args(&["--config", f.path().to_str().unwrap()]),
7389            &base_env(),
7390        )
7391        .unwrap();
7392        let s = &loaded.settings.mcp.servers[0];
7393        assert_eq!(
7394            s.allow.as_deref(),
7395            Some(&["charge_lookup".to_string(), "invoice_*".to_string()][..]),
7396            "absent consumer allow inherits the catalog ceiling"
7397        );
7398    }
7399
7400    #[test]
7401    fn service_reference_refuses_restated_connection_settings() {
7402        let f = write_tmp(
7403            &format!(
7404                "{CATALOG}mcp:\n  servers:\n    - {{name: money, service: billing, endpoint: \"https://other.example\"}}\n"
7405            ),
7406            "yaml",
7407        );
7408        let e = load(
7409            &args(&["--config", f.path().to_str().unwrap()]),
7410            &base_env(),
7411        )
7412        .unwrap_err();
7413        let msg = format!("{e}");
7414        assert!(msg.contains("restates `endpoint`"), "{msg}");
7415    }
7416
7417    #[test]
7418    fn service_allow_widening_is_refused() {
7419        let f = write_tmp(
7420            &format!(
7421                "{CATALOG}mcp:\n  servers:\n    - {{name: money, service: billing, allow: [refund_all]}}\n"
7422            ),
7423            "yaml",
7424        );
7425        let e = load(
7426            &args(&["--config", f.path().to_str().unwrap()]),
7427            &base_env(),
7428        )
7429        .unwrap_err();
7430        let msg = format!("{e}");
7431        assert!(msg.contains("widens the ceiling"), "{msg}");
7432    }
7433
7434    #[test]
7435    fn unknown_service_reference_is_refused() {
7436        let f = write_tmp(
7437            "config_version: \"1\"\nstore: {kind: memory}\nmcp:\n  servers:\n    - {name: x, service: nope}\n",
7438            "yaml",
7439        );
7440        let e = load(
7441            &args(&["--config", f.path().to_str().unwrap()]),
7442            &base_env(),
7443        )
7444        .unwrap_err();
7445        assert!(format!("{e}").contains("unknown service 'nope'"), "{e}");
7446    }
7447
7448    #[test]
7449    fn tag_floor_applies_to_inline_matching_servers() {
7450        // An INLINE server pointing under a catalogued endpoint gets the
7451        // entry's tags unioned in — under-tagging cannot launder a sensitive
7452        // endpoint past the trifecta gate.
7453        let f = write_tmp(
7454            &format!(
7455                "{CATALOG}mcp:\n  servers:\n    - {{name: sneaky, endpoint: \"https://billing.example/mcp/sub\"}}\n"
7456            ),
7457            "yaml",
7458        );
7459        let (loaded, _) = load(
7460            &args(&["--config", f.path().to_str().unwrap()]),
7461            &base_env(),
7462        )
7463        .unwrap();
7464        assert_eq!(
7465            loaded.settings.mcp.servers[0].tags["*"],
7466            vec!["sensitive"],
7467            "the catalog's tags are a floor for any matching endpoint"
7468        );
7469    }
7470
7471    #[test]
7472    fn egress_closed_refuses_uncatalogued_and_admits_catalogued() {
7473        let f = write_tmp(
7474            &format!(
7475                "{CATALOG}security: {{egress: closed}}\nmcp:\n  servers:\n    - {{name: rogue, endpoint: \"https://rogue.example/mcp\"}}\n"
7476            ),
7477            "yaml",
7478        );
7479        let e = load(
7480            &args(&["--config", f.path().to_str().unwrap()]),
7481            &base_env(),
7482        )
7483        .unwrap_err();
7484        let msg = format!("{e}");
7485        assert!(
7486            msg.contains("matches no `kind: mcp` services: catalog entry"),
7487            "{msg}"
7488        );
7489
7490        let ok = write_tmp(
7491            &format!(
7492                "{CATALOG}security: {{egress: closed}}\nmcp:\n  servers:\n    - {{name: money, service: billing}}\n"
7493            ),
7494            "yaml",
7495        );
7496        load(
7497            &args(&["--config", ok.path().to_str().unwrap()]),
7498            &base_env(),
7499        )
7500        .expect("a catalogued reference passes closed egress");
7501    }
7502
7503    #[test]
7504    fn ambiguous_catalog_endpoints_are_refused() {
7505        let f = write_tmp(
7506            "config_version: \"1\"\nstore: {kind: memory}\nservices:\n  a: {endpoint: \"https://s.example/mcp\"}\n  b: {endpoint: \"https://s.example/mcp/deeper\"}\n",
7507            "yaml",
7508        );
7509        let e = load(
7510            &args(&["--config", f.path().to_str().unwrap()]),
7511            &base_env(),
7512        )
7513        .unwrap_err();
7514        assert!(format!("{e}").contains("prefix-comparable"), "{e}");
7515    }
7516
7517    #[test]
7518    fn service_match_respects_segment_boundaries() {
7519        let mut services = BTreeMap::new();
7520        services.insert(
7521            "a".to_string(),
7522            Service {
7523                kind: ServiceKind::Mcp,
7524                endpoint: "https://s.example/api".into(),
7525                headers: BTreeMap::new(),
7526                tags: BTreeMap::new(),
7527                allow: None,
7528                exclude: Vec::new(),
7529                auth: None,
7530                rate: None,
7531                timeout: None,
7532                methods: None,
7533                breaker: None,
7534            },
7535        );
7536        let m = ServiceKind::Mcp;
7537        assert!(service_match(&services, m, "https://s.example/api").is_some());
7538        assert!(service_match(&services, m, "https://s.example/api/v2").is_some());
7539        assert!(
7540            service_match(&services, m, "https://s.example/apiary").is_none(),
7541            "prefix match is on segment boundaries, not string prefixes"
7542        );
7543        assert!(service_match(&services, m, "https://other.example/api").is_none());
7544        assert!(
7545            service_match(&services, m, "http://s.example/api").is_none(),
7546            "scheme must match"
7547        );
7548        assert!(
7549            service_match(&services, ServiceKind::Http, "https://s.example/api").is_none(),
7550            "matching is kind-filtered"
7551        );
7552    }
7553
7554    #[test]
7555    fn peer_references_resolve_and_all_four_kinds_gate_closed_egress() {
7556        // A `kind: peer` entry feeds a2a.peers[].service, and closed mode
7557        // covers intelligence endpoints, peers and http-step literals too.
7558        let f = write_tmp(
7559            "config_version: \"1\"\nstore: {kind: memory}\nsecurity: {egress: closed}\nservices:\n  brain: {kind: intelligence, endpoint: \"https://intel.example/v1\"}\n  buddy: {kind: peer, endpoint: \"https://peer.example\", auth: {kind: static, token: \"{{secret:PEER}}\"}}\n  hooks: {kind: http, endpoint: \"https://hooks.example\", methods: [POST]}\na2a:\n  peers:\n    - {name: pal, service: buddy}\nworkflows:\n  - name: w\n    steps:\n      s: {kind: once}\n      h: {kind: http, depends_on: [s], method: POST, url: \"https://hooks.example/x\"}\n      f: {kind: finish, depends_on: [h], status: completed}\n",
7560            "yaml",
7561        );
7562        let (loaded, _) = load(
7563            &args(&["--config", f.path().to_str().unwrap()]),
7564            &base_env(),
7565        )
7566        .expect("all surfaces catalogued ⇒ closed mode admits the config");
7567        let p = &loaded.settings.a2a.peers[0];
7568        assert_eq!(p.endpoint, "https://peer.example", "peer inherited");
7569        assert!(p.auth.is_some(), "peer inherited auth");
7570
7571        let bad = write_tmp(
7572            "config_version: \"1\"\nstore: {kind: memory}\nsecurity: {egress: closed}\na2a:\n  peers:\n    - {name: rogue, endpoint: \"https://rogue.example\"}\n",
7573            "yaml",
7574        );
7575        let e = load(
7576            &args(&["--config", bad.path().to_str().unwrap()]),
7577            &base_env(),
7578        )
7579        .unwrap_err();
7580        assert!(
7581            format!("{e}").contains("kind: peer"),
7582            "an uncatalogued peer is refused naming the kind: {e}"
7583        );
7584
7585        let badi = write_tmp(
7586            "config_version: \"1\"\nstore: {kind: memory}\nsecurity: {egress: closed}\nintelligence: {endpoints: \"https://rogue-intel.example/v1\"}\n",
7587            "yaml",
7588        );
7589        let e = load(&args(&["--config", badi.path().to_str().unwrap()]), &[]).unwrap_err();
7590        assert!(
7591            format!("{e}").contains("kind: intelligence"),
7592            "an uncatalogued intelligence endpoint is refused: {e}"
7593        );
7594    }
7595
7596    #[test]
7597    fn kind_specific_entry_fields_are_validated() {
7598        let f = write_tmp(
7599            "config_version: \"1\"\nstore: {kind: memory}\nservices:\n  x: {kind: http, endpoint: \"https://x.example\", tags: {\"*\": [egress]}}\n  y: {kind: mcp, endpoint: \"https://y.example\", methods: [GET]}\n",
7600            "yaml",
7601        );
7602        let e = load(
7603            &args(&["--config", f.path().to_str().unwrap()]),
7604            &base_env(),
7605        )
7606        .unwrap_err();
7607        let msg = format!("{e}");
7608        assert!(msg.contains("`tags` applies to `kind: mcp`"), "{msg}");
7609        assert!(msg.contains("`methods` applies to `kind: http`"), "{msg}");
7610    }
7611
7612    /// The message hop cap is a fail-closed gate, so it has to bind on a
7613    /// config that never mentions it. An operator who has not heard of
7614    /// `max_message_depth` is exactly the one a runaway chain would surprise.
7615    #[test]
7616    fn the_message_hop_cap_binds_without_being_configured() {
7617        let l = Limits::default();
7618        assert_eq!(l.max_message_depth, None, "unset by default");
7619        assert_eq!(l.message_depth(), DEFAULT_MESSAGE_DEPTH);
7620        assert!(
7621            l.message_depth() > 0,
7622            "a cap of 0 would refuse every message"
7623        );
7624        // An explicit setting still wins.
7625        let tuned = Limits {
7626            max_message_depth: Some(2),
7627            ..Default::default()
7628        };
7629        assert_eq!(tuned.message_depth(), 2);
7630    }
7631
7632    #[test]
7633    fn pattern_subsumption_covers_the_glob_grammar() {
7634        assert!(pattern_subsumes("charge_lookup", "charge_lookup"));
7635        assert!(pattern_subsumes("charge_lookup", "charge_*"));
7636        assert!(pattern_subsumes("charge_*", "charge_*"));
7637        assert!(pattern_subsumes("charge_x_*", "charge_*"));
7638        assert!(!pattern_subsumes("charge_*", "charge_lookup"));
7639        assert!(!pattern_subsumes("refund_all", "charge_*"));
7640        assert!(pattern_subsumes("anything", "*"));
7641    }
7642}