Skip to content

The decision record

Every call to decide returns one object, a DecisionRecord. Its JSON form is what your service stores, what a reviewer reads when a case is held, and what an auditor queries months later. This page explains every field, shows a real record, and says what to store with it and how to read its reason and health fields.

It is for the engineer who designs the table that holds decisions and for anyone who reads records to explain a decision. It assumes you have read Anatomy of a decision. The record on this page is the thin-chain case from uv run grounded-decision examples fraud_review: a suspicious flag on an $840 purchase whose card is linked to only 4 accounts, held for review.

The short version

Most of the time you need six fields:

{
  "verdict": "abstain",
  "action": null,
  "proposed_action": "block",
  "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)",
  "module": "fraud_review",
  "extras": {"state": "review"}
}

Read it as: the fraud_review policy wanted to block the card; it did not (action is null) because two checks failed, named in reason; the card is now in the review state, which you must save for its next event. The rest of the record (the event, the six check reports and the health numbers) says exactly what was measured.

Fields

DecisionRecord.to_dict() returns these keys, in this order:

Field Type Meaning
verdict "act", "abstain" or "degrade" The answer. See Act, abstain, degrade.
action string or null What to carry out. Set only on act (the proposal) and degrade (the fallback). Null on every abstain.
proposed_action string or null What the policy wanted, recorded whatever the verdict. Null when the policy had nothing to propose.
reason string One line. See Reading reason.
module string The name of the module (policy) that decided.
event object {"name", "payload"} exactly as you sent it. The payload is copied for the audit trail and never read by the checks.
channels list of 6 objects One report per check, always six, always in the order certificate, game, reconstruction, threshold, conformal, support.
health object The key numbers from the six reports, flattened for dashboards. See Reading health.
envelope string The hardness label of the module (p_exact for every example). decide does not use it; see Questions the library refuses.
extras object state: the entity's state after this event. Ignore keys you do not recognise.

In Python, record.acted is a shortcut for verdict is Verdict.ACT. It is not in the JSON.

Each check's report

Each entry in channels has the same shape:

Field Meaning
name Which check: certificate (policy shape), game (progress), reconstruction (neighbour agreement), threshold (knife-edge), conformal (unusual cases), support (support).
passed true or false. Any false makes the verdict abstain.
trust lean, paper or operational: how much proven mathematics stands behind this yes/no. See Trust levels and honest limits.
summary One line. The summaries of failed checks are joined to make reason.
metrics The numbers the check used (table below).
catalog Pointers to results in the companion math catalog: family (result number), title, lean (whether a machine-checked statement exists) and note. Informational; nothing reads them at decide time.
Check metrics keys Passes when
certificate star_height, dfa_states, max_dfa_states star_height ≤ max_star_height (3) and dfa_states ≤ max_dfa_states (256)
game parity_win, nonnegative_mean both true
reconstruction degree, lambda_hat, d_lambda_sq, margin, kesten_stigum (always 1.0) d_lambda_sq > 1 + margin
threshold n, p_hat, p_threshold, epsilon (0.1), theorem_width, operational_width, in_theorem_window, in_operational_window, theorem_vacuous in_operational_window is false
conformal p_value, alpha, n_cal, label_score, require_calibration p_value > alpha; with no calibration scores it passes unless require_calibration is true
support min_support, require_support, measured, and support when measured support ≥ min_support; when not measured it passes unless require_support is true

Two things in this table surprise people:

  • The certificate report always passes inside a record. A policy that breaks max_star_height or max_dfa_states is refused when the module is built, so no record is ever made from it. The report is kept so every record shows the limits the policy was checked against (for example star-height 0 ≤ 3; 4 states ≤ 256).
  • The trust tag is almost constant in practice. certificate is always lean, even if it fails. game and reconstruction are lean when they pass and drop to operational and paper when they fail. threshold is operational at every realistic graph size, because the proven window is wider than 1 (theorem_vacuous is true). conformal and support are always operational. Trust levels and honest limits explains why.

A real record

This is the complete record for the thin-chain case, printed with json.dumps(record.to_dict(), indent=2, ensure_ascii=False). It is about 3.5 KB as single-line JSON; 1.3 KB of that is the catalog pointers, which are the same on every record. This evidence does not measure support, so the support report says support not measured and health.support is null.

Full JSON of the thin-chain abstain (163 lines)
{
  "verdict": "abstain",
  "action": null,
  "proposed_action": "block",
  "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)",
  "module": "fraud_review",
  "event": {
    "name": "suspicious",
    "payload": {
      "amount_usd": 840,
      "merchant": "electronics"
    }
  },
  "channels": [
    {
      "name": "certificate",
      "passed": true,
      "trust": "lean",
      "summary": "star-height 0 ≤ 3; 4 states ≤ 256",
      "metrics": {
        "star_height": 0,
        "dfa_states": 4,
        "max_dfa_states": 256
      },
      "catalog": [
        {
          "family": "134",
          "title": "Generalized star height at most three",
          "lean": true,
          "note": "Every regular control language is rejected if star-height exceeds 3."
        },
        {
          "family": "104",
          "title": "Mean-payoff and parity games",
          "lean": true,
          "note": "Parity via Zielonka; 1-player mean-payoff via Karp. Truffet tropical elimination is banned."
        },
        {
          "family": "104",
          "title": "Truffet elimination counterexample",
          "lean": true,
          "note": "The Lean Comparator records a finite counterexample; the solver never uses that step."
        }
      ]
    },
    {
      "name": "game",
      "passed": true,
      "trust": "lean",
      "summary": "parity_win=True nonnegative_mean=True",
      "metrics": {
        "parity_win": true,
        "nonnegative_mean": true
      },
      "catalog": [
        {
          "family": "104",
          "title": "Mean-payoff and parity games",
          "lean": true,
          "note": "Parity via Zielonka; 1-player mean-payoff via Karp. Truffet tropical elimination is banned."
        },
        {
          "family": "104",
          "title": "Truffet elimination counterexample",
          "lean": true,
          "note": "The Lean Comparator records a finite counterexample; the solver never uses that step."
        }
      ]
    },
    {
      "name": "reconstruction",
      "passed": false,
      "trust": "paper",
      "summary": "d λ² = 0.1667 vs 1+0.050 (at or below KS; abstain)",
      "metrics": {
        "degree": 1.5,
        "lambda_hat": 0.33333333333333326,
        "d_lambda_sq": 0.1666666666666666,
        "margin": 0.05,
        "kesten_stigum": 1.0
      },
      "catalog": [
        {
          "family": "229",
          "title": "Three-state reconstruction above Kesten–Stigum",
          "lean": true,
          "note": "Go only when d λ² > 1 + margin. Non-reconstruction at equality is policy, not Lean."
        }
      ]
    },
    {
      "name": "threshold",
      "passed": false,
      "trust": "operational",
      "summary": "evidence density inside operational window (theorem width=4.39e+05, vacuous=True)",
      "metrics": {
        "n": 4,
        "p_hat": 0.5,
        "p_threshold": 0.08,
        "epsilon": 0.1,
        "theorem_width": 439069.4614197543,
        "operational_width": 1.6749170738973782,
        "in_theorem_window": false,
        "in_operational_window": true,
        "theorem_vacuous": true
      },
      "catalog": [
        {
          "family": "186",
          "title": "Friedgut–Kalai sharp threshold width",
          "lean": true,
          "note": "Theorem width is 2^19 log(1/(2ε))/(log n)². Vacuous at operational n; operational prefactor is separate."
        }
      ]
    },
    {
      "name": "conformal",
      "passed": true,
      "trust": "operational",
      "summary": "conformal p=1.000 vs α=0.100 (cover)",
      "metrics": {
        "p_value": 1.0,
        "alpha": 0.1,
        "n_cal": 40,
        "label_score": 0.1,
        "require_calibration": false
      },
      "catalog": []
    },
    {
      "name": "support",
      "passed": true,
      "trust": "operational",
      "summary": "support not measured",
      "metrics": {
        "min_support": 0.5,
        "require_support": false,
        "measured": false
      },
      "catalog": []
    }
  ],
  "health": {
    "d_lambda_sq": 0.1666666666666666,
    "reconstruction_margin": 0.05,
    "p_hat": 0.5,
    "p_threshold": 0.08,
    "threshold_width_theorem": 439069.4614197543,
    "threshold_width_operational": 1.6749170738973782,
    "in_theorem_window": false,
    "in_operational_window": true,
    "theorem_window_vacuous": true,
    "conformal_p": 1.0,
    "star_height": 0,
    "game_solved": true,
    "mixer_status": "idle",
    "support": null
  },
  "envelope": "p_exact",
  "extras": {
    "state": "review"
  }
}

Reproduce it with:

import json
from grounded_decision.examples import fraud_review as fr
from grounded_decision.types import Event

module = fr.build()
logins, outcomes = fr.thin_rumour_logins()
evidence = fr.device_graph_evidence(logins, outcomes, candidate_model_score=0.10)
record = module.decide(
    Event("suspicious", {"amount_usd": 840, "merchant": "electronics"}), evidence
)
print(json.dumps(record.to_dict(), indent=2, ensure_ascii=False))

uv run grounded-decision examples fraud_review --json prints all three records of the example in compact form.

Reading reason

reason is one of five kinds of string. Which one tells you which branch of decide ran (One decision, step by step has the code).

reason Verdict Meaning
all channels passed act Every check passed; the proposal was carried out.
statechart degrade degrade Every check passed; the state is marked as a fallback.
event '…' is not handled in state '…'; nothing proposed abstain Every check passed, but the event has no arrow out of the current state (an unknown name, or a known one out of order).
no action marked on current state abstain Every check passed; the policy moved to a state that proposes nothing.
anything else abstain The summaries of the failed checks, joined by "; ".

The failed-check summaries start with a recognisable phrase:

Summary starts with Failed check Example
d λ² = neighbour agreement d λ² = 0.1667 vs 1+0.050 (at or below KS; abstain): evidence strength 0.1667, needed above 1.05
evidence density inside operational window knife-edge density too close to the cut-off for this graph size
conformal p= unusual cases conformal p=0.032 vs α=0.100 (reject)
no calibration scores unusual cases empty calibration set with require_conformal_calibration=True
support support support 0.19 vs minimum 0.50 (does not support the proposed action), or support not measured (require_support)
parity_win= or ... vertices exceed ... progress the policy failed its build-time check; every decision will abstain

Search on the channels[].passed bits rather than on these strings when you can. The summary wording is for people and may change between versions (see Stability and versioning).

Reading health

health copies the numbers you will want to chart over time out of the six reports, so a dashboard does not have to search the channels list. For the thin-chain record:

Field Value Meaning Copied from
d_lambda_sq 0.1667 Evidence strength (\(d\lambda^2\)): links per case × agreement². Must exceed 1 + margin. reconstruction
reconstruction_margin 0.05 The margin above 1 reconstruction
p_hat 0.5 Link density of the related cases evidence
p_threshold 0.08 Your density cut-off evidence
threshold_width_operational 1.675 Full width of the knife-edge window used for the decision; the check holds when p_hat is within half of it (capped at 0.5) of p_threshold threshold
threshold_width_theorem 439069 Width the proven theorem gives; above 1 means it says nothing at this size threshold
in_operational_window true The density is on the knife-edge, so the check failed threshold
in_theorem_window false Always false when the theorem width is vacuous threshold
theorem_window_vacuous true The theorem width is above 1 threshold
conformal_p 1.0 Unusualness p-value; low means unlike past correct cases conformal
star_height 0 How deeply the policy nests repetition; 0 for policies built as charts module
game_solved true The progress check's parity result (parity_win) module
mixer_status idle Always idle on records from decide —
support null Share of related cases carrying the outcome the proposed action implies; null when not measured evidence

Three habits make health useful:

  • Chart d_lambda_sq and conformal_p per module over time. A slow slide towards 1.05 or 0.10 shows your evidence weakening before abstains spike.
  • Compare p_hat with p_threshold, not with 0. The knife-edge check is about distance from your cut-off.
  • Use channels[].passed to explain a verdict, and health to see trends. health has no pass/fail bits for neighbour agreement or unusualness.

What to store

The record is designed to be stored as it is. Store its JSON, plus four things the record does not contain:

  1. The evidence you passed in (EvidenceSnapshot.to_dict()). The record keeps the scalars the checks read, but not the calibration scores, n_states or your observations. Without the snapshot you cannot replay a decision exactly.
  2. The policy version. The record names the module but not which version of its policy decided. Store a fingerprint of the policy and its settings. See State and policy versions.
  3. Who, when and which request: entity id, timestamp, request id, tenant. The library has none of these.
  4. The entity's new state (extras.state), in a separate one-row-per-entity table, so the next event can restore it.

The reference service stores decisions in an append-only table. Its schema (from src/grounded_decision/examples/reference_service.py) is a good starting point:

CREATE TABLE IF NOT EXISTS decisions (
    decision_id      INTEGER PRIMARY KEY AUTOINCREMENT,
    request_id       TEXT NOT NULL,
    entity_id        TEXT NOT NULL,
    idempotency_key  TEXT NOT NULL,
    policy_name      TEXT NOT NULL,
    policy_version   TEXT NOT NULL,
    event_name       TEXT NOT NULL,
    state_before     TEXT,
    state_after      TEXT NOT NULL,
    verdict          TEXT NOT NULL,
    action           TEXT,
    proposed_action  TEXT,
    routing          TEXT NOT NULL,
    enforced         INTEGER NOT NULL,
    failed_channels  TEXT NOT NULL,         -- JSON list of channel names
    record_json      TEXT NOT NULL,
    evidence_json    TEXT NOT NULL,
    created_at       TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS decisions_by_entity ON decisions (entity_id, decision_id);

It pulls the fields people query (verdict, proposed_action, event_name, the failed check names, the state before and after) into columns, and keeps the full record and evidence as JSON beside them. Typical questions it answers quickly: "which blocks did we hold last week?" (verdict = 'abstain' AND proposed_action = 'block'), "how often did the knife-edge check fail?" (failed_channels LIKE '%threshold%').

Do not pickle a live Module to store state. Store extras.state and restore it into a module built from the same policy version.

Reading a stored record back

DecisionRecord.from_dict(data) rebuilds the object from its JSON, so code that combines records (for example compose_parent) can work across services. Re-running decide on the old event with new evidence is a new decision, not a replay: for an audit, read the stored record.

When the format changes

Records carry no schema version field. Under the rules in Stability and versioning, adding a field is a compatible change (readers must ignore keys they do not know), while adding or reordering a check, or renaming a verdict or check name, is a breaking one. Store the library version (grounded_decision.__version__) beside each record if you need to know which format produced it.

What an auditor checks

When a record needs explaining, read it in this order:

  1. verdict and proposed_action. abstain with a proposal is a held action; abstain without one is a policy waiting for its next event.
  2. reason and the six passed bits. They must agree: any false means reason lists that check's summary. All true with abstain means the policy had nothing to carry out.
  3. health against channels[].metrics. health.d_lambda_sq equals the reconstruction report's d_lambda_sq; health.conformal_p equals the conformal report's p_value. If they differ, the record was altered.
  4. extras.state. The state after this event. In the thin-chain case it is review: the policy moved even though the block was held.
  5. The stored evidence and policy version, to reproduce the decision.

An act record shows that the evidence passed the checks, not that the action was right; see what a verdict does not establish. Whatever you put in event.payload is copied into every record, so keep secrets out of it.

Where to go next