`core teaching supersede <old_chain_id> --subject ... --intent ... --connective ... --object ... --review-date YYYY-MM-DD` is the second corpus mutation surface (alongside accept_proposal). No replay gate — it's a deliberate operator action that replaces a hand-authored or previously discovery-promoted chain. - teaching/supersede.py — `supersede_chain()` orchestrator with pre-checks (review_date format, intent whitelist, pack-consistency via re-audit, no double-supersede, no self-supersede, no new-chain-id collision) and byte-identical rollback on post-audit failure. - teaching/proposals.py — extended `append_chain_to_corpus` with optional `superseded_by` kwarg; remains the only function in the codebase that writes to the active teaching corpus. - core/cli.py — `core teaching supersede` subcommand wired to the live `_CORPUS_PATH`; EPILOG updated with example. - tests/test_supersede.py — 13 tests pin every gate, byte-identical rollback on rejection, append-only at disk level, audit-and-runtime parity after supersession, hand_authored provenance with `supersede(<old_chain_id>)` tag. Lane state: smoke 67 / cognition 121 / teaching 17 / supersede 13 / audit 23 / proposals 16 / contemplation 16 / contemplation-wiring 6 / discovery 24 — green. `core eval cognition`: intent 100% / surface 100% / term 91.7% / versor 100% — unchanged.
196 lines
7 KiB
Python
196 lines
7 KiB
Python
"""ADR-0057 follow-up — operator-driven supersession of an active corpus chain.
|
|
|
|
Supersession is the **second** mutation surface on the reviewed
|
|
teaching corpus (alongside ``teaching.proposals.accept_proposal``).
|
|
It is *not* a proposal: there is no replay-equivalence gate and no
|
|
``ProposalLog`` round-trip. It is a direct operator action that
|
|
records: "this active chain is replaced by this new one."
|
|
|
|
Trust boundary:
|
|
|
|
- Append-only at the disk level. The earlier chain stays on disk;
|
|
the audit report and the runtime loader both honour the
|
|
``superseded_by`` field to drop it from the active view.
|
|
- The single write surface remains ``proposals.append_chain_to_corpus``.
|
|
This module composes around it; it does not write its own JSONL.
|
|
- Validation gates (pack-consistency, intent whitelist, complete
|
|
fields, no double-supersede, not self-supersede) all run before
|
|
the append. Any gate failure raises ``SupersessionError`` and
|
|
leaves the corpus byte-identical.
|
|
- No clock-time read. ``review_date`` is operator-provided.
|
|
|
|
This is distinct from ``TeachingChainProposal``: supersession is an
|
|
operator's deliberate replacement of a hand-authored or previously
|
|
discovery-promoted chain. It does not need a replay gate because
|
|
the operator is explicitly accepting any metric movement.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from pathlib import Path
|
|
|
|
from teaching.audit import audit_corpus
|
|
from teaching.proposals import append_chain_to_corpus
|
|
from teaching.provenance import Provenance
|
|
|
|
# Reused from chat.teaching_grounding to keep one definition.
|
|
from chat.teaching_grounding import _VALID_INTENTS
|
|
|
|
_REVIEW_DATE_RE: re.Pattern[str] = re.compile(r"^\d{4}-\d{2}-\d{2}$")
|
|
|
|
|
|
class SupersessionError(ValueError):
|
|
"""Raised when a supersession action fails a pre-condition gate."""
|
|
|
|
|
|
def _validate_review_date(value: str) -> str:
|
|
value = (value or "").strip()
|
|
if not _REVIEW_DATE_RE.match(value):
|
|
raise SupersessionError(
|
|
f"review_date must be YYYY-MM-DD; got {value!r}"
|
|
)
|
|
return value
|
|
|
|
|
|
def _validate_chain_fields(
|
|
subject: str, intent: str, connective: str, obj: str
|
|
) -> tuple[str, str, str, str]:
|
|
s = (subject or "").strip().lower()
|
|
i = (intent or "").strip().lower()
|
|
c = (connective or "").strip()
|
|
o = (obj or "").strip().lower()
|
|
if not s or not i or not c or not o:
|
|
raise SupersessionError(
|
|
"subject/intent/connective/object are all required"
|
|
)
|
|
if i not in _VALID_INTENTS:
|
|
raise SupersessionError(
|
|
f"intent {i!r} is not in the supported whitelist "
|
|
f"({sorted(_VALID_INTENTS)})"
|
|
)
|
|
return s, i, c, o
|
|
|
|
|
|
def supersede_chain(
|
|
*,
|
|
old_chain_id: str,
|
|
subject: str,
|
|
intent: str,
|
|
connective: str,
|
|
object_: str,
|
|
review_date: str,
|
|
corpus_path: Path,
|
|
adr_id: str = "adr-0057",
|
|
operator_note: str = "",
|
|
new_chain_id: str | None = None,
|
|
) -> str:
|
|
"""Retire ``old_chain_id`` by appending a new chain that supersedes it.
|
|
|
|
Returns the ``chain_id`` of the new active entry. Raises
|
|
``SupersessionError`` on any pre-condition violation; in that
|
|
case the corpus on disk is unchanged.
|
|
|
|
Pre-conditions (run in this order — cheapest first):
|
|
1. ``review_date`` matches ``YYYY-MM-DD``.
|
|
2. New chain fields are non-empty and ``intent`` is in
|
|
``_VALID_INTENTS``.
|
|
3. ``old_chain_id`` is currently *active* in the audit report
|
|
(it must exist and not already be superseded).
|
|
4. The new chain itself passes the same audit gates that the
|
|
runtime loader applies (pack-consistency on subject/object,
|
|
non-self-supersede). This is verified by re-running
|
|
``audit_corpus`` after the append and asserting the active
|
|
set has shifted exactly as expected; on any drift the
|
|
appended line is rolled back by truncation.
|
|
|
|
Step 4 is the safety net: it makes silent introduction of a
|
|
pack-missing or otherwise invalid replacement impossible.
|
|
"""
|
|
_ = operator_note # reserved for future audit-log wiring; CLI surfaces it today
|
|
old_id = (old_chain_id or "").strip()
|
|
if not old_id:
|
|
raise SupersessionError("old_chain_id is required")
|
|
|
|
_validate_review_date(review_date)
|
|
s, i, c, o = _validate_chain_fields(subject, intent, connective, object_)
|
|
|
|
resolved_new_id = (new_chain_id or "").strip() or f"{i}_{s}_{c}_{o}"
|
|
if resolved_new_id == old_id:
|
|
raise SupersessionError(
|
|
"new chain_id is identical to old_chain_id — supersession "
|
|
"must produce a distinct active chain"
|
|
)
|
|
|
|
# -- Pre-append audit: old_chain_id must currently be active.
|
|
pre = audit_corpus(corpus_path)
|
|
active_chain_ids = {entry.chain_id for entry in pre.loaded}
|
|
if old_id not in active_chain_ids:
|
|
# Either the chain does not exist or it is already superseded.
|
|
if any(d.chain_id == old_id for d in pre.dropped):
|
|
raise SupersessionError(
|
|
f"old_chain_id {old_id!r} is already inactive "
|
|
f"(dropped by audit) — refusing to double-supersede"
|
|
)
|
|
raise SupersessionError(
|
|
f"old_chain_id {old_id!r} is not in the active corpus"
|
|
)
|
|
if resolved_new_id in active_chain_ids:
|
|
raise SupersessionError(
|
|
f"new chain_id {resolved_new_id!r} is already active; "
|
|
"choose a distinct connective/object or pass --new-chain-id"
|
|
)
|
|
|
|
chain = {
|
|
"subject": s,
|
|
"intent": i,
|
|
"connective": c,
|
|
"object": o,
|
|
}
|
|
review_date_clean = review_date.strip()
|
|
provenance = Provenance(
|
|
adr_id=adr_id,
|
|
source="hand_authored",
|
|
review_date=review_date_clean,
|
|
raw=f"{adr_id}:hand_authored:{review_date_clean}:supersede({old_id})",
|
|
)
|
|
|
|
# Snapshot bytes so we can roll back if the post-audit invariant
|
|
# is violated (defence in depth: should be impossible given the
|
|
# pre-checks, but corpus correctness is load-bearing).
|
|
bytes_before = corpus_path.read_bytes() if corpus_path.exists() else b""
|
|
|
|
written_chain_id = append_chain_to_corpus(
|
|
chain,
|
|
corpus_path=corpus_path,
|
|
provenance=provenance,
|
|
chain_id=resolved_new_id,
|
|
superseded_by=old_id,
|
|
)
|
|
|
|
# -- Post-append audit: confirm the active set shifted.
|
|
post = audit_corpus(corpus_path)
|
|
post_active = {entry.chain_id for entry in post.loaded}
|
|
expected_dropped = (
|
|
f"superseded_by:{old_id}" in {d.reason for d in post.dropped}
|
|
)
|
|
if (
|
|
written_chain_id not in post_active
|
|
or old_id in post_active
|
|
or not expected_dropped
|
|
):
|
|
# Roll back: truncate to bytes_before.
|
|
corpus_path.write_bytes(bytes_before)
|
|
raise SupersessionError(
|
|
f"post-append audit rejected the supersession "
|
|
f"(new={written_chain_id!r} active={written_chain_id in post_active}, "
|
|
f"old_still_active={old_id in post_active}); corpus rolled back"
|
|
)
|
|
|
|
return written_chain_id
|
|
|
|
|
|
__all__ = [
|
|
"SupersessionError",
|
|
"supersede_chain",
|
|
]
|