From 063eccfd804fdbafcc82d1e9f8f9e0a9a9e29a39 Mon Sep 17 00:00:00 2001 From: David F Glidden Date: Tue, 25 Aug 2026 15:29:59 +0200 Subject: [PATCH] [HARDENING] STATE-CLAIM + the resolution state, built together (REVIEWED-127) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both halves of the schema, shipped in one change because the ruling said half a schema invites a third patch and a third patch is how a vocabulary accretes instead of being designed. 157 — resolution state. `resolved:` on any block; a resolved block is no longer due but is NOT dropped: it prints as a closed ledger, because a discharge that vanishes from the report is its own decay. The pointer must RESOLVE — a real path or a real git object — so an undocumented discharge is impossible to express rather than merely discouraged. A dangling pointer reports in the register-integrity lane, the same lane as an amendment that replaced the record it amends; both are a record closing over its own history. The 25th's hand-rename is MIGRATED back to DEFERRED-DECISION with resolved: set. That block was the per-instance workaround 157 was filed against, and it is now the migration's own test case. 158 — STATE-CLAIM. Reuses trigger_fired() verbatim and inverts only what firing MEANS: for a deferral, fired = the decision is due; for a state-claim, fired = THE CLAIM IS FALSE. Two new trigger kinds earned directly from today's instances: text-present (the trial-09 hold, falsified by REVIEWED-124's existence) and file-changed-since ("the filed rule not edited", false one hour after writing). 16 new controls, each with its discriminating half — fires on met, silent on unmet, manual listed-never-fired, resolved excluded from due-ness, the SAME block unresolved still due, a real pointer resolves, "yes, done" does not. Proven on the LIVE blocks, not only fixtures: pointing the state-claim at an older commit made it report FALSIFIED by name; replacing the resolution with "yes done" made register-integrity report it; both restored and both returned to quiet. ⚠ One control failed before shipping and the failure was the useful part. The negative control for file-changed-since pointed at FOOL-SEED-RULE.md, which this same session then edited — so "unchanged since HEAD" broke, correctly. A control whose subject is "did this file change" must not point at a file the session is changing. Re-pointed at a frozen 2026-08-02 trial artifact, with the reason recorded at the fixture. Caught because the controls run on every invocation rather than in a separate suite. First two real state-claims filed, deliberately one of each kind: ~/CLAUDE.md untouched under PENDING-150, mechanically watched and [ESCALATE]-grade the moment it goes false; and §9's channel unbuilt, marked `manual` because it has no filename yet and inventing a proxy falsifier is the error the schema's own comment warns against. ⚠ The zero-state prints a WARNING, not a tick: "0 marked, NOT none-stale" with the ~57 unmarked candidates named as a grep. An instrument that reads nothing reports exactly like one that finds nothing, and that is the failure this item exists to end. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01J6hZXNYSxEfZseBGTni4sf --- PENDING.md | 20 +- claude/governance/fool/seed/FOOL-SEED-RULE.md | 11 +- .../fool/seed/FOOL-SOUL-2026-08-25.md | 10 + scripts/governance-drift-check.py | 223 +++++++++++++++++- 4 files changed, 247 insertions(+), 17 deletions(-) diff --git a/PENDING.md b/PENDING.md index 356711f..96d84e4 100644 --- a/PENDING.md +++ b/PENDING.md @@ -4252,20 +4252,20 @@ Jurist: *"Confirm that lowercasing is applied at exactly one point in the code a | PENDING-89 source (iv) | **AUTHORIZED to read** — 55 files; answers generation diversity only, never correlation of misses | | §13.6 block | ⚠ **LIFTED** — its stated condition (§5 ratification) is met | - +⚠ **MIGRATED 2026-08-25 under REVIEWED-127.** This block was closed on the 25th by +hand-renaming its key to `DISCHARGED-DECISION`, because the schema had no way to say +*answered*. That rename was the per-instance workaround PENDING-157 was filed against, and +it is now reverted: the key is `DEFERRED-DECISION` again and the closure is carried by a +`resolved:` field the checker reads. **This block is the migration's own test case** — the +first resolved deferral, and the one whose pointer must resolve. + ⚠ **TRIGGER BLOCK ADDED 2026-08-24.** Until today this run-once, irreversible, dated act had **no trigger of any kind** — no cron, no launchd, no scheduled agent, and not a tracked DEFERRED-DECISION. It was one of the 105 prose deferrals the drift-check reports as carrying no diff --git a/claude/governance/fool/seed/FOOL-SEED-RULE.md b/claude/governance/fool/seed/FOOL-SEED-RULE.md index 258c77e..bdb9b5f 100644 --- a/claude/governance/fool/seed/FOOL-SEED-RULE.md +++ b/claude/governance/fool/seed/FOOL-SEED-RULE.md @@ -299,7 +299,16 @@ which is the entire point of having filed it early. Current state lives in - ~~No bones have been derived. No soul has been generated.~~ → **Bones derived 2026-08-25**, once, commit `5694b925`. **No soul generated** — that remains true, and §7 of the doctrine is unrun. -- `~/CLAUDE.md` has **not** been touched (PENDING-150, unbundled). +- `~/CLAUDE.md` has **not** been touched (PENDING-150, unbundled). — **still true, and now watched.** + + +*(The first negative state-claim in this system to carry a falsifier. It is the one bullet +in §6 still true, it is `[ESCALATE]`-grade the moment it stops being true, and until today +nothing in this system would have noticed. REVIEWED-127.)* - Nothing has been implemented of §8, §8a or §9 — the status line is confirmed free but not built. diff --git a/claude/governance/fool/seed/FOOL-SOUL-2026-08-25.md b/claude/governance/fool/seed/FOOL-SOUL-2026-08-25.md index f560810..c4a72f7 100644 --- a/claude/governance/fool/seed/FOOL-SOUL-2026-08-25.md +++ b/claude/governance/fool/seed/FOOL-SOUL-2026-08-25.md @@ -207,6 +207,16 @@ both went false within hours of being written and neither was caught by any mech - §8a body — unbuilt; condition 2 (variation from time or nothing, never content) unmet. - §9 channel — unbuilt. **Tarbuckle has a name, bones and a voice, and nowhere yet to be silent.** + + +*(Deliberately `manual`. §9's channel has no filename yet, so there is no path to test and +no string to match — inventing a proxy falsifier would be exactly the error this schema's +own comment warns against. `manual` is listed and never fired: the claim is tracked AND +known to be unwatched, rather than merely looking watched. REVIEWED-127.)* - No second generation. No hand edit. - ~~No regeneration ruling.~~ → **Ruled 2026-08-25** (§4a): the glosses were never a §7 input, so there is no implementation error to demonstrate and §4 REGENERATION is not diff --git a/scripts/governance-drift-check.py b/scripts/governance-drift-check.py index a342298..b290f14 100755 --- a/scripts/governance-drift-check.py +++ b/scripts/governance-drift-check.py @@ -20,6 +20,7 @@ import json import os import re import signal +import subprocess import sys from datetime import date from pathlib import Path @@ -282,6 +283,55 @@ control("register file is reachable", REVIEWED_MD.exists()) DEFERRED_RE = re.compile( r"", re.S) +# REVIEWED-127 / PENDING-158. A negative STATE-claim is the same sentence as a deferral +# about a different object: a deferral says "not yet" about a DECISION, a state-claim says +# "not yet" about a STATE. Same words, same forgetting, same substrate standing ready to +# contradict them — and until now only one of the two had a trigger. +# +# So this reuses trigger_fired() verbatim and inverts only what firing MEANS: for a +# deferral, fired = the decision is due; for a state-claim, fired = THE CLAIM IS FALSE. +# +# +# +# ⚠ Opt-in, and the limit is the item's, not a caveat added here: a marker is used by +# authors who remember to mark their claims, which is the same population that would have +# caught the claim anyway. ADOPTION is the open question, not expressibility. +STATE_CLAIM_RE = re.compile( + r"", re.S) + +# REVIEWED-127 C1 / PENDING-157. The resolution state, which BOTH kinds carry. +# +# Earned 2026-08-25: a trigger came due, was correctly discharged, and the schema had no +# way to say so. The only options were deleting the block (losing the record) or renaming +# its key by hand (losing machine-checkability, and relying on the reflex the mechanism +# exists to replace). Renaming keys one at a time is how you live with a defect. +# +# `resolved:` must SAY WHAT DISCHARGED IT. Non-empty is not enough — an undocumented +# discharge records THAT a gate closed and not WHY, which six months on is no record at +# all. A resolved block whose pointer resolves to nothing is reported as a defect, in the +# same lane as an amendment that replaced the record it amends: both close over history. +state_claims: list[dict] = [] + + +def pointer_resolves(text: str) -> bool: + """True if the resolution names something that exists — a path, or a git object.""" + for tok in re.findall(r"[A-Za-z0-9_./~-]{4,}", text or ""): + tok = tok.strip(".,;:") + if re.fullmatch(r"[0-9a-f]{7,40}", tok): + if subprocess.run(["git", "-C", str(HOME / "dotfiles"), "cat-file", "-e", + tok + "^{commit}"], capture_output=True).returncode == 0: + return True + cand = (Path(os.path.expanduser(tok)) if tok.startswith(("~", "/")) + else HOME / "dotfiles" / tok) + if cand.exists(): + return True + return False + SCAN_ROOTS = [HOME / "_Dev", HOME / "dotfiles"] TRANSCRIPTS = HOME / ".claude/projects/-Users-davidglidden" # Governance packages live outside the */docs/** convention the scan was written for, @@ -306,7 +356,22 @@ def parse_deferrals(text: str, src: Path) -> list[dict]: out.append({"slug": slug, "file": src, "root": _repo_root(src), "trigger": fields.get("trigger", "manual"), "owner": fields.get("owner", "?"), - "since": fields.get("since", "?")}) + "since": fields.get("since", "?"), + "resolved": fields.get("resolved", "").strip()}) + return out + + +def parse_state_claims(text: str, src: Path) -> list[dict]: + """Same shape as a deferral; `falsified-by` is mapped onto `trigger` so the one + evaluator serves both. Firing means the CLAIM IS FALSE, not that a decision is due.""" + out = [] + for slug, body in STATE_CLAIM_RE.findall(text): + fields = dict(re.findall(r"^\s*([a-z-]+):\s*(.+?)\s*$", body, re.M)) + out.append({"slug": slug, "file": src, "root": _repo_root(src), + "trigger": fields.get("falsified-by", "manual"), + "claims": fields.get("claims", "?"), + "since": fields.get("since", "?"), + "resolved": fields.get("resolved", "").strip()}) return out @@ -321,6 +386,33 @@ def trigger_fired(d: dict) -> bool | None: else (d["root"] / arg).exists() if kind == "date": return datetime.date.today().isoformat() >= arg + if kind == "text-present": + # Falsified by a string appearing somewhere — e.g. a hold claim falsified by the + # ruling that lifted it. Earned by trial 09: "the run is held" stayed true-looking + # for five days after REVIEWED-124 voided it, because nothing compared the two. + fp, _, needle = arg.partition(" ") + needle = needle.strip() + if not needle: + return None + target = (Path(os.path.expanduser(fp)) if fp.startswith(("~", "/")) + else d["root"] / fp) + try: + return needle in target.read_text(errors="replace") + except OSError: + return None + if kind == "file-changed-since": + # Falsified by a file having been edited since a named commit — e.g. "the filed + # rule not edited", which went false one hour after it was written. + commit, _, path = arg.partition(" ") + path = path.strip() + if not commit or not path: + return None + try: + r = subprocess.run(["git", "-C", str(d["root"]), "diff", "--quiet", + commit, "--", path], capture_output=True) + except OSError: + return None + return True if r.returncode == 1 else (False if r.returncode == 0 else None) if kind == "transcripts": # Session-count trigger. Added 2026-08-07 for PENDING-112's pre-registered # 20-session falsifier, which the jurist required be BINDING rather than a @@ -384,9 +476,27 @@ for root, pattern in _scan_targets: continue if "DEFERRED-DECISION:" in body: deferrals.extend(parse_deferrals(body, f)) + if "STATE-CLAIM:" in body: + state_claims.extend(parse_state_claims(body, f)) -fired = [d for d in deferrals if trigger_fired(d) is True] -manual = [d for d in deferrals if trigger_fired(d) is None] +# REVIEWED-127 C1. A resolved block is no longer due — but it is NOT dropped. It stays +# counted as a closed ledger, because a discharge that vanishes from the report is its own +# species of decay: the register would show three tracked deferrals and no evidence at all +# that a fourth had ever been answered. +resolved_deferrals = [d for d in deferrals if d["resolved"]] +open_deferrals = [d for d in deferrals if not d["resolved"]] +fired = [d for d in open_deferrals if trigger_fired(d) is True] +manual = [d for d in open_deferrals if trigger_fired(d) is None] + +resolved_claims = [c for c in state_claims if c["resolved"]] +open_claims = [c for c in state_claims if not c["resolved"]] +falsified = [c for c in open_claims if trigger_fired(c) is True] +claims_manual = [c for c in open_claims if trigger_fired(c) is None] + +# Same lane as the amendment-that-replaced-its-own-record check: both are a record +# closing over its own history. +dangling = [d for d in (resolved_deferrals + resolved_claims) + if not pointer_resolves(d["resolved"])] _T_OK = ("") @@ -428,6 +538,70 @@ control("trigger evaluator does NOT fire on an unmet condition " _p_wait and trigger_fired(_p_wait[0]) is False) control("deferred-decision scan surface is reachable", any(r.is_dir() for r in SCAN_ROOTS)) + +# ---- REVIEWED-127: state-claims, the two new trigger kinds, and the resolution state. +# Every one carries its discriminating half. An absence is not evidence until the +# instrument is shown capable of detecting presence — and this whole item exists because +# an instrument that reads nothing reports exactly like one that finds nothing. +_SC = ("") +_sc = parse_state_claims(_SC, CLAUDE_MD) +control("state-claim parser reads a well-formed block", + len(_sc) == 1 and _sc[0]["slug"] == "fixture" + and _sc[0]["claims"] == "the pulse has not been fetched") +control("state-claim parser rejects a non-block [negative control]", + not parse_state_claims("", CLAUDE_MD)) +control("state-claim FIRES when its falsifier is met [= the claim is now false]", + _sc and trigger_fired(_sc[0]) is True) +control("state-claim SILENT when its falsifier is unmet [the discriminating half]", + trigger_fired(parse_state_claims( + _SC.replace("date 2000-01-01", "date 2999-01-01"), CLAUDE_MD)[0]) is False) +control("state-claim `manual` is listed, never fired", + trigger_fired(parse_state_claims( + _SC.replace("date 2000-01-01", "manual"), CLAUDE_MD)[0]) is None) + +_DF = HOME / "dotfiles" +control("text-present FIRES on a string that is present", + trigger_fired({"trigger": "text-present PENDING.md PENDING-158", "root": _DF}) is True) +control("text-present SILENT on a string that is absent [negative control]", + trigger_fired({"trigger": "text-present PENDING.md zzz-not-in-this-file-zzz", + "root": _DF}) is False) +control("text-present returns None on an unreadable target [honest degradation]", + trigger_fired({"trigger": "text-present no/such/file.md x", "root": _DF}) is None) +control("file-changed-since FIRES on a file edited since the named commit", + trigger_fired({"trigger": "file-changed-since 5694b92 " + "claude/governance/fool/seed/FOOL-BONES-2026-08-25.md", + "root": _DF}) is True) +# ⚠ The fixture below is deliberately a FROZEN artifact — a trial-03 run output from +# 2026-08-02 that is never edited. The first version of this control pointed at +# FOOL-SEED-RULE.md and failed the moment that file was edited in the working tree, which +# is correct behaviour from the trigger and a badly chosen fixture: a control whose +# subject is "did this file change" must not point at a file the session is changing. +# Caught by the controls running on every invocation rather than in a separate suite. +control("file-changed-since SILENT on a file unchanged since HEAD [discriminating half]", + trigger_fired({"trigger": "file-changed-since HEAD " + "claude/governance/fool/runs/trial-03-20260802T144136Z.raw.txt", + "root": _DF}) is False) + +_RES_OK = _SC.replace("-->", " resolved: 2026-08-25 — commit 5694b925\n-->") +_RES_BAD = _SC.replace("-->", " resolved: yes, done\n-->") +control("resolved block parses its resolution", + parse_state_claims(_RES_OK, CLAUDE_MD)[0]["resolved"].startswith("2026-08-25")) +control("a resolved block is EXCLUDED from due-ness even with a met trigger " + "[C1: discharge without a hand-rename]", + not [c for c in parse_state_claims(_RES_OK, CLAUDE_MD) + if not c["resolved"] and trigger_fired(c) is True]) +control("the SAME block unresolved IS due [negative control — the exclusion must be " + "the resolution, not the fixture]", + trigger_fired(parse_state_claims(_SC, CLAUDE_MD)[0]) is True) +control("pointer that names something real resolves", + pointer_resolves("2026-08-25 — commit 5694b925")) +control("pointer that names nothing is DANGLING [C1: an undocumented discharge must be " + "impossible to express, not merely discouraged]", + not pointer_resolves("yes, done")) +control("a path pointer resolves too, not only a commit", + pointer_resolves("see claude/governance/fool/seed/FOOL-BONES-2026-08-25.md")) # Prose-deferral census: counted, never classified, and never read as absence. prose_counts = {} for _f in (HOME / "dotfiles" / "PENDING.md", HOME / "dotfiles" / "PENDING-archive.md"): @@ -580,16 +754,21 @@ else: # Deferred decisions: a fired trigger is a decision that has come DUE, not a defect. if deferrals: if fired: - print(f"\n⏰ deferred decisions: {len(fired)} of {len(deferrals)} have COME DUE") + print(f"\n⏰ deferred decisions: {len(fired)} of {len(open_deferrals)} open have COME DUE") for d in fired: print(f" {d['slug']} — owner: {d['owner']}, deferred since {d['since']}") print(f" trigger MET: {d['trigger']}") print(f" {d['file'].relative_to(HOME)}") print("\n A deferral is the claim 'not yet'. These triggers say otherwise.") else: - waiting = len(deferrals) - len(manual) - print(f"✓ deferred decisions: {len(deferrals)} tracked, none due " + waiting = len(open_deferrals) - len(manual) + print(f"✓ deferred decisions: {len(open_deferrals)} tracked, none due " f"({waiting} checkable, {len(manual)} manual-only)") + if resolved_deferrals: + print(f" ✓ plus {len(resolved_deferrals)} RESOLVED — answered, and kept in the " + f"ledger rather than dropped:") + for _r in resolved_deferrals: + print(f" {_r['slug']} — {_r['resolved']}") if prose_counts: _tot = sum(prose_counts.values()) print(f" ⚠ plus {_tot} PROSE deferral mention(s) in the register " @@ -605,4 +784,36 @@ if deferrals: print(" Any DEFERRED-DECISION block in these is INERT. This is 'could not assess',") print(" not 'nothing there' — the distinction REVIEWED-104 rules may not be collapsed.") +# State claims: a fired falsifier is not a decision coming due — it is a claim that has +# STOPPED BEING TRUE while still being written down as true. +if state_claims: + if falsified: + print(f"\n⚠ state claims: {len(falsified)} of {len(open_claims)} open are NOW FALSE") + for c in falsified: + print(f" {c['slug']} — claims: {c['claims']}") + print(f" FALSIFIED BY: {c['trigger']}") + print(f" {c['file'].relative_to(HOME)}") + print("\n A state-claim is the claim 'not yet' about a state, not a decision.") + print(" The substrate says otherwise. Correct the document, or resolve the block.") + else: + _ok = len(open_claims) - len(claims_manual) + print(f"✓ state claims: {len(open_claims)} tracked, none falsified " + f"({_ok} checkable, {len(claims_manual)} manual-only)") + if resolved_claims: + print(f" ✓ plus {len(resolved_claims)} RESOLVED — kept in the ledger.") +else: + # Silence here is NOT evidence of clean state-claims. Nothing has opted in yet, and + # PENDING-158 records why that is the open question: an opt-in marker is used by the + # authors who would have caught the claim anyway. Say so rather than print a tick. + print("· state claims: 0 marked. ⚠ NOT 'none stale' — nothing has opted in yet;") + print(" ~57 candidate negative-state claims are unmarked and unread (a grep, not a") + print(" census). Adoption is the open question, not expressibility (PENDING-158).") + +if dangling: + print(f"\n⚠ register integrity: {len(dangling)} resolved block(s) name no pointer " + f"that resolves") + for _d in dangling: + print(f" {_d['slug']} — resolved: {_d['resolved'] or '(empty)'}") + print(" A discharge recording THAT a gate closed but not WHAT closed it is no record.") + sys.exit(0)