Act, abstain, degrade¶
Every call to decide ends in one of three answers: carry out the action,
don't carry it out, or carry out a safer fallback. Your service has to turn
each answer into something concrete: execute a block, open a review ticket,
or do nothing and wait. This page explains what each verdict means, how to
route it, and what a verdict does not tell you.
It is for the engineer writing the code that runs after decide and for the
domain owner deciding which cases reach a person. It assumes you have read
Anatomy of a decision. The records below come from
uv run grounded-decision examples fraud_review and
uv run grounded-decision examples sensor_fusion. The routing labels come
from route_decision in src/grounded_decision/examples/reference_service.py.
The three verdicts at a glance¶
Every record has a verdict, an action (what to carry out, if anything)
and a proposed_action (what the policy wanted, always recorded).
| Verdict | action |
proposed_action |
When | Your service |
|---|---|---|---|---|
act |
the proposal | the proposal | Every check passed and the policy's state allows the action | Execute action |
abstain |
none | what the policy wanted, or none | A check failed, or the policy has nothing to carry out yet | Depends on proposed_action; see the two kinds of abstain |
degrade |
the fallback the state names | what the policy wanted, or none | Every check passed and the state is marked as a fallback | Execute action, which is the fallback, and flag it |
The rule for reading a record: only act and degrade fill action, and
only action may be executed. A proposed_action on an abstain is never
permission to act. It is information for the person who will decide.
Act¶
All six checks passed and the policy proposed an action from a state that
allows it. In fraud_review, a suspicious flag on a card inside a dense
ring of 16 linked accounts gives:
Execute block. Save the record and the card's new state (review).
Two kinds of abstain¶
abstain covers two situations that need opposite handling. Tell them apart
by proposed_action.
Held for a person¶
The policy wanted to do something and a check refused. The same suspicious
flag, this time on a card linked to only 4 accounts through a chain of shared
laptops:
verdict=abstain action=None proposed_action=block state=review
reason: d λ² = 0.1667 vs 1+0.050 (at or below KS; abstain); evidence density inside operational window (theorem width=4.39e+05, vacuous=True)
The policy proposed block. The neighbour-agreement check failed because 4
weakly agreeing accounts tell you little about a fifth, and the knife-edge
check failed because a 4-account graph is too small to trust its density.
Send the case to a reviewer with the proposal and the reason. The reference
service calls this routing human_review; the example printouts call it
hold_for_human.
Waiting for the next event¶
The policy has nothing to carry out yet. Every check may have passed. Two real
cases from fraud_review with the dense-ring evidence:
event=hold state review → hold verdict=abstain proposed_action=None
event=chargeback state intake → intake verdict=abstain proposed_action=None
reason (both): no action marked on current state
hold moves the card to a state that is marked "no action". chargeback is
not an event the policy knows, so the card stays where it is and nothing is
proposed. Neither has anything for a person to approve. Do nothing, save the
new state, and wait for the next event. The routing label is
await_next_event.
A multi-step policy produces this often. In a workflow written as
test(submit) ; test(approve) ; act(provision), the record for submit is an
abstain with no proposal: the workflow is waiting for approve. That is
correct behaviour, not a failure.
A failed check with nothing proposed
hold on the thin-chain evidence returns abstain with no proposal and
the failed checks in reason. Nothing was blocked, so route_decision
sends it to await_next_event. The failed checks still tell you
something about your evidence: count them on your dashboard (see
Operate and monitor). The example printouts label
this case hold_for_human instead, because a check failed. Pick one rule
for your service and apply it everywhere.
Routing table¶
verdict |
proposed_action |
Routing (route_decision) |
What happens |
|---|---|---|---|
act |
set | execute |
Carry out action |
degrade |
set or none | execute_degraded |
Carry out action, the fallback; alert its owner |
abstain |
set | human_review |
Open a review showing proposed_action and reason |
abstain |
none | await_next_event |
Nothing; save the state |
flowchart TD
R["DecisionRecord"] --> V{"verdict"}
V -- act --> E["execute action"]
V -- degrade --> D["execute action (the fallback)<br/>and alert its owner"]
V -- abstain --> P{"proposed_action<br/>set?"}
P -- yes --> H["human review<br/>show proposal + reason"]
P -- no --> W["await next event<br/>save state, do nothing"]
The reference service's version is a small pure function, so you can test it on its own:
def route_decision(verdict: Verdict, proposed_action: str | None) -> Routing:
if verdict is Verdict.ACT:
return Routing.EXECUTE
if verdict is Verdict.DEGRADE:
return Routing.EXECUTE_DEGRADED
if proposed_action:
return Routing.HUMAN_REVIEW
return Routing.AWAIT_NEXT_EVENT
Build a decision service wires it to a review queue and an outbox.
Degrade¶
Some situations call for a reduced action rather than the full one or none.
The policy author marks a state as degrade and gives it a fallback action.
When every check passes and the entity lands in that state, decide returns
degrade with the fallback in action.
The sensor_fusion example reconstructs an image of the inside of a body or
tank from electrode readings. A reconstruction that assumed a smooth model can
be published as a quantitative map. One made without that assumption is not
guaranteed to be unique, so the policy only allows a qualitative picture:
stateDiagram-v2
[*] --> intake
intake --> quantitative_map: smooth_model
intake --> qualitative_only: unconstrained_model
note right of quantitative_map: act, publish_map
note right of qualitative_only: degrade, publish_qualitative_image
With good electrode contact:
event=unconstrained_model
verdict=degrade action=publish_qualitative_image proposed_action=publish_qualitative_image
reason: statechart degrade
With half the electrodes off the skin, the same event abstains instead:
verdict=abstain action=None proposed_action=publish_qualitative_image
reason: d λ² = 0.6667 vs 1+0.050 (at or below KS; abstain); conformal p=0.032 vs α=0.100 (reject)
Three things to know about degrade:
- It is chosen by the policy, not by the checks. A failed check always
produces
abstain, neverdegrade. Degrade is not a way around weak evidence. - It is built by hand. The policy text language marks states only as
act or no-action. To mark a state
degrade, build the chart withStatechart(...).state(name, verdict=Verdict.DEGRADE, action=...), assensor_fusion.build()does. See Write a policy. - It repeats while the entity stays in the state. An event with no arrow
out of
qualitative_only(for exampleframe_done) leaves the entity there and returnsdegradeagain with the sameactionandproposed_action=None. If executing the fallback twice is harmful, give the degrade state an exit arrow, or deduplicate in your service.
Use degrade when a safe partial action exists and is better than doing nothing: serve a cached answer, switch to read-only mode, publish with a warning banner. Use abstain when the only safe options are the full action or a person's judgement.
When several modules are combined, any child abstain makes the parent
abstain; otherwise any child degrade makes the parent degrade. A hold
is never turned into an automatic action, not even a reduced one. See
Composing modules.
What a verdict does not establish¶
Direction is checked only when you measure support
In fraud_review, send clean instead of suspicious with the dense
fraud-ring evidence. The policy proposes pass. Neighbour agreement,
the knife-edge check and the unusual-cases check all pass: they ask
whether the related cases are informative, stable and familiar, not
which way they point. The support check does ask:
only 3 of the 16 related accounts were cleared, so it fails
(support 0.19 vs minimum 0.50 (does not support the proposed action))
and the pass is held. That works only because the example tells the
library which outcome each action implies. Evidence without a support
share passes the support check (support not measured), and the same
clean event would act pass.
The event your service sends chooses the direction. Choose it with your model, rule or upstream system. The library then asks whether the related cases are strong, stable, familiar and, if you measure it, pointing the same way, before it acts on that choice without a person.
So, for each verdict:
actmeans the policy proposed this action and the evidence around the case passed all six checks. It does not mean the action is correct or that a theorem guarantees the outcome, and if support was not measured, it does not mean the related cases favour this action over another. How much mathematics stands behind each check is on Trust levels and honest limits.abstainwith a proposal means the evidence was not strong, stable, familiar or supportive enough to act alone. It does not mean the proposal is wrong. Many held cases will be confirmed by the reviewer.abstainwithout a proposal means the policy had nothing to carry out. It says nothing about the evidence unlessreasonlists failed checks.degrademeans the policy's author chose a fallback for this situation and the evidence passed. It does not mean the full action was judged unsafe for this case. The policy decided that in advance.
Where to go next¶
- The decision record: every field you route on, and what to store.
- Troubleshoot: why did it abstain?: from a
reasonstring to a fix. - One decision, step by step: the four code branches that produce these verdicts.
- Roll out safely: run in shadow mode and compare how often each verdict occurs before you execute anything.