Skip to main content
A support agent wants to refund order_10042 for $84.00. Before it touches the payment provider, it asks NSR, which answers with one of three verdicts — approved, denied or refused — the rules it cited, a replayable derivation and, on approval, a proof bundle you can check yourself. This guide builds that gate end to end; the Verified Decisions API page is the contract behind it.

Before you start

The examples assume NSR_API=https://api.nsr.stateset.com and NSR_API_KEY=nsr_your_key are exported in your shell.
Two encodings are easy to mix up. Stored rules (/api/v1/rules) use head_predicate, head_args and body atoms keyed by predicate. Request-scoped facts and rules on /v1/decisions use { "predicate": { "name", "args" } } envelopes and if / then atoms keyed by name. A string starting with ? is a variable; everything else is a value.
1

Lint the policy, then store it

Three Horn-clause rules make the refund policy: a permit with a negation-as-failure guard, a deny, and a review threshold using the evaluable builtin gt. Lint each one first — POST /api/v1/rules/lint takes the same body as a create, answers { "diagnostics": [...], "has_errors": bool } and installs nothing. RULE007 (head variable unbound in the body) and RULE008 (builtin as head) are the errors to expect.
Then install all three with POST /api/v1/rules/batch. Each rule is created independently — a failure does not roll back the others — and a name that already exists is replaced, not duplicated.
Response
The head names also follow the conventions the gate recognises lexically (may_*, *_blocked, *_requires_review), so the verdict is the same whether it reads the declared effect or the head. Deny wins over permit; a fired review rule yields refused with requires_human_review: true.
2

Ask for the decision

POST /v1/decisions. The stored policy is pulled in by hydrate_org_context: true (the default); the live facts about this order travel in the request. Declaring an authorization_goal is what makes an approval carry a verifiable_bundle; external_ref lets you report the real outcome later by your own order id.
Decisions default to mode: "safe", the most thorough verification, because they are accountable outputs. The Idempotency-Key makes a retry within 24 hours replay the stored response — same decision_id, not re-decided, not re-billed — with idempotent-replayed: true.
Response (approved)
The headers carry the verdict and charge too (x-nsr-decision: approved, x-nsr-outcome-billable: true, x-nsr-outcome-cost-micros: 5000), so a proxy can log the outcome without parsing the body. Keep decision_id and proof.request_policy_hash with the refund: GET /v1/decisions/{id} returns the record — verdict, cited rule ids, the replayable request snapshot, the same policy hash and, once reported, the real outcome — while it is inside the retained window; a 404 means it rotated out of the bounded log, and billing truth lives in metering.
3

Read the three verdicts

Change the facts and the verdict follows. Add fraud_flagged(order_10042, chargeback_history) and the deny rule fires: "decision": "denied", "cited_rules": ["fraud_blocks_refund"], with a plain_explanation that names the flag. Deny wins even though the permit rule’s other premises hold.Drop inspection_passed and the engine cannot prove the goal. It does not guess — it refuses and names the exact premise that would unblock the proof, in the shape you can echo straight back:
Response (refused — evidence gap)
Make the order $620.00 instead ("args": ["order_10042", 620]) and the review rule fires — the other kind of refusal, with no missing_facts, because a review or safety refusal must not invite resubmission:
Response (refused — human review)
refused is a successful, safe outcome — never a soft yes, never something to retry blindly. With missing_facts, look each premise up in your own systems and resubmit. With requires_human_review: true, escalate; the decision also emits a decision.review_required webhook. Refusals are metered like any other outcome, so a refusal is not a free probe.
4

Verify the proof without trusting the engine

Save the approval’s verifiable_bundle exactly as returned and POST it to POST /v1/proofs/verify. The handler is stateless — no tenant state, no database. The bundle carries its own facts, rules, proof and org_id, and the checker replays the derivation against nothing but those.
Response
It is a real check. Delete the inspection_passed triple from facts in bundle.json and send it again — still a 200, because an unsound proof is a valid answer, not a client error:
Response
Since v0.9.4 the checker verifies entailment, not citation shape: every cited fact is unified with the atom the rule needs, heads and bodies are replayed, builtins are evaluated, and uncertified negation fails closed. The same kernel ships as the dependency-light nsr-proof-core crate for offline verification; upgrade it with the server, since an older verifier accepts proofs the server now rejects.
verifiable_bundle is present only when you declared an authorization_goal and the engine reproduced the proof; it is skipped above 64 facts or 64 rules across request and org scope, and for facts of arity three or more. The verdict stands on its own derivation either way.
5

Wire the gate into the agent, fail closed

The agent may call the refund tool only after an approved verdict whose bundle verifies. Every other path — denied, refused, an HTTP error, a timeout, an unreachable engine — ends without a side effect.
Three rules the code encodes:
  • Absence of a verdict is not a verdict. A timeout, a connection error, a 5xx, or the 503 the engine returns when it cannot complete machine inference all mean do not refund. 5xx responses are never cached under an idempotency key, so a later retry reaches a healthy backend.
  • Resubmit at most once, only for an evidence-gap refusal, only with facts your own systems supplied. Never lower confidence_threshold to turn a refusal into an approval.
  • Log decision_id and request_policy_hash next to the refund, then close the loop from the system that learns the truth, keyed by the order id it already holds:
honored, reversed, overridden or escalated feed GET /v1/decisions/calibration, which tells you whether a reported 0.94 holds up 94% of the time — and therefore where an auto-approve threshold can defensibly sit.

What you built

Troubleshooting

Check the encoding — facts are { "predicate": { "name", "args" } } envelopes — and that the subject token matches in the facts and in authorization_goal. A threshold needs the raw number (["order_10042", 84]), not a precomputed boolean. If missing_facts is empty and requires_human_review is true, a review rule or the GSS safety gate fired; more facts will not change that.
No bundle: you did not declare authorization_goal, a fact had three or more arguments, or the combined request-plus-org facts or rules exceeded 64. verified: false: send the bundle byte-for-byte, with no re-serialisation or wrapper, and make sure an offline nsr-proof-core is at least v0.9.4.
402 means the organization’s outcome quota is exhausted — refusals count too; watch x-nsr-outcome-remaining and the billing pages in the NSR console. 404 on a recent decision means the bounded log rotated it out; report outcomes as they happen and use GET /v1/decisions/export for a durable JSONL trail.

Next steps

Verified Decisions API

The full contract — rule effects, negation, the outcome gate, soundness invariants and the v0.9.4 verification changes.

Agent Gate

The same fail-closed pattern as an MCP gateway, so the agent never holds an unguarded refund tool.

NSR MCP server

nsr_decide, nsr_verify_proof and nsr_record_outcome_by_ref as agent tools.

Endpoint reference

Generated pages for /v1/decisions, /api/v1/rules and /v1/proofs/verify, with playgrounds.