返回 CodeWhale
setup_state.rs
根目录 / crates / config / src / 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
25 use std::collections::BTreeMap;
26 use std::path::{Path, PathBuf};
27
28 use anyhow::{Context, Result};
29 use serde::{Deserialize, Serialize};
30
31 use crate::persistence;
32
33 /// Current schema version of the persisted setup-state record.
34 pub const SETUP_STATE_SCHEMA_VERSION: u32 = 1;
35
36 /// Filename of the setup-state sidecar under `$CODEWHALE_HOME`.
37 pub 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.
46 pub 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")]
52 pub 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
77 impl 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")]
97 pub 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
119 impl 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)]
137 pub 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
153 impl 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")]
178 pub 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
193 impl 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")]
211 pub 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")]
223 pub 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")]
236 pub 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")]
255 pub 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
265 impl 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)]
279 pub 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)]
355 fn is_false(b: &bool) -> bool {
356 !*b
357 }
358
359 impl 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)]
388 pub 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
405 impl 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)]
644 mod 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 }
910
910 lines RUST