node_risk_score opened all the way — including how much of it is real today. ~13 min.
L13 gave you node_risk_score as "a noisy-OR with two stubs" and moved on. This lesson opens the file. Three
payoffs: the exact governance scoring (gorgeously EVM-flavoured), a sharp combinator insight that contrasts with the
at_risk math, and an honest reckoning with how much of this field is actually computed today — the kind of thing
you must know before you ever build a rule on it.
governance_risk
encodes exactly that intuition as a number — and it's the one term in node_risk_score that's fully alive.
node_risk_score combines four independent risk components as a noisy-OR — the probability that at
least one goes wrong (L13's combinator for compounding independent probabilities):
Each term is a probability in [0,1]; the product of their complements is "nothing goes wrong," so 1 minus that is "something does." Rounded to 4 decimals. Simple — until you look at what the four terms actually are right now.
audit = 0.5 and firewall = 0.5 are hardcoded constants (L13 flagged these), and
longevityRisk() also just returns 0.5 — "RT graph doesn't have creation_timestamp; TODO." So only
gov is computed. Substitute: score = 1 − (1−gov)(0.5)(0.5)(0.5) = 1 − 0.125·(1−gov). With gov in its
real range [0.1, 0.8], every node scores between 0.887 and 0.975 — a dynamic range of ~0.09, riding high near 1.0.
The flagship "composite risk" field is, today, governance nudging a number that's pinned near the ceiling by three
placeholders. Know this before you threshold a rule on it. (node_risk_score.go lines 130–133, 390–394.)
Now the live term. For a node with admins, governanceRisk scores each admin and keeps the minimum:
bestScore := 1.0 for adminIdx := range adminIdxs { score := scoreOneAdmin(admin) // multisig / timelock / EOA / … if score < bestScore { bestScore = score } // keep the SAFEST admin } return bestScore
admin_risk (L13) took the MAX over attack channels — the worst path, because an attacker picks the
easiest one. governance_risk takes the MIN over admins — the safest controller, because it asks a
different question: "how well-governed is this node?", and a node is considered as well-governed as its best-structured
controller. Same engine, opposite direction. Worst-case for "can it be attacked," best-case for "is it well-run."
(Worth a critical eye: is min-over-admins the right call when an attacker would target the weakest admin? It's
parity-locked to the Python model — a legitimate design question to raise, not a bug to fix.)
| Admin shape | governance score | Why |
|---|---|---|
| Timelock, delay ≥ 48h | 0.15 | ample window for users to react to a malicious upgrade |
| Timelock, delay ≥ 1h / < 1h | 0.25 / 0.30 | shorter delay, weaker mitigation |
| Multisig, k≥3 of n≥5 | 0.20 | strong threshold + signer diversity |
| Multisig, k≥2 of n≥3 / weaker | 0.30 / 0.40 | graded down by threshold and signer count |
| smart-contract-wallet | 0.50 | structured but unclassified |
| unknown admin type | 0.60 | conservative default |
| EOA admin | 0.80 | a single key — the most dangerous controller |
And the no-admin branch: if a node has no admin edges and is immutable (is_proxy == false),
governance_risk = 0.1 — you can't govern-attack what can't be changed; otherwise 0.5 (unknown). EOA-type nodes are
skipped entirely (governance = 0 — "they ARE the key," from L13). isTimelock sniffs four signals: subcategory,
a non-zero delay attribute, or "timelock" in the verified contract name / Blockscout nametag.
ComputeForTokenOnGraph runs under the TokenScheduler against each focus token's partial graph (admin/oracle/spine neighborhood) — the exact set the rules engine reads against. This dropped peak memory from 3.07 GB → ~150 MB (FORTA-2676). The scoring set is the rule scope, by construction.filterChangedScores reads the stored triple and skips any node whose rounded (gov, lon, composite) is unchanged (within scoreEpsilon = 5e-5, half the rounding quantum). governance only moves on admin/edge changes; longevity drifts sub-quantum — so most recomputes are no-op writes, now dropped before they hit the stream (L9/L31).node_risk_score "max across 6 channels: governance, longevity,
holdings, admin, oracle, lending." The code is a 4-term noisy-OR (gov · lon · audit · firewall), three of them
stubbed. The prose and the code disagree about the operator (max vs noisy-OR), the terms, and the count. As in
L13: when the doc and the code conflict, the code is the truth — and here the gap is wide enough to mislead a rule author
badly. (Compare line 82's rule:field doc with line 133's formula.)
node_risk_score exactly, explain that it's governance-only-live today (compressed to [0.89, 0.975]),
contrast min-over-admins with max-over-channels, read the multisig/timelock/immutability tiers off real EVM structure, and
explain the partial-graph + changed-only-write machinery that makes computing it affordable. That's the field, end to end —
including the parts a field doc would never tell you.
node_risk_score = 1 − (1−gov)(1−lon)(1−audit)(1−firewall). What does this noisy-OR express?node_risk_score?governance_risk takes the MIN over a node's admins, while admin_risk (L13) takes the MAX over channels. Why the opposite directions?is_proxy == false. Its governance_risk is…filterChangedScores read the stored triple and skip unchanged nodes before writing?Grounded in: pkg/risk/node_risk_score.go (computeScoresForGraph noisy-OR composite + audit/firewall=0.5, longevityRisk→0.5 stub, governanceRisk min-over-admins + multisig/timelock/EOA tiers + immutable 0.1 branch, isTimelock/timelockScore, eoaNodeTypes gov=0, ComputeForTokenOnGraph partial-graph FORTA-2676, filterChangedScores/readCurrentScores/scoreEpsilon changed-only writes; the line-82 doc vs line-133 code gap). Verify against source — the code is the truth.