# Object-level validations

> Asynchronous table-level data-quality checks (freshness, missing-data, foreign-key) and constraint toggles, configured per table through the objectValidations field.

Object-level validations are asynchronous, table-level data-quality checks that evaluate a whole table rather than individual rows. Unlike the column-level `validations` applied inline during ingestion, they run **after rollout**, on demand, independently of any single ingest request. See [Run validations on demand](#run-validations-on-demand).

You configure them per table in an `objectValidations` array, set when you [save a schema](schema-lifecycle.md#schema-definition-structure) or [add a custom object](schema-lifecycle.md#add-a-custom-object) and updated later with [Patch a solution's schema](schema-lifecycle.md#patch-a-solutions-schema).

## Entry fields

Each entry in the `objectValidations` array is an object:

| Field | Required | Description |
|-------|----------|-------------|
| `type` | Yes | One of `freshness`, `missing_data_validation`, `foreign_key`, `async_foreign_key`, `unique_key`. |
| `enabled` | Yes | Whether the rule or check is active. |
| `severity` | Depends on `type` | `error` or `warn`. Required for `freshness`, `missing_data_validation`, and `async_foreign_key`; optional for `foreign_key` (defaults to `warn`); not allowed on `unique_key`. |
| `params` | Depends on `type` | Rule parameters. Required for `freshness` and `missing_data_validation`; optional for `foreign_key` and `async_foreign_key` (only `constraintName`, see [Routing constraints](#routing-constraints-with-paramsconstraintname)); not allowed on `unique_key`. Keys depend on `type` (see below). |
| `alerts` | Optional | Names of the alert channels to notify when the rule fails. Available on `freshness`, `missing_data_validation`, and `async_foreign_key`. See [Data quality alerts](data-quality-alerts.md). |

## Data-quality rules

- **`freshness`** — flags a table whose most-recent record is older than a threshold. `params`:

  | Param | Description |
  |-------|-------------|
  | `column` | Timestamp or date column to measure recency on. |
  | `threshold` | Numeric age limit. |
  | `unit` | One of `minutes`, `hours`, `days`, `months`, `years`. |

- **`missing_data_validation`** — flags a time window that received fewer rows than expected. `params`:

  | Param | Description |
  |-------|-------------|
  | `column` | Timestamp or date column that defines the window. |
  | `interval` | Numeric length of the window. |
  | `unit` | One of `minutes`, `hours`, `days`, `months`, `years`. |
  | `minRowCount` | Minimum number of rows expected in the window. |

## Foreign-key checks

The foreign-key check verifies that every value in a foreign-key column has a matching row in the referenced table. Two entries configure it, and you can use both on the same table:

- **`foreign_key`** — runs the check **inline as rows are ingested**, scoped to the rows in each load. How a foreign-key violation is handled during ingestion — flagged on the row or routed to `<table_name>_failed_rows` — is described under [Validation behavior](api-guide.md#validation-behavior). `severity` is optional and defaults to `warn`.
- **`async_foreign_key`** — an object-level check that re-evaluates the **whole table** after rollout, on demand, independently of any ingest. Like every object-level validation it does not move any row — it **flags** each orphan row in place; `severity` is required.

Because `async_foreign_key` re-checks every row on each run, a row flagged only because its parent had not yet been loaded is **cleared automatically** once the parent arrives — so a table no longer has to be ingested parent-before-child.

Both entries flag a violating row through the same two audit columns on the table:

| Column | Description |
|--------|-------------|
| `peakAuditErrors` | JSON array of the foreign-key violations on the row, each `{ "errorCode", "errorDetails", "severity", "constraintName", "flaggedAt" }`. |
| `peakAuditLastValidationTime` | Timestamp of the run that last flagged the row. |

`flaggedAt` records when that violation was **first** seen. A later run that finds the same violation still unresolved keeps the original timestamp, so `flaggedAt` tells you how long a reference has been missing; `peakAuditLastValidationTime` is the row's most recent check.

Your downstream pipelines read `peakAuditErrors` to skip or specially handle flagged rows. When a later run finds that the parent row now exists, both columns are cleared for that row.

See [Audit columns added at rollout](schema-lifecycle.md#audit-columns-added-at-rollout) for the columns and their per-warehouse casing, and the `DI_{E|W}_23F01` [error code](api-guide.md#error-codes) reported for each violation.

### Routing constraints with `params.constraintName`

Both `foreign_key` and `async_foreign_key` accept an optional `params.constraintName` — an array of foreign-key constraint names. When present, the entry applies only to the listed constraints; when omitted, it applies to **all** foreign keys on the table.

This lets you route some constraints to the per-load `foreign_key` check and others to the whole-table `async_foreign_key` check on the same table — for example, enforce a stable reference (a currency or region table) during ingestion, while re-checking a constraint whose parent rows arrive in a separate feed asynchronously.

:::note
An `async_foreign_key` run rewrites `peakAuditErrors` on every row from the constraints it evaluated. On a table with more than one foreign key, scoping the entry to a subset therefore clears the flags its other constraints had set — so `constraintName` is best omitted unless that scoping is specifically needed.
:::

## Unique-key and primary-key checks

A `unique_key` entry **enables or disables** the asynchronous unique-key collision check for the table — it carries only `type` and `enabled` (no `params`, no `severity`). For example, `{ "type": "unique_key", "enabled": false }` turns the check off. The primary-key check is always on and cannot be disabled.

:::note
The `foreign_key` / `async_foreign_key` / `unique_key` entries are distinct from the structural `foreignKeys` / `uniqueKeys` per-table fields. Those **define** the constraints (which columns form a key, which table is referenced); the `objectValidations` entries only control **whether — and how — the corresponding check runs**.
:::

## Alerts

Add an `alerts` array to a `freshness`, `missing_data_validation`, or `async_foreign_key` entry to notify a Slack or email channel when it fails. The names must match channels registered for your tenant, and the reserved name `__ALL__` expands to all of them. Alerts are sent only when a check finishes in a `failed` or `error` state.

Channels are registered once per tenant through `/api/v2/alert-configs`, which is covered in [Data quality alerts](data-quality-alerts.md). A name that matches no registered channel is rejected with `400 Bad Request` when you save the validation.

## Defaults

- The `objectValidations` array is **optional**. Omit it and the table has no data-quality rules; its foreign-key, unique-key, and primary-key checks still run with their default behavior.
- A check you don't list runs **on by default** — the `foreign_key`, `async_foreign_key`, and `unique_key` checks are active for a table that declares the corresponding keys, unless you add an entry with `"enabled": false`. The primary-key check is always on and cannot be disabled.
- `foreign_key` severity defaults to `warn` when omitted; `async_foreign_key` requires an explicit `severity`, and `freshness` / `missing_data_validation` require both `severity` and `params`.
- Within an entry there are no other implicit defaults: `type` and `enabled` are always required. Omitting a required field returns `400 Bad Request`.

## Example

```json
"objectValidations": [
  { "type": "freshness",
    "params": { "column": "order_date", "threshold": 24, "unit": "hours" },
    "enabled": true, "severity": "error" },
  { "type": "missing_data_validation",
    "params": { "column": "order_date", "interval": 1, "unit": "days", "minRowCount": 100 },
    "enabled": true, "severity": "warn" },
  { "type": "foreign_key", "enabled": true, "severity": "warn",
    "params": { "constraintName": ["fk_orders_customer"] } },
  { "type": "async_foreign_key", "enabled": true, "severity": "warn",
    "alerts": ["data-oncall"],
    "params": { "constraintName": ["fk_orders_region"] } },
  { "type": "unique_key", "enabled": false }
]
```

## Run validations on demand

Object-level validations do not run by themselves. Starting a run for a table evaluates all its enabled async rules — `freshness`, `missing_data_validation`, and `async_foreign_key` — against the data already in it. The call returns immediately with a `run_id`.

This endpoint is served on the same host as the rest of this guide, under the `/validation-api` base path:

```
POST https://ingestion.peak.ai/validation-api/api/v1/validations/run
```

Spoke tenants have their own host here too, as described in [API host and reference](api-guide.md#api-host-and-reference): `https://ingestion.<cluster-identifier>.peak.ai/validation-api/api/v1/validations/run`. Both validation endpoints are listed in the Swagger reference under the **Validation API** section: [https://ingestion.peak.ai/api-docs/](https://ingestion.peak.ai/api-docs/).

### Payload

| Field | Required | Description |
|-------|----------|-------------|
| `solution_name` | Yes | Solution that owns the table |
| `table` | Yes | Table to validate, by bare name (`orders`) or schema-qualified name (`public.orders`) |
| `rule_types` | No | Array of rule types to run, so a subset can be re-checked — for example `["async_foreign_key"]`. When omitted, every enabled async rule on the table runs. |
| `overrides` | No | Per-run numeric parameter overrides, keyed by rule type — `freshness`: `threshold`, `unit`; `missing_data_validation`: `interval`, `minRowCount`, `unit`. Structural fields such as `column` cannot be overridden. |

### Example request

A run scoped to the foreign keys alone — the usual call once a parent table's feed has landed:

```bash
curl -X POST \
  'https://ingestion.peak.ai/validation-api/api/v1/validations/run' \
  -H 'Authorization: <your-api-key>' \
  -H 'Content-Type: application/json' \
  -d '{
    "solution_name": "QP_OOTB",
    "table": "QP_CUSTOMER_ORDERS_OOTB",
    "rule_types": ["async_foreign_key"]
  }'
```

### Response (202 Accepted)

```json
{
  "run_id": "09a911f9-696f-44c8-9cbb-708ae9adae18",
  "status": "running"
}
```

### Status codes

- `202 Accepted` — the run started; the response carries the `run_id`
- `400 Bad Request` — unknown solution or table, no enabled validations on the table (or none matching `rule_types`), an unknown rule type, an invalid override value, or the tenant's warehouse credentials could not be resolved

## Check a validation run

Returns the current state of a run, which stays `running` until the engine reaches a terminal state.

```
GET https://ingestion.peak.ai/validation-api/api/v1/validations/run/{run_id}
```

### Path parameters

- `run_id` *(required)* — the identifier returned when the run started

### Response (200 OK)

While the engine is still working, the body carries only the status:

```json
{ "run_id": "09a911f9-696f-44c8-9cbb-708ae9adae18", "status": "running" }
```

On success the body is the full run report — one entry per rule evaluated, and no `status` field:

```json
{
  "run_id": "09a911f9-696f-44c8-9cbb-708ae9adae18",
  "results": [
    {
      "rule_type": "async_foreign_key",
      "scope": "multi_table",
      "target_tables": ["STAGE.QP_CUSTOMER_ORDERS_OOTB"],
      "columns": [],
      "status": "failed",
      "severity": "warn",
      "error_codes": ["DI_W_23F01"],
      "message": "Foreign-key violations: 2 row(s) reference a non-existent parent (kept in the destination and flagged)",
      "details": {
        "constraints": [
          {
            "constraintName": "fk_orders_customer",
            "childColumns": ["customer_id"],
            "parentTable": "STAGE.QP_CUSTOMER_OOTB",
            "parentColumns": ["customer_id"],
            "orphanCount": 2
          }
        ],
        "totalOrphanRows": 2
      },
      "params": {},
      "alert_names": ["data-oncall"],
      "alerts_sent": ["data-oncall"]
    }
  ]
}
```

A `status` of `failed` on a result means the rule found a problem. If the run itself could not complete, the body reports that instead:

```json
{ "status": "failed", "error": "warehouse connection timed out" }
```

### Status codes

- `200 OK` — the run exists; the presence and value of `status` distinguish the three bodies
- `404 Not Found` — no run with that identifier, or its result has expired

## Results

Object-level validations run asynchronously. Each run's own report is available from [Check a validation run](#check-a-validation-run), and the outcomes across runs surface in the [Data Quality Dashboard](data-quality-dashboard.md).

Failures are reported under the `OBJECT_VALIDATION` [error codes](api-guide.md#error-codes) — `DI_{E|W}_24F01` (freshness), `DI_{E|W}_24M01` (missing data), and `DI_{E|W}_23F01` (foreign key), where `E` is an error and `W` a non-fatal warning, set by the entry's `severity`.

Foreign-key violations are flagged on the row through `peakAuditErrors` / `peakAuditLastValidationTime` (see [Foreign-key checks](#foreign-key-checks)); a later `async_foreign_key` run clears the flag once the parent row exists.

When a rule names alert channels, the run report also lists the channels notified for that result in `alerts_sent`. A channel that could not be reached is left out, and the run still completes.
