feat(adr-0087): rhetorical-style pack substrate (loader + default_unstyled_v1) (#74)

Substrate-only code-side for ADR-0087 (Rhetorical Style as Selection
Axis). No composer or realizer touches the new pack yet; consumer
integration is the follow-up ADR.

packs/rhetorical_style/ (new)
  - loader.py: RhetoricalStylePack frozen dataclass + load_rhetorical_
    style_pack() with fail-closed mastery-report self-seal verification
  - __init__.py: re-exports (RhetoricalStylePack, RhetoricalStylePack-
    Error, load_rhetorical_style_pack, DEFAULT_RHETORICAL_STYLE_PACK)
  - default_unstyled_v1.json + .mastery_report.json: ratified null-lift
    baseline pack (all three constraint lists empty,
    default_unstyled=true)

scripts/ratify_rhetorical_style_packs.py (new)
  - Mirror of scripts/ratify_anchor_lens_packs.py for the rhetorical-
    style pack family. Computes pack_source_sha256 with mastery_report_
    sha256 blanked, builds self-sealed mastery report, writes both
    files. Idempotent. Uses formation.hashing for canonical JSON +
    self_seal.

Schema gate (ADR-0087 §Verification)
  - Required keys allow-list: pack_id, schema_version, version,
    issued_at, default_unstyled, permitted_frames,
    required_moves_per_claim, forbidden_moves, provenance,
    mastery_report_sha256
  - Unknown keys rejected (strict gate)
  - permitted_frames: allow-list {warrant, concession, hedge,
    definitional_move}
  - required_moves_per_claim / forbidden_moves: allow-list {claim,
    evidence, warrant, concession, hedge, bare_assertion, definitional}
  - default_unstyled=true ⟺ all three lists empty
  - non-default pack must declare at least one constraint (distinguishes
    from null-lift)
  - Duplicates within a list rejected

Ratification gate
  - require_ratified=True by default
  - CORE_ALLOW_UNRATIFIED_RHETORICAL_STYLE=1 env-var bypass for dev
  - Companion mastery report SHA must match pack's declared sha
  - verify_seal(report) must pass (self-seal integrity)
  - Sister to packs.safety.SafetyPackError pattern

core/config.py
  - Added RuntimeConfig.rhetorical_style_id: str | None = None
  - No runtime code reads it yet — that's the consumer ADR's job
  - Field declared so the interface is stable when consumer lands

Tests (tests/test_adr_0087_rhetorical_style_substrate.py — 20)
  - Default pack loads, is_null_lift, mastery-report self-seal verified,
    discovery lists it as ratified
  - Schema gate: missing key, unknown key, unknown frame, unknown move,
    duplicate frame, default_unstyled-with-constraints,
    non-default-with-zero-constraints, pack_id mismatch, path traversal
  - Ratification gate: unratified pack rejected by default, env-var
    bypass, companion report missing, companion sha mismatch
  - RuntimeConfig field: default None, accepts string, independent of
    other axes

Lanes
  smoke 67/0, cognition 120/0/1, packs 6/0. core eval cognition
  byte-identical 100/91.7/100/100.

Null-lift consumer test deferred
  ADR-0087 §Required tests lists rhetorical_style_null_lift as a
  required invariant. Today it would be trivially true because no
  composer reads the field. The invariant becomes meaningful when
  the consumer ADR wires the field through the dispatch — at that
  point the null-lift test goes into the consumer PR alongside the
  three-axis orthogonality test.

Scope per ADR-0087 §Scope limits
  - No consumer code (composer/realizer changes deferred)
  - No genre packs (en_academic_v1, etc. are content efforts after
    consumer lands)
  - No prompt-routing (operator-set only)
This commit is contained in:
Shay 2026-05-20 16:19:36 -07:00 committed by GitHub
parent 351a083bb4
commit 0cf54a009d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 918 additions and 0 deletions

View file

@ -98,6 +98,14 @@ class RuntimeConfig:
# chain-walk surface byte-identically (null-drop invariant).
gloss_aware_cause: bool = False
# ADR-0087 — rhetorical-style selection axis (substrate phase).
# ``None`` resolves to ``DEFAULT_RHETORICAL_STYLE_PACK`` per the
# mounting discipline, which is the null-lift baseline. No
# composer or realizer reads this field yet — that wiring is the
# consumer ADR's job. The field is declared here so the runtime
# interface is stable when the consumer lands.
rhetorical_style_id: str | None = None
# ADR-0066 / P3.2 — opt-in thread anaphora. When enabled, the
# runtime prepends a deterministic backreference to a recent
# grounded turn when the current turn's subject lemma matches

View file

@ -0,0 +1,36 @@
"""Rhetorical-style pack layer (ADR-0087, substrate phase).
A *rhetorical style* is the substantive-frame axis: it constrains
which rhetorical-mode frames the realizer may emit and which
rhetorical-move requirements the composer applies. It is **not** a
motor on the field see ADR-0087 §Forbidden alternatives.
Sister to:
* :mod:`packs.anchor_lens` substantive-vocabulary axis
(semantic content / tradition). Rhetorical style is the third
selection axis: rhetorical-genre constraint, sibling to anchor lens,
orthogonal to register.
* :mod:`packs.safety` and :mod:`packs.identity` pack-layer
composition discipline (mastery-report self-seal, fail-closed
loader).
This is the substrate-only layer. No composer or realizer yet
consumes the pack that wiring is the consumer ADR's job. Today the
substrate exists so the consumer ADR has something to be wired
against.
"""
from packs.rhetorical_style.loader import (
DEFAULT_RHETORICAL_STYLE_PACK,
RhetoricalStylePack,
RhetoricalStylePackError,
load_rhetorical_style_pack,
)
__all__ = (
"DEFAULT_RHETORICAL_STYLE_PACK",
"RhetoricalStylePack",
"RhetoricalStylePackError",
"load_rhetorical_style_pack",
)

View file

@ -0,0 +1 @@
{"default_unstyled":true,"forbidden_moves":[],"issued_at":"2026-05-21T00:00:00Z","mastery_report_sha256":"103bcfc8790ad4163ad11d2d5a83d84415a337d5fd4439bbbf2f41d53b250223","pack_id":"default_unstyled_v1","permitted_frames":[],"provenance":"adr-0087:reviewed:2026-05-21","required_moves_per_claim":[],"schema_version":"1.0.0","version":1}

View file

@ -0,0 +1 @@
{"evidence":{"default_unstyled":true,"forbidden_moves":[],"permitted_frames":[],"required_moves_per_claim":[],"version":1},"failure_reasons":[],"issued_at":"2026-05-21T00:00:00Z","pack_id":"default_unstyled_v1","pack_source_sha256":"e16b0a66d0b7e92c7d96913ea685c32abb561775be8cd19a13a3655a341a3e88","ratification_method":"rhetorical_style_substrate","ratified":true,"report_sha256":"103bcfc8790ad4163ad11d2d5a83d84415a337d5fd4439bbbf2f41d53b250223","schema_version":"1.0.0"}

View file

@ -0,0 +1,425 @@
"""Rhetorical-style pack loader (ADR-0087 substrate phase).
Reads a ratified rhetorical-style pack from disk and constructs a
frozen :class:`RhetoricalStylePack` for the runtime to mount.
Pattern is the same as :mod:`packs.anchor_lens.loader`:
* Self-sealed mastery report verified at load time
(``require_ratified=True`` by default; bypassed by
``CORE_ALLOW_UNRATIFIED_RHETORICAL_STYLE=1`` for dev).
* Path-traversal-safe ``pack_id`` resolution.
* Allow-list schema validation unknown keys rejected; ``permitted_frames``
/ ``required_moves_per_claim`` / ``forbidden_moves`` constrained
to known-frame / known-move vocabularies.
* ``default_unstyled: true`` only valid when all three lists are
empty (the null-lift pack).
The substrate is composer-side only. At this phase no composer or
realizer yet consumes the pack the loader exists so the consumer
ADR can mount real packs against a stable contract.
ADR
---
ADR-0087 Rhetorical Style as Selection Axis (Pre-Work for Writing
Curriculum)
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
from formation.hashing import verify_seal
DEFAULT_RHETORICAL_STYLE_PACK: str = "default_unstyled_v1"
_DEFAULT_SEARCH_PATHS: tuple[Path, ...] = (
Path(__file__).resolve().parent,
)
_SCHEMA_VERSION: str = "1.0.0"
# v0 allow-lists. Deliberately small per ADR-0087 §Composer & realizer
# contract — additions require ADR amendment, not silent extension.
_ALLOWED_FRAMES: frozenset[str] = frozenset({
"warrant", # "Therefore X, because Y."
"concession", # "While X, Y."
"hedge", # "This suggests X, though Q."
"definitional_move", # "By X we mean {gloss}."
})
_ALLOWED_MOVES: frozenset[str] = frozenset({
"claim",
"evidence",
"warrant",
"concession",
"hedge",
"bare_assertion",
"definitional",
})
_REQUIRED_KEYS: frozenset[str] = frozenset({
"pack_id",
"schema_version",
"version",
"issued_at",
"default_unstyled",
"permitted_frames",
"required_moves_per_claim",
"forbidden_moves",
"provenance",
"mastery_report_sha256",
})
_MAX_LIST_LEN: int = 32
_MAX_PROVENANCE_LEN: int = 256
_MAX_ISSUED_AT_LEN: int = 64
class RhetoricalStylePackError(RuntimeError):
"""Raised when a rhetorical-style pack is missing, malformed, or
unverified.
Inherits from :class:`RuntimeError` (not :class:`ValueError`) on the
same principle as :class:`packs.safety.SafetyPackError`: a missing
rhetorical-style pack for a deployment that opts in is a
fail-closed runtime condition, not a recoverable input-validation
error.
"""
def _safe_pack_id(value: object) -> str:
"""Return a printable, length-capped version of a pack id."""
s = str(value) if value is not None else ""
return s[:64]
@dataclass(frozen=True, slots=True)
class RhetoricalStylePack:
"""Frozen rhetorical-style pack.
The substantive content of the pack is three lists:
* ``permitted_frames`` the realizer's frame allow-list (the only
rhetorical-mode frames it may emit while this pack is mounted).
* ``required_moves_per_claim`` moves the composer MUST include for
every claim it surfaces.
* ``forbidden_moves`` moves the composer MUST refuse to ratify.
The null-lift pack (``default_unstyled_v1``) has all three lists
empty and ``default_unstyled=True``. Loading it changes nothing.
"""
pack_id: str
schema_version: str
version: int
issued_at: str
default_unstyled: bool
permitted_frames: tuple[str, ...]
required_moves_per_claim: tuple[str, ...]
forbidden_moves: tuple[str, ...]
provenance: str
mastery_report_sha256: str = ""
def is_null_lift(self) -> bool:
"""True iff this pack imposes no constraints at all.
The null-lift pack is the substrate equivalent of
``anchor_lens.AnchorLens.is_null_lens`` structurally
equivalent to no pack being mounted.
"""
return (
self.default_unstyled
and not self.permitted_frames
and not self.required_moves_per_claim
and not self.forbidden_moves
)
def _validate_pack_id_for_fs(pack_id: object) -> None:
"""Reject path-traversal / slash / empty / non-string pack ids."""
if (
not pack_id
or not isinstance(pack_id, str)
or "/" in pack_id
or "\\" in pack_id
or ".." in pack_id
or pack_id.startswith(".")
):
raise RhetoricalStylePackError(
f"invalid rhetorical-style pack_id: {_safe_pack_id(pack_id)!r}"
)
for ch in pack_id:
if not (ch.isascii() and (ch.isalnum() or ch in {"_", "-"})):
raise RhetoricalStylePackError(
f"rhetorical-style pack_id must be alphanumeric/_/-: "
f"{_safe_pack_id(pack_id)!r}"
)
def _find_pack_path(pack_id: str, search_paths: Iterable[Path]) -> Path:
_validate_pack_id_for_fs(pack_id)
for directory in search_paths:
candidate = Path(directory) / f"{pack_id}.json"
if candidate.exists():
return candidate
raise RhetoricalStylePackError(
f"rhetorical-style pack {_safe_pack_id(pack_id)!r} not found in search paths"
)
def _validate_string_list(
raw: object,
*,
pack_id: str,
field_name: str,
allow_list: frozenset[str],
) -> tuple[str, ...]:
if not isinstance(raw, list):
raise RhetoricalStylePackError(
f"pack {_safe_pack_id(pack_id)!r}: {field_name!r} must be a list"
)
if len(raw) > _MAX_LIST_LEN:
raise RhetoricalStylePackError(
f"pack {_safe_pack_id(pack_id)!r}: {field_name!r} exceeds max len {_MAX_LIST_LEN}"
)
seen: set[str] = set()
out: list[str] = []
for item in raw:
if not isinstance(item, str) or not item:
raise RhetoricalStylePackError(
f"pack {_safe_pack_id(pack_id)!r}: {field_name!r} must contain non-empty strings"
)
if item not in allow_list:
raise RhetoricalStylePackError(
f"pack {_safe_pack_id(pack_id)!r}: {field_name!r} contains "
f"unknown value {item!r} (allowed: {sorted(allow_list)})"
)
if item in seen:
raise RhetoricalStylePackError(
f"pack {_safe_pack_id(pack_id)!r}: {field_name!r} contains "
f"duplicate value {item!r}"
)
seen.add(item)
out.append(item)
return tuple(out)
def _validate_envelope(raw: dict, pack_id: str) -> None:
if not isinstance(raw, dict):
raise RhetoricalStylePackError(
f"pack {_safe_pack_id(pack_id)!r}: top-level payload must be a JSON object"
)
unknown = set(raw.keys()) - _REQUIRED_KEYS
if unknown:
raise RhetoricalStylePackError(
f"pack {_safe_pack_id(pack_id)!r}: unrecognised key(s) {sorted(unknown)}; "
f"strict schema gate per ADR-0087"
)
missing = _REQUIRED_KEYS - set(raw.keys())
if missing:
raise RhetoricalStylePackError(
f"pack {_safe_pack_id(pack_id)!r}: missing required key(s) {sorted(missing)}"
)
if raw["pack_id"] != pack_id:
raise RhetoricalStylePackError(
f"pack file declares pack_id={_safe_pack_id(raw['pack_id'])!r} but "
f"was requested as {_safe_pack_id(pack_id)!r}"
)
if raw["schema_version"] != _SCHEMA_VERSION:
raise RhetoricalStylePackError(
f"pack {_safe_pack_id(pack_id)!r}: unsupported schema_version "
f"{raw['schema_version']!r} (expected {_SCHEMA_VERSION!r})"
)
if not isinstance(raw["version"], int) or isinstance(raw["version"], bool) or raw["version"] < 1:
raise RhetoricalStylePackError(
f"pack {_safe_pack_id(pack_id)!r}: version must be a positive int"
)
if not isinstance(raw["issued_at"], str) or not raw["issued_at"] or len(raw["issued_at"]) > _MAX_ISSUED_AT_LEN:
raise RhetoricalStylePackError(
f"pack {_safe_pack_id(pack_id)!r}: issued_at must be a non-empty string <= {_MAX_ISSUED_AT_LEN} chars"
)
if not isinstance(raw["default_unstyled"], bool):
raise RhetoricalStylePackError(
f"pack {_safe_pack_id(pack_id)!r}: default_unstyled must be a boolean"
)
if not isinstance(raw["provenance"], str) or not raw["provenance"] or len(raw["provenance"]) > _MAX_PROVENANCE_LEN:
raise RhetoricalStylePackError(
f"pack {_safe_pack_id(pack_id)!r}: provenance must be a non-empty string <= {_MAX_PROVENANCE_LEN} chars"
)
if not isinstance(raw["mastery_report_sha256"], str):
raise RhetoricalStylePackError(
f"pack {_safe_pack_id(pack_id)!r}: mastery_report_sha256 must be a string"
)
def _validate_unstyled_invariant(
pack_id: str,
default_unstyled: bool,
permitted_frames: tuple[str, ...],
required_moves_per_claim: tuple[str, ...],
forbidden_moves: tuple[str, ...],
) -> None:
"""Enforce ADR-0087 §Verification: ``default_unstyled: true`` only
valid when all three constraint lists are empty.
"""
if default_unstyled:
if permitted_frames or required_moves_per_claim or forbidden_moves:
raise RhetoricalStylePackError(
f"pack {_safe_pack_id(pack_id)!r}: default_unstyled=true requires "
f"empty permitted_frames / required_moves_per_claim / forbidden_moves"
)
else:
# A non-default pack that declares zero constraints is also
# rejected — it would be indistinguishable from null-lift but
# would silently miss the default_unstyled invariant test.
if not permitted_frames and not required_moves_per_claim and not forbidden_moves:
raise RhetoricalStylePackError(
f"pack {_safe_pack_id(pack_id)!r}: non-default pack must declare at "
f"least one of permitted_frames / required_moves_per_claim / forbidden_moves"
)
def load_rhetorical_style_pack(
pack_id: str = DEFAULT_RHETORICAL_STYLE_PACK,
*,
search_paths: Iterable[Path | str] | None = None,
require_ratified: bool | None = None,
) -> RhetoricalStylePack:
"""Load, validate, and return a frozen :class:`RhetoricalStylePack`.
Parameters
----------
pack_id:
Pack identifier, e.g. ``"default_unstyled_v1"``. Defaults to
:data:`DEFAULT_RHETORICAL_STYLE_PACK`.
search_paths:
Directories to search for ``<pack_id>.json``. Defaults to the
directory containing this module.
require_ratified:
If ``True``, refuse packs with an empty
``mastery_report_sha256``. If ``None`` (default), falls back
to the ``CORE_ALLOW_UNRATIFIED_RHETORICAL_STYLE`` environment
variable (refuse unless set to ``1`` / ``true`` / ``yes``).
"""
resolved_paths: list[Path] = [
Path(p) for p in (search_paths or _DEFAULT_SEARCH_PATHS)
]
pack_path = _find_pack_path(pack_id, resolved_paths)
try:
raw: dict = json.loads(pack_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise RhetoricalStylePackError(
f"pack {_safe_pack_id(pack_id)!r}: malformed JSON ({exc})"
) from exc
_validate_envelope(raw, pack_id)
permitted_frames = _validate_string_list(
raw["permitted_frames"], pack_id=pack_id,
field_name="permitted_frames", allow_list=_ALLOWED_FRAMES,
)
required_moves_per_claim = _validate_string_list(
raw["required_moves_per_claim"], pack_id=pack_id,
field_name="required_moves_per_claim", allow_list=_ALLOWED_MOVES,
)
forbidden_moves = _validate_string_list(
raw["forbidden_moves"], pack_id=pack_id,
field_name="forbidden_moves", allow_list=_ALLOWED_MOVES,
)
_validate_unstyled_invariant(
pack_id,
bool(raw["default_unstyled"]),
permitted_frames,
required_moves_per_claim,
forbidden_moves,
)
if require_ratified is None:
env = os.environ.get("CORE_ALLOW_UNRATIFIED_RHETORICAL_STYLE", "").lower()
require_ratified = env not in ("1", "true", "yes")
declared_sha = raw["mastery_report_sha256"]
if require_ratified:
if not declared_sha:
raise RhetoricalStylePackError(
f"pack {_safe_pack_id(pack_id)!r} is not ratified "
f"(mastery_report_sha256 is empty). Run "
f"scripts/ratify_rhetorical_style_packs.py or set "
f"CORE_ALLOW_UNRATIFIED_RHETORICAL_STYLE=1 for development."
)
# Companion-SHA agreement check.
report_path = pack_path.parent / f"{pack_id}.mastery_report.json"
if not report_path.is_file():
raise RhetoricalStylePackError(
f"pack {_safe_pack_id(pack_id)!r}: companion mastery report missing "
f"at {report_path}"
)
try:
report = json.loads(report_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
raise RhetoricalStylePackError(
f"pack {_safe_pack_id(pack_id)!r}: companion mastery report "
f"unreadable: {exc}"
) from exc
report_sha = str(report.get("report_sha256", ""))
if report_sha != declared_sha:
raise RhetoricalStylePackError(
f"pack {_safe_pack_id(pack_id)!r}: declared mastery_report_sha256 "
f"does not match companion report's report_sha256"
)
if not verify_seal(report, sha_field="report_sha256"):
raise RhetoricalStylePackError(
f"pack {_safe_pack_id(pack_id)!r}: companion mastery report self-seal "
f"verification failed"
)
return RhetoricalStylePack(
pack_id=raw["pack_id"],
schema_version=raw["schema_version"],
version=raw["version"],
issued_at=raw["issued_at"],
default_unstyled=bool(raw["default_unstyled"]),
permitted_frames=permitted_frames,
required_moves_per_claim=required_moves_per_claim,
forbidden_moves=forbidden_moves,
provenance=raw["provenance"],
mastery_report_sha256=declared_sha,
)
def available_rhetorical_style_packs(
search_paths: Iterable[Path | str] | None = None,
) -> list[dict]:
"""Return summary dicts for all ``.json`` packs in the search paths.
Mirror of :func:`packs.anchor_lens.loader.available_anchor_lens_packs`.
"""
resolved_paths: list[Path] = [
Path(p) for p in (search_paths or _DEFAULT_SEARCH_PATHS)
]
summaries: list[dict] = []
for directory in resolved_paths:
d = Path(directory)
if not d.is_dir():
continue
for f in sorted(d.glob("*.json")):
stem = f.stem
if stem.startswith("_") or ".mastery_report" in stem:
continue
try:
raw = json.loads(f.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
continue
summaries.append({
"pack_id": stem,
"ratified": bool(raw.get("mastery_report_sha256", "")),
"default_unstyled": bool(raw.get("default_unstyled", False)),
"version": raw.get("version", 0),
})
return summaries

View file

@ -0,0 +1,116 @@
"""Ratify rhetorical-style packs (ADR-0087 substrate phase).
For each pack in ``packs/rhetorical_style/<pack_id>.json``:
1. Compute canonical ``pack_source_sha256`` with
``mastery_report_sha256`` blanked.
2. Build a self-sealed mastery report under the
``rhetorical_style_substrate`` ratification method.
3. Write ``<pack_id>.mastery_report.json``.
4. Seal the pack with the report's SHA.
Idempotent: re-running on an already-ratified pack rewrites both
files deterministically. Output is byte-identical when content is
byte-identical (canonical JSON discipline from
:mod:`formation.hashing`).
Substrate-phase ratification is content-only no L1 lexicon gate
(the ADR's consumer phase is what teaches the realizer to read the
frames; until that lands, "frame name is in the allow-list" is the
only check the substrate needs).
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Any
from formation.hashing import canonical_json, self_seal, sha256_of
PACKS_DIR = Path(__file__).resolve().parents[1] / "packs" / "rhetorical_style"
# Pack ids to ratify, in declaration order.
PACK_IDS: tuple[str, ...] = (
# Null-lift baseline — must be first; ADR-0087 byte-identity
# invariant test pins this pack's structural equivalence to
# rhetorical_style_id=None.
"default_unstyled_v1",
)
def _pack_source_sha256(pack_payload: dict[str, Any]) -> str:
"""SHA-256 of the pack with ``mastery_report_sha256`` blanked."""
probe = dict(pack_payload)
probe["mastery_report_sha256"] = ""
return sha256_of(probe)
def _ratify_one(pack_id: str) -> tuple[bool, str]:
"""Ratify a single pack. Returns (changed, message)."""
pack_path = PACKS_DIR / f"{pack_id}.json"
if not pack_path.is_file():
return False, f"{pack_id}: pack file missing at {pack_path}"
try:
pack_payload = json.loads(pack_path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
return False, f"{pack_id}: pack JSON malformed ({exc})"
# Recompute pack_source_sha256 from the current pack content.
pack_source = _pack_source_sha256(pack_payload)
# Build the mastery report.
report: dict[str, Any] = {
"pack_id": pack_id,
"schema_version": pack_payload.get("schema_version", "1.0.0"),
"ratification_method": "rhetorical_style_substrate",
"ratified": True,
"issued_at": pack_payload.get("issued_at", ""),
"pack_source_sha256": pack_source,
"failure_reasons": [],
"evidence": {
"default_unstyled": pack_payload.get("default_unstyled", False),
"permitted_frames": pack_payload.get("permitted_frames", []),
"required_moves_per_claim": pack_payload.get("required_moves_per_claim", []),
"forbidden_moves": pack_payload.get("forbidden_moves", []),
"version": pack_payload.get("version", 0),
},
"report_sha256": "",
}
sealed_report = self_seal(report, sha_field="report_sha256")
report_sha = sealed_report["report_sha256"]
report_path = PACKS_DIR / f"{pack_id}.mastery_report.json"
report_bytes = canonical_json(sealed_report) + b"\n"
# Update the pack's mastery_report_sha256 and re-write.
pack_payload["mastery_report_sha256"] = report_sha
pack_bytes = canonical_json(pack_payload) + b"\n"
existing_pack_bytes = pack_path.read_bytes() if pack_path.is_file() else b""
existing_report_bytes = report_path.read_bytes() if report_path.is_file() else b""
changed = (
existing_pack_bytes != pack_bytes
or existing_report_bytes != report_bytes
)
if changed:
report_path.write_bytes(report_bytes)
pack_path.write_bytes(pack_bytes)
return True, f"{pack_id}: ratified (report_sha={report_sha[:12]}..)"
return False, f"{pack_id}: already ratified (report_sha={report_sha[:12]}..)"
def main(argv: list[str]) -> int:
any_changed = False
for pack_id in PACK_IDS:
changed, msg = _ratify_one(pack_id)
print(msg)
if changed:
any_changed = True
return 0 if not any_changed or "--check" not in argv else 2
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))

View file

@ -0,0 +1,331 @@
"""ADR-0087 substrate tests — rhetorical-style pack loader.
Pins the substrate contract end-to-end:
1. ``default_unstyled_v1`` ratifies and loads with the same
mastery-report self-seal discipline as anchor-lens packs.
2. Schema gate rejects unknown keys, malformed lists, invalid
``default_unstyled`` interactions.
3. Frame and move vocabularies are allow-listed; unknown values
refused.
4. ``RuntimeConfig.rhetorical_style_id`` field exists and defaults
to ``None``.
No consumer code is invoked this PR ships substrate only. The
null-lift behavior (loading ``default_unstyled_v1`` changes nothing
about runtime surfaces) is trivially true today because no composer
reads the pack; that becomes a real test when the consumer ADR lands.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from packs.rhetorical_style import (
DEFAULT_RHETORICAL_STYLE_PACK,
RhetoricalStylePack,
RhetoricalStylePackError,
load_rhetorical_style_pack,
)
from packs.rhetorical_style.loader import (
available_rhetorical_style_packs,
)
# --------------------------------------------------------------------------- #
# Default null-lift pack
# --------------------------------------------------------------------------- #
class TestDefaultUnstyledPack:
def test_loads(self) -> None:
pack = load_rhetorical_style_pack()
assert isinstance(pack, RhetoricalStylePack)
assert pack.pack_id == DEFAULT_RHETORICAL_STYLE_PACK
def test_is_null_lift(self) -> None:
pack = load_rhetorical_style_pack()
assert pack.is_null_lift() is True
assert pack.default_unstyled is True
assert pack.permitted_frames == ()
assert pack.required_moves_per_claim == ()
assert pack.forbidden_moves == ()
def test_mastery_report_self_seal_verified(self) -> None:
# Loading with require_ratified=True (the default) implicitly
# verifies the companion mastery-report self-seal. If the seal
# is broken the load raises RhetoricalStylePackError — so the
# mere fact load_rhetorical_style_pack() returns at all is the
# assertion. Re-load explicitly for clarity:
pack = load_rhetorical_style_pack(require_ratified=True)
assert pack.mastery_report_sha256, "ratified pack must declare mastery_report_sha256"
# SHA hex string length sanity.
assert len(pack.mastery_report_sha256) == 64
def test_discovery_lists_default_pack(self) -> None:
summaries = available_rhetorical_style_packs()
ids = {s["pack_id"] for s in summaries}
assert "default_unstyled_v1" in ids
# The default pack must show as ratified in the discovery list.
default_summary = next(s for s in summaries if s["pack_id"] == "default_unstyled_v1")
assert default_summary["ratified"] is True
assert default_summary["default_unstyled"] is True
# --------------------------------------------------------------------------- #
# Schema gate via fixture packs in a tmp_path
# --------------------------------------------------------------------------- #
def _write_pack(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, *, pack_id: str = "fixture_v1",
overrides: dict | None = None, write_report: bool = False,
) -> str:
from packs.rhetorical_style import loader as _loader
valid = {
"pack_id": pack_id,
"schema_version": "1.0.0",
"version": 1,
"issued_at": "2026-05-21T00:00:00Z",
"default_unstyled": True,
"permitted_frames": [],
"required_moves_per_claim": [],
"forbidden_moves": [],
"provenance": "test:fixture",
"mastery_report_sha256": "",
}
if overrides:
valid.update(overrides)
(tmp_path / f"{pack_id}.json").write_text(json.dumps(valid), encoding="utf-8")
if write_report:
# Build a minimal self-sealed report that agrees with the pack's
# mastery_report_sha256. Easiest path: use the existing ratify
# script's seal helper directly.
from formation.hashing import self_seal, sha256_of
probe = dict(valid)
probe["mastery_report_sha256"] = ""
pack_source = sha256_of(probe)
report = {
"pack_id": pack_id,
"schema_version": "1.0.0",
"ratification_method": "rhetorical_style_substrate",
"ratified": True,
"issued_at": valid["issued_at"],
"pack_source_sha256": pack_source,
"failure_reasons": [],
"evidence": {},
"report_sha256": "",
}
sealed = self_seal(report, sha_field="report_sha256")
(tmp_path / f"{pack_id}.mastery_report.json").write_text(
json.dumps(sealed), encoding="utf-8"
)
# Update pack to declare matching sha
valid["mastery_report_sha256"] = sealed["report_sha256"]
(tmp_path / f"{pack_id}.json").write_text(json.dumps(valid), encoding="utf-8")
monkeypatch.setattr(_loader, "_DEFAULT_SEARCH_PATHS", (tmp_path,))
return pack_id
class TestSchemaGate:
def test_missing_required_key_rejected(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
pack_id = _write_pack(
tmp_path, monkeypatch,
overrides={"version": None}, # we'll delete a key below
)
# Write a pack missing 'version'
bad = {
"pack_id": pack_id,
"schema_version": "1.0.0",
"issued_at": "2026-05-21T00:00:00Z",
"default_unstyled": True,
"permitted_frames": [],
"required_moves_per_claim": [],
"forbidden_moves": [],
"provenance": "test:fixture",
"mastery_report_sha256": "",
}
(tmp_path / f"{pack_id}.json").write_text(json.dumps(bad), encoding="utf-8")
with pytest.raises(RhetoricalStylePackError, match=r"missing required key"):
load_rhetorical_style_pack(pack_id, require_ratified=False)
def test_unknown_key_rejected(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
pack_id = _write_pack(
tmp_path, monkeypatch, overrides={"rogue_field": True},
)
with pytest.raises(RhetoricalStylePackError, match=r"unrecognised key"):
load_rhetorical_style_pack(pack_id, require_ratified=False)
def test_unknown_frame_rejected(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
pack_id = _write_pack(
tmp_path, monkeypatch,
overrides={
"default_unstyled": False,
"permitted_frames": ["rogue_frame"],
},
)
with pytest.raises(RhetoricalStylePackError, match=r"unknown value 'rogue_frame'"):
load_rhetorical_style_pack(pack_id, require_ratified=False)
def test_unknown_move_rejected(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
pack_id = _write_pack(
tmp_path, monkeypatch,
overrides={
"default_unstyled": False,
"required_moves_per_claim": ["rogue_move"],
},
)
with pytest.raises(RhetoricalStylePackError, match=r"unknown value 'rogue_move'"):
load_rhetorical_style_pack(pack_id, require_ratified=False)
def test_duplicate_frame_rejected(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
pack_id = _write_pack(
tmp_path, monkeypatch,
overrides={
"default_unstyled": False,
"permitted_frames": ["warrant", "warrant"],
},
)
with pytest.raises(RhetoricalStylePackError, match=r"duplicate value 'warrant'"):
load_rhetorical_style_pack(pack_id, require_ratified=False)
def test_default_unstyled_with_constraints_rejected(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# ADR-0087 invariant: default_unstyled=true ⟺ all three lists empty.
pack_id = _write_pack(
tmp_path, monkeypatch,
overrides={
"default_unstyled": True,
"permitted_frames": ["warrant"],
},
)
with pytest.raises(
RhetoricalStylePackError,
match=r"default_unstyled=true requires empty",
):
load_rhetorical_style_pack(pack_id, require_ratified=False)
def test_non_default_with_no_constraints_rejected(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# A non-default pack with zero constraints would silently look
# like null-lift but bypass the default_unstyled invariant test
# — rejected to keep the two states distinguishable.
pack_id = _write_pack(
tmp_path, monkeypatch, overrides={"default_unstyled": False},
)
with pytest.raises(
RhetoricalStylePackError,
match=r"non-default pack must declare at least one",
):
load_rhetorical_style_pack(pack_id, require_ratified=False)
def test_pack_id_mismatch_rejected(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
pack_id = _write_pack(
tmp_path, monkeypatch,
overrides={"pack_id": "different_id_v1"},
)
with pytest.raises(
RhetoricalStylePackError,
match=r"declares pack_id=",
):
load_rhetorical_style_pack(pack_id, require_ratified=False)
def test_path_traversal_pack_id_rejected(self) -> None:
with pytest.raises(RhetoricalStylePackError, match=r"invalid rhetorical-style pack_id"):
load_rhetorical_style_pack("../escape", require_ratified=False)
# --------------------------------------------------------------------------- #
# Ratification gate
# --------------------------------------------------------------------------- #
class TestRatificationGate:
def test_unratified_pack_rejected_by_default(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# require_ratified defaults to True; an empty mastery_report_sha256
# is refused absent the env-var bypass.
pack_id = _write_pack(tmp_path, monkeypatch)
monkeypatch.delenv("CORE_ALLOW_UNRATIFIED_RHETORICAL_STYLE", raising=False)
with pytest.raises(RhetoricalStylePackError, match=r"not ratified"):
load_rhetorical_style_pack(pack_id)
def test_env_var_bypass_permits_unratified(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
pack_id = _write_pack(tmp_path, monkeypatch)
monkeypatch.setenv("CORE_ALLOW_UNRATIFIED_RHETORICAL_STYLE", "1")
pack = load_rhetorical_style_pack(pack_id)
assert pack.mastery_report_sha256 == ""
def test_companion_report_missing_rejected(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Pack declares a mastery_report_sha256 but no companion file
# on disk — refused.
pack_id = _write_pack(
tmp_path, monkeypatch,
overrides={"mastery_report_sha256": "deadbeef" * 8},
)
with pytest.raises(RhetoricalStylePackError, match=r"companion mastery report missing"):
load_rhetorical_style_pack(pack_id, require_ratified=True)
def test_companion_sha_mismatch_rejected(
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
# Pack declares one SHA; companion report has a different one.
pack_id = _write_pack(tmp_path, monkeypatch, write_report=True)
# Tamper with the declared SHA on the pack to break agreement.
pack_path = tmp_path / f"{pack_id}.json"
raw = json.loads(pack_path.read_text())
raw["mastery_report_sha256"] = "f" * 64
pack_path.write_text(json.dumps(raw))
with pytest.raises(RhetoricalStylePackError, match=r"does not match companion"):
load_rhetorical_style_pack(pack_id, require_ratified=True)
# --------------------------------------------------------------------------- #
# RuntimeConfig field
# --------------------------------------------------------------------------- #
class TestRuntimeConfigField:
def test_default_is_none(self) -> None:
from core.config import RuntimeConfig
cfg = RuntimeConfig()
assert cfg.rhetorical_style_id is None
def test_field_accepts_string(self) -> None:
from core.config import RuntimeConfig
cfg = RuntimeConfig(rhetorical_style_id="default_unstyled_v1")
assert cfg.rhetorical_style_id == "default_unstyled_v1"
def test_field_independent_of_other_axes(self) -> None:
# Sanity: setting rhetorical_style_id does not perturb other
# axes' defaults (register, anchor lens, ADR-0085 flag, etc.).
from core.config import RuntimeConfig
cfg = RuntimeConfig(rhetorical_style_id="default_unstyled_v1")
assert cfg.gloss_aware_cause is False
assert cfg.transitive_surface is False
assert cfg.composed_surface is False