fix(settings): the retirement notice names the successor the table states

For any dropped set without comma-list keys the first-boot notice said "no
replacement setting: what they used to configure is fixed behavior in this
release". That is false for a retired key the retirement table gives a
successor: the flat wall-clock pair was superseded by the activity model, and
an owner told there is no replacement stops looking for the setting that took
over.

RETIRED_SETTING_SUCCESSORS is the decision, recorded next to the retirement it
explains, as RETIRED_COMMA_LIST_SETTING_KEYS already is. The notice composes one
clause per classification, so a mixed set no longer gets one sentence that is
wrong for half of it, and a key the table gives no successor gets the neutral
clause instead of either claim. A key the seam MIGRATES stays out of the map:
the acceptance pass count is consumed into the shared review-cycle cap before
the purge computes the dropped set, so it can never reach this notice.

Red-first: both new pins fail against the verbatim pre-fix config.py, which
answers the wall-clock pair with the "fixed behavior" sentence and gives the
neutral shape no wording of its own. The successor-shape pin derives its
document from the table rather than spelling the retired keys, keeping the D04
grep gate tight.
This commit is contained in:
Ouroboros 2026-09-02 16:26:07 +00:00
parent 64c02a7a6c
commit ee0acd2a77
4 changed files with 138 additions and 11 deletions

View file

@ -34,6 +34,7 @@ from ouroboros.settings_defaults import (
PACING_INTERVAL_DEFAULT_SEC, # noqa: F401
RETIRED_COMMA_LIST_SETTING_KEYS, # noqa: F401
RETIRED_SETTING_KEYS, # noqa: F401
RETIRED_SETTING_SUCCESSORS, # noqa: F401
SETTINGS_DEFAULTS, # noqa: F401
SETTINGS_KEYS_NOT_EXPORTED_TO_ENV, # noqa: F401
SUPERVISOR_LIVENESS_DEADLINE_DEFAULT_SEC, # noqa: F401
@ -748,19 +749,22 @@ def normalize_settings_raw(raw: dict) -> dict:
loaded.pop(_retired, None)
if dropped and dropped not in _RETIREMENT_NOTICE_SEEN:
_RETIREMENT_NOTICE_SEEN.add(dropped)
comma = [key for key in dropped if key in RETIRED_COMMA_LIST_SETTING_KEYS]
comma = [k for k in dropped if k in RETIRED_COMMA_LIST_SETTING_KEYS]
clauses = []
if comma:
replacement = (
clauses.append(
"the reviewer comma-lists (%s) are replaced by the structured "
"OUROBOROS_REVIEWER_SLOTS, so this install now runs the SHIPPED "
"default reviewer panel until that setting is authored"
% ", ".join(comma)
)
else:
replacement = (
"no replacement setting: what they used to configure is fixed "
"behavior in this release"
)
"OUROBOROS_REVIEWER_SLOTS, so this install now runs the SHIPPED default "
"reviewer panel until that setting is authored" % ", ".join(comma))
if named := [k for k in dropped if k in RETIRED_SETTING_SUCCESSORS]:
clauses.append("the retirement table names a successor setting: %s" % "; ".join(
"%s -> %s" % (k, ", ".join(RETIRED_SETTING_SUCCESSORS[k])) for k in named))
if rest := [k for k in dropped if k not in comma and k not in named]:
clauses.append(
"the retirement table names no successor setting for %s: they are "
"removed, not honored — see the release notes for the surface that "
"replaced them" % ", ".join(rest))
replacement = "; ".join(clauses)
log.warning(
"settings: retired key(s) %s are present in the settings document and "
"are NOT honored; %s",

View file

@ -364,6 +364,30 @@ RETIRED_COMMA_LIST_SETTING_KEYS: tuple[str, ...] = (
)
# The second classification INSIDE RETIRED_SETTING_KEYS: retired keys whose
# SUCCESSOR SETTING this retirement table states, so the first-boot notice can
# name it instead of telling the owner there is none. Membership is a decision
# recorded HERE, next to the retirement it explains — a retired key is absent
# from this map when the table names no successor for it (the knob's effect
# became fixed behavior, or the replacement is a surface rather than a setting),
# and the notice then stays neutral instead of claiming either. The pair below
# is stated twice over: by the comment above the keys in the tuple, and by the
# ABI-5/D04 rows in docs/ARCHITECTURE.md.
#
# A key whose value the read seam MIGRATES does not belong here even though its
# successor is named: `OUROBOROS_ACCEPTANCE_MAX_IMPROVEMENT_PASSES` is consumed
# into `OUROBOROS_REVIEW_MAX_CYCLES` before the purge computes the dropped set,
# so it never reaches the notice — there is no loss to report, and an entry for
# it would promise a line nothing emits.
RETIRED_SETTING_SUCCESSORS: dict[str, tuple[str, ...]] = {
# The flat wall-clock pair was superseded by the activity model.
"OUROBOROS_SOFT_TIMEOUT_SEC": (
"OUROBOROS_TASK_IDLE_TIMEOUT_SEC", "OUROBOROS_TASK_ABS_CEILING_SEC"),
"OUROBOROS_HARD_TIMEOUT_SEC": (
"OUROBOROS_TASK_IDLE_TIMEOUT_SEC", "OUROBOROS_TASK_ABS_CEILING_SEC"),
}
# The same keys from the other side: load_settings overlays env onto disk-ABSENT keys, so without this an
# ordinary load->save round-trip in a process whose env says low/off would launder that value onto disk
# unauthorised — or, once the guard reads disk, raise a PermissionError nobody authored. Owner endpoints

View file

@ -33,6 +33,10 @@ _MOVED_OWNERS = {
# ABI 7.0 (ABI-7b): the comma-list classification INSIDE the retirement
# SSOT, born in this leaf (not an extraction) for the RC auditor to snap.
"RETIRED_COMMA_LIST_SETTING_KEYS": settings_defaults,
# The stage-2 close-out's second classification inside that same SSOT: the
# retired keys whose successor SETTING the table states, so the first-boot
# notice can name it instead of claiming there is none.
"RETIRED_SETTING_SUCCESSORS": settings_defaults,
"SETTINGS_DEFAULTS": settings_defaults,
"SETTINGS_KEYS_NOT_EXPORTED_TO_ENV": settings_defaults,
"SUPERVISOR_LIVENESS_DEADLINE_DEFAULT_SEC": settings_defaults,

View file

@ -773,6 +773,101 @@ def test_a_retired_comma_list_triad_is_not_dropped_silently(isolated_settings, c
assert "shipped" in notices[0].lower()
def test_the_retirement_notice_names_the_successor_the_table_states(
isolated_settings, caplog,
):
"""The FIRST of the notice's two non-comma shapes. Any dropped set without
comma-list keys used to get one fixed sentence: "no replacement setting:
what they used to configure is fixed behavior in this release". That is
false for a retired key this retirement table gives a successor — the flat
wall-clock pair was superseded by the activity model, and the acceptance
pass count is migrated into the shared review-cycle cap — so an owner told
there is no replacement stops looking for the setting that took over.
RETIRED_SETTING_SUCCESSORS is the decision table the notice reads, and a key
absent from it gets the neutral clause instead (sibling test), never an
invented successor.
"""
import logging
from ouroboros import config as cfg
from ouroboros.settings_defaults import (
RETIRED_SETTING_KEYS,
RETIRED_SETTING_SUCCESSORS,
)
# The map classifies INSIDE the retirement tuple: a successor for a key that
# is not retired would name a migration nothing performs.
assert set(RETIRED_SETTING_SUCCESSORS) <= set(RETIRED_SETTING_KEYS)
# And a MIGRATED key is not a reportable loss: the acceptance pass count is
# consumed into the shared cap before the purge computes the dropped set, so
# an entry for it would promise a notice line nothing can emit.
assert "OUROBOROS_ACCEPTANCE_MAX_IMPROVEMENT_PASSES" not in RETIRED_SETTING_SUCCESSORS
# The document is DERIVED from the table, not spelled out: the D04 grep gate
# (tests/test_legacy_timeout_retirement.py) lets the retired wall-clock pair
# appear only in the retirement SSOT and its own audits, and this pin is
# about the CLASS "a retired key whose successor the table states", not about
# one key's spelling.
successor_bearing = sorted(RETIRED_SETTING_SUCCESSORS)
assert successor_bearing, "the notice's successor shape needs a member"
document = dict.fromkeys(successor_bearing, 900)
document["OUROBOROS_ACCEPTANCE_MAX_IMPROVEMENT_PASSES"] = 3
document["TOTAL_BUDGET"] = 10.0
cfg._RETIREMENT_NOTICE_SEEN.clear()
with caplog.at_level(logging.WARNING, logger="ouroboros.config"):
loaded = cfg.normalize_settings_raw(document)
for key in successor_bearing:
assert key not in loaded, key
assert loaded["TOTAL_BUDGET"] == 10.0
# Migrated, hence unreported: cycles = passes + 1, and the notice is silent.
assert loaded["OUROBOROS_REVIEW_MAX_CYCLES"] == "4"
notices = [r.getMessage() for r in caplog.records if "retired" in r.getMessage()]
assert len(notices) == 1, notices
assert "OUROBOROS_ACCEPTANCE_MAX_IMPROVEMENT_PASSES" not in notices[0]
for key in successor_bearing:
for successor in RETIRED_SETTING_SUCCESSORS[key]:
assert successor in notices[0], successor
assert "fixed behavior" not in notices[0]
def test_the_retirement_notice_invents_no_successor_when_the_table_states_none(
isolated_settings, caplog,
):
"""The SECOND shape, and the reason the two are separate clauses rather than
one sentence about the whole dropped set. The observability retention knob
really has no successor setting (manifests and blobs are preserved
indefinitely by contract) and the plan-task swarm timeouts have none this
table states per key, so the notice says they are gone and points at the
release notes — without promising a replacement key, and without the older
claim that their effect is now fixed behavior, which for the swarm timeouts
was never established.
"""
import logging
from ouroboros import config as cfg
from ouroboros.settings_defaults import RETIRED_SETTING_SUCCESSORS
cfg._RETIREMENT_NOTICE_SEEN.clear()
with caplog.at_level(logging.WARNING, logger="ouroboros.config"):
cfg.normalize_settings_raw({
"OUROBOROS_OBSERVABILITY_RETENTION_DAYS": 30,
"OUROBOROS_PLAN_TASK_SWARM_TIMEOUT_SEC": 60,
})
notices = [r.getMessage() for r in caplog.records if "retired" in r.getMessage()]
assert len(notices) == 1, notices
assert "OUROBOROS_OBSERVABILITY_RETENTION_DAYS" in notices[0]
assert "OUROBOROS_PLAN_TASK_SWARM_TIMEOUT_SEC" in notices[0]
assert "removed, not honored" in notices[0]
assert "release notes" in notices[0]
assert "fixed behavior" not in notices[0]
for successors in RETIRED_SETTING_SUCCESSORS.values():
for successor in successors:
assert successor not in notices[0], successor
def test_the_retirement_notice_stays_quiet_for_a_document_without_ghosts(
isolated_settings, caplog,
):