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 r31, 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/. Two rule types — the per-user daily spend cap (r06) and the cross-agent organization cap (r31) — are configured in the policy but enforced at the origin, because each needs a running spend total the edge can’t compute inside its evaluation budget. The organization cap is rail-neutral: it sums an organization’s spend across every settlement rail against one budget. See Organization-wide spend caps.

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.