mindfork 0.16.1

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
//! 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`] and
//! [`settings_to_v5`].

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 = 5;
/// 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 = 5;
/// 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)
}

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,
    },
];

/// The registry of artifacts. `settings.json` is at 5 (four steps), the chat files
/// at 5 (four); `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,
    },
];

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 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 four 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, then the one that lets an effort read
        // `max`); chats took four (the sub-agent transcripts, the dialogue-run
        // stamp, spec §9.13, the cut run titles, spec §11.2, then the same
        // `max` stamp); profiles are still dormant.
        let settings = settings_artifact();
        assert_eq!(settings.current, 5);
        let reached: Vec<u32> = settings.steps.iter().map(|s| s.to).collect();
        assert_eq!(reached, [2, 3, 4, 5]);
        let chats = chat_artifact();
        assert_eq!(chats.current, 5);
        let reached: Vec<u32> = chats.steps.iter().map(|s| s.to).collect();
        assert_eq!(reached, [2, 3, 4, 5]);
        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_artifact().apply_steps(before.clone(), 4).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 schema of `settings.json` and of a chat file — one number so far —
    /// in which a value of the effort scale became one a file may hold.
    ///
    /// **No wildcard arm, on purpose.** The effort is a strict enum stored in
    /// three kinds of file, so a new variant is a breaking change of all of
    /// them (the module's bump policy), and nothing else in the build says so:
    /// `max` was added with every test green and no step, and 0.13.0 dropped
    /// the chats that held it from its list. A variant added to
    /// [`ReasoningEffort`] stops this function compiling until its author
    /// names the schema it arrives in — and writes that schema's steps.
    fn effort_arrived_in(effort: ReasoningEffort) -> u32 {
        match effort {
            ReasoningEffort::None
            | ReasoningEffort::Minimal
            | ReasoningEffort::Low
            | ReasoningEffort::Medium
            | ReasoningEffort::High
            | ReasoningEffort::XHigh => 1,
            ReasoningEffort::Max => 5,
        }
    }

    /// 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.
    #[test]
    fn a_file_holding_an_effort_is_a_newer_version_to_a_reader_older_than_it() {
        for effort in [
            ReasoningEffort::None,
            ReasoningEffort::Minimal,
            ReasoningEffort::Low,
            ReasoningEffort::Medium,
            ReasoningEffort::High,
            ReasoningEffort::XHigh,
            ReasoningEffort::Max,
        ] {
            let since = effort_arrived_in(effort);
            assert!(
                since <= SETTINGS_SCHEMA && since <= 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}"
                );
            }
        }
    }
}