Skip to content

symfonic.agent.cutover.envelope

envelope

The migrated envelope: which invocations the kernel path may serve.

A flipped switch answers "which implementation of this capability is current?". It does not answer "does this turn need a capability that has not been migrated yet?" — and conflating the two is how a cutover silently drops behaviour: the memory the adopter configured is not recalled, and nothing raises, because the kernel path simply has no memory to recall.

So admission is checked per invocation, and a refusal names the field.

This envelope is default-DENY, and the inversion was earned. It used to be a denylist: a hand-maintained set of clauses, and any configuration matching none of them was served. Five consecutive review rounds each found a different field the list had never mentioned — graph_preset, tool_routing_mode, procedural_enforce_preconditions, synthesize_on_empty_final, the conversation manager, and finally config.agent.model / role_models, where an admitted turn calls a different model than the adopter configured, bills it, and returns its answer. Against a 129-field surface whose default answer is "admit", one clause per round does not converge; the sixth finding was as likely as the fifth.

The rule now is one sentence: the supplied config is compared field by field against a stock baseline, and any difference refuses, except for a small named allowlist of fields the migrated path provably honours. A field that is added, renamed, or simply never considered refuses by default. That also closes the second half of the same defect — the old clauses read the config with a defaulted getattr, so renaming a field made its clause match nothing and fail open.

The policy this applies is data, and lives next door in :mod:~symfonic.agent.cutover.policy; the reflection it needs is in :mod:~symfonic.agent.cutover.baseline. What is left here is the decision.

EnvelopeVerdict dataclass

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

Admitted, or refused with the name of what is missing.

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)