fix(ci): frontmatter gate, correct review phase numbers, refresh rubric dimension 8 - #435
Conversation
…s, refresh rubric dimension 8 The three orbit-api items in orbit-ui-mobile REBUILD.md section 14. 1. tools/check-frontmatter.mjs is vendored from orbit-ui-mobile and wired as a new "Skill and Agent Frontmatter" job in guards.yml, next to the Dash Ban. An unquoted YAML frontmatter value containing ": " drops the whole skill or agent with no error; that is what stopped this repo's security-reviewer loading. This repo has no Node toolchain and no package.json, so the job sets up Node itself and the script stays dependency free. Roots are the three directories that actually exist here: .claude/agents, .claude/skills, .opencode/agents. One deliberate divergence from the upstream copy: a declared root that is missing is an error rather than a quiet skip, because all three are tracked and a missing one means the gate has silently become a no op. 2. claude-review.yml told CI to skip "Phase 6 (dotnet build / test)" and "Phase 7 posting". In .claude/skills/pr-review/SKILL.md, Phase 6 is "Verify findings (adversarial)" (line 182), Phase 7 is "Validate" with the dotnet build and test block (line 225), and Phase 8 is "Report" with the posting step (line 240). Followed literally the workflow skipped the skeptic pass and ran the validation it meant to skip. The instruction now names each phase by number and by name, and says explicitly that Phase 6 must run. 3. rubric.md dimension 8 still sanctioned the gradient header and asked for "violet-glow character", both deleted by the 2026-07-17 #539 freeze. It now states the de decorated navy violet anchor, the three identity carriers, the primary versus primary-soft split, accent rationing, and the current AI slop tells, and points at the gate-versus-reviewer split so it does not re-flag what an ESLint local rule already fails on. Its preamble claimed /audit-code-quality shares the file and that "there is no second copy"; neither is true here. Verified: the only readers in this repo are pr-review/SKILL.md and claude-review.yml, there is no audit-code-quality skill here, and a second copy lives in orbit-ui-mobile. Also removes the seven banned em dashes from claude-review.yml while editing it and shrinks the dash baseline accordingly. Proof for 1: green on the real tree (8 files parse); reverting security-reviewer to the broken unquoted form in a scratch copy exits 1 and names that file; --fix restores it; deleting a root exits 1. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018M2vH4pDvNnxUPecWWvwBc
…ient_id Two follow-ups folded into the same PR. 4. .github/workflows/arch-map.yml had no permissions block, so GITHUB_TOKEN ran with the repository default (CodeQL alert 10, actions/missing-workflow-permissions). Its only steps are checkout, setup-node, node tools/arch-map.mjs and git diff, so contents: read is exactly what it needs. Audited the other eight workflows in .github/workflows for the same gap: every one already declares an explicit top-level permissions block, and each grants only what its jobs use (claude-review needs pull-requests: write + id-token: write for the review post and OIDC, dependabot-auto-merge needs contents: write + pull-requests: write to merge, the other six are contents: read). No job needed a widening, so the class is closed with one edit. 5. OAuthController.Token bound [FromForm] string? client_id and never read it: it was not passed to ExchangeCode and never compared to entry.ClientId. Dropped the parameter rather than wiring it, because nothing in the repo can establish that wiring it is safe for the live Claude.ai client, and because on the merits it would not add a control: /oauth/register hands out an unpersisted random identifier and SupportedTokenEndpointAuthMethods advertises "none", so both the authorize-time and token-time values are unverified client assertions and comparing them proves nothing about the caller. The real client binding is PKCE S256, the exact ordinal redirect_uri match, single-use TryRemove and the 5 minute expiry, all in OAuthAuthorizationStore.ExchangeCode, which this change does not touch. Callers that still send client_id are unaffected: an unbound form field is ignored. Recorded the reasoning as XML doc on the action so it cannot be re-added as dead weight, added two tests (the endpoint takes no client_id and still issues a token, and a wrong code_verifier is rejected as invalid_grant), and refreshed the committed openapi.json. Ran the CI's own oasdiff (tufin/oasdiff breaking, --fail-on ERR) against origin/main's spec: 0 errors, 1 warning (request-body-all-of-removed), so the OpenAPI Breaking-Change Gate passes. Also fixes tools/arch-map.mjs walking bin/ and obj/. Any local dotnet build made the generated map pick up build output and report false drift; CI never built, so it never saw it. The walker now skips both. dotnet build: 0 errors. dotnet test Orbit.slnx: 5573 passed, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018M2vH4pDvNnxUPecWWvwBc
The prose step fed the raw PR body to the checker with no author scoping. Dependabot copies the dependency's own changelog verbatim into the body, so an upstream release note carrying an en or em dash failed a gate on text Orbit neither wrote nor can edit, and which Dependabot regenerates on every rebase. orbit-api#425, a 21 package NuGet security bump, is red on exactly this: a Hangfire release note's en dashes. Only the prose step is scoped, since its whole premise is that the text is Orbit-authored. The file-level check and the baseline ratchet still run on bot PRs, because the files a Dependabot PR changes ARE Orbit's, so a new dash in one of them is still a real violation. Byte-identical to the fix already landed in orbit-ui-mobile (896ff118) and orbit-landing-page; the three copies stay in lockstep. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018M2vH4pDvNnxUPecWWvwBc
claude-code-action refuses to run when the PR's claude-review.yml differs from the default branch: it logs "Skipping action due to workflow validation", reports the check as passing, and posts no review. Verified on this PR's first run (job 89580872900), which is why it sat at reviewDecision REVIEW_REQUIRED with all 21 checks green and zero reviews. That made one file drag five unrelated fixes into an unreviewable PR, including an OAuth behaviour change, a workflow-permissions security fix and a new CI gate. So claude-review.yml and its dash-baseline entry go back to the main version here and move to fix/claude-review-phase-numbers, which is the only PR that has to be approved by a human. Squash-merge means this commit and its predecessors collapse into the PR body. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018M2vH4pDvNnxUPecWWvwBc
|
There was a problem hiding this comment.
Code Review: PR #435 (thomasluizon/orbit-api)
Recommendation: APPROVE
Summary
Five focused fixes: vendors a YAML-frontmatter parse gate, refreshes rubric.md dimension 8, scopes arch-map.yml's GITHUB_TOKEN, drops an unread client_id form parameter from the OAuth token endpoint (with two new tests + regenerated OpenAPI spec), and scopes the Dash Ban PR-body check away from bot-authored PRs. No prior reviews exist on this PR, so this covers the full diff.
Findings
Critical / High / Medium
None.
Low / Info
[Info] check-frontmatter.mjs's clean-run count includes files with no frontmatter to check
tools/check-frontmatter.mjs:53—targetsincludes files likerubric.md/_shared/verification-protocol.mdthat have no---frontmatter block at all, so the success message overcounts how many files actually "parse". Purely cosmetic (never a false negative); optional to fix.
Security pass — OAuth client_id removal
Verified directly (and independently confirmed by a security-reviewer subagent):
OAuthController.Tokendrops[FromForm] string? client_id, which was bound but never used for any security decision (not passed toExchangeCode, never compared toentry.ClientId; only use ofentry.ClientIdis aDebug-level log, filtered in prod).OAuthAuthorizationStore.ExchangeCode(unmodified) still enforces: PKCE S256 verification, exactredirect_urimatch, atomic single-use code removal, and a 5-minute expiry.- ASP.NET Core silently ignores unbound form fields, so removing this parameter is not a backward-compat break for any existing caller.
[AllowAnonymous]and[DistributedRateLimit("auth")]are both untouched.openapi.json's removedclient_idproperty matches only the/oauth/tokenrequest schema; the/oauth/authorizequery-paramclient_idis correctly left alone.
Not verifiable in CI
- Cross-repo dimensions (contract-aligner,
packages/shared) —orbit-ui-mobilesibling repo not checked out. Substantively N/A anyway:/oauth/tokenis Anthropic's Claude.ai OAuth client, not a shared-DTO mobile/web endpoint. - Dimension 8 (DESIGN.md) — diff only edits rubric prose describing DESIGN.md, not actual UI code; can't verify cited anchors against the sibling file.
What's good
- The
client_idremoval is root-caused and documented via XML doc<remarks>, not silently dropped. check-frontmatter.mjscorrectly treats a missing frontmatter root as an error rather than silently skipping.- PR self-audits its own preamble claims (verified skill directory counts, verified sibling
rubric.mddiff) matching the review culture the rubric asks for.
Recommendation
Approve as-is. Nothing here blocks merge; the one Info-level note is optional polish, not a defect.
There was a problem hiding this comment.
Code Review: PR #435 (thomasluizon/orbit-api)
Scope: PR #435, fix/phase6-followups → main
Recommendation: APPROVE
Summary
Eight files changed: a new tools/check-frontmatter.mjs gate + guards.yml job, an arch-map.yml permissions fix, an arch-map.mjs bin/obj-skip fix, a rubric.md refresh, and one real behavior change — dropping the dead, never-read client_id form parameter from OAuthController.Token. security-reviewer and contract-aligner independently verified the OAuth change against OAuthAuthorizationStore.ExchangeCode: ClientId was never compared server-side before or after, real client binding (PKCE S256, exact redirect_uri match, single-use code, 5-minute expiry) is untouched, and ASP.NET Core silently ignores extra unbound [FromForm] fields, so old callers (Claude.ai's MCP connector) that still send client_id are unaffected — no
Findings
Critical
None.
High
None.
Medium
None.
Low / Info
[Info] PR title/body still claim item 2 ("correct stale phase numbers in claude-review.yml") but the diff doesn't touch that file.
- dimension: Correctness
- location: PR description, section "2. Correct the stale phase numbers in
claude-review.yml" - issue: Commit
6e11da5("chore(ci): move the claude-review.yml phase-number fix to its own PR"), already on this branch, deliberately revertedclaude-review.ymlandtools/dash-baseline.jsonback tomain's version and split that fix intofix/claude-review-phase-numbers— becauseclaude-code-actionskips validation (silently "passes" with zero reviews) when a PR's ownclaude-review.ymldiffers from the default branch's. That's a sound call, but the PR body/title text wasn't updated afterward, so a reader following the description would believe item 2 shipped here. - risk: Low — purely descriptive; no code impact.
- fix: Trim the "## 2." section from the PR body (or note it was split out) before merge/squash, since the squash message becomes the permanent commit record.
[Info] tools/check-frontmatter.mjs --fix matches key: by line prefix repo-wide, not scoped to the frontmatter block.
- dimension: Dead/stale code, defense-in-depth
- location:
tools/check-frontmatter.mjs:83 - issue:
--fixrewrites the first line in the whole file starting with"<key>: ", not the first line inside the detected---…---block. - risk: Very low —
--fixis a manual, local-only convenience not invoked by CI, and requires an exact-prefix collision. - fix: Scope the
findIndexsearch to the slice oflinesbetween the two---markers.
Both Low/Info items are non-blocking and not posted as inline PR comments per the rubric's signal gate.
Subagents
| Agent | Verdict |
|---|---|
| security-reviewer | PASS — no findings; client_id removal, guards.yml bot-scoping, arch-map.yml permissions, and the new frontmatter job all reviewed clean |
| contract-aligner | MATCH (local: openapi.json ⇄ OAuthController.cs 1:1) / NOT VERIFIABLE (cross-repo: no orbit-ui-mobile checkout to compare @orbit/shared Zod schemas — low risk since this endpoint's consumer is Claude.ai's MCP connector, not the mobile app) |
Validation
Build/test skipped per this PR's CI adaptation (Build / Unit Tests / SonarCloud run as separate required checks).
What's good
- The
client_idremoval is a textbook root-cause fix: verified dead end-to-end, documented with an XML-doc<remarks>explaining why removal is safe, backed by two new unit tests, and a regeneratedoasdiff-verifiedopenapi.json. guards.yml's Dependabot-body-text carve-out is correctly scoped, not a blanket bot exemption.check-frontmatter.mjs's "missing root = hard error" design is right for a verifier.arch-map.ymlpermissions fix closes a real CodeQL-flagged gap, with a proactive audit of the other eight workflows.
Recommendation
Approve as-is. The stale "item 2" language in the PR title/body is worth a quick edit before squash-merge (squash message becomes permanent history), but it's cosmetic and not blocking.
) The CI adaptations told the reviewer to skip "Phase 6 (dotnet build / test)" and "Phase 7 posting". Both numbers were off by one against the skill they point at, .claude/skills/pr-review/SKILL.md: :182 ## Phase 6, Verify findings (adversarial) :225 ## Phase 7, Validate (dotnet build / dotnet test at :229-232) :240 ## Phase 8, Report (the Post to GitHub step at :306) Followed literally, CI skipped the adversarial skeptic pass, the one thing that decides which findings survive, and ran the dotnet validation it had been told to skip and that Build / Unit Tests / SonarCloud already cover. The exact inverse of the intent. The skill's own caller-context note at :326-327 already said the right thing ("In CI also skip Phase 7 (Validate) ... The Phase 6 adversarial pass still runs"), so the workflow was contradicting the document it invokes. Each instruction now carries the number AND the phase name, and an explicit line tells CI to RUN Phase 6, so a future phase insert cannot silently invert this again. Also removes the file's seven banned em dashes and its dash-baseline entry, since the gate reads the whole file on any PR that touches it. Split out of #435 on purpose: claude-code-action refuses to run on a PR whose claude-review.yml differs from the default branch, so any PR carrying this file gets no automated review. Keeping it alone means the other five fixes in #435 stay reviewable. Claude-Session: https://claude.ai/code/session_018M2vH4pDvNnxUPecWWvwBc Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>



Closes items 1 and 3 of "Open follow-ups carried out of Phase 6" (
orbit-ui-mobile/REBUILD.mdsection 14), plus three follow-ups found while working.1. Vendor the frontmatter gate (section 14, item 1)
An unquoted YAML frontmatter scalar whose value contains
": "makes the parser drop the entire skill or agent, silently. In orbit-ui-mobile that had disabled five skills and thesecurity-revieweragent, so/pr-reviewwas running with no security pass. This repo's ownsecurity-reviewercopy was hand fixed on the #434 branch, but nothing stopped a recurrence.tools/check-frontmatter.mjs, vendored fromorbit-ui-mobilemain(landed there as PR #594). Dependency free, as the upstream copy is.Skill and Agent Frontmatterjob inguards.yml, alongside the Dash Ban. It runs on pull requests targetingmainand on push tomain. Since this repo has no Node toolchain and nopackage.json, the job sets Node up itself (actions/setup-node@v7, node 22) and calls the script directly. Nopackage.json, no npm script, no dependency was added..claude/agents,.claude/skills,.opencode/agents.Diff against the upstream copy, so the divergence is auditable: the parser, the offender detection, the
--fixrewrite and the output are byte-identical. Three intentional differences only, all in this file: the roots above, the repo-specific header comment, and a declared root that does not exist being an error rather than a quiet skip. All three roots are tracked, so a missing one means the gate has become a no op, and a verifier that cannot look must never report clean. Nothing else was inherited or dropped.Proof, run before committing:
frontmatter ok: 8 skill and agent files parse, exit 0security-reviewerreverted to the broken unquoted form.claude/agents/security-reviewer.md [description]--fix.opencode/agentsdeletedThe job then ran green on this PR in real CI.
2. Refresh
rubric.mddimension 8 against the post #539DESIGN.md(section 14, item 3)Dimension 8 still sanctioned "the sanctioned gradient-header" and asked reviewers to flag until the scene sentence named Orbit's "navy-cosmic, violet-glow character". Both were deleted by the design freeze of 2026-07-17. It now states the de decorated navy violet anchor: no decorative glow and no gradient wash anywhere, identity carried by the orbital logo, the Astra glyph and ring indicators,
--primaryas fill and graphic only with--primary-softas accent text, plus accent rationing and the current AI slop tell list. References citeDESIGN.mdsection names as well as line numbers, since the line numbers are exactly what rotted.It also names the gate versus reviewer split, so the dimension stops asking a human to re-flag what an ESLint
local/*rule already fails on.Preamble claims, verified rather than assumed. Both were false in this repo:
/pr-reviewand/audit-code-quality" is wrong here..claude/skills/in orbit-api holds onlypr-review,second-opinionand_shared. The only readers of the rubric in this repo are.claude/skills/pr-review/SKILL.md(lines 94 and 106) and.github/workflows/claude-review.yml(line 32), and the workflow only invokes the skill.orbit-ui-mobile/.claude/skills/pr-review/rubric.mdexists, and it is not even byte identical: the api copy carries four extra lines under dimension 13 for theORBIT0002transaction teardown rule.The preamble now describes the two copies as hand aligned lockstep twins with backend only divergences sanctioned, and says a drift is a defect.
3. Scope
GITHUB_TOKENinarch-map.yml(CodeQL alert 10)arch-map.ymldeclared nopermissionsblock, soGITHUB_TOKENran with the repository default (actions/missing-workflow-permissions, medium). Its steps are checkout, setup-node,node tools/arch-map.mjs,git diff, socontents: readis exactly the scope it needs.The other eight workflows were audited for the same gap rather than assuming the one CodeQL surfaced was the only one. All eight already declare an explicit top-level block, and each grants only what its jobs use:
claude-reviewneedspull-requests: writeplusid-token: writefor the review post and OIDC,dependabot-auto-mergeneedscontents: writepluspull-requests: writeto merge, the remaining six arecontents: read. No job needed a per-job widening, so the class closes with one edit.4. Drop the unread
client_idon the OAuth token endpointOAuthController.Tokenbound[FromForm] string? client_idand never read it: not passed toExchangeCode, never compared toentry.ClientId. Dead code under standard 2, and an incomplete client-identity check under RFC 6749 section 4.1.3.Dropped rather than wired, for two reasons:
13f8a29), no test or log records what the real client sends at the token endpoint, andentry.ClientIdis only used in aDebuglog that prod filters out. Wiring it would be an unverifiable behaviour change on the one live consumer./oauth/registerreturnsGuid.NewGuid()and persists nothing, andSupportedTokenEndpointAuthMethodsadvertisesnone, so the value captured at authorize time and the value presented at token time are both unverified client assertions. Comparing two strings the same party chose proves nothing about the caller.The real client binding is untouched: PKCE S256, the exact ordinal
redirect_urimatch, the atomic single-useTryRemoveand the 5 minute expiry, all inOAuthAuthorizationStore.ExchangeCode. Callers that still sendclient_idare unaffected, since an unbound form field is ignored. The reasoning is recorded as XML doc on the action so the parameter cannot be re-added as dead weight.Two tests added: the endpoint takes no
client_idand still issues a token, and a wrongcode_verifieris rejected asinvalid_grantwith no key created.openapi.jsonregenerated. The CI's own tool was run locally againstorigin/main's spec,tufin/oasdiff breaking --fail-on ERR: 0 errors, 1 warning (request-body-all-of-removed), so the OpenAPI Breaking-Change Gate passes. It then passed in CI.Also fixed while here:
tools/arch-map.mjswalkedbin/andobj/, so any localdotnet buildmade the generated map pick up build output and report false drift. CI never builds before regenerating, so CI never saw it. The walker now skips both.5. The Dash Ban gate failed on Dependabot PRs
The
PR title and body carry no dashesstep fed the raw PR body to the checker with no author scoping. Dependabot copies the dependency's own changelog verbatim into the body, so an upstream release note carrying an en or em dash failed a gate on text Orbit neither wrote nor can edit, and which Dependabot regenerates on every rebase. #425, a 21 package NuGet security bump, was red on exactly that: a Hangfire release note's en dashes.Only the prose step is scoped to
github.event.pull_request.user.type != 'Bot', since its whole premise is that the text is Orbit-authored. The file-level check and the baseline ratchet still run on bot PRs, because the files a Dependabot PR changes ARE Orbit's. Byte-identical to the fix already landed in orbit-ui-mobile (896ff118) and orbit-landing-page, so the three copies stay in lockstep.Validation
dotnet build Orbit.slnx: 0 errors.dotnet test Orbit.slnx: 5573 passed, 0 failed.node tools/check-frontmatter.mjs,check-dashes --check-baseline,--files, and thenode tools/arch-map.mjsdrift check: all clean.dotnet format --verify-no-changeson both changed C# files: clean.Scope
orbit-api only. The orbit-ui-mobile copy of
rubric.mdis owned by a separate PR there.Two notes for the human
maintoday, so a redDash BanorSkill and Agent Frontmatterdoes not block a merge, and a gate that does not block is not a gate. Both should become required after this PR lands:Skill and Agent Frontmatterdoes not exist onmainuntil then, so adding it now would block every open PR. Not changed here; branch protection is yours to set.orbit-ui-mobiletoo, where the same script quietly skips a missing root. Not touched from here, since other agents are active in that repo.