# Tuning risk policy

Kind

guide

For

developer · operator

Verified against

@veritio/core@0.4.7

A risk score is only evidence if the constants behind it can be recovered months later. Veritio stamps a `policyVersion` into every conclusion, so the score and the rules that produced it travel together. Tuning therefore means deriving a new, differently named policy — never editing the reference constants in place.

## Why the reference constants are frozen

[Section titled “Why the reference constants are frozen”](#why-the-reference-constants-are-frozen)

`DEFAULT_RISK_POLICY` carries `policyVersion: "veritio.reference.v1"`. Those numbers are a cross-language contract: the TypeScript, Python, and Go SDKs each hold a copy, and `spec/conformance/risk-*.json` pins all three to the same digits.

Mutating that object at runtime — `DEFAULT_RISK_POLICY.bands.high = 0.4`, or a patched `node_modules` — does not produce a stricter deployment. It produces two different scoring functions that both label their output `veritio.reference.v1`. A conclusion recorded before the edit and one recorded after become indistinguishable, an independent verifier recomputing the score from the published constants gets a different number, and the Python and Go SDKs no longer agree with the TypeScript one. Nothing throws. The damage is silent and unrecoverable, because the only field that could have told you the constants changed did not change.

`riskPolicy()` exists so the mutation is never necessary. It always builds a fresh object from the reference constants and never writes back into them; the frequency fixture below asserts `defaultPolicyStillRuleFree` after deriving two hand-tuned policies. With no arguments at all, `riskPolicy()` returns a copy equal to `DEFAULT_RISK_POLICY`.

## Temperature moves nine fields at once

[Section titled “Temperature moves nine fields at once”](#temperature-moves-nine-fields-at-once)

`riskPolicy({ temperature })` takes one number in `[0, 1]` and rescales exactly nine fields. Each has three pinned endpoints — lenient at `t = 0`, the reference value at `t = 0.5`, strict at `t = 1` — and interpolation is two-segment linear:

```text
lerp(L, R, S, t) = t <= 0.5 ? round4(L + (R - L) * (t / 0.5))
                            : round4(R + (S - R) * ((t - 0.5) / 0.5))
```

Field

Lenient `t=0`

Reference `t=0.5`

Strict `t=1`

`bands.low`

0.10

0.05

0.02

`bands.medium`

0.35

0.25

0.18

`bands.high`

0.60

0.50

0.40

`bands.critical`

0.85

0.75

0.65

`rollup.decayPerWindow`

0.30

0.50

0.70

`rollup.velocityNormalizer`

4.00

3.00

2.00

`magnitude.maxBoost`

0.25

0.40

0.60

`reversibilityFactor.irreversible`

1.15

1.30

1.60

`envCriticalityFactor.production`

0.90

1.00

1.20

Everything else keeps its reference value: the eight `operationBase` seeds, the other reversibility and environment factors, the magnitude `weights` and `k`, and `rollup.windowSeconds`. Temperature never adds or removes frequency rules.

Read the table as one coordinated move rather than nine independent ones. Raising temperature attacks a score from both directions at once. The band cut points come down, so the same number lands in a higher level. The multipliers and the boost ceiling go up, so the number itself is larger. An irreversible production operation is hit three times: `maxBoost`, `irreversibleFactor`, and `productionFactor` all rise while `bands.critical` falls. Scoring the reference episode’s worst step — an irreversible `permission` change in production with `fanOut: 4` — under four temperatures makes the compounding visible:

```text
t = 0    score 0.6317  level high      bands.critical 0.85
t = 0.5  score 0.8016  level critical  bands.critical 0.75
t = 0.7  score 0.9507  level critical  bands.critical 0.71
t = 1    score 1.0000  level critical  bands.critical 0.65
```

The rollup half moves with it. Higher `decayPerWindow` means momentum survives longer gaps, and a lower `velocityNormalizer` converts that momentum into a bigger `velocityScore`, so a strict policy is both quicker to accumulate a burst and quicker to call it critical.

Temperature validates fail-closed. It must be finite, inside `[0, 1]`, and a multiple of `0.01`; `riskPolicy({ temperature: 0.333 })` throws `temperature must be a multiple of 0.01`. That restriction is not fussiness — the derived version string is built from integer hundredths only, never from float formatting, so `0.7` becomes `veritio.reference.v1+temp0.70` identically in TypeScript, Python, and Go.

One nuance worth internalising: `t = 0.5` reproduces every reference constant byte-for-byte but still stamps `veritio.reference.v1+temp0.50`. The constants are identical; the label is not. Use plain `riskPolicy()` when you mean the reference policy, and reserve the suffixed version for deployments that deliberately chose a temperature.

## Overrides always need a policyVersion

[Section titled “Overrides always need a policyVersion”](#overrides-always-need-a-policyversion)

`overrides` deep-merge after temperature derivation, one level into `operationBase`, `reversibilityFactor`, `envCriticalityFactor`, `bands`, and `rollup`, two levels into `magnitude.weights` and `magnitude.k`. Unknown keys are ignored rather than merged, so a typo cannot smuggle a field into the policy shape.

The moment any override is supplied, `overrides.policyVersion` becomes mandatory:

```text
riskPolicy({ overrides: { bands: { low: 0.1 } } })
  → TypeError: overrides.policyVersion is required when overriding policy
    fields so a hand-tuned policy is never misrepresented by a temperature
    version
```

This is the same invariant as the frozen constants, enforced at the API. An auto-derived `+temp0.70` suffix describes exactly one thing — the nine interpolated fields — and would be a lie the moment a caller also moved `operationBase.delete`. Rather than let a hand-tuned policy inherit a name that no longer describes it, the builder refuses. Name your policy after your organisation and revision (`acme.burst-detection.v1`), and bump it whenever a constant moves.

A final structural check runs after the merge: every numeric leaf finite, bands strictly ascending, multiplier factors and `windowSeconds` / `velocityNormalizer` positive, `policyVersion` non-empty. `riskPolicy({ overrides: { policyVersion: 'x', bands: { low: 0.9 } } })` throws `policy bands must be strictly ascending` instead of returning a policy whose banding math cannot be honoured.

## What the rollup knobs actually control

[Section titled “What the rollup knobs actually control”](#what-the-rollup-knobs-actually-control)

An episode score is never a sum and never an average. It is `max(peak, velocityScore)` — the worst single step, or the worst decayed momentum burst, whichever is higher — so padding an episode with quiet steps cannot dilute one dangerous one.

```text
windows(gap) = floor(max(0, gapSeconds) / windowSeconds)   // whole windows only
decay(gap)   = decayPerWindow multiplied windows(gap) times
momentum[i]  = round4(score[i] + momentum[i-1] * decay(gap))
velocityScore = clamp01(round4(maxMomentum / velocityNormalizer))
```

The fixture below scores six pinned steps, re-derives the momentum series locally from the policy constants, and asserts the local derivation reproduces the library’s `velocityScore` exactly. It then re-rolls the same steps under six single-knob overrides. Its output is byte-compared in CI.

verified episode rollup output

```json
{
  "policy": {
    "policyVersion": "veritio.reference.v1",
    "bands": {
      "low": 0.05,
      "medium": 0.25,
      "high": 0.5,
      "critical": 0.75
    },
    "rollup": {
      "windowSeconds": 60,
      "decayPerWindow": 0.5,
      "velocityNormalizer": 3,
      "frequencyRuleCount": 0
    }
  },
  "steps": [
    {
      "stepId": "step_01_read_config",
      "occurredAt": "2026-08-09T10:00:00.000Z",
      "stepScore": 0.0393,
      "gapSeconds": null,
      "wholeWindows": 0,
      "decayApplied": 1,
      "carriedIn": 0,
      "momentum": 0.0393,
      "runningPeak": 0.0393,
      "runningVelocityScore": 0.0131,
      "runningScore": 0.0393,
      "runningLevel": "none"
    },
    {
      "stepId": "step_02_update_rows",
      "occurredAt": "2026-08-09T10:00:30.000Z",
      "stepScore": 0.366,
      "gapSeconds": 30,
      "wholeWindows": 0,
      "decayApplied": 1,
      "carriedIn": 0.0393,
      "momentum": 0.4053,
      "runningPeak": 0.366,
      "runningVelocityScore": 0.1351,
      "runningScore": 0.366,
      "runningLevel": "medium"
    },
    {
      "stepId": "step_03_config_change",
      "occurredAt": "2026-08-09T10:01:00.000Z",
      "stepScore": 0.4732,
      "gapSeconds": 30,
      "wholeWindows": 0,
      "decayApplied": 1,
      "carriedIn": 0.4053,
      "momentum": 0.8785,
      "runningPeak": 0.4732,
      "runningVelocityScore": 0.2928,
      "runningScore": 0.4732,
      "runningLevel": "medium"
    },
    {
      "stepId": "step_04_bulk_update",
      "occurredAt": "2026-08-09T10:03:00.000Z",
      "stepScore": 0.48,
      "gapSeconds": 120,
      "wholeWindows": 2,
      "decayApplied": 0.25,
      "carriedIn": 0.2196,
      "momentum": 0.6996,
      "runningPeak": 0.48,
      "runningVelocityScore": 0.2928,
      "runningScore": 0.48,
      "runningLevel": "medium"
    },
    {
      "stepId": "step_05_grant_admin",
      "occurredAt": "2026-08-09T10:03:20.000Z",
      "stepScore": 0.8016,
      "gapSeconds": 20,
      "wholeWindows": 0,
      "decayApplied": 1,
      "carriedIn": 0.6996,
      "momentum": 1.5012,
      "runningPeak": 0.8016,
      "runningVelocityScore": 0.5004,
      "runningScore": 0.8016,
      "runningLevel": "critical"
    },
    {
      "stepId": "step_06_read_audit",
      "occurredAt": "2026-08-09T10:13:00.000Z",
      "stepScore": 0.03,
      "gapSeconds": 580,
      "wholeWindows": 9,
      "decayApplied": 0.002,
      "carriedIn": 0.0029,
      "momentum": 0.0329,
      "runningPeak": 0.8016,
      "runningVelocityScore": 0.5004,
      "runningScore": 0.8016,
      "runningLevel": "critical"
    }
  ],
  "episode": {
    "score": 0.8016,
    "level": "critical",
    "peak": 0.8016,
    "velocityScore": 0.5004,
    "stepCount": 6,
    "policyVersion": "veritio.reference.v1"
  },
  "dominance": {
    "peak": 0.8016,
    "velocityScore": 0.5004,
    "decidedBy": "peak",
    "peakStepId": "step_05_grant_admin",
    "momentumPeakStepId": "step_05_grant_admin",
    "velocityFromMomentum": 0.5004
  },
  "orderIndependent": {
    "note": "rollupEpisodeRisk sorts by occurredAt on a non-mutating copy, so caller array order cannot move the episode score.",
    "reversedInputMatches": true
  },
  "knobEffects": [
    {
      "label": "windowSeconds 15 (four times as many decay windows)",
      "changed": {
        "windowSeconds": 15
      },
      "rollup": {
        "score": 0.8016,
        "level": "critical",
        "peak": 0.8016,
        "velocityScore": 0.3476,
        "stepCount": 6,
        "policyVersion": "docs.rollup.window15"
      }
    },
    {
      "label": "windowSeconds 600 (every gap lands inside one window)",
      "changed": {
        "windowSeconds": 600
      },
      "rollup": {
        "score": 0.8016,
        "level": "critical",
        "peak": 0.8016,
        "velocityScore": 0.73,
        "stepCount": 6,
        "policyVersion": "docs.rollup.window600"
      }
    },
    {
      "label": "decayPerWindow 1 (momentum never cools)",
      "changed": {
        "decayPerWindow": 1
      },
      "rollup": {
        "score": 0.8016,
        "level": "critical",
        "peak": 0.8016,
        "velocityScore": 0.73,
        "stepCount": 6,
        "policyVersion": "docs.rollup.decay1"
      }
    },
    {
      "label": "decayPerWindow 0 (any full window resets carry)",
      "changed": {
        "decayPerWindow": 0
      },
      "rollup": {
        "score": 0.8016,
        "level": "critical",
        "peak": 0.8016,
        "velocityScore": 0.4272,
        "stepCount": 6,
        "policyVersion": "docs.rollup.decay0"
      }
    },
    {
      "label": "velocityNormalizer 1.5 (burst out-ranks the peak)",
      "changed": {
        "velocityNormalizer": 1.5
      },
      "rollup": {
        "score": 1,
        "level": "critical",
        "peak": 0.8016,
        "velocityScore": 1,
        "stepCount": 6,
        "policyVersion": "docs.rollup.velocity1_5"
      }
    },
    {
      "label": "velocityNormalizer 6 (burst can never out-rank the peak)",
      "changed": {
        "velocityNormalizer": 6
      },
      "rollup": {
        "score": 0.8016,
        "level": "critical",
        "peak": 0.8016,
        "velocityScore": 0.2502,
        "stepCount": 6,
        "policyVersion": "docs.rollup.velocity6"
      }
    }
  ]
}
```

Three things in that output are worth reading closely.

`windowSeconds` decides how many whole windows a gap contains, and the floor is unforgiving: the 30-second gaps in this episode carry momentum in full under the 60-second reference window, while dropping to `windowSeconds: 15` turns them into two decay windows each and pulls `velocityScore` from `0.5004` to `0.3476`. Raising it to `600` puts every gap inside one window and lifts `velocityScore` to `0.73`.

`decayPerWindow` decides how much survives each whole window. At `1` momentum never cools (`velocityScore` `0.73`); at `0` any full window resets the carry (`0.4272`). The reference `0.5` sits between them, which is why the 580-second tail arrives with `decayApplied: 0.002`.

`velocityNormalizer` is the only thing converting raw momentum into a `0..1` score, so it alone decides whether a burst can out-rank the worst step. At `1.5` the burst saturates and the episode scores `1`; at `6` `velocityScore` falls to `0.2502` and the peak wins outright. In every one of these variants `peak` stays `0.8016` — the steps never changed.

Note also `orderIndependent.reversedInputMatches: true`. The rollup sorts by `occurredAt` on a non-mutating copy, so the order a caller happens to hold the steps in cannot move the published score.

## Adding a frequency rule

[Section titled “Adding a frequency rule”](#adding-a-frequency-rule)

Peak and velocity both react to how _severe_ steps are. Neither reacts to a pattern of individually harmless steps — four failed logins in two minutes score low individually and, spread across a couple of minutes, barely move momentum. `rollup.frequencyRules` is the knob for that shape.

Each rule is `{ actions, windowSeconds, threshold, boost }` and is evaluated over the time-sorted steps:

```text
1. qualify   step.action exact-matches one of rule.actions
             (a step with no `action` never qualifies)
2. window    slide an inclusive window over the qualifying timestamps;
             advance start while endMs - startMs > windowSeconds * 1000
             count = max window size observed
3. fire      count >= threshold, at most once per episode, contributing boost

frequencyScore = clamp01(round4(sum of fired boosts))
score          = clamp01(round4(max(peak, velocityScore, frequencyScore)))
```

The fixture configures two rules on a hand-named policy — a failed-login burst that fires, and an export-burst rule left deliberately unmet — and rolls the same six steps with and without them.

verified frequency-rule output

```json
{
  "episode": [
    {
      "occurredAt": "2026-08-09T10:00:00.000Z",
      "score": 0.12,
      "action": "auth.login.failed"
    },
    {
      "occurredAt": "2026-08-09T10:00:40.000Z",
      "score": 0.12,
      "action": "auth.login.failed"
    },
    {
      "occurredAt": "2026-08-09T10:01:30.000Z",
      "score": 0.12,
      "action": "auth.login.failed"
    },
    {
      "occurredAt": "2026-08-09T10:02:10.000Z",
      "score": 0.12,
      "action": "auth.login.failed"
    },
    {
      "occurredAt": "2026-08-09T10:05:00.000Z",
      "score": 0.1
    },
    {
      "occurredAt": "2026-08-09T10:12:00.000Z",
      "score": 0.2,
      "action": "data.export.completed"
    }
  ],
  "configuredRules": [
    {
      "actions": [
        "auth.login.failed"
      ],
      "windowSeconds": 300,
      "threshold": 4,
      "boost": 0.55
    },
    {
      "actions": [
        "data.export.started",
        "data.export.completed"
      ],
      "windowSeconds": 600,
      "threshold": 3,
      "boost": 0.4
    }
  ],
  "withoutFrequencyRules": {
    "policyVersion": "veritio.reference.v1",
    "configuredRuleCount": 0,
    "score": 0.2,
    "level": "low",
    "peak": 0.2,
    "velocityScore": 0.16,
    "stepCount": 6,
    "emitsFrequencyFields": false
  },
  "withFrequencyRules": {
    "policyVersion": "acme.burst-detection.v1",
    "configuredRuleCount": 2,
    "score": 0.55,
    "level": "high",
    "peak": 0.2,
    "velocityScore": 0.16,
    "stepCount": 6,
    "emitsFrequencyFields": true,
    "frequencyScore": 0.55,
    "frequencyMatches": [
      {
        "actions": [
          "auth.login.failed"
        ],
        "windowSeconds": 300,
        "threshold": 4,
        "count": 4,
        "fired": true,
        "boost": 0.55
      },
      {
        "actions": [
          "data.export.started",
          "data.export.completed"
        ],
        "windowSeconds": 600,
        "threshold": 3,
        "count": 1,
        "fired": false,
        "boost": 0
      }
    ]
  },
  "ruleEffect": {
    "scoreBefore": 0.2,
    "scoreAfter": 0.55,
    "levelBefore": "low",
    "levelAfter": "high",
    "peakUnchanged": true,
    "velocityUnchanged": true,
    "firedBoosts": [
      {
        "actions": [
          "auth.login.failed"
        ],
        "count": 4,
        "boost": 0.55
      }
    ],
    "unfiredRules": [
      {
        "actions": [
          "data.export.started",
          "data.export.completed"
        ],
        "count": 1,
        "threshold": 3
      }
    ]
  },
  "replacesWholesale": {
    "v1Actions": [
      [
        "auth.login.failed"
      ],
      [
        "data.export.started",
        "data.export.completed"
      ]
    ],
    "v2Actions": [
      [
        "data.export.started",
        "data.export.completed"
      ]
    ],
    "v2RuleCount": 1,
    "v2DroppedLoginRule": true,
    "omittedKeyKeepsBaseRules": 0,
    "omittedKeyWindowSeconds": 30,
    "defaultPolicyStillRuleFree": true
  }
}
```

The burst rule moves the episode from `0.2` / `low` to `0.55` / `high`, while `peakUnchanged` and `velocityUnchanged` are both `true`. That is the shape of the invariant: `frequencyScore` joins a `max()`, so a rule can only ever raise an episode score. There is no rule that lowers one, and no configuration in which adding a rule makes an episode look safer.

Three boundaries are easy to get wrong.

**Fires once, not once per occurrence.** The login rule reports `count: 4` and contributes its `boost` exactly once. Ten failed logins and four failed logins produce the same `0.55`, because `boost` is a fixed contribution, not a per-event increment. Escalation past the threshold is a job for a second rule with a higher threshold and a higher boost, not for repetition.

**Co-occurrence, not total count.** The window is inclusive and slides over qualifying timestamps only. The conformance case _five failures spread past the window never co-occur_ configures `threshold: 5` over a 300-second window and feeds five failures two minutes apart; the maximum window count is `3`, the rule does not fire, and the episode stays at `0.1` / `low`. A rule counts steps that happened _together_, and lengthening an attack defeats it — which is exactly the trade you are choosing when you pick `windowSeconds`.

**A step without an `action` is invisible.** The fixture’s un-actioned step is still scored for `peak` and `velocity` but can never qualify for any rule. `action` is optional in `EpisodeRiskStep` precisely so pre-frequency callers keep working, and the conformance suite pins _steps without an action never match a rule_. If your read model builds episode steps without carrying the event action through, every rule you configure will silently report `count: 0`.

Unfired rules are reported, not dropped. `frequencyMatches` carries one entry per configured rule with its observed `count`, so “the export rule saw one qualifying step against a threshold of three” is recorded evidence rather than silence — which is what lets you tell a rule that did not fire apart from a rule that was never deployed.

### Rule-free rollups stay byte-identical

[Section titled “Rule-free rollups stay byte-identical”](#rule-free-rollups-stay-byte-identical)

When a policy configures zero rules, `rollupEpisodeRisk` takes literally the pre-frequency code path and emits no `frequencyScore` and no `frequencyMatches` members at all. The fixture reports this as `emitsFrequencyFields: false` for the reference policy and `true` for the tuned one; the conformance case _empty rule set matches the plain rollup byte-for-byte_ pins it upstream.

This matters because rollups feed canonical JSON and hashes. Adding two always-present fields — even `0` and `[]` — would have changed the bytes of every existing reference rollup and broken every anchored digest. Optionality is the compatibility mechanism, so treat `frequencyScore` and `frequencyMatches` as genuinely absent in consumers rather than defaulting them.

### Rules replace wholesale

[Section titled “Rules replace wholesale”](#rules-replace-wholesale)

`overrides.rollup.frequencyRules` is the one field that never merges. Supplying a list replaces the base list entirely; omitting the key keeps whatever the base carried. The fixture proves both: `v2` supplies one rule and ends with exactly `v2RuleCount: 1` (a merge would have left two), and a policy overriding only `windowSeconds` keeps `omittedKeyKeepsBaseRules: 0`.

Wholesale replacement is deliberate. Rules that accumulated across derivations would mean a policy’s behaviour depended on the order its ancestors were built in, and no reviewer reading `acme.burst-detection.v2` could tell which rules were actually live. A tuned policy contains exactly the rules its author wrote.

## Rule actions and factor values are policy tokens

[Section titled “Rule actions and factor values are policy tokens”](#rule-actions-and-factor-values-are-policy-tokens)

One privacy boundary is easy to miss when tuning. `redactMetadata` is key-name based — it matches key names like `password`, `token`, `email` — so it inspects names, never values. `RiskFactor.value` sits under the key `value` and is never rewritten no matter what it holds.

The assertion path skips redaction entirely. `createSecurityRiskAssertion` copies `factors` through verbatim, and `hashAssertionRecord` hashes the canonical JSON of the record directly. There is no minimization step between a factor value and a permanent digest.

So both of the strings a tuner controls must be stable policy tokens:

-   `RiskFactor.value` may only be a signal enum (`permission`, `irreversible`, `production`) or a normalized number. Never a file path, a prompt fragment, a user identifier, a message, or a description of what happened.
-   A frequency rule’s `actions` entries must be event action names from your own vocabulary (`auth.login.failed`, `data.export.completed`). They are matched by exact string equality, they are echoed back in `frequencyMatches`, and anything embedded in them travels into read models and hashed conclusions.

Neither is a place for anything user-derived. A tenant identifier or an email address glued into an action string is unredactable, permanently hashed, and — because rule matching is exact — usually broken as a rule too.

## What a tuned score proves

[Section titled “What a tuned score proves”](#what-a-tuned-score-proves)

A conclusion under `acme.burst-detection.v1` proves that these steps, under those named constants, produce that number, and that anyone holding the policy can recompute it. It does not prove the policy is well calibrated for your risk, that the burst was hostile rather than a broken retry loop, or that the host recorded every step of the episode. Risk scores are triage evidence: they order a queue and support a compliance record, and they do not by themselves establish that any legal or regulatory obligation was met.

Keep `policyVersion` moving whenever a constant moves. It is the only thing separating a deliberate retune from a silent one.

Continue with [Risk scoring](/docs/concepts/risk-scoring/) for the per-step formula behind these numbers, [Risk policy reference](/docs/reference/risk-policy/) for the full constant tables, or [Security risk assertions](/docs/guides/security-risk-assertions/) to record a conclusion as hashed evidence.

[Edit page](https://github.com/getveritio/veritio-website/edit/main/src/content/docs/docs/guides/risk-policy-tuning.mdx)

Last updated: Aug 23, 2026

[Previous  
DSAR fulfillment](/docs/guides/dsar-fulfillment/)[Next  
Security risk assertions](/docs/guides/security-risk-assertions/)

Veritio provides evidence support, not legal advice or automatic compliance.

This site uses cookieless, anonymous analytics (Umami) by default. With your consent, we also enable Google Analytics, which sets cookies and sends usage data to Google. [Privacy Policy](/legal/privacy/)
