Skip to content

symfonic.agent.cutover.retirement

retirement

The 11.0 retirement — what a caller may no longer ask for (TA8.5, TA8.18).

TA8.4 measured which supported configurations still enter a legacy body and handed this task a list. Three of the four levers on that list are retired here, and the word retired is doing exact work: the call still exists, it still takes the arguments it always took, and it refuses, by name, saying which line retired it and which capability was asked for.

That shape is the whole point, and each alternative to it was rejected for a reason worth stating:

  • Not deleted. An operator who types the 03:00 procedure into a REPL and gets AttributeError learns that something changed, not what. The lever answers for itself.
  • Not ignored. A call that returns None and moves nothing is the worst of the four: the operator watches the incident continue while believing the capability is back on legacy.
  • Not silently redirected. Serving the turn on the kernel because the rollback was refused, without saying so, is the same failure wearing a route.
  • Not accepted-then-no-op. Recording the reason and never reading it again would leave rollback_reason answering truthfully about a route that never moved.

So: an explicit refusal, every time, naming the line and the capability.

Levers and arguments, one vocabulary. TA8.5 retired three operator levers; TA8.18 retires three per-call argumentscallbacks, extra_metadata and state_overrides. They are different kinds of fact and they get different types (:class:RetiredLeverError and :class:RetiredArgumentError) so a handler and the reachability probe can tell them apart, but they share this module, this line constant and this shape of refusal, because "retired on 11.0" is one rule and three spellings of it would be three rules. The four bullets above apply to the arguments verbatim: an argument is not deleted from the signature, not ignored, not silently redirected onto another surface, and never accepted-then-no-op.

Which "11.0" this is. :data:LEVER_RETIREMENT_LINE is the source line — the release this checkout becomes — not a reading of the installed distribution. The distinction matters because the reachability probe simulates older release lines by patching stream_contract.installed_release_line, and a refusal keyed on that seam would answer "the levers are still here" for every simulated 10.4 row. The levers are gone from this code; an install on a line at or below that bound runs its own code, which still has them -- today that means 9.12, the newest such release actually published. That is what the compatibility window promises and it is kept by not backporting this module, never by a runtime branch that re-honours a retired lever or a retired argument. That is also why the refusals below are unconditional: a check that re-admitted callbacks when the install reported an older line would honour a retired argument in 11.0 code, which is the one thing the window does not promise.

The marker mirrors services.switching.release.FIRST_REJECTING_RETIREMENT_VERSION — the normative name from T1.2.6 — and is mirrored rather than imported for the same layering reason stream_contract states: the facade may only reach a runtime service through a declared port. tests/agent/cutover/test_lever_retirement.py fails if the two strings ever disagree.

RetiredArgument dataclass

RetiredArgument(call: str, instead: str)

One retired per-call argument: what was written, and what replaces it.

Two fields rather than one string because the refusal owes two different things. call is the call the adopter made -- quoted back so the traceback names the thing they typed. instead is the next step, and it lives here rather than being supplied at the raise site on purpose: refuse_retired_lever makes detail a required parameter so that a refusal can never be a dead end, and a table that carries the next step keeps the same guarantee without asking every call site to remember it. The obligation becomes structural instead of conventional.

RetiredArgumentError

RetiredArgumentError(argument: str, entry_point: str, message: str)

Bases: CutoverPathError

A per-call argument retired on the 11.0 line was supplied.

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

Named separately from :class:RetiredLeverError rather than folded into it. The reachability probe's validity predicate keys on the lever vocabulary, and RETIRED_LEVERS' keys are exactly the lever names that probe drives; a per-call argument in that table would make one vocabulary carry two different kinds of fact. Two exact classifiers beat one loose one.

Source code in src/symfonic/agent/cutover/retirement.py
def __init__(self, argument: str, entry_point: str, message: str) -> None:
    super().__init__(message)
    #: Which retired argument was supplied.
    self.argument = argument
    #: The entry point it was supplied to (``run`` / ``stream`` /
    #: ``stream_typed``), so a handler can tell a refused blocking turn
    #: from a refused stream without parsing prose.
    self.entry_point = entry_point
    #: The line that retired it. Read from :data:`LEVER_RETIREMENT_LINE`
    #: rather than re-spelled, so the attribute and the message cannot
    #: disagree.
    self.line = LEVER_RETIREMENT_LINE

RetiredLeverError

RetiredLeverError(lever: str, capability: str, message: str)

Bases: CutoverPathError

A lever retired on the 11.0 line was pulled.

Subclasses :class:~symfonic.services.shadow.errors.CutoverPathError, and through it ConfigurationError and SymfonicError, so an adopter's existing except around the cutover API still catches this — a widening of the ancestry, never a rename. The type is named all the same, because "the lever is gone" and "the evidence was filed under a path this capability may not use" are different incidents and an operator triaging at 03:00 should not have to read the message to tell them apart.

Source code in src/symfonic/agent/cutover/retirement.py
def __init__(self, lever: str, capability: str, message: str) -> None:
    super().__init__(message)
    #: Which retired lever was pulled, in the probe's vocabulary.
    self.lever = lever
    #: The capability the caller asked for. Named on the exception as well
    #: as in the message so a handler can act without parsing prose.
    self.capability = capability

refuse_retired_argument

refuse_retired_argument(argument: str, *, entry_point: str, received: Iterable[str] = ()) -> NoReturn

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

received names the keywords that actually arrived. It matters for state_overrides, which is an open **kwargs map rather than a named parameter: without it the refusal would say "state_overrides" to an adopter who typed sesion_id= and never wrote that word.

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

Source code in src/symfonic/agent/cutover/retirement.py
def refuse_retired_argument(
    argument: str,
    *,
    entry_point: str,
    received: Iterable[str] = (),
) -> NoReturn:
    """Refuse ``argument`` on ``entry_point``, citing the line that retired it.

    ``received`` names the keywords that actually arrived. It matters for
    ``state_overrides``, which is an open ``**kwargs`` map rather than a named
    parameter: without it the refusal would say "state_overrides" to an adopter
    who typed ``sesion_id=`` and never wrote that word.

    There is no ``detail`` parameter, and its absence is the same guarantee
    :func:`refuse_retired_lever` buys by making one required: the next step
    comes from :data:`RETIRED_ARGUMENTS`, so every refusal carries one and no
    call site can forget to pass it.
    """
    retired = RETIRED_ARGUMENTS[argument]
    names = ", ".join(repr(name) for name in received)
    supplied = f" (keyword(s) {names})" if names else ""
    raise RetiredArgumentError(
        argument,
        entry_point,
        f"the {argument} argument ({retired.call}) was retired on the "
        f"{LEVER_RETIREMENT_LINE} line, and {entry_point}() was asked for it"
        f"{supplied}. {retired.instead}",
    )

refuse_retired_lever

refuse_retired_lever(lever: str, capability: str, *, detail: str) -> NoReturn

Refuse lever for capability, citing the line that retired it.

detail says what the caller can do instead. It is required rather than optional: a refusal that names the line and stops is a dead end, and the operator pulling a rollback lever at 03:00 is the last person who should have to go read a migration document to find the next step.

Source code in src/symfonic/agent/cutover/retirement.py
def refuse_retired_lever(lever: str, capability: str, *, detail: str) -> NoReturn:
    """Refuse ``lever`` for ``capability``, citing the line that retired it.

    ``detail`` says what the caller can do instead. It is required rather than
    optional: a refusal that names the line and stops is a dead end, and the
    operator pulling a rollback lever at 03:00 is the last person who should
    have to go read a migration document to find the next step.
    """
    call = RETIRED_LEVERS[lever]
    raise RetiredLeverError(
        lever,
        capability,
        f"the {lever} lever ({call}) was retired on the "
        f"{LEVER_RETIREMENT_LINE} line, and {capability!r} is the capability "
        f"it was asked for. {detail}",
    )

retired_argument_supplied

retired_argument_supplied(**supplied: Any) -> str | None

The first retired argument actually supplied, in table order, or None.

The single place that decides "was a retired argument used?", so the guard loop's answer and the entry points' answer cannot drift. Emptiness is not use: callbacks=None and callbacks=[] are what an adopter who attaches nothing passes, and refusing them would retire the parameter rather than the behaviour.

An unknown keyword raises rather than being skipped. A silent skip would turn a misspelling here into a guard that quietly stopped guarding.

Source code in src/symfonic/agent/cutover/retirement.py
def retired_argument_supplied(**supplied: Any) -> str | None:
    """The first retired argument actually supplied, in table order, or ``None``.

    The single place that decides "was a retired argument used?", so the guard
    loop's answer and the entry points' answer cannot drift. Emptiness is not
    use: ``callbacks=None`` and ``callbacks=[]`` are what an adopter who
    attaches nothing passes, and refusing them would retire the *parameter*
    rather than the behaviour.

    An unknown keyword raises rather than being skipped. A silent skip would
    turn a misspelling here into a guard that quietly stopped guarding.
    """
    unknown = sorted(set(supplied) - set(RETIRED_ARGUMENTS))
    if unknown:
        raise KeyError(
            f"{', '.join(unknown)} is not a retired argument; "
            f"RETIRED_ARGUMENTS carries {', '.join(RETIRED_ARGUMENTS)}"
        )
    return next((name for name in RETIRED_ARGUMENTS if supplied.get(name)), None)