doctrine 0.12.0

Project tooling CLI
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
// SPDX-License-Identifier: GPL-3.0-only
//! `doctrine reconcile` — the reconcile writer (SL-044 B·P2, design §5.3/§5.4).
//!
//! The **sole author** of reconciled requirement status. One requirement per
//! invocation (loopable); per the chosen `--move` it applies EXACTLY ONE move and
//! emits EXACTLY ONE atomic REC (D-B8, SL-042 D-Q3 immutability — never a `rec new`
//! skeleton + append).
//!
//! - **accept / revise** → set the requirement's authored status to the operator's
//!   EXPLICIT `--to` (the B·P1 setter [`crate::requirement::set_status`], D-B4), and
//!   write a populated REC: one `[[status_delta]]` (`from`=prior authored,
//!   `to`=`--to`) and the backing coverage `[[evidence_ref]]` keys, auto-collected
//!   from the scanned coverage entries (the operator never types 4-tuples).
//! - **redesign** → drive the ADR-009 back-edge `slice status <S> design`, write a
//!   REC with EMPTY `status_delta` (F7), and write NO requirement status.
//!
//! ## The NF-001 wall (design §5.6, D-B7 — the correctness heart)
//!
//! NF-001 is an information-flow invariant: *no causal path carries observed
//! coverage into authored [`ReqStatus`], except through human judgment.* Rust can't
//! express info-flow, so the wall is LAYERED:
//!
//! 1. **Signature isolation** — [`select_status`] is a pure fn whose parameters
//!    EXCLUDE every coverage-derived type (no `Verdict`/`Composite`/`CoverageKey` in
//!    scope). Inside, the compiler proves no derivation — you cannot use data you
//!    were not handed. This is where `--to` becomes the written status; its
//!    signature is the wall and is NEVER widened to see coverage.
//! 2. **Verdict consumed only by the prompt builder** — the drift [`Verdict`] flows
//!    ONLY into [`build_prompt`] for operator display, and is ABSENT at the write
//!    site. It is never threaded into [`select_status`]'s `to`.
//! 3. **Residual-site test** — the handler wiring `--to` into [`select_status`] is
//!    covered by the verdict-independence VT (VT-5): with `--to` fixed, varying
//!    every coverage-derived input never moves the written status.
//!
//! There is deliberately NO `match verdict { … => ReqStatus::X }` anywhere — that is
//! exactly the forbidden coverage→status derivation.

use std::path::PathBuf;

use anyhow::Context;

use crate::coverage::{self, CoverageKey, Verdict};
use crate::coverage_scan;
use crate::rec::{RecDoc, RecMeta, RecMove, StatusDelta};
use crate::requirement::{self, ReqStatus};

/// The bundled `doctrine reconcile` arguments — one struct to dodge the clippy
/// arg-ceiling (mem.pattern.lint.cli-handler-args-struct) and keep the shell seam
/// narrow.
pub(crate) struct ReconcileArgs {
    /// The requirement to reconcile, canonical `REQ-NNN`.
    pub(crate) req: String,
    /// The owning slice this act is recorded against, canonical `SL-NNN`.
    pub(crate) slice: String,
    /// The reconciliation move (`accept` | `revise` | `redesign`).
    pub(crate) r#move: RecMove,
    /// The explicit target status — REQUIRED for accept/revise, ABSENT for
    /// redesign (supplying it for redesign is an error).
    pub(crate) to: Option<ReqStatus>,
    /// Optional operator note (surfaced; not stored in the REC).
    pub(crate) note: Option<String>,
}

// ---------------------------------------------------------------------------
// The NF-001 wall — pure status selection (layer 1: signature isolation).
// ---------------------------------------------------------------------------

/// Select the status to WRITE — the operator's explicit `--to`. This is the
/// NF-001 wall's **layer 1: signature isolation** (D-B7). The signature names no
/// coverage-derived type (`Verdict`/`Composite`/`CoverageKey` are out of scope),
/// so the function *body* cannot derive status from coverage — it was not handed
/// any. That is a real but narrow guarantee: it constrains this function, NOT the
/// call site that feeds it. The full no-derivation invariant is proven by all
/// three layers together — this signature + the `Verdict` being consumed only by
/// `build_prompt` (layer 2) + the behavioural `written_status_is_verdict_independent`
/// test that drives `run()` and asserts the on-disk status tracks `--to` alone
/// (layer 3, VT-5). Do NOT widen this signature to see any coverage type, and never
/// write a `match verdict => ReqStatus` here OR at the call site in `run()`.
///
/// `prior` is the current authored status (carried for the `[[status_delta]]`
/// `from` and future selection rules); the written status is `to`. `prior` is
/// intentionally unconsulted today (FREE any→any, B·P1 D-B6).
fn select_status(to: ReqStatus, prior: ReqStatus) -> ReqStatus {
    // The wall in one line: the written status is the operator's explicit `--to`,
    // never a function of coverage. `prior` is intentionally not consulted (FREE
    // any→any, B·P1 D-B6) — it is in scope only as the delta's `from`.
    let _ = prior;
    to
}

/// Build the operator-facing drift prompt (layer 2): the drift [`Verdict`] is
/// consumed HERE, for display, and NOWHERE near the write site. Pure over the
/// already-resolved verdict.
fn build_prompt(verdict: Verdict) -> String {
    let reading = match verdict {
        Verdict::Coherent => "coherent — authored status agrees with observed coverage".to_owned(),
        Verdict::Indeterminate => "indeterminate — not enough live evidence to judge".to_owned(),
        Verdict::Divergent(reason) => {
            format!("divergent — {}", divergent_label(reason))
        }
    };
    format!("drift: {reading}")
}

/// The human-readable cause of a [`Verdict::Divergent`].
fn divergent_label(reason: coverage::DivergentReason) -> &'static str {
    match reason {
        coverage::DivergentReason::ObservedFailure => {
            "a check ran and contradicted the authored status (failed cell) — fix it or withdraw the requirement"
        }
        coverage::DivergentReason::ObservedBlocked => {
            "evidence is unobtainable (blocked cell) — confirm with a human (VH) Verified attestation or withdraw the requirement"
        }
        coverage::DivergentReason::EvidenceOutrunsAuthored => {
            "live confirming evidence exists while authored status trails it"
        }
    }
}

// ---------------------------------------------------------------------------
// Pure move classification + RecDoc composition (over resolved inputs).
// ---------------------------------------------------------------------------

/// Validate `--to` against the move (design §5.3): accept/revise REQUIRE `--to`;
/// redesign FORBIDS it (F7 — redesign writes no instance status). Pure.
fn require_to(r#move: RecMove, to: Option<ReqStatus>) -> anyhow::Result<Option<ReqStatus>> {
    match (r#move, to) {
        (RecMove::Accept | RecMove::Revise, Some(s)) => Ok(Some(s)),
        (RecMove::Accept | RecMove::Revise, None) => anyhow::bail!(
            "`--to <state>` is required for `--move {}`",
            r#move.as_str()
        ),
        (RecMove::Redesign, None) => Ok(None),
        (RecMove::Redesign, Some(_)) => {
            anyhow::bail!(
                "`--to` is not valid for `--move redesign` (it writes no requirement status, F7)"
            )
        }
    }
}

/// Compose the populated [`RecDoc`] for an accept/revise act (PURE over resolved
/// inputs): one `[[status_delta]]` (`from`=prior authored, `to`=written) and the
/// backing coverage keys as `[[evidence_ref]]`. The `id` is a placeholder — the
/// engine assigns the reserved id at materialise. `owning_slice = Some(S)`.
fn compose_status_rec(
    req: &str,
    slice: &str,
    r#move: RecMove,
    prior: ReqStatus,
    written: ReqStatus,
    evidence: Vec<CoverageKey>,
) -> RecDoc {
    let title = format!("{} {req}", r#move.as_str());
    RecDoc {
        id: 0,
        slug: rec_slug(r#move, req),
        title,
        rec: RecMeta {
            r#move: r#move.as_str().to_owned(),
            owning_slice: Some(slice.to_owned()),
            decision_ref: None,
        },
        status_delta: vec![StatusDelta {
            requirement: req.to_owned(),
            from: prior.as_str().to_owned(),
            to: written.as_str().to_owned(),
        }],
        evidence_ref: evidence,
        tags: Vec::new(),
    }
}

/// Compose the EMPTY-delta [`RecDoc`] for a redesign act (F7): records the
/// reconcile→design escalation, writes NO requirement status. The backing coverage
/// keys still ride as evidence (the escalation rests on observed drift). PURE.
fn compose_redesign_rec(req: &str, slice: &str, evidence: Vec<CoverageKey>) -> RecDoc {
    RecDoc {
        id: 0,
        slug: rec_slug(RecMove::Redesign, req),
        title: format!("redesign {req}"),
        rec: RecMeta {
            r#move: RecMove::Redesign.as_str().to_owned(),
            owning_slice: Some(slice.to_owned()),
            decision_ref: None,
        },
        status_delta: Vec::new(),
        evidence_ref: evidence,
        tags: Vec::new(),
    }
}

/// The REC slug stem for a reconcile act: `<move>-<req-lowercased>`.
fn rec_slug(r#move: RecMove, req: &str) -> String {
    format!("{}-{}", r#move.as_str(), req.to_lowercase())
}

// ---------------------------------------------------------------------------
// The impure shell — resolve inputs, dispatch the move, write the atomic REC.
// ---------------------------------------------------------------------------

/// `doctrine reconcile <REQ-NNN> --slice <SL-NNN> --move <accept|revise|redesign>
/// [--to <state>] [--note <text>]` — reconcile ONE requirement (loopable).
///
/// The shell: resolve root, validate the `--slice` forward edge up front, scan
/// coverage (read-only), read the prior authored status, compute drift (for the
/// PROMPT only — never the write), dispatch the move, and write the atomic REC. All
/// git/disk/clock live here (ADR-001 pure/imperative split); classification and
/// `RecDoc` composition are pure over the resolved inputs.
pub(crate) fn run(path: Option<PathBuf>, args: &ReconcileArgs) -> anyhow::Result<()> {
    use std::io::Write as _;
    let root = crate::root::find(path, &crate::root::default_markers())?;

    // Forward-edge guard BEFORE any write/mint (mirrors rec::run_new): a dangling
    // `--slice` is refused up front, so a bad edge never mints a REC nor moves a
    // requirement. The requirement ref must resolve too (its id_from_fk + load).
    crate::integrity::ensure_ref_resolves(&root, &args.slice)?;
    let prior = requirement::load(&root, &args.req)
        .with_context(|| format!("reconcile target {} not found", args.req))?
        .status;
    let req_id = requirement::id_from_fk(&args.req)?;

    // `--to` legality is a pure function of the move; reject early.
    let to = require_to(args.r#move, args.to)?;

    // Read-only coverage resolution (the shell's git/disk seam). The Verdict is for
    // PROMPTING ONLY — it is built into the prompt and NEVER reaches the write.
    let entries = coverage_scan::scan_coverage(&root, &args.req);
    let composite = coverage::composite(&entries);
    let verdict = coverage::drift(prior, &composite);
    let evidence = coverage::distinct_keys(entries.into_iter().map(|(e, _)| e.key));

    // Surface the drift reading to the operator (Verdict consumed here, out of scope
    // at the write — NF-001 layer 2).
    let mut out = std::io::stdout();
    writeln!(out, "{}", build_prompt(verdict))?;

    // Write ordering within the act (F-5 / RV-004). The two arms order their two
    // writes differently, by which torn-write window each must avoid:
    //   • accept/revise WRITE authored status, so NF-003 (REQ-116) binds: the
    //     authored tier must always reconstruct status. The REC is materialised
    //     FIRST as a write-ahead record, so a failure between the two writes leaves
    //     the REC present and the status lagging — a detectable, re-runnable drift,
    //     never a status move with no REC explaining it.
    //   • redesign writes NO requirement status (F7), so NF-003 does not bind. Its
    //     effect is the guarded ADR-009 reconcile→design back-edge, which may
    //     legitimately REFUSE; driving the transition FIRST means a refusal mints no
    //     REC (no orphan ledger entry), and the REC records the escalation that
    //     actually happened.
    let rec_id = match args.r#move {
        RecMove::Accept | RecMove::Revise => {
            // `to` is Some here (require_to enforced it). The WRITTEN status comes
            // from the wall (`select_status(to, prior)`), never from the verdict.
            let written =
                select_status(to.context("accept/revise require --to (validated)")?, prior);
            let doc = compose_status_rec(
                &args.req,
                &args.slice,
                args.r#move,
                prior,
                written,
                evidence,
            );
            let rec_id = crate::rec::materialise_populated(&root, &doc)?; // WAL first
            requirement::set_status(&root, req_id, written)?; // then authored status
            rec_id
        }
        RecMove::Redesign => {
            let slice_id = crate::slice::parse_ref(&args.slice)?;
            crate::slice::run_status(
                Some(root.clone()),
                slice_id,
                Some(crate::slice::SliceStatus::Design),
                args.note.as_deref(),
                false,
                false,
            )?;
            let doc = compose_redesign_rec(&args.req, &args.slice, evidence);
            crate::rec::materialise_populated(&root, &doc)?
        }
    };
    writeln!(
        out,
        "Recorded rec {rec_id:03}: {} {}",
        args.r#move.as_str(),
        args.req
    )?;
    if let Some(note) = &args.note {
        writeln!(out, "note: {note}")?;
    }
    Ok(())
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
#[expect(
    clippy::unwrap_used,
    reason = "tests: fail-fast unwrap on disk/round-trip setup is idiomatic"
)]
mod tests {
    use super::*;
    use crate::requirement::{self, ReqKind};
    use std::fs;
    use std::path::Path;

    // --- fixtures ------------------------------------------------------------

    /// A born git repo with pinned identity (so `commits_touching`/`head_sha` work)
    /// at a tempdir root. Returns the tempdir (kept alive by the caller).
    fn repo() -> tempfile::TempDir {
        let dir = tempfile::tempdir().unwrap();
        git(dir.path(), &["init", "-q", "-b", "main"]);
        dir
    }

    fn git(root: &Path, args: &[&str]) -> String {
        let out = std::process::Command::new("git")
            .arg("-C")
            .arg(root)
            .args([
                "-c",
                "user.name=t",
                "-c",
                "user.email=t@t",
                "-c",
                "commit.gpgsign=false",
            ])
            .args(args)
            .output()
            .unwrap();
        assert!(
            out.status.success(),
            "git {args:?} failed: {}",
            String::from_utf8_lossy(&out.stderr)
        );
        String::from_utf8(out.stdout).unwrap().trim().to_owned()
    }

    /// Mint a requirement at `status` and return its canonical FK.
    fn mint_req(root: &Path, status: ReqStatus) -> String {
        let id = requirement::reserve(root, "fast-boot", "Fast boot", "2026-06-12")
            .unwrap()
            .eid
            .numeric_id()
            .unwrap();
        requirement::set_kind(root, id, ReqKind::Functional).unwrap();
        requirement::set_status(root, id, status).unwrap();
        requirement::canonical_id(id)
    }

    /// Mint a minimal `proposed` slice (so the `--slice` edge resolves and the
    /// redesign back-edge can transition it). Returns its canonical FK.
    fn mint_slice(root: &Path) -> String {
        crate::slice::run_new(Some(root.to_path_buf()), Some("recon".to_owned()), None).unwrap();
        // slice new mints id 1 in a fresh tree.
        "SL-001".to_owned()
    }

    /// Write a slice coverage.toml carrying one entry for `req`.
    fn write_coverage(root: &Path, slice_num: u32, req: &str, status: &str) {
        let dir = root.join(".doctrine/slice").join(format!("{slice_num:03}"));
        fs::create_dir_all(&dir).unwrap();
        let body = format!(
            "[[entry]]\nslice = \"SL-{slice_num:03}\"\nrequirement = \"{req}\"\n\
             contributing_change = \"SL-{slice_num:03}\"\nmode = \"VT\"\n\
             status = \"{status}\"\ngit_anchor = \"deadbeef\"\n"
        );
        fs::write(dir.join("coverage.toml"), body).unwrap();
    }

    fn read_rec_status(root: &Path) -> ReqStatus {
        requirement::load(root, "REQ-001").unwrap().status
    }

    /// Count the REC dirs under the rec tree.
    fn rec_ids(root: &Path) -> Vec<u32> {
        let rec_root = root.join(".doctrine/rec");
        if !rec_root.is_dir() {
            return Vec::new();
        }
        crate::entity::scan_ids(&rec_root).unwrap()
    }

    fn read_rec_doc(root: &Path, id: u32) -> RecDoc {
        let name = format!("{id:03}");
        let p = root
            .join(".doctrine/rec")
            .join(&name)
            .join(format!("rec-{name}.toml"));
        toml::from_str(&fs::read_to_string(p).unwrap()).unwrap()
    }

    // --- VT-1: accept writes status via the setter + one populated REC --------

    #[test]
    fn accept_writes_status_and_one_rec_with_delta_and_evidence() {
        let dir = repo();
        let root = dir.path();
        let req = mint_req(root, ReqStatus::Pending);
        let slice = mint_slice(root);
        write_coverage(root, 1, &req, "verified");

        run(
            Some(root.to_path_buf()),
            &ReconcileArgs {
                req: req.clone(),
                slice,
                r#move: RecMove::Accept,
                to: Some(ReqStatus::Active),
                note: None,
            },
        )
        .unwrap();

        // The authored status moved to the explicit --to (via the B·P1 setter).
        assert_eq!(read_rec_status(root), ReqStatus::Active);

        // Exactly one REC, carrying the [req, from, to] delta and the backing key.
        let ids = rec_ids(root);
        assert_eq!(ids.len(), 1, "exactly one atomic REC");
        let doc = read_rec_doc(root, *ids.first().unwrap());
        assert_eq!(doc.rec.r#move, "accept");
        assert_eq!(doc.status_delta.len(), 1);
        let d = doc.status_delta.first().unwrap();
        assert_eq!(
            (d.requirement.as_str(), d.from.as_str(), d.to.as_str()),
            (req.as_str(), "pending", "active")
        );
        // evidence_ref is the auto-collected backing coverage key (distinct).
        assert_eq!(doc.evidence_ref.len(), 1);
        assert_eq!(doc.evidence_ref.first().unwrap().requirement, req);
    }

    // --- VT-2: revise moves status; redesign escalates with empty delta -------

    #[test]
    fn revise_moves_status_with_one_rec() {
        let dir = repo();
        let root = dir.path();
        let req = mint_req(root, ReqStatus::Active);
        let slice = mint_slice(root);
        write_coverage(root, 1, &req, "failed");

        run(
            Some(root.to_path_buf()),
            &ReconcileArgs {
                req: req.clone(),
                slice,
                r#move: RecMove::Revise,
                to: Some(ReqStatus::Deprecated),
                note: None,
            },
        )
        .unwrap();

        assert_eq!(read_rec_status(root), ReqStatus::Deprecated);
        let ids = rec_ids(root);
        assert_eq!(ids.len(), 1);
        let doc = read_rec_doc(root, *ids.first().unwrap());
        assert_eq!(doc.rec.r#move, "revise");
        assert_eq!(doc.status_delta.first().unwrap().to, "deprecated");
    }

    #[test]
    fn redesign_escalates_with_empty_delta_and_no_instance_write() {
        let dir = repo();
        let root = dir.path();
        let req = mint_req(root, ReqStatus::Active);
        let slice = mint_slice(root);
        write_coverage(root, 1, &req, "failed");
        // Drive the slice to a state from which reconcile→design is legal. The
        // ADR-009 back-edge `→design` is legal from `started`/`audit`/`reconcile`.
        crate::slice::run_status(
            Some(root.to_path_buf()),
            1,
            Some(crate::slice::SliceStatus::Design),
            None,
            false,
            false,
        )
        .unwrap();
        crate::slice::run_status(
            Some(root.to_path_buf()),
            1,
            Some(crate::slice::SliceStatus::Plan),
            None,
            false,
            false,
        )
        .unwrap();
        crate::slice::run_status(
            Some(root.to_path_buf()),
            1,
            Some(crate::slice::SliceStatus::Ready),
            None,
            false,
            false,
        )
        .unwrap();
        crate::slice::run_status(
            Some(root.to_path_buf()),
            1,
            Some(crate::slice::SliceStatus::Started),
            None,
            false,
            false,
        )
        .unwrap();

        run(
            Some(root.to_path_buf()),
            &ReconcileArgs {
                req: req.clone(),
                slice,
                r#move: RecMove::Redesign,
                to: None,
                note: Some("escalating".to_owned()),
            },
        )
        .unwrap();

        // F7: NO requirement status write — the prior `active` stands.
        assert_eq!(read_rec_status(root), ReqStatus::Active);
        // The slice was driven back to `design`.
        let slice_toml =
            fs::read_to_string(root.join(".doctrine/slice/001/slice-001.toml")).unwrap();
        assert!(
            slice_toml.contains("status = \"design\""),
            "back-edge to design: {slice_toml}"
        );
        // One REC, EMPTY status_delta.
        let ids = rec_ids(root);
        assert_eq!(ids.len(), 1);
        let doc = read_rec_doc(root, *ids.first().unwrap());
        assert_eq!(doc.rec.r#move, "redesign");
        assert!(
            doc.status_delta.is_empty(),
            "redesign carries empty delta (F7)"
        );
    }

    #[test]
    fn distinct_keys_dedupes_repeated_4tuples() {
        let k = |slice: &str| CoverageKey {
            slice: slice.to_owned(),
            requirement: "REQ-001".to_owned(),
            contributing_change: slice.to_owned(),
            mode: "VT".to_owned(),
        };
        // The same key twice (the slug-symlink double-walk) collapses to one; a
        // genuinely distinct key survives.
        let out = coverage::distinct_keys([k("SL-001"), k("SL-001"), k("SL-002")].into_iter());
        assert_eq!(out.len(), 2);
        assert_eq!(out.first().unwrap().slice, "SL-001");
        assert_eq!(out.get(1).unwrap().slice, "SL-002");
    }

    #[test]
    fn redesign_rejects_a_supplied_to() {
        assert!(require_to(RecMove::Redesign, Some(ReqStatus::Active)).is_err());
    }

    #[test]
    fn accept_and_revise_require_to() {
        assert!(require_to(RecMove::Accept, None).is_err());
        assert!(require_to(RecMove::Revise, None).is_err());
    }

    // --- VT-3: one REC per requirement, two reqs → two distinct atomic RECs ----

    #[test]
    fn two_requirements_under_different_moves_emit_two_distinct_recs() {
        let dir = repo();
        let root = dir.path();
        let req1 = mint_req(root, ReqStatus::Pending); // REQ-001
        // a second requirement
        let id2 = requirement::reserve(root, "low-latency", "Low latency", "2026-06-12")
            .unwrap()
            .eid
            .numeric_id()
            .unwrap();
        requirement::set_kind(root, id2, ReqKind::Functional).unwrap();
        let req2 = requirement::canonical_id(id2); // REQ-002
        let slice = mint_slice(root);

        run(
            Some(root.to_path_buf()),
            &ReconcileArgs {
                req: req1,
                slice: slice.clone(),
                r#move: RecMove::Accept,
                to: Some(ReqStatus::Active),
                note: None,
            },
        )
        .unwrap();
        run(
            Some(root.to_path_buf()),
            &ReconcileArgs {
                req: req2,
                slice,
                r#move: RecMove::Revise,
                to: Some(ReqStatus::Deprecated),
                note: None,
            },
        )
        .unwrap();

        // Two distinct atomic RECs, one per act — no append, no merge.
        let ids = rec_ids(root);
        assert_eq!(ids.len(), 2, "one REC per move = two RECs");
        let moves: Vec<String> = ids
            .iter()
            .map(|i| read_rec_doc(root, *i).rec.r#move)
            .collect();
        assert!(moves.contains(&"accept".to_owned()));
        assert!(moves.contains(&"revise".to_owned()));
    }

    // --- VT-4: NF-001 structural — select_status signature names no coverage ---

    #[test]
    fn select_status_returns_to_independent_of_prior() {
        // The wall, exercised structurally: select_status is callable with ONLY a
        // (to, prior) pair — its signature admits no coverage-derived type. The
        // written status is always `to`, for every prior.
        for prior in [
            ReqStatus::Pending,
            ReqStatus::Active,
            ReqStatus::Retired,
            ReqStatus::Superseded,
            ReqStatus::Deprecated,
            ReqStatus::InProgress,
        ] {
            assert_eq!(select_status(ReqStatus::Active, prior), ReqStatus::Active);
            assert_eq!(select_status(ReqStatus::Pending, prior), ReqStatus::Pending);
        }
    }

    #[test]
    fn verdict_is_consumed_only_by_build_prompt() {
        // The Verdict reaches build_prompt (layer 2) and produces display text — it
        // never reaches a status. (select_status takes no Verdict — see above.)
        let p = build_prompt(Verdict::Coherent);
        assert!(p.contains("coherent"));
        let d = build_prompt(Verdict::Divergent(
            coverage::DivergentReason::ObservedFailure,
        ));
        assert!(d.contains("divergent"));
    }

    /// VT-2 (SL-179 PHASE-02): the prompt register distinguishes the two
    /// contradiction reasons — a `Failed` cell reads differently from a `Blocked`
    /// one (the operator's remedy differs: fix-or-withdraw vs attest-or-withdraw).
    #[test]
    fn divergent_label_distinguishes_failure_from_blocked() {
        let failure = build_prompt(Verdict::Divergent(
            coverage::DivergentReason::ObservedFailure,
        ));
        let blocked = build_prompt(Verdict::Divergent(
            coverage::DivergentReason::ObservedBlocked,
        ));
        assert_ne!(failure, blocked, "the two reasons must read distinctly");
        assert!(
            failure.contains("ran and contradicted"),
            "failure prompt names the run-and-contradict cause: {failure}"
        );
        assert!(
            blocked.contains("unobtainable") && blocked.contains("VH"),
            "blocked prompt names unobtainable evidence + the VH attestation remedy: {blocked}"
        );
    }

    // --- VT-5: NF-001 behavioural — verdict-independence (the key test) -------

    /// VT-5 (NF-001, REQ-114) — the wall proven AT THE WRITE PATH. Drive the REAL
    /// `run()` over on-disk coverage states that make the drift `Verdict` vary
    /// (read through the same `scan_coverage` the shell uses), holding `--to`
    /// FIXED. The authored status reconstructed from disk must ALWAYS equal `--to`,
    /// never a function of the observed coverage.
    ///
    /// This exercises the laundering surface in `run()` (`select_status` → `set_status`):
    /// were a future edit to derive status from the verdict there, the on-disk
    /// assertion below would fail. (The prior formulation called `select_status`
    /// directly and asserted `id(x)==x` — vacuous; it never touched `run()`.)
    #[test]
    fn written_status_is_verdict_independent() {
        let fixed_to = ReqStatus::Active;
        // On-disk coverage states chosen to drive distinct verdicts under a Pending
        // prior: confirming evidence, contradicting evidence, agreeing-low evidence,
        // and no coverage at all. `__none__` writes no coverage.toml.
        let coverage_states = ["verified", "failed", "planned", "__none__"];

        let mut seen_verdicts = std::collections::BTreeSet::new();
        for state in coverage_states {
            let dir = repo();
            let root = dir.path();
            let req = mint_req(root, ReqStatus::Pending);
            let slice = mint_slice(root);
            if state != "__none__" {
                write_coverage(root, 1, &req, state);
            }

            // The verdict the shell reads for this state — same scan path as `run()`.
            let entries = coverage_scan::scan_coverage(root, &req);
            let composite = coverage::composite(&entries);
            let verdict = coverage::drift(ReqStatus::Pending, &composite);
            seen_verdicts.insert(format!("{verdict:?}"));

            run(
                Some(root.to_path_buf()),
                &ReconcileArgs {
                    req: req.clone(),
                    slice,
                    r#move: RecMove::Accept,
                    to: Some(fixed_to),
                    note: None,
                },
            )
            .unwrap();

            // The wall: the AUTHORED status on disk is `--to`, whatever the verdict.
            assert_eq!(
                requirement::load(root, &req).unwrap().status,
                fixed_to,
                "written status moved with coverage {state:?} (verdict {verdict:?}) — NF-001 wall breached"
            );
        }
        // Non-vacuity: the varied coverage genuinely drove different verdicts, so the
        // invariant above was tested against a moving input, not a constant one.
        assert!(
            seen_verdicts.len() >= 2,
            "varied coverage must produce ≥2 distinct verdicts, got {seen_verdicts:?}"
        );
    }

    // --- VT-6: NF-003 — REC + commit reconstruct status from the authored tier --

    #[test]
    fn rec_and_authored_tier_reconstruct_current_status() {
        let dir = repo();
        let root = dir.path();
        let req = mint_req(root, ReqStatus::Pending);
        let slice = mint_slice(root);
        write_coverage(root, 1, &req, "verified");

        run(
            Some(root.to_path_buf()),
            &ReconcileArgs {
                req: req.clone(),
                slice,
                r#move: RecMove::Accept,
                to: Some(ReqStatus::Active),
                note: None,
            },
        )
        .unwrap();

        // Reconstruct from the AUTHORED tier alone — no runtime state recourse:
        // the requirement TOML carries the current status …
        assert_eq!(
            requirement::load(root, &req).unwrap().status,
            ReqStatus::Active
        );
        // … and the REC's delta records the move that produced it.
        let id = *rec_ids(root).first().unwrap();
        let doc = read_rec_doc(root, id);
        let d = doc.status_delta.first().unwrap();
        assert_eq!(d.from, "pending");
        assert_eq!(d.to, "active");
        // The reconcile act writes only authored tiers — no `.doctrine/state/`
        // runtime tree is created, so the reconstruction above had none to lean on.
        assert!(
            !root.join(".doctrine/state").exists(),
            "reconcile created a runtime-state tree — the authored-tier reconstruction is not self-sufficient"
        );
    }
}