Specs and actions
A checklist is carried by two entities:
- Checklist specification, spec for short: the items themselves, plus how their results add up to an outcome.
- Approval action: which requests the spec governs, which version of it they run against, and what happens when it fails.
This page is the contract for both: crafting, wiring, and permissions. If you haven't built a checklist yet, start with your first checklist instead; everything the skill did there is this API.
What a checklist spec looks like
A spec is a name, an NRN, and a definition holding the items:
{
"items": [
{
"id": "coverage_gate",
"type": "condition",
"behavior": "gate",
"severity": "major",
"title": "Build coverage above 80%",
"query": { "build.metadata.coverage.lines.percent": { "$gte": 80 } }
},
{
"id": "snyk_high_severity",
"type": "condition",
"behavior": "gate",
"severity": "critical",
"title": "No high-severity Snyk findings",
"query": { "build.metadata.snyk.high": { "$eq": 0 } }
},
{
"id": "security_signoff",
"type": "manual",
"behavior": "gate",
"severity": "major",
"title": "Security team sign-off",
"description": "Required for changes that touch auth or PII.",
"applies_when": { "scope.dimensions.environment": "production" }
},
{
"id": "cab_override",
"type": "manual",
"behavior": "override",
"title": "CAB emergency override",
"description": "Use only when a blocking item is misconfigured and the change must ship."
}
]
}
Conditions use the same query language and the same field paths as policies, so a predicate you already trust can move across unchanged.
Note the paths carry no context. prefix. The full item contract, including structured inputs, validations, and groups, is in the items reference.
Next to items, the definition takes an optional aggregation block that says how the outcome is computed:
aggregation.type | What it does |
|---|---|
derived (the default) | Nullplatform builds the expression from your items: every gate item must pass, or an override item must be approved |
expression | You supply the boolean expression yourself in an expression field. An escape hatch, not a normal crafting step |
Leaving aggregation out entirely is the same as derived. See how the outcome is computed for the expression language.
The definition also takes an optional execution_trigger, which decides whether the action starts on its own once the request is approved. See Choose what starts the approved action.
Craft the specification
Describing the spec to the np-checklist skill is the quickest way to get one, and your first checklist walks through that. To craft it yourself:
- nullplatform UI
- API
-
Go to Platform settings > Approvals > Checklists and click + New specification. The editor opens on a working one-item spec:
{
"name": "my-checklist-specification",
"description": "",
"definition": {
"items": [
{
"id": "manual_approval",
"type": "manual",
"behavior": "gate",
"severity": "major",
"title": "Manual approval",
"require_comment": true
}
],
"aggregation": {
"type": "derived"
}
}
} -
Build your definition on top of it. The Condition, Manual, External, and Group buttons drop ready-made blocks into the editor, so you can write a spec without keeping the schema in your head.
-
Check it in the pane on the right, which revalidates while you type. Checklist preview renders the list a developer would see, and Preview as flips it through Not started, In progress, Everything passed, and Everything failed, plus Failed, then overridden when the spec has an
overrideitem. None of them creates a run. -
Click Create. The spec appears in the Checklists list with its version, its status, and how many approval actions currently run on it.
Send a POST request with the definition in the request body:
curl -L 'https://api.nullplatform.com/approval/checklist/specification' \
-H 'Authorization: Bearer <token>' \
-H 'Content-Type: application/json' \
-d '{
"nrn": "organization=1:account=2:namespace=3",
"name": "my-checklist-specification",
"description": "",
"definition": {
"items": [
{
"id": "manual_approval",
"type": "manual",
"behavior": "gate",
"severity": "major",
"title": "Manual approval",
"require_comment": true
}
],
"aggregation": {
"type": "derived"
}
}
}'
The response carries the generated spec id, which looks like spec_abc123def456ghij, and the boolean expression nullplatform derived from your items to compute the outcome. Reading that expression back is the quickest way to confirm the spec enforces what you meant.
Connect it to an approval action
A spec does nothing until an approval action points at it. An action runs on a checklist or on policies, never both, so nothing can end up with two sets of rules disagreeing about the same request.
- nullplatform UI
- API
Go to Platform settings > Approvals > Settings and click + New approval action. The form creates the action with the checklist already attached. Choose the resource, entity, and action to protect, pick Checklist along with the specification and the version to pin it to, and set what happens once the run is evaluated. The form explains every field as you fill it in.

To point an action that already exists at a spec, send a POST request to the action:
curl -L 'https://api.nullplatform.com/approval/action/1842/checklist_specification' \
-H 'Authorization: Bearer <token>' \
-H 'Content-Type: application/json' \
-d '{ "checklist_specification_id": "spec_abc123def456ghij" }'
If the action still has policies attached, the call fails with 409 and a message naming how many policies are in the way. Reach out to the nullplatform team to move an action that already runs on policies onto a checklist.
Change a spec that's already live
Updating a spec never edits it in place. Every update publishes a new version: a new spec id under the same name and NRN, with the version number bumped.
- nullplatform UI
- API
Go to Platform settings > Approvals > Checklists and open the checklist you want to change. In its detail view, click New version: the spec editor reopens on the current definition, with the same validation and preview panes you had when you created it. Edit whatever you need and click Save as new version.
The detail view lists the approval actions pinned to the version you're viewing. Its Overview tab shows the definition next to the checklist preview, with the expression the engine evaluates when the checklist finishes under Approval rule. The Versions tab holds the version history, where Compare shows any two versions side by side.

Send a PATCH request with the new definition:
curl -L -X PATCH 'https://api.nullplatform.com/approval/checklist/specification/spec_abc123def456ghij' \
-H 'Authorization: Bearer <token>' \
-H 'Content-Type: application/json' \
-d '{ "definition": { "items": [ ... ] } }'
The response is version 2 with its own spec_… id.
Publishing doesn't switch anything over. Version 1 stays active and the action keeps evaluating the version id it holds, so a spec change is a two-step rollout: publish, then point the action at the new id. Pointing it back at the old id is the rollback, and runs already in flight finish on the version they snapshotted.
Compare puts the two definitions side by side, so you can see exactly what moved before you point an action at the new version.
Dry-run a spec before it governs anything
A dry run resolves the same action a real request would resolve and previews the run without creating it. It is what you do right after connecting a spec, and again after every new version. Send a POST request describing the request you want previewed:
curl -L 'https://api.nullplatform.com/approval/dry-run' \
-H 'Authorization: Bearer <token>' \
-H 'Content-Type: application/json' \
-d '{
"nrn": "organization=1:account=2:namespace=3:application=4",
"action": "deployment:create",
"approval_mode": "checklist",
"entity_id": 9876,
"requested": { "scope_id": 5521, "release_id": 7710 }
}'
approval_mode takes auto (the default: the checklist if the action has one, policies otherwise), checklist, or policy. Forcing checklist on an action with no spec attached is an error rather than a silent fallback, which is what you want when the thing you are verifying is the wiring.
The response predicts the run item by item:
{
"mode": "checklist",
"specification_snapshot": {
"id": "spec_abc123def456ghij",
"name": "staging-deploy-gate",
"version": 2
},
"preview": {
"items": [
{ "id": "coverage_gate", "type": "condition", "behavior": "gate", "predicted_status": "failed", "would_run": true, "message": "condition_not_met" },
{ "id": "security_signoff", "type": "manual", "behavior": "gate", "predicted_status": "pending", "would_pend_for_user": true }
],
"aggregate_prediction": { "best_case": "fail", "worst_case": "fail", "status": "resolved" }
}
}
Conditions are genuinely evaluated, so their predicted_status is the real verdict. Everything asynchronous reports what it would wait for instead: a manual item comes back pending with would_pend_for_user, an on_demand external comes back latent, and an auto external is only dispatched when it declares dry_run_safe: true. aggregate_prediction closes the loop: best_case says whether the request can be approved at all, and when it matches worst_case the outcome is already decided.
Pass a context object in the body to preview against values of your choosing rather than the ones nullplatform would build from the request. That is how you check a threshold from both sides without producing a build that fails it.
Choose what starts the approved action
A run that passes approves the request, but approving it doesn't have to start the action behind it. Set execution_trigger in the definition, next to items, to decide:
{
"execution_trigger": "automatic_approval",
"items": [ ... ]
}
execution_trigger | What happens once the request is approved |
|---|---|
explicit (the default) | The action waits for someone to start it: Start deployment, Create scope, or Apply changes in the UI, or a POST request to /approval/{id}/execute |
automatic_approval | The action starts on its own when no person or external system took part in the run. When one did, it waits, as with explicit |
any_approval | The action starts on its own on any approval: a run that resolves approve or approve_with_override, or a reviewer who approves the manual review |
Leaving execution_trigger out, or setting it to null, is the same as explicit. Any other value fails validation when you save the spec: a 422 whose errors name rule: execution_trigger.invalid.
For automatic_approval, what matters is whether any item involved a person or an external system. Conditions never do. Neither does a manual item that a static applies_when (its own or its group's) pruned when the run was created, anything informational, item or group, or an override item or group nobody used. Everything else does, and the action waits: any other manual item, however it ended; a manual item whose applies_when reads other items (run.items.*); any external item; an override that was used; and a run that went to manual review or waited for an override. A gate group doesn't count by itself: its children count one by one.
A few cases follow their own rule, whatever the trigger. Approving an action item transition (action_item:resolve, action_item:defer, or action_item:reject) always applies it right away. A deployment that belongs to a deployment group never starts on its own: the group's approval starts it, under these same rules. And an approval with nothing behind it to run, like parameter:read-secrets, is unaffected.
The run reads execution_trigger from the spec version it snapshotted. Changing it works like any other change: publish a new version and point the action at it, and the runs created from then on use the new value.
See what an approval will do
The run read carries an execution block, so you don't have to work the rules out yourself:
{
"execution": {
"trigger": "automatic_approval",
"automatic_run": true,
"on_approval": "execute"
}
}
trigger is the value the run read from its spec, explicit when the spec doesn't set one. automatic_run applies the rules above to the run as it stands. on_approval is execute when approving starts the action, or wait when it waits for someone. Before the run resolves, it tells you what's going to happen. Afterwards, read it next to the request's execution_status to see what did.
Choose what a failed run does
By default, a failed blocking item doesn't page anyone. The request stays open and resumable: the developer reads which item failed, fixes the cause, and redeploys, or asks for a manual review when they genuinely need another human. Reviewers are only notified at that point.
Set on_checklist_fail on the approval action to change what happens:
on_checklist_fail | What a failed run does | Ask for manual review |
|---|---|---|
deny | The request is denied outright. Nobody is notified; the failed run stays attached and readable | Never offered |
pending (default) | The request stays open. The requester fixes and redeploys, cancels, or asks for manual review, which is when reviewers get notified | Offered |
manual | The request goes to manual review right away and reviewers are notified | Offered |
Keep pending when the requester can fix what failed, like a coverage threshold or a compliance rule they can address in code. Reserve manual for checklists the requester has no way to resolve on their own, and deny for checklists that should never involve a human at all.
Who can do what
Configuring guardrails and executing them are deliberately different jobs.
| Operation | Roles |
|---|---|
| Create, update, or delete checklist specs | secops, ops, admin |
| Link a checklist spec to an approval action | secops, ops, admin |
| Dry-run a checklist spec against a context | secops, ops, admin, developer |
| Sign off a manual item or retry an external one | the team that owns the run's NRN |
| Read checklist specs and runs | all roles, including member and troubleshooting |
See authorization for how roles and grants work.
What's next
- Items reference: the full contract of every item type, behavior, and validation rule
- Approval actions: everything an action can govern beyond deployments