Skip to content

symfonic.agent.cutover.pause_policy

pause_policy

The 11.0 human-interaction pause-policy contract (TA8.42, C1-B + C1-T).

TA8.25's subgroups C1-B (ask_user_pause_ttl_seconds, agent.ask_user_pause_ttl_seconds, ask_user_tool_descriptions) and C1-T (ask_user_enabled) are one owner read from two ends: the pause a person is asked for, and the lifetime and presentation of the token that pause mints. Four rows, one capability, and this module is the contract 11.0 publishes for all four -- written before the guard below and the tests next door depend on it. The rows themselves are one table in :mod:~symfonic.agent.cutover.pause_surfaces and are re-exported here at the address importers already hold.

The replacement, in one sentence. On the 11.0 line a pause is configured on the capability the composition root wires (:class:~symfonic.capabilities.human.capability.HumanInteractionCapability), not on FrameworkConfig: its lifetime comes from :class:~symfonic.capabilities.human.values.TTLPolicy, its presentation from :class:~symfonic.capabilities.human.contribution.InteractionToolSpec, and its door from an :class:~symfonic.capabilities.human.registration.InteractionRegistration. The three C1-B fields are the legacy spelling of the first two and they refuse by name; C1-T's switch is the legacy spelling of the third and it does not move here, for the reason :data:~symfonic.agent.cutover.pause_surfaces.PAUSE_SURFACES records against it.

Why C1-B is REPLACE and not RETIRE. The capability survives -- an adopter who tuned a pause lifetime can still tune one, and one who reworded the ask_user tool can still reword it. What does not survive is the field, and with it the ambiguity of two spellings for one lifetime. That is a breaking change and it is stated as one: :attr:~symfonic.agent.cutover.pause_surfaces.PauseSurface.adopter_break carries what an adopter loses per row, and the refusal message carries the replacement, so a migration is never inferred from a dead end.

Why the TTL is refused on both spellings at once. ask_user_pause_ttl_seconds and agent.ask_user_pause_ttl_seconds are the same number in two places; retiring one and leaving the other would keep exactly the ambiguity C1-B's REPLACE exists to end, and would leave the surviving spelling honoured by only the body 11.0 retires. They therefore refuse together, through two mechanisms only because they sit at two depths: the flat field is a :data:~symfonic.agent.cutover.retired_settings.RETIRED_SETTINGS row, and the nested one is refused here, because retired_setting_supplied reads flat attributes by design and a nested row would make that table lie about its own predicate. Both raise the same class, in the same group, on the same line.

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 retired field. A retirement keyed on which body served the turn is route-conditional, which is the shape this programme refuses.

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.

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

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
    )

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}.",
    )