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 Caboodle 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 `caboodle 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 Caboodle 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 `caboodle 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 `caboodle 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 `caboodle 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) {
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").unwrap_or_else(|_| {
431            plan.quipu_mcp
432                .as_ref()
433                .map(|mcp| mcp.url.clone())
434                .unwrap_or_else(|| "http://localhost:3030".to_owned())
435        });
436        let source = crate::quipu_credential::verify(&server)
437            .context("live Quipu client credential verification")?;
438        println!("quipu credential: canonical file 0400/0700 and authenticated read verified; source: {source}");
439    }
440    for &name in &plan.tools {
441        if name == crate::model::ToolName::Quipu && plan.external_quipu {
442            // A failed external path/version probe cannot retain an earlier proof.
443            state.tools.remove(name.as_str());
444            state.write(state_path)?;
445        }
446        let adapter = adapter_for_plan(name, plan)?;
447        let version = adapter
448            .version()
449            .with_context(|| format!("{} version read-back", name.as_str()))?;
450        // aegis-5ctwu3 item 3. A verification failure on a SKEWED tool used to
451        // surface the tool's own complaint — `error: unexpected argument
452        // '--source' found` — which points at an argument, not at the cause. We
453        // verify against the contract of the release we reviewed, so when the
454        // installed version is not that release, the skew IS the finding and
455        // must be said first.
456        adapter.verify().with_context(|| {
457            let desired = adapter.desired_version();
458            if adapter.is_current(&version) {
459                format!("{} functional verification", name.as_str())
460            } else {
461                format!(
462                    "{} functional verification — VERSION SKEW: {version} is installed, \
463                     this Caboodle build reviewed and verifies against {desired}. The error \
464                     below is most likely that skew, not a broken tool. Converge with \
465                     `caboodle apply` (or `caboodle update`), then re-verify.",
466                    name.as_str()
467                )
468            }
469        })?;
470        if !(name == crate::model::ToolName::Quipu && plan.external_quipu) {
471            check_path_resolution(name)?;
472        }
473        state.tools.insert(
474            name.as_str().to_owned(),
475            ToolState {
476                version,
477                applied: true,
478                verified: true,
479            },
480        );
481        state.write(state_path)?;
482        emission::queue_transition(
483            state_path,
484            name.as_str(),
485            "verified",
486            &state.tools[name.as_str()].version,
487        )?;
488        println!("{}: verified", name.as_str());
489    }
490    // After the tools: Desire Path itself was verified above (aegis-fbaso4).
491    if crate::dp_alias::wanted(plan.tools.iter().map(|t| t.as_str())) {
492        let outcome = crate::dp_alias::verify(crate::dp_alias::locate().as_deref(), None)
493            .context("Desire Path bd -> sd alias")?;
494        println!("dp alias {}", outcome.line());
495    }
496    if let Some(model) = &plan.embedding_model {
497        // This is deliberately a re-hash, not an embed round-trip; see
498        // embedding::verify for why claiming more would be a banner.
499        embedding::verify(model).context("embedding-model artifact re-hash step")?;
500        for artifact in &model.artifacts {
501            state.models.insert(
502                artifact.name.clone(),
503                ModelArtifactState {
504                    path: model.destination.join(&artifact.name),
505                    sha256: artifact.sha256.clone(),
506                    provisioned: true,
507                    verified: true,
508                },
509            );
510            state.write(state_path)?;
511            emission::queue_transition(
512                state_path,
513                &format!("embedding-model/{}", artifact.name),
514                "verified",
515                &artifact.sha256,
516            )?;
517            println!("embedding-model {}: digest re-checked", artifact.name);
518        }
519    }
520    #[cfg(unix)]
521    if let Some(mcp) = &plan.quipu_mcp {
522        let notes = crate::quipu_mcp::verify(&mcp.url)
523            .context("quipu MCP authenticated-write probe (headersHelper)")?;
524        for note in notes {
525            println!("quipu mcp: {note}");
526        }
527        println!("quipu mcp: authenticated MCP write reached Quipu's parser; unauthenticated control was refused");
528    }
529    if let Some(selection) = &plan.crew {
530        crew::verify(selection, evidence, &mut state)?;
531        state.write(state_path)?;
532        for (name, runtime) in &state.crew {
533            emission::queue_transition(state_path, name, "verified", &runtime.version)?;
534        }
535    }
536    Ok(state)
537}
538
539/// Compare the running toolchain to the releases reviewed by this Caboodle
540/// build. Returns true only when every selected tool is current.
541pub(crate) fn reviewed_drift_status(installed: &str, desired: &str) -> &'static str {
542    match crate::release_update::behind_reviewed(installed, desired) {
543        Some(true) => "update available",
544        Some(false) if crate::release_update::behind_reviewed(desired, installed) == Some(true) => {
545            "ahead of reviewed pin; downgrade refused"
546        }
547        Some(false) | None => "version drift; review required",
548    }
549}
550
551/// A tool whose binary a package manager owns is judged by its package receipt
552/// and never installed by caboodle (aegis-6f1, option A): the package lane is
553/// its only writer. Returns the line to print and whether it is current.
554fn package_owned_status(name: crate::model::ToolName, desired: &str) -> Option<(String, bool)> {
555    use crate::package::{apt_receipt, primary_program, standing, Standing};
556    let receipt = primary_program(name)
557        .and_then(|program| apt_receipt(&program))
558        .or_else(|| crate::package::brew_receipt(name))?;
559    let status = standing(&receipt.version, desired);
560    let verdict = match status {
561        Standing::Current => "current",
562        Standing::Ahead => "ahead of reviewed pin; downgrade refused",
563        Standing::Behind => {
564            "behind reviewed pin; delivered by the package lane, not installed by caboodle"
565        }
566        Standing::Unknown => "version drift; review required",
567    };
568    Some((
569        format!(
570            "{}: {verdict} (package-owned: {} {} {}; reviewed: {desired})",
571            name.as_str(),
572            receipt.manager,
573            receipt.package,
574            receipt.version
575        ),
576        status == Standing::Current,
577    ))
578}
579
580/// Member delivery through the stack's Homebrew tap (aegis-6f1). Applies only
581/// where caboodle may drive brew and the tool has a stack formula. Returns the
582/// verified version when brew delivered (or already holds) the tool, `None` to
583/// fall back to the other lanes (the release download when the tap is older
584/// than the pin). Never downgrades.
585fn brew_update(
586    name: crate::model::ToolName,
587    adapter: &dyn crate::adapter::Adapter,
588) -> Result<Option<String>> {
589    use crate::package::{
590        brew_available, brew_converge, brew_formula, brew_program, brew_receipt, standing, Standing,
591    };
592    if brew_formula(name).is_none() || brew_program().is_none() {
593        return Ok(None);
594    }
595    let desired = adapter.desired_version();
596    match brew_receipt(name) {
597        Some(receipt) => match standing(&receipt.version, &desired) {
598            Standing::Behind => brew_converge(name, true)?,
599            verdict => {
600                let word = match verdict {
601                    Standing::Current => "current",
602                    Standing::Ahead => "ahead of reviewed pin; downgrade refused",
603                    _ => "version drift; review required",
604                };
605                println!(
606                    "{}: {word} (package-owned: brew {} {}; reviewed: {desired})",
607                    name.as_str(),
608                    receipt.package,
609                    receipt.version
610                );
611                return Ok(Some(adapter.version()?));
612            }
613        },
614        None => {
615            let Some(available) = brew_available(name) else {
616                return Ok(None);
617            };
618            // The tap must carry at least the pin, or the release fallback applies.
619            if standing(&available, &desired) == Standing::Behind {
620                return Ok(None);
621            }
622            // Never replace a newer existing copy with an older tap release.
623            if let Ok(installed) = adapter.version() {
624                if crate::release_update::behind_reviewed(&format!("pkg {available}"), &installed)
625                    == Some(true)
626                {
627                    println!(
628                        "{}: installed {installed} is newer than {} {available}; not installed by brew",
629                        name.as_str(),
630                        crate::package::STACK_TAP
631                    );
632                    return Ok(Some(installed));
633                }
634            }
635            brew_converge(name, false)?;
636        }
637    }
638    let receipt = brew_receipt(name).context("brew receipt after install")?;
639    adapter
640        .verify()
641        .with_context(|| format!("{} verification after brew delivery", name.as_str()))?;
642    let version = adapter
643        .version()
644        .with_context(|| format!("{} version read-back after brew delivery", name.as_str()))?;
645    println!(
646        "{}: delivered by brew ({} {}) and verified",
647        name.as_str(),
648        receipt.package,
649        receipt.version
650    );
651    Ok(Some(version))
652}
653
654pub fn check_updates(plan: &Plan) -> Result<bool> {
655    plan.validate()?;
656    let mut current = true;
657    for &name in &plan.tools {
658        let adapter = adapter_for_plan(name, plan)?;
659        let desired = adapter.desired_version();
660        if name == crate::model::ToolName::Quipu && plan.external_quipu {
661            println!(
662                "quipu: externally owned; release convergence not assessed ({})",
663                adapter.version()?
664            );
665            continue;
666        }
667        if let Some((line, is_current)) = package_owned_status(name, &desired) {
668            current &= is_current;
669            println!("{line}");
670            continue;
671        }
672        match adapter.version() {
673            Ok(installed) if adapter.is_current(&installed) => {
674                println!("{}: current ({installed})", name.as_str());
675            }
676            Ok(installed) => {
677                current = false;
678                println!(
679                    "{}: {} (installed: {installed}; reviewed: {desired})",
680                    name.as_str(),
681                    reviewed_drift_status(&installed, &desired)
682                );
683            }
684            Err(error) => {
685                current = false;
686                println!(
687                    "{}: missing or unreadable ({error:#}); reviewed: {desired}",
688                    name.as_str()
689                );
690            }
691        }
692    }
693    if let Some(selection) = &plan.crew {
694        current &= crew::check_updates(selection);
695    }
696    Ok(current)
697}
698
699/// Converge drifted tools to the reviewed releases and functionally verify
700/// each changed tool before recording it as current.
701pub fn update(plan: &Plan, state_path: &Path, evidence: &CrewEvidence) -> Result<State> {
702    plan.validate()?;
703    let mut state = State::read(state_path)?;
704    for &name in &plan.tools {
705        let adapter = adapter_for_plan(name, plan)?;
706        if name == crate::model::ToolName::Quipu && plan.external_quipu {
707            println!(
708                "quipu: externally owned; update skipped ({})",
709                adapter.version()?
710            );
711            continue;
712        }
713        if let Some(version) = brew_update(name, adapter.as_ref())? {
714            state.tools.insert(
715                name.as_str().to_owned(),
716                ToolState {
717                    version: version.clone(),
718                    applied: true,
719                    verified: true,
720                },
721            );
722            state.write(state_path)?;
723            emission::queue_transition(state_path, name.as_str(), "updated", &version)?;
724            continue;
725        }
726        if let Some((line, _)) = package_owned_status(name, &adapter.desired_version()) {
727            println!("{line}");
728            continue;
729        }
730        let before = adapter.version().ok();
731        if before
732            .as_deref()
733            .is_some_and(|installed| adapter.is_current(installed))
734        {
735            if name == crate::model::ToolName::Quipu {
736                crate::adapter::ensure_existing_quipu_alias()?;
737            }
738            println!("{}: current", name.as_str());
739            continue;
740        }
741        #[cfg(unix)]
742        crate::release_update::guard_reviewed_update(name, &adapter.desired_version())?;
743        eprintln!(
744            "{}: converging {:?} -> {}",
745            name.as_str(),
746            before,
747            adapter.desired_version()
748        );
749        adapter
750            .install()
751            .with_context(|| format!("{} reviewed update install", name.as_str()))?;
752        let version = adapter
753            .version()
754            .with_context(|| format!("{} version read-back after update", name.as_str()))?;
755        if !adapter.is_current(&version) {
756            bail!(
757                "{} update did not reach reviewed release {} (got {})",
758                name.as_str(),
759                adapter.desired_version(),
760                version
761            );
762        }
763        adapter
764            .verify()
765            .with_context(|| format!("{} verification after update", name.as_str()))?;
766        state.tools.insert(
767            name.as_str().to_owned(),
768            ToolState {
769                version: version.clone(),
770                applied: true,
771                verified: true,
772            },
773        );
774        state.write(state_path)?;
775        emission::queue_transition(state_path, name.as_str(), "updated", &version)?;
776        println!("{}: updated and verified", name.as_str());
777    }
778    if let Some(selection) = &plan.crew {
779        if !crew::check_updates(selection) {
780            crew::apply(selection, &mut state, false)?;
781            crew::verify(selection, evidence, &mut state)?;
782            for line in hook_bundles_verify(plan)? {
783                println!("hook bundle {line}");
784            }
785            state.write(state_path)?;
786            for (name, runtime) in &state.crew {
787                emission::queue_transition(state_path, name, "updated", &runtime.version)?;
788            }
789        }
790    }
791    Ok(state)
792}
793
794fn hook_bundles_verify(plan: &Plan) -> Result<Vec<String>> {
795    let resolved = crate::tool_bundles::resolve(&crate::hook_bundles::selected(
796        plan.tools.iter().map(|t| t.as_str()),
797    ));
798    crate::hook_bundles::verify(&crate::tool_bundles::as_pairs(&resolved))
799}
800
801pub fn verify_questions(plan: &Plan, db: Option<&Path>) -> Result<()> {
802    plan.validate()?;
803    let intent = plan.intent.as_ref().context(
804        "plan has no intended-use/question contract; regenerate it through the Phase 2 interview",
805    )?;
806    for (index, contract) in intent.anticipated_questions.iter().enumerate() {
807        // The self-test question is about the stack, not the user's graph, so it
808        // always runs against a scratch store seeded with its own fact.
809        let scratch = if contract.is_self_test() {
810            Some(seed_self_test_store()?)
811        } else {
812            None
813        };
814        let db = scratch.as_ref().map(|(_, db)| db.as_path()).or(db);
815        let mut args = vec![OsString::from("read"), OsString::from(&contract.sparql)];
816        if let Some(db) = db {
817            args.push(OsString::from("--db"));
818            args.push(db.as_os_str().to_owned());
819        }
820        let result = crate::adapter::checked("quipu", args, None)
821            .with_context(|| format!("anticipated question {} query", index + 1))?;
822        let answer = String::from_utf8_lossy(&result.stdout);
823        if !answer.contains(&contract.expected) {
824            anyhow::bail!(
825                "anticipated question {} was executable but its answer did not contain {:?}: {}",
826                index + 1,
827                contract.expected,
828                contract.question
829            );
830        }
831        println!("question {}: verified — {}", index + 1, contract.question);
832    }
833    Ok(())
834}
835
836/// A throwaway Quipu store holding exactly the self-test fact. The tempdir is
837/// returned so it lives as long as the query that reads it.
838fn seed_self_test_store() -> Result<(tempfile::TempDir, std::path::PathBuf)> {
839    use crate::model::SELF_TEST_MARKER;
840    let dir = tempfile::tempdir().context("create self-test question store")?;
841    let db = dir.path().join("self-test.db");
842    let episode = dir.path().join("episode.json");
843    std::fs::write(
844        &episode,
845        format!(
846            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":[]}}"#
847        ),
848    )
849    .context("write self-test episode")?;
850    crate::adapter::checked(
851        "quipu",
852        [
853            OsString::from("episode"),
854            episode.into_os_string(),
855            OsString::from("--db"),
856            db.clone().into_os_string(),
857        ],
858        None,
859    )
860    .context("seed the self-test question store")?;
861    Ok((dir, db))
862}
863
864/// Provision a pre-issued client credential; never mint or rotate an identity.
865pub fn provision_quipu_credential(source: &Path, server: &str) -> Result<()> {
866    let home = std::env::var_os("HOME").context("HOME required for canonical Quipu credential")?;
867    crate::quipu_credential::provision(source, server, Path::new(&home))?;
868    println!(
869        "quipu credential: issued file accepted and canonical installation verified (0400/0700)"
870    );
871    Ok(())
872}
873
874#[cfg(test)]
875mod convergence_tests {
876    use super::{decide_convergence, reviewed_drift_status, Convergence};
877
878    const REVIEWED: &str = "bobbin 0.16.2";
879
880    #[test]
881    fn reviewed_drift_never_proposes_a_downgrade_or_unknown_replacement() {
882        assert_eq!(
883            reviewed_drift_status("sd 0.1.4 (seeds)", "seeds 0.0.4"),
884            "ahead of reviewed pin; downgrade refused"
885        );
886        assert_eq!(
887            reviewed_drift_status("sd 0.0.3", "seeds 0.0.4"),
888            "update available"
889        );
890        for installed in ["sd dev", "sd 0.0.4", "sd 0.0.4-rc1"] {
891            assert_eq!(
892                reviewed_drift_status(installed, "seeds 0.0.4"),
893                "version drift; review required"
894            );
895        }
896    }
897
898    #[test]
899    fn current_is_left_alone_even_under_skip_install() {
900        // Unreachable from apply today (is_current is matched first), which is why
901        // this branch is covered here rather than deleted as apparently dead.
902        assert_eq!(
903            decide_convergence(REVIEWED, REVIEWED, true, true),
904            Convergence::Current
905        );
906        assert_eq!(
907            decide_convergence(REVIEWED, REVIEWED, true, false),
908            Convergence::Current
909        );
910    }
911
912    #[test]
913    fn stale_converges_and_skip_install_refuses_instead_of_claiming_applied() {
914        assert_eq!(
915            decide_convergence("bobbin 0.1.0", REVIEWED, false, false),
916            Convergence::Converge
917        );
918        assert_eq!(
919            decide_convergence("bobbin 0.1.0", REVIEWED, false, true),
920            Convergence::RefuseSkipInstall
921        );
922    }
923
924    #[test]
925    fn ahead_or_incomparable_is_declined_not_replaced() {
926        assert_eq!(
927            decide_convergence("bobbin 0.99.0", REVIEWED, false, false),
928            Convergence::Decline
929        );
930        assert_eq!(
931            decide_convergence("bobbin dev", REVIEWED, false, false),
932            Convergence::Decline
933        );
934    }
935}