Skip to main content
The rule engine evaluates the rules in your active ruleset against the decision payload and produces the outcome. Rules run in the order they appear in the ruleset; a BLOCK short-circuits evaluation, while REVIEW outcomes accumulate. If no rule matches, the decision is ALLOW. Rules address the payload through field paths on the ruleset’s bound schema — the built-in transaction schema unless the ruleset binds a custom one. The examples on this page use transaction paths; the grammar is the same over any schema.

Rule types

Each condition and blacklist rule carries an action (REVIEW or BLOCK); backend rules derive theirs from the backend’s response. Every rule has an enabled flag. Rules are evaluated in the order they appear in the ruleset’s rules array.
The exact ruleset JSON schema is documented in the API reference. This page describes the concepts; the schema is the source of truth for field names.

Conditions

A condition rule evaluates a boolean expression tree over the decision payload, addressed with JSONPath ($.transaction.amount, $.metadata.channel, …). Expressions compose:
  • Comparison — eq, neq, lt, lte, gt, gte; either side may be a field reference or a literal, so field-to-field comparison works
  • Set membership — value in a list: a literal set, or an array-typed field such as $.credential.pan.schemes
  • Boolean composition — and, or, not
  • Velocity — an accumulated count, sum, or distinct-count over a window (see below)
Every field the bound schema declares is addressable, including the PayPal credential fields ($.credential.paypal.email, payer_id, address_status, protection_eligibility, payer_status):
The PayPal fields are optional and become available at different stages of the PayPal flow, so predicate only on ones your integration actually sends. protection_eligibility in particular is returned by PayPal only after authorization, so a rule using it will not match a pre-authorization decision — it resolves to an absent path, and the predicate abstains: it never fires and never raises.
A path the bound schema does not declare rejects when you save the ruleset (422 naming the path). A declared path that a payload does not carry makes the predicate abstain: the rule never fires on that decision, and the abstention absorbs through and / or / not — a composite with any absent operand abstains as a whole, and not over an absent path abstains rather than firing. JSON null counts as absent; an empty string or array counts as present. Simulation shows which rules abstain and why.

Field references and filters

A field reference is a JSONPath starting with $., optionally followed by a chain of filters that transform the value before it is compared:
Filters compose left to right and are type-checked against the bound schema when you save the ruleset: domain() on a field that is not declared as email rejects with 422. A filter that receives an absent or malformed value emits absent, and the predicate abstains. A filtered value inherits the strictest classification of its inputs. In a comparison the filtered reference goes in expr; a bare path goes in path:
Velocity partitions (partition_by, distinct_on), blacklist rule fields, and HMAC inputs take the pipe string directly.

Fingerprints

hmac composes one or more field references into a single deterministic fingerprint — an HMAC-SHA256 of the ordered inputs under a secret dedicated to your instance — so you can match an identity without storing or comparing the raw values:
  • Inputs are filtered field references or string literals ({"type": "const", "value": "…"}); at least one must be a field reference, and there are at most 16.
  • The fingerprint reads hmc_ followed by 64 hex characters. The simulation trace and decision evidence render the bare hex — prefix it with hmc_ when you copy it into a rule.
  • Identical inputs and filters always produce the same fingerprint; the operator applies no normalization of its own, so express case-folding and trimming per input.
  • An absent input makes the whole composition absent, and the predicate abstains.
  • pci-sad fields cannot be inputs. A fingerprint is any-scoped, so it can be compared against a literal even when one of its inputs could not be.

Regulated fields and literals

The classification markers gate what a rule may do with a field:
  • A pci-pan or pci-sad field cannot be compared against a literal — the literal would persist in the rule. Compare it against another field reference, or hash it with hmac and compare the fingerprint. Card matching normally uses $.credential_fingerprint.
  • A pci-pan or pci-sad field cannot be a blacklist field, and a pci-sad field cannot appear anywhere in a velocity condition. A pci-pan field can partition a velocity counter or be its distinct_on value — it is stored as its fingerprint, never raw.
  • Rules referencing pci-pan or pci-sad fields submit only on instances at PCI level SAQ_D or ROC — there, hmac over the field is the way to pin a rule to a specific card.
  • Array- and object-typed fields are usable in one position: the right side of in / not_in. Anywhere a single value is expected they reject with 422.
  • pii fields are unrestricted.

Velocity

Velocity conditions fire when an accumulated value exceeds a threshold, per partition, within a window — “more than 5 attempts on this card in the last hour”, “over €200 on this customer this week”, “this payment method in more than 4 customer accounts”. One condition type covers all of these; the shapes are configurations of the same axes and compose freely: Rules written in the legacy shape — {op: velocity, field: "$.credential_fingerprint", threshold: 5, window_seconds: 3600} — stay valid and read back as the explicit default configuration (aggregate: count, window_type: rolling_seconds, the legacy field as partition_by). That spelling is kept for compatibility; write new rules with partition_by — in the extended shape field means the summed value only. Invalid axis combinations are rejected at submission with a 422 naming the offending axes; the exact validity matrix lives in the API reference. Calendar windows align to UTC boundaries — days at midnight, ISO-8601 weeks, months on the 1st, Gregorian quarters and years — and each period instance carries a stable key ("2026-W22", "2026-05", "2026-Q2"). Increments and reads attribute to the period containing the decision time, and the decision path always increments and reads the current period as one atomic operation. A closed period’s counter is retained for five minutes past its boundary, but no API surface addresses it — simulation reads the current period — and no earlier period is queryable. Amount sums (aggregate: sum) accumulate an amount-typed schema field in minor units, in a signed 64-bit accumulator, auto-partitioned by the amount’s adjacent ISO currency code — a weekly EUR limit never mixes with USD traffic on the same partition, and no FX conversion is consulted. Counters move only on the decision path: lifecycle events such as refunds never adjust a counter, so a sum over the built-in transaction schema is gross, not net. A negative amount submitted as a decision request decrements — the route for schemas that model signed records. A rolling sum over rolling_seconds slides continuously and is exact to the second, up to 48 hours; longer rolling horizons use window_type: rolling_days (window_days from 2 to 366), whose window is today so far plus the N-1 preceding whole UTC days and whose edge advances at midnight UTC, not continuously. rolling_days also serves count; it is not valid for distinct_count. Plain-partition count and distinct_count over rolling_seconds keep an uncapped window. Identity fingerprints partition a counter by an HMAC composition over an ordered list of filtered field references — “max N accounts with identical PII” without any pre-declared derived field. Canonicalization is yours, expressed per input with filter pipes ($.customer.first_name | downcase() | trim()); Specter applies none of its own. A ruleset write whose PII string input carries no normalizing filter still succeeds — Specter records the weak canonicalization in the audit trail, naming the input path. Two rules share a fingerprint counter exactly when their compositions normalize identically. A composition, whether as partition_by or as distinct_on, is valid only with rolling_seconds up to 48 hours and the day, week and month calendar windows; bounded_long, rolling_days, quarter and year are rejected at submission, so composition-keyed state never outlives a calendar month. Cardinality (aggregate: distinct_count) counts unique distinct_on values per partition — exactly, never approximately. Under bounded_long (and rolling_seconds), each distinct value’s membership expires a window-length after its first sighting (re-sighting does not refresh it); under a calendar window the member set is period-keyed and resets whole at the period boundary. The member set is opaque: every surface reports the count, never the members. The bounded_long window carries the member lifetime — 1 year by default, configurable per rule from 1 day to 5 years. Counters are shared: rules whose counter definition matches — same schema and carry-over generation (not the raw version number), partition, aggregate, window, (for sums) field and currency, and (for distinct counts) distinct_on — read the same counter, and increments fire once per decision no matter how many rules read it. A rule cannot demand a private counter. The simulation endpoint surfaces a stable counter_id per rule so you can confirm sharing intent before activating. When a custom schema publishes a new version, carry_over decides whether counters continue across the version boundary — see Schemas.

Fail posture

Each velocity rule declares what happens when its counter cannot be read or written: The posture is declared on the rule and applies to every velocity op in its condition; it is also accepted on each velocity op inside condition (the spelling the management UI writes), and a value there that differs from the rule’s is rejected at submission. Postures resolve per rule, independently — one store failure can abstain a fail-open rule and fire a fail-closed one within the same decision. A counter that has simply never been written is not a failure: it reads 0 (or an empty member set) and the rule evaluates normally. The simulation endpoint accepts a counter_store_unavailable flag to preview posture outcomes before activation.

Backend rules

A backend rule delegates the decision to a backend exposed by a Link integration. The rule names the integration slug and the backend_id to invoke; Specter calls Link to assess the transaction and maps the outcome to a Specter decision. Before any call, Specter checks that the payload contains the backend’s required fields. If any are missing, it skips the backend with an insufficient_context result and makes no call. A backend or backend-group rule may carry a condition of its own as a guard: the backend is called only when the condition holds. When it is false or abstains, the call is skipped with a guard_not_met result and evaluation continues — useful to reserve a paid risk check for high-value payments. Each backend and backend-group rule declares an on_error setting — it is required, never defaulted — that governs what happens when the backend errors or times out:

Simulation

Before you activate a ruleset, run it against a test payload:
The response is an evaluation trace: one entry per rule, in evaluation order, with its outcome — fired, not_fired, abstained (naming the absent field or absorbing operand, or the unavailable counter of a fail-open velocity rule), not_simulated for a backend rule, not_evaluated for a rule never reached — and the derivations behind it: resolved values (redacted per classification), each filter step’s output, the HMAC fingerprint, the velocity counter and its current reading. The run is inert: no decision record, counter increment, blacklist entry, webhook, or backend call. Counters are read live, so two runs may differ. Backend rules are never invoked, and because a backend could change everything downstream, a ruleset that reaches a live, synchronous backend rule simulates to no verdict — verdict_suppressed names the rule that stopped it. An async backend rule suppresses only a verdict that would otherwise be ALLOW; shadow rules (live: false) never suppress. Rulesets without backend rules simulate to a full verdict. The endpoint works on a ruleset in any lifecycle state and needs the dedicated admin:rulesets:simulate scope. See the API reference for the trace contract. After the fact, the same trace shape answers “what made this rule fire” for a recorded decision — see decision evidence.

Evaluation order

Rulesets

Version and activate collections of rules.

Backends & connections

Connect external risk engines.

Schemas

The field paths, types, and markers rules are written against.