Skip to content

symfonic.agent.cutover.orchestrator_policy

orchestrator_policy

The 11.0 memory-orchestrator policy contract (TA8.37, C1-E).

TA8.25's subgroup C1-E is the memory orchestrator read as configuration: a container (orchestrator), ten nested knobs, and a top-level enabled_layers that duplicates one of them. The triage recommended REPLACE for the whole subgroup — "a container plus duplicate enabled_layers and implementation-level factory knobs should be replaced by a versioned orchestrator policy rather than copied wholesale" — and this module is the contract 11.0 publishes, written before the guard below and the tests next door depend on it.

The replacement is not one policy object, and saying so is the point. The subgroup's twelve rows are three different kinds of thing, and a single "versioned orchestrator policy" would have flattened the difference:

  • two are read on the migrated route and are therefore admitted rather than replaced — orchestrator.default_top_k and orchestrator.scoring_weights reach a kernel turn through the retrieval port the composition root folds. Replacing them would have retired values the migrated path demonstrably honours, which is the mistake TA8.40 found for the otel_* fields.
  • five are dead where they are written, and this module refuses them by name. Three say so in their own field descriptions (compaction_threshold is "Deprecated -- use SleepConsolidator phases instead"; llm_model and llm_temperature are "Deprecated -- never read at runtime"), and two are overwritten before anything reads them: SymfonicAgent._sync_enabled_layers copies the top-level FrameworkConfig.enabled_layers over orchestrator.enabled_layers and the top-level scope_blend_mode over orchestrator.scope_blend, so an adopter who sets either nested field today gets a silent no-op.
  • three name a write boundary this line does not crosscontext_budget, write_policy and working_graph_retention. They keep their recorded REPLACE unapplied, with the argument recorded per row in :data:~symfonic.agent.cutover.memory_surfaces.MEMORY_SURFACES: a REPLACE has to leave the capability standing under a new contract, and the folded bundle declares memory-read and folds no write capability at all, so there is no contract to move them onto yet.

Why the five are REPLACE and not RETIRE. Every one of them has a live destination on this line, named per row in :attr:ReplacedOrchestratorSetting. replaced_by. What does not survive is the spelling, and for the two overwritten rows that is a strict improvement: a silent no-op becomes a named refusal that says where the value actually goes.

Before dispatch, never from the verdict. The guard below runs at the top of run/stream/stream_typed beside :func:~symfonic.agent.cutover.config_retirement.refuse_retired_setting, and for that function's three reasons: stream and stream_typed do not consult the same verdict on every reported line, and a rolled-back switch would re-honour a replaced field. A retirement keyed on which body served the turn is route-conditional, which is the shape this programme refuses.

Why these rows are not in RETIRED_SETTINGS. They are nested, and retired_setting_supplied's predicate is a flat getattr against a stock default — the same reason TA8.42's agent.ask_user_pause_ttl_seconds is refused by :mod:~symfonic.agent.cutover.pause_policy instead of by that table. The mechanism is otherwise identical: same error ancestry, same group table, same line constant, one refusal shape.

The stock baseline is derived, never stored. Each row is compared against a default-constructed instance of the live OrchestratorConfig, through :func:~symfonic.agent.cutover.baseline.stock_instance, so a table row can never drift from the model it describes — and a field this table names that the model later drops is reported as a coverage failure rather than skipped.

ReplacedOrchestratorSetting dataclass

ReplacedOrchestratorSetting(field: str, dead_because: str, replaced_by: str, adopter_break: str)

One nested orchestrator knob replaced on the 11.0 line.

replaced_by names the live surface an adopter moves to and adopter_break names what they lose moving there — both required, for the reason TA8.42's :class:~symfonic.agent.cutover.pause_surfaces. PauseSurface gives: a refusal that names the line and stops is a dead end, and a replacement named without its break is a breaking change shipped as a migration note.

path property

path: str

The dotted path, as FrameworkConfig reaches it.

ReplacedOrchestratorSettingError

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

Bases: RetiredConfigurationError

A nested orchestrator 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.pause_policy.ReplacedPauseSettingError already follows. A subclass rather than a fifth sibling because it refuses the same kind of thing at the same moment: a fact about how this agent was built, named before dispatch.

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

refuse_replaced_orchestrator_setting

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

Refuse a replaced nested orchestrator knob on entry_point, or return.

C1-E'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:REPLACED_ORCHESTRATOR_SETTINGS, so no call site can forget to carry one, and 9.12 and 10.4 keep these fields in their own artefacts because this module is not backported.

Source code in src/symfonic/agent/cutover/orchestrator_policy.py
def refuse_replaced_orchestrator_setting(entry_point: str, config: Any) -> None:
    """Refuse a replaced nested orchestrator knob on ``entry_point``, or return.

    C1-E'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:`REPLACED_ORCHESTRATOR_SETTINGS`, so no call site can forget to carry
    one, and 9.12 and 10.4 keep these fields in their own artefacts because
    this module is not backported.
    """
    from symfonic.agent.cutover.settings_contract import RETIREMENT_GROUPS

    supplied = replaced_orchestrator_setting_supplied(config)
    if supplied is None:
        return
    field, value = supplied
    row = REPLACED_ORCHESTRATOR_SETTINGS[field]
    raise ReplacedOrchestratorSettingError(
        row.path,
        entry_point,
        ORCHESTRATOR_POLICY_GROUP,
        f"the {row.path} setting (FrameworkConfig(orchestrator="
        f"OrchestratorConfig({field}={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 {ORCHESTRATOR_POLICY_GROUP!r} group, "
        f"which {RETIREMENT_GROUPS[ORCHESTRATOR_POLICY_GROUP]}. This spelling "
        f"is dead where it is written: {row.dead_because}. Configure the "
        f"outcome here instead: {row.replaced_by}. What changes for you: "
        f"{row.adopter_break}.",
    )

replaced_orchestrator_setting_supplied

replaced_orchestrator_setting_supplied(config: Any) -> tuple[str, Any] | None

The first replaced nested knob config actually uses, or None.

Returns (field, value) in :data:REPLACED_ORCHESTRATOR_SETTINGS order.

Emptiness is not use, the rule TA8.26 states for the twenty-five retired fields: a knob left at the stock OrchestratorConfig default is what a caller who asks for nothing passes, so only a value that differs is a request. There is no separate inactivity class here — unlike a flat credential_patterns=[], none of these five has a second "off" value distinct from its default.

Answers None — "nothing to refuse" — when the question cannot be put: a config object with no orchestrator attribute, an orchestrator whose class cannot be default-constructed, or a container missing the field. That 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 comparison is against a derived stock instance rather than a stored table of defaults, so this rule cannot disagree with the live model.

Source code in src/symfonic/agent/cutover/orchestrator_policy.py
def replaced_orchestrator_setting_supplied(config: Any) -> tuple[str, Any] | None:
    """The first replaced nested knob ``config`` actually uses, or ``None``.

    Returns ``(field, value)`` in :data:`REPLACED_ORCHESTRATOR_SETTINGS` order.

    **Emptiness is not use**, the rule TA8.26 states for the twenty-five
    retired fields: a knob left at the stock ``OrchestratorConfig`` default is
    what a caller who asks for nothing passes, so only a value that differs is
    a request. There is no separate inactivity class here — unlike a flat
    ``credential_patterns=[]``, none of these five has a second "off" value
    distinct from its default.

    Answers ``None`` — "nothing to refuse" — when the question cannot be put: a
    config object with no ``orchestrator`` attribute, an ``orchestrator`` whose
    class cannot be default-constructed, or a container missing the field. That
    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 comparison is against a *derived* stock instance rather than a stored
    table of defaults, so this rule cannot disagree with the live model.
    """
    container = getattr(config, ORCHESTRATOR_CONTAINER, MISSING)
    if container is MISSING or container is None:
        return None
    baseline = stock_instance(container)
    if baseline is None:
        return None
    for field in REPLACED_ORCHESTRATOR_SETTINGS:
        value = getattr(container, field, MISSING)
        if value is MISSING:
            continue
        if not equivalent(value, getattr(baseline, field, MISSING)):
            return field, value
    return None