symfonic.platform.governance_decisions¶
governance_decisions ¶
What governance decided, in a shape an operator can group and count.
A refusal already reaches a caller as an exception and an amendment already reaches the tool. Neither answers the question an operator asks the next morning: which rule did that, to which call, and how often?
Three properties shape this module.
Rule identity is declared, never inferred. Not the objection's text,
which is prose someone will reword; not the class name, which is a refactor
away; not the position in a list, which changes when a deployment inserts a
rule above it. An Objector says what it is called, and that is what a
dashboard groups by.
A decision carries no values. No arguments, no credential, no scrubbed text. Scrubbing a secret out of a call and then writing it into the line that records the scrub is not a smaller leak -- it is the same leak, in a destination usually kept longer and read by more people.
Attribution survives plurality. Two rules that both changed one call are two rule ids on one decision, not one entry saying "governance".
DecisionLog
dataclass
¶
The in-process sink, for a deployment that wants one and nothing more.
Not a default: governance() records nothing unless handed a sink, so
a deployment that never asked for a decision log does not accumulate one
for the life of the process.
by_rule ¶
Every decision one rule took part in.
The question a dashboard asks, and the reason rule_ids is a tuple
rather than a string: a decision two rules made is found by either.
Source code in src/symfonic/platform/governance_decisions.py
DecisionSink ¶
Where decisions go. A protocol in all but name.
Anything with record(decision) is one, so a deployment writes to its
own store without inheriting from this package.
GovernanceDecision
dataclass
¶
GovernanceDecision(state: str, phase: str, stage: str, rule_ids: tuple[str, ...], call_id: str, tool: str, reason: str, at: datetime = (lambda: datetime.now(UTC))())
One governance outcome for one call.
state is the vocabulary, and it is three words on purpose:
applied
A rule rewrote the call and the tool ran with the rewrite.
refused
A rule objected and the call did not run.
discarded
A rule asked for a change this phase has no way to make. It is
neither of the above and must not be reported as either: an operator
reading applied for a change that never happened is worse off
than one reading nothing.
acting_records ¶
The records that changed something, in the order the stages ran.
Two ways a stage can have acted, and the second is easy to miss. It may have steered -- an objection the kernel then has to resolve -- or it may have quietly repaired the subject and admitted it. The second leaves an ALLOW disposition and an amendment finding, so filtering on disposition alone reports a repaired call as though no rule touched it.
Source code in src/symfonic/platform/governance_decisions.py
objection_in ¶
The first record where a rule said no, or None.
record.objection is the distinction, and it already existed in the
contract. StageVerdict.annotate(subject, ...) is how a stage says "I
changed this" -- credential hygiene scrubbing an argument -- and carries no
objection. StageVerdict.steer(objection, ...) is how a stage says "I
will not have this", and the objection is its first argument.
Disposition cannot tell them apart: a repair and a refusal both leave ANNOTATE or STEER, which is how a repair came to outrank a refusal in the first place. Neither can the finding kinds alone, because a stage that repairs by replacing the subject records findings of its own kind rather than amendment findings -- reading those as objections made credential hygiene refuse every turn it scrubbed.
Source code in src/symfonic/platform/governance_decisions.py
report ¶
report(sink: Any, *, state: str, phase: str, records: Sequence[Any], reason: str, calls: Sequence[Any] = ()) -> None
Write one decision per call the rules acted on, or one for the turn.
calls are the kernel's requests for this round, and they are the only
thing read for identity -- call_id and tool name, never arguments.
An ingress or egress rung governs a turn rather than a call and records a
single decision with the call fields empty, which is honest: inventing a
call id there would let a dashboard join on something that does not exist.
A sink of None is the ordinary case and returns immediately, so a
deployment that composed no log pays for none of this.
Source code in src/symfonic/platform/governance_decisions.py
rules_in ¶
The rules that spoke in one stage record, each named individually.
A stage is a place; a rule is a decision. policy_steering is where
three tenant rules run, and reporting all three as "policy_steering"
tells an operator which file to open rather than which rule to change.
Findings carry the declared name, so they are the source; the stage name
is the fallback for a stage that objected without one.