From 0a78545e3d3ba2d496690ea5eff2eb92d70f1a24 Mon Sep 17 00:00:00 2001 From: Shay Date: Sun, 26 Jul 2026 10:20:56 -0700 Subject: [PATCH] =?UTF-8?q?feat(teaching):=20core=20proposal-queue=20resea?= =?UTF-8?q?l=20=E2=80=94=20the=20ledger=5Freseal=20stage=20(Phase=20D)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `RatificationReceipt.pending_stages` has named `ledger_reseal` since the ratification ceremony was written and nothing performed it, so a sealed ledger went stale the moment curriculum changed. This is that stage, plus the CLI verb a human runs, and it is deliberately NOT reachable from `ratify_chain` (doctrine forbids ratification automation, and a per-row reseal would churn the ledger's content_sha256). The load-bearing property is not "the ledger can be rewritten" — it is that rewriting it cannot grant a serving license by accident. Reliability is commitment precision, so a curriculum band clears theta_SERVE on correct NON-COMMITMENTS alone (Phase C, PR #121). A plain regenerate verb would flip four bands from disclosed to authoritative as a side effect of housekeeping, on evidence that is ~99% non-entailed, which ADR-0262 §5.1 rules unacceptable. So: - `plan_reseal` computes the license DELTA before writing anything, reading the current ledger through `load_capability_ledger` — the production entry point — so a tampered ledger refuses here instead of being overwritten and losing the evidence, and the stage inherits the capability's DECLARED absence policy rather than choosing its own (bridge rule 5). - `apply_reseal` REFUSES by default when any band would become newly licensed, naming the bands and their `entailed` counts. `--allow-new- licenses` is the ratification. Revocations are never gated: losing a license is the gate becoming more conservative and needs no authorization. - `deduction_serve` is deliberately absent from `_RESEALABLE`. Its ledger is SHA-sealed, ratified, and gating a live flag, and 21 of its 25 bands do not clear theta_SERVE on distinct evidence. A verb that regenerated it would erase a measured exposure as housekeeping. - `ratify`'s output now names the command that performs its pending stage. Naming a stage without naming its command is how this one stayed unperformed. Exit codes: 0 dry-run/applied, 1 refused, 2 unknown capability. [Verification]: in-worktree on canonical CPython 3.12.13 with `uv sync --locked`: smoke 571 (unchanged), deductive 327 (310 + 17). `chat/data/curriculum_serve_ledger.json` still absent after the full suite — no test writes it. Caught and fixed one real gate failure: `test_no_production_adapter_passes_missing_ok` correctly rejected an explicit `missing_ok=True`; the fix routes through the manifest instead. --- core/cli_proposal_queue.py | 107 +++++++++++- core/cli_test.py | 1 + teaching/ledger_reseal.py | 195 +++++++++++++++++++++ tests/test_ledger_reseal.py | 329 ++++++++++++++++++++++++++++++++++++ 4 files changed, 631 insertions(+), 1 deletion(-) create mode 100644 teaching/ledger_reseal.py create mode 100644 tests/test_ledger_reseal.py diff --git a/core/cli_proposal_queue.py b/core/cli_proposal_queue.py index 509d3cba..daf118c2 100644 --- a/core/cli_proposal_queue.py +++ b/core/cli_proposal_queue.py @@ -138,6 +138,84 @@ def cmd_proposal_queue_ratify(args: argparse.Namespace) -> int: f"{receipt.family_chains_before} -> {receipt.family_chains_after}") if not args.dry_run: print(f" still pending : {', '.join(receipt.pending_stages)}") + # Wiring the receipt's `ledger_reseal` stage to the verb that performs + # it. Naming a pending stage without naming its command is how this one + # stayed unperformed since the ceremony was written. + if "ledger_reseal" in receipt.pending_stages: + print( + " next : core proposal-queue reseal curriculum_serve " + "--dry-run (the ledger is stale until you do)" + ) + return 0 + + +def cmd_proposal_queue_reseal(args: argparse.Namespace) -> int: + """The ``ledger_reseal`` stage of the ceremony — explicit, never automatic. + + ``ratify_chain`` grows the corpus and names this stage in its receipt's + ``pending_stages``; nothing performed it until now, so a sealed ledger went + stale the moment curriculum changed. This is the verb a human runs. + + It refuses by default to grant a license. A band can clear θ_SERVE on correct + NON-COMMITMENTS alone, so "regenerate the artifact" and "make four bands + authoritative" would otherwise be the same keystroke. + """ + from teaching.ledger_reseal import ReadyToReseal, apply_reseal, plan_reseal + + try: + plan = plan_reseal(args.capability) + except ReadyToReseal as exc: + print(f"REFUSED: {exc}") + return 2 + + payload: dict[str, object] = { + "capability": plan.capability, + "path": str(plan.path), + "licensed_before": list(plan.licensed_before), + "licensed_after": list(plan.licensed_after), + "newly_licensed": list(plan.newly_licensed), + "revoked": list(plan.revoked), + "committed": plan.committed, + "mix": plan.mix, + "is_housekeeping": plan.is_housekeeping, + "written": False, + } + if not args.json: + print(f"reseal plan for {plan.capability}:") + print(f" artifact : {plan.path}") + for band in sorted(plan.committed): + mix = ", ".join(f"{k}={v}" for k, v in sorted(plan.mix.get(band, {}).items())) + lic = "SERVE" if band in plan.licensed_after else " " + print(f" {lic} {band:44s} committed={plan.committed[band]:6d} {mix}") + print(f" licensed now : {len(plan.licensed_before)}") + print(f" licensed after: {len(plan.licensed_after)}") + if plan.newly_licensed: + print(f" NEWLY LICENSED: {', '.join(plan.newly_licensed)}") + if plan.revoked: + print(f" revoked : {', '.join(plan.revoked)}") + + if args.dry_run: + if args.json: + print(json.dumps(payload, indent=2, sort_keys=True)) + else: + print(" (dry run — nothing written)") + return 0 + + try: + path = apply_reseal(plan, allow_new_licenses=args.allow_new_licenses) + except ReadyToReseal as exc: + if args.json: + payload["refused"] = str(exc) + print(json.dumps(payload, indent=2, sort_keys=True)) + else: + print(f"REFUSED: {exc}") + return 1 + + if args.json: + payload["written"] = True + print(json.dumps(payload, indent=2, sort_keys=True)) + else: + print(f" SEALED -> {path}") return 0 @@ -195,7 +273,7 @@ def register(subparsers: argparse._SubParsersAction) -> None: "curriculum loader admits the chain -> receipt. Refuses, and rolls " "back, if the appended row would be silently dropped. Does not " "write a ledger (bridge rule 1) or queue an arena entry; the " - "receipt names those stages." + "receipt names those stages, and `reseal` performs the first." ), ) ratify.add_argument("domain") @@ -210,5 +288,32 @@ def register(subparsers: argparse._SubParsersAction) -> None: ratify.add_argument("--json", action="store_true") ratify.set_defaults(func=cmd_proposal_queue_ratify) + from teaching.ledger_reseal import resealable_capabilities + + reseal = sub.add_parser( + "reseal", + help="re-run sealed practice and rewrite a capability's committed ledger", + description=( + "The `ledger_reseal` stage `ratify_chain` names but never performs. " + "Ratifying a chain grows the corpus without re-running practice, so " + "the ledger the serving gate reads goes stale. This rebuilds it from " + "sealed practice. REFUSES by default if the reseal would newly " + "license a band: reliability is commitment precision, so a band can " + "clear theta_SERVE on correct NON-COMMITMENTS alone, and granting a " + "serving license is a ratification rather than housekeeping." + ), + ) + reseal.add_argument("capability", choices=resealable_capabilities()) + reseal.add_argument( + "--dry-run", action="store_true", + help="report the license delta without writing", + ) + reseal.add_argument( + "--allow-new-licenses", action="store_true", + help="authorize bands that would become newly licensed (a RATIFICATION)", + ) + reseal.add_argument("--json", action="store_true") + reseal.set_defaults(func=cmd_proposal_queue_reseal) + __all__ = ["register"] diff --git a/core/cli_test.py b/core/cli_test.py index f43e5be2..c88d1b17 100644 --- a/core/cli_test.py +++ b/core/cli_test.py @@ -230,6 +230,7 @@ TEST_SUITES: dict[str, tuple[str, ...]] = { "tests/test_deduction_serve_e2e.py", "tests/test_curriculum_serve.py", "tests/test_curriculum_practice.py", + "tests/test_ledger_reseal.py", "tests/test_ratified_ledger_bridge.py", "tests/test_vocab_trigger_instrument.py", ), diff --git a/teaching/ledger_reseal.py b/teaching/ledger_reseal.py new file mode 100644 index 00000000..eaea14bb --- /dev/null +++ b/teaching/ledger_reseal.py @@ -0,0 +1,195 @@ +"""teaching/ledger_reseal.py — the ``ledger_reseal`` stage of the ceremony. + +``RatificationReceipt.pending_stages`` has named ``ledger_reseal`` since the +ratification ceremony was written, and nothing performed it. Ratifying a chain +grows the corpus; it does not re-run practice, so the sealed ledger the serving +gate reads goes stale the moment curriculum changes. This module is that stage, +and it is deliberately **not** reachable from ``ratify_chain``. + +Why it is a separate, human-run act +----------------------------------- +``AGENTS.md`` forbids ratification automation, and an auto-reseal on every +``ratify`` would churn the ledger's ``content_sha256`` once per row. But the +stronger reason is what Phase C measured: **resealing can grant licenses.** +Reliability is commitment precision and a correct UNKNOWN is a commitment, so +any band with ≥657 routable atoms clears θ_SERVE on non-commitments alone. A +plain "regenerate the artifact" verb would therefore flip four curriculum bands +from disclosed to authoritative as a side effect of housekeeping, on evidence +that is ~99% non-entailed — which ADR-0262 §5.1 rules unacceptable +(``docs/research/curriculum-practice-producer-2026-07-26.md`` §1). + +So :func:`plan_reseal` computes the license DELTA before writing anything, and +the caller must opt in explicitly to any band that would become newly licensed. +The default is refusal. Regenerating an artifact is housekeeping; changing what +the engine will assert authoritatively is a ratification, and the two must not +share a keystroke. + +Scope: this module writes a ledger and nothing else. It has no opinion on +whether curriculum content is true, cannot ratify a chain, and cannot flip a +serving flag. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable + +from core.ratified_ledger import ( + CAPABILITY_LEDGERS, + load_capability_ledger, + write_sealed_ledger, +) +from core.reliability_gate import Action, Ceilings, ClassTally, license_for + + +class ReadyToReseal(ValueError): + """A reseal was requested that cannot proceed as asked.""" + + +@dataclass(frozen=True, slots=True) +class ResealPlan: + """What a reseal would do, computed before it does it.""" + + capability: str + path: Path + #: Bands licensed by the ledger currently on disk (empty when absent). + licensed_before: tuple[str, ...] + #: Bands the freshly-built artifact would license. + licensed_after: tuple[str, ...] + #: Per-band committed volume in the new artifact. + committed: dict[str, int] + #: Per-band gold verdict mix — the axis ``ClassTally`` cannot carry. + mix: dict[str, dict[str, int]] + artifact: dict[str, Any] + + @property + def newly_licensed(self) -> tuple[str, ...]: + """Bands this reseal would grant SERVE that do not hold it today. + + The figure that decides whether the operation is housekeeping or a + ratification. + """ + before = set(self.licensed_before) + return tuple(b for b in self.licensed_after if b not in before) + + @property + def revoked(self) -> tuple[str, ...]: + """Bands that would LOSE a license — always allowed, never gated. + + Losing a license is the gate becoming more conservative, which needs no + authorization. Only granting one does. + """ + after = set(self.licensed_after) + return tuple(b for b in self.licensed_before if b not in after) + + @property + def is_housekeeping(self) -> bool: + return not self.newly_licensed + + +#: capability -> (build artifact, build ledger, build mix). +#: +#: ``deduction_serve`` is deliberately ABSENT. Its ledger is SHA-sealed, +#: ratified, and gating a live flag, and 21 of its 25 bands do not clear +#: θ_SERVE on distinct evidence (``tests/test_volume_honesty.py``). Re-sealing it +#: is Shay's ratification, not a CLI verb's, and a reseal that silently +#: regenerated it would erase the exposure this arc measured. ``estimation`` +#: ships sealed with its gate and has no reason to move. +_RESEALABLE: dict[str, tuple[Callable[[], dict[str, Any]], Callable[[], dict[str, ClassTally]], Callable[[], dict[str, dict[str, int]]]]] = {} + + +def _register_curriculum() -> None: + from evals.curriculum_serve.practice.runner import ( + build_ledger, + build_mix, + build_sealed_artifact, + ) + + _RESEALABLE["curriculum_serve"] = (build_sealed_artifact, build_ledger, build_mix) + + +def resealable_capabilities() -> tuple[str, ...]: + """Capabilities this stage will rebuild, in a stable order.""" + if not _RESEALABLE: + _register_curriculum() + return tuple(sorted(_RESEALABLE)) + + +def _licensed_bands(ledger: dict[str, ClassTally], ceilings: Ceilings) -> tuple[str, ...]: + return tuple( + sorted( + band + for band, tally in ledger.items() + if license_for(tally, Action.SERVE, ceilings).licensed + ) + ) + + +def plan_reseal(capability: str, *, ceilings: Ceilings | None = None) -> ResealPlan: + """Compute the reseal and its license delta — writing nothing. + + The read of the CURRENT ledger goes through :func:`load_capability_ledger`, + the same production entry point the serving adapters use, so two properties + come for free. A hand-edited ledger refuses here rather than being silently + overwritten (losing the evidence that it was tampered with), and this stage + inherits the capability's DECLARED absence policy instead of choosing its own + — bridge rule 5: ``missing_ok`` is a fact about the capability, not about the + call, and no production path may pass it. + """ + if capability not in resealable_capabilities(): + raise ReadyToReseal( + f"{capability!r} is not resealable — known: {list(resealable_capabilities())}. " + "Ledgers absent from that list are sealed by deliberate ratification only." + ) + spec = CAPABILITY_LEDGERS[capability] + ceilings = ceilings if ceilings is not None else Ceilings.default() + build_artifact, build_ledger, build_mix = _RESEALABLE[capability] + + current = load_capability_ledger(capability) + fresh_ledger = build_ledger() + artifact = build_artifact() + + return ResealPlan( + capability=capability, + path=spec.path, + licensed_before=_licensed_bands(current, ceilings), + licensed_after=_licensed_bands(fresh_ledger, ceilings), + committed={band: t.committed for band, t in sorted(fresh_ledger.items())}, + mix=build_mix(), + artifact=artifact, + ) + + +def apply_reseal(plan: ResealPlan, *, allow_new_licenses: bool = False) -> Path: + """Write the planned artifact, refusing to grant a license by accident. + + ``allow_new_licenses`` is the ratification. Without it, a plan that would + newly license any band refuses and names them — the operator then either + authorizes the grant or fixes the producer, but never discovers afterwards + that a serving surface became authoritative. + """ + if plan.newly_licensed and not allow_new_licenses: + detail = ", ".join( + f"{band} (committed={plan.committed.get(band, 0)}, " + f"entailed={plan.mix.get(band, {}).get('entailed', 0)})" + for band in plan.newly_licensed + ) + raise ReadyToReseal( + f"{plan.capability}: this reseal would newly license {len(plan.newly_licensed)} " + f"band(s) — {detail}. That is a ratification, not a reseal: those bands " + "would begin serving authoritatively. Re-run with --allow-new-licenses " + "only if the grant is intended, and read the `entailed` counts first — a " + "band can clear theta_SERVE on correct NON-COMMITMENTS alone (ADR-0262 §5.1)." + ) + write_sealed_ledger(plan.path, plan.artifact) + return plan.path + + +__all__ = [ + "ReadyToReseal", + "ResealPlan", + "apply_reseal", + "plan_reseal", + "resealable_capabilities", +] diff --git a/tests/test_ledger_reseal.py b/tests/test_ledger_reseal.py new file mode 100644 index 00000000..0d1cd662 --- /dev/null +++ b/tests/test_ledger_reseal.py @@ -0,0 +1,329 @@ +"""Phase D — the ``ledger_reseal`` ceremony stage and its CLI verb. + +`RatificationReceipt.pending_stages` has named `ledger_reseal` since the +ratification ceremony was written and nothing performed it. This pins the verb +that performs it, and — more importantly — the reason it refuses by default. + +The load-bearing property is **not** "the ledger can be rewritten". It is that +rewriting it cannot grant a serving license by accident. Reliability is +commitment precision, so a curriculum band clears θ_SERVE on correct +NON-COMMITMENTS alone; without the gate below, `reseal` would be a housekeeping +verb that silently made four bands authoritative. +""" + +from __future__ import annotations + +import dataclasses +import json +from pathlib import Path + +import pytest + +from core.ratified_ledger import CAPABILITY_LEDGERS, LedgerSpec, load_sealed_ledger +from core.reliability_gate import ClassTally +from teaching.ledger_reseal import ( + ReadyToReseal, + ResealPlan, + apply_reseal, + plan_reseal, + resealable_capabilities, +) + + +@pytest.fixture(scope="module") +def plan() -> ResealPlan: + return plan_reseal("curriculum_serve") + + +# --------------------------------------------------------------------------- +# 1. What is resealable, and what must never be. +# --------------------------------------------------------------------------- + +def test_only_curriculum_is_resealable() -> None: + """`deduction_serve` must not be reachable from a CLI verb. + + Its ledger is SHA-sealed, ratified, and gating a live flag, and 21 of its 25 + bands do not clear θ_SERVE on distinct evidence + (`tests/test_volume_honesty.py`). A reseal verb that regenerated it would + erase a measured exposure as a side effect of housekeeping. `estimation` + ships sealed with its own gate and has no reason to move. + """ + assert resealable_capabilities() == ("curriculum_serve",) + assert "deduction_serve" not in resealable_capabilities() + assert "estimation" not in resealable_capabilities() + # ...and both are still registered capabilities, so this is an exclusion by + # decision rather than by them being unknown. + assert {"deduction_serve", "estimation"} <= set(CAPABILITY_LEDGERS) + + +def test_unregistered_capability_refuses() -> None: + with pytest.raises(ReadyToReseal, match="not resealable"): + plan_reseal("deduction_serve") + + +# --------------------------------------------------------------------------- +# 2. Planning writes nothing, and reports the license delta. +# --------------------------------------------------------------------------- + +def test_planning_writes_nothing(plan: ResealPlan) -> None: + """A plan is a read. The artifact must still be absent afterwards.""" + assert not plan.path.exists(), ( + "planning a reseal must not create the ledger — see " + "tests/test_curriculum_practice.py::test_curriculum_ledger_is_not_committed" + ) + assert plan.capability == "curriculum_serve" + assert plan.artifact["schema"] == "curriculum_serve_ledger_v1" + + +def test_plan_reports_the_bands_a_reseal_would_newly_license(plan: ResealPlan) -> None: + """The whole point of the stage being a separate act. + + Nothing is licensed today (the ledger is absent), and a reseal would license + four bands — every band whose routable atom space reaches 657. Those are the + numbers `docs/research/curriculum-practice-producer-2026-07-26.md` §1 records. + """ + assert plan.licensed_before == () + assert len(plan.licensed_after) == 4 + assert set(plan.newly_licensed) == set(plan.licensed_after) + assert plan.revoked == () + assert plan.is_housekeeping is False + + +def test_newly_licensed_bands_are_licensed_on_non_commitments(plan: ResealPlan) -> None: + """The mix the operator must see before authorizing — `ClassTally` has no + verdict axis, so the gate itself cannot distinguish these from real + competence.""" + for band in plan.newly_licensed: + mix = plan.mix[band] + assert mix.get("entailed", 0) <= 9 + assert mix.get("unknown", 0) > 600 + assert plan.committed[band] >= 657 + + +# --------------------------------------------------------------------------- +# 3. Applying refuses to grant a license by accident. This is the unit. +# --------------------------------------------------------------------------- + +def test_apply_refuses_to_grant_a_license_and_writes_nothing( + plan: ResealPlan, tmp_path: Path +) -> None: + target = tmp_path / "curriculum_serve_ledger.json" + redirected = dataclasses.replace(plan, path=target) + with pytest.raises(ReadyToReseal, match="would newly license 4 band"): + apply_reseal(redirected) + assert not target.exists(), "a refused reseal must not have written" + + +def test_refusal_names_the_bands_and_their_entailed_counts( + plan: ResealPlan, tmp_path: Path +) -> None: + """A refusal an operator cannot act on is just an obstacle.""" + with pytest.raises(ReadyToReseal) as exc: + apply_reseal(dataclasses.replace(plan, path=tmp_path / "l.json")) + message = str(exc.value) + for band in plan.newly_licensed: + assert band in message + assert "entailed=" in message + assert "--allow-new-licenses" in message + assert "NON-COMMITMENTS" in message + + +def test_apply_with_explicit_authorization_writes_a_verifying_artifact( + plan: ResealPlan, tmp_path: Path +) -> None: + """The grant path works — into tmp, never into `chat/data/`.""" + target = tmp_path / "curriculum_serve_ledger.json" + written = apply_reseal( + dataclasses.replace(plan, path=target), allow_new_licenses=True + ) + assert written == target + tallies = load_sealed_ledger(target) + assert len(tallies) == 11 + assert all(t.wrong == 0 for t in tallies.values()) + artifact = json.loads(target.read_text(encoding="utf-8")) + assert artifact["provenance"] == "evals.curriculum_serve.practice.runner.seal_ledger" + + +def test_a_reseal_that_only_revokes_needs_no_authorization(tmp_path: Path) -> None: + """Losing a license is the gate becoming MORE conservative. + + Only granting is gated. A producer change that drops a band below the floor + must be applicable without an authorization flag, or the safe direction + becomes the hard one. + """ + plan = ResealPlan( + capability="curriculum_serve", + path=tmp_path / "l.json", + licensed_before=("band_a", "band_b"), + licensed_after=("band_a",), + committed={"band_a": 700, "band_b": 3}, + mix={"band_a": {"entailed": 700}, "band_b": {"entailed": 3}}, + artifact={"schema": "x", "classes": {}, "content_sha256": "y"}, + ) + assert plan.newly_licensed == () + assert plan.revoked == ("band_b",) + assert plan.is_housekeeping is True + assert apply_reseal(plan) == plan.path # no flag needed + assert plan.path.exists() + + +def test_a_pure_housekeeping_reseal_is_allowed(tmp_path: Path) -> None: + """Identical before/after — the ordinary case once volume is settled.""" + plan = ResealPlan( + capability="curriculum_serve", + path=tmp_path / "l.json", + licensed_before=("band_a",), + licensed_after=("band_a",), + committed={"band_a": 700}, + mix={"band_a": {"entailed": 700}}, + artifact={"schema": "x", "classes": {}, "content_sha256": "y"}, + ) + assert plan.is_housekeeping is True + apply_reseal(plan) + assert plan.path.exists() + + +# --------------------------------------------------------------------------- +# 4. A tampered ledger on disk refuses rather than being silently overwritten. +# --------------------------------------------------------------------------- + +def test_tampered_current_ledger_refuses_instead_of_being_overwritten( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Overwriting a hand-edited ledger would destroy the evidence of tampering. + + `plan_reseal` reads the current ledger through the SAME verifier the serving + path uses, so a broken `content_sha256` surfaces here. + """ + target = tmp_path / "curriculum_serve_ledger.json" + target.write_text( + json.dumps( + { + "schema": "curriculum_serve_ledger_v1", + "classes": {"curriculum_physics_causal": {"correct": 999, "wrong": 0}}, + "content_sha256": "0" * 64, + "note": "hand-edited", + "provenance": "not-sealed-practice", + }, + indent=2, + ), + encoding="utf-8", + ) + import teaching.ledger_reseal as mod + + monkeypatch.setitem( + mod.CAPABILITY_LEDGERS, + "curriculum_serve", + LedgerSpec( + capability="curriculum_serve", path=target, missing_ok=True, note="test" + ), + ) + with pytest.raises(Exception, match="content_sha256 mismatch"): + plan_reseal("curriculum_serve") + # The tampered bytes are still there to be investigated. + assert "hand-edited" in target.read_text(encoding="utf-8") + + +# --------------------------------------------------------------------------- +# 5. The CLI verb. +# --------------------------------------------------------------------------- + +def _run_cli(*argv: str) -> tuple[int, str]: + import argparse + import io + import contextlib + + from core.cli_proposal_queue import register + + parser = argparse.ArgumentParser() + register(parser.add_subparsers(dest="command", required=True)) + args = parser.parse_args(["proposal-queue", *argv]) + buf = io.StringIO() + with contextlib.redirect_stdout(buf): + code = args.func(args) + return code, buf.getvalue() + + +def test_cli_dry_run_reports_without_writing() -> None: + code, out = _run_cli("reseal", "curriculum_serve", "--dry-run") + assert code == 0 + assert "nothing written" in out + assert "NEWLY LICENSED" in out + assert not CAPABILITY_LEDGERS["curriculum_serve"].path.exists() + + +def test_cli_refuses_without_authorization() -> None: + code, out = _run_cli("reseal", "curriculum_serve") + assert code == 1 + assert "REFUSED" in out + assert not CAPABILITY_LEDGERS["curriculum_serve"].path.exists() + + +def test_cli_json_is_machine_readable() -> None: + code, out = _run_cli("reseal", "curriculum_serve", "--dry-run", "--json") + assert code == 0 + payload = json.loads(out) + assert payload["written"] is False + assert payload["is_housekeeping"] is False + assert len(payload["newly_licensed"]) == 4 + assert payload["licensed_before"] == [] + + +def test_reseal_verb_is_registered_under_proposal_queue() -> None: + """The verb has to be reachable from `core proposal-queue`, not only importable.""" + import argparse + + from core.cli_proposal_queue import register + + parser = argparse.ArgumentParser() + register(parser.add_subparsers(dest="command", required=True)) + args = parser.parse_args( + ["proposal-queue", "reseal", "curriculum_serve", "--dry-run"] + ) + assert args.proposal_queue_command == "reseal" + assert args.capability == "curriculum_serve" + assert args.dry_run is True + assert args.allow_new_licenses is False + + +# --------------------------------------------------------------------------- +# 6. The receipt now names the command that performs its pending stage. +# --------------------------------------------------------------------------- + +def test_ratify_output_names_the_reseal_command() -> None: + """Naming a pending stage without naming its command is how `ledger_reseal` + stayed unperformed since the ceremony was written. + + Asserted at SOURCE level deliberately. Exercising the branch needs a + successful non-dry-run ratification, which appends to the real committed + corpus — the test would either mutate `teaching/domain_chains/` or stub out + the very ceremony it claims to check. A source pin is the honest instrument + here, and it is stated as one rather than dressed up as a behavioural test. + """ + import inspect + + from teaching.ratification import RatificationReceipt + + import core.cli_proposal_queue as cli + + assert "ledger_reseal" in RatificationReceipt.__dataclass_fields__[ + "pending_stages" + ].default + + source = inspect.getsource(cli.cmd_proposal_queue_ratify) + assert 'if "ledger_reseal" in receipt.pending_stages' in source + assert "core proposal-queue reseal curriculum_serve" in source + + +def test_reseal_stage_is_not_reachable_from_ratify_chain() -> None: + """Doctrine: no ratification automation. `ratify_chain` must not seal.""" + import inspect + + import teaching.ratification as ratification + + source = inspect.getsource(ratification) + for forbidden in ("ledger_reseal", "seal_ledger", "write_sealed_ledger"): + assert f"import {forbidden}" not in source + assert f"{forbidden}(" not in source + tally = ClassTally("x", correct=1) + assert tally.committed == 1 # sanity: the gate module is importable here