symfonic.agent.cutover¶
cutover ¶
Per-capability cutover for the legacy engine (T3.5.1).
SymfonicAgent keeps its API and stops owning its invocation: each
capability routes either to the migrated implementation (the normalized
configuration, the invocation compiler, the shared runner) or to the legacy
body that is still there, dormant, behind the switch. Nothing is deleted here.
T4.4.6 retires the dormant paths, and only after the W5 pre-retirement gates
pass.
"Every flip has a way back that does not need a release" was true until TA8.5,
and stating what replaced it matters more than editing the sentence away. On
the 11.0 line three levers that reached the legacy body are retired and
refuse by name — see :mod:symfonic.agent.cutover.retirement. The bodies are
still there and still reachable out of the migrated envelope and on release
lines that predate the text-delta chunk contract, so this is a narrowing of the
ways in, not a dormancy claim.
AdminAuthority
dataclass
¶
An admin claim a host derived, and the principal it derived it for.
The adapter value for a deployment whose authentication is the legacy
request-stashing verifier rather than a platform
:class:~symfonic.platform.ports.ScopeResolver. A resolver's
:class:~symfonic.platform.values.AuthenticatedPrincipal already satisfies
what :func:bind_admin_authority reads, so it is bound directly and this
class is not in its way.
principal_id is required and not defaulted. That is the type doing the
contract's work: the legacy keyword's defect was that True said nothing
about who, and a claim value with an optional identity would have
reintroduced the same anonymous boolean at a longer address.
AdmissionSurface
dataclass
¶
AdmissionSurface(path: str, subgroup: str, owner: str, admitted: bool, disposition: str, served_by: tuple[str, ...], refused_by: tuple[str, ...], inert_on: tuple[str, ...], consumer: str, observable: str, off_turn_surface: str | None = None, inert_because: str | None = None, override_argument: str | None = None)
One TA8.44 row, and the surface that actually honours it.
served_by / refused_by / inert_on partition
:data:PUBLIC_ENTRY_POINTS exactly, for the reason
:class:~symfonic.agent.cutover.lifecycle_contract.LifecycleRow gives: an
unmeasured third entry point is the gap that made two thirds of this
programme's earlier evidence unusable, and a row with a hole in its surface
would reintroduce it as a data structure.
admitted is a separate field from all of that, and it is separate
deliberately. A row can be refused-to-legacy by the default-deny envelope
while its consumers are perfectly real -- that is what every MISSING
CONTRACT row in the inventory is -- and recording it here with
admitted=False keeps the argument next to the field instead of in a
document nothing checks.
CapabilitySwitch
dataclass
¶
CapabilitySwitch(capability: str, migrated_by: str, verified_by: str, legacy_fallback: str, retired_by: str = 'T4.4.6')
One capability's migration, its verification, and its way back.
Reserved for names dispatch actually consults. Four today —
:data:INVOCATION_RUN, :data:INVOCATION_STREAM,
:data:INVOCATION_STREAM_TYPED and :data:INVOCATION_CONTINUATION — and
a name earns one by owning an atomic segment of a turn, not by having an
implementation.
CoverageEvidence
dataclass
¶
CoverageEvidence(kind: CoverageKind, parity_suite: str | None = None, characterization: str | None = None, live_cutover: str | None = None, dispatch_proof: str | None = None, opaque_dependencies: tuple[str, ...] = ())
What was built and verified for a capability nothing routes through.
Deliberately without complete. On a switch, complete is the
predicate that flips it; a register has no flip, so the same word here
would answer a question nobody can act on -- and that reading is how "nine
of eleven complete" came to be heard as "nine of eleven serving turns".
What it answers instead is :attr:verified: the evidence this kind of
register owes has been filed. A true claim, and a different one.
CoverageKind ¶
Bases: StrEnum
How a migrated capability reaches real turns — and so what proves it.
One requirement list for all nine registers was wrong in a way that matters
to the instrument: it demanded a live_cutover citation from every one,
and only three can have one. memory and prompting are composed and
dispatched on every hydrating turn since #14 and #21 — through the
envelope, on the evidence a bundle carries — and reported verified =
False because no switch flipped to make that happen. The ledger built to
stop understating progress was understating exactly the two capabilities
that had travelled furthest.
So the requirement is a function of the kind:
- :attr:
CALL_SITE— the migrated implementation is the only one left on the engine's call path. Proof is that call site, insrc/. - :attr:
ENVELOPE— composed and dispatched through admission, on bundle evidence rather than a route. Proof is the integration test that shows the segment reaching the model; there is no call site to name because the legacy body is still reachable when the envelope refuses. - :attr:
BUILT— implemented and verified, and nothing dispatches it. Parity and characterization are the whole claim, and asking for more would file an optimistic citation to satisfy a field.
CoverageRegister
dataclass
¶
CoverageRegister(capability: str, migrated_by: str, verified_by: str, legacy_fallback: str, retired_by: str = 'T4.4.6')
A capability that was built — and that routes nothing.
Nine of the eleven names this module used to call switches were registers:
route_for("memory") answered kernel or legacy and no dispatch
read it, so flipping one changed which turn? None. They were counted as
migration progress all the same, and "nine of eleven flipped" measured how
much code existed rather than how much of it ran.
The distinction that decides which type a name gets: a switch owns an atomic segment of a turn, so flipping it changes what serves that turn. A register records that a capability was migrated and verified, which is real evidence and a different claim.
Memory and prompting are the sharpest illustration. Both are composed and dispatched since #14 and #21 — through the envelope, on evidence a bundle carries. Their registers still route nothing, and calling them switches would say the flip did work the envelope actually did.
CutoverSwitchboard ¶
CutoverSwitchboard(*, criteria: Mapping[str, SwitchCriteria] | None = None, recorder: CutoverCriteriaRecorder | None = None, pin: LegacyPin | None = None)
Per-capability routing between the migrated and the dormant paths.
Source code in src/symfonic/agent/cutover/switchboard.py
legacy_pin
property
¶
The build's pin, as a value. NO_LEGACY_PIN when none was set.
describe ¶
One line per switch. Names and citations only — never a credential.
Source code in src/symfonic/agent/cutover/switchboard.py
file ¶
File this capability's criteria; flips the switch when complete.
Source code in src/symfonic/agent/cutover/switchboard.py
record_fallback ¶
Count one invocation that a flipped switch still served on legacy.
Source code in src/symfonic/agent/cutover/switchboard.py
restore ¶
Undo this board's rollback. Refused while the criteria are incomplete.
Also refused while a process-wide override is in force: clearing the
instance flag would return LEGACY anyway, and a restore that
reports success without moving the route is how an operator concludes
the rollback lever is broken.
Source code in src/symfonic/agent/cutover/switchboard.py
rollback ¶
Refused since 11.0: the board-rollback lever is retired.
Still present, still accepting the arguments it always took, and it
raises :class:~symfonic.agent.cutover.retirement.RetiredLeverError
naming the line and the capability. reason is optional only so
that a call written from muscle memory refuses by name instead of
raising TypeError at the operator — it is not read.
switch_for runs first on purpose. A mistyped capability is a
different mistake from a retired lever, and answering "that lever is
gone" to rollback("invocaton.run") would send the operator looking
for a migration note about a capability that does not exist.
Source code in src/symfonic/agent/cutover/switchboard.py
rollback_reason ¶
Why this capability is on legacy: process-wide reason wins.
route_for ¶
The implementation that serves capability in this process.
The process-wide override is read first and cannot be outvoted by an
instance: a host that builds one agent per request would otherwise
answer KERNEL on every board constructed after the operator rolled
the capability back, which is the failure the lever exists to prevent.
Source code in src/symfonic/agent/cutover/switchboard.py
EnvelopeVerdict
dataclass
¶
Admitted, or refused with the name of what is missing.
GovernanceSurface
dataclass
¶
GovernanceSurface(path: str, subgroup: str, owner: str, kind: str, disposition: str, served_by: tuple[str, ...], refused_by: tuple[str, ...], inert_on: tuple[str, ...], consumer: str, observable: str, replaced_by: str | None = None, adopter_break: str | None = None, override_argument: str | None = None)
One C1-K row, and what 11.0 does with it on each entry point.
served_by / refused_by / inert_on partition
:data:~symfonic.agent.cutover.lifecycle_contract.PUBLIC_ENTRY_POINTS
exactly, for the reason LifecycleRow gives: an unmeasured third entry
point is the gap that made two thirds of this programme's earlier evidence
unusable, and a row with a hole in its surface would reintroduce it as a
data structure.
replaced_by and override_argument are mutually exclusive and one is
required, so this table cannot hold a row that was neither replaced nor
argued about -- "the triage recommended REPLACE and the code is silent" is
then not a state anybody has to infer from an absence. A replaced_by
without an adopter_break is refused for the reason this lane's
acceptance gives in as many words: collapsing REPLACE into MIGRATE ships a
breaking change as a migration note.
KernelDelegate ¶
KernelDelegate(*, model_provider: Any, instructions: str | None = None, tools: Sequence[Any] = (), recursion_limit: int | None = None, model: Any = None, role_models: Any = None, bundle: Any = None, max_conversation_messages: int | None = None, observability: ObservabilitySuite | None = None)
Serves one legacy run/stream call through the migrated path.
Bind one plan factory for the life of this agent.
bundle is the composition root's authorised
:class:~.bundle.RetrievalBundle, transported unchanged. The delegate
does not fold it and does not inspect what it contains: folding is the
host's decision about what it trusts, and a delegate that composed one
would be deciding that on the host's behalf. None is the ordinary
case — an agent that does not hydrate compiles the same plan it always
did, with no stages.
model is config.agent.model. Threading it is what lets
ALLOWED_AGENT_FIELDS name the field: an allowlist entry asserts the
migrated path honours a field, so admitting model without passing
it here would turn a refusal into a silent substitution — the agent
would be admitted to the kernel and then answered by whichever model the
provider happened to declare.
max_conversation_messages is config.agent.max_conversation_messages
— the cap replayed history is trimmed at. On the constructor rather than
the call because it is agent configuration, not a property of the turn,
the same reason recursion_limit is here. None means "not stated"
and falls back to the stock value, which is what an admitted turn always
carries anyway: the envelope refuses the field. Threading it is
nonetheless what makes the trim honour the configuration rather than a
constant, so admitting the field later is a decision about evidence
instead of a change of behaviour.
bundle.tools are merged into this agent's own tools rather than
kept beside them (TA8.12) -- one normalized set, because a second
collection only some of the four tool readers consult is how a
contributed tool becomes bindable and not callable. bundle.delegation
is held for the other half: :meth:_delegation_scope opens a run scope
on it, which is how the turn's agent_depth reaches the ceiling those
tools enforce. See :mod:~symfonic.agent.cutover.delegation.
observability is the agent-lifetime half of TA8.20's migration: the
config, the metrics_collector and the OTEL handles the engine
holds, folded into one object that can compose a per-run event sink.
None means "nobody is watching", which is both the default and the
state every agent was in before the migration, and it binds no sink at
all. Like bundle, it is transported rather than derived — deciding
what observability a deployment buys is the host's decision that
symfonic.services.observability.suite already owns.
Source code in src/symfonic/agent/cutover/delegate.py
kernel_typed_stream ¶
Enter the kernel's typed projection for one compiled plan.
The one door this module's typed route goes through, and the reason
it is here rather than in typed_route: IPL-1 declares the pipeline
heads that may enter the invocation kernel, and this delegate is one of
them. A second module reaching InvocationKernel directly would be a
second entry point into the single invocation path, which is exactly
what that rule exists to prevent.
Source code in src/symfonic/agent/cutover/delegate.py
run
async
¶
run(query: str, *, run_id: str, session_id: str = '', response_model: type[Any] | None = None, scope: Any = None, history: Sequence[Any] | None = None, attachments: Sequence[Any] | None = None, tenant_id: str | None = None, agent_depth: int | None = None) -> AgentResponse
One non-streaming turn, compiled once and run once.
scope rides on the request, never on the plan: the plan is
compiled once for the agent's life and a scope burned into it would
make one agent answer for one tenant. The bundle stays immutable
configuration and nothing shared is mutated per call.
history and attachments ride there for the same reason — see
:func:~.turn_request.turn_request.
agent_depth is the turn's delegation depth and
:meth:_delegation_scope is what reads it. Per-call for the reason
scope is: a depth fixed at construction would be a parent that could
only ever be a root.
tenant_id is telemetry identity and nothing else: the legacy
FrameworkTenantScope.tenant_id, from the same expression
_legacy_run_impl hands to _otel_run_span, so an admitted run is
attributed to the tenant the replaced path attributed it to. Separate
from scope, which the engine builds through a translation answering
None for a scope it cannot read — right for recall, wrong for
billing.
Source code in src/symfonic/agent/cutover/delegate.py
stream
async
¶
stream(query: str, *, run_id: str, session_id: str = '', response_model: type[Any] | None = None, scope: Any = None, history: Sequence[Any] | None = None, attachments: Sequence[Any] | None = None, tenant_id: str | None = None, agent_depth: int | None = None) -> AsyncIterator[StreamChunk]
The streaming projection of the same invocation, not a second one.
scope is threaded for the same reason it is on :meth:run, and its
absence here was the sharper bug of the two: streaming admission already
accepted a caller scope, so a multi-tenant stream was admitted to the
kernel and then hydrated from the bundle's default scope. "The same
invocation, projected" has to include what the invocation was for.
tenant_id is threaded for the reason given on :meth:run, and the
release below is the half that matters more here: a consumer who stops
iterating ends the run with no terminal event, which is precisely the
abandoned run ObservabilityBridge.aclose exists for.
Source code in src/symfonic/agent/cutover/delegate.py
stream_typed ¶
The typed projection of the same invocation -- ST2's kernel route.
The body, and the full keyword list options carries, live next door
in :func:~.typed_route.typed_route, for the line-budget reason
rounds, authorised and delegation already moved out. Named
here because this is the address a caller holds.
Source code in src/symfonic/agent/cutover/delegate.py
LifecycleRow
dataclass
¶
LifecycleRow(path: str, subgroup: str, owner: str, served_by: tuple[str, ...], refused_by: tuple[str, ...], inert_on: tuple[str, ...], consumer: str, observable: str, disposition: str)
One row of the public lifecycle contract.
Three dispositions per entry point, not two, because collapsing the third into either of the others is how a surface gets misreported:
served_by-- the entry point honours the row; the named consumer reads the value and the named observable moves with it.refused_by-- the entry point refuses it by name, which is a contract. Falling to legacy because the envelope is default-deny is not a refusal and never counts here.inert_on-- the entry point neither reads it nor refuses it, and that is the published behaviour rather than an omission.streaming_enabledonrunis the case: a blocking turn has no stream to disable, so the field asks nothing of it. An inert entry point is why a row can be admitted to the kernel without a value-reading consumer there, and saying so out loud is what stops "admitted" from being read as "honoured".
The three must partition :data:PUBLIC_ENTRY_POINTS exactly, because "the
third entry point was never measured" is the gap that made two thirds of
this programme's earlier evidence unusable, and a row with a hole in its
surface would reintroduce it as a data structure.
consumer is file::symbol and observable is what an operator sees
change when the value changes. Both are prose the tests assert against a
driven turn, not decoration.
PauseSurface
dataclass
¶
PauseSurface(path: str, subgroup: str, owner: str, disposition: str, served_by: tuple[str, ...], refused_by: tuple[str, ...], inert_on: tuple[str, ...], consumer: str, observable: str, replaced_by: str | None = None, adopter_break: str | None = None, override_argument: str | None = None)
One row of this lane, and what 11.0 does with it on each entry point.
served_by / refused_by / inert_on partition the public entry
points exactly. inert_on describes only the migrated route at those
entry points: it does not claim the legacy fallback lost the behaviour
recorded by consumer and observable. An unmeasured third entry
point is the gap that made earlier evidence unusable, and a row with a hole
would reintroduce it. replaced_by and override_argument are
mutually exclusive and one is required, so the table cannot hold a row
that was neither replaced nor argued about.
PromptingSurface
dataclass
¶
PromptingSurface(path: str, subgroup: str, owner: str, recorded: str, applied: str, served_by: tuple[str, ...], refused_by: tuple[str, ...], inert_on: tuple[str, ...], consumer: str, observable: str, condition: str | None = None, override_argument: str | None = None)
One TA8.38 row: what the triage recommended, and what this lane did.
served_by / refused_by / inert_on partition
:data:~symfonic.agent.cutover.lifecycle_contract.PUBLIC_ENTRY_POINTS
exactly, for the reason
:class:~symfonic.agent.cutover.admission_surfaces.AdmissionSurface gives:
an unmeasured third entry point is the gap that made two thirds of this
programme's earlier evidence unusable, and a row with a hole in its surface
would reintroduce it as a data structure.
recorded and applied are separate fields, and that is the whole
point of the type. Every row must carry the disposition this lane actually
applied; a row whose applied disposition differs from the recorded
recommendation must carry an argument as well. Silence is the one state that
cannot be expressed.
overridden
property
¶
True when this lane did not apply the recorded recommendation.
NORMALISED_PER_ROW and REPLACE name a decision; the inventory
vocabulary names an outcome, so the two are compared through the one
pairing that means "applied as written" for each.
RecursionExhaustedError ¶
Bases: GraphRecursionError, SymfonicAgentError
The invocation used every model round its budget allows.
Inherits from both hierarchies on purpose — see the module docstring. The
LangGraph base comes first so GraphRecursionError's own args
handling wins, and SymfonicAgentError.__init__ still supplies the
optional code.
Source code in src/symfonic/agent/cutover/errors.py
ReplacedPauseSettingError ¶
Bases: RetiredConfigurationError
A nested pause-policy field replaced on the 11.0 line was set.
Subclasses
:class:~symfonic.agent.cutover.config_retirement.RetiredConfigurationError
so except RetiredConfigurationError around the agent API keeps catching
this -- the widening-never-a-rename discipline that error states for its own
ancestry, and the one
:class:~symfonic.agent.cutover.container_semantics.UnownedContainerError
already follows. A subclass rather than a fourth sibling because it refuses
the same kind of thing at the same moment: a fact about how this agent was
built, named before dispatch.
It exists at all because the field is nested. The flat spelling is an
ordinary :data:~symfonic.agent.cutover.retired_settings.RETIRED_SETTINGS
row and raises the base class; a table whose predicate is "getattr
against a flat stock default" cannot hold agent.ask_user_pause_ttl_seconds
without lying about what it reads.
Source code in src/symfonic/agent/cutover/config_retirement.py
RetiredArgument
dataclass
¶
One retired per-call argument: what was written, and what replaces it.
Two fields rather than one string because the refusal owes two different
things. call is the call the adopter made -- quoted back so the
traceback names the thing they typed. instead is the next step, and it
lives here rather than being supplied at the raise site on purpose:
refuse_retired_lever makes detail a required parameter so that a
refusal can never be a dead end, and a table that carries the next step
keeps the same guarantee without asking every call site to remember it.
The obligation becomes structural instead of conventional.
RetiredArgumentError ¶
Bases: CutoverPathError
A per-call argument retired on the 11.0 line was supplied.
Subclasses :class:~symfonic.services.shadow.errors.CutoverPathError, and
through it ConfigurationError and SymfonicError, for the reason
:class:RetiredLeverError gives for its own ancestry: an adopter's
existing except around the cutover API keeps catching this. A widening,
never a rename.
Named separately from :class:RetiredLeverError rather than folded into
it. The reachability probe's validity predicate keys on the lever
vocabulary, and RETIRED_LEVERS' keys are exactly the lever names that
probe drives; a per-call argument in that table would make one vocabulary
carry two different kinds of fact. Two exact classifiers beat one loose
one.
Source code in src/symfonic/agent/cutover/retirement.py
RetiredConfigurationError ¶
Bases: CutoverPathError
A configuration field retired on the 11.0 line was set.
Subclasses :class:~symfonic.services.shadow.errors.CutoverPathError, and
through it ConfigurationError and SymfonicError, for the reason
:class:~symfonic.agent.cutover.retirement.RetiredArgumentError gives for
its own ancestry: an adopter's existing except around the agent API
keeps catching this. A widening, never a rename.
Named separately from the lever and argument errors rather than folded into either. The reachability probe's validity predicate keys on the lever vocabulary and the envelope's guard loop keys on the argument one; a configuration field in either table would make one vocabulary carry two kinds of fact. Three exact classifiers beat one loose one.
Source code in src/symfonic/agent/cutover/config_retirement.py
RetiredLeverError ¶
Bases: CutoverPathError
A lever retired on the 11.0 line was pulled.
Subclasses :class:~symfonic.services.shadow.errors.CutoverPathError, and
through it ConfigurationError and SymfonicError, so an adopter's
existing except around the cutover API still catches this — a widening
of the ancestry, never a rename. The type is named all the same, because
"the lever is gone" and "the evidence was filed under a path this
capability may not use" are different incidents and an operator triaging at
03:00 should not have to read the message to tell them apart.
Source code in src/symfonic/agent/cutover/retirement.py
RetiredSetting
dataclass
¶
One retired configuration field: its group, its default, its next step.
instead lives here rather than being supplied at the raise site for the
reason :class:~symfonic.agent.cutover.retirement.RetiredArgument gives: a
refusal that names the line and stops is a dead end, and a table that
carries the next step makes that obligation structural instead of
conventional. Where nothing replaced the field, instead says so in
those words rather than trailing off.
is_use ¶
Did the caller actually ask for this field's behaviour?
Fails closed. A value whose comparison to the stock default cannot be
answered -- an __eq__ that raises, a lazily imported stub, an
adopter object with an opinionated __len__ -- counts as use, for
the reason :func:~symfonic.agent.cutover.baseline.equivalent gives
for answering False: on an admission decision "I do not know" is
"no", and on a retirement it is "refuse".
Source code in src/symfonic/agent/cutover/settings_contract.py
Route ¶
Bases: StrEnum
Which implementation of a capability serves an invocation.
SelfAssertedAuthorityError ¶
Bases: RetiredArgumentError
is_admin=True was supplied to an entry point on the 11.0 line.
Subclasses :class:~symfonic.agent.cutover.retirement.RetiredArgumentError
rather than standing alone, and the ancestry is the claim: this is a
retired per-call argument, refused before dispatch with the same message
shape, the same entry_point attribute and the same
:data:~symfonic.agent.cutover.retirement.LEVER_RETIREMENT_LINE. Only the
table it is not in differs, and the narrower class is what lets a handler
tell an authority refusal from the three TA8.18 retirements without parsing
prose.
Source code in src/symfonic/agent/cutover/retirement.py
StreamingDisabledError ¶
Bases: SymfonicAgentError, CutoverPathError
streaming_enabled=False and a streaming entry point was asked.
The ancestry is a widening in both directions and a rename in neither.
SymfonicAgentError is what this raised before it had a name of its own
(a bare SymfonicAgentError("Streaming is disabled in configuration")),
so every existing except and every existing message match still catch
it. CutoverPathError is the vocabulary the cutover guards refuse in,
which is what lets an operator tell a contract refusal from a provider
failure without parsing prose.
Source code in src/symfonic/agent/cutover/lifecycle_refusals.py
StructuredOutputUnsupportedError ¶
Bases: SymfonicAgentError, CutoverPathError
response_model was supplied to a streaming entry point.
Same ancestry, same reason. Before this class the argument landed in
**state_overrides and refused as a retired argument, which named the
wrong thing: state_overrides is retired, response_model is not --
it is supported, on one surface, and this says which.
Source code in src/symfonic/agent/cutover/lifecycle_refusals.py
SwitchCriteria
dataclass
¶
SwitchCriteria(parity_suite: str | None = None, characterization: str | None = None, live_cutover: str | None = None, opaque_dependencies: tuple[str, ...] = (), preconditions: tuple[str, ...] = ())
SwitchDecision
dataclass
¶
Why one capability is on the route it is on.
UnaddressableTranscriptError ¶
Bases: SymfonicAgentError, CutoverPathError
Transcript persistence was asked for on a turn nothing can read back.
transcript_persistence_enabled=True wires a LangGraph checkpointer, and
a checkpointer needs a thread_id. The engine derives one from
(scope, session_id) -- the single derivation site
:func:symfonic.capabilities.human.threads.thread_id_for, shared with
get_transcript and with working-deque rehydration -- and a turn that
supplies neither has no key to file the checkpoint under.
Before TA8.41 that turn reached LangGraph and died there with
ValueError: Checkpointer requires one or more of the following
'configurable' keys, an error that names no field of this framework and
no action for the adopter. That is what made this row's admission-inventory
outcome UNKNOWN on both driven entry points rather than a measurement.
Same ancestry, and the same reason, as the two errors above it.
Source code in src/symfonic/agent/cutover/lifecycle_refusals.py
UnauthenticatedAdminClaimError ¶
Bases: CutoverPathError
An admin claim was bound without naming the principal it belongs to.
Subclasses :class:~symfonic.services.shadow.errors.CutoverPathError, and
through it ConfigurationError and SymfonicError, for the reason
every other error in this package gives for its ancestry: an adopter's
existing except around the agent API keeps catching this. A widening,
never a rename.
Named separately from :class:SelfAssertedAuthorityError because the two
are different incidents. That one is "you asked for a keyword this line
retired"; this one is "you used the replacement and it is not carrying an
identity", which is a host wiring its own authentication wrongly and is the
failure that would otherwise let the replacement decay back into the thing
it replaced.
UnownedContainerError ¶
Bases: RetiredConfigurationError
config.agent carried a semantic no child row owns.
Subclasses :class:~symfonic.agent.cutover.config_retirement.RetiredConfigurationError
so except RetiredConfigurationError around the agent API keeps catching
this -- the same widening-never-a-rename discipline that error states for
its own ancestry -- while an adopter who wants to tell the container
rejection from the twenty-five retired fields can catch the narrower class.
It is a subclass rather than a fourth sibling because it refuses the same
kind of thing at the same moment: a fact about how this agent was built,
named before dispatch. The vocabulary difference is the group, which is
:data:UNOWNED_CONTAINER_GROUP and is not a retired-settings group.
Source code in src/symfonic/agent/cutover/config_retirement.py
admin_authority ¶
The bound principal's admin bit, or False when nothing is bound.
The single place the engine asks "is this turn an administrator's?", so
run, stream and stream_typed cannot answer it differently. It is
read after :func:refuse_self_asserted_admin has run, so the keyword's
value can never contribute: the two are one decision expressed as a refusal
and a derivation rather than as a precedence rule, because a precedence rule
between an authenticated claim and a self-asserted one is a rule that can be
got backwards.
Source code in src/symfonic/agent/cutover/authority.py
admit_invocation ¶
admit_invocation(config: Any, *, scope: Any = None, session_id: str | None = None, history: Sequence[Any] | None = None, attachments: Sequence[Any] | None = None, callbacks: Sequence[Any] | None = None, extra_metadata: Any = None, state_overrides: Any = None, agent_depth: int | None = None, response_model: Any = None, sub_agents: Sequence[Any] = (), plugins: Sequence[Any] = (), human: Any = None, observability: Sequence[Any] | None = None, topology: str | None = None, bundle: Any = None) -> EnvelopeVerdict
Decide whether the compiler-and-kernel path may serve this invocation.
topology is the agent's compiled graph preset. It is not readable
from config — graph_preset is a constructor argument that lands on
the AgentGraph — so it has to be threaded in by the caller. Passing
None means "not stated", which is treated as the migrated topology;
every caller inside the engine states it.
history and attachments are still accepted here and no longer
refuse. They keep their parameters rather than losing them, because the
sole caller passes every per-call argument by name and a signature that
dropped two of them would turn an admission into a TypeError; and
because the record of which arguments this envelope has considered is the
signature. What lifted them is stated at the loop below.
response_model is deliberately not a refusal: structured output is
part of the response capability T3.1.4 verified, and the delegate compiles
it the same way the simple facade does. It is an argument rather than a
config field, so it needs no allowlist entry.
That non-refusal is about run, and TA8.41 made the distinction explicit
rather than leaving this paragraph to imply the wider claim. Structured
output is a blocking-turn contract: the two streaming entry points refuse
the argument by name, above the dispatch, in
:func:~symfonic.agent.cutover.lifecycle_refusals.refuse_streaming_structured_output.
They refuse there and not here for the reason the retired-configuration
guard gives -- a verdict-driven refusal would be route-conditional, and this
envelope is consulted on neither route when the switch is rolled back.
human is the pause transport a composition root set on the agent
(_human_interaction), threaded in for the reason topology is: it is
readable from neither config nor the call, so a check that did not
receive it could only admit it by silence. It is guarded against the same
bundle that will serve the turn -- see :func:_human_refusal.
bundle is the composition root's authorised
:class:~.bundle.RetrievalBundle. It is the only thing that lifts the
auto_hydrate=False pin, and it lifts it on evidence rather than on a
flag: both memory segments must be present and answer their operative
method. None means "nothing was authorised", which keeps the pin.
observability carries the handlers the constructor injected — the
metrics_collector and the OTEL callback bridge that
SymfonicAgent._with_metrics_callbacks prepends onto every legacy
runtime call. They are separate from the per-call callbacks argument
and have to be looked at separately: an agent built with a metrics
collector passes no callbacks at all, so a check that read only the
argument would admit the turn and the collector would simply stop
receiving events. That is why the parameter exists; since TA8.20 it no
longer refuses, because :data:ADMITTED_INJECTIONS names the consumer that
keeps those same two objects fed from the kernel event stream. The
parameter and its branch stay: the record of what this envelope has
considered is what is written here, and an injection admitted by silence
leaves none.
Source code in src/symfonic/agent/cutover/envelope.py
98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 | |
async_seam_argument ¶
The override argument, in one place, for every row that cites it.
A function rather than fourteen copies of a paragraph: the argument is one argument, and fourteen spellings of it would be fourteen chances for the reason to drift from the evidence.
Rewritten by TA8.51, because its first half stopped being true. TA8.38 wrote this when the port could not carry these rows at all; S01 built the port, so the sentence "the contract does not have an asynchronous source seam" would now be a false statement standing as fourteen rows' recorded reason. What survives unchanged is the half that actually keeps the rows refused: a port is not an admission, and nothing yet routes the configuration value through it.
Source code in src/symfonic/agent/cutover/prompt_block_contract.py
bind_admin_authority ¶
Bind an authenticated principal's admin bit for the turns inside.
claim is anything carrying principal_id and is_admin --
:class:~symfonic.platform.values.AuthenticatedPrincipal structurally, or
:class:AdminAuthority for a host whose verifier stashes facts on the
request. None is not an error -- a host that authenticated nobody says
so by having nothing to bind, and forcing it to construct an anonymous
claim would be forcing it to write the sentence this contract exists to
make unsayable -- and it binds False rather than binding nothing. That
difference only shows inside an outer binding, and it is the point:
None is what InvocationRequest.admin_claim returns for a non-admin
request, so a host reusing that spelling to scope a nested turn down
would otherwise keep the outer administrator's authority while this
function handed it False. The value yielded and the value
:func:admin_authority reads are one value in every case, which is the
only version of this contract that cannot be read backwards.
Refuses an administrator claim with no principal_id. A non-admin claim
with no id is merely uninformative and binds False, which is what it
would have meant anyway; an admin claim with no id is the anonymous boolean
wearing the replacement's clothes, and it is the one shape this function
exists to reject.
A context manager rather than a parameter, because the value has to survive
into an async generator the host returns and the caller drives later --
stream and stream_typed are consumed after their handler has
returned. The reset tolerates a token minted in another context rather than
raising: a generator driven from one task and closed from another would
otherwise turn a tidy-up into the turn's exception, and that closing
context never held this binding at all -- see the comment on the branch.
Source code in src/symfonic/agent/cutover/authority.py
clear_process_rollbacks ¶
Lift every process-wide override.
Module state that only grows is a test-isolation hazard: anything that
pins a capability here without restoring holds every agent constructed
later in the same process on legacy, and the symptom is unrelated tests
passing for the wrong reason. Resetting is therefore an exported call
rather than a loop over :func:process_rollbacks copied into each test
module — a fixture teardown has one obvious thing to invoke.
Deliberately unguarded by a reason: clearing an override is the safe direction, and an incident is exactly when nobody should have to enumerate what they turned on.
Source code in src/symfonic/agent/cutover/process.py
declared_inactive ¶
True when the row's own inactivity class calls value inactive.
Narrower than :func:is_inactive, which also answers True for the
stock default. Kept separate so "the default does not refuse" and "an
inactive value does not refuse" stay two claims rather than one.
Source code in src/symfonic/agent/cutover/config_retirement.py
governance_surfaces_for_subgroup ¶
Every row of one TA8.25 subgroup, in declaration order.
Parity is asserted per field and never aggregated, so the tests read the lane one row at a time rather than reporting a single verdict.
Source code in src/symfonic/agent/cutover/governance_surfaces.py
is_inactive ¶
True when value asks for nothing on setting.
The complement of :meth:RetiredSetting.is_use, exported so the contract
"emptiness is not use" can be asserted directly rather than inferred from a
refusal that did not happen -- which is also what a broken guard produces.
Source code in src/symfonic/agent/cutover/config_retirement.py
nested_pause_ttl_supplied ¶
The nested pause TTL this config actually asks for, or :data:MISSING.
Emptiness is not use, the rule TA8.26 states for the twenty-five retired
fields and TA8.41 restates for the container. AgentConfig's stock value
is None, which is what a caller who wants nothing passes, so only a
value that differs from it is a request.
Answers :data:~symfonic.agent.cutover.baseline.MISSING -- "nothing to
refuse" -- when the question cannot be put: a config object with no
agent attribute, or an agent with no such field. That is the one
place this rule does not fail closed, and it is the same bounded skip
retired_setting_supplied takes for the same reason: cutover
reflects over whatever object it is handed, and an object without the
attribute cannot be asking for the behaviour.
The attribute is spelled out as a literal chain rather than read through
:data:NESTED_TTL_PATH, so TA8.24's provenance detector can cite this
function as the row's consumer; one test holds the two spellings together.
Source code in src/symfonic/agent/cutover/pause_policy.py
pause_surfaces_for_subgroup ¶
Every row of one TA8.25 subgroup, in declaration order.
Parity is asserted per field and never aggregated, so the tests read the lane one subgroup at a time rather than reporting a single verdict.
Source code in src/symfonic/agent/cutover/pause_surfaces.py
process_rollback_reason ¶
Why capability is held on legacy process-wide, or None.
process_rollbacks ¶
prompting_rows_for_subgroup ¶
Every row of one TA8.25 subgroup, in declaration order.
Parity is asserted per field and never aggregated across the lane, so the tests read the lane one subgroup at a time rather than iterating the whole mapping and reporting a single verdict.
Source code in src/symfonic/agent/cutover/prompting_surfaces.py
refuse_disabled_streaming ¶
Refuse a streaming turn on an agent whose configuration disabled it.
Called from stream and stream_typed in the position the bare
SymfonicAgentError guard occupied, which is above the cutover dispatch
on purpose: the field governs the entry point, so honouring it must not
depend on which body would have served the turn.
Source code in src/symfonic/agent/cutover/lifecycle_refusals.py
refuse_replaced_pause_setting ¶
Refuse the nested pause TTL on entry_point, or return.
The nested half of C1-B's REPLACE, expressed once and called from all three
public entry points. The position, and the reasons for it, are
:func:~symfonic.agent.cutover.config_retirement.refuse_retired_setting's.
There is no detail parameter and no release-line branch, for that
function's reasons: the next step comes from :data:PAUSE_SURFACES, so no
call site can forget to carry one, and 9.12 and 10.4 keep the field in their
own artefacts because this module is not backported.
Source code in src/symfonic/agent/cutover/pause_policy.py
refuse_retired_argument ¶
refuse_retired_argument(argument: str, *, entry_point: str, received: Iterable[str] = ()) -> NoReturn
Refuse argument on entry_point, citing the line that retired it.
received names the keywords that actually arrived. It matters for
state_overrides, which is an open **kwargs map rather than a named
parameter: without it the refusal would say "state_overrides" to an adopter
who typed sesion_id= and never wrote that word.
There is no detail parameter, and its absence is the same guarantee
:func:refuse_retired_lever buys by making one required: the next step
comes from :data:RETIRED_ARGUMENTS, so every refusal carries one and no
call site can forget to pass it.
Source code in src/symfonic/agent/cutover/retirement.py
refuse_retired_setting ¶
Refuse setting on entry_point, citing the line that retired it.
There is no detail parameter, and its absence is the guarantee
:func:~symfonic.agent.cutover.retirement.refuse_retired_lever buys by
making one required: the next step comes from
:data:~symfonic.agent.cutover.retired_settings.RETIRED_SETTINGS, so every
refusal carries one and no call site can forget to pass it.
Source code in src/symfonic/agent/cutover/config_retirement.py
refuse_self_asserted_admin ¶
Refuse is_admin=True on entry_point, or return.
Emptiness is not use, the rule TA8.26 states for the twenty-five retired
fields: is_admin=False is what a caller who claims nothing passes and it
is also the parameter's default, so only a truthy value is a request. The
keyword stays in the signature for the reason
:mod:~symfonic.agent.cutover.config_retirement gives for keeping the
fields on the model -- the failure is then a named error citing the line
rather than a bare TypeError.
There is no detail parameter, and its absence is the guarantee
:func:~symfonic.agent.cutover.retirement.refuse_retired_lever buys by
making one required: the next step comes from
:data:~symfonic.agent.cutover.governance_surfaces.GOVERNANCE_SURFACES, so
every refusal carries one and no call site can forget to pass it.
Source code in src/symfonic/agent/cutover/authority.py
refuse_streaming_structured_output ¶
Refuse response_model on a streaming entry point, or return.
None is not a request, so the ordinary streaming turn never reaches the
raise: emptiness is not use, the same rule TA8.26 states for the retired
configuration fields.
Source code in src/symfonic/agent/cutover/lifecycle_refusals.py
refuse_unaddressable_transcript ¶
Require a checkpoint identity when persistence is on, or return.
The precondition is the same one ask_user_enabled has enforced since
v7.1.1, and it is stated here in the same words for the same cause: both
flags wire the checkpointer, and a checkpoint bound to an ephemeral
LangGraph-generated thread cannot be found again.
It is a refusal rather than a generated fallback identity because a
transcript is only worth persisting if it can be read back, and the reader
-- SymfonicAgent.get_transcript(scope=..., session_id=...) -- takes
exactly the two things this turn declined to supply. Minting a per-run key
here would produce a durable row no API can address, which is the
accepted-then-no-op outcome the 11.0 guards exist to end.
Route-independent and above the dispatch, for
:func:refuse_disabled_streaming's reasons.
Source code in src/symfonic/agent/cutover/lifecycle_refusals.py
refuse_unowned_container ¶
Reject the container's own semantics on entry_point, or return.
The whole of C1-A's REJECT, expressed once and called from all three public
entry points -- the position, and the reasons for it, are
:func:~symfonic.agent.cutover.config_retirement.refuse_retired_setting's:
a verdict-driven rejection would be route-conditional, and a rolled-back
switch would re-honour what this line rejects.
Source code in src/symfonic/agent/cutover/container_semantics.py
register_for ¶
The coverage register named capability, or a ConfigurationError.
Registers answer "was this built and verified?" -- never "what serves this turn?". Asking a switch for evidence is the mirror mistake and fails the same way.
Source code in src/symfonic/agent/cutover/registers.py
restore_process_wide ¶
Lift the process-wide override. Per-board rollbacks are untouched.
retired_argument_supplied ¶
The first retired argument actually supplied, in table order, or None.
The single place that decides "was a retired argument used?", so the guard
loop's answer and the entry points' answer cannot drift. Emptiness is not
use: callbacks=None and callbacks=[] are what an adopter who
attaches nothing passes, and refusing them would retire the parameter
rather than the behaviour.
An unknown keyword raises rather than being skipped. A silent skip would turn a misspelling here into a guard that quietly stopped guarding.
Source code in src/symfonic/agent/cutover/retirement.py
retired_setting_supplied ¶
The first retired field config actually uses, in table order, or None.
The single place that decides "was a retired configuration field used?", so every entry point answers it identically.
A field the object does not carry at all is skipped rather than refused.
cutover reflects over whatever config object it is handed — the
envelope's whole design — and an object without the attribute cannot be
asking for the behaviour. That is the one place this guard does not fail
closed, and it is bounded by the coverage test: every name in the table is
a live FrameworkConfig field, so a skip means a foreign object, never a
renamed field.
Source code in src/symfonic/agent/cutover/config_retirement.py
rollback_process_wide ¶
Refused since 11.0: the process-wide rollback lever is retired.
Raises :class:~symfonic.agent.cutover.retirement.RetiredLeverError,
naming the line and the capability. reason is optional only so that a
call written from muscle memory refuses by name rather than raising
TypeError; it is not read, and nothing is recorded.
switch_for runs first so a mistyped capability still reports itself as
unknown rather than as retired.
Source code in src/symfonic/agent/cutover/process.py
surfaces_for_subgroup ¶
Every row of one TA8.25 subgroup, in declaration order.
Parity is asserted per field and never aggregated across the lane, so the tests read the lane one subgroup at a time rather than iterating the whole mapping and reporting a single verdict.
Source code in src/symfonic/agent/cutover/admission_surfaces.py
switch_for ¶
The switch named capability, or a ConfigurationError.
Unknown names fail loudly rather than defaulting: a mistyped capability that silently answered "legacy" would be a cutover nobody notices did not happen.
Source code in src/symfonic/agent/cutover/routes.py
tool_result_surfaces_for_subgroup ¶
Every row of one TA8.25 subgroup, in declaration order.
Per subgroup rather than per lane for the reason tools_surfaces gives:
eight rows sharing a stamp is not eight rows sharing evidence.
Source code in src/symfonic/agent/cutover/tool_result_surfaces.py
tools_surfaces_for_subgroup ¶
Every row of one TA8.25 subgroup, in declaration order.
Parity is asserted per field and never aggregated across the lane: these rows share a branch because they share files, not because they share evidence, and a helper that returned one verdict for six rows would be the aggregation this lane's acceptance forbids.
Source code in src/symfonic/agent/cutover/tools_surfaces.py
unowned_container_semantics ¶
Why config.agent carries unowned semantics, or None.
Answers None -- "nothing attributed to the container itself" -- for the
ordinary case, which is every configuration whose agent is a stock-class
container carrying only owned children.
It also answers None when the question cannot be put: a config object
with no agent attribute at all, or one whose class cannot be
default-constructed to supply a stock container to compare against. That is
the one place this rule does not fail closed, and it is deliberate and
bounded: those objects are refused by
:func:~symfonic.agent.cutover.envelope._config_refusal on their own terms,
so answering "refuse" here would name the container for a defect that is not
the container's.