Skip to content

Plan contracts

Plan contracts turn a plan document under docs/plans/ (canonical at the workspace root, not in this product repo) into a typed, enforceable contract. The plan’s author writes YAML envelope blocks — a envelope: key in the frontmatter for the plan-level rails, plus ```envelope fenced blocks under each ### U<N> heading — and BMO materializes the plan as a goal contract row. When the plan is active for a session, every tool call an agent makes is preflight-gated against the current unit’s narrowing.

Plan contracts are opt-in per plan. Plans without envelope blocks continue to behave as plain Markdown: bmo plan check passes, bmo plan activate refuses, and the runtime preflight is a no-op. Adoption is one plan at a time.

  • Decomposition is typed, not prose-only. Each unit declares its allowed_tools and allowed_paths; a tool call outside that set is refused at runtime with a plan breach: tools / plan breach: paths soft error the agent can recover from via bmo_acknowledge_breach_and_retry.
  • Scope is enforced, not advisory. The plan’s parent_blast_radius.path_globs and parent_rails.surface bound every unit. Widening the surface field or stepping outside blast_radius.paths is a hard refuse with no acknowledgement path.
  • Context expectations are observable. Each unit’s context_tokens_expected emits a plan_budget_breach Journal observation when exceeded; the call is not vetoed, but the breach is legible.
  • Unit transitions are explicit. Moving from U<n> to U<n+1> requires bmo_plan_advance_unit with evidence matching advance_evidence_required. Direct tool calls that belong to a non-current unit stay blocked until the advance tool succeeds.

No runtime writes prompts, tools, TOML, or adaptive config on your behalf. Refinement is an operator reading bmo journal export --filter=plans, manually editing a prompt or default tool list, and submitting a PR.

The minimum viable envelope is a frontmatter envelope: key plus one ```envelope fenced block per unit:

---
title: "feat: example plan"
status: active
envelope:
plan_id: example-plan
plan_contract_version: 1
parent_rails:
allowed_tools: [read, edit, bash]
allowed_paths:
- "internal/example/**"
- "docs/plans/**"
surface: local_branch
parent_blast_radius:
path_globs: ["internal/example/**"]
budget_caps:
files_changed: 20
requirements: [R1, R2]
---

Per-unit narrowings live in fenced blocks with the envelope language tag:

### U1 — scaffolding
Introductory prose.
```envelope
allowed_tools: [read, edit]
allowed_paths: ["internal/example/**"]
surface: local_branch
context_tokens_expected: 4000
verification:
kind: command
command: "go test ./internal/example/..."
requirements: [R1]
advance_evidence_required:
verification_pass: true
```

The per-unit envelope must be narrower-than-or-equal-to the parent on every field: allowed_tools is a subset, allowed_paths is a glob-subset, surface is narrower under artifacts_only < local_branch < remote_branch_pr, and blast_radius.path_globs is a subset. bmo plan check rejects widening at CI time.

CommandPurpose
bmo plan check [--path <file>] [--strict]Validate plan envelopes without touching the database. Auto-discovers *.md files under docs/plans/ (canonical at the workspace root) when --path is omitted. --strict rejects legacy plans (no envelope blocks). Exit code 0 on success, 1 on validation failure, 2 on invocation error. Use as a git pre-push hook or CI gate.
bmo plan activate --path <file>Parse, validate, and upsert the plan as a goals.Contract row. Sets Trigger.Kind = plan and Authorship.Source = operator.
bmo plan pause --plan-id <id> --reason <text>Engage the kill switch on a plan contract. --reason is mandatory and is recorded on every subsequent preflight refusal. Refuses non-plan contracts (use bmo contract pause for cron or event rows).
bmo plan resume --plan-id <id>Clear the kill switch. Expiry and budget caps still apply. Refuses non-plan contracts.

Three fantasy.AgentTool entries expose the activation and breach-acknowledgement lifecycle to an agent runloop. None of them write to goals.Store under a non-operator Authorship.Source.

ToolWhen the agent calls it
bmo_activate_plan_contractAt the start of a /ce:work session (or equivalent) to adopt the plan’s envelope for the current session.
bmo_plan_advance_unitWhen the current unit’s work is complete and the agent is ready to move to the next unit. Requires evidence matching advance_evidence_required.
bmo_acknowledge_breach_and_retryAfter a plan breach: tools / plan breach: paths soft refusal, to re-dispatch the last call under parent rails with a breach summary. Hard-refuse fields (surface, blast_radius.paths) are a no-op at the tool level.
FieldPostureWhat happens on refusal
allowed_toolsSoft refuseTool call returns a soft-error response; agent may call bmo_acknowledge_breach_and_retry once to re-dispatch under parent rails. Journal records plan_breach_refusedplan_breach_acknowledged.
allowed_pathsSoft refuseSame as allowed_tools.
surfaceHard refuseTool call returns a soft-error response. bmo_acknowledge_breach_and_retry is a no-op on this field by construction. Journal records plan_breach_refused.
blast_radius.path_globsHard refuseSame as surface.
context_tokens_expected, rails.budgets.*AdvisoryCall proceeds. Journal records plan_budget_breach for operator review.
blast_radius.budget_caps.* (runs, wall-time, tokens)Hard enforceShared with goal-contracts § Budget enforcement; preflight refuses the run when a cap is reached.
Kill switchHard enforceA paused plan contract vetoes every subsequent tool call at the preflight layer.
graph TD A["Tool call arrives"] --> B{Kill switch
paused?} B -->|Yes| C["Hard refuse
every call"] B -->|No| D{Budget cap
reached?} D -->|Yes| E["Hard refuse
runs/wall-time/tokens"] D -->|No| F{In unit bounds?
allowed_tools
allowed_paths} F -->|No| G["Soft refuse
← acknowledge_breach_and_retry"] G -->|Ack| H["Re-dispatch
under parent rails"] F -->|Yes| I{Hard field
breach?
surface or
blast_radius} I -->|Yes| J["Hard refuse
no ack path"] I -->|No| K["✓ Proceed
Emit context_tokens or
advisory budget observation"] C --> L["Journal:
plan_breach_refused"] E --> L H --> M["Journal:
plan_breach_acknowledged"] J --> L K --> N["Journal:
plan_budget_breach
or unit_entered"]

Plan contracts contribute five verbs to the shared Journal taxonomy, scoped to existing entry kinds:

VerbKindWhen it fires
plan_unit_enteredDecisionSession enters a unit (activation for U1 or advance for U<n+1>)
plan_advanceDecisionbmo_plan_advance_unit succeeds; evidence accepted
plan_breach_refusedObservationPreflight refuses a tool call on any field
plan_budget_breachObservationcontext_tokens_expected or advisory budget exceeded; call not vetoed
plan_breach_acknowledgedActionbmo_acknowledge_breach_and_retry succeeds; re-dispatched call proceeds

Filter the export to only plan-contract entries with:

Terminal window
bmo journal export --filter=plans --session=<id>

Plan contracts enforce at runtime only when the plan is active under BMO’s sessionAgent (for example via /ce:work on the BMO harness). For other coding agents — Claude Code, Codex, Gemini CLI, or a custom loop — plan contracts are advisory:

  • bmo plan check still rejects envelope widening in CI. The authoring discipline is universal.
  • No runtime gate prevents a non-BMO harness from stepping outside a unit’s narrowing.
  • After a session, bmo journal export --filter=plans replays which calls a parallel BMO activation would have refused, supporting a manual drift review loop.

A starter plan with envelope stubs lives at docs/_templates/plan-template.md. Copy it to a new file under docs/plans/ (canonical at the workspace root; for example <date>-<slug>-plan.md), fill in the envelope: frontmatter and per-unit blocks, and run bmo plan check --path <path> before activation.

  1. Plan envelopes are operator-authored; Authorship.Source admits operator only for plan contracts.
  2. Unit narrowings must be narrower-than-or-equal-to parent rails on every field (parse-time invariant).
  3. Hard-refuse fields (surface, blast_radius.path_globs) have no acknowledgement path.
  4. Unit transitions require bmo_plan_advance_unit; direct tool calls into non-current units are preflight-refused.
  5. Kill switches are runtime-enforced, not agent-honored.
  6. No runtime writer of prompts, tools, config, or plan text exists. Plan-contract refinement is operator-authored and traceable through Journal evidence.