Skip to content

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:

verdict=act  action=block  proposed_action=block  state=review
reason: all channels passed

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, never degrade. 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 with Statechart(...).state(name, verdict=Verdict.DEGRADE, action=...), as sensor_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 example frame_done) leaves the entity there and returns degrade again with the same action and proposed_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:

  • act means 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.
  • abstain with 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.
  • abstain without a proposal means the policy had nothing to carry out. It says nothing about the evidence unless reason lists failed checks.
  • degrade means 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