Skip to main content

caboodle/
engine.rs

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