Skip to content

symfonic.agent.cutover.continuation_window

continuation_window

The window that lets the four guards land on the continuation doors (TA8.48).

TA8.47 measured the gap and refused to close it by reflex: :mod:~symfonic.agent.cutover.continuation_guard_gap records that run, stream and stream_typed each run four pre-dispatch configuration guards and that resume / resume_interrupt ran none of them, so forty-five RETIRE/REJECT rows were refused by name at three doors and served silently by the legacy continuation at the fourth. Its stated reason for stopping is this module's whole subject: the reachable case for that gap is cross-version -- a pause minted by a process whose configuration was still accepted, redeemed by a process that now refuses it -- and a guard that simply landed would strand exactly those pauses.

So the guards land, and a bounded window keeps a pre-guard pause redeemable. The adopter's configuration was legal when the token was minted; a refusal at redemption punishes them for upgrading. The window is the compensation, and it is bounded three ways, each stated and each checkable:

  • by mint provenance -- only a pause whose own issued_at claim predates :data:GUARDS_LANDED_AT, or one this build's transport cannot have minted at all, is eligible. A pause minted under the guards never had a claim on it;
  • by an explicit horizon -- :data:WINDOW_CLOSES_AT, after which the same pause is refused by name. The window is deleted outright on the :data:WINDOW_RELEASE_LINE line, which is derived from the line that removes the body this window falls through to -- see :func:_derive_window_release_line for why it is not a free constant;
  • by token expiry -- the window never extends a lifetime. An expired pause is refused as expired, by the redemption, whatever its configuration.

An unbounded exception would not be a window; it would be the guard never landing on this door, which is the state TA8.47 already recorded.

This module owns the decision; what a decision says to an adopter belongs to :mod:~symfonic.agent.cutover.continuation_window_refusals, the way lifecycle_refusals carries the wording lifecycle_contract decides. One question each, and a reader looking for "when is a pause eligible?" should not have to read three paragraphs of message text to find it.

Admission

Admission()

Whether the window admitted, and the one setting it admitted on.

Two fields and never a third. setting is singular for the reason continuation-guard-window.md gives: the four guards short-circuit on the first raise, so one redemption learns one setting.

Nothing reads this yet. The continuation doors take the window's context manager without binding it, and wiring the refusal that uses it is TA9.2's, at the moment it removes the body.

Source code in src/symfonic/agent/cutover/continuation_window_admission.py
def __init__(self) -> None:
    #: Whether this block ended in an admitted redemption.
    self.admitted = False
    #: The retired setting the admission was granted on, if there was one.
    self.setting: str | None = None

MintProvenance

MintProvenance(kind: str, issued_at: float | None = None)

When this pause was minted, and how that was established.

Two fields on one value for the reason :class:~symfonic.agent.cutover.continuation.Recognition keeps its two on one: issued_at alone cannot say whether None means "before this build" or "unreadable", and those two get opposite answers.

Source code in src/symfonic/agent/cutover/continuation_window.py
def __init__(self, kind: str, issued_at: float | None = None) -> None:
    #: ``FOREIGN_MINT``, ``UNREADABLE_MINT``, or ``"claimed"``.
    self.kind = kind
    #: The ``issued_at`` claim, when there is one.
    self.issued_at = issued_at

before_the_guards property

before_the_guards: bool

Whether this pause predates :data:GUARDS_LANDED_AT.

FOREIGN_MINT answers True structurally rather than by a comparison: PauseTokenService.mint stamps issued_at on every envelope it mints, so a token this deployment's transport does not recognise -- a legacy HMAC token the engine minted itself, or one a previous build's transport issued -- is not one this build minted, and a build that could not have minted it could not have refused it either. A claimed issued_at of 0.0 is the same fact wearing the legacy claim body: :data:~symfonic.capabilities.human.values.LEGACY_CLAIM_KEYS has no such key, so the field defaults for exactly the tokens that predate it.

guards_named_in

guards_named_in(source: str, guards: Sequence[str]) -> tuple[str, ...]

Which of guards this source does not call. Read structurally.

Exported so the door-by-door assertion has one implementation: an outcome difference could be a fixture artefact, a call that is not there cannot be, and TA8.47 read the gap this way before this task closed it.

Source code in src/symfonic/agent/cutover/continuation_window.py
def guards_named_in(source: str, guards: Sequence[str]) -> tuple[str, ...]:
    """Which of ``guards`` this source does not call. Read structurally.

    Exported so the door-by-door assertion has one implementation: an outcome
    difference could be a fixture artefact, a call that is not there cannot be,
    and TA8.47 read the gap this way before this task closed it.
    """
    return tuple(guard for guard in guards if f"{guard}(" not in source)

migration_window

migration_window(*, capability: Any, pause_token: str, now: float | None = None, warn: Callable[[Warning], None] | None = None) -> Iterator[Admission]

Run the four guards inside the window that keeps a pre-guard pause alive.

A context manager rather than a function taking the guards as callables, so each continuation door names all four in its own body at the same pre-dispatch position the three fresh doors name them. The position is the contract -- a guard consulted after the routing decision would be a route-conditional refusal, the shape :mod:~symfonic.agent.cutover.retirement rejects -- and a reader should find the names where they are supposed to be rather than one indirection away.

The token is read only once a guard has already decided to refuse, so a deployment carrying no retired setting pays nothing here and its failure ordering is untouched.

Nothing is stranded silently, in either direction. A redemption the window admits is announced; one it refuses raises the guard's own named error. What this never produces is a continuation that simply ends or returns an empty answer -- the two silent losses TA8.42 refused to accept for ask_user_enabled.

It yields an :class:~symfonic.agent.cutover.continuation_window_admission.Admission (TA8.58), so a dispatch site below the block can tell an admitted redemption apart from a deployment that carried no retired setting at all. Binding it is optional and nothing in the engine does yet; the reason it exists is that the refusal replacing a removed fallback body has to be able to say "the window admitted this and then could not serve it".

Source code in src/symfonic/agent/cutover/continuation_window.py
@contextlib.contextmanager
def migration_window(
    *,
    capability: Any,
    pause_token: str,
    now: float | None = None,
    warn: Callable[[Warning], None] | None = None,
) -> Iterator[Admission]:
    """Run the four guards inside the window that keeps a pre-guard pause alive.

    A context manager rather than a function taking the guards as callables, so
    each continuation door names all four in its own body at the same
    pre-dispatch position the three fresh doors name them. The position is the
    contract -- a guard consulted after the routing decision would be a
    route-conditional refusal, the shape
    :mod:`~symfonic.agent.cutover.retirement` rejects -- and a reader should
    find the names where they are supposed to be rather than one indirection
    away.

    The token is read only once a guard has already decided to refuse, so a
    deployment carrying no retired setting pays nothing here and its failure
    ordering is untouched.

    **Nothing is stranded silently, in either direction.** A redemption the
    window admits is announced; one it refuses raises the guard's own named
    error. What this never produces is a continuation that simply ends or
    returns an empty answer -- the two silent losses TA8.42 refused to accept
    for ``ask_user_enabled``.

    **It yields an** :class:`~symfonic.agent.cutover.continuation_window_admission.Admission`
    (TA8.58), so a dispatch site below the block can tell an admitted redemption
    apart from a deployment that carried no retired setting at all. Binding it
    is optional and nothing in the engine does yet; the reason it exists is that
    the refusal replacing a removed fallback body has to be able to say *"the
    window admitted this and then could not serve it"*.
    """
    from symfonic.agent.cutover.continuation_window_refusals import (  # noqa: PLC0415
        announce,
        restated,
    )

    admission = Admission()
    try:
        yield admission
    except RetiredConfigurationError as refusal:
        provenance = mint_provenance(capability, pause_token)
        disposition = window_disposition(
            provenance, now=redemption_clock() if now is None else now
        )
        if disposition != ADMIT_IN_WINDOW:
            raise restated(refusal, disposition, provenance) from refusal
        admission.admitted = True
        admission.setting = refusal.setting
        announcement = announce(refusal)
        logger.warning("continuation migration window: %s", announcement)
        (warn or _warn)(announcement)

mint_provenance

mint_provenance(capability: Any, pause_token: str) -> MintProvenance

When was this pause minted -- read, not authenticated for admission.

at=0.0 is the load-bearing argument. It asks the token service to verify the envelope and decode its claims while answering the expiry question "no", so a live token and an expired one are read identically here and expiry is decided where it was always decided: at the redemption, as PauseTokenExpiredError. That is the failure ordering SymfonicAgent._continuation_route protects, kept intact.

Anything this cannot read is :data:UNREADABLE_MINT rather than an exception, because a transport fault must not convert a configuration refusal into a transport error -- the refusal already stands, and this call only decides how it is worded.

Source code in src/symfonic/agent/cutover/continuation_window.py
def mint_provenance(capability: Any, pause_token: str) -> MintProvenance:
    """When was this pause minted -- read, not authenticated for admission.

    ``at=0.0`` is the load-bearing argument. It asks the token service to
    verify the envelope and decode its claims while answering the expiry
    question "no", so a live token and an expired one are read identically
    here and expiry is decided where it was always decided: at the redemption,
    as ``PauseTokenExpiredError``. That is the failure ordering
    ``SymfonicAgent._continuation_route`` protects, kept intact.

    Anything this cannot read is :data:`UNREADABLE_MINT` rather than an
    exception, because a transport fault must not convert a configuration
    refusal into a transport error -- the refusal already stands, and this call
    only decides how it is worded.
    """
    decode = getattr(capability, "decode_token", None)
    tokens = getattr(capability, "tokens", None)
    if decode is None or tokens is None:
        return MintProvenance(FOREIGN_MINT)
    try:
        envelope = decode(pause_token)
        if envelope is None:
            return MintProvenance(FOREIGN_MINT)
        claims = tokens.authenticate(envelope, at=0.0).claims
    except Exception:  # noqa: BLE001 - an unreadable token is a state, not a fault
        return MintProvenance(UNREADABLE_MINT)
    return MintProvenance("claimed", float(getattr(claims, "issued_at", 0.0) or 0.0))

redemption_clock

redemption_clock() -> float

When this redemption is happening, read through one indirection.

The single substitution seam for the window's own clock, and the reason it exists: the acceptance criterion asks that a pause redeemed inside the window and the same pause redeemed after it get different answers, and that the difference be the window rather than an accident of ordering. A test can only drive that by moving the clock, and moving the stdlib clock would move every other clock in the process with it. One function, patched in one place, keeps the two cases one variable apart.

Source code in src/symfonic/agent/cutover/continuation_window.py
def redemption_clock() -> float:
    """When this redemption is happening, read through one indirection.

    The single substitution seam for the window's own clock, and the reason it
    exists: the acceptance criterion asks that a pause redeemed *inside* the
    window and the same pause redeemed *after* it get different answers, and
    that the difference be the window rather than an accident of ordering. A
    test can only drive that by moving the clock, and moving the *stdlib* clock
    would move every other clock in the process with it. One function, patched
    in one place, keeps the two cases one variable apart.
    """
    return _clock()

window_disposition

window_disposition(provenance: MintProvenance, *, now: float) -> str

Which of the four answers this redemption gets, and only that.

Split from the wording and from the raising so a test can drive the decision directly: the acceptance criterion asks that a pause redeemed inside the window and the same pause redeemed after it differ by the window rather than by an accident of ordering, and the only input that separates those two here is now.

Source code in src/symfonic/agent/cutover/continuation_window.py
def window_disposition(provenance: MintProvenance, *, now: float) -> str:
    """Which of the four answers this redemption gets, and only that.

    Split from the wording and from the raising so a test can drive the
    decision directly: the acceptance criterion asks that a pause redeemed
    inside the window and the same pause redeemed after it differ *by the
    window* rather than by an accident of ordering, and the only input that
    separates those two here is ``now``.
    """
    if not provenance.before_the_guards:
        return (
            REFUSE_NO_MINT_TIME
            if provenance.kind == UNREADABLE_MINT
            else REFUSE_MINTED_UNDER_THE_GUARDS
        )
    return ADMIT_IN_WINDOW if now < WINDOW_CLOSES_AT else REFUSE_WINDOW_CLOSED