Skip to main content

caboodle/
engine.rs

1// Compatibility: retained library imports, persisted paths, environment keys and
2// explicit old-command/version probes remain readable during the product rename.
3use std::{ffi::OsString, path::Path};
4
5use anyhow::{bail, Context, Result};
6use serde::Deserialize;
7
8use crate::{
9    adapter::adapter_for_plan,
10    configuration,
11    crew::{self, CrewEvidence},
12    embedding, emission,
13    model::{ModelArtifactState, Plan, ShareState, State, ToolState},
14};
15
16#[derive(Deserialize)]
17struct ImportPromotion {
18    eligible: bool,
19    blockers: Vec<String>,
20}
21
22#[derive(Deserialize)]
23struct ImportResult {
24    outcome: String,
25    share_id: String,
26    staging_graph: String,
27    promotion: ImportPromotion,
28}
29
30/// What `apply` should do about an installed tool whose version is not the one
31/// this Tambo build reviewed (aegis-5ctwu3).
32///
33/// Split out as a pure function ON PURPOSE: the act it decides is a network
34/// download, so a test that drove `apply` end to end would be measuring GitHub
35/// rather than the decision. Every branch below is covered by a unit test; the
36/// branch that could not be, would not have been.
37#[derive(Debug, PartialEq, Eq)]
38pub(crate) enum Convergence {
39    /// Installed IS the reviewed release. Nothing to do.
40    Current,
41    /// Installed is strictly older. Converge upward, then prove by read-back.
42    Converge,
43    /// Installed is at or ahead of the reviewed release, or the two versions
44    /// are not comparable. Do NOT install — that would be a downgrade or a
45    /// replacement on a guess (aegis-48dvl3). Say so instead.
46    Decline,
47    /// Skewed, and the operator asked for no installs. A MISSING tool already
48    /// fails under `--skip-install`; a skewed one reporting "applied" was the
49    /// inconsistency this bead is about.
50    RefuseSkipInstall,
51}
52
53pub(crate) fn decide_convergence(
54    installed: &str,
55    desired: &str,
56    is_current: bool,
57    skip_install: bool,
58) -> Convergence {
59    if is_current {
60        return Convergence::Current;
61    }
62    if skip_install {
63        return Convergence::RefuseSkipInstall;
64    }
65    match crate::release_update::behind_reviewed(installed, desired) {
66        Some(true) => Convergence::Converge,
67        // `None` is "cannot compare", NOT "stale". Treating it as stale is how
68        // an unrecognised build gets replaced on a guess.
69        Some(false) | None => Convergence::Decline,
70    }
71}
72
73pub fn apply(plan: &Plan, state_path: &Path, skip_install: bool) -> Result<State> {
74    plan.validate()?;
75    let mut state = State::read(state_path)?;
76    let mut failures = Vec::new();
77    for &name in &plan.tools {
78        if name == crate::model::ToolName::Quipu && plan.external_quipu {
79            // A failed external path/version probe cannot retain an earlier proof.
80            state.tools.remove(name.as_str());
81            state.write(state_path)?;
82        }
83        let adapter = adapter_for_plan(name, plan)?;
84        let desired = adapter.desired_version();
85        // aegis-5ctwu3: PRESENCE IS NOT CONVERGENCE. Until this match looked at
86        // `is_current`, a binary that merely EXISTED was reported "applied" and
87        // left at whatever version it was — bobbin sat at 0.1.0 while this build
88        // reviewed 0.17.0, and `tambo install` then failed its own verify with
89        // `error: unexpected argument '--source' found`. The installer knew the
90        // newer contract, verified against it, and had never installed it; the
91        // operator got a clap error instead of "your bobbin is 16 minors old".
92        // `st doctor` had it right the whole time ("bobbin 0.1.0 installed —
93        // 0.17.0 available (STALE)"), which is the tell that this was never a
94        // detection problem.
95        let version = (|| -> Result<String> {
96            Ok(match adapter.version() {
97                Ok(installed) if adapter.is_current(&installed) => installed,
98
99                // Keep externally installed newer Bobbin builds without replacing
100                // them. Quickstart still requires the functional round-trip below;
101                // retaining a version never means it has been verified.
102                Ok(installed)
103                    if name == crate::model::ToolName::Bobbin
104                        && skip_install
105                        && crate::release_update::behind_reviewed(&desired, &installed)
106                            == Some(true) =>
107                {
108                    println!(
109                        "bobbin: warning: retaining newer installed {installed}; reviewed {desired}. \
110                         No installation performed; functional verification is still required."
111                    );
112                    installed
113                }
114
115                // Known-wrong under --skip-install. The flag means "do not install",
116                // not "call it applied anyway", and a MISSING tool already fails
117                // here — a SKEWED one reporting success was the inconsistency.
118                Ok(installed) => {
119                    match decide_convergence(&installed, &desired, false, skip_install) {
120                        Convergence::Current => installed,
121                        Convergence::RefuseSkipInstall => bail!(
122                        "{} is installed at {installed} but this Tambo build reviewed {desired}; \
123                         --skip-install refuses to converge it. Install {desired}, or drop \
124                         --skip-install to let apply converge it.",
125                        name.as_str()
126                    ),
127                        Convergence::Decline => {
128                            println!(
129                                "{}: NOT converged — installed {installed}, reviewed {desired}. \
130                             Apply does not downgrade or replace an unrecognised version; \
131                             use `tambo update-release` deliberately if that is what you want.",
132                                name.as_str()
133                            );
134                            installed
135                        }
136                        Convergence::Converge => {
137                            eprintln!(
138                                "{}: stale ({installed}); converging to reviewed {desired}",
139                                name.as_str()
140                            );
141                            adapter.install().with_context(|| {
142                                format!("{} convergence to reviewed {desired}", name.as_str())
143                            })?;
144                            let after = adapter.version().with_context(|| {
145                                format!("{} version read-back after convergence", name.as_str())
146                            })?;
147                            // The read-back is the proof, not the install's exit code.
148                            if !adapter.is_current(&after) {
149                                bail!(
150                                    "{} did not reach reviewed release {desired} (got {after}); \
151                                 refusing to record it as applied",
152                                    name.as_str()
153                                );
154                            }
155                            println!("{}: converged {installed} -> {after}", name.as_str());
156                            after
157                        }
158                    }
159                }
160
161                Err(error)
162                    if !skip_install
163                        && !(name == crate::model::ToolName::Quipu && plan.external_quipu) =>
164                {
165                    eprintln!("{}: not installed ({error:#}); installing", name.as_str());
166                    adapter
167                    .install()
168                    .with_context(|| {
169                        format!(
170                            "{} install step (run `tambo doctor` to list every blocker on this host)",
171                            name.as_str()
172                        )
173                    })?;
174                    adapter.version().with_context(|| {
175                        format!("{} version read-back after install", name.as_str())
176                    })?
177                }
178                Err(error) => {
179                    return Err(error).context(format!("{} version read-back", name.as_str()))
180                }
181            })
182        })();
183        // aegis-z1u9s0: the version above is the MANAGED copy's for a member, so
184        // a stale copy earlier on PATH left apply saying "applied" while the
185        // shell ran the older release. Apply is the step that says done; it
186        // checks what PATH runs, as verify does.
187        let version = version.and_then(|version| {
188            if name == crate::model::ToolName::Quipu && !plan.external_quipu {
189                // A current pair needs only its missing alias, never a downgrade
190                // to the older reviewed release just to add a command name.
191                if skip_install {
192                    crate::adapter::verify_existing_quipu_alias()?;
193                } else {
194                    crate::adapter::ensure_existing_quipu_alias()?;
195                }
196            }
197            if !(name == crate::model::ToolName::Quipu && plan.external_quipu) {
198                check_path_resolution(name)?;
199            }
200            Ok(version)
201        });
202        let version = match version {
203            Ok(version) => version,
204            Err(error) => {
205                // An earlier successful run is no longer evidence for this tool.
206                // Persist the invalidation before continuing with independent tools.
207                state.tools.remove(name.as_str());
208                state.write(state_path)?;
209                eprintln!("{}: FAILED: {error:#}", name.as_str());
210                failures.push(format!("{}: {error:#}", name.as_str()));
211                continue;
212            }
213        };
214        let remains_verified = state
215            .tools
216            .get(name.as_str())
217            .is_some_and(|previous| previous.version == version && previous.verified);
218        state.tools.insert(
219            name.as_str().to_owned(),
220            ToolState {
221                version,
222                applied: true,
223                verified: remains_verified,
224            },
225        );
226        state.write(state_path)?;
227        emission::queue_transition(
228            state_path,
229            name.as_str(),
230            "applied",
231            &state.tools[name.as_str()].version,
232        )?;
233        println!("{}: applied", name.as_str());
234    }
235    if !failures.is_empty() {
236        bail!(
237            "{} tool(s) failed to apply:\n{}\nSuccessful tools were saved. Fix the failures and rerun the same command. \
238             Stack configuration, model provisioning, crew setup, share imports and verification \
239             have not run; run `tambo doctor` for preflight guidance.",
240            failures.len(),
241            failures.join("\n")
242        );
243    }
244    if let Some(config) = &plan.stack_config {
245        configuration::apply(config)?;
246        println!("stack configuration: applied");
247    }
248    if let Some(model) = &plan.embedding_model {
249        let provisioned = embedding::provision(model, crate::adapter::download_https)
250            .context("embedding-model provisioning step")?;
251        for artifact in provisioned {
252            // Like tools, a re-fetch of identical pinned bytes keeps its
253            // verified mark; anything else must earn it again.
254            let remains_verified = state
255                .models
256                .get(&artifact.name)
257                .is_some_and(|previous| previous.sha256 == artifact.sha256 && previous.verified);
258            state.models.insert(
259                artifact.name.clone(),
260                ModelArtifactState {
261                    path: artifact.path.clone(),
262                    sha256: artifact.sha256.clone(),
263                    provisioned: true,
264                    verified: remains_verified,
265                },
266            );
267            state.write(state_path)?;
268            emission::queue_transition(
269                state_path,
270                &format!("embedding-model/{}", artifact.name),
271                "applied",
272                &artifact.sha256,
273            )?;
274            let outcome = match artifact.outcome {
275                embedding::ArtifactOutcome::Fetched => "provisioned",
276                embedding::ArtifactOutcome::Current => "current",
277            };
278            println!("embedding-model {}: {outcome}", artifact.name);
279        }
280    }
281    #[cfg(unix)]
282    if let Some(mcp) = &plan.quipu_mcp {
283        let outcome = crate::quipu_mcp::provision(&mcp.url, mcp.token_file.as_deref())
284            .context("quipu MCP registration step (headersHelper)")?;
285        println!("quipu mcp: {outcome}");
286    }
287    if let Some(selection) = &plan.crew {
288        crew::apply(selection, &mut state, skip_install)?;
289        state.write(state_path)?;
290        for (name, runtime) in &state.crew {
291            emission::queue_transition(state_path, name, "applied", &runtime.version)?;
292        }
293    }
294    // After the tools and crew (which may have just installed st): register the
295    // stack's hook bundles for the tools this plan installs (aegis-u1ybxo).
296    // Each tool's OWN bundle when the installed tool prints one (aegis-5s32or.5).
297    let resolved = crate::tool_bundles::resolve(&crate::hook_bundles::selected(
298        plan.tools.iter().map(|t| t.as_str()),
299    ));
300    for (name, _, source) in &resolved {
301        println!("hook bundle {name}: source {}", source.label());
302    }
303    for line in crate::hook_bundles::register(&crate::tool_bundles::as_pairs(&resolved), "st")? {
304        println!("hook bundle {line}");
305    }
306    // seeds + Desire Path: redirect habitual `bd` to `sd`, never overwriting a
307    // host's existing `bd` rule (aegis-fbaso4).
308    if crate::dp_alias::wanted(plan.tools.iter().map(|t| t.as_str())) {
309        let outcome = crate::dp_alias::apply(crate::dp_alias::locate().as_deref(), None)
310            .context("Desire Path bd -> sd alias step")?;
311        println!("dp alias {}", outcome.line());
312    }
313    load_vocabulary(plan)?;
314    consume_shares(plan, &mut state, state_path)?;
315    Ok(state)
316}
317
318/// Load the pinned Quechua vocabulary (aegis-1i5h1j.3) into the plan's Quipu
319/// store. It is part of Quipu: a plan without Quipu, or without a store to load
320/// into, loads nothing, and verify still proves the release round trip in a
321/// throwaway store.
322fn load_vocabulary(plan: &Plan) -> Result<()> {
323    if !plan.tools.contains(&crate::model::ToolName::Quipu) {
324        return Ok(());
325    }
326    let cached = crate::vocabulary::ensure_cached()?;
327    match plan.quipu_db.as_deref() {
328        Some(db) => {
329            crate::vocabulary::load(db)?;
330            println!(
331                "vocabulary: quechua v{} loaded into {}",
332                crate::vocabulary::QUECHUA_VERSION,
333                db.display()
334            );
335        }
336        None => println!(
337            "vocabulary: quechua v{} cached at {} (no plan quipu_db to load into)",
338            crate::vocabulary::QUECHUA_VERSION,
339            cached.display()
340        ),
341    }
342    Ok(())
343}
344
345fn consume_shares(plan: &Plan, state: &mut State, state_path: &Path) -> Result<()> {
346    let Some(db) = plan.quipu_db.as_deref() else {
347        return Ok(());
348    };
349    for share in &plan.shares {
350        let result = crate::adapter::checked(
351            "quipu",
352            [
353                OsString::from("import"),
354                share.as_os_str().to_owned(),
355                OsString::from("--db"),
356                db.as_os_str().to_owned(),
357            ],
358            None,
359        )
360        .with_context(|| format!("import canonical Quipu share {}", share.display()))?;
361        let imported: ImportResult = serde_json::from_slice(&result.stdout)
362            .with_context(|| format!("parse Quipu import result for {}", share.display()))?;
363        if !matches!(
364            imported.outcome.as_str(),
365            "staged" | "quarantined" | "unchanged"
366        ) {
367            bail!(
368                "Quipu returned unknown share import outcome {:?} for {}",
369                imported.outcome,
370                share.display()
371            );
372        }
373        state.shares.insert(
374            imported.share_id.clone(),
375            ShareState {
376                path: share.clone(),
377                staging_graph: imported.staging_graph,
378                outcome: imported.outcome.clone(),
379                promotion_eligible: imported.promotion.eligible,
380                blockers: imported.promotion.blockers,
381            },
382        );
383        state.write(state_path)?;
384        println!(
385            "share {}: {} (promotion eligible: {})",
386            imported.share_id, imported.outcome, imported.promotion.eligible
387        );
388    }
389    Ok(())
390}
391
392/// Refuse to call a tool verified when the copy PATH runs is not the copy that
393/// was verified (aegis-70qlhs). A different file with the same version is a
394/// note, not a failure: it runs the same build.
395fn check_path_resolution(name: crate::model::ToolName) -> Result<()> {
396    let path = std::env::var_os("PATH");
397    for &program in crate::adapter::programs(name) {
398        match crate::adapter::path_resolution(program, path.as_deref()) {
399            crate::adapter::PathResolution::Managed => {}
400            crate::adapter::PathResolution::NotOnPath => println!(
401                "{}: note: `{program}` is not on PATH; add the install directory to PATH to run it",
402                name.as_str()
403            ),
404            crate::adapter::PathResolution::SameBuild { runs, managed } => println!(
405                "{}: note: PATH runs {} rather than {}, but both report the same version",
406                name.as_str(),
407                runs.display(),
408                managed.display()
409            ),
410            crate::adapter::PathResolution::Shadowed {
411                runs,
412                runs_version,
413                managed,
414                managed_version,
415            } => anyhow::bail!(
416                "{program} is SHADOWED: PATH runs {} ({}), not the verified {} ({}). \
417                 Remove the stale copy or put {} earlier on PATH",
418                runs.display(),
419                runs_version.trim(),
420                managed.display(),
421                managed_version.trim(),
422                managed
423                    .parent()
424                    .map(|d| d.display().to_string())
425                    .unwrap_or_default()
426            ),
427        }
428    }
429    Ok(())
430}
431
432pub fn verify(plan: &Plan, state_path: &Path, evidence: &CrewEvidence) -> Result<State> {
433    plan.validate()?;
434    let mut state = State::read(state_path)?;
435    // The hook bundles apply registers for the plan's tools (aegis-u1ybxo).
436    // Asserted for every plan, as apply registers for every plan, and FIRST:
437    // it is independent of the tools' functional checks, and a skewed tool
438    // failing below must not hide whether the hooks are registered (measured
439    // on a fresh Mac, where a bobbin skew left the bundles never asserted).
440    for line in hook_bundles_verify(plan)? {
441        println!("hook bundle {line}");
442    }
443    if plan.tools.contains(&crate::model::ToolName::Quipu) && plan.requires_remote_quipu() {
444        if let Some(record) = state.tools.get_mut("quipu") {
445            record.verified = false;
446            state.write(state_path)?;
447        }
448        let server = std::env::var("QUIPU_SERVER")
449            .ok()
450            .filter(|url| !url.trim().is_empty())
451            .unwrap_or_else(|| {
452                plan.quipu_mcp
453                    .as_ref()
454                    .map(|mcp| mcp.url.clone())
455                    .unwrap_or_else(|| "http://localhost:3030".to_owned())
456            });
457        let source = crate::quipu_credential::verify(&server)
458            .context("live Quipu client credential verification")?;
459        println!("quipu credential: canonical file 0400/0700 and authenticated read verified; source: {source}");
460    }
461    for &name in &plan.tools {
462        if name == crate::model::ToolName::Quipu && plan.external_quipu {
463            // A failed external path/version probe cannot retain an earlier proof.
464            state.tools.remove(name.as_str());
465            state.write(state_path)?;
466        }
467        let adapter = adapter_for_plan(name, plan)?;
468        let version = adapter
469            .version()
470            .with_context(|| format!("{} version read-back", name.as_str()))?;
471        // aegis-5ctwu3 item 3. A verification failure on a SKEWED tool used to
472        // surface the tool's own complaint — `error: unexpected argument
473        // '--source' found` — which points at an argument, not at the cause. We
474        // verify against the contract of the release we reviewed, so when the
475        // installed version is not that release, the skew IS the finding and
476        // must be said first.
477        adapter.verify().with_context(|| {
478            let desired = adapter.desired_version();
479            if adapter.is_current(&version) {
480                format!("{} functional verification", name.as_str())
481            } else {
482                format!(
483                    "{} functional verification — VERSION SKEW: {version} is installed, \
484                     this Tambo build reviewed and verifies against {desired}. The error \
485                     below is most likely that skew, not a broken tool. Converge with \
486                     `tambo apply` (or `tambo update`), then re-verify.",
487                    name.as_str()
488                )
489            }
490        })?;
491        if !(name == crate::model::ToolName::Quipu && plan.external_quipu) {
492            check_path_resolution(name)?;
493        }
494        state.tools.insert(
495            name.as_str().to_owned(),
496            ToolState {
497                version,
498                applied: true,
499                verified: true,
500            },
501        );
502        state.write(state_path)?;
503        emission::queue_transition(
504            state_path,
505            name.as_str(),
506            "verified",
507            &state.tools[name.as_str()].version,
508        )?;
509        println!("{}: verified", name.as_str());
510    }
511    // After the tools: Desire Path itself was verified above (aegis-fbaso4).
512    if crate::dp_alias::wanted(plan.tools.iter().map(|t| t.as_str())) {
513        let outcome = crate::dp_alias::verify(crate::dp_alias::locate().as_deref(), None)
514            .context("Desire Path bd -> sd alias")?;
515        println!("dp alias {}", outcome.line());
516    }
517    if let Some(model) = &plan.embedding_model {
518        // This is deliberately a re-hash, not an embed round-trip; see
519        // embedding::verify for why claiming more would be a banner.
520        embedding::verify(model).context("embedding-model artifact re-hash step")?;
521        for artifact in &model.artifacts {
522            state.models.insert(
523                artifact.name.clone(),
524                ModelArtifactState {
525                    path: model.destination.join(&artifact.name),
526                    sha256: artifact.sha256.clone(),
527                    provisioned: true,
528                    verified: true,
529                },
530            );
531            state.write(state_path)?;
532            emission::queue_transition(
533                state_path,
534                &format!("embedding-model/{}", artifact.name),
535                "verified",
536                &artifact.sha256,
537            )?;
538            println!("embedding-model {}: digest re-checked", artifact.name);
539        }
540    }
541    #[cfg(unix)]
542    if let Some(mcp) = &plan.quipu_mcp {
543        let notes = crate::quipu_mcp::verify(&mcp.url)
544            .context("quipu MCP authenticated-write probe (headersHelper)")?;
545        for note in notes {
546            println!("quipu mcp: {note}");
547        }
548        println!("quipu mcp: authenticated MCP write reached Quipu's parser; unauthenticated control was refused");
549    }
550    if let Some(selection) = &plan.crew {
551        crew::verify(selection, evidence, &mut state)?;
552        state.write(state_path)?;
553        for (name, runtime) in &state.crew {
554            emission::queue_transition(state_path, name, "verified", &runtime.version)?;
555        }
556    }
557    Ok(state)
558}
559
560/// Compare the running toolchain to the releases reviewed by this Tambo
561/// build. Returns true only when every selected tool is current.
562pub(crate) fn reviewed_drift_status(installed: &str, desired: &str) -> &'static str {
563    match crate::release_update::behind_reviewed(installed, desired) {
564        Some(true) => "update available",
565        Some(false) if crate::release_update::behind_reviewed(desired, installed) == Some(true) => {
566            "ahead of reviewed pin; downgrade refused"
567        }
568        Some(false) | None => "version drift; review required",
569    }
570}
571
572/// A tool whose binary a package manager owns is judged by its package receipt
573/// and never installed by Tambo (aegis-6f1, option A): the package lane is
574/// its only writer. Returns the line to print and whether it is current.
575fn package_owned_status(name: crate::model::ToolName, desired: &str) -> Option<(String, bool)> {
576    use crate::package::{apt_receipt, primary_program, standing, Standing};
577    let receipt = primary_program(name)
578        .and_then(|program| apt_receipt(&program))
579        .or_else(|| crate::package::brew_receipt(name))?;
580    let status = standing(&receipt.version, desired);
581    let verdict = match status {
582        Standing::Current => "current",
583        Standing::Ahead => "ahead of reviewed pin; downgrade refused",
584        Standing::Behind => {
585            "behind reviewed pin; delivered by the package lane, not installed by Tambo"
586        }
587        Standing::Unknown => "version drift; review required",
588    };
589    Some((
590        format!(
591            "{}: {verdict} (package-owned: {} {} {}; reviewed: {desired})",
592            name.as_str(),
593            receipt.manager,
594            receipt.package,
595            receipt.version
596        ),
597        status == Standing::Current,
598    ))
599}
600
601/// Member delivery through the stack's Homebrew tap (aegis-6f1). Applies only
602/// where Tambo may drive brew and the tool has a stack formula. Returns the
603/// verified version when brew delivered (or already holds) the tool, `None` to
604/// fall back to the other lanes (the release download when the tap is older
605/// than the pin). Never downgrades.
606fn brew_update(
607    name: crate::model::ToolName,
608    adapter: &dyn crate::adapter::Adapter,
609) -> Result<Option<String>> {
610    use crate::package::{
611        brew_channel, brew_converge, brew_formula, brew_program, standing, Standing,
612    };
613    if brew_formula(name).is_none() || brew_program().is_none() {
614        return Ok(None);
615    }
616    let Some(channel) = brew_channel(name)? else {
617        return Ok(None);
618    };
619    let desired = adapter.desired_version();
620    let stable = format!("pkg {}", channel.stable);
621    if channel.pinned {
622        let installed = channel
623            .installed
624            .context("pinned brew formula has no installed receipt")?;
625        println!(
626            "{}: pinned Homebrew formula; update skipped ({} {}; stable: {})",
627            name.as_str(),
628            crate::package::STACK_TAP,
629            installed,
630            channel.stable
631        );
632        return brew_version(adapter, &installed).map(Some);
633    }
634    match channel.installed {
635        Some(installed) => match standing(&installed, &stable) {
636            Standing::Behind => {
637                // A tap older than the minimum reviewed release cannot deliver
638                // it; keep the existing package ownership/fallback decision.
639                if standing(&channel.stable, &desired) == Standing::Behind {
640                    return Ok(None);
641                }
642                brew_converge(name, true)?;
643            }
644            Standing::Current | Standing::Ahead => {
645                let word = if standing(&installed, &stable) == Standing::Ahead {
646                    "ahead of stable channel; downgrade refused"
647                } else {
648                    "current stable channel"
649                };
650                println!(
651                    "{}: {word} (package-owned: brew {} {}; stable: {})",
652                    name.as_str(),
653                    crate::package::STACK_TAP,
654                    installed,
655                    channel.stable
656                );
657                return brew_version(adapter, &installed).map(Some);
658            }
659            Standing::Unknown => {
660                anyhow::bail!("{} brew versions cannot be compared", name.as_str())
661            }
662        },
663        None => {
664            if standing(&channel.stable, &desired) == Standing::Behind {
665                return Ok(None);
666            }
667            if let Ok(installed) = adapter.version() {
668                if crate::release_update::behind_reviewed(&stable, &installed) == Some(true) {
669                    println!(
670                        "{}: installed {installed} is newer than {} {}; not installed by brew",
671                        name.as_str(),
672                        crate::package::STACK_TAP,
673                        channel.stable
674                    );
675                    return Ok(Some(installed));
676                }
677            }
678            brew_converge(name, false)?;
679        }
680    }
681    let receipt = brew_channel(name)?
682        .context("brew channel after delivery")?
683        .installed
684        .context("brew receipt after delivery")?;
685    if !matches!(
686        standing(&receipt, &stable),
687        Standing::Current | Standing::Ahead
688    ) {
689        anyhow::bail!(
690            "{} brew update did not reach stable {} (receipt: {receipt})",
691            name.as_str(),
692            channel.stable
693        );
694    }
695    // Resolve after delivery: a fresh package install creates the PATH pair.
696    let package_adapter = adapter.package_adapter()?;
697    let adapter = package_adapter.as_deref().unwrap_or(adapter);
698    adapter
699        .verify()
700        .with_context(|| format!("{} verification after brew delivery", name.as_str()))?;
701    let version = brew_version(adapter, &receipt)?;
702    println!(
703        "{}: delivered by brew ({} {}) and verified",
704        name.as_str(),
705        crate::package::STACK_TAP,
706        receipt
707    );
708    Ok(Some(version))
709}
710
711fn brew_version(adapter: &dyn crate::adapter::Adapter, receipt: &str) -> Result<String> {
712    // Current/pinned reads also use the package pair, never an unrelated
713    // Cargo copy. This is deliberately lazy until a read-back is needed.
714    let package_adapter = adapter.package_adapter()?;
715    let adapter = package_adapter.as_deref().unwrap_or(adapter);
716    let version = adapter
717        .version()
718        .context("version read-back after brew delivery")?;
719    // Quipu reports its client and server as two semicolon-separated
720    // identities. Check both; a plain version line must not absorb the
721    // separator, and a current client must not hide a stale server.
722    let identities: Vec<_> = if adapter.name() == crate::model::ToolName::Quipu {
723        version.split(';').take(2).collect()
724    } else {
725        vec![version.as_str()]
726    };
727    if identities.iter().any(|identity| {
728        crate::package::standing(receipt, identity.trim()) != crate::package::Standing::Current
729    }) {
730        anyhow::bail!("brew receipt and executable version disagree");
731    }
732    Ok(version)
733}
734
735pub fn check_updates(plan: &Plan) -> Result<bool> {
736    plan.validate()?;
737    let mut current = true;
738    for &name in &plan.tools {
739        let adapter = adapter_for_plan(name, plan)?;
740        let desired = adapter.desired_version();
741        if name == crate::model::ToolName::Quipu && plan.external_quipu {
742            println!(
743                "quipu: externally owned; release convergence not assessed ({})",
744                adapter.version()?
745            );
746            continue;
747        }
748        if let Some((line, is_current)) = package_owned_status(name, &desired) {
749            current &= is_current;
750            println!("{line}");
751            continue;
752        }
753        match adapter.version() {
754            Ok(installed) if adapter.is_current(&installed) => {
755                println!("{}: current ({installed})", name.as_str());
756            }
757            Ok(installed) => {
758                current = false;
759                println!(
760                    "{}: {} (installed: {installed}; reviewed: {desired})",
761                    name.as_str(),
762                    reviewed_drift_status(&installed, &desired)
763                );
764            }
765            Err(error) => {
766                current = false;
767                println!(
768                    "{}: missing or unreadable ({error:#}); reviewed: {desired}",
769                    name.as_str()
770                );
771            }
772        }
773    }
774    if let Some(selection) = &plan.crew {
775        current &= crew::check_updates(selection);
776    }
777    Ok(current)
778}
779
780/// Converge drifted tools to the reviewed releases and functionally verify
781/// each changed tool before recording it as current.
782pub fn update(plan: &Plan, state_path: &Path, evidence: &CrewEvidence) -> Result<State> {
783    plan.validate()?;
784    let mut state = State::read(state_path)?;
785    if crate::package::brew_program().is_some()
786        && plan.tools.iter().any(|name| {
787            crate::package::brew_formula(*name).is_some()
788                && !(*name == crate::model::ToolName::Quipu && plan.external_quipu)
789        })
790    {
791        crate::package::brew_refresh()?;
792    }
793    for &name in &plan.tools {
794        let adapter = adapter_for_plan(name, plan)?;
795        if name == crate::model::ToolName::Quipu && plan.external_quipu {
796            println!(
797                "quipu: externally owned; update skipped ({})",
798                adapter.version()?
799            );
800            continue;
801        }
802        if let Some(version) = brew_update(name, adapter.as_ref())? {
803            state.tools.insert(
804                name.as_str().to_owned(),
805                ToolState {
806                    version: version.clone(),
807                    applied: true,
808                    verified: true,
809                },
810            );
811            state.write(state_path)?;
812            emission::queue_transition(state_path, name.as_str(), "updated", &version)?;
813            continue;
814        }
815        if let Some((line, _)) = package_owned_status(name, &adapter.desired_version()) {
816            println!("{line}");
817            continue;
818        }
819        let before = adapter.version().ok();
820        if before
821            .as_deref()
822            .is_some_and(|installed| adapter.is_current(installed))
823        {
824            if name == crate::model::ToolName::Quipu {
825                crate::adapter::ensure_existing_quipu_alias()?;
826            }
827            println!("{}: current", name.as_str());
828            continue;
829        }
830        #[cfg(unix)]
831        crate::release_update::guard_reviewed_update(name, &adapter.desired_version())?;
832        eprintln!(
833            "{}: converging {:?} -> {}",
834            name.as_str(),
835            before,
836            adapter.desired_version()
837        );
838        adapter
839            .install()
840            .with_context(|| format!("{} reviewed update install", name.as_str()))?;
841        let version = adapter
842            .version()
843            .with_context(|| format!("{} version read-back after update", name.as_str()))?;
844        if !adapter.is_current(&version) {
845            bail!(
846                "{} update did not reach reviewed release {} (got {})",
847                name.as_str(),
848                adapter.desired_version(),
849                version
850            );
851        }
852        adapter
853            .verify()
854            .with_context(|| format!("{} verification after update", name.as_str()))?;
855        state.tools.insert(
856            name.as_str().to_owned(),
857            ToolState {
858                version: version.clone(),
859                applied: true,
860                verified: true,
861            },
862        );
863        state.write(state_path)?;
864        emission::queue_transition(state_path, name.as_str(), "updated", &version)?;
865        println!("{}: updated and verified", name.as_str());
866    }
867    if let Some(selection) = &plan.crew {
868        if !crew::check_updates(selection) {
869            crew::apply(selection, &mut state, false)?;
870            crew::verify(selection, evidence, &mut state)?;
871            for line in hook_bundles_verify(plan)? {
872                println!("hook bundle {line}");
873            }
874            state.write(state_path)?;
875            for (name, runtime) in &state.crew {
876                emission::queue_transition(state_path, name, "updated", &runtime.version)?;
877            }
878        }
879    }
880    Ok(state)
881}
882
883fn hook_bundles_verify(plan: &Plan) -> Result<Vec<String>> {
884    let resolved = crate::tool_bundles::resolve(&crate::hook_bundles::selected(
885        plan.tools.iter().map(|t| t.as_str()),
886    ));
887    crate::hook_bundles::verify(&crate::tool_bundles::as_pairs(&resolved))
888}
889
890pub fn verify_questions(plan: &Plan, db: Option<&Path>) -> Result<()> {
891    plan.validate()?;
892    let intent = plan.intent.as_ref().context(
893        "plan has no intended-use/question contract; regenerate it through the Phase 2 interview",
894    )?;
895    for (index, contract) in intent.anticipated_questions.iter().enumerate() {
896        // The self-test question is about the stack, not the user's graph, so it
897        // always runs against a scratch store seeded with its own fact.
898        let scratch = if contract.is_self_test() {
899            Some(seed_self_test_store()?)
900        } else {
901            None
902        };
903        let db = scratch.as_ref().map(|(_, db)| db.as_path()).or(db);
904        let mut args = vec![OsString::from("read"), OsString::from(&contract.sparql)];
905        if let Some(db) = db {
906            args.push(OsString::from("--db"));
907            args.push(db.as_os_str().to_owned());
908        }
909        let result = crate::adapter::checked("quipu", args, None)
910            .with_context(|| format!("anticipated question {} query", index + 1))?;
911        let answer = String::from_utf8_lossy(&result.stdout);
912        if !answer.contains(&contract.expected) {
913            anyhow::bail!(
914                "anticipated question {} was executable but its answer did not contain {:?}: {}",
915                index + 1,
916                contract.expected,
917                contract.question
918            );
919        }
920        println!("question {}: verified — {}", index + 1, contract.question);
921    }
922    Ok(())
923}
924
925/// A throwaway Quipu store holding exactly the self-test fact. The tempdir is
926/// returned so it lives as long as the query that reads it.
927fn seed_self_test_store() -> Result<(tempfile::TempDir, std::path::PathBuf)> {
928    use crate::model::SELF_TEST_MARKER;
929    let dir = tempfile::tempdir().context("create self-test question store")?;
930    let db = dir.path().join("self-test.db");
931    let episode = dir.path().join("episode.json");
932    std::fs::write(
933        &episode,
934        format!(
935            r#"{{"name":"Tambo self-test question","episode_body":"self-test seed","source":"caboodle","group_id":"caboodle-verification","nodes":[{{"name":"{SELF_TEST_MARKER}","type":"Verification","description":"Tambo self-test question seed"}}],"edges":[]}}"#
936        ),
937    )
938    .context("write self-test episode")?;
939    crate::adapter::checked(
940        "quipu",
941        [
942            OsString::from("episode"),
943            episode.into_os_string(),
944            OsString::from("--db"),
945            db.clone().into_os_string(),
946        ],
947        None,
948    )
949    .context("seed the self-test question store")?;
950    Ok((dir, db))
951}
952
953/// Provision a pre-issued client credential; never mint or rotate an identity.
954pub fn provision_quipu_credential(source: &Path, server: &str) -> Result<()> {
955    let home = std::env::var_os("HOME").context("HOME required for canonical Quipu credential")?;
956    crate::quipu_credential::provision(source, server, Path::new(&home))?;
957    println!(
958        "quipu credential: issued file accepted and canonical installation verified (0400/0700)"
959    );
960    Ok(())
961}
962
963#[cfg(test)]
964mod convergence_tests {
965    use super::{decide_convergence, reviewed_drift_status, Convergence};
966
967    const REVIEWED: &str = "bobbin 0.16.2";
968
969    #[test]
970    fn reviewed_drift_never_proposes_a_downgrade_or_unknown_replacement() {
971        assert_eq!(
972            reviewed_drift_status("sd 0.1.4 (seeds)", "seeds 0.0.4"),
973            "ahead of reviewed pin; downgrade refused"
974        );
975        assert_eq!(
976            reviewed_drift_status("sd 0.0.3", "seeds 0.0.4"),
977            "update available"
978        );
979        for installed in ["sd dev", "sd 0.0.4", "sd 0.0.4-rc1"] {
980            assert_eq!(
981                reviewed_drift_status(installed, "seeds 0.0.4"),
982                "version drift; review required"
983            );
984        }
985    }
986
987    #[test]
988    fn current_is_left_alone_even_under_skip_install() {
989        // Unreachable from apply today (is_current is matched first), which is why
990        // this branch is covered here rather than deleted as apparently dead.
991        assert_eq!(
992            decide_convergence(REVIEWED, REVIEWED, true, true),
993            Convergence::Current
994        );
995        assert_eq!(
996            decide_convergence(REVIEWED, REVIEWED, true, false),
997            Convergence::Current
998        );
999    }
1000
1001    #[test]
1002    fn stale_converges_and_skip_install_refuses_instead_of_claiming_applied() {
1003        assert_eq!(
1004            decide_convergence("bobbin 0.1.0", REVIEWED, false, false),
1005            Convergence::Converge
1006        );
1007        assert_eq!(
1008            decide_convergence("bobbin 0.1.0", REVIEWED, false, true),
1009            Convergence::RefuseSkipInstall
1010        );
1011    }
1012
1013    #[test]
1014    fn ahead_or_incomparable_is_declined_not_replaced() {
1015        assert_eq!(
1016            decide_convergence("bobbin 0.99.0", REVIEWED, false, false),
1017            Convergence::Decline
1018        );
1019        assert_eq!(
1020            decide_convergence("bobbin dev", REVIEWED, false, false),
1021            Convergence::Decline
1022        );
1023    }
1024}