Skip to content

silent-revert canary: declares_removal() does not recognize a revert: subject, the only revert spelling the PR-title gate permits #2837

Description

@kyle-sexton

The silent-revert canary merged in #2808 suppresses its finding when a commit
declares the removal in one of three constrained forms. The only revert subject
this repository's own PR-title gate permits is not one of them, so a deliberate
revert reaches main wearing a subject the canary does not recognize. Whether
it is then reported turns entirely on the body, which nothing here constrains —
and in this repository's one deliberate revert the body carried neither
remaining form, so the shipped detector reports it as a suspected silent revert
and exits 1.

Verified against origin/main at 1199240452ca411881c5defee293986f92ccf76a.

Mechanism

declares_removal() in scripts/check-silent-revert.sh (lines 184-207 at
1199240452) accepts exactly three deliberately-constrained forms:

  1. a subject matching the glob 'Revert "'*
  2. a body line matching ^This reverts commit [0-9a-f]{7,40}
  3. a body line matching ^Intentional-removal:[[:space:]]*[^[:space:]]

The constraint is intentional and the header argues for it well. Form 3's
comment notes that it Requires a non-empty reason so an empty trailer cannot be pasted in as a blanket mute, and the FALSE-POSITIVE STRATEGY section states
that the forms are Deliberately NOT a substring search for "revert", because a
body reading "this does not revert X" would silence a real finding. That
reasoning is sound. The problem is that the resulting set has a gap this
repository's merge and title configuration steers every revert into.

Form 1 cannot occur under the current configuration

gh api repos/melodic-software/claude-code-plugins reports:

{"allow_merge_commit":false,"allow_rebase_merge":false,"allow_squash_merge":true,
 "squash_merge_commit_title":"PR_TITLE","squash_merge_commit_message":"PR_BODY"}

Squash-only, and squash_merge_commit_title: PR_TITLE makes the PR title the
default squash subject. Direct pushes are not an escape hatch: the base
ruleset (id 17988999) is enforcement: active over ~DEFAULT_BRANCH with
bypass_actors: [] and carries the rules deletion, non_fast_forward,
required_linear_history, and pull_request. Every commit on main therefore
arrives through a squashed pull request. (required_linear_history is also why
the first-parent and all-commit counts below are identical, both 1527.)

.github/workflows/pr-title.yml gates every PR title through
melodic-software/ci-workflows/.github/workflows/semantic-pr.yml@90f1c54, on
pull_request: types: [opened, edited, reopened, synchronize]edited means
a re-title revalidates, so the gate cannot be dodged by renaming after a green
run. The caller's entire with: block is:

    with:
      runner: ubuntu-24.04

No types: override, so the reusable's default governs. At the pinned SHA that
input is default: '', documented as "Empty uses the action's spec-aligned
defaults (build, chore, ci, docs, feat, fix, perf, refactor, revert, style,
test)." A Revert "…" subject carries no Conventional-Commits type prefix at
all, so there is no entry in that default list it could match. The reusable's
ignore-labels input also defaults to '' and the caller sets none, so there
is no label bypass.

The gate is not advisory. pr-title / pr-title is a required status check in
the ci-gate ruleset, enforcement: active with bypass_actors: [].

The empirical pair is the discriminating evidence: across all 1527 first-parent
commits of main, subjects beginning Revert " appear 0 times, while the
Conventional-Commits spelling revert: did pass the gate and merge
(1d1fca6e8, below). One spelling merges; the other has never appeared once.

Form 2 has never appeared, on a denominator of one

squash_merge_commit_message: PR_BODY means the squash message is the PR body.
The reusable also sets validate-single-commit to default: false, documented
as "governed repos set the squash title to PR_TITLE, so the branch commit never
becomes the subject" — the branch commit's message is discarded at merge by
design. Whether form 2 survives onto main is therefore a pure function of
what the PR body contains at column 0. Occurrences across all 1527 first-parent
commits: 0.

State the weight of that count honestly: it is a count, not a reachability
argument. This history contains exactly one deliberate revert, so the
relevant denominator for form 2 is 1, not 1527. What is established is that
that revert's message lacked the line — not that the form is unreachable in
principle.

Form 3 has never appeared, and mostly could not have

The Intentional-removal: trailer works, but requires the author to know the
canary exists and to write the trailer before merging. Occurrences across all
1527 first-parent commits: 0 — a figure that is close to tautological,
since the convention was introduced by #2808 only ten first-parent commits
before HEAD and did not exist for the other 1517.

Counts were produced by iterating git rev-list --first-parent origin/main and
applying the script's own regexes to each commit's git log -1 --format=%B.

Reproduction

This repository's one deliberate revert on main is 1d1fca6e8
revert: remove Cursor dual-target marketplace manifests (#1835) (#1839)
reverting b6c4b58f3 (#1835), which is its single parent. Its commit message
is byte-for-byte the body of PR #1839, which documents the revert at length
("Revert squash merge b6c4b58 (#1835)…", "Reverts #1835") but contains no
column-0 This reverts commit line and no Intentional-removal: trailer, so
none of the three forms matches. The commit is also absent from
scripts/silent-revert-acknowledged.txt.

Run from a scratch clone checked out at 1199240452, with the shipped script,
corpus files, and thresholds unmodified:

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

SILENT REVERT SUSPECTED

  removed by   1d1fca6e8  revert: remove Cursor dual-target marketplace manifests (#1835) (#1839)
               2026-07-30 19:41:47 -0400
  content from b6c4b58f3  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)

  by file:
     981  .cursor-plugin/marketplace.json
     204  scripts/cursor-export/test.mjs
      98  scripts/cursor-export/io.mjs
      80  scripts/cursor-export/paths.mjs
      [... 98 further files (102 in total), then the "sample of the removed content" and
       "What to do" blocks, elided here ...]

EXIT=1

The volume and recency gates are not what fails — they behave exactly as
designed. A revert removes what its target added, and reverting a recent commit
puts the culprit well inside the 40-commit window, so a large deliberate revert
clears both thresholds by construction.

Stated precisely: a large deliberate revert of a recent commit is
reported here unless its commit message carries a column-0
This reverts commit <sha> line or an Intentional-removal: trailer, or unless
it is cleared afterward by a full-SHA row in
scripts/silent-revert-acknowledged.txt (ack_reason(), the retrospective
half of the disposition path, short-circuits to clean immediately after
declares_removal() does). A small revert, or one whose target is outside the
window, is never reported regardless of intent forms. The gap is that the
subject — the one part of the message this repo's tooling constrains, and the
part git revert writes as the subject — cannot contribute at all, because the
only revert subject the title gate admits is not a form declares_removal()
recognizes. (git revert also writes the This reverts commit <sha> line into
the body, which is form 2; what reaches the squash message on main is the PR
body, and this repository has no measurement of how often that line survives,
because it has had one deliberate revert.)

Scope: a faithful replay of the class, not a live production fire

1d1fca6e8 landed 641 first-parent commits before the canary itself
(7b47d2253, #2808), and 651 before current main.
.github/workflows/silent-revert-canary.yml scans a pushed range resolved from
github.event.before..after on push: main; its pull_request trigger is
paths-filtered and both scan steps are gated if: github.event_name != 'pull_request',
so a PR is never inspected. 1d1fca6e8 was therefore never inside a scanned
range and never fired in CI. The run above is a replay of the class against the
shipped detector, not a report of an observed CI failure.

It is not moot. Every setting that produces the defeating subject shape is
unchanged and currently active: pull-request-only merges into main,
squash-only with PR_TITLE as the squash subject, and a required
Conventional-Commits title gate whose default type list contains no entry a
Revert "…" subject could match. The next deliberate revert lands inside a
scanned range with the identical shape.

The workflow is non-blocking — it is outside ci.yml and outside the
ci-status aggregate the ci-gate ruleset keys on — so the cost is a red
advisory lane and the credibility erosion the canary's own header warns about
("A canary that cries wolf gets disabled, which is worse than not having one"),
not a blocked merge.

Calibration blind spot

The measured false-positive rate in the script header — "over the last 500
first-parent commits of main, the canary fires 5 times -- 1%" — never exercised
intent suppression at all. Across the entire 1527-commit history of main, not
one commit matches any of the three forms, so declares_removal() has never
returned 0 in production even once. Both verified-legitimate fires inside the
calibration window (6f0a31109 / #2640 and 91e77fc16 / #2135) were
dispositioned retrospectively through scripts/silent-revert-acknowledged.txt,
not by intent suppression.

1d1fca6e8, the only revert:-typed subject in the whole history, sits 641
commits behind the canary and so was outside the 500-commit window. The
measured 1% is accurate for the history it sampled and simply silent about the
suppression path: the corpus never contained a commit that could have exercised
it.

Proposed fix

Extend declares_removal() to also accept a Conventional-Commits revert
subject: revert:, and by the spec's grammar revert(<scope>): and
revert!:. Only revert: is observed to have passed this repository's title
gate, but all three are the same type token under the default type list the
gate enforces, and it is the only revert spelling that gate permits.

The tradeoff is worth stating for whoever takes this, because it cuts against
the design the header defends. Adding a fourth form widens the suppression
surface, and every widening is a potential false-negative hole. The mitigation
is to keep the new form as constrained as the existing three: anchored at the
start of the subject and requiring the type token, its optional scope or !,
and its colon — never a substring search for "revert" anywhere in the message.
A subject that begins revert: has passed a required Conventional-Commits gate
asserting the author typed the revert type deliberately, which is a stronger
intent signal than a prose mention and comparable in strength to the Revert "
form the script already trusts.

A test case belongs alongside it in scripts/check-silent-revert.test.sh, and
1d1fca6e8 is available as a real-history fixture proving the gap closed.

Whether form 1 should be retained is a secondary judgement call. It cannot
occur under current settings, but it costs nothing and would matter if the
merge or title settings ever changed.

Not covered by the open silent-revert issues

Issue #2831 and its PR #2832 correct the incident corpus's attribution. #2832
touches four files — .github/workflows/silent-revert-canary.yml,
scripts/check-silent-revert.sh, scripts/silent-revert-acknowledged.txt, and
scripts/silent-revert-incidents.txt — but every hunk in
check-silent-revert.sh falls within that file's first ~120 lines, well clear
of declares_removal() at 184-207, and #2831 states explicitly that detector
behavior and thresholds must not change. Issue #2833 concerns what
--verify-known-incidents actually asserts about a replayed incident, and
issue #2828 concerns a partial re-land the canary cannot see. None of the three
touches the intent-suppression form set.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions