Skip to content

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.

uv run grounded-decision examples step_hello

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).

uv run grounded-decision examples step_reconstruction_gate

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.

uv run grounded-decision examples step_threshold_window

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.

uv run grounded-decision examples step_conformal_outlier

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.

uv run grounded-decision examples step_require_calibration

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.

uv run grounded-decision examples step_grammar_sequence

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.

uv run grounded-decision examples step_unknown_event_stutter

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).

uv run grounded-decision examples step_parse_rejects_garbage

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".

uv run grounded-decision examples step_star_height_reject

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).

uv run grounded-decision examples step_pipeline_and

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:

  1. AR-2001 is requested with no saved state.
  2. AR-2001 is approved, with the saved state sent back.
  3. AR-2002 is approved, but the worker forgot to send its saved state.
uv run grounded-decision examples step_json_roundtrip

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:

  1. 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 on ParseError or StarHeightError (steps 8 and 9).
  2. Per request, load the request's saved state (None for 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 call snapshot_from_graph(labels, edges, p_threshold=..., scores=..., label_score=...). Build evidence from your data covers this step in detail.
  3. 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.
  4. Act on the verdict. On act, perform action. On abstain with a proposed action, put the proposal and reason on the approval queue. On abstain with 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). On degrade, run the fallback action the policy names.
  5. Persist extras["state"] with a policy fingerprint, and the whole record as the audit entry.
  6. 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):

uv run grounded-decision examples --track step --json

Where to go next