Skip to main content

caboodle/
model.rs

1use std::{
2    collections::BTreeMap,
3    fs,
4    path::{Path, PathBuf},
5};
6
7use anyhow::{bail, Context, Result};
8use serde::{Deserialize, Serialize};
9
10pub const SCHEMA_VERSION: u32 = 1;
11
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "kebab-case")]
14pub enum Profile {
15    Kg,
16    Retrieval,
17    CodeIntel,
18    Crew,
19    Everything,
20}
21
22impl Profile {
23    /// Every tool a NEW plan for this profile selects: the built-ins, then the
24    /// embedded stack members that declare this profile, in name order.
25    pub fn tools(self) -> Vec<ToolName> {
26        let mut tools = self.builtin_tools();
27        tools.extend(self.members());
28        tools
29    }
30
31    /// Whether `tools` is a valid selection for this profile: the built-ins
32    /// exactly, then any members of this profile in name order. A selection
33    /// saved before a member existed stays valid after an upgrade adds one
34    /// (wu M1, aegis-1i5h1j); an undeclared, duplicate or out-of-order member
35    /// is refused.
36    pub fn accepts(self, tools: &[ToolName]) -> bool {
37        let builtins = self.builtin_tools();
38        let members = self.members();
39        let (head, tail) = tools.split_at(tools.len().min(builtins.len()));
40        let mut allowed = members.iter();
41        head == builtins.as_slice() && tail.iter().all(|t| allowed.any(|m| m == t))
42    }
43
44    /// Members declaring this profile, in name order.
45    pub fn members(self) -> Vec<ToolName> {
46        crate::members::all()
47            .iter()
48            .filter(|m| m.profiles.contains(&self))
49            .map(|m| ToolName::Member(m.name.as_str()))
50            .collect()
51    }
52
53    /// The built-in tools of this profile, which every plan for it must list.
54    pub fn builtin_tools(self) -> Vec<ToolName> {
55        match self {
56            Self::Kg => vec![ToolName::Quipu, ToolName::Camayoc],
57            Self::Retrieval => vec![ToolName::Quipu, ToolName::Camayoc, ToolName::Bobbin],
58            Self::CodeIntel => vec![
59                ToolName::Quipu,
60                ToolName::Camayoc,
61                ToolName::Bobbin,
62                ToolName::Yupana,
63            ],
64            Self::Crew => vec![ToolName::Quipu, ToolName::Camayoc, ToolName::Bobbin],
65            Self::Everything => vec![
66                ToolName::Quipu,
67                ToolName::Camayoc,
68                ToolName::Bobbin,
69                ToolName::Yupana,
70                ToolName::DesirePath,
71            ],
72        }
73    }
74}
75
76#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
77#[serde(rename_all = "kebab-case")]
78pub enum CrewMode {
79    Shantytown,
80    Creel,
81    Both,
82    Standalone,
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
86#[serde(rename_all = "kebab-case")]
87pub enum CrewOwner {
88    Shantytown,
89    Creel,
90}
91
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
93#[serde(rename_all = "kebab-case")]
94pub enum CrewRouting {
95    SingleOwner,
96    ExplicitHandoff,
97}
98
99#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
100#[serde(deny_unknown_fields)]
101pub struct CrewSelection {
102    pub mode: CrewMode,
103    pub durable_owner: Option<CrewOwner>,
104    pub burst_owner: Option<CrewOwner>,
105    pub routing: Option<CrewRouting>,
106    pub policy: CrewPolicy,
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
110#[serde(deny_unknown_fields)]
111pub struct CrewPolicy {
112    pub identity_source: IdentitySource,
113    pub model: Option<String>,
114    pub tools: Vec<ToolName>,
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
118#[serde(rename_all = "kebab-case")]
119pub enum IdentitySource {
120    Quipu,
121}
122
123impl CrewPolicy {
124    fn standard() -> Self {
125        Self {
126            identity_source: IdentitySource::Quipu,
127            model: None,
128            tools: Profile::Crew.tools(),
129        }
130    }
131
132    fn validate(&self) -> Result<()> {
133        if !Profile::Crew.accepts(&self.tools) {
134            bail!("crew policy tools must match the crew profile conventions");
135        }
136        if self
137            .model
138            .as_ref()
139            .is_some_and(|model| model.trim().is_empty())
140        {
141            bail!("crew policy model must be omitted or non-empty");
142        }
143        Ok(())
144    }
145}
146
147impl CrewSelection {
148    pub fn for_mode(mode: CrewMode) -> Self {
149        match mode {
150            CrewMode::Shantytown => Self {
151                mode,
152                durable_owner: Some(CrewOwner::Shantytown),
153                burst_owner: None,
154                routing: Some(CrewRouting::SingleOwner),
155                policy: CrewPolicy::standard(),
156            },
157            CrewMode::Creel => Self {
158                mode,
159                durable_owner: None,
160                burst_owner: Some(CrewOwner::Creel),
161                routing: Some(CrewRouting::SingleOwner),
162                policy: CrewPolicy::standard(),
163            },
164            CrewMode::Both => Self {
165                mode,
166                durable_owner: Some(CrewOwner::Shantytown),
167                burst_owner: Some(CrewOwner::Creel),
168                routing: Some(CrewRouting::ExplicitHandoff),
169                policy: CrewPolicy::standard(),
170            },
171            CrewMode::Standalone => Self {
172                mode,
173                durable_owner: None,
174                burst_owner: None,
175                routing: None,
176                policy: CrewPolicy::standard(),
177            },
178        }
179    }
180
181    fn validate(&self) -> Result<()> {
182        let mut expected = Self::for_mode(self.mode);
183        expected.policy = self.policy.clone();
184        if self != &expected {
185            bail!(
186                "crew {:?} must use the declared ownership/routing contract; expected {:?}",
187                self.mode,
188                expected
189            );
190        }
191        self.policy.validate()?;
192        Ok(())
193    }
194}
195
196#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
197pub enum ToolName {
198    Quipu,
199    Camayoc,
200    Bobbin,
201    Yupana,
202    DesirePath,
203    /// A stack member declared as data (`members/<name>.toml`, aegis-1i5h1j).
204    /// Only names embedded in this build exist: deserialising any other name
205    /// is refused, exactly as an unknown built-in always was.
206    Member(&'static str),
207}
208
209impl ToolName {
210    /// Every built-in tool. `parse` resolves built-in names from this list and
211    /// member validation reserves them from it, so a built-in missing here is
212    /// unparseable (its tests fail) rather than silently shadowable (malcolm N2).
213    pub const BUILTINS: [ToolName; 5] = [
214        Self::Quipu,
215        Self::Camayoc,
216        Self::Bobbin,
217        Self::Yupana,
218        Self::DesirePath,
219    ];
220
221    pub fn as_str(self) -> &'static str {
222        match self {
223            Self::Quipu => "quipu",
224            Self::Camayoc => "camayoc",
225            Self::Bobbin => "bobbin",
226            Self::Yupana => "yupana",
227            Self::DesirePath => "desire-path",
228            Self::Member(name) => name,
229        }
230    }
231
232    /// The tool called `name`: a built-in, or a member embedded in this build.
233    pub fn parse(name: &str) -> Option<Self> {
234        if let Some(builtin) = Self::BUILTINS.into_iter().find(|t| t.as_str() == name) {
235            return Some(builtin);
236        }
237        Some(Self::Member(crate::members::get(name)?.name.as_str()))
238    }
239}
240
241impl Serialize for ToolName {
242    fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
243        s.serialize_str(self.as_str())
244    }
245}
246
247impl<'de> Deserialize<'de> for ToolName {
248    fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
249        let name = String::deserialize(d)?;
250        Self::parse(&name).ok_or_else(|| {
251            serde::de::Error::custom(format!(
252                "unknown tool '{name}': neither a built-in tool nor a stack member of this \
253                 caboodle build (a member removed since the plan was written, or a plan from \
254                 a newer caboodle); re-plan or use the caboodle that wrote it"
255            ))
256        })
257    }
258}
259
260#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
261#[serde(rename_all = "kebab-case")]
262pub enum QuipuFlavor {
263    /// The reviewed release feature set, which deliberately excludes the
264    /// `lancedb` cargo feature — `vector.backend = "lancedb"` refuses at
265    /// startup on a box installed this way.
266    #[default]
267    Release,
268    /// The same reviewed revision built with the `lancedb` feature added.
269    /// Only the feature list changes, so this flavor cannot drift to an
270    /// unreviewed Quipu.
271    Lancedb,
272}
273
274impl QuipuFlavor {
275    /// The default flavor is omitted from serialized plans so that plans
276    /// written before this field existed and plans written today stay
277    /// byte-identical.
278    fn is_release(&self) -> bool {
279        *self == Self::Release
280    }
281}
282
283#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
284#[serde(deny_unknown_fields)]
285pub struct Plan {
286    pub schema_version: u32,
287    pub profile: Profile,
288    pub tools: Vec<ToolName>,
289    #[serde(default, skip_serializing_if = "Vec::is_empty")]
290    pub shares: Vec<PathBuf>,
291    #[serde(default, skip_serializing_if = "Option::is_none")]
292    pub quipu_db: Option<PathBuf>,
293    #[serde(default, skip_serializing_if = "QuipuFlavor::is_release")]
294    pub quipu_flavor: QuipuFlavor,
295    /// Quipu is owned outside Tambo; prove the executables selected by PATH.
296    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
297    pub external_quipu: bool,
298    #[serde(default, skip_serializing_if = "Option::is_none")]
299    pub stack_config: Option<StackConfig>,
300    #[serde(default, skip_serializing_if = "Option::is_none")]
301    pub crew: Option<CrewSelection>,
302    #[serde(default, skip_serializing_if = "Option::is_none")]
303    pub intent: Option<InstallIntent>,
304    #[serde(default, skip_serializing_if = "Option::is_none")]
305    pub embedding_model: Option<crate::embedding::EmbeddingModel>,
306    /// Register Quipu's MCP endpoint with Claude Code through a headersHelper
307    /// that reads the token at connect time (aegis-nvw6ye). Opt-in because it
308    /// changes the user's Claude configuration.
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub quipu_mcp: Option<QuipuMcp>,
311    /// Per-host exclusions selected with `--host-profile` (aegis-fv4).
312    #[serde(default, skip_serializing_if = "Vec::is_empty")]
313    pub host_profiles: Vec<HostProfile>,
314}
315
316/// A host on which another lane owns part of the plan. Selecting it drops
317/// that part for the run and says so; an excluded runtime is never reported
318/// as verified (aegis-fv4).
319#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
320#[serde(deny_unknown_fields)]
321pub struct HostProfile {
322    pub name: String,
323    /// The lane that owns the crew runtimes on this host. When set, the
324    /// plan's `[crew]` is excluded on this host.
325    #[serde(default, skip_serializing_if = "Option::is_none")]
326    pub crew_excluded_owner: Option<String>,
327    /// Plan tools another lane owns on this host, each named with that lane
328    /// (aegis-fv4 D3: shuttle has no reviewed macOS delivery yet). Dropped for
329    /// the run and reported, never installed or verified by caboodle.
330    #[serde(default, skip_serializing_if = "Vec::is_empty")]
331    pub excluded_tools: Vec<ExcludedTool>,
332}
333
334#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
335#[serde(deny_unknown_fields)]
336pub struct ExcludedTool {
337    pub tool: ToolName,
338    pub owner: String,
339}
340
341#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
342#[serde(deny_unknown_fields)]
343pub struct QuipuMcp {
344    /// The Quipu server (or its `/mcp` endpoint). Never a credential.
345    pub url: String,
346    /// Absolute path of this host's token FILE, baked in as the helper's
347    /// default so Claude Code's own environment needs no QUIPU_AUTH_TOKEN_FILE.
348    /// A path, never the token.
349    #[serde(default, skip_serializing_if = "Option::is_none")]
350    pub token_file: Option<String>,
351}
352
353impl QuipuMcp {
354    fn validate(&self) -> Result<()> {
355        require_safe_text("quipu_mcp.url", &self.url)?;
356        let rest = self
357            .url
358            .strip_prefix("https://")
359            .or_else(|| self.url.strip_prefix("http://"))
360            .ok_or_else(|| anyhow::anyhow!("quipu_mcp.url must be an http:// or https:// URL"))?;
361        let authority = rest.split('/').next().unwrap_or("");
362        if authority.is_empty() || authority.contains('@') || self.url.contains(['?', '#', ' ']) {
363            bail!(
364                "quipu_mcp.url must be a plain server URL with no credentials, query or fragment"
365            );
366        }
367        if let Some(path) = &self.token_file {
368            require_safe_text("quipu_mcp.token_file", path)?;
369            if !path.starts_with('/') || path.contains(['\'', '\n', '\r']) {
370                bail!(
371                    "quipu_mcp.token_file must be an absolute path without quotes or line breaks"
372                );
373            }
374        }
375        Ok(())
376    }
377}
378
379#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
380#[serde(deny_unknown_fields)]
381pub struct StackConfig {
382    pub quipu: QuipuStackConfig,
383}
384
385#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
386#[serde(deny_unknown_fields)]
387pub struct QuipuStackConfig {
388    pub owl: QuipuOwlConfig,
389}
390
391#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
392#[serde(deny_unknown_fields)]
393pub struct QuipuOwlConfig {
394    pub reactive_materialize: bool,
395}
396
397impl StackConfig {
398    pub fn recommended() -> Self {
399        Self {
400            quipu: QuipuStackConfig {
401                owl: QuipuOwlConfig {
402                    reactive_materialize: true,
403                },
404            },
405        }
406    }
407}
408
409#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
410#[serde(deny_unknown_fields)]
411pub struct InstallIntent {
412    pub intended_use: String,
413    #[serde(default)]
414    pub crew_members: Vec<CrewMemberTheme>,
415    pub anticipated_questions: Vec<QuestionContract>,
416}
417
418#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
419#[serde(deny_unknown_fields)]
420pub struct CrewMemberTheme {
421    pub name: String,
422    pub theme: String,
423    pub domain: String,
424    pub role: String,
425}
426
427#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
428#[serde(deny_unknown_fields)]
429pub struct QuestionContract {
430    pub question: String,
431    pub answer_shape: String,
432    pub seed_intent: String,
433    pub sparql: String,
434    pub expected: String,
435    /// Set only on the built-in self-test contract. An explicit flag, not the
436    /// marker: a user's own question may legitimately use the same marker (CI's
437    /// fixture does), and must never be answered from caboodle's scratch store.
438    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
439    pub self_test: bool,
440}
441
442/// Marker the built-in self-test question seeds and expects. Same contract CI's
443/// quipu round trip uses (adapter.rs), so a fresh box can pass it with no
444/// ontology of its own (aegis-ro425e.4).
445pub const SELF_TEST_MARKER: &str = "caboodle-verify-roundtrip";
446
447impl QuestionContract {
448    /// The question offered when a first-time user presses enter at the
449    /// question-count prompt. It proves the store answers a real query; it
450    /// says nothing about the user's own domain, and `verify-questions` always
451    /// runs it against its own seeded scratch store.
452    pub fn self_test() -> Self {
453        Self {
454            question: "does the knowledge store answer a query about a fact written to it?".to_owned(),
455            answer_shape: "one entity label".to_owned(),
456            seed_intent: format!(
457                "caboodle writes one Verification node named {SELF_TEST_MARKER} into a scratch store"
458            ),
459            sparql: format!(
460                "SELECT ?s ?label WHERE {{ ?s <http://www.w3.org/2000/01/rdf-schema#label> ?label . FILTER(?label = \"{SELF_TEST_MARKER}\") }}"
461            ),
462            expected: SELF_TEST_MARKER.to_owned(),
463            self_test: true,
464        }
465    }
466
467    /// The explicit flag, or (for plans written before the flag existed) an
468    /// exact match on every field of the self-test contract. Never the marker
469    /// alone: that made a user question sharing the marker pass vacuously
470    /// against the seeded scratch store (caught by CI's negative control).
471    pub fn is_self_test(&self) -> bool {
472        self.self_test
473            || *self
474                == Self {
475                    self_test: false,
476                    ..Self::self_test()
477                }
478    }
479}
480
481impl InstallIntent {
482    pub fn read(path: &Path) -> Result<Self> {
483        if !path.exists() {
484            bail!(
485                "no intent file at {}; run `tambo init --guided` to answer the interview instead, or write one (see the \"Using caboodle\" chapter of docs/book)",
486                path.display()
487            );
488        }
489        let body =
490            fs::read_to_string(path).with_context(|| format!("read intent {}", path.display()))?;
491        let intent: Self =
492            toml::from_str(&body).with_context(|| format!("parse intent {}", path.display()))?;
493        intent.validate()?;
494        Ok(intent)
495    }
496
497    pub fn validate(&self) -> Result<()> {
498        require_safe_text("intended use", &self.intended_use)?;
499        if self.anticipated_questions.is_empty() {
500            bail!("intent requires at least one anticipated ontology question");
501        }
502        let mut names = std::collections::BTreeSet::new();
503        for member in &self.crew_members {
504            for (field, value) in [
505                ("crew member name", &member.name),
506                ("crew member theme", &member.theme),
507                ("crew member domain", &member.domain),
508                ("crew member role", &member.role),
509            ] {
510                require_safe_text(field, value)?;
511            }
512            if !names.insert(member.name.to_ascii_lowercase()) {
513                bail!("duplicate crew member name {:?}", member.name);
514            }
515        }
516        let mut questions = std::collections::BTreeSet::new();
517        for contract in &self.anticipated_questions {
518            for (field, value) in [
519                ("anticipated question", &contract.question),
520                ("answer shape", &contract.answer_shape),
521                ("seed intent", &contract.seed_intent),
522                ("expected answer", &contract.expected),
523            ] {
524                require_safe_text(field, value)?;
525            }
526            if !questions.insert(contract.question.to_ascii_lowercase()) {
527                bail!("duplicate anticipated question {:?}", contract.question);
528            }
529            let query = contract.sparql.to_ascii_uppercase();
530            if !(query.split_whitespace().any(|word| word == "SELECT")
531                || query.split_whitespace().any(|word| word == "ASK"))
532            {
533                bail!("anticipated question SPARQL must contain SELECT or ASK");
534            }
535            require_safe_text("anticipated question SPARQL", &contract.sparql)?;
536        }
537        Ok(())
538    }
539}
540
541fn require_safe_text(field: &str, value: &str) -> Result<()> {
542    if value.trim().is_empty() {
543        bail!("{field} must not be empty");
544    }
545    let lower = value.to_ascii_lowercase();
546    for marker in [
547        "password=",
548        "token=",
549        "secret=",
550        "authorization:",
551        "bearer ",
552    ] {
553        if lower.contains(marker) {
554            bail!("{field} appears to contain a credential ({marker})");
555        }
556    }
557    Ok(())
558}
559
560impl Plan {
561    /// Remote access is an explicit advanced option. Local embedded plans never
562    /// need a server or a client credential.
563    pub fn requires_remote_quipu(&self) -> bool {
564        self.quipu_mcp.is_some()
565            || std::env::var("QUIPU_SERVER").is_ok_and(|url| !url.trim().is_empty())
566    }
567
568    pub fn for_profile(profile: Profile) -> Self {
569        Self {
570            schema_version: SCHEMA_VERSION,
571            profile,
572            tools: profile.tools(),
573            shares: Vec::new(),
574            quipu_db: None,
575            quipu_flavor: QuipuFlavor::default(),
576            external_quipu: false,
577            stack_config: Some(StackConfig::recommended()),
578            crew: None,
579            intent: None,
580            embedding_model: None,
581            quipu_mcp: None,
582            host_profiles: Vec::new(),
583        }
584    }
585
586    pub fn for_crew(mode: CrewMode) -> Self {
587        Self {
588            profile: Profile::Crew,
589            crew: Some(CrewSelection::for_mode(mode)),
590            ..Self::for_profile(Profile::Crew)
591        }
592    }
593
594    pub fn read(path: &Path) -> Result<Self> {
595        if !path.exists() {
596            bail!(
597                "no plan at {}; run `tambo init --guided` (or `tambo plan`) first",
598                path.display()
599            );
600        }
601        let body =
602            fs::read_to_string(path).with_context(|| format!("read plan {}", path.display()))?;
603        let plan: Self =
604            toml::from_str(&body).with_context(|| format!("parse plan {}", path.display()))?;
605        plan.validate()?;
606        Ok(plan)
607    }
608
609    pub fn write(&self, path: &Path) -> Result<()> {
610        self.validate()?;
611        let body = toml::to_string_pretty(self).context("serialize plan")?;
612        fs::write(path, body).with_context(|| format!("write plan {}", path.display()))
613    }
614
615    pub fn validate(&self) -> Result<()> {
616        if self.schema_version != SCHEMA_VERSION {
617            bail!(
618                "unsupported plan schema {}; expected {}",
619                self.schema_version,
620                SCHEMA_VERSION
621            );
622        }
623        if self.external_quipu && self.quipu_flavor != QuipuFlavor::Release {
624            bail!("external_quipu cannot request a Tambo build flavor");
625        }
626        if self.tools.is_empty() {
627            bail!("plan selects no tools");
628        }
629        if !self.profile.accepts(&self.tools) {
630            bail!(
631                "tools do not match the {:?} profile conventions",
632                self.profile
633            );
634        }
635        if !self.shares.is_empty() && self.quipu_db.is_none() {
636            bail!("plans with knowledge shares require an explicit --quipu-db");
637        }
638        if self.shares.iter().any(|path| path.as_os_str().is_empty()) {
639            bail!("knowledge share paths must not be empty");
640        }
641        if let Some(intent) = &self.intent {
642            intent.validate()?;
643        }
644        if let Some(model) = &self.embedding_model {
645            model.validate()?;
646        }
647        if let Some(mcp) = &self.quipu_mcp {
648            if !self.tools.contains(&ToolName::Quipu) {
649                bail!("[quipu_mcp] requires a profile that installs quipu");
650            }
651            mcp.validate()?;
652        }
653        match (self.profile, &self.crew) {
654            (Profile::Crew, Some(crew)) => crew.validate()?,
655            (Profile::Crew, None) => bail!("crew profile requires a [crew] selection"),
656            (Profile::Everything, Some(crew)) => crew.validate()?,
657            (_, Some(_)) => bail!("[crew] is only valid with profile = \"crew\" or \"everything\""),
658            (_, None) => {}
659        }
660        let mut names = std::collections::HashSet::new();
661        for host in &self.host_profiles {
662            if host.name.trim().is_empty() || !names.insert(host.name.as_str()) {
663                bail!("host profile names must be non-empty and unique");
664            }
665            if host
666                .crew_excluded_owner
667                .as_deref()
668                .is_some_and(|owner| owner.trim().is_empty())
669            {
670                bail!(
671                    "host profile {}: crew_excluded_owner must name the owning lane",
672                    host.name
673                );
674            }
675            let mut excluded = std::collections::BTreeSet::new();
676            for entry in &host.excluded_tools {
677                if entry.owner.trim().is_empty() {
678                    bail!(
679                        "host profile {}: excluded tool {} must name the owning lane",
680                        host.name,
681                        entry.tool.as_str()
682                    );
683                }
684                // Built-ins are the profile's fixed prefix: excluding one would leave
685                // a plan no profile accepts. Only declared stack members qualify.
686                if !matches!(entry.tool, ToolName::Member(_))
687                    || !self.tools.contains(&entry.tool)
688                    || !excluded.insert(entry.tool)
689                {
690                    bail!(
691                        "host profile {}: excluded tool {} must be a selected stack member, listed once",
692                        host.name,
693                        entry.tool.as_str()
694                    );
695                }
696            }
697        }
698        Ok(())
699    }
700
701    /// Apply a named host profile. Returns the plan to run and the exclusion
702    /// lines to print. An unknown name refuses rather than running everything.
703    pub fn for_host(mut self, host: Option<&str>) -> Result<(Self, Vec<String>)> {
704        let Some(host) = host else {
705            return Ok((self, Vec::new()));
706        };
707        let profile = self
708            .host_profiles
709            .iter()
710            .find(|p| p.name == host)
711            .cloned()
712            .with_context(|| format!("plan has no host profile named {host}"))?;
713        let mut excluded = Vec::new();
714        if let Some(owner) = profile.crew_excluded_owner {
715            if self.profile == Profile::Crew {
716                bail!("host profile {host} excludes crew, but this plan's profile IS crew");
717            }
718            if self.crew.take().is_some() {
719                excluded.push(format!(
720                    "crew: EXCLUDED on host profile {host}; owned by {owner}; not installed or verified by caboodle"
721                ));
722            }
723        }
724        // The returned plan has already applied this profile's tool exclusions, so
725        // it must not carry them into a later validate() of the selected plan.
726        if let Some(applied) = self.host_profiles.iter_mut().find(|p| p.name == host) {
727            applied.excluded_tools.clear();
728        }
729        for entry in &profile.excluded_tools {
730            if let Some(index) = self.tools.iter().position(|t| *t == entry.tool) {
731                self.tools.remove(index);
732                excluded.push(format!(
733                    "{}: EXCLUDED on host profile {host}; owned by {}; not installed or verified by caboodle",
734                    entry.tool.as_str(),
735                    entry.owner
736                ));
737            }
738        }
739        Ok((self, excluded))
740    }
741}
742
743#[derive(Debug, Default, Serialize, Deserialize)]
744pub struct State {
745    pub schema_version: u32,
746    pub tools: BTreeMap<String, ToolState>,
747    #[serde(default)]
748    pub crew: BTreeMap<String, CrewRuntimeState>,
749    #[serde(default)]
750    pub shares: BTreeMap<String, ShareState>,
751    #[serde(default)]
752    pub models: BTreeMap<String, ModelArtifactState>,
753}
754
755#[derive(Debug, Clone, Serialize, Deserialize)]
756pub struct ToolState {
757    pub version: String,
758    pub applied: bool,
759    pub verified: bool,
760}
761
762#[derive(Debug, Clone, Serialize, Deserialize)]
763pub struct CrewRuntimeState {
764    pub version: String,
765    pub applied: bool,
766    pub verified: bool,
767}
768
769#[derive(Debug, Clone, Serialize, Deserialize)]
770pub struct ModelArtifactState {
771    pub path: PathBuf,
772    pub sha256: String,
773    pub provisioned: bool,
774    /// True only after verify re-hashed the artifact on disk; a fetch alone
775    /// records `provisioned`, never `verified`.
776    pub verified: bool,
777}
778
779#[derive(Debug, Clone, Serialize, Deserialize)]
780pub struct ShareState {
781    pub path: PathBuf,
782    pub staging_graph: String,
783    pub outcome: String,
784    pub promotion_eligible: bool,
785    pub blockers: Vec<String>,
786}
787
788impl State {
789    pub fn read(path: &Path) -> Result<Self> {
790        if !path.exists() {
791            return Ok(Self {
792                schema_version: SCHEMA_VERSION,
793                tools: BTreeMap::new(),
794                crew: BTreeMap::new(),
795                shares: BTreeMap::new(),
796                models: BTreeMap::new(),
797            });
798        }
799        let body =
800            fs::read_to_string(path).with_context(|| format!("read state {}", path.display()))?;
801        let state: Self = serde_json::from_str(&body)
802            .with_context(|| format!("parse state {}", path.display()))?;
803        if state.schema_version != SCHEMA_VERSION {
804            bail!("unsupported state schema {}", state.schema_version);
805        }
806        Ok(state)
807    }
808
809    pub fn write(&self, path: &Path) -> Result<()> {
810        let parent = path.parent().unwrap_or_else(|| Path::new("."));
811        fs::create_dir_all(parent)
812            .with_context(|| format!("create state directory {}", parent.display()))?;
813        let mut tmp = tempfile::NamedTempFile::new_in(parent)
814            .with_context(|| format!("create temporary state in {}", parent.display()))?;
815        serde_json::to_writer_pretty(&mut tmp, self).context("serialize state")?;
816        use std::io::Write;
817        writeln!(tmp).context("finish state")?;
818        tmp.persist(path)
819            .map_err(|error| error.error)
820            .with_context(|| format!("replace state {}", path.display()))?;
821        Ok(())
822    }
823}
824
825#[cfg(test)]
826mod self_test_question_tests {
827    use super::QuestionContract;
828
829    #[test]
830    fn self_test_is_the_flag_never_the_marker() {
831        assert!(QuestionContract::self_test().is_self_test());
832        // A user question that shares the marker (CI's fixture does) is NOT the
833        // self-test and must be verified against the user's store.
834        let mut users_own = QuestionContract::self_test();
835        users_own.self_test = false;
836        users_own.question = "what depends on the service?".to_owned();
837        assert!(!users_own.is_self_test());
838        // A plan written before the flag existed: every field equal, flag absent.
839        let legacy = QuestionContract {
840            self_test: false,
841            ..QuestionContract::self_test()
842        };
843        assert!(legacy.is_self_test());
844    }
845
846    #[test]
847    fn the_flag_round_trips_and_is_omitted_for_user_questions() {
848        let toml = toml::to_string(&QuestionContract::self_test()).unwrap();
849        assert!(toml.contains("self_test = true"));
850        let mut user = QuestionContract::self_test();
851        user.self_test = false;
852        user.question = "q".to_owned();
853        assert!(!toml::to_string(&user).unwrap().contains("self_test"));
854    }
855}
856
857#[cfg(test)]
858mod host_profile_tests {
859    use super::{CrewMode, ExcludedTool, HostProfile, Plan, Profile, ToolName};
860
861    fn plan() -> Plan {
862        let mut plan = Plan::for_profile(Profile::Everything);
863        plan.crew = Plan::for_crew(CrewMode::Both).crew;
864        plan.host_profiles = vec![HostProfile {
865            name: "mac".into(),
866            crew_excluded_owner: Some("st-peer-autopull".into()),
867            excluded_tools: Vec::new(),
868        }];
869        plan
870    }
871
872    #[test]
873    fn host_profile_drops_an_excluded_tool_and_names_its_owner() {
874        // aegis-fv4 D3: a tool with no reviewed delivery on this host.
875        let mut excluding = plan();
876        let tool = *excluding
877            .tools
878            .iter()
879            .find(|t| matches!(t, ToolName::Member(_)))
880            .expect("the everything profile carries stack members");
881        excluding.host_profiles[0].excluded_tools = vec![ExcludedTool {
882            tool,
883            owner: "its own release lane".into(),
884        }];
885        excluding.validate().unwrap();
886        let (selected, lines) = excluding.clone().for_host(Some("mac")).unwrap();
887        assert!(!selected.tools.contains(&tool));
888        assert!(lines.iter().any(|l| l.starts_with(tool.as_str())
889            && l.contains("EXCLUDED")
890            && l.contains("its own release lane")));
891        // The selected plan still validates (it is re-checked by the commands).
892        selected.validate().unwrap();
893        let (unchanged, _) = excluding.for_host(None).unwrap();
894        assert!(unchanged.tools.contains(&tool));
895    }
896
897    #[test]
898    fn excluded_tools_must_be_owned_selected_and_listed_once() {
899        let mut ownerless = plan();
900        let tool = *ownerless
901            .tools
902            .iter()
903            .find(|t| matches!(t, ToolName::Member(_)))
904            .unwrap();
905        ownerless.host_profiles[0].excluded_tools = vec![ExcludedTool {
906            tool,
907            owner: " ".into(),
908        }];
909        assert!(ownerless.validate().is_err());
910        let mut twice = plan();
911        let entry = ExcludedTool {
912            tool,
913            owner: "lane".into(),
914        };
915        twice.host_profiles[0].excluded_tools = vec![entry.clone(), entry.clone()];
916        assert!(twice.validate().is_err());
917        let mut unselected = plan();
918        unselected.tools.retain(|t| *t != tool);
919        unselected.host_profiles[0].excluded_tools = vec![ExcludedTool {
920            tool,
921            owner: "lane".into(),
922        }];
923        assert!(unselected.validate().is_err());
924        let mut builtin = plan();
925        builtin.host_profiles[0].excluded_tools = vec![ExcludedTool {
926            tool: ToolName::Bobbin,
927            owner: "lane".into(),
928        }];
929        assert!(builtin.validate().is_err());
930    }
931
932    #[test]
933    fn host_profile_drops_crew_and_names_the_owner() {
934        let (selected, lines) = plan().for_host(Some("mac")).unwrap();
935        assert!(selected.crew.is_none());
936        assert_eq!(lines.len(), 1);
937        assert!(lines[0].contains("EXCLUDED") && lines[0].contains("st-peer-autopull"));
938        let (unchanged, none) = plan().for_host(None).unwrap();
939        assert!(unchanged.crew.is_some() && none.is_empty());
940        assert!(plan().for_host(Some("nonesuch")).is_err());
941    }
942
943    #[test]
944    fn host_profiles_must_be_named_uniquely_and_own_what_they_exclude() {
945        let mut duplicate = plan();
946        duplicate
947            .host_profiles
948            .push(duplicate.host_profiles[0].clone());
949        assert!(duplicate.validate().is_err());
950        let mut ownerless = plan();
951        ownerless.host_profiles[0].crew_excluded_owner = Some(" ".into());
952        assert!(ownerless.validate().is_err());
953        let mut crew_only = Plan::for_crew(CrewMode::Both);
954        crew_only.host_profiles = plan().host_profiles;
955        assert!(crew_only.for_host(Some("mac")).is_err());
956    }
957}