The layer the whole system exists to feed: customer rules → evaluation → firing. ~12 min.
We've built the graph, maintained it, and computed risk on it. But none of that is the product. The product is: a customer says "alert me if an admin can drain more than $5M of my token," and the system watches for exactly that, forever. The rule engine (pkg/rules) is where the risk graph finally becomes an alert. This is what it was all for.
A customer rule (pkg/rules/types.go Rule) is a small declarative spec:
type threshold | aggregate | event | balance — the evaluation strategy
condition a field path + operator + value (e.g. node_risk_score > 0.8)
scope which nodes it applies to (one address, a portfolio, top-N, all a portfolio's admins…)
trigger cooldown / re-alert timing
Read it as a sentence: "for [scope], when [field] [operator] [value], fire." The four rule types map to four questions:
| Rule type | Answers | Example |
|---|---|---|
threshold | Does a node's field cross a value? | utilization_pct > 95 on a market |
aggregate | Does a sum over a scope cross a value? | portfolio-wide admin_risk_usd > $10M |
event | Did a specific on-chain event happen? | curator changed on my vault |
balance | Live wallet balance check (Redis + RPC) | balance_usd < $1000 |
Source: pkg/rules/types.go (Rule, Condition, Scope, RuleType), threshold.go/aggregate.go/balance.go.
The field path in a condition is the hinge between this lesson and Lesson 6.
The risk engine writes risk values onto nodes; the rule engine reads them by path. The catalog of
evaluable fields is a real schema (docs/rule-fields.json, fields.gen.go),
and every field declares where it comes from:
| Field | Unit | Written by (source) |
|---|---|---|
node_risk_score | 0..1 | risk.NodeRiskScoreComputer (L6) |
admin_risk_usd | USD | risk.AdminRiskComputer (L6) |
exit_liquidity_json.*.total_exit_usd | USD | risk (L6, wildcard per-token) |
utilization_pct | % | enrichment.LendingRefresher (L5) |
balance_usd | USD | live — Redis cache + RPC (L4·L7) |
scalar/wildcard risk fields
are recomputed periodically (the catalog shows period_ns: 7200000000000 = 2 hours);
live fields like balance are evaluated on_event (e.g. balance_changed,
lending_dirty). So a rule's responsiveness is bounded by its field's cadence — a 2-hourly risk field can't
alert in seconds. Knowing a field's source + cadence tells you both its meaning and its latency.
Source: docs/rule-fields.json (path · kind · unit · cadence · rule_types · scopes · source). A lint (scripts/lint-schema-coverage.sh) keeps the schema endpoint in sync with the code.
Before evaluating, the engine resolves the rule's scope into a concrete list of node IDs
(pkg/rules/scope.go ScopeResolver.Resolve):
| Scope | Resolves to |
|---|---|
address / wallet | one node |
portfolio_holders | every holder of a portfolio's tokens |
portfolio_admins | every admin of a portfolio's contracts |
top_n | the N riskiest nodes by some field |
graph_id-scoped, never a full scan. "Resolve a scope" is "run a bounded,
anchored Cypher traversal." This is where those rules stop being abstract.
The Engine (engine.go) runs two things concurrently:
runHTTP — a CRUD + dry-run API: customers create/edit rules, and can test a rule against the current graph without firing (handleDryRun).runPeriodicEval → evalCycle — the heartbeat: on each cycle, for each rule, resolve scope → read the field for each node → check the condition → run the firing state machine.Two cadences again mirror the system's push-based philosophy (L1): periodic for the 2-hourly risk fields,
event-driven for live fields (a balance_changed or lending_dirty signal evaluates the
relevant rules immediately).
Here's the non-obvious depth. A rule does not fire an alert every time the condition is true —
that would spam a customer with thousands of identical alerts while a threshold stays breached. Instead, each
(rule, node) pair runs through a firing state machine (firing.go):
An emitted AlertEvent is published (publisher.go) and stored in
OpenSearch (opensearch.go, opensearchstore.go) — note this
is the system's one use of OpenSearch, separate from Memgraph/Redis; alerts are documents you search/filter,
not graph nodes. Downstream, the alert-processor (cmd/alert-processor,
pkg/alertprocessor) consumes and delivers them. The loop is finally closed:
field path in a condition (e.g. node_risk_score) is the link to which lesson's subsystem?admin_risk_usd (a periodic field, 2h cadence) can't alert within seconds. Why?portfolio_holders) means…dry-run endpoint lets a customer…Grounded in: pkg/rules/{engine,types,scope,firing,threshold,aggregate,balance,publisher,opensearch}.go,
docs/rule-fields.json (the field catalog: path · kind · cadence · source), cmd/{rule-engine,alert-processor}/main.go,
pkg/alertprocessor/. Verify against source — the code is the truth.