---
title: Planned PR Backend
read_when:
  - "working with plan storage or plan backends"
  - "adding plan storage behavior without checking plan backend type"
  - "understanding how plans are stored as planned pull requests"
  - "modifying plan-save or land pipeline for plan handling"
tripwires:
  - action: "validating plan_id in exec scripts without checking provider type"
    warning: "Planned PR plan_id IS the PR number (not an issue number). Check provider type before assuming plan_id semantics. Issue-based plans use issue numbers; planned-PR plans use PR numbers."
    score: 4
  - action: "using gh issue view on a plan ID without checking plan backend type"
    warning: "Planned PR plan IDs are PR numbers. Using gh issue view on a planned-PR plan produces a confusing 404. Route to gh pr view based on backend type."
    score: 7
  - action: "checking erk exec plan-save --format json output for empty result"
    warning: "Empty stdout does not mean failure. The duplicate-detection path writes JSON to stderr, not stdout. Always capture both streams with 2>&1 or check for empty stdout and retry with stderr capture."
    score: 5
  - action: "calling update_metadata() on PlannedPRBackend without verifying plan exists"
    warning: "PlannedPRBackend.update_metadata() raises PlanHeaderNotFoundError if the header block is absent. Read operations (get_plan) return None gracefully. Always check plan existence before writing metadata."
    score: 6
---

# Planned PR Backend

PlannedPRBackend stores plans as GitHub draft pull requests. It is the only active plan storage backend.

## Backend Selection

The plan backend is hardcoded to `"planned_pr"`. All plans are stored as GitHub draft pull requests via PlannedPRBackend. The former `get_plan_backend()` function and `PlanBackendType` type alias were deleted in PR #7971 (objective #7911 node 1.1).

The `ERK_PLAN_BACKEND` environment variable is no longer read by application code. Setting it has no effect.

<!-- Source: src/erk/core/context.py, create_context -->

## Architecture

PlannedPRBackend uses **composition** — it wraps the top-level GitHub gateway, not inheritance. The class is at `packages/erk-shared/src/erk_shared/plan_store/planned_pr.py`.

Key design decisions:

- **Lightweight init**: No I/O in `__init__`, dependencies injected (`github`, `time`)
- **Provider name**: `"github-draft-pr"` (preserved from pre-rename `DraftPRPlanBackend` for backward compatibility — existing plan metadata in GitHub PR bodies uses this string, so changing it would break metadata deserialization)
- **Immutable metadata fields**: `schema_version`, `created_at`, `created_by` are protected from updates

## PR Body Format

Plan PRs use a structured body format: metadata block + separator + plan content.

- **Separator**: `"\n\n---\n\n"`
- **Label**: `"erk-plan"`
- **Structure**: `<!-- plan-header metadata -->` + separator + `# Plan: Title` + content

Helper functions `build_plan_stage_body()` and `extract_plan_content()` (public, in `planned_pr_lifecycle.py`) handle composition and extraction.

## Plan File Commit

Plans are committed to `.erk/impl-context/plan.md` on the planned PR branch. The `plan_save.py` script uses `commit_files_to_branch()` — a git plumbing approach that writes directly to the branch without checking it out, avoiding race conditions when multiple sessions share the same worktree.

## Branch Naming

Three branch name formats are supported:

- **P-prefix**: `P{number}-{slug}` (issue-based plan branches)
- **Objective format**: `P{number}-O{objective}-{slug}` (plans linked to objectives)
- **Planned PR**: `plnd/{slug}-{MM-DD-HHMM}` or `plnd/O{objective}-{slug}-{MM-DD-HHMM}` (planned-PR plans)

See [branch-plan-resolution.md](branch-plan-resolution.md) for how branches resolve to plans.

## Land Pipeline Integration

Planned PR plans don't have separate review PRs, so the land pipeline skips review PR cleanup for them.

## Learn Pipeline Integration

The learn pipeline detects planned-PR plans via `plan_backend.get_provider_name()`. For planned-PR plans, `plan_id` is the PR number — the learn pipeline can call `github.get_pr(plan_id)` directly without metadata extraction. See [Plan ID Semantics](plan-id-semantics.md) for details.

## Metadata API Asymmetry

PlannedPRBackend metadata operations have asymmetric error behavior:

| Operation           | Missing Header Block | Behavior                         |
| ------------------- | -------------------- | -------------------------------- |
| `get_plan()`        | No header found      | Returns `None` gracefully        |
| `update_metadata()` | No header found      | Raises `PlanHeaderNotFoundError` |

**Implication:** Always check that a plan exists (via `get_plan()`) before calling `update_metadata()`. Write operations assume the header block is present because it should have been created during `create_plan()`.

## Type Narrowing

The backend uses explicit `isinstance()` checks and conditional type conversion for metadata fields, following LBYL discipline rather than `cast()`.

## Title Prefixing Behavior

Planned PR titles (and issue-based plan titles) are prefixed with a label-based tag via `get_title_tag_from_labels()` in `packages/erk-shared/src/erk_shared/plan_utils.py:178-190`.

<!-- Source: packages/erk-shared/src/erk_shared/plan_utils.py:178-190, get_title_tag_from_labels -->

The function returns `"[erk-learn]"` if `"erk-learn"` is in the labels list, otherwise `"[erk-plan]"`. The prefix is prepended to the plan title during PR creation (e.g., `[erk-plan] My Feature`). For erk-learn plans, the prefix becomes `[erk-learn]` to distinguish documentation/learning plans from implementation plans in the PR list.

## GraphQL Refactor: `list_plan_prs_with_details()`

The plan data provider fetches PRs via a single GraphQL query (`list_plan_prs_with_details()`) instead of N+1 REST calls. This function lives in `packages/erk-shared/src/erk_shared/gateway/github/real.py:1636` and returns:

- `list[PRDetails]` — for plan content extraction
- `dict[int, list[PullRequestInfo]]` — for display metadata (checks, review threads, merge status)

The single query uses `GET_PLAN_PRS_WITH_DETAILS_QUERY` from `graphql_queries.py` and fetches review decision, conflict status, and CI checks in one round-trip, replacing the previous approach of fetching each PR individually.

## Implementation Setup and .erk/impl-context/ Cleanup

When implementing a planned-PR plan, the `.erk/impl-context/` staging directory must be cleaned up before implementation begins. This directory contains `plan.md` and `ref.json` committed during plan-save to give the draft PR a non-empty diff.

**Cleanup pattern:**

1. `setup_impl_from_pr.py` reads the files into `.erk/impl-context/` but does NOT delete them (deferred cleanup)
2. `plan-implement.md` Step 2d performs the actual `git rm -rf .erk/impl-context/ && git commit && git push`
3. The step is **idempotent** — safe to run when the directory doesn't exist

**Rebase conflict handling:** After cleanup commits on the plan branch, a `git pull --rebase` may be needed before pushing further implementation commits. Skipping this can cause non-fast-forward push failures when the remote branch has diverged.

For full details, see [Impl-Context Staging Directory](impl-context.md).

## plan-save Duplicate Detection Output Routing

When `erk exec plan-save --format json` detects that a plan was already saved in the current session (via the `plan-saved-issue` marker), it returns `{"skipped_duplicate": true, "plan_number": <N>}`. This JSON is written to **stderr**, not stdout.

Empty stdout from `plan-save` does not indicate failure. Always capture both streams:

```bash
erk exec plan-save --format json --session-id "..." 2>&1
```

If stdout is empty, check stderr for the duplicate-detection response before assuming the command failed.

## Related Topics

- [Branch Plan Resolution](branch-plan-resolution.md) - How branches resolve to plans
- [Git Plumbing Patterns](../architecture/git-plumbing-patterns.md) - Git plumbing approach for branch safety
- [Plan Storage Testing](../testing/dual-backend-testing.md) - Plan storage testing patterns
- [Impl-Context Staging Directory](impl-context.md) - Staging directory lifecycle and cleanup
