AGPL-3.0-or-later · forever.

showcase · tier 4 · ops & support

Incident war room

Logs, status history and the runbook gathered in parallel, a typed timeline, and a recovery check before any draft.

The postmortem draft is ready before the retro is scheduled. · conformance-gated in nika-spec ↗ · re-proven at every push

  • 11tasksthe plan falls out of the bindings
  • 9wavesparallel by construction
  • 3verbs exercisedinvoke · exec · infer
  • T4the tierops & support

The whole file

11 tasks · 9 waves

The plan falls out of the bindings, nothing is scheduled by hand.

incident-war-room.nika.yamlsource
nika: v1workflow:  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 fileinputs:  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: truepermits:  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 }}   # « no » at the gate → 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: warningoutputs:  timeline:    value: ${{ tasks.timeline.output }}    description: "The reconstructed, typed incident timeline"  postmortem: ${{ tasks.postmortem.output }}

nika inspect · engine 0.106.1 · vendored graph, never re-derived

15 declared edges · 9 waves · the plan falls out of the bindings