[#218] feat: dedicated review identity + adoption-gated light-PR auto-approval - #466
[#218] feat: dedicated review identity + adoption-gated light-PR auto-approval#466rucka wants to merge 22 commits into
Conversation
Verdict
PR: #466 · Author: rucka · Reviewer: independent review agent · Date: 2026-08-28 · Story: US-218 · Type: feature Classification matrix — per dimension
Tier = max(assessed) = risk:red, unchanged from refinement. Cost = green — no paid SDK, metered call or IaC in the diff. AssessmentsSecurity — Input validationVerdict: green — inputs are tags/flags/enums; Security — Output handlingVerdict: green — Security — AuthenticationVerdict: yellow — the credential model is sound (secret store, least-privilege, never in repo; secret scan of the diff is clean), but the App path is not followable as written: the health probe is wrong (M1) and the installation-token minting step is missing (m2). Security — AuthorizationVerdict: red — C1. The Security — Introduced vulnerabilitiesVerdict: red — 1 introduced (C1), 0 pre-existing. The vulnerability is introduced by offering CostVerdict: Architecture (Coupling)Verdict: green — the adapter is a leaf asset; Bug fix — Red test before fixVerdict: not applicable — not a bug fix. Acceptance criteria
DetailsFindings by severityCritical (must fix before merge)
Major (must fix before merge)
Minor (must fix before merge — same bar as Major, just lower impact)
Questions (informational, never blocking)
What was verified, and how
|
…, not prose Review round 1 on PR #466. Two defects were real, not doc drift: - Critical: a `bot-user` identity types as `user.type == "User"` on the reviews API, so `human_approval_jq_filter`'s type clause did NOT exclude it — a machine approval satisfied the 🔴 explicit-HUMAN gate and a risk:red PR reached ready-to-merge with no human. Adds the login clause (`.user.login != env.REVIEW_IDENTITY_LOGIN`) + `review_identity_exclusion_ok`, which makes an unprovisioned login a NOT-healthy identity (⇒ halt). Additive: with the var unset the clause matches nothing. - Major: Step 5.3 mapped ANY approved verdict to APPROVE, so the light row's adoption gate was decorative — every green/yellow PR self-approved. `light_auto_approve_allowed` is now the third argument of `identity_verdict_event` and the sole authority for the APPROVE event; everything else is a COMMENT-form review. Also: App health probes fixed (`gh api user` 403s on an installation token), JWT→installation-token minting documented, a write-touching probe added, ADR verification pointer corrected, Q5 scoped to the verdict, and the smoke guard moved from a prose grep onto the resolved event matrix. Refs: #218
…ctions Review round 2 on PR #466. - review-identity.sh: `user | bot-user)` — the skills forward the way-of-working literal verbatim, so `bot-user` was a permanent HALT on every correctly provisioned bot-user repo. Same vocabulary documented on pair_review_publication_mode. Smoke pins both spellings. - github guide: App publication snippet assigns IDENTITY_KIND/CONFIGURED/ HEALTHY from adoption + the probe outcome (was unset ⇒ silent commit status on the App path); token-prerequisite scoped to the session path, the workflow+relay claim the App path contradicts is gone. - light residual on all 3 surfaces + ADR-018: once the family is declared, `light` is a merge-authorizing label and must be access-controlled. - review SKILL 5.4b: session-mode skip scoped to the audit action; the `Light row:` report line is unconditional, matching the Output Format. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
rucka
left a comment
There was a problem hiding this comment.
Verdict
risk:red · cost:green — APPROVED — il gate lega l’identità esclusa a quella che firma davvero e tutte le varianti di mismatch ora falliscono chiuse.
Open findings: 0. Ogni finding è risolto; nessun Major/Minor è aperto su questa PR.
PR: #466 · Author: rucka · Reviewer: rucka (self-review) · Date: 2026-08-30 · Story: US-218 · Type: bug fix
Classification matrix — per dimension
| Dimension | Tier | Note |
|---|---|---|
| Service/domain criticality | red | Protegge l'identità che può emettere review e cambiare lo stato di merge. |
| Change/diff risk | red | Adapter shell e contratti host/distribuiti toccano il controllo di identità. |
| Business impact | red | Un'identità agendo al posto di quella esclusa invalida la separazione reviewer/author. |
| Security relevance | red | Il check difende una boundary di autorizzazione/review. |
| Coupling balance | yellow | Adapter, guide, skill e smoke restano sincronizzati dal dataset/generator. |
Tier = max dimensione valutata. Cost green: nessun servizio o consumo runtime introdotto.
Assessments
Security — Authentication / authorization
Verdict: green — il controllo ora richiede l'identità effettivamente agente e fallisce su assenza o mismatch; nessun token o comando privilegiato è aggiunto.
Security — Introduced vulnerabilities
Verdict: green — 0 introdotte; il diff restringe il gate, non amplia autorizzazioni.
Cost
Verdict: cost:green — shell locale e documentazione, senza nuovo provider o costo variabile.
Architecture (Coupling)
Verdict: green — il contratto è unico nel dataset e le copie generate/smoke sono verificate insieme.
Bug fix — Red test before fix
Verdict: green — quattro casi di contratto erano rossi sul precedente argomento a due identità; il contratto ora richiede ACTING e i casi sono verdi.
Details
Finding closure and evidence
| Severity | Finding | Closure |
|---|---|---|
| Major | L'esclusione verificava il login configurato, non chi stava realmente firmando. | review_identity_exclusion_ok riceve ACTING; assenza e mismatch HALT. |
| Minor | Health/guide/test/smoke usavano il contratto a due argomenti. | Tutti usano la stessa firma e la guida pinna il repo. |
| Minor | Copie generate potevano divergere dall'adapter canonico. | Dataset rigenerato e smoke copre login uguale, diverso, mancante e forma bot-user. |
- Red-first: test con vecchio contratto — 4 failure previste.
- Regressione:
pnpm --filter @pair/knowledge-hub exec vitest run src/conformance/review-identity.test.ts— 139 passed. - Smoke:
bash scripts/smoke-tests/scenarios/review-identity.sh— PASS. - Gate completo:
pnpm quality-gate— PASS. - CI remoto: build, preview, secret-scan e smoke — SUCCESS; merge state
CLEAN.
Required human action
La review self-authored viene pubblicata come commento e non soddisfa l'approvazione esplicita. Per risk:red serve una review APPROVED di un umano non-autore sullo SHA 6c0d482822554a50f8b0e22ea19da40987edf4f9; fino ad allora la PR resta pr-state:to-be-reviewed. Nessun auto-merge.
|
In response to the first review and the round-7 escalation (now minimized, per the convention that flush stated). Converged — review↔fix loop closed at round 17 (head
|
| round | commit | findings | closed |
|---|---|---|---|
| 1 | 11c29cb8 |
1 Critical, 2 Major, 5 Minor | human_approval_jq_filter gained the REVIEW_IDENTITY_LOGIN clause + review_identity_exclusion_ok; identity_verdict_event's APPROVE gated by light_auto_approve_allowed; App-safe installation-repositories probe |
| 2 | 045fea0f |
1 Major, 4 Minor | bot-user case arm added to the exclusion (was excluded by omission); light label access-control obligation recorded |
| 3 | ba50238a |
3 Major, 2 Minor | identity_verdict_event gained self_authored; resolve_pr_state hoisted to an unconditional step; the adoption read parses the shipped bullet, executed by smoke |
| 4 | c077cbdc |
2 Major, 3 Minor | review_identity_kind_ok (present-but-unparseable ⇒ HALT, never none); the 5.4 skip scoped to steps 3–5 so 5.4b always runs |
| 5 | b090f033 |
4 Minor | 4th Light row: value; ADR entry-point enumeration; the exclusion stated as two clauses |
| 6 | 2361fa52 |
1 Major, 2 Minor | review_identity_health introduced — per-run artifact-free probes split from setup-time artifact-leaving ones; mid-write 403/422 HALT |
| 7 | — | escalated | 3 Major + 2 Minor flagged for a human structural call (closure table below) |
| 8 | 0dd55506 |
3 Minor | Review: row enumerates both outcomes; presence probe line-anchored (prose is not configuration) |
| 9 | 2bb60c84 |
3 Minor | AUTH probe tests repository membership, not reachability; App author probe gates (a health failure, not a verdict degradation) |
| 10 | c7b9795e |
3 Major | the author comparison carries both login shapes gh emits for one Bot actor (app/<slug> GraphQL, <slug>[bot] REST) — a single-shape gate was inert |
| 11 | 15d4cfbe |
2 Minor | the supersede exit needs statuses granted and in the mint payload; halt under Review enforcement: disabled reports and continues |
| 12 | a3a11882 |
— | the bot-user health input is the repository variable read back on the run, never the ambient env; membership tests made literal (grep -Fqx); authorship checks print their own reason |
| 13 | ed1f9969 |
— | the light match is exact on every shape (the ambiguous space-joined form fails closed); the halt scope bound to phases that perform an identity write; the presence class enumerates its decorations |
| 14 | 5b201489 |
— | every agent-side evaluator must export REVIEW_IDENTITY_LOGIN; an unreadable PR author is unknown health; the adoption-kind read ordered on presence |
| 15 | d0161a6e |
— | the audit comment stays after the review, its residual stated on four surfaces and reported as a fifth Light row: value |
| 16 | 0c421402 |
— | human_approval_jq_filter has four evaluators — the generator (setup-gates) names the env thread as a fourth unmodified respect; both authorship probes pin the read to $REPO |
| 17 | 6c0d4828 |
1 Major, 2 Minor | the exclusion binds the acting identity, not merely a provisioned login |
Round-7 escalation — closure, one line per finding
| # | sev | escalated finding | closed by |
|---|---|---|---|
| 1 | Major | the login clause was wired into exactly one evaluation environment (the CI job); three agent-side sites evaluated the same filter with nothing populating it | rounds 14 + 16 — all agent-side sites mandate exporting the variable, and the fourth evaluator (the CI job) is covered at its generator: setup-gates now names REVIEW_IDENTITY_LOGIN: ${{ vars.* }} as an authorization property that must survive generation unmodified, alongside trusted ref / head-SHA pinning / pending-first |
| 2 | Major | the health snippets read the login from the ambient shell with nothing populating it ⇒ a correctly provisioned repo HALTs on every review | round 12 — the health input is gh api "repos/$REPO/actions/variables/REVIEW_IDENTITY_LOGIN" --jq .value, read back on the run, and the read's exit status is captured so a 403 (grant missing) and a 404 (never set) each name themselves |
| 3 | Major | "an App installation is never a PR author" was false, and App probe 3 assigned $PR_AUTHOR without comparing it |
rounds 9 + 10 — the parenthetical is deleted, the MANDATORY no-PR-authoring rule is lifted to the shared section binding both forms, and probe 3 gates on both login shapes; unknown slug is itself not-healthy |
| 4 | Minor | the case "identity is the PR author + light authorized" fitted none of the four Light row: values |
round 15 — a fifth value exists and the case picks it; the report follows the event actually submitted, and round 13/15 pinned the residual it names |
| 5 | Minor | pr-states.md's session row reads "unchanged and unreported", against both skills' mandatory Identity: session output row |
NOT changed — see below |
Round 17 — the last Major, and why it was one
The exclusion gate verified that some login was provisioned; the 🔴 predicate, however, excludes that login and no other. A repository whose variable named acme-bot while the flow actually ran under other-bot therefore resolved healthy, and other-bot's approving review satisfied the explicit human approval — the exact hole the check exists to close, passing as green.
review_identity_exclusion_ok <kind> <login> <acting> now binds the account that will sign. Every non-matching shape fails closed: acting absent ⇒ unknown is never excluded ⇒ not healthy; acting ≠ provisioned ⇒ not healthy, with the mismatch named on stderr. app is untouched — it is excluded by user.type == "Bot" before any login comparison. Test-first: the old two-argument contract produced 4 expected failing conformance cases; after the smallest adapter change, 139/139.
Not changed — one Minor, deliberately left open
[Minor] pr-states.md § actor table, session row — "Today's behavior, unchanged and unreported". Escalation finding 5, filed at round 7 and never picked up by a later round. Both consumer skills mandate an unconditional Identity: session output row and a Light row: n-a — no identity (session mode) line, so "unreported" is at best ambiguous about which surface it describes (the host records nothing; the flow does report). It is a one-phrase documentation wording fix in the model doc with no behavioral consequence — no adapter, no skill step and no test reads that phrase — which is why it did not fail the gate and did not fail conformance.
This publish/sync step is explicitly scoped to no code changes, so it is reported rather than fixed: either fold the phrase into the next commit on this branch — "unchanged — reported as Identity: session, never as a degradation", the wording the docs page already uses — or accept it and let it close with the PR. This is the one finding that is open, and it is named rather than dropped.
Verification on this head (6c0d4828), re-run during this sync
| gate | result |
|---|---|
pnpm quality-gate |
PASS (exit 0) — all three Custom Gate Registry gates; tiering disabled ⇒ full suite, CI parity |
review-identity.test.ts |
139 / 139 |
scenarios/review-identity.sh |
228 PASS / 0 FAIL, exit 0 |
| knowledge-hub vitest | 5034 / 5034, 49 files |
| remote CI | build · smoke · secret-scan · preview — SUCCESS |
pair-review |
success — APPROVED — 0 open Major/Minor findings |
State synthesized with the shipped adapter against this PR's real reviews, not asserted:
resolve_pr_state pass approved red 0 → to-be-reviewed
merge_allowed pass approved red 0 → NO
pr-state: tier 'red' requires explicit human approval — staying to-be-reviewed
Required human action
pr-state:to-be-reviewed is the correct synthesized state, not a stale label: at 🔴 with green gates and an APPROVED verdict, the table's green / APPROVED / 🔴 / absent row is to-be-reviewed until an explicit human approval exists on the current head. The pair review here is self-authored, published as a comment-form review, and by construction cannot supply it.
To merge, a non-author human must record an APPROVED review on SHA 6c0d482822554a50f8b0e22ea19da40987edf4f9, and merge by hand. No auto-merge, and nothing in this run merged, closed or deleted anything. The blocked T11 (end-to-end verification on a real identity — no App or bot user exists for this repository) is a stated gap for that human gate, recorded in the ADR-018 amendment and the PR body, deliberately not filed as a separate card.
Bound re-review to immutable deltas and require a complete finite-state inventory before review or remediation.
Require real boundary evidence for externally-defined state and repair claims.\n\nRefs: #416
Questions are, by the review template's own definition, questions for the human: items the reviewer marks "No change requested". The floor defaulted to none, so they entered the set the fixer must resolve, and each answer enlarged the diff and created fresh surface for the next review. Measured on PR #477: the PR reached a zero-actionable APPROVED twice; both times the following round implemented the Questions and the re-review found new Minor findings inside the code that round had just added (three, then two). Work now completes when Critical, Major and Minor are closed. An explicit severityFloor still wins. The default is applied softly - a template without Minor, or without a usable ranking, falls back to no floor instead of throwing, because a default must not break a run that never asked for it. - ADL 2026-09-03-the-review-severity-floor-defaults-to-minor.md - three tests: Questions-only converges and is carried, a Minor still blocks, a vocabulary without Minor falls back Refs: #219 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… yaml-parsed allow-list (#477) The check-only pre-push gate was the sole enforcement point for formatting, and `--no-verify` skips it. CI now runs `format:check` in a dedicated workflow, and the workflow's own shape is guarded by a composition check. The guard parses `.github/workflows/format.yml` with yaml@2.8.2 and expresses every rule as an allow-list over the parsed document, per ADL 2026-09-01; the hand-rolled line reader it replaces is retired. Nothing is ever rewritten in CI — write-mode formatters are banned by the same guard. Also carries a path-scoped `.gitleaks.toml` allowlist for a synthetic 40-hex SHA fixture in scripts/smoke-tests, which gitleaks matched as a generic-api-key at historical commit 5e99d53. Closes #413 🤖 Generated with [Claude Code](https://claude.com/claude-code)
…ntity) - amendment records what is adopted (identity + host-agnostic adapter + adoption-gated light row) - and what is NOT changed: the 🔴 `user.type == "User"` predicate, resolve_pr_state's table - supersedes the "distinct reviewer identity stays out of scope" consequence bullet - way-of-working (repo + template): `Review identity` = `none` (default, session token) - Task: T1 — ADR-018 amendment Refs: #218
…proval Extends the shipped PR state flow (#234/#390); rebuilds none of it. resolve_pr_state's table and the 🔴 predicate are untouched. - assets/review-identity.sh: host-agnostic identity adapter — three modes (session default / identity / halt), native verdict event, Checks-API-vs- commit-status publication, deterministic audit-comment writer - pr-state.sh: light_auto_approve_allowed sibling — declaration + tag + below-red + already-ready-to-merge; zero criteria (D18), inert on this repo - review Steps 5.3/5.4/5.4b: resolve who acts, native APPROVE/REQUEST_CHANGES, publication mode, light row + audit; HALT-not-fallback on a broken identity - publish-pr Phase 5: same adapter governs its own host writes - pr-states.md + github-implementation.md: model + per-host setup (App recommended, bot user alternative, permissions, credential handling, probes) - smoke scenario review-identity.sh (identity x light x tier x verdict matrix, 🔴-predicate regression, grep guards, installed-project assertions) + CI list - conformance review-identity.test.ts (60 contracts) - docs site: concepts/review-identity + CP5/e2e/meta registration - Tasks: T2, T3, T4, T5, T6, T7, T8, T9, T10 Refs: #218
…, not prose Review round 1 on PR #466. Two defects were real, not doc drift: - Critical: a `bot-user` identity types as `user.type == "User"` on the reviews API, so `human_approval_jq_filter`'s type clause did NOT exclude it — a machine approval satisfied the 🔴 explicit-HUMAN gate and a risk:red PR reached ready-to-merge with no human. Adds the login clause (`.user.login != env.REVIEW_IDENTITY_LOGIN`) + `review_identity_exclusion_ok`, which makes an unprovisioned login a NOT-healthy identity (⇒ halt). Additive: with the var unset the clause matches nothing. - Major: Step 5.3 mapped ANY approved verdict to APPROVE, so the light row's adoption gate was decorative — every green/yellow PR self-approved. `light_auto_approve_allowed` is now the third argument of `identity_verdict_event` and the sole authority for the APPROVE event; everything else is a COMMENT-form review. Also: App health probes fixed (`gh api user` 403s on an installation token), JWT→installation-token minting documented, a write-touching probe added, ADR verification pointer corrected, Q5 scoped to the verdict, and the smoke guard moved from a prose grep onto the resolved event matrix. Refs: #218
…ctions Review round 2 on PR #466. - review-identity.sh: `user | bot-user)` — the skills forward the way-of-working literal verbatim, so `bot-user` was a permanent HALT on every correctly provisioned bot-user repo. Same vocabulary documented on pair_review_publication_mode. Smoke pins both spellings. - github guide: App publication snippet assigns IDENTITY_KIND/CONFIGURED/ HEALTHY from adoption + the probe outcome (was unset ⇒ silent commit status on the App path); token-prerequisite scoped to the session path, the workflow+relay claim the App path contradicts is gone. - light residual on all 3 surfaces + ADR-018: once the family is declared, `light` is a merge-authorizing label and must be access-controlled. - review SKILL 5.4b: session-mode skip scoped to the audit action; the `Light row:` report line is unconditional, matching the Output Format. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…xtraction fixed Review round 3 — 5 findings, all fixed in place. - identity_verdict_event gains <self_authored>: `session` mode returns the NATIVE APPROVE/REQUEST_CHANGES unless the acting account authored the PR (or authorship is unknown ⇒ COMMENT, fail-safe). Collapsing every session verdict to COMMENT dropped real change requests + real approvals on every two-human repo — the shipped default path - Step 5.3: the resolve_pr_state synthesis is its own UNCONDITIONAL step (all three modes, every verdict), no longer nested in the identity-only, APPROVED-only APPROVE-authority step Step 5.4 was told not to redo - github guide: IDENTITY_KIND extraction parses the shipped markdown BULLET (`- **Review identity**: `app``) instead of an anchor that matched nothing and silently yielded `none` ⇒ session ⇒ commit-status - report rows: `Identity approve:` deleted; Step 5.4b + degradation quote the Output Format block's `Light row:` strings verbatim - docs page: "Four properties", and the comment-form verdict scoped to self-review on every surface (skill, pr-states, github guide, ADR, mdx) - smoke: session x authorship matrix, pr-state label reachability per mode, and the guide's extraction EXECUTED against the real way-of-working + dataset template + fixtures; conformance pins the scoping Refs: #218
…he idempotent skip - review_identity_kind_ok: the adapter owns the vocabulary; a `Review identity` key present but unparseable is configured-but-unusable (HALT), never `none` (= session ⇒ session-token review, no HALT raised) - Step 5.4 idempotency skip scoped to steps 3-5, target Step 5.4b: a re-review submits a fresh identity review, so its audit comment + Light row still run - light_auto_approve_allowed normalises newlines too; the review skill names the concrete label read - both consumer surfaces enumerate all six adapter entry points - audit-comment possessive; JWT->installation-token exchange in the curl form GitHub documents Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UQJzGMhRqBRRboxMrRqFPP
…try points + the login clause - `Light row:` gains a 4th value `n-a — no approving verdict (row not consulted)`: identity mode + CHANGES-REQUESTED (or an unresolved verdict) matched none of the three and had to fabricate one. Bound per case in Step 5.4b, in the Output Format block and in Step 5.3 step 3. - ADR-018 "What is adopted" item 1 lists all six adapter entry points (the four-function list let a second-host implementer omit `review_identity_exclusion_ok`); the conformance loop now covers the ADR too. - ADR-018 Consequences: the 🔴 exclusion is type (App) + login (bot user), and the amendment ADDS the login clause — it did not leave the rule untouched. - publish-pr HALT: the `pr-state:to-be-reviewed` label does not happen either. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UQJzGMhRqBRRboxMrRqFPP
…ey; identity must not author PRs review round 6. - review_identity_health: `healthy` computed per run from artifact-free probes (auth + grants, exclusion folded in). Kills the inert PROBES_PASSED that nothing set — the guide's write-probes are setup-only, so every review halted on a correctly provisioned repo. Guide splits step 5 (setup, leaves artifacts) from step 6 (per-run); mid-write 403/422 = HALT, never a session fallback. - presence probe anchored to the key shape (phrase + colon): prose mentioning "review identity" no longer HALTs a repo that runs none. - identity_verdict_event reads authorship in identity mode too (per-mode default: unknown = not self-authored) -> self-authored identity degrades to COMMENT instead of a 422 that leaves pair-review success with no review body. Setup rule + degradation entry + read-back action (Review: NOT SUBMITTED blocks publication). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UQJzGMhRqBRRboxMrRqFPP
…anchored; containment count - Output Format `Review:` row: two values like its siblings (landed | NOT SUBMITTED), and `no separate VERDICT comment` — 5.4b's audit comment is required in identity mode. - Presence probe anchored at line start: a colon-bearing prose sentence no longer answers PRESENT and HALTs every review on a project running no identity. - Host page: residual folded into the declaration bullet; two announced, two bullets. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UQJzGMhRqBRRboxMrRqFPP
…ership; an App CAN author PRs - Output Format `Check:` row gains `NOT PUBLISHED — advisory, <host error>`, the value Step 5.4 step 3 + Graceful Degradation mandate (both CONTINUE, not HALT). - App per-run probe 1: `/installation/repositories` membership of `$REPO`, not `.total_count` — an installation token is org-wide, so reachability is not scope. - App per-run probe 3 GATES: `$APP_SLUG` (captured at mint time — `GET /app` is a JWT endpoint) vs `<slug>[bot]`; unknown slug ⇒ not healthy. The MANDATORY "must not open pull requests" rule lifted to cover BOTH forms; failure-mode row is now `halt`. - Review skill drops the false "an App installation is never a PR author" exemption. - Smoke executes both probes with a stubbed `gh`; conformance pins the row + both probes. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UQJzGMhRqBRRboxMrRqFPP
… literal membership; one producer per context Round 10. 1. Probe 3 compared `gh pr view --json author` (GraphQL, renders a Bot actor as `app/<slug>`) against `<slug>[bot]` (the REST shape) — never true, so the gate was inert. A one-App project (publisher + `Review identity: app`) passed health, ran the whole review, then 422'd on submission. Both shapes compared now. Measured: gh pr view 14276 --repo cli/cli --json author -q .author.login => app/dependabot ; gh api repos/cli/cli/pulls/14276 --jq .user.login => dependabot[bot]. SKILL.md, ADR-018 and the smoke stub carried the same error. 2. AUTH probe used `grep -qx "$REPO"` — $REPO as a basic regex. `acme/pair.js` matches a listed `acme/pairXjs` => membership asserted on a repo the App was never installed on. `grep -Fqx` + a dotted-repo smoke negative. 3. `pair-review` is dual-form and the form is resolved independently by publish-pr and by review 5.4 — switching `Review identity` with PRs open leaves two producers on one required context. Enablement-transition rule (drain, or supersede the outgoing form) in the guide, both skills, the ADR and the docs page. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UQJzGMhRqBRRboxMrRqFPP
…the publish HALT is scoped to an actual identity write Round 11. 1. The enablement transition's second exit 403s as documented. Step 4 mints the installation token with an explicit `permissions` payload and the token carries ONLY that subset, so `Commit statuses: write` on the App alone leaves `POST /statuses` at 403 — the stale pending `pair-review` status survives and the PR the rule exists to unblock stays blocked. The snippet, the App permission list and the mint note now say both changes are needed and that the payload, not the grant, is the limit; and that adding it before the grant 422s the mint for every run, so it goes in only while taking that exit. ADR-018 + review 5.4 realigned. 2. publish-pr Phase 5 resolved identity health and HALTed BEFORE step 4 read `Review enforcement` (default `disabled`), where the phase writes nothing as the identity. An App key in CI + a run on a laptop => halt => no `pr-state:*` label and NO REVIEW DISPATCH, on every story, for a credential this phase never uses. HALT scoped to enforcement enabled; disabled reports the unusable identity as a finding on the `Identity:` row and continues to the label + dispatch. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UQJzGMhRqBRRboxMrRqFPP
…probes name their own reason - bot-user per-run probe reads `actions/variables/REVIEW_IDENTITY_LOGIN` back into $RV, gates AUTH_OK on it and feeds it to review_identity_health — the ambient env var is gone. Exporting the login without `gh variable set` made health green while the gate clause compared against "" and matched every account. - light row matches WHOLE FIELDS on newline/comma input (`ui: light theme` is not `light`); the space-joined form is demoted to a documented legacy fallback. - App probe 3 + the bot-user author check print their own AUTHOR reason before zeroing PERMS_OK, so health stops blaming grants for an authorship misconfiguration. - the synthesis runs "in both modes that continue", not "all three" — halt ends the review. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UQJzGMhRqBRRboxMrRqFPP
…n identity write; the presence class names its shapes Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UQJzGMhRqBRRboxMrRqFPP
…thorship is unknown health - the three agent-side `human_approval_jq_filter` sites mandate exporting REVIEW_IDENTITY_LOGIN from the repo variable; unexported the clause is inert - the mid-write HALT drops the `pair-review` publication (advisory-continue) - both authorship probes zero PERMS_OK when the author read fails or is empty - the kind validator runs only when the key is present — silent default path - the LINE-form exactness claim is scoped; the comma-in-a-name residual stated Refs: #218
…ual is stated and reported The two HALT-bound identity writes run review-then-comment and the HALT does not undo the first: a 403 on the audit comment leaves an auto-approved, merge-ready PR carrying an identity APPROVE with no audit comment. Order kept (a comment posted first would claim an APPROVE the host may refuse), reason stated, residual named where the publication and label residuals already are, and reported as a fifth Light row value instead of rendering 'audit comment posted' on a PR without one. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UQJzGMhRqBRRboxMrRqFPP
…probes pin the repo setup-gates enumerated three properties that must survive generation "unmodified" and the REVIEW_IDENTITY_LOGIN env thread — the fourth evaluator of the same filter — was not among them, so the omitted line read as optional next to three called out as load-bearing; unset, the clause matches every account and a bot signs its own risk:red gate. Both per-run authorship probes read the PR author unpinned while every sibling call pins $REPO: from a worktree whose origin is another repository the read SUCCEEDS against the wrong PR and the one-credential misconfiguration passes health. The idempotency contract gained the audit comment (N reviews => N comments, deliberately) and section 5 is scoped to the report; the audit `action` vocabulary now covers the unresolved verdict both assets fail-safe to. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UQJzGMhRqBRRboxMrRqFPP
6c0d482 to
613f7fa
Compare
7b55900 to
adb9627
Compare
PR Information
PR Title: [#218] feat: dedicated review identity + adoption-gated light-PR auto-approval
Story/Epic: #218 — parent epic #212 Supervised automation
Type: Feature
Priority: P0 (Must-Have)
Assignee: rucka
Labels:
risk:red·pr-state:to-be-reviewedHead:
6c0d4828— pair review APPROVED, 0 open Critical/Major/Minor findings (pair-reviewchecksuccesson this head). Atrisk:redthe state staysto-be-revieweduntil a non-author human records an APPROVED review on this exact SHA (pr-states.md synthesis table, rowgreen / APPROVED / 🔴 / absent). Nothing here merges automatically.Summary
What Changed
A project can now provision a dedicated review identity (GitHub App recommended, bot user alternative) that executes every code-host write of the review flow — the native verdict and the
pair-reviewpublication — instead of the session user's token. Plus one adoption-gated row that lets that identity sign the approving review alight-tagged, sub-🔴, already-ready-to-mergePR needs.The shipped PR state flow (#234/#390, ADR-018 Option 3) is extended, never rebuilt:
resolve_pr_state's decision table is untouched and the required-check enforcement is untouched.Three deltas, plus the one predicate change they force:
pr-states.md,github-implementation.mdand the ADR — because an adapter wired from a list that omitsreview_identity_exclusion_oklets a bot user sign the 🔴 approval) —resolve_identity_moderesolvessession(default, not a degradation) /identity/halt. Configured-but-broken HALTs with a setup pointer and never falls back to the session user.review_identity_healthis where thehealthyflag comes from (see Wherehealthycomes from below). The<identity_kind>vocabulary is the adoption literal the skills forward verbatim —app,bot-user, anduseras its short form — and it is owned byreview_identity_kind_ok, so the adoption read is two questions (see The adoption read is two questions below)..pair/knowledge/assets/review-identity.sh(new)REQUEST_CHANGES; on the App pathpair-reviewpublishes as a check run instead of a commit status. The defaultsessionpath is unchanged and still submits the native event;--commentthere is the self-review case only.light_auto_approve_allowedreads a tag, an adoption declaration, the tier and the synthesis. Zero classification criteria (D18). Inert on this repo (Active: riskonly)..pair/knowledge/assets/pr-state.shhuman_approval_jq_filtergains one clause — see The one shipped predicate that changed below..pair/knowledge/assets/pr-state.shpair-reviewpublication, the audit comment), but the403-mid-write HALT binds only two of them: the review and the audit comment. A refusedpair-reviewpublication is the documented advisory-continue case (pair-review: NOT PUBLISHED — advisory, the verdict still lives in the native review) — stated identically in both skills' Step 5.3/step 3, their HALT-condition entries, their Graceful Degradation and the host guide, so one event never carries two mandated behaviors. Thepr-state:*label is not an identity write at all: it is a board view nobody is attributed for, written by the session token in every mode, which is why the label's pre-existing non-blocking refusal survives instead of colliding with the HALT — and why noissuesgrant appears on the App. Symmetrically, thehaltmode binds only a phase that actually performs an identity host write: PR publication underReview enforcement: disabled(the default) writes nothing as the identity, so it reports the unusable identity and continues — stated in the model (pr-states.md+ the ADR), not only in the consumer skill that implements it.pr-states.mdhaltrow, ADR-018 amendmentpair-reviewbecomes dual-form, so the enablement transition is ruled — check run onapp, commit status otherwise, with the form resolved independently at publish time and at review time. SwitchingReview identitywith PRs already open would leave two producers on one required context, so the guide, both skills and the ADR carry the rule: drain open PRs before switching, or supersede the outgoing form on the head — the second exit needingstatusesin the mint payload, not only on the App. See One producer per required context below.github-implementation.md§ Dedicated review identity, review Step 5.4, publish-pr Phase 5Why This Change
Review actions become auditable per-identity rather than attributed to whoever's token happened to be loaded, and the self-authored
--commentdegradation retires for the blocking direction (the identity is not the PR author, so the host accepts the real event). This is the approval gate #219's supervisor loop consumes.The one shipped predicate that changed
human_approval_jq_filter— the 🔴 explicit-human-approval predicate — now also excludes by login:Why it is not redundant with the type clause. Only a GitHub App installation types as
user.type == "Bot". Abot-useridentity is an ordinary machine account and types as"User"on the reviews API — the type clause does not exclude it. Without the login clause, arisk:redPR with green gates and an APPROVED verdict would reachready-to-mergeon a machine approval alone, with no human involvement.The exclusion is therefore two clauses, one per account kind, and it is checked rather than asserted:
review_identity_exclusion_ok <kind> <login> <acting>makes an unprovisionedREVIEW_IDENTITY_LOGINa not-healthy identity, soresolve_identity_modeyieldshaltinstead of running with the clause inert.And the check binds the login that is actually SIGNING (this head,
6c0d4828). The gate used to verify only that some login was provisioned; the 🔴 predicate, however, excludes that login and no other, so a variable namingacme-botwhile the flow runs underother-botleft the acting account's approval satisfying the explicit human approval — the exact hole the exclusion exists to close, passing as healthy. The third argument is the login the identity's own credential answers with on this run (gh api user --jq .login), and every non-matching shape now fails closed:<acting>absent ⇒ unknown is never excluded ⇒ not healthy;<acting>≠<login>⇒ not healthy, with the mismatch named on stderr.appis unaffected — it is excluded byuser.type == "Bot", before any login comparison.Additive by construction: with the repository variable unset the clause matches nothing, so every pre-existing outcome is unchanged.
And the clause is an ENV input, so every one of its FOUR evaluators has to be told to populate it. The CI job resolves it from
${{ vars.REVIEW_IDENTITY_LOGIN }}; the agent evaluates the same filter in three places — review Step 5.3 step 2 and Step 5.4 step 5, and/pair-process-implement's post-review merge precondition — and nothing exported it there. Measured on the shipped filter, two APPROVED reviews on headabc(id 1 byacme-bot,type: "User"; id 2 by a human),PR_AUTHOR=rucka:.user.login != ""is true for every account, so a bot-user identity's approval cast outside this flow (a maintainer holding the bot PAT, #219's supervisor loop) reachedready-to-mergeandmerge_allowedon arisk:redPR. The CIpair-explicit-approvaljob catches that only whereReview enforcement: enabledand branch protection is applied — not the default, and not this repo. All three agent-side sites now mandate exportingREVIEW_IDENTITY_LOGINfrom the repository variable ($RV, which Step 5.3 step 1 already reads back) into the shell that evaluates the filter, and each states that the login clause is inert without it.The fourth evaluator is the CI job — and the skill that GENERATES it is
/pair-capability-setup-gates. Its Step 4.5 enumerated exactly three properties that must survive generation unmodified ("all three are authorization properties rather than style"): trusted ref, head-SHA pinning, pending-first. TheREVIEW_IDENTITY_LOGIN: ${{ vars.REVIEW_IDENTITY_LOGIN }}env thread was not among them, so an agent generating the job "from the host guide's template" on a repo declaringReview identity: bot-user+Review enforcement: enabledreads the dropped line as optional config while three neighbours are called out as load-bearing — and the job then evaluates the filter with the variable unset, on the one surface where the 🔴 gate is actually binding. It is now the fourth unmodified respect, naming the repository variable and what the clause degrades to without it.The adoption read is two questions, and
noneis not a defaultReview identityis read fromway-of-working.mdas a markdown bullet.nonein that key does not mean "unparsed" — it means no identity is configured, which resolvessession, i.e. the review is written with the session token. So an extraction that recognises one markdown shape and degrades everything else tononeis the session-user fallback AC4 forbids, reached with no HALT because the flow never learns an identity was configured:- **Review identity**: \bot-user` — …`bot-userbot-user- **Review identity**: appappapp**Review identity**: bot-user(no bullet)none⇒session⇒ review signed by the session account- Review identity: app(no bold)none⇒session⇒ review signed by the session accountnonenonenone— the presence probe is anchored to a KEY AT THE START OF A LINE, so prose does not make a project look configurednone— a colon mid-sentence is not a key: an unanchored phrase-then-colon match would answer PRESENT here and HALT every review on a project that configured nothing## Review identity: app(an ATX heading)none⇒session⇒ review signed by the session account> - **Review identity**: app(inside a blockquote)none⇒session⇒ review signed by the session accountThe read is therefore split: presence is detected by key shape at the start of a line, with the accepted DECORATIONS enumerated rather than assumed — blockquote markers (
>, repeatable), an ATX heading (#..######), a list bullet (-/*), bold markers, any of them omitted:grep -qiE '^[[:space:]]*(>[[:space:]]*)*(#{1,6}[[:space:]]*)?[-*]?[[:space:]]*\*{0,2}Review identity\*{0,2}[[:space:]]*:'It is deliberately not agnostic about POSITION (prose is not configuration), and the enumeration is stated as such because a shape outside the set reads as ABSENT ⇒
none⇒session, which is the silent session-token fallback this whole read exists to prevent — the last two table rows above were exactly that hole until this head. The value is extracted from the shipped bullet, andreview_identity_kind_okdecides whether it parsed — but only when the key is present. Ordering the two questions the other way round called the validator with the empty string on the DEFAULT path (the key genuinely absent: every project that has not opted in), so it printed its own HALT-flavoured diagnostic — "emptyis not a Review identity value … HALT and fix the key, never treat it asnone" — on every review and every publish of a correctly configured repository, contradicting the flow's ownIdentity: sessionand telling an operator to fix a key the project deliberately does not have. The absent key now resolvesnonesilently; the present-but-unparseable one still speaks, loudly. The vocabulary lives in the adapter, not in a markdown snippet that can drift fromreview_identity_exclusion_ok/pair_review_publication_mode. The smoke scenario executes the guide's own lines against the real files, the dataset template and six fixtures, the two unparseable shapes included.Where
healthycomes fromresolve_identity_mode's second argument is the single signal separatingidentityfromhalt, so it is computed, never assumed:review_identity_health <kind> <auth_ok> <perms_ok> <login> <acting>, fed by probes run on this run, with the exclusion check folded in so no caller can compute health while forgetting it — and<acting>, the login the identity's own credential answers with, folded in with it so the exclusion is checked against the account that will sign rather than against whatever the variable happens to name.The probes are split by cost, because the two halves cannot both run per review:
gh api /installation/repositories --paginate --jq '.repositories[].full_name' | grep -Fqx "$REPO"(MEMBERSHIP — an installation token is org-wide, so a 200 is not scope;-Fbecause a repo name routinely carries a., a regex metachar) + the installation-token exchange withpermissionsrequested explicitly (GitHub answers 422 when the installation lacks a grant) +$PR_AUTHORvs bothapp/${APP_SLUG}and${APP_SLUG}[bot]· bot user:gh api userfor the acting login +gh api "repos/$REPO/actions/variables/REVIEW_IDENTITY_LOGIN" --jq .value(the acting login must equal that repository variable, read back on this run) +repos/{repo}/collaborators/{login}/permission+$ACTINGvs the PR authorpair-identity-probecheck run (undeletable) + a posted/deleted scratch commentThe bot-user health input is the repository VARIABLE, read back on the run — never the agent's environment. The 🔴 clause this check exists to arm is evaluated in the
pair-explicit-approvaljob, which resolves${{ vars.REVIEW_IDENTITY_LOGIN }}. So the probe reads that value back into$RV, requires[ -n "$RV" ] && [ "$ACTING" = "$RV" ]beforeAUTH_OK=1, and passes$RV— not an ambient$REVIEW_IDENTITY_LOGIN— toreview_identity_health. An operator who exports the login in the shell/CI env but never runsgh variable set(or stores it as a secret, or scopes the variable to an Environment thispull_request_targetjob does not use) would otherwise get a healthy identity while the gate reads the empty string:The exploitable path is an approval that account casts outside this flow (a maintainer using the bot PAT, #219's supervisor loop, a second automation) — the review flow itself refuses to approve at 🔴. With the read-back the probe answers
AUTH_OK=0in exactly that state ⇒ health0⇒halt; a variable naming a different account fails for the same reason.That read-back also adds a grant to the bot-user PAT —
Variables: read— and it is the one grant no WRITE needs, so a PAT provisioned for the writes alone authenticates, passes every other probe and is refused only here. Provisioning succeeds too, becausegh variable setruns under the maintainer's token. The probe therefore captures the read's exit status instead of swallowing it with|| true: a 403 (grant missing) and a 404 (variable never set) each print their own reason before zeroing the flag, soreview_identity_healthno longer reports "the credential did not authenticate" about a credential that answered200one line above, sending the operator to re-issue a PAT that is fine.Whatever a read probe cannot cover at run time is covered by one rule, on all three surfaces: a
403/422met mid-write is a HALT, reported against the artifact that failed — never a retry with the session token, and never apair-reviewpublication implying a review that did not land. With one named exception, so the rule never collides with a sibling one: a refusedpair-reviewpublication ispair-review: NOT PUBLISHED — advisoryand CONTINUES, exactly as Step 5.4 step 3 and Graceful Degradation have always said — the verdict lives in the native review, and stopping would leave the PR with neither the state label nor the report. Anything that is not exactly1— including "probe not run" — is not healthy, so a repository that skipped the per-run check getshalt, not a silent session fallback.The identity must not be the PR's author — on BOTH forms
The host rejects a review action on your own pull request (
422 Can not request changes on your own pull request). An identity that also opens PRs — the shape an unattended loop falls into when one credential runs the whole pipeline — would therefore have every native event refused while the separate publication step still markedpair-reviewsuccess: an approving verdict as a green required check on a PR carrying no review body.It is not a bot-user-only rule. A GitHub App authors pull requests too, so the cheapest setup (one App, one credential to store, publishing the PR and reviewing it) hits it exactly as a shared machine account does. The rule is therefore stated once, for both forms, in the shared Dedicated review identity section rather than under Bot user.
And the comparison carries TWO login shapes, because
ghrenders one Bot actor differently per API. Measured, not inferred:The probe reads the author with
gh pr view --json author, so a gate comparing only against${APP_SLUG}[bot]is never true — inert on exactly the path it guards. Both shapes are compared now (case "$PR_AUTHOR" in "app/${APP_SLUG:-}" | "${APP_SLUG:-}[bot]") PERMS_OK=0 ;; esac), and the two smoke cases feed the two real values. A bot-user identity is a plainUserand compares literally on both paths, so that form was never affected.Two layers, in the order they fire:
$PR_AUTHORvs bothapp/${APP_SLUG}(GraphQL) and${APP_SLUG}[bot](REST);$APP_SLUGis captured at mint time, sinceGET /appis a JWT endpoint an installation token cannot read — an unknown slug is itself not-healthy$ACTINGvs the PR authorPERMS_OK=0⇒ not healthy ⇒haltwith the setup pointer, nothing writtenidentity_verdict_eventreads<self_authored>in both modesCOMMENTform — the verdict is published in full instead of being lost to a rejected eventAn UNREADABLE author is unknown health, not "not the author". Both probes used to compare an empty
$PR_AUTHORagainst the identity's login, match neither shape and leave the flag at1— while the sibling unknown-input arm in the same block ($APP_SLUGunset) correctly zeroes it. The one-credential pipeline plus a transient author read (network, a wrong$PR, a token that cannot read the PR) therefore resolved healthy, ran asidentity, and met the host's422 Can not request changes on your own pull requestmid-write: a HALT with the check left pending and no review submitted — the exact outcome the probe promises to prevent before any host write. Both probes now capture the read's outcome and zeroPERMS_OKwith their own reason (the pull request's author could not be read … unknown authorship is unknown health), and the smoke scenario drives each under a stub whose author read exits 404.And both probes PIN the read to
$REPO. Every other host call in those two snippets pins the repository (gh api /installation/repositories,gh api "repos/$REPO/collaborators/…",gh api "repos/$REPO/actions/variables/…"); the author read did not. Run from a cwd whoseoriginis a different repository — pair's own orchestrator runs skills from detached worktrees under../pair-worktrees/<id>, and an agent harness may invoke from a parent directory —gh pr view 466resolves PR 466 of that repo and returns an unrelated author. The comparison then finds no match,PERMS_OKstays1, health resolvesidentity, and the one-credential misconfiguration reaches the native review and dies on422 Can not request changes on your own pull requestmid-write. The failure is not symmetric with a failed read: a read that fails or returns empty is correctly handled (PERMS_OK=0, above); a read that succeeds against the wrong repository silently passed. Both reads are nowgh pr view "$PR" --repo "$REPO" --json author -q .author.login, and the smoke stubs model the cwd — an unpinned call answers a foreign login, so every existing authorship fixture asserts the pin.Each authorship check prints its own reason. It is a third, distinct failure sharing
PERMS_OK— the flag that means the required grants were observed — so a silent zero madereview_identity_healthemit the grant-shaped diagnostic and send the operator of the one-credential pipeline (the misconfiguration the guide calls the likeliest) to re-inspect App grants that are correct, with nothing in the trail naming authorship. Both forms now emitreview-identity: the identity (<login>) is this pull request's AUTHOR — not a grant problem…with the § pointer before zeroing the flag, and the unknown-$APP_SLUGarm carries its own line too.The adapter's defaults stay per-mode and deliberately asymmetric — unknown ⇒ self-authored in
session(the acting account routinely is the author), unknown ⇒ not self-authored inidentity(setup forbids it, and the other default would collapse every identity verdict to COMMENT and delete the feature). A wrong guess there is loud (a 422), not silent, and Step 5.3's read-back has a defined action: a review the read does not show isReview: NOT SUBMITTED — <host error>, check left pending, the resolved check is not published, and the pending one stays. That is one of the two values the report'sReview:row enumerates — and theCheck:row likewise carriesNOT PUBLISHED — advisory, <host error>, the outcome Step 5.4 and Graceful Degradation both mandate on a refused publication (both CONTINUE, not HALT). The Output Format block is the literal rendering contract, so a row missing its failure value has an agent assert an artifact that was never written.One producer per required context — the enablement transition
pair-reviewis now dual-form: a check run on anappidentity, a commit status on every other mode. The form is resolved independently at two moments in a PR's life — by publish-pr when the PR is created, and by review Step 5.4 when the verdict lands — so aReview identitychange between them splits one required context across two records:Review identity: nonepair-reviewcommit status,pendingReview identity: app, #100 is reviewedpair-reviewcheck run,successWhich record branch protection honours is host-defined; if it honours the stale pending status, #100 is unmergeable with a manual
POST /repos/{owner}/{repo}/statuses/{sha}as the only exit. The guide already stated this rule for the sibling context (pair-explicit-approvalis a status, deliberately not a same-named check run) and now states it for this one. Two exits, both documented:Review identity.statusesgranted on the App AND requested in step 4's installation-tokenpermissionspayload: the token carries only the subset that payload asks for, so the grant alone leaves thePOST /statusesat403 Resource not accessible by integrationand the stale pending status uncleared — the exact outcome the rule exists to prevent. It is added only while taking this exit, because step 4 requestspermissionsexplicitly and GitHub422s a permission the installation was never granted, i.e. an unconditional request breaks the mint on every run. Retiring needs the old App's token, which a session run no longer has. Both conditions are why drain is the normative exit rather than this one.The light row is load-bearing, not decorative
light_auto_approve_allowedis the third argument ofidentity_verdict_eventand the sole authority for anAPPROVEevent the identity signs:identityAPPROVE— the only path to a native approveidentityCOMMENT, verdict token leading the bodyidentityCOMMENT(fail-safe closed)identityREQUEST_CHANGES, ungated — a block never unlocks a mergeidentityCOMMENT— the host rejects the native event; reported as a setup findingsessionAPPROVE/REQUEST_CHANGES— the shipped default, unchanged by this PRsessionCOMMENT(the host rejects a self-authored verdict; unknown authorship is fail-safe)The report row has one value per case, five in total —
approved,approved — … audit comment NOT POSTED (<host error>) — HALT, the APPROVE stands,not-authorized — <unmet condition>,n-a — no approving verdict (row not consulted)(identity mode where the verdict is not APPROVED, so the row is never evaluated and there is no stderr condition to name) andn-a — no identity (session mode)— so the row is emitted truthfully on every run, blocking reviews included, and #219's loop can key on it.The audit comment follows the review, by choice, and the choice carries a stated residual. Every input the comment renders (action, tag, declaration, tier, state) is resolved before the review is submitted, so the order is not a dependency. It is posted after because it attributes an action that has happened: posting it first would leave a permanent comment asserting an
APPROVEthe host may then refuse (422on a self-authored event, a403mid-write) on a PR carrying no review at all. The cost, recorded rather than assumed away: the two writes are both HALT-bound and seconds apart, and the HALT on the second does not roll the first back — a403on the audit comment leaves anidentity-modeAPPROVEon aready-to-mergePR with no paired audit comment, mergeable with no human action whereverrequired_approving_review_count >= 1. It is loud, not silent: the run HALTs and reports the fifthLight row:value above — the one documented exception to "every identity review is paired with its audit comment", stated at Step 5.3 step 5, Step 5.4b step 1, in the HALT Conditions entry (which no longer claims an unqualified "before any host write" for a write interrupted mid-flight) and in the ADR-018 amendment.On a repository setting
required_approving_review_count >= 1, a nativeAPPROVEis what satisfies the host's approvals rule. Gating it on the row is what stops every green/yellow PR with an approving verdict from being approved by the identity,lighttag or not, declaration or not. The row governs the review cast on the project's behalf; it is not read insessionmode, where the account whose token is loaded signs its own review exactly as it did before this PR.The tag read is whole-FIELD, because label names contain spaces. Hosts ship label names like
good first issue; a UI project equally carriesui: light theme. Collapsing every input shape to spaces made that single label indistinguishable from thelighttag — arisk:greenPR carrying only it reachedAPPROVEon a repository withrequired_approving_review_count >= 1, and the audit comment named aTag: lightthe PR does not carry. The two delimited shapes are now matched as whole trimmed fields:ready-to-merge)lightlightweightrisk:green+ui: light theme, one per linerisk:green,ui: light themerisk:green+light, one per lineui: light theme— the LINE read's output for a PR carrying that ONE labelrisk:green light— the legacyjoin(" ")shapetheme, lighton its own line (the newline preserved)theme, light— the same ONE label with its trailing newline strippedThe match is exact on every shape, because no shape falls through to a substring test. Newline input splits on newlines alone (a label name may legally contain a comma), comma input on commas, and a string carrying neither delimiter is one whole trimmed field — which is precisely what the documented line read emits for a PR carrying a single label. That last arm used to be a space-substring test, so
ui: light themereached APPROVE through the read the asset itself calls exact.The cost is bounded and deliberate: the legacy space-joined
join(" ")shape now fails closed for a multi-label PR (risk:green light⇒ no-op) instead of being accepted. Once joined, nothing in the string separates the tag from a label containing the word —good first issue,help wanted,ui: light theme— and an irrecoverably ambiguous input must not authorize a merge-enabling event. The skills' documented read is the line formgh pr view <n> --json labels -q '.labels[].name'.The residual the last table row names.
LABELS="$(gh pr view <n> --json labels -q '.labels[].name')"strips the trailing newline, so a PR carrying the ONE labeltheme, lightreaches the helper with no newline, takes the comma branch, is cut intotheme+lightand matches. Nothing in that string says whether the comma delimits two names or belongs to one — the same irrecoverable ambiguity as the joined shape, arriving through the read the asset calls exact. It is not reachable through this row today: a sub-🔴 tier requires arisk:*label, hence a second field and a newline. So what changed is the claim, not the behavior — the LINE form is documented as exact "whenever the string actually carries a newline (≥2 labels, or the trailing newline preserved)", the residual is stated next to the joined-shape one, and both shapes are pinned as smoke fixtures. Preserve the trailing newline and the exact branch is taken.Two containments, both mechanical:
lightlabel on a project whose## Tag Projectiondeclares nolightfamily authorizes nothing.pair-explicit-approvaleither way (App by type, bot user by login).And one residual, stated rather than assumed away. The first containment holds only on projects that never declared the family. On one that has declared it — and that sets
required_approving_review_count >= 1— nothing in the flow verifies who applied thelightlabel: any collaborator with write or triage access can tag their own sub-🔴 PR and the identity's authorizedAPPROVEsatisfies the host rule with no second person. That is the row working as designed, so the declaration is an authorization decision:lightmust be access-controlled (applied from classification; manual application restricted and audited). Recorded in the ADR-018 amendment and on all three consumer surfaces (pr-states.md,github-implementation.md, the docs page) so an adopter meets it before opting in. Inert here —Active: riskonly.Story Context
User Story: As a team gating automated merges, I want a dedicated review identity that executes the already-shipped approval/block flow, plus an adoption-gated auto-approval row restricted to light-tagged PRs, so that review actions are auditable per-identity, light PRs become mergeable without a human native-approve, and nothing red merges without explicit human approval (R5.5, R5.6).
Acceptance Criteria coverage:
pair-review; App path uses the Checks APIreview-identity.sh(resolve_identity_mode,identity_verdict_event,pair_review_publication_mode), review Steps 5.3–5.4, publish-pr Phase 5light+ tagged + sub-🔴 + merge-enabling ⇒ native APPROVE + audit commentlight_auto_approve_allowedwired asidentity_verdict_event's authority; Step 5.4b auditevent: 🟢 light, declared, all green ⇒ APPROVEresolve_pr_stateunchangedevent: 🟡 approved, NO light tag ⇒ COMMENTresolve_identity_mode,review_identity_health(per-run probes + exclusion), untouchedresolve_tierreview_identity_health app 1 0 '' '' → 0⇒resolve_identity_mode 1 0 → halt;review_identity_health bot-user 1 1 acme-bot other-bot → 0(acting ≠ provisioned)identity_audit_commentwith three actions (approve/comment/block); Step 5.3 verify reads the acting account back; the one exception — a refused audit-comment write after the review landed — HALTs and is reported, never silentadr-018-pr-state-flow-required-checks.mdamendment 2026-08-28Changes Made
Implementation Details
resolve_pr_state's table is untouched.github-implementation.md— App (recommended) and bot user, required permissions, secret-store credential handling, the JWT → installation-token exchange (withpermissionsrequested explicitly), the setup-time write probes and the per-run artifact-free health check as two separate steps, the failure-mode table, and thepair-explicit-approvaljob wiring forREVIEW_IDENTITY_LOGIN.review-identity.sh(seven entry points, names no host command); review Steps 5.3–5.4 and publish-pr Phase 5 resolve WHO writes before writing anything.pr-state.sh;identity_audit_commentrenders the reason from the same inputs so it is reconstructable from the PR alone.type:"User"machine-account fixture.concepts/review-identity, cross-linked fromconcepts/pr-state-flow, registered inmeta.json/ e2e / CP5.Files Changed
.pair/knowledge/assets/review-identity.sh(+ dataset mirror) ·packages/knowledge-hub/src/conformance/review-identity.test.ts·scripts/smoke-tests/scenarios/review-identity.sh·apps/website/content/docs/concepts/review-identity.mdx.pair/knowledge/assets/pr-state.sh(+ mirror) ·.claude/skills/pair-process-review/SKILL.md+pair-capability-publish-pr/SKILL.md+pair-capability-setup-gates/SKILL.md+pair-process-implement/post-review-merge.md(+ mirrors) ·github-implementation.md+pr-states.md(+ mirrors) ·adr-018-pr-state-flow-required-checks.md·.pair/adoption/tech/way-of-working.md(+ mirror) ·apps/website/content/docs/concepts/{meta.json,pr-state-flow.mdx}·apps/website/e2e/docs.e2e.test.ts·qa/release-validation/CP5-website-docs-completeness.md·scripts/smoke-tests/lib/ci-tests.shDatabase Changes
n/a — no schema, no migration, no data change.
API Changes
Breaking change to a shipped shell contract, contained by fail-safe defaults:
identity_verdict_event <mode> <verdict>→identity_verdict_event <mode> <verdict> <approve_authorized> <self_authored>. Both new arguments are fail-safe:approve_authorizeddefaults to0(not authorized), so an unmigrated call never yields an unauthorizedAPPROVE;self_authoredis read in both modes, with per-mode defaults — unknown ⇒ self-authored insession(⇒COMMENT, exactly what a 3-argument call already produced), unknown ⇒ not self-authored inidentity(setup forbids a PR-authoring identity; the other default would collapse every identity verdict to COMMENT). Shipped in the same PR as its only callers.human_approval_jq_filterreads one new env var,REVIEW_IDENTITY_LOGIN. Unset ⇒ the clause matches nothing ⇒ every prior outcome unchanged.review_identity_exclusion_ok <identity_kind> <review_identity_login> <acting_login>—<acting_login>is the account the identity's credential answers with on this run; on the machine-user form it must EQUAL<review_identity_login>, and absent or mismatched is fail-safe NOT excluded ⇒ not healthy ⇒halt(the 🔴 predicate excludes the provisioned login and no other, so a flow signing under a different account would otherwise satisfy the human approval itself).<identity_kind>acceptsapp,bot-user(the adoption literal) anduser(its short form); any other value is fail-safe NOT excluded ⇒ not healthy ⇒halt.pair_review_publication_modeshares that vocabulary and matches onlyapppositively, so an unknown kind degrades to the commit status.review_identity_kind_ok <identity_kind>— the vocabulary check the adoption read validates against (app | user | bot-user | none). It is what makes a present-but-unparseable key a HALT instead of a silentnone— and the guide's read calls it only when the key is present, so the default (absent) path resolvesnonewithout emitting the validator's HALT-flavoured diagnostic.review_identity_health <identity_kind> <auth_ok> <perms_ok> <review_identity_login> <acting_login>— echoes1/0, thehealthyargument ofresolve_identity_mode. Any probe outcome that is not exactly1(including absent) is not healthy, andreview_identity_exclusion_okis folded in so health cannot be computed without it.<review_identity_login>must be the value READ BACK from the host's configuration store on this run — the same store the 🔴 predicate's job resolves it from — never the caller's ambient environment variable of the same name.light_auto_approve_allowed <pr-labels> …accepts the label list newline- or comma-delimited, and matches whole trimmed fields on both — a string carrying neither delimiter is one whole field, so the match is exact on every shape and the ambiguous space-joinedjoin(" ")input authorizes nothing beyond the degenerate single-label case. The skills' documented read is the line formgh pr view <n> --json labels -q '.labels[].name'.lightweightis still notlighton every shape.Testing
Test Coverage
packages/knowledge-hub— 5034 passed / 5034, 49 files (mirror-guard included).review-identity.test.ts(139 contracts) pins the adapter, HALT-not-fallback, the two-clause exclusion bound to the acting login (absent and mismatched both fail closed), the runtime health source, the gated APPROVE, the per-mode authorship defaults, the unconditional state synthesis (session+identity—haltends the review), the light-row contracts in the skill + KB text, the repo-pinned authorship reads, the idempotency contract's audit-comment item, the auditactionvocabulary, the generator's fourth unmodified respect, and the audit-comment ordering: the order is justified where it is chosen, its residual is stated where the publication and label residuals are, and the fifthLight row:value exists for the case it produces.scripts/smoke-tests/scenarios/review-identity.sh— the full identity × light × tier × verdict matrix, thesession× authorship matrix,review_identity_exclusion_okacross every kind/login/acting-login combination (matching, mismatched, absent acting, bot-user form), onepr-state:*label per mode × verdict, the 🔴-predicate regression against both fixtures, the DoD grep guards, the label list in all three separator shapes, thereview_identity_healthmatrix (probe outcomes × kind × login), the identity × authorship matrix, the host guide's own adoption read AND its App and bot-user health probes executed — the adoption read against the realway-of-working.md, the dataset template and twelve fixtures; the App's probes 1 and 3 and the whole bot-user probe slab sliced out of the guide and eval-ed under a stubbedgh(repo in/out of the installation, a prefix near-miss, a regex-metacharacter near-miss, the App as PR author in both login shapes, an unknown slug; and for the bot user: variable matching ⇒ healthy, variable unset while the ambient env var matches ⇒ NOT healthy, variable naming another account, PAT that does not authenticate, read-only collaborator, identity-is-author — plus an assertion that each authorship check names AUTHORSHIP on stderr rather than firing silently, and — on both forms — a stubbed author read that FAILS (404) or returns EMPTY, asserted to zeroPERMS_OKand to name itself) — the guide's lines are sliced out of the file and run, not asserted as text; the four unparseable shapes (no bold, no bullet, ATX heading, blockquote) are asserted to HALT and the three prose shapes (the third carrying a mid-sentence colon) to resolvenone— each of them with EMPTY stderr, which is what pins the default path as silent. The 🔴-predicate block additionally runs the filter withenv -u REVIEW_IDENTITY_LOGINon the machine-account fixture, asserting the clause is inert unexported and that the resulting state isready-to-merge— the measurement the agent-side export mandate exists to close.apps/websitePlaywright — 39 passed (the new docs page is registered and reachable).Test Results
Re-run on this head (
6c0d4828) as part of the publish/sync step:pnpm quality-gateexit 0,review-identity.test.ts139/139,scenarios/review-identity.sh228 PASS / 0 FAIL. Remote CI on this head:build,smoke,secret-scan,preview— all SUCCESS.Tiering is
disabledon this repo, so the full adopted suite ran regardless of therisk:redtag — all three Custom Gate Registry gates.Testing Strategy
Happy path: declared
lightfamily +lighttag + 🟢/🟡 tier + green gates + APPROVED ⇒ nativeAPPROVE+ audit comment.Edge cases:
lighttagged but undeclared ⇒COMMENT·lightat 🔴 ⇒COMMENT(most restrictive wins) · gates red ⇒COMMENT· untagged tier ⇒ fail-safe red ·identity_verdict_event's authority argument absent or malformed ⇒COMMENT·session+ self-authored or unknown authorship ⇒COMMENT,session+ a second reviewer ⇒ the native event · adoption key absent fromway-of-working.md⇒none, but present and unparseable ⇒ HALT, nevernone; prose mentioning the phrase without the key ⇒none, never a HALT — colon-bearing prose included · identity mode where the identity is the PR author ⇒ the per-run probe answers not-healthy ⇒haltbefore any write (App and bot user alike), withCOMMENTremaining the in-flow fail-safe where no probe ran or the authorship read failed · an App reachable but never installed on this repository (org-wide installation token) ⇒ AUTH probe 0 ⇒halt, instead of a full review discarded at the first 404 ·$REPO=acme/pair.jsagainst an installation listingacme/pairXjs⇒ AUTH probe 0 (a.is a regex metachar, andgrep -qxmatched) · the App as PR author in either login shape (app/<slug>from GraphQL,<slug>[bot]from REST) ⇒ author probe 0.Error handling: configured-but-broken identity ⇒
haltwith the setup pointer, never a session-user fallback · a per-run probe that failed or never ran ⇒review_identity_health= 0 ⇒halt· a403/422met mid-write on the review or the audit comment ⇒ HALT on the refused write — the writes that already landed are not rolled back, so a refused audit comment leaves theAPPROVEin place and reports it as the fifthLight row:value — while the same refusal on thepair-reviewpublication ⇒ advisory, continue · unprovisionedREVIEW_IDENTITY_LOGINon abot-user⇒ not healthy ⇒halt· a kind the adapter does not know ⇒ fail-safe not-excluded ⇒halt(bothbot-useranduserARE known, asserted on both entry points).Regression, executed on this head:
Quality Assurance
Code Quality Checklist
COMMENT/halt/ fail-safe red)Review Areas
resolve_pr_stateDeployment Information
Configuration Changes
Only for a project that adopts an identity (nothing here is required to merge this PR):
Review identity: app | bot-userintech/way-of-working.md(defaultnone— unchanged).bot-useronly, MANDATORY: repository variableREVIEW_IDENTITY_LOGIN= the bot's login, threaded into thepair-explicit-approvaljob (env: REVIEW_IDENTITY_LOGIN: ${{ vars.REVIEW_IDENTITY_LOGIN }}). It must be a repository variable specifically — the per-run probe reads it back withgh api "repos/$REPO/actions/variables/REVIEW_IDENTITY_LOGIN", so an exported env var, a secret, or a variable scoped to an Environment thepull_request_targetjob does not use all leave the identity not healthy ⇒ the flow HALTs. This is not advisory: it is the only thing keeping a machine account out of the 🔴 human gate.App path: a short-lived installation token per run, minted from the private key (never stored), plus
APP_SLUGcaptured in the same step (GET /appneeds the JWT; in a workflow it isactions/create-github-app-token'sapp-slugoutput) — the per-run author probe cannot run without it, and an unknown slug is not-healthy. The key lives in the secret store, never the repository.Turning it on with PRs already open: drain them first (merge/close), or take the guide's supersede step — which additionally requires
statusesboth granted on the App and added to the mint payload, for that transition only. Otherwise thepair-reviewcontext ends up with a record in each form and only one of them is ever updated again.On an unusable identity at publish time (publish-pr Phase 5 step 3) the outcome depends on
Review enforcement, because that is what decides whether this phase writes as the identity at all:Review enforcementenabledpair-reviewregistrationpr-state:*label and no dispatch; the HALT entry says so, since an operator told only "the check did not register" would not think to look at the labeldisabled(the default)Identity:row and the flow still appliespr-state:to-be-reviewedand dispatches the review. Halting here would strand every story of an enforcement-disabled project unlabelled and un-dispatched over a credential this phase never uses; the review re-resolves the same identity at its own Step 5.4, where its writes actually happen, and HALTs thereRollback Plan
Revert the PR. Nothing here is stateful: no schema, no migration, no host configuration written by the flow. A project that had adopted an identity reverts to
Review identity: none⇒sessionmode ⇒ the pre-#218 behavior exactly.Breaking Changes
One shell-contract signature change, listed under API Changes above. It is contained by a fail-safe default and ships with its only callers, so no consumer migration is required.
Documentation
Documentation Updates
pr-states.md§ Dedicated review identity + § Adoption-gated light auto-approval ·github-implementation.md§ Dedicated review identity (setup, probes, failure modes, job wiring)concepts/review-identity, cross-linked fromconcepts/pr-state-flow;docs:stalenessgreenKnowledge Sharing
review_identity_exclusion_okturns the claim into a checked precondition of acting at allSecurity Considerations
Security Review
pair-explicit-approvalby two clauses, one per account kind, with an unprovisioned login treated as not-healthy; (b) a nativeAPPROVEis authorized only by the adoption-gated light row; and the credential that acts is re-verified per run (review_identity_health) rather than trusted from a setup-time check, with a mid-write refusal resolving to HALTSecurity Testing
-----BEGIN,ghp_/ghs_/github_pat_,password=,secret=— clean). The repository's deterministic secret scan (D24) is the CI backstop.administration— the identity must not be able to edit branch protection.Risk Assessment
Technical Risks
type:"User"machine-account fixtureAPPROVEevent, asserted on the resolved event across 7 matrix cases; the tag is matched as a whole label FIELD on every shape (no substring fallback), so a multi-word label containing the word (ui: light theme) is not the tag and the ambiguous joined shape fails closed. Residual on a repo that HAS declared the family: label application is not itself access-controlled by the flow — documented as an adopter obligation on all three surfaces + the ADR, not left implicithaltbefore any write), comparing both login shapesghemits +identity_verdict_eventdegrading to the COMMENT form as the residual; the check is never published without the reviewReview identityswitched with pull requests already open ⇒ two producers on thepair-reviewcontextReviewer Guide
Review Focus Areas
pr-state.shhuman_approval_jq_filter+review-identity.sh review_identity_exclusion_ok) — is the 🔴 gate genuinely closed for both account kinds, and is the additive claim (unset var ⇒ no behavior change) true? Specifically: the precondition is checked against the value the gate job resolves (vars.REVIEW_IDENTITY_LOGIN, read back per run), so a login that exists only in the agent's environment cannot make the identity look healthy while the clause is inert. And symmetrically: every place the agent evaluates that filter (review Step 5.3 step 2, Step 5.4 step 5,/pair-process-implementStep 4.1) now mandates exporting the variable into the evaluating shell, because the CI job'svars.*wiring covers only the enforcement-enabled path. And the fourth evaluator's generator (/pair-capability-setup-gatesStep 4.5) now names the env thread as an authorization property the generated job must keep, alongside trusted ref / head-SHA pinning / pending-first.identity_verdict_event's third argument) — is there any path to a nativeAPPROVEthat does not go throughlight_auto_approve_allowed?2b. The health flag has exactly one writer — is
review_identity_healthfed by probes that run on this run, and is every artifact-leaving probe confined to setup? An unwritablehealthywould make either every review HALT on a correct setup, or every reviewed head carry a probe check run.github-implementation.md§ Dedicated review identity) — can a maintainer follow steps 1–7 end to end and land a working, correctly-scoped identity? Every snippet is self-contained: step 5 (setup probes), step 6 (the per-run health check, which assignsAUTH_OKfrom repository MEMBERSHIP andPERMS_OKfrom the mint answer plus the author comparison) and step 7 (publication, which assignsIDENTITY_KEY_PRESENT/IDENTITY_KIND/IDENTITY_CONFIGUREDand derivesIDENTITY_HEALTHYfromreview_identity_health). The read is executed by the smoke scenario against the real files, so a shape that silently yieldsnonefails the gate rather than the adopter. Step 4's JWT → installation-token exchange uses the form GitHub itself documents (curl -H "Authorization: Bearer $JWT") rather than relying ongh's own auth scheme for an App JWT — no live App exists to verify that here (T11), and a 401 there is indistinguishable from a bad signature. The mint payload is the token's real permission set, not the App's grant list: the baseline requestspull_requests/checks/contents, and the only documented addition (statuses, for the supersede exit) is conditional in both directions — ungranted-but-requested422s the mint for every run, granted-but-unrequested403s the write.Review identity:literal, and the adapter accepts it. A kind the adapter rejects is a permanent HALT on a correctly provisioned repository, so the smoke scenario exercisesbot-useranduseron bothreview_identity_exclusion_okandpair_review_publication_mode.sessionmode reads<self_authored>and degrades toCOMMENTonly on a self-authored PR (or an unreadable authorship).pr-state:*label, always — review Step 5.3 step 2 synthesizes the state unconditionally in both modes that continue (sessionandidentity—haltalready ended the review) and for every verdict, and Step 5.4 step 5 publishes that one value; nothing about the label depends on the identity or on the verdict being APPROVED.APPROVEwithout its paired audit comment would be an approval whose reason is not reconstructable from the PR (AC5). The Idempotent Re-invocation section states this consequence rather than contradicting it: N re-invocations on an unchanged head leave N reviews and N audit comments — deliberate, the pairing invariant holding — and §5's "no duplicate comment artifact" is scoped to the report. The residual is stated too: the comments are byte-identical (the projection renders no head SHA, timestamp or review id), so the pairing is 1-to-1 by count and order, not per-pair from the comment text.Testing the Changes
Key Test Scenarios
count_inassertions inreview-identity.sh.event_formatrix, 7 cases, asserted on the resolved event rather than on prose.resolve_identity_mode 1 0andreview_identity_exclusion_ok user '' ''.review_identity_exclusion_ok bot-user acme-review-bot acme-review-bot⇒ excluded (a repository provisioned per the guide must not HALT forever).sessionmode keeps the native verdict —identity_verdict_event session changes-requested 0 0⇒REQUEST_CHANGES;... 0 1and the 3-argument form ⇒COMMENT.- **Review identity**: \app`⇒app⇒checks-api; against this repo's file ⇒none; againstReview identity: bot-userand- Review identity: app⇒ HALT, notnone`.review_identity_health app 1 1 '' ''⇒1⇒identity;... app 1 0 '' ''(a 403 on the permission probe) ⇒0⇒halt;... bot-user 1 1 '' ''(login unprovisioned) ⇒0; no arguments ⇒0.PR_AUTHOR=app/acme-review(whatgh pr view --json authoractually returns) and withacme-review[bot](the REST shape) ⇒PERMS_OK=0⇒halton both. Before this head only the second was compared, so the gate was inert. And it does not lose the verdict where no probe ran:identity_verdict_event identity approved 1 1⇒COMMENT(not a rejectedAPPROVE), while... 1(authorship unknown) ⇒APPROVE.8b. Reachable is not installed, and membership is LITERAL — the guide's probe 1, eval-ed with the installation holding
acme/pairwhile the run is inacme/late⇒AUTH_OK=0(it answered1on a.total_counttest); and with$REPO=acme/pair.jsagainst a listedacme/pairXjs⇒AUTH_OK=0(it matched, undergrep -qx, because.is any-char).noneon both, not a HALT. The second is what a line anchor buys over a phrase-then-colon match.POST /statusesruns with the step-4 installation token, whose permissions are exactly what that mint requested; the guide, the App permission list, the ADR and review Step 5.4 all now state the payload — not the grant — is the limit.light_auto_approve_allowed "$(printf 'risk:green\nlight\n')" 1 green ready-to-merge⇒ authorized, while"$(printf 'risk:green\nui: light theme\n')","risk:green,ui: light theme", the bare"ui: light theme"and the joined"risk:green light"⇒ no-op. A string with no delimiter used to fall through to a space-substring test, so the exact output of the documented line read for a one-label PR authorized an APPROVE. The one residual is pinned both ways:"theme, light"(newline stripped) ⇒ authorized — documented — and$'theme, light\n'⇒ no-op.…is this pull request's AUTHOR — not a grant problem…before zeroingPERMS_OK, soreview_identity_health's grant-shaped message no longer stands alone for an authorship misconfiguration. And an author read that fails or returns empty zeroesPERMS_OKwith its own reason on both forms (app_author_perms acme-review __fail__⇒0;bot_probe acme-bot acme-bot __fail__⇒1:0) instead of passing as "not the author".Review identitykey, answersnoneand writes nothing to stderr. Before this head it printedreview-identity: 'empty' is not a Review identity value … HALT and fix the keyon every review of every unconfigured repository.gh pr view <n>answersunrelated-human, soapp_author_perms acme-review 'app/acme-review'⇒1(healthy — the defect) andbot_probe acme-bot acme-bot acme-bot⇒1:1before the pin,0and1:0after it. Seven fixtures flipped red→green on this head.identity_verdict_event identity <unknown> …⇒COMMENT, so a COMMENT-form review by the identity lands and is audited;action = commentnow covers it explicitly, matching Step 5.4b step 2's fourth value instead of leaving the agent to invent a token.env -u REVIEW_IDENTITY_LOGIN … jq -r "$(human_approval_jq_filter)"on a single machine-account APPROVED review counts1, andresolve_pr_state pass approved red 1⇒ready-to-merge. That is the state the three agent-side sites now forbid by mandating the export.6c0d4828) —review_identity_exclusion_ok bot-user acme-bot other-bot⇒ NOT excluded ⇒ health0⇒halt, and... bot-user acme-bot ''(acting unknown) ⇒ the same, each naming its own reason on stderr. Against the previous two-argument contract those cases resolved healthy while the 🔴 predicate excluded a login nobody was signing under — 4 conformance cases were red before the fix, 139/139 after, with the smoke scenario covering matching / mismatched / absent / bot-user-form acting logins.Dependencies & Related Work
Blocking Dependencies
None. Prerequisites #215, #233, #234 (merged as #390) and #240 are all closed.
Related PRs
Follow-up Work
Review enforcement: disabledand branch protection unapplied; the identity work is independent of that human step.Pre-Submission Checklist
main