2828
2929from __future__ import annotations
3030
31+ import base64
32+ import hashlib
3133import inspect
3234import types
3335import typing
7375# `InputRequiredResult` rather than as a standalone server-to-client request.
7476# Pinned (not `LATEST_MODERN_VERSION`, which moves when newer revisions are added).
7577_INPUT_REQUIRED_VERSION = "2026-07-28"
76- _STATE_VERSION = 1
78+ _STATE_VERSION = 2 # v2 adds per-entry question digests
7779
7880
7981class Resolve :
@@ -494,12 +496,19 @@ async def _elicit(elicit: Elicit[Any], key: str, res: _Resolution) -> Elicitatio
494496 if not res .input_required :
495497 return await res .context .elicit (elicit .message , elicit .schema )
496498
499+ # Every recorded outcome - accept, decline, AND cancel - is pinned to the exact
500+ # question it answered: a decline of one wording must not suppress a reworded
501+ # question that reuses the same wire key after a redeploy. The digest is
502+ # computed once per question per round and shared by restore and persist.
503+ q = _question_digest (elicit )
504+
497505 # A recorded outcome from a prior round is consulted only here, after the body
498506 # decided to ask, so a `request_state` entry can never stand in for a resolver's
499- # own computation. Re-validate it against the live `Elicit.schema`. A recorded
500- # outcome wins over a re-sent answer; an invalid entry self-deletes and falls
501- # through to the fresh answer (or to re-asking).
502- outcome = _restore_outcome (res , key , elicit .schema )
507+ # own computation. It is honored only for the exact question being asked, and
508+ # accept data is re-validated against the live `Elicit.schema`. A recorded
509+ # outcome wins over a re-sent answer; a stale or invalid entry self-deletes and
510+ # falls through to the fresh answer (or to re-asking).
511+ outcome = _restore_outcome (res , key , elicit .schema , q )
503512 if outcome is not None :
504513 return outcome
505514
@@ -521,12 +530,12 @@ async def _elicit(elicit: Elicit[Any], key: str, res: _Resolution) -> Elicitatio
521530 ) from e
522531 # Persist the exact wire content that just passed validation - never the
523532 # model - so restoring next round revalidates the same bytes the client sent.
524- res .persist [key ] = _StateEntry (action = "accept" , data = answer .content )
533+ res .persist [key ] = _StateEntry (action = "accept" , data = answer .content , q = q )
525534 return AcceptedElicitation (data = data )
526535 if answer .action == "decline" :
527- res .persist [key ] = _StateEntry (action = "decline" )
536+ res .persist [key ] = _StateEntry (action = "decline" , q = q )
528537 return DeclinedElicitation ()
529- res .persist [key ] = _StateEntry (action = "cancel" )
538+ res .persist [key ] = _StateEntry (action = "cancel" , q = q )
530539 return CancelledElicitation ()
531540
532541
@@ -595,6 +604,21 @@ class _StateEntry(BaseModel):
595604
596605 action : Literal ["accept" , "decline" , "cancel" ]
597606 data : Any = None
607+ q : str | None = None
608+ """Digest of the exact rendered question this outcome answered."""
609+
610+
611+ def _question_digest (elicit : Elicit [Any ]) -> str :
612+ """Pin an outcome to the exact rendered question the client was shown.
613+
614+ Computed over the rendered ElicitRequest params bytes - the same bytes the
615+ client displayed - so a recorded outcome survives only as long as the
616+ question is byte-identical. A redeploy that rewords the message or changes
617+ the schema re-asks instead of silently reusing a stale answer.
618+ """
619+ rendered = _elicit_request (elicit ).params .model_dump_json (by_alias = True , exclude_none = True )
620+ digest = hashlib .sha256 (rendered .encode ()).digest ()[:16 ]
621+ return base64 .urlsafe_b64encode (digest ).decode ().rstrip ("=" )
598622
599623
600624class _State (BaseModel ):
@@ -607,8 +631,11 @@ class _State(BaseModel):
607631def _decode_state (request_state : str | None ) -> dict [str , _StateEntry ]:
608632 """Decode the per-call resolution progress from `request_state`.
609633
610- `request_state` is client-trusted (integrity sealing is a follow-up); validate
611- it through `_State` and treat anything malformed as "no progress yet".
634+ The string arrives boundary-authenticated (the middleware only forwards
635+ plaintext this server minted), so anything malformed or version-mismatched
636+ here is inner-format drift within the operator's own fleet - e.g. a rolling
637+ upgrade - where treating it as "no progress yet" and re-asking is exactly
638+ right.
612639 """
613640 if not request_state :
614641 return {}
@@ -642,12 +669,15 @@ def _outcome_from_state(entry: _StateEntry, schema: type[BaseModel]) -> Elicitat
642669 return _accepted (schema .model_validate (entry .data ))
643670
644671
645- def _restore_outcome (res : _Resolution , key : str , schema : type [BaseModel ]) -> ElicitationResult [Any ] | None :
672+ def _restore_outcome (res : _Resolution , key : str , schema : type [BaseModel ], q : str ) -> ElicitationResult [Any ] | None :
646673 """Restore `key`'s recorded outcome from a prior round, or `None` when absent.
647674
648- `request_state` is client-trusted, so an entry whose data fails validation gets
649- the `_decode_state` treatment - dropped as if no progress was recorded, so the
650- question is asked again - rather than surfacing a validation error.
675+ An entry is honored only for the exact question being asked - `q` is the
676+ live question's digest, precomputed by the caller: one pinned to a different
677+ rendered question (the server reworded or reshaped it since the outcome was
678+ recorded), or whose accepted data fails validation against the live
679+ `schema`, is dropped as if no progress was recorded - so the question is
680+ asked again - rather than surfacing an error.
651681
652682 Carries the original decoded entry forward unchanged in `res.persist`: if a
653683 later resolver is still pending, the next round's `request_state` is built from
@@ -657,6 +687,9 @@ def _restore_outcome(res: _Resolution, key: str, schema: type[BaseModel]) -> Eli
657687 entry = res .state .get (key )
658688 if entry is None :
659689 return None
690+ if entry .q != q :
691+ del res .state [key ]
692+ return None
660693 try :
661694 outcome = _outcome_from_state (entry , schema )
662695 except ValidationError :
0 commit comments