Checklist items
Every checklist 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 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, which is why a predicate can move between the two unchanged.
{
"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.
context. prefixWrite 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.
{
"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.
{
"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: 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:
{
"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_bywith no.id: the approval exposes its requester as a plain user id.$approval.requested_by.idresolves 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.
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.
{
"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.
{
"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:
{
"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.
and, or, not, true, or falseThe 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.
{
"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, 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.
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: what checklists solve and how a run works
- Specs and actions: creating specs via the API and connecting actions
- Approval request lifecycle: what happens around the run, from trigger to execution
- Policies: the same condition language in its single-verdict form, to be deprecated in favor of checklists