docket apply --json and docket plan --json replace the human-readable output with one JSON
object per line (JSON-lines). This is what you reach for when a CI pipeline or dashboard needs to
consume the result programmatically instead of scraping text. docket validate --json does the same
for offline problems, and every stream has a JSON Schema.
Every event carries a version integer, pinned at 1. Consumers should branch on version so a
future schema change does not silently break them. Values marked sensitive - inputs declared
sensitive: true, or task fields tagged sensitive:"true" - are masked as ***. Masking covers
every string field a secret can reach, including name and play (a loop over a sensitive value
expands the task name) and the when / reason fields on play_skipped (a play predicate can
interpolate a sensitive input). It applies to every stream docket emits: the apply / plan run
stream, validate --json, and --list-tasks --json - including that stream's loop_item, whose
strings are masked at any depth, object keys included.
What is never masked is docket's own vocabulary: the type, phase, probe, and status fields
are fixed enums rather than recipe content, and masking one would emit a stream that fails the
schemas below.
name is the correlation key. Two runs of the same recipe emit the same name for the same task,
so a dashboard or a CI job comparing plan to apply can line them up. A task with no name: in
the recipe is named after the resource it manages -
dokku_config[app=api] - which is stable for the same reason.
Four caveats on masking, each of which can make two distinct tasks indistinguishable in the stream:
- A generated name embeds the task's identity field values. No field is ever both an identity key
and declared sensitive, but a
sensitive: trueinput interpolated into one still reaches the name, and it is masked there. A name that renders entirely as***also cannot be pasted back into--start-at-task, which matches on the real name. - Masking is substring replacement. A short sensitive value can mask a large part of two different
names into the same
***-bearing string. Pin aname:on the tasks a consumer must tell apart. - A sensitive value carrying leading or trailing whitespace masks its trimmed spelling as well as its literal one. Docket trims some of the text it builds - a loop item in a task name, for one - and substring replacement would otherwise miss the trimmed form, so both are masked everywhere they appear. Padding a secret therefore widens what masks rather than narrowing it.
- A sensitive value that a generated name has to quote masks its escaped spelling as well as its
literal one, for the same reason. An address quotes a key value holding a comma, a
], or a", and quoting escapes what it wraps, sosec"retreaches the name assec\"ret. Both spellings are masked everywhere they appear.
One event is emitted per play start, per task, and one summary at the end. The fields differ
slightly between apply and plan:
| Event | Required fields | Optional fields |
|---|---|---|
play_start |
version, type, name, ts |
host |
play_skipped |
version, type, name, ts |
when, reason |
warning |
version, type, play, name, reason, message, ts |
- |
task (apply) |
version, type, play, name, status (ok/changed/skipped/error), changed, state, desired_state, duration_ms, ts |
error, skip_reason, stdout, stderr, exit_code, ignored, commands, phase, group |
task (plan) |
version, type, play, name, status (ok/+/~/-/skipped/error), would_change, state, desired_state, duration_ms, ts |
reason, mutations, commands, error, phase, group |
summary (apply) |
version, type, tasks, changed, ok, skipped, errors, plays_skipped, duration_ms |
- |
summary (plan) |
version, type, tasks, would_change, in_sync, skipped, errors, plays_skipped, duration_ms |
- |
A few fields need a word of explanation:
skip_reasonaccompanies askippedapply task when a reason was recorded.erroron a task whose plan or apply failed, andmessageandreasonon awarningraised while planning, are rendered from the task's result by the same function an embedder calls,sdk.Diagnostics.erroris the diagnostic's message prefixed withssh:for a failure of the ssh transport anddokku:for anything else.stdout,stderr, andexit_codeare the failing command's output and are present only on an errored apply task.ignoredistruewhenignore_errorsswallowed the error, in which case the task counts toward neithererrorsnor the exit code.phaseisblock,rescue, oralwayson a child of a group;groupistrueon the group envelope itself.- On a plan task,
statemirrorsdesired_state. Plan never mutates, so it has no post-mutation state to report; the field exists sotaskevents have the same key set on both commands. mutationsis ordered deterministically for a given server state, so two plans of a server nothing has touched emit the same list in the same order and a consumer diffing them sees no churn. The list itemizes the change rather than the order the work happens in: an authoritativesetorclearsends the whole collection in a single command however many lines it carries.
A warning event precedes the task event it is associated with so consumers can correlate by
ordering. The reason is a stable machine key so consumers can branch on the category:
reason |
Emitted when |
|---|---|
deprecated |
A task whose type implements Deprecation() is about to run; message carries the deprecation notice. |
unknown_property |
A property task's probe found no matching key in the plugin's :report --format json payload (a stale key map or a dokku version that does not emit it). |
probe_rejected |
An older plugin rejected :report --format json outright, so the property task could not probe current state. |
probe_indeterminate |
A probe ran and was understood but could not answer, because the server will not reveal enough of the state to compare it - a dokku_registry_auth credential held by a docker credential helper or stored as an identity token, for example. The task reports drift and applies rather than assuming a match. |
service_image_drift |
A dokku_service_create task found the service running an image other than the one the recipe pins and left it alone, or could not read the running image to compare at all. See dokku_service_create. |
service_option_drift |
A dokku_service_create task found the service holding a create-time option (config_options, custom_env, memory, shm_size or a network) other than the one the recipe declares and left it alone. The message names the options that differ, never a custom_env value. See dokku_service_create. |
In every case message is masked, so a registered sensitive value that reaches the warning (for
example server stderr echoed by a rejected probe) renders as ***. --list-tasks --json does not
emit a separate warning event; instead, the list_task event for a deprecated task carries
"deprecated": true and a deprecation field with the message, masked the same way.
unknown_property, probe_rejected and probe_indeterminate are probe failures: this run could
not read the state, and another run against a different server or a newer plugin might.
service_image_drift and service_option_drift are the other kind - the state was read, and the
task is reporting a difference it is deliberately not reconciling, because dokku's remedy recreates
the service container (<service>:upgrade for an image, a stop and a start for an option). They
ride on an ok task, not a ~ one, so a recipe carrying that mismatch still exits 0 under
--detailed-exitcode; set image_drift: error on the task when a run should fail on it instead,
or image_drift: upgrade to reconcile it. A task type that can never read its state is a third thing, declared rather than
discovered, and it is not a warning at all. The list_task event carries it as
"probe": "unsupported" (the task reports drift on every run) or "probe": "partial" (only some
of its fields are read), each with a probe_caveat naming what cannot be read. Both keys are
absent for a task that probes everything it manages. probe_caveat is prose and is masked;
probe is an enum and is not. A recipe containing an unsupported task never exits 0 under
--detailed-exitcode.
One notice deliberately appears in no stream at all: the
--vars-file permissions warning is about a file, not a
task, so there is no play or name to correlate it against. It is written to stderr in every mode -
apply --json, plan --json, and validate --json alike - which leaves stdout untouched, so a
clean validate --json still writes nothing there.
Both task flavors include commands as an array of resolved, masked dokku command strings. It
is an array rather than a single string because some tasks (such as dokku_buildpacks) legitimately
run several commands, and an array keeps that structure for jq '.commands[]'. The plan array
reports the commands apply would run; the apply array reports what it did run. Both use the
same rendering, so plan and apply output stay byte-identical for the same logical operation.
A plan --json line for a config task with two new keys:
{"version":1,"type":"task","play":"tasks","name":"configure","status":"~","would_change":true,"state":"present","desired_state":"present","reason":"2 key(s) to set","mutations":["set KEY (new)","set SECRET (new)"],"commands":["dokku --quiet config:set --encoded api KEY=*** SECRET=***"],"duration_ms":58,"ts":"2026-04-26T11:30:00Z"}docket validate --json writes a different stream: one validate_problem object per problem, and
nothing at all when the recipe is clean. A clean run exits 0 with empty stdout, so a consumer can
treat any output as failure without parsing it. Every problem carries version, type, code, and
message; play, task, line, column, and hint appear when they are known. message and
hint are masked.
A task body that breaks several of its declared field constraints
yields one invalid_task_input problem per constraint, so a recipe is fixed in one pass. Each
carries field, the recipe key path at fault (password, users[1].hash), or for a rule over
several of an item's keys the item's path (users[1]). field is absent on a rule over several of
the task's own keys and on every other problem. It names a key, never a value, so it is not masked.
{"code":"unknown_task_type","column":7,"hint":"did you mean \"dokku_app\"?","line":4,"message":"unknown task type \"dokku_appp\"","play":"play #1","task":"task #1 \"typo\"","type":"validate_problem","version":1}
{"code":"missing_required_field","column":9,"line":8,"message":"missing required field \"app\" on dokku_config","play":"play #1","task":"task #2 \"configure\"","type":"validate_problem","version":1}code is a stable machine key. Branch on it rather than on message, which is prose and may be
reworded:
code |
Reported when |
|---|---|
yaml_parse |
The recipe is not parseable YAML. |
json5_parse |
The recipe is not parseable JSON5. |
hcl_parse |
The recipe is not parseable HCL, or holds something a recipe cannot: an HCL variable, function call or template. |
duplicate_key |
The same key appears twice in one mapping. |
recipe_shape |
The recipe is not a list of plays, or a play is not a mapping. |
task_entry_shape |
A task entry does not carry exactly one task-type key. |
empty_task_body |
A task-type key has a null body (dokku_app: with nothing after it). |
unknown_task_type |
The task-type key is not registered. hint carries a did-you-mean. |
unknown_key |
An unrecognized envelope key sits alongside the task-type key. |
envelope_key_type |
An envelope key has the wrong type (for example tags: as a string). |
play_key_type |
A play's targeting key has the wrong type or an unusable value (for example sudo: yes, which YAML 1.2 reads as a string, or a host: that is not [user@]host[:port]). |
duplicate_task_name |
Two tasks in one play share a name. |
block_shape |
A block / rescue / always clause is not a list of task entries. |
block_empty |
A block: clause contains no child tasks. |
block_orphan_clause |
A task entry declares rescue or always with no block. |
block_with_task_type |
A group entry also carries a task-type key. |
envelope_key_unsupported |
An envelope key is reserved for a future release but not yet implemented. hint names the tracking issue. |
task_body_decode |
The task body does not decode into the task's struct. |
missing_required_field |
A field tagged required:"true" is absent or zero. |
invalid_task_input |
The task body breaks one of its declared field constraints - a field required or refused in its state, mutually-exclusive fields, fields set together - or its own Validate() rejected a value, such as an enum or a format. |
template_render |
A {{ ... }} template failed to render. |
unsafe_input_value |
An input value would break the scalar it is substituted into. See special characters in values. |
input_missing |
(--strict) A required: true input has no default and no supplied value. |
invalid_input_name |
An input name is not a valid {{ .name }} variable - a hyphenated name, for example. |
invalid_input_type |
An input declares a type: docket does not implement. hint carries a did-you-mean. |
invalid_input_default |
An input's default: cannot be parsed as the type: it declares. An omitted default is the zero value and is never flagged. |
reserved_input_name |
An input name collides with a built-in flag. |
register_duplicate |
Two tasks register: the same name. |
loop_var_outside_loop |
.item or .index is referenced outside a loop:. |
expr_compile |
A when / changed_when / failed_when predicate does not compile. |
unknown_play_reference |
(--strict) --play names a play that does not exist. |
unknown_start_at_task |
(--strict) --start-at-task names a task that does not exist. |
vars_file_error |
A --vars-file could not be read or parsed. |
argument_error |
The command's own arguments are invalid. |
read_error |
The recipe could not be read. |
The three streams have machine-readable JSON Schemas, so a consumer can validate what it parses instead of trusting the tables above. Each is drafted against 2020-12 and describes one line, not the whole stream: split on newlines and validate each line independently.
| Stream | Schema |
|---|---|
apply --json, plan --json |
schemas/events-v1.schema.json |
apply --list-tasks --json, plan --list-tasks --json |
schemas/list-tasks-v1.schema.json |
validate --json |
schemas/validate-v1.schema.json |
docket schema also emits JSON, but it is not one of these streams: it is a
single document describing docket's task types rather than a line-per-event record of a run, and
its schema - schemas/task-catalog-v1.schema.json -
validates the whole document at once. Its version is its own, independent of the version on
the events above.
The saved plan docket plan --output writes is a whole document
too, described by schemas/plan-v1.schema.json. Its events array
records the same plays and tasks the plan --json stream reports, without timing or warnings, and
masked the same way - but its inputs hold every resolved value in the clear.
--list-tasks --json has its own schema because it is a different stream, not a subset of the run
stream: no task executes, no server is contacted, its per-task event is list_task rather than
task, there is no summary, and its play_skipped carries play where the run stream's carries
name and omits ts.
Every schema sets additionalProperties: false, and docket's own test suite validates real emitted
events against these files, so a field that exists in the code but not in the schema fails CI.
--json and --detailed-exitcode compose, so a pipeline can stream JSON to a dashboard while still
branching on the plan exit code:
docket plan --json --detailed-exitcode | tee plan.jsonlapply takes the same flag, which is the cheapest way to answer "did anything change?" without
parsing the stream at all: 0 means nothing changed, 2 means something did, 1 means an error.
Without it, apply exits 0 either way. The equivalent signal inside the stream is
summary.changed > 0 for the run, or task.changed per task:
docket apply --json --detailed-exitcode | tee apply.jsonl
# $? would be tee's status, not docket's; read the head of the pipeline instead.
case "${PIPESTATUS[0]}" in
0) echo "no changes" ;;
2) echo "changed" ;;
*) echo "failed" ;;
esacA load-time failure - an unreadable recipe, a parse error, an unknown task type - happens before the
emitter starts, so it produces no JSON on stdout and a human-readable message on stderr. Treat a
non-zero exit with empty stdout as a failure whose detail is on stderr, or run
docket validate --json first to get the same problems as structured events.
Do not read that the other way round: a non-zero exit does not imply empty stdout.
--list-tasks --json exits 1 when a when: fails to evaluate, and the stream is complete. For a
play when:, the failure is a play_skipped event whose reason carries the when error: <err>
form, followed by every remaining play's tasks. For a task when:, it is a list_task event
carrying "when_error": true, followed by every remaining task.
- Command reference - the
--jsonand--detailed-exitcodeflags - Task catalog - the
docket schemadocument, a separate JSON format - Wrapping docket from ansible-dokku - a worked consumer of these streams
- Task envelope - how
ignore_errorsshows up as"ignored": true