Build a decision service¶
Calling module.decide(event, evidence) in a Python prompt is one
thing; a service your card authoriser can call, that remembers each
card, survives retries, keeps an audit trail and sends held cases to a
person, is another. This tutorial walks through a runnable HTTP service
that ships with the package and does all of that, one concern at a time.
Each step names the production problem, shows the code that solves it,
and gives a curl command with output copied from a real run.
You should have done Your first decision and
From your data to evidence. You need a
development checkout (Install), curl, and optionally
jq to shorten the output. Allow about an hour.
The service is src/grounded_decision/examples/reference_service.py. It
uses only the Python standard library (http.server, sqlite3, json,
logging, threading), so it adds no dependencies. It is meant to be
copied: run it, read it, then replace one piece at a time with what your
platform already has (Postgres for SQLite, FastAPI for http.server,
your warehouse for the demo tables, your case tool for the review
table).
Try it in your browser
The showcase site's the decision runtime page steps through this tutorial's four requests in your browser (the same policy, evidence, checks and routing as POST /decide) and lets you send more. It runs this package itself, in Pyodide.
The shown numbers are the documented example, as the engine computed it; change a setting to compute it in your browser.
The decision runtime runs here when JavaScript is on; the decision runtime page has it too.
The policy¶
The domain is card fraud review, from
src/grounded_decision/examples/fraud_review.py. Each card starts in
intake. A suspicious authorisation moves it to review, which
proposes block; a clean one moves it to auto, which proposes
pass. A hold event from either moves it to hold, which proposes
nothing.
stateDiagram-v2
[*] --> intake
intake --> auto: clean
intake --> review: suspicious
auto --> hold: hold
review --> hold: hold
note right of auto: proposes pass
note right of review: proposes block
There is no way back to intake and no suspicious transition out of
review. Step 5 shows what that means for a second authorisation on the
same card.
The evidence is the device graph around the flagged account: accounts that logged in from the same phone or laptop are linked, and each carries the outcome of its last fraud review, exactly as in From your data to evidence.
What you will have at the end¶
caller (card authoriser, queue consumer, batch job)
| POST /decide {entity_id, event, idempotency_key, candidate_model_score}
v
DecisionRequestHandler thin HTTP adapter: reads and writes bytes
v
DecisionRoutes paths to methods, errors to JSON (any server can call it)
v
DecisionService.decide
1. entity lock EntityLocks (in-process)
2. idempotency lookup IdempotencyStore
3. load state + check policy version StateStore
4. build evidence EvidenceProvider <- your data plugs in here
5. decide DecisionEngine (built once, locked)
6. route route_decision (pure function)
7. one transaction: state (optimistic version)
+ audit row (append-only)
+ review row or outbox row
+ idempotency key and response
8. one JSON log line + metrics
Eleven endpoints: POST /decide, GET /decisions/<entity_id>,
GET /reviews, POST /reviews/<id>/resolve, GET /outbox,
GET /report, GET /metrics and GET /health, plus three for learning
the settings from your own outcomes (step 11): POST /outcomes,
GET /history.jsonl and GET /parameters.
Every request carries a candidate_model_score: your risk model's score
for this authorisation. The service passes it to the evidence as the
case's unusualness score (label_score), and the unusual-case check
compares it with the scores of the last 40 confirmed fraud cases (0.40
to 1.18). It holds when the score is higher than almost all of them.
Every request on this page sends 0.1, below all 40, so that check passes
throughout and the other checks do the work.
Step 0: run it¶
That runs
uv run python -m grounded_decision.examples.reference_service --seed-demo-data.
--seed-demo-data loads two groups of related accounts into the
evidence tables:
- a dense ring,
acct_000toacct_015: sixteen accounts cycling through eight phones, thirteen confirmed fraud; - a thin chain,
acct_900toacct_903: four accounts linked through three shared laptops.
Seeding is idempotent, so restarting is safe. Other flags: --host,
--port (default 8080), --database (default
grounded_decision_reference_service.sqlite3 in your temp directory),
--shadow (step 10), --parameters, --allow-uncertified,
--agreement-baseline and --seed-learning-demo (step 11) and
--log-level DEBUG (adds the access log). Pass
flags through SERVICE_ARGS, for example
make service SERVICE_ARGS="--seed-demo-data --port 9000".
The process logs two lines and waits:
2026-10-09 12:56:17,637 serving http://127.0.0.1:8080 policy=fraud_review@557323c7e95c settings=settings@13447e40df90 learned=none mode=enforce database=/var/folders/.../grounded_decision_reference_service.sqlite3
2026-10-09 12:56:17,637 try: curl -s http://127.0.0.1:8080/decide -d '{"entity_id": "acct_000", "event": {"name": "suspicious"}, "idempotency_key": "try-1", "candidate_model_score": 0.1}'
In a second terminal, check that it is alive:
{
"database": "/var/folders/.../grounded_decision_reference_service.sqlite3",
"enforcement": "enforce",
"enforcement_reason": "enforcing",
"learned_parameters": null,
"open_reviews": 0,
"parameters_fingerprint": "settings@13447e40df90",
"policy_name": "fraud_review",
"policy_version": "fraud_review@557323c7e95c",
"risk_monitor": {
"detail": "start with --parameters to monitor the error rate",
"status": "not_configured"
},
"status": "ok",
"valid_events": [
"clean",
"hold",
"suspicious"
]
}
Now make one decision: a suspicious $840 electronics purchase on a
card inside the dense ring.
curl -s localhost:8080/decide -d '{
"entity_id": "acct_000",
"event": {"name": "suspicious", "payload": {"amount_usd": 840, "merchant": "electronics"}},
"idempotency_key": "auth-0001",
"candidate_model_score": 0.1
}' | jq '{routing, verdict: .record.verdict, action: .record.action,
reason: .record.reason, state_before, state_after,
evidence_summary, outbox_id, review_id, policy_version, parameters_fingerprint}'
{
"routing": "execute",
"verdict": "act",
"action": "block",
"reason": "all channels passed",
"state_before": null,
"state_after": "review",
"evidence_summary": {
"accounts": 16,
"shared_device_links": 40,
"truncated": false
},
"outbox_id": 1,
"review_id": null,
"policy_version": "fraud_review@557323c7e95c",
"parameters_fingerprint": "settings@13447e40df90"
}
The card is blocked. Without the jq filter the response also has
request_id, decision_id, entity_id, idempotency_key, enforced,
and under record the full decision record: all six checks with their
numbers, the health summary and extras.state
(The decision record). The rest of this page
explains how each field got there.
Step 1: the policy, built once and versioned¶
The concern. Building a Module runs the policy checks (star height,
and the game that asks whether the workflow can keep going). That is the
expensive part; decide itself takes a few microseconds for this policy
(about 7 µs on a laptop). So the module is built once at start-up. And
because a saved state such as "review" is a position in one version
of the policy, every saved state and every audit row must say which
version produced it.
The code. build_fraud_engine builds the module and fingerprints it:
def build_fraud_engine(evidence_parameters: Mapping[str, object]) -> DecisionEngine:
module = fraud_review.build()
return DecisionEngine(module, policy_fingerprint(module, evidence_parameters))
The engine carries two versions, because two different things change:
policy_fingerprinthashes what gives a saved state its meaning: every state with its verdict, action, payoff and priority; every transition; the initial state; the envelope; and the policy text if there is one. The result is a short string such asfraud_review@557323c7e95c. It is stored with each card's state, stamped on each audit row, and checked when state is restored (step 5).parameters_fingerprinthashes the settings that change what the checks conclude: theLimits(margins, knife-edge window, unusualness level), the evidence settings (the ring density line, the calibration scores, the neighbourhood caps, the agreement baseline) and, when loaded, the learned parameters (step 11). The result looks likesettings@13447e40df90. It is stamped on every audit row and returned with every decision, but it never blocks a saved state: a new calibration set or a re-learned margin does not change whatreviewmeans, so cards keep their state across it.
Two processes running the same code get the same strings, so a rolling deploy of unchanged code is seamless.
Try it. GET /health (above) shows the running version, and
valid_events lists the event names the policy understands. Request
validation checks against that list (step 3).
Step 2: the evidence provider, where your data plugs in¶
The concern. In production nobody types evidence numbers in. You
build the graph of related cases from raw rows and let
snapshot_from_graph compute the numbers. The question is where that
code lives and how you swap the demo tables for your warehouse.
The code. One interface is the seam:
class EvidenceProvider(Protocol):
def evidence_parameters(self) -> Mapping[str, object]:
"""Settings that change decisions (thresholds, calibration, caps)."""
...
def evidence_for(
self, entity_id: str, candidate_model_score: float
) -> EvidenceSnapshot:
"""Return the evidence for ``entity_id`` right now."""
...
The shipped implementation, SqliteDeviceGraphEvidenceProvider, reads
two tables, device_logins(account_id, device_id, logged_in_at) and
review_outcomes(account_id, outcome, reviewed_at, source). For one
account it walks outwards: the account, its devices, the other accounts
on those devices, their devices, and so on. It stops at 500 accounts and
skips any device used by more than 50 accounts (a shop kiosk or an
office network is not evidence of a ring). It labels each account with
its most recent review outcome (never-reviewed accounts count as
cleared) and passes the rows to fraud_review.device_graph_evidence,
which calls snapshot_from_graph:
def evidence_for(self, entity_id, candidate_model_score):
with self.database.connection() as connection:
device_logins, truncated = self._neighbourhood_logins(connection, entity_id)
accounts = sorted({account for account, _ in device_logins})
outcomes = self._last_outcomes(connection, accounts)
snapshot = fraud_review.device_graph_evidence(
device_logins, outcomes, candidate_model_score=candidate_model_score
)
return dataclasses.replace(
snapshot,
observations={
**snapshot.observations,
"entity_id": entity_id,
"truncated": truncated,
},
)
The ring account sees 16 accounts and 40 shared-device links. The chain account sees 4 accounts and 3 links. That is the whole difference between the two decisions in step 4.
Swapping in your warehouse. Keep the walk and replace the three
small query methods (_devices_of, _accounts_on, _last_outcomes)
with queries against your tables. If a walk per request is too slow,
run it in a batch job every few minutes, cache EvidenceSnapshot.to_dict()
per account, and have evidence_for read the cache and set
label_score from the request. Three rules hold whatever the source:
- Missing data must produce thin evidence, never padded evidence. An account with no logins gets an empty graph and the decision is held. That is the right answer.
- Bound the work: cap the neighbourhood and set a query timeout.
- Return every setting that changes decisions from
evidence_parameters, so changing a threshold changes the settings version and you can tell which decisions it made.
Build evidence from your data covers choosing the graph, the labels and the calibration set for other domains.
Try it. Decide for an account the evidence tables have never seen:
curl -s localhost:8080/decide -d '{"entity_id": "acct_777",
"event": {"name": "suspicious"}, "idempotency_key": "auth-0002",
"candidate_model_score": 0.1}' \
| jq '{routing, verdict: .record.verdict, reason: .record.reason, evidence_summary}'
{
"routing": "human_review",
"verdict": "abstain",
"reason": "d λ² = 0.0000 vs 1+0.050 (at or below KS; abstain); evidence density inside operational window (theorem width=1, vacuous=True)",
"evidence_summary": {
"accounts": 0,
"shared_device_links": 0,
"truncated": false
}
}
No related cases, no automatic block. The card goes to a person.
Step 3: the request, validated at the edge¶
The concern. A malformed request must fail before any state is touched, and it must list everything that is wrong at once, so the caller fixes it in one round trip. Unknown event names need special care: the library treats an event the policy doesn't know as "stay put, propose nothing", which is safe but hides typos.
The code. parse_decide_request checks every field and collects
every problem into one RequestValidationError (HTTP 400). The event
name must be one of the policy's events. The handler also refuses bodies
over 64 KiB and bodies without a Content-Length header.
Try it.
curl -s -w '\nHTTP %{http_code}\n' localhost:8080/decide \
-d '{"entity_id": "acct_000", "event": {"name": "flagged"}, "candidate_model_score": "high"}'
{
"error": "invalid_request",
"message": "The request body is not a valid decide request: 'idempotency_key' must be a non-empty string; 'event.name' must be one of ['clean', 'hold', 'suspicious'], got 'flagged'; 'candidate_model_score' must be a finite number",
"problems": [
"'idempotency_key' must be a non-empty string",
"'event.name' must be one of ['clean', 'hold', 'suspicious'], got 'flagged'",
"'candidate_model_score' must be a finite number"
]
}
HTTP 400
Broken JSON gets the same treatment ("the body is not valid JSON
(Expecting value: ...)"), and an unknown path gets a 404 that lists the
paths that exist.
Step 4: decide and route¶
The concern. The library returns a verdict; your system needs to know which component handles it next. That mapping is the only business logic between the library and your side effects, so it should be small, pure and tested on its own.
The code.
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
| Verdict | proposed_action |
Routing | What the service does |
|---|---|---|---|
| act | set | execute |
writes the action to the outbox (step 7) |
| degrade | any | execute_degraded |
writes the policy's fallback action to the outbox |
| abstain | set | human_review |
opens a review with the proposal and the reason |
| abstain | none | await_next_event |
nothing: the policy is waiting for its next event |
A proposed_action on an abstain is what the policy wanted. It is
never permission to act. tests/test_reference_service.py tests every
row of this table. Act, abstain, degrade
explains the verdicts.
Try it. The ring card acted and routed to execute. Now the same
$840 purchase on the chain account:
curl -s localhost:8080/decide -d '{"entity_id": "acct_900",
"event": {"name": "suspicious", "payload": {"amount_usd": 840, "merchant": "electronics"}},
"idempotency_key": "auth-0003", "candidate_model_score": 0.1}' \
| jq '{routing, verdict: .record.verdict, action: .record.action,
proposed_action: .record.proposed_action, reason: .record.reason,
state_after, evidence_summary, review_id, outbox_id}'
{
"routing": "human_review",
"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)",
"state_after": "review",
"evidence_summary": {
"accounts": 4,
"shared_device_links": 3,
"truncated": false
},
"review_id": 2,
"outbox_id": null
}
Same event, same model score, same policy:
- The ring has 5 links per account on average and neighbour agreement 0.55, so evidence strength is \(5 \times 0.55^2 = 1.51\), above the 1.05 the check requires. Its link density is 0.33; with 16 accounts the knife-edge window reaches 0.209 either side of the line at 0.08 (up to 0.289), so 0.33 is clear of it.
- The chain has 1.5 links per account and agreement 1/3 (two of its three links join accounts with the same outcome), so strength is \(1.5 \times (1/3)^2 = 0.17\). With only 4 accounts the window is as wide as it gets, 0.5 either side of the line, so its density of 0.5 is inside it. With 6 or fewer related accounts, the knife-edge check holds almost every decision.
A score cut-off would have frozen both cards. This service freezes one
and sends the other to a person, as review 2 (review 1 is acct_777
from step 2).
Step 5: state that survives between requests¶
The concern. The policy remembers where each card is, but the library does not store anything between calls. The service must save each card's state and restore it on the next request. Three problems come with that:
- A
Moduleholds the current card's state while it decides, so it is not thread-safe. - Two concurrent events for the same card must not both read
intakeand both write their own next state. - A state saved under an old policy version must not be silently restored into a new one: editing a policy can renumber or rename its states.
The code. StateStore keeps one row per card:
CREATE TABLE entity_state (
entity_id TEXT PRIMARY KEY,
policy_name TEXT NOT NULL,
policy_version TEXT NOT NULL,
state TEXT NOT NULL,
version INTEGER NOT NULL, -- optimistic concurrency counter
updated_at TEXT NOT NULL
);
Problem 1: DecisionEngine holds one module and one lock, taken only
around restore + decide:
def decide(self, saved_state, event, evidence):
with self._lock:
if saved_state is None:
self.module.reset()
else:
self.module.restore(saved_state)
return self.module.decide(event, evidence)
Evidence reads, database writes and network calls all happen outside
the lock, and the locked work takes microseconds, so the lock is not the
bottleneck. If profiling ever shows contention, use a pool of modules
(a queue.Queue of modules built at start-up) or one module per
single-threaded worker process.
Problem 2 is handled twice. Within a process, EntityLocks holds a lock
per card id (256 shared locks chosen by a hash, so memory stays
bounded). Across processes, the version column does it: the write is
UPDATE ... WHERE entity_id = ? AND version = ?, and if another process
got there first it changes zero rows, the transaction rolls back, and
the decision is retried with the fresh state, up to three times, then
HTTP 409. On Postgres, SELECT ... FOR UPDATE works too.
Problem 3: if the stored policy_version differs from the running one,
the service refuses with HTTP 409 policy_version_mismatch. Migrate
saved states (map old state names to new ones) before restoring across a
policy change. State and policy versions
explains why and how.
State is saved even when the verdict is abstain. The policy advances on every event it recognises; only the side effect is withheld.
Try it. Send a second suspicious event for the ring card that was
just blocked:
curl -s localhost:8080/decide -d '{"entity_id": "acct_000",
"event": {"name": "suspicious", "payload": {"amount_usd": 95}},
"idempotency_key": "auth-0004", "candidate_model_score": 0.1}' \
| jq '{routing, verdict: .record.verdict, proposed_action: .record.proposed_action,
reason: .record.reason, state_before, state_after}'
{
"routing": "await_next_event",
"verdict": "abstain",
"proposed_action": null,
"reason": "event 'suspicious' is not handled in state 'review'; nothing proposed",
"state_before": "review",
"state_after": "review"
}
The card is already in review, and the policy has no suspicious
step out of review, so it proposes nothing and the service does
nothing: a card is not blocked twice. If your product needs a fresh
decision per authorisation, make entity_id the authorisation rather
than the card, or add a step back to intake in the policy (step 13).
Step 6: the audit log¶
The concern. Every decision must be explainable later, by card, by policy version and by which check failed, and the log must not be editable after the fact.
The code. AuditLog.append writes one row per decision to the
decisions table: request id, card id, idempotency key, policy name and
version, settings version (parameters_fingerprint), event name, state before and after, verdict, action, proposed
action, routing, whether it was enforced, the names of the failed
checks, and the full record.to_dict() and evidence.to_dict() as
JSON. The JSON columns load back with DecisionRecord.from_dict and
EvidenceSnapshot.from_dict, so a replay job can rebuild any decision
and run it against a candidate policy. Two triggers make the table
append-only:
CREATE TRIGGER decisions_are_append_only_update
BEFORE UPDATE ON decisions
BEGIN SELECT RAISE(ABORT, 'decisions is append-only'); END;
On Postgres, revoke UPDATE and DELETE on the table from the
service's role.
Try it.
curl -s localhost:8080/decisions/acct_000 \
| jq '{current_state, decisions: [.decisions[] | {decision_id, idempotency_key,
event_name, state_before, state_after, verdict, routing}]}'
{
"current_state": "review",
"decisions": [
{
"decision_id": 1,
"idempotency_key": "auth-0001",
"event_name": "suspicious",
"state_before": null,
"state_after": "review",
"verdict": "act",
"routing": "execute"
},
{
"decision_id": 4,
"idempotency_key": "auth-0004",
"event_name": "suspicious",
"state_before": "review",
"state_after": "review",
"verdict": "abstain",
"routing": "await_next_event"
}
]
}
Each entry also has request_id, policy_version,
parameters_fingerprint, failed_channels,
created_at and the full record. ?limit=N returns the most recent
N.
Step 7: idempotency and exactly-once side effects¶
The concern. Clients retry: load balancers time out and resend, Kafka redelivers, mobile apps retry on a flaky network. Without protection, a retry advances the policy again, writes a second audit row, and blocks the card twice. Separately, calling the card processor from inside the request can leave the world and the database disagreeing: the call succeeds and the commit fails, or the other way round.
The code. Two tables, both written in the decision's single
transaction (Database.transaction is BEGIN IMMEDIATE ... COMMIT,
rolled back on any exception):
idempotency_keysstores the caller's key, a hash of the request, and the exact response. A repeated key with the same request returns the stored response byte for byte, with the headerIdempotent-Replay: true, and nothing else happens. A repeated key with a different request is a client bug and gets HTTP 409. If two processes race on the same key, the loser's insert fails, its transaction rolls back, and it returns the winner's stored response.outboxstores the side effect to carry out (blockforacct_000, witheffect_key = "decision:1"). The service never calls the processor itself. A separate relay (a loop, a cron job, a change-data stream) reads pending rows, calls the processor witheffect_keyas its idempotency key, and marks them sent. A crashed relay re-sends; the processor ignores the duplicate key.
The rule that ties it together: the state update, audit row, review or outbox row, and idempotency key commit together or not at all.
Try it. Repeat the very first request exactly:
curl -s -i localhost:8080/decide -d '{"entity_id": "acct_000",
"event": {"name": "suspicious", "payload": {"amount_usd": 840, "merchant": "electronics"}},
"idempotency_key": "auth-0001", "candidate_model_score": 0.1}' | sed -n 1,8p
HTTP/1.0 200 OK
Server: GroundedDecisionReference/1.0 Python/3.12.13
Date: Fri, 09 Oct 2026 16:56:27 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 5356
X-Request-Id: 57146e14-3836-401e-8392-6693a619db4d
Idempotent-Replay: true
Reuse the key for a different request:
curl -s -w '\nHTTP %{http_code}\n' localhost:8080/decide -d '{"entity_id": "acct_000",
"event": {"name": "clean"}, "idempotency_key": "auth-0001", "candidate_model_score": 0.1}'
{
"error": "idempotency_key_reused",
"message": "Idempotency key 'auth-0001' was already used for a different request. Use a new key for a new request."
}
HTTP 409
The outbox still holds exactly one block:
{
"outbox": [
{
"action": "block",
"authority": "policy",
"created_at": "2026-10-09T16:56:27.933889+00:00",
"decision_id": 1,
"effect_key": "decision:1",
"entity_id": "acct_000",
"outbox_id": 1,
"review_id": null,
"status": "pending"
}
]
}
Use a key that is naturally unique per request (the authorisation id is ideal), and delete keys older than your longest retry horizon on a schedule.
Step 8: the review queue, and closing the loop¶
The concern. An abstain with a proposal is a case for a person. The reviewer needs to see why the service held back, and their answer should improve the next decision.
The code. ReviewQueue.enqueue stores the proposal, the reason, the
names of the failed checks and the health numbers. A reviewer who sees
"neighbour agreement failed, three links" decides differently from one
who sees "unusual case: score unlike anything confirmed recently".
Resolving a review (POST /reviews/<id>/resolve with
{"outcome": "confirmed_fraud" | "cleared", "reviewer": "..."}) does
four things in one transaction:
- marks the review resolved, with the outcome and reviewer;
- writes the outcome into
review_outcomes, the table the evidence provider reads, so this person's judgement becomes evidence for the next decision about any linked account; - records the held decision's outcome in
decision_outcomes: the proposedblockwas right if the reviewer confirmed fraud and wrong if they cleared the account. This is how held cases get labels for learning (step 11); - writes the action the reviewer authorised (
blockorpass) to the outbox withauthority = "human", and resets the card's state tointake, because the case is closed.
Try it. List the open reviews; there are two, acct_777 (review 1)
and acct_900 (review 2). Here is the second:
{
"created_at": "2026-10-09T16:56:27.957501+00:00",
"decision_id": 3,
"entity_id": "acct_900",
"failed_channels": [
"reconstruction",
"threshold"
],
"health": {
"conformal_p": 1.0,
"d_lambda_sq": 0.1666666666666666,
"game_solved": true,
"in_operational_window": true,
"in_theorem_window": false,
"mixer_status": "idle",
"p_hat": 0.5,
"p_threshold": 0.08,
"reconstruction_margin": 0.05,
"star_height": 0,
"support": null,
"theorem_window_vacuous": true,
"threshold_width_operational": 1.6749170738973782,
"threshold_width_theorem": 439069.4614197543
},
"outcome": 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)",
"resolved_at": null,
"review_id": 2,
"reviewer": null,
"status": "open"
}
failed_channels uses the record's names for the checks:
reconstruction is neighbour agreement and threshold is the
knife-edge check. threshold_width_operational is 1.67, wider than the
whole 0-to-1 range, which is why the 4-account chain could not pass.
Resolve it:
curl -s localhost:8080/reviews/2/resolve \
-d '{"outcome": "confirmed_fraud", "reviewer": "alice@example.com"}' \
| jq '{action, outbox_id, review: (.review | {review_id, status, outcome, reviewer}), decision_outcome}'
{
"action": "block",
"outbox_id": 2,
"review": {
"review_id": 2,
"status": "resolved",
"outcome": "confirmed_fraud",
"reviewer": "alice@example.com"
},
"decision_outcome": {
"action_was_correct": true,
"decision_id": 3,
"recorded": true
}
}
curl -s localhost:8080/decisions/acct_900 now reports
"current_state": "intake". Resolving the same review again returns
HTTP 409 already_resolved. GET /reviews?status=resolved and
?status=all list closed cases. In production, the review row is what
you send to your case tool, and the resolve endpoint is the webhook that
tool calls back.
Step 9: observability¶
The concern. The act rate will move, and you need to know why before you page anyone. A broken evidence job (empty graphs, so neighbour agreement fails everywhere) and data sitting on the decision line (the knife-edge check fails) both look like "act rate dropped" if you only watch the verdict.
The code. Each decision writes one JSON log line with the ids you will search by, the verdict and routing, the failed checks, and the health numbers that explain most abstains:
2026-10-09 12:56:27,958 {"conformal_p": 1.0, "d_lambda_sq": 0.1667, "enforced": true, "entity_id": "acct_900", "event": "suspicious", "failed_channels": ["reconstruction", "threshold"], "idempotency_key": "auth-0003", "latency_ms": 2.071, "message": "decision", "p_hat": 0.5, "p_threshold": 0.08, "parameters_fingerprint": "settings@13447e40df90", "policy_version": "fraud_review@557323c7e95c", "request_id": "9bca399a-1dd9-41c9-ac59-f57f09cd91ef", "routing": "human_review", "state_after": "review", "verdict": "abstain"}
The event payload is deliberately not logged: it can hold personal data,
and the audit table already has it under access control. Callers can
send X-Request-Id to correlate with their own logs; otherwise the
service generates one and returns it in the response header.
ServiceMetrics keeps counters in the Prometheus text format. After the
steps above:
# TYPE grounded_decision_decisions_total counter
grounded_decision_decisions_total{verdict="abstain",routing="await_next_event"} 1
grounded_decision_decisions_total{verdict="abstain",routing="human_review"} 2
grounded_decision_decisions_total{verdict="act",routing="execute"} 1
# TYPE grounded_decision_channel_failures_total counter
grounded_decision_channel_failures_total{channel="reconstruction"} 2
grounded_decision_channel_failures_total{channel="threshold"} 2
# TYPE grounded_decision_decision_latency_seconds summary
grounded_decision_decision_latency_seconds_sum 0.008547
grounded_decision_decision_latency_seconds_count 4
# TYPE grounded_decision_idempotent_replays_total counter
grounded_decision_idempotent_replays_total 1
# TYPE grounded_decision_state_conflicts_total counter
grounded_decision_state_conflicts_total 0
# TYPE grounded_decision_outcomes_recorded_total counter
grounded_decision_outcomes_recorded_total{action="correct"} 1
grounded_decision_outcomes_recorded_total{action="wrong"} 0
# TYPE grounded_decision_enforcing gauge
grounded_decision_enforcing 1
outcomes_recorded_total counts the review resolution's outcome;
enforcing is 0 in shadow mode. With learned parameters loaded the
risk monitor adds its gauges (step 11).
Alert on failures per check, not on the act rate alone: a jump in
channel_failures_total{channel="reconstruction"} with flat traffic
usually means the evidence source returned empty or stale graphs.
GET /health doubles as the liveness probe (it queries the database)
and reports the policy version, so a deploy dashboard can confirm every
instance runs the same one. Operate and monitor
lists what to watch and what each signal means, and
Troubleshoot: why did it abstain? goes from
a symptom to the check and the fix.
Step 10: roll out in shadow mode first¶
The concern. You do not switch a fraud system to a new decision engine on day one. You run it beside the existing rule, compare, and only then let it act.
The code. --shadow turns enforcement off. Every decision is still
made, stored, audited and counted, and state still advances, so the
shadow policy tracks each card exactly as it would in production. But no
review is opened and nothing is written to the outbox: the existing
system stays in charge. GET /report compares, for the current policy
version, the grounded service with the naive rule "carry out whatever
the policy proposes" (which is what a score cut-off does);
withheld_by_grounding counts the decisions the service would have
held.
Try it. Stop the first server (Ctrl-C; it drains in-flight
requests, prints reference service: interrupted (Ctrl-C) and exits
with status 130) or use another port:
uv run python -m grounded_decision.examples.reference_service \
--seed-demo-data --shadow --port 8090 --database /tmp/shadow.sqlite3
# in another terminal:
for acct in acct_000 acct_005 acct_900 acct_902; do
curl -s localhost:8090/decide -d "{\"entity_id\": \"$acct\",
\"event\": {\"name\": \"suspicious\"}, \"idempotency_key\": \"shadow-$acct\",
\"candidate_model_score\": 0.1}" | jq -c '{entity_id, routing, enforced, outbox_id, review_id}'
done
{"entity_id":"acct_000","routing":"execute","enforced":false,"outbox_id":null,"review_id":null}
{"entity_id":"acct_005","routing":"execute","enforced":false,"outbox_id":null,"review_id":null}
{"entity_id":"acct_900","routing":"human_review","enforced":false,"outbox_id":null,"review_id":null}
{"entity_id":"acct_902","routing":"human_review","enforced":false,"outbox_id":null,"review_id":null}
{
"by_routing": {
"execute": 2,
"human_review": 2
},
"by_verdict": {
"abstain": 2,
"act": 2
},
"decisions": 4,
"failed_channels": {
"reconstruction": 2,
"threshold": 2
},
"grounded_acts": 2,
"naive_acts": 4,
"policy_version": "fraud_review@557323c7e95c",
"withheld_by_grounding": 2
}
The naive rule would have blocked all four cards; the service blocks the two in the ring and holds the two in the chain. The report is per policy version, so each threshold or policy change starts a fresh comparison. Roll out safely gives the full plan: how long to shadow, how to compare against real outcomes, and the order in which to switch enforcement on.
Step 11: learn the settings from your own outcomes¶
The concern. Every number the checks use so far was typed in: the
evidence-strength margin (0.05), the knife-edge window, the unusualness
level (0.10) and the 40 calibration scores. Nobody can say what error
rate they buy. Worse, the calibration scores are risk-model scores of
confirmed-fraud cases, while each request's candidate_model_score is
the score of this case for whatever action is proposed: a clean
authorisation is compared with fraud cases. What you can say is how
often an automatic decision may be wrong ("at most 10%"). This step lets
the service learn everything else from its own decisions and their
outcomes, with a certificate, and then watch that the certificate still
holds.
The loop: decide, record each decision's outcome, export the history, learn, restart with the learned parameters, monitor.
POST /decide ──> decisions ──> POST /outcomes (and review resolutions)
│
GET /history.jsonl <────────────────┘
│
make learn HISTORY=history.jsonl MAX_RISK=0.1 ──> parameters.json
│
restart with --parameters parameters.json ──> GET /parameters, GET /health
The code.
POST /outcomeswith{"decision_id", "action_was_correct"}records what eventually turned out to be true about the proposed action of any decision, carried out or held: a chargeback on a passed card, a customer complaint about a block, a fraud review. It is idempotent per decision (the same outcome again returns"recorded": false; the opposite outcome is HTTP 409, because an outcome is never overwritten). Resolving a review records the held decision's outcome automatically (step 8).GET /history.jsonlexports every decision that proposed an action and has an outcome, one line per decision, in exactly the formatgrounded-decision learnreads: the evidence the decision was made on, the proposed action,action_was_correct,case_id = "decision:<id>", andwas_acted_on(true when the decision was enforced and routed toexecuteorexecute_degraded). The learner useswas_acted_onto warn when the history has outcomes only for decisions that were carried out.--parameters parameters.jsonloads the learned parameters at start-up. The module is built with the learned check settings, and for each request the evidence gets the calibration scores of past cases where the same proposed action turned out correct: ablockis compared with blocks that were right, apasswith passes that were right. The service refuses to start with parameters that are not certified, unless you pass--allow-uncertified(then every decision is held, which is what uncertified parameters do).GET /parametersshows the parameters in force, their full provenance and both fingerprints. The learned fingerprint (params@…) is whatgrounded-decision learnprinted; the settings fingerprint (settings@…) is what every decision carries.RiskGuardfeeds every outcome of a decision made under the loaded settings to aRiskMonitorbuilt for the learned target.GET /healthandGET /metricsshow its status, its bounds on the recent error rate and how many outcomes it has seen.
Try it. This needs more traffic than the ring and the chain, so the
service ships a synthetic workload: --seed-learning-demo loads 2,000
separate device neighbourhoods (from the parameter_learning example),
each around one flagged account, with the truth known. Most accounts in
these neighbourhoods are clean and their links are partly random, so
measure agreement against the neighbourhood's own label mix
(--agreement-baseline label_mix): with the default measure, random
links between mostly-clean accounts look like agreement. The
parameter learning case shows
the effect. Stop the first server, then:
uv run python -m grounded_decision.examples.reference_service \
--seed-learning-demo --agreement-baseline label_mix \
--port 8081 --database /tmp/learning.sqlite3
In a second terminal, send the first 1,600 authorisations and, for each one, the outcome your chargeback and review systems would report later. This is the only part that stands in for your systems:
uv run python - 0 1600 <<'PY'
import json, sys, urllib.request
from grounded_decision.examples.parameter_learning import synthetic_authorisations
from grounded_decision.examples.reference_service import LEARNING_DEMO_CASES, LEARNING_DEMO_SEED
def post(path, body):
request = urllib.request.Request("http://127.0.0.1:8081" + path, json.dumps(body).encode())
with urllib.request.urlopen(request) as response:
return json.load(response)
start, stop = int(sys.argv[1]), int(sys.argv[2])
cases = synthetic_authorisations(LEARNING_DEMO_CASES, seed=LEARNING_DEMO_SEED)[start:stop]
verdicts = {}
for case in cases:
decision = post("/decide", {"entity_id": case.flagged_account,
"event": {"name": case.event_name},
"idempotency_key": f"auth-{case.case_id}",
"candidate_model_score": case.unusualness_score})
# Later, when the truth is known (chargeback, review, customer contact):
proposed = decision["record"]["proposed_action"]
post("/outcomes", {"decision_id": decision["decision_id"],
"action_was_correct": case.action_was_correct(proposed)})
verdicts[decision["record"]["verdict"]] = verdicts.get(decision["record"]["verdict"], 0) + 1
print(f"{len(cases)} decisions, outcomes recorded for all:", verdicts)
PY
With the hand-set settings the service acted on 341 of the 1,600, and 25 of those 341 (7.3%) turned out wrong. Export the history and look at one line:
curl -s localhost:8081/history.jsonl > history.jsonl
head -1 history.jsonl | jq -c '{case_id, proposed_action, action_was_correct, was_acted_on,
evidence: (.evidence | {n, degree, lambda_hat, p_hat, label_score})}'
{"case_id":"decision:1","proposed_action":"pass","action_was_correct":true,"was_acted_on":false,"evidence":{"n":11,"degree":6.545454545454546,"lambda_hat":-0.058823529411764705,"p_hat":0.6545454545454545,"label_score":0.371}}
This decision was held (was_acted_on: false), and its outcome is in
the history anyway: the script recorded outcomes for every decision.
That is what lets the learner judge the held cases too.
Learn. MAX_RISK is the business input: the largest share of automatic
decisions you accept being wrong. First ask for 2%:
Goal: at most 2.0% wrong of the decisions it acts on, certified with 95% confidence.
History: 1600 labelled cases, split calibration 480, risk 800, holdout 320 (seed 0, dataset data@f3b0ff0e9b59).
Tried 1200 combinations of settings; 0 were certified.
Procedure: fixed-sequence testing. The combinations were ordered on the calibration part (most clearly safe there first), then tested in that order on the risk part, each at significance 0.05, stopping at the first failure.
No combination could be certified, so these settings hold every decision. Either there is too little history (even with zero errors, at least 149 acted-on cases are needed in the risk part), or every setting that acts often enough makes too many mistakes. Fixes: more labelled history, a better definition of related cases or a better model score, or a higher acceptable risk.
Warning: the first combination tested (the most clearly safe one on the calibration part) failed on the risk part, so testing stopped: the target is too strict for this history, or there is too little history to show it is met
Fingerprint: params@ff131423f2e2
Wrote parameters.json
make: *** [learn] Error 2
1,600 cases cannot certify 2% here, and the command exits with status 2.
It still writes parameters.json, but those parameters hold every
decision, and the service refuses to start with them:
python -m grounded_decision.examples.reference_service: error: the parameters have status 'no_safe_configuration', not 'certified': no setting could be shown to meet the target, so they hold every decision. Collect more labelled history, or pass --allow-uncertified to run anyway (everything will be held).
Ask for 10%:
Goal: at most 10.0% wrong of the decisions it acts on, certified with 95% confidence.
History: 1600 labelled cases, split calibration 480, risk 800, holdout 320 (seed 0, dataset data@f3b0ff0e9b59).
Tried 1200 combinations of settings; 540 were certified.
Procedure: fixed-sequence testing. The combinations were ordered on the calibration part (most clearly safe there first), then tested in that order on the risk part, each at significance 0.05, stopping at the first failure.
Chosen: reconstruction margin -1 (evidence strength must exceed 0), window prefactor 0 (knife-edge check off), decision line each case's own, conformal alpha 0.2, minimum support 0 (support check off).
Certificate: with 95% confidence the true error rate is at most 10.0%, provided future cases are independent draws from the same process as this history.
Risk part (the data the settings were chosen on, so optimistic): acted on 495 of 800, 33 wrong.
Hold-out (never used for choosing): acted on 194 of 320 (60.6%), 16 wrong; error rate at most 12.26%.
Fingerprint: params@c1b67d6a0eda
Wrote parameters.json
The learner switched the evidence-strength check off (margin -1: strength must only exceed 0) and the knife-edge check too (on this data they held cases without preventing errors at a 10% target), kept the unusual-case check at alpha 0.2, and replaced the 40 hand-set calibration scores with the scores of past cases where each action was right. The support check stays off: the service does not measure support, so there is nothing to learn. The hold-out line is an honest check on 320 cases the learner never saw; with 194 acts there, its upper bound (12.26%) is wider than the target, which is expected for so few cases. Parameter learning explains every line, and Learning settings with a guarantee explains the "Procedure" line.
Restart the service with the learned parameters (Ctrl-C the first, keep the same database):
uv run python -m grounded_decision.examples.reference_service \
--agreement-baseline label_mix --port 8081 --database /tmp/learning.sqlite3 \
--parameters parameters.json
2026-10-09 12:59:59,102 serving http://127.0.0.1:8081 policy=fraud_review@557323c7e95c settings=settings@1c275e611777 learned=params@c1b67d6a0eda mode=enforce database=/tmp/learning.sqlite3
The policy version is unchanged, so every saved card state is still valid; only the settings version moved. Check what is in force:
curl -s localhost:8081/parameters | jq '{parameters_fingerprint, learned_fingerprint,
check_settings, calibration_source, status: .learned.status,
calibration_scores: (.learned.parameters.calibration_scores | map_values(length)),
provenance: (.learned.provenance | {target, procedure, seed, dataset_fingerprint, split_sizes})}'
{
"parameters_fingerprint": "settings@1c275e611777",
"learned_fingerprint": "params@c1b67d6a0eda",
"check_settings": {
"conformal_alpha": 0.2,
"min_support": 0.0,
"operational_fk_prefactor": 0.0,
"reconstruction_margin": -1.0
},
"calibration_source": "learned: learned.parameters.calibration_scores, per proposed action",
"status": "certified",
"calibration_scores": {
"block": 204,
"pass": 175
},
"provenance": {
"target": {
"confidence": 0.95,
"max_risk": 0.1,
"measure": "error_rate_among_acts"
},
"procedure": "fixed_sequence",
"seed": 0,
"dataset_fingerprint": "data@f3b0ff0e9b59",
"split_sizes": {
"calibration": 480,
"holdout": 320,
"risk": 800
}
}
}
Send the remaining 400 authorisations with the same script
(uv run python - 1600 2000 <<'PY' … PY):
The service now acts on 66.5% of authorisations (266 of 400) where the hand-set settings acted on 21.3%, and 13 of the 266 (4.9%) were wrong, against 7.3% before. Watch the monitor:
curl -s localhost:8081/health | jq '{enforcement, enforcement_reason, learned_parameters, risk_monitor}'
{
"enforcement": "enforce",
"enforcement_reason": "enforcing",
"learned_parameters": {
"fingerprint": "params@c1b67d6a0eda",
"status": "certified"
},
"risk_monitor": {
"errors": 13,
"lower_bound": 0.029147,
"status": "within_target",
"target": {
"confidence": 0.95,
"max_risk": 0.1,
"measure": "error_rate_among_acts"
},
"trials": 266,
"tripped": false,
"upper_bound": 0.076579,
"window": 500
}
}
The recent error rate among acted-on decisions is between 2.9% and
7.7% (95% bounds), so the monitor says it is below 10%
(within_target); with fewer outcomes it would say uncertain. The same
numbers are on GET /metrics as grounded_decision_risk_monitor_*
gauges.
The fail-safe: a high error rate switches the service to shadow
When the monitor's lower bound on the recent error rate rises above
the target ("status": "above_target"), the world no longer looks
like the history the parameters were certified on. The service then
switches itself to shadow mode: it keeps deciding and recording
everything, but writes nothing to the outbox and opens no reviews.
GET /health says "enforcement": "shadow" with the reason, and
grounded_decision_enforcing drops to 0: alert on it. The switch
stays until you restart with re-learned parameters (restarting with
the same ones rebuilds the monitor from the database and trips again).
tests/test_reference_service.py shows it happening.
What the certificate does and does not cover:
- Record outcomes for held decisions too. If only acted-on
decisions get outcomes, the history describes only the cases the
current settings already liked, and the learner cannot tell whether
holding the others was necessary (the selective labels problem).
Review resolutions label held cases for you; label the rest when the
truth arrives. The exported
was_acted_onlets the learner notice: when every case with a known outcome was acted on,learnprintsWarning: every case with a known outcome was acted on; outcomes of held decisions are missing, so the certificate describes a filtered population. - Independent cases. The certificate assumes the cases in the history are independent. Decisions about accounts in the same fraud ring share their evidence and usually their outcome. Keep one decision per cluster of related cases when you export (the demo's neighbourhoods are separate by construction).
- Re-learn, don't patch. The calibration scores and the check
settings were certified together. Refreshing only the calibration
scores, say daily, changes the configuration the certificate was
about. Re-run
make learnon the fuller history and restart instead; the settings version changes, saved card states do not.
Step 12: concurrency and scaling¶
The reference server runs one thread per request, which is why the engine lock, the per-card locks and one short-lived database connection per unit of work exist. To scale out:
- More workers. Put the same
DecisionServicebehind a production server with several processes (FastAPI under uvicorn or gunicorn; Integrate into a service shows that wrapper and a Kafka consumer). Each process builds its own engine at start-up. Same-card races across processes are caught by the stateversioncolumn, and idempotency-key races by the primary key. - SQLite to Postgres. SQLite in write-ahead-log mode handles one
writer at a time: fine for one host and a few hundred decisions per
second. Beyond that, replace
Databasewith a connection pool. The SQL is portable apart from four details listed in theDatabasedocstring:?placeholders become%s,AUTOINCREMENTbecomes an identity column,BEGIN IMMEDIATEbecomesBEGIN, and the append-only triggers become revoked privileges. - Hot cards. With many processes, the in-process locks only reduce retries; correctness comes from the version check. If one card is very busy, partition the event stream by card id (a Kafka partition key) so one consumer owns each card.
- Shutdown. Ctrl-C and
SIGTERMboth stop the server cleanly: open transactions roll back, in-flight requests drain, and the last line on stderr says which signal stopped it (reference service: interrupted (Ctrl-C), exit status 130, orreference service: stopped (SIGTERM), exit status 0). There is no traceback at any stage, including during start-up and seeding. - Evidence. At scale the evidence walk is the expensive part, not
decide. Precompute snapshots in a batch job and cache them per card.
Step 13: adapt it to your domain¶
Three things change. Everything else (state, audit, idempotency, outbox, review queue, metrics, shadow mode, HTTP) is generic and stays.
- The policy. Replace
build_fraud_enginewith a function that builds yourModule, from policy text withModule.from_expror with theStatechartbuilder (Write a policy). Decide which events move a case forward, which state ends it, and whether a resolved case returns to the start. The fingerprint and the list of valid events follow automatically. - The evidence provider. Implement
EvidenceProvideragainst your data: what the related cases are and what links them (accounts sharing devices, sensors agreeing about a machine, reviewers agreeing about a post), where the outcomes come from, and what the calibration scores are (or learn them, step 11). Return your settings fromevidence_parameters. Build evidence from your data and Choose thresholds and calibration cover these choices; the Example library maps many domains. - What each routing does.
route_decisionrarely changes, but what follows does: what the outbox relay calls for anexecute(freeze a card, page an engineer, approve a loan), where ahuman_reviewrow goes (your case tool), and what a resolved review means (REVIEW_OUTCOME_LABELSandACTION_FOR_REVIEW_OUTCOMEare the fraud-specific mappings). Renamecandidate_model_scoreif your domain calls it something else.
Then replace the demo seed with your own fixtures, copy
tests/test_reference_service.py, and change its expectations to your
cases: one that should act, one that should be held for review, one
replay and one bad request.
What this service does not do¶
- It does not measure support. The evidence provider does not tell
the library which outcome each action implies, so the support check
passes with
support not measuredon every decision, and acleanevent on a ring account would actpass. To turn it on, passsupporting_label=fraud_review.SUPPORTING_LABEL[proposed_action]todevice_graph_evidenceinevidence_for(usemodule.peek_proposed_actionto find the proposed action first); see Do the related cases support this action? - The unusual-case check is not exercised in steps 0 to 10. Every request there sends 0.1, which is below every calibration score. Send a score above 1.18 to see it hold.
- Small groups of related accounts are held almost always. That is the knife-edge check working as designed; plan reviewer capacity for it.
Next¶
- Integrate into a service: the same design with FastAPI, a Kafka consumer and cached evidence.
- Roll out safely and Operate and monitor: running it in production.
- State and policy versions: saving state and changing a policy safely.
- Live service desks: simulated desks that run the full loop over time, including recovery after outages.