trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
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
//! Tests for session-store corruption detection, disclosure, and repair
//! (#5007).
//!
//! Why: the 2026-08-06 incident was a complete JSON document followed by 1111
//! stale bytes from a longer earlier version of the same document. Every test
//! that claims to cover that failure is built on a fixture with exactly that
//! shape — [`corrupt_with_stale_tail`] — rather than on generic garbage like
//! `{ not json ]`, which any parse failure would produce and which therefore
//! proves nothing about this defect.
//! What: detection (`load_*`), the writer race (`two_stores_*`), disclosure
//! (`store_records_*`, `log_reload_fallback_*`), and repair (`repair_*`,
//! `plan_*`), plus unit coverage of every helper in `store_integrity.rs`.
//! Test: this file IS the test module; run with
//! `cargo test -p trusty-mpm store_integrity`.

use std::path::PathBuf;

use chrono::{TimeZone, Utc};
use tempfile::TempDir;

use super::*;
use crate::session_manager::record::{ManagedSessionId, ManagedSessionState, SessionRecord};
use crate::session_manager::store::{SessionStore, StoreError};

/// A record with a caller-chosen task string, so two serializations of the same
/// store can differ in LENGTH without differing in which sessions they hold.
///
/// Why: the incident's discarded bytes named only a session already present in
/// the recovered document. Reproducing that needs a shorter document over a
/// longer one whose SESSION SET is identical — a fixture that dropped a session
/// would exercise the orphan-refusal path instead, which is a different test.
/// What: a minimal `SessionRecord` whose `task` is `task`.
/// Test: used by every fixture in this file.
fn record(id: ManagedSessionId, task: &str) -> SessionRecord {
    SessionRecord {
        id,
        tmux_name: format!("tm-5007-{id}"),
        cwd: PathBuf::from("/tmp"),
        task: task.to_string(),
        state: ManagedSessionState::Active,
        created_at: Utc::now(),
        last_activity_at: None,
        workspace_path: None,
        repo_url: None,
        branch: None,
        pending_decision: None,
        proposed_default: None,
        correlation: Default::default(),
        runtime: Default::default(),
        ephemeral: false,
        workspace_owned: false,
        source_id: None,
        claude_session_id: None,
        scrollback_path: None,
        last_cwd: None,
        deliverable_id: None,
        pane_id: None,
        tmux_server: None,
        injection_status: Default::default(),
        worktree_owner: None,
        terminal_at: None,
        stop_cause: None,
        kind: Default::default(),
    }
}

/// The exact 2026-08-06 corruption shape, built from real store writes.
///
/// Why: a fixture assembled by hand could be wrong about what the writer
/// actually produces. This one serializes the SAME two sessions twice through
/// the production `SessionStore::save`, once with a long task and once with a
/// short one, then lays the short document over the long one — leaving a
/// complete document followed by the longer document's tail, which is precisely
/// what the incident file contained.
/// What: corrupts `<dir>/sessions.json` in place and returns
/// `(valid_prefix_len, trailing_len, session_ids)`.
/// Test: consumed by `load_reports_a_trailing_tail_as_corruption_with_a_byte_offset`
/// and the repair tests.
async fn corrupt_with_stale_tail(dir: &TempDir) -> (usize, usize, Vec<ManagedSessionId>) {
    let ids = vec![ManagedSessionId::new(), ManagedSessionId::new()];
    let long_task = "x".repeat(400);

    let mut store = SessionStore::load(dir.path()).await.expect("load");
    store
        .upsert_many(ids.iter().map(|id| record(*id, &long_task)).collect())
        .await
        .expect("seed long");
    let path = dir.path().join("sessions.json");
    let long = std::fs::read_to_string(&path).expect("read long");

    // The SAME two sessions, serialized shorter.
    let mut store = SessionStore::load(dir.path()).await.expect("reload");
    store
        .upsert_many(ids.iter().map(|id| record(*id, "short")).collect())
        .await
        .expect("seed short");
    let short = std::fs::read_to_string(&path).expect("read short");

    assert!(
        long.len() > short.len(),
        "fixture invariant: the earlier document must be the longer one"
    );
    let tail = &long[short.len()..];
    std::fs::write(&path, format!("{short}{tail}")).expect("write corrupt");
    (short.len(), tail.len(), ids)
}

/// Why: this is the defect. A store file that is a complete document followed
/// by a stale tail must be reported as CORRUPTION, naming the file and the byte
/// offset where the good data ends — not as the bare
/// `trailing characters at line 3755 column 2` that told the operator nothing.
/// What: builds the incident's exact shape, loads it, and asserts the error is
/// `StoreError::Corrupt` carrying the measured prefix and tail lengths, and
/// that its message names the file, the offset, and the repair command.
/// Test: this test.
#[tokio::test]
async fn load_reports_a_trailing_tail_as_corruption_with_a_byte_offset() {
    let dir = TempDir::new().expect("tempdir");
    let (valid, trailing, _) = corrupt_with_stale_tail(&dir).await;

    let err = SessionStore::load(dir.path())
        .await
        .expect_err("a store with a stale tail must not load");
    let StoreError::Corrupt(integrity) = &err else {
        panic!("expected StoreError::Corrupt, got {err:?}");
    };
    assert_eq!(
        integrity.valid_prefix_bytes,
        Some(valid),
        "the diagnosis must locate the end of the valid document"
    );
    assert_eq!(
        integrity.trailing_bytes(),
        trailing,
        "the diagnosis must count the stale tail"
    );
    let msg = err.to_string();
    assert!(
        msg.contains("sessions.json"),
        "the message must name the file: {msg}"
    );
    assert!(
        msg.contains(&format!("byte {valid}")),
        "the message must name the byte offset: {msg}"
    );
    assert!(
        msg.contains("tm repair session-store"),
        "the message must name the repair command: {msg}"
    );
}

/// Why: the read paths fall back to cached data on ANY store error. That is
/// right for a transient I/O failure and wrong to do silently for corruption,
/// so the two have to be distinguishable at the type level.
/// What: asserts `is_corruption` is true for a corrupt store and false for an
/// I/O error.
/// Test: this test.
#[tokio::test]
async fn corruption_is_distinguished_from_transient_io() {
    let dir = TempDir::new().expect("tempdir");
    corrupt_with_stale_tail(&dir).await;
    let err = SessionStore::load(dir.path()).await.expect_err("corrupt");
    assert!(err.is_corruption(), "a corrupt store is corruption");

    let io = StoreError::Io(std::io::Error::other("nfs hiccup"));
    assert!(
        !io.is_corruption(),
        "a transient I/O error must NOT be classified as corruption"
    );
}

/// Why: `read_file` used to answer EVERY read failure with an empty store. A
/// permissions error or an EIO therefore produced a store with zero sessions,
/// and the next `save()` would have written that emptiness over the operator's
/// entire fleet. Only a genuinely absent file may mean "fresh store".
/// What: puts a DIRECTORY where `sessions.json` belongs — a read error that is
/// not `NotFound` on every platform — and asserts `load` fails instead of
/// reporting an empty store.
/// Test: this test.
#[tokio::test]
async fn load_propagates_a_read_error_instead_of_reporting_an_empty_store() {
    let dir = TempDir::new().expect("tempdir");
    std::fs::create_dir(dir.path().join("sessions.json")).expect("mkdir over the store path");

    let err = SessionStore::load(dir.path())
        .await
        .expect_err("an unreadable store must not load as empty");
    assert!(
        !matches!(err, StoreError::NotFound(_)),
        "an unreadable store is not a missing session: {err:?}"
    );
}

/// Why: an absent store still has to mean "no sessions yet", or a fresh machine
/// could never start. This pins that the stricter error handling above did not
/// break the legitimate case.
/// What: loads an empty directory and asserts an empty store.
/// Test: this test.
#[tokio::test]
async fn load_still_treats_an_absent_store_as_empty() {
    let dir = TempDir::new().expect("tempdir");
    let mut store = SessionStore::load(dir.path()).await.expect("load");
    assert!(store.all().await.expect("all").is_empty());
}

/// Why: the store used to record a reload failure nowhere, so the callers that
/// fell back to cached data left no trace that they had. The degradation must
/// be observable, and must clear once a read succeeds — a sticky one would
/// train operators to ignore it.
/// What: seeds a store, corrupts the file, asserts a failed reload records a
/// corrupt degradation naming the file, then restores the file and asserts the
/// next successful reload clears it.
/// Test: this test.
#[tokio::test]
async fn store_records_and_clears_a_reload_failure() {
    let dir = TempDir::new().expect("tempdir");
    let mut store = SessionStore::load(dir.path()).await.expect("load");
    store
        .upsert(record(ManagedSessionId::new(), "task"))
        .await
        .expect("seed");
    assert!(store.degradation().is_none(), "a fresh store is healthy");

    let path = dir.path().join("sessions.json");
    let good = std::fs::read_to_string(&path).expect("read good");
    std::fs::write(&path, format!("{good}{good}")).expect("append a stale tail");
    assert!(store.reload_if_changed().await.is_err(), "reload must fail");
    let degraded = store.degradation().expect("the failure must be recorded");
    assert!(degraded.corrupt, "a parse failure is corruption");
    assert!(
        degraded.message.contains("sessions.json"),
        "the recorded message names the file: {}",
        degraded.message
    );

    std::fs::write(&path, &good).expect("restore");
    store.reload_if_changed().await.expect("reload succeeds");
    assert!(
        store.degradation().is_none(),
        "a successful reload must clear the degradation"
    );
}

/// Why (#5027 review): the severity split is the ONLY reason
/// `log_reload_fallback` exists, and nothing asserted it — swapping its
/// `error!` and `warn!` left the suite green. The old test asserted
/// `is_corruption()`, which `corruption_is_distinguished_from_transient_io`
/// already covers, then called the function for coverage. This one reads the
/// level off the events the function actually emits.
/// What: captures both calls through the crate's existing `LogBufferLayer` and
/// asserts the corrupt case lands at ERROR and the transient one at WARN.
/// `#[serial]` for the same reason `ensure_managed_config_dir_emits_the_frozen_skill_warning`
/// carries it: installing a subscriber perturbs the process-global interest
/// cache.
/// Test: this test.
#[test]
#[serial_test::serial]
fn log_reload_fallback_separates_corruption_from_a_transient_error() {
    use tracing_subscriber::layer::SubscriberExt;

    let corrupt = StoreError::Serialize("trailing characters".into());
    let transient = StoreError::Io(std::io::Error::other("nfs hiccup"));

    let buffer = trusty_common::log_buffer::LogBuffer::new(64);
    let subscriber = tracing_subscriber::registry().with(
        trusty_common::log_buffer::LogBufferLayer::new(buffer.clone()),
    );
    tracing::subscriber::with_default(subscriber, || {
        crate::session_manager::store_health::log_reload_fallback(&corrupt, 3);
        crate::session_manager::store_health::log_reload_fallback(&transient, 3);
    });

    let lines = buffer.tail(64);
    let corrupt_line = lines
        .iter()
        .find(|l| l.contains("CORRUPT"))
        .unwrap_or_else(|| panic!("no line reported the corrupt store: {lines:#?}"));
    assert!(
        corrupt_line.contains("ERROR"),
        "a corrupt store never clears on its own and blocks every write, so it must be \
         ERROR, not a level an operator can scroll past: {corrupt_line}"
    );

    let transient_line = lines
        .iter()
        .find(|l| l.contains("reload failed"))
        .unwrap_or_else(|| panic!("no line reported the transient failure: {lines:#?}"));
    assert!(
        transient_line.contains("WARN"),
        "a possibly-transient read failure must stay WARN, or corruption stops standing \
         out from an NFS hiccup: {transient_line}"
    );
}

/// Why (#5007, the issue's hypothesis): the corruption shape — a shorter
/// document over a longer one with the old tail surviving — is what two writers
/// sharing ONE staging file produce. Each opens `sessions.json.tmp` with
/// truncate and streams its own serialization in; the file that gets renamed
/// into place ends up as long as the longer document and as valid as the
/// shorter one. Removing the shared name removes the mechanism.
/// What: two stores over the SAME directory must stage through different files,
/// and neither may be the store itself or live outside its directory (the
/// rename has to stay on one filesystem to remain atomic).
/// Test: this test.
#[tokio::test]
async fn two_stores_over_one_path_do_not_share_a_staging_file() {
    let dir = TempDir::new().expect("tempdir");
    let a = SessionStore::load(dir.path()).await.expect("load A");
    let b = SessionStore::load(dir.path()).await.expect("load B");
    assert_ne!(
        a.staging_path_for_test(),
        b.staging_path_for_test(),
        "two writers of one store must never share a staging file"
    );
    assert_ne!(
        a.staging_path_for_test(),
        dir.path().join("sessions.json"),
        "the staging file must not be the store itself"
    );
    assert_eq!(
        a.staging_path_for_test().parent(),
        Some(dir.path()),
        "the staging file must be a sibling so the rename stays on one filesystem"
    );
}

/// Why: a repair rewrites the operator's only copy of the fleet's state, so it
/// must back up first and cut at the measured document boundary — not at
/// whatever serde's line/column happens to point at.
/// What: repairs the incident-shaped fixture, then asserts the backup holds the
/// original bytes verbatim, the store parses again, both sessions survive, and
/// the store accepts a write — the property the corruption destroyed.
/// Test: this test.
#[tokio::test]
async fn repair_backs_up_then_truncates_to_the_valid_document() {
    let dir = TempDir::new().expect("tempdir");
    let (valid, trailing, ids) = corrupt_with_stale_tail(&dir).await;
    let path = dir.path().join("sessions.json");
    let before = std::fs::read_to_string(&path).expect("read corrupt");

    let now = Utc
        .with_ymd_and_hms(2026, 8, 6, 9, 1, 17)
        .single()
        .expect("timestamp");
    let outcome = repair(&path, false, now).expect("repair");

    assert_eq!(outcome.kept_bytes, valid);
    assert_eq!(outcome.discarded_bytes, trailing);
    assert_eq!(outcome.sessions, 2);
    assert!(
        outcome.forced_orphans.is_empty(),
        "the incident's tail names no unknown session"
    );
    assert_eq!(
        std::fs::read_to_string(&outcome.backup).expect("read backup"),
        before,
        "the backup must hold the original bytes verbatim"
    );

    let mut store = SessionStore::load(dir.path())
        .await
        .expect("the repaired store must load");
    let all = store.all().await.expect("all");
    assert_eq!(all.len(), 2, "both sessions survive the repair");
    for id in &ids {
        assert!(store.get(id).await.is_ok(), "{id} survived");
    }
    store
        .upsert(record(ManagedSessionId::new(), "post-repair write"))
        .await
        .expect("a repaired store must accept writes");
}

/// Why: truncation discards bytes. If those bytes are the only record of a
/// session, the repair would silently delete it — so the tool must refuse and
/// say which ids are at stake rather than quietly making the file parse.
/// What: appends a tail naming an id absent from the valid document, asserts
/// the refusal names it, and asserts the file was NOT modified.
/// Test: this test.
#[tokio::test]
async fn repair_refuses_an_orphaned_tail_without_force() {
    let dir = TempDir::new().expect("tempdir");
    let mut store = SessionStore::load(dir.path()).await.expect("load");
    store
        .upsert(record(ManagedSessionId::new(), "kept"))
        .await
        .expect("seed");
    let path = dir.path().join("sessions.json");
    let good = std::fs::read_to_string(&path).expect("read good");
    let orphan = ManagedSessionId::new().to_string();
    let corrupt = format!("{good}\n{{\"sessions\":{{\"{orphan}\":null}}}}");
    std::fs::write(&path, &corrupt).expect("write corrupt");

    let err = repair(&path, false, Utc::now()).expect_err("must refuse");
    let RepairError::OrphanedSessions { orphans, .. } = &err else {
        panic!("expected OrphanedSessions, got {err:?}");
    };
    assert!(
        orphans.contains(&orphan),
        "the refusal must name the at-risk id: {orphans:?}"
    );
    assert_eq!(
        std::fs::read_to_string(&path).expect("re-read"),
        corrupt,
        "a refused repair must not modify the file"
    );
}

/// Why: the refusal has to be overridable — an operator who has looked at the
/// backup and decided the tail is junk needs a way through.
/// What: repairs the same orphaned fixture with `force`, and asserts the
/// discarded id is REPORTED rather than dropped in silence.
/// Test: this test.
#[tokio::test]
async fn repair_with_force_discards_an_orphaned_tail() {
    let dir = TempDir::new().expect("tempdir");
    let mut store = SessionStore::load(dir.path()).await.expect("load");
    store
        .upsert(record(ManagedSessionId::new(), "kept"))
        .await
        .expect("seed");
    let path = dir.path().join("sessions.json");
    let good = std::fs::read_to_string(&path).expect("read good");
    let orphan = ManagedSessionId::new().to_string();
    std::fs::write(
        &path,
        format!("{good}\n{{\"sessions\":{{\"{orphan}\":null}}}}"),
    )
    .expect("write corrupt");

    let outcome = repair(&path, true, Utc::now()).expect("forced repair");
    assert!(
        outcome.forced_orphans.contains(&orphan),
        "a forced repair must report what it discarded: {:?}",
        outcome.forced_orphans
    );
    assert!(
        SessionStore::load(dir.path()).await.is_ok(),
        "the forced repair still produces a loadable store"
    );
}

/// Why: re-running the repair after it has worked must not cut a healthy file.
/// What: asserts a parseable store yields `NotCorrupt`.
/// Test: this test.
#[tokio::test]
async fn plan_refuses_a_healthy_store() {
    let dir = TempDir::new().expect("tempdir");
    let mut store = SessionStore::load(dir.path()).await.expect("load");
    store
        .upsert(record(ManagedSessionId::new(), "task"))
        .await
        .expect("seed");
    let err = plan(&dir.path().join("sessions.json")).expect_err("healthy");
    assert!(matches!(err, RepairError::NotCorrupt(_)), "got {err:?}");
}

/// Why: a file whose HEAD is broken cannot be recovered by truncation, and
/// pretending otherwise would leave the operator with an empty store and a
/// backup they were never told to look at.
/// What: asserts a truncated document is refused with `NoValidPrefix`.
/// Test: this test.
#[test]
fn repair_rejects_a_file_with_no_valid_prefix() {
    let dir = TempDir::new().expect("tempdir");
    let path = dir.path().join("sessions.json");
    std::fs::write(&path, r#"{"sessions": {"a": "#).expect("write");
    let err = plan(&path).expect_err("no valid prefix");
    assert!(matches!(err, RepairError::NoValidPrefix { .. }), "{err:?}");
}

/// Why: the whole repair rests on this measurement.
/// What: a whole document measures its own length; trailing junk does not move
/// the boundary.
/// Test: this test.
#[test]
fn valid_prefix_bytes_measures_a_whole_document() {
    let doc = r#"{"sessions":{}}"#;
    assert_eq!(valid_prefix_bytes(doc), Some(doc.len()));
    assert_eq!(
        valid_prefix_bytes(&format!("{doc}garbage")),
        Some(doc.len()),
        "trailing junk must not move the document boundary"
    );
}

/// Why: see [`repair_rejects_a_file_with_no_valid_prefix`] — the `None` case is
/// what routes a hopeless file away from truncation.
/// What: an unterminated document measures nothing.
/// Test: this test.
#[test]
fn valid_prefix_bytes_is_none_for_a_truncated_head() {
    assert_eq!(valid_prefix_bytes(r#"{"sessions": {"#), None);
}

/// Why: the byte counts are what an operator checks the tool's arithmetic
/// against before letting it cut their store.
/// What: the tail length is total minus prefix, and the whole file when nothing
/// parsed.
/// Test: this test.
#[test]
fn trailing_bytes_counts_the_discarded_tail() {
    let base = StoreIntegrity {
        path: PathBuf::from("/x/sessions.json"),
        total_bytes: 146_201,
        valid_prefix_bytes: Some(145_090),
        repairable_by_truncation: true,
        detail: "trailing characters".into(),
        line: 3755,
        column: 2,
    };
    assert_eq!(base.trailing_bytes(), 1111, "the incident's own numbers");
    assert_eq!(
        StoreIntegrity {
            valid_prefix_bytes: None,
            ..base
        }
        .trailing_bytes(),
        146_201,
        "with no valid prefix the whole file is unusable"
    );
}

/// Why: the original message named neither the file nor an offset nor a fix.
/// What: asserts all three appear.
/// Test: this test.
#[test]
fn diagnostic_names_the_file_the_offset_and_the_repair_command() {
    let msg = StoreIntegrity {
        path: PathBuf::from("/x/sessions.json"),
        total_bytes: 146_201,
        valid_prefix_bytes: Some(145_090),
        repairable_by_truncation: true,
        detail: "trailing characters at line 3755 column 2".into(),
        line: 3755,
        column: 2,
    }
    .diagnostic();
    assert!(msg.contains("/x/sessions.json"), "{msg}");
    assert!(msg.contains("byte 145090"), "{msg}");
    assert!(msg.contains("1111 trailing byte(s)"), "{msg}");
    assert!(msg.contains("tm repair session-store"), "{msg}");
}

/// Why (#5027 review): for `{"sessions": 5}` the store said "corrupt, repair
/// with `tm repair session-store`" and the command answered "parses cleanly;
/// nothing to repair" — and the diagnostic described "0 trailing byte(s)",
/// junk that is not there. A message that names a fix which refuses sends the
/// operator down a dead end while the store stays wedged.
/// What: asserts the schema-failure diagnostic describes no phantom tail and
/// advertises no command that would decline.
/// Test: this test.
#[test]
fn diagnostic_does_not_advertise_a_repair_that_would_refuse() {
    let raw = r#"{"sessions": 5}"#;
    let integrity =
        validate(&PathBuf::from("/x/sessions.json"), raw).expect_err("valid JSON, but not a store");
    assert_eq!(
        integrity.trailing_bytes(),
        0,
        "the whole file is one complete document"
    );
    assert!(
        !integrity.repairable_by_truncation,
        "there is nothing for a truncation to cut"
    );

    let msg = integrity.diagnostic();
    assert!(
        !msg.contains("tm repair session-store"),
        "the message must not name a command that refuses this file: {msg}"
    );
    assert!(
        !msg.contains("trailing byte(s)"),
        "and must not describe trailing junk that is not there: {msg}"
    );
    assert!(
        msg.contains("not a session store"),
        "it must say what is actually wrong: {msg}"
    );
}

/// Why (#5027 review): the store, `tm doctor`, and `tm repair session-store`
/// used to validate three different schemas, so a file could be simultaneously
/// "corrupt — run the repair" and "parses cleanly; nothing to repair". This is
/// the disagreement itself, pinned.
/// What: writes valid JSON that is not a loadable store and asserts the store
/// rejects it AND `plan` refuses it as `NotAStore` rather than `NotCorrupt`.
/// Test: this test.
#[tokio::test]
async fn plan_and_the_store_agree_that_valid_json_is_not_a_store() {
    let dir = TempDir::new().expect("tempdir");
    let path = dir.path().join("sessions.json");
    std::fs::write(&path, r#"{"sessions": 5}"#).expect("write");

    assert!(
        SessionStore::load(dir.path()).await.is_err(),
        "the daemon cannot load this file"
    );
    let err = plan(&path).expect_err("neither may the repair call it healthy");
    assert!(
        matches!(err, RepairError::NotAStore { .. }),
        "expected NotAStore, got {err:?}"
    );
    assert_eq!(
        std::fs::read_to_string(&path).expect("re-read"),
        r#"{"sessions": 5}"#,
        "a refused plan must not modify the file"
    );
}

/// Why (#5027 review): truncation is a repair only if what survives LOADS.
/// Cutting a file whose head is valid JSON but not a store leaves it exactly as
/// wedged as it was, minus the evidence.
/// What: a head the daemon cannot load, followed by a stale tail, is refused.
/// Test: this test.
#[test]
fn plan_refuses_to_cut_down_to_a_head_that_is_not_a_store() {
    let dir = TempDir::new().expect("tempdir");
    let path = dir.path().join("sessions.json");
    std::fs::write(&path, r#"{"sessions":{"a":{}}}trailing"#).expect("write");

    let err = plan(&path).expect_err("the head is not a loadable store");
    assert!(
        matches!(err, RepairError::NotAStore { .. }),
        "expected NotAStore, got {err:?}"
    );
}

/// Why: `analyze` is what turns a serde error into the diagnosis; both of its
/// outcomes need pinning.
/// What: a trailing tail yields a measured prefix.
/// Test: this test.
#[test]
fn analyze_locates_the_valid_prefix_of_a_trailing_tail() {
    let raw = r#"{"sessions":{}}trailing"#;
    let err = serde_json::from_str::<serde_json::Value>(raw).expect_err("must fail");
    let got = analyze(&PathBuf::from("/x/sessions.json"), raw, &err);
    assert_eq!(got.valid_prefix_bytes, Some(15));
    assert_eq!(got.total_bytes, raw.len());
    assert_eq!(got.trailing_bytes(), raw.len() - 15);
}

/// Why: see above.
/// What: a truncated head has no recoverable prefix.
/// Test: this test.
#[test]
fn analyze_reports_no_valid_prefix_for_a_broken_head() {
    let raw = r#"{"sessions": {"#;
    let err = serde_json::from_str::<serde_json::Value>(raw).expect_err("must fail");
    let got = analyze(&PathBuf::from("/x/sessions.json"), raw, &err);
    assert_eq!(got.valid_prefix_bytes, None);
}

/// Why: a second repair must not overwrite the first repair's evidence, and the
/// backup has to be findable next to the file it came from.
/// What: asserts the name shape used by the 2026-08-06 manual backup.
/// Test: this test.
#[test]
fn backup_path_is_a_timestamped_sibling() {
    let now = Utc
        .with_ymd_and_hms(2026, 8, 6, 9, 1, 17)
        .single()
        .expect("timestamp");
    let got = backup_path(&PathBuf::from("/x/session-manager/sessions.json"), now);
    assert_eq!(
        got,
        PathBuf::from("/x/session-manager/sessions.json.corrupt-backup-20260806-090117")
    );
}

/// Why: the orphan check must read ids from the DESERIALIZED document, so a
/// UUID mentioned inside a task description is not mistaken for a surviving
/// session.
/// What: a store whose only session's task text names a second uuid — only the
/// map key counts.
/// Test: this test.
#[test]
fn kept_ids_come_from_the_map_keys_not_the_document_text() {
    let key = "aaaaaaaa-1111-2222-3333-444444444444";
    let mentioned = "bbbbbbbb-1111-2222-3333-444444444444";
    let raw = loadable_store_json(&[(key, &format!("see {mentioned}"))]);
    let data = validate(&PathBuf::from("/x/sessions.json"), &raw).expect("a loadable store");

    let ids = kept_ids(&data);
    assert!(ids.contains(key), "{ids:?}");
    assert!(
        !ids.contains(mentioned),
        "a uuid inside a task string is not a session key: {ids:?}"
    );
}

/// Why (#5027 review): the cut lands at an arbitrary offset, so a session id
/// can STRADDLE it. Scanning only the discarded tail sees the id's second half,
/// which matches no UUID shape, so the refusal does not fire and the repair
/// drops a session the recovered document never had.
/// What: puts an id across the boundary and asserts the scan still names it.
/// Before the widened window this returned an empty list.
/// Test: this test.
#[test]
fn orphan_scan_sees_an_id_that_straddles_the_cut() {
    let id = "aaaaaaaa-2222-3333-4444-555555555555";
    let raw = format!("HEAD{id}TAIL");
    // Cut inside the id, right after `aaaaaaaa-2222-3333-4444-`.
    let valid = "HEAD".len() + 24;
    assert!(
        raw[valid..].starts_with("555555555555"),
        "fixture invariant: the cut must split the id"
    );

    let got = orphaned_ids(&raw, valid, &BTreeSet::new());
    assert!(
        got.contains(&id.to_string()),
        "an id spanning the cut must still be reported: {got:?}"
    );
}

/// Why: the discarded tail is a fragment that cannot be parsed, so the only way
/// to ask "does it name a session?" is to scan it.
/// What: finds canonical UUIDs in a JSON fragment.
/// Test: this test.
#[test]
fn uuid_tokens_finds_ids_in_a_json_fragment() {
    let frag = r#""aaaaAAAA-1111-2222-3333-444444444444": {"state": "active"#;
    let found = uuid_tokens(frag);
    assert!(
        found.contains("aaaaaaaa-1111-2222-3333-444444444444"),
        "{found:?}"
    );
}

/// Why: over-reporting only makes the repair refuse more often; under-reporting
/// would let it delete a session. This pins that plain text yields nothing.
/// What: prose and a non-hex lookalike both yield no ids.
/// Test: this test.
#[test]
fn uuid_tokens_ignores_non_uuid_text() {
    assert!(uuid_tokens("no identifiers here, just prose and 1234-5678").is_empty());
    assert!(
        uuid_tokens("zzzzzzzz-1111-2222-3333-444444444444").is_empty(),
        "non-hex characters are not a uuid"
    );
}