---
title: "Behaviour norms"
description: "Learn what normal looks like for the app (traffic, errors, latency, CPU, loop delay, memory) by hour of week, deterministically and with no model, and emit an anomaly signal when it leaves the range."
section: "interfaces"
status: "Building for 0.4 · ships as a beta API"
spec: "norms"
canonical: "https://mainsdk.dev/docs/interfaces/norms"
updated: "2026-10-28"
---

# Behaviour norms

> Learn what normal looks like for the app (traffic, errors, latency, CPU, loop delay, memory) by hour of week, deterministically and with no model, and emit an anomaly signal when it leaves the range.

Status: Building for 0.4 · ships as a beta API · Canonical: https://mainsdk.dev/docs/interfaces/norms · Updated: 2026-10-28

`norms` lets your app learn what normal looks like for itself, by hour of week. When a metric leaves that range and stays out, it emits one `anomaly.detected` signal.

## At a glance

- Status: Building for 0.4 · ships as a beta API
- Phase: 1 (Core, public beta)
- Milestone: 0.4 Hardening
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/norms.md
- Category: Observability and analytics

Calls:

- `norms.get(metric, { at? })`
- `norms.list({ cursor?, limit? })`
- `norms.delete(metric, { reason })`
- `new Norms(opts)`
- `parseNorms(section?)`
- `normsIssues(section)`

In main.yaml:

```yaml
norms:
  season: hour-of-week
  tz: Asia/Kolkata
  learn: 7d
  min-samples: 20                      # observations a bucket needs before it speaks
  persist: 5m                          # how often the model is stored, 1s to 24h
```

Guarantees, whatever the driver:

- The same input gives the same model and the same signals
- No dependency, no model and no service
- Holds numbers only, never a person


## Drivers

Pick one in main.yaml with `norms.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Built in | `builtin` | yes |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-NORMS-0001` | check | A `norms` value is invalid: `every` outside 1m to 15m, an unknown `season`, metric or time zone, `learn` over 28d, `min-samples` under 10, `z` under 2, or `margin` outside 0% to 200% |
| `MAIN-NORMS-0002` | runtime |  |
| `MAIN-NORMS-0003` | runtime |  |
| `MAIN-NORMS-0004` | runtime |  |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| EWMA and decayed histogram model | 1 | 0.4 | `norms#model` |
| Seasonal buckets | 1 | 0.4 | `norms#season` |
| Auth failures, access denials and refused egress as inputs | 1 | 0.4 | `norms#inputs-security` |
| Anomaly signals | 1 | 0.4 | `norms#anomaly` |
| Model persisted in the runtime store | 1 | 0.4 | `norms#persist` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

```yaml title="main.yaml"
norms:
  season: hour-of-week
  tz: Asia/Kolkata
  learn: 7d
```

```ts title="app/example.ts"
import { norms } from "mainsdk";

const n = await norms.get("requests");
console.log(n.bucket, n.p50, n.p99, n.learning);   // "mon-10" 118 240 true
```

Adding a `norms` section turns learning on. Without it nothing runs and `norms.get` throws `MAIN-CONFIG-0003`.

## Configuration

```yaml title="main.yaml"
norms:
  every: 1m                  # one value per metric per window; 1m to 15m
  season: hour-of-week       # hour-of-week (168 buckets) | hour-of-day (24) | none (1)
  tz: Asia/Kolkata           # time zone for buckets; default UTC
  learn: 7d                  # anomalies are info and learning: true until the model is this old; 0 to 28d
  min-samples: 30            # values a bucket needs before it is used; else hour-of-day, else global
  half-life: 14d             # how fast old behaviour fades, counted in visits to a bucket
  metrics: [cpu, loop, memory, requests, errors, latency]
  sensitivity: { z: 4, quantile: p99, margin: 20%, hold: 3 }   # hold: consecutive windows out of range
  persist: 5m
```

| Key | What it does |
| --- | --- |
| `every` | The window. Windows are aligned to the clock, so instances agree on buckets. |
| `season` | How values are bucketed. Scoring uses the finest bucket with `min-samples` values. |
| `learn` | While the model is younger than this, anomalies are `info` with `learning: true`. |
| `half-life` | Old behaviour fades by half after this many visits to a bucket. |
| `sensitivity` | A value is out of range high when it is above `quantile * (1 + margin)` and above `mean + z * sd`. Only `requests` is also checked low. `hold` windows in a row raise the signal. |
| `persist` | How often the model is written to the runtime store, so a restart or another instance keeps what was learned. |

| Metric | One value per window |
| --- | --- |
| `cpu`, `loop`, `memory` | Mean CPU share, highest loop delay p99 and last memory share from `resources`. Skipped on serverless. |
| `requests` | Requests per minute from the request counters. |
| `errors`, `latency` | 5xx share and p95 in ms; skipped under 20 requests in the window. |
| `auth-failures` | Refused sign-ins per minute, counted from `auth.failed` security events when the app has a `securityEvents` feed; skipped with `MAIN-NORMS-0002` without one. |
| `denials`, `egress-refused` | Access denials per minute from `mainsdk:access.decision` and enforced refusals per minute from `mainsdk:network.decision`. Counts only: no user, tenant or address is kept. |

## Calls

| Call | What it does |
| --- | --- |
| `norms.get(metric, { at? })` | The bucket for now, or for a time: count, mean, sd, p50, p95, p99, `learning` and the last value. |
| `norms.list({ cursor?, limit? })` | One view per configured metric, for now. |
| `norms.delete(metric, { reason })` | Forget one metric's model, or `"all"`. Needs `norms.manage` and is audited. |

## Signals

- `anomaly.detected` (subject `metric:<name>`): `value`, `expected` (`mean`, `sd`, `p50`, `p99`), `bucket`, `score`, `direction` (`high` or `low`), `learning`. Severity is `high` from a score of 8, `medium` from 6, else `low`, and `info` while learning. It is sent again each minute while the anomaly holds.
- `anomaly.cleared` after `hold` windows back in range.
- `pressure.contention` when loop delay is high while your own CPU is at or under its median: the instance is waiting on something else on the machine.

## Helpers for wiring and tests

| Call | What it does |
| --- | --- |
| `new Norms(opts)` | The learner of one app instance, for tests and custom wiring. |
| `parseNorms(section?)` | Read the `norms` section of `main.yaml` into settings. |
| `normsIssues(section)` | List what is wrong in the `norms` section, without throwing. |

## What you get

- A learner that is plain arithmetic: a decayed mean and variance and a decayed 48-bin histogram per hour of the week.
- A value out of range raises one signal with the value, the expected range and a score, and a playbook, a person or the advisor can then act.
- It is free.
- The same input gives the same model and the same signals.
- No dependency, no model and no service.
- Holds numbers only, never a person, a tenant or an address.
- Reads counters the SDK already keeps once a window, off the request path. A store that does not answer leaves the model in memory.

## Errors and checks

- `MAIN-NORMS-0001`: a `norms` value is invalid (`mainsdk check`).
- `MAIN-NORMS-0002`: a metric's source is not available; it is skipped, logged once.
- `MAIN-NORMS-0003`: the stored model was another version or corrupt; it was discarded and learning restarted.
- `MAIN-NORMS-0004`: `norms.delete` refused, the caller lacks `norms.manage` (403).

Each code's cause and fix is in [check codes](https://mainsdk.dev/docs/reference/check-codes).

## Swapping drivers

`norms` has no drivers: the learner is built in, and `persist` sets how often the model is written to the runtime store.

## Testing

`createSim` in `@mainsdk/testing` plays hours of traffic and CPU curves against an app in a second, on a fake clock, so you can assert what norms noticed. See [testing](https://mainsdk.dev/docs/interfaces/testing).

```bash
npx mainsdk norms          # each metric's bucket, count, p50, p99 and learning state
npx mainsdk info --json    # the same under "norms"
```

## Next

- [Signals](https://mainsdk.dev/docs/interfaces/signals): where `anomaly.detected` goes.
- [Playbooks](https://mainsdk.dev/docs/interfaces/playbooks): act on an anomaly.
- [Testing kit](https://mainsdk.dev/docs/interfaces/testing): `createSim` and the fake clock.
- [Check codes](https://mainsdk.dev/docs/reference/check-codes): each code's cause and fix.
