ADR-162: Stacked-PR contract — branch naming + base resolution + rebase semantics + audit shape
Status: Proposed
Date: 2026-05-06
Related: warp#1686 (parent EPIC, stacked-PR / branch-of-branch workflow), warp#1688 (sister Phase 0: agent-clones LSP foundation — IDE half of dogfood), warp#1659 (audit precedent: gyrum-fleet runs cancel JSONL audit shape — mirrored here for rebase events), warp#1406 (rollover precedent: 1-week soft-warn before flipping the default), warp#1054 (pattern model: recent_deploys.go interface + http impl + mock — auto-rebase daemon mirrors), warp#1517 (start-work collision check — same gate applies to stacked branches), warp#1597 (branch-on-origin guard — relaxed for child branches), ADR-126 (precedent: durable engineering content in git — contract ADRs land before consumer code), ADR-115 (principle-aware reviewers — quote pinned contracts when reviewing substrate PRs)
Context
Phase 0 of EPIC warp#1686 ("Stacked PRs — branch of a branch"). Today every gyrum-* tool assumes PRs target main. This works while work is independent; it breaks when work is hierarchical. Operator starts a feature on feat/operator-thing; a sub-task should be dispatched to an agent; agent's work depends on operator's in-flight (unmerged) commits. The four current options — wait for merge, cherry-pick, after-the-fact rebase, lose the dependency — are all painful, and the pain shows up as either operator work that agents cannot help with at all or agent work that ships divorced from its parent context. Stacked PRs ("branch of a branch") are how mid-to-large eng teams routinely solve this; Sapling, Graphite, Phabricator, JetBrains Code With Me, the Linux kernel patch series — entire products and disciplines built around the shape. Gyrum is behind on the workflow tooling, not on the idea.
The EPIC body lays out a six-phase substrate (start-work flag, review-pr base resolution, complete-pr base resolution, auto-rebase daemon, warp parent_ticket field, chain visualisation). Each phase ships consumer code that reads a contract: branch naming, where the parent reference lives, what happens when the parent advances, what happens when the parent merges, where the audit goes. Without that contract written down once and pinned, every Phase-1+ implementer re-derives it from chat memory, drifts, and the gates trip in production rather than at author time. ADR-126 (durable engineering content in git) sets the precedent that fleet-wide contracts land as ADRs before consumer code so persona reviewers (per ADR-115, principle-aware reviewers) can quote them when reviewing the substrate PRs. This ADR is the same shape applied to stacked-PR substrate.
Decision
The stacked-PR contract has six pinned components: branch-naming convention, base resolution, rebase semantics, final-merge cascade, audit shape, and rollover policy. Every gyrum-* tool MUST conform; bypasses go through the same audited-override pattern (feedback_admin_override_with_audit.md) that every other fleet rule uses.
1. Branch-naming convention
Operator parent branch: feat/<descriptor> (existing — no change).
Agent child branch: feat/<descriptor>-agent-<sub-name>, where <sub-name> is unique across all agents currently working off the same parent.
When two or more agents branch off the same parent, each gets a distinct <sub-name> suffix:
feat/operator-thing # operator parent
feat/operator-thing-agent-validate-input # agent A child
feat/operator-thing-agent-add-tests # agent B child
feat/operator-thing-agent-doc-update # agent C child
The shape is reserved-by-convention, not enforced at the git layer; gyrum-start-work --branch-from-current (Phase 1, SP1.1) is the canonical creator and emits this name from the parent's branch + the user-supplied sub-task descriptor.
2. Base resolution
Every gyrum-* tool reads pr.base.ref from the GitHub API as the authoritative parent reference. Nothing hard-codes main. Concretely: gyrum-review-pr computes diff against pr.base.ref (Phase 2 / SP2.1), gyrum-complete-pr merges into pr.base.ref (Phase 3 / SP3.1), the auto-rebase daemon rebases against pr.base.ref (Phase 4 / SP4.1). The branch-meta JSON (.gyrum/branch-meta/<branch>.json) MAY mirror the parent reference for offline tools, but the GitHub API is the source of truth and the meta file is a cache. Cache invalidation rule: if the GitHub API disagrees with the meta file, the API wins and the meta file is rewritten on next read.
3. Rebase semantics
When the parent branch advances (operator pushes a new commit to feat/<descriptor>), every open child PR with base.ref == feat/<descriptor> is rebased automatically:
- No conflict — daemon force-pushes the rebased child under
--force-with-lease, re-triggers CI on the child PR, and JSONL's the event (component 5). - Conflict — daemon pauses, files an auto-tagged
priority:urgentwarp ticket pinging the operator with the conflicting file list, and leaves the child PR in its pre-rebase state. The child's CI is NOT re-triggered (its state still reflects the un-rebased base). The operator resolves the conflict manually (rebase + push from their checkout, or pair with the agent), then re-runs the daemon.
Stacks are append-only. Reordering commits within a stack (Sapling-style "drop a commit from middle of stack") is explicitly out of scope for v1; each child ships at the end of the parent's commit sequence.
4. Final-merge cascade
When the parent PR merges to main:
- For each child PR with
base.ref == <parent-branch>: the daemon issuesPATCH /repos/<owner>/<repo>/pulls/<n>withbase = main, switching the child's target. - The daemon then rebases the child against main (component 3 semantics apply: no conflict → force-push + re-trigger CI; conflict → pause + ping).
- The child PR's review state is preserved. The persona reviewers do NOT re-run; the rebase changes the base, not the delta.
The cascade is ordered: child PRs further down the stack (children of children) are not supported in v1. If the operator has stacked operator-on-operator (their own work on their own work), the cascade fires once per parent merge; deeper nesting is out of scope.
5. Audit shape
Every stacked-PR substrate event JSONL's a record to ~/.gyrum/findings/findings.jsonl, mirroring the audit shape locked by warp#1659 (gyrum-fleet runs cancel). The record schema:
{
"ts": "2026-05-06T22:15:00Z",
"event": "stacked-pr.rebase | stacked-pr.base-switch | stacked-pr.conflict-pause",
"parent_pr": 1234,
"child_pr": 1237,
"parent_branch": "feat/operator-thing",
"child_branch": "feat/operator-thing-agent-validate-input",
"before_sha": "abc123…",
"after_sha": "def456…",
"outcome": "success | conflict | base-switched",
"actor": "auto-rebase-daemon | gyrum-complete-pr | manual",
"warp_ticket": "warp#1689"
}
The daemon writes; consumers (gyrum-review-pr's session-log helper, the chain-visualisation route in Phase 6) read. The findings.jsonl entry is the structural record; the warp-ticket cross-reference (component 3 conflict cases) is the human-readable mirror.
6. Rollover policy
The flag --branch-from-current on gyrum-start-work is opt-in for one week from this ADR's merge date, mirroring the warp#1406 rollover precedent. During the soft-warn window, agents default to main-targeting branches; passing --branch-from-current opts into the stacked shape and stamps the branch-meta accordingly. After the rollover window, the default flips: when the operator's HEAD is on a feature branch at gyrum-start-work time, agents default to --branch-from-current; passing --branch-from-main reverts to the legacy main-targeting shape.
The flip is gated by a gyrum-setup template-version bump (mechanism: same shape as the gyrum-<section>-vN markers the existing CLAUDE.md regen uses — see feedback_claudemd_template_marker_bump.md). The bump is a no-op when the rollover date has passed; the template carries the date and the flip happens implicitly across the fleet on the next gyrum-setup run.
Consequences
What becomes easier. Operators can dispatch agents to help with their actual in-flight feature work, not just independent substrate. Reviews see the agent's delta against the operator's parent rather than the operator's full WIP plus the agent's contribution conflated. The auto-rebase daemon converts "operator pushed; agent's stack is now stale" from a manual chore into a structural event with an audit trail. The cascade-on-merge converts "parent merged; agent has to manually retarget" into a transparent base switch.
What becomes harder. Six new pieces of substrate to land (one per phase), each with its own gate surface and persona-review pass. The auto-rebase daemon (Phase 4 / SP4.1) is the largest unit of new infrastructure; it inherits the warp#1054 mediator pattern (interface + http impl + mock) so unit tests cover the rebase logic without hitting GitHub, but the integration surface (a real Action that runs on every parent push) is new. Stacked PRs introduce a new collision shape — warp#1517's start-work collision gate must understand parent/child relationships so it doesn't refuse a legitimate child branch as colliding with its own parent.
What we sign up to operate. The findings.jsonl entries grow — by ~2x for stacks (parent merge fires base-switch + rebase per child) — but stay bounded by stack count, which the EPIC body caps implicitly via "10% of work is the stacked case". The auto-rebase daemon's failure mode is observable (conflict cases file warp tickets); silent-failure modes (daemon not running, force-push refused) ride the same gyrum-review-pr gate the existing review pass uses. Per-phase consumer code lands behind the contract this ADR pins; bumps to the contract require a successor ADR.
Alternatives considered
Pin every PR to main; emulate stacks via cherry-picks. Lost: cherry-picks lose the parent's commit identity, so persona reviewers cannot quote the parent's commit messages, and the audit log conflates "this is a child of an in-flight parent" with "this is an independent fix". The cherry-pick path also forces conflict resolution at integration time rather than rebase time, which is the more expensive position. Stacked PRs preserve commit identity by construction.
Hard-code the parent branch in
.gyrum/branch-meta/<branch>.jsonand ignorepr.base.ref. Lost: the meta file is local; if the operator retargets their own PR (rare, but supported bygh pr edit --base), the meta file goes stale silently. Readingpr.base.refmakes the GitHub API the source of truth and the meta file a cache, which is the same shape every other gyrum tool already uses for PR state.Ship without auto-rebase; require operators to manually rebase children on every parent push. Lost: the manual path scales as O(parent-pushes × child-count); for a parent with three open children, every operator push becomes three manual rebases. The pain compounds and the workflow stops being usable past two children. Auto-rebase is the structural cure, and warp#1054's mediator pattern makes it testable enough to land safely.
Day-1 hard-default to
--branch-from-currentwhen the operator's HEAD is on a feature branch. Lost: the soft-warn rollover precedent (warp#1406) exists because hard flips break agents that have memorised the legacy default. One week is the precedent-set window; long enough for agents to encounter the warning, short enough that the default flip is not deferred indefinitely. Component 6 names the policy.Support cross-repo stacked PRs (a child in repo B targeting a parent in repo A). Lost: cross-repo dependencies are a different shape — they require a release-coordination mechanism, not a rebase mechanism. The EPIC body explicitly excludes them; the contract here is within-repo only. Future ADR can extend if the need arises.
Out of scope
- Implementing the auto-rebase daemon — Phase 4 / SP4.1 child ticket.
- Per-phase consumer code (start-work, review-pr, complete-pr changes) — Phase 1-3 child tickets SP1.1 / SP2.1 / SP3.1.
- The
parent_ticketwarp field — Phase 5 / SP5.1. - The chain-visualisation route — Phase 6 / SP6.1, deferred.
- Cross-repo stacked PRs — explicitly excluded from v1.
- Sapling-style interactive rebase across stack — explicitly excluded from v1; stacks are append-only.
- Stacked PRs initiated by operators (operator-on-operator) — operators MAY use the pattern manually but tooling does not mandate it for non-agent dispatches.
Supersedes: the implicit "every PR targets main" assumption baked into gyrum-start-work, gyrum-review-pr, and gyrum-complete-pr pre-2026-05-06.
Superseded by: {{leave blank until a later ADR reverses this one}}