fix(governance): stop an empty issue body stranding an approved plan - #215
Open
ArchitectOvPan wants to merge 1 commit into
Open
fix(governance): stop an empty issue body stranding an approved plan#215ArchitectOvPan wants to merge 1 commit into
ArchitectOvPan wants to merge 1 commit into
Conversation
githubRequestContext stores empty and whitespace-only issue text as null, while a live GET /issues/:number read returns "" for a body cleared after creation. The Builder plan gate digests one on each side, so a project on builderPlanPolicy "required" refused the dispatch as builder_plan_stale against a revision that never moved, and the denial audit blamed an issue edit that never happened. Canonicalize in githubIssueRevisionContext so both producers agree. Both paths already funnel through it, so this leaves the stored shape of run.trigger.request untouched and keeps the digest producer-independent. The existing tests could not catch this: each exercised one producer, and the integration fixture reused the recorded digest as its own observed freshness evidence, making the comparison true by construction. The fixture now derives each side the way production does.
ArchitectOvPan
force-pushed
the
fix/issue-revision-empty-body
branch
from
August 30, 2026 00:39
5b3513f to
e51641d
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changes
githubIssueRevisionContextnow folds empty and whitespace-only text ontonullbefore the issue-revision digest is taken, matching whatgithubRequestContextalready stores. Both producers of that digest now agree,so a project on
builderPlanPolicy: "required"stops reportingbuilder_plan_stalefor an issue that never changed.Why
The Builder gate compares two digests that are produced by two different pieces
of code, and they canonicalize "this issue has no body" differently.
Write side — at Architect dispatch the router stores the issue snapshot in
run.trigger.requestviagithubRequestContext(router.ts:733-734), which uses
nullableText(router.ts:781-783):ensureArchitectPlanAcceptanceseals the digest of that snapshot into theproposal payload
(orchestrator.ts:948).
Read side — at Builder dispatch
resolveBuilderPlanFreshnessForProposalre-derives the digest from a live
GET /issues/:number(builder-plan-freshness.ts:110-112),
which goes through
normalizedText(issue-revision.ts:113-115):
nullableText("")isnull.normalizedText("")is"".JSON.stringifysees two different objects, so the two digests differ for an issue nobody
touched, and the gate refuses the dispatch at
builder-plan-policy.ts:629-630:
The write side collapses
null,undefined,""," ","\n","\t"and" \n "onto one digest. The read side gives each of them a different one.nullis the only representation that lands in the same place on both sides,so an issue with no body at all is fine and an issue whose body is empty or
whitespace-only can never dispatch.
Reproduction
builderPlanPolicy: "required".(
PATCH /issues/:nwithbody: " "). GitHub stores and returns it verbatim./architect. The run succeeds and publishes a plan./builder.Expected: the Builder run starts. Actual:
409 builder_plan_stale, reasonbase_or_issue_revision_changed, and arun.builder_plan_deniedaudit event —against a revision that never moved. Re-running does not help; the stored
digest can never be reproduced from a live read, so the plan is stranded for the
life of the proposal. The audit trail attributes the refusal to a change in the
issue, which sends whoever investigates to look for an edit that did not happen.
The added integration test reproduces exactly this through
assertBuilderPlanDispatch; without the source change it fails with{ statusCode: 409, code: 'builder_plan_stale', details: { reason: 'base_or_issue_revision_changed' } }.Why the suite did not catch it
Both existing tests exercise one side each, and never compare them:
only calls
githubIssueRevisionContext, so it never sees what the router stored.githubRequestContextreturns, but never digests it.computed one digest from a hand-written request literal and then used that same
value as the observed freshness evidence, so the comparison was true by
construction.
That last one is the reason this could not surface: the integration test proved
the gate compares two values, not that the two producers agree. This PR rewires
the fixture to derive each side the way production does —
githubRequestContextfor the stored digest,
githubIssueRevisionContextfor the live one. The changeis digest-neutral for the existing cases, so every other assertion in that file
is unaffected.
The fix
Canonicalizing in
githubIssueRevisionContextrather than changingnullableTextis deliberate:githubIssueRevisionContext(
githubIssueRevisionSha256re-applies it to whatever it is handed), so onechange fixes both without touching the stored shape of
run.trigger.request,which the runner also reads as the end-user request.
depends on. Any future producer gets the same canonical form for free.
githubIssueRevisionSha256relies on when itre-canonicalizes an already-canonical context.
The security property is unchanged: whitespace-only and empty bodies are
equivalent to each other, and any body that gains or loses material text still
moves the digest. There are explicit tests for both directions.
Effect on stored digests
Digests are recomputed on both sides at dispatch, so nothing persisted needs a
migration. The only proposals whose digest changes meaning are ones whose issue
body is empty or whitespace-only — exactly the ones that could never dispatch
before. A proposal sealed before this change against a whitespace-only body
keeps its stored digest and will now match, because the live read canonicalizes
to the same
null.Two related divergences I did not change
Both are unreachable through the GitHub API, so fixing them would be speculative:
"bug"on the writeside (router.ts:743 trims) and
"bug "on the read side (issueLabelsdoes not). GitHub trims label names oncreate and rename, so it cannot occur.
materialIssueComment(issue-revision.ts:95) reads
comment.authorType.toLowerCase()and would throw on a comment without thatfield.
FacilityGithubClient.listIssueComments(client.ts:875) defaults it to
"User", so no live path can produce one.Happy to fold either in if you would rather have the canonicalizer total.
Verification
pnpm verifypasses locallyWhat I ran, and what it proved:
vitest run test/github-issue-revision.test.ts— 9 passed with the fix.Reverting only
issue-revision.tsfails 4 of them, so the new cases are a realregression test and not a restatement of current behaviour.
vitest run test/builder-plan-policy.integration.test.tsagainst Postgres 16 —32 passed. Reverting only
issue-revision.tsfailsadmits a fresh plan whose issue body GitHub reports as empty rather than absentwith the exact production error.
@facility/apisuite against Postgres — 591 passed. Three failures(
api.test.tsaudit-chain and readiness-doctor,github.test.tsreview agent)reproduce identically on a clean checkout of
mainin this environment andare unrelated to this change; they need seeded registry and installation
fixtures my local database does not have.
tsc --noEmitfor@facility/api,biome check .over 422 files, andnode guards/run.mjsall clean.before the change, 2 after — the two documented above as unreachable.