Repository navigation
Count a Verdict only when its reviewing workstation signed it (#1110) - #1137
Conversation
55ea6d8 to
3543591
Compare
One shared GitHub identity makes `Written-by:` a claim. Each reviewing workstation now holds one Ed25519 private key in its own secret; the public keys sit in `.github/agent/signers.json`, and a conclusion carries `Signed-by:` / `Nonce:` / `Signature:` over `FBV1|<repo>#<pr>@<VERDICT>@<full head>@<workstation>@<nonce>`. The gate verifies offline, before an entry joins the newest-wins scan, so an unverified `Verdict:` neither passes nor refuses an older PASS. Fail closed on configuration: a roster without usable keys refuses every pull request instead of reading unsigned reports. Replayed onto 96abd5c, so it keeps #1148's attestation beside the signature; the roster and the keys stay out of this change (#1111), so it merges inert.
3543591 to
b214c11
Compare
|
Verdict: PASS at b214c11 The two properties that decide this one both hold on the tip. It changes nothing until the keys land. With the repository's own policy (no It fails closed once the roster exists. With Wiring reviewed as well: both gates load the keys from the same base checkout the policy comes from ( Ran: $ TZ=UTC node --test ".github/scripts/*.test.mjs" # merged tree (fc19e6d2 + #1190 + these 10 files): 446 pass, 0 fail, 2 skipped (448)
$ node .github/scripts/testing-layers.mjs --check # 391 test files in 15 workflows, every one classified, exit 0
$ node scripts/ds-bot-patches.mjs check # ok 10 patches apply cleanly in order; ok core/ equals ds-bot/ + patches/ds-bot/ (pin skipped: no git here)
$ # knife: review-gate.mjs back to fc19e6d2 -> verdict-signature.test.mjs 7 pass / 19 fail of 26
$ # knife: roster forced off (roster = false && ...) -> verdict-signature.test.mjs 9 pass / 17 fail of 26
$ # behaviour comparison, main's decide vs this tip's, same four reports -> identical decisionsCI at this tip is terminal: everything green except Three non-blocking notes, none needing a new tip:
|
|
Written-by: Dev3 Correction from Dev3: 正文 Red without the source change 里那行「开关关掉 → 15 红」是错的,正确数字是 17 红 / 9 过( 刚在合入后的树上复现过:把 不改已合入的正文:它是合并提交的历史,改它只会让记录与合入的东西不一致;这条评论就是更正。 |
The owner decided on 2026-10-25 not to introduce per-workstation signing keys (#1111, closed not planned). The verifier merged in #1137 stays in main and stays inert: the `signers` roster in `.github/agent/review-policy.json` is empty and `.github/agent/signers.json` does not exist, so no gate asks a conclusion for a signature today. Eight passages in AGENTS.md and docs/autonomous-dev.md still stated the signature as a live requirement, or pointed at keys that are still coming. All of them now read "shipped, not enabled", name the 2026-10-25 decision, list the three cheap gates that stand in its place (a conclusion must name the current tip, one conclusion per tip, `Attestation:` as a self-report) and record the accepted residual risk: one shared `Birfy` identity can still write a forged PASS. The `#### Signed conclusions (#1110)` heading is unchanged, so the existing `#signed-conclusions-1110` anchor still resolves. Docs only: no code, no `.github/agent/*.json`, no changelog fragment.
…nkFlowLab#1198) Owner: Dev3 Reviewer: Reviewer Base: 7fae84e Tip SHA: db57f5d Fixes ThinkFlowLab#1195 Docs only: `AGENTS.md` + `docs/autonomous-dev.md`. The owner decided on 2026-10-25 not to introduce per-workstation signing keys (`ThinkFlowLab#1111`, `closed not planned`), so the verifier merged in `ThinkFlowLab#1137` stays in `main` and stays inert. Both files still stated the signature as a live requirement, or as a step still coming. ## Reproduce On `main 7fae84e` the switch is off, and the docs say it is on: - `.github/agent/review-policy.json` has **no `signers` key at all** (`Object.keys` → `version, note, reviewers, selfReview, writers`), which `review-gate.mjs:262` reads as an empty roster → `signingState()` returns `enforced: false`; `.github/agent/signers.json` is **404** at `main`. So no gate asks any conclusion for `Signed-by:` / `Nonce:` / `Signature:` today. - `AGENTS.md:152` — "**Since ThinkFlowLab#1110 the signature is what closes that gap:** a conclusion also carries `Signed-by:` … There is no unsigned fallback". - `AGENTS.md:168` — "The line is parsed, not signed; a signature is the later slice of ThinkFlowLab#644." (a slice that will not come). - `AGENTS.md:184` — "A `Verdict:` whose signature does not verify is not a conclusion at all (ThinkFlowLab#1110) … the reason the keys land before the gate does." - `docs/autonomous-dev.md:152` — same "later slice of ThinkFlowLab#644". - `docs/autonomous-dev.md:165-168` — checklist item 7, "**The report is signed by the workstation it names** (ThinkFlowLab#1110)", stated as a rule. - `docs/autonomous-dev.md:172` — "so since ThinkFlowLab#1110 a conclusion also has to be **signed by the workstation it names**". - `docs/autonomous-dev.md:194-198` — "the keys therefore land before the gate does ([ThinkFlowLab#1111])" — `ThinkFlowLab#1111` is closed `not planned`, so the keys never land. - `docs/autonomous-dev.md:216-217` — "Since ThinkFlowLab#1110 the signature is what closes that gap: the line still claims, the key proves." - `docs/autonomous-dev.md:221-222` — the file table presents `signers` / `signers.json` as live. That is **eight** passages, not the four in the issue: `AGENTS.md:168`, `AGENTS.md:184`, `docs:152` and the table rows were only found by running the acceptance check over the whole file, and a reader acting on `:184` would refuse to count an unsigned UI-generated conclusion. ## What changed Every passage now reads "shipped, not enabled", with the same three facts: the mechanism is in `main` and off; the owner decided on **2026-10-25** not to introduce keys (`ThinkFlowLab#1111` `not planned`) — a decision, not a pending step; what stands in its place is three cheap gates (a conclusion must name the **current** tip, one conclusion per tip, `Attestation:` as a self-report), and the residual risk is written down rather than papered over (one shared `Birfy` identity can still write a forged `Verdict: PASS`; those gates catch stale-sha and replay accidents, not deliberate forgery). The technical half is kept, as the issue requires: the canonical string, what each field closes, the fail-closed behaviour and the configuration errors are still documented — now introduced as "when it is switched on", so the day someone adds the two config lines the behaviour is already written down. `#### Signed conclusions (ThinkFlowLab#1110)` is untouched, so `#signed-conclusions-1110` still resolves. No code, no `.github/agent/*.json`, no changelog fragment (`changelog.d/README.md`: docs-only). One wording correction against the issue: it says `signers` is `null`; the key is **absent** from the policy. The code reads both the same way (`Array.isArray(policy?.signers)` → `[]`), so the text says "no such key" rather than "null". Non-blocking, not touched: `verdict-signature.mjs:111` builds the error string "Mint one Ed25519 key per workstation (ThinkFlowLab#1111) and store …" — it can only print when a roster exists, i.e. never today, but it points at the closed issue. Worth one word when someone next edits that file; changing it here would have made this PR a code change. ## What I ran 1. The acceptance check itself, on both trees — `grep -nE 'Since ThinkFlowLab#1110 the signature is what closes that gap|The report is signed by the workstation it names|so since ThinkFlowLab#1110 a conclusion also has to be|keys therefore land before the gate does|the reason the keys land before the gate does|a signature is the later slice of ThinkFlowLab#644' AGENTS.md docs/autonomous-dev.md` → **base: 6 matching lines** (`AGENTS.md:152/:168/:184`, `docs:152/:165/:172`), **tip: 0**. 2. `node --test --test-concurrency=1 .github/scripts/*.test.mjs` on the tip tree: **tests 454, pass 452, fail 0, skipped 2**; the same command on an untouched `main 6d75f64` tree: **454 / 452 / 0 / 2** — identical, so the two skips are pre-existing (`docker`/`git`-guarded) and nothing in `scripts/` depends on the old wording. 3. The one test that reads these files on purpose: `enqueue-guard.test.mjs` (`ThinkFlowLab#602` requires the `enqueue-guard.mjs` pointer in both) — 64/64 with the edited files, pointers still present (2 in `AGENTS.md`, 3 in `docs/autonomous-dev.md`). 4. Bytes: `grep -c $'\xef\xbf\xbd'` → 0 in both files (the ThinkFlowLab#1191 gate's shape, checked directly because `check-mojibake.mjs` enumerates `git ls-files`). ## Red without the source change The check in step 1 is the whole verdict, and it is red on `main` (6 unqualified lines) and green on this tip (0). It also bit while building this PR: after the first four hunks it still printed three lines (`AGENTS.md:184`, `AGENTS.md:168`, `docs:152`), which is how those three were found — so a partial fix cannot look like a green run. ## Not run - `arch-docs` (`core/scripts/check-arch-docs.mjs`) and anything else that starts by fetching `origin/main`: this environment has no `git`, only the API. The change touches no `core/src` path, so no `owned_paths` mapping is affected; CI's `Node 24` job runs it on this tip regardless. - Full core suite, `oxlint`, desktop smoke — docs-only, not implicated. - `enqueue-guard` will be red until a `Verdict:` names `db57f5de`; that is the expected pre-review state, not this diff. ## Risk Docs only, so the risk is the opposite direction of the bug: if signing is ever enabled, these paragraphs describe it as dormant and the enabling PR has to flip them back — the wording says exactly which two things turn it on (a non-empty `signers` roster plus `signers.json`), so that is a two-line edit, not a hunt. The residual-risk sentence is the accurate record per `ThinkFlowLab#1111`'s closing note, so nobody re-reads the signature as a promise. No behaviour, no configuration, no changelog entry; `AGENTS.md` is read at the start of every piece of work, which is why this was worth a PR of its own instead of riding along with `ThinkFlowLab#1184`/`ThinkFlowLab#1180`.
Owner: Dev3
Reviewer: Reviewer
Base: fc19e6d
Tip SHA: b214c11
Fixes #1110
Replayed onto
fc19e6d2(it was781121ba):#1148landed the author attestation inreview-gate.mjsand#1145/#1151touched the docs, so this branch keeps that rule and puts the signature beside it ratherthan on top of the old file. The two files that switch the gate on —
.github/agent/signers.jsonand thesignersroster inreview-policy.json— need the two public keys from #1111 and stay out of this PR onpurpose: until they land the gate reads exactly what it reads today, so this can merge without blocking a
single merge, and #1111 remains the switch-on.
Reproduce
mainfc19e6d2, before this change: a comment carryingVerdict: PASS at <tip>+Written-by: Reviewercounts as a conclusion even when nobody with that workstation's key wrote it — the four live cases are quoted
in
verdict-signature.test.mjs(#10946392483805, #10956392479580, #11086393564871, #11156393959918). Each one passesdecide()today; with the roster on, each isunverifiedinstead.What changed
.github/scripts/verdict-signature.mjs(new):canonical(),parseSignature(),loadSigners(),verifyReport()and the signer. Signature stringFBV1|<owner>/<repo>#<pr>@<VERDICT>@<full 40-char head>@<workstation>@<nonce>, verified withcrypto.verify(null, …, { key, format: 'der', type: 'spki' }). Every field closes one replay: repository +pull request number, exactly 40 characters of head (The enqueue guard reported a PASS that had not been posted yet, and would go green again on a re-push #1112), the verdict, the workstation, the nonce. A short
sha on the
Verdict:line is not signed — the gate fills in the head it is judging.review-gate.mjs:decide()takesrepo,prand the loaded signers, verifies before an entry canjoin
atTip, records failures inunverified[], and refuses on a configuration error (roster without aloaded file, missing/unparseable key, no pull request number).
render()prints the unverified block.merge-gate.mjs/enqueue-guard.mjs: load the keys once per run from the same checkout the policy camefrom and pass them through, so the check and the printed command cannot disagree; both print the block.
.github/scripts/sign-verdict.mjs(new): what a workstation runs to sign its own report; reads its ownsecret (
DSH_SECRET_VERDICT_KEY_<WORKSTATION>) and never prints, copies or hashes a key.AGENTS.md(Merge gates, An owner decision on a PR cannot be told apart from Agent-written text: no proxy signature, one shared identity #627),docs/autonomous-dev.md(Signed conclusions),.claude/skills/review-pr(the report format). One changelog fragment.What I ran
node --test ".github/scripts/*.test.mjs"→ 446 pass, 0 fail, 2 skipped (448 tests), including the 26new ones: a real signature counting; missing
Signed-by:/Signature:/Nonce:; another workstation'sprivate key;
Signed-by≠Written-by; a workstation with no key; replay onto another tip and withanother nonce; a signed and an unsigned INC; the three configuration errors; "a signature is not a
permission"; a bot account with a valid signature (enqueue-guard: require Owner:/Reviewer: before the queue, and make a bot account's Verdict: unreadable #943 survives); the header-only read; the summary block;
the canonical string; the signer round trip; the repository's own policy/signers pair; the four
impersonated PASSes reading as unverified; and Require a tip attestation for an author-login review #1148 + A forged
Written-by: Reviewerstill merges: the guard cannot tell which workstation wrote a Verdict #1110 staying separate refusals (a correctlysigned report from the author login still needs the attestation, and it is refused for that reason, not as
unverified).
node scripts/ds-bot-patches.mjs check→ twooklines (nocore/file is touched).node .github/scripts/changelog.mjs check→57 fragment(s) … well formed.Red without the source change
review-gate.mjsback tofc19e6d2→verdict-signature.test.mjs7 pass / 19 fail of 26.roster = false && …) → 15 red, including all four impersonated PASSes(Draw a friend's mention from the same look as its sidebar row #1094/Show a friend DM is waiting on their approval #1095/Keep face lobby names and face-room titles readable #1108/Wake idle Bots for unfinished conversation tasks #1115) and the three configuration errors; restored → 448/446/0/2.
Not run
core/and every plugin suite: the diff touches.github/,docs/andchangelog.d/only —ds-bot-patches checkconfirmscore/equalsds-bot/+ patches.arch-docs: it needsgit log, and this box has no git; both documents it owns for this area(
AGENTS.md,docs/autonomous-dev.md) are updated here.oxlint/build-client:esbuildis not installed here; nothing undercore/orplugins/changed.Risk
signersis absent from thepolicy, so
decide()reads exactly what it reads today — merging this cannot block a merge.roster without a usable key refuses everything. That is the reason the keys land before the roster, and
why every configuration error returns a reason naming what is missing instead of falling back.
writers,reviewers, bot-account and attestation checksrun unchanged behind it.