Skip to content

ci: add a post-merge silent-revert canary - #2808

Merged
kyle-sexton merged 3 commits into
mainfrom
fix/silent-revert-canary
Aug 16, 2026
Merged

ci: add a post-merge silent-revert canary#2808
kyle-sexton merged 3 commits into
mainfrom
fix/silent-revert-canary

Conversation

@kyle-sexton

@kyle-sexton kyle-sexton commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

No linked issue

Summary

A post-merge canary for the failure class in #2691: a merge that silently deletes content another recently-merged commit had just added, leaving no Revert: marker and no failing test. Item 4 of that issue. Detection only — it runs on push to main, is not in ci.yml, and is not wired into ci-status, so it can never gate a merge.

It also corrects the issue's premise. This was not a stale-base failure, which means strict_required_status_checks_policy would not have prevented it. Details below.

Fix

scripts/check-silent-revert.sh blames the lines each merge deleted against its own parent and reports when a large block traces to a single commit inside a recency window. Plus scripts/check-silent-revert.test.sh (26 hermetic cases), two data files, and .github/workflows/silent-revert-canary.yml.

Why blame-of-deleted-lines, and not the alternatives

Three designs were measured against the real history before one was chosen.

Merge-base staleness — tested and rejected on evidence. It exonerates all three real incidents:

$ git merge-base --is-ancestor f603880d refs/pull/2641/head && echo YES
YES
$ git grep -c "read-only supporting allowlist" refs/pull/2641/head -- 'plugins/disk-hygiene/**'
(no output — zero occurrences)
$ git grep -c "read-only supporting allowlist" f603880d -- 'plugins/disk-hygiene/**'
f603880d:plugins/disk-hygiene/skills/clean/SKILL.md:1
f603880d:plugins/disk-hygiene/skills/clean/scripts/destructive_guard.py:1

PR #2641's head had #2639 in its ancestry and zero occurrences of #2639's content in its tree. #2639 shows the identical shape against #2635. These branches were up to date with main in history and stale only in content — a bad conflict resolution or a force-push from an older worktree.

So strict would have passed all three merges, and a merge queue would have too (CI was green — the tests were deleted alongside the code). That reframes the canary: for this class it is not defence in depth behind a real fix, it is the only control that fires at all. No ruleset is touched here and nothing in github-iac changes; ADR 0001 stands as written.

This also matters for #2799, which merged while this was in flight. That PR landed scripts/check-stale-base-overlap.sh as a required PR gate (it is in ci-status's needs), and its header cites these very incidents: "claude-code-plugins#2691: #2635 then #2639 lost inside ten minutes". Its rule is merge-base(HEAD, base) == tip(base) → fresh, exit 0. I ran the merged script against the actual PR branches:

$ git worktree add --detach <tmp> refs/pull/2641/head
$ git branch -f base2641 eda5ae5ed          # main's tip when #2641 merged
$ bash ./check-stale-base-overlap.sh --check base2641
check-stale-base-overlap: HEAD is up to date with base2641
exit 0

$ git checkout --detach refs/pull/2639/head
$ git branch -f base2639 b7793d3b4          # main's tip when #2639 merged
$ bash ./check-stale-base-overlap.sh --check base2639
check-stale-base-overlap: HEAD is up to date with base2639
exit 0

It exits 0 — "fresh" — on both branches that did the reverting, because both were genuinely up to date with main. That is not a bug in #2799; it correctly detects the stale-base class it targets. But its header claims these incidents as its motivating case, and for those it is a false negative. The two guards are complementary rather than redundant: #2799 prevents a stale base pre-merge, this catches a stale tree post-merge, and only the second fires on what actually happened. Worth correcting #2799's header comment either way.

Curated marker strings (the issue's own suggestion 3) — rejected. Only catches what someone pre-registered, and nobody had registered #2632, #2635 or #2639. Registration happens after you know a fix matters, which is the knowledge the incident destroys.

PR-creation-time overlap — rejected as non-discriminating. At the 17-concurrent-PR rate ADR 0001 records, nearly every PR has siblings landing while it is open.

False-positive strategy

A canary that cries wolf gets disabled, which is worse than none.

  1. Volume, aggregated per culprit commit — summing across culprits would re-admit ordinary iteration.
  2. Recency window (40 first-parent commits). Stated limitation: content reverted from outside the window is missed by design.
  3. Intent, in constrained forms only — a Revert " subject, a This reverts commit <sha> line, or an explicit Intentional-removal: trailer. Deliberately not a substring search for "revert": a body reading "this does not revert X" would silence a real finding.
  4. Non-blocking — post-merge only, outside ci-status.

Measured, not assumed — and measured by running the shipped script itself over the last 500 first-parent commits of main, not a stand-in:

FIRE    853 cc58cbc53 fix(repo-fleet-hygiene): report bare repos with live working trees (#2633)
FIRE    451 9239f1541 feat(disk-hygiene): verify redundant checkout evidence (#2641)
FIRE    390 6f0a31109 fix(repo-fleet-hygiene): restore GraphQL merge evidence and rollups after #2633
FIRE    346 f603880da fix(disk-hygiene): session-honest belt, read-only allowlist, ... (#2639)
FIRE    340 91e77fc16 fix(hook-utils): stop a NUL in a payload value from voiding two blocking guards
MEASUREMENT COMPLETE over 500 commits

5 fires in 500 merges — 1%, zero errors. Three are the confirmed incidents. The other two are real and are not detector bugs: #2640 (390) is the manual restore of #2633's revert, and #2135 (340) is a deliberate merge reconciliation the author argues at length in the PR body. Both are pre-recorded in the acknowledgment file so main starts green.

Rename detection is deliberately left ON. An earlier revision passed --no-renames, which silently made the shipped detector a different detector from the calibrated one: without detection a git mv decomposes into delete + add, the delete side reaches the --diff-filter=MD enumeration as a whole-file removal, and relocating a large file a recent commit had added would fire. In a repo that restructures skills and docs this often, that is a live false-positive class. With detection on, the shipped script reproduces the calibration corpus exactly (the five rows above), and a test pins the rename case. The narrow cost, stated rather than hidden: content gutted in the same commit that renames its file is not attributed.

The uncomfortable part, stated plainly: 340 is the largest false positive and 346 is the smallest true one. No threshold separates them. Picking a number inside that 2% gap would be overfitting, so the threshold is 200 — which costs nothing (200 and 300 fire on the identical five commits) and leaves headroom for a smaller future revert. Precision is traded for recall because the miss is expensive and the fire is cheap.

Cheap requires a disposition path in both directions in time, so there are two: the prospective Intentional-removal: trailer (one line in the PR body, which GitHub carries into the squash message), and scripts/silent-revert-acknowledged.txt for a fire that can only be judged after the fact — a commit message cannot be amended post-merge. Without the second, one legitimate fire leaves the canary permanently red, and a permanently red canary is one on its way to being deleted. It matches changelog-parity-baseline.txt in shape, matches full 40-char SHAs only, and requires a recorded reason, so it is an audit trail rather than a mute button.

A third incident, previously unfiled

Calibration surfaced one #2691 never identified: #2633's squash dropped #2632's finding rollups (853 lines), which #2640 restored by hand the same night. Nobody filed it. That is the clearest argument for automating the detection.

Verification

Real output, all from this branch.

Catches the actual incident (requirement 1) — replayed against the real merges, and pinned in scripts/silent-revert-incidents.txt so CI re-proves it on every run:

$ scripts/check-silent-revert.sh --verify-known-incidents
ok   f603880da fires as recorded  (#2639 dropped #2635 (346 lines), 13 minutes later)
ok   9239f1541 fires as recorded  (#2641 dropped #2639 (451 lines), 10 minutes later)
ok   cc58cbc53 fires as recorded  (#2633 dropped #2632's rollups (853 lines) -- unfiled until now)
ok   c8470efd0 stays clean as recorded  (docs(conventions) rewrote 129 lines of a doc #2679 had just added)

Canary reproduces every recorded incident at the shipped settings.

Range mode over the incident window — both fire, the interleaved unrelated merges stay clean:

$ scripts/check-silent-revert.sh a95f240f~1..9239f154
ok a95f240f7 feat(disk-hygiene): prioritize tidiness over reclaimable bytes in reports (#2635)
ok b7793d3b4 fix(repo-fleet-hygiene): degrade non-repo paths under --root (#2630)
SILENT REVERT SUSPECTED
  removed by   f603880da  fix(disk-hygiene): session-honest belt, ... (#2639)
  content from a95f240f7  feat(disk-hygiene): prioritize tidiness over ... (#2635)
  lines lost   346  (threshold 200, window 40 commits)
ok eda5ae5ed feat(repo-fleet-hygiene): gather merge evidence via aliased GraphQL (#2642)
SILENT REVERT SUSPECTED
  removed by   9239f1541  feat(disk-hygiene): verify redundant checkout evidence (#2641)
  content from f603880da  fix(disk-hygiene): session-honest belt, ... (#2639)
  lines lost   451  (threshold 200, window 40 commits)

Actionable when it fires (requirement 4) — it names what disappeared, which commit removed it, which commit added it, the per-file split, and the sample quotes back the very content #2691 reported as lost:

  by file:
     235  plugins/disk-hygiene/skills/clean/scripts/destructive_guard.py
     156  plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py
      26  plugins/disk-hygiene/skills/clean/SKILL.md
      ...
  sample of the removed content:
      | - The skill-frontmatter guard is a fail-closed allowlist. It permits canonical bundled scan/preview
      | calls made from literal shell words, a small read-only supporting set for cleanup inspection

Tests (requirement 5) — scripts/check-silent-revert.test.sh, hermetic synthetic repos, following the scripts/*.test.sh + test-git-helpers.sh convention. 27 cases: attribution, per-culprit aggregation, the recency window, the pure-rename negative, every intent form, the "prose mentioning revert must still fire" negative, the empty-trailer negative, abbreviated-SHA rejection, whole-file deletion, the shipped default in both directions, and fail-closed exit 2 on an unresolvable range, a malformed range, and an unreachable pinned commit.

$ bash scripts/check-silent-revert.test.sh
check-silent-revert.test.sh: 27 passed, 0 failed

Verified in real CI on this PR, not just locally. The lane runs on pull_request (scoped by paths to the canary's own files) so the detector is exercised before it lands — the scan step is gated off for PR events, so nothing on a PR inspects that PR. From the actual run log (job, 12s):

Test the silent-revert detector    check-silent-revert.test.sh: 27 passed, 0 failed
Replay the recorded incidents      ok   f603880da fires as recorded  (#2639 dropped #2635 (346 lines), 13 minutes later)
Replay the recorded incidents      ok   9239f1541 fires as recorded  (#2641 dropped #2639 (451 lines), 10 minutes later)
Replay the recorded incidents      ok   cc58cbc53 fires as recorded  (#2633 dropped #2632's rollups (853 lines) -- unfiled until now)
Replay the recorded incidents      ok   c8470efd0 stays clean as recorded  (docs(conventions) rewrote 129 lines ...)
Replay the recorded incidents      Canary reproduces every recorded incident at the shipped settings.

That run matters for a specific reason. The blame-header pattern originally used an ERE interval ({40}), and interval support is an awk-implementation variable — the runner's default awk is mawk, development machines run gawk. Had it not matched, attribution would emit nothing and every commit would report ok: a false green, the exact failure class this canary exists to remove. It is now interval-free and proven against the runner's own awk above.

Cannot block a merge — verified, not just asserted. The required contexts on main are:

$ gh api repos/melodic-software/claude-code-plugins/rules/branches/main \
    --jq '.[] | select(.type=="required_status_checks") | .parameters.required_status_checks[].context'
pr-title / pr-title
pr-issue-linkage / pr-issue-linkage
do-not-merge / do-not-merge
ci-status

Silent-revert canary is not among them, and its only appearance in ci.yml is the workflow_schema filename list — never ci-status's needs. A non-required check cannot gate a merge.

Repo gates, all run locally on this branch: shellcheck --rcfile=.shellcheckrc -x (exit 0, and this repo enables require-double-brackets and add-default-case), check-shell-portability.sh --paths and --all (exit 0), zizmor (no findings), actionlint (exit 0), typos (exit 0), editorconfig-checker (exit 0), check-jsonschema --builtin-schema vendor.github-workflows (ok), check-silent-skips.sh (exit 0). The new workflow is registered in ci.yml's workflow_schema file list; the shell scripts are committed 100755.

Design notes for review

  • No cancelling concurrency group, unlike ci.yml — a cancelled canary run is a silently missed detection.
  • Fail-closed on an unusable push range. github.event.before is all-zeros on a first push or history rewrite; the workflow falls back to the head commit and emits a ::warning:: saying earlier commits were not scanned, rather than reporting a clean scan of nothing. An unresolvable range exits 2, never 0.
  • Self-test runs before every scan, so a broken detector cannot mask a regression behind a green canary — the same never-skip, self-test-first shape the ci.yml gates use.
  • What runs on a PR is the detector's unit tests, never detection. The pull_request trigger is paths-scoped to the canary's own five files, so it is inert on every other PR, and both scan steps carry if: github.event_name != 'pull_request'.
  • Remaining limitations, stated rather than hidden: content reverted from outside the 40-commit recency window is missed by design; content gutted in the same commit that renames its file is not attributed; and no threshold separates a large deliberate rewrite from a silent revert, which is what the acknowledgment file exists to absorb.

Related

Refs #2691 — this is item 4; the issue covers more and stays open.
Refs #2799 — item 2, the merge-time stale-base gate, merged while this was in flight. Complementary, not redundant: verified above to exit 0 on both reverting branches, so its header's claim to cover these incidents needs correcting.
Refs #2713 — the docs-only silent-revert blind spot, same class from the test-coverage side.
Refs melodic-software/github-iac docs/adr/0001-relax-strict-required-status-checks.md — unchanged; the evidence above argues it was never the relevant control for this failure mode.

@cursor

cursor Bot commented Aug 16, 2026

Copy link
Copy Markdown

Bugbot is not enabled for your account, so this pull request was not reviewed.

Enable Bugbot in the Cursor dashboard to get automatic reviews on future PRs.

@claude

claude Bot commented Aug 16, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 1m 29s —— View job


Claude finished the security review

Task: Run /review:security-review for PR #2808 (fix/silent-revert-canarymain, HEAD a90501d).

  • Gather context (PR diff, custom instructions)
  • Invoke /review:security-review and follow its instructions
  • Post findings

Security review

Reviewed the two security-relevant surfaces in this diff: the new .github/workflows/silent-revert-canary.yml workflow and the shell it calls (scripts/check-silent-revert.sh, scripts/check-silent-revert.test.sh). No vulnerabilities found.

Specifically checked and cleared:

  • Trigger surfacesilent-revert-canary.yml: push to main + workflow_dispatch only. No pull_request_target or workflow_run, so untrusted PR content never runs with this workflow's token.
  • PermissionsL61-62: scoped to contents: read, and checkout sets persist-credentials: false (L70-73) — no token available to scripts even if something were compromised downstream.
  • Script-injection via the github context — the workflow correctly threads github.event.before / github.event.after through env: (EVENT_BEFORE / EVENT_AFTER) rather than interpolating them into the run: body (L100-108), and both values are validated with git rev-parse --verify before use. This is exactly the pattern that avoids the classic "expression injection in a run: block" class of finding.
  • Command/argument injection in the shell scripts — filenames from git diff --name-only -z are read with -d ''/read -r (null-safe) and every git invocation that takes a filename uses a -- separator before the path (check-silent-revert.sh attribute_file()), so a maliciously-named file (e.g. one starting with -) can't be interpreted as a flag. No eval, no unsanitized interpolation into a shell command, no network calls (curl/wget) anywhere in the new scripts.
  • Untrusted content handled safely — commit subjects/bodies (declares_removal()) and the acknowledgment file (ack_reason()) are matched with case/grep -E and read -r, never eval'd or sourced.
  • Supply chainactions/checkout is pinned to a full commit SHA with a version comment (already zizmor's lane, but confirmed present and correctly pinned).

This lane is scoped to security only — no code-quality/style/test-coverage commentary is included here.
· Branch: fix/silent-revert-canary

@github-actions

github-actions Bot commented Aug 16, 2026

Copy link
Copy Markdown

Last security-reviewed head: 253aa544552527a4877cca98bb4af3a691d91233. On the next push, the relevance gate compares only the commits since this SHA; delete this comment to force a full re-review.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a90501d38d

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread scripts/check-silent-revert.sh Outdated
@claude

claude Bot commented Aug 16, 2026

Copy link
Copy Markdown

Claude finished @kyle-sexton's task in 1m 24s —— View job


Claude finished the security review

Task: Run /review:security-review for PR #2808 (fix/silent-revert-canarymain, HEAD 253aa54).

  • Gather context (PR diff, custom instructions)
  • Invoke /review:security-review and follow its instructions
  • Post findings

Security review

This head (253aa54) had already been security-reviewed once at a90501d with no findings. Since then, one commit ("ci: keep rename detection on and test the canary pre-merge") landed, touching 3 files. I reviewed that delta plus re-confirmed the previously-cleared surfaces still hold. No vulnerabilities found.

New surface in this commit — the pull_request trigger added to silent-revert-canary.yml:

  • It's pull_request, not pull_request_target — runs with the PR's own workflow definition and no secret access, the safe pattern for untrusted branches.
  • Scoped via paths: to only the canary's own files (L58-64), so it's inert on unrelated PRs.
  • permissions: contents: read (L67-68) is unchanged and applies to this trigger too — no token available even to a malicious PR-supplied step.
  • The two steps that run on pull_request (self-test, --verify-known-incidents, L89-98) don't interpolate any github.event.* value into their run: bodies, so there's no script-injection surface there. The range-resolve and scan steps (the ones that do use github.event.before/after via env:) are gated if: github.event_name != 'pull_request' (L108-110, L136-137), so they never run on this trigger at all.
  • This is the same pull_request + contents: read posture already used by ci.yml's own top-level trigger — not a new pattern being introduced into the repo.

scripts/check-silent-revert.sh changes — this commit drops --no-renames from both git diff invocations in attribute_file()/scan_commit() to fix the rename-mishandling correctness bug flagged by Codex's review, and loosens the git blame --line-porcelain header regex from a {40} interval to [0-9a-f]+ [0-9]+ [0-9]+ with an explicit length($1) == 40 check (portability across mawk/gawk, not a security change). Neither touches trust boundaries, external input handling, or credential handling; both are pure detection-logic changes over already-trusted repo content (git blame/git diff output of the checked-out repo itself).

scripts/check-silent-revert.test.sh only gained a new hermetic test case (t_rename_is_not_a_removal) exercising the fix above — no security-relevant content.

.github/workflows/ci.yml's one-line change just adds silent-revert-canary.yml to the existing workflow_schema file list already reviewed at a90501d — no change in behavior.

No CRITICAL, IMPORTANT, or SUGGESTION findings.
· Branch: fix/silent-revert-canary

kyle-sexton and others added 3 commits August 16, 2026 01:13
Detects a merge that silently deleted content another recently-merged
commit had just added, with nothing in its own message saying it meant
to. This is item 4 of #2691.

The canary blames the lines each merge removed against its parent and
reports when a large block traces to a single commit inside a recency
window. Volume, recency, constrained intent markers, and a
non-blocking post-merge-only trigger keep the firing rate at 1% over
the last 500 first-parent commits of main.

Two designs were measured and rejected first. Merge-base staleness
exonerates all three real incidents, because every reverting branch
was up to date with main in HISTORY and stale only in CONTENT --
`git merge-base --is-ancestor f603880 refs/pull/2641/head` is true
while that branch's tree carried zero occurrences of #2639's markers.
Curated marker strings only catch what somebody pre-registered, and
nobody had registered the fixes that were lost.

That evidence also corrects the issue's premise: this was not a
stale-BASE failure, so `strict_required_status_checks_policy` would
have passed all three merges, and so would a merge queue (CI was green
-- the tests were deleted alongside the code). No ruleset is touched
here.

Calibration surfaced a third, previously unfiled incident: #2633's
squash dropped #2632's rollups (853 lines), which #2640 restored by
hand.

Refs #2691

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018S8a1S71VxhLTRWBtMuEvp
Two gaps the earlier commit's own checks could not surface.

Rename detection was ON in the calibration run that produced the
measured false-positive rate, but the shipped attribute_file passed
--no-renames. Those are different detectors: without detection a
`git mv` decomposes into delete + add, the delete side reaches the
--diff-filter=MD enumeration as a whole-file removal, and relocating a
large file a recent commit had added would fire. This repo restructures
skills and docs constantly, so that is a live false-positive class.
Dropping --no-renames restores parity between what ships and what was
measured, and a new test pins it.

The blame-header awk pattern used an ERE interval ({40}). Interval
support is an awk-implementation variable and the runner's default awk
is mawk while development machines run gawk; a pattern that silently
failed to match would make attribution emit nothing and every commit
report `ok` -- a false green, the exact failure class this canary
exists to remove. Rewritten interval-free.

That false-green risk is also why the workflow now runs on
pull_request, scoped by paths to the canary's own files: the self-test
and the incident replay run before the detector lands rather than after.
The scan step is gated off for PR events, so detection stays post-merge
and the lane still cannot gate a merge -- it is not in ci.yml and not in
that workflow's ci-status aggregate.

Refs #2691

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018S8a1S71VxhLTRWBtMuEvp
Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
@cursor
cursor Bot force-pushed the fix/silent-revert-canary branch from 253aa54 to 317f175 Compare August 16, 2026 01:13
@kyle-sexton
kyle-sexton merged commit 7b47d22 into main Aug 16, 2026
45 checks passed
@kyle-sexton
kyle-sexton deleted the fix/silent-revert-canary branch August 16, 2026 01:22
@claude

claude Bot commented Aug 16, 2026

Copy link
Copy Markdown

Claude finished @cursor[bot]'s task in 31s —— View job


Code review

  • Gather context (PR state, diff, REVIEW.md/CLAUDE.md scope)
  • Apply /review:code-review skip gate
  • Post result

This PR is merged (state: MERGED). Per the /review:code-review skip gate, a closed/merged PR is not reviewed — stopping here with no inline findings posted.

For reference, this PR already went through a full inline security review (/review:security-review, no findings on either reviewed head) and a Codex P2 review comment (rename-attribution false positive), which the author acknowledged as addressed in a follow-up commit before merge.
· Branch: fix/silent-revert-canary

@github-actions

Copy link
Copy Markdown

Claude has reviewed this PR 1 time. The lane skips further automatic reviews after 5; deleting this comment resets the count.

kyle-sexton added a commit that referenced this pull request Aug 16, 2026
…trict recommendation (#2830)

## What

Wording-only correction to two defects in how
`scripts/check-stale-base-overlap.sh` (shipped in #2799) describes
itself, and to the same framing where it had propagated.

The gate itself is unchanged and stays required. It correctly detects
the stale-**base** class.

## Defect 1 — false coverage citation

The header cited `claude-code-plugins#2691: #2635 then #2639 lost inside
ten minutes` as its motivating incident. Those incidents are a different
class:

```
git merge-base --is-ancestor f603880 refs/pull/2641/head   # exit 0 — #2639 WAS in the ancestry
git grep -c "read-only supporting allowlist" refs/pull/2641/head   # zero — the tree lacked the work
```

The bases were fresh; the trees were stale. Run against those exact
branches this gate exits 0 ("fresh"). A required check was naming
incidents it does not detect.

The class that covers them is `scripts/check-silent-revert.sh` (#2808).
The two cover **disjoint** classes; neither subsumes the other. Every
touched surface now says that explicitly.

## Defect 2 — a recommendation barred by an accepted ADR

The header called GitHub's `requiredStatusChecks.strict` "the durable
prevention". That is wrong twice over:

- It is barred. `melodic-software/github-iac` →
`docs/adr/0001-relax-strict-required-status-checks.md` is ACCEPTED and
implemented as a non-strict ruleset; its revisit trigger prescribes
per-repo granularity, not a global flip.
- It is false. The measurement above shows strict — and a merge queue —
would have **passed** all three recorded incidents.

Replaced with a neutral statement of scope. Nothing here endorses the
setting, softly or otherwise, and nothing recommends flipping it.
`.github/workflows/silent-revert-canary.yml` and
`scripts/check-silent-revert.sh` already carried the accurate framing
and were deliberately left untouched.

## Files

| File | Change |
| --- | --- |
| `scripts/check-stale-base-overlap.sh` | Header "Why" rewritten with an
explicit SCOPE paragraph; the "Durable prevention" stderr line replaced
with a neutral scope statement |
| `.github/workflows/ci.yml` | Job comment only — no logic, wiring, or
`needs` touched |
| `plugins/source-control/skills/babysit-prs/reference/freshness.md` |
Dropped the "#2635 then #2639 ... inside ten minutes" citation and the
"when the durable `strict` setting is not yet on" framing |
| `plugins/source-control/skills/pull-request/reference/merge.md` |
Dropped "the durable fix is `strict: true`" |
| `plugins/source-control/CHANGELOG.md` | New 0.54.9 entry; 0.54.7's
trailing clause amended because it actively recommended the barred
setting rather than merely recording a release |
| `plugins/source-control/.claude-plugin/plugin.json` | 0.54.8 → 0.54.9
(changelog-parity) |

## Verification

- `bash -n scripts/check-stale-base-overlap.sh` — OK. The header sits
directly above `set -uo pipefail`; parsing is intact.
- `bash scripts/check-stale-base-overlap.test.sh` — **ALL PASS**, exit 0
(all five cases).
- No behavioral change. The only non-comment lines in the diff are
stderr prose inside two `echo` strings.
- `plugins/disk-hygiene/CHANGELOG.md:83` describes the incidents as
"squash-merged from stale bases" — the same wrong premise, but it is
another plugin's release history and makes no claim about this gate's
coverage. Left alone deliberately.

## Related

No linked issue. This corrects wording on surfaces shipped by an
already-merged PR; nothing here closes a GitHub issue.

- #2799 — merged the gate and the incorrect header wording this PR
corrects.
- #2808 — merged `scripts/check-silent-revert.sh`, the detector that
actually covers the disjoint class.
- #2691 — the audit whose incidents were miscited. CLOSED as COMPLETED
and deliberately left closed; referenced for context only, with no
closing keyword.
- `melodic-software/github-iac` →
`docs/adr/0001-relax-strict-required-status-checks.md` — the ACCEPTED
ADR that bars the recommendation removed here. Not touched by this PR.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Aug 16, 2026
…2832)

Closes #2831.

The silent-revert canary merged in #2808 ships an incident corpus that
misattributes one of its three incidents. Since
`--verify-known-incidents`
replays that corpus on every `push: main`, the corpus is the guard's own
honesty proof, and a wrong row costs it the credibility it exists to
earn.

The guard already contradicts its own fixture. Running the shipped
detector on
the incident commit prints:

```
$ scripts/check-silent-revert.sh --commit cc58cbc
  content from bfb66be  feat(repo-fleet-hygiene): add finding rollups and scalable handoff plans (#2644)
  lines lost   853
  content from eda5ae5  feat(repo-fleet-hygiene): gather merge evidence via aliased GraphQL (#2642)
  lines lost   301
```

while the corpus recorded that commit as
`#2633 dropped #2632's rollups (853 lines) -- unfiled until now`.

## What was wrong, and what it is now

**The named victim never merged.** PR #2632 is CLOSED, not merged:
`mergeCommit` null, `mergedAt` null, closed unmerged
2026-08-14T23:19:00Z, head
`2b9391be` not an ancestor of `origin/main`, and zero occurrences of
`rollup`
in its diff.

**There were two victims, and neither was #2632.** Attributing each of
the 1165
lines `cc58cbc53` (#2633) deleted to the commit `git blame` credits it
to:

| culprit | PR | lines |
| --- | --- | ---: |
| `bfb66beb8` | #2644 — finding rollups and handoff plans | 853 |
| `eda5ae5ed` | #2642 — aliased GraphQL merge evidence | 301 |
| 11 other commits, ≤4 lines each | — | 11 |
| **total** | | **1165** |

1165 matches the squash diffstat exactly
(`6 files changed, 549 insertions(+), 1165 deletions(-)`). #2642 was not
mentioned in the corpus at all, despite the detector reporting it as a
separate
finding. Corroboration:
`plugins/repo-fleet-hygiene/skills/audit/scripts/audit-fleet.sh` went
2178
lines / 8 `graphql` / 9 `rollup` at `bfb66beb8` → 1700 / 0 / 0 at
`cc58cbc53` →
2348 with both restored at `6f0a31109` (#2640) → 2933 on current
`origin/main`.

**853 was correct but unqualified.** It is a blame attribution against
one
culprit — not the deleting squash's diffstat total (1165) and not what
#2644
added (942 insertions in its own squash commit, per
`git diff --shortstat d55ffbf bfb66be`). Both 853 and 301 now say
what they
measure, in the fixture and in the script header.

Worth noting for anyone re-measuring: `gh pr view 2644 --json additions`
says
947, not 942, because the PR-level count is three-dot against the merge
base
while the commit diffstat is two-dot against the parent, and main moved
under
the branch in between. #2640 diverges the same way (`+1281/−463` as a
commit,
`1269/451` as a PR). The branch text avoids the trap by citing no
lines-added figure at all — it just says the blame counts are not that.

**"Unfiled until now" was false.** #2656 recorded this merge event,
pinned to
the same commits, roughly 23 hours before #2808 merged — from the
test-coverage
angle rather than as a silent revert. The corpus now cites it and frames
the
canary's contribution as detection speed and automation rather than
discovery.

## Two judgement calls worth reviewing

**The `#2632` string still appears twice**, in a new note recording that
the
row previously named it and why that was wrong. That is a refutation,
not an
attribution; it is there so the error is not silently reintroduced. Say
the
word if you would rather it be dropped entirely.

**`check-silent-revert.sh` said the canary "fires 5 times" over 500
commits.**
Measured per commit: `f603880da` 1 finding, `9239f1541` 1, `cc58cbc53`
2,
`6f0a31109` 1 (pre-acknowledgment, 390 lines), `91e77fc16` 1
(pre-acknowledgment, 340 lines) — 5 commits, 6 findings. The number was
counting commits, so the count is unchanged; the units are now stated so
the
6-vs-5 gap is not read as an error.

## Blast radius

Comments and free-text fixture notes only.

- `diff <(git show HEAD~1:scripts/check-silent-revert.sh | grep -v
'^\s*#') <(grep -v '^\s*#' scripts/check-silent-revert.sh)`
  is empty — no non-comment line of the detector changed.
- No threshold, window, or expectation moved. The fixture parser reads
`expect sha note`; only `note` text changed, and both `expect` and `sha`
are
  untouched.
- `shellcheck`, `bash -n`, `actionlint`, and `typos` all pass.

`scripts/check-silent-revert.sh --verify-known-incidents` after the
change:

```
ok   f603880 fires as recorded  (#2639 dropped #2635 (346 lines), 13 minutes later)
ok   9239f15 fires as recorded  (#2641 dropped #2639 (451 lines), 10 minutes later)
ok   cc58cbc fires as recorded  (#2633 dropped #2644's rollups (853 blamed lines) and #2642's GraphQL merge evidence (301); already recorded in #2656)
ok   c8470ef stays clean as recorded  (docs(conventions) rewrote 129 lines of a doc #2679 had just added)

Canary reproduces every recorded incident at the shipped settings.
```

## Related

- Refs #2808 — the PR that merged the canary and introduced the
misattributed
  corpus row.
- Refs #2691 — the original silent-revert audit the corpus is built
from. Its
two incidents (#2639/#2635 and #2641/#2639) were already recorded
correctly
  and are untouched here.
- Refs #2656 — the pre-existing record of this merge event, now cited by
the
corpus in place of the "unfiled until now" claim. Stays
CLOSED/COMPLETED.
- Refs #2644, #2642, #2633, #2640 — the merges measured above. Refs
#2632,
  which is not one of them: it never merged, which is the whole point.
- Refs #2833 — follow-up raised in review here: the replay checks only
that a
recorded commit still fires, never which culprit or how many lines, so a
row
can keep printing a reproduction it no longer performs. Pre-existing,
and out
  of scope for a change constrained to leave the detector untouched.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_018S8a1S71VxhLTRWBtMuEvp

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Aug 17, 2026
…ributions (#2843)

Closes #2837. Closes #2833.

## Summary

Three defects in the silent-revert canary, plus one unfiled
harness-safety fix
in a file this change already owns. Branched off `2de57a379` (#2832),
which had
already merged.

## Fix

## 1. `declares_removal()` could not read the only revert subject that
merges here (#2837)

The detector accepted three intent forms. This repo is squash-only with
`squash_merge_commit_title: PR_TITLE`, so the squash subject is the PR
title,
and `.github/workflows/pr-title.yml` gates every title through a
required
Conventional-Commits check whose default type list is all-lowercase — it
admits
`revert:` and contains nothing a `Revert "…"` subject could match. So a
deliberate revert reached `main` wearing a subject the detector could
not read.

`declares_removal()` now also accepts the Conventional-Commits revert
type,
anchored at the start of the **subject**, requiring the literal
lowercase token,
its optional `(scope)` and/or `!`, its colon, and a non-empty
description:

```
^revert(\([^()]+\))?!?:[[:space:]]*[^[:space:]]
```

Never a substring search for "revert" — kept exactly as constrained as
the three
forms beside it.

### Before / after on the repo's one real deliberate revert

`1d1fca6e8` — `revert: remove Cursor dual-target marketplace manifests
(#1835) (#1839)`

Before (at `2de57a379`, shipped detector, thresholds unmodified):

```
$ bash scripts/check-silent-revert.sh --commit 1d1fca6

SILENT REVERT SUSPECTED

  removed by   1d1fca6  revert: remove Cursor dual-target marketplace manifests (#1835) (#1839)
               2026-07-30 19:41:47 -0400
  content from b6c4b58  feat: add Cursor dual-target marketplace manifests (#1835)
               2026-07-30 18:17:36 -0400  (1 commit(s) earlier on main)
  lines lost   3361  (threshold 200, window 40 commits)
  [... 102 files, sample block and "What to do" block elided; 130 lines total ...]

EXIT=1
```

After:

```
$ bash scripts/check-silent-revert.sh --commit 1d1fca6
declared 1d1fca6 revert: remove Cursor dual-target marketplace manifests (#1835) (#1839)
         removal is declared: the subject carries the Conventional-Commits revert type
EXIT=0
```

### Suppression is not widened over anything the corpus records

- No recorded incident has a `revert`-prefixed subject — `f603880da`
`fix(disk-hygiene): …`, `9239f1541` `feat(disk-hygiene): …`, `cc58cbc53`
`fix(repo-fleet-hygiene): …`, `c8470efd0` `docs(conventions): …`. All
four rows
  still hold (green run below).
- Neither acknowledgment-file commit is revert-prefixed either —
`6f0a31109`
`fix(repo-fleet-hygiene): …`, `91e77fc16` `fix(hook-utils): …` — so no
ack row
goes dead now that `declares_removal()` short-circuits ahead of
`ack_reason()`.
- The header's "fires on 5 commits — 1%" calibration figure is therefore
  unchanged; none of those five is revert-prefixed.

New tests pin both directions. `revert:`, `revert(scope):`, `revert!:`
and
`revert(scope)!:` suppress; `feat: do not revert the alpha guard (#99)`,
`reverted: drop the alpha guard (#99)`, `Revert: drop the alpha guard
(#99)` and
a bare `revert:` with no description all still fire. The pre-existing
case only
covered a *body* mention of "revert"; the subject is what the new form
reads, so
that is where a substring bug would widen.

## 2. `verify_known_incidents` asserted only "something fired" (#2833)

A `fires` row passed on `scan_commit`'s exit status while the note
beside it
named a specific culprit and a specific line count that nothing checked.
On
`cc58cbc53`, whose deletions trace to two culprits, losing the
`eda5ae5ed`
attribution entirely would still have printed `ok` on the surviving
`bfb66beb8`
finding — the canary announcing a reproduction it did not perform.

A `fires` row may now carry a bracketed attribution expectation after
its sha:

```
fires <sha> [<culprit-full-sha>=<blamed-lines>,<culprit-full-sha>=<blamed-lines>] <note>
```

and the replay asserts the run's findings are **exactly** that set —
same
culprits, same per-culprit counts, no extras, no omissions. Full
40-character
culprit shas only, the same discipline `silent-revert-acknowledged.txt`
uses.

Counts come from a new `FINDINGS_SINK` file that `report_finding`
appends
`<full-culprit-sha> <count>` to, not from scraping the human report —
the report
prints a 9-character abbreviation, which is not enough sha to assert on.
Nothing
else sets `FINDINGS_SINK`, so `--commit` and range mode are
byte-identical.

Every recorded figure was **measured, not transcribed from the notes** —
the
sink was wired first and the observed values recorded:

```
f603880 -> a95f240 346
9239f15 -> f603880 451
cc58cbc -> bfb66be 853
             eda5ae5 298
```

Three of the four agree with the notes #2832 corrected; the fourth is
298 rather
than 301, for the reason in section 5. A malformed field is exit 2
(cannot
run), never a FAIL and never a pass — a silently misread expectation is
the same
false green this file exists to remove. The field is optional so a row
can be
pinned before its attribution is measured, but a new well-formedness
assertion
requires every *shipped* `fires` row to carry one.

## 3. The workflow header stated a reason that was not true (unfiled)

`.github/workflows/silent-revert-canary.yml` asserted *"There is no
`pull_request` trigger, so it can never gate a PR"* — while its own
`on:` block
has a paths-filtered `pull_request` trigger, added by #2808 to run the
detector's
unit tests, and explained at length 20 lines further down in the same
file. The
conclusion holds for a different reason: both scan steps are gated
`if: github.event_name != 'pull_request'`, and the lane sits outside
`ci.yml`
and its `ci-status` aggregate. Wording corrected to the actual reason.

**No trigger and no `if:` changed.** `git diff` on that file is comment
lines
only — one hunk, `@@ -13,6 +13,9 @@`, entirely inside the `#` header.

## 4. The detector's line counts depended on ambient git config
(unfiled, found by #2833's new assertion)

The first CI run of this branch went red, and the failure is the most
valuable
thing in this PR. The runner reported the `eda5ae5ed` attribution as
**298**
lines where my machine measured **301**:

```
FAIL cc58cbc fires, but NOT as recorded
     recorded attribution:   eda5ae5…  301
     what the detector reported:  eda5ae5…  298
```

Cause: `attribute_file` called bare `git diff --unified=0`, so it
inherited
whatever `diff.algorithm` the caller's config carried. I have
`diff.algorithm = histogram` set globally; CI has nothing set and
therefore uses
git's default `myers`. The algorithm changes which lines a hunk calls
deleted,
so it changes the per-culprit counts this canary **thresholds on**.
Reproduced
directly:

```
$ GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=diff.algorithm GIT_CONFIG_VALUE_0=myers \
    FINDINGS_SINK=… scripts/check-silent-revert.sh --commit cc58cbcbfb66be…  853
eda5ae5…  298      # 301 under histogram
```

Three lines is harmless in itself. The principle is not: the same drift
can
carry a count across the 200-line threshold, so a commit could fire on
one
machine and stay silent on another, and the header's "fires on 5 commits
over
500 — 1%" calibration only ever described one algorithm.

`attribute_file` now pins `--diff-algorithm=myers -M` explicitly, and
the file
enumeration pins `-M` too. **Both are git's defaults, so this does not
change
what CI detects today** — CI already had no `diff.algorithm` set. It
makes a
local run match CI, not the reverse. `-M` covers the same exposure for
rename
detection, which the existing header calls "load-bearing rather than
incidental": `diff.renames = false` in a developer's config would
decompose a
`git mv` into delete + add and make relocating a large recent file fire.

The recorded figure is now **298**, and the prose figures in the script
header
and the corpus are corrected with the refutation attached, so nobody
re-measuring on a histogram machine "corrects" it back to 301.

Worth stating plainly: nothing asked for this. #2833's exact-count
assertion
turned a silent, config-dependent divergence into a red build on its
first run —
which is precisely the argument for asserting attributions instead of
exit
status.

## 5. The test harness could commit the developer's work as `test
<t@t.test>` (unfiled)

Found the hard way while developing this. `mk_repo` is called as
`repo="$(mk_repo)"`, so a `return 1` inside the command substitution
cannot abort
the suite — the caller just gets `""`. And `""` is not inert: `git -C
""` is
documented as a no-op, so the next `add -A` + `commit` staged and
committed my
uncommitted work into the checkout, authored `test <t@t.test>`. Such a
commit
cannot be pushed here — it fails `required_signatures` with `no_user`.

`mk_repo` now yields a path derived from `SELF_DIR` that does not exist,
so every
git call against it fails loudly and the assertions go red —
fail-closed, which
is what a harness that cannot build its fixture should do. Scoped to
this file
only; if the same `repo="$(mk_repo)"` shape exists in sibling harnesses
that is a
separate follow-up.

## Verification

`scripts/check-silent-revert.sh --verify-known-incidents`, run on a
machine with
`diff.algorithm = histogram` set globally — it now agrees with CI
exactly,
because the flags are pinned:

```
ok   f603880 fires as recorded, 1 attribution(s) reproduced exactly  (#2639 dropped #2635 (346 blamed lines), 13 minutes later)
ok   9239f15 fires as recorded, 1 attribution(s) reproduced exactly  (#2641 dropped #2639 (451 blamed lines), 10 minutes later)
ok   cc58cbc fires as recorded, 2 attribution(s) reproduced exactly  (#2633 dropped #2644's rollups (853 blamed lines) and #2642's GraphQL merge evidence (298); already recorded in #2656)
ok   c8470ef stays clean as recorded  (docs(conventions) rewrote 129 lines of a doc #2679 had just added)

Canary reproduces every recorded incident at the shipped settings.
```

Injected failure against the **shipped** corpus (`853` changed to `852`
in a
copy) — the mechanism is proven on real rows, not only on synthetic
fixtures:

```
$ SILENT_REVERT_INCIDENTS=<copy with 298 -> 297> scripts/check-silent-revert.sh --verify-known-incidents
ok   f603880 fires as recorded, 1 attribution(s) reproduced exactly  (...)
ok   9239f15 fires as recorded, 1 attribution(s) reproduced exactly  (...)
FAIL cc58cbc fires, but NOT as recorded  (#2633 dropped #2644's rollups (853 blamed lines) and #2642's GraphQL merge evidence (298); already recorded in #2656)
     recorded attribution:
       bfb66be 853
       eda5ae5 297
     what the detector reported:
       bfb66be 853
       eda5ae5 298
     A row that fires for the wrong reason is not a reproduction.
     Do NOT edit the row to match; find out why the attribution moved.
ok   c8470ef stays clean as recorded  (...)

The canary no longer reproduces the incidents it was built for.
Do not relax the recorded expectations to make this pass.
EXIT=1
```

The commit still fires — exit status alone would have passed this row.
Note the
row is red on a **one-line** discrepancy in one of two attributions,
which is
exactly the regression #2833 describes.

`scripts/check-silent-revert.test.sh`: **49 passed, 0 failed**,
including
`replay fails when the finding is attributed to a different culprit`,
`replay fails when the recorded line count no longer reproduces`, and
`replay fails when one of two recorded attributions stops reproducing`.
So the
assertion is proven by permanent tests, not only by a one-off injection.

## 6. Review follow-up: an unterminated attribution field read as
*absent*

Both automated review lanes independently flagged the same real gap, and
they
were right. A `fires` row whose field opened with `[` but never closed
it failed
the `[[ "$rest" == \[*\]* ]]` glob, so `attribution` stayed empty, the
remainder
became free-text `note`, and the row fell back to passing on exit status
alone —
reintroducing the exact pre-#2833 gap by the one route nobody would look
at, and
contradicting the contract documented directly above it.

A leading `[` now COMMITS the row to carrying an attribution;
unterminated takes
the malformed path. Reproduced against the real corpus with the closing
bracket
stripped from the `f603880da` row:

```
check-silent-revert: unterminated attribution field for f603880… (no closing ']'): [a95f240…=346 #2639 dropped #2635 …
EXIT=2
```

The four malformed shapes already pinned all carried a closing `]`, so
this one
was untested; `[<sha>=40` and a bare `[` are now pinned too. The shipped
corpus
was never at risk — `t_shipped_data_files_are_wellformed`'s regex covers
it —
but that is a separate layer and does not hold for a custom
`SILENT_REVERT_INCIDENTS`.

## 7. Verification follow-up: `git blame` was still ambient-config
dependent

Fresh-context verification of section 4 found that fix was only half of
one.
Pinning the diff flags left `attribute_file`'s `git blame` call bare, so
`blame.ignoreRevsFile` — an ordinary setting in any repo carrying a
bulk-reformat commit — still decided the per-culprit counts the replay
now
asserts on. On the real corpus, with that setting naming `bfb66beb8`:

| culprit | pinned | with `blame.ignoreRevsFile` |
| --- | ---: | ---: |
| `bfb66beb8…` | 853 | **259** |
| `eda5ae5ed…` | 298 | **322** |

On a synthetic fixture it is worse than a wrong number. With the pin
absent and
that config present, the detector reports **no finding at all** on a
genuine
silent revert:

| | clean config | hostile config |
| --- | --- | --- |
| pinned | `culprit 40` | `culprit 40` |
| unpinned | `culprit 40` | **(nothing — the canary goes silent)** |

That is a false green reached through the developer's own gitconfig —
the
precise failure this canary exists to remove.

**The obvious fix does not work, and the comment says so.**
`-c blame.ignoreRevsFile=` does *not* clear it: the documented "an empty
file
name resets the list" applies to the **option**, and the `-c` form was
measured
leaving the hostile value fully in effect (853 → 259 with the reset
supposedly
applied). Only `--no-ignore-revs-file` actually resets. The symmetry
with the
`-c` pins above is wrong here and is deliberately not used.

`t_counts_are_immune_to_ambient_git_config` pins the property: the same
fixture
scanned twice, once under a hostile `GIT_CONFIG_GLOBAL` setting
`blame.ignoreRevsFile`, `diff.algorithm` and `diff.renames`, asserting
identical
exit status and identical per-culprit findings. **Confirmed
discriminating** —
with the blame pin stripped it fails, and it fails because the hostile
run
reports nothing at all.

`shellcheck`, `actionlint`, `typos`, `bash -n` and
`scripts/check-shell-portability.sh` all pass.

## Test plan

Run from a clean checkout of this branch, with `unset GIT_DIR
GIT_WORK_TREE`:

1. `bash scripts/check-silent-revert.test.sh` — the detector's own unit
suite.
Expect **49 passed, 0 failed**. Covers all four `revert:` spellings, the
   four negative subject cases (`feat: do not revert ...`, `reverted:`,
`Revert:`, bare `revert:`), the wrong-culprit / wrong-count / missing-
   attribution replay regressions, all six malformed-field shapes, and
   `t_counts_are_immune_to_ambient_git_config`.
2. `bash scripts/check-silent-revert.sh --verify-known-incidents` — the
real
   corpus. Expect exit 0 with all three `fires` rows reporting
`attribution(s) reproduced exactly` and the `clean` row staying clean.
3. Negative control for step 2: edit
`scripts/silent-revert-incidents.txt` to
inject a wrong culprit sha (leaving the count correct, so the commit
still
   fires) and, separately, a wrong line count. Each must exit **1** with
   `fires, but NOT as recorded`. Restore the file afterwards.
4. Set `diff.algorithm = histogram` in global git config and repeat
steps 1-2.
   The numbers must not move — that is what the new flag pins buy.
5. `git diff origin/main...HEAD --
.github/workflows/silent-revert-canary.yml`
   must show comment-only changes; no executable YAML line may differ.

## Related

- Refs #2808 — the PR that merged the canary and its three intent forms.
- Refs #2832 / #2831 — the corpus-attribution correction this branches
off;
  #2833 was raised in its review.
- Refs #2691 — the original silent-revert audit the corpus is built
from.
- Refs #1839 — the deliberate revert (`1d1fca6e8`) used as the
real-history
  fixture for #2837.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Aug 17, 2026
…ts corpus (#2986)

#2917 item 2 named the same unhedged `31h28m` claim in two files. #2930
corrected
`scripts/check-silent-revert.sh` and left
`scripts/silent-revert-incidents.txt`
byte-identical, so since #2930 the two files have actively contradicted
each other
on the one question the whole restoration assertion turns on. This
finishes item 2
and clears the two smaller prose defects the same review pass found.

## 1. The surviving claim in the corpus file

`scripts/silent-revert-incidents.txt` said:

> It says nothing about whether the content that commit deleted is on
main today,
> and that gap has already cost this repo 31h28m: the content #2828 is
about was
> missing from main while the f603880 row below printed `ok ... fires
as
> recorded` and the replay exited 0.

The defect is that "that gap has already cost this repo 31h28m" charges
the whole
absence to a mechanism that existed for only part of it, and it does so
in the same
breath as "the replay exited 0" — so the sentence reads as thirty-one
hours of a
running, silent replay. It was not. The canary did not exist for most of
that
window.

Rewritten to separate the two windows in the same terms the script now
uses, and
to keep the point the passage was carrying — that the replay's silence
was never a
statement about content presence:

> The content #2828 is about was absent from main for 31h28m: f603880
removed it
> and 534eac1 (#2829) put it back.
>
> 31h28m is the CONTENT-ABSENCE window, not a window this replay ran
through in
> silence -- the canary did not exist for most of it. It merged part-way
in, at
> 7b47d22 (#2808), 6h13m before the restore, so the replay covered
only the tail
> of the absence. Over that tail the f603880 row below printed
> `ok ... fires as recorded` and the replay exited 0 while the content
was still
> gone: the row was never wrong about what it asserted, it asserted the
other
> question. Two re-lands (#2714, #2803) each missed it, and a hand audit
caught it,
> not this file.

Both figures are closed historical intervals between two fixed commits,
so neither
can rot — that is why they are stated rather than replaced with a hedge.
Neither is
a running total.

### What the two figures measure

Both are committer-date deltas, re-derived here rather than carried
over:

| interval | endpoints | seconds | figure |
| --- | --- | --- | --- |
| content absence | `f603880da` (1786752447) → `534eac138` (1786865745)
| 113298 | 31h28m18s → **31h28m** |
| replay presence | `7b47d2253` (1786843322) → `534eac138` (1786865745)
| 22423 | 6h13m43s → **6h13m** |

`7b47d2253` (#2808, the commit that merged this canary) is an ancestor
of
`534eac138` and its timestamp falls inside the absence interval, so the
canary
genuinely merged part-way through. The replay therefore covered 22423 of
113298
seconds — under a fifth — and the prose says "only the tail" rather than
quoting
that fraction, matching the script.

## 2. The broken antecedent in `check-silent-revert.sh`

In the `The restoration assertion (#2855)` comment block, one paragraph
establishes
that marker text is matched with `grep -F` and therefore "may contain
any character
-- no separator is reserved inside it". The next sentence a reader needs
is "The
ONE reserved position is a LEADING `[`". #2930 inserted its ~30-line
uniqueness
block ("a marker must occur exactly ONCE in the file it binds to", plus
the
definition/use two-row design and the corpus-review obligation) between
them, and
did so without even a blank comment line before "The ONE reserved
position", so
that sentence ran straight out of a paragraph about counting occurrences
and its
antecedent sat about thirty lines uphill.

Fixed by relocating the uniqueness block below the disposition
paragraphs, which
restores the original adjacency: whitespace/any-character → `[` is the
one reserved
position → the disposition needs a non-empty reason → the disposition
ends at the
first `]`. The uniqueness block now closes the section.

Two connective repairs were needed because the block no longer follows
the sentence
it used to lean on, and both preserve what is asserted:

- `For the same reason a marker must occur exactly ONCE` → `Literal
matching is
also why a marker must occur exactly ONCE`. "The same reason" was the
looseness
  of literal substring matching, which the block's own next sentence
(``grep -qF` answers "is this string anywhere in this file"`)
immediately
  restates, so naming it costs nothing and removes the anaphor.
- `Nothing enforces this:` → `Nothing enforces the once-rule:`, since
"this" now
  has no adjacent referent.

Nothing else in the block changed — the diff is a pure relocation apart
from those
two clauses.

## 3. The distributing parenthetical

Both files claimed that both `f603880da` markers occur in the same three
sibling
sites:

> Both markers on the f603880 row also occur elsewhere in the same
plugin on
> current main -- the engine script, its test file, CHANGELOG.md

That reads as three sites applying to both markers. Re-measured from
scratch on
`origin/main` with `git grep -F` per literal, no pathspec:

**README marker** (bound to `plugins/disk-hygiene/README.md`) — 5 hits:

- `plugins/disk-hygiene/README.md:6` (the bound file)
- `plugins/disk-hygiene/skills/clean/scripts/hygiene.py:2807`
- `plugins/disk-hygiene/skills/clean/scripts/hygiene.py:3601`
- `plugins/disk-hygiene/skills/clean/scripts/test_hygiene.py:566`
- `scripts/silent-revert-incidents.txt:107` (the marker row itself)

No CHANGELOG hit.

**evals marker** (bound to
`plugins/disk-hygiene/skills/clean/evals/evals.json`) —
3 hits:

- `plugins/disk-hygiene/skills/clean/evals/evals.json:138` (the bound
file)
- `plugins/disk-hygiene/CHANGELOG.md:139`
- `scripts/silent-revert-incidents.txt:108` (the marker row itself)

No engine-script or test hit.

So the sites do not distribute: the engine script and its test belong to
the README
marker only, CHANGELOG.md to the evals marker only. The load-bearing
claim survives
untouched — each literal does occur outside the file it binds to, which
is why a
repo-wide grep would report the incident restored on a tree where it is
not — and
the prose now says that in the per-marker form.

Neither rewrite quotes either literal. Doing so would have added an
occurrence of
the very string whose occurrence set the sentence enumerates; the prose
refers to
them by role ("the README marker", "the evals marker"), which is what
the script
already did. Nor does either rewrite assert exhaustiveness, since both
literals also
appear on the corpus page itself — a fact that file already states
separately.

### The provenance sentence had to move with it

The script's old follow-on read:

> Stated in the present tense on purpose: the sibling copies are a
property of main
> as it stands now, not of the tree during the #2828 window.

Measurement contradicts that for the README half, so it could not be
left standing
next to a corrected parenthetical:

- The evals half holds. At `f603880da` the evals literal occurred
nowhere in the
tree, and a `git log -S` walk over the whole `f603880da..534eac1`
range returns
exactly one commit — `534eac138` itself. So
`plugins/disk-hygiene/CHANGELOG.md`
acquired its copy at the restore, and the interval is measured rather
than
interpolated between endpoints: a repo-wide grep for that literal
matched nothing
  at any point during the absence.
- The README half does not. `git log -S` over `f603880da..534eac1`
returns
`94ae28728` (#2803, one of the two re-lands the passage already names as
having
missed the README) and then the restore. `94ae28728` put the string into
`hygiene.py` and `test_hygiene.py` and never into `README.md`, and it
landed
2026-08-15T20:37:17-04:00, which is 44m45s before `7b47d2253` merged
this canary.
Grepping at `7b47d2253` confirms it: three hits, all in the engine
script and its
  test, none in `README.md`.

So for the entire tail of the absence that the replay actually covered,
a repo-wide
grep for the README marker matched on a tree whose bound file did not
have the
content. The passage now says that, which turns the path-scoping
argument from a
hypothetical into the answer this very incident would have produced.

## Scope note

The parenthetical defect is called out in #2981 against
`check-silent-revert.sh`,
but `silent-revert-incidents.txt` carried its own shorter copy of the
same claim.
Fixing only one would have re-created exactly the two-files-disagree
condition this
issue exists to close, so both were corrected — the corpus version kept
shorter, to
that file's register.

### A third site carries the same defect, and is deliberately NOT
touched here

Verification swept `31h28m` across the whole tree rather than only the
two files in
scope, and turned up a site neither #2917 nor #2981 names —
`.github/workflows/silent-revert-canary.yml:154–157`, the workflow that
actually
runs the replay:

> Both are needed, and the gap between them has already cost 31h28m: the
content
> #2828 is about was missing from main while the replay printed `ok` and
exited 0

Same unhedged construction, charging all 31h28m to a period in which
"the replay
printed `ok`". It is pre-existing — `git diff origin/main` on that path
is empty —
and it is outside the file scope agreed for this change, so it is left
alone here
rather than folded in unannounced.

It should be filed and fixed, and the correction is already derived
above: the
content was absent for 31h28m, of which this replay was present only for
the final
6h13m, and over that tail it printed `ok` and exited 0 while the content
was still
gone. Recording it here so the remaining inconsistency is a known,
measured
follow-up rather than something a later reader rediscovers as evidence
that this
fix was incomplete.

## Verification

CI is the verification of record; the shell suites were deliberately not
run
locally (they behave badly on Windows). Everything asserted above was
measured with
`git log`, `git rev-parse`, `git merge-base`, `git log -S`, and `git
grep -F`, at
explicit revs.

A fresh-context verifier subagent then re-derived every figure
independently, with
the reasoning above withheld: both committer-date windows and their
arithmetic, the
ancestry and interval placement of `7b47d2253` and `94ae28728`, the
complete
per-literal occurrence sets on `origin/main` and on the branch tree
(confirming the
edits added no new occurrence of either literal), the adjacency repair
in part 2 and
that the relocated block's claims are unchanged, that every `31h28m`
mention in the
two edited files is now hedged and window-separated, and that the corpus
file
contains no stray non-comment line that would make the parser exit 2. It
returned
PASS on all sixteen claims, and its repo-wide sweep is what surfaced the
workflow
site noted above.

The edits also add no line over 80 columns to either file — the over-80
counts are
identical to `origin/main`'s (61 in the script, 13 in the corpus, all
pre-existing).

## Related

Closes #2981

- #2917 — the parent issue, whose item 2 this completes
- #2930 — corrected the script half and left the corpus half behind
- #2855 — added the restoration markers and the comment block part 2
repairs
- #2847 — the standard requiring a figure that does not rot

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Aug 21, 2026
…nt (#3066)

## Summary

`.github/workflows/silent-revert-canary.yml` was the third site of the
claim #2917 set out to remove. Its comment charged the whole `31h28m`
content-absence window to the replay's silence — so the workflow
contradicted the two `scripts/` files the job it documents actually
invokes. This brings the last of the three sites into agreement.

## Fix

Comment text only, in one hunk. No `run:`, `uses:`, `with:`, or any
other executable YAML was touched (`git diff --numstat` = `6 3` on that
one file; every `+`/`-` line begins with ` #`).

Before:

> incidents deleted is on the tree TODAY. Both are needed, and the gap
> between them has already cost 31h28m: the content #2828 is about was
> missing from main while the replay printed `ok` and exited 0, two
> re-lands each missed it, and a hand audit found it, not this lane.

After:

> incidents deleted is on the tree TODAY. Both are needed, and #2828
> is the gap made real: its content sat off main for 31h28m, from
> f603880 to the 534eac1 (#2829) restore. This lane covered only
> the tail -- it merged at 7b47d22 (#2808), 6h13m out from the
> restore -- and across that tail the replay still printed `ok` and
> exited 0 with the content gone: it answered the other question. Two
> re-lands each missed it, and a hand audit found it, not this lane.

The load-bearing point is preserved rather than deleted: the replay's
silence still did not mean the content was present. What changes is
which window that silence is charged to. "Both are needed" is kept
deliberately — the `ORDER IS LOAD-BEARING` paragraph immediately below
reads off it. The wording is the workflow's own rather than a lift from
either sibling; in particular it keeps this file's "not this lane" voice
instead of the scripts' "not this canary" / "not this file".

## Verification

Both figures re-derived here from committer dates (`git show -s
--format=%ct`), not carried over from the issue:

| window | from | to | seconds | rendered |
| --- | --- | --- | --- | --- |
| content absence | `f603880da` `1786752447` | `534eac138` `1786865745`
| `113298` | 31h28m |
| replay presence | `7b47d2253` `1786843322` | `534eac138` `1786865745`
| `22423` | 6h13m |

`113298s` = 31h28m18s and `22423s` = 6h13m43s; both are floored to the
minute, matching the convention the two sibling files already use.
`7b47d2253` is an ancestor of `main` (`git merge-base --is-ancestor`
exit 0) and its committer epoch falls strictly inside the absence
interval, which is what makes "only the tail" true.

Repo-wide sweep for further sites: swept all tracked files for `31h28m`,
`31h`, `6h13m`, `113298`, `22423`, the three incident shas, and
numberless restatements (`exited 0 for`, ``printed `ok` ``, `while the
replay`, `absence window`). **No fourth site exists.** The only near-hit
outside the three known files is
`plugins/claude-ops/skills/lanes/scripts/restart-consumer.test.sh:572` —
"`lock-held` and exited 0 forever" about a Task Scheduler consumer — an
unrelated sentence that merely shares the words. No follow-up issue
needed.

The change is comment-only, so no test asserts it; CI is the
verification of record for the surrounding workflow, and the shell
suites were not run locally (they run badly on this machine's platform).

## The other two sites

Both siblings are already on `main` and untouched here — #2930 corrected
`scripts/check-silent-revert.sh`, and #2986 (merged as `0e9ed8eb0`)
corrected `scripts/silent-revert-incidents.txt`. This branch is merged
up to a `main` that carries both, so all three passages can be read side
by side in one tree. The workflow was the last holdout.

## Related

- #2917 — the parent issue that named this claim
- #2930 — corrected `scripts/check-silent-revert.sh`
- #2986 — corrected `scripts/silent-revert-incidents.txt`
- #2847 — the standard this follows: state the claim that stays true
rather than renumbering a figure that rots

Closes #2988

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
kyle-sexton added a commit that referenced this pull request Aug 21, 2026
…omma (#3081)

Closes #2874
Closes #2875

## Summary

The silent-revert canary's self-proof lane could report a green replay
after verifying nothing. `verify_known_incidents` used `while read`
without keeping a last line that lacks a trailing newline, and it had no
row-count floor, so an empty file, a comments-only file, or a one-row
file whose only row was dropped all printed "Canary reproduces every
recorded incident" and exited 0. The same read idiom dropped the last
acknowledgment-file row (fail-closed). Attribution grammar from #2843 is
already on main; a trailing comma in `[sha=n,]` was still accepted
because word splitting under `IFS=','` drops a trailing empty field,
against the file's own "exactly that grammar / exit 2" doctrine.

## Fix

In `scripts/check-silent-revert.sh`:

- `verify_known_incidents` and `ack_reason` now use `while read … || [[
-n "$var" ]]` so a final unterminated row is processed rather than
dropped.
- `verify_known_incidents` counts verified rows and dies (exit 2) when
the count is zero — an empty or comments-only incident file is a broken
input, not a pass.
- `parse_attribution_field` rejects a field that starts with, ends with,
or contains a doubled comma before splitting, so a trailing comma is
exit 2 like every other empty-entry position.

## Test plan

`bash scripts/check-silent-revert.test.sh` — 112 passed, 0 failed. New
cases pin the empty-file / comments-only floor, newline vs no-newline
two-row parity (fixtures written with printf so the trailing newline is
real), a one-row file with no trailing newline, an unterminated last ack
row, and all four comma positions.

## Verification

- `bash scripts/check-silent-revert.test.sh` — 112 passed, 0 failed
- `shellcheck -x scripts/check-silent-revert.sh
scripts/check-silent-revert.test.sh` — clean
- New coverage:
- empty and comments-only incident files exit 2 (not the success banner)
- a two-row file without a trailing newline reaches the same FAIL as the
same file with one (fixtures differ by exactly one trailing byte)
  - a one-row file with no trailing newline still verifies that row
- an acknowledgment file whose last row has no trailing newline still
clears
- all four empty-entry comma positions (`[sha=n,]`, `[,sha=n]`,
`[sha=n,,sha=m]`, `[,]`) exit 2

## Related

Refs #2843 (attribution grammar already merged; this PR only closes the
remaining reader defects).
Refs #2833 (why the attribution field exists).
Refs #2808 (canary and incident corpus).

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
kyle-sexton added a commit that referenced this pull request Aug 21, 2026
…on git errors (#3084)

Closes #2879
Closes #2880

## Summary

The silent-revert canary's `clean` rows named a specific sub-threshold
line count in their notes, but the grammar rejected an attribution field
on those rows, so the figure was prose. Pinning the detector's diff
flags moved 129 to 136 on the previous closest-miss row and nothing went
red. Independently, `attribute_file` discarded git stderr on both
commands that produce a count, so a failed diff or blame was
byte-identical to "this file had nothing to attribute" and every corpus
figure was a lower bound.

## Fix

In `scripts/check-silent-revert.sh`:

- A `clean` row may carry the same bracketed `[<sha>=<n>]` field a
`fires` row does. The replay asserts the run's largest in-window
sub-threshold attribution is exactly that set. The row's pass/fail is
unchanged: nothing above the threshold is still what makes it clean.
- `attribute_file` checks git diff/blame status separately from output.
A non-zero status is exit 2 ("the canary could not run") with the
captured stderr on the die message. Successful runs still discard
stderr. `git-diff(1)` of two commits exits 0 on success even when the
files differ (`--exit-code` is what would turn a difference into status
1), so any non-zero status here is a real failure. `git-blame(1)` has no
analogous "found something" status.
- `attribute_file` is no longer piped into awk, so `die` cannot land in
a subshell and be swallowed back into a quiet zero.

The two shipped clean rows now record their measured maxima:
`3584ae1fa…=195` on `9a2307c43` and `6370a44e7…=136` on `c8470efd0`.

Not in scope: `-diff`/`binary` paths (#2883) and `diff.renameLimit`
(#2884).

## Verification

- `bash scripts/check-silent-revert.test.sh` — 121 passed, 0 failed
- `bash scripts/check-silent-revert.sh --verify-known-incidents` — both
clean rows print `largest sub-threshold attribution reproduced exactly`
- `bash scripts/check-silent-revert.sh --verify-restoration` — 7 markers
present
- `shellcheck -x scripts/check-silent-revert.sh
scripts/check-silent-revert.test.sh` — clean
- New coverage:
- a clean row whose recorded count or culprit has moved fails (`stays
clean, but NOT as recorded`) — the 129 → 136 shape
  - a firing commit labeled clean still fails on status
- a failed blame or content diff is exit 2, including when reached
through the replay
  - successful attribution still discards git stderr
  - every shipped clean row carries a well-formed attribution field

## Related

Refs #2833 (why the attribution field exists on `fires` rows).
Refs #2843 (pinned diff/blame flags that moved the unasserted clean-row
figure).
Refs #2874 / #2875 (reader defects just merged; this PR starts from that
main).
Refs #2883 (recall gap for `-diff`/`binary` paths — not this PR).
Refs #2884 (`diff.renameLimit` — not this PR).
Refs #2808 (canary and incident corpus).

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
kyle-sexton added a commit that referenced this pull request Aug 21, 2026
…etection (#3088)

Closes #2883
Closes #2884

## Summary

`scripts/check-silent-revert.sh` attributed deleted lines from `git
diff` hunks, then blamed those ranges. Two holes sat next to that
design: paths the repo's own `.gitattributes` mark `-diff` or `binary`
emit `Binary files differ` and contribute zero lines (#2883), and git's
default `diff.renameLimit` of 1000 can decompose a large
basename-changing content-touched relocation into adds plus deletes -- a
false positive on a clean machine, no hostile config required (#2884).
#2843 was scoped to pins only and correctly left both out.

## Fix

In `scripts/check-silent-revert.sh`:

- When the path-limited content diff produces no hunks and a `Binary
files` header, recover old-side ranges from a **blob-to-blob** diff.
That invocation has no path, so no attribute. Git's content heuristic
still treats a genuine binary as binary. `--text` / `-a` is not used:
gitattributes(5) Unset on `diff` generates `Binary files differ`, and
`--text` would force hunks through that -- a 200 KB random blob is ~800
"lines" and would fire at the shipped threshold of 200 from a commit
that reverted nothing.
- Pass `-l0` on the `--name-only` enumeration in `scan_commit` only.
git-diff(1): `-l` gates the exhaustive O(N²) rename pass; "a value of 0
is treated as unlimited". git-config(1) `diff.renameLimit` defaults to
1000 and is equivalent to `-l`. Exact-rename and basename-preserving
pre-passes are not limit-gated; the exposure is relocate more than ~1000
files AND change their content AND change their basenames. `-l0` is
inert on `attribute_file` (pathspec-limited, no pair can form).
Enumeration stderr stays visible so a skipped exhaustive pass still
announces itself if a future bound is too low.

Not `--text`. Not a pin of a default. `-l0` overrides git's own bound;
that is the honest fix #2884 describes.

## Verification

- `bash scripts/check-silent-revert.test.sh` — 130 passed, 0 failed (was
121)
- `bash scripts/check-silent-revert.sh --verify-known-incidents` — all
five rows reproduced exactly (853 / 451 / 346 / 298 / 195 / 136
unchanged)
- `bash scripts/check-silent-revert.sh --verify-restoration` — 7 markers
present
- `shellcheck -x scripts/check-silent-revert.sh
scripts/check-silent-revert.test.sh` — clean
- New coverage:
- `-diff` lock file (320 lines) + binary png churn + 25-line text file:
lock and text fire, png does not
- stripping the `Binary files` recovery makes the lock deletion a FALSE
GREEN at threshold 200
  - detector code does not pass `--text`
  - a wholly deleted `-diff` lock file is attributed (empty-blob branch)
  - a 320-line ASCII PDF-like blob marked `binary` stays clean
  - a 200 KB binary png re-export stays clean
- 1001-file basename-changing content-touched relocation stays clean
(`-l0`)
- stripping `-l0` under `diff.renameLimit=1` makes a 12×20 relocation
fire

### Full-scan runtime (#2884 acceptance)

Measured on this machine (git 2.43.0) over the 500 first-parent commits
of `origin/main` ending at `8b61b2b1` (`ab012e219..HEAD`), plus the
1001-file basename-changing content-touched fixture — the commit that
finally is not a no-op for `-l0`.

| run | elapsed | result |
| --- | --- | --- |
| 500-commit scan, origin/main script | 131.606 s | exit 1 (known
incidents fire; expected) |
| 500-commit scan, this PR (`-l0` + blob recovery) | 132.253 s | exit 1,
same findings |
| 1001-file fixture, `-M` (git default limit) | 0.003 s | `1001 A` +
`1001 D`, warning: exhaustive rename detection skipped |
| 1001-file fixture, `-M -l0` | 0.104 s | `1001 R`, no warning |

The full scan moved by 0.6 s (0.5%). That is not material. `-l0` is the
landing; `-l5000` is not needed. The 1001-file exhaustive pass costs 104
ms on this machine — the bound git's default exists to impose is not
paying for anything at this corpus size.

## Related

Refs #2843 (pins only; `-l0` and `-diff` were correctly excluded).
Refs #2847 (header calibration; this PR updates the #2883/#2884
limitation notes that lane wrote).
Refs #2879 / #2880 (just merged; this PR starts from that main).
Refs #2691 / #2808 (canary and incident corpus).

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Kyle Sexton <kyle-sexton@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants