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.
What it gives you
Section titled “What it gives you”- Decomposition is typed, not prose-only. Each unit declares its
allowed_toolsandallowed_paths; a tool call outside that set is refused at runtime with aplan breach: tools/plan breach: pathssoft error the agent can recover from viabmo_acknowledge_breach_and_retry. - Scope is enforced, not advisory. The plan’s
parent_blast_radius.path_globsandparent_rails.surfacebound every unit. Widening thesurfacefield or stepping outsideblast_radius.pathsis a hard refuse with no acknowledgement path. - Context expectations are observable. Each unit’s
context_tokens_expectedemits aplan_budget_breachJournal observation when exceeded; the call is not vetoed, but the breach is legible. - Unit transitions are explicit. Moving from
U<n>toU<n+1>requiresbmo_plan_advance_unitwith evidence matchingadvance_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.
Envelope syntax
Section titled “Envelope syntax”The minimum viable envelope is a frontmatter envelope: key plus one ```envelope fenced block per unit:
---title: "feat: example plan"status: activeenvelope: 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.
```envelopeallowed_tools: [read, edit]allowed_paths: ["internal/example/**"]surface: local_branchcontext_tokens_expected: 4000verification: 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.
| Command | Purpose |
|---|---|
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. |
Agent tools
Section titled “Agent tools”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.
| Tool | When the agent calls it |
|---|---|
bmo_activate_plan_contract | At the start of a /ce:work session (or equivalent) to adopt the plan’s envelope for the current session. |
bmo_plan_advance_unit | When 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_retry | After 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. |
Breach posture
Section titled “Breach posture”| Field | Posture | What happens on refusal |
|---|---|---|
allowed_tools | Soft refuse | Tool 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_refused → plan_breach_acknowledged. |
allowed_paths | Soft refuse | Same as allowed_tools. |
surface | Hard refuse | Tool 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_globs | Hard refuse | Same as surface. |
context_tokens_expected, rails.budgets.* | Advisory | Call proceeds. Journal records plan_budget_breach for operator review. |
blast_radius.budget_caps.* (runs, wall-time, tokens) | Hard enforce | Shared with goal-contracts § Budget enforcement; preflight refuses the run when a cap is reached. |
| Kill switch | Hard enforce | A paused plan contract vetoes every subsequent tool call at the preflight layer. |
Contract enforcement flow
Section titled “Contract enforcement flow”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"]
Journal integration
Section titled “Journal integration”Plan contracts contribute five verbs to the shared Journal taxonomy, scoped to existing entry kinds:
| Verb | Kind | When it fires |
|---|---|---|
plan_unit_entered | Decision | Session enters a unit (activation for U1 or advance for U<n+1>) |
plan_advance | Decision | bmo_plan_advance_unit succeeds; evidence accepted |
plan_breach_refused | Observation | Preflight refuses a tool call on any field |
plan_budget_breach | Observation | context_tokens_expected or advisory budget exceeded; call not vetoed |
plan_breach_acknowledged | Action | bmo_acknowledge_breach_and_retry succeeds; re-dispatched call proceeds |
Filter the export to only plan-contract entries with:
bmo journal export --filter=plans --session=<id>Cross-harness posture
Section titled “Cross-harness posture”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 checkstill 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=plansreplays which calls a parallel BMO activation would have refused, supporting a manual drift review loop.
Authoring template
Section titled “Authoring template”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.
Invariants at a glance
Section titled “Invariants at a glance”- Plan envelopes are operator-authored;
Authorship.Sourceadmitsoperatoronly for plan contracts. - Unit narrowings must be narrower-than-or-equal-to parent rails on every field (parse-time invariant).
- Hard-refuse fields (
surface,blast_radius.path_globs) have no acknowledgement path. - Unit transitions require
bmo_plan_advance_unit; direct tool calls into non-current units are preflight-refused. - Kill switches are runtime-enforced, not agent-honored.
- No runtime writer of prompts, tools, config, or plan text exists. Plan-contract refinement is operator-authored and traceable through Journal evidence.