symfonic.services.switching¶
switching ¶
Distributed route switching and invocation pinning (T2.3.6).
One binding contract, two backends (DMC). Operated platforms bind the
authorized, audited control plane over a per-key-linearizable switch store;
library mode binds a hermetic in-process static-generation backend. Both
answer the same BindingSource port, and both feed the same per-invocation
binding stage — so the kernel never learns which one it is running under.
ActiveInvocationRegistry ¶
Append-only from the data plane; indexed by bundle and by generation.
Source code in src/symfonic/services/switching/registry.py
admission_lock
property
¶
Held across bind-and-register so the pair is one atomic admission.
expire_lease ¶
EFX-L-5 — a crashed worker's record is closed by lease expiry.
invalidate ¶
CUT-AIR-4 — only the control plane, and only via SCP-REV-4.
Source code in src/symfonic/services/switching/registry.py
quiescent_below ¶
CUT-AIR-3 — no open record on this bundle below epoch.
Actor
dataclass
¶
SCP-AUTH-1 — an authenticated human or named workflow identity.
kind='service' exists so it can be refused: a shared service account
is the exact anti-pattern the clause names, and a vocabulary that could not
express it would turn a stated rule into an unenforceable one.
AdmissionController ¶
AdmissionController(*, source: Any, registry: ActiveInvocationRegistry | None = None, worker_id: str = 'worker', barrier: QuiescenceBarrier | None = None, clock: Callable[[], float] = time.time)
Binds a generation and registers the invocation under one lock.
Source code in src/symfonic/services/switching/admission.py
acknowledge_freeze
async
¶
CUT-BR-6 — this worker states that it has observed the freeze.
Source code in src/symfonic/services/switching/admission.py
admit
async
¶
admit(*, invocation_id: str, bundle_id: str, tenant_scope_hash: str, inherited_pin: InvocationPin | None = None) -> BoundGeneration
Resolve one snapshot and register it, atomically against the freeze.
Source code in src/symfonic/services/switching/admission.py
resume
async
¶
resume(pin: InvocationPin | None, *, invocation_id: str, tenant_scope_hash: str = '') -> BoundGeneration
Resume under the artifact's pin, not under this worker's binding.
Source code in src/symfonic/services/switching/admission.py
AuditRecord
dataclass
¶
AuditRecord(seq: int, prev_hash: str, actor: str, role: str, bundle_id: str, prior_epoch: int, proposed_vector_hash: str, outcome: str, reason: str, timestamp: float)
One mutation attempt — accepted or denied, both recorded identically.
BindingSource ¶
Bases: Protocol
Resolve a route bundle's binding. Async-first, read-only, fail-closed.
describe ¶
resolve
async
¶
resolve_epoch
async
¶
CUT-SS-7 — the binding an earlier admission ran under.
Resume paths need it: a pin names an epoch, and placing the resumed work back on that epoch's vector is the only way a switch that landed while the work was paused does not silently follow it.
Source code in src/symfonic/services/switching/ports.py
BindingStage
dataclass
¶
BindingStage(bundle_id: str, tenant_scope_hash: str = '', inherited_pin: InvocationPin | None = None)
A bind-phase stage: what to bind, never what was bound.
Deliberately holds no vector, epoch, or binding source. Two invocations of one compiled plan must be able to observe two different generations, and a stage that cached a resolved vector could not express that.
child ¶
Derive the stage a sub-agent runs under (EFX-L-2: never widening).
A child inherits the parent's pin rather than resolving its own. A child that re-resolved could land on a newer generation mid-way through its parent's invocation, which is the exact mixed-generation run that CUT-SS-4 forbids.
Source code in src/symfonic/services/switching/binding_stage.py
execute
async
¶
Capture exactly one generation vector for one invocation.
Source code in src/symfonic/services/switching/binding_stage.py
BindingUnavailableError ¶
BoundGeneration
dataclass
¶
What one invocation captured: a pin, its binding, and its registry row.
record is None in library mode: CUT-AIR-5 makes the registry
operated-platform-only, and a library adopter must not find themselves
maintaining a drain-proof table they have no control plane to drain for.
BreakGlassCredential
dataclass
¶
SCP-BG-1/3 — pre-provisioned, sealed, single-use, bundle-scoped.
BundleBinding
dataclass
¶
BundleBinding(bundle_id: str, epoch: int, generation_vector: GenerationVector, freeze_state: FreezeState = NO_FREEZE, source: str = 'release-static', fetched_at: float = 0.0, stale: bool = False)
DMC-1 — what a BindingSource answers with.
source is one of release-static, control-plane or
local-override (CUT-PIN-3), which is what makes "where did this
binding come from?" answerable in a diagnostic without guessing.
BundleRecord
dataclass
¶
BundleRecord(bundle_id: str, epoch: int, generation_vector: GenerationVector, freeze_state: FreezeState = NO_FREEZE, audit_head: str = '0' * 64, rollback_vector: GenerationVector | None = None, committed_at: float = 0.0)
CUT-SS-1/CUT-SS-3 — the entire binding state of one bundle.
One record, one atomic write. There is deliberately no way to express a half-switched bundle: the vector, the freeze and the audit head move together or not at all.
CompatibilityConstraint
dataclass
¶
subject_generation requires requires at minimum or newer.
ConstraintSet ¶
The declared constraints plus the well-formedness rules every vector obeys.
Source code in src/symfonic/services/switching/constraints.py
validate ¶
Raise on the first illegal combination (fail-closed, SEC-FCP-1).
Source code in src/symfonic/services/switching/constraints.py
violations ¶
Every reason this vector is illegal, in declaration order.
Source code in src/symfonic/services/switching/constraints.py
ConstraintViolationError ¶
Bases: ConfigurationError
CUT-RB-4/CUT-RB-5 — the proposed vector is not a legal combination.
A ConfigurationError on purpose: an illegal vector is a configuration
fault whether it arrives from a control-plane proposal or a local override,
and library-mode construction must reject it with the taxonomy adopters
already catch.
ControlPlaneBindingSource ¶
ControlPlaneBindingSource(store: InMemorySwitchStore, constraints: ConstraintSet, *, t_stale: float = DEFAULT_T_STALE, t_outage: float = DEFAULT_T_OUTAGE, cutover_window: float = DEFAULT_T_CUTOVER_WINDOW, clock: Callable[[], float] = time.monotonic)
Cache-first BindingSource implementing CUT-BR-1..6.
Source code in src/symfonic/services/switching/control_plane_source.py
refresh
async
¶
The watcher/poller write point. Never called from the hot path.
Source code in src/symfonic/services/switching/control_plane_source.py
resolve_epoch
async
¶
CUT-SS-7 — reconstruct the binding an earlier admission ran under.
Source code in src/symfonic/services/switching/control_plane_source.py
EnvelopeSigner ¶
EnvelopeSigner(keyset: Keyset, *, producer_package_version: str, clock: Callable[[], float] = time.time)
Mints and verifies pin envelopes against a keyset (ENV-2, ENV-6, KEY-5).
Source code in src/symfonic/services/switching/envelope.py
mint ¶
mint(*, pin: InvocationPin, payload: bytes, schema_id: str, envelope_version: int = 2) -> PinEnvelope
ENV-6 — refuse to mint when the active key is unavailable.
Source code in src/symfonic/services/switching/envelope.py
verify ¶
verify(envelope: PinEnvelope | None, *, pinless_policy: PinlessArtifactPolicy | None = None, schema_id: str = '') -> InvocationPin
Verify first, then dispatch on version. Never the other way round.
Source code in src/symfonic/services/switching/envelope.py
EnvelopeVerificationError ¶
EnvelopeVersionError ¶
EvidenceInvalidated
dataclass
¶
EvidenceInvalidated(freeze_epoch_id: str, bundle_id: str, reason: str, evidence_ids: tuple[str, ...], kinds: tuple[str, ...], invalidated_at: float = 0.0)
The event T4.4.7 consumes when a freeze revocation lands.
FreezeState
dataclass
¶
FreezeState(freeze_epoch_id: str | None = None, frozen_legacy_vector: GenerationVector | None = None, retiring: frozenset[str] = frozenset(), created_by: str | None = None, approved_by: str | None = None, created_at: float = 0.0)
SCP-FRZ-1 — the retirement-freeze epoch object, or its absence.
retiring is the set of generation ids the freeze disables for new
admissions. It is computed once, when the freeze is committed, so a later
vector change cannot silently widen or narrow what the freeze barred.
bars ¶
The retiring generations this vector would newly admit work onto.
FreezeViolationError ¶
GenerationVector
dataclass
¶
CUT-RB-3 — {capability_or_port → generation_id}, canonically ordered.
Stored as a sorted tuple rather than a mapping so two vectors built from
differently-ordered dicts are the same value, compare equal, and hash to
the same vector_hash. Ordering is the whole reason the hash is stable
enough to travel inside a checkpoint envelope.
HashChainAuditLog ¶
An in-process append-only hash chain (the reference implementation).
Source code in src/symfonic/services/switching/audit.py
assert_no_credentials
staticmethod
¶
SCP-AUD-3 — refuse credential-shaped free text before it is chained.
Source code in src/symfonic/services/switching/audit.py
commit ¶
Chain a prepared record, refusing one that no longer fits the chain.
Source code in src/symfonic/services/switching/audit.py
prepare ¶
prepare(*, actor: str, role: str, bundle_id: str, prior_epoch: int, proposed_vector_hash: str, outcome: str, reason: str = '') -> AuditRecord
Build the next record without chaining it.
Preparing and committing are separate because the bundle record must store the audit head that attests it (SCP-AUD-1), and the record it attests is only durable once the CAS lands. Preparing lets the caller compute that head, commit the bundle, and only then chain the entry — so a lost CAS race never leaves an "accepted" record for a mutation that never happened.
Source code in src/symfonic/services/switching/audit.py
tamper_for_test ¶
Rewrite one record in place — the only way to exercise SCP-AUD-2.
It lives on the log rather than in a test helper so that "the chain detects this" is asserted against the same object production uses, and so any future storage backend inherits the same characterisation.
Source code in src/symfonic/services/switching/audit.py
verify ¶
SCP-AUD-2 — recompute the chain from genesis.
Source code in src/symfonic/services/switching/audit.py
InMemorySwitchStore ¶
Per-key linearizable CAS over an in-process dict, with epoch history.
Every mutation takes the same lock and re-reads the current epoch inside
it, so a lost race raises SwitchConflictError instead of overwriting.
set_unreachable models the control-plane outage the availability rules
in CUT-BR exist to survive.
Source code in src/symfonic/services/switching/store.py
compare_and_swap
async
¶
CUT-SS-2 — commit record iff the stored epoch is still expected.
Source code in src/symfonic/services/switching/store.py
history
async
¶
CUT-SS-7 — the full epoch history, for audit reconstruction.
InvocationPin
dataclass
¶
InvocationPin(bundle_id: str, epoch: int, vector_hash: str, source: str, stale_binding: bool = False, freeze_epoch_id: str | None = None)
The travelling half of a binding: what a checkpoint or token carries.
A pin names an epoch and a vector hash, never the vector's contents. A resuming worker resolves the contents from its own binding source and refuses if they disagree, so a pin cannot smuggle an unvalidated vector across a process boundary.
InvocationRecord
dataclass
¶
InvocationRecord(invocation_id: str, tenant_scope_hash: str, bundle_id: str, admitted_epoch: int, generation_vector_hash: str, admitted_at: float, stale_binding: bool = False, completed_at: float | None = None, closed_reason: str | None = None, invalidated_by: str | None = None)
One admitted invocation. Carries a scope hash, never tenant content.
Keyset ¶
key_id → (material, state), with exactly one active key.
Key material never leaves this object: there is no accessor that returns
it, __repr__ names only ids and states, and signing happens here rather
than in the caller. A getter would be convenient exactly once and then live
forever in a log line.
Source code in src/symfonic/services/switching/keys.py
KeysetUnavailableError ¶
Bases: SwitchingError
KEY-5 — no active key to mint with, or no keyset to verify against.
Availability, never authorization (EMAP-6). A verifier with no keyset must deny, but the holder's token is provably still redeemable — reporting the outage as 401 tells them to stop retrying something that will work again in a minute.
LegacyPinRetiredError ¶
Bases: ConfigurationError
LIB-OV-5 — this release removed the pinned legacy generation.
PinEnvelope
dataclass
¶
PinEnvelope(envelope_version: int, generation_vector_hash: str, schema_id: str, producer_package_version: str, created_at: float, key_id: str, pin: InvocationPin, payload: bytes, signature: str = '')
ENV-1 — the authenticated wrapper around an opaque payload.
describe ¶
A diagnostic line: ids and hashes only (SEC-CRED-2, KEY-6).
Source code in src/symfonic/services/switching/envelope.py
signing_input ¶
Every envelope field plus the payload — ENV-2 covers both.
Source code in src/symfonic/services/switching/envelope.py
PinlessArtifactError ¶
PinlessArtifactPolicy
dataclass
¶
PinlessArtifactPolicy(accept: bool = False, attributed_vector_hash: str | None = None, reason: str = '')
CUT-PIN-1/2 — what happens to an artifact that carries no pin.
The default is refusal. Accepting one requires naming, at construction, the vector it should be attributed to and why — because the alternative is a baked-in default binding, which is the thing CUT-PIN-1 exists to forbid.
QuiescenceBarrier ¶
CUT-BR-6 — the worker-acknowledged half of the drain proof.
A freeze is only propagated when every worker says so. Waiting out
T_stale + T_outage and hoping is the alternative this replaces: it
cannot distinguish "every worker saw the freeze" from "every worker is
wedged", and those two states need opposite responses.
Source code in src/symfonic/services/switching/registry.py
ReleaseProfile
dataclass
¶
ReleaseProfile(package_version: str, static_vectors: Mapping[str, GenerationVector], constraints: ConstraintSet, override_table: Mapping[str, Sequence[GenerationVector]] = dict())
Everything the hermetic library backend is allowed to know (DMC-3).
A profile is compiled data: the static vector the release was validated against, the constraint set it shipped, and the documented override table. There is no field for "where to look this up", because looking anything up is precisely what library mode may not do.
RetirementEvidence
dataclass
¶
RetirementEvidence(evidence_id: str, freeze_epoch_id: str, bundle_id: str, kind: str, detail: str, recorded_at: float = 0.0, invalidated_by: str | None = None)
One gathered result, permanently attributed to one freeze epoch.
RetirementEvidenceStore ¶
Append-only evidence with epoch-scoped invalidation and a subscriber seam.
Source code in src/symfonic/services/switching/evidence.py
covers ¶
Whether every required evidence kind is present and still valid.
Source code in src/symfonic/services/switching/evidence.py
invalidate_epoch ¶
Atomically mark every row of this epoch invalid and emit the event.
Source code in src/symfonic/services/switching/evidence.py
valid_for ¶
SCP-REV-4: reads filter on non-invalidated AND matching epoch.
Source code in src/symfonic/services/switching/evidence.py
StaticBindingSource ¶
StaticBindingSource(profile: ReleaseProfile, *, overrides: Mapping[str, GenerationVector] | None = None)
Resolve bindings from the release's static vector plus local overrides.
Source code in src/symfonic/services/switching/static_source.py
describe ¶
The startup log line: vector hash and where it came from, never keys.
Source code in src/symfonic/services/switching/static_source.py
resolve_epoch
async
¶
A library release has exactly one epoch: the one it compiled in.
Source code in src/symfonic/services/switching/static_source.py
StoreUnavailableError ¶
SwitchAuthorizationError ¶
SwitchAuthorizer ¶
SwitchConflictError ¶
SwitchControlPlane ¶
SwitchControlPlane(*, store: InMemorySwitchStore, constraints: ConstraintSet, audit: HashChainAuditLog, authorizer: SwitchAuthorizer, validated_vectors: Sequence[GenerationVector] = (), evidence: RetirementEvidenceStore | None = None, clock: Callable[[], float] = time.time)
The authorized, audited switch-state service.
Source code in src/symfonic/services/switching/control_plane.py
break_glass_revert
async
¶
break_glass_revert(credential: BreakGlassCredential, bundle_id: str, *, expected_epoch: int | None = None, reason: str) -> BundleRecord
SCP-BG — revert-only, single-use, always audited, never a freeze verb.
Source code in src/symfonic/services/switching/control_plane.py
commit_vector
async
¶
commit_vector(actor: Actor, bundle_id: str, vector: GenerationVector, *, expected_epoch: int | None = None, reason: str = '') -> BundleRecord
CUT-RB-2/CUT-SS-2 — one bundle, one validated vector, one CAS.
Source code in src/symfonic/services/switching/control_plane.py
freeze
async
¶
freeze(actor: Actor, bundle_id: str, *, approver: Actor, expected_epoch: int | None = None, retiring: Sequence[str] | None = None, reason: str = 'freeze') -> BundleRecord
SCP-FRZ-1 — commit a reversible retirement-freeze epoch object.
Source code in src/symfonic/services/switching/control_plane.py
retirement_ready ¶
retirement_ready(bundle_id: str, freeze_epoch_id: str, *, registry: object | None = None, barrier: object | None = None, epoch: int | None = None) -> bool
SCP-FRZ-3 — every leg of the freeze-and-drain gate, or False.
Deliberately returns a boolean rather than raising: this is a gate a runbook polls, and "not yet" is its normal answer, not an exception.
Source code in src/symfonic/services/switching/control_plane.py
revoke_freeze
async
¶
revoke_freeze(actor: Actor, bundle_id: str, *, expected_epoch: int | None = None, reason: str) -> BundleRecord
SCP-REV — release-owner-only, audited, evidence-invalidating.
Source code in src/symfonic/services/switching/control_plane.py
rollback
async
¶
rollback(actor: Actor, bundle_id: str, *, expected_epoch: int | None = None, reason: str = 'rollback') -> BundleRecord
CUT-RB-6 — rollback is a CAS to the named vector, never an edit.
Source code in src/symfonic/services/switching/control_plane.py
SwitchingError ¶
Bases: SymfonicError
Root of the cutover taxonomy. Never raised directly.
Every refusal carries a transport-neutral code (ERR-5). Nothing here
knows about HTTP; the code exists so a caller — including one across a
package boundary, such as the pause-token reader that only sees an opaque
signer port — can tell "you may not" from "I cannot right now" without
matching on the exception's type or its message text.
derive_library_key ¶
derive_library_key(secret: str, *, salt: bytes, info: bytes = b'symfonic-envelope') -> tuple[str, bytes]
LIB-EA-1 — HKDF-SHA256 from an adopter secret; key_id is its fingerprint.
LIB-EA-2 is the important half: there is no default secret. An adopter who configures nothing gets a construction error, not a package-wide shared key.
Source code in src/symfonic/services/switching/keys.py
parse_generation ¶
Split name@N (CUT-RB-3 vocabulary). Unparsable ids never guess.