Skip to content

Commit d04712b

Browse files
authored
fix(gate): refuse the shared write identity on the Owner: and Reviewer: lines (#1228) (#1229)
Owner: Dev2 Reviewer: Reviewer Written-by: Dev2 Fixes #1228 Tip SHA: eeb3176 ## Reproduce On `main` (`58bd5575`), `headerReasons()` in `.github/scripts/enqueue-rules.mjs` reads `Owner:` against `writers` and `Reviewer:` against `reviewers`/`writers`. `Birfy` is in both lists — it is the one account every Bot writes as — so both lines accepted it and named no workstation. #1214 merged with `Reviewer: Birfy` in the body the queue entry read. Before this change the guard answered `ok: true` for `Owner: Dev3 / Reviewer: Birfy` and for `Owner: Birfy / Reviewer: Reviewer`. ## What changed `.github/scripts/enqueue-rules.mjs`: `headerReasons()` refuses the shared identity on either line **before** either vocabulary is consulted, with two sentences in the voice of `missing line: Owner:`: - `Owner names "birfy", who is the shared write identity every Bot writes as — name the Bot that claims this pull request` - `Reviewer names "birfy", who is the shared write identity every Bot writes as — name the Bot that reviews` Nothing else changes: a `writers` Bot name on `Reviewer:` still passes while `Birfy` is in `reviewers`, an independent login still passes, `Reviewer:` naming the same Bot as `Owner:` is still refused (#951), and a name outside both vocabularies still gets both sentences. One implementation: `guard()` and `merge-gate` read the same function (#812, #960). The checkpoint needs no new field — an `Owner:` that names nobody is already refused, so `claim: "Birfy"` cannot agree with an accepted owner. `review-policy.json` is untouched: who may record a PASS is a different question and editing it is T3. `AGENTS.md` (enqueue-guard clause) and `docs/autonomous-dev.md` (where the vocabularies are listed) say the same thing in one clause each. ## What I ran ``` node --test .github/scripts/enqueue-guard.test.mjs # before the guard change: 1..85, # pass 79, # fail 6 (the six #1228 cases) # after: 1..85, # pass 85, # fail 0 node --test .github/scripts/enqueue-guard.test.mjs .github/scripts/merge-gate.test.mjs # 1..129, # pass 129, # fail 0 node --test ".github/scripts/*.test.mjs" # 1..495, # pass 493, # fail 0 (2 skipped by their own tests) node .github/scripts/check-mojibake.mjs --check # ok — 0 occurrences of U+FFFD in 1066 text file(s) node .github/scripts/changelog.mjs check # 2 fragment(s) in changelog.d/ are well formed ``` ## Red without the source change The six `#1228:` cases, run against `headerReasons()` without the two refusals: ``` not ok 61 - #1228: Reviewer: Birfy names no reviewer, and the refusal names the line and the value not ok 62 - #1228: Owner: Birfy claims no owner, and the refusal names the line and the value not ok 64 - #1228: @ and case are normalised first, so the shared identity is refused once, not twice not ok 65 - #1228: one unusable name in a list is enough not ok 66 - #1228: the shared identity on both lines is refused on both, and the overlap refusal is unchanged not ok 67 - #1228: the checkpoint cannot become the way in — claim "Birfy" under Owner: Birfy is refused # tests 85 / # pass 79 / # fail 6 ``` The controls stay green in the same run: `#1228: two real Bots on the two lines are still accepted`, `#951: a name that is both a login and a writers name`, and the `#943`/`#947` shapes. ## Not run (and why) - `npm run verify` / `node --test ".github/scripts/*.test.mjs"` is all I could run here: this machine has no `git`, and `arch-docs` (`git log <base>..HEAD`) and the `core` suite (which installs DSH under `core/dev/harness`) need it. The change touches no `core/` file, so the `core` suite is CI's first run of it at this tip. - `actionlint` and the pack check of the `package` job: not run here; they are part of CI. ## Risk - A body that names `Birfy` on either line is refused until it names a Bot: #1226 is open with `Reviewer: Birfy` and will be refused at enqueue entry until its owner corrects that line. #1214 already merged and is not re-judged (`merged`), so history stays as it is. - `merge-gate` shares `headerReasons()`, so a queue entry whose body names the shared identity now fails there too — that is the same rule, not a second one. - No loosening: the refusal only adds reasons; nothing that was refused before is accepted now. Docs-not-needed: `.github/scripts/` is not in any subsystem page's `owned_paths`; the two prose places that list the vocabularies (`AGENTS.md`, `docs/autonomous-dev.md`) are updated in this diff. ## Checkpoint <!-- flybot:checkpoint --> ```json { "pr": 1229, "claim": "Dev2", "issue": 1228, "summary": "Both header lines could name the shared write identity, which is the one value the two policy vocabularies both contain, so a body could name no Bot and still pass the queue entry. The guard now refuses it on either line before either vocabulary is consulted, with one sentence per line that echoes the missing-line refusal. Nothing else about the lines changes: real Bots, independent logins and the owner/reviewer overlap refusal behave as before. The checkpoint gains no field, because an Owner line that names nobody is refused already. The policy file is untouched.", "files": [".github/scripts/enqueue-rules.mjs", ".github/scripts/enqueue-guard.test.mjs", ".github/scripts/merge-gate.test.mjs", "AGENTS.md", "docs/autonomous-dev.md"], "checks": { "ran": "node --test .github/scripts/enqueue-guard.test.mjs .github/scripts/merge-gate.test.mjs (129/129); node --test '.github/scripts/*.test.mjs' (493 pass, 0 fail); mojibake; changelog", "result": "green", "at": "eeb31767ff918d5085587c1e767c3b37f0be59d8" }, "next": "none — ready for review" } ``` <!-- /flybot:checkpoint -->
1 parent bc86fca commit d04712b

5 files changed

Lines changed: 85 additions & 9 deletions

File tree

‎.github/scripts/enqueue-guard.test.mjs‎

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -425,8 +425,10 @@ test('#943, reworded by #947: a Reviewer: outside both vocabularies is refused a
425425
assert.ok(r.reasons.includes('Reviewer names "someone-else", who is not a writers Bot name'), r.reasons.join('; '))
426426
})
427427

428+
// `Birfy` used to stand here as the second spelling; since #1228 it is the one value neither line may name,
429+
// so the @ and the case are shown on the listed login itself.
428430
test('#943: an @ and any case are fine on a login `reviewers` lists', () => {
429-
const r = run({ body: 'Owner: Dev\nReviewer: @Reviewer-Agent, BIRFY\nFixes #943\n', comments: [comment()] })
431+
const r = run({ body: 'Owner: Dev\nReviewer: @Reviewer-Agent, REVIEWER-AGENT\nFixes #943\n', comments: [comment()] })
430432
assert.equal(r.ok, true, r.reasons.join('; '))
431433
})
432434

@@ -440,7 +442,9 @@ test('#947: a Reviewer: naming a writers Bot name is accepted', () => {
440442
})
441443

442444
test('#947: either vocabulary is accepted, with an @ and in any case', () => {
443-
for (const line of ['Reviewer: @Reviewer2', 'Reviewer: reviewer', 'Reviewer: Birfy', 'Reviewer: Reviewer2, Birfy', 'Reviewer: reviewer-agent, @REVIEWER2']) {
445+
// `Reviewer: Birfy` and `Reviewer: Reviewer2, Birfy` sat in this list until #1228 made the shared identity
446+
// a refusal of its own; the cases that keep them out are the `#1228:` ones below.
447+
for (const line of ['Reviewer: @Reviewer2', 'Reviewer: reviewer', 'Reviewer: reviewer-agent, @REVIEWER2']) {
444448
const r = run({ body: `Owner: Dev\n${line}\nFixes #947\n`, comments: [comment()] })
445449
assert.equal(r.ok, true, `${line}: ${r.reasons.join('; ')}`)
446450
}
@@ -571,8 +575,10 @@ test('#951: overlap in either direction, in a list, or through @ and case is ref
571575
assert.equal(ok.ok, true, ok.reasons.join('; '))
572576
})
573577

574-
// `Birfy` is both a `reviewers` login and the one identity every Bot writes as, so it is the sharpest
575-
// fixture: each line alone is perfectly valid, and only the comparison can refuse it.
578+
// `Birfy` is both a `reviewers` login and the one identity every Bot writes as. Until #1228 that made it the
579+
// sharpest fixture for this comparison — each line alone looked valid and only the comparison could refuse
580+
// it; the value is now refused on its own too (:the `#1228:` cases below), so this one pins that the overlap
581+
// refusal is still there beside it, unchanged.
576582
test('#951: a name that is both a login and a writers name is refused on both lines', () => {
577583
const r = run({ body: 'Owner: Birfy\nReviewer: Birfy\nFixes #951\n', comments: [comment()] })
578584
assert.equal(r.ok, false)
@@ -664,6 +670,63 @@ test('#962: Docs is registered in writers; Owner: Docs passes and an unknown own
664670
})
665671

666672

673+
// #1228: the two lines name a workstation, but `reviewers` (logins) and `writers` (Bot names) overlap on
674+
// exactly one value — `Birfy`, the shared identity every Bot writes as. Each line alone looked valid, so a
675+
// body could name no Bot at all and still enter the queue; #1214 merged with `Reviewer: Birfy`. The value is
676+
// refused on either line, with the same normalisation (`@`, case) the two vocabularies already use.
677+
const SHARED_OWNER = 'Owner names "birfy", who is the shared write identity every Bot writes as — name the Bot that claims this pull request'
678+
const SHARED_REVIEWER = 'Reviewer names "birfy", who is the shared write identity every Bot writes as — name the Bot that reviews'
679+
const sharedRefusals = (r) => r.reasons.filter((reason) => /is the shared write identity/.test(reason))
680+
681+
test('#1228: Reviewer: Birfy names no reviewer, and the refusal names the line and the value', () => {
682+
const r = run({ body: 'Owner: Dev3\nReviewer: Birfy\nFixes #1228\n', comments: [comment()] })
683+
assert.equal(r.ok, false)
684+
assert.deepEqual(sharedRefusals(r), [SHARED_REVIEWER])
685+
})
686+
687+
test('#1228: Owner: Birfy claims no owner, and the refusal names the line and the value', () => {
688+
const r = run({ body: 'Owner: Birfy\nReviewer: Reviewer\nFixes #1228\n', comments: [comment()] })
689+
assert.equal(r.ok, false)
690+
assert.deepEqual(sharedRefusals(r), [SHARED_OWNER])
691+
})
692+
693+
test('#1228: two real Bots on the two lines are still accepted', () => {
694+
for (const body of ['Owner: Dev3\nReviewer: Reviewer\nFixes #1228\n', 'Owner: Dev3\nReviewer: reviewer-agent\nFixes #1228\n']) {
695+
const r = run({ body, comments: [comment()] })
696+
assert.equal(r.ok, true, `${body}: ${r.reasons.join('; ')}`)
697+
assert.deepEqual(sharedRefusals(r), [], `${body}: the rule is about one value, not about the lines`)
698+
}
699+
})
700+
701+
test('#1228: @ and case are normalised first, so the shared identity is refused once, not twice', () => {
702+
const r = run({ body: 'Owner: Dev3\nReviewer: @BIRFY\nFixes #1228\n', comments: [comment()] })
703+
assert.equal(r.ok, false)
704+
assert.deepEqual(sharedRefusals(r), [SHARED_REVIEWER])
705+
})
706+
707+
test('#1228: one unusable name in a list is enough', () => {
708+
const r = run({ body: 'Owner: Dev3\nReviewer: Birfy, Reviewer\nFixes #1228\n', comments: [comment()] })
709+
assert.equal(r.ok, false)
710+
assert.deepEqual(sharedRefusals(r), [SHARED_REVIEWER])
711+
})
712+
713+
test('#1228: the shared identity on both lines is refused on both, and the overlap refusal is unchanged', () => {
714+
const r = run({ body: 'Owner: Birfy\nReviewer: Birfy\nFixes #1228\n', comments: [comment()] })
715+
assert.equal(r.ok, false)
716+
assert.deepEqual(sharedRefusals(r), [SHARED_OWNER, SHARED_REVIEWER])
717+
assert.deepEqual(selfReviewRefusals(r), ['Reviewer names "birfy", who is also the Owner of this pull request'])
718+
})
719+
720+
// The checkpoint compares `claim` with the Owner names, so it cannot become the way in: an Owner line that
721+
// names nobody is already refused, and nothing new is added to the block.
722+
test('#1228: the checkpoint cannot become the way in — claim "Birfy" under Owner: Birfy is refused', () => {
723+
const body = `Owner: Birfy\nReviewer: Reviewer\nFixes #1228\n\n${checkpoint({ claim: 'Birfy', issue: 1228 })}`
724+
const r = run({ raw: true, body, comments: [comment()] })
725+
assert.equal(r.ok, false)
726+
assert.deepEqual(sharedRefusals(r), [SHARED_OWNER])
727+
assert.equal(r.reasons.some((reason) => /checkpoint field "claim"/.test(reason)), false, 'no new field rule: the Owner line is what refuses it')
728+
})
729+
667730
// ---- #1112: the guard reported a PASS that had not been posted yet, and a re-push could go green again.
668731
// The three cases below are one per failure mode the issue names; each is red on the code before it.
669732

‎.github/scripts/enqueue-rules.mjs‎

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,12 @@ const labelNames = (labels) => (labels ?? []).map((l) => String(typeof l === 'st
5656
// A Bot name can only through the one identity every Bot writes as, `Birfy`, so it names a reviewer only
5757
// while that account is in `reviewers`; with it gone the name is vocabulary and the line is refused.
5858
const SHARED_WRITE_LOGIN = 'Birfy'
59+
// #1228: that same account is the one value both vocabularies contain, so both lines accepted it: a body
60+
// could name `Birfy` on `Owner:` (a `writers` name) or on `Reviewer:` (a `reviewers` login) and the queue
61+
// entry read it as a workstation. #1214 merged with `Reviewer: Birfy` for exactly that reason. Neither line
62+
// is about the account; each asks which Bot claims, and which Bot reviews, so the shared identity is refused
63+
// before either vocabulary is consulted — otherwise "listed" would keep standing in for "named a Bot".
64+
const SHARED_WRITE_NAME = SHARED_WRITE_LOGIN.toLowerCase()
5965
// One line's names: split on commas, strip a leading `@`, compare case-insensitively. Both header lines
6066
// read their values this way so `@Dev`, `dev` and `DEV` are one name wherever they appear (#951).
6167
const headerNames = (line, pattern) => line.replace(pattern, '').split(',').map((value) => value.trim().replace(/^@/, '').toLowerCase()).filter(Boolean)
@@ -73,7 +79,8 @@ function headerReasons(body, policy) {
7379
const ownerNames = owner ? [...new Set(headerNames(owner, OWNER_LINE))] : []
7480
if (!ownerNames.length) reasons.push('missing line: Owner:')
7581
for (const name of ownerNames) {
76-
if (!bots.has(name)) reasons.push(`Owner names "${name}", who is not a writers Bot name`)
82+
if (name === SHARED_WRITE_NAME) reasons.push(`Owner names "${name}", who is the shared write identity every Bot writes as — name the Bot that claims this pull request`)
83+
else if (!bots.has(name)) reasons.push(`Owner names "${name}", who is not a writers Bot name`)
7784
}
7885
const owners = new Set(ownerNames)
7986
const reviewer = lines.find((line) => REVIEWER_LINE.test(line))
@@ -84,6 +91,11 @@ function headerReasons(body, policy) {
8491
if (!names.length) reasons.push('missing line: Reviewer:')
8592
let passable = false
8693
for (const name of names) {
94+
// #1228: refused before the vocabularies, because this value is the one both of them list.
95+
if (name === SHARED_WRITE_NAME) {
96+
reasons.push(`Reviewer names "${name}", who is the shared write identity every Bot writes as — name the Bot that reviews`)
97+
continue
98+
}
8799
if (logins.has(name)) {
88100
passable = true
89101
continue

‎.github/scripts/merge-gate.test.mjs‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -409,7 +409,7 @@ const queueReal = (fx, body = fx.comment.body, pullBody) => main({
409409
if (path.includes('/files')) return fx.files.map((filename) => JSON.stringify({ filename })).join('\n')
410410
if (path.includes('/comments')) return JSON.stringify({ user: { login: fx.comment.login }, body, created_at: fx.comment.created_at, author_association: 'OWNER' })
411411
if (path.includes('/reviews')) return ''
412-
if (/\/pulls\/\d+$/.test(path)) return JSON.stringify({ head: { sha: fx.head, repo: { full_name: 'ThinkFlowLab/FlyBot' } }, user: { login: fx.author }, labels: [{ name: 'agent-pr' }], body: pullBody ?? bodyWithCheckpoint(['Owner: Reviewer', 'Reviewer: Birfy', 'Fixes #' + fx.pr, ''].join('\n'), fx.files, fx.pr) })
412+
if (/\/pulls\/\d+$/.test(path)) return JSON.stringify({ head: { sha: fx.head, repo: { full_name: 'ThinkFlowLab/FlyBot' } }, user: { login: fx.author }, labels: [{ name: 'agent-pr' }], body: pullBody ?? bodyWithCheckpoint(['Owner: Reviewer', 'Reviewer: Reviewer2', 'Fixes #' + fx.pr, ''].join('\n'), fx.files, fx.pr) })
413413
throw new Error('fake gh: unexpected ' + path)
414414
},
415415
env: { GITHUB_STEP_SUMMARY: '', REVIEW_POLICY: REAL_POLICY, GH_REPO: 'ThinkFlowLab/FlyBot', HEAD_REF: `refs/heads/gh-readonly-queue/main/pr-${fx.pr}-c74e1207${'0'.repeat(32)}`, MODE: 'queue' },
@@ -454,7 +454,7 @@ test('#660-#663: a PASS from another GitHub login still passes; the author\'s ow
454454
test('#644: #663 passes once the author comment attests a role that is not the Owner', () => {
455455
const fx = REAL.find((f) => f.pr === 663)
456456
const body = fx.comment.body.replace('Written-by: Reviewer\n', `Written-by: Reviewer\nAttestation: ${fx.head} role=Reviewer\n`)
457-
const result = queueReal(fx, body, bodyWithCheckpoint(`Owner: Dev\nReviewer: Birfy\nFixes #${fx.pr}\n`, fx.files, fx.pr))
457+
const result = queueReal(fx, body, bodyWithCheckpoint(`Owner: Dev\nReviewer: Reviewer2\nFixes #${fx.pr}\n`, fx.files, fx.pr))
458458
assert.equal(result.ok, true, result.reasons.join('; '))
459459
assert.equal(result.verdict.by, 'Birfy')
460460
})

‎AGENTS.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ What this buys is presence and a name from a closed list, not truth: with one sh
175175
- **An author-login comment is not a review (#644, option B, first slice).** When the conclusion's GitHub login is the pull request author, `selfReview` is not enough. The header — the lines before the first blank, and within the first eight non-empty lines — carries `Attestation: <40-character sha> role=<Bot>`. The sha is the whole tip (#1112). `role` is the `Written-by` name. That role is not an `Owner:` of the pull request. A free-form comment, a `Verdict:` with no attestation line, a line after the blank, a short sha, a role that is not the `Written-by` name, or a role that is the Owner refuses. A conclusion from a different GitHub login is unchanged. `decide` in [`review-gate.mjs`](.github/scripts/review-gate.mjs) is the check, and the reviewed-tip job, `enqueue-guard` and `merge-gate` all call it. The line is parsed, not signed. A signature was carved out later (#644) and is shipped but not enabled (#1110, #1111): the `signers` roster is empty, so no conclusion is signed today. T0/T1 still need no PASS.
176176
- **`hold` is human-only.** Only a person sets the label and only a person lifts it; automation refuses a held pull request and never removes the label itself. That is the one human input left on the merge path.
177177
- **The label awaiting-approval is retired.** A PASS at the tip goes straight to the queue through the enqueue guard (#687); no pull request waits for an approval click and the label is not set again.
178-
- **A pull request enters the queue with its issue and its `agent-pr` label.** On a branch this repository owns, the enqueue guard also refuses one whose body names no issue (`Fixes #<n>` / `Closes #<n>`) and one without the `agent-pr` label (#718), so "one issue, one pull request" is checked rather than remembered. It refuses a **live, non-draft** request whose body carries no `Owner:` line or no `Reviewer:` line in its first 20 lines, or a `Reviewer:` that is neither a `reviewers` login nor a `writers` Bot name, or that names nobody who can record a PASS — a Bot name counts only through the one identity every Bot writes as, `Birfy`, so it names nobody while that account is outside `reviewers` (#947); a `Reviewer:` that names the same Bot as the `Owner:` is refused too, compared name by name after commas, a leading `@` and case are normalised, because one Bot owning and reviewing the same pull request is the shape #940 had with no lines at all (#951); both lines also need a value the policy knows — a `Reviewer:` outside both vocabularies and an `Owner:` outside `writers` are refused, and a line with no value is the missing line, so an empty `Owner:` cannot clear the comparison (#951); the refusal names the missing line, or the word and the list it missed (#943, #947, #951; #940 merged in about seven minutes with the largest diff of the day and neither line). A **merged** pull request is never re-judged: this check also runs on every comment, and history is not a request. Two exemptions: a mechanical release PR, which carries neither by design, and a **fork** (`pull.head.repo.full_name` is not this repository), whose head we cannot push to and where the contributor keeps their own workflow.
178+
- **A pull request enters the queue with its issue and its `agent-pr` label.** On a branch this repository owns, the enqueue guard also refuses one whose body names no issue (`Fixes #<n>` / `Closes #<n>`) and one without the `agent-pr` label (#718), so "one issue, one pull request" is checked rather than remembered. It refuses a **live, non-draft** request whose body carries no `Owner:` line or no `Reviewer:` line in its first 20 lines, or a `Reviewer:` that is neither a `reviewers` login nor a `writers` Bot name, or that names nobody who can record a PASS — a Bot name counts only through the one identity every Bot writes as, `Birfy`, so it names nobody while that account is outside `reviewers` (#947); a `Reviewer:` that names the same Bot as the `Owner:` is refused too, compared name by name after commas, a leading `@` and case are normalised, because one Bot owning and reviewing the same pull request is the shape #940 had with no lines at all (#951); both lines also need a value the policy knows — a `Reviewer:` outside both vocabularies and an `Owner:` outside `writers` are refused, and a line with no value is the missing line, so an empty `Owner:` cannot clear the comparison (#951); neither line may name `Birfy`, the one identity every Bot writes as, because it is the single value both vocabularies list and so names no workstation at all (#1228); the refusal names the missing line, or the word and the list it missed (#943, #947, #951; #940 merged in about seven minutes with the largest diff of the day and neither line). A **merged** pull request is never re-judged: this check also runs on every comment, and history is not a request. Two exemptions: a mechanical release PR, which carries neither by design, and a **fork** (`pull.head.repo.full_name` is not this repository), whose head we cannot push to and where the contributor keeps their own workflow.
179179

180180
**`enqueue-guard` writes a check on the head, and the queue's ruleset does not require it** (#812, re-read 2026-10-24). Team pull requests merge upstream in `ThinkFlowLab/FlyBot` through the `main merge queue`, whose ruleset requires three contexts — `Node 24 · DSH 0.2.0-rc.2`, `tests-ok` and `merge-gate`; the paragraph below (#960) is how `merge-gate` reads the body the queue does not read. `enqueue-guard` still runs on that head, and on the queue entry: [`enqueue-guard.yml`](.github/workflows/enqueue-guard.yml) takes its code from `main` and never from the pull request, on every event that can change the answer for a fixed head — a push, a label added or removed, a comment, a review body, or a manual run — and calls [`enqueue-guard.mjs`](.github/scripts/enqueue-guard.mjs)'s own decision, the same function that prints the command, so the check and the command cannot disagree. It refuses a head that carries a `hold`, a pull request with no `agent-pr`, or a body that names no issue, and — **for T2 and T3 only** — a head that has no PASS naming that exact tip or a PASS that lists no `Ran:`; a T0/T1 change and the mechanical release pull request need no PASS at all, because the tier is computed from the diff and never read from a `risk:T*` label (#640). A new commit is a new head and a new check run, and a green check never carries over.
181181

‎docs/autonomous-dev.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,8 @@ PASS is never a queue permit on its own (#602); on a live, non-draft pull reques
294294
Bot name, or a Bot name whose shared `Birfy` identity is not in `reviewers`, #947, or a `Reviewer:` naming the same Bot as
295295
the `Owner:`, compared per name after commas, `@` and case, #951; both lines need a value the policy knows — an `Owner:`
296296
outside `writers` is refused and a line with no value is the missing line, so an empty `Owner:` cannot clear the
297-
comparison, #951), naming the missing line or the word
297+
comparison, #951; and neither line may name the shared `Birfy` identity, the one value both vocabularies list and so the
298+
one value that names no workstation, #1228), naming the missing line or the word
298299
that missed both lists (#943) — a merged pull request is not re-judged, because this check also runs on every comment; the
299300
same call refuses a body whose `## Checkpoint` block is missing or wrong, one field at a time ([AGENTS.md → The checkpoint
300301
block](../AGENTS.md#the-checkpoint-block-a-hand-off-the-next-bot-can-read-1202), skeleton in

0 commit comments

Comments
 (0)