Skip to content

Decision 069: the outer loop runs in the origin repository, and each work item declares its surface

  • Status: proposed
  • Date: 2026-08-09
  • Deciders: @MadaraUchiha-314 (issue #183 and the review of PR #184 — the topology, the choice, and where the choice lives are all the owner's)
  • Work item: issue-183
  • Spec: docs/specs/issue-183/
  • Refines: decision-065 (the PDLC is two loops) — this says where each of them runs when the work spans repositories.
  • Amends: decision-051 §5, whose invariant was "artifact iteration happens in pull-request review". It becomes: artifact iteration happens on a durable, reviewable surface — the pull request or the work item — never in a terminal.
  • Revised in review (PR #184): the surface began here as a repository config key, workflow.outerLoop.surface, and became a per-work-item declaration at phase-selection before this decision shipped. D7 records what holds; the alternatives table records what it replaced, and why.

Context

Issue #183, verbatim: "When a work item needs contributions into multiple repos, then the initial contribution for the outer loop should work only in the repo where the issue was created … if n number of repos need contribution, then n PRs will be raised for the inner loop in those respective repos." And: "Users should be given an option whether to make the outer loop on the issue or as PRs … The inner loop has no configurability." The reason given is concrete: "to prevent unnecessary PRs … The-loop PR is open, which never gets closed or merged in because it was just for brainstorming and requirements."

decision-065 split the process into two loops but left the topology unsaid, and the shipped harness assumed single-repo in four separate places: a qualified closing reference to another repository was dropped by the router; an inner loop was keyed by PR number alone; artifact iteration required a pull request; and await-inner-loops could not tell "no contributions needed" from "the contribution was never opened".

Decision

Sub-decisionWhat was chosenWhy
D1 — the outer loop runs in the origin repositorythe repository the ticket was created in (ticketing.github); the work item's one spec chain lives there, and every inner loop's state under itThe ticket is the work item's identity, and the spec chain is the work item's. One spec chain in one place is what decision-065 D2/D4 already protect; this says which place.
D2 — one contributing repository, one pull request, one inner loopn repositories ⇒ n PRs; the origin repository gets one only if it also receives codeThe ticket's own words. It is also the smallest arrangement that keeps each repository's review where its code is.
D3 — an inner loop is qualified by repository, and the origin's layout does not movepr-loops/<owner>__<repo>/pr-<n>/ for a contributing repository; pr-loops/pr-<n>/ unchanged for the originA PR number is unique within a repository, not between them. Two layouts is the price of not migrating work items that are mid-flight; the shipped path staying byte-identical is worth more than uniformity.
D4 — the repository name is validated, never sanitizedat least two segments, each [A-Za-z0-9._-]+, never ./..; ValueError otherwise, at every path-building call siteThe value becomes a directory name and arrives from a webhook payload or an operator's --pr-repo. A name quietly rewritten into a valid one files one repository's inner-loop state under another repository's name — worse than a refusal.
D5 — cross-repo closing references routea qualified Closes <owner>/<repo>#<n> (or URL form, or a closingIssuesReferences entry naming its repository) yields a ref in that repository; the branch convention stays localWithout it, a PR in a contributing repository cannot reach its own work item at all. The previous rule — "a closing reference to another repository is not ours" — held only while a work item lived in one repository.
D6 — cross-repo linkage is unconditional, not a new toggleno config key gates itTwo pre-existing boundaries already bound it: the ingress (an event only arrives from a repository the operator's receiver or poll source covers) and arming (an unstarted work item drops at _awaiting_start). A toggle would be a second name for "the operator configured this repository".
D7 — the outer loop's surface is declared by the WORK ITEM, at phase-selection; the inner loop's is not declared at allone extra checklist row (outer-loop-on-pull-request) beside the phase rows, signed by the same authorized the-loop execute, frozen into graph-state.json and the portable record. Default: the work item itself. No key in harness-config.yaml, none in cli-config.yaml. pdlc-pr-loop carries no equivalent, here or anywhereThe owner's call on PR #184, and it is the better model: multi-repo-ness is a property of the work item, not of the project — one repository has both a one-repo bugfix and a three-repo migration, and a repo-level key forces one answer on both. phase-selection is already where a named, authorized human declares this item's shape with provenance, so the answer arrives signed and frozen rather than as a setting nobody re-reads. The default is the work item because that is the case the ticket complains about: a pull request opened for a discussion and never merged.
D8 — the surface chooses the review surface, not whether artifacts are checked inthe spec chain is committed and linked from the ticket either wayEvery gate in the process graph reads files. A spec that lives only in comments could not be gated, locked, or diffed — and "reference, don't duplicate" would invert.
D9 — on the default surface, a pull request in the origin repository is a landing PRopened after the chain is locked and the inner loops have finished, or not at all when the origin repository is itself a contributing repositoryThis is the ticket's actual complaint answered: the PR that exists is one with something to merge, not a discussion that never closes.
D10 — declared repositories are a gaterepos: in execution-log.md's front matter; await-inner-loops holds implementation until each declared repository has a loop and every started loop has finished; declaring nothing keeps the vacuous passThe vacuous pass is load-bearing for single-repo work and must stay. Across repositories it hides a real failure — a PR that was planned and never opened — so the fix is an explicit declaration rather than a changed default. Inferring the set from tasks.md prose would make a gate depend on parsing prose, which the graph exists to avoid.
D11 — a malformed repos: entry blocks; a missing loop waitsblock for an unusable repository name, wait for work in progressWaiting on ../../etc waits forever. A fault in a checked-in file is a fault; an unopened PR is patience.
D12 — the session is told, not left to inferthe assignment and the prompt context name the resolved surface, and a cross-repo claim command carries --pr-repoThe same argument decision-051 §4 makes for the interaction directive: a rule the session never reads is a rule that does not hold. A claim without --pr-repo would evaluate a different loop.

Consequences

Positive.

  • The ticket's shape is now expressible: three repositories, three PRs, one spec chain, no discussion-only PR anywhere.
  • A PR in a contributing repository can reach its work item at all — previously impossible.
  • pr-loops/pr-7/ cannot mean two different pull requests.
  • A planned contribution that never arrives is a held gate naming the repository, instead of a pass.
  • Nothing existing moves: no state migration, no config migration, no new config key at all (CURRENT_CONFIG_VERSION untouched), and a work item that ticks no surface box and declares no repos: behaves exactly as it did — its outer loop simply happens on the work item, which is where an unattended one was always discussed.

Negative / accepted costs.

  • Two state layouts. pr-loops/pr-<n>/ and pr-loops/<owner>__<repo>/pr-<n>/ coexist forever. Accepted for the back-compat it buys; the reader of a checkout sees the qualifier only where it is needed.
  • __ is a lossy encoding. owner/repoowner__repo cannot be reversed unambiguously for a repository name containing __. Nothing reverses it: declared repositories are matched forward, by key. Anything that later wants the reverse must record the repository, not parse the directory name.
  • The origin repository must be configured for the declared-repos gate. Without ticketing.github, a top-level pr-<n>/ cannot be attributed, and a declaration naming the origin repository waits. Fail-closed and self-describing, but it is a new way for a half-configured repository to hold a gate.
  • Cross-repo routing widens what an event can name. A hostile PR in a watched repository can now name a work item in another watched repository, and — if that work item is armed — get its comments delivered into that session. This is the same exposure a hostile PR in the origin repository already had; the untrusted-excerpt framing in the prompt is the mitigation, unchanged.
  • D9 and the surface rules are guidance, not gates. the-loop opens no pull requests, so nothing mechanically prevents a session from opening a discussion PR under surface: issue. The record is the execution log's ## Pull requests section, which the reviewer-briefing node already gates for presence — not for judgement.
  • decision-051's invariant is weaker by one surface. The configuration it refused — specs iterated in a terminal, reasoning dying with the scrollback — is still refused. What it did not distinguish, and this does, is that the ticket is as durable and as public as the pull request.

Alternatives considered

OptionWhy not
One repo-qualified layout for every inner loop (pr-loops/<owner>__<repo>/pr-<n>/ always)Tidier, and it strands every work item mid-flight behind a state migration for a cosmetic gain.
Keep the surface in the harness config (workflow.outerLoop.surface) — the first draft of this decisionA repository-wide answer is wrong for half that repository's work items, and the question only ever arises per work item. Removed in review (PR #184) before it shipped.
Put the surface in the CLI config beside interaction.modeDifferent axes. interaction.mode says where a human is sitting — a property of the operator's machine (decision-032). The surface says where this work item is collaborated on, which is neither the machine's property nor the repository's.
Ask "is this multi-repo?" and derive the surface from itOne box answering two questions. The surface is what the loop actually needs to know; whether other repositories are involved is already expressed by repos: and by the pull requests that appear.
A second human gate for the surfaceissue-177 spent a whole work item establishing that a work item's shape is declared once, by a named human, before anything runs. A second stop would be a second thing to wait on, for a question the first stop is already asking.
Default the surface to the pull requestIt is the wrong default for the case the ticket is about, and the checklist makes asking free. A work item that wants a PR ticks one box.
Infer the contributing repositories from tasks.mdA gate that parses prose is the thing decision-041 exists to avoid; and the inference would be wrong exactly when it matters (a repository named in passing).
A config toggle for cross-repo linkageSee D6: the ingress and the arming gate already bound it.
Record the whole owner/repo inside each inner graph-state.json instead of in the pathThe path has to be unique regardless — two loops cannot share a directory — so the state field would be a second source of the same fact.
Let the-loop open the landing PR itselfthe-loop opens no pull requests anywhere; adding a first one here would be a new GitHub write path for a rule the agent can follow.

Released under the MIT License.