symfonic.services.effects¶
effects ¶
EFX — the effect-aware security-revert fence (T2.3.8).
Atomic, generation-scoped effect admission at every port T2.3.7 classified, and a security revert that is honest about its own reach: it closes new admissions for the rejected generation, blocks fenced commits, requests cancellation, quarantines late results, drains what it can within a bounded budget, and records as exposed everything it could not stop — irreversible effects that had already left, and anything an opaque in-process extension did outside the ports entirely.
The load-bearing decision is that the fencing claim is derived rather than
asserted (see :mod:exposure and :mod:incident). A revert cannot be
configured to report success it did not achieve.
BoundedDrain ¶
BoundedDrain(tracker: InFlightTracker, *, clock: Callable[[], float] = time.time, sleep: Callable[[float], Awaitable[None]] = _default_sleep, interval: float = 0.05, budget: float = 5.0)
Waits, briefly, for in-flight work to reach a terminal state.
Source code in src/symfonic/services/effects/drain.py
CommitDecision ¶
Bases: StrEnum
EFX-F-3 — what the commit-time conditional check decided.
SUPPRESSED deliberately does not say "cancelled" or "undone". The
write did not land; whether an external effect already left the process is
a separate question the exposure ledger answers.
CompensationOutcome
dataclass
¶
CompensationOutcome(port_id: str, invocation_id: str, invoked: bool, credentials_rotated: bool, note: str, rule_id: str | None = None)
What containment actually did. invoked is never assumed true.
CompensationRegistry ¶
Which irreversible ports have an approved remedy, and which block cutover.
Source code in src/symfonic/services/effects/compensation.py
cutover_blocked_ports ¶
Irreversible ports among these that carry no approved rule.
Source code in src/symfonic/services/effects/compensation.py
CompensationRule
dataclass
¶
CompensationRule(rule_id: str, port_id: str, procedure: str, tested: bool, approved_by: str, rotates_credentials: bool = False)
One documented, tested procedure for undoing what can be undone.
ContainmentCoordinator ¶
ContainmentCoordinator(registry: CompensationRegistry, *, rotator: Callable[[str], None] | None = None)
Runs the approved remedy, or records precisely why it could not.
Source code in src/symfonic/services/effects/compensation.py
DrainReport
dataclass
¶
What the drain observed. complete is false if anything was contained.
EffectAdmissionController ¶
EffectAdmissionController(*, store: InMemoryLeaseStore, fences: FenceLedger, ports: FencedPortRegistry, tracker: InFlightTracker, quarantine: ResultQuarantine, clock: Callable[[], float] = time.time)
One admission path for every classified effect port.
Source code in src/symfonic/services/effects/admission.py
admit
async
¶
EFX-L-1 — no effect attempt exists without a live, unfenced lease.
Source code in src/symfonic/services/effects/admission.py
commit
async
¶
EFX-F-3 — the conditional check that decides whether the write lands.
Source code in src/symfonic/services/effects/admission.py
commit_or_raise
async
¶
The raising variant, for callers that treat suppression as fatal.
Source code in src/symfonic/services/effects/admission.py
deliver_result
async
¶
Hand back a port's answer, or quarantine it if the fence has landed.
Source code in src/symfonic/services/effects/admission.py
dispatch
async
¶
The last check before the effect leaves. Raising here costs nothing.
Source code in src/symfonic/services/effects/admission.py
EffectFenceError ¶
Bases: SymfonicError
Root of the effect-admission taxonomy. Never raised directly.
EffectLease
dataclass
¶
EffectLease(lease_id: str, invocation_id: str, bundle_id: str, admitted_epoch: int, generation_vector_hash: str, tenant_scope_hash: str, acquired_at: float, expires_at: float, stale_binding: bool = False, revoked_reason: str | None = None, parent_lease_id: str | None = None)
EFX-L-1 — the revocable authorization to perform effects.
generation_vector_hash is what a revert keys on, and it is captured at
acquisition rather than read at use time: an invocation must be judged on
the generation it was admitted under, not on whatever is current when it
happens to call.
EffectReversibility ¶
Bases: StrEnum
EFX-F-2 — can the framework undo this port's effect after it landed?
IRREVERSIBLE is the mandatory fence-check set: external side effects,
durable deletes, outbound notifications. For those, cancellation is never
a remedy, so a revert must compensate and account rather than claim.
EffectTicket
dataclass
¶
EffectTicket(ticket_id: str, lease_id: str, invocation_id: str, port_id: str, operation: str, admitted_at: float, irreversible: bool)
One admitted effect attempt against one port under one lease.
ExposureRecord
dataclass
¶
ExposureRecord(invocation_id: str, kind: ExposureKind, subject: str, reason: str, ticket_id: str | None = None)
Something a revert could not suppress, named rather than glossed over.
ExtensionExecution
dataclass
¶
One extension the invocation actually ran.
attested_clean is tri-state on purpose. True is a sentinel
attestation that no direct effect was witnessed, False is a witnessed
one, and None — the default — is "nobody watched", which is not the
same as "nothing happened".
Fence
dataclass
¶
Fence(fence_id: str, kind: FenceKind, reason: str, raised_at: float, raised_by: str = '', bundle_id: str | None = None, epoch_ceiling: int | None = None, rejected_vector_hash: str | None = None, tenant_scope_hash: str | None = None)
One barrier. Keyed narrowly on purpose: a revert is not an outage.
covers ¶
Does this fence bar the generation (or subject) the lease captured?
Source code in src/symfonic/services/effects/fence.py
FenceLedger
dataclass
¶
Append-only. Nothing here lowers a fence, and that is the point.
covering ¶
The first fence that bars this lease, or None.
FencedEffectError ¶
Bases: LeaseInvalidError
EFX-F-3 — a fence covered this operation at one of its checkpoints.
A subclass of :class:LeaseInvalidError because a fence is one specific
way a lease stops authorizing effects, and because a revert revokes the
leases it fences: both conditions are true afterwards, and a caller that
only cares "may I proceed?" should not have to catch two types to find
out. A caller that does care — an incident writer distinguishing "the
fence stopped it" from "the lease aged out" — catches this one first.
FencedPort
dataclass
¶
FencedPort(port_id: str, family: EffectFamily, reversibility: EffectReversibility, externally_visible: bool, rationale: str)
One classified effect port, plus the verdict the fence needs from it.
FencedPortRegistry ¶
Every classified effect port, with its reversibility verdict.
Source code in src/symfonic/services/effects/ports.py
assert_covers_classification ¶
assert_covers_classification(classification: EffectPortClassification = DEFAULT_EFFECT_CLASSIFICATION) -> None
The fence's table and T2.3.7's table name the same ports, or raise.
Source code in src/symfonic/services/effects/ports.py
FencingClaim
dataclass
¶
What the revert is entitled to say about one invocation's effects.
InFlightTracker ¶
Ticket lifecycle, cancellation requests, and dispatch timestamps.
Source code in src/symfonic/services/effects/tracker.py
dispatched_before ¶
Did every dispatched effect for this invocation leave before moment?
Source code in src/symfonic/services/effects/tracker.py
mark ¶
Terminal states are sticky: a contained ticket never becomes drained.
Source code in src/symfonic/services/effects/tracker.py
InMemoryLeaseStore ¶
A single-process lease table with conditional writes (DMC-3).
Source code in src/symfonic/services/effects/store.py
acquire_child
async
¶
EFX-L-2 — a child narrows its parent's lease and never outlives it.
Source code in src/symfonic/services/effects/store.py
check
async
¶
One atomic verification that this lease may still perform effects.
check_locked ¶
Fence first, then lease state. Caller holds lock.
The fence is consulted before liveness because a revert revokes what it fences, so both are true afterwards and the more specific answer is the useful one.
Source code in src/symfonic/services/effects/store.py
expire_due ¶
EFX-L-5 — a crashed worker's lease expires and stops holding the drain.
Source code in src/symfonic/services/effects/store.py
revoke_covered ¶
Close every live lease this fence covers. Caller holds lock.
Source code in src/symfonic/services/effects/store.py
IncompleteFenceCoverageError ¶
Bases: ConfigurationError
A classified effect port has no reversibility verdict.
A ConfigurationError on purpose: the fence's coverage of T2.3.7's
table is a build-time property, and a gap is a misconfiguration adopters
already catch in that taxonomy.
LeaseAcquisitionError ¶
Bases: EffectFenceError
EFX-L-1 — the conditional write that would grant a lease did not hold.
The bundle's epoch moved, or a fence already covers the requested generation or subject. Fail-closed: no lease, no effect.
LeaseInvalidError ¶
Bases: EffectFenceError
A lease that existed is no longer usable: revoked, expired, or superseded.
EFX-L-3 and EFX-L-5 both land here, because the invocation's next effect attempt is denied identically in either case.
LeaseStoreUnavailableError ¶
Bases: EffectFenceError
The lease backend could not be reached, so no fact could be established.
Not a denial of a known state — an inability to know one. The fence treats it as denial anyway (SEC-FCP-1) and records the degradation.
OpaqueExposureAccountant ¶
Turns executed extensions into exposures and a withheld-or-not claim.
Source code in src/symfonic/services/effects/exposure.py
account ¶
Every extension exposure for these invocations, demoting as it goes.
Source code in src/symfonic/services/effects/exposure.py
claim_for ¶
Derived from live trust state, so a later demotion still withdraws it.
Source code in src/symfonic/services/effects/exposure.py
QuarantinedResult
dataclass
¶
QuarantinedResult(ticket_id: str, invocation_id: str, port_id: str, reason: str, quarantined_at: float)
One held result. The payload is deliberately not stored.
Keeping the payload would create a second copy of tenant data outside the invocation that earned it, on a path nobody has classified for retention. The record proves a result arrived and was withheld; that is what an incident needs.
ResultQuarantine ¶
Holds late results. Nothing here ever releases one.
Source code in src/symfonic/services/effects/quarantine.py
deliverable ¶
RevertAccounting ¶
RevertAccounting(*, accountant: OpaqueExposureAccountant, containment: ContainmentCoordinator, ports: FencedPortRegistry)
Turns the fenced set into exposures, claims, and blocked cutovers.
Source code in src/symfonic/services/effects/accounting.py
blocked ¶
Ports whose exposure has no approved rule, so cutover cannot proceed.
Source code in src/symfonic/services/effects/accounting.py
claims ¶
claims(invocations: tuple[str, ...], exposures: tuple[ExposureRecord, ...]) -> tuple[FencingClaim, ...]
One claim per affected invocation, over both axes of exposure.
The accountant only knows extensions. An invocation that ran none but whose irreversible effect had already left the process is still exposed, and printing SUPPRESSED beside its own exposure record would be precisely the overstatement this package exists to prevent — so the port-crossing exposures override a clean extension verdict here.
Source code in src/symfonic/services/effects/accounting.py
contain_all ¶
Run the approved compensation for each exposure, or record its absence.
exposures ¶
exposures(dispatched: list[EffectTicket], invocations: tuple[str, ...]) -> tuple[ExposureRecord, ...]
Irreversible effects that already left, plus every opaque extension.
Source code in src/symfonic/services/effects/accounting.py
invocations
staticmethod
¶
invocations(covered: tuple[EffectLease, ...], affected: tuple[EffectTicket, ...]) -> tuple[str, ...]
Every invocation the fence reached, whether or not it crossed a port.
Effect tickets are the wrong sole key here. An opaque in-process extension performs its effects without crossing a classified port, so an invocation can hold a fenced lease and own no ticket at all — exactly the invocation a revert must never leave out of the record. An invocation is accounted for because it was authorized under the rejected generation, not because it happened to cross a port the framework can see.
Source code in src/symfonic/services/effects/accounting.py
SecurityRevertCoordinator ¶
SecurityRevertCoordinator(*, store: InMemoryLeaseStore, fences: FenceLedger, tracker: InFlightTracker, drain: BoundedDrain, quarantine: ResultQuarantine, accountant: OpaqueExposureAccountant, containment: ContainmentCoordinator, ports: FencedPortRegistry, clock: Callable[[], float] = time.time)
Closes admissions, blocks commits, contains what it cannot stop.
Source code in src/symfonic/services/effects/revert.py
refresh ¶
Re-read the suppression and quarantine ledgers for this incident.
A provider or tool result dispatched before the fence landed arrives
after revert returns, so the frozen record cannot contain it — the
late-result evidence would otherwise be structurally unreachable from
the incident. This re-reads both ledgers, still scoped to the tickets
this revert accounted for, and returns a new record with the same
incident_id. Nothing else is recomputed: the fence, the drain, and
the claims are findings about a moment, not a running total.
Source code in src/symfonic/services/effects/revert.py
revert
async
¶
revert(*, bundle_id: str, reason: str, actor: str, rejected_vector_hash: str | None = None, epoch_ceiling: int | None = None, tenant_scope_hash: str | None = None) -> SecurityRevertIncident
Raise the fence, then account for everything it could not stop.
Source code in src/symfonic/services/effects/revert.py
SecurityRevertIncident
dataclass
¶
SecurityRevertIncident(incident_id: str, bundle_id: str, reason: str, actor: str, linearized_at: float, fence: Fence, revoked_leases: tuple[str, ...] = (), stale_admissions: tuple[str, ...] = (), cancelled: tuple[str, ...] = (), affected_tickets: tuple[str, ...] = (), suppressed_commits: tuple[str, ...] = (), quarantined: tuple[str, ...] = (), exposures: tuple[ExposureRecord, ...] = (), compensations: tuple[CompensationOutcome, ...] = (), claims: tuple[FencingClaim, ...] = (), cutover_blocked: tuple[str, ...] = (), degraded: bool = False, degraded_reason: str = '', drain: DrainReport = (lambda: DrainReport(drained=(), contained=(), budget=0.0, elapsed=0.0))())
Every leg of one security revert, in one reviewable record.
fencing_claim_withheld
property
¶
Derived. Exposure, a withheld claim, or degradation all take it true.
TicketState ¶
Bases: StrEnum
Where one effect attempt got to. The drain reads these and only these.
UnapprovedCompensationError ¶
Bases: ConfigurationError
An irreversible port has no tested, approved compensation rule.
Raised at registration for an untested rule and at lookup for a missing one. Either way the capability does not cut over.
UnclassifiedEffectPortError ¶
Bases: EffectFenceError
SEC-FCP-5 precedent — an effect port carries no fence row.
An unclassified port is not "probably reversible". It is a hole in the evidence, and the operation that found it is denied.