New Release
Calendar-Aligned Velocity Windows
Velocity rules now count over calendar periods, not just rolling windows: the condition’s newwindow_type accepts day, week, month, quarter, and
year alongside rolling_seconds. Boundaries are UTC — ISO-8601 weeks,
Gregorian quarters and years — and every period instance carries a stable key
("2026-W22", "2026-05", "2026-Q2") that the simulation trace reports.
Existing rules are untouched: the legacy velocity shape reads back as aggregate: count over rolling_seconds.New Release
Amount-Sum Velocity Limits
“Weekly amount limit €200” is now a single rule:aggregate: sum accumulates
an amount-typed schema field per partition and window, in minor units, in a
signed 64-bit accumulator. Counters auto-partition by the amount’s ISO
currency code, so a EUR limit never mixes with USD traffic and no FX
conversion is involved. Sums move only on the decision path — lifecycle
events such as refunds never adjust a counter — and the simulation trace
shows which currency counter a test payload lands on. Rolling sums over
rolling_seconds stay exact to the second up to 48 hours; longer rolling
horizons use the new day-quantized window_type: rolling_days (window_days
2 to 366), which also serves count.New Release
Identity-Fingerprint Velocity
A velocity counter can now be partitioned by an HMAC composition: an ordered list of filtered field references whose fingerprint becomes the counter key, so “max N accounts with identical PII” needs no pre-declared derived field. Canonicalization is the rule author’s explicit choice via filter pipes; Specter applies none of its own — a PII string input without a normalizing filter is accepted and recorded in the audit trail as a weak-canonicalization warning. Rules share a fingerprint counter exactly when their compositions normalize identically. Compositions are limited to short windows (rolling_seconds up
to 48 hours, or day / week / month) so that fingerprint-keyed state never
outlives a calendar month. A pci-pan field may now partition a velocity
counter, or be its distinct_on value — it is stored as its fingerprint, never
raw; pci-sad stays rejected anywhere in a velocity condition.New Release
Cross-Customer Cardinality
aggregate: distinct_count counts the unique values of a distinct_on
attribute per partition — exactly, never approximately — enabling rules like
“this payment method in at most 4 customer accounts”. Under bounded_long and
rolling_seconds each distinct value’s membership expires a window-length
after its first sighting; a calendar window resets its member set at the
period boundary. The member set stays opaque: every surface reports the count,
never the members. The new
bounded_long window carries the member lifetime — one year by default,
per-rule configurable from a day to five years.New Release
Choose How Velocity Rules Fail
Every velocity rule now declares afail_posture for counter-store failures:
open (the default) abstains and lets evaluation continue — the previous
behavior — while closed fires the rule’s declared action. Postures resolve
per rule, independently, even on a shared counter. A never-written counter is
not a failure: it reads zero. Pass counter_store_unavailable to the
simulation endpoint to preview posture outcomes before activating a ruleset.Improvement
Every Provisional Decision Reaches a Final Answer
A decision that consults an asynchronous backend comes backPROVISIONAL, and
the final outcome follows on your webhook once the evaluation completes. If
Specter restarted while that evaluation was still running, the outcome never
arrived — leaving the transaction unresolved on your side with nothing left to
wait for.A decision left provisional for more than five minutes is now finalized
automatically as ALLOW, the same answer Specter already gives when a backend
times out, and the decision.async.allow event is delivered to your webhook as
usual. The backend result carries error: "stranded", so an interrupted
evaluation is distinguishable from one that ran to completion when you
reconcile.An interruption on our side is not a failure of your backend. stranded does
not count towards backend error rates and does not appear in the error views;
in the console it reads as “interrupted by restart”.Improvement
Webhook Destinations Are Checked Before They Are Saved
Registering or updating a webhook whose destination is not a completehttp
or https address is now refused with a 422 at the moment you save it.
Previously the destination was stored as given, and the mistake surfaced only
as notifications that could never be delivered — with nothing in the
registration to show why. Clearing a destination entirely returned a server
error rather than a validation message.Webhooks registered before this change keep running. Their destination is
checked the next time you edit them.Maintenance
A Schema That Registers Is a Schema That Works
Two kinds of custom schema document were accepted at registration and then failed when Specter came to use them — one at the first decision, one as a server error. Both are now refused at registration with a422 naming the
part of the document at fault, so a successful registration means the schema
is usable.Both cases involve $ref: a reference to a key whose name would need JSON
Pointer escaping (~0, ~1) is not supported, and a reference that resolves
to something other than a schema object is rejected. See
Schemas for how documents are registered
and published.Improvement
Improvements and Bugfixes
- A decision is available to read the moment you receive it. The decision record was previously written in the background, so a lookup made immediately after a decision could come back empty. Automatic blacklist population can no longer be lost either — it is committed together with the event that triggers it.
- Simulation rejects a payload carrying a card number exactly as a live decision does, so a simulation you rely on before going live matches what the real endpoint will do. A card-number field is recognized whatever name it is bound under.
- The rule editor no longer offers a comparison it will refuse to save. Pairing a literal value against a card-data path is blocked while you build the condition, instead of failing at save.
- Signing in to the console works for accounts holding the full set of permissions; some sign-ins previously failed outright. Existing sessions are unaffected.
- A value entered outside its permitted range in the console returns a validation message rather than an unexpected error.
Improvement
Blacklist Entries Carry Data Classifications
Every blacklist entry now carriesvalue_classification on its API
representation — the x-data-classification the entry’s field_path
resolves to across the registered schemas: any or pii. A path no schema
declares is any (an omitted marker is a positive assertion, and an entry on
such a path is inert, since undeclared keys never enter a decision payload).
The marking is stamped when the entry is written and re-resolved whenever a
schema is registered or published, so it always follows the schemas’ current
declarations. Existing rows are classified automatically; no action is
needed. In the other direction, a schema registration or publication that
would re-classify a currently-blacklisted path as pci-pan or pci-sad is
refused with 422 naming the path.The retention posture is now a documented statement: blacklist entries are a
fraud-prevention record, exempt from the decision-record retention windows.
An expired entry stops matching but its value remains in the table — a TTL
does not bound retention, and deleting the entry is the only erasure. Entry
values remain readable in clear on the Admin API and UI so the list stays
inspectable and a data subject’s entries can be found and deleted on request;
every read of a value classified as personal data is recorded as an audit
event.Improvement
Interceptors Enforce the Built-in-Binding Contract
Interceptors have always mapped onto the built-intransaction schema; that
contract is now enforced instead of degrading silently. Creating or updating
an interceptor rejects a field_mapping target the transaction schema does
not declare with 422 naming the offending field_mapping[i].target —
dynamic metadata.<key> targets remain accepted. Mappings stored before this
gate are not swept; they surface on their next edit.Creating or updating an interceptor whose context currently resolves to a
custom-bound ruleset now rejects with 422 naming the context and the bound
schema — such an interceptor could never execute, so the mistake surfaces at
authoring. A context with no active ruleset yet is accepted, since its
binding is not knowable at that point. If a context is rebound to a custom
schema after the interceptor exists, executing it refuses with 502 INTERCEPTOR_BINDING_UNSUPPORTED, naming the context and the bound schema —
previously every mapped target was silently dropped as undeclared and the
decision evaluated a near-empty payload. Passthrough interceptors (empty
mapping) never touch the binding and are unaffected.The mapping editor is now tier-aware: below SAQ-D, credential.pan.* targets
are withheld from the offered list (the served target schema carries a
withheld flag), the “Full PAN” template is hidden, and pan leaves the
credential-type selector — while stored mappings on withheld targets stay
readable with a badge rather than degrading to unknown strings.Maintenance
Ruleset Request Shape Covers All Four Rule Types
The request body forPOST /api/admin/rulesets and PATCH /api/admin/rulesets/{id}
now documents what Specter has always accepted: type is condition, backend,
backend_group, or blacklist — the specification previously listed only the
first two — and the fields each type carries are on the request shape, not only
on the response: fields, ttl_seconds, and populate_on for blacklist rules;
strategy, members, and merge for backend groups; live for backend rules.
id and name are marked required, as they have always been, and so is the
fail posture: on_error is required on backend and backend-group rules (never
defaulted to allow), and action is required on blacklist rules as on
condition rules. A backend group carries at least 2 members; a split group
requires a weight of at least 1 on every member (ignored for fallback and
parallel); and merge on a non-parallel strategy is rejected, not ignored.Two behaviors are documented for the first time. A backend or backend-group rule
may carry a condition that guards the call: when it is false or abstains,
the backend is skipped with a guard_not_met result and evaluation continues.
And backend_results[].error documents the no-call codes — insufficient_context,
guard_not_met, backend_capability_disabled, backend_disabled, and
not_simulated; invocation failures additionally surface transport-level codes
such as timeout, network, and http_error.Documentation only; no endpoint changed. Regenerate clients to pick up the
request shape.Maintenance
Disabled Async Backend Rules No Longer Affect the Verdict
A backend rule withasync: true and enabled: false no longer turns an
ALLOW into PROVISIONAL. Previously the rule’s mere presence in the ruleset
put the decision on the asynchronous path even though the rule itself never
ran. A disabled rule now has no effect of any kind on the outcome, on the
decision API and in the simulation trace alike.Maintenance
Contract Corrections: Filtered Operands, HMAC Literals, Markers
A documentation-only round that aligns the API reference with the behavior that shipped. No endpoint changed; if you generate clients from our OpenAPI document, regenerate to pick up the corrected shapes.- Filtered comparison operands use
expr. A field reference that carries a filter pipe chain is written{"expr": "$.customer.email | downcase()"}(long form{"type": "filter_expr", "expr": …}).{"path": …}takes a bare path only — a pipe inside it rejects as an undeclared path. Velocity fields, blacklist rule fields, and HMAC inputs keep taking the pipe string directly. - HMAC fingerprints are
hmc_-prefixed. A literal you compare against anhmacoperand must readhmc_followed by the 64-character digest. The simulation trace and decision evidence render the bare digest; prefix it when you copy it into a rule. HMAC inputs may also include string literals ({"type": "const", "value": …}) alongside at least one field reference. - Classification markers completed. Fourteen fields of the built-in
transaction schema that Specter treats as
piiwere shown without a marker:customer.id, thecity/country/postal_code/stateleaves ofbillingandshipping,credential.masked_pan.first_six/last_four,credential.paypal.payer_id, andfrequent_flyer_number/country_codeon airline passengers. On decision reads,customer_idispiias well. - Smaller fixes.
shipping.destination_typesis an array of strings;masked_pan.first_sixis exactly six digits; a line item needsnameorsku; phone examples are E.164;on_error: reviewis accepted when creating a rule; schema versions have no draft state — every publication creates an immutable version,activeuntil deprecated; the schema-registry list endpoints are not paginated (the documentedafter/limitparameters never existed);carry_overgoverns velocity counters only, never blacklist entries; acontextkey in a simulation payload is ignored rather than rejected; the interceptor endpoint documents its422(validation / PCI gate) and502(field-mapping fault) responses.
Improvement
Velocity Counters Honor carry_over
Publishing a schema version with carry_over: false now does what it says:
rulesets bound to the new version count from zero, while counters of earlier
versions are untouched and keep serving the rulesets still bound to them.
With the default carry_over: true, counters for paths present in both
versions continue across the version boundary. Until now the flag was
accepted and stored but had no effect on counting.Blacklist entries are global and not versioned, so they are never affected by
this flag.Improvement
Backend Results Carry the Backend’s Reference
Eachbackend_results entry now surfaces backend_reference as a top-level
field: the reference the backend itself assigned to its evaluation, the value
you quote when correlating a Specter decision with the backend’s own records.Previously the reference was only readable inside raw_response, the backend’s
verbatim body. The dedicated field is stable across backends and stays
addressable even when the raw body is not served. null when the backend
supplied no reference. Additive — existing consumers are unaffected.Improvement
Stored Payload Redaction Follows the Classification Markers
The payload copy stored on an interceptor decision is now redacted by the bound schema’s classification markers — the same markers shown on the API reference — replacing the old name-and-shape guesswork.What this protects better: everything markedpii or pci-* is redacted by
declaration — customer ids, device IP, addresses, passenger details —
whether or not the field’s name suggests it. What becomes visible: unmarked
fields now store verbatim, including short numbers the old heuristic hid on
suspicion alone, such as amounts, counts, and numeric references or metadata
values.Two protections are independent of markers and unchanged in intent: PAN-like
and IBAN-like values are removed wherever they appear — now including
space- or dash-delimited and numerically encoded forms — and a mapping row
flagged sensitive: true always redacts its target.One behavior is deliberately gone: Specter no longer guesses sensitivity
from a source field’s name. A field named cvv or csc is not special —
if a mapped source carries data that must not be stored, set
sensitive: true on that mapping row. That flag is the supported mechanism,
and the only one.Payloads stored before this change keep their original redacted form.Improvement
Regulated Data Ages Out of Decision Records
Decision records now observe a per-scope retention window, configured on your instance as a number of days per classification (pii, pci-pan,
pci-sad). Once a scope’s window has passed, every field of that scope reads
as null on GET /api/decisions/{id} — for the decision record that is the
pii set: customer_id, device_ip, device_fingerprint; no pci-pan or
pci-sad value is ever persisted on the record, so those windows bound stored
payloads only. The key stays and the rest of the record — including the
unmarked masked_credential — is untouched. The boundary is enforced on every
read, not only when the background sweep reaches the row.A scope you do not configure is retained without bound, and any-scoped
fields — amounts, references, the credential fingerprint — are never aged out,
so an old decision stays correlatable.backend_results[].raw_response is the one place no marker reaches: the body
is the backend’s own shape, so it is nulled on the first configured window to
expire. backend_reference is held outside the body and survives it.Improvement
Regulated Fields No Longer Compare Against a Literal
A rule that compares apci-pan- or pci-sad-scoped field against a literal
value now rejects at submission with 422, on every tier.The reason is the literal, not the field. Writing
credential.pan.value == "4111111111111111" or cvc == "123" stores that value
in the rule itself, where it comes back on every read of the ruleset and renders
in the rule editor — the one place regulated card data was still able to come to
rest. Card and authentication data are never persisted, and a rule is no
exception.The field stays addressable. What changes is what may sit on the other side of
the comparison:- compare it against another field reference — that stores no value;
- hash it with
hmacand compare the digest against a literal (thehmc_-prefixed fingerprint) — a digest is not regulated data; - for card matching specifically, use the credential fingerprint.
pii fields still take a literal, and velocity thresholds
and windows are numbers rather than compared values, so counters are unaffected.
Rules already stored keep evaluating; the check runs when you next save the
ruleset, so an existing rule of this shape surfaces the first time you edit it.Maintenance
backend_options Removed From the Decision Request
backend_options is gone from the decision request. The field was specified as
a passthrough for provider-specific values, but nothing ever read it: no
backend received it and no rule could reference it, so anything you sent in it
was accepted and discarded.Sending it remains harmless. Unknown fields on the decision request are
ignored, so existing integrations keep working untouched and no request starts
failing because of this change. Only the specification changes: if you generate
clients from our OpenAPI document, the field disappears on the next
regeneration.Provider-specific data belongs in a named field instead. If you were relying on
backend_options to reach a provider, talk to us and we will model the field
properly.Improvement
List and Object Fields Belong on One Side of in
An array- or object-typed field is now addressable in a single operand position:
the right side of in / not_in, the set that membership is tested against.
"visa" in credential.pan.schemes is the shape, and it is unchanged.Everywhere else the field stands for one value, which a list or an object is not
— so the submission rejects with 422 naming the declared type. That covers a
scalar comparison (already the case), and now also the left side of
in / not_in, a velocity field, and a blacklist rule field. Each of
those could only ever produce “no opinion” at decision time, so a rule written
that way never did anything.Blacklist entries follow the same rule. POST /api/admin/blacklist now
rejects an array- or object-typed field_path with 422: an entry matches
exactly on the pair of field_path and value against one resolved value, so a
list can never equal the stored string. Such an entry was stored and shown but
could never block anything. Entries carry no schema binding, so the check is
conservative across your whole registry — a path that any registered document
and version types as a list or an object cannot key an entry, which means every
container path of any registered document is unkeyable.The rule editor and the entry picker already offered these fields in the
positions that accept them, so anything built in the UI is unaffected. Rules and
entries already stored keep working as before; the check runs when you next save
the ruleset or create an entry.Maintenance
scheme Removed From Card Credentials
The singular credential.pan.scheme / credential.masked_pan.scheme
string is gone; use the schemes array, which carries the same fact and
covers co-branded cards. Requests that still send scheme are not
rejected — the field is dropped like any other undeclared key — but a rule
referencing $.credential.pan.scheme no longer submits. Rewrite such
rules as membership over the array: "visa" in $.credential.pan.schemes.
A rule already saved against the old path keeps evaluating and abstains,
because the path now resolves absent (tri-state semantics); re-submitting
that ruleset rejects until the reference is rewritten.Improvement
Classification Markers on the Decision Payload
The API reference now shows a classification marker on every field of the built-in decision payload that carries regulated data.credential.pan.value is
marked pci-pan; the personal data you send for evaluation is marked pii —
customer names, email, date of birth, billing and shipping addresses, phone
numbers, device IP, and the passenger details on an airline booking.Fields without a marker hold none of those categories. masked_credential is
the case worth knowing: 411111 •••••• 1111 identifies nobody on its own, so it
carries no marker even though it looks card-like. The
full legend
is on the API reference.Nothing about any endpoint changed — this is documentation only.Maintenance
Credential Modelled as a Discriminated Union
credential on the decision request is now specified as a discriminated
union on type, with one variant per credential kind — pan, masked_pan,
sepa, and paypal. The request shape is unchanged: the same type value
paired with the same sibling object is what you have always sent. What
changes is that the pairing is now expressed in the specification rather than
only in prose, so the reference documentation shows one tab per credential
kind instead of four always-optional objects, and generated clients get a
proper tagged union.The specification now rejects combinations that were never valid but that the
previous shape technically allowed — most notably a type with no matching
credential object. If you generate clients or validate against our OpenAPI
document, regenerate against the current version.Improvement
Rulesets Declare Their Schema
The schema binding announced on August 7 is now enforced end to end.schemais required when you create a ruleset.POST /api/admin/rulesetswithout it rejects with422; send{"schema": {"name": "transaction"}}for the built-in payload, or{"name": …, "version": N}for a custom schema. Rulesets created before this change were backfilled with thetransactionbinding and keep working.- Rule paths validate against the bound schema. A field reference the
bound schema does not declare rejects at submission with
422naming the path — a typo surfaces when you save, not as a silent abstention at decision time.$.metadata.*stays free-form. Existing rulesets are checked the next time you save them. - Velocity fields follow the schema. Any declared scalar field can be
counted, not only the four launch paths;
pci-pan- andpci-sad-scoped fields are refused, because a counter key persists the counted value. - Decisions carry their schema. Decision responses and
GET /api/decisions/{id}includeschema— the(name, version)the decision was evaluated against. Additive; existing consumers are unaffected.
New Release
See Why a Rule Fired
Decisions can now carry evidence: the request payload as it stood when the decision was made, and — for each rule that fired — the values behind it.GET /api/decisions/{id}/payload returns the stored request, and
GET /api/decisions/{id}/evidence returns per-rule evidence together with a
replay of the exact ruleset version that decision was pinned to. Both read under
the existing decisions:read scope.Nothing is kept by default. Retention is opt-in per data classification, and
what is stored is decided by each field’s classification: any keeps the value,
pii keeps the path with a redaction sentinel, pci-pan keeps only the
fingerprint, and pci-sad is never stored at all. Because the sensitive tiers
are already reduced when written, evidence works on instances below SAQ-D.New Release
Typed Schema Fields and Data-Classification Markers
Custom schema fields now declare a type from a closed vocabulary — fromemail, iban, and phone to amount, pan, and masked-pan — and
Specter validates incoming payloads against it at ingestion: Luhn checks
for card numbers, ISO 8601 for dates, check digits for IBANs, closed enums
for schemes and funding types. Each field also carries a data-classification
marker (pci-pan, pci-sad, pii, or none) that governs where its value
may flow: marked values are redacted from error bodies, logs, and audit
events, pan fields are always PCI-scoped, and sensitive authentication
data is never stored — below SAQ-D it is rejected outright, and nothing
that persists data (velocity counters, blacklist entries) may reference a
pci-sad field.New Release
Built-In Transaction Schema
The decision payload shape Specter ships with is now a first-class schema: read-only, introspectable through the schema registry like any custom schema, and evolving additively only — existing fields never change type or disappear. Rulesets that name no custom schema are backfilled to bind the built-in, so every ruleset now states what it evaluates against.New Release
BIN Attributes on Card Credentials
Card credentials now expose BIN-derived attributes —bin, schemes,
type, segment, currency_code, issuer_name, and
issuer_country_code — on both credential.pan and
credential.masked_pan, ready to use in rule conditions. All attributes
are optional and reflect the BIN data as provided: Specter does not curate,
reconcile, or guarantee consistency between them. schemes is an array —
match it with the in operator. When simulation detects diverging BIN
attributes, the trace carries a bin_attribute_divergence warning.New Release
Custom Decision Schemas
Define the shape of your own decision payloads with the new schema registry under/api/admin/schemas. Schemas are named and versioned: every
publication creates a new, immutable version, and a superseded version can
be deprecated while it keeps serving the rulesets bound to it. A ruleset
binds one (schema, version) pair — the binding validates
incoming payloads and resolves rule field paths. The identity fields of a
persisted ruleset (schema, context) no longer change on PATCH:
sending a different value returns a 422, resending the same value is a
no-op.The registry endpoints require the new admin:schemas:read and
admin:schemas:write scopes.New Release
Filter Pipes and HMAC Fingerprints in Rule Conditions
Field references in rule conditions now accept filter pipe chains —$.customer.email | trim() | downcase() — wherever a field path appears:
condition operands, velocity fields, and HMAC inputs. The v1 filter set is
closed and typed; filters compose left to right, and a filter receiving an
absent or malformed value abstains rather than failing the rule. Filtered
values inherit the strictest data-classification marker of their inputs.A new hmac operator composes an ordered list of filtered field references
into a deterministic HMAC-SHA256 fingerprint, so rules can correlate on
stable derived identifiers without exposing the underlying values.New Release
Simulate Rulesets Without Side Effects
The newPOST /api/admin/rulesets/{id}/simulate endpoint evaluates a test
payload you supply against any persisted ruleset — DRAFT, ACTIVE, or
INACTIVE — and returns a full evaluation trace: the verdict the ruleset
would reach, and for every rule whether it fired, abstained, or was skipped,
and why. Simulation is inert — no decision is recorded, no velocity counter
is incremented, and no backend is invoked. Velocity conditions read live
counter state and report the value they evaluated against alongside the
rule’s threshold and rolling window, so the trace shows how close a rule
was to firing.An abstention always names its cause: absent_fields where a field the rule
addressed was not in the payload, and absorbed_operands where one was
present but could not be used — a membership test whose set operand is not
an array, for instance, which otherwise looks like a rule that silently
never matches.Simulation requires the new admin:rulesets:simulate scope.New Release
PayPal Payment Method Type
Decision requests now accept apaypal credential alongside pan,
masked_pan, and sepa. The credential carries email, payer_id,
address_status, protection_eligibility, and payer_status — all
optional, all caller-injected from your PayPal flow, and all addressable in
rules as $.credential.paypal.*. Send whichever your flow has captured at
the point you call Specter. The email is never persisted raw; the decision
log keeps the credential fingerprint (computed from payer_id) and a masked
form. See
Credential types.Improvement
Report Fraud on Any Decision
fraud_report events are no longer tied to the payment lifecycle: they are
accepted in any payment state — including for decisions that never received a
lifecycle event — so you can feed fraud signals back to Specter even if you
only use it for screening. An accepted report sets a permanent fraud marker on
the decision (fraud_marked_at, fraud_source, fraud_reason), independent
of payment_state, and continues to fan out to backends and auto-populate the
blacklist. See
lifecycle events.Improvement
Smarter Condition Fields in the Rule Editor
The rule editor’s condition field suggestions are now served by the backend, so the type-ahead always matches the exact request shape your rules evaluate against — including nested credential fields and your$.metadata.* paths.
Fields that are unavailable on your instance, such as raw PAN paths below
SAQ-D, are omitted automatically.New ReleaseImprovement
Run Specter Within SAQ A and SAQ A-EP Scope
Specter now runs on instances assessed at SAQ A or SAQ A-EP — you no longer need a SAQ-D environment to use it. Below SAQ-D, Specter actively enforces the boundary that keeps your assessment scope small: request bodies carrying a raw card number (PAN) outside the designated credential field are rejected with a422 validation error, and Interceptor payloads are scanned so raw
card data never reaches your instance.The platform test-card policy applies on every instance: on the Test tier,
decision requests accept only known test cards; on the Production tier, test
cards are rejected. Violations return a 403 with a clear classifier.New Release