# AIM 3.3.0rc8: safe play/task/host progress **Service / wire / event version:** 1.0, stable additive 1.x. **Detail schema:** `play_task_host_v1`. **Canonical Ansible Core:** 2.19.11. **Qualification:** locally tested with simulated native command/callback fixtures; real Ansible 2.19.11 and managed-host acceptance is still required for this feature. ## 1. Select the feature without breaking existing clients Clients receive anonymous progress and aggregate counters by omitting `progress_mode`. Current Core accepts `summary` (the default) or `detail` in `RunRequest`. There is no `-vvv`, debug-output forwarding or raw-output option. Inspect `aimctl capabilities` / `AimService.capabilities()` first: ```json { "execution_progress": { "modes": ["summary", "detail"], "default": "summary", "request_field": "progress_mode", "detail_schema": "play_task_host_v1", "detail_event_kinds": ["play_started", "play_skipped", "play_stopped", "task_started", "host_result", "task_retry", "task_async_poll", "host_recap"], "static_source_labels_only": true, "raw_output": false, "error_policy": "fixed_diagnostic_hints", "qualification": "controller_acceptance_required" } } ``` This is the relevant capability fragment, not the entire response. Servers without the advertised detail capability can reject unknown request fields: do not send `progress_mode` to them. Absence of the capability means use the legacy default or explain that detailed progress needs a newer core. Administrative enablement and same-UID runtime readiness remain separate checks. For a known inventory host, prepare a request with detail selected: ```bash printf '%s\n' '{"api_version":"1.0","operation":"prepare","request":{"customer":"CUSTOMER","playbook":"debug_test_connection","hosts":["HOST"],"progress_mode":"detail"}}' | aimctl request ``` Substitute real catalog/customer/host identifiers. Input to `aimctl request` is **one JSON object on one line**, not pretty-printed multiline JSON. Preparation collects no password and contacts no managed host. Use the same normalized request (including the mode) and its revision for execution. Changing the mode changes the reviewed request and requires preparation/review again. An ordinary request never contains credentials; use the private provider/FD documented in `ADDON_API.md`. Detailed mode still emits existing `stage`, `progress`, `stats`, and `result` events. A detail renderer should ignore anonymous `progress` events to avoid duplicate task lines. An existing summary client can ignore unfamiliar event kinds. Product version is 3.3.0rc8; API, wire and event version strings stay 1.0. ## 2. Public event envelope and fields Every event has `event_version`, `run_id`, increasing `sequence`, UTC `timestamp`, and `kind`. On the wire it is wrapped as `{"type":"event","event":{...}}`. IDs (`play_id`, `task_id`) are opaque strings scoped to a run. Correlate with IDs, not names or the last task that happened to arrive. Results can interleave across hosts under a free strategy. Serial batches can enter the same play again and get new IDs. Repeated task labels are not unique identities. | Kind | Additional fields | Meaning | |---|---|---| | `play_started` | `play_id`, `label`, `label_redacted` | A play occurrence began; label is unexpanded source text or a fixed placeholder | | `play_skipped` | `play_id`, `reason: "no_hosts_matched"` | Native no-matching-host callback; not an authentication failure | | `play_stopped` | `play_id`, `reason: "no_hosts_remaining"` | Native no-hosts-remaining callback; not equivalent to a harmless no-match skip | | `task_started` | `play_id`, `task_id`, `label`, `label_redacted`, `handler` | A task/handler started; no arguments, module name, role path, source path or templated name | | `host_result` | `play_id`, `task_id`, `host`, `host_redacted`, `status`, `changed`, `ignored`, `details_redacted`, `error` | One aggregate host/task outcome | | `task_retry` | `play_id`, `task_id`, `host`, `host_redacted`, `attempt`, `details_redacted` | Native task retry, not AIM replay of the playbook | | `task_async_poll` | Same fields as `task_retry` | Async poll notification; no job ID or result payload | | `host_recap` | `host`, `host_redacted`, `counts` | Final counts for one logical inventory host | `host_result.status` is `ok`, `changed`, `skipped`, `failed`, or `unreachable`. `changed` is a boolean and can also be true on a failed task. `ignored` reflects native ignore-error/ignore-unreachable handling; a failed event is not by itself proof that the whole run failed. Rescue and ignored counts remain in final stats. `attempt` is an integer or null; it is always null when details are withheld. `host` is a reviewed logical inventory name, never a resolved connection address, delegation target, loop label or variable. Out-of-scope/unsafe names are represented as null with `host_redacted: true`. This can occur for trusted delegated/dynamic inventory work: host limits are an operational selection, not a security sandbox. A host name containing a supplied credential is also withheld. Every `counts` mapping has nonnegative integers for `ok`, `changed`, `failures`, `unreachable`, `skipped`, `rescued`, `ignored`. Host recaps are followed by the existing aggregate `stats` and final run result. Do not count progress lines to calculate a recap, assume one task header per host, or infer success from a zero process exit without the authoritative final response/result. A failure followed by rescue can still result in a successful native run. There is no invented `play_completed` event: use the actual following play/final stats boundary. Loops produce aggregate host/task outcomes, not item values/events. Async poll and retry are optional native notifications; fire-and-forget async work is not thereby certified complete. The console's exact visual layout is not the API. Example host outcome (IDs/timestamp illustrative): ```json { "event_version": "1.0", "run_id": "example-run", "sequence": 12, "timestamp": "2026-09-19T12:00:00+00:00", "kind": "host_result", "play_id": "p2", "task_id": "t1", "host": "host01.example.test", "host_redacted": false, "status": "unreachable", "changed": false, "ignored": false, "details_redacted": false, "error": { "code": "connection_refused", "message": "The connection was refused. Check the target listener, port and firewall.", "classification": "diagnostic_hint" } } ``` ## 3. Failure information without raw error text A host failure carries `error: {code, message, classification}`. Success/skip events carry `error: null`. Messages are fixed strings owned by core. They never contain the original result's `msg`, module arguments, exception, stdout or stderr. | Code | Interpretation | |---|---| | `connection_refused` | Native unreachable message matched a connection-refusal signature; check listener, port and firewall | | `connection_timeout` | Native unreachable message matched a connection timeout | | `name_resolution_failed` | Native unreachable message matched a DNS/name-resolution error | | `tls_verification_failed` | Native unreachable message matched certificate verification failure | | `authentication_failed` | Native unreachable message matched a recognized authentication rejection | | `permission_denied` | Failed task message matched an access/permission denial | | `host_unreachable` | No narrower supported hint; transport/authentication details remain withheld | | `task_failed` | No narrower supported hint; arbitrary module error text remains withheld | | `details_withheld` | Sensitive/no_log result; no diagnostic classification is exposed | Hints are derived from bounded native message signatures, **not a definitive root cause or proof that a supplied password was used**. Localized/unrecognized messages can produce generic errors. Never drive automatic credential retries, disable TLS validation, open firewall rules or replay operations from these hints. The existing RunResult error/remote-work flag remains authoritative for lifecycle decisions. Preflight errors still use the existing fixed service error codes; this is not a raw syntax-error/traceback channel. More detail may require an authorized operator's trusted terminal diagnostics, handled as potentially sensitive data. ## 4. Safety contract and unavoidable trust boundary Core reads the original parsed play/task `name` field, not Ansible's templated `get_name()` or rendered task fields. Missing, templated, overlong, control-bearing, URL-bearing or credential-assignment-like labels are replaced by fixed labels. Known supplied credential values matching a label are also withheld at the public boundary. Labels have at most 200 characters / 800 UTF-8 bytes; public host names at most 255 characters. Longer valid inventory identifiers can execute, but their name is withheld in the detail stream. Known true or potentially true `no_log` on a task, block, role or play suppresses its label. For dynamic no_log expressions core does not attempt to render the expression. Results marked `no_log`, censored, or containing hidden loop results retain only status/changed/ignored, allowed host and IDs. Their failures use `details_withheld`. Retry attempts are hidden for these results. **Static source labels and inventory names must themselves be non-secret.** No filter can identify every secret literally hard-coded in a name. A runtime-only sensitivity flag also cannot retroactively retract a static header already sent. This is a trusted controller/source contract, not a general secret-scanning engine or malicious-playbook sandbox. Add-on authors must never put credentials or variable values in labels. Dynamic names deliberately lose their expansion. Nothing here authorizes raw `debug` values, Checkmk configuration contents, registered variables, `invocation`, environment/command strings, loop items, exceptions, custom stats, module stdout/stderr, or `-v/-vv/-vvv/-vvvv` output. `raw_task_output` remains unsupported. Known password masking is defense in depth, not permission to pass arbitrary output through a redactor. Core validates the private callback schema, IDs, sequences and counts before building public events. Detail collection is bounded to 200,000 private frames (including legacy counters/handshakes), 50,000 play occurrences and 50,000 task IDs. These are safety bounds, not an estimated progress denominator. Malformed, truncated, out-of-order or incomplete streams cannot produce a successful run. Typical core errors are `invalid_event_stream`, `event_limit`, `event_bridge_unavailable`, and `event_bridge_incomplete`. A native failure may end before a complete recap: retain received events and show failure/unknown, never fabricate the missing recap. The event sink must remain responsive or enqueue into a bounded queue; it must not block on slow browsers. Core still uses the established process deadlines and cancellation. No output detail mode changes account permissions, credential precedence, execution enablement, or automatic retry policy. ## 5. Example plain-text renderer for an independent client This is an illustrative client function, not a new core CLI command. Feed it validated public event objects from your client after version/capability checks. Use text nodes/HTML escaping in a browser, not `innerHTML`; escape rich terminal markup as well. Treat labels as text, not as commands or markup. Persist only the minimal events your deployment policy needs and restrict job-log visibility. ```python class ProgressText: def __init__(self): self.tasks = {} self.active_task = None self.recap_started = False def __call__(self, event): kind = event.get('kind') if kind == 'play_started': print('\nPLAY [' + event['label'] + ']', flush=True) self.active_task = None elif kind == 'play_skipped': print('skipping: no hosts matched', flush=True) elif kind == 'play_stopped': print('stopped: no hosts remaining', flush=True) elif kind == 'task_started': self.tasks[event['task_id']] = event['label'] self.active_task = event['task_id'] prefix = 'HANDLER' if event['handler'] else 'TASK' print('\n' + prefix + ' [' + event['label'] + ']', flush=True) elif kind == 'host_result': task_id = event['task_id'] # Results can interleave: repeat the correct header, not the last name. if self.active_task != task_id: print('\nTASK [' + self.tasks.get(task_id, 'Task') + ']', flush=True) self.active_task = task_id host = event['host'] or 'host withheld' suffix = ' (ignored)' if event['ignored'] else '' error = event.get('error') if error: suffix += ' => ' + error['message'] print(event['status'] + ': [' + host + ']' + suffix, flush=True) elif kind in ('task_retry', 'task_async_poll'): print(kind + ': [' + (event['host'] or 'host withheld') + ']', flush=True) elif kind == 'host_recap': if not self.recap_started: print('\nPLAY RECAP', flush=True) self.recap_started = True counts = ' '.join(k + '=' + str(v) for k, v in event['counts'].items()) print((event['host'] or 'host withheld') + ' : ' + counts, flush=True) # Ignore legacy anonymous progress to avoid duplicate lines in detail mode. # The caller handles stage/stats/result and the authoritative final response. ``` A GUI timeline should key results by `(run_id, play_id, task_id, host)` where the host is visible. It must not merge all withheld-host records as one known machine. Keep the original event sequence for audit; a display regrouping is presentation only. The built-in `aim` terminal still uses its existing native Ansible output, not this example renderer. No add-on source was changed for this feature. ## 6. Native contracts consulted (not proof of native execution) Implementation targets Ansible Core 2.19.11 callback entry points, original source mappings and CallbackTaskResult public properties. Any dependence on these internals stays in the core-owned adapter, not in an add-on. - https://docs.ansible.com/projects/ansible-core/2.19/plugins/callback.html - https://docs.ansible.com/projects/ansible-core/2.19/reference_appendices/logging.html - https://raw.githubusercontent.com/ansible/ansible/v2.19.11/lib/ansible/plugins/callback/default.py - https://raw.githubusercontent.com/ansible/ansible/v2.19.11/lib/ansible/executor/task_result.py - https://raw.githubusercontent.com/ansible/ansible/v2.19.11/lib/ansible/executor/playbook_executor.py - https://raw.githubusercontent.com/ansible/ansible/v2.19.11/lib/ansible/playbook/base.py Follow `SANITY.md` for exact-runtime/controller acceptance before treating the new timeline as qualified in an installed service or add-on deployment. ## Final target outcomes Do not reconstruct final requested-target success from this live detail stream. `RunResult.target_summary` and `RunResult.targets` now provide Core-owned final accounting under `target_outcome_summary_v1` in both summary and detail modes. The detail stream remains for live presentation and diagnostics; the final target summary is authoritative for per-request host outcomes. See `TARGET_OUTCOMES.md`. ## Purposeful data is separate The 3.3.0rc8 `operation_result` channel does not loosen this host/task event contract. Only explicitly catalogued, typed and validated set_stats data is returned in the final result; see OPERATION_RESULTS.md. Arbitrary debug output and configuration bodies remain excluded from progress events. The Checkmk reader now publishes parsed redacted sections through its own declared report, not raw debug text.