ADR-109: type: claude step and observer: field
Status: Proposed Date: 2026-04-26 Related: ADR-067 (playbooks-unified-primitive), ADR-083 (defense-in-depth-rule-promotion), ADR-090 (ai-as-scaffold-pipelines-as-runtime), ADR-104 (dry-run-on-history-adaptive-gate), ADR-107 (gates-as-playbook-primitives), ADR-108 (sub-agent-fires-as-first-class)
Context
Today Claude wraps the runtime: a parent Claude session fires a playbook, observes it, decides to fix or retry, fires the next. The parent is the loop driver, the bottleneck, and the implicit trust boundary. Removing the parent removes the loop.
ADR-107 named the principle ("gates as playbook primitives") and migrated four open gate tickets (#313 dry-run, #317 e2e, #323 hard-assert, #324 iteration ceiling) into role-step or meta-playbook shape. But every one of those gates ultimately needs a "Claude reads X and decides Y" call somewhere — for verdicts on dry-run output, for e2e summaries, for context-aware hard-asserts, for fix-loop escalation reasoning. Today each lands as a separate epic with its own bespoke shape; the pattern needs to be one primitive.
The 2026-04-26 deploy postmortem (warp #321) sharpened the requirement. The operator framed it as: the runtime should call Claude at well-defined boundaries with a declared scope, not the other way around. The work to land the primitive is tracked in warp #334 (sub-tickets P-C1 through P-C9), and the structural-checks layer that keeps it trustworthy is warp #335 (P-C10).
Decision
The runtime gains two related primitives, both invocations of claude -p with a declared scope, model pin, latency cap, and token cap:
type: claudestep — Claude is the step. Used when the structured verdict is the deliverable (dry-run gate verdict, e2e summary, context-aware hard-assert).observer:clause — Claude sits beside any other step, reads its Server-Sent Events (SSE) stream, returns a verdict viaon_verdict:mapping or files a warp ticket viaon_anomaly:(see ADR-110). Used when the deliverable is the structural step's side effects, and Claude only watches.
Both shapes require the same fields: model: <name>@<pinned-version> (no floating versions in active playbooks), scope: {can_read, can_write, can_run} allowlists, prompt_ref: library/prompts/<name>.md@v<n> (versioned, regression-tested), max_latency_seconds, max_tokens_per_run, and an output schema for type: claude (the runtime asserts the verdict matches). The runtime kills steps that exceed latency or token budgets and reports state: failed_timeout / state: failed_budget_exceeded so the failure surfaces in /operate like any other.
The author-time decision tree is three-way: observer: claude / on_anomaly: file-warp-ticket is the default; type: claude is rare (verdict is the deliverable); type: pause is mandatory for irreversible / destructive / money-spending / identity-changing operations — no model judgment substitutes for an operator confirmation on those.
Consequences
What becomes easier.
- Every "Claude reads X and decides" gate collapses into one primitive instead of N bespoke epics. The four open gate tickets (#313, #317, #323, #324) all reshape as
type: claudeinvocations of versioned prompts (P-C7 in warp #334). - The factory runs without a parent Claude session in the loop. An operator fires a playbook from a fresh terminal; the runtime hits a
type: claudestep, spawnsclaude -p, captures the verdict, blocks or passes accordingly. Removing the parent doesn't remove the safety; the playbook just fails closed at the step instead of skipping it. - Cost, latency, and drift become auditable per step.
max_tokens_per_runandmax_latency_secondsare required fields; per-day envelope at~/.gyrum/claude-step-budgets.yamlfails the run on excess. Model pinning + nightly regression-corpus replay catches drift before it ships verdicts that diverge from 30 days prior.
What becomes harder.
- Prompts become code. They live in
dark-factory/prompts/<name>@v<n>.md, ship with regression corpora, and bumping a prompt is a PR with the corpus update in the same diff. The mitigation is the structural-checks layer (warp #335 P-C10) — see the table below. - Model-pinning ceremony. Active playbooks reject
claude-sonnet-latest-style floating versions; bumping a model pin is a PR that must re-run the regression corpus. The cost is real but the alternative (silent verdict drift on model upgrades) is worse. - Trust-boundary discipline. An over-broad
scope.can_writeis a backdoor; the review gate (P-C6) refuses anything outside the safe allowlist["adr/**", "docs/**", "playbooks/**", "tmp/**"]without an audited operator override.
The 6 tradeoffs and their structural mitigations (each row is one of the warp #335 P-C10 checks):
| # | Tradeoff | Structural mitigation | Layer |
|---|---|---|---|
| 1 | Prompt as code (bad prompts ship bugs) | prompt_ref versioning + regression corpus runner; gyrum-validate-playbook runs corpus on PR |
Authoring + PR review |
| 2 | Cost (every call burns tokens) | max_tokens_per_run field + playbook_token_budget frontmatter required; runtime kills on excess; daily envelope at ~/.gyrum/claude-step-budgets.yaml |
Authoring + Runtime |
| 3 | Latency (10-30s per step) | max_latency_seconds field required; runtime kills at deadline; failure surfaces in /operate (warp #320) |
Authoring + Runtime |
| 4 | Model drift (same prompt + input → different verdict over time) | model: <name>@<pinned-version> required; floating versions rejected for status: active playbooks; nightly claude-step-drift-detection.yaml cron re-runs corpus, files auto-ticket on divergence |
Authoring + Periodic |
| 5 | Scope creep (over-broad can_write = backdoor) |
gyrum-review-pr refuses scope.can_write outside ["adr/**", "docs/**", "playbooks/**", "tmp/**"]; broader scope needs gyrum-complete-pr --admin "<reason>" audit-logged per feedback_admin_override_with_audit.md; runtime sandbox enforces at execution |
PR review + Runtime |
| 6 | type: claude vs type: pause confusion (model judging an irreversible op) |
Destructive-pattern matcher in gyrum-validate-playbook (DNS mutations, compose down -v, terraform destroy, kubectl delete, raw SQL DROP/TRUNCATE/DELETE-without-WHERE, gh repo delete, gh release delete) refuses type: claude and suggests type: pause |
Authoring |
The four-layer defense (authoring / PR review / runtime / periodic) follows the same shape as ADR-083's deny-list-cannot-be-single-gate finding.
What we sign up to maintain.
- The prompt library at
dark-factory/prompts/, including versioning conventions, regression corpora, and the drift-detection meta-playbook. - The runtime executor for both shapes (P-C1 + P-C2), including the SSE multiplexing for
observer:and the sandbox forscope. - The validator + review-gate rules across
gyrum-validate-playbookandgyrum-review-pr.
Alternatives considered
- One primitive (
type: claudeonly) — push the observer pattern into a step decorator. Rejected: observers are parallel to the structural step, not sequential. A decorator implies wrapping; the observer reads the step's stream while the step still runs and fires the verdict at completion, which the two-shape model expresses cleanly. - No primitive — keep the current per-epic bespoke shape. Rejected: this is exactly the position warp #334 was filed against. Each epic re-derives prompt versioning, scope rules, cost tracking, and review gating. The fleet ends up with N inconsistent implementations instead of one auditable primitive.
- Skip
type: pause— assume the destructive-pattern matcher catches everything. Rejected: matchers leak. The author-time rule "irreversible ops aretype: pause, full stop" is the only gate that survives a missing pattern.
Supersedes: none Superseded by: none yet