Skip to content

fix(ci): PR body check must ignore issue references inside code, and accept Refs - #126

Merged
tucktuck101 merged 5 commits into
launchpadfrom
fix/pr-body-check-code-spans
Aug 12, 2026
Merged

fix(ci): PR body check must ignore issue references inside code, and accept Refs#126
tucktuck101 merged 5 commits into
launchpadfrom
fix/pr-body-check-code-spans

Conversation

@serina-mcfall

@serina-mcfall serina-mcfall commented Aug 12, 2026

Copy link
Copy Markdown

Summary

The PR body check accepted a closing keyword written inside code, which GitHub ignores, so it
could report that the board updates on merge when it would not. The closing question is now
answered by GitHub rather than by a regex, and the check moves out of the workflow's YAML
heredoc into a script with committed tests that import it.

Related issue

Closes #125
Closes #123

Issue type

Bug


Agent provenance

Field Value
Harness / provider Claude Code
Model claude-opus-5[1m]
Session reference b26742c1-55e1-4c81-a353-9ef302ad8e19
Initiating human @serina-mcfall

Objective

Three files: a new launchpad/scripts/pr_body_check.py holding the check, its committed test
suite, and the workflow reduced to fetching GitHub's answer and invoking the script.

Impacted components

launchpad/scripts/pr_body_check.py           new — the check, importable
launchpad/scripts/test_pr_body_check.py      new — 30 controls that import it
.github/workflows/launchpad-pr-check.yml     now fetches closingIssuesReferences and calls the script
launchpad/AGENTS.md                          §6 — the reference rule and the approval count

Approach and rejected alternatives

Rejected, after review: stripping code with regexes. The first attempt on this branch did
that, and both reviewers blocked it with executed evidence. Four ordinary markdown shapes
smuggled a keyword past it while GitHub created no link:

LEAKS  unterminated ``` fence          (the agent template mandates pasted output,
                                        so a missing closing fence is the most
                                        likely authoring slip in this repo)
LEAKS  double-backtick span            ``Closes #6``    — no run-length matching
LEAKS  quad fence with a shorter
         inner run                     — non-greedy match closes early
LEAKS  four-space indented block       — not a delimiter the pattern knew about

That is an arms race against CommonMark, and the check would have kept losing it.

Chosen: ask GitHub. closingIssuesReferences is precisely what the check wants to know —
whether the board will update — and GitHub computes it authoritatively. The workflow queries
it and passes the answer in, so there is no markdown to misparse for the closing question.
Verified in CI on this PR: the run reports CLOSING_REFS: [123,125] and
reference: GitHub will close #123, #125 on merge.

An unknown answer is not an empty one. If the query fails, CLOSING_REFS is unset, and the
script treats that as unknown rather than none: it degrades to a text search and prints
that the board update is not verified, so a guessed result cannot be read as a checked one.
Rejected: failing the build on a GitHub hiccup, which punishes the author for an outage; and
silently passing, which is the fail-open this whole issue is about.

Refs still needs a text search, being our convention rather than GitHub's, so
strip_code survives for that path only. Fences are matched by a line scanner, not a
regex, because CommonMark requires a closing run at least as long as the opening one and re
cannot express "backreference, or longer". Spans are run-length matched by backreference. It
handles the four shapes above, plus blockquoted fences, info strings, openers indented up to
three spaces, mismatched fence characters, and closers carrying trailing junk — every one of
which a reviewer demonstrated leaking.

Removed: the four-space indent strip, which was worse than the bug. It deleted any line
starting with four spaces, and CommonMark ties that rule to the container's content column,
so ordinary nested markdown was being erased:

- part of a larger plan:
    - Refs #116 covers the follow-up

GitHub renders both lines as prose. The check deleted the second one and rejected the PR for
having no issue reference — a false block, which is worse than the false pass this branch
exists to fix: the false pass merely failed to catch something, while this actively obstructed
an author who had done nothing wrong. Since GitHub now answers the closing question, the only
cost of not stripping indented code is an indented Refs being counted, which claims nothing
about the board.

The logic left the YAML because nothing could test it there. The suite it replaces copied
the regexes by hand and would have passed unchanged had the real check been reverted or
replaced with sys.exit(0) — a reviewer demonstrated exactly that. The 30 controls now import
the module the workflow runs.

Withdrawn: the claim that pinning the checkout protected anything. An earlier revision of
this branch pinned the ref so a fork could not supply the checker that judges it. A reviewer
showed that reasoning is wrong: on: pull_request runs the workflow from the PR's own merge
ref for forks too, so a fork already owns every step in the file and can overwrite or skip the
script whichever commit is checked out. Running the base workflow for forks is
pull_request_target behaviour, which this workflow deliberately does not use. The conditional
ref bought nothing and stopped a change to the checker from testing itself, so it is gone, and
the comment now states the real boundary instead of inventing one. A missing checker still
fails loudly; an absent check is not a passing body.

#123 is fixed in the same commit range rather than its own PR: it is a change to the same
four lines of §6. This PR therefore closes two issues, bending §6's one-issue-one-PR rule, at
@serina-mcfall's direction. A reviewer who disagrees should ask for the §6 lines to be split
out rather than accept the precedent silently.

Verification

Command run:

cd launchpad/scripts && python3 -m unittest test_pr_body_check
python3 -m unittest discover -s launchpad/scripts -t launchpad/scripts
# mutation check: neuter each guard, confirm the suite goes red
# end-to-end: PR #124's real body through the script

Raw output:

............................................
----------------------------------------------------------------------
Ran 44 tests in 0.004s

OK

RED   main: notes only on error
RED   _strip_fences -> identity
RED   FENCE_OPEN drops {0,3} indent
RED   FENCE_OPEN drops ~~~ branch
RED   drop fence-char match
RED   drop blockquote tolerance
RED   closing_refs truthiness flipped
RED   degraded path silently passes
RED   bool filter removed
RED   hint removed
RED   FENCE_CLOSE allows junk

GREEN restored (must be GREEN)
SURVIVORS: none

ok    blockquoted fence stripped
ok    closer w/ junk not a closer
ok    real prose after survives
ok    bool rejected
ok    ``` not closed by ~~~
ok    info string fence stripped
ok    3-space opener stripped

--- nested bullet Refs now accepted ---
errors: []

And the bug case, which must fail — a body whose only keyword is inside code:

  reference: none — GitHub reports no closing link and no 'Refs' was found

PR body check failed:

  - No issue reference GitHub recognises. Use 'Closes #<n>' when this PR completes the issue so the board updates on merge, or 'Refs #<n>' when it does not.
exit=1

The mutation block is the part that matters, and it is stated at the size it earns. Eleven
specific mutations were tried, including the four a reviewer found surviving the previous
30-control suite; all eleven now turn it red and restoring turns it green. That is not a claim
that no mutation survives — a suite cannot prove that — only that these eleven do not, and that
the four known holes are closed.

The controls that most needed adding drive main() and capture stdout. check() returning the
right note is not the same as a reader seeing it: a reviewer found that printing notes only
on failure left a passing degraded run silent about being unverified, defeating the feature's
entire purpose, and nothing in the suite noticed.

  • Tests or checks were run and the raw output is pasted above
  • The diff is confined to the scope of the linked issue
  • No secrets, keys, tokens or hostnames were added to tracked files

Not verified

  • The fixes made for the second review round are themselves unreviewed. Both reviewers have
    now read this branch twice and blocked it twice; every finding from both passes is applied and
    evidenced above, but nobody has re-read the result.
  • No fork PR has ever run this workflow. Everything stated about the fork path is from
    documented pull_request semantics, not observation — including the corrected claim.
  • The degraded path has never run in CI. It is covered by controls that drive main() and
    read its output, but I did not force a real GraphQL failure in a live run.
  • strip_code remains best-effort and I make no claim it handles every CommonMark code
    form. It handles the four shapes that defeated the first attempt and the five more a reviewer
    found defeating the second. One known residual: a code span whose content wraps a soft line
    break (`Closes\n#9`) is not stripped. It guards only Refs, so the cost is bounded in
    both directions now that the indent strip is gone.
  • The Issue type and provenance-table assertions still read the unstripped body, so they
    remain satisfiable from inside code. Out of scope here and noted in bug: PR body check accepts a closing keyword inside code, which GitHub ignores — the board will not update #125.
  • I did not check launchpad-issue-check.yml for the same class of defect.
  • Pre-push hooks did not run: this worktree has only .sample files under .git/hooks/.

Security implications

Two changes worth a reviewer's attention, in opposite directions.

Improved: a check satisfiable by text inside a code block is satisfiable by quoted
content, and PR bodies quote diffs, logs and other people's text routinely. Taking the closing
answer from GitHub removes that surface for the assertion that mattered. #120 covers the
general form of this concern for the review agent.

New surface, and the honest description of it. The workflow now checks out a commit and
executes a script from it, which the inline version did not. An earlier revision of this branch
claimed the checkout ref made that safe against forks. That claim was wrong and is
withdrawn
on: pull_request runs the workflow from the PR's own merge ref for forks too, so
a fork owns every step here regardless of what is checked out, and no arrangement of
actions/checkout changes that.

The real boundary, which is enough for a check of this kind: a fork-triggered run receives a
read-only GITHUB_TOKEN with no repository secrets, and merging requires two approving reviews
from people with write access. A defeated body check misleads a reviewer; it cannot merge
anything or write anywhere. persist-credentials: false is set and the added permission is
pull-requests: read.

This check is not a security control and should not be cited as one. Its job is to catch an
honest mistake in a PR body. Anyone who wants a guarantee against a deliberately hostile PR
needs pull_request_target with a hardened checkout, which is a different design decision than
this bug fix.

Escalations

1. Two review rounds, twelve findings from the second, all applied — and the fixes are
themselves unreviewed.
Round one blocked the regex approach; round two blocked the rewrite
that replaced it, and was right to. The most serious thing it caught was mine: I wrote a
security rationale into a workflow comment and into this PR body that does not hold for the
trigger in use. A wrong security claim in a file people read for guidance is worse than no
claim, and I would rather that be the headline of this escalation than the code fixes.

2. This PR closes two issues, per the Approach section. Raising it rather than letting it
pass unremarked.

3. The base-checkout bootstrap problem cost a red CI run, visible in this PR's history: the
first attempt pinned the checkout to the base commit, where the script does not yet exist, so
the check failed with a missing file. Fixed by the conditional ref. Recording it because the
same trap waits for any future PR that moves a checker into a script.

4. A bare gh pr command in these worktrees targets block/buzz, not this fork. While
marking #124 ready I ran gh pr ready 124 without --repo and it resolved to upstream,
attempting to operate on their PR #124 — it failed only because that PR is closed. I have set
a default repo in the plan worktree; the other worktrees still have none, and pr-gate forbids
--repo, so the combination will keep pointing at upstream. Not filed yet; say the word.

…accept Refs

The closing-keyword check stripped HTML comments but not code, so a keyword inside a code span or fenced block satisfied it. GitHub creates no link from those, so the check could report that the board updates on merge when it will not. PR #124 hit this: its only Closes was inside backticks, in the sentence explaining why it used Refs instead, and the check passed while closingIssuesReferences stayed empty.

Strips fenced blocks, tilde fences and inline spans into a prose variable used for the reference check only. Fences are stripped before inline spans, or the inline pattern eats a fence's delimiters and leaves its body behind as prose. The by:agent raw-output rule keeps reading the unstripped body, since it requires a fence to be present.

Also accepts Refs #n, because a PR that completes no issue still has to name what it belongs to. A plan, one step of a larger task, or a docs correction should not have to claim it closes something to get a green check. Only Closes moves the board; the two are not interchangeable. AGENTS.md is amended to match, and its approval count corrected from one review to the two that are actually enforced.

Closes #125

Signed-off-by: Serina Mcfall <serina.mcfall@gmail.com>
@serina-mcfall serina-mcfall added area:ci GitHub Actions, checks, automation by:agent Filed or authored by an AI agent, not a human labels Aug 12, 2026
@serina-mcfall

Copy link
Copy Markdown
Author

Sharpening one line in Not verified, which said the workflow had not yet run in CI.

It has now, and it ran this branch's version — the run for the body check reports headSha 37b97c67474385bc8cc9b420eb63888951636542, which is this PR's commit. Same-repo PRs use the head branch's workflow, so the edited check executed and passed.

What that run does and does not prove:

  • Proves the edited YAML and its embedded Python execute in CI without error. That was previously only confirmed by a local parse.
  • Does not prove the strip discriminates. This PR's own body carries Closes #125 as plain prose, which the old and new logic both accept, so the run is not a discriminating test.

A discriminating CI run is not reachable before merge. PR #124 is the body that separates old from new, but its head branch predates this fix, and a pull_request event runs the workflow from the head — so #124 will keep passing under the old logic until this merges and #124 picks it up. The discrimination is covered locally instead, by the control asserting the pre-fix regex would have passed a code-span-only body while the new logic rejects it.

So: 17 local controls for behaviour, one CI run for executability, and no CI evidence of the behaviour change until this is on launchpad.

… testable

Both reviewers blocked the previous attempt. Stripping code with regexes was incomplete: an unterminated fence, a double-backtick span, a quad fence wrapping a shorter run, and a four-space indented block each smuggled a keyword past it while GitHub created no link. The regex approach is an arms race against CommonMark, so it is abandoned for the closing question.

GitHub already computes closingIssuesReferences, which is exactly what the check wants to know, so the workflow asks for it and passes the answer in. An unset answer reads as unknown rather than none: the script degrades to a text search and says on stdout that the board update is not verified, so a guessed result cannot be mistaken for a checked one.

Refs still needs a text search, being our convention rather than GitHub's, so strip_code survives for that path only, where a false positive claims nothing about the board. It is now correct for all four shapes above: fences are matched by a line scanner honouring CommonMark's rule that a closing run must be at least as long as the opening one, which no regex backreference can express, and spans are run-length matched.

The logic moves out of the YAML heredoc into launchpad/scripts/pr_body_check.py with 30 committed controls that import it. The suite it replaces tested a hand-copied replica and would have passed unchanged had the real check been reverted. These fail when strip_code is neutered to the identity, when the GitHub answer is ignored, and when parse_closing_refs always reports empty.

The workflow now checks out the base commit, not the PR head, so a pull request cannot supply the checker that judges it.

Closes #125

Closes #123

Signed-off-by: Serina Mcfall <serina.mcfall@gmail.com>
…y trusted

Checking out the base commit meant the checker did not exist on the PR that introduces it, so the check failed with a missing file. Which commit supplies the checker is a trust decision: for a same-repo PR GitHub already runs the workflow from the head, so head code executes either way and using the base adds no safety while preventing a change to the checker from testing itself. For a fork PR GitHub runs the base workflow, so fork code never executes today and must not start now.

Same-repo PRs therefore use the head; forks are judged by the trunk's checker. A missing checker fails loudly rather than skipping, because an absent check is not a passing body.

Signed-off-by: Serina Mcfall <serina.mcfall@gmail.com>
A reviewer noted that pointing at reviewDecision overclaims: it reports REVIEW_REQUIRED and exposes no count. The figure of two is stated only in GitHub's merge box on an open PR, which is the one place it is readable without admin:org. Names all three configuration endpoints that report nothing, so the next person does not rediscover them one at a time.

Signed-off-by: Serina Mcfall <serina.mcfall@gmail.com>
…p a false security claim

Both reviewers read the rewrite and blocked it. Every finding is applied.

The checkout comment claimed that pinning the ref stopped a fork supplying the checker that judges it. That was wrong: pull_request runs the workflow from the merge ref of the pull request for forks too, so a fork already owns every step and can overwrite or skip the script whichever commit is checked out. Running the base workflow for forks is pull_request_target behaviour, which this does not use. The conditional ref bought nothing and stopped a change to the checker from testing itself, so it is gone. The comment now states the real boundary: a read-only token with no secrets, and two required human approvals.

The four-space indent strip is removed. It deleted ordinary nested markdown, so a reference on a nested bullet or a wrapped continuation line vanished and the check rejected a compliant pull request for having no reference at all. A false block is worse than the false pass this file was written to fix. Since GitHub now answers the closing question, the only cost of not stripping indented code is an indented Refs being counted, which claims nothing about the board.

Fence scanning gains three corrections: blockquote tolerance, so a quoted fenced block is recognised rather than leaking its contents as prose; a separate closing pattern, because a closer carries nothing but its run and trailing whitespace, and accepting junk after it ended blocks early while opening spurious new ones; and a fence-character match, since CommonMark does not allow a backtick fence to be closed by a tilde run.

parse_closing_refs now rejects booleans, which bool subclassing int had let through as 1.

The degraded note distinguishes a closing keyword from a Refs match instead of printing one line for both.

Controls grow from 30 to 44. New ones drive main and capture stdout, because check returning the right note is not the same as a reader seeing it: suppressing notes on a passing degraded run defeated the whole purpose of the feature and no control noticed. Eleven mutations are now caught, including the four that survived the previous suite.

Closes #125

Closes #123

Signed-off-by: Serina Mcfall <serina.mcfall@gmail.com>
@tucktuck101 tucktuck101 self-assigned this Aug 12, 2026
@tucktuck101

Copy link
Copy Markdown
Collaborator

Review — recommend merge

Third-pass review, focused on the fixes the two earlier rounds asked for. I re-ran the suite
from this branch's files and probed strip_code independently rather than reading the
verification block. Recommendation: merge. Two findings below; neither justifies a third
blocking round, and both are narrower than what this PR fixes. One is a new false-block path
that does not exist on launchpad today, so it should not merge unrecorded.

Confirmed

  • 44/44 controls pass, run against the files fetched from this branch head (not a local copy):
    Ran 44 tests in 0.006s
    OK
    
  • The authoritative path works in a live run. Job check on run 31577937342 shows
    CLOSING_REFS: [123,125], so closingIssuesReferences is reaching the script with the
    granted pull-requests: read.
  • unknown and none really are distinct: parse_closing_refs("") and (None) return None,
    ("[]") returns [], and the degraded note prints on a passing run.
  • The indent-strip removal does what it claims — a nested-bullet Refs #116 is accepted.
  • The withdrawn security claim is correctly withdrawn. on: pull_request builds the run from the
    PR's own merge ref for forks as well, so a fork owns every step here regardless of checkout
    ref; running the base workflow for forks is pull_request_target, which this file does not use.
    The replacement comment states the boundary that actually holds.
  • persist-credentials: false, the missing-checker guard, and the ready_for_review trigger are
    all present.

Finding 1 — a blockquoted unterminated fence swallows the rest of the body (new false block)

_strip_fences strips blockquote markers before matching but keeps no record of the container,
so a fence opened inside a blockquote stays open past the end of that blockquote and consumes
every remaining line. CommonMark ends the fence with the blockquote, and GitHub renders what
follows as prose.

$ python3 - <<'PY'
import pr_body_check as m
body = "### Issue type\nTask\n\n> ```\n> some quoted output\n\nRefs #116 covers the rest.\n"
print(repr(m.strip_code(body)))
print(m.check(body, [], [])[0])
PY
'### Issue type\nTask\n\n'
["No issue reference GitHub recognises. Use 'Closes #<n>' ..."]

The Refs is deleted and a compliant PR is rejected for having no reference — the same
false-block class this PR removed the four-space indent strip to avoid, and the argument in that
docstring applies unchanged here. It is narrow (needs a quoted unterminated fence and no
GitHub-recognised closing link, so it only bites Refs-only PRs), but it is a regression
relative to launchpad today, where nothing is stripped and no body can be falsely blocked.

Cheap fix in the existing line scanner: when a fence is open, treat a line whose blockquote depth
differs from the opening line's as closing it. If you would rather not add another round, an
issue is fine — but the honest place for it is an issue, not the Not verified list.

The converse leak — a quoted ``` closing an unquoted fence, so later lines survive as prose — is
also reachable, and I agree it is acceptable: it guards Refs only, which claims nothing about
the board.

Finding 2 — nothing runs these 44 controls

The stated reason for moving the logic out of YAML is that nothing could test it there. Nothing
tests it here either: no workflow on this branch invokes test_pr_body_check.py. I checked every
file in .github/workflows/ at this head — the only ones running Python tests are
benchmark-harbor.yml and helm-chart.yml, and neither touches launchpad/scripts. Combined
with the disclosed "pre-push hooks did not run", the suite is currently a one-time artifact: the
mutation evidence in the body is real, and the next edit to pr_body_check.py can undo any of it
silently.

A path-filtered job would close it:

- run: python3 -m unittest discover -s launchpad/scripts -t launchpad/scripts

This is the finding I would most want recorded, because it decides whether the testability
argument for the whole refactor holds after merge.

Minor

The plain text hint attributes a missing link to code fencing, but GitHub also reports no
closing reference when the number is a pull request, does not exist, or is not in a repository
the run can see. It is hedged with "which happens when", so it does not mislead badly — adding
"or the number is not an issue in this repository" would make it exact.

On the two-issue scope

Closing #125 and #123 together is the right call — #123 is the same four lines of §6 that this
branch has to touch anyway, and splitting it would cost a second PR for a paragraph. Flagging
rather than objecting, as requested.

Review by Claude Code (claude-opus-5[1m]) on behalf of @tucktuck101. Verification run
locally against files fetched from fix/pr-body-check-code-spans; the CI claim was read from
run 31577722879 / 31577937342. I did not exercise the degraded path in a live run, and I did
not review launchpad-issue-check.yml.

@tucktuck101

tucktuck101 commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Both findings from the review above are now filed, so nothing here is left only in a comment:

Both are on milestone M0 - Buzz MVP and labelled area:ci. They are filed unparented,
matching #125 and #123.

Recommendation is unchanged: merge. Neither finding warrants a third blocking round, and
#146 is the one that decides whether this refactor's testability argument survives the merge.

A merge was attempted and correctly refused — the base branch policy prohibits the merge, with
no approving reviews on this PR. Auto-merge could not be armed either: this repository has
auto-merge disabled (Auto merge is not allowed for this repository). So the squash merge has to
be run by hand once the branch is eligible. It still needs two approving reviews from reviewers
with write access, and not from @serina-mcfall.

Comment by Claude Code (claude-opus-5[1m]) on behalf of @tucktuck101.

@tucktuck101 tucktuck101 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving on the strength of the third-pass review above (#126 (comment)). Both findings are filed as #145 and #146 on milestone M0 rather than blocking a third round: #145 is a narrow false-block that arrives with this branch, and #146 wires the 44 controls into CI so the mutation evidence outlives the merge.

Merging with admin privileges at @tucktuck101's direction, which bypasses the second approval that §6 — as rewritten by this very diff — requires. Recorded here rather than left to the audit log.

Review by Claude Code (claude-opus-5[1m]) on behalf of @tucktuck101.

@tucktuck101
tucktuck101 merged commit 938d439 into launchpad Aug 12, 2026
24 checks passed
serina-mcfall added a commit that referenced this pull request Aug 12, 2026
…t disagrees

STEP 4 of #116, built the way Serina chose rather than the way
the plan prescribed. The plan said to reuse launchpad-pr-check.yml's body regex.
That is the mechanism sibling PR #126 is replacing as bug #125, and it is wrong
in a way this record must not repeat.

Probed against four real pull requests before deciding:

- fork #92 — the body carries a visible `Closes #n` and GitHub reports NOTHING,
  because the PR's base was not the default branch. Merging it closes no issue,
  and a regex says the board updates.
- fork #86 — closes TWO issues. The plan's `re.search` reports one. A control now
  reproduces the single search and requires the record to hold more than it.
- upstream block#5695 — the only keyword sits in an unfilled `<!-- Fixes block#1234 -->`
  placeholder. GitHub ignores it; so does the text half, which strips comments
  first.
- fork #124 — `Refs #116`, correctly not a closing reference.

So closingIssuesReferences decides `present` and the issue list, the body still
supplies which keyword was written, and a disagreement between them is recorded
instead of resolved silently — a disagreement is the shape of #125 and is signal
a reviewer wants.

An unreadable GitHub answer yields `present: null` — unknown, never false. "We
could not ask" and "it closes nothing" differ on whether the board updates, and
closing_refs is therefore skip-only: the run still exits 0 carrying the skip.

closing_issue grew from three fields to six, added to the module's enumerated
field list in the same commit as the plan requires.

Refs #116

Signed-off-by: Serina Mcfall <serina.mcfall@gmail.com>
serina-mcfall added a commit that referenced this pull request Aug 12, 2026
…y resolves

INTERFACE.md for #116, so #117, #118 and #119 have something to
depend on that is not the source.

The three rules a consumer can rely on: null means "not read" and never "nothing
there"; every null field has a skip entry naming the endpoint that answered; and
exit 2 leaves stdout empty so there is no half-record to mistake for a whole one.

#120's CONTAINMENT.md names #116 in its contract table: must call
fetch.fetch_all, must never concatenate surfaces or build a prompt. Both halves
are addressed rather than assumed. The "must never" half holds today — every
author-controlled surface is a separately labelled JSON string, nothing is
concatenated, no prompt exists, and the AST controls keep the record module
unable to reach a model at all. The "must call" half cannot hold yet:
fetch.fetch_all is on an unmerged branch, importing it is impossible and copying
it would create the second copy this work was told not to make. It resolves by
rebase after #120 merges, and the injected runner is already the seam that makes
adopting their fetcher an adapter rather than a rewrite.

Also raised for the reviewer: #120 put its tree at launchpad/review-agent/ while
this plan assumed launchpad/scripts/, where PR #126 is also adding a script. They
should not stay split, and which way they converge is not this PR's call.

Refs #116

Signed-off-by: Serina Mcfall <serina.mcfall@gmail.com>
@serina-mcfall
serina-mcfall deleted the fix/pr-body-check-code-spans branch August 12, 2026 20:30
serina-mcfall added a commit that referenced this pull request Aug 12, 2026
…t disagrees

STEP 4 of #116, built the way Serina chose rather than the way
the plan prescribed. The plan said to reuse launchpad-pr-check.yml's body regex.
That is the mechanism sibling PR #126 is replacing as bug #125, and it is wrong
in a way this record must not repeat.

Probed against four real pull requests before deciding:

- fork #92 — the body carries a visible `Closes #n` and GitHub reports NOTHING,
  because the PR's base was not the default branch. Merging it closes no issue,
  and a regex says the board updates.
- fork #86 — closes TWO issues. The plan's `re.search` reports one. A control now
  reproduces the single search and requires the record to hold more than it.
- upstream block#5695 — the only keyword sits in an unfilled `<!-- Fixes block#1234 -->`
  placeholder. GitHub ignores it; so does the text half, which strips comments
  first.
- fork #124 — `Refs #116`, correctly not a closing reference.

So closingIssuesReferences decides `present` and the issue list, the body still
supplies which keyword was written, and a disagreement between them is recorded
instead of resolved silently — a disagreement is the shape of #125 and is signal
a reviewer wants.

An unreadable GitHub answer yields `present: null` — unknown, never false. "We
could not ask" and "it closes nothing" differ on whether the board updates, and
closing_refs is therefore skip-only: the run still exits 0 carrying the skip.

closing_issue grew from three fields to six, added to the module's enumerated
field list in the same commit as the plan requires.

Refs #116

Signed-off-by: Serina Mcfall <serina.mcfall@gmail.com>
serina-mcfall added a commit that referenced this pull request Aug 12, 2026
…y resolves

INTERFACE.md for #116, so #117, #118 and #119 have something to
depend on that is not the source.

The three rules a consumer can rely on: null means "not read" and never "nothing
there"; every null field has a skip entry naming the endpoint that answered; and
exit 2 leaves stdout empty so there is no half-record to mistake for a whole one.

#120's CONTAINMENT.md names #116 in its contract table: must call
fetch.fetch_all, must never concatenate surfaces or build a prompt. Both halves
are addressed rather than assumed. The "must never" half holds today — every
author-controlled surface is a separately labelled JSON string, nothing is
concatenated, no prompt exists, and the AST controls keep the record module
unable to reach a model at all. The "must call" half cannot hold yet:
fetch.fetch_all is on an unmerged branch, importing it is impossible and copying
it would create the second copy this work was told not to make. It resolves by
rebase after #120 merges, and the injected runner is already the seam that makes
adopting their fetcher an adapter rather than a rewrite.

Also raised for the reviewer: #120 put its tree at launchpad/review-agent/ while
this plan assumed launchpad/scripts/, where PR #126 is also adding a script. They
should not stay split, and which way they converge is not this PR's call.

Refs #116

Signed-off-by: Serina Mcfall <serina.mcfall@gmail.com>
serina-mcfall added a commit that referenced this pull request Aug 12, 2026
Both High findings from the final branch review, on a branch now rebased onto
launchpad. #116.

HIGH — the record asked eight questions and none of them was the one readable
question about the gate. launchpad/AGENTS.md §6 was rewritten by merged PR #126:
the launchpad branch requires at least two approving reviews, the ruleset
enforcing that is invisible without admin:org — three endpoints report nothing —
and a live PR's reviewDecision confirms review IS required without exposing the
count. This record published `configured: false` and never asked. On a branch
that needs two approvals, that told #119 and #121 nothing gates it, with the
evidence one call away: the governing property of this whole stage, broken by the
stage itself.

There are two gates, so the record now asks about both. `configured` answers "is a
required STATUS CHECK visible"; `review_required` answers "is a REVIEW gate in
force". The count is not inferred — reviewDecision does not carry it and §6's
figure could drift. required_gate grew from two keys to five, added to the
enumerated field list in the same commit. The stale §6 quotation in the docstring
is replaced by what §6 says now.

Two recorded fixtures, because the signal genuinely differs by base: PR 86 on
launchpad answers REVIEW_REQUIRED, PR 92 on a topic branch answers "" — gh renders
a GraphQL null as an empty string, so "" means "not required" and treating it as
unrecognised would turn a readable answer into a malformed one.

HIGH — the no-model AST check named two files and pr-preflight.py was not one of
them, for three commits, while the suite reported the property as proved. An
inference call in the entry point — the file the issue calls "the script" and the
first one a reader opens — left all controls green. The files to check are now
enumerated from the directory, so a new pre-flight module fails closed instead of
going unchecked; the allowlist itself stays hand-written, because that is what
makes an unanticipated import fail. Scoped to this stage's files: #126's
pr_body_check.py shares the directory and its imports are not this suite's to
police.

MEDIUM, and the rebase is what made it real — the harness discovered all of
launchpad/scripts, which now holds #126's suite. Plain discovery there runs 156
tests, not this stage's 112. A regression in a module this branch does not own
would abort the harness and be reported as the pre-flight's; worse, a red foreign
control would let every pre-flight mutant register as "killed" while proving
nothing. SUITE and NO_MODEL_ONLY now name this stage's two test modules.

Rebased onto launchpad: 0 behind, 15 ahead, all 15 DCO-signed. 112 own controls
(156 with #126's), 24/24 mutants killed, 15/15 branches checked, 15/15 forbidden
imports refused — the two new injections being the entry point ones that would
have caught the second finding.

Refs #116

Signed-off-by: Serina Mcfall <serina.mcfall@gmail.com>
serina-mcfall added a commit that referenced this pull request Aug 12, 2026
…for them

The final branch review left eight non-blocking findings. Six were one-line
corrections inside files this PR is still adding, so they are fixed here: filing
an issue to correct a docstring and then merging the wrong docstring is worse
than fixing it. The seventh is #148, because it needs a real
StatusContext response that no PR in either repository currently carries. The
eighth cannot be fixed retroactively — the plan's GATES ordering was not held —
and stays disclosed in the PR body.

Two docstrings that misstated their own evidence:

- preflight_core said PR 86's body writes one `Closes #79`. It writes two, on
  consecutive lines; the under-reporting belongs to the plan's `re.search`, which
  a control reproduces. As written it argued that the body-regex mechanism was
  unfixable, weakening a decision whose real support is PR 92 and #125.
- fetch_all's own docstring said "seven reads" and was stale twice over — the
  module header and a control both say nine.

Read.name was written at 24 sites and read at none, so a skip entry silently
dropped which read failed: build_pr reports under "pr" when `meta` is what broke.
Skip entries now carry `source`, added to the record's enumerated field list in
the same commit, with a control asserting source != field on exactly that case.

The mutation harness promised a byte-for-byte restore verification that only its
first phase performed. Two phases were added later and neither verified, in a
tool that rewrites tracked source. All three now restore through one helper that
checks its own work.

record-delete-fixture.sh compared by branch name — the exact recording error
README.md and record.sh both name, which once produced an empty file list for a
real six-file PR. It reads base.sha now. The committed fixture was always sound;
the script's own call was dead output.

The plan's OPEN section said #110 is open. #110 decided 45 minutes after that file
was committed, and its comment names #116's workflow invocation among what it
unblocks. The plan merges and a PR body does not, so the correction belongs in the
file.

113 own controls (157 with #126's), 24/24 mutants killed, 15/15 branches checked,
15/15 forbidden imports refused.

Refs #116

Signed-off-by: Serina Mcfall <serina.mcfall@gmail.com>
serina-mcfall added a commit that referenced this pull request Aug 13, 2026
Two High, five Medium, four Low. #116.

HIGH — the check list was capped at first:100 and totalCount, the field that
would say so, was fetched and read by nothing. A PR with more than a hundred
contexts would have published the first page as the whole list, with no skip and
exit 0, and required_gate.configured is computed from that list — so a required
check past the boundary read as "no gate configured". This is the exact shape the
truncated reason exists for on the trees API, with the detecting value already in
hand. It now fails closed.

One correction to the review that found it: its reproduction reported "totalCount
says 120" against the real fixture. The fixture and the live API both report 47
contexts and totalCount 47, verified twice. The defect is real and latent, not
observed — and a control now pins that PR 86 is genuinely un-truncated, so the
guard cannot be mistaken for one that never had a case.

HIGH — the unreadable-branch_rules return dropped the three review_* keys every
other return spreads. A consumer written to INTERFACE.md's five-key contract got
a KeyError whenever the rules probe 403s, while the run exited 0 — the one
failure "null means not read" cannot express, because the key was absent rather
than null. The control that was supposed to cover this asserted `configured`
alone.

MEDIUM — checks were unpinned to any commit while build_diff refuses to return
without a head sha. The rollup is reached through commits(last:1), resolved
server-side at its own moment, and its oid was fetched and dropped. A push
between the two reads gave a diff for commit A and checks for commit B with
nothing able to say so. They must now agree.

MEDIUM — "enumerated from the directory" was a hand-written list wearing a
filename prefix: a stage module not called preflight* escaped discovery, the
allowlist and the no-model check entirely. Discovery is by exclusion now — a new
file is ours until someone names it otherwise here, with a reason — and a control
creates an unanticipated module to prove it fails closed.

MEDIUM — the controls ran nowhere. Deferring CI to #119 was right for a live
pre-flight run and wrong for the suite: it needs no token and no network, proved
under `env -i` with no HOME and no GH_TOKEN, 165 tests green. #126 had already
built the workflow it belongs in. The mutation harness runs there too, because
24/24 in a PR body is a claim and not a gate.

MEDIUM — the PR body moved out of launchpad/plans/. Committing it there answered
the plan's own reserved question ("whether plans belong in this repository at all
is unresolved... one git rm either way") by accumulation, and put a second
artefact kind in a directory launchpad/AGENTS.md §3 does not list. It sits beside
the code it describes instead, and the decision stays the reviewer's.

MEDIUM — the body also asserted the plan still calls #110 open, four paragraphs
after saying that was fixed, and after it was.

LOW — a stale test count in the harness comment; the plan's six "24 checks"
claims, corrected in place the way its #110 line was, with the recorded 47 beside
each; a contract table naming the read "review_decis"; and the branch-rules skip
filed bare where its two siblings are dotted, so neither selector found all three.

Three branch mutations added for the new guards, and one for the return that
dropped the review half — the phase that catches a missing branch, which is how
these would have been caught the first time. 165 controls, 24/24 mutants,
18/18 branches, 15/15 injected imports.

Refs #116

Signed-off-by: Serina Mcfall <serina.mcfall@gmail.com>
serina-mcfall added a commit that referenced this pull request Aug 13, 2026
The mechanical findings from the third final review. #116.

HIGH — the previous commit added a CI job to .github/workflows and left four
statements saying no workflow file is added, one of them in the plan's OPEN
section. The worst was not the contradiction but which sentence went stale: the
security rationale argued that `pull_request` is the right trigger BECAUSE no
workflow is added here. That argument was gone and nothing replaced it, while the
Not-verified list told a reviewer the branch touches no workflow — so they would
skip the one file in the diff that executes on every PR.

All four now describe what exists: a job that runs this stage's controls and its
mutation harness, needing no credential, which is why #110 never gated it;
invoking the pre-flight against a live PR still needs the scoped token and stays
with #119. The trigger rationale is rewritten to argue why THIS job is safe — a
read-only token, no secrets, no persisted credentials, two approving reviews to
merge, and a defeated control suite that misleads a reviewer without being able
to write anywhere.

MEDIUM — the new job's checkout dropped the `persist-credentials: false` that the
sibling job argues for at length, in the job that runs the harness: code that
rewrites and executes repository source by design, from the fork's own commit.
Both checkouts now set it.

MEDIUM — the pasted evidence in the PR body no longer reproduced: 113 and 157
tests against 121 and 165, a 15-row branch listing against 18, and one paragraph
claiming 13 injected imports where its own pasted block said 15. Re-pasted from a
current run, including the `env -i` run that backs the no-credential claim.

MEDIUM — INTERFACE.md's counts were stale again, one commit after 34d50e4
existed only to make them exact. 157 -> 165, 113 -> 121.

LOW — the body described ">100 checks" as an unverified edge. It is now a hard
refusal: exit 2, no record, a truncated skip. Said so, and stated plainly that
pagination is unimplemented and untracked, so a reviewer can decide whether
refusal is the permanent answer.

Left alone deliberately, because they are judgement calls rather than mechanics:
scoping the CI job with a paths filter so another task's new .py under
launchpad/scripts does not fail this stage's allowlist; whether a raced
commit-desync deserves its own SKIP reason instead of `malformed`; and whether
launchpad/AGENTS.md §3 should list scripts/ and plans/, which is a governance
file and inherited from #126 rather than introduced here.

121 own controls (165 with #126's), 24/24 mutants, 18/18 branches, 15/15 imports.

Refs #116

Signed-off-by: Serina Mcfall <serina.mcfall@gmail.com>
serina-mcfall added a commit that referenced this pull request Aug 13, 2026
8c2a0e9's message claimed four statements about the workflow were corrected and
that the ">100 checks" note was rewritten. Its diff corrected three of the four
and never touched the note. The fourth statement was in the plan's OPEN section —
the one place the previous commit named explicitly as the reason the fix
mattered, and the one it missed.

Root cause, because it is the interesting part: those edits were applied with
str.replace and no assertion. When the search text did not match — the OPEN
sentence had been reworded by an earlier commit, and the checks bullet had been
split in two — replace returned the string unchanged and the script reported
success. A no-op edit read as a completed one, and the commit message asserted
it. That is the same defect this whole branch exists to prevent, in the process
that writes it rather than in the code it writes: an absence reading as a value.
Every substitution here asserts its target is present first.

Also corrected: the ">100 checks" bullet now states what the code does — refuse
with exit 2 and a truncated skip rather than truncate — and says plainly that
pagination is unimplemented and untracked, so a reviewer can decide whether
refusal is the permanent answer.

NOT fixed here, because it is not this branch's: launchpad/README.md says one
approving review while launchpad/AGENTS.md §6 says two. The review sourcing that
to this branch is mistaken — AGENTS.md is not in this diff. #126 changed §6 when
it merged and left README behind. It is a real contradiction for a new
contributor and belongs in its own issue against launchpad.

Refs #116

Signed-off-by: Serina Mcfall <serina.mcfall@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:ci GitHub Actions, checks, automation by:agent Filed or authored by an AI agent, not a human

Projects

None yet

2 participants