Skip to main content

car_registry/
declarative.rs

1//! Declarative agents — in-daemon agents that need no external process.
2//!
3//! A [`DeclarativeAgentSpec`](crate::declarative::DeclarativeAgentSpec) is pure data: an identity (system prompt), a
4//! tool **allowlist** (names of tools the daemon already exposes), an optional
5//! deny list, a standing goal, an optional deterministic completion goal, an
6//! optional structured cadence request, and scenarios (test cases). The daemon runs it with a generic model→tool loop — there is no command to spawn, so a
7//! non-developer never installs Node/Python/anything.
8//!
9//! This is a **parallel registry** to [`crate::supervisor`], deliberately NOT
10//! an extension of `supervisor::AgentSpec`: that type's whole security model is
11//! the absolute-executable-path validation in `Supervisor::upsert` (the
12//! 2026-05 audit's control). A declarative agent has no command, so it must
13//! never travel through that path. `agents.list` read-merges declarative
14//! entries (tagged `kind:"declarative"`) for the unified host view.
15
16use serde::{Deserialize, Serialize};
17use std::path::{Path, PathBuf};
18
19/// A test case for a declarative agent: run it on `input`, the output must
20/// contain `expect` (a stable substring — not an exact match, so the contract
21/// tolerates benign model variation).
22#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
23pub struct Scenario {
24    pub input: String,
25    pub expect: String,
26}
27
28/// Deterministic completion contract for a declarative agent invocation.
29/// The daemon runs `check` in the same scratch worktree after each agent pass
30/// and re-drives until it exits 0 or `max_iterations` is exhausted.
31#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
32pub struct DeclarativeGoal {
33    pub check: String,
34    #[serde(default = "default_goal_iterations")]
35    pub max_iterations: u32,
36}
37
38fn default_goal_iterations() -> u32 {
39    8
40}
41
42/// Scheduler-compatible trigger kinds that a declarative agent can request.
43/// The serialized spellings match `car_scheduler::TaskTrigger` for the shared
44/// variants, without making the registry depend on the scheduler (which already
45/// depends on the registry).
46#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
47#[serde(rename_all = "snake_case")]
48pub enum AgentCadenceTrigger {
49    Cron,
50    Interval,
51    Manual,
52}
53
54/// The user's requested recurring cadence, preserved on the declarative spec.
55/// Activation into a durable scheduler task is a separate operation.
56#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
57pub struct AgentCadence {
58    pub trigger: AgentCadenceTrigger,
59    /// Cron expression for `cron`, interval string for `interval`, and empty for
60    /// `manual`.
61    pub schedule: String,
62    #[serde(default)]
63    pub timezone: Option<String>,
64    /// The exact trimmed phrase the user supplied.
65    pub phrase: String,
66}
67
68/// Who owns the agent's conversation context across a multi-turn run.
69///
70/// The daemon already bounds the assistant and coder loops to the model's
71/// context window; a declarative agent had no such bound and no way to ask for
72/// one. This is that switch, per agent, named for who does the work rather than
73/// for a mechanism the spec author cannot see.
74///
75/// Deserialization is deliberately TOLERANT (see the hand-written impl below):
76/// an unrecognized value warns and falls back to [`ContextPolicy::Car`] rather
77/// than failing the parse. A strict impl would be a foot-gun here — the
78/// authoring guide teaches hand-editing `declagents.json`, and `DeclRegistry`
79/// treats an unparseable file as an empty registry that the next `upsert`
80/// writes back, so one mistyped policy would delete every agent on the box.
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize)]
82#[serde(rename_all = "lowercase")]
83pub enum ContextPolicy {
84    /// CAR manages it: before each model call the running history is compacted
85    /// to fit the model's context window, exactly as the assistant and coder
86    /// loops do (oldest middle turns dropped on a turn boundary, the system
87    /// prompt and the original task pinned, a `[history compacted:` notice left
88    /// behind). The default, so an agent that never thinks about context still
89    /// gets a bounded one.
90    #[default]
91    Car,
92    /// The agent author manages it. CAR never compacts this agent's history —
93    /// the spec owns whatever summarizing, truncation or externalization it
94    /// wants. Choose it deliberately: nothing else bounds the transcript.
95    #[serde(rename = "self")]
96    SelfManaged,
97}
98
99impl ContextPolicy {
100    /// Whether CAR compacts this agent's history.
101    ///
102    /// Exhaustive on purpose (repo rule): a `matches!` here would make every
103    /// future variant silently self-managed — i.e. would turn "we added a
104    /// policy" into "we switched compaction off for it", which is the failure
105    /// mode with no symptom until a run overflows.
106    pub fn is_car_managed(self) -> bool {
107        match self {
108            ContextPolicy::Car => true,
109            ContextPolicy::SelfManaged => false,
110        }
111    }
112
113    /// The on-disk / on-the-wire spelling.
114    pub fn as_str(self) -> &'static str {
115        match self {
116            ContextPolicy::Car => "car",
117            ContextPolicy::SelfManaged => "self",
118        }
119    }
120}
121
122impl<'de> Deserialize<'de> for ContextPolicy {
123    /// Accepts `"car"` and `"self"`; ANYTHING else warns and yields `Car`.
124    ///
125    /// The strict derive returned `unknown variant`, and one bad character in a
126    /// hand-edited `declagents.json` then travelled the whole swallow chain —
127    /// [`DeclRegistry::read_all`] maps a parse error to an empty registry, and
128    /// the next `upsert` persists that emptiness — so a typo in an optional
129    /// field could unregister every agent the user had. A field whose only two
130    /// values both mean "keep running" must never be able to do that. The
131    /// fallback is the managed default, which is also the safe one: the worst
132    /// outcome of guessing wrong is a bounded history for an author who wanted
133    /// to bound it themselves, and the warning says so.
134    ///
135    /// The spec id is not reachable from here (serde hands this impl only the
136    /// field's own value), so the warning names the value; the surrounding
137    /// spec is one `declagents.json` grep away.
138    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
139    where
140        D: serde::Deserializer<'de>,
141    {
142        // Through `serde_json::Value` rather than `String` so a non-string
143        // (`"context": 5`, `null`, an object) takes the same tolerant path
144        // instead of the parse error this impl exists to prevent.
145        let raw = serde_json::Value::deserialize(deserializer)?;
146        match raw.as_str() {
147            Some("car") => Ok(ContextPolicy::Car),
148            Some("self") => Ok(ContextPolicy::SelfManaged),
149            _ => {
150                tracing::warn!(
151                    value = %raw,
152                    "declarative agent spec has an unrecognized `context` policy {raw}; \
153                     expected \"car\" or \"self\" — using \"car\" (CAR manages the \
154                     history). Fix the value in declagents.json to silence this."
155                );
156                Ok(ContextPolicy::Car)
157            }
158        }
159    }
160}
161
162/// The answers captured by CarHost's guided Agent Builder.
163///
164/// These stay beside the generated runtime fields so reopening an agent can
165/// restore the exact user-authored inputs instead of trying to reverse-engineer
166/// them from a model-written identity or standing goal.
167#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
168pub struct AgentBuilderDraft {
169    /// `AgentBuilderTemplate.rawValue` (`custom` for a hand-authored draft).
170    #[serde(default)]
171    pub template_id: String,
172    #[serde(default)]
173    pub name: String,
174    #[serde(default)]
175    pub responsibility: String,
176    #[serde(default)]
177    pub example: String,
178    #[serde(default)]
179    pub access: String,
180    #[serde(default)]
181    pub cadence: String,
182    #[serde(default)]
183    pub delivery: String,
184    #[serde(default)]
185    pub privacy: String,
186}
187
188/// A declarative, in-daemon agent.
189#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
190pub struct DeclarativeAgentSpec {
191    /// Filename-safe id (derived from the project slug).
192    pub id: String,
193    pub name: String,
194    /// System prompt — who the agent is and how it behaves.
195    pub identity: String,
196    /// Allowlist of tool names the agent may call. The daemon exposes ONLY
197    /// these to the model; anything else is invisible. Empty = no tools
198    /// (a pure-reasoning agent).
199    #[serde(default)]
200    pub tools: Vec<String>,
201    /// Extra hard denials layered under the allowlist (belt and suspenders).
202    #[serde(default)]
203    pub denied_tools: Vec<String>,
204    /// The agent's persistent objective, prepended to every run.
205    #[serde(default)]
206    pub standing_goal: String,
207    /// Optional deterministic completion contract. This is not shown to the
208    /// model as a tool; it is the runtime-owned verifier for each invocation.
209    #[serde(default)]
210    pub goal: Option<DeclarativeGoal>,
211    /// The user's structured cadence request. Existing specs predate this field,
212    /// so absence remains valid and means no cadence was requested.
213    #[serde(default)]
214    pub cadence: Option<AgentCadence>,
215    /// Acceptance scenarios — the contract the coder→agent loop drives to green.
216    #[serde(default)]
217    pub scenarios: Vec<Scenario>,
218    /// The seven user-authored Agent Builder answers and selected template.
219    /// Legacy and hand-authored specs have no draft and continue to load.
220    #[serde(default, skip_serializing_if = "Option::is_none")]
221    pub builder_draft: Option<AgentBuilderDraft>,
222    /// The immediately preceding registered version. Upsert always truncates
223    /// this to one level, so repeated edits cannot grow an unbounded history.
224    #[serde(default, skip_serializing_if = "Option::is_none")]
225    pub previous: Option<Box<DeclarativeAgentSpec>>,
226    /// Lifecycle toggle. A disabled agent stays registered but won't run.
227    #[serde(default = "default_true")]
228    pub enabled: bool,
229    /// Who manages the conversation context of a run: CAR (compaction keyed to
230    /// the model's context window, the default) or the agent itself.
231    ///
232    /// `#[serde(default)]` is load-bearing: every spec already written to
233    /// `declagents.json` predates this field and must keep loading, with the
234    /// same managed behavior a new one gets.
235    #[serde(default)]
236    pub context: ContextPolicy,
237}
238
239fn default_true() -> bool {
240    true
241}
242
243impl DeclarativeAgentSpec {
244    /// Structural problems that make a spec unusable. Empty = valid.
245    pub fn validate(&self) -> Vec<String> {
246        let mut issues = Vec::new();
247        if !is_filename_safe(&self.id) {
248            issues.push(format!(
249                "invalid agent id (alphanumeric + -_.): {:?}",
250                self.id
251            ));
252        }
253        if self.name.trim().is_empty() {
254            issues.push("agent name is empty".into());
255        }
256        if self.identity.trim().is_empty() {
257            issues.push("agent identity (system prompt) is empty".into());
258        }
259        if let Some(goal) = &self.goal {
260            if goal.check.trim().is_empty() {
261                issues.push("agent goal.check is empty".into());
262            }
263            if goal.max_iterations == 0 || goal.max_iterations > 50 {
264                issues.push("agent goal.max_iterations must be between 1 and 50".into());
265            }
266        }
267        // Scenarios are the contract the coder→agent loop drives to green; an
268        // agent with none was never validated against any example. The builder
269        // always writes at least one, so a zero-scenario spec is a hand-edit or
270        // a malformed write — reject it at upsert rather than register an
271        // unverifiable agent.
272        if self.scenarios.is_empty() {
273            issues.push("agent must have at least one acceptance scenario".into());
274        }
275        issues
276    }
277}
278
279fn is_filename_safe(id: &str) -> bool {
280    !id.is_empty()
281        && id != "."
282        && id != ".."
283        && id
284            .chars()
285            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
286}
287
288/// File-backed registry of declarative agents at `declagents.json` under the
289/// CAR state root (`CAR_DECLAGENTS_PATH` overrides for tests/embedders).
290/// Atomic write-through — the on-disk JSON is authoritative across restarts,
291/// mirroring the supervisor manifest's hygiene.
292pub struct DeclRegistry {
293    path: PathBuf,
294}
295
296impl DeclRegistry {
297    /// `declagents.json` under the CAR state root — `$CAR_HOME` when set,
298    /// otherwise `~/.car`. `CAR_DECLAGENTS_PATH` is the narrower override and
299    /// still wins over both: it names the file outright, so a caller that set
300    /// it means that exact path regardless of where the rest of the state
301    /// lives.
302    pub fn user_default() -> Result<Self, String> {
303        if let Some(p) = std::env::var_os("CAR_DECLAGENTS_PATH") {
304            return Ok(Self {
305                path: PathBuf::from(p),
306            });
307        }
308        let root = car_home::root()
309            .ok_or("cannot resolve home directory (CAR_HOME/HOME/USERPROFILE unset)")?;
310        Ok(Self {
311            path: root.join("declagents.json"),
312        })
313    }
314
315    pub fn at(path: impl Into<PathBuf>) -> Self {
316        Self { path: path.into() }
317    }
318
319    /// Exact file this registry reads and writes.
320    ///
321    /// Location is registry metadata, not part of [`DeclarativeAgentSpec`], so
322    /// exposing it cannot leak into persisted `declagents.json` records.
323    pub fn path(&self) -> &Path {
324        &self.path
325    }
326
327    fn read_all(&self) -> Vec<DeclarativeAgentSpec> {
328        std::fs::read_to_string(&self.path)
329            .ok()
330            .and_then(|s| serde_json::from_str(&s).ok())
331            .unwrap_or_default()
332    }
333
334    fn write_all(&self, specs: &[DeclarativeAgentSpec]) -> Result<(), String> {
335        if let Some(parent) = self.path.parent() {
336            std::fs::create_dir_all(parent)
337                .map_err(|e| format!("create {}: {e}", parent.display()))?;
338        }
339        let json = serde_json::to_string_pretty(specs).map_err(|e| e.to_string())?;
340        // Atomic: write a sibling temp then rename over the target.
341        let tmp = self.path.with_extension("json.tmp");
342        std::fs::write(&tmp, json).map_err(|e| format!("write {}: {e}", tmp.display()))?;
343        std::fs::rename(&tmp, &self.path)
344            .map_err(|e| format!("rename into {}: {e}", self.path.display()))
345    }
346
347    /// Insert or replace by id. Validates the spec first.
348    ///
349    /// Replacements retain exactly one prior version in `previous`. Any history
350    /// already carried by the existing or incoming value is discarded before
351    /// writing, keeping the registry bounded across repeated edits.
352    pub fn upsert(&self, mut spec: DeclarativeAgentSpec) -> Result<(), String> {
353        let issues = spec.validate();
354        if !issues.is_empty() {
355            return Err(format!("invalid declarative agent: {}", issues.join("; ")));
356        }
357        let mut all = self.read_all();
358        if let Some(existing) = all.iter_mut().find(|s| s.id == spec.id) {
359            let mut previous = existing.clone();
360            previous.previous = None;
361            spec.previous = Some(Box::new(previous));
362            *existing = spec;
363        } else {
364            spec.previous = None;
365            all.push(spec);
366        }
367        self.write_all(&all)
368    }
369
370    pub fn list(&self) -> Vec<DeclarativeAgentSpec> {
371        self.read_all()
372    }
373
374    pub fn get(&self, id: &str) -> Option<DeclarativeAgentSpec> {
375        self.read_all().into_iter().find(|s| s.id == id)
376    }
377
378    pub fn remove(&self, id: &str) -> Result<bool, String> {
379        let mut all = self.read_all();
380        let before = all.len();
381        all.retain(|s| s.id != id);
382        let removed = all.len() != before;
383        if removed {
384            self.write_all(&all)?;
385        }
386        Ok(removed)
387    }
388
389    pub fn set_enabled(&self, id: &str, on: bool) -> Result<(), String> {
390        let mut all = self.read_all();
391        let spec = all
392            .iter_mut()
393            .find(|s| s.id == id)
394            .ok_or_else(|| format!("no declarative agent '{id}'"))?;
395        spec.enabled = on;
396        self.write_all(&all)
397    }
398}
399
400#[cfg(test)]
401mod tests {
402    use super::*;
403
404    fn spec(id: &str) -> DeclarativeAgentSpec {
405        DeclarativeAgentSpec {
406            id: id.into(),
407            name: "Test".into(),
408            identity: "You are a test agent.".into(),
409            tools: vec!["read_file".into()],
410            denied_tools: vec![],
411            standing_goal: "be helpful".into(),
412            goal: None,
413            cadence: None,
414            scenarios: vec![Scenario {
415                input: "hi".into(),
416                expect: "ok".into(),
417            }],
418            builder_draft: None,
419            previous: None,
420            enabled: true,
421            context: ContextPolicy::default(),
422        }
423    }
424
425    fn temp_registry() -> (tempfile::TempDir, DeclRegistry) {
426        let dir = tempfile::tempdir().unwrap();
427        let reg = DeclRegistry::at(dir.path().join("declagents.json"));
428        (dir, reg)
429    }
430
431    #[test]
432    fn upsert_rejects_spec_with_no_scenarios() {
433        let (_d, reg) = temp_registry();
434        let mut s = spec("no-scenarios");
435        s.scenarios.clear();
436        let err = reg
437            .upsert(s)
438            .expect_err("zero-scenario spec must be rejected");
439        assert!(
440            err.contains("at least one acceptance scenario"),
441            "got: {err}"
442        );
443    }
444
445    #[test]
446    fn upsert_rejects_invalid_goal_contract() {
447        let (_d, reg) = temp_registry();
448        let mut s = spec("bad-goal");
449        s.goal = Some(DeclarativeGoal {
450            check: " ".into(),
451            max_iterations: 0,
452        });
453        let err = reg.upsert(s).expect_err("invalid goal must be rejected");
454        assert!(err.contains("goal.check"), "{err}");
455        assert!(err.contains("goal.max_iterations"), "{err}");
456    }
457
458    #[test]
459    fn upsert_list_get_remove_round_trip() {
460        let (_d, reg) = temp_registry();
461        reg.upsert(spec("email-bot")).unwrap();
462        reg.upsert(spec("note-taker")).unwrap();
463        assert_eq!(reg.list().len(), 2);
464        assert_eq!(reg.get("email-bot").unwrap().name, "Test");
465        // Upsert replaces, doesn't duplicate.
466        let mut updated = spec("email-bot");
467        updated.name = "Renamed".into();
468        reg.upsert(updated).unwrap();
469        assert_eq!(reg.list().len(), 2);
470        let current = reg.get("email-bot").unwrap();
471        assert_eq!(current.name, "Renamed");
472        assert_eq!(current.previous.as_deref().unwrap().name, "Test");
473        assert!(current.previous.as_deref().unwrap().previous.is_none());
474        assert!(reg.remove("email-bot").unwrap());
475        assert!(!reg.remove("email-bot").unwrap());
476        assert_eq!(reg.list().len(), 1);
477    }
478
479    #[test]
480    fn set_enabled_toggles() {
481        let (_d, reg) = temp_registry();
482        reg.upsert(spec("a")).unwrap();
483        reg.set_enabled("a", false).unwrap();
484        assert!(!reg.get("a").unwrap().enabled);
485        reg.set_enabled("a", true).unwrap();
486        assert!(reg.get("a").unwrap().enabled);
487        assert!(reg.set_enabled("missing", true).is_err());
488    }
489
490    #[test]
491    fn invalid_spec_is_rejected() {
492        let (_d, reg) = temp_registry();
493        let mut bad = spec("../escape");
494        assert!(reg.upsert(bad.clone()).is_err());
495        bad.id = "ok".into();
496        bad.identity = "  ".into();
497        assert!(reg.upsert(bad).is_err());
498    }
499
500    #[test]
501    fn get_preserves_existing_registry_bytes_and_path_is_derived() {
502        let dir = tempfile::tempdir().unwrap();
503        let path = dir.path().join("declagents.json");
504        let fixture = r#"[
505  {
506    "id": "existing-agent",
507    "name": "Existing",
508    "identity": "You preserve old state.",
509    "tools": [],
510    "standing_goal": "remain compatible",
511    "scenarios": [{"input": "ping", "expect": "pong"}],
512    "enabled": true
513  }
514]
515"#;
516        std::fs::write(&path, fixture).unwrap();
517        let reg = DeclRegistry::at(&path);
518
519        assert_eq!(reg.path(), path.as_path());
520        assert_eq!(reg.get("existing-agent").unwrap().name, "Existing");
521        assert_eq!(
522            std::fs::read(&path).unwrap(),
523            fixture.as_bytes(),
524            "a metadata read must not write registry_path into persisted user state"
525        );
526    }
527
528    #[test]
529    fn persists_across_handles() {
530        let dir = tempfile::tempdir().unwrap();
531        let path = dir.path().join("declagents.json");
532        DeclRegistry::at(&path).upsert(spec("persist")).unwrap();
533        // A fresh handle reads the same on-disk state.
534        assert!(DeclRegistry::at(&path).get("persist").is_some());
535    }
536
537    #[test]
538    fn cadence_round_trips_and_is_optional_for_existing_specs() {
539        let mut s = spec("cadence");
540        s.cadence = Some(AgentCadence {
541            trigger: AgentCadenceTrigger::Cron,
542            schedule: "30 8 * * 1-5".into(),
543            timezone: Some("America/New_York".into()),
544            phrase: "Weekdays at 8:30".into(),
545        });
546        let json = serde_json::to_value(&s).unwrap();
547        assert_eq!(json["cadence"]["trigger"], "cron");
548        assert_eq!(json["cadence"]["schedule"], "30 8 * * 1-5");
549        assert_eq!(json["cadence"]["timezone"], "America/New_York");
550        assert_eq!(json["cadence"]["phrase"], "Weekdays at 8:30");
551        assert_eq!(
552            serde_json::from_value::<DeclarativeAgentSpec>(json).unwrap(),
553            s
554        );
555
556        let legacy = serde_json::from_value::<DeclarativeAgentSpec>(serde_json::json!({
557            "id": "legacy",
558            "name": "Legacy",
559            "identity": "You predate cadence.",
560            "scenarios": [{"input": "ping", "expect": "pong"}],
561        }))
562        .unwrap();
563        assert_eq!(legacy.cadence, None);
564    }
565
566    #[test]
567    fn builder_draft_and_previous_round_trip_with_one_level_of_history() {
568        let (_d, reg) = temp_registry();
569        let mut first = spec("editable");
570        first.builder_draft = Some(AgentBuilderDraft {
571            template_id: "researchBrief".into(),
572            name: "Research Brief".into(),
573            responsibility: "Research assigned questions".into(),
574            example: "Compare three options".into(),
575            access: "Public web".into(),
576            cadence: "When assigned".into(),
577            delivery: "Save in Work".into(),
578            privacy: "Keep supplied files local".into(),
579        });
580        reg.upsert(first.clone()).unwrap();
581
582        let mut second = first;
583        second.standing_goal = "Deliver cited research".into();
584        second.builder_draft.as_mut().unwrap().cadence = "Every weekday".into();
585        // An incoming chain must not defeat the one-level bound.
586        second.previous = Some(Box::new(spec("ignored-history")));
587        reg.upsert(second).unwrap();
588
589        let loaded = DeclRegistry::at(reg.path()).get("editable").unwrap();
590        assert_eq!(
591            loaded.builder_draft.as_ref().unwrap().cadence,
592            "Every weekday"
593        );
594        let previous = loaded.previous.as_deref().unwrap();
595        assert_eq!(
596            previous.builder_draft.as_ref().unwrap().cadence,
597            "When assigned"
598        );
599        assert!(previous.previous.is_none());
600
601        let json = serde_json::to_string(&loaded).unwrap();
602        let round_tripped: DeclarativeAgentSpec = serde_json::from_str(&json).unwrap();
603        assert_eq!(round_tripped, loaded);
604    }
605
606    #[test]
607    fn legacy_spec_defaults_builder_history_to_none() {
608        let legacy = serde_json::from_value::<DeclarativeAgentSpec>(serde_json::json!({
609            "id": "legacy",
610            "name": "Legacy",
611            "identity": "You predate editing.",
612            "scenarios": [{"input": "ping", "expect": "pong"}]
613        }))
614        .unwrap();
615        assert!(legacy.builder_draft.is_none());
616        assert!(legacy.previous.is_none());
617    }
618
619    #[test]
620    fn context_policy_round_trips_through_the_spec() {
621        // The wire/disk spelling is the spec author's spelling: `car` / `self`.
622        let mut s = spec("ctx");
623        assert_eq!(s.context, ContextPolicy::Car, "default is CAR-managed");
624        let json = serde_json::to_value(&s).unwrap();
625        assert_eq!(json["context"], "car");
626        assert_eq!(
627            serde_json::from_value::<DeclarativeAgentSpec>(json).unwrap(),
628            s
629        );
630
631        s.context = ContextPolicy::SelfManaged;
632        let json = serde_json::to_value(&s).unwrap();
633        assert_eq!(json["context"], "self");
634        assert_eq!(
635            serde_json::from_value::<DeclarativeAgentSpec>(json).unwrap(),
636            s
637        );
638    }
639
640    #[test]
641    fn a_spec_written_before_the_context_field_still_loads_as_car_managed() {
642        // Every agent already on disk predates the field. It must load — with
643        // the managed default, not a parse error and not an unmanaged run.
644        let dir = tempfile::tempdir().unwrap();
645        let path = dir.path().join("declagents.json");
646        std::fs::write(
647            &path,
648            r#"[
649  {
650    "id": "legacy-agent",
651    "name": "Legacy",
652    "identity": "You predate the context field.",
653    "tools": [],
654    "standing_goal": "remain compatible",
655    "scenarios": [{"input": "ping", "expect": "pong"}],
656    "enabled": true
657  }
658]
659"#,
660        )
661        .unwrap();
662
663        let loaded = DeclRegistry::at(&path).get("legacy-agent").unwrap();
664        assert_eq!(loaded.context, ContextPolicy::Car);
665        assert!(loaded.context.is_car_managed());
666    }
667
668    #[test]
669    fn a_mistyped_context_value_warns_and_defaults_instead_of_failing_the_parse() {
670        // The blast radius of strictness here is the whole registry, not one
671        // field: `read_all` maps a parse error to an empty Vec and the next
672        // `upsert` writes that back. So a typo in a hand-edited spec — which
673        // the authoring guide invites — must degrade to the managed default
674        // (with a warning), never to "every agent deleted".
675        let spec = serde_json::from_value::<DeclarativeAgentSpec>(serde_json::json!({
676            "id": "typo",
677            "name": "Typo",
678            "identity": "x",
679            "scenarios": [{"input": "a", "expect": "b"}],
680            "context": "mine",
681        }))
682        .expect("a mistyped context policy must still load");
683        assert_eq!(spec.context, ContextPolicy::Car);
684
685        // Non-strings take the same path (`"context": 5` is the same class of
686        // hand-edit), and so does a wrong case.
687        for bad in [
688            serde_json::json!(5),
689            serde_json::json!(null),
690            serde_json::json!({"who": "me"}),
691            serde_json::json!("Self"),
692        ] {
693            let spec = serde_json::from_value::<DeclarativeAgentSpec>(serde_json::json!({
694                "id": "typo",
695                "name": "Typo",
696                "identity": "x",
697                "scenarios": [{"input": "a", "expect": "b"}],
698                "context": bad,
699            }))
700            .expect("a malformed context policy must still load");
701            assert_eq!(spec.context, ContextPolicy::Car);
702        }
703    }
704
705    #[test]
706    fn a_mistyped_context_value_cannot_empty_the_registry() {
707        // The chain the test above exists to break, end to end: a file holding
708        // two agents, one of them hand-edited wrong, must still list two — and
709        // an upsert on top of it must not persist an empty registry.
710        let dir = tempfile::tempdir().unwrap();
711        let path = dir.path().join("declagents.json");
712        std::fs::write(
713            &path,
714            r#"[
715  {
716    "id": "good-agent",
717    "name": "Good",
718    "identity": "You are fine.",
719    "scenarios": [{"input": "ping", "expect": "pong"}],
720    "enabled": true,
721    "context": "self"
722  },
723  {
724    "id": "typo-agent",
725    "name": "Typo",
726    "identity": "You were hand-edited.",
727    "scenarios": [{"input": "ping", "expect": "pong"}],
728    "enabled": true,
729    "context": "mine"
730  }
731]
732"#,
733        )
734        .unwrap();
735
736        let reg = DeclRegistry::at(&path);
737        assert_eq!(reg.list().len(), 2, "a typo must not empty the registry");
738        assert_eq!(
739            reg.get("typo-agent").unwrap().context,
740            ContextPolicy::Car,
741            "the mistyped policy falls back to the managed default"
742        );
743        assert_eq!(
744            reg.get("good-agent").unwrap().context,
745            ContextPolicy::SelfManaged,
746            "a valid neighbour is unaffected"
747        );
748
749        reg.upsert(spec("third")).unwrap();
750        assert_eq!(
751            DeclRegistry::at(&path).list().len(),
752            3,
753            "upsert after a tolerated typo must not have written an empty registry"
754        );
755    }
756}