Step by step: one access-request service in eleven runs¶
This track teaches the library by building one small service, an automatic approver for access requests, one idea at a time. It is for engineers who will wire the library into a service and for the people who own the policy and want to predict what it will do. Each of the eleven runs adds exactly one idea to the ones before it. When you finish, you can predict every verdict the library returns, tell a deploy-time error from a per-request hold, and write the request handler that keeps a workflow's state between calls.
You need Python and a shell. You don't need any of the mathematics: each step links to the concept page that explains its check in plain words, and the glossary defines every term.
uv run grounded-decision examples --track step # all eleven, in order
uv run grounded-decision examples step_hello # one step
uv run grounded-decision examples step_hello --json
From Python, list_examples(track="step") lists the steps and
run_example("step_hello", as_json=True) returns
{id, title, records, summary}. The source is
src/grounded_decision/examples/stepwise.py; each runner's docstring
opens with the idea it teaches.
The service you are building¶
An internal tool lets engineers request access to a production system, such as a database or a payments console. Today a manager clicks "approve". The team wants routine requests granted automatically, but only when something more than "someone asked for it" supports the grant.
Granting every request goes wrong in exactly the cases that matter:
- the requester's team does not actually use this system;
- the request looks unlike any legitimate request seen before;
- the service is new and has no history to compare against.
So each decision looks at the requester's peer group, the related cases for this request:
| Piece of evidence | What it is in the access service |
|---|---|
| the people in the graph | the requester and their closest colleagues |
| links between them | two people who shared an on-call rotation or reviewed each other's code in the last 90 days |
| each person's label | 1 if that person currently holds the requested access, else 0 |
| calibration set | the anomaly model's scores on the last forty requests that security later confirmed were legitimate |
| candidate score | the anomaly model's score on this request |
cut-off density (p_threshold) |
the link density at which a group counts as one working team rather than people who share a building |
If the requester sits in a well-linked team whose members consistently hold the access, "this person should have it too" is a reasonable inference, and the service grants. Otherwise it holds the request and sends it to a human approver with the reason attached.
The vocabulary, in one table¶
Every step uses the same four objects. One decision is always
module.decide(event, evidence) -> DecisionRecord.
| Object | What it is | In this service |
|---|---|---|
| module | Your policy, compiled into a small state machine: which events lead to which proposed actions. Built once at start-up. | "when access_requested arrives, propose grant_access" |
| event | What just happened, named the way the policy names it, plus a JSON payload carried into the record for audit. | access_requested with {requester, system} |
| evidence | The numbers that describe the related cases around this decision (EvidenceSnapshot). |
the peer group, summarised |
DecisionRecord |
The verdict (act, abstain or degrade), the action taken or the proposed action that was blocked, the reason, and one entry per check. |
the audit-log row |
The evidence fields have short names because they are the names in the
underlying results. You rarely type them; snapshot_from_graph
computes them from your graph.
EvidenceSnapshot field |
Plain meaning |
|---|---|
n |
how many people (cases) are in the graph |
p_hat |
link density: the fraction of possible links that exist |
p_threshold |
your cut-off density, a tuning constant chosen per product |
degree |
average links per person: how many witnesses each case has |
lambda_hat |
neighbour agreement (λ): how much more often linked people share a label than chance, from −1 to 1 |
scores |
the calibration set: model scores on recent cases where the answer was confirmed |
label_score |
the candidate score for this case (higher means less like the confirmed cases) |
The module acts only when six independent checks all say yes:
two about the policy itself, run once when it compiles
(policy shape,
progress), and four about the data,
run on every decision
(neighbour agreement,
knife-edge,
unusual cases,
support; the step examples do not
measure support, so that check passes with support not measured).
Step 1: step_hello¶
The situation. Alice asks for read access to the orders database. Her team clearly holds it. This is the smallest complete call.
access_policy = Module.from_expr(
"access_requests", parse("test(access_requested) ; act(grant_access)")
)
record = access_policy.decide(
Event("access_requested", {"requester": "alice", "system": "orders_db_readonly"}),
well_corroborated_evidence(),
)
parse turns the policy text into an expression and Module.from_expr
compiles it; do both once, when the service starts. In the policy
language, test(x) means "an event named x arrives", act(y) marks
the action to propose, and ; means "then".
well_corroborated_evidence() is a ready-made snapshot that passes
every data check; step 2 computes evidence from real records.
Run it.
What to notice. The middle of the printed output (trimmed) is the whole decision:
INPUT no saved cursor — this case starts fresh
Event access_requested {requester=alice, system=orders_db_readonly}
evidence STRONG, 80 observations
neighborhood d=8 λ=0.55 strength dλ²=2.42 (need > 1.05) STRONG — above KS
density p̂=0.35 flicker band p*=0.08 stable (outside window)
conformal label=0.1 bag=40 p=1 vs α=0.10 in-distribution
DECISION
all six checks passed — fire
certificate PASS can we compile this policy (star-height ≤ 3)?
game PASS can the policy keep making progress (parity win, nonnegative mean)?
reconstruction PASS do noisy neighbors recover the seed (dλ² > 1+margin)?
threshold PASS is density outside the operational flicker window?
conformal PASS is this candidate weirder than the calibration bag?
→ ACT because all six channels passed and action 'grant_access' is marked on this grammar state
OUTPUT fire grant_access
action=grant_access
extras['state']=s1 (cursor to persist)
The printer uses some shorthand of its own. This table translates it into the words used on these pages:
| Printer says | Means |
|---|---|
fire, ACT |
verdict act: carry out the action |
HOLD, hold_for_human |
verdict abstain with a blocked proposed action: send it to a person |
await_next_event |
verdict abstain with nothing proposed: the policy is waiting for its next event |
DEGRADE |
verdict degrade: carry out the policy's named fallback action |
cursor, extras['state'] |
the saved state: where this request stands in the policy |
d, λ, dλ², "above KS" |
links per case, neighbour agreement, evidence strength, and whether it clears the 1.05 line |
p̂, flicker band p* |
observed link density, and the cut-off it is compared with (the knife-edge check) |
label, bag, p, α |
candidate score, calibration set, unusualness p-value, and the 0.10 bar it must clear |
channels |
the record field that lists the six checks |
Here the evidence strength is \( d\lambda^2 = 8 \times 0.55^2 = 2.42 \),
well above the 1.05 line; the density 0.35 is far from the 0.08
cut-off; and the candidate score 0.1 is lower than every calibration
score, so its p-value is 1.0. The record has verdict == "act",
action == "grant_access" and extras["state"] == "s1". The naive
policy grants too.
The takeaway. The whole API is module.decide(event, evidence),
and its output is a record you persist with record.to_dict(). The
grounded module and the naive one only disagree when the evidence is
weak, which is what the next four steps show. See
One decision, step by step for the
complete path and The decision record for
every field.
Step 2: step_reconstruction_gate¶
The situation. Bob and Carol each request the same access. Both sit in a 20-person team with identical working relationships: six close colleagues each, 60 links. Only who holds the access differs. In Bob's team, 17 of 20 hold it and linked colleagues agree 80% of the time. In Carol's, half hold it, scattered with no pattern.
This step builds the evidence from raw records. colleague_links
produces the edges and peer_group_evidence passes them, with each
person's current access, to snapshot_from_graph. That function counts
n, the density p_hat, the average degree, and lambda_hat
(\( 2 \times \text{share of links whose ends agree} - 1 \) for yes/no
labels).
What to notice.
| Requester | Team pattern | lambda_hat |
evidence strength \( d\lambda^2 \) | Verdict |
|---|---|---|---|---|
| Bob | 17 of 20 hold it; linked people agree 80% | 0.60 | 2.16 | act |
| Carol | half hold it, scattered | about −0.07 | 0.03 | abstain, grant_access blocked |
Carol's record gives the reason
d λ² = 0.0267 vs 1+0.050 (at or below KS; abstain): her colleagues'
access tells you nothing about whether she should have it, however many
colleagues there are. The other data checks pass for both, so this one
check explains the whole difference. The step also calls
module.reset() between the two independent requests.
The takeaway. The first data check asks whether linked cases agree
often enough for their labels to say something about this one. Average
links per case times agreement squared must exceed about 1. Note what
it does not ask: whether the agreement points towards granting. A team
that consistently lacks the access agrees just as strongly. The event
your service sends (access_requested from a requester whose peers
hold it) carries the direction; the check only says whether the related
cases are informative enough to act on. Read
Do the related cases agree?.
Step 3: step_threshold_window¶
The situation. The service treats a peer group as "one working team" when its link density reaches a cut-off, here 0.20 for a 50-person group. That matters for access: "her team holds it" is only a reason if the people around her really work as one team. Dan's group has density 0.45, clearly a team. Erin's group has density 0.21, right on the line: add or drop one shared on-call rotation and she is either in a team or just in a building with these people.
What to notice. The summary reports window_half_width = 0.105.
With 50 people the module refuses to rely on any density inside
0.20 ± 0.105, that is 0.095 to 0.305. Dan (0.45) is outside the window
and granted. Erin (0.21) is inside it, so she is held with the reason
evidence density inside operational window, even though her evidence
strength (2.42) passes. Both snapshots come from
well_corroborated_evidence(...) with only the density fields
overridden, which is the easiest way to isolate one check in your own
experiments.
The takeaway. A strong neighbourhood is not enough when the measurement sits so close to your cut-off that a little noise flips the reading. The window narrows as the group grows (about ±0.30 at 10 people, ±0.12 at 40, ±0.06 at 200), so small groups need a density far from the line. The window is two-sided: it only refuses to decide near the line; your policy decides what each side means. Read Are we on a knife-edge?.
Step 4: step_conformal_outlier¶
The situation. Two requests from teams with perfect evidence. One is an ordinary 2pm request with anomaly score 0.10. The other comes at 3am from a new country and scores 1.5. The calibration set is forty scores from 0.40 to 1.18, from requests security confirmed as legitimate.
What to notice. The unusualness p-value is
\( (1 + \#\{\text{calibration scores} \ge \text{this score}\}) / 41 \).
The ordinary request has p = 1.0 and is granted. The 3am request scores
above every calibration case, so p = 1/41 = 0.024, below the 0.10 bar,
and it is held with conformal p=0.024 vs α=0.100 (reject).
The takeaway. The module holds cases that look unlike anything it has been right about before, even when the related cases are perfect. In production that hold sends the request to security review, and the reviewer's ruling becomes a new calibration case. Read Have we seen cases like this?.
Step 5: step_require_calibration¶
The situation. Day one of the service: there are no confirmed past requests yet, so the calibration set is empty. The step decides the same request twice, once with default settings and once with production settings.
What to notice. With default Limits, an empty calibration set
counts as "no objection": the unusualness check passes and the module
grants (default_limits_verdict = act). With
Limits(require_conformal_calibration=True) the empty set fails the
check, and the module holds with
no calibration scores (require_conformal_calibration)
(production_limits_verdict = abstain).
The takeaway. Limits is the per-deployment configuration, passed
at compile time:
Module.from_expr(name, expr, limits=production_limits). Turn on
require_conformal_calibration in production so that "no history" is
never mistaken for "nothing unusual". See
Limits and performance and
Integrate into a service.
Step 6: step_grammar_sequence¶
The situation. The policy now needs two events in order:
test(access_requested) ; test(manager_approved) ; act(grant_access).
The request arrives, then the manager's approval, on the same module
with no reset in between.
What to notice. After the request, the verdict is abstain with
reason no action marked on current state, proposed_action is empty,
and every check passed. The printer labels this await_next_event: the
policy is waiting for approval. When the approval arrives, the module
grants, and the state moves from s1 to s3.
The takeaway. A module keeps state between decide calls, so a
policy can require several events in order. "Waiting" and "held" are
both abstain in the record; tell them apart by proposed_action (empty
when waiting) and by whether any check failed. A dashboard that counts
every abstain as "evidence failed" will mislabel the first row. Read
Act, abstain, degrade.
Step 7: step_unknown_event_stutter¶
The situation. A producer upstream starts sending
access_extension_requested before the policy knows that event name.
What to notice. The summary shows state_before = s0,
state_after = s0, proposed_action = null and verdict = abstain,
with the reason
event 'access_extension_requested' is not handled in state 's0'; nothing proposed.
The evidence checks still ran and appear in the record; nothing moved
and nothing was proposed.
The takeaway. Events the policy does not handle in the current state do nothing, safely: names outside its vocabulary, and known names that arrive out of order (step 11 shows one). Alert on records where nothing was proposed and the state did not change: they usually mean a producer and the policy disagree about event names. See Troubleshoot: why did it abstain?.
Step 8: step_parse_rejects_garbage¶
The situation. A bad template substitution leaves a stray $ in the
policy text: test(access_requested) $ act(grant_access).
What to notice. parse raises ParseError with the position and
the tokens it expected:
line 1 col 24: unexpected '$'; expected one of: ')', '*', ';', '|'
test(access_requested) $ act(grant_access)
^
No module and no DecisionRecord are produced, so records is empty.
The takeaway. Policy errors are deploy-time errors, not per-request errors. Compile every policy in CI and at service start-up, never inside a request handler. See Write a policy.
Step 9: step_star_height_reject¶
The situation. Someone writes a policy with four levels of nested
repetition: ((((test(access_requested))*)*)*)*, where (...)* means
"repeat zero or more times".
What to notice. Module.from_expr raises StarHeightError with
star-height 4 exceeds max 3. The policy-shape check allows at most
three levels of nested repetition (Limits().max_star_height), and it
checks this once, at compile time. records is empty.
The takeaway. The library only accepts policies simple enough for
its compile-time checks, and it tells you when you build the module.
Handle StarHeightError like a ParseError: fail the deploy, not a
request. Read Is the policy well-formed?.
Step 10: step_pipeline_and¶
The situation. Granting now needs two independent opinions, each a
separate module: peer_access_check (the peer group from steps 1 to 4)
and device_trust_check (evidence about the requester's laptop and the
managed devices it links to).
What to notice. In the first pair both children read the same strong
snapshot through Pipeline(name, modules).decide(event, evidence), and
the parent acts. In the second pair each child gets its own evidence:
the peer check acts, but the device evidence is thin (evidence strength
0.08, density inside the window), so the device check holds and the
parent holds too (one_child_unsupported_parent_verdict = abstain).
The takeaway. To combine modules, decide each child and pass the
records to compose_parent. The parent returns act only if every
child acted, abstain if any child abstained, and degrade if no child
abstained and at least one degraded: a hold always beats a fallback.
There is no majority vote. Act on, and persist, the parent verdict,
never a child's act. Read Composing modules
and, for gates that are not modules, Combinations.
Step 11: step_json_roundtrip¶
The situation. A stateless worker serves HTTP requests, so the state
from step 6 has to live in your database between calls.
serve_access_request builds the JSON body {state, event, evidence}
and calls handle_request, which restores the state (or starts fresh
when it is None) and decides once. Three requests arrive:
- AR-2001 is requested with no saved state.
- AR-2001 is approved, with the saved state sent back.
- AR-2002 is approved, but the worker forgot to send its saved state.
What to notice. Request 1 waits for approval and returns
saved_state = s1. Request 2 restores s1 and grants
(approval_with_saved_state_action = grant_access). Request 3 starts
from the beginning and sees an approval before any request. The policy
does not handle manager_approved in its initial state, so the entity
stays in s0 and the record says
event 'manager_approved' is not handled in state 's0'; nothing proposed,
with nothing proposed
(approval_with_lost_state_proposed_action = null).
The takeaway. This is the request-handler pattern for any policy
with more than one step: load the saved state, decide once, save
extras["state"] back. A lost state shows up as an abstain with
nothing proposed and no failed check; alert on it and don't retry.
Store a fingerprint of the policy next to each saved state, because
editing a policy renumbers its states. Read
State and policy versions.
Compile time versus decide time¶
| Example | Fails at | What it catches |
|---|---|---|
step_hello |
never | the normal path |
step_reconstruction_gate |
decide: neighbour agreement | colleagues carry no signal |
step_threshold_window |
decide: knife-edge | density on the team line |
step_conformal_outlier |
decide: unusual case | unprecedented request |
step_require_calibration |
decide: unusual case | no history yet |
step_grammar_sequence |
decide: nothing proposed | waiting for approval |
step_unknown_event_stutter |
decide: no transition | unknown event name |
step_parse_rejects_garbage |
parse: ParseError |
illegal character |
step_star_height_reject |
compile: StarHeightError |
four nested repetitions |
step_pipeline_and |
parent combination | one unsupported child |
step_json_roundtrip |
decide, if state is lost | worker forgot to send state |
Treat the compile rows as deploy failures and the decide rows as per-request records. Don't restart a process because a data check failed, and don't serve a policy that failed to compile.
Wiring the service for real¶
The eleven steps, mapped onto a production access service:
- At start-up, parse and compile the policy with production limits:
Module.from_expr("access_requests", parse(POLICY), limits=Limits(require_conformal_calibration=True)). Fail the deploy onParseErrororStarHeightError(steps 8 and 9). - Per request, load the request's saved state (
Nonefor a new request) and build the evidence from live data: working relationships for the edges, current access holders for the labels, the anomaly model's score for the candidate score, and recently confirmed legitimate requests for the calibration set. Then callsnapshot_from_graph(labels, edges, p_threshold=..., scores=..., label_score=...). Build evidence from your data covers this step in detail. - Decide with
handle_request(module, {"state": ..., "event": ..., "evidence": ...})(step 11). Modules are not thread-safe: use one per worker, or restore state under a lock. - Act on the verdict. On
act, performaction. Onabstainwith a proposed action, put the proposal andreasonon the approval queue. Onabstainwith nothing proposed, there is nothing to approve: the policy is waiting (step 6) or the event was unknown or out of order (steps 7 and 11). Ondegrade, run the fallback action the policy names. - Persist
extras["state"]with a policy fingerprint, and the whole record as the audit entry. - Feed back each human ruling on a held request into the calibration set.
Build a decision service is this list as a runnable HTTP service.
Try it: isolate one check in your own module¶
from grounded_decision.examples.common import well_corroborated_evidence
from grounded_decision.grammar import parse
from grounded_decision.runtime import Module
from grounded_decision.types import Event
access_policy = Module.from_expr(
"access_requests", parse("test(access_requested) ; act(grant_access)")
)
baseline = access_policy.decide(Event("access_requested"), well_corroborated_evidence())
access_policy.reset()
fewer_colleagues = access_policy.decide(
Event("access_requested"),
well_corroborated_evidence(average_links_per_entity=2.0),
)
print(baseline.verdict, fewer_colleagues.verdict)
print([check.name.value for check in fewer_colleagues.channels if not check.passed])
Override one field at a time until exactly one check name prints:
average_links_per_entity or neighbor_label_correlation for
neighbour agreement, observed_link_density or
meaningful_link_density for the knife-edge check, and
candidate_risk_score for the unusual-case check.
For regression tests, --json exposes each step's summary keys (for
example window_half_width, production_limits_verdict,
approval_with_lost_state_verdict):
Where to go next¶
- Adaptive loops: the same checks on a stream of decisions whose evidence drifts.
- Combinations: extra gates beside
decide. - Example library: every runnable example.