← Files APODICTIC Development EditorARCHIVED FILE

scripts/finding_trace.py

42.6 KB · Nov 3, 2026 · 18:57 UTC

↓ Download file

#!/usr/bin/env python3
"""finding-trace — cross-artifact Finding Lifecycle ID integrity (Harness Engineering).

`validate.sh finding-trace <run_folder> [--strict]` (or explicit files) shells out here.
Every material finding carries a durable Finding Lifecycle ID (`apodictic.finding.v1.id`,
`F-<ORIGIN>-<NN>`). `structured-findings` owns intra-ledger ID hygiene and `softness-check`
owns severity fidelity (locked -> delivered) by ID. This validator owns the *un-owned*
dimension: cross-artifact REFERENTIAL INTEGRITY + sidecar lifecycle COHERENCE.

  E1 dangling reference   a letter HTML-comment / severity_calibration citation to an
                          F-... ID that is not in the ledger (typo / phantom).
  E2 phantom sidecar      execution.finding_states key that is not a ledger ID.
  E3 invalid state        finding_states value not in {locked, delivered, revised}.
  E4 dangling revision    a revision-plan / coaching artifact cites an F-... ID not in the ledger.
  W1 lifecycle coverage   once synthesis has cleared (sidecar phase >= run_synthesis),
                          a Must-Fix/Should-Fix ledger ID with no finding_states entry
                          (advisory; ERROR under --strict).
  W2 revision coverage    a Must-Fix ledger ID not referenced in any revision plan, when one
                          is present (advisory; ERROR under --strict).
  E5 phantom completion   a completed-revision artifact MENTIONS a ledger ID whose finding_states
                          is "revised" but carries no `<!-- resolved: F-... -->` marker for it (the
                          in-scope report and the rolling sidecar disagree; IDs not in the report
                          are out of scope — a finding resolved in an earlier round is left alone).
  W3 completion coverage  a ledger ID marked resolved in a completed-revision artifact whose
                          finding_states entry is not "revised" (advisory; ERROR under --strict).
  E6 dangling retcon src  a Retcon Plan retcon_item `source` finding-ref (Retcon Planning F3,
                          primarily a Pass-8 seed) that is not in the ledger.

Each artifact is optional; a missing one skips its dimension (no false failure).
Reuses apodictic_artifacts.parse_blocks (one block grammar). See docs/finding-lifecycle-ids.md.

  finding_trace.py finding-trace <run_folder> [--strict]
  finding_trace.py finding-trace <ledger.md> [<letter.md>] [<sidecar.json>] [--strict]
  finding_trace.py --self-test

Exit: 0 clean / WARN-only, 1 ERROR (or WARN under --strict), 2 usage.
"""
import glob
import json
import os
import re
import sys

try:
    import apodictic_artifacts as art
except ImportError:
    art = None


def _has_block(text, btype):
    """True if `text` carries a real apodictic:<btype> block (a parsed carrier, not a prose mention).

    Classifying on parsed blocks — not a raw substring — keeps a file that merely *names* the marker
    in prose from being misrouted/skipped (the 2026-06-20 resolver-hardening sweep). Gated by
    validate.sh validator-conventions (M2)."""
    if art is None:
        return ("apodictic:%s" % btype) in (text or "")
    return any(bt == btype for bt, _o, _e in art.parse_blocks(text or ""))

_STATES = ("locked", "delivered", "revised")
_SYNTH_BOUND = ("Must-Fix", "Should-Fix")
# Synthesis has cleared (findings locked) once the gate frontier reaches a gated phase.
_SYNTH_CLEARED_PHASES = ("run_synthesis", "run_spot_check")

# Exact Lifecycle-ID token (so F-P5-01 != F-P5-011); mirrors honesty_check._id_present.
# Single-sourced from apodictic_artifacts.FID_RE (the host — dependency direction: this module
# imports art, never the reverse). The literal below is only the art-less degraded fallback;
# the apodictic_artifacts --self-test (fid_re_lockstep) pins it byte-equal to FID_RE.
_ID_RE = art.FID_RE if art is not None else re.compile(r"(?<![\w-])F-[A-Za-z0-9]+-[0-9]{2,}(?![\w-])")
_COMMENT_RE = re.compile(r"<!--(.*?)-->", re.DOTALL)
# Explicit resolution marker in a completed-revision artifact: `<!-- resolved: F-XX-NN -->`.
# A bare mention (e.g. a finding listed under "Flags still present") is NOT a resolution claim,
# so completion (E5/W3) keys on this marker, not on any F-... token in the report.
_RESOLVED_RE = re.compile(r"<!--\s*resolved:(.*?)-->", re.DOTALL | re.IGNORECASE)

# Editorial-letter filename globs (output-structure.md naming).
_LETTER_GLOBS = ("*_Core_DE_Synthesis_*.md", "*_Full_DE_*.md", "*_Editorial_Letter_*.md")
_LEDGER_GLOB = "*_Findings_Ledger_*.md"
# Revision-plan / coaching artifact globs (the lifecycle stage after the letter).
_REVISION_GLOBS = ("*_Session_Plan_*.md", "*_Revision_*.md")
# Completed-revision artifacts — a SUBSET of _REVISION_GLOBS: the Revision REPORT specifically
# (`*_Revision_Report_*.md`, state-lifecycle.md §Revision Round Output), where a finding's lifecycle
# advances to `revised`. Narrowed from `*_Revision_*.md` so a deadline-coaching `*_Revision_Calendar_*.md`
# (a revision-stage artifact, but not a completion) is excluded — matching the Increment-4a gate's
# `revision_report` key. Session plans express intent (plan coverage, W2); completion artifacts
# express done work (E5/W3).
_COMPLETION_GLOBS = ("*_Revision_Report_*.md",)
# Retcon Plan artifacts (Retcon Planning, F3): retcon_item blocks may carry a `source` finding-ref
# (primarily a Pass-8 Reveal-Economy finding) recording what the item was seeded from — E6 checks it
# resolves to a ledger finding.
_RETCON_GLOB = "*_Retcon_Plan_*.md"


def _read(path):
    try:
        with open(path, encoding="utf-8") as fh:
            return fh.read()
    except (OSError, UnicodeDecodeError):
        return None


def ledger_inventory(ledger_text):
    """{id: severity} for the ledger's apodictic.finding.v1 blocks. The authoritative ID set."""
    inv = {}
    if not ledger_text or art is None:
        return inv
    for bt, obj, _err in art.parse_blocks(ledger_text):
        if bt == "finding" and isinstance(obj, dict) and obj.get("id"):
            # art.fid_key: a malformed ledger finding with a non-hashable id (list/object) must not crash
            # this index key (the authoritative-ID-set sibling of the manifest finding_id crash class).
            inv[art.fid_key(obj["id"])] = obj.get("severity")
    return inv


def letter_cited_ids(letter_text):
    """F-... IDs cited in the letter — tokens inside HTML comments (the canonical citation
    surface: `<!-- finding: F-XX-NN -->` near a finding, and apodictic:severity_calibration
    blocks, which are themselves HTML comments). IDs never appear in author-facing prose."""
    cited = set()
    if not letter_text:
        return cited
    for body in _COMMENT_RE.findall(letter_text):
        cited.update(_ID_RE.findall(body))
    return cited


def revision_cited_ids(text):
    """F-... IDs referenced in a revision-plan / coaching artifact. These are working documents,
    so IDs are matched ANYWHERE in the text (inline prose references count) — not comment-only."""
    return set(_ID_RE.findall(text)) if text else set()


def resolved_cited_ids(text):
    """F-... IDs explicitly marked resolved in a completed-revision artifact via
    `<!-- resolved: F-XX-NN -->` markers. A bare mention (a finding under "Flags still present"
    or "New issues introduced") is NOT a resolution claim and is excluded — only an explicit
    marker counts a finding as a completed revision."""
    ids = set()
    if not text:
        return ids
    for body in _RESOLVED_RE.findall(text):
        ids.update(_ID_RE.findall(body))
    return ids


def retcon_source_ids(text):
    """Well-formed F-... `source` refs declared on the retcon_item blocks of a Retcon Plan (F3).
    Only schema-shaped F-refs are returned; a malformed `source` is the retcon-plan schema's job
    (R1), not E6's, so this filters by the ID pattern to avoid double-reporting a format error."""
    ids = set()
    if not text or art is None:
        return ids
    for bt, obj, _err in art.parse_blocks(text):
        if bt != "retcon_item" or not isinstance(obj, dict):
            continue
        src = obj.get("source")
        if isinstance(src, str) and _ID_RE.fullmatch(src):
            ids.add(src)
    return ids


def sidecar_state(sidecar_text):
    """(finding_states dict, phase, parse_ok) from a Diagnostic_State.meta.json.
    parse_ok is False when a *discovered* sidecar is present but not valid JSON — the
    caller must treat that as an error, not a clean empty lifecycle state."""
    try:
        meta = json.loads(sidecar_text)
    except (ValueError, TypeError):
        return {}, None, False
    ex = meta.get("execution", {}) if isinstance(meta, dict) else {}
    fs = ex.get("finding_states") or {}
    return (fs if isinstance(fs, dict) else {}), ex.get("phase"), True


def event_advanced_revised_ids(sidecar_text, run_folder, sidecar_path):
    """M1c — the event-scoped extension of E5's in-scope set (docs/finding-lifecycle-ids.md:112).

    On a GOVERNED sidecar (state_version >= 2 with a gate_events[] log), return the ledger ids that
    a CLEARING event of THIS run advanced to `revised` — i.e. events whose recorded run_folder
    resolves to `run_folder` and whose finding_deltas set an id to `revised`. On an ungoverned
    sidecar (no gate_events / state_version < 2), or when the run folder can't be matched, return an
    EMPTY set — so trace() stays byte-identical to today for ungoverned inputs.

    run_folder events store the path RELATIVE to the sidecar's dir (run_gate._rel_run_folder), so a
    match is by realpath equality against the run folder under trace. This reads the log only; it
    never re-derives finding_states (W1/E2/E3 still key on the folded execution.finding_states)."""
    ids = set()
    if not sidecar_text or run_folder is None or sidecar_path is None:
        return ids
    try:
        meta = json.loads(sidecar_text)
    except (ValueError, TypeError):
        return ids
    ex = meta.get("execution", {}) if isinstance(meta, dict) else {}
    if not isinstance(ex, dict) or ex.get("state_version", 1) < 2:
        return ids
    events = ex.get("gate_events")
    if not isinstance(events, list):
        return ids
    sc_dir = os.path.dirname(os.path.abspath(sidecar_path))
    target = os.path.realpath(os.path.abspath(run_folder))
    for e in events:
        if not isinstance(e, dict):
            continue
        rf = e.get("run_folder")
        deltas = e.get("finding_deltas")
        if not isinstance(rf, str) or not isinstance(deltas, dict):
            continue
        if os.path.realpath(os.path.join(sc_dir, rf)) != target:
            continue
        for fid, st in deltas.items():
            if st == "revised":
                ids.add(fid)
    return ids


def trace(ledger_text, letter_text, sidecar_text, revision_texts=None, completion_texts=None,
          retcon_texts=None, strict=False, event_advanced=None):
    """Run the cross-artifact trace. Returns (code, lines).

    revision_texts   = ALL revision-stage artifacts (session plans + completed revisions) — the
                       surface for E4 (dangling) and W2 (plan coverage).
    completion_texts = the completed-revision (*_Revision_Report_*.md) SUBSET — the surface for E5
                       (phantom completion) and W3 (completion follow-through). Callers keep the
                       invariant completion_texts ⊆ revision_texts.
    retcon_texts     = Retcon Plan artifacts (F3) — the surface for E6 (a retcon_item `source`
                       finding-ref that is not in the ledger).
    event_advanced   = M1c — ids advanced to `revised` by a CLEARING event of THIS run (governed
                       sidecars only; event_advanced_revised_ids). WIDENS E5's in-scope set from
                       comp_mentioned to comp_mentioned ∪ event_advanced. The E5 PREDICATE and the
                       resolved_ids union are unchanged — only the iteration set grows. None/empty
                       (the ungoverned default) leaves behaviour byte-identical.
    """
    lines, errs, warns = [], [], []

    inv = ledger_inventory(ledger_text)
    if not inv:
        return 0, ["finding-trace: no ledger findings found — nothing to trace"]

    have_letter = letter_text is not None
    have_sidecar = sidecar_text is not None
    revision_texts = revision_texts or []
    have_revision = bool(revision_texts)
    completion_texts = completion_texts or []
    have_completion = bool(completion_texts)
    cited = letter_cited_ids(letter_text) if have_letter else set()
    rev_cited = set()
    for rt in revision_texts:
        rev_cited |= revision_cited_ids(rt)
    resolved_ids = set()    # explicitly resolved (<!-- resolved: ID -->) in a completion artifact
    comp_mentioned = set()  # any F-... token (bare) in a completion artifact — E5's in-scope set
    for ct in completion_texts:
        resolved_ids |= resolved_cited_ids(ct)
        comp_mentioned |= revision_cited_ids(ct)
    retcon_texts = retcon_texts or []
    have_retcon = bool(retcon_texts)
    retcon_sources = set()  # F-... `source` refs declared on retcon_item blocks (F3)
    for rt in retcon_texts:
        retcon_sources |= retcon_source_ids(rt)
    finding_states, phase, sc_ok = sidecar_state(sidecar_text) if have_sidecar else ({}, None, True)
    event_advanced = set(event_advanced or ())  # M1c: governed event-scope extension of E5 (empty by default)

    # E1 — dangling reference (letter cites an ID not in the ledger)
    if have_letter:
        for cid in sorted(cited):
            if cid not in inv:
                errs.append("E1 dangling reference: letter cites %s — not in the ledger" % cid)
    # E4 — dangling revision reference (revision plan cites an ID not in the ledger)
    if have_revision:
        for cid in sorted(rev_cited):
            if cid not in inv:
                errs.append("E4 dangling reference: revision plan cites %s — not in the ledger" % cid)
    # E6 — dangling retcon source (a retcon_item `source` finding-ref not in the ledger, F3)
    if have_retcon:
        for sid in sorted(retcon_sources):
            if sid not in inv:
                errs.append("E6 dangling retcon source: retcon plan seeds an item from %s — "
                            "not in the ledger" % sid)
    # E0 — a discovered sidecar that cannot be parsed is an ERROR, not a clean empty lifecycle:
    # otherwise E2/E3/W1 are silently bypassed on the artifact that is supposed to carry the state.
    if have_sidecar and not sc_ok:
        errs.append("E0 unparseable sidecar: Diagnostic_State.meta.json is present but not valid "
                    "JSON — lifecycle coherence cannot be verified")
    # E2 / E3 — sidecar coherence (only when the sidecar parsed)
    if have_sidecar and sc_ok:
        for fid in sorted(finding_states):
            if fid not in inv:
                errs.append("E2 phantom sidecar state: finding_states[%s] — not in the ledger" % fid)
            if finding_states[fid] not in _STATES:
                errs.append("E3 invalid state: finding_states[%s]=%r (expected %s)"
                            % (fid, finding_states[fid], "/".join(_STATES)))
    # W1 — lifecycle coverage (only once synthesis has cleared, and the sidecar parsed)
    synth_cleared = sc_ok and phase in _SYNTH_CLEARED_PHASES
    if have_sidecar and sc_ok and synth_cleared:
        for fid in sorted(inv):
            if inv[fid] in _SYNTH_BOUND and fid not in finding_states:
                warns.append("W1 coverage: %s (%s) locked but has no finding_states entry"
                             % (fid, inv[fid]))
    # W2 — revision follow-through coverage (a Must-Fix not picked up by any revision plan)
    if have_revision:
        for fid in sorted(inv):
            if inv[fid] == "Must-Fix" and fid not in rev_cited:
                warns.append("W2 follow-through: Must-Fix %s not referenced in any revision plan" % fid)
    # E5 — phantom completion: an in-scope contradiction — a completed-revision artifact *mentions*
    # a `revised` ledger finding (bare, e.g. under "Flags still present") but carries no
    # `<!-- resolved: ID -->` marker for it, so the report and the sidecar disagree. Scoped to IDs
    # the current report actually mentions (comp_mentioned): the sidecar's finding_states is a
    # rolling all-session map, so a finding resolved in an EARLIER (out-of-scope) round is durably
    # `revised` but simply won't appear in this report — and must not be flagged (PR #32 review P1).
    # M1c widens the SCOPE (not the predicate) on governed sidecars: an id advanced to `revised` by
    # THIS run's clearing event (event_advanced) is in scope too — so a marker removed AFTER the
    # clear (post-clear tampering, the incoherence E5 exists to catch) is surfaced even if the id is
    # not bare-mentioned in the current report. The resolved_ids union across ALL completion
    # artifacts of the run is unchanged, so a marker living in a SIBLING artifact still clears it.
    if have_sidecar and sc_ok:
        for fid in sorted(comp_mentioned | event_advanced):
            if finding_states.get(fid) == "revised" and fid in inv and fid not in resolved_ids:
                errs.append("E5 phantom completion: finding_states[%s]=revised but the completed-"
                            "revision artifact mentions it without a <!-- resolved: %s --> marker "
                            "— the report and the sidecar disagree" % (fid, fid))
    # W3 — completion follow-through: a finding explicitly marked resolved in a completed round
    # whose lifecycle was not advanced to `revised`. Keys on the resolved marker (so a
    # still-present mention never triggers it). Needs a parseable sidecar to judge against.
    if have_completion and have_sidecar and sc_ok:
        for fid in sorted(resolved_ids):
            if fid in inv and finding_states.get(fid) != "revised":
                warns.append("W3 completion: %s marked resolved in a completed-revision artifact "
                             "but finding_states[%s]=%r (expected 'revised')"
                             % (fid, fid, finding_states.get(fid)))

    # Per-ID trace report
    if not have_sidecar:
        sc_note = " (no sidecar — lifecycle trace skipped)"
    elif not sc_ok:
        sc_note = " (sidecar UNPARSEABLE — see E0)"
    else:
        sc_note = ""
    lines.append("finding-trace: %d ledger finding(s)%s%s%s%s"
                 % (len(inv),
                    "" if have_letter else " (no letter — letter trace skipped)",
                    sc_note,
                    "" if have_revision else " (no revision plan — follow-through skipped)",
                    "; %d retcon source(s) traced" % len(retcon_sources) if have_retcon else ""))
    for fid in sorted(inv):
        state = ((finding_states.get(fid, "—") if sc_ok else "?") if have_sidecar else "n/a")
        mark = ("cited" if fid in cited else "UNCITED") if have_letter else "n/a"
        if not have_revision:
            rev_mark = "n/a"
        elif fid in resolved_ids:
            rev_mark = "done"      # explicitly marked resolved in a completed-revision artifact
        elif fid in rev_cited:
            rev_mark = "planned"   # mentioned in a revision plan / report (not marked resolved)
        else:
            rev_mark = "—"
        lines.append("  %-12s sev=%-9s state=%-9s letter=%-7s rev=%s" % (fid, inv[fid], state, mark, rev_mark))

    for e in errs:
        lines.append("  ERROR: %s" % e)
    for w in warns:
        lines.append("  %s: %s" % ("ERROR (--strict)" if strict else "WARN", w))

    if errs or (strict and warns):
        lines.append("finding-trace: FAIL (%d error(s)%s)"
                     % (len(errs), ", %d strict warn(s)" % len(warns) if (strict and warns) else ""))
        return 1, lines
    if warns:
        lines.append("WARN: finding-trace: %d advisory coverage gap(s) — see W1 above" % len(warns))
    else:
        lines.append("finding-trace: PASS (referential integrity + sidecar coherence)")
    return 0, lines


# ---------------------------------------------------------------- artifact resolution

def _walk_up_sidecar(start):
    d = os.path.abspath(start if os.path.isdir(start) else os.path.dirname(start))
    for _ in range(4):
        sc = os.path.join(d, "Diagnostic_State.meta.json")
        if os.path.exists(sc):
            return sc
        d = os.path.dirname(d)
    return None


def _newest(paths):
    return max(paths, key=os.path.getmtime) if paths else None


def resolve_run_folder(folder):
    """(ledger, letter, sidecar, [revisions], [completions], [retcons]) — newest per single
    artifact; revisions = all session-plan + revision-stage matches; completions = the
    *_Revision_Report_*.md subset; retcons = all Retcon Plan matches (F3)."""
    ledger = _newest(glob.glob(os.path.join(folder, _LEDGER_GLOB)))
    letter = None
    for g in _LETTER_GLOBS:
        letter = _newest(glob.glob(os.path.join(folder, g)))
        if letter:
            break
    sidecar = _walk_up_sidecar(folder)
    revisions = []
    for g in _REVISION_GLOBS:
        revisions += glob.glob(os.path.join(folder, g))
    completions = []
    for g in _COMPLETION_GLOBS:
        completions += glob.glob(os.path.join(folder, g))
    retcons = glob.glob(os.path.join(folder, _RETCON_GLOB))
    return ledger, letter, sidecar, revisions, completions, retcons


def classify_files(paths):
    """Classify explicit file args into (ledger, letter, sidecar, [revisions], [completions],
    [retcons])."""
    ledger = letter = sidecar = None
    revisions, completions, retcons = [], [], []
    for p in paths:
        base = os.path.basename(p)
        if p.endswith(".json"):
            sidecar = p
        elif "_Retcon_Plan_" in base:
            retcons.append(p)
        elif _has_block(_read(p) or "", "finding"):
            ledger = p
        elif "_Session_Plan_" in base or "_Revision_" in base:
            revisions.append(p)
            if "_Revision_Report_" in base:   # completion ⊂ revision-stage; calendars stay non-completion
                completions.append(p)
        else:
            letter = p
    return ledger, letter, sidecar, revisions, completions, retcons


def run(paths, strict=False):
    if len(paths) == 1 and os.path.isdir(paths[0]):
        run_folder = paths[0]
        ledger, letter, sidecar, revisions, completions, retcons = resolve_run_folder(paths[0])
    else:
        ledger, letter, sidecar, revisions, completions, retcons = classify_files(paths)
        # Explicit-file mode: the run folder under trace is the dir the ledger lives in (the
        # completion artifacts sit beside it) — the anchor event_advanced matches events against.
        run_folder = os.path.dirname(os.path.abspath(ledger)) if ledger else None
    if not ledger:
        return 2, ["finding-trace: no Findings Ledger found (need a *_Findings_Ledger_*.md or a "
                   "file with apodictic:finding blocks)"]
    sidecar_text = None
    if sidecar:  # a sidecar was discovered — read it; unreadable counts as present-but-bad (E0)
        sidecar_text = _read(sidecar)
        if sidecar_text is None:
            sidecar_text = ""
    revision_texts = [t for t in (_read(r) for r in revisions) if t is not None]
    completion_texts = [t for t in (_read(c) for c in completions) if t is not None]
    retcon_texts = [t for t in (_read(r) for r in retcons) if t is not None]
    # M1c — governed event-scope extension of E5 (empty for ungoverned sidecars → byte-identical).
    event_advanced = event_advanced_revised_ids(sidecar_text, run_folder, sidecar) if sidecar else set()
    return trace(_read(ledger), _read(letter) if letter else None, sidecar_text,
                 revision_texts=revision_texts, completion_texts=completion_texts,
                 retcon_texts=retcon_texts, strict=strict, event_advanced=event_advanced)


# ---------------------------------------------------------------- self-test

def run_self_test():
    import tempfile
    import shutil
    rc = {"v": 0}
    made = []

    def check(name, cond):
        print("  %s: %s" % (name, "OK" if cond else "FAIL"))
        if not cond:
            rc["v"] = 1

    # non-UTF8 artifact: _read must degrade to the unreadable path (None), never
    # a traceback (the disposition_check adjacent-exception class, swept repo-wide)
    import tempfile as _tf
    _fd, _nu = _tf.mkstemp(suffix=".md")
    with os.fdopen(_fd, "wb") as _fh:
        _fh.write(b"\xff\xfenot utf-8\xff")
    check("non_utf8_read_returns_none", _read(_nu) is None)
    os.unlink(_nu)

    def finding(fid, sev="Must-Fix"):
        return ('<!-- apodictic:finding\n{"schema":"apodictic.finding.v1","id":"%s",'
                '"mechanism":"m","severity":"%s","confidence":"HIGH","evidence_refs":["c"],'
                '"fix_class":"x","risk_if_fixed":"y"}\n-->' % (fid, sev))

    ledger = "## Pass 5 — Ledger Entry\n" + finding("F-P5-01") + "\n" + finding("F-P5-02", "Should-Fix") + "\n"
    # regression: a non-hashable ledger id must not crash the authoritative-ID index (fid_key SSoT)
    check("ledger_inventory_nonhashable_id_no_crash",
          isinstance(ledger_inventory("<!-- apodictic:finding\n" + json.dumps(
              {"schema": "apodictic.finding.v1", "id": [1, 2], "severity": "Must-Fix", "mechanism": "m"})
              + "\n-->"), dict))
    letter_clean = ("# Edit\n## What Needs Work\nThe pacing collapses in Chapter 9. "
                    "<!-- finding: F-P5-01 -->\nThe stakes stay abstract. <!-- finding: F-P5-02 -->\n"
                    '<!-- apodictic:severity_calibration\n{"schema":"apodictic.severity_calibration.v1",'
                    '"id":"F-P5-01","locked":"Must-Fix","delivered":"Must-Fix","direction":"unchanged",'
                    '"rationale":"held"}\n-->\n')
    letter_dangling = letter_clean + "Also a typo'd ref. <!-- finding: F-P5-99 -->\n"

    def sidecar(states, phase="run_synthesis"):
        return json.dumps({"execution": {"phase": phase, "finding_states": states}})

    sc_ok = sidecar({"F-P5-01": "locked", "F-P5-02": "locked"})
    sc_phantom = sidecar({"F-P5-01": "locked", "F-XX-09": "locked"})
    sc_invalid = sidecar({"F-P5-01": "frozen"})
    sc_partial = sidecar({"F-P5-01": "locked"})           # F-P5-02 missing -> W1
    sc_presynth = sidecar({}, phase="")                    # pre-synthesis -> W1 skipped

    # clean trace
    code, _ = trace(ledger, letter_clean, sc_ok)
    check("clean_trace_passes", code == 0)

    # E1 dangling letter reference
    code, lines = trace(ledger, letter_dangling, sc_ok)
    check("e1_dangling_ref", code == 1 and any("E1 dangling" in ln and "F-P5-99" in ln for ln in lines))

    # E2 phantom sidecar state
    code, lines = trace(ledger, letter_clean, sc_phantom)
    check("e2_phantom_sidecar", code == 1 and any("E2 phantom" in ln and "F-XX-09" in ln for ln in lines))

    # E3 invalid state
    code, lines = trace(ledger, letter_clean, sc_invalid)
    check("e3_invalid_state", code == 1 and any("E3 invalid" in ln for ln in lines))

    # W1 coverage: advisory (exit 0) by default, ERROR under --strict
    code_w, lines_w = trace(ledger, letter_clean, sc_partial)
    check("w1_coverage_advisory", code_w == 0 and any("W1 coverage" in ln and "F-P5-02" in ln for ln in lines_w))
    code_s, _ = trace(ledger, letter_clean, sc_partial, strict=True)
    check("w1_coverage_strict_fails", code_s == 1)

    # W1 skipped pre-synthesis (no false positive before findings are locked)
    code, _ = trace(ledger, letter_clean, sc_presynth)
    check("w1_skipped_presynthesis", code == 0)

    # present-but-malformed sidecar is an ERROR (not a clean empty lifecycle state)
    code, lines = trace(ledger, letter_clean, "{ not valid json")
    check("malformed_sidecar_errors", code == 1 and any("E0 unparseable" in ln for ln in lines))

    # E4 — dangling revision reference (revision plan cites an ID not in the ledger)
    code, lines = trace(ledger, letter_clean, sc_ok, revision_texts=["Plan: address F-P5-99 next."])
    check("e4_dangling_revision", code == 1 and any("E4 dangling" in ln and "F-P5-99" in ln for ln in lines))

    # revision plan references the Must-Fix -> clean (no W2)
    code, _ = trace(ledger, letter_clean, sc_ok, revision_texts=["Session 1: fix F-P5-01 (the pacing)."])
    check("revision_covers_mustfix", code == 0)

    # W2 follow-through: revision plan present but the Must-Fix not referenced -> advisory, strict ERROR
    code_w, lines_w = trace(ledger, letter_clean, sc_ok, revision_texts=["Session 1: polish only."])
    check("w2_followthrough_advisory",
          code_w == 0 and any("W2 follow-through" in ln and "F-P5-01" in ln for ln in lines_w))
    code_s, _ = trace(ledger, letter_clean, sc_ok, revision_texts=["Session 1: polish only."], strict=True)
    check("w2_followthrough_strict_fails", code_s == 1)

    # --- Increment 3: revision-completion (`revised`) lifecycle ---
    sc_revised = sidecar({"F-P5-01": "revised", "F-P5-02": "locked"})
    sc_delivered = sidecar({"F-P5-01": "delivered", "F-P5-02": "delivered"})
    # Completion = an explicit `<!-- resolved: ID -->` marker (NOT a bare mention).
    rev_done_01 = "# Revision Report\n## Flags resolved\nPacing now lands. <!-- resolved: F-P5-01 -->"
    rev_done_02 = "# Revision Report\n## Flags resolved\nStakes reworked. <!-- resolved: F-P5-02 -->"
    # A legitimate report naming a worked-but-unresolved finding (no resolved marker).
    rev_still_present = ("# Revision Report\n## Flags still present\nF-P5-01 remains present; the "
                         "attempted fix did not land.\n")

    # E5 phantom completion: the in-scope report MENTIONS F-P5-01 (still present, no resolved
    # marker) but the sidecar marks it revised -> the report and the sidecar disagree.
    rev_contradict = ("# Revision Report\n## Flags still present\nF-P5-01 remains present.\n"
                      "## Flags resolved\nStakes reworked. <!-- resolved: F-P5-02 -->")
    code, lines = trace(ledger, letter_clean, sc_revised,
                        revision_texts=[rev_contradict], completion_texts=[rev_contradict])
    check("e5_phantom_completion",
          code == 1 and any("E5 phantom" in ln and "F-P5-01" in ln for ln in lines))

    # E5 scope (PR #32 re-review P1): finding_states is a rolling all-session map. A finding
    # revised in an EARLIER round, with a new report resolving only F-P5-02 and NOT mentioning
    # F-P5-01, must NOT E5-fail F-P5-01 (it was resolved out of scope).
    code, lines = trace(ledger, letter_clean, sc_revised,
                        revision_texts=[rev_done_02], completion_texts=[rev_done_02])
    check("e5_no_falsepos_out_of_scope",
          code == 0 and not any("E5 phantom" in ln for ln in lines))

    # E5 needs a completion artifact mentioning the finding: revised state, no completion -> no E5
    code, _ = trace(ledger, letter_clean, sc_revised)
    check("e5_skipped_without_completion", code == 0)

    # --- M1c: event-scoped E5 (docs/finding-lifecycle-ids.md:112) ---
    # The widened set is comp_mentioned ∪ event_advanced; the predicate is unchanged.

    # (i) POSITIVE — post-clear tampering. A governed clearing event advanced F-P5-01 to revised,
    # its resolved marker has since been REMOVED, and the id is mentioned NOWHERE in the current
    # report. Silent under the old comp-mention scope; caught once F-P5-01 is event_advanced.
    rev_no_mention = "# Revision Report\n## Flags resolved\nStakes reworked. <!-- resolved: F-P5-02 -->"
    code, lines = trace(ledger, letter_clean, sc_revised,
                        revision_texts=[rev_no_mention], completion_texts=[rev_no_mention],
                        event_advanced={"F-P5-01"})
    check("m1c_e5_event_scope_positive",
          code == 1 and any("E5 phantom" in ln and "F-P5-01" in ln for ln in lines))
    # ...and WITHOUT event_advanced (ungoverned) the same inputs stay silent — byte-identical today.
    code0, lines0 = trace(ledger, letter_clean, sc_revised,
                          revision_texts=[rev_no_mention], completion_texts=[rev_no_mention])
    check("m1c_e5_ungoverned_silent",
          code0 == 0 and not any("E5 phantom" in ln for ln in lines0))

    # (ii) INTRA-RUN CROSS-ARTIFACT (pinned): F-P5-01 is event_advanced AND bare-mentioned in
    # completion artifact B, but its <!-- resolved --> marker lives in SIBLING completion artifact A
    # of the same run. The resolved_ids union across ALL completion artifacts sees the marker -> the
    # id is in resolved_ids -> stays PASS even under the widened scope.
    comp_a_marker = "# Revision Report A\n## Flags resolved\n<!-- resolved: F-P5-01 -->"
    comp_b_mention = "# Revision Report B\n## Notes\nF-P5-01 came up again in discussion.\n"
    code, lines = trace(ledger, letter_clean, sc_revised,
                        revision_texts=[comp_a_marker, comp_b_mention],
                        completion_texts=[comp_a_marker, comp_b_mention],
                        event_advanced={"F-P5-01"})
    check("m1c_e5_intra_run_cross_artifact_pass",
          code == 0 and not any("E5 phantom" in ln for ln in lines))

    # (iii) event_advanced but state NOT revised (e.g. still delivered) -> predicate unchanged, no E5
    code, lines = trace(ledger, letter_clean, sc_delivered,
                        revision_texts=[rev_no_mention], completion_texts=[rev_no_mention],
                        event_advanced={"F-P5-01"})
    check("m1c_e5_event_scope_needs_revised_state",
          code == 0 and not any("E5 phantom" in ln for ln in lines))

    # event_advanced_revised_ids: ungoverned sidecar (no gate_events) -> empty set (byte-identity guard)
    ung = json.dumps({"execution": {"finding_states": {"F-P5-01": "revised"}}})
    check("m1c_event_advanced_ungoverned_empty",
          event_advanced_revised_ids(ung, os.getcwd(), os.path.join(os.getcwd(), "Diagnostic_State.meta.json")) == set())

    # revised + explicitly marked resolved -> clean (no E5, no W3)
    code, _ = trace(ledger, letter_clean, sc_revised,
                    revision_texts=[rev_done_01], completion_texts=[rev_done_01])
    check("revised_with_completion_clean", code == 0)

    # W3 completion follow-through: marked resolved but state still `delivered`
    code_w, lines_w = trace(ledger, letter_clean, sc_delivered,
                            revision_texts=[rev_done_01], completion_texts=[rev_done_01])
    check("w3_completion_advisory",
          code_w == 0 and any("W3 completion" in ln and "F-P5-01" in ln for ln in lines_w))
    code_s, _ = trace(ledger, letter_clean, sc_delivered,
                      revision_texts=[rev_done_01], completion_texts=[rev_done_01], strict=True)
    check("w3_completion_strict_fails", code_s == 1)

    # REGRESSION (PR #32 review P1): a worked-but-unresolved finding ("Flags still present", no
    # resolved marker) must NOT count as completed -> no W3, and must pass even under --strict.
    code, lines = trace(ledger, letter_clean, sc_delivered,
                        revision_texts=[rev_still_present], completion_texts=[rev_still_present])
    check("w3_skips_unresolved_mention", code == 0 and not any("W3 completion" in ln for ln in lines))
    check("w3_strict_ok_on_still_present",
          trace(ledger, letter_clean, sc_delivered, revision_texts=[rev_still_present],
                completion_texts=[rev_still_present], strict=True)[0] == 0)
    # ...and a still-present finding shows rev=planned (mentioned), not rev=done (resolved)
    check("still_present_not_done",
          any("F-P5-01" in ln and "rev=planned" in ln for ln in lines))

    # W3 needs a parseable sidecar to judge advancement: completion present, no sidecar -> skipped
    code, _ = trace(ledger, letter_clean, None,
                    revision_texts=[rev_done_01], completion_texts=[rev_done_01])
    check("w3_skipped_without_sidecar", code == 0)

    # graceful: ledger-only run skips letter + sidecar dimensions
    code, lines = trace(ledger, None, None)
    check("ledger_only_graceful", code == 0 and any("letter trace skipped" in ln for ln in lines))

    # no ledger findings -> nothing to trace (exit 0)
    code, _ = trace("# empty\n", letter_clean, sc_ok)
    check("no_findings_noop", code == 0)

    # exact-boundary ID match: F-P5-011 must NOT satisfy a citation of F-P5-01
    led1 = "## L\n" + finding("F-P5-011") + "\n"
    let1 = "x <!-- finding: F-P5-01 -->\n"   # cites F-P5-01, ledger has only F-P5-011
    code, lines = trace(led1, let1, None)
    check("exact_boundary_ids", code == 1 and any("F-P5-01" in ln and "E1" in ln for ln in lines))

    # E6 — dangling retcon source (Retcon Planning F3): a retcon_item `source` finding-ref must
    # resolve to a ledger finding. Matched by id; a malformed source is retcon-plan's R1, not E6.
    def retcon(source):
        return ('# Proj Retcon Plan\n## Retcon Targets\n- T1: x\n'
                '<!-- apodictic:retcon_item\n{"schema":"apodictic.retcon_item.v1","id":"RX-01",'
                '"target_id":"T1","kind":"setup-debt","mutability":"free","retcon_type":"dramatic",'
                '"intervention_class":"plant a detail","disposition":"author seeds it","source":"%s"}\n-->\n'
                % source)
    code, lines = trace(ledger, None, None, retcon_texts=[retcon("F-P5-01")])
    check("e6_source_resolves_clean", code == 0 and any("retcon source(s) traced" in ln for ln in lines))
    code, lines = trace(ledger, None, None, retcon_texts=[retcon("F-P8-77")])
    check("e6_dangling_source",
          code == 1 and any("E6 dangling retcon source" in ln and "F-P8-77" in ln for ln in lines))
    code, lines = trace(ledger, None, None, retcon_texts=[retcon("F-P8-7")])   # one-digit suffix
    check("e6_malformed_not_flagged", code == 0 and not any("E6" in ln for ln in lines))
    code, _ = trace(ledger, None, None, retcon_texts=["# plan\nno retcon blocks\n"])
    check("e6_no_source_noop", code == 0)

    # run-folder resolution + explicit-file classification
    d = tempfile.mkdtemp()
    made.append(d)
    with open(os.path.join(d, "Proj_Findings_Ledger_run.md"), "w", encoding="utf-8", newline="") as fh:
        fh.write(ledger)
    with open(os.path.join(d, "Proj_Core_DE_Synthesis_run.md"), "w", encoding="utf-8", newline="") as fh:
        fh.write(letter_clean)
    with open(os.path.join(d, "Diagnostic_State.meta.json"), "w", encoding="utf-8", newline="") as fh:
        fh.write(sc_ok)
    with open(os.path.join(d, "Proj_Session_Plan_run.md"), "w", encoding="utf-8", newline="") as fh:
        fh.write("# Session 1\nAddress F-P5-01 (pacing) and F-P5-02 (stakes).\n")
    with open(os.path.join(d, "Proj_Revision_Report_run.md"), "w", encoding="utf-8", newline="") as fh:
        fh.write("# Revision Report\n## Flags resolved\nPacing now lands. <!-- resolved: F-P5-01 -->\n")
    with open(os.path.join(d, "Proj_Retcon_Plan_run.md"), "w", encoding="utf-8", newline="") as fh:
        fh.write(retcon("F-P5-02"))   # F3: source resolves to the ledger -> no E6
    rc_code, rc_lines = run([d])
    check("run_folder_resolution", rc_code == 0)
    check("run_folder_rev_done", any("rev=done" in ln for ln in rc_lines))       # F-P5-01 resolved marker
    check("run_folder_rev_planned", any("rev=planned" in ln for ln in rc_lines))  # F-P5-02 in plan only
    check("run_folder_retcon_traced", any("retcon source(s) traced" in ln for ln in rc_lines))
    check("explicit_files_classify",
          run([os.path.join(d, "Proj_Findings_Ledger_run.md"),
               os.path.join(d, "Proj_Core_DE_Synthesis_run.md"),
               os.path.join(d, "Diagnostic_State.meta.json")])[0] == 0)
    check("explicit_files_completion",
          any("rev=done" in ln for ln in run([os.path.join(d, "Proj_Findings_Ledger_run.md"),
                                              os.path.join(d, "Proj_Revision_Report_run.md")])[1]))
    # (glob narrowing) a Revision *Calendar* carrying a resolved marker is NOT a completion —
    # so its marker must not advance any finding to rev=done. Genuine regression guard: pre-fix
    # the broad *_Revision_*.md glob classified a calendar as a completion.
    with open(os.path.join(d, "Proj_Revision_Calendar_run.md"), "w", encoding="utf-8", newline="") as fh:
        fh.write("# Revision Calendar\n<!-- resolved: F-P5-01 -->\n")
    check("calendar_not_completion",
          not any("rev=done" in ln for ln in run([os.path.join(d, "Proj_Findings_Ledger_run.md"),
                                                  os.path.join(d, "Proj_Revision_Calendar_run.md")])[1]))
    check("missing_ledger_usage_error", run([os.path.join(d, "Diagnostic_State.meta.json")])[0] == 2)

    # --- M1c integration: a governed run folder whose clearing event advanced F-P5-01 to revised,
    # marker since removed and the id mentioned nowhere -> run([folder]) surfaces E5 (event-scoped).
    dm = tempfile.mkdtemp()
    made.append(dm)
    with open(os.path.join(dm, "Proj_Findings_Ledger_run.md"), "w", encoding="utf-8", newline="") as fh:
        fh.write(ledger)
    # governed sidecar: state_version 2 + a clearing revision_round event for THIS run folder ("."),
    # finding_deltas advancing F-P5-01 to revised; folded finding_states carries revised.
    gov = {"execution": {"state_version": 2, "phase": "revision_round",
                         "finding_states": {"F-P5-01": "revised"},
                         "gate_events": [
                             {"gate": "revision_round", "result": "passed", "provenance": "mechanical",
                              "run_folder": ".", "attested_contract": [], "attested_items": [],
                              "checks": [{"validator": "x", "result": "ok"}],
                              "finding_deltas": {"F-P5-01": "revised"}}]}}
    with open(os.path.join(dm, "Diagnostic_State.meta.json"), "w", encoding="utf-8", newline="") as fh:
        json.dump(gov, fh)
    # a completion report that mentions F-P5-02 only (F-P5-01 marker removed, not mentioned)
    with open(os.path.join(dm, "Proj_Revision_Report_run.md"), "w", encoding="utf-8", newline="") as fh:
        fh.write("# Revision Report\n## Flags resolved\n<!-- resolved: F-P5-02 -->\n")
    mc_code, mc_lines = run([dm])
    check("m1c_run_folder_event_scope_e5",
          mc_code == 1 and any("E5 phantom" in ln and "F-P5-01" in ln for ln in mc_lines))
    # sibling artifact carrying the F-P5-01 marker clears it (resolved_ids union) -> back to PASS
    with open(os.path.join(dm, "Proj_Revision_Report_sibling.md"), "w", encoding="utf-8", newline="") as fh:
        fh.write("# Revision Report (sibling)\n<!-- resolved: F-P5-01 -->\n")
    mc2_code, _ = run([dm])
    check("m1c_run_folder_sibling_marker_clears", mc2_code == 0)

    for d in made:
        shutil.rmtree(d, ignore_errors=True)
    print("Self-test: PASS" if rc["v"] == 0 else "Self-test: FAIL")
    return rc["v"]


def main(argv):
    if "--self-test" in argv:
        return run_self_test()
    args = [a for a in argv[1:] if a not in ("finding-trace",)]
    strict = "--strict" in args
    paths = [a for a in args if not a.startswith("--")]
    if not paths:
        print("Usage: finding_trace.py finding-trace <run_folder|files...> [--strict] | --self-test")
        return 2
    code, lines = run(paths, strict=strict)
    for ln in lines:
        print(ln)
    return code


if __name__ == "__main__":
    sys.exit(main(sys.argv))

SHA-256: 90699a1dd9d07ec1abf97b27e026db6a86df9cd2d479dad6ed5d09e1c8d57457