mindfork 0.21.0

A terminal AI chat written in Rust: local models via llama.cpp, or OpenAI, Anthropic, Gemini, Grok and OpenRouter in the cloud, with persistent memory, notes, RAG and tools.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
//! Schema versions of saved data and a **pure JSON-migration framework**.
//!
//! The single home for the schema-version constants (per artifact: settings/profiles/
//! chats/DB change at different rates, one global number would force "migrating"
//! untouched files). Only Value-level logic lives here (version detection, running
//! steps); file I/O, backups, and control-parsing into typed structs live in
//! [`crate::features::data_migration`] (orchestration; `shared` cannot depend on
//! `features`, and the pre-migration backup lives in `features::backup`). See
//! [docs/history/release-engineering.md](../../../docs/history/release-engineering.md) §3.4 and ADR 0006.
//!
//! **Bump policy** (release-engineering.md F12): an additive change (a new field with
//! `#[serde(default)]`, a new table/column with a default) — **no bump**, as before;
//! a breaking one (renaming/moving/changing semantics/removing a field) — bump the
//! constant + a migration step + a golden fixture of the old format + a CHANGELOG
//! entry (the "Data" rubric). **A new value of an existing enum is breaking**,
//! though nothing in the file moves: an older binary fails the parse of the
//! whole file on a value it does not know (docs/lessons.md §8), so the bump is
//! what makes its refusal say "a newer version" — see [`settings_to_v4`],
//! [`settings_to_v5`] and [`settings_to_v7`].

use std::cmp::Ordering;

use anyhow::{Context, Result};
use serde_json::Value;

/// Schema version of `settings.json`. Matches [`crate::shared::config::SCHEMA_VERSION`]
/// (the default of the `AppConfig.schema_version` field) — the invariant is checked by
/// a test.
pub const SETTINGS_SCHEMA: u32 = 7;
/// Schema version of `profiles.json`.
pub const PROFILES_SCHEMA: u32 = 1;
/// Schema version of a chat file `chats/<id>.json`.
pub const CHAT_SCHEMA: u32 = 6;
/// SQLite schema version (`PRAGMA user_version`). The DB migration runner is in
/// [`crate::shared::storage::db`] (baseline 0→1 + steps in transactions).
pub const DB_SCHEMA: u32 = 1;

/// A JSON migration step: a pure transformation "version `< to` → `to`".
pub struct Step {
    /// The target version this step produces.
    pub to: u32,
    /// A short description for the migration log (not user-facing text).
    pub summary: &'static str,
    /// The value transformation. Must be pure (no I/O).
    pub apply: fn(Value) -> Result<Value>,
}

/// Description of a versioned JSON artifact: how to detect a value's version and the
/// chain of steps to the current one. The format doesn't change today (all schemas = 1,
/// `steps` are empty) — the framework is "in production" on empty migrations; the first
/// real migration will add a step + a fixture.
pub struct JsonArtifact {
    /// Name for logging/errors (`"settings.json"`).
    pub name: &'static str,
    /// The current (target) schema version.
    pub current: u32,
    /// Detects a value's version structurally (existing files aren't rewritten:
    /// "no version field → 1").
    pub detect: fn(&Value) -> u32,
    /// Steps `1→2→…→current`, in ascending order of `to`.
    pub steps: &'static [Step],
}

/// Verdict on a value's version relative to the artifact's current schema.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Assessment {
    /// The version matches the current one — no migration needed.
    UpToDate,
    /// The version is older — steps `from → current` need to run.
    Migrate { from: u32 },
    /// The version is **newer** than the app (data from a newer mindfork version) —
    /// migration is impossible; the caller refuses to start (release-engineering.md F10).
    Downgrade { from: u32 },
}

impl JsonArtifact {
    /// Detects a value's version and compares it to the current one.
    pub fn assess(&self, v: &Value) -> Assessment {
        let from = (self.detect)(v);
        match from.cmp(&self.current) {
            Ordering::Equal => Assessment::UpToDate,
            Ordering::Less => Assessment::Migrate { from },
            Ordering::Greater => Assessment::Downgrade { from },
        }
    }

    /// Runs the steps `from → current`. The result is not yet validated by a typed
    /// parse — the orchestrator does that (control-parse before writing).
    pub fn apply_steps(&self, mut v: Value, from: u32) -> Result<Value> {
        for step in self.steps.iter().filter(|s| s.to > from) {
            tracing::info!(
                artifact = self.name,
                to = step.to,
                "migration step: {}",
                step.summary
            );
            v = (step.apply)(v)
                .with_context(|| format!("migration step {} → v{}", self.name, step.to))?;
        }
        Ok(v)
    }
}

/// `settings.json` version — by the `schema_version` field (absent → 1).
fn detect_settings(v: &Value) -> u32 {
    v.get("schema_version").and_then(Value::as_u64).unwrap_or(1) as u32
}

/// `profiles.json` version — historically a bare array (→ 1); an envelope shape with a
/// `schema_version` field will appear at the first breaking change.
fn detect_profiles(v: &Value) -> u32 {
    if v.is_array() {
        1
    } else {
        v.get("schema_version").and_then(Value::as_u64).unwrap_or(1) as u32
    }
}

/// Chat-file version — by the `v` field (absent → 1). The field was not written
/// while the schema stayed at 1; since 2 every save writes it (`Chat::v`), so a
/// migrated file is never detected as 1 again.
fn detect_chat(v: &Value) -> u32 {
    v.get("v").and_then(Value::as_u64).unwrap_or(1) as u32
}

/// The values `settings.json` v1 wrote for the sub-agent knobs by default —
/// frozen here, because a migration step describes the past and must not follow
/// the constants in `config.rs` when those move again.
const V1_SUBAGENT_TIMEOUT_SECS: u64 = 60;
const V1_SUBAGENT_MAX_TOKENS: u64 = 1024;

/// `settings.json` 1→2: the sub-agent gained tools (spec §9.3.2,
/// docs/research/subagent-chats.md §3.12), so its one-request timeout
/// `tools.subagent_timeout_secs` became the whole-run `subagent_run_timeout_secs`
/// with a default ten times larger, and its per-round reply cap's default rose.
/// A value the user left at the old default is **dropped** (the new default
/// applies on read — `ToolSettings` is `#[serde(default)]`); a value the user
/// changed is carried over under the new name, because a number somebody typed
/// is a decision. Nothing else in the file is touched.
fn settings_to_v2(mut v: Value) -> Result<Value> {
    if let Some(tools) = v.get_mut("tools").and_then(Value::as_object_mut) {
        if let Some(old) = tools.remove("subagent_timeout_secs")
            && old.as_u64() != Some(V1_SUBAGENT_TIMEOUT_SECS)
        {
            tools.insert("subagent_run_timeout_secs".into(), old);
        }
        if tools.get("subagent_max_tokens").and_then(Value::as_u64) == Some(V1_SUBAGENT_MAX_TOKENS)
        {
            tools.remove("subagent_max_tokens");
        }
    }
    v["schema_version"] = Value::from(2);
    Ok(v)
}

/// The reply budget `settings.json` v2 wrote by default, and the one v3 writes —
/// both frozen here for the same reason as the constants above: a migration step
/// describes one transition, and must not move when today's defaults do.
const V2_MAX_TOKENS: u64 = 2048;
const V3_MAX_TOKENS: u64 = 16384;

/// `settings.json` 2→3: the default reply budget rose from 2048 to 16384, because
/// that number covers the model's **reasoning** as well on every provider that
/// bills it that way, and 2048 was set before any of them did. Measured on
/// `gemini-2.5-pro` with thinking on: 1697 tokens of thinking left 347 for the
/// answer and the reply came back cut (`MAX_TOKENS`); the same question at 16384
/// finished on its own (docs/research/robustness-and-defaults.md F5).
///
/// A value left at the old default is **rewritten** to the new one; a value the
/// user typed is untouched, because a number somebody chose is a decision — the
/// same rule the 1→2 step follows. Nothing else in the file changes.
///
/// Rewritten rather than dropped, which is what the 1→2 step does with its
/// numbers, and the difference is not cosmetic: `ToolSettings::default()` carries
/// its constant, so a removed key lands on the new default, while
/// `SamplingConfig::default()` has `max_tokens: None` — the 16384 lives in
/// `AppConfig::default()`, which serde never consults for a section that is
/// present. Dropping the key here would therefore have meant *no limit at all*,
/// which is a different decision than the one being made. A test asserts the
/// parsed outcome, not the JSON, for exactly this reason.
fn settings_to_v3(mut v: Value) -> Result<Value> {
    if let Some(sampling) = v.get_mut("default_sampling").and_then(Value::as_object_mut)
        && sampling.get("max_tokens").and_then(Value::as_u64) == Some(V2_MAX_TOKENS)
    {
        sampling.insert("max_tokens".into(), Value::from(V3_MAX_TOKENS));
    }
    v["schema_version"] = Value::from(3);
    Ok(v)
}

/// `settings.json` 3→4: **nothing in the file changes** — the version is what
/// changes. 4 is the first schema in which a slot's `mode` may read
/// `"openrouter"` (docs/research/openrouter-mode.md, fork F3), and a mode is a
/// strict enum: a binary that does not know the value fails the parse of the
/// whole file. What such a binary then does depends on its age — since 0.12.0 it
/// refuses to start naming an unknown variant, and up to 0.11.2 it fell back to
/// the defaults in silence and wrote them over the file, stored keys included
/// (docs/lessons.md §8). With the version raised, every one of them that has the
/// downgrade guard (ADR 0006 §4) refuses **before** it reads a value, and says
/// why: the data was written by a newer version.
///
/// The step stamps the version whether or not any slot uses the new mode. A
/// file stamped only once it holds the value would be read by an older binary
/// until the day the mode was selected — and then not, with nothing about the
/// application having changed that day.
fn settings_to_v4(mut v: Value) -> Result<Value> {
    v["schema_version"] = Value::from(4);
    Ok(v)
}

/// `settings.json` 4→5: **nothing in the file changes** — the version is what
/// changes, as in [`settings_to_v4`]. 5 is the first schema in which a
/// `reasoning_effort` may read `"max"` (spec §8.1), and the effort scale is a
/// strict enum: 0.13.0 fails the parse of the whole file on that value and
/// refuses to start naming an unknown variant.
///
/// The stamp answers for `profiles.json` too, which has no step of its own. A
/// profile's `default_sampling` may hold the value, and 0.13.0 refuses that
/// file the same way — but `settings.json` is the file a start reads first
/// ([`crate::features::data_migration`]), so with the version raised the
/// refusal is made there, for every root this version has started on, before
/// `profiles.json` is opened. A step for the profiles would have been the
/// first change of that file's shape (a bare array has nowhere to carry a
/// version), bought for a refusal that is already made.
///
/// The chat files take the same stamp for a reason of their own — see
/// [`super::chat_steps::chat_to_v5`].
fn settings_to_v5(mut v: Value) -> Result<Value> {
    v["schema_version"] = Value::from(5);
    Ok(v)
}

/// The self-model block's cap `settings.json` was written with by default
/// through 0.9.4, and the one v6 puts in its place — frozen here for the same
/// reason as the constants above.
const V5_PROMPT_CAP: u64 = 1200;
const V6_PROMPT_CAP: u64 = 4000;

/// `settings.json` 5→6: a self-model cap left at the default of 0.9.0–0.9.4 is
/// raised to today's. 0.9.5 raised the default `self_model.prompt_cap` from 1200
/// to 4000 (docs/history/self-model-injection-budget.md, D5), and an existing
/// file kept its own number, since the field is always written — so an
/// installation from before it that never touched the setting is still at 1200.
/// Measured there on a mature profile (docs/history/self-model-observations.md
/// §3.4), the block has spent 1050 characters before the observations begin:
/// they show a fragment of one, 7% of the pick at the median, and the model of
/// the interlocutor shows one trait. No way of sharing the room makes 1200 hold
/// such a profile; the best one measured shows 21%.
///
/// **Exactly** the old default is rewritten — the owner's decision of
/// 2026-10-18 (fork B′ (b1)), against the research's recommendation to leave
/// it: for an application with this much to say about itself, 1200 is too
/// little. Any other value stays, because a small cap is a real choice on a
/// small local window (Ollama's default is 4096 tokens). A stored 1200 cannot
/// be told from a chosen one, so a 1200 chosen on purpose becomes 4000 too —
/// the price [`settings_to_v3`] already paid for 2048. Written rather than
/// dropped, as there: a dropped key would land on whatever the default is on
/// the day it is read, and the step describes one transition.
fn settings_to_v6(mut v: Value) -> Result<Value> {
    if let Some(self_model) = v.get_mut("self_model").and_then(Value::as_object_mut)
        && self_model.get("prompt_cap").and_then(Value::as_u64) == Some(V5_PROMPT_CAP)
    {
        self_model.insert("prompt_cap".into(), Value::from(V6_PROMPT_CAP));
    }
    v["schema_version"] = Value::from(6);
    Ok(v)
}

/// `settings.json` 6→7: **nothing in the file changes**, as in
/// [`settings_to_v4`]. 7 is the first schema in which a slot's `mode` may read
/// `"deepseek"` (docs/history/deepseek-mode.md §4.1) — the assistant's, the
/// impersonation engine's or the embedder's — and 0.20.0 refuses a file naming
/// it as an unknown variant. The chat files take the same stamp, for the reason
/// [`super::chat_steps::chat_to_v6`] gives.
fn settings_to_v7(mut v: Value) -> Result<Value> {
    v["schema_version"] = Value::from(7);
    Ok(v)
}

const SETTINGS_STEPS: &[Step] = &[
    Step {
        to: 2,
        summary: "the sub-agent's one-request timeout becomes a whole-run limit",
        apply: settings_to_v2,
    },
    Step {
        to: 3,
        summary: "the default reply budget rises to cover a model's reasoning",
        apply: settings_to_v3,
    },
    Step {
        to: 4,
        summary: "a slot's mode may name the OpenRouter gateway",
        apply: settings_to_v4,
    },
    Step {
        to: 5,
        summary: "a reasoning effort may read max",
        apply: settings_to_v5,
    },
    Step {
        to: 6,
        summary: "a self-model cap left at the 0.9.4 default rises to 4000",
        apply: settings_to_v6,
    },
    Step {
        to: 7,
        summary: "a slot's mode may name DeepSeek's API",
        apply: settings_to_v7,
    },
];

/// The registry of artifacts. `settings.json` is at 7 (six steps), the chat files
/// at 6 (five); `profiles.json` is still at 1 with no steps.
pub fn settings_artifact() -> JsonArtifact {
    JsonArtifact {
        name: "settings.json",
        current: SETTINGS_SCHEMA,
        detect: detect_settings,
        steps: SETTINGS_STEPS,
    }
}

pub fn profiles_artifact() -> JsonArtifact {
    JsonArtifact {
        name: "profiles.json",
        current: PROFILES_SCHEMA,
        detect: detect_profiles,
        steps: &[],
    }
}

const CHAT_STEPS: &[Step] = &[
    Step {
        to: 2,
        summary: "a transcript is synthesized for every old call_subagent record",
        apply: super::chat_steps::chat_to_v2,
    },
    Step {
        to: 3,
        summary: "chat files may carry dialogue runs (RunKind::Dialogue)",
        apply: super::chat_steps::chat_to_v3,
    },
    Step {
        to: 4,
        summary: "a run title the old 100-character cap cut gets its tail back",
        apply: super::chat_steps::chat_to_v4,
    },
    Step {
        to: 5,
        summary: "a reasoning effort may read max",
        apply: super::chat_steps::chat_to_v5,
    },
    Step {
        to: 6,
        summary: "a reply's metadata may name the deepseek mode",
        apply: super::chat_steps::chat_to_v6,
    },
];

pub fn chat_artifact() -> JsonArtifact {
    JsonArtifact {
        name: "chats/<id>.json",
        current: CHAT_SCHEMA,
        detect: detect_chat,
        steps: CHAT_STEPS,
    }
}

/// The paths of `fixture` that `read_back` — the fixture parsed into its typed
/// structure and serialized again — does not hold with the same value: keys the
/// reader ignored, and values it changed. For the golden fixtures' own tests.
#[cfg(test)]
pub(super) fn dropped_paths(fixture: &Value, read_back: &Value) -> Vec<String> {
    fn walk(at: &str, fixture: &Value, read_back: &Value, out: &mut Vec<String>) {
        match (fixture, read_back) {
            (Value::Object(want), Value::Object(got)) => {
                for (key, value) in want {
                    let path = format!("{at}/{key}");
                    match got.get(key) {
                        Some(kept) => walk(&path, value, kept, out),
                        None => out.push(path),
                    }
                }
            }
            (Value::Array(want), Value::Array(got)) if want.len() == got.len() => {
                for (i, (value, kept)) in want.iter().zip(got).enumerate() {
                    walk(&format!("{at}/{i}"), value, kept, out);
                }
            }
            (want, got) if want == got => {}
            _ => out.push(at.to_string()),
        }
    }
    let mut out = Vec::new();
    walk("", fixture, read_back, &mut out);
    out
}

#[cfg(test)]
mod stored_enums_tests;

#[cfg(test)]
mod tests {
    use super::*;
    use crate::entities::sampling::ReasoningEffort;
    use serde_json::json;

    #[test]
    fn schema_constants_match_config_default() {
        // Invariant: the default of `AppConfig.schema_version` and settings' current stay in sync.
        assert_eq!(SETTINGS_SCHEMA, crate::shared::config::SCHEMA_VERSION);
    }

    #[test]
    fn detect_uses_field_or_defaults_to_one() {
        assert_eq!(detect_settings(&json!({"schema_version": 3})), 3);
        assert_eq!(detect_settings(&json!({})), 1);
        assert_eq!(detect_profiles(&json!([])), 1); // a bare array = v1
        assert_eq!(detect_profiles(&json!({"schema_version": 2})), 2);
        assert_eq!(detect_chat(&json!({"v": 5})), 5);
        assert_eq!(detect_chat(&json!({"title": "x"})), 1);
    }

    // A synthetic "current = 2" artifact with a 1→2 step — checks the engine separately
    // from the real registry (where all schemas = 1 and there are no steps).
    fn to_v2(mut v: Value) -> Result<Value> {
        v["schema_version"] = json!(2);
        Ok(v)
    }
    const SYNTH_STEPS: &[Step] = &[Step {
        to: 2,
        summary: "test bump",
        apply: to_v2,
    }];
    fn synth() -> JsonArtifact {
        JsonArtifact {
            name: "synthetic",
            current: 2,
            detect: detect_settings,
            steps: SYNTH_STEPS,
        }
    }

    #[test]
    fn assess_classifies_up_to_date_migrate_downgrade() {
        let a = synth();
        assert_eq!(
            a.assess(&json!({"schema_version": 2})),
            Assessment::UpToDate
        );
        assert_eq!(
            a.assess(&json!({"schema_version": 1})),
            Assessment::Migrate { from: 1 }
        );
        assert_eq!(a.assess(&json!({})), Assessment::Migrate { from: 1 });
        assert_eq!(
            a.assess(&json!({"schema_version": 3})),
            Assessment::Downgrade { from: 3 }
        );
    }

    #[test]
    fn apply_steps_runs_only_needed_steps() {
        let a = synth();
        let out = a
            .apply_steps(json!({"schema_version": 1, "x": 7}), 1)
            .unwrap();
        assert_eq!(out["schema_version"], json!(2));
        assert_eq!(out["x"], json!(7), "other fields are preserved");
        // from == current → no steps run (the value stays unchanged).
        let noop = a.apply_steps(json!({"schema_version": 2}), 2).unwrap();
        assert_eq!(noop["schema_version"], json!(2));
    }

    #[test]
    fn apply_step_error_propagates() {
        fn boom(_v: Value) -> Result<Value> {
            anyhow::bail!("broken")
        }
        const STEPS: &[Step] = &[Step {
            to: 2,
            summary: "boom",
            apply: boom,
        }];
        let a = JsonArtifact {
            name: "x",
            current: 2,
            detect: detect_settings,
            steps: STEPS,
        };
        assert!(a.apply_steps(json!({}), 1).is_err());
    }

    #[test]
    fn real_registry_versions_and_steps() {
        // Settings took five real steps (the sub-agent's timeout, the reply
        // budget that has to cover reasoning, the version that lets a mode
        // name the OpenRouter gateway, the one that lets an effort read
        // `max`, the self-model cap left at 0.9.4's default, then the version
        // that lets a mode name DeepSeek's API); chats took five (the sub-agent
        // transcripts, the dialogue-run stamp, spec §9.13, the cut run titles,
        // spec §11.2, the same `max` stamp, then the same `deepseek` stamp);
        // profiles are still dormant.
        let settings = settings_artifact();
        assert_eq!(settings.current, 7);
        let reached: Vec<u32> = settings.steps.iter().map(|s| s.to).collect();
        assert_eq!(reached, [2, 3, 4, 5, 6, 7]);
        let chats = chat_artifact();
        assert_eq!(chats.current, 6);
        let reached: Vec<u32> = chats.steps.iter().map(|s| s.to).collect();
        assert_eq!(reached, [2, 3, 4, 5, 6]);
        let profiles = profiles_artifact();
        assert_eq!(profiles.current, 1);
        assert!(profiles.steps.is_empty());
    }

    /// The golden v1 shape: the two sub-agent knobs under the names and
    /// defaults `settings.json` carried before the step, beside fields the step
    /// must leave alone.
    fn v1_settings(timeout: u64, max_tokens: u64) -> Value {
        json!({
            "schema_version": 1,
            "max_tool_rounds": 8,
            "tools": {
                "web_enabled": true,
                "subagent_max_tokens": max_tokens,
                "subagent_timeout_secs": timeout,
                "confirm_dangerous": false
            }
        })
    }

    #[test]
    fn settings_step_drops_old_defaults_so_the_new_ones_apply() {
        let out = settings_artifact()
            .apply_steps(v1_settings(60, 1024), 1)
            .unwrap();
        // The chain runs to its end, wherever that has moved to since.
        assert_eq!(out["schema_version"], json!(SETTINGS_SCHEMA));
        let tools = out["tools"].as_object().unwrap();
        assert!(!tools.contains_key("subagent_timeout_secs"));
        assert!(!tools.contains_key("subagent_run_timeout_secs"));
        assert!(!tools.contains_key("subagent_max_tokens"));
        // Untouched neighbours.
        assert_eq!(tools["web_enabled"], json!(true));
        assert_eq!(tools["confirm_dangerous"], json!(false));
        assert_eq!(out["max_tool_rounds"], json!(8));
        // And the migrated value parses into today's config with today's defaults.
        let cfg: crate::shared::config::AppConfig = serde_json::from_value(out).unwrap();
        assert_eq!(
            cfg.tools.subagent_run_timeout_secs,
            crate::shared::config::DEFAULT_SUBAGENT_RUN_TIMEOUT_SECS
        );
        assert_eq!(
            cfg.tools.subagent_max_tokens,
            crate::shared::config::DEFAULT_SUBAGENT_MAX_TOKENS
        );
    }

    #[test]
    fn settings_step_carries_a_changed_value_under_the_new_name() {
        let out = settings_artifact()
            .apply_steps(v1_settings(120, 2048), 1)
            .unwrap();
        let tools = out["tools"].as_object().unwrap();
        assert!(!tools.contains_key("subagent_timeout_secs"));
        assert_eq!(tools["subagent_run_timeout_secs"], json!(120));
        assert_eq!(tools["subagent_max_tokens"], json!(2048));
        let cfg: crate::shared::config::AppConfig = serde_json::from_value(out).unwrap();
        assert_eq!(cfg.tools.subagent_run_timeout_secs, 120);
        assert_eq!(cfg.tools.subagent_max_tokens, 2048);
    }

    #[test]
    fn settings_step_tolerates_a_file_without_the_tools_section() {
        let out = settings_artifact()
            .apply_steps(json!({"schema_version": 1}), 1)
            .unwrap();
        assert_eq!(out["schema_version"], json!(SETTINGS_SCHEMA));
    }

    /// 2→3: a reply budget left at the old default is dropped so the new one
    /// applies, and one the user typed is kept — a number somebody chose is a
    /// decision (docs/research/robustness-and-defaults.md F5).
    #[test]
    fn settings_step_lifts_the_untouched_reply_budget_and_keeps_a_chosen_one() {
        let at_the_old_default = settings_artifact()
            .apply_steps(
                json!({"schema_version": 2, "default_sampling": {"max_tokens": 2048, "thinking": true}}),
                2,
            )
            .unwrap();
        assert_eq!(at_the_old_default["schema_version"], json!(SETTINGS_SCHEMA));
        let sampling = at_the_old_default["default_sampling"].as_object().unwrap();
        assert_eq!(sampling["max_tokens"], json!(16384), "{sampling:?}");
        // The neighbour a user may well have turned off stays as it was.
        assert_eq!(sampling["thinking"], json!(true));
        let cfg: crate::shared::config::AppConfig =
            serde_json::from_value(at_the_old_default).unwrap();
        assert_eq!(
            cfg.default_sampling.max_tokens,
            crate::shared::config::AppConfig::default()
                .default_sampling
                .max_tokens
        );

        for chosen in [512u64, 4096, 65536] {
            let out = settings_artifact()
                .apply_steps(
                    json!({"schema_version": 2, "default_sampling": {"max_tokens": chosen}}),
                    2,
                )
                .unwrap();
            assert_eq!(
                out["default_sampling"]["max_tokens"],
                json!(chosen),
                "a typed {chosen} must survive"
            );
        }

        // A file that never wrote the section at all migrates without inventing one.
        let bare = settings_artifact()
            .apply_steps(json!({"schema_version": 2}), 2)
            .unwrap();
        assert_eq!(bare["schema_version"], json!(SETTINGS_SCHEMA));
        assert!(bare.get("default_sampling").is_none());
    }
    /// `settings.json` as 0.12.0 wrote it: the golden v3 shape, with a gateway
    /// typed into `external` the way install.md described it.
    const V3_SETTINGS: &str = include_str!("fixtures/settings_v3.json");

    /// 3→4 moves nothing. What it changes is what an **older** binary says about
    /// the file, and that is the version alone.
    #[test]
    fn settings_step_to_v4_stamps_the_version_and_touches_nothing_else() {
        let before: Value = serde_json::from_str(V3_SETTINGS).unwrap();
        let after = settings_to_v4(before.clone()).unwrap();
        assert_eq!(after["schema_version"], json!(4));
        let mut restamped = after.clone();
        restamped["schema_version"] = json!(3);
        assert_eq!(restamped, before, "every other value is where it was");

        // And it is what today's config reads: `external` still holds the
        // gateway the user typed — nothing is moved into the new mode for them
        // (docs/research/openrouter-mode.md, fork F12).
        let cfg: crate::shared::config::AppConfig = serde_json::from_value(after).unwrap();
        assert_eq!(cfg.engine.mode, crate::shared::config::ServerMode::External);
        assert_eq!(
            cfg.engine.external.url.as_deref(),
            Some("https://openrouter.ai/api/v1")
        );
        assert_eq!(cfg.engine.openrouter, Default::default());
        assert!(cfg.openrouter.attribution, "on unless switched off");
    }

    /// The reason for the bump, from the other side: a file the new mode was
    /// written into is one the previous schema's reader refuses **by version**,
    /// before it meets the value it cannot parse.
    #[test]
    fn a_file_naming_the_gateway_is_a_newer_version_to_the_previous_schema() {
        let mut cfg = crate::shared::config::AppConfig::default();
        cfg.engine.mode = crate::shared::config::ServerMode::OpenRouter;
        cfg.impersonation_engine.mode = crate::shared::config::ImpersonationMode::OpenRouter;
        let written = serde_json::to_value(&cfg).unwrap();
        assert_eq!(written["engine"]["mode"], json!("openrouter"));
        assert_eq!(written["impersonation_engine"]["mode"], json!("openrouter"));
        assert_eq!(written["schema_version"], json!(SETTINGS_SCHEMA));

        // The reader 0.12.0 ships: the same artifact, one step shorter.
        let previous = JsonArtifact {
            name: "settings.json",
            current: 3,
            detect: detect_settings,
            steps: &SETTINGS_STEPS[..2],
        };
        assert_eq!(
            previous.assess(&written),
            Assessment::Downgrade {
                from: SETTINGS_SCHEMA
            },
            "refused as newer, not parsed into an unknown variant"
        );
        // Today's reader takes it as it is.
        assert_eq!(settings_artifact().assess(&written), Assessment::UpToDate);
        let back: crate::shared::config::AppConfig = serde_json::from_value(written).unwrap();
        assert_eq!(back, cfg);
    }

    /// `settings.json` as 0.13.0 wrote it: the golden v4 shape — the slots on
    /// the gateway, and the effort at `xhigh`, the top of the scale that
    /// version knew. The 0.13.0 binary starts on it (the journal has the run).
    const V4_SETTINGS: &str = include_str!("fixtures/settings_v4.json");

    /// A golden file proves something only while every key in it is one the
    /// reader takes: a misspelt key is ignored, not refused, and the fixture
    /// would then pin nothing about the field it meant.
    #[test]
    fn the_v4_settings_fixture_holds_nothing_the_config_drops() {
        let fixture: Value = serde_json::from_str(V4_SETTINGS).unwrap();
        let cfg: crate::shared::config::AppConfig =
            serde_json::from_value(fixture.clone()).unwrap();
        let read_back = serde_json::to_value(&cfg).unwrap();
        assert_eq!(dropped_paths(&fixture, &read_back), Vec::<String>::new());
    }

    /// 4→5 moves nothing, like 3→4: what changes is what 0.13.0 says about
    /// the file.
    #[test]
    fn settings_step_to_v5_stamps_the_version_and_touches_nothing_else() {
        let before: Value = serde_json::from_str(V4_SETTINGS).unwrap();
        let after = settings_to_v5(before.clone()).unwrap();
        assert_eq!(after["schema_version"], json!(5));
        let mut restamped = after.clone();
        restamped["schema_version"] = json!(4);
        assert_eq!(restamped, before, "every other value is where it was");

        // Nobody is moved to the new value: the effort stays what was chosen.
        let cfg: crate::shared::config::AppConfig = serde_json::from_value(after).unwrap();
        assert_eq!(
            cfg.default_sampling.reasoning_effort,
            Some(ReasoningEffort::XHigh)
        );
        assert_eq!(
            cfg.impersonation_sampling.reasoning_effort,
            Some(ReasoningEffort::Minimal)
        );
    }

    /// The reader a version at schema `current` ships: today's artifact, as
    /// many steps shorter as it is older.
    fn reader_at(art: JsonArtifact, current: u32) -> JsonArtifact {
        let known = art.steps.iter().filter(|s| s.to <= current).count();
        JsonArtifact {
            current,
            steps: &art.steps[..known],
            ..art
        }
    }

    /// The reason for both stamps, from the other side: a file holding a value
    /// of the scale is, to every reader older than the value, a **newer
    /// version** — refused before it is parsed, where the parse would have
    /// refused the settings as an unknown variant and skipped the chat. The
    /// values and their schemas are the ledger's ([`stored_enums_tests`]), whose
    /// `match` has no wildcard arm.
    #[test]
    fn a_file_holding_an_effort_is_a_newer_version_to_a_reader_older_than_it() {
        for (effort, since) in super::stored_enums_tests::efforts() {
            // An effort lives in both kinds of file, so it names a schema
            // both have reached.
            assert!(since <= SETTINGS_SCHEMA.min(CHAT_SCHEMA), "{effort:?}");

            let mut cfg = crate::shared::config::AppConfig::default();
            cfg.default_sampling.reasoning_effort = Some(effort);
            let settings = serde_json::to_value(&cfg).unwrap();
            let profile = crate::entities::profile::Profile::new("A", "s");
            let mut chat = crate::entities::chat::Chat::from_profile(&profile, "t");
            chat.sampling_override = Some(crate::entities::sampling::SamplingConfig {
                reasoning_effort: Some(effort),
                ..Default::default()
            });
            let chat = serde_json::to_value(&chat).unwrap();
            assert_eq!(settings_artifact().assess(&settings), Assessment::UpToDate);
            assert_eq!(chat_artifact().assess(&chat), Assessment::UpToDate);

            for older in 1..since {
                assert_eq!(
                    reader_at(settings_artifact(), older).assess(&settings),
                    Assessment::Downgrade {
                        from: SETTINGS_SCHEMA
                    },
                    "{effort:?}: settings.json read at schema {older}"
                );
                assert_eq!(
                    reader_at(chat_artifact(), older).assess(&chat),
                    Assessment::Downgrade { from: CHAT_SCHEMA },
                    "{effort:?}: a chat file read at schema {older}"
                );
            }
        }
    }

    /// `settings.json` as 0.19.0 wrote it for an installation begun on
    /// 0.9.0–0.9.4 that never touched the self-model's cap: the golden v5
    /// shape, the cap at that version's default of 1200, beside an effort at
    /// `max`, which only schema 5 may hold.
    const V5_SETTINGS: &str = include_str!("fixtures/settings_v5.json");

    #[test]
    fn the_v5_settings_fixture_holds_nothing_the_config_drops() {
        let fixture: Value = serde_json::from_str(V5_SETTINGS).unwrap();
        let cfg: crate::shared::config::AppConfig =
            serde_json::from_value(fixture.clone()).unwrap();
        let read_back = serde_json::to_value(&cfg).unwrap();
        assert_eq!(dropped_paths(&fixture, &read_back), Vec::<String>::new());
    }

    /// 5→6: the old default becomes 4000, and nothing else in the file moves
    /// (docs/history/self-model-observations.md §3.4, §6 B′).
    #[test]
    fn settings_step_to_v6_raises_a_cap_left_at_the_old_default() {
        let before: Value = serde_json::from_str(V5_SETTINGS).unwrap();
        assert_eq!(before["self_model"]["prompt_cap"], json!(1200));
        let after = settings_to_v6(before.clone()).unwrap();
        assert_eq!(after["schema_version"], json!(6));
        assert_eq!(after["self_model"]["prompt_cap"], json!(4000));
        let mut restored = after.clone();
        restored["schema_version"] = json!(5);
        restored["self_model"]["prompt_cap"] = json!(1200);
        assert_eq!(restored, before, "every other value is where it was");

        // What today's config reads: the new cap, its neighbours as they were.
        let cfg: crate::shared::config::AppConfig = serde_json::from_value(after).unwrap();
        assert_eq!(cfg.self_model.prompt_cap, 4000);
        assert_eq!(cfg.self_model.narrative_in_prompt, 3);
        assert_eq!(cfg.self_model.max_narrative, 50);
        assert_eq!(
            cfg.default_sampling.reasoning_effort,
            Some(ReasoningEffort::Max)
        );
    }

    /// Any other stored cap is a choice, and stays — a small one above all, on
    /// a small local window. The step's one exception is the old default.
    #[test]
    fn settings_step_to_v6_keeps_any_other_cap() {
        for chosen in [100usize, 800, 1199, 1201, 1500, 4000, 12000] {
            let out = settings_artifact()
                .apply_steps(
                    json!({"schema_version": 5, "self_model": {"prompt_cap": chosen}}),
                    5,
                )
                .unwrap();
            assert_eq!(out["schema_version"], json!(SETTINGS_SCHEMA));
            assert_eq!(
                out["self_model"]["prompt_cap"],
                json!(chosen),
                "a stored {chosen} must survive"
            );
            let cfg: crate::shared::config::AppConfig = serde_json::from_value(out).unwrap();
            assert_eq!(cfg.self_model.prompt_cap, chosen);
        }
    }

    /// A file that never wrote the cap, or its section, gets nothing invented:
    /// it reads as today's default, as it did before the step.
    #[test]
    fn settings_step_to_v6_leaves_a_file_without_the_cap_alone() {
        for before in [
            json!({"schema_version": 5}),
            json!({"schema_version": 5, "self_model": {"narrative_in_prompt": 5}}),
        ] {
            let after = settings_artifact().apply_steps(before.clone(), 5).unwrap();
            let mut restamped = after.clone();
            restamped["schema_version"] = json!(5);
            assert_eq!(restamped, before);
            let cfg: crate::shared::config::AppConfig = serde_json::from_value(after).unwrap();
            assert_eq!(
                cfg.self_model.prompt_cap,
                crate::shared::config::DEFAULT_SELF_MODEL_PROMPT_CAP
            );
        }
    }

    /// The installation the step is for, arriving straight from 0.9.4, at
    /// schema 1, with no release run in between: the whole chain runs, and the
    /// cap ends at 4000.
    #[test]
    fn a_settings_file_from_0_9_4_reaches_the_raised_cap_through_the_whole_chain() {
        let out = settings_artifact()
            .apply_steps(
                json!({"schema_version": 1, "self_model": {"prompt_cap": 1200}}),
                1,
            )
            .unwrap();
        assert_eq!(out["schema_version"], json!(SETTINGS_SCHEMA));
        assert_eq!(out["self_model"]["prompt_cap"], json!(4000));
    }

    /// The other side of the bump: a file the step wrote is, to the reader
    /// 0.19.0 ships (schema 5), a newer version's data, refused before a
    /// value of it is parsed.
    #[test]
    fn a_file_past_the_cap_step_is_a_newer_version_to_the_previous_schema() {
        let migrated = settings_artifact()
            .apply_steps(serde_json::from_str(V5_SETTINGS).unwrap(), 5)
            .unwrap();
        assert_eq!(
            reader_at(settings_artifact(), 5).assess(&migrated),
            Assessment::Downgrade {
                from: SETTINGS_SCHEMA
            }
        );
        assert_eq!(settings_artifact().assess(&migrated), Assessment::UpToDate);
    }

    /// `settings.json` as 0.20.0 wrote it for the setup its manual names:
    /// DeepSeek's API in the `external` mode, a key in a variable, and a `grok`
    /// section beside it. The golden v6 shape.
    const V6_SETTINGS: &str = include_str!("fixtures/settings_v6.json");

    #[test]
    fn the_v6_settings_fixture_holds_nothing_the_config_drops() {
        let fixture: Value = serde_json::from_str(V6_SETTINGS).unwrap();
        let cfg: crate::shared::config::AppConfig =
            serde_json::from_value(fixture.clone()).unwrap();
        let read_back = serde_json::to_value(&cfg).unwrap();
        assert_eq!(dropped_paths(&fixture, &read_back), Vec::<String>::new());
    }

    /// 6→7 moves nothing, like 3→4: nobody is moved to the new mode, and the
    /// `external` slot at DeepSeek's URL stays where it was.
    #[test]
    fn settings_step_to_v7_stamps_the_version_and_touches_nothing_else() {
        let before: Value = serde_json::from_str(V6_SETTINGS).unwrap();
        let after = settings_to_v7(before.clone()).unwrap();
        assert_eq!(after["schema_version"], json!(7));
        let mut restamped = after.clone();
        restamped["schema_version"] = json!(6);
        assert_eq!(restamped, before, "every other value is where it was");

        let cfg: crate::shared::config::AppConfig = serde_json::from_value(after).unwrap();
        assert_eq!(cfg.engine.mode, crate::shared::config::ServerMode::External);
        assert_eq!(
            cfg.engine.external.url.as_deref(),
            Some("https://api.deepseek.com/v1")
        );
        assert_eq!(
            cfg.engine.deepseek,
            crate::shared::config::CloudSettings::default(),
            "a section 0.20.0 never wrote reads as the default"
        );
    }

    /// The reason for both stamps, from the other side: a file naming the
    /// `deepseek` mode is, to every reader older than it, a **newer version** —
    /// refused before it is parsed, where the parse would have refused the
    /// settings as an unknown variant and skipped the chat.
    #[test]
    fn a_file_holding_the_deepseek_mode_is_a_newer_version_to_the_previous_schema() {
        use crate::shared::config::{AppConfig, ImpersonationMode, ServerMode};
        let mut cfg = AppConfig::default();
        cfg.engine.mode = ServerMode::DeepSeek;
        cfg.impersonation_engine.mode = ImpersonationMode::DeepSeek;
        let settings = serde_json::to_value(&cfg).unwrap();
        assert_eq!(settings["engine"]["mode"], json!("deepseek"));
        assert_eq!(settings_artifact().assess(&settings), Assessment::UpToDate);
        assert_eq!(
            reader_at(settings_artifact(), 6).assess(&settings),
            Assessment::Downgrade {
                from: SETTINGS_SCHEMA
            },
            "settings.json read by 0.20.0"
        );

        let fixture: Value =
            serde_json::from_str(include_str!("fixtures/chat_v5_external_deepseek.json")).unwrap();
        let mut chat: crate::entities::chat::Chat = serde_json::from_value(fixture).unwrap();
        chat.v = CHAT_SCHEMA;
        chat.messages[1]
            .metadata
            .as_mut()
            .expect("the reply's metadata")
            .mode = ServerMode::DeepSeek;
        let chat = serde_json::to_value(&chat).unwrap();
        assert_eq!(chat["messages"][1]["metadata"]["mode"], json!("deepseek"));
        assert_eq!(chat_artifact().assess(&chat), Assessment::UpToDate);
        assert_eq!(
            reader_at(chat_artifact(), 5).assess(&chat),
            Assessment::Downgrade { from: CHAT_SCHEMA },
            "a chat file read by 0.20.0"
        );
    }
}