// SPDX-License-Identifier: GPL-3.0-only
//! Mutable runtime state under `.doctrine/state/` — phase tracking.
//!
//! Separate from the scaffold engine by *contract*, not IO (slice-004 D3): the
//! engine writes write-once authored filesets and refuses clobber; this module
//! owns idempotently-rewritten runtime state, which is disposable and
//! gitignored. The two share only `fsutil` primitives. State paths are always
//! computed from the slice id — the convenience symlink is never followed
//! (id is identity).

use std::collections::BTreeSet;
use std::fs;
use std::io::{ErrorKind, Write};
use std::path::{Path, PathBuf};

use crate::kinds::DISPATCH_REF_PREFIX;
use anyhow::Context;
use clap::ValueEnum;
use serde::Deserialize;
use toml_edit::Item;

use crate::boundary::{BoundaryRow, Provenance};
use crate::fsutil;
// `Plan`/`PlanPhase` are now an engine-tier leaf (`crate::plan`, SL-016): the
// state layer depends *down* on a neutral home, not up into the slice-CLI
// module — the slice↔state cycle is gone (ADR-001). Residual debt: `PhaseStatus`
// below still carries `clap::ValueEnum`, so the arg parser leaks into the state
// layer — out of scope here; split the CLI enum from the stored value if a
// second consumer appears.
use crate::plan::{Plan, PlanPhase};

/// Slice-scoped runtime-state tree, relative to the project root.
const STATE_SLICE_DIR: &str = ".doctrine/state/slice";
/// The authored slice tree — only the convenience `phases` symlink is written
/// here (gitignored); never tracking data (the runtime/authored boundary).
const SLICE_DIR: &str = ".doctrine/slice";

/// A phase's runtime lifecycle status. The CLI surface for `slice phase`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub(crate) enum PhaseStatus {
    Planned,
    #[value(name = "in_progress")]
    InProgress,
    Completed,
    Blocked,
}

impl PhaseStatus {
    pub(crate) fn as_str(self) -> &'static str {
        match self {
            Self::Planned => "planned",
            Self::InProgress => "in_progress",
            Self::Completed => "completed",
            Self::Blocked => "blocked",
        }
    }
}

/// A phase's contribution to the rollup before folding: either a parsed status
/// string from its `phase-NN.toml`, or a `.md`-only crash-partial whose status
/// is unreadable. Keeping the latter explicit is what stops `total` from
/// silently shrinking (design D8 / R-F4).
#[derive(Debug, PartialEq, Eq)]
pub(crate) enum StemStatus<'a> {
    Toml(&'a str),
    MissingToml,
}

/// Derived completion counts for one slice's phases. Every phase lands in exactly
/// one bucket, so the total is their sum and can never undercount; `unknown` (a
/// status string outside the `PhaseStatus` set) and `missing_toml` are kept
/// distinct so corruption is surfaced, not folded into "incomplete" (R-F3/R-F5).
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub(crate) struct PhaseRollup {
    pub(crate) planned: u32,
    pub(crate) in_progress: u32,
    pub(crate) completed: u32,
    pub(crate) blocked: u32,
    pub(crate) unknown: u32,
    pub(crate) missing_toml: u32,
}

impl PhaseRollup {
    /// Every phase, summed across buckets — never undercounts.
    pub(crate) fn total(&self) -> u32 {
        self.planned
            + self.in_progress
            + self.completed
            + self.blocked
            + self.unknown
            + self.missing_toml
    }

    /// Phases whose tracking is malformed (unrecognised status or unreadable
    /// `.toml`) — surfaced as the `?N` marker, and suppresses divergence.
    pub(crate) fn anomalies(&self) -> u32 {
        self.unknown + self.missing_toml
    }
}

/// Fold per-stem statuses into the bucket counts. Pure — no IO, no clock. An
/// unrecognised status string lands in `unknown`; a `.md`-only stem in
/// `missing_toml`. The known set is `PhaseStatus`'s own value names, so the
/// vocabulary has one source (no parallel string list).
pub(crate) fn fold_rollup(stems: &[StemStatus<'_>]) -> PhaseRollup {
    let mut r = PhaseRollup::default();
    for stem in stems {
        match stem {
            StemStatus::MissingToml => r.missing_toml += 1,
            StemStatus::Toml(s) => match PhaseStatus::from_str(s, false) {
                Ok(PhaseStatus::Planned) => r.planned += 1,
                Ok(PhaseStatus::InProgress) => r.in_progress += 1,
                Ok(PhaseStatus::Completed) => r.completed += 1,
                Ok(PhaseStatus::Blocked) => r.blocked += 1,
                Err(_) => r.unknown += 1,
            },
        }
    }
    r
}

/// What `init_phases` did, so the caller reports drift without this module
/// printing. `created`: phases that got a file written this run (new, or a
/// crash-partial phase completed per-file). `orphan`: tracking on disk whose
/// plan phase is gone (a rename presents as orphan + a fresh phase) — reported,
/// never silently consumed. `pruned`: orphans removed under explicit `--prune`.
#[derive(Debug, Default, PartialEq, Eq)]
pub(crate) struct InitReport {
    pub created: Vec<String>,
    pub orphan: Vec<String>,
    pub pruned: Vec<String>,
}

/// Canonical state path for a slice's phase tracking, computed from the id.
pub(crate) fn phases_dir(project_root: &Path, slice_id: u32) -> PathBuf {
    project_root
        .join(STATE_SLICE_DIR)
        .join(format!("{slice_id:03}"))
        .join("phases")
}

/// Validate a phase id and derive its filename stem. Enforces the canonical
/// `PHASE-<digits>` form (→ `phase-NN`), which both makes the derivation total
/// and rejects filesystem-unsafe input — empty, separators, `..`, leading dot
/// all fail the single rule, since a phase id reaches the filesystem (finding
/// 4). `NN` is kept verbatim; only the `PHASE-` prefix is lowercased.
pub(crate) fn phase_stem(phase_id: &str) -> anyhow::Result<String> {
    let digits = phase_id
        .strip_prefix("PHASE-")
        .filter(|d| !d.is_empty() && d.bytes().all(|b| b.is_ascii_digit()))
        .with_context(|| format!("Phase id {phase_id:?} must match PHASE-<digits>"))?;
    Ok(format!("phase-{digits}"))
}

/// Reverse of `phase_stem`: recover the canonical `PHASE-NN` id from an on-disk
/// stem so the drift report speaks one dialect — `orphan`/`pruned` match
/// `created`'s canonical ids, never the derived filename form. Stems reach this
/// only from `existing_phase_stems`, which guarantees the `phase-` prefix.
fn phase_id_from_stem(stem: &str) -> String {
    format!("PHASE-{}", stem.strip_prefix("phase-").unwrap_or(stem))
}

/// Materialise, per declared phase, a `phase-NN.toml` (tracking) + `phase-NN.md`
/// (disposable sheet) under the state tree. Ensures the (gitignored,
/// possibly-absent) parent, then writes any **missing file of each pair** —
/// per-file skip, so a phase left half-written by a crash completes on re-run.
/// Diffs on-disk tracking against the plan: orphans (plan phase gone) are
/// reported, removed only under `prune`. Refreshes the verified convenience
/// symlink. Idempotent on the no-drift path. All phase ids are validated up
/// front (before any write); uniqueness is guaranteed by `Plan::parse`.
pub(crate) fn init_phases(
    project_root: &Path,
    slice_id: u32,
    plan: &Plan,
    prune: bool,
) -> anyhow::Result<InitReport> {
    // Validate + derive every phase stem before touching the filesystem, so one
    // malformed id fails the whole init clean rather than after the earlier phases
    // are already written (a non-atomic init rejecting input knowable up front).
    // `phase_stem` is the single `PHASE-<digits>` validator.
    let stems = plan
        .phases
        .iter()
        .map(|phase| Ok((phase, phase_stem(&phase.id)?)))
        .collect::<anyhow::Result<Vec<_>>>()?;

    let dir = phases_dir(project_root, slice_id);
    fs::create_dir_all(&dir).with_context(|| format!("Failed to create {}", dir.display()))?;

    let mut report = InitReport::default();
    let mut plan_stems = BTreeSet::new();

    for (phase, stem) in &stems {
        plan_stems.insert(stem.clone());
        let wrote_toml = write_if_absent(
            &dir.join(format!("{stem}.toml")),
            &render_tracking(&phase.id),
        )?;
        let wrote_md =
            write_if_absent(&dir.join(format!("{stem}.md")), &render_phase_sheet(phase)?)?;
        if wrote_toml || wrote_md {
            report.created.push(phase.id.clone());
        }
    }

    for stem in existing_phase_stems(&dir)? {
        if plan_stems.contains(&stem) {
            continue;
        }
        if prune {
            drop(fs::remove_file(dir.join(format!("{stem}.toml"))));
            drop(fs::remove_file(dir.join(format!("{stem}.md"))));
            report.pruned.push(phase_id_from_stem(&stem));
        } else {
            report.orphan.push(phase_id_from_stem(&stem));
        }
    }

    refresh_symlink(project_root, slice_id)?;
    Ok(report)
}

/// Write `body` to `path` only if absent. `create_new` makes the skip atomic;
/// the partial-init recovery (finding round-2) rides on this per-file skip.
fn write_if_absent(path: &Path, body: &str) -> anyhow::Result<bool> {
    match fsutil::create_new_file(path) {
        Ok(mut f) => {
            f.write_all(body.as_bytes())
                .with_context(|| format!("Failed to write {}", path.display()))?;
            Ok(true)
        }
        Err(e) if e.kind() == ErrorKind::AlreadyExists => Ok(false),
        Err(e) => Err(e).with_context(|| format!("Failed to create {}", path.display())),
    }
}

/// Distinct `phase-*` stems already present on disk (from either file of a
/// pair). A missing dir yields an empty set.
pub(crate) fn existing_phase_stems(dir: &Path) -> anyhow::Result<BTreeSet<String>> {
    let mut stems = BTreeSet::new();
    let entries = match fs::read_dir(dir) {
        Ok(entries) => entries,
        Err(e) if e.kind() == ErrorKind::NotFound => return Ok(stems),
        Err(e) => return Err(e).with_context(|| format!("Failed to read {}", dir.display())),
    };
    for entry in entries {
        let entry = entry?;
        let name = entry.file_name();
        let Some(name) = name.to_str() else { continue };
        if let Some(stem) = name
            .strip_suffix(".toml")
            .or_else(|| name.strip_suffix(".md"))
            && stem.starts_with("phase-")
        {
            stems.insert(stem.to_string());
        }
    }
    Ok(stems)
}

/// The one field the rollup reads from a `phase-NN.toml`. Optional + tolerant:
/// unknown keys are ignored, and a malformed file parses to `status = None`
/// (treated as `missing_toml`, never an error — design § 5.5).
#[derive(Deserialize)]
struct TrackingStatus {
    status: Option<String>,
}

/// Read one phase stem's status string. `None` when the `.toml` is absent (a
/// `.md`-only crash-partial), unparseable, or carries no `status` — all of which
/// the fold counts as `missing_toml` rather than dropping the phase (R-F4). A
/// genuine IO error (not "not found") propagates.
pub(crate) fn read_phase_status(dir: &Path, stem: &str) -> anyhow::Result<Option<String>> {
    let path = dir.join(format!("{stem}.toml"));
    let text = match fs::read_to_string(&path) {
        Ok(t) => t,
        Err(e) if e.kind() == ErrorKind::NotFound => return Ok(None),
        Err(e) => return Err(e).with_context(|| format!("Failed to read {}", path.display())),
    };
    Ok(toml::from_str::<TrackingStatus>(&text)
        .ok()
        .and_then(|t| t.status))
}

/// Derive the phase-completion rollup for a slice from its runtime tracking tree.
/// The phase set comes from `existing_phase_stems` — the same notion `init_phases`
/// uses (either half of the pair) — so `.md`-only crash-partials count as
/// `missing_toml` and the total never silently shrinks (D8). `None` only when no
/// phase exists at all (dir absent or empty): the *untracked* signal. The path is
/// id-derived; the convenience `phases` symlink is never followed.
pub(crate) fn phase_rollup(
    project_root: &Path,
    slice_id: u32,
) -> anyhow::Result<Option<PhaseRollup>> {
    let dir = phases_dir(project_root, slice_id);
    let stems = existing_phase_stems(&dir)?;
    if stems.is_empty() {
        return Ok(None);
    }
    let statuses: Vec<Option<String>> = stems
        .iter()
        .map(|stem| read_phase_status(&dir, stem))
        .collect::<anyhow::Result<_>>()?;
    let stem_statuses: Vec<StemStatus<'_>> = statuses
        .iter()
        .map(|s| match s {
            Some(status) => StemStatus::Toml(status),
            None => StemStatus::MissingToml,
        })
        .collect();
    Ok(Some(fold_rollup(&stem_statuses)))
}

/// The minimal v1 phase-tracking skeleton (slice-004 §5.2): a phase-level
/// status plus an (initially empty) append-only progress log. Richer
/// per-criterion/task rows graduate to TOML when a consumer lands (D5/Q2).
/// Mutated later by
/// `set_phase_status` via `toml_edit` (comment/unknown-key preserving), so it
/// is rendered, never reserialised.
fn render_tracking(phase_id: &str) -> String {
    format!(
        "schema  = \"doctrine.phase.tracking\"\n\
         version = 1\n\
         phase   = \"{phase_id}\"\n\
         status  = \"planned\"   # planned | in_progress | completed | blocked\n\
         started      = \"\"\n\
         completed    = \"\"\n\
         last_updated = \"\"\n\
         \n\
         # Append-only runtime progress log, written by `doctrine slice phase`.\n"
    )
}

/// Render the disposable phase sheet from the embedded template.
fn render_phase_sheet(phase: &PlanPhase) -> anyhow::Result<String> {
    Ok(crate::install::asset_text("templates/phase.md")?
        .replace("{{phase_id}}", &phase.id)
        .replace("{{name}}", &phase.name)
        .replace("{{objective}}", &phase.objective))
}

/// Refresh the gitignored convenience symlink
/// `.doctrine/slice/<id>/phases → ../../state/slice/<id>/phases`. Verified
/// (a wrong link is replaced, a real file/dir errors); never authority.
fn refresh_symlink(project_root: &Path, slice_id: u32) -> anyhow::Result<()> {
    let name = format!("{slice_id:03}");
    let link = project_root.join(SLICE_DIR).join(&name).join("phases");
    let target = PathBuf::from(format!("../../state/slice/{name}/phases"));
    fsutil::set_symlink(&link, &target)
}

/// Shared edit-preserving skeleton for the two phase-sheet writers
/// ([`set_phase_status`] and [`reconcile_phase_status`]): read the id-derived
/// sheet (`phase_stem` validates the id; the convenience `phases` symlink is
/// never followed), parse it with `toml_edit` so hand-added comments and unknown
/// keys survive (entity-model § Rust model — the file is mutated, never
/// reserialised), hand the mutable root table to `mutate`, then write it back.
/// The lone `fs::write` lives HERE, so both writers share one skeleton and one
/// disallowed-methods waiver rather than duplicating the `toml_edit` boilerplate
/// (SL-190 EX-1, DRY). `now`/status decisions stay with the caller's closure.
// fn-level expect: the lone fs::write cannot carry a stmt-level attribute on
// stable Rust (stmt_expr_attributes).
#[expect(
    clippy::disallowed_methods,
    reason = "runtime phase sheet — disposable, atomicity not required"
)]
fn edit_phase_sheet<F>(
    project_root: &Path,
    slice_id: u32,
    phase_id: &str,
    mutate: F,
) -> anyhow::Result<()>
where
    F: FnOnce(&mut toml_edit::Table) -> anyhow::Result<()>,
{
    let stem = phase_stem(phase_id)?;
    let path = phases_dir(project_root, slice_id).join(format!("{stem}.toml"));
    let text = fs::read_to_string(&path).with_context(|| {
        format!(
            "Phase tracking not found at {} — run `doctrine slice phases` first",
            path.display()
        )
    })?;
    let mut doc = text
        .parse::<toml_edit::DocumentMut>()
        .with_context(|| format!("Failed to parse {}", path.display()))?;
    mutate(doc.as_table_mut())?;
    fs::write(&path, doc.to_string()).with_context(|| format!("Failed to write {}", path.display()))
}

/// Edit-preserving status transition on one phase: set `status`, stamp
/// `last_updated` (and `started`/`completed` on first entry), and append a
/// `[[progress]]` row — then (DEGRADING, AFTER the sheet write) capture/evict the
/// phase's source-delta boundary. Shares the read-parse-write skeleton with
/// [`edit_phase_sheet`]; the registry side-effects that mutate the recorded
/// boundaries are deliberately kept OUT of that shared core (they are what makes
/// this writer unsuitable for reconcile, SL-190). `now` is supplied by the shell
/// (the clock stays out of this layer).
pub(crate) fn set_phase_status(
    project_root: &Path,
    slice_id: u32,
    phase_id: &str,
    status: PhaseStatus,
    note: Option<&str>,
    now: &str,
) -> anyhow::Result<()> {
    // Captured inside the sheet mutation but consumed by the DEGRADING registry
    // side-effects AFTER the write — those never leak into the shared skeleton.
    let mut was_completed = false;
    let mut capture_end = None;

    edit_phase_sheet(project_root, slice_id, phase_id, |table| {
        // Capture the prior status BEFORE the insert overwrites it: a reopen
        // (Completed→non-Completed) must retire the now-defunct boundary (D8/P2-1).
        was_completed =
            table.get("status").and_then(Item::as_str) == Some(PhaseStatus::Completed.as_str());
        table.insert("status", toml_edit::value(status.as_str()));
        table.insert("last_updated", toml_edit::value(now));
        if status == PhaseStatus::InProgress
            && table.get("started").and_then(Item::as_str) == Some("")
        {
            table.insert("started", toml_edit::value(now));
        }
        // `completed` holds a stamp iff the phase is completed: stamp once on first
        // completion (a re-complete keeps the original time), and clear it on any
        // non-completed status so a reopen leaves no stale completion time on an
        // in-progress/blocked phase (an otherwise internally-contradictory record).
        if status == PhaseStatus::Completed {
            if table.get("completed").and_then(Item::as_str) == Some("") {
                table.insert("completed", toml_edit::value(now));
            }
        } else {
            table.insert("completed", toml_edit::value(""));
            // Reopen (Completed→non-Completed): clear the start-stamp so the binding
            // re-captures a FRESH range on the next in_progress flip (capture_phase_boundary
            // below re-stamps it), never reusing the stale start (P2-1). The registry row is
            // evicted just after the sheet write (D8).
            if was_completed {
                table.insert("code_start_oid", toml_edit::value(""));
            }
        }

        // Solo phase-binding capture (SL-147 PHASE-04, design D5): a SEPARATE,
        // ADDITIVE, DEGRADING step that records the per-phase code boundary into the
        // arm-neutral registry. It NEVER alters the status-flip behaviour above — on
        // any git/bare-repo failure (or in a dispatch coordination context) it
        // degrades to a no-op with a NAMED warning and the transition still
        // completes. On InProgress it stamps `code_start_oid` (HEAD) into the sheet
        // once; on Completed it reads that back and records `(start, HEAD)` via the
        // F-6 guard + upsert. The stamp must ride THIS doc write, so it mutates the
        // table before the write; the registry record happens AFTER the write.
        capture_end = capture_phase_boundary(project_root, slice_id, phase_id, status, table);

        let mut row = toml_edit::Table::new();
        row.insert("timestamp", toml_edit::value(now));
        row.insert("status", toml_edit::value(status.as_str()));
        if let Some(note) = note {
            row.insert("note", toml_edit::value(note));
        }
        table
            .entry("progress")
            .or_insert_with(|| Item::ArrayOfTables(toml_edit::ArrayOfTables::new()))
            .as_array_of_tables_mut()
            .context("`progress` exists but is not an array of tables")?
            .push(row);
        Ok(())
    })?;

    // Reopen eviction (design D8): a Completed→non-Completed transition retires the
    // recorded boundary — drop its registry row so a re-completion records a fresh one
    // rather than the conformance gate blessing a stale row. DEGRADING like the record
    // tail below (named warning, never returned): the binding must NEVER block a status
    // transition (D5), and a non-repo/bare cwd makes `boundaries_path` itself error. A
    // lingering row is self-healing — the re-completion's upsert overwrites it — and a
    // never-recompleted reopen surfaces it loudly via the completeness gate (Extra).
    // Primary-tree-resolved like the writer; an absent row is a no-op (`Ok(false)`).
    if was_completed
        && status != PhaseStatus::Completed
        && let Err(e) = forget_source_delta(project_root, slice_id, phase_id)
    {
        warn_capture(phase_id, &format!("reopen eviction failed: {e:#}"));
    }

    // The registry write is the tail of the DEGRADING capture: it runs only on a
    // completion that resolved a `(start, end)` pair, and its failure is reported
    // (named warning) but never returned — the status transition above already
    // succeeded and must stand.
    if let Some(CaptureCompletion { start, end }) = capture_end
        && let Err(e) = record_source_delta(
            project_root,
            slice_id,
            BoundaryRow {
                phase: phase_id.to_string(),
                code_start_oid: start,
                code_end_oid: end,
                // Solo binding capture is the solo landing writer (design §5.3).
                provenance: Provenance::Solo,
            },
        )
    {
        warn_capture(phase_id, &format!("recording source delta failed: {e:#}"));
    }

    Ok(())
}

/// The resolved completion boundary handed back by [`capture_phase_boundary`]
/// when a `Completed` transition has both a stamped start and a readable HEAD —
/// the input to the registry record that runs AFTER the sheet write.
struct CaptureCompletion {
    start: String,
    end: String,
}

/// Solo phase-binding capture step (SL-147 PHASE-04). Mutates `table` (the phase
/// sheet) to stamp `code_start_oid` on entry to `InProgress`, and returns the
/// `(start, end)` pair to record on `Completed`. Returns `None` (no registry
/// write) for every other status, when the arm guard fires (a *live* coordination
/// worktree exists for `dispatch/<slice_id>` — D3/D9), or when any git read
/// degrades — emitting a NAMED warning so the operator sees why no row landed,
/// WITHOUT blocking the transition. `project_root` doubles as the git cwd (the
/// repo it lives in).
fn capture_phase_boundary(
    project_root: &Path,
    slice_id: u32,
    phase_id: &str,
    status: PhaseStatus,
    table: &mut toml_edit::Table,
) -> Option<CaptureCompletion> {
    if status != PhaseStatus::InProgress && status != PhaseStatus::Completed {
        return None;
    }

    // Arm guard: a dispatch coordination context records via the funnel recorder,
    // never the solo binding — they must NEVER both record a phase. Key on a *live*
    // coordination worktree for `dispatch/<slice_id>` (D3 + D9), not the current
    // branch: the branch-proxy was unsound when the flip ran from the session root
    // (the primary tree on `main` while the coord worktree owns `dispatch/NNN`), and
    // a solo `/worktree` fork on its own feature branch must still capture. Liveness
    // (`!prunable && path.exists()`) means a stale/pruned coord entry reads as absent,
    // so capture is never suppressed forever. A probe error warns + stands down.
    match crate::git::live_worktree_for_ref(
        project_root,
        &format!("{DISPATCH_REF_PREFIX}{slice_id:03}"),
    ) {
        Ok(Some(_)) => return None,
        Ok(None) => {}
        Err(e) => {
            warn_capture(phase_id, &format!("coord worktree probe failed: {e}"));
            return None;
        }
    }

    let head = match crate::git::resolve_ref(project_root, "HEAD") {
        Ok(Some(oid)) => oid,
        Ok(None) => {
            warn_capture(phase_id, "HEAD does not resolve (unborn/detached)");
            return None;
        }
        Err(e) => {
            warn_capture(phase_id, &format!("HEAD probe failed: {e}"));
            return None;
        }
    };

    if status == PhaseStatus::InProgress {
        // Stamp the start once: an in-progress re-entry (or a reopen → re-start)
        // keeps the original start so the boundary spans the whole phase.
        if table
            .get("code_start_oid")
            .and_then(Item::as_str)
            .is_none_or(str::is_empty)
        {
            table.insert("code_start_oid", toml_edit::value(&head));
        }
        return None;
    }

    // status == Completed: read the stamped start back. An absent start (the
    // phase was never flipped in_progress under the binding) degrades — without a
    // start there is no boundary to record.
    let Some(start) = table
        .get("code_start_oid")
        .and_then(Item::as_str)
        .filter(|s| !s.is_empty())
        .map(str::to_owned)
    else {
        warn_capture(
            phase_id,
            "no code_start_oid stamped (phase never entered in_progress under the binding) — no boundary recorded",
        );
        return None;
    };

    Some(CaptureCompletion { start, end: head })
}

/// Emit a single NAMED capture-degradation warning to stderr (the binding never
/// blocks a status transition — design D5). Routed through one helper so the
/// message shape is uniform and greppable.
fn warn_capture(phase_id: &str, detail: &str) {
    let _ignored = writeln!(
        std::io::stderr(),
        "warning: phase-binding capture skipped for {phase_id}: {detail}"
    );
}

// ---------------------------------------------------------------------------
// Recorded source-delta registry (SL-147 PHASE-02)
// ---------------------------------------------------------------------------
//
// The arm-neutral record of each phase's committed code boundary — the same
// per-phase `(code_start, code_end)` pair the claude arm writes into its
// committed dispatch ledger, but for ANY arm and persisted as gitignored
// runtime state. ONE file per slice, shared across every worktree of the repo:
// it resolves against the PRIMARY working tree (`crate::git::primary_worktree`),
// NOT `root::find(cwd)`, so a worker recording from a linked worktree writes the
// row a later integrator reads from the main tree.
//
// Tier: `.doctrine/state/slice/<NNN>/boundaries.toml` — disposable, never
// authored (mirrors `phases_dir`'s path idiom one level up). No funnel consumer
// is wired yet; PHASE-03 reads it (slice conformance) and PHASE-04 writes
// through it (record-delta / slice phase binding), so the symbols are
// dead-code-phased per-symbol until then.

/// The slice's recorded source-delta registry: a thin container over the
/// arm-neutral [`BoundaryRow`] (the row type is owned by the `boundary` leaf,
/// not re-declared here — this is a container, not a parallel row). Round-trips
/// through `boundaries.toml` with `[[boundary]]` table headers.
#[derive(Debug, Clone, PartialEq, Eq, Default, serde::Serialize, serde::Deserialize)]
pub(crate) struct SourceDeltas {
    #[serde(default, rename = "boundary")]
    pub rows: Vec<BoundaryRow>,
}

/// Canonical registry path for a slice, resolved against the PRIMARY working
/// tree so every worktree shares one file:
/// `<primary>/.doctrine/state/slice/<NNN>/boundaries.toml`. `cwd` may be any
/// path in the repo (a linked worker worktree is the typical caller). A
/// bare/not-a-repo `cwd` yields a clean named error via `primary_worktree`.
pub(crate) fn boundaries_path(cwd: &Path, slice_id: u32) -> anyhow::Result<PathBuf> {
    let primary = crate::git::primary_worktree(cwd)?;
    Ok(primary
        .join(STATE_SLICE_DIR)
        .join(format!("{slice_id:03}"))
        .join("boundaries.toml"))
}

/// Load a slice's registry from `path` (an absent file is an empty registry,
/// never an error; a present-but-malformed file is a hard error). The shared
/// read half of the registry's read-modify-write surface — mirrors the
/// `ledger::load` idiom.
fn read_registry(path: &Path) -> anyhow::Result<SourceDeltas> {
    match fs::read_to_string(path) {
        Ok(text) => {
            toml::from_str(&text).with_context(|| format!("Failed to parse {}", path.display()))
        }
        Err(e) if e.kind() == ErrorKind::NotFound => Ok(SourceDeltas::default()),
        Err(e) => Err(e).with_context(|| format!("Failed to read {}", path.display())),
    }
}

/// Persist a slice's registry to `path`, creating the slice runtime dir on first
/// write. The shared write half (mirrors `ledger::store`); the single
/// `fs::write`/`create_dir_all` site, so the disposable-state lint expectation
/// lives here, not on every caller.
#[expect(
    clippy::disallowed_methods,
    reason = "runtime registry — disposable gitignored state, atomicity not required"
)]
fn write_registry(path: &Path, registry: &SourceDeltas) -> anyhow::Result<()> {
    if let Some(parent) = path.parent() {
        fs::create_dir_all(parent)
            .with_context(|| format!("Failed to create {}", parent.display()))?;
    }
    let body = toml::to_string(registry).context("serialize source-delta registry")?;
    fs::write(path, body).with_context(|| format!("Failed to write {}", path.display()))
}

/// Read the recorded source deltas for `slice_id` (empty Vec when the file is
/// absent or empty — never an error). Mirrors the `read_phase_status`
/// not-found-is-empty idiom; a present-but-malformed file is a hard error.
pub(crate) fn read_source_deltas(cwd: &Path, slice_id: u32) -> anyhow::Result<Vec<BoundaryRow>> {
    Ok(read_registry(&boundaries_path(cwd, slice_id)?)?.rows)
}

/// Record (UPSERT by phase) one phase's committed source delta into the slice's
/// registry. The guard runs BEFORE the write: `code_start` must be an ancestor
/// of `code_end` (a real forward delta, possibly empty when equal) AND
/// `code_end` must be a non-merge commit (`parents().len() <= 1`) — the boundary
/// is a single linear code tip, never a merge. A bare/not-a-repo `cwd`, or a
/// `code_*` oid git cannot resolve, surfaces as a clean named error (the git
/// leaf's `CaptureError`), never a panic. Read-modify-write; the dir/file are
/// created on first write. `row.phase` keys the upsert so a re-record of the
/// same phase replaces (never duplicates) its row.
pub(crate) fn record_source_delta(
    cwd: &Path,
    slice_id: u32,
    row: BoundaryRow,
) -> anyhow::Result<()> {
    if !crate::git::is_ancestor(cwd, &row.code_start_oid, &row.code_end_oid)? {
        anyhow::bail!(
            "record_source_delta: code_start {} is not an ancestor of code_end {} (not a forward delta)",
            row.code_start_oid,
            row.code_end_oid
        );
    }
    if crate::git::parents(cwd, &row.code_end_oid)?.len() > 1 {
        anyhow::bail!(
            "record_source_delta: code_end {} is a merge commit (boundary must be a non-merge code tip)",
            row.code_end_oid
        );
    }

    let path = boundaries_path(cwd, slice_id)?;
    let mut registry = read_registry(&path)?;

    // Sticky provenance merge (design §5.2/§5.3, D12), keyed on the INCOMING row
    // and resolved here inside the RMW so it is atomic — never a caller pre-read.
    // The range/oids always take the incoming value (a re-record corrects the
    // boundary); only the landing-path stamp is sticky.
    match registry.rows.iter_mut().find(|r| r.phase == row.phase) {
        Some(existing) => {
            let keep = match row.provenance {
                // Landing writers are authoritative — they overwrite.
                Provenance::Solo | Provenance::Funnel => row.provenance,
                // A correction (Manual) or a legacy read (Unknown) never
                // reclassifies an already-recorded landing path.
                Provenance::Manual | Provenance::Unknown => existing.provenance,
            };
            *existing = row;
            existing.provenance = keep;
        }
        // A fresh phase stores the incoming value as-is.
        None => registry.rows.push(row),
    }

    write_registry(&path, &registry)
}

/// Derive a single-commit boundary row for `commit` — exactly its own patch
/// `[S^, S]` (design SL-189 §5.2). Resolve `commit` to a full oid (a ref that
/// does not resolve is a clean named error), then require EXACTLY one parent:
/// this rejects BOTH merge commits (>1 parent) AND root commits (0 parents) —
/// neither has a single "own patch". `git diff S^..S` is then precisely `S`'s
/// delta, so trailed knowledge, refresh-base merges, and foreign source drop out
/// by construction. The tight, safe-by-construction counterpart to a hand-passed
/// `start..end` range; the caller pairs it with [`record_source_delta`], which
/// re-applies the ancestor/non-merge guard on the built row.
pub(crate) fn single_commit_boundary(
    root: &Path,
    commit: &str,
    provenance: crate::boundary::Provenance,
    phase: &str,
) -> anyhow::Result<crate::boundary::BoundaryRow> {
    let end = crate::git::resolve_ref(root, commit)?.with_context(|| {
        format!("single_commit_boundary: {commit} does not resolve to a commit")
    })?;
    let parents = crate::git::parents(root, &end)?;
    let [start] = parents.as_slice() else {
        anyhow::bail!(
            "single_commit_boundary: {commit} must have exactly one parent (a non-merge, \
             non-root commit with a single own patch), found {}",
            parents.len()
        );
    };
    Ok(crate::boundary::BoundaryRow {
        phase: phase.into(),
        code_start_oid: start.clone(),
        code_end_oid: end,
        provenance,
    })
}

/// Evict one phase's row from the slice's registry — the inverse of
/// [`record_source_delta`] and its reopen-eviction sibling (design D8; the sole
/// caller is the PHASE-03 completed→non-completed reopen, which must not leave a
/// stale row behind a cleared start-stamp). Primary-tree-resolved like the
/// writer. Returns whether a row was removed: an absent registry file or an
/// absent phase is a no-op `Ok(false)`, never an error. No git guard — a removal
/// records no range.
pub(crate) fn forget_source_delta(cwd: &Path, slice_id: u32, phase: &str) -> anyhow::Result<bool> {
    let path = boundaries_path(cwd, slice_id)?;
    let mut registry = read_registry(&path)?;

    let before = registry.rows.len();
    registry.rows.retain(|r| r.phase != phase);
    if registry.rows.len() == before {
        return Ok(false);
    }
    write_registry(&path, &registry)?;
    Ok(true)
}

/// The completeness verdict of the recorded registry against a slice's completed
/// phases (design F-2): either every completed phase has exactly one row (and no
/// row belongs to a non-completed phase), or the registry is incomplete with a
/// named gap. `slice conformance` refuses to emit a clean diff when this returns
/// `Incomplete` — partial coverage must never read as conformance.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum Completeness {
    /// One row per completed phase, no extras — the registry covers the work.
    Complete,
    /// A coverage gap, naming the offending phase(s) so the operator can act.
    Incomplete { gaps: Vec<CompletenessGap> },
}

/// A single named coverage gap between recorded rows and completed phases.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum CompletenessGap {
    /// A completed phase with no recorded boundary row.
    Missing { phase: String },
    /// A recorded row whose phase is not completed (or does not exist).
    Extra { phase: String },
    /// More than one recorded row for the same phase.
    Duplicate { phase: String },
}

impl CompletenessGap {
    /// One-line, phase-naming description for the `incomplete` render.
    pub(crate) fn describe(&self) -> String {
        match self {
            CompletenessGap::Missing { phase } => {
                format!("completed phase {phase} has no recorded source-delta row")
            }
            CompletenessGap::Extra { phase } => {
                format!("recorded row for {phase}, which is not a completed phase")
            }
            CompletenessGap::Duplicate { phase } => {
                format!("recorded more than one row for phase {phase}")
            }
        }
    }
}

/// Pure cross-check (design F-2): every completed phase id MUST have exactly one
/// recorded row, and every recorded row's phase MUST be completed. `completed`
/// is the set of completed `PHASE-NN` ids; `recorded` is the ordered list of
/// `row.phase` strings (duplicates surfaced). Zero-delta rows (start==end) are
/// not special-cased here — a recorded row is a recorded row; the writer's guard
/// owns range validity. Gaps are reported in a stable order (missing, extra,
/// duplicate) so the message is deterministic. No IO — the reads are the caller's.
pub(crate) fn check_completeness(
    completed: &BTreeSet<String>,
    recorded: &[String],
) -> Completeness {
    let mut counts: std::collections::BTreeMap<&str, u32> = std::collections::BTreeMap::new();
    for phase in recorded {
        *counts.entry(phase.as_str()).or_insert(0) += 1;
    }

    let mut gaps = Vec::new();
    for phase in completed {
        if !counts.contains_key(phase.as_str()) {
            gaps.push(CompletenessGap::Missing {
                phase: phase.clone(),
            });
        }
    }
    for (phase, count) in &counts {
        if !completed.contains(*phase) {
            gaps.push(CompletenessGap::Extra {
                phase: (*phase).to_string(),
            });
        } else if *count > 1 {
            gaps.push(CompletenessGap::Duplicate {
                phase: (*phase).to_string(),
            });
        }
    }

    if gaps.is_empty() {
        Completeness::Complete
    } else {
        Completeness::Incomplete { gaps }
    }
}

/// The set of completed `PHASE-NN` ids for a slice, read from its runtime phase
/// sheets (the same id-derived tree `phase_rollup` reads — the `phases` symlink
/// is never followed). A `.md`-only crash-partial or unreadable `.toml` is not
/// "completed", so it is excluded (and will surface as a `MissingRow` gap if a
/// row was nonetheless recorded — fail-closed). Empty when no phase exists.
pub(crate) fn completed_phase_ids(
    project_root: &Path,
    slice_id: u32,
) -> anyhow::Result<BTreeSet<String>> {
    let dir = phases_dir(project_root, slice_id);
    let mut completed = BTreeSet::new();
    for stem in existing_phase_stems(&dir)? {
        if let Some(status) = read_phase_status(&dir, &stem)?
            && PhaseStatus::from_str(&status, false) == Ok(PhaseStatus::Completed)
        {
            completed.insert(phase_id_from_stem(&stem));
        }
    }
    Ok(completed)
}

/// IO wrapper over [`check_completeness`] (design F-2): reads the recorded rows
/// (from the primary-tree registry, via `cwd`) and the completed phase ids (from
/// the `project_root` state tree), then cross-checks them. The conformance shell
/// invokes ONLY this — no phase-sheet reading leaks into the command/algebra
/// layers. `cwd` resolves the shared registry; `project_root` is the local state
/// tree (they coincide in the primary worktree).
pub(crate) fn registry_completeness(
    cwd: &Path,
    project_root: &Path,
    slice_id: u32,
) -> anyhow::Result<Completeness> {
    let recorded: Vec<String> = read_source_deltas(cwd, slice_id)?
        .into_iter()
        .map(|row| row.phase)
        .collect();
    let completed = completed_phase_ids(project_root, slice_id)?;
    Ok(check_completeness(&completed, &recorded))
}

// ---------------------------------------------------------------------------
// Composite phase-truth resolver (SL-190 PHASE-01) — pure core
// ---------------------------------------------------------------------------

/// Per-phase "landed" signal: does a durable/derived artifact say this phase's
/// source delta is already in? `RefPresent` is the committed `phase/<slice>-NN`
/// git ref (durable, cross-machine); `CachePresent` is the primary-local
/// registry cache row (covers the mid-drive window before the refs are cut at
/// prepare-review); `Absent` is neither. Only presence matters to the resolver —
/// both present variants read as landed (`is_landed`). See design § "Canonical
/// source of truth".
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LandedSignal {
    RefPresent,
    CachePresent,
    Absent,
}

impl LandedSignal {
    /// Whether either landed source (durable ref or derived cache) is present.
    pub(crate) fn is_landed(self) -> bool {
        matches!(self, Self::RefPresent | Self::CachePresent)
    }
}

/// Which shape a CONFLICT takes (design total table): a phase reads LANDED yet a
/// live coord sheet disagrees. `Rework` = the sheet is back to
/// `in_progress`/`blocked` (active re-dispatch); `ReworkReset` = the sheet is
/// `planned` again (reset to the start). Surfaced, never silently resolved.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ConflictKind {
    Rework,
    ReworkReset,
}

/// The composite per-phase truth — exactly the design total table's outcomes.
/// `InFlight`/`Local` carry the deciding sheet's status string. `Unknown` is the
/// total function's catch-all (no source has a usable opinion, incl. phase-set
/// drift). `Anomaly` mirrors `PhaseRollup::anomalies` (malformed tracking).
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum PhaseTruth {
    Landed,
    Conflict(ConflictKind),
    InFlight(String),
    Planned,
    Local(String),
    Anomaly,
    Unknown,
}

/// Cross-source disagreement summary for a slice's phases. `assert_worthy` is
/// the `--assert` gate: true ONLY on a CONFLICT or an opinionated disagreement
/// (two sources holding differing non-anomaly, non-unknown statuses). Anomaly,
/// Unknown, and phase-set mismatch are surfaced (for the human table) but do NOT
/// arm `--assert` (design "--assert scope") — otherwise a fresh handoff machine
/// is permanently red.
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub(crate) struct Divergence {
    pub(crate) conflicts: Vec<String>,
    pub(crate) disagreements: Vec<String>,
    pub(crate) anomalies: Vec<String>,
    pub(crate) unknown: Vec<String>,
    pub(crate) phase_set_mismatch: Vec<String>,
}

impl Divergence {
    /// The `--assert` predicate: conflicts and opinionated disagreements only.
    pub(crate) fn assert_worthy(&self) -> bool {
        !self.conflicts.is_empty() || !self.disagreements.is_empty()
    }
}

/// One sheet's concrete opinion: a parseable `PhaseStatus`, or `Anomaly` for an
/// unrecognised status string / `.md`-only crash-partial (mirrors how
/// `fold_rollup` buckets `unknown`/`missing_toml`).
enum Opinion {
    Status(PhaseStatus),
    Anomaly,
}

/// Interpret a per-phase `StemStatus` as a sheet opinion. Reuses
/// `PhaseStatus::from_str` — the single status vocabulary (no parallel list).
fn opinion(stem: &StemStatus<'_>) -> Opinion {
    match stem {
        StemStatus::MissingToml => Opinion::Anomaly,
        StemStatus::Toml(s) => match PhaseStatus::from_str(s, false) {
            Ok(ps) => Opinion::Status(ps),
            Err(_) => Opinion::Anomaly,
        },
    }
}

/// The landed signal for `id`, defaulting to `Absent` when the phase is absent
/// from the landed map.
fn landed_at(landed: &[(String, LandedSignal)], id: &str) -> LandedSignal {
    landed
        .iter()
        .find(|(k, _)| k.as_str() == id)
        .map_or(LandedSignal::Absent, |(_, v)| *v)
}

/// The sheet opinion for `id` in a per-phase status map, or `None` when the
/// phase is absent from that map (phase-set drift).
fn opinion_at(map: &[(String, StemStatus<'_>)], id: &str) -> Option<Opinion> {
    map.iter()
        .find(|(k, _)| k.as_str() == id)
        .map(|(_, v)| opinion(v))
}

/// Resolve composite per-phase truth from the three source maps — **pure and
/// total** (no git/disk/clock/rng; the shell gathers the inputs). Iterates the
/// deterministic union of phase ids across all maps, so no phase is ever
/// dropped; a phase with no usable opinion falls through to `Unknown`.
///
/// `coord` is `Option`: `None` means **no coord tree at all** (→ the LOCAL
/// branch, design table's "(no coord tree)" row); `Some(map)` with the phase
/// absent means **coord tree present but this phase unknown to it** (→ `Unknown`
/// with a phase-set-mismatch, the plan-drift row). That distinction is
/// load-bearing. Anomaly rows suppress divergence (mirroring
/// `PhaseRollup::anomalies`).
pub(crate) fn resolve_phase_truth(
    landed: &[(String, LandedSignal)],
    coord: Option<&[(String, StemStatus<'_>)]>,
    local: &[(String, StemStatus<'_>)],
) -> (Vec<(String, PhaseTruth)>, Divergence) {
    // Deterministic union of phase ids across every supplied map (BTreeSet).
    let mut ids: BTreeSet<String> = BTreeSet::new();
    ids.extend(landed.iter().map(|(k, _)| k.clone()));
    if let Some(coord) = coord {
        ids.extend(coord.iter().map(|(k, _)| k.clone()));
    }
    ids.extend(local.iter().map(|(k, _)| k.clone()));

    let mut out = Vec::with_capacity(ids.len());
    let mut div = Divergence::default();

    for id in ids {
        let landed_here = landed_at(landed, &id);
        let truth = match coord {
            // No coord tree at all → LANDED if landed, else the LOCAL sheet.
            None => {
                if landed_here.is_landed() {
                    PhaseTruth::Landed
                } else {
                    match opinion_at(local, &id) {
                        Some(Opinion::Status(s)) => PhaseTruth::Local(s.as_str().to_string()),
                        Some(Opinion::Anomaly) => {
                            div.anomalies.push(id.clone());
                            PhaseTruth::Anomaly
                        }
                        // Known only to the landed map (which says absent) — an
                        // orphan with no sheet. Total catch-all → Unknown.
                        None => {
                            div.phase_set_mismatch.push(id.clone());
                            div.unknown.push(id.clone());
                            PhaseTruth::Unknown
                        }
                    }
                }
            }
            // Coord tree present.
            Some(coord_map) => match opinion_at(coord_map, &id) {
                // Phase absent from the coord tree → plan drift → Unknown.
                None => {
                    div.phase_set_mismatch.push(id.clone());
                    div.unknown.push(id.clone());
                    PhaseTruth::Unknown
                }
                Some(coord_op) => match (landed_here.is_landed(), coord_op) {
                    (true, Opinion::Status(PhaseStatus::Completed)) => PhaseTruth::Landed,
                    (true, Opinion::Status(PhaseStatus::InProgress | PhaseStatus::Blocked)) => {
                        div.conflicts.push(id.clone());
                        PhaseTruth::Conflict(ConflictKind::Rework)
                    }
                    (true, Opinion::Status(PhaseStatus::Planned)) => {
                        div.conflicts.push(id.clone());
                        PhaseTruth::Conflict(ConflictKind::ReworkReset)
                    }
                    // Landed but the coord sheet is malformed: landed stands,
                    // anomaly noted, divergence suppressed.
                    (true, Opinion::Anomaly) => {
                        div.anomalies.push(id.clone());
                        PhaseTruth::Landed
                    }
                    (
                        false,
                        Opinion::Status(
                            s @ (PhaseStatus::InProgress
                            | PhaseStatus::Blocked
                            | PhaseStatus::Completed),
                        ),
                    ) => {
                        note_disagreement(local, &id, s, &mut div);
                        PhaseTruth::InFlight(s.as_str().to_string())
                    }
                    (false, Opinion::Status(PhaseStatus::Planned)) => {
                        note_disagreement(local, &id, PhaseStatus::Planned, &mut div);
                        PhaseTruth::Planned
                    }
                    // Not landed and the coord sheet is malformed → anomaly.
                    (false, Opinion::Anomaly) => {
                        div.anomalies.push(id.clone());
                        PhaseTruth::Anomaly
                    }
                },
            },
        };
        out.push((id, truth));
    }

    (out, div)
}

/// Record an opinionated disagreement when the local sheet holds a differing
/// concrete status from the (non-landed) coord sheet. Anomaly/absent local
/// sheets never disagree (anomaly suppresses divergence).
fn note_disagreement(
    local: &[(String, StemStatus<'_>)],
    id: &str,
    coord_status: PhaseStatus,
    div: &mut Divergence,
) {
    if let Some(Opinion::Status(local_status)) = opinion_at(local, id)
        && local_status != coord_status
    {
        div.disagreements.push(id.to_string());
    }
}

/// Per-phase status reader: mirrors `phase_rollup`'s IO (`phases_dir` +
/// `existing_phase_stems` + `read_phase_status`) but keeps phases **distinct**
/// instead of folding to bucket counts (SL-190 EX-4). Returns, per phase, its
/// canonical `PHASE-NN` id (via `phase_id_from_stem`) and `Option<status>` —
/// `None` for a `.md`-only / unparseable `.toml`. The caller maps
/// `Some → StemStatus::Toml` / `None → StemStatus::MissingToml`. Order is the
/// deterministic `existing_phase_stems` (`BTreeSet`) order.
pub(crate) fn read_phase_statuses(
    project_root: &Path,
    slice_id: u32,
) -> anyhow::Result<Vec<(String, Option<String>)>> {
    let dir = phases_dir(project_root, slice_id);
    let mut out = Vec::new();
    for stem in existing_phase_stems(&dir)? {
        let status = read_phase_status(&dir, &stem)?;
        out.push((phase_id_from_stem(&stem), status));
    }
    Ok(out)
}

// ---------------------------------------------------------------------------
// Reconcile writer (SL-190 PHASE-03) — status-only, no registry side effects
// ---------------------------------------------------------------------------

/// What the reconcile writer should do with one phase, given its resolved
/// composite [`PhaseTruth`] (SL-190 EX-4). Distinguishes an enforced status
/// (`Write`) from the two silences: `Skip` (a disagreement the verb must not
/// auto-resolve — reported, never written) and `Leave` (the composite has no
/// opinion, so a locally-set status survives untouched).
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ReconcileAction {
    /// Composite has an opinion — write this status to the primary sheet.
    Write(PhaseStatus),
    /// CONFLICT / UNKNOWN / anomaly — skip and report, never auto-written.
    Skip,
    /// Composite is silent (a `Local`-only phase) — leave the sheet as-is.
    Leave,
}

/// Map a resolved [`PhaseTruth`] to the reconcile writer's action (SL-190 EX-4,
/// design "Reconcile conflict rule"). `Landed` writes `completed`; `InFlight(s)`
/// writes `s`; `Planned` writes `planned`; `Conflict` / `Unknown` / `Anomaly` are
/// SKIPPED (reported, not written); `Local` is LEFT (the composite is silent — a
/// locally-completed inline phase absent from the landed oracle must never be
/// regressed). Pure and total; the `InFlight` status string is re-parsed through
/// the single `PhaseStatus` vocabulary (an unrecognised string degrades to skip).
pub(crate) fn reconcile_action(truth: &PhaseTruth) -> ReconcileAction {
    match truth {
        PhaseTruth::Landed => ReconcileAction::Write(PhaseStatus::Completed),
        PhaseTruth::InFlight(s) => match PhaseStatus::from_str(s, false) {
            Ok(ps) => ReconcileAction::Write(ps),
            Err(_) => ReconcileAction::Skip,
        },
        PhaseTruth::Planned => ReconcileAction::Write(PhaseStatus::Planned),
        PhaseTruth::Conflict(_) | PhaseTruth::Unknown | PhaseTruth::Anomaly => {
            ReconcileAction::Skip
        }
        PhaseTruth::Local(_) => ReconcileAction::Leave,
    }
}

/// Status-only reconcile writer (SL-190 PHASE-03, EX-1). Sets ONLY `status` (and
/// stamps `last_updated`) on the phase sheet — NO `[[progress]]` audit row, NO
/// `capture_phase_boundary`, NO `record_source_delta`/`forget_source_delta`.
/// Unlike [`set_phase_status`] it must NOT mutate the source-delta registry it is
/// reconciled FROM: doing so would be a read-then-clobber-your-own-truth cycle
/// (design "Reconcile writer"). Shares the edit-preserving skeleton via
/// [`edit_phase_sheet`] (DRY).
///
/// Idempotent by construction: when the sheet already holds `status` the write is
/// skipped entirely (returns `Ok(false)`), so a re-run leaves the sheet
/// byte-identical — no `last_updated` churn. Returns `Ok(true)` iff it wrote.
/// `now` is supplied by the shell (the clock stays out of this layer).
pub(crate) fn reconcile_phase_status(
    project_root: &Path,
    slice_id: u32,
    phase_id: &str,
    status: PhaseStatus,
    now: &str,
) -> anyhow::Result<bool> {
    let stem = phase_stem(phase_id)?;
    let dir = phases_dir(project_root, slice_id);
    // Idempotence: a phase already at its target status is a no-op — skip the
    // write so a second reconcile is byte-identical (no last_updated restamp).
    if read_phase_status(&dir, &stem)?.as_deref() == Some(status.as_str()) {
        return Ok(false);
    }
    edit_phase_sheet(project_root, slice_id, phase_id, |table| {
        table.insert("status", toml_edit::value(status.as_str()));
        table.insert("last_updated", toml_edit::value(now));
        Ok(())
    })?;
    Ok(true)
}

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

#[cfg(test)]
mod tests {
    use super::*;

    fn plan(ids: &[&str]) -> Plan {
        Plan {
            phases: ids
                .iter()
                .map(|id| PlanPhase {
                    id: (*id).to_string(),
                    name: String::new(),
                    objective: String::new(),
                    entrance_criteria: Vec::new(),
                    exit_criteria: Vec::new(),
                    verification: Vec::new(),
                })
                .collect(),
        }
    }

    /// A slice dir must exist for the symlink refresh to land.
    fn make_slice_dir(root: &Path, slice_id: u32) {
        fs::create_dir_all(root.join(SLICE_DIR).join(format!("{slice_id:03}"))).unwrap();
    }

    // --- phase_stem ---

    #[test]
    fn phase_stem_derives_and_validates() {
        assert_eq!(phase_stem("PHASE-01").unwrap(), "phase-01");
        assert_eq!(phase_stem("PHASE-137").unwrap(), "phase-137");
    }

    #[test]
    fn phase_stem_rejects_malformed_ids() {
        for bad in [
            "",
            "PHASE-",
            "phase-01",
            "PHASE-1a",
            "PHASE-../x",
            "PHASE-0/1",
            ".PHASE-01",
            "P01",
        ] {
            assert!(phase_stem(bad).is_err(), "{bad:?} should be rejected");
        }
    }

    // --- init_phases ---

    #[test]
    fn init_phases_materialises_each_declared_phase() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        make_slice_dir(root, 4);

        let report = init_phases(root, 4, &plan(&["PHASE-01", "PHASE-02"]), false).unwrap();
        assert_eq!(report.created, vec!["PHASE-01", "PHASE-02"]);

        let phases = phases_dir(root, 4);
        for stem in ["phase-01", "phase-02"] {
            assert!(phases.join(format!("{stem}.toml")).is_file());
            assert!(phases.join(format!("{stem}.md")).is_file());
        }
        let tracking = fs::read_to_string(phases.join("phase-01.toml")).unwrap();
        assert!(tracking.contains("phase   = \"PHASE-01\""));
        assert!(tracking.contains("status  = \"planned\""));

        // writes land ONLY under .doctrine/state (the boundary invariant);
        // nothing tracking-shaped under the authored slice dir.
        assert!(!root.join(SLICE_DIR).join("004/phase-01.toml").exists());
    }

    #[test]
    fn init_phases_is_idempotent_and_preserves_edits() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        make_slice_dir(root, 4);
        let p = plan(&["PHASE-01"]);

        init_phases(root, 4, &p, false).unwrap();
        // hand-edit the sheet
        let sheet = phases_dir(root, 4).join("phase-01.md");
        fs::write(&sheet, "EDITED").unwrap();

        let report = init_phases(root, 4, &p, false).unwrap();
        assert!(
            report.created.is_empty(),
            "no re-creation on the no-drift path"
        );
        assert_eq!(
            fs::read_to_string(&sheet).unwrap(),
            "EDITED",
            "edits survive"
        );
    }

    #[test]
    fn init_phases_completes_a_crash_partial_phase() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        make_slice_dir(root, 4);
        let phases = phases_dir(root, 4);
        fs::create_dir_all(&phases).unwrap();
        // simulate a crash after the .toml but before the .md
        fs::write(phases.join("phase-01.toml"), "partial").unwrap();

        let report = init_phases(root, 4, &plan(&["PHASE-01"]), false).unwrap();
        assert_eq!(
            report.created,
            vec!["PHASE-01"],
            "the missing file completes the phase"
        );
        assert!(phases.join("phase-01.md").is_file());
        // the pre-existing .toml is left as-is (per-file skip)
        assert_eq!(
            fs::read_to_string(phases.join("phase-01.toml")).unwrap(),
            "partial"
        );
    }

    #[test]
    fn init_phases_reports_orphans_without_removing_them() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        make_slice_dir(root, 4);

        // start with two phases, then rename PHASE-02 → PHASE-03 in the plan
        init_phases(root, 4, &plan(&["PHASE-01", "PHASE-02"]), false).unwrap();
        let report = init_phases(root, 4, &plan(&["PHASE-01", "PHASE-03"]), false).unwrap();

        assert_eq!(report.created, vec!["PHASE-03"]);
        assert_eq!(report.orphan, vec!["PHASE-02"]);
        assert!(report.pruned.is_empty());
        // orphan tracking is NOT silently removed
        assert!(phases_dir(root, 4).join("phase-02.toml").is_file());
    }

    #[test]
    fn init_phases_rejects_a_malformed_id_before_writing_anything() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        make_slice_dir(root, 4);

        let err = init_phases(root, 4, &plan(&["PHASE-01", "bad", "PHASE-03"]), false).unwrap_err();
        assert!(err.to_string().contains("must match PHASE-<digits>"));
        // the valid earlier phase did not leak — nothing was materialised
        assert!(!phases_dir(root, 4).join("phase-01.toml").exists());
    }

    #[test]
    fn init_phases_prunes_orphans_only_when_asked() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        make_slice_dir(root, 4);

        init_phases(root, 4, &plan(&["PHASE-01", "PHASE-02"]), false).unwrap();
        let report = init_phases(root, 4, &plan(&["PHASE-01"]), true).unwrap();

        assert_eq!(report.pruned, vec!["PHASE-02"]);
        assert!(report.orphan.is_empty());
        let phases = phases_dir(root, 4);
        assert!(!phases.join("phase-02.toml").exists());
        assert!(!phases.join("phase-02.md").exists());
        assert!(phases.join("phase-01.toml").is_file());
    }

    #[test]
    fn init_phases_refreshes_the_convenience_symlink() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        make_slice_dir(root, 4);

        init_phases(root, 4, &plan(&["PHASE-01"]), false).unwrap();
        let link = root.join(SLICE_DIR).join("004/phases");
        // the link must *resolve* to the canonical state dir — not merely string-
        // match a hand-written relative target, which would pass even if the two
        // sides of the convention drifted apart.
        assert_eq!(
            fs::canonicalize(&link).unwrap(),
            fs::canonicalize(phases_dir(root, 4)).unwrap(),
        );
    }

    #[test]
    fn init_phases_creates_the_state_parent_on_demand() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        make_slice_dir(root, 4);
        assert!(!root.join(STATE_SLICE_DIR).exists());

        init_phases(root, 4, &plan(&["PHASE-01"]), false).unwrap();
        assert!(phases_dir(root, 4).is_dir());
    }

    // --- set_phase_status ---

    fn init_one(root: &Path) {
        make_slice_dir(root, 4);
        init_phases(root, 4, &plan(&["PHASE-01"]), false).unwrap();
    }

    #[test]
    fn set_phase_status_sets_fields_and_appends_progress() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        init_one(root);

        set_phase_status(
            root,
            4,
            "PHASE-01",
            PhaseStatus::InProgress,
            Some("kickoff"),
            "2026-06-04T10:00:00Z",
        )
        .unwrap();
        let doc = fs::read_to_string(phases_dir(root, 4).join("phase-01.toml"))
            .unwrap()
            .parse::<toml_edit::DocumentMut>()
            .unwrap();

        assert_eq!(doc["status"].as_str(), Some("in_progress"));
        assert_eq!(doc["started"].as_str(), Some("2026-06-04T10:00:00Z"));
        assert_eq!(doc["last_updated"].as_str(), Some("2026-06-04T10:00:00Z"));
        assert_eq!(doc["completed"].as_str(), Some("")); // not yet
        let progress = doc["progress"].as_array_of_tables().unwrap();
        assert_eq!(progress.len(), 1);
        let row = progress.get(0).unwrap();
        assert_eq!(row["status"].as_str(), Some("in_progress"));
        assert_eq!(row["note"].as_str(), Some("kickoff"));
    }

    #[test]
    fn set_phase_status_stamps_started_once_and_completed_on_completion() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        init_one(root);

        set_phase_status(root, 4, "PHASE-01", PhaseStatus::InProgress, None, "T1").unwrap();
        set_phase_status(root, 4, "PHASE-01", PhaseStatus::InProgress, None, "T2").unwrap();
        set_phase_status(root, 4, "PHASE-01", PhaseStatus::Completed, None, "T3").unwrap();

        let doc = fs::read_to_string(phases_dir(root, 4).join("phase-01.toml"))
            .unwrap()
            .parse::<toml_edit::DocumentMut>()
            .unwrap();
        assert_eq!(
            doc["started"].as_str(),
            Some("T1"),
            "started stamped once, not overwritten"
        );
        assert_eq!(doc["completed"].as_str(), Some("T3"));
        assert_eq!(doc["status"].as_str(), Some("completed"));
        assert_eq!(doc["progress"].as_array_of_tables().unwrap().len(), 3);
    }

    #[test]
    fn set_phase_status_clears_completed_on_reopen() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        init_one(root);

        set_phase_status(root, 4, "PHASE-01", PhaseStatus::Completed, None, "T1").unwrap();
        set_phase_status(root, 4, "PHASE-01", PhaseStatus::InProgress, None, "T2").unwrap();

        let doc = fs::read_to_string(phases_dir(root, 4).join("phase-01.toml"))
            .unwrap()
            .parse::<toml_edit::DocumentMut>()
            .unwrap();
        assert_eq!(doc["status"].as_str(), Some("in_progress"));
        assert_eq!(
            doc["completed"].as_str(),
            Some(""),
            "reopen clears the stale completion stamp"
        );
    }

    #[test]
    fn set_phase_status_preserves_comments_and_unknown_keys() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        init_one(root);
        let path = phases_dir(root, 4).join("phase-01.toml");
        // hand-edit: a comment and an unknown key the tool does not model
        let edited = format!(
            "{}\n# a hand-written note\nowner = \"alice\"\n",
            fs::read_to_string(&path).unwrap()
        );
        fs::write(&path, edited).unwrap();

        set_phase_status(root, 4, "PHASE-01", PhaseStatus::Blocked, None, "T1").unwrap();

        let after = fs::read_to_string(&path).unwrap();
        assert!(after.contains("# a hand-written note"), "comment survives");
        assert!(after.contains("owner = \"alice\""), "unknown key survives");
        assert!(after.contains("status  = \"blocked\"") || after.contains("status = \"blocked\""));
    }

    #[test]
    fn set_phase_status_resolves_by_id_blind_to_the_symlink() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        init_one(root);
        // remove the convenience symlink entirely
        fs::remove_file(root.join(SLICE_DIR).join("004/phases")).unwrap();

        set_phase_status(root, 4, "PHASE-01", PhaseStatus::Completed, None, "T1").unwrap();
        let doc = fs::read_to_string(phases_dir(root, 4).join("phase-01.toml"))
            .unwrap()
            .parse::<toml_edit::DocumentMut>()
            .unwrap();
        assert_eq!(doc["status"].as_str(), Some("completed"));
    }

    #[test]
    fn set_phase_status_errors_when_tracking_absent() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        make_slice_dir(root, 4);
        let err =
            set_phase_status(root, 4, "PHASE-99", PhaseStatus::InProgress, None, "T1").unwrap_err();
        assert!(err.to_string().contains("Phase tracking not found"));
    }

    // --- fold_rollup (pure) ---

    #[test]
    fn fold_rollup_empty_is_all_zero() {
        assert_eq!(fold_rollup(&[]), PhaseRollup::default());
    }

    #[test]
    fn fold_rollup_counts_each_known_status_into_its_bucket() {
        let stems = [
            StemStatus::Toml("completed"),
            StemStatus::Toml("completed"),
            StemStatus::Toml("in_progress"),
            StemStatus::Toml("planned"),
            StemStatus::Toml("blocked"),
        ];
        let r = fold_rollup(&stems);
        assert_eq!(
            r,
            PhaseRollup {
                planned: 1,
                in_progress: 1,
                completed: 2,
                blocked: 1,
                unknown: 0,
                missing_toml: 0,
            }
        );
    }

    #[test]
    fn fold_rollup_buckets_unknown_status_and_missing_toml_separately() {
        let stems = [
            StemStatus::Toml("completed"),
            StemStatus::Toml("garbage"), // typo / outside the enum
            StemStatus::MissingToml,     // .md-only crash-partial
        ];
        let r = fold_rollup(&stems);
        assert_eq!(r.completed, 1);
        assert_eq!(r.unknown, 1);
        assert_eq!(r.missing_toml, 1);
        // every stem is counted — nothing silently dropped (R-F4)
        let counted =
            r.planned + r.in_progress + r.completed + r.blocked + r.unknown + r.missing_toml;
        assert_eq!(counted, 3);
    }

    // --- phase_rollup (IO) ---

    fn write_phase_toml(dir: &Path, stem: &str, status: &str) {
        fs::create_dir_all(dir).unwrap();
        fs::write(
            dir.join(format!("{stem}.toml")),
            format!("status = \"{status}\"\n"),
        )
        .unwrap();
    }

    #[test]
    fn phase_rollup_is_none_when_untracked() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        // no state tree at all
        assert_eq!(phase_rollup(root, 9).unwrap(), None);
        // dir exists but holds no phase files
        fs::create_dir_all(phases_dir(root, 9)).unwrap();
        assert_eq!(phase_rollup(root, 9).unwrap(), None);
    }

    #[test]
    fn phase_rollup_counts_materialised_phases() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let phases = phases_dir(root, 9);
        write_phase_toml(&phases, "phase-01", "completed");
        write_phase_toml(&phases, "phase-02", "completed");
        write_phase_toml(&phases, "phase-03", "in_progress");

        let r = phase_rollup(root, 9).unwrap().unwrap();
        assert_eq!(r.completed, 2);
        assert_eq!(r.in_progress, 1);
    }

    #[test]
    fn phase_rollup_md_only_stem_is_missing_toml_not_dropped() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let phases = phases_dir(root, 9);
        write_phase_toml(&phases, "phase-01", "completed");
        // phase-02 has only its .md sheet (crash-partial) — still a phase
        fs::write(phases.join("phase-02.md"), "# sheet\n").unwrap();

        let r = phase_rollup(root, 9).unwrap().unwrap();
        assert_eq!(r.completed, 1);
        assert_eq!(r.missing_toml, 1);
    }

    #[test]
    fn phase_rollup_unparseable_or_typo_status_is_surfaced() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let phases = phases_dir(root, 9);
        write_phase_toml(&phases, "phase-01", "donezo"); // typo → unknown
        fs::write(phases.join("phase-02.toml"), "this is not = valid toml\n").unwrap();

        let r = phase_rollup(root, 9).unwrap().unwrap();
        assert_eq!(r.unknown, 1, "typo status → unknown");
        assert_eq!(r.missing_toml, 1, "unparseable .toml → missing_toml");
    }

    // --- resolve_phase_truth (pure core, SL-190 PHASE-01) -------------------

    /// VT-1: table-driven over the full design total table — LANDED, CONFLICT
    /// (rework + rework-reset), IN-FLIGHT, PLANNED, LOCAL, ANOMALY, and
    /// phase-set drift. Asserts both the per-phase `PhaseTruth` and, where
    /// relevant, `Divergence.assert_worthy()`.
    #[test]
    fn resolve_phase_truth_covers_full_design_table() {
        let p = || "PHASE-01".to_string();

        // (name, landed, coord-sheet status, expected truth, expected assert).
        // A live coord tree holds `coord_status`; local mirrors it so no
        // spurious opinionated disagreement is recorded on these rows.
        let grid: &[(&str, LandedSignal, &str, PhaseTruth, bool)] = &[
            // LANDED: ref/cache present + coord silent-or-completed.
            (
                "landed+completed",
                LandedSignal::RefPresent,
                "completed",
                PhaseTruth::Landed,
                false,
            ),
            // CONFLICT rework: landed + coord back to in_progress/blocked.
            (
                "landed+in_progress→rework",
                LandedSignal::CachePresent,
                "in_progress",
                PhaseTruth::Conflict(ConflictKind::Rework),
                true,
            ),
            (
                "landed+blocked→rework",
                LandedSignal::RefPresent,
                "blocked",
                PhaseTruth::Conflict(ConflictKind::Rework),
                true,
            ),
            // CONFLICT rework-reset: landed + coord reset to planned.
            (
                "landed+planned→rework-reset",
                LandedSignal::CachePresent,
                "planned",
                PhaseTruth::Conflict(ConflictKind::ReworkReset),
                true,
            ),
            // LANDED + anomaly-noted: coord sheet malformed, divergence suppressed.
            (
                "landed+garbage→landed(anomaly-noted)",
                LandedSignal::RefPresent,
                "garbage",
                PhaseTruth::Landed,
                false,
            ),
            // IN-FLIGHT: not landed, coord actively working.
            (
                "absent+in_progress→in-flight",
                LandedSignal::Absent,
                "in_progress",
                PhaseTruth::InFlight("in_progress".into()),
                false,
            ),
            // PLANNED: not landed, coord planned.
            (
                "absent+planned→planned",
                LandedSignal::Absent,
                "planned",
                PhaseTruth::Planned,
                false,
            ),
            // ANOMALY: not landed, coord sheet malformed — surface, suppress.
            (
                "absent+garbage→anomaly",
                LandedSignal::Absent,
                "garbage",
                PhaseTruth::Anomaly,
                false,
            ),
        ];

        for (name, landed, coord_status, want, want_assert) in grid {
            let coord_status: &str = coord_status;
            let landed_map = vec![(p(), *landed)];
            let coord_map = vec![(p(), StemStatus::Toml(coord_status))];
            let local_map = vec![(p(), StemStatus::Toml(coord_status))];
            let (truths, div) = resolve_phase_truth(&landed_map, Some(&coord_map), &local_map);
            assert_eq!(truths, vec![(p(), want.clone())], "PhaseTruth for {name}");
            assert_eq!(
                div.assert_worthy(),
                *want_assert,
                "assert_worthy for {name}"
            );
        }

        // ANOMALY via a `.md`-only crash-partial (MissingToml), landed + absent.
        {
            let landed_map = vec![(p(), LandedSignal::RefPresent)];
            let coord_map = vec![(p(), StemStatus::MissingToml)];
            let local_map = vec![(p(), StemStatus::MissingToml)];
            let (truths, div) = resolve_phase_truth(&landed_map, Some(&coord_map), &local_map);
            assert_eq!(truths, vec![(p(), PhaseTruth::Landed)]);
            assert!(!div.assert_worthy(), "anomaly suppresses divergence");
        }
        {
            let landed_map = vec![(p(), LandedSignal::Absent)];
            let coord_map = vec![(p(), StemStatus::MissingToml)];
            let local_map = vec![(p(), StemStatus::MissingToml)];
            let (truths, div) = resolve_phase_truth(&landed_map, Some(&coord_map), &local_map);
            assert_eq!(truths, vec![(p(), PhaseTruth::Anomaly)]);
            assert!(!div.assert_worthy());
            assert!(div.anomalies.contains(&p()), "anomaly surfaced in summary");
        }

        // LANDED with NO coord tree (design "(none)" row).
        {
            let landed_map = vec![(p(), LandedSignal::RefPresent)];
            let local_map = vec![(p(), StemStatus::Toml("planned"))];
            let (truths, _div) = resolve_phase_truth(&landed_map, None, &local_map);
            assert_eq!(truths, vec![(p(), PhaseTruth::Landed)]);
        }

        // LOCAL: not landed, NO coord tree → the local sheet's status.
        {
            let landed_map = vec![(p(), LandedSignal::Absent)];
            let local_map = vec![(p(), StemStatus::Toml("in_progress"))];
            let (truths, div) = resolve_phase_truth(&landed_map, None, &local_map);
            assert_eq!(truths, vec![(p(), PhaseTruth::Local("in_progress".into()))]);
            assert!(!div.assert_worthy(), "LOCAL alone is not assert-worthy");
        }

        // Opinionated disagreement (no landed): coord vs local differ → assert.
        {
            let landed_map = vec![(p(), LandedSignal::Absent)];
            let coord_map = vec![(p(), StemStatus::Toml("in_progress"))];
            let local_map = vec![(p(), StemStatus::Toml("completed"))];
            let (truths, div) = resolve_phase_truth(&landed_map, Some(&coord_map), &local_map);
            assert_eq!(
                truths,
                vec![(p(), PhaseTruth::InFlight("in_progress".into()))]
            );
            assert!(
                div.assert_worthy(),
                "coord vs local disagreement arms --assert"
            );
        }

        // Phase-set drift: coord tree present but this phase absent from it.
        {
            let q = || "PHASE-02".to_string();
            let landed_map = vec![(q(), LandedSignal::Absent)];
            let coord_map: Vec<(String, StemStatus<'_>)> = vec![]; // present tree, phase absent
            let local_map = vec![(q(), StemStatus::Toml("planned"))];
            let (truths, div) = resolve_phase_truth(&landed_map, Some(&coord_map), &local_map);
            assert_eq!(
                truths,
                vec![(q(), PhaseTruth::Unknown)],
                "phase-set drift → Unknown, never dropped"
            );
            assert!(
                div.phase_set_mismatch.contains(&q()),
                "phase-set mismatch surfaced"
            );
            assert!(div.unknown.contains(&q()), "Unknown recorded in summary");
            assert!(
                !div.assert_worthy(),
                "phase-set drift does NOT arm --assert"
            );
        }

        // Union is deterministic (sorted) and drops no phase.
        {
            let landed_map = vec![
                ("PHASE-02".to_string(), LandedSignal::RefPresent),
                ("PHASE-01".to_string(), LandedSignal::Absent),
            ];
            let coord_map = vec![
                ("PHASE-01".to_string(), StemStatus::Toml("in_progress")),
                ("PHASE-02".to_string(), StemStatus::Toml("completed")),
            ];
            let local_map = vec![
                ("PHASE-01".to_string(), StemStatus::Toml("in_progress")),
                ("PHASE-02".to_string(), StemStatus::Toml("completed")),
            ];
            let (truths, _div) = resolve_phase_truth(&landed_map, Some(&coord_map), &local_map);
            let ids: Vec<&str> = truths.iter().map(|(id, _)| id.as_str()).collect();
            assert_eq!(ids, vec!["PHASE-01", "PHASE-02"], "deterministic union");
            assert_eq!(truths.len(), 2, "no phase dropped");
        }
    }

    /// VT-2: the per-phase reader returns each phase's status without collapsing
    /// to counts — its pairs map to the right `StemStatus` per phase.
    #[test]
    fn read_phase_statuses_keeps_phases_distinct() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let phases = phases_dir(root, 9);
        write_phase_toml(&phases, "phase-01", "completed");
        write_phase_toml(&phases, "phase-03", "in_progress");
        // phase-02 has only its .md sheet (crash-partial) → MissingToml.
        fs::write(phases.join("phase-02.md"), "# sheet\n").unwrap();

        let got = read_phase_statuses(root, 9).unwrap();
        // The caller's mapping: Some → Toml, None → MissingToml.
        let as_stem: Vec<(String, StemStatus<'_>)> = got
            .iter()
            .map(|(id, s)| {
                (
                    id.clone(),
                    match s {
                        Some(status) => StemStatus::Toml(status.as_str()),
                        None => StemStatus::MissingToml,
                    },
                )
            })
            .collect();

        assert_eq!(
            as_stem,
            vec![
                ("PHASE-01".to_string(), StemStatus::Toml("completed")),
                ("PHASE-02".to_string(), StemStatus::MissingToml),
                ("PHASE-03".to_string(), StemStatus::Toml("in_progress")),
            ],
            "each phase's StemStatus preserved without collapsing to counts"
        );
    }

    // --- recorded source-delta registry (SL-147 PHASE-02) ------------------

    /// Run git in `dir` with a pinned identity; panic on failure, return stdout.
    fn git(dir: &Path, args: &[&str]) -> String {
        let out = std::process::Command::new("git")
            .arg("-C")
            .arg(dir)
            .args(args)
            .env("GIT_AUTHOR_NAME", "Doctrine Test")
            .env("GIT_AUTHOR_EMAIL", "test@doctrine.invalid")
            .env("GIT_COMMITTER_NAME", "Doctrine Test")
            .env("GIT_COMMITTER_EMAIL", "test@doctrine.invalid")
            .output()
            .expect("spawn git");
        assert!(
            out.status.success(),
            "git {args:?} failed: {}",
            String::from_utf8_lossy(&out.stderr).trim()
        );
        String::from_utf8_lossy(&out.stdout).trim().to_string()
    }

    /// A fresh repo with one commit on `main`; returns its canonical path.
    fn init_repo(dir: &Path) -> PathBuf {
        fs::create_dir_all(dir).unwrap();
        git(dir, &["init", "-q", "-b", "main"]);
        git(dir, &["commit", "-q", "--allow-empty", "-m", "root"]);
        fs::canonicalize(dir).unwrap()
    }

    // Generic registry-row helper: provenance is irrelevant to the upsert/round-trip
    // contracts these tests assert, so it carries the neutral `Unknown`. The merge
    // tests below construct rows with explicit provenance instead.
    fn row(phase: &str, start: &str, end: &str) -> BoundaryRow {
        BoundaryRow {
            phase: phase.into(),
            code_start_oid: start.into(),
            code_end_oid: end.into(),
            provenance: Provenance::Unknown,
        }
    }

    // VT-2: round-trip — write rows then read them back identically.
    #[test]
    fn source_deltas_round_trip_through_disk() {
        let tmp = tempfile::tempdir().unwrap();
        let repo = init_repo(&tmp.path().join("repo"));
        let a = git(&repo, &["rev-parse", "HEAD"]);
        git(&repo, &["commit", "-q", "--allow-empty", "-m", "two"]);
        let head = git(&repo, &["rev-parse", "HEAD"]);

        // Absent file reads as empty, not an error.
        assert!(read_source_deltas(&repo, 147).unwrap().is_empty());

        record_source_delta(&repo, 147, row("PHASE-01", &a, &a)).unwrap();
        record_source_delta(&repo, 147, row("PHASE-02", &a, &head)).unwrap();

        let rows = read_source_deltas(&repo, 147).unwrap();
        assert_eq!(
            rows,
            vec![row("PHASE-01", &a, &a), row("PHASE-02", &a, &head)]
        );
    }

    // VT-2: the file lands under the slice's runtime tree with `[[boundary]]`.
    #[test]
    fn source_deltas_path_is_slice_scoped_runtime_state() {
        let tmp = tempfile::tempdir().unwrap();
        let repo = init_repo(&tmp.path().join("repo"));
        let head = git(&repo, &["rev-parse", "HEAD"]);
        record_source_delta(&repo, 147, row("PHASE-01", &head, &head)).unwrap();

        let path = boundaries_path(&repo, 147).unwrap();
        assert!(
            path.ends_with(".doctrine/state/slice/147/boundaries.toml"),
            "{path:?}"
        );
        let text = fs::read_to_string(&path).unwrap();
        assert!(text.contains("[[boundary]]"), "table header: {text}");
        assert!(text.contains("phase = \"PHASE-01\""), "{text}");
    }

    // VT-1: a re-record of the same phase UPSERTs (replaces) rather than dupes.
    #[test]
    fn record_upserts_by_phase() {
        let tmp = tempfile::tempdir().unwrap();
        let repo = init_repo(&tmp.path().join("repo"));
        let a = git(&repo, &["rev-parse", "HEAD"]);
        git(&repo, &["commit", "-q", "--allow-empty", "-m", "two"]);
        let head = git(&repo, &["rev-parse", "HEAD"]);

        record_source_delta(&repo, 147, row("PHASE-01", &a, &a)).unwrap();
        record_source_delta(&repo, 147, row("PHASE-01", &a, &head)).unwrap();

        let rows = read_source_deltas(&repo, 147).unwrap();
        assert_eq!(rows, vec![row("PHASE-01", &a, &head)], "one row, replaced");
    }

    // VT-2: the sticky provenance merge is keyed on the INCOMING row and runs
    // INSIDE record_source_delta's RMW (atomic, not a caller pre-read). Landing
    // writers (Solo/Funnel) are authoritative; Manual never reclassifies an
    // existing path (incl. a legacy Unknown); a fresh phase stores the incoming
    // value as-is. The range upsert is unaffected.
    #[test]
    fn record_source_delta_merges_provenance_keyed_on_incoming() {
        let tmp = tempfile::tempdir().unwrap();
        let repo = init_repo(&tmp.path().join("repo"));
        let a = git(&repo, &["rev-parse", "HEAD"]);
        git(&repo, &["commit", "-q", "--allow-empty", "-m", "two"]);
        let b = git(&repo, &["rev-parse", "HEAD"]);

        let prov_row = |phase: &str, p: Provenance| BoundaryRow {
            phase: phase.into(),
            code_start_oid: a.clone(),
            code_end_oid: b.clone(),
            provenance: p,
        };
        let recorded = |phase: &str| -> Provenance {
            read_source_deltas(&repo, 200)
                .unwrap()
                .into_iter()
                .find(|r| r.phase == phase)
                .expect("row present")
                .provenance
        };

        // Incoming Manual preserves any existing landing path (a correction never
        // reclassifies) — including a legacy Unknown.
        for (phase, existing) in [
            ("PHASE-01", Provenance::Funnel),
            ("PHASE-02", Provenance::Solo),
            ("PHASE-03", Provenance::Unknown),
        ] {
            record_source_delta(&repo, 200, prov_row(phase, existing)).unwrap();
            record_source_delta(&repo, 200, prov_row(phase, Provenance::Manual)).unwrap();
            assert_eq!(
                recorded(phase),
                existing,
                "Manual must not reclassify {existing:?}"
            );
        }

        // A fresh phase (no existing row) stores the incoming value as-is.
        record_source_delta(&repo, 200, prov_row("PHASE-04", Provenance::Manual)).unwrap();
        assert_eq!(
            recorded("PHASE-04"),
            Provenance::Manual,
            "fresh row keeps incoming"
        );

        // A landing writer is authoritative and overwrites any existing path.
        record_source_delta(&repo, 200, prov_row("PHASE-05", Provenance::Solo)).unwrap();
        record_source_delta(&repo, 200, prov_row("PHASE-05", Provenance::Funnel)).unwrap();
        assert_eq!(
            recorded("PHASE-05"),
            Provenance::Funnel,
            "incoming Funnel overwrites"
        );

        record_source_delta(&repo, 200, prov_row("PHASE-06", Provenance::Funnel)).unwrap();
        record_source_delta(&repo, 200, prov_row("PHASE-06", Provenance::Solo)).unwrap();
        assert_eq!(
            recorded("PHASE-06"),
            Provenance::Solo,
            "incoming Solo overwrites"
        );
    }

    // VT-1 (SL-189): single_commit_boundary derives [S^, S] for a linear
    // non-merge commit, and ERRORS on BOTH a merge commit (>1 parent) and a root
    // commit (0 parents) — neither has a single "own patch".
    #[test]
    fn single_commit_boundary_scopes_own_patch_rejects_merge_and_root() {
        let tmp = tempfile::tempdir().unwrap();
        let repo = init_repo(&tmp.path().join("repo"));
        let root_oid = git(&repo, &["rev-parse", "HEAD"]);

        // Linear non-merge S: its parent is the root commit → [root, S].
        git(&repo, &["commit", "-q", "--allow-empty", "-m", "S"]);
        let s = git(&repo, &["rev-parse", "HEAD"]);

        let built = single_commit_boundary(&repo, &s, Provenance::Manual, "PHASE-01").unwrap();
        assert_eq!(built.code_start_oid, root_oid, "start = S^");
        assert_eq!(built.code_end_oid, s, "end = S");
        assert_eq!(built.phase, "PHASE-01");
        assert_eq!(built.provenance, Provenance::Manual);

        // Merge commit → rejected (two parents, no single own patch).
        git(&repo, &["checkout", "-q", "-b", "side", &root_oid]);
        git(&repo, &["commit", "-q", "--allow-empty", "-m", "side"]);
        git(&repo, &["checkout", "-q", "main"]);
        git(&repo, &["merge", "-q", "--no-ff", "--no-edit", "side"]);
        let merge = git(&repo, &["rev-parse", "HEAD"]);
        assert!(
            single_commit_boundary(&repo, &merge, Provenance::Manual, "PHASE-01").is_err(),
            "merge commit (>1 parent) → rejected"
        );

        // Root commit → rejected (no parent).
        assert!(
            single_commit_boundary(&repo, &root_oid, Provenance::Manual, "PHASE-01").is_err(),
            "root commit (0 parents) → rejected"
        );
    }

    // VT-3: forget_source_delta evicts one phase's row (the reopen-eviction
    // sibling of record_source_delta — design D8, caller is PHASE-03). Record →
    // forget → absent (Ok(true)); forget on an absent phase, or an absent
    // registry file, is a no-op Ok(false), never an error. Siblings survive.
    #[test]
    fn forget_source_delta_evicts_then_is_idempotent() {
        let tmp = tempfile::tempdir().unwrap();
        let repo = init_repo(&tmp.path().join("repo"));
        let a = git(&repo, &["rev-parse", "HEAD"]);

        record_source_delta(&repo, 200, row("PHASE-01", &a, &a)).unwrap();
        record_source_delta(&repo, 200, row("PHASE-02", &a, &a)).unwrap();

        // Evict a present phase → true, only its row gone.
        assert!(forget_source_delta(&repo, 200, "PHASE-01").unwrap());
        assert_eq!(
            read_source_deltas(&repo, 200).unwrap(),
            vec![row("PHASE-02", &a, &a)],
            "only PHASE-01 evicted"
        );

        // Re-forgetting the now-absent phase → false, no error (idempotent).
        assert!(!forget_source_delta(&repo, 200, "PHASE-01").unwrap());

        // Forget against an entirely absent registry file → false, no error.
        assert!(!forget_source_delta(&repo, 999, "PHASE-01").unwrap());
    }

    // VT-2 resolver: a record from a LINKED worktree lands the row in the
    // PRIMARY tree's file (the cross-worktree shared-file contract).
    #[test]
    fn record_from_linked_worktree_targets_primary_tree() {
        let tmp = tempfile::tempdir().unwrap();
        let primary = init_repo(&tmp.path().join("primary"));
        let head = git(&primary, &["rev-parse", "HEAD"]);
        let fork = tmp.path().join("fork");
        git(
            &primary,
            &[
                "worktree",
                "add",
                "-q",
                "-b",
                "feat",
                fork.to_str().unwrap(),
            ],
        );
        let fork = fs::canonicalize(&fork).unwrap();

        // Record from the LINKED worktree.
        record_source_delta(&fork, 147, row("PHASE-01", &head, &head)).unwrap();

        // The file exists under the PRIMARY tree, NOT the fork.
        assert!(
            primary
                .join(".doctrine/state/slice/147/boundaries.toml")
                .exists()
        );
        assert!(
            !fork
                .join(".doctrine/state/slice/147/boundaries.toml")
                .exists()
        );
        // And reads back from either worktree (same resolved path).
        assert_eq!(
            read_source_deltas(&fork, 147).unwrap(),
            vec![row("PHASE-01", &head, &head)]
        );
        assert_eq!(
            read_source_deltas(&primary, 147).unwrap(),
            vec![row("PHASE-01", &head, &head)]
        );
    }

    // VT-1 guard: a non-ancestor (start NOT before end) range is rejected.
    #[test]
    fn guard_rejects_non_ancestor_range() {
        let tmp = tempfile::tempdir().unwrap();
        let repo = init_repo(&tmp.path().join("repo"));
        let a = git(&repo, &["rev-parse", "HEAD"]);
        git(&repo, &["commit", "-q", "--allow-empty", "-m", "two"]);
        let head = git(&repo, &["rev-parse", "HEAD"]);

        // start = later commit, end = earlier → not an ancestor.
        let err = record_source_delta(&repo, 147, row("PHASE-01", &head, &a)).unwrap_err();
        assert!(format!("{err:#}").contains("not an ancestor"), "{err:#}");
        // Nothing was persisted.
        assert!(read_source_deltas(&repo, 147).unwrap().is_empty());
    }

    // VT-1 guard: a merge commit as code_end is rejected.
    #[test]
    fn guard_rejects_merge_code_end() {
        let tmp = tempfile::tempdir().unwrap();
        let repo = init_repo(&tmp.path().join("repo"));
        let base = git(&repo, &["rev-parse", "HEAD"]);
        // Branch A.
        git(&repo, &["commit", "-q", "--allow-empty", "-m", "a"]);
        let a = git(&repo, &["rev-parse", "HEAD"]);
        // Branch B off base.
        git(&repo, &["checkout", "-q", "-b", "side", &base]);
        git(&repo, &["commit", "-q", "--allow-empty", "-m", "b"]);
        // Merge B into A → a 2-parent merge tip.
        git(&repo, &["checkout", "-q", "main"]);
        git(&repo, &["merge", "-q", "--no-ff", "--no-edit", "side"]);
        let merge = git(&repo, &["rev-parse", "HEAD"]);
        assert_eq!(crate::git::parents(&repo, &merge).unwrap().len(), 2);

        let err = record_source_delta(&repo, 147, row("PHASE-01", &a, &merge)).unwrap_err();
        assert!(format!("{err:#}").contains("merge commit"), "{err:#}");
        assert!(read_source_deltas(&repo, 147).unwrap().is_empty());
    }

    // VT-1 guard: a valid ancestor + non-merge pair is accepted and persisted.
    #[test]
    fn guard_accepts_valid_ancestor_non_merge_pair() {
        let tmp = tempfile::tempdir().unwrap();
        let repo = init_repo(&tmp.path().join("repo"));
        let a = git(&repo, &["rev-parse", "HEAD"]);
        git(&repo, &["commit", "-q", "--allow-empty", "-m", "two"]);
        let head = git(&repo, &["rev-parse", "HEAD"]);
        assert!(crate::git::parents(&repo, &head).unwrap().len() <= 1);

        record_source_delta(&repo, 147, row("PHASE-01", &a, &head)).unwrap();
        assert_eq!(
            read_source_deltas(&repo, 147).unwrap(),
            vec![row("PHASE-01", &a, &head)]
        );
    }

    // VT-2: a not-a-repo cwd surfaces a clean named error, never a panic.
    #[test]
    fn record_in_non_repo_is_a_named_error() {
        let tmp = tempfile::tempdir().unwrap();
        let err = record_source_delta(tmp.path(), 147, row("PHASE-01", "x", "y")).unwrap_err();
        let msg = format!("{err:#}");
        assert!(!msg.is_empty(), "named error, not a panic: {msg}");
    }

    // --- solo phase-binding capture (SL-147 PHASE-04, T1) ------------------

    /// A phase sheet under a repo's runtime tree, ready for `set_phase_status`.
    fn seed_phase_sheet(repo: &Path, slice: u32, stem: &str) {
        let dir = phases_dir(repo, slice);
        fs::create_dir_all(&dir).unwrap();
        fs::write(
            dir.join(format!("{stem}.toml")),
            "status = \"planned\"\nstarted = \"\"\ncompleted = \"\"\n",
        )
        .unwrap();
    }

    // VT-1/VT-3: in_progress → completed records the boundary deterministically; a
    // reopen EVICTS the row and clears the stamp (D8/P2-1) so the re-completion records
    // a FRESH range — never a stale start preserved across the reopen.
    #[test]
    fn binding_records_boundary_then_evicts_and_refreshes_on_reopen() {
        let tmp = tempfile::tempdir().unwrap();
        let repo = init_repo(&tmp.path().join("repo"));
        seed_phase_sheet(&repo, 147, "phase-01");

        let start = git(&repo, &["rev-parse", "HEAD"]);
        set_phase_status(&repo, 147, "PHASE-01", PhaseStatus::InProgress, None, "T1").unwrap();
        // Land a code commit, then complete: the row spans start → new HEAD.
        git(&repo, &["commit", "-q", "--allow-empty", "-m", "code"]);
        let end1 = git(&repo, &["rev-parse", "HEAD"]);
        set_phase_status(&repo, 147, "PHASE-01", PhaseStatus::Completed, None, "T2").unwrap();

        assert_eq!(
            read_source_deltas(&repo, 147).unwrap(),
            vec![BoundaryRow {
                provenance: Provenance::Solo,
                ..row("PHASE-01", &start, &end1)
            }],
            "one boundary spanning the phase"
        );

        // Reopen (Completed→non-Completed) EVICTS the recorded row and clears the
        // start-stamp (design D8/P2-1): mid-reopen the registry is empty, so a redo
        // re-captures a FRESH range rather than reusing the stale start.
        set_phase_status(&repo, 147, "PHASE-01", PhaseStatus::InProgress, None, "T3").unwrap();
        assert!(
            read_source_deltas(&repo, 147).unwrap().is_empty(),
            "reopen evicts the recorded row — no stale row survives the reopen"
        );
        // The reopen→in_progress re-stamps a fresh start at the reopen HEAD (== end1,
        // no commit landed between the completion and the reopen).
        let restart = git(&repo, &["rev-parse", "HEAD"]);
        git(&repo, &["commit", "-q", "--allow-empty", "-m", "more"]);
        let end2 = git(&repo, &["rev-parse", "HEAD"]);
        set_phase_status(&repo, 147, "PHASE-01", PhaseStatus::Completed, None, "T4").unwrap();

        assert_eq!(
            read_source_deltas(&repo, 147).unwrap(),
            vec![BoundaryRow {
                provenance: Provenance::Solo,
                ..row("PHASE-01", &restart, &end2)
            }],
            "re-completion records exactly one FRESH row (start re-stamped at reopen, not preserved)"
        );
    }

    // VT-1: a no-commit phase (no code landed between start and completion) records
    // a zero-delta row (start == end), never an error.
    #[test]
    fn binding_records_zero_delta_for_a_no_commit_phase() {
        let tmp = tempfile::tempdir().unwrap();
        let repo = init_repo(&tmp.path().join("repo"));
        seed_phase_sheet(&repo, 147, "phase-01");
        let head = git(&repo, &["rev-parse", "HEAD"]);

        set_phase_status(&repo, 147, "PHASE-01", PhaseStatus::InProgress, None, "T1").unwrap();
        set_phase_status(&repo, 147, "PHASE-01", PhaseStatus::Completed, None, "T2").unwrap();

        assert_eq!(
            read_source_deltas(&repo, 147).unwrap(),
            vec![BoundaryRow {
                provenance: Provenance::Solo,
                ..row("PHASE-01", &head, &head)
            }],
            "zero-delta row, start == end"
        );
    }

    // --- reconcile writer (SL-190 PHASE-03) --------------------------------

    // VT-2: the status-only reconcile writer has NO registry side effects — after a
    // reconcile the `boundaries.toml` registry is byte-identical AND no `[[progress]]`
    // row was appended to the reconciled sheet (unlike `set_phase_status`, which would
    // append a progress row and evict the boundary on this Completed→InProgress reopen).
    #[test]
    fn reconcile_writer_leaves_registry_bytes_and_progress_log_untouched() {
        let tmp = tempfile::tempdir().unwrap();
        let repo = init_repo(&tmp.path().join("repo"));
        seed_phase_sheet(&repo, 147, "phase-01");

        // Drive a real completion via `set_phase_status`: this records a boundary row
        // AND appends two `[[progress]]` rows (in_progress, completed).
        set_phase_status(&repo, 147, "PHASE-01", PhaseStatus::InProgress, None, "T1").unwrap();
        set_phase_status(&repo, 147, "PHASE-01", PhaseStatus::Completed, None, "T2").unwrap();

        let boundaries = boundaries_path(&repo, 147).unwrap();
        let registry_before = fs::read(&boundaries).unwrap();
        let sheet = phases_dir(&repo, 147).join("phase-01.toml");
        let progress_before = fs::read_to_string(&sheet)
            .unwrap()
            .matches("[[progress]]")
            .count();

        // Reconcile to a DIFFERENT status (Completed→InProgress): a genuine write.
        let wrote =
            reconcile_phase_status(&repo, 147, "PHASE-01", PhaseStatus::InProgress, "T3").unwrap();
        assert!(wrote, "a status change is a real write");

        // The status flipped on the sheet...
        assert_eq!(
            read_phase_status(&phases_dir(&repo, 147), "phase-01")
                .unwrap()
                .as_deref(),
            Some("in_progress"),
        );
        // ...but the registry bytes are UNTOUCHED (no eviction — reconcile never
        // mutates the source of truth it reads).
        assert_eq!(
            fs::read(&boundaries).unwrap(),
            registry_before,
            "boundaries.toml is byte-identical after a reconcile"
        );
        // ...and NO `[[progress]]` row was appended.
        assert_eq!(
            fs::read_to_string(&sheet)
                .unwrap()
                .matches("[[progress]]")
                .count(),
            progress_before,
            "no progress row appended by the reconcile writer"
        );
    }

    // VT-2 (idempotence): a reconcile to the sheet's CURRENT status is a no-op — the
    // sheet is byte-identical (no `last_updated` churn) and the writer reports it did
    // not write.
    #[test]
    fn reconcile_writer_is_a_byte_identical_noop_when_status_already_matches() {
        let tmp = tempfile::tempdir().unwrap();
        let repo = init_repo(&tmp.path().join("repo"));
        seed_phase_sheet(&repo, 147, "phase-01");
        set_phase_status(&repo, 147, "PHASE-01", PhaseStatus::InProgress, None, "T1").unwrap();

        let sheet = phases_dir(&repo, 147).join("phase-01.toml");
        let before = fs::read(&sheet).unwrap();

        let wrote =
            reconcile_phase_status(&repo, 147, "PHASE-01", PhaseStatus::InProgress, "T-LATER")
                .unwrap();
        assert!(!wrote, "already at target — no write");
        assert_eq!(fs::read(&sheet).unwrap(), before, "sheet is byte-identical");
    }

    // EX-4: the PhaseTruth → reconcile-action mapping is explicit and total.
    #[test]
    fn reconcile_action_maps_every_truth_variant() {
        use ReconcileAction as A;
        assert_eq!(
            reconcile_action(&PhaseTruth::Landed),
            A::Write(PhaseStatus::Completed)
        );
        assert_eq!(
            reconcile_action(&PhaseTruth::InFlight("in_progress".into())),
            A::Write(PhaseStatus::InProgress)
        );
        assert_eq!(
            reconcile_action(&PhaseTruth::Planned),
            A::Write(PhaseStatus::Planned)
        );
        assert_eq!(
            reconcile_action(&PhaseTruth::Conflict(ConflictKind::Rework)),
            A::Skip
        );
        assert_eq!(reconcile_action(&PhaseTruth::Unknown), A::Skip);
        assert_eq!(reconcile_action(&PhaseTruth::Anomaly), A::Skip);
        assert_eq!(
            reconcile_action(&PhaseTruth::Local("completed".into())),
            A::Leave
        );
    }

    // VT-1: a git-unavailable transition (non-repo cwd) DEGRADES — no row, no
    // panic, and the status flip still COMPLETES.
    #[test]
    fn binding_degrades_without_blocking_when_git_unavailable() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path(); // not a git repo
        init_one(root); // seeds slice 4 / PHASE-01 sheet via init_phases

        // Transition succeeds despite no resolvable HEAD.
        set_phase_status(root, 4, "PHASE-01", PhaseStatus::InProgress, None, "T1").unwrap();
        set_phase_status(root, 4, "PHASE-01", PhaseStatus::Completed, None, "T2").unwrap();

        let doc = fs::read_to_string(phases_dir(root, 4).join("phase-01.toml"))
            .unwrap()
            .parse::<toml_edit::DocumentMut>()
            .unwrap();
        assert_eq!(
            doc["status"].as_str(),
            Some("completed"),
            "flip still landed"
        );
        // No registry written (boundaries_path itself errors in a non-repo, so the
        // read returns a named error — the point is the transition above succeeded).
        assert!(read_source_deltas(root, 4).is_err());
    }

    // VT-1: arm mutual-exclusion — a LIVE coordination worktree for `dispatch/<NNN>`
    // makes the binding stand down, EVEN when the flip runs from the session root (the
    // primary tree on `main`, never checked out to `dispatch/NNN`). The guard keys on
    // worktree liveness (D3/D9), not the current branch — the branch-proxy was unsound
    // under flip-from-session-root (design §2).
    #[test]
    fn binding_stands_down_under_a_live_coord_worktree() {
        let tmp = tempfile::tempdir().unwrap();
        let repo = init_repo(&tmp.path().join("repo"));
        seed_phase_sheet(&repo, 147, "phase-01");
        // A live coordination worktree on dispatch/147 — the dispatch funnel owns
        // recording. The primary repo stays on `main` (the session-root flip the
        // branch-proxy could not see).
        let coord = tmp.path().join("coord");
        git(
            &repo,
            &[
                "worktree",
                "add",
                "-b",
                "dispatch/147",
                &coord.to_string_lossy(),
            ],
        );

        set_phase_status(&repo, 147, "PHASE-01", PhaseStatus::InProgress, None, "T1").unwrap();
        git(&repo, &["commit", "-q", "--allow-empty", "-m", "code"]);
        set_phase_status(&repo, 147, "PHASE-01", PhaseStatus::Completed, None, "T2").unwrap();

        assert!(
            read_source_deltas(&repo, 147).unwrap().is_empty(),
            "live coord worktree: the binding records nothing (the funnel recorder owns it)"
        );
    }

    // VT-2: liveness, not name-match — a PRUNABLE / deleted-path `dispatch/<NNN>` coord
    // entry reads as ABSENT, so the solo binding records (a stale coord worktree must not
    // suppress capture forever). git keeps listing a worktree whose checkout was deleted,
    // marking it `prunable`, until `git worktree prune` runs.
    #[test]
    fn binding_records_when_coord_worktree_is_prunable() {
        let tmp = tempfile::tempdir().unwrap();
        let repo = init_repo(&tmp.path().join("repo"));
        seed_phase_sheet(&repo, 147, "phase-01");
        let coord = tmp.path().join("coord");
        git(
            &repo,
            &[
                "worktree",
                "add",
                "-b",
                "dispatch/147",
                &coord.to_string_lossy(),
            ],
        );
        // Delete the coord checkout on disk: git still lists dispatch/147, now prunable.
        std::fs::remove_dir_all(&coord).unwrap();

        let start = git(&repo, &["rev-parse", "HEAD"]);
        set_phase_status(&repo, 147, "PHASE-01", PhaseStatus::InProgress, None, "T1").unwrap();
        git(&repo, &["commit", "-q", "--allow-empty", "-m", "code"]);
        let end = git(&repo, &["rev-parse", "HEAD"]);
        set_phase_status(&repo, 147, "PHASE-01", PhaseStatus::Completed, None, "T2").unwrap();

        assert_eq!(
            read_source_deltas(&repo, 147).unwrap(),
            vec![BoundaryRow {
                provenance: Provenance::Solo,
                ..row("PHASE-01", &start, &end)
            }],
            "a prunable coord entry is not live → the solo binding records"
        );
    }

    // VT-2: a solo-context completion (NOT on `dispatch/<NNN>`) records exactly one
    // — the mutual-exclusion's positive arm. A solo `/worktree` fork is on its own
    // feature branch, never the coordination branch, so it still captures.
    #[test]
    fn binding_captures_on_a_solo_feature_branch() {
        let tmp = tempfile::tempdir().unwrap();
        let repo = init_repo(&tmp.path().join("repo"));
        seed_phase_sheet(&repo, 147, "phase-01");
        // A solo fork's own branch — NOT dispatch/147.
        git(&repo, &["checkout", "-q", "-b", "feat/solo-work"]);
        let start = git(&repo, &["rev-parse", "HEAD"]);

        set_phase_status(&repo, 147, "PHASE-01", PhaseStatus::InProgress, None, "T1").unwrap();
        git(&repo, &["commit", "-q", "--allow-empty", "-m", "code"]);
        let end = git(&repo, &["rev-parse", "HEAD"]);
        set_phase_status(&repo, 147, "PHASE-01", PhaseStatus::Completed, None, "T2").unwrap();

        assert_eq!(
            read_source_deltas(&repo, 147).unwrap(),
            vec![BoundaryRow {
                provenance: Provenance::Solo,
                ..row("PHASE-01", &start, &end)
            }],
            "solo context: exactly one boundary"
        );
    }

    // VT-5 (irreducible floor, design §5.5/§9): a completed PURE-SOLO phase with NO
    // stamped start (never flipped in_progress under the binding) and no coord worktree
    // records NOTHING (+ a surfaced warning) — never a manufactured garbage row. The
    // UNTOUCHED conformance consumer (`registry_completeness`) then reports Incomplete
    // naming the phase, so the accepted floor never silently passes (record-delta remedy).
    #[test]
    fn binding_absent_start_records_nothing_and_completeness_flags_incomplete() {
        let tmp = tempfile::tempdir().unwrap();
        let repo = init_repo(&tmp.path().join("repo"));
        seed_phase_sheet(&repo, 147, "phase-01");

        // Straight to Completed — no in_progress flip, so no `code_start_oid` was ever
        // stamped: the absent-stamp branch records nothing.
        set_phase_status(&repo, 147, "PHASE-01", PhaseStatus::Completed, None, "T1").unwrap();

        assert!(
            read_source_deltas(&repo, 147).unwrap().is_empty(),
            "no stamped start → no boundary recorded (no garbage row)"
        );
        assert_eq!(
            registry_completeness(&repo, &repo, 147).unwrap(),
            Completeness::Incomplete {
                gaps: vec![CompletenessGap::Missing {
                    phase: "PHASE-01".to_string()
                }]
            },
            "the floor surfaces as Incomplete end-to-end, never a silent pass"
        );
    }

    // --- completeness check (SL-147 PHASE-03, design F-2) -------------------

    fn completed_set(ids: &[&str]) -> BTreeSet<String> {
        ids.iter().map(|s| (*s).to_string()).collect()
    }

    // VT-2: one row per completed phase, no extras → Complete.
    #[test]
    fn completeness_is_complete_when_rows_cover_completed_phases() {
        let completed = completed_set(&["PHASE-01", "PHASE-02"]);
        let recorded = vec!["PHASE-01".to_string(), "PHASE-02".to_string()];
        assert_eq!(
            check_completeness(&completed, &recorded),
            Completeness::Complete
        );
    }

    // VT-2: a completed phase with no recorded row → Incomplete, naming it.
    #[test]
    fn completeness_flags_a_missing_row_for_a_completed_phase() {
        let completed = completed_set(&["PHASE-01", "PHASE-02"]);
        let recorded = vec!["PHASE-01".to_string()];
        let Completeness::Incomplete { gaps } = check_completeness(&completed, &recorded) else {
            panic!("expected incomplete");
        };
        assert_eq!(
            gaps,
            vec![CompletenessGap::Missing {
                phase: "PHASE-02".to_string()
            }]
        );
        assert!(gaps[0].describe().contains("PHASE-02"));
    }

    // VT-2: a recorded row whose phase is not completed → Incomplete (ExtraRow).
    #[test]
    fn completeness_flags_an_extra_row() {
        let completed = completed_set(&["PHASE-01"]);
        let recorded = vec!["PHASE-01".to_string(), "PHASE-09".to_string()];
        let Completeness::Incomplete { gaps } = check_completeness(&completed, &recorded) else {
            panic!("expected incomplete");
        };
        assert_eq!(
            gaps,
            vec![CompletenessGap::Extra {
                phase: "PHASE-09".to_string()
            }]
        );
    }

    // VT-2: more than one row for the same completed phase → DuplicateRow.
    #[test]
    fn completeness_flags_a_duplicate_row() {
        let completed = completed_set(&["PHASE-01"]);
        let recorded = vec!["PHASE-01".to_string(), "PHASE-01".to_string()];
        assert_eq!(
            check_completeness(&completed, &recorded),
            Completeness::Incomplete {
                gaps: vec![CompletenessGap::Duplicate {
                    phase: "PHASE-01".to_string()
                }]
            }
        );
    }

    // VT-2: `completed_phase_ids` reads only `completed` phases from the sheets.
    #[test]
    fn completed_phase_ids_returns_only_completed_phases() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        let phases = phases_dir(root, 147);
        write_phase_toml(&phases, "phase-01", "completed");
        write_phase_toml(&phases, "phase-02", "in_progress");
        write_phase_toml(&phases, "phase-03", "completed");

        let completed = completed_phase_ids(root, 147).unwrap();
        assert_eq!(completed, completed_set(&["PHASE-01", "PHASE-03"]));
    }

    // VT-2 (integration): registry_completeness composes the registry read +
    // phase-sheet read — a completed phase missing its row fails closed.
    #[test]
    fn registry_completeness_fails_closed_on_unrecorded_completed_phase() {
        let tmp = tempfile::tempdir().unwrap();
        let repo = init_repo(&tmp.path().join("repo"));
        let head = git(&repo, &["rev-parse", "HEAD"]);

        let phases = phases_dir(&repo, 147);
        write_phase_toml(&phases, "phase-01", "completed");
        write_phase_toml(&phases, "phase-02", "completed");
        record_source_delta(&repo, 147, row("PHASE-01", &head, &head)).unwrap();

        let verdict = registry_completeness(&repo, &repo, 147).unwrap();
        assert_eq!(
            verdict,
            Completeness::Incomplete {
                gaps: vec![CompletenessGap::Missing {
                    phase: "PHASE-02".to_string()
                }]
            }
        );
    }
}
