Skip to content

symfonic.capabilities.human.errors

errors

The human-interaction error taxonomy, split by what an operator must do.

Every refusal carries a code: a short, transport-neutral word. The legacy engine raised one exception type and discriminated on exactly these strings, so a route that mapped "gone" to 410 keeps working โ€” but the type now carries the distinction too, because except SymfonicAgentError could not tell "the user took too long" from "somebody tampered with this token".

The splits that matter:

  • expired vs unauthorized (SEC-PTK-1/2). One deserves "ask again", the other deserves a security alert. Neither is a subclass of the other, and a test asserts it.
  • replayed vs unauthorized (SEC-PTK-3). A replay is a token that was real. Reporting it as a forgery would page somebody for a double-clicked button.
  • checkpoint lost vs not found. The first is a deployment fact (a volatile checkpointer restarted); the second is a missing row on a durable backend. Collapsing them is what sent operators chasing token bugs that were restarts.
  • operated-platform-only. Asking a library-mode deployment for a drain proof is a category error (LIB-TL-4), not a failure to produce one.

CallBindingError

Bases: PauseTokenError

The token was minted for another call of the same run (HK2).

ask_user correlates on tool_call_id and a registered interrupt on interrupt_id; this is the refusal for both. A run that paused twice -- routine for an onboarding agent still working a checklist -- has two live tokens whose run, session and scope are all identical, so the call id is the only axis that separates them. Verifying the other three and not this one would let the second question's answer be recorded against the first.

CheckpointLostError

Bases: HumanInteractionError

The checkpoint is gone and the backend never promised to keep it.

ConsumptionDurabilityError

Bases: InteractionConfigurationError

Durable checkpoints with a volatile jti store (SEC-PTK-7).

DuplicateInteractionError

Bases: InteractionRegistrationError

Two registrations claim one name; there is no defensible winner.

HumanInteractionError

Bases: Exception

Base for everything this capability raises.

InteractionConfigurationError

Bases: HumanInteractionError, ValueError

The deployment wired something that cannot work, before any run starts.

InteractionRegistrationError

Bases: HumanInteractionError, ValueError

A named interaction is not describable, so nothing may register it.

OperatedPlatformOnlyError

Bases: HumanInteractionError

A drain proof or retirement horizon was asked of a library deployment.

PauseCheckpointNotFoundError

Bases: HumanInteractionError

A durable backend has no such checkpoint or no such pause payload.

PauseTokenError

Bases: HumanInteractionError

Base for every refusal that names the token itself.

PauseTokenExpiredError

Bases: PauseTokenError

The token was real and its window closed. Ask the question again.

PauseTokenReplayedError

Bases: PauseTokenError

The token was real and somebody already redeemed it.

PauseTokenUnauthorizedError

PauseTokenUnauthorizedError(*args: object, code: str | None = None)

Bases: PauseTokenError

The token does not authenticate: forged, tampered, or unreadable.

One refusal that reaches here is not about the token at all: when the verification keyset is unavailable the signer must deny, and denying is all it can do โ€” but telling the holder "your token is bad" while the secret manager is down is a lie, and one their retry logic acts on. So the reason may override the code (EMAP-6): the transport reads code and answers 503 + Retry-After instead of 401. The class is unchanged because the security decision is unchanged; only the explanation is now honest.

Source code in src/symfonic/capabilities/human/errors.py
def __init__(self, *args: object, code: str | None = None) -> None:
    super().__init__(*args)
    if code is not None:
        self.code = code

from_signer_refusal classmethod

from_signer_refusal(cause: BaseException) -> PauseTokenUnauthorizedError

The denial a failed verify becomes, carrying the reason's code.

Lives beside the class rather than at the call site so there is one place where "which refusals are about the token?" is answered, and so the token service does not have to know that a signer's dependency has a taxonomy at all.

Source code in src/symfonic/capabilities/human/errors.py
@classmethod
def from_signer_refusal(cls, cause: BaseException) -> PauseTokenUnauthorizedError:
    """The denial a failed ``verify`` becomes, carrying the reason's code.

    Lives beside the class rather than at the call site so there is one
    place where "which refusals are about the token?" is answered, and so
    the token service does not have to know that a signer's dependency has
    a taxonomy at all.
    """
    return cls(f"pause token is not accepted: {cause}", code=availability_code(cause))

PayloadBindingError

Bases: PauseTokenError

The paused request is not the one the token was minted for (SEC-PTK-5).

ResponseValidationError

Bases: HumanInteractionError, ValueError

The answer does not fit the registered response schema.

RetirementHorizonError

Bases: HumanInteractionError

A recorded retirement horizon forbids this, and re-recording it too.

RunBindingError

Bases: PauseTokenError

The token was minted for another run of the same session (HK2).

Its own class rather than a SessionBindingError, because the two say different things to an operator. A session mismatch is a caller answering from the wrong conversation; a run mismatch is a caller answering the right conversation's previous question -- a stale browser tab, a retried request, a queue that replayed. Both refuse; only one of them suggests somebody is looking at an old page.

ScopeBindingError

Bases: PauseTokenError

The redeeming scope is not the minting scope (SEC-PTK-4).

SessionBindingError

Bases: PauseTokenError

The token belongs to another session of the same tenant.

TokenLedgerError

Bases: HumanInteractionError

The issuance ledger refuses a record that contradicts what it holds.

TokenTTLError

Bases: HumanInteractionError, ValueError

A lifetime outside the configured bound. Never clamped, always refused.

TurnStateNotRecordedError

Bases: HumanInteractionError

A recorded turn state does not describe the turn the token binds (HK2).

UnissuedTokenError

Bases: PauseTokenError

The authoritative ledger never issued this token, so it does not exist.

A separate class from :class:PauseTokenUnauthorizedError even though both deny: this one means the envelope was fine and the ledger still says no, which is a deployment/routing question, not a forgery.

UnknownInteractionError

Bases: HumanInteractionError, KeyError

Nothing is registered under that name.

Also a :class:KeyError because the shipped registry was a dict and an adopter's except KeyError around a lookup is a reasonable thing to have.