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    /// True when this record was *derived* from existing config rather than
311    /// persisted by an explicit setup run. Lets `/setup` and `doctor` explain
312    /// why an updating user is not treated as a broken fresh install.
313    #[serde(default, skip_serializing_if = "is_false")]
314    pub inherited: bool,
315}
316
317#[allow(clippy::trivially_copy_pass_by_ref)]
318fn is_false(b: &bool) -> bool {
319    !*b
320}
321
322impl Default for SetupState {
323    fn default() -> Self {
324        Self {
325            schema_version: SETUP_STATE_SCHEMA_VERSION,
326            steps: BTreeMap::new(),
327            constitution_choice: ConstitutionChoice::default(),
328            constitution_checkpoint_completed_for: None,
329            constitution_language: None,
330            constitution_source: ConstitutionSource::default(),
331            constitution_validity: ConstitutionValidity::default(),
332            constitution_authoring: None,
333            constitution_preview_hash: None,
334            constitution_preview_version: 0,
335            runtime_posture_source: RuntimePostureSource::default(),
336            inherited: false,
337        }
338    }
339}
340
341/// Observable, secret-free facts about existing config used to derive a safe
342/// inherited setup-state for users who upgrade without a `setup_state.json`.
343///
344/// The caller (TUI/CLI) gathers these from `ConfigToml`, the trust marker, and
345/// the constitution files; keeping them as plain data keeps this module pure and
346/// unit-testable.
347#[derive(Debug, Clone, Default)]
348pub struct InheritedConfigFacts {
349    /// A provider/model route is configured.
350    pub has_provider_route: bool,
351    /// A key or local runtime is available (presence only — never the value).
352    pub has_credentials_or_local_runtime: bool,
353    /// The user has previously made a trust/approval decision.
354    pub trust_chosen: bool,
355    /// Onboarding language, if known.
356    pub language: Option<String>,
357    /// A structured user-global `constitution.json` exists.
358    pub has_user_constitution: bool,
359    /// An expert full-Markdown override is active.
360    pub has_expert_override: bool,
361    /// Validity of the user-global constitution, if present.
362    pub user_constitution_validity: ConstitutionValidity,
363}
364
365impl SetupState {
366    /// Status for a step, defaulting to [`StepStatus::NotStarted`].
367    #[must_use]
368    pub fn status(&self, step: SetupStep) -> StepStatus {
369        self.steps
370            .get(&step)
371            .map_or(StepStatus::NotStarted, |e| e.status)
372    }
373
374    /// Record (insert or replace) an entry for `step`.
375    pub fn set_step(&mut self, step: SetupStep, entry: StepEntry) -> &mut Self {
376        self.steps.insert(step, entry);
377        self
378    }
379
380    #[must_use]
381    fn step_verified(&self, step: SetupStep) -> bool {
382        self.status(step) == StepStatus::Verified
383    }
384
385    /// Provider/model is acceptable for first-run readiness when it is either
386    /// verified or in an actionable needs-action state (the EPIC keeps a
387    /// failed-key path reaching the ready screen).
388    #[must_use]
389    fn provider_model_ready_or_needs_action(&self) -> bool {
390        matches!(
391            self.status(SetupStep::ProviderModel),
392            StepStatus::Verified | StepStatus::NeedsAction
393        )
394    }
395
396    /// First-run "ready": language verified, provider/model ready-or-needs-action,
397    /// runtime posture inherited/confirmed, and an explicit constitution choice.
398    #[must_use]
399    pub fn first_run_ready(&self) -> bool {
400        self.step_verified(SetupStep::Language)
401            && self.provider_model_ready_or_needs_action()
402            && self.runtime_posture_source.is_reviewed()
403            && self.constitution_choice.is_explicit()
404    }
405
406    /// Operate/Fleet "ready": provider credentials are verified, runtime
407    /// posture has been reviewed, and the user has explicitly reviewed the
408    /// Fleet/Operate on-ramp. This is intentionally separate from
409    /// [`first_run_ready`](Self::first_run_ready): a local-first user can be
410    /// ready for ordinary first use before enabling durable multi-worker work.
411    #[must_use]
412    pub fn operate_ready(&self) -> bool {
413        self.first_run_ready()
414            && self.step_verified(SetupStep::ProviderModel)
415            && self.step_verified(SetupStep::OperateFleet)
416    }
417
418    /// Update "ready" for `version`: the constitution checkpoint for that lane is
419    /// complete. Everything else is inherited from existing config.
420    #[must_use]
421    pub fn update_ready(&self, version: &str) -> bool {
422        self.constitution_checkpoint_completed_for.as_deref() == Some(version)
423    }
424
425    /// Whether the once-per-version update checkpoint should still be shown.
426    #[must_use]
427    pub fn needs_constitution_checkpoint(&self, version: &str) -> bool {
428        !self.update_ready(version)
429    }
430
431    /// Mark the constitution checkpoint complete for `version` (the bundled /
432    /// default path is a valid completion).
433    pub fn complete_constitution_checkpoint(
434        &mut self,
435        version: impl Into<String>,
436        choice: ConstitutionChoice,
437    ) -> &mut Self {
438        self.constitution_checkpoint_completed_for = Some(version.into());
439        self.constitution_choice = choice;
440        self
441    }
442
443    /// Derive a safe inherited state for an existing user with no persisted
444    /// `setup_state.json`. Surfaces they already configured become
445    /// [`StepStatus::Verified`]; an update never looks like a fresh, broken
446    /// setup. The constitution checkpoint is intentionally left incomplete so
447    /// updating users still see it once.
448    #[must_use]
449    pub fn derive_inherited(facts: &InheritedConfigFacts) -> Self {
450        let mut state = SetupState {
451            inherited: true,
452            ..SetupState::default()
453        };
454        let inherited = "inherited";
455
456        if facts.language.is_some() {
457            state.set_step(
458                SetupStep::Language,
459                StepEntry::new(StepStatus::Verified, true, inherited),
460            );
461            state.constitution_language = facts.language.clone();
462        }
463
464        if facts.has_provider_route && facts.has_credentials_or_local_runtime {
465            state.set_step(
466                SetupStep::ProviderModel,
467                StepEntry::new(StepStatus::Verified, true, inherited),
468            );
469        } else if facts.has_provider_route {
470            state.set_step(
471                SetupStep::ProviderModel,
472                StepEntry::new(StepStatus::NeedsAction, true, inherited),
473            );
474        }
475
476        if facts.trust_chosen {
477            state.set_step(
478                SetupStep::TrustSandbox,
479                StepEntry::new(StepStatus::Verified, true, inherited),
480            );
481            state.runtime_posture_source = RuntimePostureSource::Inherited;
482        }
483
484        // Constitution: classify the active surface, but never auto-complete the
485        // checkpoint — the update lane requires the user to acknowledge it once.
486        if facts.has_expert_override {
487            state.constitution_source = ConstitutionSource::ExpertOverride;
488            state.constitution_choice = ConstitutionChoice::ExpertOverride;
489        } else if facts.has_user_constitution {
490            state.constitution_source = ConstitutionSource::UserGlobal;
491            state.constitution_validity = facts.user_constitution_validity;
492            if facts.user_constitution_validity == ConstitutionValidity::Valid {
493                state.constitution_choice = ConstitutionChoice::GuidedCustom;
494            }
495        } else {
496            state.constitution_source = ConstitutionSource::Bundled;
497        }
498
499        state
500    }
501
502    /// Path to the setup-state sidecar under `$CODEWHALE_HOME`.
503    pub fn path() -> Result<PathBuf> {
504        Ok(crate::codewhale_home()?.join(SETUP_STATE_FILE_NAME))
505    }
506
507    /// Load the persisted setup-state from the home sidecar.
508    ///
509    /// Returns `Ok(None)` when the file is missing **or** unreadable/corrupt, so
510    /// callers fall back to [`derive_inherited`](Self::derive_inherited) rather
511    /// than forcing a fresh wizard. A corrupt record is logged, never fatal.
512    pub fn load() -> Result<Option<Self>> {
513        Ok(Self::load_from(&Self::path()?))
514    }
515
516    /// Load from an explicit path (testable). See [`load`](Self::load) for the
517    /// missing/corrupt fallback contract.
518    #[must_use]
519    pub fn load_from(path: &Path) -> Option<Self> {
520        let raw = match std::fs::read_to_string(path) {
521            Ok(raw) => raw,
522            Err(e) if e.kind() == std::io::ErrorKind::NotFound => return None,
523            Err(e) => {
524                tracing::warn!(
525                    target: "config::setup_state",
526                    "could not read {} ({e}); deriving status from existing config",
527                    path.display()
528                );
529                return None;
530            }
531        };
532        match serde_json::from_str::<SetupState>(&raw) {
533            Ok(state) => Some(state),
534            Err(e) => {
535                tracing::warn!(
536                    target: "config::setup_state",
537                    "{} is not a valid setup-state record ({e}); deriving status from existing config",
538                    path.display()
539                );
540                None
541            }
542        }
543    }
544
545    /// Atomically persist this record to the home sidecar.
546    pub fn save(&self) -> Result<()> {
547        let path = Self::path()?;
548        self.save_to(&path)
549    }
550
551    /// Atomically persist to an explicit path (testable).
552    pub fn save_to(&self, path: &Path) -> Result<()> {
553        persistence::atomic_write_json(path, self)
554            .with_context(|| format!("failed to persist setup state to {}", path.display()))
555    }
556}
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561
562    fn verified(version: &str) -> StepEntry {
563        StepEntry::new(StepStatus::Verified, true, version)
564    }
565
566    #[test]
567    fn default_is_not_first_run_ready() {
568        let state = SetupState::default();
569        assert!(!state.first_run_ready());
570        assert_eq!(state.constitution_choice, ConstitutionChoice::Unset);
571    }
572
573    #[test]
574    fn persistence_is_optional_before_verification() {
575        let persistence_index = SetupStep::ALL
576            .iter()
577            .position(|step| *step == SetupStep::Persistence)
578            .expect("persistence step");
579        let verification_index = SetupStep::ALL
580            .iter()
581            .position(|step| *step == SetupStep::Verification)
582            .expect("verification step");
583
584        assert!(persistence_index < verification_index);
585
586        let mut state = SetupState::default();
587        state.set_step(SetupStep::Language, verified("0.8.67"));
588        state.set_step(SetupStep::ProviderModel, verified("0.8.67"));
589        state.runtime_posture_source = RuntimePostureSource::Confirmed;
590        state.constitution_choice = ConstitutionChoice::Bundled;
591        assert!(state.first_run_ready());
592
593        state.set_step(
594            SetupStep::Persistence,
595            StepEntry::new(StepStatus::NeedsAction, false, "0.8.67"),
596        );
597        assert!(state.first_run_ready());
598        assert!(!state.operate_ready());
599    }
600
601    #[test]
602    fn first_run_ready_requires_all_pillars() {
603        let mut state = SetupState::default();
604        state.set_step(SetupStep::Language, verified("0.8.67"));
605        state.set_step(SetupStep::ProviderModel, verified("0.8.67"));
606        state.runtime_posture_source = RuntimePostureSource::Confirmed;
607        // Still missing an explicit constitution choice.
608        assert!(!state.first_run_ready());
609        state.constitution_choice = ConstitutionChoice::Bundled;
610        assert!(state.first_run_ready());
611    }
612
613    #[test]
614    fn operate_ready_is_separate_from_first_run_ready() {
615        let mut state = SetupState::default();
616        state.set_step(SetupStep::Language, verified("0.8.67"));
617        state.set_step(SetupStep::ProviderModel, verified("0.8.67"));
618        state.runtime_posture_source = RuntimePostureSource::Confirmed;
619        state.constitution_choice = ConstitutionChoice::Bundled;
620        assert!(state.first_run_ready());
621        assert!(!state.operate_ready());
622
623        state.set_step(SetupStep::OperateFleet, verified("0.8.67"));
624        assert!(state.operate_ready());
625    }
626
627    #[test]
628    fn operate_ready_requires_verified_provider_not_needs_action() {
629        let mut state = SetupState::default();
630        state.set_step(
631            SetupStep::ProviderModel,
632            StepEntry::new(StepStatus::NeedsAction, true, "0.8.67"),
633        );
634        state.runtime_posture_source = RuntimePostureSource::Confirmed;
635        state.set_step(SetupStep::OperateFleet, verified("0.8.67"));
636
637        assert!(!state.operate_ready());
638    }
639
640    #[test]
641    fn needs_action_provider_still_reaches_ready() {
642        let mut state = SetupState::default();
643        state.set_step(SetupStep::Language, verified("0.8.67"));
644        state.set_step(
645            SetupStep::ProviderModel,
646            StepEntry::new(StepStatus::NeedsAction, true, "0.8.67"),
647        );
648        state.runtime_posture_source = RuntimePostureSource::Inherited;
649        state.constitution_choice = ConstitutionChoice::Deferred;
650        assert!(state.first_run_ready());
651    }
652
653    #[test]
654    fn deferred_constitution_counts_as_explicit_choice() {
655        assert!(ConstitutionChoice::Deferred.is_explicit());
656        assert!(ConstitutionChoice::Bundled.is_explicit());
657        assert!(!ConstitutionChoice::Unset.is_explicit());
658    }
659
660    #[test]
661    fn update_ready_tracks_checkpoint_version() {
662        let mut state = SetupState::default();
663        assert!(state.needs_constitution_checkpoint("0.8.67"));
664        state.complete_constitution_checkpoint("0.8.67", ConstitutionChoice::Bundled);
665        assert!(state.update_ready("0.8.67"));
666        assert!(!state.needs_constitution_checkpoint("0.8.67"));
667        // A later lane re-arms the checkpoint.
668        assert!(state.needs_constitution_checkpoint("0.8.68"));
669    }
670
671    #[test]
672    fn derive_inherited_marks_existing_user_safe() {
673        let facts = InheritedConfigFacts {
674            has_provider_route: true,
675            has_credentials_or_local_runtime: true,
676            trust_chosen: true,
677            language: Some("en".to_string()),
678            has_user_constitution: false,
679            has_expert_override: false,
680            user_constitution_validity: ConstitutionValidity::Unknown,
681        };
682        let state = SetupState::derive_inherited(&facts);
683        assert!(state.inherited);
684        assert_eq!(state.status(SetupStep::Language), StepStatus::Verified);
685        assert_eq!(state.status(SetupStep::ProviderModel), StepStatus::Verified);
686        assert_eq!(state.status(SetupStep::TrustSandbox), StepStatus::Verified);
687        assert_eq!(state.constitution_source, ConstitutionSource::Bundled);
688        // The update checkpoint must still be shown to an upgrading user.
689        assert!(state.needs_constitution_checkpoint("0.8.67"));
690    }
691
692    #[test]
693    fn derive_inherited_classifies_provider_without_key_as_needs_action() {
694        let facts = InheritedConfigFacts {
695            has_provider_route: true,
696            has_credentials_or_local_runtime: false,
697            ..InheritedConfigFacts::default()
698        };
699        let state = SetupState::derive_inherited(&facts);
700        assert_eq!(
701            state.status(SetupStep::ProviderModel),
702            StepStatus::NeedsAction
703        );
704    }
705
706    #[test]
707    fn derive_inherited_picks_up_existing_user_constitution() {
708        let facts = InheritedConfigFacts {
709            has_user_constitution: true,
710            user_constitution_validity: ConstitutionValidity::Valid,
711            ..InheritedConfigFacts::default()
712        };
713        let state = SetupState::derive_inherited(&facts);
714        assert_eq!(state.constitution_source, ConstitutionSource::UserGlobal);
715        assert_eq!(state.constitution_choice, ConstitutionChoice::GuidedCustom);
716        assert_eq!(state.constitution_validity, ConstitutionValidity::Valid);
717    }
718
719    #[test]
720    fn round_trips_through_json_sidecar() {
721        let tmp = tempfile::tempdir().unwrap();
722        let path = tmp.path().join(SETUP_STATE_FILE_NAME);
723
724        let mut state = SetupState::default();
725        state.set_step(
726            SetupStep::ProviderModel,
727            verified("0.8.67").with_result("openai · mimo-ultraspeed"),
728        );
729        state.constitution_choice = ConstitutionChoice::GuidedCustom;
730        state.constitution_preview_version = 3;
731        state.save_to(&path).unwrap();
732
733        let loaded = SetupState::load_from(&path).expect("record should load");
734        assert_eq!(loaded, state);
735        // Enum keys serialize as snake_case strings.
736        let raw = std::fs::read_to_string(&path).unwrap();
737        assert!(raw.contains("\"provider_model\""), "{raw}");
738        assert!(raw.contains("openai · mimo-ultraspeed"));
739    }
740
741    #[test]
742    fn constitution_authoring_round_trips_and_stays_optional() {
743        let tmp = tempfile::tempdir().unwrap();
744        let path = tmp.path().join(SETUP_STATE_FILE_NAME);
745
746        let state = SetupState {
747            constitution_choice: ConstitutionChoice::GuidedCustom,
748            constitution_authoring: Some(ConstitutionAuthoring::ModelDrafted),
749            ..Default::default()
750        };
751        state.save_to(&path).unwrap();
752
753        let loaded = SetupState::load_from(&path).expect("record should load");
754        assert_eq!(
755            loaded.constitution_authoring,
756            Some(ConstitutionAuthoring::ModelDrafted)
757        );
758        let raw = std::fs::read_to_string(&path).unwrap();
759        assert!(raw.contains("\"model_drafted\""), "{raw}");
760    }
761
762    #[test]
763    fn record_without_authoring_field_still_loads() {
764        // Records written before the model-drafting lane carry no
765        // constitution_authoring key; they must load with None, not fail.
766        let tmp = tempfile::tempdir().unwrap();
767        let path = tmp.path().join(SETUP_STATE_FILE_NAME);
768        std::fs::write(
769            &path,
770            r#"{"schema_version":1,"constitution_choice":"guided_custom"}"#,
771        )
772        .unwrap();
773        let loaded = SetupState::load_from(&path).expect("legacy record should load");
774        assert_eq!(loaded.constitution_authoring, None);
775        assert_eq!(loaded.constitution_choice, ConstitutionChoice::GuidedCustom);
776    }
777
778    #[test]
779    fn corrupt_record_falls_back_to_none() {
780        let tmp = tempfile::tempdir().unwrap();
781        let path = tmp.path().join(SETUP_STATE_FILE_NAME);
782        std::fs::write(&path, "{ not valid json").unwrap();
783        assert!(SetupState::load_from(&path).is_none());
784    }
785
786    #[test]
787    fn missing_record_is_none_not_error() {
788        let tmp = tempfile::tempdir().unwrap();
789        let path = tmp.path().join("does-not-exist.json");
790        assert!(SetupState::load_from(&path).is_none());
791    }
792
793    #[test]
794    fn step_result_carries_no_secret_by_construction() {
795        // The result field is a caller-supplied safe summary; this documents the
796        // contract that callers pass names, not keys.
797        let entry = verified("0.8.67").with_result("provider: openai, model: mimo");
798        let json = serde_json::to_string(&entry).unwrap();
799        assert!(!json.to_lowercase().contains("sk-"));
800    }
801}