Skip to content

symfonic.agent.cutover.continuation_window_refusals

continuation_window_refusals

What a migration-window decision says to the adopter reading it (TA8.48).

Split from :mod:~symfonic.agent.cutover.continuation_window the way lifecycle_refusals is split from lifecycle_contract: that module decides whether a pause is inside the window, this one decides what the answer says. Two questions, and the second is long enough that folding it into the first would bury the rule under three paragraphs of message text.

The refusal is the guard's own. The class, the setting attribute, the group and the retirement line are the three fresh doors' -- "a configuration refused by name on run is refused by name on all four" is a claim about one refusal rather than about four similar ones -- and what a redemption adds over a fresh turn is appended rather than substituted.

It is actionable, and it is distinguishable. HK2 required three answers at a redemption to stay apart: an expired token, a missing checkpoint, and everything else. A configuration refusal is the "everything else", and it says so in the message rather than leaving an adopter to infer it from a class name they have not met before. Each refusing disposition then adds its own sentence: an adopter told "minted after the guards landed" about a token that was never dated would go looking for the wrong thing.

An admitted redemption is announced. A turn that quietly keeps working on a retired setting is how an adopter reaches the horizon unprepared, so the window says what it did, which setting it did it for, and the date it stops.

ContinuationWindowWarning

Bases: DeprecationWarning

A redemption the migration window admitted, and the date it stops.

A DeprecationWarning rather than a log line alone because the audience is the adopter running the upgrade, not the operator reading a dashboard, and because -W error is how a deployment can opt into finding these at test time instead of at the horizon.

announce

announce(refusal: RetiredConfigurationError) -> ContinuationWindowWarning

What an admitted-under-the-window redemption says on its way through.

Source code in src/symfonic/agent/cutover/continuation_window_refusals.py
def announce(refusal: RetiredConfigurationError) -> ContinuationWindowWarning:
    """What an admitted-under-the-window redemption says on its way through."""
    return ContinuationWindowWarning(
        f"{refusal} This redemption was admitted anyway, under the migration "
        f"window for pauses minted before the guards landed. {WINDOW_BOUND} "
        f"Migrate off {refusal.setting} before then: after the horizon this "
        "pause, and every pause like it, is refused."
    )

restated

restated(refusal: RetiredConfigurationError, disposition: str, provenance: MintProvenance) -> BaseException

The guard's own error, re-raised with the window paragraph appended.

A subclass that ever stopped taking the four-argument shape falls back to the original error rather than losing it: a refusal that cannot be reworded is still a refusal, and dropping it to raise a TypeError from the rewording would turn a named refusal into a bug report.

Source code in src/symfonic/agent/cutover/continuation_window_refusals.py
def restated(
    refusal: RetiredConfigurationError,
    disposition: str,
    provenance: MintProvenance,
) -> BaseException:
    """The guard's own error, re-raised with the window paragraph appended.

    A subclass that ever stopped taking the four-argument shape falls back to
    the original error rather than losing it: a refusal that cannot be reworded
    is still a refusal, and dropping it to raise a ``TypeError`` from the
    rewording would turn a named refusal into a bug report.
    """
    message = f"{refusal} {NEIGHBOURING_FAILURES} {why(disposition, provenance)}"
    try:
        return type(refusal)(
            refusal.setting, refusal.entry_point, refusal.group, message
        )
    except Exception:  # noqa: BLE001 - never lose a refusal to a constructor
        return refusal

why

why(disposition: str, provenance: MintProvenance) -> str

The one sentence that separates this refusing case from the other two.

Source code in src/symfonic/agent/cutover/continuation_window_refusals.py
def why(disposition: str, provenance: MintProvenance) -> str:
    """The one sentence that separates this refusing case from the other two."""
    minted = (
        datetime.fromtimestamp(provenance.issued_at, tz=UTC).isoformat()
        if provenance.issued_at
        else "an unstated time"
    )
    closes = datetime.fromtimestamp(WINDOW_CLOSES_AT, tz=UTC).isoformat()
    if disposition == REFUSE_WINDOW_CLOSED:
        return (
            f"This pause was minted at {minted}, before the guards landed, and "
            "the migration window that kept such a pause redeemable closed at "
            f"{closes}. It is not redeemable now and will not become "
            "redeemable again; start a fresh turn on a migrated configuration."
        )
    if disposition == REFUSE_MINTED_UNDER_THE_GUARDS:
        return (
            f"This pause was minted at {minted}, after the continuation guards "
            "landed, so the migration window for pre-guard pauses does not "
            "reach it: the configuration was already refused when the token "
            "was issued."
        )
    return (
        "The presented token is one this deployment's transport claims and "
        "this build cannot read a mint time from, so the migration window -- "
        "which is granted on a pause's own issued_at claim -- cannot be "
        "granted for it."
    )