Skip to content

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.

A rule is the smallest unit of policy. Every rule carries:

  • rule_id — stable identifier inside the policy.
  • type — the rule category (r01 through r29, plus r_custom). Each type maps to a handler with its own predicate and parameters.
  • order — integer position. Lower runs first; ties are broken by rule_id ascending.
  • enabled — disabled rules are skipped during evaluation.
  • params — typed parameters for the rule’s predicate (for example, the per-currency caps map for the max-amount rule).
  • action_on_match — what to do when the predicate fails: reject, escalate, or allow (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.

The engine is first-match-by-order, not score-based and not “all-rules-must-pass-then-vote”. The flow:

  1. Filter to enabled rules.
  2. Drop catalog-eligibility rules (c01c04) — those are scored by a separate, catalog-time evaluator, not by the mandate path.
  3. 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).
  4. Sort by order ascending, ties broken by rule_id ascending.
  5. Walk the sorted list. For each rule, dispatch to its handler with the verified mandate.
  6. Stop on the first non-passed outcome. Map the outcome to a final decision (approved, escalated, rejected, or evaluator_error).
  7. 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.

The engine produces a discriminated-union result. Each maps to a specific audit decision:

Result kindWhen it firesAudit decision
approvedAll enabled rules passed, or an allow-action rule fired an exemption.approved
escalatedA rule failed with action_on_match: 'escalate'. The mandate is paused; resolution is recorded later by the HITL worker.pendingapproved/rejected on resolution
rejectedA rule failed with action_on_match: 'reject'.rejected
evaluator_errorAny 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.

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 + same now_ms always 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 rejected decision. Silence is never a valid outcome. The granular failure reason is preserved on the audit trace for diagnosis.

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.

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 returns evaluator_errorrejected.
  • 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 records rule_budget_exhausted and 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.

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:

  1. r05 — predicate: 20 <= 50passed. Continue.
  2. r07 — destructive action refund, 20 > 10 → fails. action_on_match is escalate → engine enqueues an escalation and returns kind: 'escalated' with the escalation_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.

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