ADR-111
Deliver the pause event outward — operator-configured CloudEvents webhook
accepted · 2026-08-06 · L0 L4
0ADR-111: Deliver the pause event outward — operator-configured CloudEvents webhook #
Context #
A blocking nika:prompt under a non-interactive surface journals a
workflow_paused event and exits cleanly with run state paused
(spec ADR-099 rider; emission site crates/nika-runtime/src/lib.rs:1275,
single-site per INV-024). The pause is durable — but silent outward. The
journal records it; nothing tells a human who has walked away. Discovery today
is polling (nika trace ls marks paused traces) or watching the terminal.
Every workflow system that pauses for humans ships an outbound signal:
AWS Step Functions hands out a task token at .waitForTaskToken; GitHub
deployment protection rules receive a webhook and answer with a callback;
CNCF Serverless Workflow correlates human callbacks on CloudEvents attributes.
The engine has all the ingredients — the pause payload (task, mode, message,
choices, approval ticket fields), an SSRF-defended HTTP effect crate
(nika-http: static + DNS-resolve + per-hop redirect re-check), and an
env-var configuration surface (NIKA_*) — but no delivery seam.
Non-goals that bound this decision: no daemon, no inbound listener, no language change. The workflow file never declares where notifications go — delivery is a deployment concern, so it rides operator configuration, never the contract.
Decision #
At the moment the engine journals `workflow_paused`, it also POSTs that event — as a CloudEvents 1.0.2 structured JSON envelope, optionally signed with Standard Webhooks headers — to an operator-configured URL. Default OFF. Delivery failure never affects the run.
Configuration (env — the engine's existing config surface) #
NIKA_NOTIFY_URL— the webhook target. Absent ⇒ the feature is OFF and the engine opens no socket (the sovereign default).NIKA_NOTIFY_SECRET— optional, Standard Webhookswhsec_base64 secret. Present ⇒ requests carry awebhook-signatureheader (v1,HMAC-SHA256 over{msg_id}.{timestamp}.{payload}).webhook-idandwebhook-timestampheaders are always sent (they cost nothing and give receivers an idempotency key for free).
The envelope (CloudEvents 1.0.2, structured mode) #
Content-Type: application/cloudevents+json. Required attributes per the
spec: specversion: "1.0", id, source, type. Ours:
type:sh.nika.run.paused(reverse-DNS per the CloudEvents convention)source: the run URI (trace identity)subject:task:<id>· the pausing task id, namespacedid: deterministic · sha256 overtrace_id:task, lowercase hex. Re-delivering the same pause yields the same id (consumers dedup for free); a later re-pause of the same task yields a new id. The contract holds by construction, not by chain position: the trace id is the fresh per-run mint the trace file is named from (a resume leg opens a NEW trace), and within one trace a task pauses at most once (the pause ends the run).time: RFC 3339, from the engine's injected clockdata: a curated subset of theworkflow_pausedjournal payload, not the frame verbatim ·workflow,task,mode,message?,choices?,approval_digest?, plus the two facts a remote surface needs to act:trace_pathand the renderedresume_hintteaching line. What stays on the machine: the journal'snote(the envelope speaks the same teaching throughresume_hint) and the approval ticket's replay-guard internals ·approval_shown_hash,approval_nonce,approval_minted_at_ms,approval_ttl_seconds. The resumed run validates--answeragainst those locally; only the digest rides, so the replay surface stays bounded. Masking is unchanged: the payload renders over the secret-marker scope upstream, exactly as the journal does.
One serde struct. No new dependency for the envelope; the signature needs
hmac (RustCrypto — sha2 is already in the tree for the chain).
Delivery discipline (per the CloudEvents HTTP-Webhook companion spec) #
Single POST, 3-second timeout, never follow a redirect (the SSRF per-hop
re-check in nika-http already refuses cross-target hops), any 2xx counts as
delivered. No retries in R1. The OPTIONS abuse-protection handshake is
explicitly out of scope: an operator-configured target IS the consent.
Sequencing — the pause path is sacred #
The notifier runs AFTER the workflow_paused journal write and BEFORE
process exit, awaited with its timeout (never spawned — the pause path exits
the process, and a detached task would be dropped mid-flight).
Every lane delivers · including an interactive TTY run, where the POST leaves before the terminal's in-process ask continues the run · because the human a gate waits on may be far from the keyboard, so the outbound signal must not wait on the local ask.
Its outcome is journaled as one of two additive event kinds:
notify_delivered(target host, duration)notify_failed(target host, error class — including the SSRF refusal class; the URL is judged by the samenika-httpfloor as every other engine egress)
Neither outcome changes the run's state: the run exits paused with the same
code whether the webhook succeeded, failed, or was never configured. The
notification is observable history, not control flow.
What this is not #
- Not a language surface: zero envelope change, zero new YAML, zero flags. The conformance contract binds required events and semantics, not journal bytes; the two kinds are engine-additive.
- Not a queue: one event, one POST, at-most-once. Consumers that need fan-out, retries or routing put a relay behind the URL (a self-hosted ntfy topic already works as-is for phone push).
- Not the answer path: answering stays
nika run … --resume <trace> --answer <task>=<value>(ADR-099). This ADR only makes the question heard.
Consequences #
Positive #
- Any surface — notification relays, dashboards, atelier tooling, CI — learns about a waiting gate the second it happens, through two boring, widely implemented standards (CloudEvents envelope, Standard Webhooks signature) instead of a bespoke format.
- The deterministic event id + always-on
webhook-idheader give consumers exactly-once processing without any server-side state on our side. - Sovereignty intact: OFF by default, operator-pointed, SSRF-floored, journaled. The workflow file stays a pure contract.
Negative #
- The pause path gains up to ~8 s of worst-case latency when a URL is
configured and the target is slow · the 3 s request budget
(
request.timeoutindeliver) plus the 5 s SSRF DNS-resolve advisory budget (DNS_RESOLVE_TIMEOUTinnika-http), which runs before and outside the request budget. Bounded and journaled; acceptable for a path whose next step is a human. No dynamic test exercises this bound today: the e2e "unreachable" scenario points at a closed loopback port and fails as instant connection-refused, never at the timeout. - Two more event kinds for run-report consumers to render.
- A secret in
NIKA_NOTIFY_SECRETis env-borne; store-backed references can follow later without changing the header contract.
Neutral #
- R2 candidates, deliberately deferred: multiple URLs; an
on_finishedsibling event; secret-store references for the URL/secret; surfacing paused runs as MCP tasks ininput_requiredstate with elicitation for the answer (per the MCP 2026-07-28 tasks extension — client support is still uneven, so it waits behind a flag until the webhook seam has proven the payload).
Tests (ship with the implementation — e2e unless noted) #
- Kind unit test — the two kinds serialize snake_case and class as Workflow.
- Default OFF — a pausing run with no
NIKA_NOTIFY_URLjournals nonotify_*kind and opens no socket. - Delivery · a local listener receives ONE structured CloudEvents POST whose
required attributes and curated
datamatch the pause payload; the trace gainsnotify_delivered. - SSRF refusal — a metadata-range URL yields no POST, a
notify_failedcarrying the SSRF class, and an unchangedpausedexit. - Failure is not control flow — an unreachable target still exits
pausedwith the same code, within timeout + margin. - Envelope golden — the serialized envelope pins the required CloudEvents attributes and the deterministic id.
- Signature vector — with a
whsec_secret configured, thewebhook-signatureheader verifies against an independent Standard Webhooks implementation's test vector.
Alternatives considered #
- Author-facing notify on pause (a workflow-declared hook): rejected —
couples the contract to deployment topology and breaks "the file is the
contract". The authored
nika:notifybuiltin keeps its own job (a DAG task), which structurally cannot fire at pause time. - A bespoke JSON payload without an envelope: rejected — CloudEvents costs one struct and buys an ecosystem of consumers; inventing a shape buys nothing.
- A daemon/queue with retries: rejected — a supervisor contradicts the engine's no-daemon posture; operators who need delivery guarantees put a relay they own behind the URL.
- Spawn-and-exit delivery: rejected — the process exits on pause; a detached task is a silent drop. Awaited-with-timeout is the only honest sequencing.
read at v0.109.2 · the decision record ships with the engine