---
sidebar_label: Items reference
toc_max_heading_level: 3
doc_id: 6b2d47e1-05fc-4a83-b71e-9d3c8f52a604
description: >-
  Reference for checklist items: the four item types, the three behaviors
  that decide what blocks the action, structured inputs and validation rules on
  manual items, and how nullplatform computes the final outcome of a run.
keywords:
  - checklists
  - checklist items
  - approvals
  - validations
  - inputs
  - override
  - guardrails
  - nullplatform
---

# Checklist items

Every [checklist](/docs/approvals/checklists) is an array of items, and each item answers two separate questions. 
- Its **type** says how the item gets resolved, whether nullplatform evaluates it, a person signs it, or one of your systems reports back. 
- Its **behavior** says what the result does to the action, whether it can block it, or is only there to be read.

Keeping those apart is what makes a checklist expressive. A security scan and a compliance note can both be automatic conditions, but one blocks the deploy and the other is there for the record.

Whatever its type, every item requires two fields: an `id` and a `title`. The `id` is how nullplatform addresses the item, both in the [outcome expression](#how-the-outcome-is-computed) and in the item's own state. The `title` is the label a person reads in the list, and a condition needs one as much as a sign-off does: the run is a list someone reads, even when nothing in it is waiting on a human.

## Item types

### `condition`

Evaluated automatically against the request context using a `query`, a mongo-like filter document. This is the same condition language and the same field paths used by [policies](/docs/approvals/policies), which is why a predicate can move between the two unchanged.

```json
{
  "id": "coverage_gate",
  "type": "condition",
  "behavior": "gate",
  "title": "Build coverage above 80%",
  "query": { "build.metadata.coverage.lines.percent": { "$gte": 80 } }
}
```

Operators include `$eq`, `$gt`, `$gte`, `$lt`, `$lte`, `$in`, `$and`, `$or`, and `$nor`.

:::warning Field paths carry no `context.` prefix
Write `build.metadata.coverage`, never `context.build.metadata.coverage`. A `context.`-rooted path is rejected when the checklist spec is saved. Before that validation existed, such a spec saved cleanly and then failed every run on every request.
:::

Watch the shape of what you're comparing. Coverage, for example, is an object rather than a number, so `build.metadata.coverage` compared against `80` will never match. Address the field you actually mean, like `build.metadata.coverage.lines.percent`.

### `manual`

Waits for a person. The item stays pending until someone submits a decision, which is recorded with their identity and their comment.

```json
{
  "id": "security_signoff",
  "type": "manual",
  "behavior": "gate",
  "title": "Security team sign-off",
  "description": "Required for changes that touch auth or PII."
}
```

The `title` is what the developer sees in the list, and the `description` is where you put the guidance the signer needs. Write it for someone who doesn't have the context you have right now.

A plain manual item collects a decision and, if you set `require_comment: true`, a comment. When the sign-off needs more than that, give it structure:

#### Collect structured inputs

A manual item can declare `inputs`: a JSON Schema describing what the signer must fill in. The form is rendered from the schema, validated when the person submits, and the submitted values are persisted on the item, so "which CAB ticket covered this?" has an answer in the audit trail.

```json
{
  "id": "cab_approval",
  "type": "manual",
  "behavior": "gate",
  "title": "Change Advisory Board approval",
  "inputs": {
    "schema": {
      "type": "object",
      "required": ["cab_ticket", "risk_level"],
      "properties": {
        "cab_ticket": {
          "type": "string",
          "title": "CAB ticket",
          "pattern": "^CAB-\\d{4}-\\d{4}$"
        },
        "risk_level": {
          "type": "string",
          "title": "Risk level",
          "enum": ["low", "medium", "high"]
        }
      }
    }
  }
}
```

The schema follows the same convention as [service specifications](/docs/services/craft-a-service/service-specs): JSON Schema with a top-level `type: object`, validated twice, once when the spec is saved and again when the values are submitted. You can add a `ui_schema` to control the layout; without one, the form renders as a vertical layout in schema order, which is usually fine.

Submitted values appear as `input_values` when you read the run.

#### Guard who can sign with validations

`validations` are rules evaluated on the server when someone tries to resolve the item positively. They use the same query language as conditions, but run against a different document: the submission itself, so a rule can see the `inputs` being submitted, the `actor` signing, the `approval` being decided, and the other items in the run.

String values that start with `$` are references: nullplatform resolves them against that document before evaluating the rule, so one field of the submission can be compared against another. That's all a four-eyes rule takes, the rule that stops whoever requested the change from signing it off themselves:

```json
{
  "validations": [
    {
      "id": "four_eyes",
      "message": "Someone other than the requester must confirm.",
      "rule": { "actor.user_id": { "$ne": "$approval.requested_by" } }
    },
    {
      "id": "peer_role",
      "message": "Requires developer or admin on this scope.",
      "rule": { "actor.effective_roles": { "$in": ["developer", "admin"] } }
    }
  ]
}
```

Two properties of validations matter:

- **They gate only the positive outcome.** Rules run when someone submits a pass. A rejection always applies without running any rule: a validation can stop a sign-off, never a refusal.
- **A broken reference fails closed.** If a `$` reference doesn't resolve, because of a typo or a field that doesn't exist, the rule fails and the error names the offending reference. It never resolves silently to nothing, which would quietly disable the check. Note the four-eyes rule above references `$approval.requested_by` with no `.id`: the approval exposes its requester as a plain user id. `$approval.requested_by.id` resolves to nothing, so that version of the rule blocks every signer rather than just the requester.

When a rule fails, the signer gets the rule's `message` next to the field, not a generic error.

<video width="100%" autoPlay loop muted playsInline className="helper-image">
  <source src="/img/approvals/checklist-signoff.mp4" type="video/mp4" />
</video>

#### Delegate a validation to an external system

Instead of a `rule`, a validation can declare `external: { kind, inputs }`. When the person submits, the item enters a `validating` status while nullplatform dispatches the check and waits for the answer. All external validations pass and the item lands on the status the person submitted; any of them fails, or the dispatch itself fails, and the item returns to `pending` with the failure recorded on the card.

This is how "a human signs, but the system double-checks the ticket they cited" works. The other end is typically a workflow.

### `external`

Delegates the whole check to one of your systems. Nullplatform dispatches an event carrying the item's `kind` and inputs, and waits for the system to call back with the result.

```json
{
  "id": "e2e_suite",
  "type": "external",
  "behavior": "gate",
  "title": "End-to-end suite",
  "external": {
    "kind": "e2e-suite",
    "trigger": "auto",
    "timeout_seconds": 900,
    "inputs": {
      "release_id": "{{ context.release.id }}"
    }
  }
}
```

With `trigger: auto`, nullplatform dispatches the item as soon as the run starts. With `trigger: on_demand`, the item sits in `latent` until someone activates it from the run. Choose `on_demand` for checks that cost real time or money, like an end-to-end suite, so they run when someone asks for them rather than on every request.

`timeout_seconds` is how long nullplatform waits for the callback, counted from the moment the item is dispatched, which for an `on_demand` item means from the moment it's activated. If the callback never arrives, the item lands on `timed_out`, which blocks the action exactly like a failure while staying distinguishable from one in the audit trail.

Placeholders in `inputs` are the one place the `context.` prefix survives, because that value is the payload you're sending rather than a query being evaluated. Keep `{{ context.release.id }}` as-is here.

The natural consumer of these events is a workflow subscribed to the same `kind`.

### `group`

Holds other items in `children`, which can themselves be groups. Groups organize the list the developer reads, like "Pre-deploy checks" and "Manual sign-offs". They're for structure, not sequencing: nullplatform evaluates conditions in parallel and waits on asynchronous items independently, so ordering items never controls when they run.

```json
{
  "id": "pre_deploy_checks",
  "type": "group",
  "behavior": "gate",
  "title": "Pre-deploy checks",
  "children": [
    {
      "id": "tests_passed",
      "type": "condition",
      "behavior": "gate",
      "title": "Tests passed",
      "query": { "build.metadata.tests_status": { "$eq": "passed" } }
    },
    {
      "id": "lint_clean",
      "type": "condition",
      "behavior": "gate",
      "title": "Lint clean",
      "query": { "build.metadata.lint_errors": { "$eq": 0 } }
    }
  ]
}
```

#### Require all children, or any

By default a group passes when **all** of its children pass. Set `aggregation: any` when one is enough:

```json
{
  "id": "peer_signoff",
  "type": "group",
  "behavior": "gate",
  "title": "Peer sign-off",
  "aggregation": "any",
  "children": [
    {
      "id": "developer_signs",
      "type": "manual",
      "behavior": "gate",
      "title": "A peer developer signs off"
    },
    {
      "id": "admin_signs",
      "type": "manual",
      "behavior": "gate",
      "title": "An account admin signs off"
    }
  ]
}
```

That group reads as "either a peer developer or an account admin signs off", which is much closer to how organizations actually work than forcing both. One restriction: a group with `aggregation: any` can't contain `override` children, and the spec is rejected at save if it does, because "any" semantics would let an override slip through as a routine pass.

## Behaviors

Every item must declare one. This is the field that decides what your guardrail actually enforces.

| Behavior | Effect on the action |
|---|---|
| `gate` | Blocks when it fails. The run can't be approved normally while a blocking item is failing |
| `informational` | Never blocks, whatever the result. Use it to surface a report or a diagnostic |
| `override` | Only matters once a blocking item has failed. Approving it resolves the run as approved with an override |

Use `informational` more than you'd expect. Attaching a scan report to every request costs nothing and gives reviewers context, and it doesn't add a way for the action to get stuck.

Keep `override` items rare and clearly labeled. An override is a deliberate, attributable decision to ship past a failed guardrail, so the `description` should name the circumstances under which that's acceptable.

## Item ids

Each item needs an `id` matching `^[a-z][a-z0-9_]{0,63}$`, unique within the checklist spec. The id is how the outcome expression refers to the item and how per-item state is keyed, so it shows up in logs and in the audit trail.

:::danger Never name an item `and`, `or`, `not`, `true`, or `false`
The expression that computes the outcome reserves those five words as operators and literals. An item called `or` makes the expression unparseable and the whole run resolves as failed. The spec is rejected when you save it, so you'll find out immediately. Note `nor` is not reserved.
:::

## Apply an item conditionally

Any item or group accepts `applies_when`, a query in the same language that decides whether the item is part of this run at all. An item excluded this way is marked as not applicable and never blocks.

```json
{
  "id": "security_signoff",
  "type": "manual",
  "behavior": "gate",
  "title": "Security team sign-off",
  "applies_when": { "scope.dimensions.environment": "production" }
}
```

This is how one checklist spec serves several environments. The list a developer sees in staging is the same guardrail with the production-only items stood down, rather than a second spec that drifts from the first.

## Severity

Items take an optional `severity` of `info`, `minor`, `major`, or `critical`. Severity does not affect the outcome: a failing `minor` blocking item blocks exactly as hard as a failing `critical` one. What it drives is how the item is presented and how it reads in audit reports, so set it to help a human triage the list.

## Item statuses

| Status | Meaning |
|---|---|
| `passed` | Resolved successfully |
| `failed` | Resolved unsuccessfully |
| `pending` | Waiting on a person, or on an external system that was dispatched |
| `latent` | An `on_demand` external item waiting for someone to activate it. Its timeout doesn't start until then |
| `in_progress` | Activated, or acknowledged by the external system now working on it |
| `validating` | A manual item was submitted and its external validations are being checked |
| `skipped` | Never ran. The item's `sub_status` says why |
| `timed_out` | An external item never called back within its window. It blocks like a failure |
| `cancelled` | The run ended before this item resolved |

`skipped` covers three different stories, told apart by `sub_status`. The run read returns it on the item itself, next to `status`, together with `applicable`, which is `false` only for an item that `applies_when` pruned out of the run:

| `sub_status` | What happened |
|---|---|
| `not_applicable` | The item's `applies_when` didn't match, so it was never part of this run and takes no part in the outcome |
| `not_required` | The run was approved before this item had to run |
| `not_evaluated` | The run failed before reaching this item |

The last two are the resolution sweep. When a run settles on `approve` or `fail`, every item still unresolved is closed out as `skipped` rather than left hanging, so a finished run never shows items in limbo. A run that ends `cancelled` or `expired` sweeps them to `cancelled` instead.

Every manual resolution is attributed: the run records `resolved_by` with the person's identity, their effective roles at that moment, and the timestamp. Together with `input_values` and the per-item logs, that's what turns "someone approved it" into an answer an auditor can use.

## How the outcome is computed

A run tracks an aggregate status while items resolve, then settles on a final outcome.

| Aggregate status | What it means |
|---|---|
| `pending_items` | At least one item is still unresolved |
| `pending_aggregation` | Every item resolved, the outcome is being computed |
| `pending_override` | A blocking item failed and an override item is available but undecided |
| `resolved` | The run ended and the final outcome is set |

| Final outcome | When it happens |
|---|---|
| `approve` | Every blocking item passed, or the checklist has none |
| `approve_with_override` | A blocking item failed and an override item was approved |
| `fail` | A blocking item failed with no override approved, or none defined |
| `cancelled` | The run was cancelled externally |
| `expired` | The approval action's allowed execution window passed first |

Nullplatform derives a boolean expression from your items: all `gate` items must pass, or any `override` item must be approved. For the spec on [Specs and actions](/docs/approvals/checklist-specs#what-a-checklist-spec-looks-like), that expression is:

```
( coverage_gate.passed AND snyk_high_severity.passed AND security_signoff.passed )
OR cab_override.passed
```

Reading that expression is the quickest way to check that a checklist enforces what you meant. If the shape you need can't be expressed by "all blocking items, or an override", you can supply your own expression in the spec, though it is an escape hatch rather than a normal crafting step.

### When a run fails

A failed run isn't necessarily the end of the request. By default the approval stays open and resumable: the developer reads which item failed, fixes the cause, and redeploys, or asks for a manual review when they need a human decision. Reviewers are notified only when review is requested. If you'd rather send failures straight to reviewers, that's the action-level `checklist_fail_mode: auto` setting, covered in [Choose what a failed run does](/docs/approvals/checklist-specs#choose-what-a-failed-run-does).

Every transition, including the aggregate status before and after, is written to the run's event trail. When a run ends in a state you didn't expect, that trail plus the per-item logs tell you which item moved and when.

## What's next

- [Checklists](/docs/approvals/checklists): what checklists solve and how a run works
- [Specs and actions](/docs/approvals/checklist-specs): creating specs via the API and connecting actions
- [Approval request lifecycle](/docs/approvals/approval-requests): what happens around the run, from trigger to execution
- [Policies](/docs/approvals/policies): the same condition language in its single-verdict form, to be deprecated in favor of checklists
