ADR-068: Playbook runtime architecture
Status: Accepted Date: 2026-04-23 Related: ADR-067 (playbooks unified primitive), ADR-064 (gyrum-catalog + hex architecture), ADR-062 (design system + component strategy), ADR-061 (extract pipelines + projects libraries), ADR-060 (catalog-driven infrastructure), ADR-053 (frontend observability), ADR-050 (bearer auth for product metrics)
Decision (one paragraph)
We build an embedded, Postgres-backed playbook runtime inside
ai-research that executes playbooks (ADR-067) as first-class server-side
work. The runtime streams events and terminal output to the frontend over
SSE, accepts interactive replies over POST, and dispatches each
step to a pluggable StepExecutor — shell, ssh, claude, http, pipeline,
experiment, prompt, prompt_secret, approval, subplaybook. The queue is a
Postgres-backed job queue (the river library, SELECT … FOR UPDATE SKIP LOCKED) so durability and back-pressure live in the database we
already run, not a second piece of infrastructure. Every step declares
target: local | host:X | container:Y at parse time — there is no
bare-localhost default. Spec is YAML with a JSON-Schema validator
generated from a Go struct. Security hardening (who can run what, secret
vault, destructive-step annotation, audit chain) is explicitly deferred
to ADR-069; the frontend-side rendering contract (Svelte component,
form shapes, terminal pane) is deferred to ADR-070; extraction of
the runtime to its own service (gyrum-playbook-runtime) is deferred
to ADR-071 and triggered by operational need, not speculation.
Context
ADR-067 declared the playbook a unified primitive: one schema, one catalogue, one review cadence, six kinds today (journey, experiment, incident_response, service_runbook, release_flow, onboarding). The primitive settled the shape of an operational procedure.
Nothing executes one.
Today, when a human or an agent wants to "run" a playbook, they do one of:
- Open the Markdown file and follow the steps by hand (journey, runbook, onboarding).
- Run
bash experiments/exp-315-hetzner-audit/run-*.shfrom a laptop terminal (experiment). - Invoke
gyrum-complete-prfrom a laptop terminal (release flow). - Fall into an Alertmanager page and open a Slack thread (incident response).
Each path has a different substrate (human eyes, a shell on a laptop, an SDK CLI, a chat room). None of them streams progress back to the platform UI, pauses for a typed question, resumes after an answer, records a structured run envelope, or emits metrics into the distill observability stack (ADR-050, ADR-053). Ad-hoc work is done ad-hoc-ly and the trail is lost.
Concretely, four things are missing:
A runtime. No server-side engine takes a playbook id, resolves its front-matter and body, and executes the steps in order. Every screen that wants to "run the onboarding flow" or "run exp-316 again" has to reimplement orchestration.
Async + durability. A Hetzner audit run (exp-315) takes ~90 seconds on a cold cache and ~20 seconds on a warm one. A release flow can take ~10 minutes. Neither fits in an HTTP request window. Today nothing survives an ai-research restart mid-run, because nothing is persisted mid-run.
Interactivity. The operator's framing is explicit:
if we had a install grafana and it needed some expra answers the frontend user, it would be helpful if they could send back answers … i like when you deploy you show the termianl output
Playbooks that pause for a typed answer, a choice, or a yes/no approval are the majority of the useful ones (install-this-service, rotate-this-key, approve-this-destructive-change). Fire-and-forget is a subset, not the default.
A security boundary around arbitrary shell. Several kinds of playbook step ultimately run bash (
shellstep), or shell out tossh, or invokeclaudewith agent tooling. Each of these is a large security surface. No primitive to gate them exists today. We must not ship a shell-running web service without one — but we also must not block the walking skeleton on the full security model. This ADR names that gap and fences it.
The sibling constraint, from the same operator framing:
1 yaml 2 probably best on the server, i dont want the frontend doing any dirty work, this may even mean a message bus to handle them and they return a job no and provide status whilst they work
The frontend is thin. It displays progress; it does not orchestrate. Authoritative state lives on the server, in a persistent store, and the server is the only thing that executes.
This ADR layers the runtime on top of the ADR-067 data model. It is deliberately an architecture ADR — Go interfaces, SQL tables, HTTP endpoints, SSE payload shapes — because the contracts here outlive the implementation and every follow-on ADR (069, 070, 071) pivots on them.
Decision
1. Summary of gating choices (committed, not re-litigated)
Five decisions are settled in this ADR. Each is motivated in the sections below; the table is the quick index.
| # | Choice | Rejected alternatives | Gated by |
|---|---|---|---|
| 1 | Embedded in ai-research for Phase 1-3 | standalone service from day one | extraction is ADR-071 |
| 2 | Postgres-backed queue (river, SKIP LOCKED) |
NATS, Temporal, Redis Streams, in-memory only | re-evaluated at Phase 4 |
| 3 | SSE (GET) + POST reply, not WebSocket | WebSocket, long-poll, gRPC streaming | frontend contract is ADR-070 |
| 4 | Every step declares target: (no implicit local) |
default-to-localhost | local execution gated by ADR-069 |
| 5 | YAML + JSON-Schema from Go struct | JSON spec, DSL, HCL, custom format | §9 |
2. The state machine
A playbook_run is a row whose life is a short, enumerated state
machine. States are values in a Postgres enum; transitions are
explicit; every transition is a committed SQL transaction before the
runtime moves to the next step.
pending ──▶ running ──▶ completed
│ │
│ ├──▶ awaiting_input ──▶ running
│ │
│ ├──▶ failed
│ │
│ └──▶ cancelled
│
└──▶ cancelled
States:
pending— run row is written, queue job enqueued, no worker has claimed it yet.running— worker has claimed the run; current step is in flight.awaiting_input— current step is aprompt,prompt_secret, orapproval; the run is suspended, the SSE stream has emittedawaiting_input, and nothing will advance until a valid reply lands onPOST /input.completed— terminal; all steps returned success (or a step withon_error: continueadvanced past its own failure).failed— terminal; a step returned an error that was not tolerated.cancelled— terminal; a user hit the cancel endpoint or the run exceeded its max wall-clock.
Sub-state on the in-flight step lives on the playbook_run_steps row:
queued | running | awaiting_input | completed | failed:<reason>.
The run-level state is a projection of the step-level states.
On-restart behaviour
ai-research is restart-safe in the tier-1 sense (observability, DB-first writes) but it is not guaranteed up. When a replica restarts:
- On boot, the runtime queries for
playbook_runswithstate = 'running'whose owning worker (worker_id) is this replica's previous boot (worker_idincludes a boot nonce). - Each such run has its current
playbook_run_stepsrow markedfailed:interruptedin a single transaction. - The run transitions to
awaiting_inputwith a synthetic prompt:{question: "Step <name> was interrupted by a restart. Resume from this step, skip it, or abort the run?", schema: {type: enum, values: [resume, skip, abort]}}. - An
awaiting_inputSSE event is emitted; the operator's nextPOST /inputreply selects the recovery path.
This is deliberate: automatic resume is wrong for any step with side effects (ssh, shell, claude with tools, http POST). We make the operator confirm. The run is not lost; the next action is not guessed.
A run whose owning replica is confirmed dead (out of scope for this ADR — a future supervisor role) is handed to a new worker through the same awaiting_input handshake.
3. The step executor contract
The central extension point. A step executor is a single Go interface:
// StepExecutor runs exactly one playbook step.
//
// Implementations:
// - MUST stream stdout/stderr lines through `events` as they arrive,
// not buffered until the end.
// - MUST honour ctx.Done() for cancellation; a cancelled run will
// cancel the context within one worker tick (~100ms).
// - MUST NOT write to the playbook_runs or playbook_run_steps
// tables directly; state transitions are the runtime's job.
// - MUST return outputs as a flat map[string]any; nested structures
// are allowed but top-level keys are what `save_as:` binds.
// - SHOULD return a typed error from runtime/errors.go so the
// runtime can classify (tolerable / fatal / awaiting_input).
type StepExecutor interface {
// Kind returns the `type:` discriminator this executor handles
// (e.g. "shell", "ssh", "claude"). One executor per kind.
Kind() string
// Validate is called once, at parse time, before any run starts.
// It inspects the step's spec (after YAML → struct decoding) and
// returns an error if the step is malformed. Validation errors
// are reported before the run is enqueued, never mid-run.
Validate(step Step) error
// Execute runs the step. `inputs` is the result of resolving the
// step's `with:` block against the run's variable environment
// (previous `save_as:`, initial inputs, secrets).
//
// The executor writes events to `events`; the channel is
// buffered (default 256). A full channel causes the executor to
// block, which back-pressures stdout-chatty steps to the rate
// the consumer (SSE pump) can drain.
Execute(
ctx context.Context,
step Step,
inputs map[string]any,
events chan<- Event,
) (outputs map[string]any, err error)
}
Event payloads are a small, closed set:
type Event struct {
Kind EventKind // step_started, stdout, stderr, step_completed,
// awaiting_input, run_completed
StepID string // stable id, e.g. "step-3-install-grafana"
TS time.Time
Body map[string]any // kind-specific; see §8
}
The runtime itself owns the run_completed event (the executor
never emits it) and the awaiting_input → running transition (the
executor emits awaiting_input and returns a sentinel error
errAwaitingInput; the runtime persists the pause, suspends the
worker slot, and resumes the same executor instance when the reply
arrives).
Back-pressure
The events channel is buffered (default 256 events, tunable per
run). If the SSE pump cannot drain fast enough — a chatty shell step
emitting thousands of lines per second — the executor blocks on
send. This is on purpose: the alternative (drop events) loses
terminal output; the alternative (unbounded buffer) OOMs the
replica. Blocking the producer is the correct failure mode.
For steps that legitimately produce very high-rate output (a large
rsync, a database dump), the step's spec can declare
output_mode: summary | tail:N | full (default full). summary
stores a one-line digest; tail:N stores only the last N lines;
full keeps everything. This gives the playbook author an explicit
knob without changing the contract.
4. Built-in step types
Ten executors ship in Phase 1-4. Each has a fixed shape.
shell
Local bash. The most powerful and most dangerous. Gated behind the
step's target: declaration; target: local requires the playbook
front-matter to carry an explicit allow_local: true flag and an
entry in a future runtime allow-list (ADR-069). target: host:X
delegates to the ssh executor internally. target: container:X
runs inside a disposable container (Phase 4+).
- id: dump-disk
type: shell
target: local # REQUIRED — no default
run: "df -h"
timeout: 30s
save_as: disk_summary
ssh
Delegates to the gyrum-catalog SSH Executor port (ADR-064). The SSH executor already has the host-resolution and credential-handling shape; the runtime passes the resolved host alias, the command, and a callback that forwards streamed lines into the runtime's events channel. No secrets ever appear in the runtime's memory longer than needed to populate the SSH session.
- id: restart-grafana
type: ssh
target: host:ops-vps # resolves via gyrum-catalog
run: "sudo systemctl restart grafana-server"
timeout: 60s
claude
Calls the Anthropic SDK from Go directly — not claude -p CLI.
Rationale: fewer moving parts (no CLI binary, no subprocess), secrets
stay in-process, streaming tokens arrive as they are generated, and
the SDK wrapper centralises rate-limit back-off.
- id: summarise-diagnostics
type: claude
target: local # "local" for claude = in-process SDK call
prompt: |
Given the runbook diagnostics output below, summarise the three
most likely root causes in one paragraph each.
---
{{ outputs.dump_disk.stdout }}
model: claude-opus-4-7
expects: text # text | json
on_error: fail # fail | continue | default_to: "<value>"
timeout: 60s
save_as: diagnostics_summary
Every type: claude step must declare expects: and on_error:.
Claude calls are slow (2-10s typical, 30s tail) and non-deterministic;
the author cannot hand-wave either dimension. expects: json
triggers a JSON-mode SDK call plus a parse step; a parse failure is an
executor-level error and respects on_error.
http
Arbitrary REST call. Method, URL, headers, body, expected status, response capture. Used for calling out to gyrum services, GitHub API, etc.
- id: create-gh-issue
type: http
target: host:github.com
method: POST
url: "https://api.github.com/repos/{{ inputs.repo }}/issues"
headers:
Authorization: "Bearer {{ secrets.github_token }}"
body:
title: "Runbook triggered: {{ inputs.alert_id }}"
body: "{{ outputs.diagnostics_summary }}"
expect_status: [201]
save_as: issue
target: here is the hostname the HTTP call is going to — it is
recorded for audit and can be constrained by ADR-069.
prompt
Pauses the run, emits awaiting_input, waits for POST /input.
The reply is validated against a schema and stored in the variable
environment under the step's id.
- id: ask-grafana-hostname
type: prompt
target: local
question: "What hostname should Grafana be served at?"
schema:
type: string
pattern: "^[a-z0-9.-]+$"
default: "grafana.example.com"
save_as: grafana_host
prompt_secret
Same shape as prompt but the reply is written to the secret vault
(ADR-069) and referenced by id, never echoed back into
playbook_run_events. The terminal pane shows •••••••• for any
stdout line that contains the secret value (a cheap textual
scrub).
- id: ask-api-key
type: prompt_secret
target: local
question: "Paste the API key to store in the vault."
save_as: api_key_secret_id
approval
Boolean gate. Pauses until POST /input with value: approve or
value: reject. Reject is a fatal step error (run → failed); the
on_error: knob lets the author override to continue.
- id: confirm-destructive
type: approval
target: local
question: "This will delete the old Grafana config. Proceed?"
pipeline
Invokes a named gyrum-pipelines run (ADR-061). The pipeline runs
in-process (library call, not a subprocess), streams its step events
through the same events channel, and returns the pipeline's final
outputs as this step's outputs.
- id: run-hetzner-audit-pipeline
type: pipeline
target: local
pipeline: hetzner-audit
with:
project_id: "{{ inputs.project_id }}"
save_as: audit_result
experiment
Invokes a named experiment (by its ADR-067 kind: experiment id).
The experiment's run-*.sh is executed via the shell executor
under the hood, with its declared inputs/outputs surfaced through
the playbook runtime's variable environment. This is the migration
bridge — existing experiments become step executors without being
rewritten.
- id: audit-hetzner
type: experiment
target: host:ops-vps
experiment: hetzner-audit
save_as: hetzner_report
subplaybook
Invokes another playbook by its kind:id reference (same syntax as
ADR-067 related: fields). The sub-run is a full playbook_run
row with its parent's id on parent_run_id. Events from the sub-run
are forwarded into the parent's SSE stream prefixed with the sub-run
id. Max nesting depth is 5; deeper nesting fails at parse time.
- id: run-release-flow
type: subplaybook
target: local
playbook: release_flows:gyrum-complete-pr
with:
pr_number: "{{ inputs.pr_number }}"
save_as: release_result
The ten executors above are the Phase 1-4 set. Adding an eleventh
(e.g. type: slack-post, type: github-pr-create) is a new
implementation of StepExecutor plus a registry entry; the contract
does not change.
5. Queue + durability
A run is a row. A step is a row. An event is a row (bounded retention). The queue is a table. One Postgres, three concerns.
The river library
We use river — a pure-Go,
Postgres-backed job queue using SELECT … FOR UPDATE SKIP LOCKED.
Rationale:
- Pure Go, no cgo, fits the ai-research module stack.
SKIP LOCKEDis the correct primitive for work distribution across replicas without a coordinator.- Job state, retries, dead-letter are tables we can inspect with
psql. There is no extra observability story to write; our existing slog + distill metrics wrap the worker loop. - No second piece of infrastructure to operate. The Postgres we already back up is the only backing store.
river is a dependency, not an architecture commitment. If it goes
unmaintained, the port is ~400 lines of SQL — we wrote a smaller
equivalent for earlier experiments. We would only move off Postgres
(to NATS or Temporal) if the queue becomes a throughput bottleneck;
that evaluation happens at Phase 4.
Schema sketch
CREATE TYPE playbook_run_state AS ENUM (
'pending', 'running', 'awaiting_input',
'completed', 'failed', 'cancelled'
);
CREATE TABLE playbook_runs (
id uuid PRIMARY KEY,
playbook_kind text NOT NULL, -- ADR-067 kind
playbook_id text NOT NULL, -- ADR-067 id
playbook_version text NOT NULL, -- content hash of the playbook file
state playbook_run_state NOT NULL DEFAULT 'pending',
parent_run_id uuid REFERENCES playbook_runs(id),
actor text NOT NULL, -- github handle of the initiator
initial_inputs jsonb NOT NULL,
outputs jsonb NOT NULL DEFAULT '{}',
worker_id text, -- replica + boot nonce
started_at timestamptz,
finished_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
CHECK (parent_run_id IS NULL OR id <> parent_run_id)
);
CREATE INDEX ON playbook_runs (state) WHERE state IN ('pending', 'running', 'awaiting_input');
CREATE INDEX ON playbook_runs (playbook_kind, playbook_id, created_at DESC);
CREATE INDEX ON playbook_runs (parent_run_id);
CREATE TABLE playbook_run_steps (
id uuid PRIMARY KEY,
run_id uuid NOT NULL REFERENCES playbook_runs(id) ON DELETE CASCADE,
step_index int NOT NULL, -- 0-based, matches YAML order
step_id text NOT NULL, -- author's `id:` field
step_type text NOT NULL, -- shell / ssh / claude / …
target text NOT NULL, -- local / host:X / container:Y
state text NOT NULL, -- queued / running / awaiting_input / completed / failed:<reason>
inputs jsonb NOT NULL,
outputs jsonb NOT NULL DEFAULT '{}',
error text,
started_at timestamptz,
finished_at timestamptz,
UNIQUE (run_id, step_index)
);
CREATE TABLE playbook_run_events (
id bigserial PRIMARY KEY,
run_id uuid NOT NULL REFERENCES playbook_runs(id) ON DELETE CASCADE,
step_id text,
kind text NOT NULL, -- step_started / stdout / stderr / …
body jsonb NOT NULL,
ts timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX ON playbook_run_events (run_id, id);
Events are append-only. A nightly job (Phase 2+) truncates events for runs older than 30 days, keeping the run + step rows for historical aggregation.
Transaction boundaries
State transitions are committed before the next step runs. The sequence for a healthy run is:
- Client
POST /runs→ row insertedstate: pending,riverjob enqueued, txn commits. - Worker claims job (
SKIP LOCKED), updates run tostate: running,worker_idset; txn commits. - For each step:
- Insert
playbook_run_stepsrowstate: running; txn commits. - Executor runs; events are written to
playbook_run_eventsas they arrive (single-row inserts, no txn per event — batched). - On success: update step to
completed, merge outputs intoplaybook_runs.outputs; txn commits.
- Insert
- Update run to
completed; emitrun_completedevent; txn commits.
The invariant: at every commit boundary, the run row and its
step rows are internally consistent. A replica crash mid-step leaves
the run in state: running with a running step whose worker_id
matches the dead replica; restart recovery (§2) detects and repairs.
6. Streaming protocol (SSE)
Endpoint surface
POST /api/v1/playbook-runs
GET /api/v1/playbook-runs/{id}
GET /api/v1/playbook-runs/{id}/events # SSE
POST /api/v1/playbook-runs/{id}/input
POST /api/v1/playbook-runs/{id}/cancel
GET /api/v1/playbook-runs?kind=…&status=… # list, paginated
All endpoints sit behind the existing ai-research auth (session
cookie + CSRF, per ADR-036). The SSE endpoint additionally accepts
a short-lived query-string token for the browser's EventSource
(which cannot send custom headers) — the pattern we already use for
distill metrics (ADR-050) and will formalise in ADR-069.
POST /playbook-runs
{
"playbook": "service_runbook:alertmanager-debug",
"inputs": {
"alert_id": "alert-0xABCD",
"service": "grafana-server"
},
"idempotency_key": "client-chosen-uuid"
}
Returns:
{
"run_id": "0195b3ee-…",
"state": "pending",
"events_url": "/api/v1/playbook-runs/0195b3ee-…/events"
}
Idempotency: an identical (actor, playbook, inputs, idempotency_key)
tuple within 5 minutes returns the existing run_id instead of
creating a second row.
GET /playbook-runs/{id}/events — SSE stream
event: run_started
data: {"run_id":"0195b3ee-…","playbook":"service_runbook:alertmanager-debug","actor":"@jon","ts":"2026-04-23T14:00:00Z"}
event: step_started
data: {"step_id":"dump-disk","step_index":0,"type":"shell","target":"host:ops-vps","ts":"2026-04-23T14:00:01Z"}
event: stdout
data: {"step_id":"dump-disk","line":"Filesystem Size Used Avail Use% Mounted on","ts":"2026-04-23T14:00:01.123Z"}
event: stdout
data: {"step_id":"dump-disk","line":"/dev/sda1 100G 84G 12G 88% /","ts":"2026-04-23T14:00:01.124Z"}
event: step_completed
data: {"step_id":"dump-disk","exit_code":0,"outputs":{"stdout":"…","exit_code":0},"ts":"2026-04-23T14:00:01.250Z"}
event: step_started
data: {"step_id":"ask-api-key","step_index":1,"type":"prompt_secret","target":"local","ts":"2026-04-23T14:00:01.251Z"}
event: awaiting_input
data: {"step_id":"ask-api-key","question":"Paste the API key to store in the vault.","schema":{"type":"string"},"ts":"2026-04-23T14:00:01.252Z"}
…client POSTs /input…
event: step_completed
data: {"step_id":"ask-api-key","outputs":{"secret_id":"vlt_01J…"},"ts":"2026-04-23T14:00:05.000Z"}
event: run_completed
data: {"run_id":"0195b3ee-…","state":"completed","outputs":{…},"ts":"2026-04-23T14:00:10.000Z"}
Event ids are monotonic per run (Last-Event-ID header supported
for reconnect). A client reconnecting within the retention window
replays events strictly greater than its last seen id — no missed
output, no duplicate output.
POST /playbook-runs/{id}/input
{
"step_id": "ask-api-key",
"value": "sk-ant-…"
}
Returns 204 No Content on success. The runtime validates value
against the step's schema: and, on success, resumes the worker
slot (the prompt_secret executor's Execute returns from its
block-on-channel). On schema failure, returns 400 with the
validation error; the run remains awaiting_input so the UI can
re-prompt without a new step id.
Why SSE and not WebSocket
SSE is one-way (server → client) plus a separate POST for the client → server direction. That asymmetry matches our data flow exactly — we stream a lot of lines out; we send a small number of replies in. WebSocket gives us a bidirectional channel we would half-use, plus framing, plus proxy quirks (Caddy handles SSE trivially; WS needs header forwarding and timeout tuning).
SSE also degrades to a plain curl — a developer investigating a
hung run can literally curl -N /events and read the stream.
WebSocket cannot do that without a dedicated client.
7. Target restriction — target: on every step
Every step declares where it runs. There is no implicit default.
target: local
target: host:<alias> # alias resolves via gyrum-catalog
target: container:<image> # runs inside a disposable container (Phase 4+)
This is enforced at parse time, not runtime. A playbook missing
a target: on any step fails YAML → struct decoding with a
specific error. The operator cannot accidentally ship a playbook
that runs bash on the platform host because they forgot a field.
Why this matters. target: is the hook that ADR-069 will hang
its allow-lists and audit annotations on. A step that declares
target: local is asking for the most privileged execution context
available; that declaration is explicit, reviewable in the playbook
file, and recorded in playbook_run_steps.target for every run.
target: local in Phase 1. Allowed, because the walking
skeleton needs it. Gated by a runtime-wide allow-list:
// ai-research config
runtime.local_execution_enabled: true | false
runtime.local_execution_allowlist: [release-flow:gyrum-complete-pr, …]
Before ADR-069 lands, the allow-list is operator-managed and tiny (one or two playbooks). After ADR-069, the allow-list mechanism is replaced by the full security model (signed playbooks, role-based permissions, secret-vault reads).
target: container:<image>. The Phase 4 answer to "shell
playbooks need to run untrusted code". A disposable container
(Docker, or firecracker via a future abstraction) is the sandbox.
Spec-ed here; not built in Phase 1.
8. Event payload shapes (full reference)
All SSE data: fields are JSON. Fields marked ? are optional.
| Event | Field | Type | Notes |
|---|---|---|---|
run_started |
run_id |
uuid | |
playbook |
kind:id |
||
actor |
string | GitHub handle | |
ts |
ISO-8601 | ||
step_started |
step_id |
string | author's id: |
step_index |
int | 0-based | |
type |
string | executor kind | |
target |
string | resolved target | |
ts |
ISO-8601 | ||
stdout |
step_id |
string | |
line |
string | one line (LF-delimited) | |
ts |
ISO-8601 | ||
stderr |
step_id |
string | |
line |
string | ||
ts |
ISO-8601 | ||
step_completed |
step_id |
string | |
exit_code |
int? | present for shell/ssh | |
outputs |
object | the step's outputs map | |
ts |
ISO-8601 | ||
step_failed |
step_id |
string | |
error |
string | typed error class + message | |
on_error |
string | what the runtime did (fail/continue/default_to) |
|
ts |
ISO-8601 | ||
awaiting_input |
step_id |
string | |
question |
string | author's question: |
|
schema |
object | JSON-Schema fragment | |
default? |
any | ||
is_secret |
bool | prompt_secret → true | |
ts |
ISO-8601 | ||
run_completed |
run_id |
uuid | |
state |
string | completed / failed / cancelled |
|
outputs |
object | merged run outputs | |
ts |
ISO-8601 |
The event schema is versioned: a v: field at the top level, v: 1 for this ADR, bumped on any breaking change. The
frontend-observ SDK already uses this pattern (ADR-053).
9. YAML spec + JSON-Schema validator
The spec is YAML — it matches the playbook front-matter convention (ADR-067), it is diff-reviewable, and humans write it.
The source of truth is a Go struct. go generate walks the
struct and produces a JSON-Schema artifact committed under
schemas/playbook-runtime.schema.json. Consumers:
- CI runs a YAML linter that validates every playbook's runtime section against the schema. Broken spec fails the PR.
- Editor LSP (a thin wrapper around
redhat.vscode-yaml) autocompletes field names and flags unknown fields inline. - The runtime itself uses the Go struct directly (no schema-at-runtime parsing; the struct is the parser).
Struct sketch (abridged):
type Playbook struct {
// ADR-067 front-matter fields — kind, id, title, persona, owner, etc.
FrontMatter PlaybookFrontMatter
// ADR-068 runtime section — present when the playbook is executable.
Runtime *PlaybookRuntime `yaml:"runtime,omitempty"`
}
type PlaybookRuntime struct {
Inputs []InputSpec `yaml:"inputs"`
Steps []Step `yaml:"steps"`
Outputs []OutputSpec `yaml:"outputs,omitempty"`
Timeout Duration `yaml:"timeout,omitempty"` // whole-run cap
}
type Step struct {
ID string `yaml:"id"`
Type string `yaml:"type"` // shell / ssh / claude / …
Target string `yaml:"target"` // REQUIRED
With map[string]any `yaml:"with,omitempty"` // templated
When string `yaml:"when,omitempty"` // expression
SaveAs string `yaml:"save_as,omitempty"`
OnError string `yaml:"on_error,omitempty"`// fail / continue / default_to:…
Timeout Duration `yaml:"timeout,omitempty"`
// Kind-specific fields are carried in a sidecar map and validated
// by the executor's Validate(step) at parse time.
Raw map[string]any `yaml:",inline"`
}
The Raw sidecar is how per-executor fields (run:, prompt:,
url:, playbook:) ride without the top-level struct knowing them.
The executor's Validate() method decodes Raw into its own
kind-specific struct and errors if fields are missing, unknown, or
mistyped.
Templating: we use a tiny, audited expression language (Go's
text/template plus a sprig subset, explicitly whitelisted
functions). No arbitrary code execution inside templates. Expression
evaluation is pure, no I/O.
10. Frontend integration
In scope for this ADR: the contract. The rendering implementation details are ADR-070.
A Svelte component consumes the SSE stream:
<PlaybookRunner
playbookId="service_runbook:alertmanager-debug"
initialInputs={{alert_id, service}}
on:completed={(e) => navigateToOutputs(e.detail.outputs)}
/>
Internally the component:
POST /playbook-runs→ receivesrun_id+events_url.- Opens
new EventSource(events_url). - For each
stdout/stderrevent, appends a line to a terminal pane. ANSI colours are rendered byanser— a ~4KB library that produces HTML from ANSI-escaped text. Notxterm.js— it is ~300KB, brings a full VT emulator we do not need, and its DOM shape fights Svelte's reactivity. - For each
awaiting_inputevent, renders a form derived from the step's JSON-Schema fragment; the operator's submit becomesPOST /input. - For
run_completed, emits a Svelte event upward so the host page can react (navigate, refresh a list, display a report).
The component ships first inside ai-frontend. Once it has two
independent callers (the Platform UI runbook page and the Ops UI
release page), it migrates to gyrum-ui as <gy-playbook-runner>
per ADR-062's "prove then lift" pattern.
The frontend never touches the database, never has an executor, never orchestrates. Its job is: show progress, collect replies, display the final report.
11. Observability
Metrics (via distill, ADR-050 / ADR-053):
| Metric | Type | Labels | Question answered |
|---|---|---|---|
playbook_run_started_total |
counter | kind, playbook_id, actor |
"how often does this playbook get run?" |
playbook_run_completed_total |
counter | kind, playbook_id, result (completed/failed/cancelled) |
"what's the success rate?" |
playbook_step_duration_seconds |
histogram | type, playbook_id |
"which step types are slow? which playbooks have a slow step?" |
playbook_awaiting_input_duration_seconds |
histogram | playbook_id, step_type |
"how long do operators take to reply? which prompts are blocking?" |
playbook_events_dropped_total |
counter | reason |
"are we back-pressuring? are we losing output?" (should always be 0 — channel blocks, not drops) |
playbook_runs_in_flight |
gauge | kind |
"load" |
playbook_runs_awaiting_input |
gauge | kind |
"how many operators are we waiting on right now?" |
Cardinality: playbook_id is bounded by the catalogue (≤ a few
hundred long-term); actor is bounded by team size. Both safe per
ADR-005.
Structured logs: every worker loop logs slog.With("run_id", run_id, "step_id", step_id, "playbook", kind+":"+id) once per run
claim. Every step emission also includes those fields. A grep run_id=<uuid> against the ai-research logs yields the full story
of any run.
Tracing: OpenTelemetry spans — playbook.run wraps the whole run,
playbook.step wraps each step, executors optionally add
kind-specific spans (e.g. claude.sdk.call). Propagation from the
originating HTTP request is automatic.
12. Claude-step safety
Claude steps deserve a dedicated section because they fail differently from every other step.
Slow. A single call is 2-10s typical, 30s tail. Several calls
in a sequence compound. The step-level timeout: defaults to 30s;
run-level timeout: defaults to 10 minutes.
Non-deterministic. Two identical prompts can produce different
outputs. on_error: is therefore required on every type: claude step; the author cannot implicitly assume "the model will
get it right".
Rate-limited. The SDK wrapper handles 429s with exponential
back-off, up to the step's timeout. If the timeout is hit, the
step fails and on_error: takes over.
JSON mode. expects: json triggers the SDK's JSON mode plus a
json.Unmarshal into map[string]any. Parse failure is a typed
error (ErrClaudeJSONParse) distinct from a timeout or an API
error, so on_error: default_to: can target it specifically.
Prompt injection. Outputs of earlier steps flow into the
prompt: template via {{ outputs.X.stdout }}. Untrusted input
means a shell output can contain "ignore previous instructions…".
Mitigations:
- The Anthropic SDK wrapper wraps user-templated content in an
explicit
<untrusted>…</untrusted>delimiter and prepends a system prompt that tells the model to treat that content as data, not instructions. - For
type: claudesteps inside a playbook whosetarget:history includes a shell run, ADR-069 will require atrust: untrustedmarker that downgrades the model's allowed tools.
No tool use in Phase 1-3. A type: claude step is a one-shot
prompt → text / JSON. Tools (code execution, web fetch, file
access) are a Phase 4+ feature with its own ADR; the target:
field will be how we gate per-tool permissions.
13. Mapping existing artifacts
The primitive lands on a live fleet. Nothing is thrown away.
| Existing artifact | Becomes | Work |
|---|---|---|
| Import wizard (ai-frontend multi-step onboarding flow) | kind: playbook, id: import-project with steps: covering the wizard's screens |
New playbook file; the wizard frontend becomes a <PlaybookRunner> consumer |
| exp-315 Hetzner audit | kind: playbook, id: hetzner-audit (or kind: experiment playbook per ADR-067 with a runtime section added) |
Wrap the existing run-*.sh as a type: experiment step |
| exp-316 GitHub audit | kind: playbook, id: github-audit |
Same shape |
gyrum-complete-pr release flow |
kind: release_flow, id: gyrum-complete-pr — already the ADR-067 migration step 2 playbook, now with a runtime section |
Steps: preflight checks → review gates → merge → post-merge; some are shell, some are http, one is approval |
| gyrum-catalog SSH Executor port | The backing port for type: ssh |
No new work — the port already exists (ADR-064) |
| gyrum-pipelines runs | The backing library for type: pipeline |
No new work — library exists (ADR-061) |
| Existing runbooks without a runtime section | kind: service_runbook playbooks, read-only today |
Add runtime: section when they become executable |
The rule: every existing piece becomes either a step executor, a playbook spec, or both. No parallel system.
14. Phased build plan
| Phase | Scope | Duration | Exit criteria |
|---|---|---|---|
| 0 | ADRs 067, 068, 069, 070 merged | this week | this ADR + the three siblings land |
| 1 | Walking skeleton: type: shell only, synchronous (in-request), one POST endpoint, one playbook (release_flow:gyrum-complete-pr dry-run) |
1 week | A human can POST a run id and see it complete in the response; logs show structured events |
| 2 | Async + streaming: PG queue, SSE endpoint, terminal pane in ai-frontend | 1 week | Operator kicks a run from the UI, watches stdout stream live, sees run_completed event |
| 3 | Interactive input: prompt, prompt_secret, approval executors |
3 days | An install-grafana-style playbook pauses, asks, resumes |
| 4 | Step plugins: ssh, claude, pipeline, experiment, http, subplaybook |
ongoing | Each plugin has a green test, a runbook of its own, a metric in the dashboard |
| 5 | Security hardening per ADR-069 | 1 week | local execution behind a signed allow-list; secret vault live; destructive-step gating live; audit chain extends existing ADR-039 hash chain |
| 6 | Migration: import wizard, experiments, release flow, runbooks → playbooks | ongoing | One per PR; ADR-067 §7 migration plan is the index |
| 7 | Observability polish: dashboard pack per ADR-046; alerts per ADR-023 | 1 week (after Phase 2) | One "playbook runtime" dashboard; burn-rate alert on playbook_run_completed{result="failed"} |
Phase 1 is deliberately the smallest useful thing — one playbook, one step type, synchronous. The point is to de-risk the contract end-to-end before we pay for the queue infrastructure. Phase 2 adds the queue; everything after is additive.
15. Consequences
Harder.
- New service surface in ai-research. The runtime is not small: ~6 HTTP endpoints, ~10 executors, a state machine, a queue integration, SSE pump, event retention. We budget this explicitly and use the phased plan to keep each PR ≤ 800 lines.
- Postgres is load-bearing for run state. The existing operational disciplines (nightly backup, restore drill per ADR-020 disaster-recovery) now cover a new kind of data. Retention policy: events 30 days, runs + steps indefinitely, PII never present in either (secrets go to the vault, not the row).
- Every new screen action is a YAML + executor pair. A developer who wanted to add a "click here to restart Grafana" button now writes a playbook and maybe an executor, not a bespoke backend handler. This is the point (uniform audit, uniform observability, uniform pause-for-input) but it is friction in the short term.
- Composition via
subplaybookrisks infinite recursion. Max depth is 5, enforced at parse time by walking therelated:+subplaybook:graph. Cycles fail parsing loudly. - Arbitrary shell is a huge security surface. We name it, we
gate it behind
target: local, we gate that behind an allow-list, and we fence the hardening in ADR-069. Production playbooks with ashellstep do not ship before ADR-069 lands. - SSE has a concurrency footprint. One open SSE connection per active run per viewing client. ai-research's HTTP server (net/http with a large ulimit) handles thousands. We revisit if we ever need tens of thousands of concurrent viewers, which is not a 2026 problem.
- Retrofit cost. The migration (§13) touches the import wizard, ~316 experiments, the release flow, and every runbook that wants a runtime section. ADR-067's 30-40 PR migration plan now has a sibling runtime-section migration plan of similar size.
Easier.
- One control plane for operational work. "Run the onboarding flow", "replay exp-315 on a fresh VM", "execute the release flow" all go through the same runtime, the same SSE stream, the same terminal pane, the same metrics. An operator learns one thing.
- Frontend becomes thin and forgettable. New operations do not
require frontend work beyond pointing
<PlaybookRunner>at a new id. Design reviews shrink. - Agents and humans share a substrate. A Claude agent that
needs to "restart the service" POSTs to the same endpoint a
human uses. Audit logs don't need to distinguish "human ran
this" vs "agent ran this" —
actoris either. - The gaps KPI gets teeth. ADR-067's "% playbooks with known gaps" becomes "% playbook runs that completed successfully". Trend data on actual operational health replaces trend data on self-reported gap counts.
- Interactivity is first-class. "What hostname should I use?"
mid-run is a
promptstep, not a back-and-forth in Slack. The playbook is the transcript.
What we sign up to maintain.
- The
StepExecutorinterface. Adding an executor is easy; changing the interface is a breaking change across every executor we have shipped. We budget interface churn to once per year, planned. - The SSE event schema. Versioned; bumping
v:is an ADR. - The Postgres schema. Standard migration discipline (one forward migration per PR, never squash the history).
- The max depth of 5 for
subplaybook. Lowering it is easy; raising it requires demonstrated need. - The 30-day event retention. Long-term audit requires a separate append-only store (ADR-038 audit sink, ADR-039 hash chain) — ADR-069 will detail how playbook events flow into it.
16. Alternatives considered
Temporal (full workflow engine). Rejected. It is the right tool if we had dozens of long-running, cross-service workflows with sophisticated retry, versioning, and migration needs. We have one service and a walking skeleton. Temporal shapes your code around its SDK (every workflow is a specific function signature); we would be paying that tax from day one for capabilities we will not use until Phase 5+. The evaluation trigger to revisit: if Phase 4 cross-service playbooks outnumber single-service playbooks, or if the Postgres queue hits its ceiling.
NATS (or any message broker). Rejected, for now. NATS becomes attractive when playbook events matter to other services — a dashboard in grafana-server subscribing to
playbook.*.completed, a metrics-compute service reacting to failures. Until then, Postgres NOTIFY + our existing metrics pipeline cover the same ground. We re-evaluate at Phase 4.Redis Streams. Rejected. We do not run Redis. Adopting it for this is a new operational concern;
riveron the existing Postgres is not.In-memory queue only (no durability). Rejected. Any run longer than ~30s is vulnerable to a deploy. We deploy ai-research frequently.
WebSocket for the bidirectional channel. Rejected. The channel is asymmetric (many events out, few replies in); SSE + POST reflects that asymmetry cleanly and degrades to
curl. WebSocket requires us to design a client-to-server protocol (message kinds, reconnect semantics) that POST already gives us.Long-poll. Rejected. Strictly worse than SSE on every axis (latency, reconnect, proxy behaviour). Its only advantage — working through strict proxies that strip SSE — is not a problem we have.
Frontend-driven execution (browser runs the steps). Rejected, by the operator directly:
i dont want the frontend doing any dirty work
Additionally: frontend-driven execution puts secrets in the browser, cannot ssh, cannot run bash, cannot be trusted by third-party services that need a stable origin. The browser is a UI, not an orchestrator.
claude -pCLI shellout vs direct Anthropic SDK. Rejected the CLI. Shelling out to a CLI adds a subprocess, requires a binary in the ai-research image, loses token-level streaming, and moves secrets into process argv / env. The SDK call is in-process, streams, and keeps secrets confined to acredentials.Providerin memory.JSON spec instead of YAML. Rejected. Authors write these. YAML's multi-line strings (for
run:andprompt:) and comments are load-bearing. JSON would force us into escaped string blobs for every shell command and every prompt.HCL / custom DSL. Rejected. YAML is the format every other gyrum primitive uses (ADR-013 templates, ADR-018 product.yml, ADR-023 alerts, ADR-067 playbooks). One more format is one more thing for authors to learn and for tooling to support.
Standalone
gyrum-playbook-runtimeservice from day one. Rejected. A new service means a new deploy pipeline, a new on-call rotation, a new auth integration, a new metrics scrape — on top of the runtime implementation itself. Embedding in ai-research, where the operator UI already lives, shortens the walking-skeleton path by weeks. The operational cost of extraction later is bounded (the runtime is already a module with a clean boundary — the HTTP handlers, the executors, the queue integration); the operational cost of a premature extraction is unbounded. ADR-071 will pull the trigger when any of: (a) a non-ai-research service wants to run playbooks, (b) runtime load threatens ai-research's response-time SLOs, or (c) the security model wants to run the runtime in a different trust boundary than the main UI.
17. Follow-on ADRs
ADR-069: Playbook security model. Who can run what (role → playbook permission). Secret vault (where
prompt_secretvalues live, howtype: httpandtype: shellreference them). Destructive-step annotation (destructive: truein the YAML; requires anapprovalstep before the destructive step; the pair is enforced at parse time). Audit chain (every run writes into the ADR-038 audit sink with ADR-039 hash-chain integrity). Signed playbooks (only playbooks from a trusted repo can carrytarget: local). Idempotency keys and destructive-step deduplication.ADR-070: Playbook UI contract. The frontend binding. Exact Svelte component API, form-rendering rules from JSON-Schema, terminal pane rendering with
anser, reconnect UX, error UX, cancel UX, run-history list, drill-through to a completed run's events. Ports togyrum-uias<gy-playbook-runner>.ADR-071: Playbook runtime extraction. The trigger conditions (§16 above), the extraction plan (lift the
runtime/package, the HTTP handlers, and therivertables into a new repo), the cutover (blue-green on the HTTP surface, Postgres stays shared until the security model tightens), the operational hand-off (dashboards, alerts, runbooks).ADR-078: Trigger-driven playbook orchestration. The front door to this runtime — how a
playbook_rungets created without a human clicking. Additivetriggers:YAML block;cron/webhook/alertkinds (and a deferredpolled_change); migration 003 addstrigger_kind+trigger_idtoplaybook_runs. Complementary to ADR-077 (agent coordination) — triggers fire the start, approvals gate the middle.
Migration path
Shippable in the small-PR rhythm ADR-067 already uses.
| Step | Where | What |
|---|---|---|
| 1 | dark-factory | This ADR merges (Phase 0). |
| 2 | dark-factory | ADR-069 drafts (security model), ADR-070 drafts (UI contract). |
| 3 | ai-research | Postgres migrations: playbook_runs, playbook_run_steps, playbook_run_events. Wire river. |
| 4 | ai-research | StepExecutor interface + shell executor. POST /playbook-runs + GET /playbook-runs/{id}. Synchronous (Phase 1). |
| 5 | ai-research | Worker pool + SSE /events endpoint. One playbook end-to-end (release_flow:gyrum-complete-pr dry-run). Phase 2 skeleton. |
| 6 | ai-frontend | <PlaybookRunner> component consuming SSE. Terminal pane with anser. Wire to the release page. |
| 7 | ai-research | prompt, prompt_secret, approval executors + POST /input. Phase 3. |
| 8 | ai-research | ssh, http, pipeline, experiment, subplaybook, claude executors. One executor per PR. Phase 4. |
| 9 | dark-factory | ADR-069 merges. Allow-list + secret vault + destructive annotation + audit chain. Phase 5. |
| 10 | ai-research | Migrate existing runbooks/experiments to carry a runtime: section. Phase 6. |
| 11 | ai-frontend | Dashboard pack + alerts (ADR-046 / ADR-023). Phase 7. |
| 12 | (maybe) gyrum-playbook-runtime | ADR-071 extraction if/when triggered. |
Steps 1-4 are in-scope for this ADR's shipping cohort. Steps 5-11 are tracked follow-ups on the ai-research board. Step 12 is explicitly gated.
Review cadence (for this ADR)
Re-review every 90 days (next: 2026-07-22) against:
- Runtime throughput — are we queue-bound, CPU-bound, or executor-bound? Does the "revisit Postgres queue" trigger from §5 fire?
- Failure modes in the wild — restart recovery (§2), back-pressure (§3), SSE reconnect (§6). Have the designed behaviours held?
type: claudereliability — actual p50/p95/p99 latencies, error rates, rate-limit incidence. Does §12 need tightening?- Security hardening — has ADR-069 landed on schedule? If not, what
production playbooks with
target: localare at risk? - Extraction pressure — does ADR-071 want to fire?
Stale flag on the Owner dashboard once last_reviewed on this ADR
passes 90 days.
References
ADR-067 — playbooks unified primitive (the data model this runtime executes)
ADR-064 — gyrum-catalog library + hex architecture; the SSH Executor port backs
type: sshADR-062 — design system;
<PlaybookRunner>follows the "prove in ai-frontend, lift to gyrum-ui" patternADR-061 — gyrum-pipelines library; backs
type: pipelineADR-060 — catalog-driven infrastructure; host aliases in
target: host:Xresolve against the catalogADR-053 — frontend observability; client-side SSE errors flow through
/client-logADR-050 — bearer auth for product metrics; the short-lived token pattern for SSE auth borrows this shape
ADR-046 — tier-1 discipline dashboard pack; where the runtime's dashboard lives
ADR-039 — audit hash chain; tamper-evidence for playbook-run events (ADR-069 wires this in)
ADR-038 — audit sink; long-term home for run history
ADR-036 — CSRF double-submit cookie; applies to
POST /playbook-runsandPOST /inputADR-023 — alerts as code; the runtime's failure alerts are written here
ADR-020 — shared infra vs per-product isolation; Postgres backup/restore now covers playbook run state
ADR-005 — cardinality labels vs fields; the metric labels in §11 are vetted against this
Operator framing, 2026-04-23:
take each playbook, giving it a guide and using the experiments and pipeline as the actual running, introducing text input and a report at the end… everything else I suppose action wise leads you to that point, even actions on screens will really be dealing with these playbook pages behind the scenes
1 yaml 2 probably best on the server, i dont want the frontend doing any dirty work, this may even mean a message bus to handle them and they return a job no and provide status whilst they work, but id also like the to be interactive ith the user, suppose we had a install grafana and it needed some expra answers the frontend user, it would be helpful if they could send back answers, i suppose if the backend ran bash, so might have claude -p, i like when you deploy you show the termianl output
Supersedes: none Superseded by: leave blank until a later ADR reverses this one