nika: v1
workflow:
id: incident-war-room
description: "parallel evidence → typed timeline → settle + recheck → refuse or draft → record"
model: ollama/qwen3.5:4b # local · zero key · `--model mock/echo` rehearses the whole file
inputs:
alert:
type: bool
default: false
description: "Ping on-call when the run settles. OFF by default so a rehearsal touches no webhook."
settle:
type: string
default: "30s"
description: "How long to let the fix propagate before the confirming re-poll"
status_url:
type: string
default: "https://status.internal.example.com/v1/services/checkout-api"
description: "The service status endpoint · polled twice"
const:
service: "checkout-api"
log_window: "90 minutes ago"
runbook: "./examples/fixtures/checkout-api-runbook.md"
secrets:
oncall_webhook:
source: env
key: ONCALL_WEBHOOK_URL
egress: # sanction the one send · the secret IS the URL
- to: "nika:notify"
host_from_self: true
permits:
exec: ["git"]
tools: ["nika:assert", "nika:fetch", "nika:notify", "nika:prompt", "nika:read", "nika:wait", "nika:write"]
net:
http:
# The status host is judged here even though the URL arrives as an input:
# an input moves the ARGUMENT, never the wall. Point --var status_url at
# another host and the poll is refused NIKA-SEC-004 until you name it here.
- "status.internal.example.com"
# `host_from_self:` above sanctions the FLOW (the secret may be the URL) —
# it does not grant the capability. The host stays unknown at check, so it
# is judged at RUN against this bound: name the escalation host here or the
# send is refused mid-run, after the tokens are already spent.
- "hooks.slack.com" # swap for wherever ONCALL_WEBHOOK_URL points
fs:
# The write path is built from `const.service`, so it is pinned before the
# run starts — but `check` reads straight past a const embedded in a longer
# string, so it is not declared for you. Written out literally, because a
# permit bound IS the wall and cannot interpolate (NIKA-AUTH-007).
read: ["./examples/fixtures/checkout-api-runbook.md"]
write: ["./incidents/checkout-api-postmortem.md"]
tasks:
# ── the human gate · FIRST, before any evidence is gathered ──
#
# All three trifecta legs are live here: a private read (the runbook),
# untrusted ingress (the status endpoint), and external egress (git, the
# status host, the write, the webhook). NEP-0002 requires a blocking human
# gate that DOMINATES every path to every egress-capable task, so it belongs
# at the root — a gate placed later cannot dominate, because the gathered
# evidence reaches the postmortem along its own data edge.
#
# On-call is already at the keyboard during an incident, so one confirmation
# up front costs nothing and authorizes the whole envelope at once.
approve:
invoke:
# blocking · no `default:` · a gate with a default is not a gate, and the
# checker agrees in both directions: add `default:` here and NIKA-SEC-009
# fires instead, because a defaulted prompt no longer dominates anything.
# The [headless-prompt] hint is the price of a real gate — pay it here.
# Pauses the run (exit 4) — resume with
# nika run <file> --resume <trace> --answer approve=true
tool: "nika:prompt"
args:
message: |
Incident war-room for ${{ const.service }} will:
· read git history for the last ${{ const.log_window }}
· poll ${{ inputs.status_url }} (twice, ${{ inputs.settle }} apart)
· read ${{ const.runbook }}
· write ./incidents/${{ const.service }}-postmortem.md
· ping the on-call webhook (armed: ${{ inputs.alert }})
Proceed? [yes/no]
# ── the gather wave · all three run in parallel, one per verb ──
# « What shipped just before it broke? » is the first question of every
# incident, and the runbook says so out loud.
deploys:
with:
go: ${{ tasks.approve.output }}
when: ${{ with.go == true }}
exec:
command: ["git", "--no-pager", "log", "--oneline", "--since=${{ const.log_window }}"]
on_error:
# Missing evidence is EVIDENCE. The stand-in is labelled so the timeline
# below is told the deploy log was unreadable and cannot quietly invent a
# release that never happened.
recover: "(deploy log unavailable · git could not be read here)"
status_history:
with:
go: ${{ tasks.approve.output }}
when: ${{ with.go == true }}
invoke:
tool: "nika:fetch"
args:
url: "${{ inputs.status_url }}"
mode: jq
jq: ".history"
retry:
# A status page mid-incident is exactly the endpoint that flaps. Retry is
# safe here because a GET is idempotent — the checker warns [retry-effects]
# when you retry something that is not.
max_attempts: 3
backoff_strategy: exponential
jitter: true
on_error:
recover: [] # offline · no history to reconstruct from
runbook:
with:
go: ${{ tasks.approve.output }}
when: ${{ with.go == true }}
invoke:
tool: "nika:read"
args: { path: "${{ const.runbook }}" }
# ── reconstruct · a TYPED timeline, not a paragraph ──
timeline:
with: # three value edges · the wave joins here
deploys: ${{ tasks.deploys.output }}
history: ${{ tasks.status_history.output }}
runbook: ${{ tasks.runbook.output }}
when: ${{ with.runbook != null && size(with.runbook) > 0 }} # « no » at the gate (null) or an empty answer · nothing to reconstruct
infer:
max_tokens: 1500
prompt: |
Deploys in the window ·
${{ with.deploys }}
Status history · ${{ with.history }}
Runbook · ${{ with.runbook }}
Reconstruct the incident timeline as events. Every event cites the
evidence it came from; if a source says it is unavailable, say the
timeline is incomplete rather than filling the gap.
schema:
# `additionalProperties: false` on EVERY object node — without it the
# shape is a suggestion and each provider returns a slightly different
# object. A typed timeline is the whole point of this task.
type: object
additionalProperties: false
required: [events]
properties:
events:
type: array
items:
type: object
additionalProperties: false
required: [at, what, evidence]
properties:
at: { type: string }
what: { type: string }
evidence: { type: string }
# ── settle, then confirm recovery before claiming it ──
settle:
after:
timeline: success # state, no data · wait only once there is something to confirm
invoke:
tool: "nika:wait"
args: { duration: "${{ inputs.settle }}" }
recheck:
after:
settle: success
with:
go: ${{ tasks.approve.output }} # the second network touch re-reads the decision too
when: ${{ with.go == true }}
invoke:
tool: "nika:fetch"
args:
url: "${{ inputs.status_url }}"
mode: jq
jq: ".current.state"
on_error:
# The rehearsal's stand-in says the service came back, so the green path
# is the one you see offline. Point --var status_url at a real endpoint
# and anything other than « operational » blocks the draft below — that
# refusal is the designed path, not a bug in the file.
recover: "operational"
confirmed:
with:
recovered: ${{ tasks.recheck.output == 'operational' }} # the whole check crosses as ONE boundary expression
invoke:
tool: "nika:assert"
args:
condition: "${{ with.recovered }}"
message: "Service is NOT back to operational: postmortem draft blocked"
# ── the draft · only after recovery is proven ──
postmortem:
with:
events: ${{ tasks.timeline.output }}
after:
confirmed: success # state, no data · recovery proven or no draft
infer:
max_tokens: 2000
prompt: |
Timeline · ${{ with.events }}
Write the postmortem draft · summary · impact · root-cause
hypotheses · 3 follow-up actions with owners left blank.
thinking:
# A reasoning budget, honoured by seats that support extended thinking
# and ignored by the ones that do not — declaring it never fails a run.
enabled: true
budget_tokens: 4000
save:
with:
postmortem: ${{ tasks.postmortem.output }}
invoke:
tool: "nika:write"
args:
path: "./incidents/${{ const.service }}-postmortem.md"
content: "${{ with.postmortem }}"
create_dirs: true
# ── the record · lands on EVERY outcome, including the refused one ──
#
# `after: {save: terminal}` admits all four settled states, so this task runs
# when the draft lands AND when `confirmed` refused it and `save` was
# cancelled — which is precisely the outcome on-call needs to hear about.
# It is a TASK, not an `on_finally:` hook: cleanup errors are LOGGED and DO
# NOT PROPAGATE (03 §on_finally · best-effort lane), so a webhook that dies
# in a hook dies in silence — measured, the run stays green with nothing
# sent. An alert you cannot trust to fail loudly is not an alert.
alert:
after:
save: terminal
with:
armed: ${{ inputs.alert }}
outcome: ${{ tasks.save.status }} # observe WHAT happened · same pass-set as the after edge
when: ${{ with.armed == true }} # OFF by default · a rehearsal touches no webhook
invoke:
tool: "nika:notify"
args:
channel: webhook
target: "${{ secrets.oncall_webhook }}"
message: "${{ const.service }} war-room settled · postmortem draft ${{ with.outcome }}"
severity: warning
outputs:
timeline:
value: ${{ tasks.timeline.output }}
description: "The reconstructed, typed incident timeline"
postmortem: ${{ tasks.postmortem.output }}