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