# Checks

Source: https://mostlyright.md/docs/reference/checks/

Declare checks in the recipe and inspect their results after the worker runs them against the finished table.

Read the results of a run with:

```sh
mr-data checks RUN_ID --json
```

## How a check runs

The worker builds the table, writes it, then re-opens the candidate and evaluates every declared check in one bounded pass. Each check produces one result, pass or fail. A failing check never short-circuits the rest.

One result has two spellings, and which one you read depends on where you read it.

`mr-data checks` prints the worker's own wire shape, off the run's terminal event:

```json
{
  "check_id": "GRAIN_UNIQUE",
  "passed": true,
  "detail": "key (station, observed_at) is distinct across all 6132 rows, as expected"
}
```

| Field | Type | Meaning |
| --- | --- | --- |
| `check_id` | `^[A-Z][A-Z0-9_]{0,127}$` | the recipe's own `check_id`, upper-cased |
| `passed` | boolean | whether the assertion held |
| `detail` | string or `null` | what the worker measured, in words |
| `scope` | string | which relation this check covered, added only when the run's receipt records one |

The run record carries `check_outcome`, the vocabulary every reader of the table sees:

```json
{
  "name": "GRAIN_UNIQUE",
  "status": "passed",
  "reason": "key (station, observed_at) is distinct across all 6132 rows, as expected"
}
```

| Field | Type | Required | Meaning |
| --- | --- | --- | --- |
| `name` | `^[A-Z][A-Z0-9_]{0,127}$` | yes | the recipe's own `check_id`, upper-cased |
| `status` | `passed`, `failed`, `not_evaluated` | yes | what happened |
| `reason` | string, ≤1024 chars | no | the detail the worker reported |

The run record has no `passed`, and the wire has no `status`. Studio derives `status` when it writes the record, from the worker's own `status` where the worker states one, and otherwise from `passed`. A `detail` opening `not_evaluated: ` becomes `not_evaluated` rather than `failed`.

The run reports the registered id upper-cased. A recipe writes `check_id` as a lowercase identifier and the run reports `GRAIN_UNIQUE`. Upper-casing is injective over the registered alphabet, so two distinct registered ids never collide into one reported identity.

## Enforcement

Every check states `enforcement`, and there is no default.

| `enforcement` | A failure means |
| --- | --- |
| `required` | the run ends `failed` with `failure_code: "CHECK_FAILED"`, `failed_stage: "check"`, and `failure_detail` of `CHECK_ID: <the check's own detail>` |
| `advisory` | the result goes on the receipt and the run continues |

## The five kinds

A recipe declares at most 128 checks. The schema puts every optional bound on every kind, so which bounds a kind reads is a run-time rule rather than a registration one:

| `kind` | `columns` | Also required | Refused if you also state |
| --- | --- | --- | --- |
| `key_uniqueness` | 1 to 16, or none to fall back to `table.grain` | none | `max_null_ppm`, `min_value`, `max_value`, `min_rows`, `max_rows`, `max_gap_seconds` |
| `null_ceiling` | exactly one | `max_null_ppm` | every other bound |
| `value_range` | exactly one | both `min_value` and `max_value` | every other bound |
| `continuity` | exactly one | `max_gap_seconds` | every other bound |
| `row_expectation` | none. Registration refuses a named column | at least one of `min_rows`, `max_rows` | every other bound |

Registration refuses a bound the check kind does not read instead of silently dropping it. Writing `max_rows: 100` beside a null ceiling would otherwise leave a run reporting "1 of 1 declared checks passed" over a bound nothing evaluated. Move the bound to a check of the kind that reads it.

`description` is prose and belongs to no kind. Every branch drops it alike, and it never appears in a refusal.

### `key_uniqueness`

Asserts the tuple of the named columns is distinct across every stored row.

`columns` names 1 to 16 columns. Naming 17 or more returns `DECLARED_CHECK_INVALID`. Omitting `columns`, or writing `[]`, falls back to `table.grain`, the one default the translation synthesizes. The worker refuses a recipe with no grain to fall back to, or one whose grain names more than 16 columns. `table.grain` allows up to 32, and a uniqueness check may not state a key that wide.

```json
{
  "check_id": "grain_unique",
  "kind": "key_uniqueness",
  "enforcement": "required",
  "columns": ["station", "observed_at"],
  "description": "One row per station and observation time."
}
```

Pass detail: `key (station, observed_at) is distinct across all 6132 rows, as expected`

Fail detail: `key (station, observed_at) repeats on 4 of 6132 rows, first at ('NYC', '2026-03-01T00:51:00Z'), where 0 repeats were expected`

### `null_ceiling`

Asserts the named column's share of null rows sits at or below `max_null_ppm`, in parts per million of the table's rows. It is a ceiling rather than a zero-null rule: `max_null_ppm: 20000` allows two per cent.

Exactly one column. `max_null_ppm` is an integer between 0 and 1,000,000. A fraction has no spelling here, because the document carries no fractional number.

```json
{
  "check_id": "temperature_present",
  "kind": "null_ceiling",
  "enforcement": "required",
  "columns": ["air_temp_c"],
  "max_null_ppm": 20000
}
```

Pass detail: `column 'air_temp_c' is 3104 ppm null (19 of 6132 rows), at or below the declared ceiling of 20000 ppm`

Fail detail: the same sentence with `above` in place of `at or below`.

### `value_range`

Asserts every non-null value of the named column lies inside the inclusive interval `[min_value, max_value]`.

Exactly one column, and both bounds. The worker refuses a one-sided range instead of creating an unspecified open bound.

Bounds are decimal strings matching `^-?(?:0|[1-9][0-9]{0,38})(?:\.[0-9]{1,18})?$`. The comparison is exact and never goes through a float, so a row sitting on the declared minimum is inside it.

```json
{
  "check_id": "humidity_in_range",
  "kind": "value_range",
  "enforcement": "required",
  "columns": ["relative_humidity_pct"],
  "min_value": "0",
  "max_value": "100"
}
```

Pass detail: `column 'relative_humidity_pct' lies within [0, 100] across 6113 non-null values of 6132 rows`

Fail detail: `column 'relative_humidity_pct' holds '104.5', above the declared maximum of 100`

A column whose values cannot be ordered against the declared bounds fails with `whose type is not orderable against the declared range [0, 100]`.

The registered bound grammar cannot spell a date, so a range over a `date` or `timestamp` column has no registered spelling.

### `continuity`

Asserts no two consecutive distinct values of the named column are further apart than `max_gap_seconds`. Repeated values collapse before the comparison: two rows carrying one timestamp are neither a gap nor a stride.

Exactly one column, and `max_gap_seconds`.

The name says seconds and the measurement follows the column. The worker infers the ordering domain from the column's own stored values. Over a temporal column it measures the gap in seconds, the type the member carries. Over a numeric column it measures in the column's own units, and the member's name still says seconds. Every detail names the domain it measured in.

```json
{
  "check_id": "hourly_continuity",
  "kind": "continuity",
  "enforcement": "advisory",
  "columns": ["observed_at"],
  "max_gap_seconds": 7200,
  "description": "Two consecutive observations more than two hours apart is a reporting outage."
}
```

Pass detail: `column 'observed_at' never gaps by more than 3600 across 6132 distinct values, the widest between '2026-03-01T00:51:00Z' and '2026-03-01T01:51:00Z', at or below the declared ceiling of 7200, measured in seconds between timestamps`

Fail detail: `column 'observed_at' gaps from '…' to '…' by 18000, above the declared ceiling of 7200, measured in seconds between timestamps`

A column with no non-null value, or with one distinct value, passes and says so. No consecutive pair exists for a gap to sit between.

### `row_expectation`

Asserts the stored row count sits inside the declared bounds. Either bound may stand alone. Registration refuses a check with neither. The count comes from the Parquet metadata, so nothing scans rows for it.

Registration refuses a named column instead of silently dropping it. `min_rows` is a non-negative integer and `max_rows` a positive one. Both stop at 10,000,000 rows, the most one run may write. A floor over that ceiling returns a refusal that names the reason.

```json
{
  "check_id": "enough_rows",
  "kind": "row_expectation",
  "enforcement": "required",
  "min_rows": 24,
  "max_rows": 1000000
}
```

Pass detail: `row count 6132 satisfies the declared bounds [24, 1000000]`

Fail detail: `row count 11 is below the declared minimum of 24`

## When a check is never evaluated

The pass over the stored rows has a byte budget of 64 GiB and a time budget of 900 seconds. Over either, every row-reading check reports `not_evaluated` with `passed: false` and the reason in its `detail`, always behind the literal prefix `not_evaluated: `. The prefix is how the third state survives a wire that carries only a boolean.

```json
{
  "check_id": "HUMIDITY_IN_RANGE",
  "passed": false,
  "detail": "not_evaluated: CHECK_SCAN_BUDGET_EXCEEDED: estimated scan of 91234567890 bytes exceeds the 68719476736-byte ceiling"
}
```

The run does not fail for it. A deployment budget is not evidence about the data, so an unevaluated check remains unevaluated rather than passing. `row_expectation` still reports normally because it reads metadata rather than rows.

A `key_uniqueness` check over the table's clustered key costs nothing. The key is distinct across the table when every part is internally distinct and no two parts' recorded ranges overlap. The manifest knows both already. On an unclustered key it is a bounded scan instead.

## Codes a run reports

Every one of these codes settles the run as `failed`. The run validates the check dialect at the `acquire` stage, before the first byte arrives. A malformed check costs a queued run rather than a whole build.

| Code | `failed_stage` | When |
| --- | --- | --- |
| `DECLARED_CHECKS_INVALID` | `acquire` | the check list itself is wrong: more than 128 checks, or not a list |
| `DECLARED_CHECK_INVALID` | `acquire` | one check is wrong: an unknown kind, a missing bound its kind requires, a bound its kind does not read, the wrong number of columns, an unsatisfiable row bound |
| `DECLARED_CHECK_DUPLICATE` | `acquire` | two checks claim one id |
| `DECLARED_CHECK_COLUMN_UNKNOWN` | `acquire` | a check names a column the specification does not declare |
| `DECLARED_CHECK_UNREADABLE` | `check` | nothing could open or read the finished table, or it does not carry a declared column |
| `CHECK_FAILED` | `check` | a `required` check failed, and `failure_detail` is `CHECK_ID: <detail>` |

Sentences worth recognising:

```
DECLARED_CHECK_INVALID: check TEMPERATURE_PRESENT reads exactly one column and names 3
DECLARED_CHECK_INVALID: check GRAIN_UNIQUE is a key_uniqueness check and states max_rows,
  which a check of that kind does not read; a bound that is never evaluated would be
  reported as satisfied, so state it on a check whose kind reads it
DECLARED_CHECK_INVALID: check HUMIDITY_IN_RANGE bounds the values of column relh and
  states no max_value; an unstated bound is not an open one
```

Two controls that are not declared checks also report `CHECK_FAILED`. One is `_replay_equivalence`, when the re-derived candidate does not match what was written. The others are the required controls whose detail begins `RECIPE_DRIFT:` or `SHAPE_DRIFT:`. [Run states and errors](/docs/reference/run-states-and-errors/) covers those.

## Registration checks the columns, not the shapes

The registration walk refuses a check that names a column the table does not declare, and two checks sharing an id. Both answer `422 RECIPE_DOCUMENT_INCOHERENT` with the pointer `/checks/N/columns/M`. It does not apply the per-kind field rules, because the schema accepts every bound on every kind. Those refusals arrive at the acquire stage of the first run. Get the shape right before starting one. [Recipe document](/docs/reference/recipe/#checks) has the member-by-member grammar.
