← Files APODICTIC Development EditorARCHIVED FILE

scripts/refutation_check.py

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

↓ Download file

#!/usr/bin/env python3
"""APODICTIC finding-disconfirmation gates (docs/finding-disconfirmation.md §8).

HIGH means "survived": the Step 6b Finding Disconfirmation Pass (run-synthesis.md
§Processing Protocol) attempts to REFUTE each locked Must-Fix / HIGH Should-Fix finding
against the manuscript text and records every attempt in
`[Project]_Refutation_Record_[runlabel].md` — one `apodictic.refutation.v1` block per
attempt plus exactly one `apodictic.refutation_budget.v1` block. Three arms enforce it:

  refutation-coverage <editorial_letter> <findings_ledger> <refutation_record>
      V1 — no HIGH without survived refutation. Every synthesis-bound ledger finding at
      confidence HIGH needs a record block with attempted:true + outcome "survived", OR a
      cap-bound disclosure marker `<!-- refutation: not-attempted-budget F-<ORIGIN>-<NN> -->`
      in the letter BODY — honored ONLY when the RECOMPUTED budget actually binds (the
      eligible set recomputed from the ledger — Must-Fix ∪ HIGH Should-Fix, minus
      disposition-exempt — exceeds the spec cap 15) AND the block says bound:true AND the id
      is unprocessed (a marker on a processed finding, under an unbound budget, or under a
      bound:true claim the ledger recompute does not corroborate, is an ERROR: the marker is
      a disclosure, not an exemption you can reach for). The budget block is the pass's
      SELF-REPORT: its eligible/bound/cap are recomputed/pinned against the ledger and the
      spec — mismatch is a blocking ERROR (recompute, don't trust — the recorded-field
      rule applied to an exemption gate; Codex P1, PR #161: a fabricated bound:true
      budget must never let an untested HIGH ship as "cap-bound"). Every Must-Fix needs a
      record with attempted:true — the cap can never bind on Must-Fix (their ceiling is 10,
      the cap is 15), so a missing Must-Fix record is a silent skip, forbidden. Every record
      id must resolve to a locked (Must-Fix/Should-Fix) ledger finding (dangling = ERROR). A
      finding whose id carries a live declined/deferred disposition marker
      (docs/finding-dispositions.md; grammar SSoT `apodictic_artifacts.parse_disposition_markers`,
      read from the ledger or the letter) is exempt — the author already ruled (spec §5).

  refutation-evidence <refutation_record> [<manuscript_snapshot>] [--require-snapshot]
      V2 — attempts without quote-anchored counter-evidence don't count. Every record needs
      counter_evidence_quotes / alternative_explanations at minItems 1 (schema; a violation
      voids the attempt and is an ERROR). Each quote must occur VERBATIM in the intake
      manuscript snapshot — checked against the as-is snapshot bytes — and be single-line
      (no \\n / bare \\r). Verbatim-presence only (count >= 1): A6's uniqueness/offset
      requirements are deliberately dropped — a counter-evidence quote is evidence, not an
      anchor (docs/annotated-manuscript.md §A6 divergence, stated in the spec). A fabricated
      quote (count 0) is an ERROR — you cannot claim an attempt without touching real text.
      Quote > 25 words -> WARN (quote-budget hygiene, counter_evidence_quotes only; WARNs are
      advisory, exit 0, per the standing validate.sh convention). Each alternative_explanations
      entry must carry a quote mark, a Ch./p. locator, or a contract-artifact reference ->
      WARN if none (the §6.4 grounding floor; deeper grounding is prompt-side, audited by the
      rubber-stamp eval fixture). Snapshot handling splits by what the flow requires:
        * the snapshot is REQUIRED (missing = ERROR) under --require-snapshot, or when the
          record's folder holds a *_Core_DE_Synthesis_* / *_Full_DE_Synthesis_* letter (the
          same run-shape detection the annotated-manuscript offer uses) — a missing snapshot
          on a core-de/full-de run is a broken intake, not a degrade case;
        * elsewhere a missing snapshot is a WARN and every demotion in the record is VOID:
          outcomes weakened/refuted may NOT be transcribed into the ledger without verified
          quotes (confidence stays unchanged), and the letter must carry the disclosure line
          "counter-evidence quotes unverified — no intake snapshot; demotions withheld".
      When present, each record's snapshot_path/snapshot_sha256 must match the snapshot file
      actually on disk (as-is bytes, hashed) — mismatch = ERROR (the record was written
      against different bytes). snapshot_path is a MODEL-WRITTEN value used for filesystem
      READS only: it is resolved beside the record and contained to that folder (realpath);
      an absolute or escaping path is refused by name, never followed, never written to.
      Budget arithmetic: processed == min(eligible, cap) and bound == (eligible > processed),
      from the block's own fields -> ERROR on mismatch. cap must equal the spec constant 15
      (§5 — not a per-run knob) and processed must equal the count of schema-valid refutation
      blocks in the record -> both ERROR (the block is a self-report; promoted from WARN per
      the Codex P1 — a processed count with no blocks behind it is fabricated work).

  refutation-write-scope <findings_ledger> <refutation_record>
      V3 — the pass may write only refutation.* + confidence; any severity write fails the
      run. Schema-clean records only. A `severity` key at any depth — or any canonical
      severity token (Must-Fix | Should-Fix | Could-Fix) used as a field value — inside a
      refutation/budget block is an ERROR (the schema omits the property; this arm enforces
      it against the subset checker's unknown-key tolerance). For every processed id: ledger
      `confidence` == record `confidence_after`, and confidence_after obeys the outcome caps
      (survived = unchanged, never confidence-raising; weakened capped at MEDIUM; refuted =
      LOW/UNCERTAIN). The equality assertion is SKIPPED for records V2 voided for a missing
      snapshot (re-derived here from the record's snapshot_path resolved beside the record) —
      the expected ledger != record delta IS the withheld demotion. V3 is a pre-delivery
      fail-the-run gate, not a write-prevention mechanism: it cannot stop a bad transcription
      from being written, it stops the run from shipping with one (the deficit-lock /
      softness-check posture). "The letter cannot deliver below the locked severity" remains
      owned by deficit-lock + softness-check, which this pass runs entirely downstream of.

  --self-test    built-in cases (all three arms; disk fixtures in a temp dir)

Block grammar + schema validation come from the shared `apodictic_artifacts` module
(schemas/ are the source of truth: apodictic.refutation.v1 + apodictic.refutation_budget.v1).
Marker scanning strips code spans via the `override_marker` SSoT (meta-linter M5/M6 class)
and boundary-matches the Finding Lifecycle ID (F-P5-01 != F-P5-011). Malformed blocks are
refused with named shape errors — never silently filtered before comparison.

Exit: 0 pass (WARNs/NOTEs are advisory), 1 ERROR(s), 2 usage error.
"""
import glob
import hashlib
import os
import re
import sys

import apodictic_artifacts as art
from override_marker import strip_code_spans  # code-span stripping SSoT (meta-linter M6)

REFUTATION_SCHEMA = "apodictic.refutation.v1"
BUDGET_SCHEMA = "apodictic.refutation_budget.v1"
LOCKED = {"Must-Fix", "Should-Fix"}
# §5 cap: 15 findings per run — a SPEC CONSTANT (the Must-Fix ceiling is 10, so the cap can
# only ever bind on HIGH Should-Fix). Pinned here because the budget block's `cap` field is
# model-written: a lowered cap manufactures a "binding" budget, the same fabrication class
# as a lied bound:true (Codex P1, PR #161) — recompute/pin, don't trust.
SPEC_CAP = 15
# §7 caps table: outcome -> permitted confidence_after values. survived is absent on
# purpose — its rule is equality with the ledger (unchanged; never confidence-raising),
# asserted separately.
OUTCOME_CAPS = {"weakened": ("MEDIUM", "LOW", "UNCERTAIN"),
                "refuted": ("LOW", "UNCERTAIN")}
QUOTE_WORD_BUDGET = 25  # output-policy.md §Output Constraints quote budget
PINNED_DISCLOSURE = "counter-evidence quotes unverified — no intake snapshot; demotions withheld"

# Cap-bound disclosure marker (spec §5), pinned form:
#   <!-- refutation: not-attempted-budget F-<ORIGIN>-<NN> -->
# Scanned on the code-span-stripped letter BODY only; the id is boundary-matched so
# F-P5-01 never matches inside F-P5-011 (the override_marker discipline, new `refutation:`
# comment family — no collision with override:/finding:/declined:/deferred:/resolved:).
_BUDGET_MARKER_RE = re.compile(
    r"<!--\s*refutation:\s*not-attempted-budget\s+(F-[A-Za-z0-9]+-[0-9]{2,})(?![\w-])\s*-->")
# Letter body = everything before the first appendix heading (markers in appendix bodies
# are non-canonical, the standing body-vs-appendix override discipline).
_APPENDIX_SPLIT_RE = re.compile(r"Appendix\s+[A-Z]\b|Severity\s+Calibration", re.IGNORECASE)
# §6.4 grounding floor for alternative_explanations: a quote mark, a Ch./p. locator, or a
# contract-artifact reference. Quote marks are the double-quote family only (an apostrophe
# in a contraction must not count as grounding).
_QUOTE_MARK_RE = re.compile(r'["“”]')
_PAGE_LOCATOR_RE = re.compile(r"\bpp?\.\s*\d+")
_CONTRACT_REF_RE = re.compile(r"\bcontract\b|_Contract_", re.IGNORECASE)
# Run-shape detection (the annotated-manuscript offer's rule): the run wrote a full letter.
_RUN_SHAPE_GLOBS = ("*_Core_DE_Synthesis_*.md", "*_Full_DE_Synthesis_*.md")


def _body(letter_text):
    m = _APPENDIX_SPLIT_RE.search(letter_text or "")
    return letter_text[:m.start()] if m else (letter_text or "")


def parse_record(record_text):
    """((refutations, budgets), shape_errors) — the record's schema-validated refutation /
    refutation_budget blocks (parsed via art.parse_blocks, never a raw marker scan). Malformed blocks (bad
    JSON, non-dict payload, schema-invalid) are refused with NAMED shape errors and excluded
    from the returned lists — never silently filtered into a comparison."""
    refs, budgets, errs = [], [], []
    r_schema = art.load_schema(REFUTATION_SCHEMA)
    b_schema = art.load_schema(BUDGET_SCHEMA)
    n_r = n_b = 0
    for btype, obj, jerr in art.parse_blocks(record_text or ""):
        if btype == "refutation":
            n_r += 1
            where = "refutation block #%d" % n_r
            schema = r_schema
            dest = refs
        elif btype == "refutation_budget":
            n_b += 1
            where = "refutation_budget block #%d" % n_b
            schema = b_schema
            dest = budgets
        else:
            continue
        if jerr:
            errs.append("%s: invalid JSON — %s" % (where, jerr))
            continue
        if not isinstance(obj, dict):
            errs.append("%s: payload is not a JSON object" % where)
            continue
        block_errs = art.validate_obj(obj, schema, where)
        if block_errs:
            errs.extend(block_errs)
            continue
        dest.append(obj)
    return (refs, budgets), errs


def _locked_findings(ledger_text):
    """Locked apodictic.finding.v1 dicts from the ledger (tolerant parse — ledger block
    validity is structured-findings/deficit-lock's job, not this gate's)."""
    out = []
    for btype, obj, jerr in art.parse_blocks(ledger_text or ""):
        if jerr or btype != "finding" or not isinstance(obj, dict):
            continue
        if str(obj.get("schema", "")).startswith("apodictic.finding."):
            out.append(obj)
    return out


def _records_by_id(refs):
    """(id -> record, duplicate_errors). Ids are fid_key-coerced so a malformed id can
    never crash a dict key (the non-hashable/non-string id class)."""
    out, errs = {}, []
    for r in refs:
        fid = art.fid_key(r.get("id"))
        if fid in out:
            errs.append("duplicate refutation block for id %s — one attempt record per finding" % fid)
        out[fid] = r
    return out, errs


def _the_budget(budgets, errs):
    """Exactly one budget block per record (spec §4). Appends named errors; returns the
    block or None."""
    if len(budgets) == 0:
        errs.append("record carries no %s block — the budget block is the mechanical basis "
                    "for cap-bound disclosure; a zero-eligible run still writes one with "
                    "eligible: 0" % BUDGET_SCHEMA)
        return None
    if len(budgets) > 1:
        errs.append("record carries %d %s blocks — exactly one budget block per record"
                    % (len(budgets), BUDGET_SCHEMA))
        return None
    return budgets[0]


def _disposition_ids(*texts):
    """Ids carried by live declined/deferred disposition markers in any of `texts`
    (grammar SSoT: apodictic_artifacts.parse_disposition_markers — code-span-stripped,
    boundary-matched)."""
    out = set()
    for t in texts:
        for m in art.parse_disposition_markers(t or ""):
            out.add(m["id"])
    return out


def _resolve_snapshot(record_dir, snapshot_path):
    """(resolved_path_or_None, refusal_or_None). Containment: snapshot_path is a
    MODEL-WRITTEN value used only for filesystem READS — resolved beside the record and
    confined to that folder via realpath. Absolute paths and traversal (including symlink
    escape) are refused by name, never followed. Never used as a write target."""
    if not isinstance(snapshot_path, str) or not snapshot_path.strip():
        return None, "snapshot_path is not a non-empty string"
    if os.path.isabs(snapshot_path):
        return None, ("snapshot_path %r is absolute — it must be the intake-snapshot "
                      "filename beside the record; refusing to read it" % snapshot_path)
    base = os.path.realpath(record_dir or ".")
    target = os.path.realpath(os.path.join(base, snapshot_path))
    if target != base and not target.startswith(base + os.sep):
        return None, ("snapshot_path %r escapes the run folder (containment) — refusing "
                      "to read it" % snapshot_path)
    return target, None


def _core_full_run_shape(record_dir):
    """True iff the record's folder holds a *_Core_DE_Synthesis_* / *_Full_DE_Synthesis_*
    letter — the run-shape detection the annotated-manuscript offer uses. On these runs the
    intake snapshot is mandatory (run-core.md §Intake Protocol), so refutation-evidence
    requires it even without the explicit --require-snapshot flag."""
    for g in _RUN_SHAPE_GLOBS:
        if glob.glob(os.path.join(record_dir or ".", g)):
            return True
    return False


def _read_bytes(path):
    try:
        with open(path, "rb") as fh:
            return fh.read()
    except OSError:
        return None


def _short(s, n=60):
    s = str(s)
    return s if len(s) <= n else s[:n] + "…"


# ------------------------------------------------------------------ V1 coverage

def refutation_coverage(letter_text, ledger_text, record_text):
    """V1 — no HIGH without survived refutation. Returns (errs, warns, notes)."""
    errs, warns, notes = [], [], []
    (refs, budgets), shape_errs = parse_record(record_text)
    errs.extend(shape_errs)
    budget = _the_budget(budgets, errs)
    bound = budget.get("bound") if budget else None
    by_id, dup_errs = _records_by_id(refs)
    errs.extend(dup_errs)

    findings = _locked_findings(ledger_text)
    locked = [f for f in findings if f.get("severity") in LOCKED]
    ledger_ids = {art.fid_key(f.get("id")) for f in locked if f.get("id") is not None}
    dispo = _disposition_ids(ledger_text, letter_text)

    # Budget recompute (BLOCKING — the recorded-field rule applied to an exemption gate:
    # recompute a recorded value, never trust it; Codex P1, PR #161): the budget block is
    # the pass's SELF-REPORT, and the
    # cap-bound exemption used to trust it — a fabricated bound:true with eligible/processed
    # lies let an untested HIGH ship as "cap-bound" with zero real refutation work, with the
    # mismatches surfacing only as advisory WARNs. eligible is DERIVED from the lock
    # (Must-Fix ∪ HIGH Should-Fix, dispositions excluded), bound is DERIVED from that
    # recompute vs the spec cap, and cap is the §5 spec constant — every one of the block's
    # fields is a claim to verify, never an input.
    recomputed_eligible = sum(
        1 for f in locked
        if art.fid_key(f.get("id")) not in dispo
        and (f.get("severity") == "Must-Fix"
             or (f.get("severity") == "Should-Fix" and f.get("confidence") == "HIGH")))
    recomputed_bound = recomputed_eligible > SPEC_CAP
    if budget is not None:
        if budget.get("cap") != SPEC_CAP:
            errs.append("budget block records cap: %r but the refutation cap is the spec "
                        "constant %d (docs/finding-disconfirmation.md §5) — the cap is not "
                        "a per-run knob; a lowered cap manufactures a binding budget"
                        % (budget.get("cap"), SPEC_CAP))
        if budget.get("eligible") != recomputed_eligible:
            errs.append("budget block records eligible: %r but the ledger's eligible set "
                        "(Must-Fix ∪ HIGH Should-Fix, dispositions excluded) recomputes to "
                        "%d — eligible is derived from the lock, not asserted; reconcile "
                        "the budget with the ledger"
                        % (budget.get("eligible"), recomputed_eligible))
        if bound != recomputed_bound:
            errs.append("budget block records bound: %r but the ledger recompute derives "
                        "bound: %r (eligible recomputes to %d vs cap %d) — bound is a "
                        "claim the ledger must corroborate, never an input to the "
                        "cap-bound exemption"
                        % (bound, recomputed_bound, recomputed_eligible, SPEC_CAP))

    # Cap-bound disclosure markers — letter BODY only, code spans stripped (SSoT). The
    # exemption is honored only when the RECOMPUTED budget actually binds: the block's
    # bound:true is necessary (the pass must have disclosed) but never sufficient.
    markers = set(_BUDGET_MARKER_RE.findall(strip_code_spans(_body(letter_text))))
    for fid in sorted(markers):
        if fid in by_id:
            errs.append("cap-bound disclosure marker names %s, which HAS a refutation record "
                        "— the marker is a disclosure of an unprocessed finding, not an "
                        "exemption; remove the marker or the record" % fid)
        elif bound is not True:
            errs.append("cap-bound disclosure marker for %s under an unbound budget "
                        "(bound: %r) — the marker is honored only when the budget block "
                        "records bound: true" % (fid, bound))
        elif not recomputed_bound:
            errs.append("cap-bound disclosure marker for %s but the recomputed budget does "
                        "NOT bind (eligible recomputes to %d from the ledger, cap %d) — "
                        "bound: true in the budget block is a claim to verify, never an "
                        "input; run the Step 6b pass for %s or demote its confidence per "
                        "the §7 caps table" % (fid, recomputed_eligible, SPEC_CAP, fid))
        elif fid not in ledger_ids:
            errs.append("cap-bound disclosure marker names %s, which resolves to no locked "
                        "ledger finding — a disclosure for a phantom finding" % fid)

    # Per-finding coverage.
    for f in locked:
        sev = f.get("severity")
        conf = f.get("confidence")
        fid = art.fid_key(f.get("id"))
        label = "%s — %s" % (fid, _short(f.get("mechanism", "?")))
        if fid is None:
            if sev == "Must-Fix" or conf == "HIGH":
                errs.append("locked %s finding without a Finding Lifecycle ID cannot be "
                            "matched to a refutation record (%s) — assign an id "
                            "(apodictic.finding.v1 requires one)" % (sev, label))
            continue
        if fid in dispo:
            if fid in by_id:
                notes.append("%s carries a declined/deferred disposition AND a refutation "
                             "record — the record stands, the disposition exempts nothing "
                             "extra" % fid)
            else:
                notes.append("%s carries a declined/deferred disposition — exempt from the "
                             "refutation requirement (the author already ruled; spec §5)" % fid)
                continue
        rec = by_id.get(fid)
        if rec is None:
            if sev == "Must-Fix":
                errs.append("Must-Fix finding %s has no refutation record — every Must-Fix "
                            "is always processed (the cap cannot bind on Must-Fix); run the "
                            "Step 6b pass for it or record the author's disposition" % label)
            elif conf == "HIGH":
                if fid in markers and bound is True and recomputed_bound:
                    notes.append("HIGH finding %s not attempted — cap-bound and disclosed "
                                 "(marker honored: bound: true corroborated by the ledger "
                                 "recompute); its letter language must say convergence-only, "
                                 "not stress-tested" % fid)
                elif fid not in markers:
                    errs.append("HIGH finding %s has no survived refutation record and no "
                                "cap-bound disclosure marker — HIGH means survived; demote "
                                "the confidence per the §7 caps table, run the pass, or "
                                "disclose the cap with "
                                "<!-- refutation: not-attempted-budget %s --> (honored only "
                                "when the recomputed budget binds)" % (label, fid))
                # fid in markers under an unbound budget, or under a bound:true the
                # recompute does not corroborate, already errored in the marker scan.
            continue
        if rec.get("attempted") is not True:
            errs.append("%s finding %s has a refutation record with attempted: false — a "
                        "recorded skip is not an attempt; %s" %
                        (sev, label,
                         "Must-Fix is never skippable" if sev == "Must-Fix"
                         else "a HIGH needs a real survived attempt"))
            continue
        if conf == "HIGH" and rec.get("outcome") != "survived":
            errs.append("HIGH finding %s: record outcome is %r — HIGH requires a survived "
                        "attempt; transcribe confidence_after per the §7 caps table "
                        "(refutation-write-scope checks the exact transcription)"
                        % (label, rec.get("outcome")))

    # Dangling records: every record id resolves to a LOCKED ledger finding.
    all_ids = {art.fid_key(f.get("id")) for f in findings if f.get("id") is not None}
    for fid in sorted(k for k in by_id if k is not None):
        if fid not in ledger_ids:
            if fid in all_ids:
                errs.append("refutation record id %s resolves to a ledger finding that is "
                            "not synthesis-bound (not Must-Fix/Should-Fix) — the pass's "
                            "eligible set is locked findings only" % fid)
            else:
                errs.append("dangling refutation record id %s — it resolves to no ledger "
                            "finding" % fid)
    for fid in (k for k in by_id if k is None):
        errs.append("refutation record with a null id — cannot resolve to a ledger finding")
    return errs, warns, notes


# ------------------------------------------------------------------ V2 evidence

def refutation_evidence(record_text, record_dir, snapshot_arg=None, require_snapshot=False):
    """V2 — attempts without quote-anchored counter-evidence don't count.
    Returns (errs, warns, notes). `snapshot_arg` is the explicit CLI snapshot path (or
    None: resolve each record's snapshot_path beside the record, contained)."""
    errs, warns, notes = [], [], []
    (refs, budgets), shape_errs = parse_record(record_text)
    errs.extend(shape_errs)
    budget = _the_budget(budgets, errs)

    # Budget arithmetic (spec §V2): internal consistency of the block's own fields, plus
    # the two grounded checks — cap pinned to the spec constant, processed pinned to the
    # actual schema-valid block count (BLOCKING, promoted from WARN per the Codex P1 on
    # PR #161: the block is a self-report; a processed count with no blocks behind it is
    # fabricated work).
    if budget is not None:
        cap, eligible, processed, bound = (budget.get("cap"), budget.get("eligible"),
                                           budget.get("processed"), budget.get("bound"))
        if cap != SPEC_CAP:
            errs.append("budget block records cap: %r but the refutation cap is the spec "
                        "constant %d (docs/finding-disconfirmation.md §5) — the cap is not "
                        "a per-run knob; a lowered cap manufactures a binding budget"
                        % (cap, SPEC_CAP))
        if processed != min(eligible, cap):
            errs.append("budget arithmetic: processed (%r) != min(eligible, cap) = %r — the "
                        "pass processes Must-Fix first, then HIGH Should-Fix, up to the cap"
                        % (processed, min(eligible, cap)))
        if bound != (eligible > processed):
            errs.append("budget arithmetic: bound (%r) != (eligible > processed) (%r) — the "
                        "bound flag is derived, not asserted" % (bound, eligible > processed))
        if processed != len(refs):
            errs.append("budget block records processed: %r but the record carries %d "
                        "schema-valid refutation block(s) — processed is a recorded count "
                        "the record itself must corroborate (malformed blocks are refused "
                        "above and do not count); reconcile the budget with the record"
                        % (processed, len(refs)))

    required = bool(require_snapshot) or _core_full_run_shape(record_dir)
    explicit = None
    if snapshot_arg is not None:
        if os.path.isfile(snapshot_arg):
            explicit = snapshot_arg
        else:
            errs.append("named manuscript snapshot not found: %s — a snapshot the caller "
                        "names must exist (for the resolve-beside-the-record path, omit the "
                        "argument)" % snapshot_arg)
            return errs, warns, notes

    byte_cache = {}
    for i, rec in enumerate(refs, 1):
        fid = art.fid_key(rec.get("id")) or "refutation block #%d" % i
        # --- resolve the snapshot this record binds ---
        if explicit is not None:
            target = explicit
            sp = rec.get("snapshot_path")
            if isinstance(sp, str) and os.path.basename(sp) != os.path.basename(explicit):
                errs.append("%s: record snapshot_path names %r but the run's snapshot is %r "
                            "— the record was written against a different draft" %
                            (fid, sp, os.path.basename(explicit)))
                continue
        else:
            target, refusal = _resolve_snapshot(record_dir, rec.get("snapshot_path"))
            if refusal:
                errs.append("%s: %s" % (fid, refusal))
                continue
        if not os.path.isfile(target):
            if required:
                errs.append("%s: manuscript snapshot missing (%s) — core-de/full-de runs "
                            "persist the intake snapshot (run-core.md §Intake Protocol); a "
                            "missing snapshot here is a broken intake, not a degrade case" %
                            (fid, os.path.basename(str(target))))
            else:
                warns.append("%s: manuscript snapshot missing (%s) — counter-evidence quotes "
                             "are unverifiable, so every demotion in this record is VOID: "
                             "outcomes weakened/refuted may NOT be transcribed into the "
                             "ledger (confidence stays unchanged), and the letter must carry "
                             "the disclosure line \"%s\"" %
                             (fid, os.path.basename(str(target)), PINNED_DISCLOSURE))
                if rec.get("outcome") in OUTCOME_CAPS:
                    notes.append("%s: demotion (%s) withheld — unverified without the "
                                 "snapshot" % (fid, rec.get("outcome")))
            continue
        if target not in byte_cache:
            byte_cache[target] = _read_bytes(target)
        raw = byte_cache[target]
        if raw is None:
            errs.append("%s: snapshot %s exists but cannot be read" % (fid, os.path.basename(target)))
            continue
        # --- record-vs-snapshot identity: as-is bytes, hashed ---
        digest = hashlib.sha256(raw).hexdigest()
        if digest != rec.get("snapshot_sha256"):
            errs.append("%s: snapshot_sha256 mismatch — record %s… vs on-disk %s… — the "
                        "record was written against different bytes; re-run the Step 6b "
                        "pass against the current snapshot" %
                        (fid, str(rec.get("snapshot_sha256"))[:12], digest[:12]))
            continue
        text = raw.decode("utf-8", errors="replace")
        # --- verbatim, single-line quotes (count >= 1; uniqueness deliberately dropped) ---
        for j, q in enumerate(rec.get("counter_evidence_quotes") or [], 1):
            where = "%s quote #%d" % (fid, j)
            if not isinstance(q, str) or not q.strip():
                errs.append("%s: empty or non-string — a counter-evidence quote is verbatim "
                            "manuscript bytes" % where)
                continue
            if "\n" in q or "\r" in q:
                errs.append("%s: multi-line — quotes are single-line snapshot substrings "
                            "(the A6 locator semantics; quote the single most diagnostic "
                            "line)" % where)
                continue
            if text.count(q) < 1:
                errs.append("%s: not found verbatim in the snapshot — a fabricated quote "
                            "voids the attempt (you cannot claim an attempt without "
                            "touching real text): %r" % (where, _short(q, 80)))
                continue
            if len(q.split()) > QUOTE_WORD_BUDGET:
                warns.append("%s: %d words exceeds the %d-word quote budget "
                             "(output-policy.md §Output Constraints) — trim to the "
                             "diagnostic core" % (where, len(q.split()), QUOTE_WORD_BUDGET))
        # --- §6.4 grounding floor for alternatives ---
        for j, a in enumerate(rec.get("alternative_explanations") or [], 1):
            if not isinstance(a, str):
                errs.append("%s alternative #%d: not a string" % (fid, j))
                continue
            if not (_QUOTE_MARK_RE.search(a) or _PAGE_LOCATOR_RE.search(a)
                    or art.chapter_token(a) or _CONTRACT_REF_RE.search(a)):
                warns.append("%s alternative #%d carries no quote mark, Ch./p. locator, or "
                             "contract reference — each rival reading must point at text or "
                             "a contract/intent statement (§6.4 grounding floor): %r"
                             % (fid, j, _short(a, 80)))
    return errs, warns, notes


# ------------------------------------------------------------------ V3 write-scope

def _severity_channel_errors(obj, where, severity_tokens):
    """Recursive banned-key/banned-value walk: a `severity` key at any depth, or any string
    value exactly equal to a canonical severity token, is a severity channel in a record
    that must not have one."""
    errs = []

    def walk(node, path):
        if isinstance(node, dict):
            for k, v in node.items():
                kpath = "%s.%s" % (path, k)
                if isinstance(k, str) and k.lower() == "severity":
                    errs.append("%s: severity key at %s — the refutation record has no "
                                "severity channel by design; severity stays with the "
                                "Deficit Lock" % (where, kpath))
                walk(v, kpath)
        elif isinstance(node, list):
            for idx, v in enumerate(node):
                walk(v, "%s[%d]" % (path, idx))
        elif isinstance(node, str) and node in severity_tokens:
            errs.append("%s: canonical severity token %r used as a field value at %s — the "
                        "record may not carry severity in any channel" % (where, node, path))

    walk(obj, "$")
    return errs


def refutation_write_scope(ledger_text, record_text, record_dir):
    """V3 — no severity channel in the record; exact confidence transcription.
    Returns (errs, warns, notes)."""
    errs, warns, notes = [], [], []
    (refs, budgets), shape_errs = parse_record(record_text)
    errs.extend(shape_errs)
    severity_tokens = set(art.load_severity_values())

    # Severity-channel scan runs on EVERY parsed refutation/budget dict — including
    # schema-invalid ones (an invalid block must not dodge the severity kill), so re-parse
    # the raw blocks rather than only the schema-clean lists.
    n = 0
    for btype, obj, jerr in art.parse_blocks(record_text or ""):
        if btype not in ("refutation", "refutation_budget") or jerr or not isinstance(obj, dict):
            continue
        n += 1
        errs.extend(_severity_channel_errors(obj, "%s block #%d" % (btype, n), severity_tokens))

    # Confidence transcription: ledger confidence == record confidence_after, per the
    # outcome caps table. Voided records (snapshot missing beside the record — the V2
    # no-flag degrade) skip the equality assertion: the withheld demotion IS the correct
    # ledger state. Caps on confidence_after itself always apply.
    ledger_conf = {}
    for f in _locked_findings(ledger_text):
        fid = art.fid_key(f.get("id"))
        if fid is not None:
            ledger_conf[fid] = f.get("confidence")
    for rec in refs:
        fid = art.fid_key(rec.get("id"))
        outcome = rec.get("outcome")
        ca = rec.get("confidence_after")
        if outcome in OUTCOME_CAPS and ca not in OUTCOME_CAPS[outcome]:
            errs.append("%s: outcome %r caps confidence_after at %s — record says %r "
                        "(§7 caps table)" % (fid, outcome,
                                             "/".join(OUTCOME_CAPS[outcome]), ca))
        if fid not in ledger_conf:
            continue  # dangling — refutation-coverage owns that ERROR
        target, refusal = _resolve_snapshot(record_dir, rec.get("snapshot_path"))
        if refusal:
            errs.append("%s: %s" % (fid, refusal))
            continue
        void = not os.path.isfile(target)
        if void:
            notes.append("%s: snapshot missing beside the record — V2-voided; the "
                         "confidence_after-equality assertion is skipped (the withheld "
                         "demotion is the correct ledger state)" % fid)
            continue
        if ledger_conf[fid] != ca:
            errs.append("%s: transcription mismatch — ledger confidence %r != record "
                        "confidence_after %r; the synthesis agent transcribes "
                        "confidence_after into the locked ledger block per the §7 caps "
                        "table before Step 7 (run-synthesis.md §6b), and %s" %
                        (fid, ledger_conf[fid], ca,
                         "survival never raises confidence" if outcome == "survived"
                         else "a demotion must actually land in the ledger"))
    return errs, warns, notes


# ------------------------------------------------------------------ report / CLI

def report(errs, warns, notes, label):
    for nline in notes:
        print("NOTE: %s" % nline)
    for w in warns:
        print("WARN: %s" % w)
    for e in errs:
        print("ERROR: %s" % e)
    if errs:
        print("%s: FAIL (%d error(s), %d warning(s))" % (label, len(errs), len(warns)))
        return 1
    print("%s: PASS%s" % (label, (" (%d warning(s))" % len(warns)) if warns else ""))
    return 0


def _read_file(path, what):
    try:
        with open(path, encoding="utf-8") as fh:
            return fh.read(), None
    except (OSError, UnicodeDecodeError) as exc:
        return None, "cannot read %s %s: %s" % (what, path, exc)


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

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

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

    # non-UTF8 artifact on the refutation-coverage CLI read path must degrade to the
    # named cannot-read usage-error exit (2), never a raw UnicodeDecodeError traceback
    # (the disposition_check adjacent-exception class, swept repo-wide)
    _nud = tempfile.mkdtemp()
    try:
        _nu = os.path.join(_nud, "letter.md")
        _ok = os.path.join(_nud, "ok.md")
        with open(_nu, "wb") as _fh:
            _fh.write(b"\xff\xfenot utf-8\xff")
        with open(_ok, "w", encoding="utf-8") as _fh:
            _fh.write("# ok\n")
        check("non_utf8_read_file_named_error",
              _read_file(_nu, "editorial letter")[0] is None
              and "cannot read editorial letter" in (_read_file(_nu, "editorial letter")[1] or ""))
        check("non_utf8_coverage_cli_degrades",
              main(["refutation_check.py", "refutation-coverage", _nu, _ok, _ok]) == 2)
    finally:
        shutil.rmtree(_nud)

    def finding(fid, sev="Must-Fix", conf="HIGH", mech="the want never costs her anything"):
        return ('<!-- apodictic:finding\n{"schema":"apodictic.finding.v1","id":"%s",'
                '"mechanism":"%s","severity":"%s","confidence":"%s",'
                '"evidence_refs":["Ch. 3"],"fix_class":"x","risk_if_fixed":"y"}\n-->' %
                (fid, mech, sev, conf))

    SNAP = ("# Snapshot\n\nChapter 3\n\n"
            "For a moment she almost chooses the orchard over the debt.\n"
            "Then she folded the thought away and said nothing.\n")
    snap_sha = hashlib.sha256(SNAP.encode("utf-8")).hexdigest()
    QUOTE = "For a moment she almost chooses the orchard over the debt."
    ALT = "Restraint could be the design — Ch. 3 frames her caution as inheritance."

    def ref_block(fid="F-P5-01", outcome="survived", conf_after="HIGH", quotes=None,
                  alts=None, sha=None, snap_path="Snap_Manuscript_Snapshot_r1.md", extra=""):
        quotes = [QUOTE] if quotes is None else quotes
        alts = [ALT] if alts is None else alts
        import json as _json
        obj = ('{"schema":"apodictic.refutation.v1","id":"%s","attempted":true,'
               '"outcome":"%s",%s"counter_evidence_quotes":%s,"alternative_explanations":%s,'
               '"rationale":"A paid cost would have refuted this; none found.",'
               '"confidence_after":"%s","snapshot_path":"%s","snapshot_sha256":"%s"}' %
               (fid, outcome, extra, _json.dumps(quotes), _json.dumps(alts), conf_after,
                snap_path, sha or snap_sha))
        return "<!-- apodictic:refutation\n%s\n-->" % obj

    def budget_block(cap=15, eligible=1, processed=1, bound=False):
        return ('<!-- apodictic:refutation_budget\n{"schema":"apodictic.refutation_budget.v1",'
                '"cap":%d,"eligible":%d,"processed":%d,"bound":%s}\n-->' %
                (cap, eligible, processed, "true" if bound else "false"))

    LEDGER = "## Pass 5 — Ledger Entry\n### Notable Findings\n" + finding("F-P5-01") + "\n"
    LETTER = ("# Development Edit: Snap\n## What Needs Work\n"
              "The want never costs her anything (Ch. 3). <!-- finding: F-P5-01 -->\n"
              "## Appendix B: Severity Calibration\nF-P5-01 held.\n")
    RECORD = "# Refutation Record\n" + ref_block() + "\n" + budget_block() + "\n"

    tmp = tempfile.mkdtemp()
    try:
        with open(os.path.join(tmp, "Snap_Manuscript_Snapshot_r1.md"), "w",
                  encoding="utf-8", newline="") as fh:
            fh.write(SNAP)
        snap_file = os.path.join(tmp, "Snap_Manuscript_Snapshot_r1.md")

        # 1. valid record, survived HIGH Must-Fix -> all three arms PASS
        e1, w1, _ = refutation_coverage(LETTER, LEDGER, RECORD)
        check("v1_valid_survived_pass", not e1 and not w1, str(e1 + w1))
        e2, w2, _ = refutation_evidence(RECORD, tmp, snap_file, require_snapshot=True)
        check("v2_valid_survived_pass", not e2 and not w2, str(e2 + w2))
        e3, w3, _ = refutation_write_scope(LEDGER, RECORD, tmp)
        check("v3_valid_survived_pass", not e3 and not w3, str(e3 + w3))

        # 2. HIGH finding, no record, budget unbound -> V1 ERROR (the headline gate)
        led2 = LEDGER + finding("F-P5-02", sev="Should-Fix", mech="subplot stalls without payoff") + "\n"
        rec2 = "# Refutation Record\n" + ref_block() + "\n" + budget_block(eligible=2, processed=1) + "\n"
        e, _, _ = refutation_coverage(LETTER, led2, rec2)
        check("v1_high_no_record_unbound_errors",
              any("F-P5-02" in x and "no survived refutation record" in x for x in e), str(e))

        # 3. Cap-bound disclosure — recompute-not-trust (Codex P1, PR #161). A GENUINELY
        #    binding budget: 16 eligible (1 Must-Fix + 15 HIGH Should-Fix) > cap 15, 15
        #    processed in ledger order, marker on the one unprocessed HIGH -> PASS with
        #    note. Then the attacks, each a blocking ERROR: bound:true whose ledger
        #    recompute does NOT bind (naming the marker's finding); bound:false marker;
        #    fabricated eligible (the Codex repro shape — promoted from WARN); lowered
        #    cap; marker on a processed id.
        sf_ids = ["F-P8-%02d" % i for i in range(1, 16)]
        led_bound = LEDGER + "".join(
            finding(fid, sev="Should-Fix", mech="subplot %s stalls" % fid) + "\n" for fid in sf_ids)
        rec_bound = ("# Refutation Record\n" + ref_block() + "\n"
                     + "".join(ref_block(fid=fid) + "\n" for fid in sf_ids[:-1])
                     + budget_block(eligible=16, processed=15, bound=True) + "\n")
        let_bound = LETTER.replace("## Appendix B",
                                   "<!-- refutation: not-attempted-budget F-P8-15 -->\n## Appendix B")
        e, w, notes = refutation_coverage(let_bound, led_bound, rec_bound)
        check("v1_bound_marker_honored", not e and any("F-P8-15" in x for x in notes), str(e + w))
        # bound:true the ledger recompute does not corroborate (2 eligible <= cap 15) —
        # the fabricated-exemption keystone: the marker must ERROR naming the finding.
        rec3 = "# Refutation Record\n" + ref_block() + "\n" + budget_block(eligible=2, processed=1, bound=True) + "\n"
        let3 = LETTER.replace("## Appendix B",
                              "<!-- refutation: not-attempted-budget F-P5-02 -->\n## Appendix B")
        e, _, _ = refutation_coverage(let3, led2, rec3)
        check("v1_marker_fake_bound_errors",
              any("F-P5-02" in x and "does NOT bind" in x for x in e)
              and any("claim the ledger must corroborate" in x for x in e), str(e))
        e, _, _ = refutation_coverage(let3, led2, rec2)  # same marker, bound:false
        check("v1_marker_unbound_budget_errors", any("unbound budget" in x for x in e), str(e))
        # fabricated eligible (the Codex repro shape): 16/15/bound:true claimed on a
        # 2-finding ledger -> blocking ERROR, not the old advisory WARN.
        rec3f = ("# Refutation Record\n" + ref_block() + "\n"
                 + budget_block(eligible=16, processed=15, bound=True) + "\n")
        e, w, _ = refutation_coverage(let3, led2, rec3f)
        check("v1_fabricated_eligible_errors",
              any("eligible is derived from the lock" in x for x in e) and not w, str(e + w))
        # lowered cap: cap is the §5 spec constant, not a knob a record can turn.
        rec3c = ("# Refutation Record\n" + ref_block() + "\n"
                 + budget_block(cap=1, eligible=2, processed=1, bound=True) + "\n")
        e, _, _ = refutation_coverage(let3, led2, rec3c)
        check("v1_lowered_cap_errors", any("not a per-run knob" in x for x in e), str(e))
        let3p = LETTER.replace("## Appendix B",
                               "<!-- refutation: not-attempted-budget F-P5-01 -->\n## Appendix B")
        e, _, _ = refutation_coverage(let3p, LEDGER, RECORD)
        check("v1_marker_on_processed_errors", any("HAS a refutation record" in x for x in e), str(e))
        # marker quoted in a code span is a documentation example, not a live disclosure
        let3c = LETTER.replace("## Appendix B",
                               "`<!-- refutation: not-attempted-budget F-P8-15 -->`\n## Appendix B")
        e, _, _ = refutation_coverage(let3c, led_bound, rec_bound)
        check("v1_marker_codespan_decoy_rejected",
              any("F-P8-15" in x and "no survived refutation record" in x for x in e), str(e))
        # boundary: a marker for F-P8-151 must not disclose F-P8-15 — and under a genuinely
        # bound budget an unknown marked id is a phantom disclosure
        let3b = LETTER.replace("## Appendix B",
                               "<!-- refutation: not-attempted-budget F-P8-151 -->\n## Appendix B")
        e, _, _ = refutation_coverage(let3b, led_bound, rec_bound)
        check("v1_marker_id_boundary",
              any("F-P8-15" in x and "no survived" in x for x in e)
              and any("phantom" in x for x in e), str(e))

        # 4. empty quotes / empty alternatives -> V2 ERROR (attempt void; schema minItems)
        rec4a = "# R\n" + ref_block(quotes=[]) + "\n" + budget_block() + "\n"
        e, _, _ = refutation_evidence(rec4a, tmp, snap_file)
        check("v2_empty_quotes_errors", any("counter_evidence_quotes" in x for x in e), str(e))
        rec4b = "# R\n" + ref_block(alts=[]) + "\n" + budget_block() + "\n"
        e, _, _ = refutation_evidence(rec4b, tmp, snap_file)
        check("v2_empty_alternatives_errors", any("alternative_explanations" in x for x in e), str(e))

        # 5. fabricated / multi-line / over-budget / sha-mismatch / snapshot handling
        e, _, _ = refutation_evidence("# R\n" + ref_block(quotes=["This sentence is not in the snapshot."])
                                      + "\n" + budget_block() + "\n", tmp, snap_file)
        check("v2_fabricated_quote_errors", any("not found verbatim" in x for x in e), str(e))
        e, _, _ = refutation_evidence("# R\n" + ref_block(quotes=["line one\nline two"])
                                      + "\n" + budget_block() + "\n", tmp, snap_file)
        check("v2_multiline_quote_errors", any("multi-line" in x for x in e), str(e))
        long_line = "word " * 29 + "end of the long snapshot line"
        snap_long = SNAP + long_line + "\n"
        with open(os.path.join(tmp, "Long_Manuscript_Snapshot_r1.md"), "w",
                  encoding="utf-8", newline="") as fh:
            fh.write(snap_long)
        sha_long = hashlib.sha256(snap_long.encode("utf-8")).hexdigest()
        rec5 = ("# R\n" + ref_block(quotes=[long_line], sha=sha_long,
                                    snap_path="Long_Manuscript_Snapshot_r1.md")
                + "\n" + budget_block() + "\n")
        e, w, _ = refutation_evidence(rec5, tmp, os.path.join(tmp, "Long_Manuscript_Snapshot_r1.md"))
        check("v2_long_quote_warns_not_errors", not e and any("word quote budget" in x or "quote budget" in x for x in w),
              str(e + w))
        e, _, _ = refutation_evidence("# R\n" + ref_block(sha="0" * 64) + "\n" + budget_block() + "\n",
                                      tmp, snap_file)
        check("v2_sha_mismatch_errors", any("snapshot_sha256 mismatch" in x for x in e), str(e))
        # missing snapshot: ERROR under --require-snapshot; WARN + void without it
        tmp2 = tempfile.mkdtemp()
        try:
            rec_void = ("# R\n" + ref_block(outcome="weakened", conf_after="MEDIUM")
                        + "\n" + budget_block() + "\n")
            e, _, _ = refutation_evidence(rec_void, tmp2, None, require_snapshot=True)
            check("v2_missing_snapshot_required_errors", any("broken intake" in x for x in e), str(e))
            e, w, notes = refutation_evidence(rec_void, tmp2, None)
            check("v2_missing_snapshot_warns_voids",
                  not e and any("VOID" in x and PINNED_DISCLOSURE in x for x in w)
                  and any("withheld" in x for x in notes), str(e + w))
            # ...and V3 skips the equality assertion for the voided record (ledger still HIGH)
            e, _, notes = refutation_write_scope(LEDGER, rec_void, tmp2)
            check("v3_void_skips_equality", not e and any("V2-voided" in x for x in notes), str(e))
            # run-shape detection: a Core_DE_Synthesis letter beside the record implies the flag
            with open(os.path.join(tmp2, "Proj_Core_DE_Synthesis_r1.md"), "w",
                      encoding="utf-8", newline="") as fh:
                fh.write("# letter\n")
            e, _, _ = refutation_evidence(rec_void, tmp2, None)
            check("v2_run_shape_implies_required", any("broken intake" in x for x in e), str(e))
        finally:
            shutil.rmtree(tmp2)
        # ungrounded alternative -> WARN
        e, w, _ = refutation_evidence("# R\n" + ref_block(alts=["could be intentional"])
                                      + "\n" + budget_block() + "\n", tmp, snap_file)
        check("v2_ungrounded_alternative_warns", not e and any("grounding floor" in x for x in w),
              str(e + w))
        # containment: platform-native absolute + escaping snapshot_path refused by name
        abs_outside = os.path.abspath(os.path.join(tmp, os.pardir, "outside.md")).replace(os.sep, "/")
        e, _, _ = refutation_evidence("# R\n" + ref_block(snap_path=abs_outside) + "\n"
                                      + budget_block() + "\n", tmp, None)
        check("v2_absolute_snapshot_path_refused", any("absolute" in x for x in e), str(e))
        e, _, _ = refutation_evidence("# R\n" + ref_block(snap_path="../outside.md") + "\n"
                                      + budget_block() + "\n", tmp, None)
        check("v2_escaping_snapshot_path_refused", any("containment" in x for x in e), str(e))
        # budget arithmetic
        e, _, _ = refutation_evidence("# R\n" + ref_block() + "\n"
                                      + budget_block(eligible=3, processed=1) + "\n", tmp, snap_file)
        check("v2_budget_processed_arithmetic_errors",
              any("processed" in x and "min(eligible, cap)" in x for x in e), str(e))
        e, _, _ = refutation_evidence("# R\n" + ref_block() + "\n"
                                      + budget_block(bound=True) + "\n", tmp, snap_file)
        check("v2_budget_bound_arithmetic_errors", any("bound" in x and "derived" in x for x in e), str(e))
        # processed must equal the actual schema-valid block count — a blocking ERROR
        # (promoted from WARN per the Codex P1: processed with no blocks behind it is
        # fabricated work), and the cap is the §5 spec constant, not a knob.
        e, w, _ = refutation_evidence("# R\n" + ref_block() + "\n"
                                      + budget_block(eligible=2, processed=2) + "\n", tmp, snap_file)
        check("v2_processed_count_mismatch_errors",
              any("schema-valid refutation block" in x for x in e) and not w, str(e + w))
        e, _, _ = refutation_evidence("# R\n" + ref_block() + "\n"
                                      + budget_block(cap=1, eligible=1, processed=1) + "\n", tmp, snap_file)
        check("v2_lowered_cap_errors", any("not a per-run knob" in x for x in e), str(e))
        # budget block missing / duplicated
        e, _, _ = refutation_evidence("# R\n" + ref_block() + "\n", tmp, snap_file)
        check("v2_missing_budget_block_errors", any("no apodictic.refutation_budget.v1" in x for x in e), str(e))
        e, _, _ = refutation_evidence("# R\n" + ref_block() + "\n" + budget_block() + "\n"
                                      + budget_block() + "\n", tmp, snap_file)
        check("v2_duplicate_budget_block_errors", any("exactly one budget block" in x for x in e), str(e))

        # 6. severity channel in a refutation block -> V3 ERROR
        rec6 = ("# R\n" + ref_block(extra='"severity":"Should-Fix",') + "\n" + budget_block() + "\n")
        e, _, _ = refutation_write_scope(LEDGER, rec6, tmp)
        check("v3_severity_key_errors", any("severity key" in x for x in e), str(e))
        rec6b = ("# R\n" + ref_block(extra='"note":"Must-Fix",') + "\n" + budget_block() + "\n")
        e, _, _ = refutation_write_scope(LEDGER, rec6b, tmp)
        check("v3_severity_token_value_errors",
              any("used as a field value" in x for x in e), str(e))

        # 7. transcription miss / cap violation -> V3 ERROR
        rec7 = ("# R\n" + ref_block(outcome="weakened", conf_after="MEDIUM") + "\n"
                + budget_block() + "\n")
        e, _, _ = refutation_write_scope(LEDGER, rec7, tmp)  # ledger still HIGH
        check("v3_transcription_miss_errors", any("transcription mismatch" in x for x in e), str(e))
        rec7b = ("# R\n" + ref_block(outcome="refuted", conf_after="HIGH") + "\n"
                 + budget_block() + "\n")
        e, _, _ = refutation_write_scope(LEDGER, rec7b, tmp)
        check("v3_cap_violation_errors", any("caps confidence_after" in x for x in e), str(e))
        # survived must EQUAL the ledger (never confidence-raising): ledger MEDIUM, record HIGH
        led7 = "## Ledger\n" + finding("F-P5-01", conf="MEDIUM") + "\n"
        e, _, _ = refutation_write_scope(led7, RECORD, tmp)
        check("v3_survived_never_raises", any("transcription mismatch" in x for x in e), str(e))
        # V1 also refuses the untranscribed HIGH (outcome != survived at HIGH)
        e, _, _ = refutation_coverage(LETTER, LEDGER, rec7)
        check("v1_high_with_nonsurvived_record_errors",
              any("HIGH requires a survived attempt" in x for x in e), str(e))

        # 8. dangling record id -> V1 ERROR; non-dict payload -> clean named error, no crash
        rec8 = RECORD + ref_block(fid="F-ZZ-09") + "\n"
        e, _, _ = refutation_coverage(LETTER, LEDGER, rec8.replace(budget_block(), budget_block(eligible=1, processed=1)))
        check("v1_dangling_record_errors", any("dangling refutation record id F-ZZ-09" in x for x in e), str(e))
        # a record on a non-locked (Could-Fix) finding is out of scope
        led8 = LEDGER + finding("F-P5-03", sev="Could-Fix", conf="LOW", mech="a polish nit") + "\n"
        e, _, _ = refutation_coverage(LETTER, led8, RECORD + ref_block(fid="F-P5-03") + "\n")
        check("v1_could_fix_record_errors", any("not synthesis-bound" in x for x in e), str(e))
        (r_ok, b_ok), errs8 = parse_record("<!-- apodictic:refutation\n42\n-->")
        check("nondict_payload_named_error",
              not r_ok and any("not a JSON object" in x for x in errs8), str(errs8))
        (r_ok, b_ok), errs8b = parse_record('<!-- apodictic:refutation\n{"schema":"apodictic.refutation.v1"\n-->')
        check("broken_carrier_named_error", not r_ok and any("invalid JSON" in x for x in errs8b), str(errs8b))
        # attempted:false is a recorded skip, not an attempt
        rec8c = RECORD.replace('"attempted":true', '"attempted":false')
        e, _, _ = refutation_coverage(LETTER, LEDGER, rec8c)
        check("v1_attempted_false_errors", any("attempted: false" in x for x in e), str(e))
        # duplicate refutation blocks for one id
        e, _, _ = refutation_coverage(LETTER, LEDGER, RECORD + ref_block() + "\n")
        check("v1_duplicate_record_errors", any("duplicate refutation block" in x for x in e), str(e))
        # disposition-exempt finding: declined Must-Fix without a record passes with a note
        led9 = LEDGER + "\n<!-- declined: F-P5-01 — the author already ruled -->\n"
        e, _, notes = refutation_coverage(LETTER, led9, "# R\n" + budget_block(eligible=0, processed=0) + "\n")
        check("v1_disposition_exempts", not e and any("exempt" in x for x in notes), str(e))
    finally:
        shutil.rmtree(tmp)

    print("Self-test: %s" % ("PASS" if rc["v"] == 0 else "FAIL"))
    return rc["v"]


def main(argv):
    if len(argv) < 2:
        print(__doc__)
        return 2
    cmd = argv[1]
    if cmd == "--self-test":
        return run_self_test()
    if cmd == "refutation-coverage":
        if len(argv) < 5:
            print("Usage: refutation_check.py refutation-coverage <editorial_letter> "
                  "<findings_ledger> <refutation_record>")
            return 2
        texts = []
        for path, what in ((argv[2], "editorial letter"), (argv[3], "findings ledger"),
                           (argv[4], "refutation record")):
            text, err = _read_file(path, what)
            if err:
                print("Error: %s" % err)
                return 2
            texts.append(text)
        return report(*refutation_coverage(*texts), label="refutation-coverage")
    if cmd == "refutation-evidence":
        args = [a for a in argv[2:] if a != "--require-snapshot"]
        require = "--require-snapshot" in argv[2:]
        if len(args) < 1:
            print("Usage: refutation_check.py refutation-evidence <refutation_record> "
                  "[<manuscript_snapshot>] [--require-snapshot]")
            return 2
        record_text, err = _read_file(args[0], "refutation record")
        if err:
            print("Error: %s" % err)
            return 2
        snapshot_arg = args[1] if len(args) > 1 else None
        record_dir = os.path.dirname(os.path.abspath(args[0]))
        return report(*refutation_evidence(record_text, record_dir, snapshot_arg, require),
                      label="refutation-evidence")
    if cmd == "refutation-write-scope":
        if len(argv) < 4:
            print("Usage: refutation_check.py refutation-write-scope <findings_ledger> "
                  "<refutation_record>")
            return 2
        ledger_text, err = _read_file(argv[2], "findings ledger")
        if err:
            print("Error: %s" % err)
            return 2
        record_text, err = _read_file(argv[3], "refutation record")
        if err:
            print("Error: %s" % err)
            return 2
        record_dir = os.path.dirname(os.path.abspath(argv[3]))
        return report(*refutation_write_scope(ledger_text, record_text, record_dir),
                      label="refutation-write-scope")
    print("Unknown command: %s" % cmd)
    return 2


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

SHA-256: 1021c88b98f078658f18442d3fdb519fb44401929ae329a8a7a36c70c348ffd5