core/tests/test_ledger_reseal.py
Shay 0a78545e3d feat(teaching): core proposal-queue reseal — the ledger_reseal stage (Phase D)
`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.
2026-07-26 10:20:56 -07:00

329 lines
13 KiB
Python

"""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