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_atclaim 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_LINEline, which is derived from the line that removes the body this window falls through to -- see :func:_derive_window_release_linefor 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 ¶
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
MintProvenance ¶
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
before_the_guards
property
¶
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 ¶
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
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
mint_provenance ¶
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
redemption_clock ¶
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
window_disposition ¶
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.