Repository navigation
Strix run-name is an unenforced parse contract: any suffix makes the scheduler preserve every stale run and stop dispatching #1941
Description
While triaging PR #1009 I found a coupling that would disable Strix dispatch for every open PR in the organization if that branch merged as written. Filing separately because the defect is a latent fragility in main, independent of whether #1009 ever lands.
The coupling
.github/workflows/strix.yml sets a run-name, and scripts/ci/pr_review_merge_scheduler_core.py parses that name back out to recover the reviewed head SHA:
# pr_review_merge_scheduler_core.py:3487-3493
prefixes = tuple(f"{title} {repo}#{number}@" for title in sorted(titles, key=len, reverse=True))
prefix = next((c for c in prefixes if display_title.startswith(c)), None)
if prefix is None:
raise ValueError("repository_dispatch run has no trusted target identity")
return validate_git_sha(display_title.removeprefix(prefix)).lower()The prefix ends at @, so everything after @ must be a bare 40-hex SHA and nothing else. That contract lives entirely in the string format — nothing enforces it, and nothing in strix.yml mentions it.
Why it matters
PR #1009 appends a merge-state suffix to that same run-name:
… #<n>@${{ …pr_head_sha… }}:${{ github.event.client_payload.merge_state || 'open' }}
Executed against origin/main's real function:
Strix Security Scan org/repo#7@aaaa…aaaa → OK
Strix Security Scan org/repo#7@aaaa…aaaa:open → ValueError: invalid git sha: 'aaaa…aaaa:open'
_review_run_still_superseded (core:3506-3517) catches ValueError, prints ::warning::Preserving review run … failed closed, and returns False — meaning not superseded. Failing closed is right in isolation; the consequence is not. A stale Strix run can then never be proven stale, so it is preserved, folded into the current run refs, and dispatch_strix_evidence answers already_running rather than dispatching. Every open PR in every target repository, from one string edit in a different file.
The class of defect
This is the shape already catalogued as silently-inactive required checks: a guard that looks fully configured while a narrower condition quietly never matches. The novelty is the trust boundary being a display string — the producer (strix.yml) and the consumer (the scheduler core) have no shared constant, no test spanning both, and no comment on either side naming the other.
Two cheap fixes, not exclusive:
- Make the parse tolerant.
validate_git_sha(display_title.removeprefix(prefix).split(":", 1)[0])— a suffix stops being fatal, and the reviewed head is still recovered exactly. - Pin the contract from both ends. One test asserting
strix.yml'srun-nameends with the head-SHA expression and that_review_run_target_headaccepts what that template produces. Today each side is tested only against its own idea of the format.
A third, worth considering separately: the except (…ValueError…) in _review_run_still_superseded cannot distinguish "the API told us something inconsistent" (preserve — correct) from "we cannot parse our own run-name" (a bug in us). The second deserves a distinct, louder signal than a ::warning:: that scrolls past.
Status
Not fixed here — the parse is in the scheduler core, which several sessions are actively changing, and the run-name is in a required workflow. Recorded so the fix is deliberate rather than a side effect of someone else's merge. No repository state was changed while finding this; PR #1009 was left byte-identical.
🤖 Generated with Claude Code
Activity
seonghobae commentedon Sep 5, 2026
Independently verified on
mainatf2f91b80(a second reading, not a relay):.github/workflows/strix.yml:2-5—run-name: Strix Security Scan <target_repository>#<pr_number>@<…>.scripts/ci/pr_review_merge_scheduler_core.py:3486-3493— buildsf"{title} {repo}#{number}@"prefixes, strips the matching one fromdisplay_title, and passes the remainder tovalidate_git_sha; anything after@that is not a bare SHA raisesValueError.:3511-3516—except (KeyError, RuntimeError, TypeError, ValueError)→::warning::Preserving review run … live stale-run revalidation failed closed→return False.run_nameoccurrences undertests/arerunpy.run_path(..., run_name="__main__").So the contract lives only in the string format, and a producer-side change (as #1009's branch makes, appending
:open) fails closed into "preserve", which is the state that blocksdispatch_strix_evidencewithalready_running. Fix shape I would back: one shared constant for the separator/format used by both sides, one round-trip test that renders therun-nametemplate and parses it, and a split of thatexceptso aValueErrorfrom our own format is loud rather than preserved. Not touching the core while other sessions have it open.seonghobae commentedon Sep 6, 2026
같은 뿌리가 이 파일에 넷입니다 — 개별 수정이면 다섯 번째가 나옵니다
다른 세션이
already_running이 프로덕션에서 한 번도 발동한 적이 없다는 것을 찾았고(#1983), 소스에서 확인했습니다. 원인이 이 이슈와 같습니다.name정확 일치인데, 그 매처가 찾는 세 워크플로가 전부run-name:을 답니다(opencode-review.yml·opencode-review-dispatch.yml·strix.yml각 1회).run-name이 있으면 GitHub 은name에 렌더된 제목을 넣습니다:그래서 그 비교는 참이 될 수 없습니다.
전수 조사
run.name/display_title을 안정 식별자로 쓰는 지점입니다.3200run_data.get("name") != workflow— 정확 일치3257run_name != workflow and not in aliases— 정확 일치 (#1983)3263·3268·3273display_title.startswith(prefix)→removeprefix3354·3356·33613488·3492·34953802·3804name == workflow및display_title.startswith(...)대안 필드는 이미 쓰이고 있습니다 —
run_data.get("path")3회,get("workflow_id")1회. 그리고 한 곳은 이미 올바른 형태입니다:제안
path또는workflow_id기반으로 옮기면3200·3257·3802가 함께 닫히고 이 이슈의 절반도 닫힙니다. 개별 수정으로 가면 같은 부류의 다섯 번째가 나옵니다.범위를 넓히면 리뷰가 무거워지므로 판단은 소유자 몫입니다. 최소한 각 수정의 커밋 메시지에 "같은 부류가 N 곳" 만 남겨도 다음 사람이 전수를 다시 찾지 않아도 됩니다.
🤖 Generated with Claude Code
seonghobae commentedon Sep 6, 2026
The same root cause silently disables a policy boundary in the REST fallback
Following up on the
run.name/display_title-as-identifier survey above. One of the sites I listed —pr_review_merge_scheduler_core.py:1250— turns out to be materially worse than thedisplay_titleprefix-parse sites, because it defeats a fail-closed guard rather than mis-parsing a title.
What the code promises
fetch_workflow_names_by_check_suite_rest(:1224) builds{check_suite_id: workflow_name}from RESTrun.nameand feeds it torest_check_node(:1273-1276), which reshapes it into GraphQL'scheckSuite.workflowRun.workflow.name. Its docstring states the contract:What the two APIs actually return
Joined on
check_suite_idfor.githubheadab457b69, all nine workflows on that head:workflow.namerun.nameopencode-review.ymlRequired OpenCode ReviewRequired OpenCode Review ContextualWisdomLab/.github#834@ab457b69…noema-review.ymlRequired Noema Review… ContextualWisdomLab/.github#834@ab457b69…strix.ymlStrix Security Scan… ContextualWisdomLab/.github#834@ab457b69…pr-review-merge-scheduler.ymlRequired PR Review Merge Schedulersast-semgrep,python-security,security-scan,codeql-pr,agent-review-runtime-quality-ciThe split is exactly
run-name:presence — the three workflows that declare one all diverge, the sixthat don't are all identical. GraphQL returns the
Workflowobject's staticname:; REST returns therendered run title.
What that does to the consumers
They compare the value by equality against static names:
is_strix_context(:1661) —workflow_name in {"Strix Security Scan", "Strix"}isFalse, andthe second clause
node.get("name") == "strix" and workflow_name in {None, REST_UNKNOWN_GITHUB_ACTIONS_WORKFLOW}is also
Falsebecause the name is present. A genuine Strix check run is not recognised as Strix.workflow.get("name") == "Required OpenCode Review"isFalse.is_opencode_check_run(:1636) survives, but only incidentally: its first clause matches thecheck-run job name
opencode-reviewbefore the workflow identity is consulted.The sharp part is the sentinel.
REST_UNKNOWN_GITHUB_ACTIONS_WORKFLOW(:341, set at :1274) exists sothat unknown workflow identity fails closed instead of passing as a source failure — and :1663 explicitly
honours it. But it is keyed on the name being absent. This failure mode is the name being present and
wrong, so the guard never engages. The safety net is indexed on absence while the actual fault is
contamination.
The repo already knows the rendering rule
scripts/ci/opencode_coverage_identity.py:143-147reconstructs the rendered title directly —f"{DISPATCH_WORKFLOW_NAME} {target_repo}#{pr_number}@{head_sha}"— and accepts either the bare name orthat title. The format matches the live REST strings above character for character. So this is not
undocumented GitHub behaviour; the handling exists one file over and
core.pydoes not use it.Credit:
core.py:1250was flagged as suspicious by peer 1 and the sibling-file precedent was found byhost 1; the join above is my verification of both.
Not verified
How often the REST path (
rest_pr_node, :1301) is taken versus GraphQL in production. I have not measuredthat, so I am not claiming a current production impact rate. Worth noting the direction, though: a
fallback is normally reached when the primary is degraded, so the policy boundary relaxes precisely during
an incident.
Suggested fix shape
Prefer
path/workflow_idfor workflow identity, whichcore.py:3062already does(
workflow_key = str(run.get("workflow_id") or run.get("path") or run.get("name") or "")). Where a namemust be used, reuse the
opencode_coverage_identityapproach and accept both the bare and rendered formsrather than adding another prefix-strip.
seonghobae commentedon Sep 6, 2026
Follow-up: the REST fallback's trigger set, since I left reachability open above
I said I had not measured when the REST path is taken. I still have no production rate, but the trigger
set itself is readable, and it changes the severity picture in both directions.
Both entry points guard identically —
fetch_open_prs(:1472-1479) andfetch_pr(:1500-1505):github_resource_inaccessible(:1133) is not an incident condition. It matches"Resource not accessible by integration"— a token-scope state. If the scheduler ever runs under aninstallation that cannot read the GraphQL fields, REST is not a fallback, it is the steady-state path, and
the policy boundary is relaxed continuously rather than during a blip.
is_transient_github_api_error(:958) fires on HTTP 500/502/503/504,connection reset|refused|timed out,gateway timeout,i/o timeout,stream error,service unavailable,temporary failure,timeout,unexpected end of JSON input, and anyjson.JSONDecodeError(:960-961) — so a truncatedresponse also routes here.
And one correction to the obvious guess: rate limiting does not trigger it.
"API rate limit exceeded"is matched byRATE_LIMIT_DIAGNOSTIC_RE(:954) and deliberately kept out ofTRANSIENT_GITHUB_API_ERRORS(comment at :951-953);gh_graphqlwaits and retries it internally, and arate-limit failure that survives the retries hits
raise, not the REST path. Given how much shared-bucketcontention this org has, that was the first mechanism I expected to matter here, and it doesn't. Worth
stating so nobody re-derives it the wrong way.
So the accurate framing is: the contaminated-identity path is entered when GitHub's GraphQL endpoint is
erroring or answering with malformed JSON, or whenever the integration lacks GraphQL read scope. The
first is bounded and correlates with incidents; the second is unbounded and silent. I have measured
neither's frequency in production.
seonghobae commentedon Sep 6, 2026
Correction: I gave the wrong direction for the coverage-evidence consumer
In my earlier comment
I wrote that on the REST path "coverage-evidence (:2243) —
workflow.get("name") == "Required OpenCode Review"isFalse", listing it alongsideis_strix_contextas a case of evidence being lost. That isbackwards. host 1 reproduced all three consumers on live pre-fix payloads and caught it.
The predicate is negative —
is_non_authoritative_coverage_check_run— and its consumer keeps acheck precisely when it returns
False:So a contaminated workflow name does not withhold coverage evidence there. It admits central
metadata-only evidence that the GraphQL path correctly rejects:
The three consumers therefore fail in three different directions, not two:
is_strix_contextFalsefor a realstrixcheck runis_opencode_check_runTrueis_non_authoritative_coverage_check_runFalse, so the check is keptReached only when
SCHEDULER_REQUIRED_WORKFLOW_REPOSITORYis set; how often that holds in productionis not measured.
How I got it wrong: I read the returned expression and inferred the function's meaning from it,
without reading the function's name or its caller.
return workflow.get("name") == "Required OpenCode Review"reads like an identity test; it is the body of a predicate that answers the opposite question.An expression does not tell you the polarity of the decision it feeds.
host 1 also sharpened the
is_strix_contextfinding. Its rescue clause requiresworkflow_name in {None, REST_UNKNOWN_GITHUB_ACTIONS_WORKFLOW}— so the clause meant to save a checkrun whose workflow is unknown carries the same absence assumption the sentinel does. Contamination
defeats both designed safety nets at once, not just the sentinel.
#1986fixes all three at the single point they share; its regression test for this case is nowwritten in the fail-open direction so the severity reads correctly.
seonghobae commentedon Sep 6, 2026
A third site survives both fixes:
dispatch_strix_evidence's per-repository guard is deadAuditing the rest of this class after
#1986, one site is still comparing a rendered run name to adeclared workflow name, and it is neither what
#1983fixed nor what#1986fixes.pr_review_merge_scheduler_core.py:3885-3895:workflowhere issecurity_workflow, whose CLI default is"Strix Security Scan"(:6175). The runsit filters are
repository_dispatchruns, whosenameis rendered throughrun-name:.Measured on every Strix dispatch run in the last 7 days — 7 of 7:
The predicate never holds, so
busy_refsis always empty and theif busy_refs:branch below it cannever run. The dispatch path is live:
dispatch_strix_evidencepostsevent_type: "strix-scan", andthose 7 runs are its receivers.
Not the same site as
#1983. That PR (74224b20) changed onlyactive_review_run_refs(
@@ -3254,7 +3254,24 @@), addingrun_name == candidate or run_name.startswith(f"{candidate} ").Line 3890 still carries a bare
==;git blameputs its last change at269e5bd9, before that PR.The irony is local: the immediately preceding block in the same function (
:3863-3869) already routesthrough the alias-aware helper, so one function contains both the fixed and the unfixed form.
What it does and does not affect. The same-head case is covered —
active_review_run_refscatchesactive runs on the current head and returns early at
:3874. What is lost is the broader guard: thisone skips dispatch when the dispatch repository has any active Strix run for the target repository,
across pull requests. So the failure is missing serialization per target repository, not duplicate
dispatch for one head.
That distinction matters because it does not contradict the earlier census finding that 35
in-progress Strix runs mapped to 35 unique
(repo, head_sha)pairs with zero duplicates. Those aredifferent questions; this guard was never the thing that produced that zero.
Limits. The 7 sampled runs were
completed, while the guard scansactive_workflow_runs(..., statuses=("queued", "in_progress")).nameis bound when the run is createdand does not vary with status, so the conclusion carries — but I did not observe the predicate against a
live queued run. I also have not measured how often two pull requests of one target repository are
dispatched concurrently, so I am not claiming an observed cost.
One more trap worth recording: the most recent 100
repository_dispatchruns in this repository containzero Strix entries — CodeQL Scan Dispatch alone fills 80 of them. Reading that as "this path is
unused" would have been a false negative. The 7 runs only appear once the query is widened by date.
Follow-up rather than a change now:
#1986is in flight on this same file, so a second branch wouldconflict. I will take this after it merges unless someone else has it.
seonghobae commentedon Sep 6, 2026
Corroborated at 100/100, and the false negative has a named mechanism
peer 1 reproduced the
:3890finding independently with a larger sample —strix.yml,event=repository_dispatch, since 2026-08-31: 100 runs, 100 rendered names, 0 bare. So the deadpredicate is confirmed on 107 runs between us, not 7.
They also identified why my first query returned zero, and it is worth recording because it is a
sampling artifact rather than a date-window problem. Both queries below run against the same
repository, same event filter, same moment:
A repository-level listing samples across all workflows, so a high-frequency workflow crowds the others
off any bounded page — invisibly, because the page is full and nothing reports truncation. Widening by
date recovers some rows; querying the workflow endpoint removes the bias instead of fighting it.
One further note from peer 1 on scope, which matters for anyone reading
#1983as having closed thisclass: that PR's reasoning counted the call sites of
active_review_run_refsand fixed both.dispatch_strix_evidencecalls that helper and separately carries its own inline==comparison,so counting callers did not reach it.