Decisions

ADR-109: `type: claude` step and `observer:` field

ADR-107 named the principle ("gates as playbook primitives") and migrated four open gate tickets ([#313](https://warp.gyrum.ai/items/313) dry-run, [#317](https://warp.gyrum.ai/items/317) e2e,…

#109

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:

  1. type: claude step — Claude is the step. Used when the structured verdict is the deliverable (dry-run gate verdict, e2e summary, context-aware hard-assert).
  2. observer: clause — Claude sits beside any other step, reads its Server-Sent Events (SSE) stream, returns a verdict via on_verdict: mapping or files a warp ticket via on_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: claude invocations 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: claude step, spawns claude -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_run and max_latency_seconds are required fields; per-day envelope at ~/.gyrum/claude-step-budgets.yaml fails 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_write is 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 for scope.
  • The validator + review-gate rules across gyrum-validate-playbook and gyrum-review-pr.

Alternatives considered

  • One primitive (type: claude only) — 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 are type: pause, full stop" is the only gate that survives a missing pattern.

Supersedes: none Superseded by: none yet