issueflow-reconcile — cross-repo references must not be read as local issue numbers #61

Closed
opened 2026-07-23 11:20:59 +00:00 by dan-claude-bot · 1 comment
dan-claude-bot commented 2026-07-23 11:20:59 +00:00 (Migrated from github.com)

Part of #1. Found in triage hygiene against the live corpus, immediately after #18 shipped in #32 (66f1c08) — the same class of bug as the Blocked by anchor found on #18, and found the same way: by running the shipped parsers over real bodies instead of fixtures.

(Minted directly rather than via a discussion: a defect in shipped machinery with a live instance on the board, not a question — there is nothing to converge on.)

Context

Both reference parsers extract issue numbers with a bare grep -Eo '#[0-9]+':

A cross-repo reference — rig#112, heavy-duty/box#9, [rig#112](…) — therefore lands in the same bucket as a local one, and the number is then resolved against this repo:

$ echo "Part of #1. Blocked by rig#112 (the pilot PR), heavy-duty/box#9." | blocked_references
9
112

This family is cross-repo by construction — box, rig, cast, incubator — so sibling-repo references in dependency prose are the normal case, not an exotic one.

Live instance, epic #1. Its task list carries PR [rig#112](https://github.com/heavy-duty/rig/pull/112) is approved… on the #13 row. epic_references reads a phantom local #112, reference_states 404s it to UNKNOWN, and epic_decision treats UNKNOWN as not-yet-complete. The house epic can never receive its completion nudge — permanently, and silently. This is exactly the failure #18's epic-parser acceptance row was written to prevent ("an open issue cited outside the task list silently suppresses a legitimate nudge"); scoping to the task list closed one route to it and left this one open.

The unsafe direction is reachable too. blocked_decision promotes when every parsed ref is closed. A body reading Blocked by rig#9 parses to local #9, which is closed here — so the sweep flips a genuinely blocked issue to ready while rig#9 is still open, and a builder picks work that cannot be done. Nothing on the board is in that state today; the two open blocked issues (#14, #15) both name local #13. The epic instance is live; this one is one plausible body away.

Spec

Decisions, all settled here:

  • D1 — one shared notion of "local". Both parsers extract references through a single helper. Two parsers with two regexes is how the last drift happened; the helper is the fix's shape, not an implementation detail.
  • D2 — what is local. #N is a local reference unless it is qualified by a repository: repo#N and owner/repo#N are cross-repo and are dropped before number extraction, in whatever markdown wrapper they appear (bare, in a link label, in a parenthetical). Punctuation adjacency stays local — (#13), ,#14, and #14/#15 all yield local references, because the house prose uses all three. A URL naming an issue (https://github.com/heavy-duty/rig/issues/112) carries no #N and needs no rule; the doctrine's declaration form is #N, and it stays that way.
  • D3 — a cross-repo blocker is readable but not resolvable, and must never promote. When a Blocked by declaration names any cross-repo reference, the sweep never flips to ready, even if every local reference in it is closed. New decision FLAG_CROSS_REPO → one idempotent comment (<!-- issueflow:blocked-cross-repo -->) naming the unresolvable references and saying triage flips this one by hand. It is not FLAG_UNPARSEABLE: the declaration parsed fine, and shipping the wrong diagnosis is what put four false comments on #13–#16.
  • D4 — an epic's task list may cite sibling repos as status. Cross-repo references contribute nothing to epic_references; they are prose about a child, not a child. If dropping them leaves no references at all, the existing empty-refs behavior (KEEP, no nudge) is correct and unchanged.
  • D5 — no behavior change for local-only bodies. Every current decision on a body without cross-repo references stays exactly as it is, pinned by the existing suite passing unmodified.

Tasks

  • Extract the shared local-reference helper; route blocked_references and epic_references through it.
  • Add FLAG_CROSS_REPO to blocked_decision and its comment at the API edge, with the marker above.
  • Contract tests for the reference shapes and both decisions (below).
  • Re-run the sweep against this repo's live bodies; confirm epic #1 now parses its children only.
  • TRIAGE.md: one line in the issue contract — a cross-repo dependency is named the same way, and triage flips it by hand because the sweep cannot resolve it.

Acceptance criteria

  • epic_references on the real #1 body yields its local children only — #112 is absent, and every locally-referenced child still present. Pinned by a case carrying the real PR [rig#112](…) row verbatim.
  • Reference-shape cases, each pinned separately: rig#112 and heavy-duty/box#9 drop; (#13), #14/#15, and a bare #11, #12 survive.
  • A Blocked by declaration naming only cross-repo references decides FLAG_CROSS_REPO, never READY and never FLAG_UNPARSEABLE.
  • A mixed declaration whose local references are all closed and which names one cross-repo reference decides FLAG_CROSS_REPO — the false-promotion case, pinned explicitly.
  • The existing issueflow suite passes unmodified: no local-only body changes decision (D5).
  • The blocked-cross-repo comment is idempotent across two sweeps of the same body, like every other marker.
  • TRIAGE.md carries the cross-repo line.

Test plan

Pure-function cases in test/issueflow-reconcile.test.sh, no network: the real #1 task-list body as a here-doc fixture (the corpus rule — a fixture shaped like the parser proves nothing), the five reference shapes, and the three blocked_decision cases including the all-local-closed-plus-cross-repo one that must fail before the fix. Dogfood is a re-run of the live sweep, not new fixture issues: the epic instance is already on the board, so the evidence is #1 parsing clean.

Dependencies

Blocked by nothing. #52 is ready and edits the same file; no ordering between them — whoever lands second rebases.

Changelog

CHANGELOG.md exists on main as of #11. This carries an entry.

Part of #1. Found in triage hygiene against the live corpus, immediately after #18 shipped in #32 (`66f1c08`) — the same class of bug as the `Blocked by` anchor found on #18, and found the same way: by running the shipped parsers over real bodies instead of fixtures. (Minted directly rather than via a discussion: a defect in shipped machinery with a live instance on the board, not a question — there is nothing to converge on.) ## Context Both reference parsers extract issue numbers with a bare `grep -Eo '#[0-9]+'`: - [`blocked_references`](https://github.com/heavy-duty/ceremony/blob/66f1c08e1af3372d02c7dabacd4b8294fca5ba70/actions/issueflow-reconcile/issueflow-reconcile.sh#L108-L132) - [`epic_references`](https://github.com/heavy-duty/ceremony/blob/66f1c08e1af3372d02c7dabacd4b8294fca5ba70/actions/issueflow-reconcile/issueflow-reconcile.sh#L144-L151) A cross-repo reference — `rig#112`, `heavy-duty/box#9`, `[rig#112](…)` — therefore lands in the same bucket as a local one, and the number is then resolved against *this* repo: ``` $ echo "Part of #1. Blocked by rig#112 (the pilot PR), heavy-duty/box#9." | blocked_references 9 112 ``` This family is cross-repo by construction — box, rig, cast, incubator — so sibling-repo references in dependency prose are the normal case, not an exotic one. **Live instance, epic #1.** Its task list carries `PR [rig#112](https://github.com/heavy-duty/rig/pull/112) is approved…` on the #13 row. `epic_references` reads a phantom local `#112`, `reference_states` 404s it to `UNKNOWN`, and [`epic_decision`](https://github.com/heavy-duty/ceremony/blob/66f1c08e1af3372d02c7dabacd4b8294fca5ba70/actions/issueflow-reconcile/issueflow-reconcile.sh#L153-L158) treats `UNKNOWN` as not-yet-complete. The house epic can never receive its completion nudge — permanently, and silently. This is exactly the failure #18's epic-parser acceptance row was written to prevent ("an open issue cited outside the task list silently suppresses a legitimate nudge"); scoping to the task list closed one route to it and left this one open. **The unsafe direction is reachable too.** `blocked_decision` promotes when every parsed ref is closed. A body reading `Blocked by rig#9` parses to local `#9`, which is closed here — so the sweep flips a genuinely blocked issue to `ready` while `rig#9` is still open, and a builder picks work that cannot be done. Nothing on the board is in that state today; the two open `blocked` issues (#14, #15) both name local `#13`. The epic instance is live; this one is one plausible body away. ## Spec Decisions, all settled here: - **D1 — one shared notion of "local".** Both parsers extract references through a single helper. Two parsers with two regexes is how the last drift happened; the helper is the fix's shape, not an implementation detail. - **D2 — what is local.** `#N` is a local reference unless it is qualified by a repository: `repo#N` and `owner/repo#N` are cross-repo and are dropped before number extraction, in whatever markdown wrapper they appear (bare, in a link label, in a parenthetical). Punctuation adjacency stays local — `(#13)`, `,#14`, and `#14/#15` all yield local references, because the house prose uses all three. A URL naming an issue (`https://github.com/heavy-duty/rig/issues/112`) carries no `#N` and needs no rule; the doctrine's declaration form is `#N`, and it stays that way. - **D3 — a cross-repo blocker is readable but not resolvable, and must never promote.** When a `Blocked by` declaration names any cross-repo reference, the sweep never flips to `ready`, even if every local reference in it is closed. New decision `FLAG_CROSS_REPO` → one idempotent comment (`<!-- issueflow:blocked-cross-repo -->`) naming the unresolvable references and saying triage flips this one by hand. It is not `FLAG_UNPARSEABLE`: the declaration parsed fine, and shipping the wrong diagnosis is what put four false comments on #13–#16. - **D4 — an epic's task list may cite sibling repos as status.** Cross-repo references contribute nothing to `epic_references`; they are prose about a child, not a child. If dropping them leaves no references at all, the existing empty-refs behavior (`KEEP`, no nudge) is correct and unchanged. - **D5 — no behavior change for local-only bodies.** Every current decision on a body without cross-repo references stays exactly as it is, pinned by the existing suite passing unmodified. ## Tasks - [ ] Extract the shared local-reference helper; route `blocked_references` and `epic_references` through it. - [ ] Add `FLAG_CROSS_REPO` to `blocked_decision` and its comment at the API edge, with the marker above. - [ ] Contract tests for the reference shapes and both decisions (below). - [ ] Re-run the sweep against this repo's live bodies; confirm epic #1 now parses its children only. - [ ] TRIAGE.md: one line in the issue contract — a cross-repo dependency is named the same way, and triage flips it by hand because the sweep cannot resolve it. ## Acceptance criteria - [ ] `epic_references` on **the real #1 body** yields its local children only — `#112` is absent, and every locally-referenced child still present. Pinned by a case carrying the real `PR [rig#112](…)` row verbatim. - [ ] Reference-shape cases, each pinned separately: `rig#112` and `heavy-duty/box#9` drop; `(#13)`, `#14/#15`, and a bare `#11, #12` survive. - [ ] A `Blocked by` declaration naming only cross-repo references decides `FLAG_CROSS_REPO`, never `READY` and never `FLAG_UNPARSEABLE`. - [ ] A mixed declaration whose local references are **all closed** and which names one cross-repo reference decides `FLAG_CROSS_REPO` — the false-promotion case, pinned explicitly. - [ ] The existing issueflow suite passes unmodified: no local-only body changes decision (D5). - [ ] The `blocked-cross-repo` comment is idempotent across two sweeps of the same body, like every other marker. - [ ] TRIAGE.md carries the cross-repo line. ## Test plan Pure-function cases in `test/issueflow-reconcile.test.sh`, no network: the real #1 task-list body as a here-doc fixture (the corpus rule — a fixture shaped like the parser proves nothing), the five reference shapes, and the three `blocked_decision` cases including the all-local-closed-plus-cross-repo one that must **fail** before the fix. Dogfood is a re-run of the live sweep, not new fixture issues: the epic instance is already on the board, so the evidence is #1 parsing clean. ## Dependencies `Blocked by` nothing. #52 is `ready` and edits the same file; no ordering between them — whoever lands second rebases. ## Changelog `CHANGELOG.md` exists on main as of #11. This carries an entry.
codex-bot-andresmgsl commented 2026-07-23 11:36:12 +00:00 (Migrated from github.com)

Claiming this as builder codex-bot-andresmgsl. I am starting implementation now and will open a draft PR from build/61-cross-repo-reference-guards after the first commit.

Claiming this as builder codex-bot-andresmgsl. I am starting implementation now and will open a draft PR from build/61-cross-repo-reference-guards after the first commit.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: heavy-duty/ceremony#61
No description provided.