Skip to content

symfonic.agent.cutover.config_retirement

config_retirement

The 11.0 configuration retirement — one rule for twenty-five fields (TA8.26).

:mod:~symfonic.agent.cutover.retirement retired three operator levers and three per-call arguments. This module retires twenty-five configuration fields — TA8.24's cluster C2 — and it is deliberately the same shape, because "retired on 11.0" is one rule and a third spelling of it would be a third chance to disagree about the wording, the ancestry and the line.

What changes for an adopter. Today a config carrying one of these fields is merely refused by the envelope: admit_invocation is default-deny, no allowlist entry names the field, and the turn quietly runs on the legacy body. That is the absence of a decision. From this module on, the field refuses by name, saying which line retired it, which contractual group it refuses with, and either what replaced it or that nothing did.

The four alternatives retirement.py rejects for the levers are rejected here verbatim, and one of them is what the field does today:

  • Not deleted. The field stays on :class:FrameworkConfig, so an adopter who sets it meets a named error rather than the silent drop _warn_unknown_fields performs for an unknown key.
  • Not ignored. A field that changes nothing while the caller believes it did is the failure this task exists to end.
  • Not silently redirected. Serving the turn on legacy because the field was not admitted, without saying so, is the status quo wearing a route.
  • Not accepted-then-no-op.

Before dispatch, never from the verdict. The guard runs at the top of run/stream/stream_typed, beside :func:~symfonic.agent.engine._refuse_retired_arguments and for the same three reasons. stream_typed consults no envelope at all, so a verdict-driven refusal would honour a retired field there and refuse it on run — a route-conditional retirement. stream skips the verdict entirely on a line that predates the text-delta chunk contract, which is exactly the seam retirement.py says a retirement must not be keyed on. And a rolled-back switch would re-honour a retired field, which is the "silently redirected" outcome. There is no release-line branch anywhere below: 9.12 and 10.4 keep their behaviour in their own artefacts, and the compatibility window is kept by not backporting this module.

Emptiness is not use. :meth:RetiredSetting.is_use answers one question — "did the caller ask for this behaviour?" — and answers it in one place, so the guard and any future reader cannot drift. A field at its stock default, or set to a value :data:~symfonic.agent.cutover.settings_contract.INACTIVE_CLASSES calls inactive, is what a caller who asks for nothing passes.

RetiredConfigurationError

RetiredConfigurationError(setting: str, entry_point: str, group: str, message: str)

Bases: CutoverPathError

A configuration field retired on the 11.0 line was set.

Subclasses :class:~symfonic.services.shadow.errors.CutoverPathError, and through it ConfigurationError and SymfonicError, for the reason :class:~symfonic.agent.cutover.retirement.RetiredArgumentError gives for its own ancestry: an adopter's existing except around the agent API keeps catching this. A widening, never a rename.

Named separately from the lever and argument errors rather than folded into either. The reachability probe's validity predicate keys on the lever vocabulary and the envelope's guard loop keys on the argument one; a configuration field in either table would make one vocabulary carry two kinds of fact. Three exact classifiers beat one loose one.

Source code in src/symfonic/agent/cutover/config_retirement.py
def __init__(
    self, setting: str, entry_point: str, group: str, message: str
) -> None:
    super().__init__(message)
    #: Which retired field was set, spelled as ``FrameworkConfig`` spells it.
    self.setting = setting
    #: The entry point it reached (``run`` / ``stream`` / ``stream_typed``),
    #: so a handler can tell a refused blocking turn from a refused stream
    #: without parsing prose. Every entry point refuses; the attribute
    #: records which one was asked, not which ones would have refused.
    self.entry_point = entry_point
    #: The contractual group the field refuses with. Named on the exception
    #: because "this whole family of legacy consolidation dials is gone" is
    #: a different piece of news from "this one field is gone", and an
    #: adopter migrating twelve settings should be told once.
    self.group = group
    #: The line that retired it. Read from
    #: :data:`~symfonic.agent.cutover.retirement.LEVER_RETIREMENT_LINE`
    #: rather than re-spelled, so the attribute and the message cannot
    #: disagree with the levers or the arguments.
    self.line = LEVER_RETIREMENT_LINE

declared_inactive

declared_inactive(setting: str, value: Any) -> bool

True when the row's own inactivity class calls value inactive.

Narrower than :func:is_inactive, which also answers True for the stock default. Kept separate so "the default does not refuse" and "an inactive value does not refuse" stay two claims rather than one.

Source code in src/symfonic/agent/cutover/config_retirement.py
def declared_inactive(setting: str, value: Any) -> bool:
    """``True`` when the row's own inactivity class calls ``value`` inactive.

    Narrower than :func:`is_inactive`, which also answers ``True`` for the
    stock default. Kept separate so "the default does not refuse" and "an
    inactive value does not refuse" stay two claims rather than one.
    """
    return bool(INACTIVE_CLASSES[RETIRED_SETTINGS[setting].inactive](value))

is_inactive

is_inactive(setting: str, value: Any) -> bool

True when value asks for nothing on setting.

The complement of :meth:RetiredSetting.is_use, exported so the contract "emptiness is not use" can be asserted directly rather than inferred from a refusal that did not happen -- which is also what a broken guard produces.

Source code in src/symfonic/agent/cutover/config_retirement.py
def is_inactive(setting: str, value: Any) -> bool:
    """``True`` when ``value`` asks for nothing on ``setting``.

    The complement of :meth:`RetiredSetting.is_use`, exported so the contract
    "emptiness is not use" can be asserted directly rather than inferred from a
    refusal that did not happen -- which is also what a broken guard produces.
    """
    retired = RETIRED_SETTINGS[setting]
    return not retired.is_use(value)

refuse_retired_setting

refuse_retired_setting(setting: str, *, entry_point: str, value: Any) -> NoReturn

Refuse setting on entry_point, citing the line that retired it.

There is no detail parameter, and its absence is the guarantee :func:~symfonic.agent.cutover.retirement.refuse_retired_lever buys by making one required: the next step comes from :data:~symfonic.agent.cutover.retired_settings.RETIRED_SETTINGS, so every refusal carries one and no call site can forget to pass it.

Source code in src/symfonic/agent/cutover/config_retirement.py
def refuse_retired_setting(setting: str, *, entry_point: str, value: Any) -> NoReturn:
    """Refuse ``setting`` on ``entry_point``, citing the line that retired it.

    There is no ``detail`` parameter, and its absence is the guarantee
    :func:`~symfonic.agent.cutover.retirement.refuse_retired_lever` buys by
    making one required: the next step comes from
    :data:`~symfonic.agent.cutover.retired_settings.RETIRED_SETTINGS`, so every
    refusal carries one and no call site can forget to pass it.
    """
    retired = RETIRED_SETTINGS[setting]
    raise RetiredConfigurationError(
        setting,
        entry_point,
        retired.group,
        f"the {setting} setting ({setting_call(setting, value)}) was retired "
        f"on the {LEVER_RETIREMENT_LINE} line, and {entry_point}() was asked "
        f"for it. It is retired as part of the {retired.group!r} group, which "
        f"{RETIREMENT_GROUPS[retired.group]}. {retired.instead}",
    )

retired_setting_supplied

retired_setting_supplied(config: Any) -> str | None

The first retired field config actually uses, in table order, or None.

The single place that decides "was a retired configuration field used?", so every entry point answers it identically.

A field the object does not carry at all is skipped rather than refused. cutover reflects over whatever config object it is handed — the envelope's whole design — and an object without the attribute cannot be asking for the behaviour. That is the one place this guard does not fail closed, and it is bounded by the coverage test: every name in the table is a live FrameworkConfig field, so a skip means a foreign object, never a renamed field.

Source code in src/symfonic/agent/cutover/config_retirement.py
def retired_setting_supplied(config: Any) -> str | None:
    """The first retired field ``config`` actually uses, in table order, or ``None``.

    The single place that decides "was a retired configuration field used?", so
    every entry point answers it identically.

    A field the object does not carry at all is skipped rather than refused.
    ``cutover`` reflects over whatever config object it is handed — the
    envelope's whole design — and an object without the attribute cannot be
    asking for the behaviour. That is the one place this guard does not fail
    closed, and it is bounded by the coverage test: every name in the table is
    a live ``FrameworkConfig`` field, so a skip means a foreign object, never a
    renamed field.
    """
    for setting, retired in RETIRED_SETTINGS.items():
        value = getattr(config, setting, MISSING)
        if value is MISSING:
            continue
        if retired.is_use(value):
            return setting
    return None

setting_call

setting_call(setting: str, value: Any) -> str

The construction the adopter wrote, quoted back at them.

Derived rather than stored beside each row. :data:~symfonic.agent.cutover.retirement.RETIRED_ARGUMENTS stores its call because three arguments have three different shapes; twenty-five configuration fields have one, and writing it out twenty-five times would be twenty-five chances to quote a field under a name it does not have.

Source code in src/symfonic/agent/cutover/config_retirement.py
def setting_call(setting: str, value: Any) -> str:
    """The construction the adopter wrote, quoted back at them.

    Derived rather than stored beside each row.
    :data:`~symfonic.agent.cutover.retirement.RETIRED_ARGUMENTS` stores its
    ``call`` because three arguments have three different shapes; twenty-five
    configuration fields have one, and writing it out twenty-five times would
    be twenty-five chances to quote a field under a name it does not have.
    """
    return f"FrameworkConfig({setting}={brief(value)})"