Skip to main content

codewhale_config/
setup_state.rs

1//! Unified setup-state model for the v0.8.67 constitution-first setup lane
2//! (#3403).
3//!
4//! This is the single record every setup step (#3404–#3412) reads and writes so
5//! that "configured", "skipped", "verified", and "ready" mean the same thing
6//! everywhere. It is persisted as a JSON sidecar (`setup_state.json`) under
7//! `$CODEWHALE_HOME`, written atomically through [`crate::persistence`] so it is
8//! independent of `config.toml`'s comment-preserving writes and can never leave
9//! a half-written file.
10//!
11//! The record holds two things:
12//!
13//! 1. A per-[`SetupStep`] [`StepEntry`] (status, required, safe summary,
14//!    writing lane version).
15//! 2. The constitution-first fields the wizard, the update checkpoint, and
16//!    `/constitution` all coordinate on.
17//!
18//! Readiness is a *derived* property ([`first_run_ready`](SetupState::first_run_ready)
19//! / [`update_ready`](SetupState::update_ready)); it is never persisted, so the
20//! rules can evolve without a migration.
21//!
22//! Secrets never appear here: [`StepEntry::result`] is a short human-facing
23//! summary (provider name, model id, mode name), never a key.
24
25use std::collections::BTreeMap;
26use std::path::{Path, PathBuf};
27
28use anyhow::{Context, Result};
29use serde::{Deserialize, Serialize};
30
31use crate::persistence;
32
33/// Current schema version of the persisted setup-state record.
34pub const SETUP_STATE_SCHEMA_VERSION: u32 = 1;
35
36/// Filename of the setup-state sidecar under `$CODEWHALE_HOME`.
37pub const SETUP_STATE_FILE_NAME: &str = "setup_state.json";
38
39/// Version of the *telemetry notice content* — not the app version.
40///
41/// The notice is owed whenever
42/// [`SetupState::telemetry_notice_decided_for`] does not match this string.
43/// Bumping it re-asks everyone, so it is bumped only when what would be
44/// collected materially changes. Keying it to the app version would re-prompt
45/// every release, which is nagging with extra steps.
46pub const TELEMETRY_NOTICE_VERSION: &str = "1";
47
48/// Canonical setup step ids. The ordering matches the first-run spine so a
49/// `BTreeMap<SetupStep, _>` renders in wizard order.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
51#[serde(rename_all = "snake_case")]
52pub enum SetupStep {
53    /// Language first, so later screens and constitution prose are localized.
54    Language,
55    /// Provider + key (or local runtime) and a default model.
56    ProviderModel,
57    /// Trust, approvals, sandbox, network — runtime posture (#3406).
58    TrustSandbox,
59    /// User-global constitution choice / checkpoint.
60    Constitution,
61    /// Operate/Fleet readiness: provider auth, worker runtime, roster, and
62    /// concurrency review. Plan-limit detection remains a separate product
63    /// decision; this step only records reviewed current facts.
64    OperateFleet,
65    /// Hotbar shortcuts are optional, but now have a first-class setup card.
66    Hotbar,
67    /// Tools / MCP / skills / plugins (later lanes; tracked for completeness).
68    ToolsMcp,
69    /// Remote / mobile runtime (later lane; tracked for completeness).
70    RemoteRuntime,
71    /// Persistence paths for setup state, config, constitution, memory, and notes.
72    Persistence,
73    /// Final verification / doctor / ready summary.
74    Verification,
75}
76
77impl SetupStep {
78    /// All steps in canonical first-run order.
79    pub const ALL: [SetupStep; 10] = [
80        SetupStep::Language,
81        SetupStep::ProviderModel,
82        SetupStep::TrustSandbox,
83        SetupStep::Constitution,
84        SetupStep::OperateFleet,
85        SetupStep::Hotbar,
86        SetupStep::ToolsMcp,
87        SetupStep::RemoteRuntime,
88        SetupStep::Persistence,
89        SetupStep::Verification,
90    ];
91}
92
93/// Status of a single setup step. Shared vocabulary so `/setup`, `doctor`, and
94/// the context report never invent their own meanings.
95#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
96#[serde(rename_all = "snake_case")]
97pub enum StepStatus {
98    /// Never visited.
99    NotStarted,
100    /// Suggested for a good first-run experience but not required.
101    Recommended,
102    /// Available but entirely optional.
103    Optional,
104    /// Intentionally postponed; surfaces in the report, does not block.
105    Deferred,
106    /// Currently being worked on.
107    InProgress,
108    /// Completed and checked (e.g. key validated, mode confirmed).
109    Verified,
110    /// Reached a usable-but-incomplete state needing user action
111    /// (e.g. a key that failed validation). Does not block the ready screen.
112    NeedsAction,
113    /// Attempted and errored.
114    Failed,
115    /// Explicitly skipped by the user.
116    Skipped,
117}
118
119impl StepStatus {
120    /// True for statuses that count as "the user dealt with this step" for the
121    /// purpose of reaching the ready screen.
122    #[must_use]
123    pub fn is_settled(self) -> bool {
124        matches!(
125            self,
126            StepStatus::Verified
127                | StepStatus::NeedsAction
128                | StepStatus::Deferred
129                | StepStatus::Optional
130                | StepStatus::Skipped
131        )
132    }
133}
134
135/// One persisted entry per setup step.
136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
137pub struct StepEntry {
138    pub status: StepStatus,
139    /// Whether this step blocks "ready" for the lane that owns it. First-run and
140    /// update lanes differ; see the readiness helpers on [`SetupState`].
141    #[serde(default)]
142    pub required: bool,
143    /// Short, safe human-facing summary — provider name, model id, mode name,
144    /// health. **Never a secret.**
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub result: Option<String>,
147    /// Lane (e.g. `"0.8.67"`) that last wrote this entry, so staleness is
148    /// visible to `/setup`, `doctor`, and the context report.
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub version: Option<String>,
151}
152
153impl StepEntry {
154    /// A freshly-visited entry written by `version`.
155    #[must_use]
156    pub fn new(status: StepStatus, required: bool, version: impl Into<String>) -> Self {
157        Self {
158            status,
159            required,
160            result: None,
161            version: Some(version.into()),
162        }
163    }
164
165    #[must_use]
166    pub fn with_result(mut self, result: impl Into<String>) -> Self {
167        self.result = Some(result.into());
168        self
169    }
170}
171
172/// The user's constitution decision. Every value except [`Unset`] counts as an
173/// explicit choice for readiness.
174///
175/// [`Unset`]: ConstitutionChoice::Unset
176#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
177#[serde(rename_all = "snake_case")]
178pub enum ConstitutionChoice {
179    /// No decision recorded yet.
180    #[default]
181    Unset,
182    /// Accepted the bundled/default constitution floor. Creates no custom file.
183    Bundled,
184    /// Created a guided structured user-global constitution.
185    GuidedCustom,
186    /// Expert full-Markdown override
187    /// (`$CODEWHALE_HOME/prompts/constitution.md` + opt-in env).
188    ExpertOverride,
189    /// Explicitly postponed; bundled law applies until the user returns.
190    Deferred,
191}
192
193impl ConstitutionChoice {
194    /// True for any value other than [`Unset`](ConstitutionChoice::Unset).
195    #[must_use]
196    pub fn is_explicit(self) -> bool {
197        !matches!(self, ConstitutionChoice::Unset)
198    }
199}
200
201/// How the active custom constitution was authored. Recorded alongside
202/// [`ConstitutionChoice::GuidedCustom`] so `/setup`, `doctor`, and the report
203/// can show provenance without parsing free-text step results.
204///
205/// This is a *new optional field* rather than a new [`ConstitutionChoice`]
206/// variant so records written by this lane still load in older binaries
207/// (unknown fields are ignored on read; an unknown enum variant would fail the
208/// whole parse and force the inherited-state fallback).
209#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
210#[serde(rename_all = "snake_case")]
211pub enum ConstitutionAuthoring {
212    /// Deterministically rendered from the guided answers.
213    Guided,
214    /// Drafted by the user's configured model from the guided answers, then
215    /// schema-validated, bounded, previewed, and ratified. Advisory authorship
216    /// only — the drafting model gains no authority from having written it.
217    ModelDrafted,
218}
219
220/// Which constitution surface is currently the active user-global law.
221#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
222#[serde(rename_all = "snake_case")]
223pub enum ConstitutionSource {
224    /// Only the bundled floor is active.
225    #[default]
226    Bundled,
227    /// A structured `constitution.json` under `$CODEWHALE_HOME`.
228    UserGlobal,
229    /// An expert full-Markdown override file.
230    ExpertOverride,
231}
232
233/// Validity of the active user-global constitution file, if any.
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
235#[serde(rename_all = "snake_case")]
236pub enum ConstitutionValidity {
237    /// No custom file, or validity not yet evaluated.
238    #[default]
239    Unknown,
240    /// Parsed and usable.
241    Valid,
242    /// Present but failed to parse / structurally invalid.
243    Invalid,
244    /// Present but carried no usable policy.
245    Empty,
246    /// Present but could not be read.
247    Unreadable,
248}
249
250/// Where the current runtime posture came from. Mirrors the rule that a
251/// constitution may *recommend* posture but only an explicit config action
252/// (#3406) applies it.
253#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
254#[serde(rename_all = "snake_case")]
255pub enum RuntimePostureSource {
256    /// Not yet reviewed.
257    #[default]
258    Unset,
259    /// Carried over from existing config without an explicit confirmation.
260    Inherited,
261    /// The user explicitly reviewed and confirmed the posture in setup.
262    Confirmed,
263}
264
265impl RuntimePostureSource {
266    /// True when posture has been inherited or confirmed (either satisfies
267    /// first-run readiness).
268    #[must_use]
269    pub fn is_reviewed(self) -> bool {
270        matches!(
271            self,
272            RuntimePostureSource::Inherited | RuntimePostureSource::Confirmed
273        )
274    }
275}
276
277/// The persisted, per-version setup-state record.
278#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
279pub struct SetupState {
280    pub schema_version: u32,
281
282    /// Per-step status entries.
283    #[serde(default)]
284    pub steps: BTreeMap<SetupStep, StepEntry>,
285
286    // ── Constitution-first fields ───────────────────────────────────────
287    /// The user's constitution decision.
288    #[serde(default)]
289    pub constitution_choice: ConstitutionChoice,
290    /// Lane version (e.g. `"0.8.67"`) whose constitution checkpoint the user has
291    /// completed. Drives the once-per-version update checkpoint (#3794).
292    #[serde(default, skip_serializing_if = "Option::is_none")]
293    pub constitution_checkpoint_completed_for: Option<String>,
294    /// Language the constitution prose was authored/reviewed in.
295    #[serde(default, skip_serializing_if = "Option::is_none")]
296    pub constitution_language: Option<String>,
297    /// Which surface is the active user-global law.
298    #[serde(default)]
299    pub constitution_source: ConstitutionSource,
300    /// Validity of the active user-global constitution file.
301    #[serde(default)]
302    pub constitution_validity: ConstitutionValidity,
303    /// How the active custom constitution was authored (guided deterministic
304    /// vs model-drafted-then-ratified). `None` for bundled/deferred/inherited.
305    #[serde(default, skip_serializing_if = "Option::is_none")]
306    pub constitution_authoring: Option<ConstitutionAuthoring>,
307    /// Stable content hash of the most recently previewed/accepted rendered
308    /// constitution (see [`crate::user_constitution::UserConstitution::preview_hash`]).
309    #[serde(default, skip_serializing_if = "Option::is_none")]
310    pub constitution_preview_hash: Option<String>,
311    /// Monotonic counter bumped each time a custom constitution is saved, so the
312    /// report and `/constitution` can show which revision is live.
313    #[serde(default)]
314    pub constitution_preview_version: u32,
315    /// Where the current runtime posture came from.
316    #[serde(default)]
317    pub runtime_posture_source: RuntimePostureSource,
318
319    /// Host-enforced Workflow dispatch and terminal receipts have been proven
320    /// for this installation. Older records did not carry this proof and must
321    /// deserialize false even if their Operate/Fleet card was marked Verified.
322    #[serde(default, skip_serializing_if = "is_false")]
323    pub operate_receipts_verified: bool,
324
325    /// True when this record was *derived* from existing config rather than
326    /// persisted by an explicit setup run. Lets `/setup` and `doctor` explain
327    /// why an updating user is not treated as a broken fresh install.
328    #[serde(default, skip_serializing_if = "is_false")]
329    pub inherited: bool,
330
331    // ── Telemetry notice ────────────────────────────────────────────────
332    /// [`TELEMETRY_NOTICE_VERSION`] whose telemetry notice the user has
333    /// answered. `None` means the notice is still owed.
334    ///
335    /// Never auto-completed and never deferred-completed: unlike the
336    /// constitution checkpoint, which records a `Deferred` completion on the
337    /// skip-onboarding path, a telemetry notice that was not rendered and
338    /// answered leaves this `None`. Silence is not consent, so the failure
339    /// mode of a missing notice is that nothing is ever collected.
340    ///
341    /// These are *fields* rather than a new [`SetupStep`] variant on purpose:
342    /// an unknown enum variant fails the whole record parse and silently drops
343    /// the user back to derived-inherited state — including their constitution
344    /// checkpoint — while unknown fields are ignored.
345    #[serde(default, skip_serializing_if = "Option::is_none")]
346    pub telemetry_notice_decided_for: Option<String>,
347    /// The user's answer to the notice. Only meaningful when
348    /// [`Self::telemetry_notice_decided_for`] matches the current
349    /// [`TELEMETRY_NOTICE_VERSION`].
350    #[serde(default, skip_serializing_if = "is_false")]
351    pub telemetry_opt_in: bool,
352}
353
354#[allow(clippy::trivially_copy_pass_by_ref)]
355fn is_false(b: &bool) -> bool {
356    !*b
357}
358
359impl Default for SetupState {
360    fn default() -> Self {
361        Self {
362            schema_version: SETUP_STATE_SCHEMA_VERSION,
363            steps: BTreeMap::new(),
364            constitution_choice: ConstitutionChoice::default(),
365            constitution_checkpoint_completed_for: None,
366            constitution_language: None,
367            constitution_source: ConstitutionSource::default(),
368            constitution_validity: ConstitutionValidity::default(),
369            constitution_authoring: None,
370            constitution_preview_hash: None,
371            constitution_preview_version: 0,
372            runtime_posture_source: RuntimePostureSource::default(),
373            operate_receipts_verified: false,
374            inherited: false,
375            telemetry_notice_decided_for: None,
376            telemetry_opt_in: false,
377        }
378    }
379}
380
381/// Observable, secret-free facts about existing config used to derive a safe
382/// inherited setup-state for users who upgrade without a `setup_state.json`.
383///
384/// The caller (TUI/CLI) gathers these from `ConfigToml`, the trust marker, and
385/// the constitution files; keeping them as plain data keeps this module pure and
386/// unit-testable.
387#[derive(Debug, Clone, Default)]
388pub struct InheritedConfigFacts {
389    /// A provider/model route is configured.
390    pub has_provider_route: bool,
391    /// A key or local runtime is available (presence only — never the value).
392    pub has_credentials_or_local_runtime: bool,
393    /// The user has previously made a trust/approval decision.
394    pub trust_chosen: bool,
395    /// Onboarding language, if known.
396    pub language: Option<String>,
397    /// A structured user-global `constitution.json` exists.
398    pub has_user_constitution: bool,
399    /// An expert full-Markdown override is active.
400    pub has_expert_override: bool,
401    /// Validity of the user-global constitution, if present.
402    pub user_constitution_validity: ConstitutionValidity,
403}
404
405impl SetupState {
406    /// Status for a step, defaulting to [`StepStatus::NotStarted`].
407    #[must_use]
408    pub fn status(&self, step: SetupStep) -> StepStatus {
409        self.steps
410            .get(&step)
411            .map_or(StepStatus::NotStarted, |e| e.status)
412    }
413
414    /// Record (insert or replace) an entry for `step`.
415    pub fn set_step(&mut self, step: SetupStep, entry: StepEntry) -> &mut Self {
416        self.steps.insert(step, entry);
417        self
418    }
419
420    #[must_use]
421    fn step_verified(&self, step: SetupStep) -> bool {
422        self.status(step) == StepStatus::Verified
423    }
424
425    /// Provider/model is acceptable for first-run readiness when it is either
426    /// verified or in an actionable needs-action state (the EPIC keeps a
427    /// failed-key path reaching the ready screen).
428    #[must_use]
429    fn provider_model_ready_or_needs_action(&self) -> bool {
430        matches!(
431            self.status(SetupStep::ProviderModel),
432            StepStatus::Verified | StepStatus::NeedsAction
433        )
434    }
435
436    /// First-run "ready": language verified, provider/model ready-or-needs-action,
437    /// runtime posture inherited/confirmed, and an explicit constitution choice.
438    #[must_use]
439    pub fn first_run_ready(&self) -> bool {
440        self.step_verified(SetupStep::Language)
441            && self.provider_model_ready_or_needs_action()
442            && self.runtime_posture_source.is_reviewed()
443            && self.constitution_choice.is_explicit()
444    }
445
446    /// Operate/Fleet "ready": provider credentials are verified, runtime
447    /// posture has been reviewed, and the user has explicitly reviewed the
448    /// Fleet/Operate on-ramp. This is intentionally separate from
449    /// [`first_run_ready`](Self::first_run_ready): a local-first user can be
450    /// ready for ordinary first use before enabling durable multi-worker work.
451    #[must_use]
452    pub fn operate_ready(&self) -> bool {
453        self.first_run_ready()
454            && self.step_verified(SetupStep::ProviderModel)
455            && self.step_verified(SetupStep::OperateFleet)
456            && self.operate_receipts_verified
457    }
458
459    /// Update "ready" for `version`: the constitution checkpoint for that lane is
460    /// complete. Everything else is inherited from existing config.
461    #[must_use]
462    pub fn update_ready(&self, version: &str) -> bool {
463        self.constitution_checkpoint_completed_for.as_deref() == Some(version)
464    }
465
466    /// Whether the once-per-version update checkpoint should still be shown.
467    #[must_use]
468    pub fn needs_constitution_checkpoint(&self, version: &str) -> bool {
469        !self.update_ready(version)
470    }
471
472    /// Mark the constitution checkpoint complete for `version` (the bundled /
473    /// default path is a valid completion).
474    pub fn complete_constitution_checkpoint(
475        &mut self,
476        version: impl Into<String>,
477        choice: ConstitutionChoice,
478    ) -> &mut Self {
479        self.constitution_checkpoint_completed_for = Some(version.into());
480        self.constitution_choice = choice;
481        self
482    }
483
484    /// True when the telemetry notice for `version` has not been answered.
485    ///
486    /// A decision recorded against a *different* notice version does not
487    /// count: the content changed, so the answer is stale and is owed again.
488    #[must_use]
489    pub fn needs_telemetry_notice(&self, version: &str) -> bool {
490        self.telemetry_notice_decided_for.as_deref() != Some(version)
491    }
492
493    /// Record the user's answer to the telemetry notice for `version`.
494    ///
495    /// Call this only from a path where the notice was actually rendered and
496    /// the user actually answered. Deferral, skip-onboarding, and any
497    /// non-interactive surface must leave the record untouched.
498    pub fn record_telemetry_notice(
499        &mut self,
500        version: impl Into<String>,
501        opt_in: bool,
502    ) -> &mut Self {
503        self.telemetry_notice_decided_for = Some(version.into());
504        self.telemetry_opt_in = opt_in;
505        self
506    }
507
508    /// True when the user was asked the current notice and said yes.
509    ///
510    /// This is one half of the emit condition; the other is `telemetry = true`
511    /// in config. Neither alone suffices, which is what makes a stale
512    /// pre-existing `telemetry = true` — settable and inert for a long time —
513    /// not consent.
514    #[must_use]
515    pub fn telemetry_accepted(&self, version: &str) -> bool {
516        !self.needs_telemetry_notice(version) && self.telemetry_opt_in
517    }
518
519    /// True when the user was asked the current notice and said no.
520    ///
521    /// Distinct from "never asked": only a recorded decline is an answer, and
522    /// only an answer may be acted on destructively.
523    #[must_use]
524    pub fn telemetry_declined(&self, version: &str) -> bool {
525        !self.needs_telemetry_notice(version) && !self.telemetry_opt_in
526    }
527
528    /// Derive a safe inherited state for an existing user with no persisted
529    /// `setup_state.json`. Surfaces they already configured become
530    /// [`StepStatus::Verified`]; an update never looks like a fresh, broken
531    /// setup. The constitution checkpoint is intentionally left incomplete so
532    /// updating users still see it once.
533    #[must_use]
534    pub fn derive_inherited(facts: &InheritedConfigFacts) -> Self {
535        let mut state = SetupState {
536            inherited: true,
537            ..SetupState::default()
538        };
539        let inherited = "inherited";
540
541        if facts.language.is_some() {
542            state.set_step(
543                SetupStep::Language,
544                StepEntry::new(StepStatus::Verified, true, inherited),
545            );
546            state.constitution_language = facts.language.clone();
547        }
548
549        if facts.has_provider_route && facts.has_credentials_or_local_runtime {
550            state.set_step(
551                SetupStep::ProviderModel,
552                StepEntry::new(StepStatus::Verified, true, inherited),
553            );
554        } else if facts.has_provider_route {
555            state.set_step(
556                SetupStep::ProviderModel,
557                StepEntry::new(StepStatus::NeedsAction, true, inherited),
558            );
559        }
560
561        if facts.trust_chosen {
562            state.set_step(
563                SetupStep::TrustSandbox,
564                StepEntry::new(StepStatus::Verified, true, inherited),
565            );
566            state.runtime_posture_source = RuntimePostureSource::Inherited;
567        }
568
569        // Constitution: classify the active surface, but never auto-complete the
570        // checkpoint — the update lane requires the user to acknowledge it once.
571        if facts.has_expert_override {
572            state.constitution_source = ConstitutionSource::ExpertOverride;
573            state.constitution_choice = ConstitutionChoice::ExpertOverride;
574        } else if facts.has_user_constitution {
575            state.constitution_source = ConstitutionSource::UserGlobal;
576            state.constitution_validity = facts.user_constitution_validity;
577            if facts.user_constitution_validity == ConstitutionValidity::Valid {
578                state.constitution_choice = ConstitutionChoice::GuidedCustom;
579            }
580        } else {
581            state.constitution_source = ConstitutionSource::Bundled;
582        }
583
584        state
585    }
586
587    /// Path to the setup-state sidecar under `$CODEWHALE_HOME`.
588    pub fn path() -> Result<PathBuf> {
589        Ok(crate::codewhale_home()?.join(SETUP_STATE_FILE_NAME))
590    }
591
592    /// Load the persisted setup-state from the home sidecar.
593    ///
594    /// Returns `Ok(None)` when the file is missing **or** unreadable/corrupt, so
595    /// callers fall back to [`derive_inherited`](Self::derive_inherited) rather
596    /// than forcing a fresh wizard. A corrupt record is logged, never fatal.
597    pub fn load() -> Result<Option<Self>> {
598        Ok(Self::load_from(&Self::path()?))
599    }
600
601    /// Load from an explicit path (testable). See [`load`](Self::load) for the
602    /// missing/corrupt fallback contract.
603    #[must_use]
604    pub fn load_from(path: &Path) -> Option<Self> {
605        let raw = match std::fs::read_to_string(path) {
606            Ok(raw) => raw,
607            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
608            Err(e) => {
609                tracing::warn!(
610                    target: "config::setup_state",
611                    "could not read {} ({e}); deriving status from existing config",
612                    path.display()
613                );
614                return None;
615            }
616        };
617        match serde_json::from_str::<SetupState>(&raw) {
618            Ok(state) => Some(state),
619            Err(e) => {
620                tracing::warn!(
621                    target: "config::setup_state",
622                    "{} is not a valid setup-state record ({e}); deriving status from existing config",
623                    path.display()
624                );
625                None
626            }
627        }
628    }
629
630    /// Atomically persist this record to the home sidecar.
631    pub fn save(&self) -> Result<()> {
632        let path = Self::path()?;
633        self.save_to(&path)
634    }
635
636    /// Atomically persist to an explicit path (testable).
637    pub fn save_to(&self, path: &Path) -> Result<()> {
638        persistence::atomic_write_json(path, self)
639            .with_context(|| format!("failed to persist setup state to {}", path.display()))
640    }
641}
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646
647    fn verified(version: &str) -> StepEntry {
648        StepEntry::new(StepStatus::Verified, true, version)
649    }
650
651    #[test]
652    fn default_is_not_first_run_ready() {
653        let state = SetupState::default();
654        assert!(!state.first_run_ready());
655        assert_eq!(state.constitution_choice, ConstitutionChoice::Unset);
656    }
657
658    #[test]
659    fn persistence_is_optional_before_verification() {
660        let persistence_index = SetupStep::ALL
661            .iter()
662            .position(|step| *step == SetupStep::Persistence)
663            .expect("persistence step");
664        let verification_index = SetupStep::ALL
665            .iter()
666            .position(|step| *step == SetupStep::Verification)
667            .expect("verification step");
668
669        assert!(persistence_index < verification_index);
670
671        let mut state = SetupState::default();
672        state.set_step(SetupStep::Language, verified("0.8.67"));
673        state.set_step(SetupStep::ProviderModel, verified("0.8.67"));
674        state.runtime_posture_source = RuntimePostureSource::Confirmed;
675        state.constitution_choice = ConstitutionChoice::Bundled;
676        assert!(state.first_run_ready());
677
678        state.set_step(
679            SetupStep::Persistence,
680            StepEntry::new(StepStatus::NeedsAction, false, "0.8.67"),
681        );
682        assert!(state.first_run_ready());
683        assert!(!state.operate_ready());
684    }
685
686    #[test]
687    fn first_run_ready_requires_all_pillars() {
688        let mut state = SetupState::default();
689        state.set_step(SetupStep::Language, verified("0.8.67"));
690        state.set_step(SetupStep::ProviderModel, verified("0.8.67"));
691        state.runtime_posture_source = RuntimePostureSource::Confirmed;
692        // Still missing an explicit constitution choice.
693        assert!(!state.first_run_ready());
694        state.constitution_choice = ConstitutionChoice::Bundled;
695        assert!(state.first_run_ready());
696    }
697
698    #[test]
699    fn operate_ready_is_separate_from_first_run_ready() {
700        let mut state = SetupState::default();
701        state.set_step(SetupStep::Language, verified("0.8.67"));
702        state.set_step(SetupStep::ProviderModel, verified("0.8.67"));
703        state.runtime_posture_source = RuntimePostureSource::Confirmed;
704        state.constitution_choice = ConstitutionChoice::Bundled;
705        assert!(state.first_run_ready());
706        assert!(!state.operate_ready());
707
708        state.set_step(SetupStep::OperateFleet, verified("0.8.67"));
709        assert!(
710            !state.operate_ready(),
711            "a legacy Verified card is not receipt proof"
712        );
713        state.operate_receipts_verified = true;
714        assert!(state.operate_ready());
715    }
716
717    #[test]
718    fn legacy_verified_operate_card_without_receipt_proof_fails_closed() {
719        let mut legacy = SetupState::default();
720        legacy.set_step(SetupStep::Language, verified("0.8.67"));
721        legacy.set_step(SetupStep::ProviderModel, verified("0.8.67"));
722        legacy.set_step(SetupStep::OperateFleet, verified("0.8.67"));
723        legacy.runtime_posture_source = RuntimePostureSource::Confirmed;
724        legacy.constitution_choice = ConstitutionChoice::Bundled;
725        let raw = serde_json::to_string(&legacy).expect("serialize legacy-style state");
726        assert!(!raw.contains("operate_receipts_verified"), "{raw}");
727
728        let loaded: SetupState = serde_json::from_str(&raw).expect("load legacy-style state");
729
730        assert_eq!(loaded.status(SetupStep::OperateFleet), StepStatus::Verified);
731        assert!(!loaded.operate_receipts_verified);
732        assert!(!loaded.operate_ready());
733    }
734
735    #[test]
736    fn operate_ready_requires_verified_provider_not_needs_action() {
737        let mut state = SetupState::default();
738        state.set_step(
739            SetupStep::ProviderModel,
740            StepEntry::new(StepStatus::NeedsAction, true, "0.8.67"),
741        );
742        state.runtime_posture_source = RuntimePostureSource::Confirmed;
743        state.set_step(SetupStep::OperateFleet, verified("0.8.67"));
744
745        assert!(!state.operate_ready());
746    }
747
748    #[test]
749    fn needs_action_provider_still_reaches_ready() {
750        let mut state = SetupState::default();
751        state.set_step(SetupStep::Language, verified("0.8.67"));
752        state.set_step(
753            SetupStep::ProviderModel,
754            StepEntry::new(StepStatus::NeedsAction, true, "0.8.67"),
755        );
756        state.runtime_posture_source = RuntimePostureSource::Inherited;
757        state.constitution_choice = ConstitutionChoice::Deferred;
758        assert!(state.first_run_ready());
759    }
760
761    #[test]
762    fn deferred_constitution_counts_as_explicit_choice() {
763        assert!(ConstitutionChoice::Deferred.is_explicit());
764        assert!(ConstitutionChoice::Bundled.is_explicit());
765        assert!(!ConstitutionChoice::Unset.is_explicit());
766    }
767
768    #[test]
769    fn update_ready_tracks_checkpoint_version() {
770        let mut state = SetupState::default();
771        assert!(state.needs_constitution_checkpoint("0.8.67"));
772        state.complete_constitution_checkpoint("0.8.67", ConstitutionChoice::Bundled);
773        assert!(state.update_ready("0.8.67"));
774        assert!(!state.needs_constitution_checkpoint("0.8.67"));
775        // A later lane re-arms the checkpoint.
776        assert!(state.needs_constitution_checkpoint("0.8.68"));
777    }
778
779    #[test]
780    fn derive_inherited_marks_existing_user_safe() {
781        let facts = InheritedConfigFacts {
782            has_provider_route: true,
783            has_credentials_or_local_runtime: true,
784            trust_chosen: true,
785            language: Some("en".to_string()),
786            has_user_constitution: false,
787            has_expert_override: false,
788            user_constitution_validity: ConstitutionValidity::Unknown,
789        };
790        let state = SetupState::derive_inherited(&facts);
791        assert!(state.inherited);
792        assert_eq!(state.status(SetupStep::Language), StepStatus::Verified);
793        assert_eq!(state.status(SetupStep::ProviderModel), StepStatus::Verified);
794        assert_eq!(state.status(SetupStep::TrustSandbox), StepStatus::Verified);
795        assert_eq!(state.constitution_source, ConstitutionSource::Bundled);
796        // The update checkpoint must still be shown to an upgrading user.
797        assert!(state.needs_constitution_checkpoint("0.8.67"));
798    }
799
800    #[test]
801    fn derive_inherited_classifies_provider_without_key_as_needs_action() {
802        let facts = InheritedConfigFacts {
803            has_provider_route: true,
804            has_credentials_or_local_runtime: false,
805            ..InheritedConfigFacts::default()
806        };
807        let state = SetupState::derive_inherited(&facts);
808        assert_eq!(
809            state.status(SetupStep::ProviderModel),
810            StepStatus::NeedsAction
811        );
812    }
813
814    #[test]
815    fn derive_inherited_picks_up_existing_user_constitution() {
816        let facts = InheritedConfigFacts {
817            has_user_constitution: true,
818            user_constitution_validity: ConstitutionValidity::Valid,
819            ..InheritedConfigFacts::default()
820        };
821        let state = SetupState::derive_inherited(&facts);
822        assert_eq!(state.constitution_source, ConstitutionSource::UserGlobal);
823        assert_eq!(state.constitution_choice, ConstitutionChoice::GuidedCustom);
824        assert_eq!(state.constitution_validity, ConstitutionValidity::Valid);
825    }
826
827    #[test]
828    fn round_trips_through_json_sidecar() {
829        let tmp = tempfile::tempdir().unwrap();
830        let path = tmp.path().join(SETUP_STATE_FILE_NAME);
831
832        let mut state = SetupState::default();
833        state.set_step(
834            SetupStep::ProviderModel,
835            verified("0.8.67").with_result("openai · mimo-ultraspeed"),
836        );
837        state.constitution_choice = ConstitutionChoice::GuidedCustom;
838        state.constitution_preview_version = 3;
839        state.save_to(&path).unwrap();
840
841        let loaded = SetupState::load_from(&path).expect("record should load");
842        assert_eq!(loaded, state);
843        // Enum keys serialize as snake_case strings.
844        let raw = std::fs::read_to_string(&path).unwrap();
845        assert!(raw.contains("\"provider_model\""), "{raw}");
846        assert!(raw.contains("openai · mimo-ultraspeed"));
847    }
848
849    #[test]
850    fn constitution_authoring_round_trips_and_stays_optional() {
851        let tmp = tempfile::tempdir().unwrap();
852        let path = tmp.path().join(SETUP_STATE_FILE_NAME);
853
854        let state = SetupState {
855            constitution_choice: ConstitutionChoice::GuidedCustom,
856            constitution_authoring: Some(ConstitutionAuthoring::ModelDrafted),
857            ..Default::default()
858        };
859        state.save_to(&path).unwrap();
860
861        let loaded = SetupState::load_from(&path).expect("record should load");
862        assert_eq!(
863            loaded.constitution_authoring,
864            Some(ConstitutionAuthoring::ModelDrafted)
865        );
866        let raw = std::fs::read_to_string(&path).unwrap();
867        assert!(raw.contains("\"model_drafted\""), "{raw}");
868    }
869
870    #[test]
871    fn record_without_authoring_field_still_loads() {
872        // Records written before the model-drafting lane carry no
873        // constitution_authoring key; they must load with None, not fail.
874        let tmp = tempfile::tempdir().unwrap();
875        let path = tmp.path().join(SETUP_STATE_FILE_NAME);
876        std::fs::write(
877            &path,
878            r#"{"schema_version":1,"constitution_choice":"guided_custom"}"#,
879        )
880        .unwrap();
881        let loaded = SetupState::load_from(&path).expect("legacy record should load");
882        assert_eq!(loaded.constitution_authoring, None);
883        assert_eq!(loaded.constitution_choice, ConstitutionChoice::GuidedCustom);
884    }
885
886    #[test]
887    fn corrupt_record_falls_back_to_none() {
888        let tmp = tempfile::tempdir().unwrap();
889        let path = tmp.path().join(SETUP_STATE_FILE_NAME);
890        std::fs::write(&path, "{ not valid json").unwrap();
891        assert!(SetupState::load_from(&path).is_none());
892    }
893
894    #[test]
895    fn missing_record_is_none_not_error() {
896        let tmp = tempfile::tempdir().unwrap();
897        let path = tmp.path().join("does-not-exist.json");
898        assert!(SetupState::load_from(&path).is_none());
899    }
900
901    #[test]
902    fn step_result_carries_no_secret_by_construction() {
903        // The result field is a caller-supplied safe summary; this documents the
904        // contract that callers pass names, not keys.
905        let entry = verified("0.8.67").with_result("provider: openai, model: mimo");
906        let json = serde_json::to_string(&entry).unwrap();
907        assert!(!json.to_lowercase().contains("sk-"));
908    }
909}