Policy engine
The policy engine is the part of Sill that decides what an arriving agent is allowed to do. Each site has one active policy — a versioned, ordered set of rules. For every signed mandate, the engine walks the policy in order, dispatches each enabled rule to its handler, and returns one of three outcomes: approve, escalate (route to a human reviewer), or reject. Evaluation runs at Sill’s edge, is deterministic for a given input, and is fail-closed on any internal error.
Rule shape
Section titled “Rule shape”A rule is the smallest unit of policy. Every rule carries:
rule_id— stable identifier inside the policy.type— the rule category (r01throughr29, plusr_custom). Each type maps to a handler with its own predicate and parameters.order— integer position. Lower runs first; ties are broken byrule_idascending.enabled— disabled rules are skipped during evaluation.params— typed parameters for the rule’s predicate (for example, the per-currencycapsmap for the max-amount rule).action_on_match— what to do when the predicate fails:reject,escalate, orallow(an explicit exemption — the policy is approved on first match).
A policy is a list of rules plus a version string. Only one policy is active per site at any time. Publishing a new version is an atomic swap; existing in-flight evaluations finish against the version they started under.
Evaluation order — first match wins
Section titled “Evaluation order — first match wins”The engine is first-match-by-order, not score-based and not “all-rules-must-pass-then-vote”. The flow:
- Filter to enabled rules.
- Drop catalog-eligibility rules (
c01–c04) — those are scored by a separate, catalog-time evaluator, not by the mandate path. - If the evaluation is on the Discovery chain class, drop transactional-only rule types (the rules whose semantics only make sense against a signed intent — for example the max-amount rule).
- Sort by
orderascending, ties broken byrule_idascending. - Walk the sorted list. For each rule, dispatch to its handler with the verified mandate.
- Stop on the first non-
passedoutcome. Map the outcome to a final decision (approved,escalated,rejected, orevaluator_error). - If every enabled rule passes, return
approved.
Once a rule matches, the engine short-circuits: remaining rules are not evaluated, but they ARE recorded in the audit trace with action_taken: 'none' and a not_evaluated_due_to_short_circuit marker, so the trace stays complete.
flowchart TD
A[Signed mandate verified] --> B[Filter + sort enabled rules]
B --> E{Next rule?}
E -- no --> APPROVE([approved])
E -- yes --> F[Dispatch handler]
F --> G{Outcome}
G -- passed --> E
G -- failed, action=allow --> EXEMPT([approved · exempted])
G -- failed, action=reject --> REJECT([rejected])
G -- failed, action=escalate --> ESCALATED([escalated · enqueued])
G -- evaluator_error --> ERR([rejected · fail-closed])
The evaluator sorts the rule list by order then rule_id, walks it in a single loop, and returns on the first match. There is no second pass and no aggregate scoring.
Outcomes
Section titled “Outcomes”The engine produces a discriminated-union result. Each maps to a specific audit decision:
| Result kind | When it fires | Audit decision |
|---|---|---|
approved | All enabled rules passed, or an allow-action rule fired an exemption. | approved |
escalated | A rule failed with action_on_match: 'escalate'. The mandate is paused; resolution is recorded later by the HITL worker. | pending → approved/rejected on resolution |
rejected | A rule failed with action_on_match: 'reject'. | rejected |
evaluator_error | Any internal failure (CPU budget exhausted, handler threw, missing handler, escalation enqueue failed, required runtime binding absent). | rejected (fail-closed) |
The full evaluator trace — every rule ID, whether it passed, the action taken, and the reason — is attached to the audit record the engine writes. Operators can read the trace later to see exactly why a mandate was approved, escalated, or rejected.
Determinism and fail-closed
Section titled “Determinism and fail-closed”Two properties hold by construction:
- Determinism. In the no-abort case,
evaluatePolicy(verified_mandate, policy, now_ms)is a byte-for-byte pure mapping over its inputs. The same mandate + same policy + samenow_msalways yields the same decision, the same trace, and the same audit record. - Fail-closed. Any internal failure — a per-rule CPU budget exhausting, a handler throwing, a rule type with no handler, an escalation enqueue failing, a required runtime binding missing — maps to a final
rejecteddecision. Silence is never a valid outcome. The granular failure reason is preserved on the audit trace for diagnosis.
Rule categories
Section titled “Rule categories”The current evaluable rule set spans agent-identity guardrails, rate limits, intent / amount checks, dark-pattern detection, prompt-injection defenses, geofencing, customer-scoping, integrity checks on signed manifests, and the merchant’s own custom expression (r_custom). Some categories map directly to OWASP LLM Top 10, OWASP Top 10 for Agentic Applications, and MITRE ATLAS entries — the framework mapping table lives at /reference/compliance/. One rule type (the per-user daily spend cap) is configured in the policy but enforced at the origin, because the spend total it needs is an aggregate the edge can’t compute inside its evaluation budget.
The dashboard’s Guardrails view is the authoritative, merchant-facing surface for which rule types are available, what their parameters mean, and which action_on_match values are typical for each.
CPU budget
Section titled “CPU budget”Policy evaluation runs at the edge under a tight CPU budget — both per-rule and whole-policy. The edge runtime supplies two abort signals to the evaluator:
- Whole-policy signal — checked before every rule. If it fires, the running rule is recorded as
policy_budget_exhausted, remaining rules get cleanup markers, and the engine returnsevaluator_error→rejected. - Per-rule signal — passed into each handler. A handler that respects the signal can short-circuit its own work; if a handler throws an
AbortError, the engine recordsrule_budget_exhaustedand fail-closes.
Cleanup-marker append is deliberately not budget-checked: it is a fixed-time push per remaining rule, bounded by the policy’s rule cap, so the engine cannot get stuck after the budget trips.
Worked example
Section titled “Worked example”A minimal policy with two rules — a max-amount cap on USD and a HITL-on-destructive-actions check — evaluated against a $20 refund mandate in USD:
{ "version": "pol_v3", "rules": [ { "rule_id": "rul_01H...", "type": "r05", "order": 10, "enabled": true, "action_on_match": "reject", "params": { "type": "r05", "caps": { "USD": 50.00 }, "on_unlisted_currency": "reject" } }, { "rule_id": "rul_02H...", "type": "r07", "order": 20, "enabled": true, "action_on_match": "escalate", "params": { "type": "r07", "auto_approve_caps": { "USD": 10.00 } } } ]}Trace, in order:
r05— predicate:20 <= 50→passed. Continue.r07— destructive actionrefund,20 > 10→ fails.action_on_matchisescalate→ engine enqueues an escalation and returnskind: 'escalated'with theescalation_id.
The audit record carries the trace for both rules and the escalation_id. When the reviewer resolves the escalation, the origin worker writes the resolution record into the same Merkle-chained envelope.
Frequently asked
Section titled “Frequently asked”Is the engine pass/fail across all rules, or first-match?
First-match-by-order. The first rule that matches with a non-passed outcome decides the mandate; remaining rules are recorded but not evaluated.
Can a rule explicitly approve a mandate?
Yes — action_on_match: 'allow' is an exemption. If the rule’s predicate fails (so the rule “matches”), the engine returns approved with exempted_by_rule_id set. Use sparingly; most rules should be reject or escalate.
What happens on a missing handler or a thrown handler?
Both map to evaluator_error, which the runtime persists as a rejected audit decision with a granular reason (rule_handler_missing or rule_handler_threw). The publish validator rejects unsupported rule types so this is a defense-in-depth path, not the normal one.
Why are catalog rules filtered out?
Catalog-eligibility rules (c01–c04) live in the same site policy row but are scored by a different evaluator at catalog-build time, not on the mandate path. Dispatching them through the mandate evaluator would fail-closed and brick the policy on every mandate.
Why are some rules dropped on the Discovery chain class?
Rules whose semantics read fields only a signed transactional mandate carries (intent amount, intent currency, signed expiry, delegation chain) would emit nonsensical verdicts against Discovery-shaped observation traffic. The engine filters them out when chain_class === 'discovery'.
Does the engine retry on a CPU-budget timeout?
No. A budget exhaustion is a final, fail-closed rejected decision; the audit record carries cpu_budget_exhausted: true on the relevant rule’s trace entry. Retry behavior, if any, is owned by the caller.
See also
Section titled “See also”- Guardrails — the merchant-facing rule editor.
- Signed mandates — the input to every policy evaluation.
- Human in the loop — what happens on an
escalateoutcome. - Audit envelope — where the evaluator trace is stored.
- Verify a signature — third-party verification of any signed Sill surface.
- Compliance — framework mappings (OWASP LLM Top 10, OWASP Agentic, MITRE ATLAS, NIST AI RMF).
- Concepts — Account / Site / Policy / Rule vocabulary.