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
ina 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)
$.credential.paypal.email, payer_id, address_status, protection_eligibility, payer_status):
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.
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:
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 withhmc_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-sadfields cannot be inputs. A fingerprint isany-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-panorpci-sadfield cannot be compared against a literal — the literal would persist in the rule. Compare it against another field reference, or hash it withhmacand compare the fingerprint. Card matching normally uses$.credential_fingerprint. - A
pci-panorpci-sadfield cannot be a blacklist field, and apci-sadfield cannot appear anywhere in a velocity condition. Apci-panfield can partition a velocity counter or be itsdistinct_onvalue — it is stored as its fingerprint, never raw. - Rules referencing
pci-panorpci-sadfields submit only on instances at PCI levelSAQ_DorROC— there,hmacover 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 with422. piifields 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 integrationslug 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: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
Related
Rulesets
Version and activate collections of rules.
Backends & connections
Connect external risk engines.
Schemas
The field paths, types, and markers rules are written against.