Skip to content

symfonic.agent.cutover.continuation_redemption_removed

continuation_redemption_removed

What a redemption says when the body it routes into is no longer there (TA8.58).

TA8.57 rehearsed TA9.2's deletion and measured the two redemptions that survive it badly. A pause the continuation router sends to legacy -- an engine-minted HMAC token, or a deployment the migration window admitted -- reaches self._legacy_continuation_impl and, on a tree where that body is gone, the caller is handed::

AttributeError: 'SymfonicAgent' object has no attribute '_legacy_continuation_impl'

That is a capability loss wearing a stack trace. TA8.42 refused a silent capability loss for ask_user_enabled; an internal attribute error leaking to an adopter is the same defect, and the window case is worse in kind, because the framework has already told that adopter -- in a ContinuationWindowWarning it emitted on the way in -- that the redemption was admitted.

This module is the answer that replaces the AttributeError. It is the refusal TA9.2 raises where the fallback branch used to call the deleted body, and it is shipped ahead of the deletion so the wording, the class and the distinguishability can be driven before anything is removed. What the refusal says belongs to :mod:~symfonic.agent.cutover.continuation_removal_refusals; this module owns the failure itself.

It is a fourth answer, and it stays apart from the other three. HK2 made an expired token, a missing checkpoint and a refused axis three separate answers so a caller could decide between re-asking and escalating. A removed redemption path is none of those: the token may be perfectly live, the checkpoint may be sitting in the store, and no axis was ever checked because nothing was redeemed. The class is a :class:~symfonic.services.shadow.ports.CutoverPathError -- the retirement vocabulary, catchable by an adopter's existing except around the agent API -- and deliberately not a :class:~symfonic.capabilities.human.errors.HumanInteractionError, so the separation holds by isinstance and not only by prose.

This module raises nothing on its own. No dispatch site calls it yet; wiring it is TA9.2's, at the same moment it removes the body. TA8.58 moves no release line, claims no RET row and moves no gate. The conflict that produces this failure is characterised, with its three exits, in RET-PREP/window-retirement-conflict.md.

ContinuationRedemptionRemovedError

ContinuationRedemptionRemovedError(*, entry_point: str, token_fingerprint: str, mint: str, setting: str | None = None, admitted_under_window: bool = False, message: str | None = None)

Bases: CutoverPathError

The route this pause takes has no body on this release.

Ancestry is :class:~symfonic.services.shadow.ports.CutoverPathError, the same one :class:~symfonic.agent.cutover.config_retirement.RetiredConfigurationError carries, for the same reason: an adopter's existing except around the agent API keeps catching it. A widening, never a rename -- and never a HumanInteractionError, which is the family HK2's three answers live in.

Every attribute is a fact a handler would otherwise have to parse out of prose, and the two line attributes are read from the release constants so the exception and the message cannot disagree with them.

Source code in src/symfonic/agent/cutover/continuation_redemption_removed.py
def __init__(
    self,
    *,
    entry_point: str,
    token_fingerprint: str,
    mint: str,
    setting: str | None = None,
    admitted_under_window: bool = False,
    message: str | None = None,
) -> None:
    super().__init__(
        message
        if message is not None
        else removal_message(
            entry_point=entry_point,
            fingerprint=token_fingerprint,
            mint=mint,
            setting=setting,
            admitted_under_window=admitted_under_window,
        )
    )
    #: Which public door was asked (``resume`` / ``resume_interrupt``).
    self.entry_point = entry_point
    #: The pause's short identity. Never the token.
    self.token_fingerprint = token_fingerprint
    #: What this build could say about when the pause was minted.
    self.mint = mint
    #: The retired setting that put this redemption in the window, when one
    #: did; ``None`` for a pause routed to legacy purely by recognition.
    self.setting = setting
    #: Whether the window had already announced this redemption as admitted.
    self.admitted_under_window = admitted_under_window
    #: The line that removed the body.
    self.removed_on_line = LEVER_RETIREMENT_LINE
    #: The line that deletes the window. Equal to :attr:`removed_on_line`
    #: only once somebody settles the conflict this class exists to report.
    self.window_release_line = WINDOW_RELEASE_LINE

refuse_removed_redemption

refuse_removed_redemption(*, entry_point: str, pause_token: str, capability: Any = None, setting: str | None = None, admitted_under_window: bool = False) -> NoReturn

Refuse a redemption whose body is gone, naming all four things.

The provenance is read through :func:~symfonic.agent.cutover.continuation_window.mint_provenance, the same reader the window's own decision uses, so "what your token was" in this message and "what the window thought your token was" cannot be two answers.

Source code in src/symfonic/agent/cutover/continuation_redemption_removed.py
def refuse_removed_redemption(
    *,
    entry_point: str,
    pause_token: str,
    capability: Any = None,
    setting: str | None = None,
    admitted_under_window: bool = False,
) -> NoReturn:
    """Refuse a redemption whose body is gone, naming all four things.

    The provenance is read through
    :func:`~symfonic.agent.cutover.continuation_window.mint_provenance`, the
    same reader the window's own decision uses, so "what your token was" in this
    message and "what the window thought your token was" cannot be two answers.
    """
    raise ContinuationRedemptionRemovedError(
        entry_point=entry_point,
        token_fingerprint=token_fingerprint(pause_token),
        mint=describe_mint(mint_provenance(capability, pause_token)),
        setting=setting,
        admitted_under_window=admitted_under_window,
    )

removed_redemption async

removed_redemption(*, entry_point: str, pause_token: str, capability: Any = None, setting: str | None = None, admitted_under_window: bool = False) -> AsyncIterator[Any]

The refusal in the shape the dispatch site expects: an event stream.

Both continuation doors build a body and then iterate it inside contextlib.aclosing. A drop-in replacement for the deleted call has to be closeable and has to raise from the iteration, so the failure arrives where the caller is already reading events rather than from an expression the caller never sees. It yields nothing before it raises: no partial answer, no half-written session.

Source code in src/symfonic/agent/cutover/continuation_redemption_removed.py
async def removed_redemption(
    *,
    entry_point: str,
    pause_token: str,
    capability: Any = None,
    setting: str | None = None,
    admitted_under_window: bool = False,
) -> AsyncIterator[Any]:
    """The refusal in the shape the dispatch site expects: an event stream.

    Both continuation doors build a body and then iterate it inside
    ``contextlib.aclosing``. A drop-in replacement for the deleted call has to
    be closeable and has to raise *from the iteration*, so the failure arrives
    where the caller is already reading events rather than from an expression
    the caller never sees. It yields nothing before it raises: no partial
    answer, no half-written session.
    """
    refuse_removed_redemption(
        entry_point=entry_point,
        pause_token=pause_token,
        capability=capability,
        setting=setting,
        admitted_under_window=admitted_under_window,
    )
    yield  # pragma: no cover - unreachable; makes this an async generator