Skip to content

Commit 766bacf

Browse files
eduralphclaude
andcommitted
feat(revalidate): add pdca revalidate — re-gate a frozen bundle (#11)
A bundle's check-gates.json is frozen at Check time, but the gates run against a moving substrate (engine, ruleset, deps). A frozen FAIL the current engine would never reproduce is otherwise indistinguishable from a real accepted failure. `pdca revalidate <id>` re-runs the gates against the current engine and records the staleness, without re-deciding §9. - gates.run_gates_dry: same single-sourced _run_checks as `pdca gates` but write_to=None, so re-gating a COMPLETE bundle never overwrites its frozen check-gates.json / .md. - revalidate.py: diffs frozen vs fresh rows by (element, rule_id, check), writes an additive revalidation-<date>.json {date, engine_rev, per-row old→new, changed, regression}. Never touches the frozen files or §9. - cli: `pdca revalidate <id> [--date]`; refuses a non-COMPLETE bundle (rc 2), exits nonzero on any delta (rc 1), quiet confirmation otherwise. - act: the bundle index surfaces revalidation deltas (read-only) so Act can tell a stale frozen FAIL from a real failure where it already looks. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 973d22c commit 766bacf

5 files changed

Lines changed: 317 additions & 3 deletions

File tree

template/src/pdca_harness/act.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
from dataclasses import dataclass, field
2020
from pathlib import Path
2121

22-
from . import state
22+
from . import revalidate, state
2323
from .config import Config
2424

2525
_DATE_RE = re.compile(r"(\d{4}-\d{2}-\d{2})")
@@ -35,6 +35,7 @@ class ActEntry:
3535
needs_human: list[str] = field(default_factory=list) # §6 items (cleared or not)
3636
unproven: list[str] = field(default_factory=list) # §7 unproven lines
3737
act_candidates: list[str] = field(default_factory=list) # §10 hints
38+
reval_deltas: list[str] = field(default_factory=list) # revalidation stamps (#11)
3839

3940

4041
def frozen_bundles(cfg: Config) -> list[Path]:
@@ -77,8 +78,14 @@ def render_index(entries: list[ActEntry], pats: dict[str, list[str]]) -> str:
7778
f"- §6 NEEDS-HUMAN ({len(e.needs_human)}): " + ("; ".join(e.needs_human) or "—"),
7879
f"- §7 unproven ({len(e.unproven)}): " + ("; ".join(e.unproven) or "—"),
7980
f"- §10 Act candidates ({len(e.act_candidates)}): " + ("; ".join(e.act_candidates) or "—"),
80-
"",
8181
]
82+
# Only when present — a frozen gate result the current engine now contradicts
83+
# (esp. a frozen FAIL now PASS = stale artifact, or a frozen PASS now FAIL =
84+
# regression). Surfaced here so Act can tell stale records from real failures.
85+
if e.reval_deltas:
86+
lines.append(f"- revalidation deltas ({len(e.reval_deltas)}): "
87+
+ "; ".join(e.reval_deltas))
88+
lines.append("")
8289
lines += ["## Recurring signals (appear in >1 cycle)"]
8390
any_pat = False
8491
for label, items in pats.items():
@@ -144,6 +151,7 @@ def _extract(summary: Path, bundle: Path) -> ActEntry:
144151
needs_human=_checkitems(s6),
145152
unproven=_unproven(s7),
146153
act_candidates=_candidates(s10),
154+
reval_deltas=revalidate.deltas(bundle), # frozen-gate staleness surfaced (#11)
147155
)
148156

149157

template/src/pdca_harness/cli.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import sys
1414
from pathlib import Path
1515

16-
from . import act, brief, driver, flow, gates, publish, queue, signoff, state
16+
from . import act, brief, driver, flow, gates, publish, queue, revalidate, signoff, state
1717
from .config import Config
1818

1919
# Ordering for the cheap-first sign-off queue (docs 03 §sign-off queue).
@@ -64,6 +64,11 @@ def main(argv: list[str] | None = None) -> int:
6464
p_gates.add_argument("issue_id", nargs="?")
6565
p_gates.add_argument("--working-tree", action="store_true", help="repo-scoped gates only (the CI merge re-gate)")
6666

67+
p_reval = sub.add_parser("revalidate",
68+
help="re-run gates on a COMPLETE bundle vs the current engine; write a dated stamp (never re-decides §9)")
69+
p_reval.add_argument("issue_id")
70+
p_reval.add_argument("--date", help="ISO date for the stamp (default: today)")
71+
6772
p_actidx = sub.add_parser("act-index", help="read-only index of frozen cycles + recurring signals")
6873
p_actidx.add_argument("--since", help="only cycles signed off on/after this ISO date")
6974

@@ -106,6 +111,8 @@ def main(argv: list[str] | None = None) -> int:
106111
return _queue(cfg)
107112
if args.cmd == "gates":
108113
return _gates(cfg, args)
114+
if args.cmd == "revalidate":
115+
return _revalidate(cfg, args)
109116
if args.cmd == "act-index":
110117
return _act_index(cfg, args)
111118
if args.cmd == "act-log":
@@ -291,6 +298,29 @@ def _gates(cfg: Config, args: argparse.Namespace) -> int:
291298
return 1 if result["overall"] == "fail" else 0
292299

293300

301+
def _revalidate(cfg: Config, args: argparse.Namespace) -> int:
302+
"""Re-gate a COMPLETE bundle against the current engine; write a dated stamp.
303+
304+
Reuses the single-sourced gate runner (``gates.run_gates_dry`` — no write to the
305+
frozen ``check-gates.json``) and records ``revalidation-<date>.json``. Refuses a
306+
non-COMPLETE bundle; never re-decides §9. Exits nonzero iff a row changed, so a
307+
delta is visible to the caller; an unchanged result is a quiet confirmation.
308+
"""
309+
d = cfg.bundle(args.issue_id)
310+
if not d.exists():
311+
print(f"no such bundle: {d}", file=sys.stderr)
312+
return 1
313+
if state.state(d) != state.COMPLETE:
314+
print(f"revalidate refuses {d.name}: not COMPLETE (state {state.state(d)}). "
315+
"Revalidation re-gates a frozen bundle; finish sign-off first.",
316+
file=sys.stderr)
317+
return 2
318+
date = args.date or datetime.date.today().isoformat()
319+
result = revalidate.revalidate(cfg, d, date)
320+
print(revalidate.render_md(result))
321+
return 1 if result["changed"] else 0
322+
323+
294324
def _act_index(cfg: Config, args: argparse.Namespace) -> int:
295325
"""Print the read-only Act bundle index across frozen cycles."""
296326
entries = act.index(cfg, since=args.since)

template/src/pdca_harness/gates.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,16 @@ def run_working_tree(cfg: Config) -> dict:
4545
return _finalize(rows, name="working-tree", write_to=None)
4646

4747

48+
def run_gates_dry(d: Path, cfg: Config) -> dict:
49+
"""Run every gate for bundle ``d`` against the CURRENT engine WITHOUT writing the
50+
frozen ``check-gates.json`` — the gate runner behind ``pdca revalidate`` (issue #11).
51+
52+
Same single-sourced ``_run_checks`` as :func:`run_gates`, but ``write_to=None`` so a
53+
re-gate of an already-COMPLETE bundle never mutates its frozen record."""
54+
rows = _run_checks(cfg, cwd=cfg.root, bundle=d, scopes=("repo", "bundle"))
55+
return _finalize(rows, name=d.name, write_to=None)
56+
57+
4858
# ----------------------------------------------------------------------------
4959
def _bundle_target(
5060
bundle: Path | None,
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
"""``pdca revalidate`` — re-gate a frozen bundle against the current engine (issue #11).
2+
3+
A bundle's ``check-gates.json`` is written once at Check time and frozen when the
4+
bundle goes COMPLETE; that immutability is correct — the bundle is the record of what
5+
was decided. But the gates run against a *moving* substrate (the engine code, the
6+
conformance ruleset, the dependency repos under test). When those improve, a frozen
7+
``FAIL`` the current engine would never reproduce becomes indistinguishable from a real
8+
failure the human knowingly accepted.
9+
10+
``revalidate`` re-runs the **same single-sourced gate set** as ``pdca gates``
11+
(:func:`gates.run_gates_dry` — which never writes the frozen file) against the current
12+
engine and records an **additive, dated** stamp ``revalidation-<date>.json`` recording
13+
each row's ``old → new`` result. It **never** mutates ``check-gates.json`` /
14+
``check-gates.md`` or ``SUMMARY.md`` §9 — the original decision stands. A changed result
15+
in *either* direction is a delta; a frozen ``PASS`` now ``FAIL`` is a real regression
16+
signal.
17+
"""
18+
19+
from __future__ import annotations
20+
21+
import json
22+
import subprocess
23+
from pathlib import Path
24+
25+
from . import gates
26+
from .config import Config
27+
28+
29+
def revalidate(cfg: Config, d: Path, date: str) -> dict:
30+
"""Re-gate COMPLETE bundle ``d`` against the current engine; write a dated stamp.
31+
32+
Returns the revalidation result and writes ``revalidation-<date>.json`` into the
33+
bundle — additive (one stamp per date, never overwriting a prior one) and never
34+
touching the frozen ``check-gates.json`` / ``check-gates.md`` / §9.
35+
"""
36+
frozen = json.loads((d / "check-gates.json").read_text(encoding="utf-8"))
37+
fresh = gates.run_gates_dry(d, cfg)
38+
39+
old_by = {_row_key(r): r for r in frozen.get("rows", [])}
40+
new_by = {_row_key(r): r for r in fresh.get("rows", [])}
41+
# Union, preserving the frozen order then any rows the current engine added.
42+
keys = list(old_by) + [k for k in new_by if k not in old_by]
43+
44+
rows = []
45+
for key in keys:
46+
o, n = old_by.get(key), new_by.get(key)
47+
ref = o or n
48+
old_res = o["result"] if o else None
49+
new_res = n["result"] if n else None
50+
rows.append({
51+
"check": ref["check"],
52+
"element": ref.get("element", ""),
53+
"rule_id": ref.get("rule_id", ""),
54+
"gating": (n or o).get("gating", False),
55+
"old": old_res,
56+
"new": new_res,
57+
"changed": old_res != new_res,
58+
})
59+
60+
result = {
61+
"date": date,
62+
"engine_rev": _engine_rev(cfg.root),
63+
"bundle": d.name,
64+
"frozen_overall": frozen.get("overall"),
65+
"current_overall": fresh.get("overall"),
66+
"changed": any(r["changed"] for r in rows),
67+
# A gating row that was PASS and is now FAIL is the load-bearing signal.
68+
"regression": any(r["gating"] and r["old"] == "pass" and r["new"] == "fail"
69+
for r in rows),
70+
"rows": rows,
71+
}
72+
(d / f"revalidation-{date}.json").write_text(
73+
json.dumps(result, indent=2) + "\n", encoding="utf-8")
74+
return result
75+
76+
77+
def render_md(result: dict) -> str:
78+
"""A compact old→new delta table; changed rows are the deltas, the rest confirm."""
79+
head = f"# Revalidation — {result['bundle']}{result['date']}"
80+
rev = f" (engine {result['engine_rev']})" if result.get("engine_rev") else ""
81+
verdict = ("REGRESSION — a frozen PASS is now FAIL" if result["regression"]
82+
else "deltas found" if result["changed"]
83+
else "no change — frozen record confirmed against the current engine")
84+
lines = [head + rev, "", f"**{verdict}.** The frozen check-gates.json is unchanged.",
85+
"", "| Check | Old | New | Δ | Gating |", "|---|---|---|---|---|"]
86+
for r in result["rows"]:
87+
delta = "→" if r["changed"] else ""
88+
lines.append(f"| {r['check']} | {r['old'] or '—'} | {r['new'] or '—'} | "
89+
f"{delta} | {'yes' if r['gating'] else 'no'} |")
90+
return "\n".join(lines) + "\n"
91+
92+
93+
def deltas(d: Path) -> list[str]:
94+
"""One-line summaries of every changed row across ``d``'s revalidation stamps.
95+
96+
Read-only; for the Act bundle index to surface staleness where Act already looks.
97+
"""
98+
out: list[str] = []
99+
for stamp in sorted(d.glob("revalidation-*.json")):
100+
try:
101+
res = json.loads(stamp.read_text(encoding="utf-8"))
102+
except (OSError, json.JSONDecodeError):
103+
continue
104+
for r in res.get("rows", []):
105+
if r.get("changed"):
106+
out.append(f"{r['check']} {r['old']}{r['new']} ({res.get('date', '?')})")
107+
return out
108+
109+
110+
def _row_key(row: dict) -> tuple[str, str, str]:
111+
"""Stable identity for a gate row across re-gates: matrix element + rule + label."""
112+
return (row.get("element", ""), row.get("rule_id", ""), row.get("check", ""))
113+
114+
115+
def _engine_rev(root: Path) -> str:
116+
"""Short git rev of the engine under test — best-effort provenance, never fatal."""
117+
try:
118+
out = subprocess.run(["git", "rev-parse", "--short", "HEAD"], cwd=root,
119+
capture_output=True, text=True, check=True)
120+
return out.stdout.strip()
121+
except Exception: # noqa: BLE001 — provenance is a nicety, absence is not an error
122+
return ""

template/tests/test_revalidate.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""Offline slice for `pdca revalidate` — re-gate a frozen bundle (stdlib unittest).
2+
3+
Proves the issue-#11 contract: revalidate re-runs the single-sourced gates against the
4+
current engine, writes an additive dated stamp, NEVER mutates the frozen
5+
check-gates.json / check-gates.md / §9, refuses a non-COMPLETE bundle, reports a changed
6+
row in either direction, and surfaces deltas where Act looks. Deterministic real gates
7+
(`true` / `false`) flip a gate's result between freeze and revalidate with no Claude /
8+
Docker. Run from the project root: PYTHONPATH=src python -m unittest discover -s tests
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import json
14+
import shutil
15+
import tempfile
16+
import unittest
17+
from pathlib import Path
18+
from types import SimpleNamespace
19+
20+
from pdca_harness import act, cli, gates, revalidate, state
21+
from pdca_harness.config import Config, LeafConfig
22+
23+
# A real bundle-scoped gate keyed on a stable (element, rule_id, check) so a frozen row
24+
# and a fresh row line up; only the cmd's exit code (true/false) decides pass/fail.
25+
_GATE = {"id": "C4", "tier": "C4", "label": "verify", "scope": "bundle", "gating": True}
26+
_PASS = {**_GATE, "cmd": "true"}
27+
_FAIL = {**_GATE, "cmd": "false"}
28+
29+
30+
def _stub_config(root: Path) -> Config:
31+
return Config(
32+
root=root,
33+
bundle_root=root / "results",
34+
process_dir=root / "process",
35+
templates_dir=root / "templates",
36+
default_branch="main",
37+
tracker_system="github",
38+
tracker_url="",
39+
issue_id_example="#1",
40+
builder=LeafConfig(mode="stub", family="claude"),
41+
reviewer=LeafConfig(mode="stub", family="codex"),
42+
planner=LeafConfig(mode="stub", family="claude", interactive=True),
43+
signoff=LeafConfig(mode="stub", family="claude", interactive=True),
44+
publisher=LeafConfig(mode="stub", family="claude", interactive=True),
45+
act=LeafConfig(mode="stub", family="claude", interactive=True),
46+
)
47+
48+
49+
class Revalidate(unittest.TestCase):
50+
def setUp(self) -> None:
51+
self.tmp = Path(tempfile.mkdtemp())
52+
self.cfg = _stub_config(self.tmp)
53+
54+
def tearDown(self) -> None:
55+
shutil.rmtree(self.tmp, ignore_errors=True)
56+
57+
def _complete_bundle(self, iid: str, *, frozen_gate: dict) -> Path:
58+
"""A COMPLETE (frozen) bundle whose check-gates.json was written by `frozen_gate`."""
59+
d = self.cfg.bundle(iid)
60+
d.mkdir(parents=True)
61+
(d / "brief.md").write_text("- **Slug:** reval\n", encoding="utf-8")
62+
(d / "patch.diff").write_text("--- a\n+++ b\n", encoding="utf-8")
63+
self.cfg.gates_checks = [frozen_gate]
64+
gates.run_gates(d, self.cfg) # writes the frozen check-gates.json / .md
65+
(d / "SUMMARY.md").write_text(
66+
"## 9. Check sign-off\n- Outcome: accepted\n- By / date: t / 2026-06-04\n",
67+
encoding="utf-8")
68+
self.assertEqual(state.state(d), state.COMPLETE)
69+
return d
70+
71+
def test_stamp_written_and_frozen_files_untouched(self) -> None:
72+
# Frozen FAIL (old engine); the current engine PASSes. Revalidate records the
73+
# delta but leaves the frozen record byte-for-byte intact.
74+
d = self._complete_bundle("REVAL", frozen_gate=_FAIL)
75+
before = {name: (d / name).read_bytes()
76+
for name in ("check-gates.json", "check-gates.md", "SUMMARY.md")}
77+
self.cfg.gates_checks = [_PASS] # engine since fixed
78+
result = revalidate.revalidate(self.cfg, d, "2026-06-12")
79+
80+
self.assertTrue((d / "revalidation-2026-06-12.json").exists())
81+
for name, blob in before.items():
82+
self.assertEqual((d / name).read_bytes(), blob,
83+
f"revalidate must not touch the frozen {name}")
84+
self.assertTrue(result["changed"])
85+
self.assertFalse(result["regression"]) # FAIL→PASS is a stale artifact, not a regression
86+
c4 = next(r for r in result["rows"] if r["element"] == "C4")
87+
self.assertEqual((c4["old"], c4["new"]), ("fail", "pass"))
88+
89+
def test_regression_when_frozen_pass_now_fails(self) -> None:
90+
# Frozen PASS; the current engine FAILs the same gate — the load-bearing signal.
91+
d = self._complete_bundle("REG", frozen_gate=_PASS)
92+
self.cfg.gates_checks = [_FAIL]
93+
result = revalidate.revalidate(self.cfg, d, "2026-06-12")
94+
self.assertTrue(result["changed"])
95+
self.assertTrue(result["regression"])
96+
c4 = next(r for r in result["rows"] if r["element"] == "C4")
97+
self.assertEqual((c4["old"], c4["new"]), ("pass", "fail"))
98+
99+
def test_unchanged_is_a_quiet_confirmation(self) -> None:
100+
# Same gate result at freeze and now → no delta; the CLI exits 0.
101+
d = self._complete_bundle("SAME", frozen_gate=_PASS)
102+
self.cfg.gates_checks = [_PASS]
103+
rc = cli._revalidate(self.cfg, SimpleNamespace(issue_id="SAME", date="2026-06-12"))
104+
self.assertEqual(rc, 0)
105+
stamp = json.loads((d / "revalidation-2026-06-12.json").read_text(encoding="utf-8"))
106+
self.assertFalse(stamp["changed"])
107+
108+
def test_cli_exit_nonzero_on_delta(self) -> None:
109+
d = self._complete_bundle("DELTA", frozen_gate=_FAIL)
110+
self.cfg.gates_checks = [_PASS]
111+
rc = cli._revalidate(self.cfg, SimpleNamespace(issue_id="DELTA", date="2026-06-12"))
112+
self.assertEqual(rc, 1) # a changed row is surfaced to the caller
113+
self.assertTrue((d / "revalidation-2026-06-12.json").exists())
114+
115+
def test_refuses_non_complete_bundle(self) -> None:
116+
# A bundle that is only PLANNED (brief, no patch) must be refused, no stamp.
117+
d = self.cfg.bundle("PARTIAL")
118+
d.mkdir(parents=True)
119+
(d / "brief.md").write_text("- **Slug:** x\n", encoding="utf-8")
120+
self.assertNotEqual(state.state(d), state.COMPLETE)
121+
rc = cli._revalidate(self.cfg, SimpleNamespace(issue_id="PARTIAL", date="2026-06-12"))
122+
self.assertEqual(rc, 2)
123+
self.assertEqual(list(d.glob("revalidation-*.json")), [])
124+
125+
def test_missing_bundle_returns_one(self) -> None:
126+
rc = cli._revalidate(self.cfg, SimpleNamespace(issue_id="GHOST", date=None))
127+
self.assertEqual(rc, 1)
128+
129+
def test_act_index_surfaces_revalidation_delta(self) -> None:
130+
# A COMPLETE bundle carrying a revalidation delta shows it in the Act index,
131+
# so Act can tell a stale frozen FAIL from a real accepted failure.
132+
d = self._complete_bundle("ACTREVAL", frozen_gate=_FAIL)
133+
self.cfg.gates_checks = [_PASS]
134+
revalidate.revalidate(self.cfg, d, "2026-06-12")
135+
entries = act.index(self.cfg)
136+
entry = next(e for e in entries if e.bundle.name == "issue_ACTREVAL")
137+
self.assertTrue(entry.reval_deltas)
138+
rendered = act.render_index(entries, act.patterns(entries))
139+
self.assertIn("revalidation deltas", rendered)
140+
self.assertIn("fail→pass", rendered)
141+
142+
143+
if __name__ == "__main__":
144+
unittest.main()

0 commit comments

Comments
 (0)