The switchboard: one route per capability, derived from filed evidence.
Three rules hold here, and each of them exists because of a way a cutover goes
wrong when it does not:
- A route is derived, never declared.
route_for reads the filed
criteria. There is no "force kernel" argument, so a capability cannot be
live in production while its evidence says it is not ready.
- Rollback keeps the evidence. Deleting the record on rollback would erase
the only account of why the flip was believed safe — which is the first
thing the post-incident review asks for.
- A build-time pin is not a lever. TA8.54's
pin holds capabilities on
legacy; it is set where the agent is constructed, is unreachable from a
running process, and refuses restore rather than reporting success.
- A fallback is data. When a flipped switch still runs legacy because the
invocation left the migrated envelope, that is counted and attributed. A
cutover whose fallback rate nobody can see is a cutover that can be 100%
fallback and still look complete.
TA8.5 retires two of this class's levers on the 11.0 line, and each now
raises :class:~symfonic.agent.cutover.retirement.RetiredLeverError instead of
moving a route:
- :meth:
CutoverSwitchboard.rollback — the board-rollback lever.
- Construction with unfiled criteria. This is the inversion, and it is the
one nobody asked for: a board whose parity citation was never filed used to
degrade to the legacy body, silently, because missing evidence and filed
evidence-that-says-no produced the same route. On the
11.0 line a board
without evidence is not constructible at all.
What stays is deliberate. route_for still reads _rolled_back, and
restore still lifts it, because the mechanism is what T4.4.6 deletes; a
mechanism ripped out here would take the fallback accounting and the parity
comparison with it. What is gone is every supported way for an operator to put
anything into that map.
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
The build's pin, as a value. NO_LEGACY_PIN when none was set.
describe
One line per switch. Names and citations only — never a credential.
Source code in src/symfonic/agent/cutover/switchboard.py
| 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
|
SwitchDecision
dataclass
SwitchDecision(capability: str, route: Route, reason: str, switch: CapabilitySwitch)
Why one capability is on the route it is on.