Skip to content

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

AdminAuthority(principal_id: str, is_admin: bool = False)

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.

citations property

citations: tuple[str, ...]

Every citation actually filed, for a checker to resolve.

required property

required: tuple[str, ...]

The citations this kind of register owes.

unfiled property

unfiled: tuple[str, ...]

The owed citations still absent.

verified property

verified: bool

Every owed citation filed. Says nothing about what serves a turn.

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, in src/.
  • :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
def __init__(
    self,
    *,
    criteria: Mapping[str, SwitchCriteria] | None = None,
    recorder: CutoverCriteriaRecorder | None = None,
    pin: LegacyPin | None = None,
) -> None:
    #: TA8.54. The build's legacy pin, resolved by
    #: ``symfonic.agent.cutover.legacy_pin.build_legacy_pin`` from the
    #: release profile. Not a lever and not a rollback: an operator cannot
    #: reach it from a running process, and a pin is set where the agent is
    #: constructed or it is not set at all.
    self._pin: LegacyPin = NO_LEGACY_PIN if pin is None else pin
    self._criteria: dict[str, SwitchCriteria] = default_criteria()
    for capability, record in (criteria or {}).items():
        switch_for(capability)
        self._criteria[capability] = record
    self._recorder = recorder or CutoverCriteriaRecorder(ExtensionTrustRegistry())
    self._rolled_back: dict[str, str] = {}
    self._fallbacks: dict[str, dict[str, int]] = {}
    for capability, record in self._criteria.items():
        if not record.complete:
            refuse_retired_lever(
                "unfiled-criteria",
                capability,
                detail=(
                    "Its cutover criteria are unfiled (missing "
                    f"{', '.join(record.missing)}), and an unfiled switch "
                    f"no longer degrades to legacy on the "
                    f"{LEVER_RETIREMENT_LINE} line -- it refuses to build "
                    "the board. Nobody asked for the legacy body here: "
                    "this is what used to happen when the evidence was "
                    "simply missing, which is the one route change that "
                    "was never anyone's decision. File the citation, or "
                    "remove the capability from the ledger."
                ),
            )
        self._file(capability, record)

legacy_pin property

legacy_pin: LegacyPin

The build's pin, as a value. NO_LEGACY_PIN when none was set.

describe

describe() -> str

One line per switch. Names and citations only — never a credential.

Source code in src/symfonic/agent/cutover/switchboard.py
def describe(self) -> str:
    """One line per switch. Names and citations only — never a credential."""
    return "\n".join(
        f"{decision.capability}: {decision.route.value} ({decision.reason})"
        for decision in self.decisions()
    )

file

file(capability: str, criteria: SwitchCriteria) -> CutoverEvidence

File this capability's criteria; flips the switch when complete.

Source code in src/symfonic/agent/cutover/switchboard.py
def file(self, capability: str, criteria: SwitchCriteria) -> CutoverEvidence:
    """File this capability's criteria; flips the switch when complete."""
    switch_for(capability)
    if not criteria.complete:
        raise CutoverPathError(
            f"{capability!r} is missing non-shadow cutover criteria: "
            + ", ".join(criteria.missing)
        )
    evidence = self._file(capability, criteria)
    self._criteria[capability] = criteria
    self._rolled_back.pop(capability, None)
    return evidence

record_fallback

record_fallback(capability: str, reason: str) -> None

Count one invocation that a flipped switch still served on legacy.

Source code in src/symfonic/agent/cutover/switchboard.py
def record_fallback(self, capability: str, reason: str) -> None:
    """Count one invocation that a flipped switch still served on legacy."""
    switch_for(capability)
    counts = self._fallbacks.setdefault(capability, {})
    counts[reason] = counts.get(reason, 0) + 1

restore

restore(capability: str) -> None

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
def restore(self, capability: str) -> None:
    """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.
    """
    switch_for(capability)
    record = self._criteria[capability]
    if not record.complete:
        raise CutoverPathError(
            f"{capability!r} cannot be restored to the migrated path: "
            "missing " + ", ".join(record.missing)
        )
    held = process_rollback_reason(capability)
    if held is not None:
        raise CutoverPathError(
            f"{capability!r} is held on legacy process-wide ({held}); lift "
            "it with symfonic.agent.cutover.restore_process_wide before "
            "restoring this agent"
        )
    if self._pin.holds(capability):
        # Same argument as the process-wide hold above: clearing the
        # instance flag would return LEGACY anyway, and a restore that
        # reports success without moving the route is how an operator
        # concludes the lever is broken. A pin is unset by rebuilding the
        # agent, because that is where it was set.
        raise CutoverPathError(
            f"{capability!r} is pinned to legacy by this build "
            f"({self._pin.describe()}); a pin is not a lever and cannot be "
            "lifted from a running process. Rebuild the agent without it."
        )
    self._rolled_back.pop(capability, None)

rollback

rollback(capability: str, *, reason: str | None = None) -> None

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
def rollback(self, capability: str, *, reason: str | None = None) -> None:
    """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.
    """
    switch_for(capability)
    refuse_retired_lever(
        "board-rollback",
        capability,
        detail=(
            "It cannot be put back on its legacy body: the route is "
            "derived from filed evidence and there is no "
            "supported way to move it by hand. If the migrated path is "
            "serving turns wrongly, that is a defect to fix or a release "
            "to roll back -- not a route to flip. Installs on 10.4 and "
            "below keep this lever for the length of the compatibility "
            "window; it is retired on this line and not backported."
        ),
    )

rollback_reason

rollback_reason(capability: str) -> str | None

Why this capability is on legacy: process-wide reason wins.

Source code in src/symfonic/agent/cutover/switchboard.py
def rollback_reason(self, capability: str) -> str | None:
    """Why this capability is on legacy: process-wide reason wins."""
    switch_for(capability)
    return process_rollback_reason(capability) or self._rolled_back.get(capability)

route_for

route_for(capability: str) -> Route

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
def route_for(self, capability: str) -> Route:
    """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.
    """
    switch_for(capability)
    if process_rollback_reason(capability) is not None:
        return Route.LEGACY
    if capability in self._rolled_back:
        return Route.LEGACY
    if self._pin.holds(capability):
        return Route.LEGACY
    return Route.KERNEL if self._criteria[capability].complete else Route.LEGACY

EnvelopeVerdict dataclass

EnvelopeVerdict(admitted: bool, reason: str | None = None)

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
def __init__(
    self,
    *,
    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,
) -> None:
    """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.
    """
    self._observability = observability
    self._delegation = getattr(bundle, "delegation", None)
    self._history_cap = history_cap(max_conversation_messages)
    self._plans = AgentPlanFactory(
        model_provider=model_provider,
        instructions=instructions,
        tools=merge_capability_tools(tools, getattr(bundle, "tools", ())),
        max_model_rounds=rounds_for_recursion_limit(recursion_limit),
        model=model,
        # TA8.60. Not ``dict(role_models or {})``: that raises on a foreign
        # value, on only the doors that build a delegate, so they disagree.
        role_models=dict(role_models) if isinstance(role_models, Mapping) else {},
        stages=getattr(bundle, "stages", ()),
        stage_handlers=getattr(bundle, "stage_handlers", None) or {},
        authorized_effects=authorised_effects(bundle),
        capability_names=getattr(bundle, "capability_names", ()),
    )

kernel_typed_stream

kernel_typed_stream(plan: Any, request: TurnRequest) -> Any

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
def kernel_typed_stream(self, plan: Any, request: TurnRequest) -> Any:
    """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.
    """
    return _KERNEL.stream_typed(plan, request)

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
async def run(
    self,
    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.
    """
    request = turn_request(
        query, scope, history, attachments, cap=self._history_cap, run_id=run_id
    )
    observed = for_turn(
        self._observability,
        run_id=run_id,
        session_id=session_id,
        tenant_id=tenant_id,
        entry_point="run",
        prompt=query,
        request=request,
    )
    plan = self._plans.compile(response_model, sink_factory=observed)
    try:
        # Around the *kernel* call, not the compile: the plan is compiled
        # once for the agent's life, so a depth burned into it would make
        # one agent answer at one depth forever.
        async with self._delegation_scope(agent_depth):
            result = await _KERNEL.run(plan, request)
    finally:
        # Idempotent and safe after a normal terminal, so it is unconditional
        # rather than reached only on the error path: the case it exists for
        # is the run that never reached a terminal at all, and that run does
        # not announce itself.
        if observed is not None:
            await observed.release()
    return as_response(result, run_id=run_id, session_id=session_id)

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
async def stream(
    self,
    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.
    """
    request = turn_request(
        query, scope, history, attachments, cap=self._history_cap, run_id=run_id
    )
    observed = for_turn(
        self._observability,
        run_id=run_id,
        session_id=session_id,
        tenant_id=tenant_id,
        entry_point="stream",
        prompt=query,
        request=request,
    )
    plan = self._plans.compile(response_model, sink_factory=observed)
    # ``InvocationKernel`` is binding-agnostic and therefore exposes its
    # projected stream as kernel events. This delegate always compiles an
    # ``AgentPlanFactory`` response binding, whose ``build_event`` output
    # is the public ``AgentEvent`` consumed by ``_as_chunk`` below.
    stream = cast(
        AsyncIterator[AgentEvent],
        _KERNEL.stream(plan, request),
    )
    try:
        # Held open for the whole drain: a hand-off happens mid-stream, and
        # a scope closed after the first chunk would read depth ``0``.
        async with self._delegation_scope(agent_depth):
            async for event in stream:
                chunk = as_chunk(event, run_id=run_id)
                if chunk is not None:
                    yield chunk
    finally:
        await closing(stream)
        if observed is not None:
            await observed.release()

stream_typed

stream_typed(query: str, *, run_id: str, **options: Any) -> AsyncIterator[Any]

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
def stream_typed(
    self, query: str, *, run_id: str, **options: Any
) -> AsyncIterator[Any]:
    """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.
    """
    from symfonic.agent.cutover.typed_route import typed_route

    return typed_route(self, query, run_id=run_id, **options)

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_enabled on run is 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

overridden: bool

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

RecursionExhaustedError(message: str, code: str | None = None)

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
def __init__(self, message: str, code: str | None = None) -> None:
    SymfonicAgentError.__init__(self, message, code)

ReplacedPauseSettingError

ReplacedPauseSettingError(setting: str, entry_point: str, group: str, message: str)

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
def __init__(
    self, setting: str, entry_point: str, group: str, message: str
) -> None:
    super().__init__(message)
    #: Which retired field was set, spelled as ``FrameworkConfig`` spells it.
    self.setting = setting
    #: The entry point it reached (``run`` / ``stream`` / ``stream_typed``),
    #: so a handler can tell a refused blocking turn from a refused stream
    #: without parsing prose. Every entry point refuses; the attribute
    #: records which one was asked, not which ones would have refused.
    self.entry_point = entry_point
    #: The contractual group the field refuses with. Named on the exception
    #: because "this whole family of legacy consolidation dials is gone" is
    #: a different piece of news from "this one field is gone", and an
    #: adopter migrating twelve settings should be told once.
    self.group = group
    #: The line that retired it. Read from
    #: :data:`~symfonic.agent.cutover.retirement.LEVER_RETIREMENT_LINE`
    #: rather than re-spelled, so the attribute and the message cannot
    #: disagree with the levers or the arguments.
    self.line = LEVER_RETIREMENT_LINE

RetiredArgument dataclass

RetiredArgument(call: str, instead: str)

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

RetiredArgumentError(argument: str, entry_point: str, message: str)

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
def __init__(self, argument: str, entry_point: str, message: str) -> None:
    super().__init__(message)
    #: Which retired argument was supplied.
    self.argument = argument
    #: The entry point it was supplied to (``run`` / ``stream`` /
    #: ``stream_typed``), so a handler can tell a refused blocking turn
    #: from a refused stream without parsing prose.
    self.entry_point = entry_point
    #: The line that retired it. Read from :data:`LEVER_RETIREMENT_LINE`
    #: rather than re-spelled, so the attribute and the message cannot
    #: disagree.
    self.line = LEVER_RETIREMENT_LINE

RetiredConfigurationError

RetiredConfigurationError(setting: str, entry_point: str, group: str, message: str)

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
def __init__(
    self, setting: str, entry_point: str, group: str, message: str
) -> None:
    super().__init__(message)
    #: Which retired field was set, spelled as ``FrameworkConfig`` spells it.
    self.setting = setting
    #: The entry point it reached (``run`` / ``stream`` / ``stream_typed``),
    #: so a handler can tell a refused blocking turn from a refused stream
    #: without parsing prose. Every entry point refuses; the attribute
    #: records which one was asked, not which ones would have refused.
    self.entry_point = entry_point
    #: The contractual group the field refuses with. Named on the exception
    #: because "this whole family of legacy consolidation dials is gone" is
    #: a different piece of news from "this one field is gone", and an
    #: adopter migrating twelve settings should be told once.
    self.group = group
    #: The line that retired it. Read from
    #: :data:`~symfonic.agent.cutover.retirement.LEVER_RETIREMENT_LINE`
    #: rather than re-spelled, so the attribute and the message cannot
    #: disagree with the levers or the arguments.
    self.line = LEVER_RETIREMENT_LINE

RetiredLeverError

RetiredLeverError(lever: str, capability: str, message: str)

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
def __init__(self, lever: str, capability: str, message: str) -> None:
    super().__init__(message)
    #: Which retired lever was pulled, in the probe's vocabulary.
    self.lever = lever
    #: The capability the caller asked for. Named on the exception as well
    #: as in the message so a handler can act without parsing prose.
    self.capability = capability

RetiredSetting dataclass

RetiredSetting(group: str, stock: Any, inactive: str, instead: str)

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

is_use(value: Any) -> bool

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
def is_use(self, value: Any) -> bool:
    """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".
    """
    if equivalent(value, self.stock):
        return False
    try:
        return not INACTIVE_CLASSES[self.inactive](value)
    except Exception:  # noqa: BLE001 - an unanswerable value is a refusal
        return True

Route

Bases: StrEnum

Which implementation of a capability serves an invocation.

SelfAssertedAuthorityError

SelfAssertedAuthorityError(argument: str, entry_point: str, message: str)

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
def __init__(self, argument: str, entry_point: str, message: str) -> None:
    super().__init__(message)
    #: Which retired argument was supplied.
    self.argument = argument
    #: The entry point it was supplied to (``run`` / ``stream`` /
    #: ``stream_typed``), so a handler can tell a refused blocking turn
    #: from a refused stream without parsing prose.
    self.entry_point = entry_point
    #: The line that retired it. Read from :data:`LEVER_RETIREMENT_LINE`
    #: rather than re-spelled, so the attribute and the message cannot
    #: disagree.
    self.line = LEVER_RETIREMENT_LINE

StreamingDisabledError

StreamingDisabledError(entry_point: str, message: str)

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
def __init__(self, entry_point: str, message: str) -> None:
    super().__init__(message)
    #: The streaming entry point that was asked. Recorded because a
    #: deployment that disables streaming usually calls exactly one of the
    #: two, and "which surface did my caller reach for" is the question the
    #: operator actually has.
    self.entry_point = entry_point
    #: The field that declined. Named on the exception so a handler does
    #: not have to match on wording.
    self.setting = "streaming_enabled"

StructuredOutputUnsupportedError

StructuredOutputUnsupportedError(entry_point: str, message: str)

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
def __init__(self, entry_point: str, message: str) -> None:
    super().__init__(message)
    self.entry_point = entry_point
    self.parameter = "response_model"

SwitchCriteria dataclass

SwitchCriteria(parity_suite: str | None = None, characterization: str | None = None, live_cutover: str | None = None, opaque_dependencies: tuple[str, ...] = (), preconditions: tuple[str, ...] = ())

One capability's three non-shadow criteria, each a citation or None.

missing property

missing: tuple[str, ...]

The criteria still absent, in the order the recorder names them.

as_criteria

as_criteria() -> dict[str, Any]

The mapping CutoverCriteriaRecorder.record_non_shadow expects.

Source code in src/symfonic/agent/cutover/criteria.py
def as_criteria(self) -> dict[str, Any]:
    """The mapping ``CutoverCriteriaRecorder.record_non_shadow`` expects."""
    return {name: getattr(self, name) for name in NON_SHADOW_CRITERIA}

SwitchDecision dataclass

SwitchDecision(capability: str, route: Route, reason: str, switch: CapabilitySwitch)

Why one capability is on the route it is on.

UnaddressableTranscriptError

UnaddressableTranscriptError(entry_point: str, message: str)

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
def __init__(self, entry_point: str, message: str) -> None:
    super().__init__(message, "bad_request")
    self.entry_point = entry_point
    self.setting = "transcript_persistence_enabled"

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

UnownedContainerError(setting: str, entry_point: str, group: str, message: str)

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
def __init__(
    self, setting: str, entry_point: str, group: str, message: str
) -> None:
    super().__init__(message)
    #: Which retired field was set, spelled as ``FrameworkConfig`` spells it.
    self.setting = setting
    #: The entry point it reached (``run`` / ``stream`` / ``stream_typed``),
    #: so a handler can tell a refused blocking turn from a refused stream
    #: without parsing prose. Every entry point refuses; the attribute
    #: records which one was asked, not which ones would have refused.
    self.entry_point = entry_point
    #: The contractual group the field refuses with. Named on the exception
    #: because "this whole family of legacy consolidation dials is gone" is
    #: a different piece of news from "this one field is gone", and an
    #: adopter migrating twelve settings should be told once.
    self.group = group
    #: The line that retired it. Read from
    #: :data:`~symfonic.agent.cutover.retirement.LEVER_RETIREMENT_LINE`
    #: rather than re-spelled, so the attribute and the message cannot
    #: disagree with the levers or the arguments.
    self.line = LEVER_RETIREMENT_LINE

admin_authority

admin_authority() -> bool

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
def admin_authority() -> bool:
    """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.
    """
    return _ADMIN_AUTHORITY.get()

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 configgraph_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
def 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.
    """
    hydration = _hydration_refusal(config, bundle)
    if hydration is not None:
        return _refuse(hydration)

    prompting = _prompting_refusal(config, bundle)
    if prompting is not None:
        return _refuse(prompting)

    delegation = _delegation_refusal(sub_agents, bundle)
    if delegation is not None:
        return _refuse(delegation)

    extensions = _extensions_refusal(plugins, bundle)
    if extensions is not None:
        return _refuse(extensions)

    human_interaction = _human_refusal(human, bundle)
    if human_interaction is not None:
        return _refuse(human_interaction)

    if topology is not None and topology != MIGRATED_TOPOLOGY:
        return _refuse(
            f"graph_preset={topology!r} is not the {MIGRATED_TOPOLOGY!r} "
            "topology the migrated path compiles; serving it on the kernel "
            "would replace the agent's chosen pipeline"
        )

    config_refusal = _config_refusal(config, bundle)
    if config_refusal is not None:
        return _refuse(config_refusal)

    # Constructor-injected handler sets. Default-DENY through
    # :data:`ADMITTED_INJECTIONS`, the same way the per-call loop below reads
    # :data:`ADMITTED_ARGUMENTS` -- an injection with no named consumer refuses.
    #
    # ``observability`` is the one entry, and TA8.20 replaced a flat refusal
    # with it rather than deleting the branch. The refusal said "the migrated
    # path emits no callback events", which was true and is no longer: the
    # kernel event stream now reaches the metrics collector and the OTEL bridge
    # through ``ServiceBindings.event_sink``, bound at the compile seam by
    # ``symfonic.agent.cutover.observability.ObservabilitySuite``. Deleting the
    # branch would have admitted the injection by *absence*, which is the
    # unreadable outcome ``policy.py`` argues against for arguments and which
    # here would also have admitted any future injection that was never
    # considered.
    if observability and "observability" not in ADMITTED_INJECTIONS:
        return _refuse(
            "a metrics_collector or OTEL callback bridge is attached to this "
            "agent and no consumer on the migrated path is named for it; the "
            "observability capability's cutover switch is not flipped"
        )

    # Per-call arguments. Default-DENY, same as the config half: an argument
    # refuses by name unless :data:`ADMITTED_ARGUMENTS` carries an entry naming
    # the consumer that honours it. Adding a keyword to ``run``/``stream`` and
    # forgetting it here refuses the turn rather than serving it half-read.
    #
    # ``scope`` is the one entry today, and it took three steps to earn it. It
    # was refused outright while nothing on the migrated path consumed a scope
    # -- a turn that named a tenant would have been served from whatever scope
    # the plan was compiled with. TA8.8 lifted it *conditionally*, on a bundle
    # that serves hydration or the system prompt, because a scope propagated to
    # a capability that cannot read it changes nothing and hides that it
    # changed nothing.
    #
    # TA8.11 dropped the condition, and dropped it by removing what made it
    # necessary rather than by weakening the rule. The condition existed
    # because a bundle-less turn had no reader for the scope; it now has two
    # that do not depend on a bundle at all -- the tenant budget breaker and
    # AGENT_IDENTITY seeding, both in ``SymfonicAgent._apply_scope_effects``,
    # which the kernel branch calls in the position the legacy body runs it.
    # Leaving the condition in place would have sent every bundle-less
    # multi-tenant turn to legacy for a reason that had stopped being true.

    # ``history`` and ``attachments`` leave the loop below unconditionally, and
    # unlike ``scope`` they need no predicate — there is nothing for one to be
    # conditional on. Both are per-call arguments that reach the same
    # ``PromptAssembly`` and the same ``build_request`` call on every admitted
    # turn; there is no configuration under which one of them is carried and
    # the other dropped, so a condition here could only ever be true.
    #
    # The allowlist statement each of them is lifted on, stated once:
    #
    # * ``history`` — ``KernelDelegate`` trims it with
    #   ``symfonic.agent.engine._pair_aware_history_slice``, the *same function*
    #   ``_legacy_run_impl`` calls, at the same
    #   ``config.agent.max_conversation_messages``, then carries it to
    #   ``TurnRequest.history`` -> ``PromptAssembly.history`` ->
    #   ``ConversationAdapter.open_turn`` -> ``build_request``, which replays it
    #   between the system message and the user turn. That field itself stays
    #   refused below the loop: honouring the cap is what a future allowlist
    #   entry for it would need, not the entry itself.
    # * ``attachments`` — carried unconverted to ``TurnRequest.attachments`` and
    #   consumed by ``symfonic.agent._content_blocks._build_human_content``,
    #   again the same function the legacy body calls, on a family computed by
    #   the same ``_detect_provider_family`` against the same provider object.
    #
    # Neither is lifted on the existence of a *field*: TA8.10 files a separate
    # A/B for each, because they have different consumers and different failure
    # modes and an aggregate pass would hide one of them failing.
    #
    # ``session_id`` leaves the loop in TA8.19, on its own entry and its own
    # A/B, never aggregated with another argument's. What earned it is
    # ``SymfonicAgent._resolve_session``: one derivation site that both routes
    # call in the position the three legacy bodies inlined it, so issuance,
    # cross-tenant collision refusal and activity tracking happen on the kernel
    # route too and the *resolved* id -- not the caller's -- is what reaches
    # ``KernelDelegate.run``.
    #
    # Read the direction of that fix carefully, because it is the opposite of
    # what a guard can do. The loop below tests ``if value``, so it only ever
    # saw a *truthy* session id, and that case was already safe: it refused,
    # loudly, and fell back. The broken case was the falsy one -- an admitted
    # stateless turn returned ``AgentResponse.session_id == ""`` and registered
    # nothing, with zero recorded fallbacks, because there was no value to
    # refuse. No refusal reaches that; only a consumer does. Admitting the
    # argument is what closes it.


    # ``callbacks``, ``extra_metadata`` and ``state_overrides`` are a third
    # answer since TA8.18: not admitted, and not waiting on a switch either --
    # retired on the 11.0 line, with :data:`RETIRED_ARGUMENTS` naming what the
    # adopter does instead. All three stay in the loop below rather than
    # leaving it, for the reason ``policy.py`` gives about admission by
    # absence: an argument that vanishes from the tuple leaves no record of
    # why, and "retired by absence" is as unreadable as "admitted by absence".
    #
    # ``agent_depth`` and ``sub_agents`` leave the loop in TA8.12, on two
    # separate entries and two separate A/Bs. They are two arguments, not one,
    # and an aggregate pass would hide either of them failing -- which is the
    # rule TA8.10 set for ``history``/``attachments`` and it is not relaxed
    # because these two happen to migrate together.
    #
    # ``agent_depth`` refused until this task for a stated reason: lifting it
    # needed "a named consumer on the migrated side that reads the depth", and
    # ``KernelDelegate`` had none. It has one now --
    # ``symfonic.agent.cutover.delegate.KernelDelegate._delegation_scope``,
    # which opens ``DelegationCapability.run_scope`` at the turn's depth so the
    # ceiling check in ``DelegationTools.delegate`` sees the tree the turn is
    # actually in. The engine's ``_active_agent_depth`` context variable is
    # still set by ``_with_active_scope`` on both routes and is still not that
    # consumer: it keeps the identity-seed guard honest and is read by the
    # *legacy* graph's delegation tool, which an admitted turn never reaches.
    #
    # Read the direction of that fix the way TA8.19 read ``session_id``'s,
    # because it is the same one. The loop tests ``if value``, so it only ever
    # saw a depth of ``1`` or more; ``0`` and ``None`` are falsy and never
    # reached the branch on either spelling. A guard cannot close the falsy
    # case, and a value threaded to nothing is not migrated however loudly the
    # truthy case refuses. Only a consumer closes it, and admitting the
    # argument is what makes the consumer reachable.
    #
    # ``sub_agents`` is *conditional*, the way ``scope`` was in TA8.8 and for
    # the same reason: a child roster admitted onto a path with no way to reach
    # a child changes nothing and hides that it changed nothing. The condition
    # is ``bundle.serves_delegation()`` and it is checked above the loop, by
    # name, so the refusal says which half is missing rather than "sub_agents".
    #
    # ``plugins`` leaves the loop in TA8.21, *conditionally*, the way
    # ``sub_agents`` did and for the same reason: a plugin admitted onto a path
    # that harvests nothing changes nothing and hides that it changed nothing.
    # The condition is ``bundle.serves_extensions()`` and it is checked above
    # the loop, by name, so the refusal says which half is missing rather than
    # "plugins". What earned it is
    # ``symfonic.agent.cutover.extensions.ExtensionsCapability`` -- the
    # ``contribute()`` the RCH-1 waiver said nothing supplied -- whose
    # resolution stage harvests each plugin's hook per turn into the snapshot
    # the prompt compiler reads, and
    # ``symfonic.agent.cutover.extensions.enforce_guardrails``, which is the
    # single site ``SymfonicAgent.validate_action`` asks a contributed policy
    # from on *both* routes.
    #
    # It is admitted with its behaviour change written down first rather than
    # inferred afterwards: the tier/layer remap a bridged fragment undergoes is
    # ``evidence/RET-PREP/decision-plugin-tier-layer.md``, and it means the two
    # routes' prompt text is deliberately *not* equal. An A/B asserting byte
    # equality there would be asserting that decision was never taken.
    #
    # It is also **not** the reachability probe's next out-of-envelope driver,
    # and that is a decision rather than an oversight. The probe's ``out``
    # dimension is
    # refuse-*then*-degrade by definition, so whatever drives it produces a
    # legacy reach and the reported count only ever measures which argument was
    # chosen to stay unmigrated. TA8.12 retired the dimension instead of
    # repointing it a fourth time; the synthetic fixture that keeps the probe's
    # sensitivity under test lives in
    # ``tests/agent/cutover/synthetic_out_of_envelope.py`` and is a test
    # construct that no shipped code path can reach.

    # This function still *returns* for them. It is a router, and a router that
    # can raise makes every one of its callers a potential raiser; the
    # ``test_per_call_legacy_arguments_are_named`` case depends on that too.
    # The raise lives at the entry points instead
    # (``SymfonicAgent._refuse_retired_arguments``), which is why there is no
    # third field on :class:`EnvelopeVerdict`: the engine refuses before it
    # ever asks for a verdict, so a "retired" disposition here would be a state
    # nothing on the dispatch path could read. What this branch gives a *direct*
    # caller of ``admit_invocation`` is the reason, in the retirement's own
    # words rather than the switch's.
    for name, value in (
        ("scope", scope),
        ("session_id", session_id),
        ("history", history),
        ("attachments", attachments),
        ("callbacks", callbacks),
        ("extra_metadata", extra_metadata),
        ("state_overrides", state_overrides),
        ("agent_depth", agent_depth),
        ("sub_agents", sub_agents),
        ("plugins", plugins),
    ):
        if not value or name in ADMITTED_ARGUMENTS:
            continue
        if name in RETIRED_ARGUMENTS:
            return _refuse(
                f"{name} is not a legacy-engine argument waiting on a switch: "
                f"it was retired on the {LEVER_RETIREMENT_LINE} line. "
                f"{RETIRED_ARGUMENTS[name].instead}"
            )
        return _refuse(
            f"{name} is a legacy-engine argument; the capability that "
            "consumes it has not been flipped"
        )

    return EnvelopeVerdict(admitted=True)

async_seam_argument

async_seam_argument() -> str

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
def async_seam_argument() -> str:
    """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.
    """
    return (
        "the kernel prompting contribution port grew its asynchronous half in "
        "TA8.51 (S01): AsyncContributionSource.aread sits beside the unchanged "
        "ContributionSource.read, compile_prompt_async awaits it, and all "
        "fourteen rows are driven per row on the kernel's run, stream and "
        "stream_typed doors with the body newly appearing in the compiled "
        "prompt. That closes the seam TA8.38 recorded as missing and closes it "
        "only. These rows stay MISSING CONTRACT because a port is not an "
        "admission: nothing routes config.prompt_blocks into a "
        "PromptContribution, no per-row parity has been measured between a "
        "PromptBlockSpec member and the contribution member it maps onto, and "
        "ALLOWED_FRAMEWORK_FIELDS is unchanged. Admitting the field on the "
        "strength of the seam alone would assert that the migrated path honours "
        "the value at any value, which is the silent drop TA8.19 found for "
        "session_id wearing a newer excuse. The rows therefore stay refused to "
        "legacy, where they are still served; the contract they move to is "
        "published above; and the work that would move them is "
        f"{ADMISSION_FOLLOW_UP}"
    )

bind_admin_authority

bind_admin_authority(claim: Any) -> Iterator[bool]

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
@contextmanager
def bind_admin_authority(claim: Any) -> Iterator[bool]:
    """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.
    """
    if claim is None:
        is_admin = False
        principal_id = ""
    else:
        is_admin = bool(getattr(claim, "is_admin", False))
        principal_id = str(getattr(claim, "principal_id", "") or "").strip()
    if is_admin and not principal_id:
        raise UnauthenticatedAdminClaimError(
            "an admin claim must name the principal it was derived for: "
            f"{type(claim).__name__} carries is_admin=True and no "
            "principal_id. On the "
            f"{LEVER_RETIREMENT_LINE} line the admin bypass is a fact about an "
            "authenticated principal, so an anonymous claim is refused rather "
            "than honoured -- it is the retired is_admin=True keyword under a "
            "new name, and honouring it here would undo the replacement it is "
            "part of."
        )
    token = _ADMIN_AUTHORITY.set(is_admin)
    try:
        yield is_admin
    finally:
        # ``ValueError`` means the token was minted in another context -- the
        # shape ``_bound`` takes when sse-starlette drives the frames in one
        # task and closes the generator from another. Suppressed rather than
        # compensated for: the ``set`` landed in the *driving* task's context
        # and this one was never touched, so writing anything here (``False``,
        # or the value seen where the bind was entered, which in this shape is
        # the driving task's and not this context's) cannot reach the context
        # holding the binding and WOULD overwrite an outer binding held by the
        # closing task -- a silent de-escalation of somebody else's turn,
        # dressed as tidy-up. The driving task's copy dies with that task.
        with suppress(ValueError):
            _ADMIN_AUTHORITY.reset(token)

clear_process_rollbacks

clear_process_rollbacks() -> None

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
def clear_process_rollbacks() -> None:
    """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.
    """
    _ROLLED_BACK.clear()

declared_inactive

declared_inactive(setting: str, value: Any) -> bool

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
def declared_inactive(setting: str, value: Any) -> bool:
    """``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.
    """
    return bool(INACTIVE_CLASSES[RETIRED_SETTINGS[setting].inactive](value))

governance_surfaces_for_subgroup

governance_surfaces_for_subgroup(subgroup: str) -> tuple[GovernanceSurface, ...]

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
def governance_surfaces_for_subgroup(subgroup: str) -> tuple[GovernanceSurface, ...]:
    """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.
    """
    return tuple(
        surface
        for surface in GOVERNANCE_SURFACES.values()
        if surface.subgroup == subgroup
    )

is_inactive

is_inactive(setting: str, value: Any) -> bool

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
def is_inactive(setting: str, value: Any) -> bool:
    """``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.
    """
    retired = RETIRED_SETTINGS[setting]
    return not retired.is_use(value)

nested_pause_ttl_supplied

nested_pause_ttl_supplied(config: Any) -> Any

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
def nested_pause_ttl_supplied(config: Any) -> Any:
    """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.
    """
    container = getattr(config, "agent", MISSING)
    if container is MISSING:
        return MISSING
    value = getattr(container, "ask_user_pause_ttl_seconds", MISSING)
    if value is MISSING or value is None or equivalent(value, None):
        return MISSING
    return value

pause_surfaces_for_subgroup

pause_surfaces_for_subgroup(subgroup: str) -> tuple[PauseSurface, ...]

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
def pause_surfaces_for_subgroup(subgroup: str) -> tuple[PauseSurface, ...]:
    """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.
    """
    return tuple(
        surface for surface in PAUSE_SURFACES.values() if surface.subgroup == subgroup
    )

process_rollback_reason

process_rollback_reason(capability: str) -> str | None

Why capability is held on legacy process-wide, or None.

Source code in src/symfonic/agent/cutover/process.py
def process_rollback_reason(capability: str) -> str | None:
    """Why ``capability`` is held on legacy process-wide, or ``None``."""
    switch_for(capability)
    return _ROLLED_BACK.get(capability)

process_rollbacks

process_rollbacks() -> Mapping[str, str]

Every capability currently held on legacy process-wide, with reasons.

Source code in src/symfonic/agent/cutover/process.py
def process_rollbacks() -> Mapping[str, str]:
    """Every capability currently held on legacy process-wide, with reasons."""
    return MappingProxyType(dict(_ROLLED_BACK))

prompting_rows_for_subgroup

prompting_rows_for_subgroup(subgroup: str) -> tuple[PromptingSurface, ...]

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
def prompting_rows_for_subgroup(subgroup: str) -> tuple[PromptingSurface, ...]:
    """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.
    """
    return tuple(
        row for row in PROMPTING_SURFACES.values() if row.subgroup == subgroup
    )

refuse_disabled_streaming

refuse_disabled_streaming(entry_point: str, config: Any) -> None

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
def refuse_disabled_streaming(entry_point: str, config: Any) -> None:
    """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.
    """
    if getattr(config, "streaming_enabled", True):
        return
    raise StreamingDisabledError(
        entry_point,
        "Streaming is disabled in configuration: "
        f"FrameworkConfig(streaming_enabled=False), and {entry_point}() is a "
        f"streaming entry point. On the {LEVER_RETIREMENT_LINE} line this field "
        "governs stream() and stream_typed() and nothing else -- run() serves "
        "the same turn without a stream and is unaffected by it. Set "
        "streaming_enabled=True to stream, or call run().",
    )

refuse_replaced_pause_setting

refuse_replaced_pause_setting(entry_point: str, config: Any) -> None

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
def refuse_replaced_pause_setting(entry_point: str, config: Any) -> None:
    """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.
    """
    from symfonic.agent.cutover.settings_contract import RETIREMENT_GROUPS

    value = nested_pause_ttl_supplied(config)
    if value is MISSING:
        return
    surface = PAUSE_SURFACES[NESTED_TTL_PATH]
    raise ReplacedPauseSettingError(
        NESTED_TTL_PATH,
        entry_point,
        PAUSE_POLICY_GROUP,
        f"the {NESTED_TTL_PATH} setting (FrameworkConfig(agent=AgentConfig("
        f"ask_user_pause_ttl_seconds={brief(value)}))) was replaced on the "
        f"{LEVER_RETIREMENT_LINE} line, and {entry_point}() was asked for it. "
        f"It is replaced as part of the {PAUSE_POLICY_GROUP!r} group, which "
        f"{RETIREMENT_GROUPS[PAUSE_POLICY_GROUP]}. Configure the lifetime on "
        f"the capability instead: {surface.replaced_by}. What changes for you: "
        f"{surface.adopter_break}.",
    )

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
def 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.
    """
    retired = RETIRED_ARGUMENTS[argument]
    names = ", ".join(repr(name) for name in received)
    supplied = f" (keyword(s) {names})" if names else ""
    raise RetiredArgumentError(
        argument,
        entry_point,
        f"the {argument} argument ({retired.call}) was retired on the "
        f"{LEVER_RETIREMENT_LINE} line, and {entry_point}() was asked for it"
        f"{supplied}. {retired.instead}",
    )

refuse_retired_setting

refuse_retired_setting(setting: str, *, entry_point: str, value: Any) -> NoReturn

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
def refuse_retired_setting(setting: str, *, entry_point: str, value: Any) -> NoReturn:
    """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.
    """
    retired = RETIRED_SETTINGS[setting]
    raise RetiredConfigurationError(
        setting,
        entry_point,
        retired.group,
        f"the {setting} setting ({setting_call(setting, value)}) was retired "
        f"on the {LEVER_RETIREMENT_LINE} line, and {entry_point}() was asked "
        f"for it. It is retired as part of the {retired.group!r} group, which "
        f"{RETIREMENT_GROUPS[retired.group]}. {retired.instead}",
    )

refuse_self_asserted_admin

refuse_self_asserted_admin(entry_point: str, *, is_admin: Any) -> None

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
def refuse_self_asserted_admin(entry_point: str, *, is_admin: Any) -> None:
    """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.
    """
    if not is_admin:
        return
    _refuse(entry_point)

refuse_streaming_structured_output

refuse_streaming_structured_output(entry_point: str, response_model: Any) -> None

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
def refuse_streaming_structured_output(
    entry_point: str, response_model: Any
) -> None:
    """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.
    """
    if response_model is None:
        return
    raise StructuredOutputUnsupportedError(
        entry_point,
        f"response_model={getattr(response_model, '__name__', response_model)!r} "
        f"was supplied to {entry_point}(), which does not serve it. On the "
        f"{LEVER_RETIREMENT_LINE} line structured output is a blocking-turn "
        "contract: it is bound to the compiled plan and delivered on "
        "AgentResponse.structured, and neither streaming projection has a field "
        "to carry it. Call run(query, response_model=...) for the "
        "structured answer, or stream without it and parse the streamed text "
        "yourself. It is refused rather than accepted-and-dropped because a "
        "model that shaped nothing is the silent no-op this line exists to end.",
    )

refuse_unaddressable_transcript

refuse_unaddressable_transcript(entry_point: str, config: Any, scope: Any, session_id: Any) -> None

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
def refuse_unaddressable_transcript(
    entry_point: str, config: Any, scope: Any, session_id: Any
) -> None:
    """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.
    """
    if not getattr(config, "transcript_persistence_enabled", False):
        return
    if scope is not None and session_id:
        return
    raise UnaddressableTranscriptError(
        entry_point,
        "transcript_persistence_enabled=True requires both `scope` and "
        f"`session_id` on every turn, and {entry_point}() supplied "
        f"{'no scope' if scope is None else 'no session_id'}. The two derive "
        "the deterministic thread_id the checkpoint is filed under, and "
        "get_transcript(scope=..., session_id=...) is the only way to read it "
        "back -- a checkpoint bound to an ephemeral LangGraph-generated thread "
        "is written and then unaddressable. Supply both, or set "
        "transcript_persistence_enabled=False.",
    )

refuse_unowned_container

refuse_unowned_container(entry_point: str, config: Any) -> None

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
def refuse_unowned_container(entry_point: str, config: Any) -> None:
    """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.
    """
    reason = unowned_container_semantics(config)
    if reason is None:
        return
    raise UnownedContainerError(
        CONTAINER_ROW,
        entry_point,
        UNOWNED_CONTAINER_GROUP,
        f"{reason}. The agent container row was rejected on the "
        f"{LEVER_RETIREMENT_LINE} line, and {entry_point}() was asked for it. "
        f"It is rejected as part of the {UNOWNED_CONTAINER_GROUP!r} group, "
        "which names a value attributed to a typed grouping object rather than "
        f"to a capability any implementation on this line reads. {_INSTEAD}",
    )

register_for

register_for(capability: str) -> CoverageRegister

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
def register_for(capability: str) -> CoverageRegister:
    """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.
    """
    # Imported inside the function, because ``routes`` imports this module:
    # the mirror-mistake message is the one place a register has to know the
    # switch ledger, and a module-level import would make that one message a
    # circular dependency between the two halves of one split.
    from symfonic.agent.cutover.routes import CAPABILITY_SWITCHES
    from symfonic.core.contracts.errors import ConfigurationError

    try:
        return COVERAGE_REGISTERS[capability]
    except KeyError:
        pass
    if capability in CAPABILITY_SWITCHES:
        raise ConfigurationError(
            f"{capability!r} is a switch, not a coverage register: dispatch "
            "reads it every turn. Ask switch_for() for it."
        )
    raise ConfigurationError(
        f"unknown capability {capability!r}; known registers are "
        f"{sorted(COVERAGE_REGISTERS)}."
    )

restore_process_wide

restore_process_wide(capability: str) -> None

Lift the process-wide override. Per-board rollbacks are untouched.

Source code in src/symfonic/agent/cutover/process.py
def restore_process_wide(capability: str) -> None:
    """Lift the process-wide override. Per-board rollbacks are untouched."""
    switch_for(capability)
    _ROLLED_BACK.pop(capability, None)

retired_argument_supplied

retired_argument_supplied(**supplied: Any) -> str | None

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
def retired_argument_supplied(**supplied: Any) -> str | None:
    """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.
    """
    unknown = sorted(set(supplied) - set(RETIRED_ARGUMENTS))
    if unknown:
        raise KeyError(
            f"{', '.join(unknown)} is not a retired argument; "
            f"RETIRED_ARGUMENTS carries {', '.join(RETIRED_ARGUMENTS)}"
        )
    return next((name for name in RETIRED_ARGUMENTS if supplied.get(name)), None)

retired_setting_supplied

retired_setting_supplied(config: Any) -> str | None

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
def retired_setting_supplied(config: Any) -> str | None:
    """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.
    """
    for setting, retired in RETIRED_SETTINGS.items():
        value = getattr(config, setting, MISSING)
        if value is MISSING:
            continue
        if retired.is_use(value):
            return setting
    return None

rollback_process_wide

rollback_process_wide(capability: str, *, reason: str | None = None) -> None

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
def rollback_process_wide(capability: str, *, reason: str | None = None) -> None:
    """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.
    """
    switch_for(capability)
    refuse_retired_lever(
        "process-rollback",
        capability,
        detail=(
            "It cannot be put back on its legacy body, and a process-wide "
            "global was the wrong scope for a per-deployment "
            "fact even while it worked: it redefined routing for every agent "
            "in the interpreter, tenants included, and for agents built after "
            "the call. Lifting one is still supported -- restore_process_wide "
            "and clear_process_rollbacks are untouched. Installs on 10.4 and "
            "below keep this lever for the length of the compatibility "
            "window; it is retired on this line and not backported."
        ),
    )

surfaces_for_subgroup

surfaces_for_subgroup(subgroup: str) -> tuple[AdmissionSurface, ...]

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
def surfaces_for_subgroup(subgroup: str) -> tuple[AdmissionSurface, ...]:
    """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.
    """
    return tuple(
        surface
        for surface in ADMISSION_SURFACES.values()
        if surface.subgroup == subgroup
    )

switch_for

switch_for(capability: str) -> CapabilitySwitch

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
def switch_for(capability: str) -> CapabilitySwitch:
    """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.
    """
    from symfonic.core.contracts.errors import ConfigurationError

    try:
        return CAPABILITY_SWITCHES[capability]
    except KeyError:
        pass
    if capability in COVERAGE_REGISTERS:
        # Named apart from an unknown capability, because this one exists and
        # the caller's expectation is what is wrong. Answering with a route --
        # which this function used to do -- is how nine names that dispatch
        # never reads came to be counted as flipped switches.
        raise ConfigurationError(
            f"{capability!r} is a coverage register, not a switch: the "
            "capability is migrated and verified, and no dispatch reads it, so "
            "there is no route to give you. Ask register_for() for its "
            "evidence. A name becomes a switch when flipping it changes what "
            f"serves a turn; today that is {sorted(CAPABILITY_SWITCHES)}."
        )
    raise ConfigurationError(
        f"unknown cutover capability {capability!r}; known switches are "
        f"{sorted(CAPABILITY_SWITCHES)} and known registers are "
        f"{sorted(COVERAGE_REGISTERS)}."
    )

tool_result_surfaces_for_subgroup

tool_result_surfaces_for_subgroup(subgroup: str) -> tuple[AdmissionSurface, ...]

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
def tool_result_surfaces_for_subgroup(subgroup: str) -> tuple[AdmissionSurface, ...]:
    """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.
    """
    return tuple(
        surface
        for surface in TOOL_RESULT_SURFACES.values()
        if surface.subgroup == subgroup
    )

tools_surfaces_for_subgroup

tools_surfaces_for_subgroup(subgroup: str) -> tuple[AdmissionSurface, ...]

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
def tools_surfaces_for_subgroup(subgroup: str) -> tuple[AdmissionSurface, ...]:
    """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.
    """
    return tuple(
        surface for surface in TOOLS_SURFACES.values() if surface.subgroup == subgroup
    )

unowned_container_semantics

unowned_container_semantics(config: Any) -> str | None

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.

Source code in src/symfonic/agent/cutover/container_semantics.py
def unowned_container_semantics(config: Any) -> str | None:
    """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.
    """
    container = getattr(config, "agent", MISSING)
    if container is MISSING:
        return None
    stock = stock_instance(config)
    if stock is None:
        return None
    expected = getattr(stock, "agent", MISSING)
    if expected is MISSING:
        return None

    if type(container) is not type(expected):
        return (
            f"config.agent is a {type(container).__name__}, not the stock "
            f"{type(expected).__name__}. A substituted container class carries "
            "semantics attributed to the agent grouping itself rather than to "
            "any of its children"
        )

    declared = field_names(container)
    if declared is None:
        return (
            "config.agent cannot be enumerated, so which of its children a "
            "value belongs to is unknowable and the value is attributable only "
            "to the container"
        )

    attached = _extra_attributes(container, declared)
    if attached:
        return (
            f"config.agent carries the attached attribute "
            f"{attached[0]!r}, which no child row owns"
        )

    for name in declared:
        if name in AGENT_CONTAINER_CHILDREN:
            continue
        actual = getattr(container, name, MISSING)
        if not equivalent(actual, getattr(expected, name, MISSING)):
            return (
                f"config.agent.{name}={brief(actual)} was set, and no child row "
                "owns that name; it is a semantic of the container and of "
                "nothing else"
            )
    return None