> ## Documentation Index
> Fetch the complete documentation index at: https://omniflow-vpn.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Risk controls

> External blocklists and anomaly detection — evidence for an operator, never an automatic sanction.

Omniflow can subscribe to external blocklists and can watch a handful of metrics
for values that look wrong. Both surfaces produce rows an operator reads.

One rule governs the whole area and is enforced in the schema, the service
layer, and the panel copy: **nothing here punishes a customer**. A match and a
signal are evidence. The adverse action, when there is one, is an ordinary
customer or finance mutation on the customer's own page, with its own
permission, its own mandatory reason, and its own audit event.

That is what makes automated detection safe to run at all: the worst a false
positive can do is cost an operator the time it takes to dismiss it.

## Blocklists

An operator configures a source: a display name, what its entries identify
(`telegram_id`, `email`, or `username`), an HTTPS URL, an optional authorization
header, and a refresh interval.

### What is stored

Only a digest. Each entry is kept as the SHA-256 of its normalised value, salted
by the kind it identifies. Three things follow.

Omniflow holds no readable copy of a third party's list, so it cannot leak or
republish one. A fingerprint from one installation means nothing in another. And
a lookup is still an exact index probe, which is the only kind of lookup a
blocklist needs.

Normalisation happens before hashing and is per kind. Telegram identifiers are
reduced to their canonical decimal form, usernames are lowercased with a leading
`@` or a `t.me/` prefix stripped, and an address is lowercased in the domain
only — lowercasing the local part or stripping dots is a provider-specific
convention that would make two genuinely different addresses collide.

### Refresh

A refresh replaces a source's entries wholesale inside one transaction, so an
entry the publisher removed stops matching in the same instant the new set
starts, and a refresh that fails part way leaves the previous list intact rather
than an arbitrary prefix of the new one.

Both a JSON array and a plain-text file of one value per line are accepted, and
the shape is decided from the first non-space byte rather than from a declared
content type — a list served with a careless `text/plain` still imports. A line
that does not normalise is skipped and counted rather than failing the refresh:
one malformed row in a third-party list must not leave an operator with a stale
blocklist and no explanation.

A source publishing more than 500,000 entries is refused. A list that suddenly
grows that large is far more likely to be misconfigured or redirected than
genuinely that big, and importing it would replace a working list with garbage.

### Deciding a match

A match records the customer, the source, and the kind of identifier that hit.
An operator decides `allowed` or `blocked` with a mandatory reason, and their
identity is recorded, so an adverse decision can never be anonymous.

Re-detecting a match that has already been decided does not reopen it. The
allowlist goes further: an allowlisted customer is skipped entirely by the
check, which is what makes an operator's "this one is fine" survive the next
refresh of the source.

A blocked match can be appealed, which returns it to the review queue.

## Anomaly detection

Four metrics can be watched, each with its own rule:

| Metric | Measures |
| - | - |
| `purchase` | Paid order value per customer in the window |
| `refund` | Refunded value per customer in the window |
| `referral` | Qualified referrals per inviter in the window |
| `traffic` | Observed traffic on a subscription |

A rule carries a window, a warning threshold, an alert threshold, and a minimum
sample. Thresholds are in the metric's own unit — bytes for traffic, minor units
for purchase and refund, a plain count for referral — because converting them
would mean showing an operator a number they did not type.

`minimum_sample` is what stops a brand-new installation raising an alert on its
first three orders. A window with fewer observations than that raises nothing.

> [!NOTE]
>
> The traffic rule is a **level** check, not a rate. Remnawave owns traffic and
> Omniflow holds only the counter it last observed, so the rule flags a
> subscription whose observed usage has passed a threshold, not one whose usage
> grew quickly. The rule's window controls how often such a subscription is
> re-raised, not what is measured.

### Deduplication

A condition that persists across several evaluation runs is one signal, not one
per run. The dedupe key is the metric, the subject, and the window bucket the
observation falls in, so a customer who stays above a threshold for six hours
produces one alert per window rather than one per sweep. A signal an operator
has already reviewed stays reviewed when the same condition is observed again.

### Evidence

Each signal carries the numbers that produced it — counts, sums, the currency,
the window — and never a message body, a link, a token, an address, or any other
customer content. An operator reviewing a signal needs to see why it fired,
which is arithmetic, not correspondence.

### Notifications

A raised signal is queued to the `anomaly` operator topic in the same
transaction that persists it, so an alert cannot be announced for a signal that
was rolled back. The notice names the metric, the severity, and the numbers. It
does not name the customer: an operator group is a chat, and the panel is where
somebody with the permission looks the subject up.

## When any of this runs

Both surfaces are driven by `internal/sweeper` in the worker process, not by a
request. Its passes run as River periodic jobs — `lifecycle_sweep` and
`anomaly_evaluation` — so each runs once per interval however many worker
replicas are up, and each is idempotent, so a pass that runs twice reaches the
same state as one that ran once. See
[Jobs and queues](https://omniflow-vpn.mintlify.site/operations/jobs-and-queues.md#periodic-passes-and-in-process-loops).

| Pass | Cadence |
| - | - |
| Gift and personal-offer expiry | Every `lifecycle_sweep`, five minutes |
| Blocklist refresh | Every `lifecycle_sweep`, but only for sources whose own interval has elapsed |
| Anomaly evaluation | Its own `anomaly_evaluation` pass, fifteen minutes |

Anomaly evaluation runs on its own cadence because it aggregates over the orders
table, and a persisting signal is deduplicated by window — running it more often
produces no extra alerts, only extra work.

A failing pass never stops the others. A publisher being unreachable must not
prevent gifts expiring, and a rule that cannot be evaluated must not stop a list
refreshing. Each blocklist source is independent for the same reason: one being
down leaves the others refreshed and leaves the failing source's previous
entries intact, rather than emptying a list nobody could replace.

## Editing thresholds and sources

Both are edited in the panel under **Risk**, with `risk.write`.

The threshold form applies the same coherence rule the evaluator does — an alert
threshold at least the warning threshold, and a window of at least five minutes
— so a rule that would be silently skipped at evaluation time is refused at save
time instead. Each field states the unit it is in.

The source form is HTTPS-only, and an empty authorization header means "keep
what is stored", so re-saving a source never echoes its credential back into the
browser. That holds only while the URL is unchanged: saving a new URL without
re-entering the header is refused with `422 secret_required`, because a header
entered for one publisher must never be sent to another.

The worker downloads a source only from a **public** address. Loopback, private,
link-local (which includes cloud metadata), and the other non-public ranges are
refused at connect time, after the hostname has been resolved, and again for
every redirect — which must also stay on HTTPS. A source refused this way is
marked failing with the error code `destination_refused`, distinct from
`unreachable`, so the panel shows that the address itself is the problem.

## Permissions

`risk.read` reads sources, matches, rules, and signals. `risk.write` decides a
match, appeals one, manages sources and rules, and edits the allowlist.

Every role that holds `risk.write` also holds `customers.read`, which is
asserted by a unit test: a role that could decide a match without being able to
read the customer would be deciding blind.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.