# Data checks

A pipeline that runs at 5 am and writes the report someone opens at 8 should not be trusted
blindly. A `check` step looks at the data before it is delivered: fixed expectations catch the
obvious, an optional agent review catches the odd, and a failure stops the run before yesterday's
good file is overwritten.

```yaml
name: daily-sales
llm:
  default: { provider: anthropic, model: claude-opus-5-5 }

steps:
  - id: sales
    use: postgres.read
    with: { dsn: "${DATABASE_URL}", query: "select * from sales where day = current_date - 1" }

  - id: verify
    check: sales
    expect:
      rows: { min: 1000, max: 50000 }
      not_null: [order_id, total]
      unique: order_id
      accepted: { region: [north, south, east, west] }
      range: { total: { min: 0 } }
      fresh: { field: day, max_age: 2d }
      vs_last_run: { rows: 30%, sum: { total: 40% } }
    review: >
      Daily sales report for the finance team. Flag anything that looks wrong:
      totals out of the usual range, a region with no orders, odd categories.
    on_fail: stop            # or warn

  - id: report
    use: excel.write
    input: verify
    with:
      path: "smb://fileserver/finance/sales_{date}.xlsx"
      latest: "smb://fileserver/finance/sales.xlsx"
      keep: 30

notify:
  channels:
    finance: { type: teams, webhook: "${TEAMS_WEBHOOK}" }
  rules:
    - on: check.failed
      to: [finance]
```

The check passes the records through unchanged, so the writer reads from it (`input: verify`).

## Fixed expectations

Deterministic and free. Each failure explains itself, with the numbers.

| Expectation | Example | Fails when |
| --- | --- | --- |
| `rows` | `{ min: 1000, max: 50000 }` | the row count is outside the range |
| `not_null` | `[order_id, total]` | a column has empty values |
| `unique` | `order_id` or `[a, b]` | a column has duplicates |
| `accepted` | `{ region: [north, south] }` | a column has a value outside the list |
| `range` | `{ total: { min: 0, max: 10000 } }` | a number is outside the range |
| `fresh` | `{ field: day, max_age: 1d }` | the newest date is older than `max_age` (`30m`, `6h`, `1d`, `2w`) |
| `vs_last_run` | `{ rows: 30%, sum: { total: 40% } }` | the row count, or a column's sum, changed more than that since the last run that passed |

`vs_last_run` compares with the profile of the last run whose check passed, kept in the run store.
On the first run there is nothing to compare with, so it passes.

## Agent review

With `review:`, an agent step looks at the data too. It does not read every row: it gets a profile
(row count; nulls, distinct values, min, max, mean and sum or the most common values for each
column; 15 sample rows), the profile of the last delivered run, and the fixed checks that failed.
That keeps the cost to one small call per run.

The agent answers with a verdict and findings in plain language, for example *"The north region
has no orders today; on the last run it had 312."* When it passes, its findings are kept as
`notes`. Its tokens count in the run's budget and in the step's spend.

## When a check fails

- `on_fail: stop` (default): the step fails with the findings, so nothing after it runs and the
  last good output stays in place.
- `on_fail: warn`: the data goes on and the findings are reported.

Both emit `check.failed` with `findings` (a list of `{check, detail}`), so any notification rule
can route them; a passing check emits `check.passed`. Both show on the dashboard's timeline.

Source: https://agentl.com.br/checks/
