Skip to content

fix(queue): let a pull-request-bound follow-up inherit its parent's binding so pr-cure-change is reachable #269

Description

@bketelsen

Problem

A worker can never create the pr-cure-change follow-up that
ADR-0061
requires for a substantive cure. The path is structurally unreachable, so the
only lawful outcome when a cure needs a patch change is block_work and a
hand-minted root from the operator.

Observed 2026-08-28 on a pr-cure item for frostyard/updex#403 (head
5ac9b99). The worker did the mechanical cure, established that the change
needed was a two-file documentation edit on the pull request's own branch, and
tried to propose the pr-cure-change child ADR-0061 prescribes.
complete_work refused the entire completion:

follow-up "pr-cure-change": an existing-pull-request item must bind its pull
request: a review or cure record, or a sourceRef naming <url>@<head SHA>

The three pieces that close the loop:

  1. src/mcp/server.ts:64-76followUpSchema is a z.strictObject over
    exactly eight fields (kind, objective, instructions,
    acceptanceCriteria, allowedActions, delegableActions,
    requiredArtifact, executionTarget). There is no sourceRef, cure, or
    review, and strictObject rejects any additional key. A worker has no
    way to send a binding.
  2. src/queue/store.ts:1830 — the follow-up is validated as
    contractProblem({ ...followUp, parentId: parent.id }). Only parentId is
    added; the parent's own cure record and sourceRef are not carried onto
    the child.
  3. src/queue/store.ts:3002hasPullRequestBinding is satisfied only by a
    defined cure/review or a sourceRef matching
    <prefix>:https://github.com/<owner>/<repo>/pull/<n>@<40 hex>. With (1)
    and (2), a proposed child can satisfy none of them, so
    contractProblem returns existing-pull-request-without-binding
    (store.ts:3082-3087) for every pr-cure-change proposal.

Nothing else mints the kind either: enqueueCureRoot (store.ts:1012) throws
unless kind === "pr-cure", and grep -rn "pr-cure-change" src/ finds only
instruction text, refusal messages, and read-side kind sets — no creation
path anywhere.

ADR-0073's
binding predicate is correct in itself; the defect is that it was applied to
proposed children without giving a cure child any way to inherit the binding
its parent already holds. The two ADRs are individually sound and jointly
unsatisfiable.

The failure is also silent in the worst way: the refusal rejects the whole
complete_work, so a worker that follows ADR-0061 faithfully cannot complete
at all, and the tempting workaround — declaring the child
executionTarget: "new-pull-request" — passes validation while instructing
the next worker to open a second pull request for a head that already has one,
which ADR-0061 forbids.

The change

Let the store derive a cure/review child's binding from its parent instead of
requiring the worker to supply one.

  1. In src/queue/store.ts, where complete_work builds each follow-up
    (around store.ts:1826-1845), when the proposed child's kind is a
    pull-request-bound kind and the parent itself carries a binding, copy the
    parent's sourceRef (and cure/review record as applicable) onto the
    child before calling contractProblem, and persist it in
    insertWork. A child must bind exactly the parent's pull request and head
    — never a different one.
  2. Keep followUpSchema in src/mcp/server.ts closed. Do not add
    sourceRef, cure, or review to it: a worker-supplied binding would let
    a proposal name an arbitrary pull request and head, which is precisely what
    the predicate exists to prevent. The binding must come from the parent.
  3. When the parent has no binding and the child declares
    executionTarget: "existing-pull-request", keep refusing exactly as today,
    with the same code and message.
  4. Update docs/specs/work-queue.md (the follow-up contract rules) and
    docs/design/queue-execution-boundary.md to state that a
    pull-request-bound child inherits its parent's binding and cannot name
    another.
  5. If the resolution changes what ADR-0061 or ADR-0073 decided rather than
    implementing them, write the new ADR first and link both — do not amend an
    Accepted ADR.

Do not: bump SCHEMA_VERSION or add a migration rung; add or change an
MCP tool; widen followUpSchema; or let a child bind a pull request or head
different from its parent's.

Acceptance criteria

  1. On the pull request head's check workflow run, the job log shows the new
    test names running and passing, and the run concludes success.
  2. A new test proves the end-to-end path: a pr-cure item with a cure
    record completes with one pr-cure-change follow-up declaring
    executionTarget: "existing-pull-request", the completion is accepted,
    and the stored child's binding names the same pull request URL and the same
    head SHA as the parent's.
  3. A new test proves the guard still holds: the same follow-up proposed under
    a parent with no binding is still refused with
    existing-pull-request-without-binding.
  4. A new test proves a child cannot bind a different pull request or head
    than its parent.
  5. grep -rn "pr-cure-change" src/ shows at least one creation path, not only
    instruction text and refusal messages.
  6. git diff origin/main -- src/queue/store.ts shows no change to
    SCHEMA_VERSION and no edit or reorder of any existing migration rung;
    git diff origin/main -- src/mcp/server.ts shows no new field on
    followUpSchema.
  7. npm run check passes.

Blast radius

This widens what a completion may create, so it is the authorization boundary
and deserves the higher risk tier. The mitigation is that the binding is
derived from the parent and never accepted from the worker, so a proposal
cannot reach a pull request its parent does not already own. If an
implementation would let a worker influence which pull request or head a child
binds, that is a defect — block rather than ship it.

Until this lands, every substantive cure dead-ends at block_work and needs
the operator to mint the bound root by hand.

Metadata

Metadata

Assignees

No one assigned

    Labels

    snowcatQueued for the Snowcat fleet

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions