diff --git a/.github/workflows/verify.yaml b/.github/workflows/verify.yaml
index b203b52d..189e020e 100644
--- a/.github/workflows/verify.yaml
+++ b/.github/workflows/verify.yaml
@@ -97,6 +97,19 @@ jobs:
run: go run github.com/zricethezav/gitleaks/v8@v8.30.1 detect --source . --no-banner --redact --log-opts="HEAD"
- name: sanitization check (D-002, REQ-E7-S05-01)
run: bash hack/check-sanitization.sh
+ # REDMAIN-N1 / D-168 (REQ-REDMAIN-N1-02) — commit subjects must not lead with
+ # a LITERAL emoji: cliff.toml's parsers key on the ASCII gitmoji shortcode, so
+ # such a subject matches none of them and is published under "### Other" on the
+ # Release page (the REL-14/D-137 defect through another door). Deliberately NOT
+ # guarded off pull_request, unlike the changelog drift gate below (D-125/OQ-30):
+ # a PR is the LAST moment the subject can still be reworded, because hard rule 2
+ # forbids rewriting it once it is on main. Needs the full history the gitleaks
+ # comment above already requires (fetch-depth: 0); no argument — the argument
+ # form is the sandbox mode that skips the published-history exemption checks.
+ # Also runs locally in `task check` via hack/release/changelog_gate_test.sh §9,
+ # which is where both of its polarities are proved.
+ - name: commit subject gate (REDMAIN-N1 — no literal emoji, D-168)
+ run: bash hack/release/commit_subject_gate.sh
- run: go test -race ./...
# E7-S04 — determinism gate (P4-E1-S12): double-run named engine + conformance goldens.
# Keep this block distinct — E7-S05 adds sanitization steps below/after.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 26c7b24e..14d6619f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -140,6 +140,12 @@ repository still gets a decision, never by following the link; no release carrie
- :memo: docs(backlog): track the D-154 guard-idiom S7688 residual as SONAR-SHELL-R01
- :memo: docs(decisions): correct SONAR-SHELL to 54 of 54 and withdraw two overstated figures (D-171)
- :memo: docs(decisions): repoint D-166 at the shifted E11 backlog lines (:594->:602, :613->:621)
+- :memo: specs(redmain): the literal-emoji commit subject defect, both halves (REDMAIN-N1/N2)
+- :memo: docs(decisions): D-168 records the REDMAIN-N1/N2 fix, its narrowness and its hand-off
+- :memo: specs(redmain): N2-02 states the decoupled retire path; add N1-04 for the allowlist pin
+- :memo: docs(decisions): D-168 (d) is corrected — one authority was the wrong instinct here
+- :memo: specs(redmain): N2-02 stops claiming coverage the script does not have; file REDMAIN-N4
+- :memo: docs(decisions): D-168 (f) records the unexercised paths and points at REDMAIN-N4
### Features
- :sparkles: feat(audit): measure REL-03/REL-07 by real mutation run, not by name
@@ -164,6 +170,7 @@ repository still gets a decision, never by following the link; no release carrie
- :bug: fix(lint): a redirection is not a version guard — require an adjacent comparison
- :bug: fix(lint): the include check missed 'source', one of its two spellings
- :bug: fix(lint): drop the reversed-form guard pattern — it opened a fresh fail-open
+- :bug: fix(release): decouple the two exemption lists so REDMAIN-N3 has a green state (D-168)
### Other
- 👷 ci(docs): stop uploading the Pages artifact on pull requests
@@ -196,6 +203,7 @@ repository still gets a decision, never by following the link; no release carrie
- :white_check_mark: test(ci): grade the shared PR-reach helper itself, on fixtures (D-157)
- :white_check_mark: test(lint): pin all eleven detection patterns, not the four the tree happens to use
- :white_check_mark: test(lint): give the non-probe absence assertions an anti-vacuity partner
+- :white_check_mark: test(release): reject literal-emoji commit subjects, and let §8 see them (D-168)
## [0.3.0] - 2026-08-18
### Chores
diff --git a/docs/decisions/decisions.md b/docs/decisions/decisions.md
index ffa83d0e..11e09921 100644
--- a/docs/decisions/decisions.md
+++ b/docs/decisions/decisions.md
@@ -172,5 +172,6 @@ project/process decisions.
| D-165 | 2026-08-25 | **E10-S01 extracts the forge conformance suite into importable Go, and the extraction found that the two SHA-guard cases had NEVER run against GitLab despite their catalog rows claiming `forge: gitlab`.** **That second half is a DOC-TRUTH defect in its own right, same species as [D-157], [D-160] and [D-164]:** `catalog.yaml` asserted adapter coverage that did not exist, for two L1 cases on the SHA-guard — the single most safety-relevant property in the suite — and nothing detected it for the entire life of the file, because the only thing checking the catalog compared it against test-function NAMES, which were all present and correct. A row claiming coverage is not evidence of coverage. Scope decisions taken deliberately: **(a)** `conformance.Factory` returns a `forge.Forge`, NOT the composite `forge.RunPort` that REQ-E10-S01-01 names — `RunPort` is E10-S02's deliverable and S02 is **LGTM-gated**, so declaring it in this `[autonomous]` lane would smuggle an LGTM-gated core-contract change past the `backlog.md:557` GOVERNANCE marker; S02 depends on S01 exactly so the port change lands against an executable suite, and the substitution is then a one-line change. **(b)** Merge/approve/thread write counts are taken by a PORT-LEVEL decorator (`portcount.go`), not per adapter, because they are not observable at the transport layer for GitLab — its `MergeCAS` re-reads heads and refuses client-side, so a moved head produces ZERO merge HTTP requests and an httptest harness would report 0 attempts where the port was called once. **(c)** Cases take pins from `Fixture.Pins()` instead of literals, because GitLab synthesises its merge-result digest (`gitlab.SyntheticDigest`) — a literal digest is why those cases could only ever run on the fake. Collapsing that digest is E10-S03. **Assertions were STRENGTHENED, never downgraded (REQ-E10-S01-04):** the summary-updated-in-place check was guarded behind `if ff, ok := f.(*fake.Forge); ok` and therefore unfailable on every non-fake backend; merge ATTEMPTS are now distinguished from merges PERFORMED, so "the pre-check refused before any mutation" (0 attempts) is separable from "the atomic CAS refused" (1 attempt), which `len(Merges) == 0` alone could not express; and a `NotesUpdated() > 0` check was replaced with an exact count after a gate proved no corruption of that value could flip it. Coverage of the catalogued cases went from partial-and-asymmetric to **all five on both adapters**; the package's passing assertions went from 9 test functions to 103 subtests. **The three self-checks are the deliverable as much as the extraction is** — observed-execution catalog matching, can-fail-against-sabotage on every adapter, and per-observation load-bearing corruption; the last of these found two real weaknesses in code that had already passed review, which is the recorded lesson that reviewers must EXECUTE rather than read. **Foreseeable consequence of THIS commit, recorded here so E10-S02's author does not discover it:** the extraction moved ~700 statements from `_test.go` (uncounted) into production-classified Go whose only exerciser is the three self-check gates, and global coverage now sits at **91.1% against a 91.0% floor**. Every future E10 story that adds a conformance case also adds uncovered `t.Fatalf` assertion branches and will red the D-010/D-128 floor. The fix when it happens is NOT to delete assertions — it is either to extend the load-bearing gate to the new case (which covers those branches as a side effect, and is the reason the floor is met today) or to rule on whether a test-support package belongs in the coverage denominator at all. Logged in `agent-context/INBOX.md`. Revert: the pre-extraction `_test.go` bodies are one commit back; the gates are additive and worth keeping regardless. |
| D-166 | 2026-09-03 | **ADR-0002's published claim that the `rego` tier is an "escape hatch for cross-entry checks" is WITHDRAWN; the tier's justification is folds/aggregates and unbounded graph reachability, and four D-156 review residuals are closed with it.** Substance first: the `rego` bullet of `docs/adr/0002-policy-frontends-rego-declarative.md` had read, since 2026-07-21, "Escape hatch for cross-entry checks, complex derivations, whole-branch conventions." **D-156 (2026-08-23) struck all three clauses** and the sentence stayed published anyway. It now states the two shapes D-156 measured — **folds/aggregates over an in-input collection** (tier 1 has `size()` and no `sum`/`reduce`/`math.*`/`lists.*`) and **unbounded graph reachability over an in-input adjacency** — and **ADR-0002 Amendment 1** records the withdrawn sentence verbatim with the reason each clause failed: cross-entry/cross-manifest checks are tier-1 expressible where the data is present and fail on **input availability** where it is not, which REQ-E11-S05-01's identical-`EvaluationInput` pin hands to the Rego tier unchanged; "complex derivations" is the named-intermediate shape, struck because inlining is semantically identical and `[expr].all(v, …)` computes it once; "whole-branch conventions" is the same input-availability limit renamed. The Options table's 2026-07-21 "ceiling: cross-entry logic" cell is **left standing** as a record of the deliberation, marked superseded rather than rewritten. **Why this did not wait for E11-S12, which nominally owned it (REQ-E11-S12-01), and why the mechanism matters more than the sentence:** that REQ's `Test:` list omitted `docs/adr/0002-*` — the actual wrong file — and its `Verify: task check` has no pin over the phrase, so **no gate could ever have failed on it**; it was invisible to CI by construction, and S12 is story 12 of 14, so if E11 never proceeded the ADR would have stayed wrong indefinitely. The correction was carried instead by standalone backlog residual **E11-R01**, which is what actually kept it from being lost. **The structural hole is narrowed, NOT closed, and the difference is worth stating precisely:** `docs/adr/0002-policy-frontends-rego-declarative.md` is now named in REQ-E11-S12-01's `Test:` list, so the file is inside S12's reviewed sweep instead of outside it. That is a **review** pin, not a gate. **Nothing machine-consumes a REQ's `Test:` list** — no script, workflow or Go test parses those lines, and the only gate that reads ADRs at all (`hack/docs/truthlag_pins_test.sh`) compares each ADR's Status row against `docs/adr/README.md` and never looks at body content. **So a regression re-inserting the withdrawn sentence into ADR-0002 today still fails nothing.** This row named two reasons no gate could catch E11-R01 — the omitted `Test:` entry and the absent pin over the phrase — and only the first is addressed; the second would need a new pin in `hack/docs/`, which this lane is not scoped to write and which is therefore left as named, open work rather than implied. **Four D-156 round-4 residuals closed in the same pass, all doc/spec, zero Go and zero schema.** **F4-01** — §6's verdict table had drifted from the body it summarises: the F3-01 round (`c094f15`) rewrote §5 and the spec's mirror but left the planning doc's table at its F-01 wording, so row **D** still gave the pre-fix answer. Re-derived in **both copies** (`docs/planning/rego-tier-ceiling.md` §6 and `openspec/specs/p5-e11-rego-backend/spec.md`'s E11-S01 mirror, which drift independently): the property is that iteration **levels** are syntactic, a bounded `k`-hop check is affordable and on a small graph **complete**, the adjacency is "deliverable today" rather than "available today", and the row carries §5's own caveat that no provider in the corpus ships one. Row **A2** gains the value binder `[expr].all(v, …)`, omitted in both copies, which was understating what tier 1 can express. Same class, found while re-deriving and fixed with it: the spec's E11-S01 "Binds E11-S04" paragraph still said `split` and `graph.reachable` jointly carry the graph shape, corrected everywhere else by `c094f15`. **F4-02** — a missed cross-reference renumber: `:159` still pointed at the pre-`c094f15` §1.2; the claim it cites is in §1.3. Every `§` reference in both files was swept and each resolves to a real section; only that one was wrong. (No count is published here: the figure this row first carried was not reproducible, and any such total drifts as the files grow — an unbackable denominator is the exact defect clause (iv) exists to avoid.) **F4-03** — §1.2's headline read "the number of iterations a tier-1 CEL expression performs cannot be made to depend on the data", which read alone is **false**: a comprehension over an input collection costs `\|N\|`, and `\|N\|` is data. The claim the record needs is its own next sentence's — iteration **levels** are syntactic, nesting depth is fixed by the expression text — and that is now the headline, with the false wording quoted and withdrawn rather than silently reworded, and the downstream restatements swept and aligned per the classification below — **including D-156's own row above, which is the most-quoted copy of both withdrawn claims** (`docs/planning/rego-tier-ceiling.md` cites D-156 as its `Authority:`). That row is amended in place rather than left standing, following the precedent of `c094f15`, which amended D-156 in place and is what inserted the false sentence and the six-figure costs into it in the first place; this log's convention is a living statement of the decision, not an append-only audit trail. **Nothing is silently deleted:** all four amended sites in D-156 quote what they published and point here. **SWEEP RESULT, stated as a classification rather than as a completeness claim — deliberately, and this is the substantive lesson of the row.** Three successive drafts of this correction asserted that no copy of the withdrawn claims survived, and independent review falsified all three: first D-156's own row, then `openspec/specs/backlog.md`, then `docs/adr/0013-appendix-syntax-gallery.md:200`, which was invisible to the sweep because it is written `cross-*entry*` — markdown emphasis inside the word, which `git grep cross-entry` cannot match. Per this repository's own retrospective rule, **when the denominator cannot be made provably complete, fix the CLAIM, not the denominator**, so this row reports what it classified: **(i) METHOD** — every tracked text file, each line passed through ``sed 's/[*_`]//g'`` to strip markdown emphasis *before* matching, for `cross-entry`, `whole-branch`, `complex derivation`, `iteration count` and each withdrawn cost figure; the un-stripped grep that missed the third survivor is superseded by it. **(ii) CORRECTED** — `docs/adr/0002-*` (bullet + Amendment 1), `docs/adr/0013-appendix-syntax-gallery.md` (two sites: the archetype-6 verdict and the Ceiling summary), `docs/planning/rego-tier-ceiling.md`, `openspec/specs/p5-e11-rego-backend/spec.md`, and D-156 above. **(iii) LEFT DELIBERATELY, with reason** — `docs/adr/0008-change-classification-routing-scope.md:44`'s "cross-entry uniqueness" is a `scope: branch` **input-availability feature**, not this claim; and two rows in `openspec/specs/backlog.md`, **which differ and should not be collapsed**: `:621` (E11-R01) still reads **OPEN** and still calls ADR-0002 wrong, while `:602` (E11-S01) restates the withdrawn "iteration count" wording **inside a row already marked `✅ DONE (D-156)`** — the more misleading of the two, because a reader has no cue that anything in it is outstanding. That file belongs to another lane in flight and **this lane must not edit it** — both are handed on, not counted as done. **(iv) NOT ASSERTED** — that no further copy exists. Prose this lane does not own may still carry one, and a later discovery makes this record **incomplete rather than wrong**, which is the whole point of writing it this way. Recorded because retaining a claim after the thing that justified it was withdrawn is this repository's own recurring failure species (D-157, D-160, D-164, D-165), and the first draft of this row committed it — it asserted completeness while D-156 still carried both withdrawn claims verbatim. **F4-04, the one that is disclosed rather than fixed** — §5 published evaluation costs to six significant figures (`89,551`/`235,297`/`686,317` at `\|N\|=50`, `462,618` at `\|N\|=200`) while quoting the generating expression **nowhere**, in a document whose method everywhere else is reproduce-don't-reason. The preferred fix — quote the exact CEL expression per row — **is not available**: the probe was deliberately uncommitted, the expression text was never written down, and nothing in the tree recovers it; **inventing a plausible frontier expression and presenting it as the one measured was rejected outright** as making the record look reproducible while being false. So the figures are rounded to percentage-of-budget, the unit the argument actually uses, and a marked note records the withdrawn precision verbatim, why no reader can falsify a figure, what setup *is* recorded (`newEvalEnv` verbatim, `cel-go v0.31.0`, the real `cel.CostLimit(1_000_000)`, a ring graph of the stated `\|N\|`/degree, a BFS-frontier form over the §1.1 binder), and what would close it — **one committed `assent test` fixture with a stubbed `edges` fact and the frontier expression, which discharges §5's deliverability caveat at the same time**. Nothing load-bearing moves: §5 uses only that cost is roughly linear in `k` and that a `k ≥ \|N\|` check fits at `\|N\|=50` and not at `\|N\|=200`, and the verdict never rested on the envelope at all. **What this row does NOT do.** It does not reopen D-156's verdict — every shape verdict is unchanged, and this pass corrects how they were *stated*, not what they are. It writes no Go, adds no dependency, touches no schema. **Flagged, not edited, because another lane owns the file:** `openspec/specs/backlog.md`'s **E11-R01** row still reads **OPEN** and still describes the ADR as wrong; it should be flipped to DONE against this row. Revert: restore the `rego` bullet's struck sentence and delete Amendment 1 — which republishes a claim D-156 measured as false. |
| D-167 | 2026-09-03 | **D-162 executed: the P5-DEM epic is re-decomposed spec-first and all 54 `REQ-DEM-*` rows now carry `Test:`/`Verify:`/`Level:`.** The premise was verified before acting, not assumed: `openspec/specs/p5-dem-demo-repos/spec.md` was 758 lines with **54 REQ rows** (57 raw `REQ-DEM-` occurrences — the other three are the epic-prefix line and two cross-references) and **zero annotations**; the file's single `Test:`/`Verify:`/`Level:` occurrence was the 🔴 precondition paragraph at `spec.md:366-373` *describing* the gap. D-162 required this be done spec-first and **rejected** annotating lane-by-lane as stories are picked up, because a story picked up with no definition of done is what produced this project's documented six tests that cannot fail. **House style derived from the siblings, not invented:** E10 and E11 write a three-line sub-bullet block in the order `Test:` → `Verify:` → `Level:` (E6/E5 write the same three fields inline); `Test:` names artifact paths, several of which the story creates (E10-S00 names `docs/planning/github-addressing-model.md` before it exists); `Verify:` is a runnable command; `Level:` is **defined by `GUIDELINES.md:29-31`** (golden decision tests L0, adopter harness L1, cassettes L2, real GitLab L3) — `internal/forge/conformance/catalog.yaml` is *usage* of those tokens, not their definition, and the row originally miscited it as the source. This epic reads the scale one notch broader at L0/L1 (static-file gate; CLI-driven harness), stated as such at `spec.md:383-395`, because DEM's REQs are examples, docs and gates rather than engine semantics — the broadening matches E10/E11 usage and changes no REQ's level. Corrected while here: **E10 has 54 `Test:` rows, not the 53** the spec and backlog both quoted. **Distribution: 15 L0, 30 L1, 4 L2, 5 L3.** Commands are grounded in the tree as it stands, and the row distinguishes an **existing gate** from an **existing symbol a story must still write a gate for** — `TestBuiltinRepoFileMostSpecific`, `TestUnmatchedFileDeleteFailsSafeReview`, `hack/dogfood-examples.sh` discovery, the `hack/lint/depguard_test.sh` adversarial gate and `--coverage` (`cmd/assent/test.go:41-49`) are gates that exist and run today; **🔴 CORRECTION (2026-09-03, second review round): the sentence that stood here — that `ResolveFactsChecked` and `ScrubEnv`/`ScrubArgv` are functions whose tests do not exist — was FALSE on both symbols, and it was false because it rested on a symbol-name grep.** `git grep -l 'ScrubEnv\|ScrubArgv' -- '*_test.go'` is empty, but that proves only that no test *mentions the identifier* — **the exact instrument D-165 records as wrong**. Read against the tree instead: `ResolveFactsChecked` is directly tested at `internal/provider/declaration_test.go:33,72,106` and `sensitive_test.go:162`; and `ScrubEnv`/`ScrubArgv` are called by `CallExec` (`transport.go:239,242`) whose behaviour is gated by `internal/provider/isolation_test.go:80` (`TestIsolationNoWriteToken`, REQ-E5-S03-01) and `:146` (`TestIsolationNoCredentialInArgv`, REQ-E5-S03-03) — which spawn a real provider binary with `UPSTREAM_TOKEN`/`LDAP_SECRET`/`CI_JOB_SECRET` canaries and fail if any `TOKEN`-or-`SECRET`-named entry reaches the child. That IS REQ-DEM-S02-03's clause, so that row now points at those tests and requests **no** new gate; the earlier draft would have directed a story to write a duplicate. **The one row that genuinely does need a gate written is REQ-DEM-S02-02** — `internal/provider/guide_example_test.go` is absent, and its fixture (the guide's own copy-pasteable payload) is new even though `ResolveFactsChecked` is well covered; it now carries the marker this row previously credited it with but which it did not have. **The underlying distinction stays load-bearing: `go test -run` over a pattern matching nothing exits 0** (measured), so a `Verify:` naming an unwritten test is green-by-vacuity — the defect class this ruling exists to close. The lesson worth more than the correction: this row asserted a coverage gap from a name grep, in a ruling about not asserting properties the evidence cannot establish. **The judgment this row exists to record: five REQs got an honest `Level:` downgrade and NO command rather than a plausible-looking `Verify:` that would never run.** REQ-DEM-S05-04, REQ-DEM-S06-03 and REQ-DEM-S10-03 are defeated by facts already in the spec — the evaluation unit is one file (`cmd/assent/run.go:266,289`, singular `adoptertest.Case`) and the E6 provider fence (`cmd/assent/test_provider_fence_test.go:69-80`) keeps tier 1 on `facts.yaml` stubs — so each is L3, closing at DEM-S14 or not at all; REQ-DEM-S13-01 needs org credentials plus operator authorization beyond AGENTS.md rule 2, REQ-DEM-S14-01 needs a live MR, and `task check` is hermetic by construction. Three further REQs (S02-04, S04-03, and S11-02's screenful clause) name **no** command because none exists: `hack/docs/example_format_inventory_test.sh` reconciles `examples/README.md` against `examples/packs/*` and reads `examples/repos/README.md` not at all, and grepping a doc for a phrase asserts the presence of words rather than of the explanation. Two REQs that say *determined by running the code* (S08-02, S10-02) split their `Verify:` explicitly: the determination is manual and carries its commands, and the result is then locked into a golden or an `expect.yaml` so the seam reds on drift. One `Verify:` gained a second half it did not have: REQ-DEM-S09-03's *identical diff APPROVEs in dev and REVIEWs in prod* is satisfied by two **different** diffs unless a test asserts the two cases' `base/`↔`head/` bytes are identical, so it now demands both. **Scope:** annotation only — zero implementation code, zero schema bytes, no demo repository. DEM-S00 stays gated by `engine-grade · maintainer LGTM` and was not started. One prose normalization: REQ-DEM-S10-01's inner Delete/Edit bullet list became two bolded sentences so the annotation block below it cannot read as a third sibling of them; no fact changed. **Renumbered D-166 → D-167 on 2026-09-03**: `lane/e11-docs` read `decisions.md` at the same max (D-165) and took D-166 first; that row was already through review by number, so this one moved. Citations in `openspec/specs/p5-dem-demo-repos/spec.md` and `openspec/specs/backlog.md` moved with it. The commit subject that first landed this row still says D-166 and is in `CHANGELOG.md` unamendable — the follow-up subject names the correction. Revert: `git revert` the DEM annotation commits and this row — the epic returns to 0 annotations and the precondition at `spec.md:366-373` returns with it, which is the state D-162 ruled unacceptable. |
+| D-168 | 2026-09-05 | **REDMAIN-N1 and REDMAIN-N2 are closed together, as the INBOX required: a literal-emoji commit subject is now rejected by a gate, and `changelog_gate_test.sh` §8's `### Other` detector is no longer blind to that same shape.** Both premises were verified in-tree at `20c80cb` before anything was written: rendering `cliff.toml` puts THREE entries under `### Other`, and the third — `- 👷 ci(docs): stop uploading the Pages artifact on pull requests` (`dfdae69`) — declares the fileable type `ci`, which is exactly what §8 forbids, while §8's pattern `^- :[a-z0-9_]+: ($FILEABLE_TYPES)[(:]` requires an ASCII shortcode and therefore could not see it; the OK line still printed its hardcoded "revert + one malformed subject" prose against a 3-line block. **(a) The rule enforced is narrow on purpose: the first character of a commit subject must be ASCII** — NOT "the subject matches the whole convention". Measured over the whole of this repository's history, two legitimate shapes carry no gitmoji at all (Dependabot's `build(deps): bump …`, GitHub's `Merge pull request #N from …`), so the full-convention rule would have rejected 30+ commits it has no business rejecting, while the narrow rule has exactly ONE hit in the whole history — `dfdae69`, the defect itself. (No commit count is quoted: it grows with every push, and a self-dating denominator in this log rots.) Chosen over a full commit-lint (rejected: false positives on bots, and it is a bigger decision than this defect needs) and over a git hook (rejected: hooks are not committed, not enforceable, and invisible to CI). **(b) Placement: no 22nd `task check` stage.** `CHECK_STAGES` in `hack/audit/exitgate_test.sh` asserts the Taskfile's `check:` list is EQUAL to it, so a new stage is a change to that pin — and that file is outside this lane's fence in a four-lane parallel wave. The gate is reached instead through the EXISTING `release-changelog-gate-test` stage (§9 runs it) and, more importantly, as an UNGUARDED step of `verify.yaml`'s `verify:` job. That is deliberate asymmetry with the changelog drift gate one screen below it, which stays `github.event_name != 'pull_request'` under D-148/OQ-30: a pull request is the LAST moment a bad subject can still be reworded, because once it is on `main` hard rule 2 forbids rewriting it. Per REDMAIN-R1 a green PR is otherwise no evidence at all here — this is the first changelog-integrity check that actually runs on `pull_request`. **(c) Published history is tolerated by full commit SHA, never by shape.** `dfdae69` cannot be rewritten (hard rule 2), so it sits in `LEGACY_ALLOW_SHAS`; a SHA cannot be inherited by a future commit the way a pattern-shaped exemption could. Three self-checks keep it honest: every listed SHA must resolve here and be an ancestor of HEAD, every listed SHA must ITSELF still be a detection (a stale exemption reds the gate), and the detector is re-proved on every run against a fabricated known-bad/known-good pair so a mistyped pattern fails loudly instead of matching nothing — the vacuity mode this repo has shipped twice. **The list's LENGTH AND CONTENT are additionally pinned from a SECOND file** (`LEGACY_EXPECTED` in `changelog_gate_test.sh` §9d, set-equality, both sides guarded non-empty): review found that all three in-gate self-checks still pass if a lane lands a literal-emoji commit and appends its SHA in a later commit of the SAME pull request — it resolves, it is an ancestor, it genuinely IS a detection — so an allowlist whose size nothing pins can grow unremarked. Growing it is now a deliberate two-file change plus a row here, the same ratchet `CHECK_STAGES` applies to the `check:` stage list. **(d) The two halves keep SEPARATE lists, linked one way only — and the first attempt at this was WRONG.** §8's `### Other` exemption was initially derived from `LEGACY_ALLOW_SHAS` on a "one authority, no drift" argument. Review simulated REDMAIN-N3 landing and showed that design has **no green state afterwards**: the two lists answer different questions that decouple at exactly that moment — `LEGACY_ALLOW_SHAS` asks *is the commit SUBJECT still a literal emoji* (permanent: `dfdae69`'s subject can never change), §8 asks *is the RENDERED ENTRY still mis-filed* (temporary: N3 makes it false). Coupled, §8 red with a remedy that could not be executed — dropping the SHA reddened the commit-subject gate on `dfdae69` AND reddened §8 again on its own non-empty guard. §8 therefore keeps its own `OTHER_EXEMPT_SHAS`, which MAY be empty (empty is the post-N3 steady state, and with no exemption in force every `### Other` line is checked — strictly stronger), and the only remaining link is the **one-way subset invariant** `OTHER_EXEMPT_SHAS ⊆ LEGACY_ALLOW_SHAS`, read live through `--legacy-shas`, which the empty set satisfies and which therefore cannot deadlock. Recorded as a correction rather than silently rewritten: a "single authority" is the right instinct for two facts that stay equal, and the wrong one for two facts that are scheduled to diverge. **(e) What is NOT fixed, and why**: the rendered entry still sits under `### Other` on the Release page. Re-filing it needs a `cliff.toml` `commit_parsers` entry and `cliff.toml` is outside this lane's fence — filed as **REDMAIN-N3** in `openspec/specs/backlog.md`. The exemption is built to force that hand-off: when N3 lands, the exempt entry stops appearing under `### Other` and §8 reds — and the remedy it prints is now one the N3 lane can actually execute: **delete that SHA from `OTHER_EXEMPT_SHAS` in `changelog_gate_test.sh` §8, and nothing else; do NOT touch `LEGACY_ALLOW_SHAS`.** Proved end-to-end in a scratch clone, not argued: a `cliff.toml` parser for the literal-emoji prefix was added, `CHANGELOG.md` regenerated (the entry moves to Chores), the gate went red with that message, the message was followed literally — one line deleted — and both gates came back green. The control run, deleting the SHA from `LEGACY_ALLOW_SHAS` instead, still reds on `dfdae69`, which is what the coupled design forced. A red for a good change is the intended ratchet; a red with no green state on the far side was a defect. **Incidental finding, recorded because it changes what a gate may cite:** `AGENTS.md` and `CLAUDE.md` exist in the working checkout but are **untracked** (not gitignored — simply never committed), so no gate can pin rule 5 against them; the gate and its docs cite `GUIDELINES.md` § Repository discipline, which is tracked and says the same thing. Non-vacuity: eight mutations run in a scratch clone, each red for its own stated reason and restored green — exemption deleted (reds naming `dfdae69`), detector pattern broken (reds at the self-proof), `OTHER_MAPPABLE_RE` reverted to the prefix-required form (§8b reds), §8's exemption subtraction removed (§8 reds on the REAL rendered `👷 ci(docs)` line — the proof that Half A is no longer fail-open), exemption made stale (reds with the retire message), verify.yaml step deleted / guarded off `pull_request` / given a repo argument (§9a reds on each), gate script deleted (§8 reds). Fix round adds four more, all red for their own reason and restored green: the reviewer's allowlist-growth attack (a literal-emoji commit landed and its SHA appended — the gate itself still says OK, §9d reds on the pin), `LEGACY_EXPECTED` emptied (reds on an array-LENGTH guard, because `printf '%s\n' "${a[@]}"` on an empty array writes a blank line and a file-size guard there would never have fired), `--legacy-shas` made to print nothing (reds rather than comparing two empty files), and §8 exempting a SHA absent from `LEGACY_ALLOW_SHAS` (reds on the subset invariant). Review also found §8's first detector still shape-specific — it accepted exactly one non-ASCII token and exactly one space, so `👷 ci(docs):`, `👷ci(docs):` and `🚀 :rocket: feat(x):` slipped past, the same species of defect REDMAIN-N2 was filed against. The quantifiers are now general and §8b's probe is 12 lines with a line-by-line expected verdict. **(f) A truth-claim this row must not overstate, found by the third reviewer and recorded rather than quietly narrowed:** the three properties (d) and (e) are really about — the subset invariant, the empty-exemption-list path, and the retire message — have **no standing control**. Running `changelog_gate_test.sh` on the real tree exercises none of them: the subset grep always passes, the empty-list branch is never taken, and the retire message is never emitted. They were proved by one-off scratch-clone **simulation**, which is evidence this lane produced once, not a probe that re-runs — a materially weaker guarantee than §8a/§8b/§9b/§9d, which each carry a standing mutation. `REQ-REDMAIN-N2-02`'s `Test:`/`Verify:` line now says so in those words instead of implying the script covers them. Closing the gap is **REDMAIN-N4** (`openspec/specs/backlog.md`), a tracked OPEN residual with its own row: a §8c driving all three over a fabricated `### Other` block, the way §8b drives the detector over a probe. Deferred deliberately to its own lane — it is a new gate surface and this one had already taken two fix rounds — not because it is optional. Three fail-closed P3s ride on that row: §9d's mutant runs in foreign mode (comment precision, the mutation still proves its claim), `grep -v -x -F -f` would mis-message if two commits ever shared an exempt subject, and `OTHER_MAPPABLE_RE` also flags non-emoji non-ASCII prefixes that the commit-subject gate makes unreachable. **Revert:** delete `hack/release/commit_subject_gate.sh`, revert §8's detector to `^- :[a-z0-9_]+: ($FILEABLE_TYPES)[(:]`, drop §8b/§9 and the `verify.yaml` step — the repository returns to a state where a literal-emoji subject lands unchallenged and is published under `### Other`, which is the state REDMAIN-N1/N2 were filed against. |
| D-170 | 2026-09-05 | **A `docs/` page that is not reachable from the published site can no longer sit there unnoticed: nav membership is gated, and the only sanctioned alternative is an explicit, commented, justified exclusion.** The premise handed to this lane was re-measured and **holds exactly**: `mkdocs build --strict` on `main` at `20c80cb` listed **56 of 66 `docs/**.md` files** as absent from `nav:`, green, because `mkdocs.yml:65` set `validation.omitted_files: info` and `--strict` escalates WARNING and above only. Two prior spec rows already stated the limitation in prose without closing it (REQ-DEM-S01-03, REQ-DEM-S02-01: "nav linkage is therefore **manual review** unless a later change bumps `omitted_files` to `warn`"). **What the number was hiding is the part worth recording.** It conflated two populations that nothing in the repo could tell apart: **25 product pages that simply fell out** — all 22 ADRs, `docs/architecture/policy-profiles.md`, and both frozen `docs/contracts/p3-e5-publication-protocol/` documents, which E9-S08/D-103 never re-added when it trimmed the nav to product pages — and **31 pages that are deliberately not product docs**: `docs/planning/**` (27, kept out of the nav by GUIDELINES.md "Repository discipline" and by D-103 itself), the two `docs/assets/**` maintainer runbooks for regenerating brand SVGs and VHS tapes, `docs/decisions/evidence/**` (a per-story evidence artifact reachable from the decision log), and `docs/adr/template.md` (an authoring stub whose body is literally `ADR-nnnn: Title`). So the defect was never "56 pages are missing"; it was that **deliberate exclusion and accidental omission were indistinguishable**, and the one check that could have separated them was pinned below the level `--strict` acts on. **Decision, three parts.** (1) The 25 product pages go into `nav:` — the ADRs as a full section under the existing ADR index, policy profiles under Architecture, and a new Contracts section. (2) The 31 others are named in a **commented `not_in_nav:` block**, one comment per group stating why that group is not a product page; adding a glob there is a publication decision, not a way to silence a page that merely lacks a nav row. (3) `validation.omitted_files` is raised to **`warn`** — `error` was tried first and mkdocs rejects it for that key (`Expected one of ['warn', 'info', 'ignore']`), which is exactly why part (4) exists. **`not_in_nav:` and NOT `exclude_docs:`, deliberately.** Excluding the planning subtree from the build was the stronger-looking option and is wrong here: **7 published pages carry 19 links into `docs/planning/**`, 10 of them from `docs/decisions/decisions.md`**, so dropping those pages out of the site turns every one of those links into a `--strict` failure and forces edits to existing decision-log rows. `not_in_nav` keeps the pages built and link-reachable while stating, in the config, that they are not navigation destinations — which is precisely what GUIDELINES.md asks for. **The gate's own guard, because `warn` means the gate lives or dies with `--strict`.** `.github/workflows/docs.yaml` gains a step that (a) greps its own `Build site` step for `mkdocs build --strict` and (b) writes a page that is in neither `nav:` nor `not_in_nav:`, runs the strict build, and **asserts it fails naming that page**. Mutation-verified in four states before landing: armed → guard green; `omitted_files` back to `info` → guard RED ("the gate is NOT armed"); `not_in_nav` widened to `**` → guard RED; `--strict` removed from `Build site` → guard RED naming that removal. The gate itself was proven able to return "found" the same way: a scratch unlinked page reds the strict build under the new config and builds **green** under the old one, so this is new signal rather than a restatement of something already covered. This is the repo's own D-123/D-124 idiom — a gate nothing guards is a gate nobody notices being deleted. **Publication review, the highest-risk part of this lane.** All 25 newly navigated pages were read in full against AGENTS.md hard rule 1 before exposure: no employer names, no internal system names, no internal policy content, no verbatim private material. Examples throughout are generic (`platform/orders-service`, `topic-registry:orders.events.v1`, Kafka-topic and tfvars shapes); the only third-party names are public products (GitLab, GitHub, Kyverno, OPA, cel-go, Keycloak, Entra ID). `hack/check-sanitization.sh` passes over the tree. The three internal-looking pages were checked too and are excluded on **editorial** grounds, not secrecy: they are already-public maintainer runbooks and evidence, not product pages. **What was verified beyond the build being green**, because a green build was the whole problem: the site was built and inspected — 66 HTML pages, **3 816 internal links, 0 broken**; all 25 new pages render with substantial bodies; every one is reachable from the rendered sidebar; **no duplicate nav label and no duplicate page `
`**. **Not done, on purpose:** `validation.anchors` stays `info`. It is the same invisibility class (one real broken anchor exists today in `docs/planning/spikes/spike-secure-setup.md` and builds green under `--strict`), but it gates intra-page targets rather than site reachability and needs that anchor fixed first — tracked as `DOCSNAV-R01`. Also not done: wiring `task docs-build` into `task check`. It is not a `check:` stage today, so nav completeness is a CI-side gate only; closing that would edit `Taskfile.yml`, which is outside this lane's fence, and is left as a stated gap rather than a silent one. **Revert:** `git revert` this lane's commits — `mkdocs.yml` returns to `omitted_files: info` with a 10-entry nav, the 25 pages return to being built-but-unnavigated, and the workflow guard goes with them; the pages themselves are never deleted by either direction. |
| D-171 | 2026-09-05 | **DOC-TRUTH — the `SONAR-SHELL` backlog row asked for 54 SonarCloud `shelldre` findings to be cleared in four CI-gate scripts; all 54 had been cleared 33 days earlier, and the row still said `OPEN`.** Same species as [D-157], [D-160], [D-164] and [D-166]: a tracked file asserting work that no longer existed. **The number is 54 of 54.** An earlier draft of this row, of `openspec/specs/backlog.md:97`, and of commit `eed18ad`'s subject said *52 of 54*; that figure is **WITHDRAWN**. It was `54 minus the 2 sites outstanding today`, which silently conflates the original population with a later one and is the exact claim-versus-evidence defect this row exists to condemn. Measured at `9a772d4`: **34/34** `S7688`, **9/9** `S7679`, **3/3** `S131`, **1/1** `S7684`, and both remaining families cleared — zero residual in any of the six. `eed18ad` and the `CHANGELOG.md` entry generated from it still carry the withdrawn *52*; they are **superseded, not erased** (no force-push, hard rule 2; the changelog is regenerated, never hand-edited). **The timeline, to the hour.** `c2a6ab7` introduced the row at **18:06** on 2026-08-03 from a live SonarCloud query taken earlier that day; `9a772d4` (*style(hack): shell-script hygiene per SonarCloud shelldre rules*) landed at **22:23** the same evening and applied every rule family the row names. The row was stale after **4h17m** and nobody re-read it for 33 days. Two independent corroborations. First, arithmetic over the repo's own recorded live queries: 2026-08-03 totals 115 = 46 `go:S3776` + 54 shell + 15 misc, and the 2026-08-13 re-query at `agent-context/archive/INBOX-2026-08-16.md:71-72` reports **66 CODE_SMELL** — 66 cannot still contain the 54, because 66 − 54 = 12 is fewer than the 46 `go:S3776` alone. That query reports the total only; *"no shell findings remained"* is **derived from that arithmetic, not quoted from it**. Second, [D-150] (2026-08-16) describes the same residue as *"concentrated in 34 table-driven Go test files"*. **The 2 `S7688` sites outstanding today are a separate, later population**, not a remainder of the 54: `895ff7e` (BASH32-F01 / [D-154]) introduced them at `hack/validate-schemas-stock.sh:55-56` **20 days after** `9a772d4` cleared that set. They are deliberately not converted here, and are tracked as **SONAR-SHELL-R01** in `openspec/specs/backlog.md` — a standalone residual row, because a residual mentioned only inside a row that has just flipped to DONE leaves tracking with it, which is precisely what [D-166] recorded about E11-R01. They are one copy of a four-site idiom whose canonical spelling is the USAGE block in `hack/lib/require-bash.sh:40-41`, with siblings in `hack/release/verify-artifacts.sh:21-22` and `hack/docs/truthlag_pins_test.sh:45-46`; moving one copy puts the idiom at odds with its own canonical form for a 2-issue non-gating win, so they must move together. **What this lane contributes is the evidence that was missing.** `9a772d4`'s message claims *"byte-identical behaviour … no check semantics, exit codes, output strings, or control flow changed"* about four scripts that **are** the CI gates, and that claim had never been tested — the classic way a `[`→`[[`, an invented `*)` arm, or an added `return` converts a working gate into a fail-open one while every other gate stays green. A differential harness ran all four scripts in three revisions (`9a772d4^`, `9a772d4`, `20c80cb`) against happy-path *and* negative inputs — denylist hit / miss / missing-file, a planted internal hostname, a planted employee-ID, a base64-encoded denylist term inside a YAML value **and** the same payload in a non-YAML file that must stay clean, a `DRAFT` marker, a rego file with no quarantine marker, one whose marker follows the first code line, one where it precedes it, a stray `rego:` leaf, the negative fixture edited until it validates, the whole permanent-negative directory removed (the non-vacuity backstop fired), an unknown `kind`, an out-of-group fixture that must skip, and an unknown spike profile — byte-comparing exit status, stdout and stderr. **Every case was identical in all three revisions.** Non-vacuity was proven with four mutants, each red for its own reason: turning `check-migration-invariants.sh`'s `*) break ;;` arm into `*) ;;` flips its negative case **1 → 0**, a real fail-open; deleting `smoke.sh`'s `*)` profile arm replaces a named refusal with `BASE: unbound variable`; swapping the two locals `report()` now binds silently relabels every `HIT` line; and moving `report_fail()`'s explicit `return` above its `fail=1` flips the stock-validator's negative case **1 → 0**. **On that first mutant, stated precisely:** `9a772d4` did **not** touch `*) break ;;` — that arm predates it and is context in its diff. `9a772d4`'s `S131` fix added `*) ;;` to the **adjacent** case statement five lines above (`:55-58`, on `$line`), while `*) break ;;` belongs to the one below it (`:61-64`, on `$trimmed`). The hazard the mutant demonstrates is therefore sharper than "S131 is risky": where two case statements sit adjacently, an `S131` *add a default arm* aimed at the wrong one silently converts the gate to fail-open, and only a negative test over the ordering it enforces can tell the two apart. **Marked note — one withdrawn precision, per [D-166]'s F4-04 remedy.** An earlier draft published *"59 checks … all 59 identical"*. **That figure is withdrawn**: the harness lived in a scratch directory, was never committed, and is gone with the session, so no reader can falsify the count. The preferred fix — commit the harness as a standing gate — **is not available to this lane**: it belongs under `hack/lint/`, wired into `Taskfile.yml`'s `check:` and pinned in `CHECK_STAGES`, all outside this lane's fence with three lanes in flight, and landing it **unwired** would itself be the *gate invoked by nothing* defect [D-124] exists to forbid. Reconstructing a plausible harness and presenting it as the one that ran was rejected outright as making the record look reproducible while being false. What **is** recorded and re-runnable: the three revisions by SHA, the enumerated case list above, the comparison method (exit status + stdout + stderr, byte-compared, with bash's own `: line N:` diagnostic prefix normalised because the three legs run from different paths), the pinned validator (`npm ci --ignore-scripts --prefix hack/schemas-validator`), and GNU coreutils 9.11 ahead of this box's uutils build. What would close it: **SONAR-SHELL-R01** carries the harness as a standing-gate candidate. The argument here needs only that *every* case matched in all three revisions, which an independent reviewer reproduced over a subset. Gate matrix green on the unchanged scripts: `task check` EXIT=0 across 21 stages, `task coverage` 91.1% against the 91% floor. **The four scripts are byte-unchanged by this lane** — the correct outcome when the work is already done, and the reason this row records the real numbers instead of a padded 54. |
diff --git a/hack/release/README.md b/hack/release/README.md
index 0e2b1263..c6ef0bf8 100644
--- a/hack/release/README.md
+++ b/hack/release/README.md
@@ -16,6 +16,41 @@ template emits **categorized subject lines only — no commit SHAs** (D-101, oss
| `task changelog-verify` | Fail closed if `CHANGELOG.md` drifts from `cliff.toml` output (release gate) |
| `bash hack/release/verify-changelog.sh` | Same check as `task changelog-verify` (script entry point) |
| `task release-changelog-gate-test` | AUD-S02: proves the drift gate is wired and fires — in `task check` (REQ-AUD-S02-01/02) |
+| `bash hack/release/commit_subject_gate.sh` | REDMAIN-N1 / D-168: rejects a commit subject that leads with a **literal emoji** instead of the ASCII gitmoji shortcode |
+
+### Commit subjects must lead with the ASCII shortcode (REDMAIN-N1 / D-168)
+
+`cliff.toml`'s `commit_parsers` key on the ASCII gitmoji **shortcode**
+(`:construction_worker:`), which is also what `GUIDELINES.md` § Repository discipline mandates.
+A subject that leads with the **literal emoji** (`👷 ci(docs): …`) matches none of them and falls
+through the `.*` catch-all into `### Other` on the published GitHub Release page — the REL-14 /
+D-137 defect reached through a different door. `dfdae69` is the commit where human attention
+failed; `commit_subject_gate.sh` is the replacement for that attention.
+
+- The rule is narrow on purpose: **the first character of the subject must be ASCII.** It is not
+ "the subject matches the whole convention" — Dependabot's `build(deps): bump …` and GitHub's
+ `Merge pull request #N from …` carry no shortcode and are legitimate.
+- Published history is tolerated **by full commit SHA** (`LEGACY_ALLOW_SHAS`), never by shape, and
+ each exempt SHA must still *be* a detection — a stale exemption reds the gate. The list's exact
+ content is pinned from a second file (`LEGACY_EXPECTED`, `changelog_gate_test.sh` §9d), so it
+ cannot grow through an unremarked append: adding an exemption is a two-file change plus a row in
+ `docs/decisions/decisions.md`.
+- Reachable two ways: a step of `verify.yaml`'s `verify:` job (**not** guarded off
+ `pull_request`, so a PR reds while the subject can still be reworded) and, locally, `task check`
+ through `changelog_gate_test.sh` §9. No 22nd `task check` stage was added — `CHECK_STAGES` in
+ `hack/audit/exitgate_test.sh` asserts the Taskfile's `check:` list is *equal* to it.
+- Both polarities and the exemption's load-bearingness are proved in `changelog_gate_test.sh`
+ §9/§9a–§9d; the matching `### Other` detector fix is §8/§8b.
+
+**Two exemption lists, and they are not interchangeable.** `LEGACY_ALLOW_SHAS`
+(`commit_subject_gate.sh`) answers *"is this commit's SUBJECT still a literal emoji?"* — permanent,
+because hard rule 2 forbids rewriting the subject. `OTHER_EXEMPT_SHAS` (`changelog_gate_test.sh`
+§8) answers *"is this commit's RENDERED ENTRY still mis-filed under `### Other`?"* — temporary,
+because REDMAIN-N3 will teach `cliff.toml` to file it correctly. Deriving one from the other left
+**no green state** after N3 (keep the entry, §8 reds as stale; drop it, the commit-subject gate
+reds on `dfdae69`), so they are separate, linked only by the one-way invariant
+`OTHER_EXEMPT_SHAS ⊆ LEGACY_ALLOW_SHAS` — which the empty set satisfies. **When N3 lands, delete
+the SHA from `OTHER_EXEMPT_SHAS` and nothing else**; §8's failure message says exactly this.
Long-lived notes for consumers of released artifacts (currently the **D-120 `pins.toolDigest`**
warning) live in `cliff.toml`'s `[changelog] header`, not in `CHANGELOG.md`: the file is
diff --git a/hack/release/changelog_gate_test.sh b/hack/release/changelog_gate_test.sh
index 502efb77..a9d92c4e 100755
--- a/hack/release/changelog_gate_test.sh
+++ b/hack/release/changelog_gate_test.sh
@@ -545,6 +545,43 @@ echo "OK: polarity control — removing the parser brings all three merge subjec
# new parser entries must put it back under Other. The second assertion is
# structural rather than a snapshot, so it keeps holding as history grows: NO
# line in Other may declare a type this repo knows how to file.
+#
+# REDMAIN-N2 / D-168 — THE DETECTOR WAS FAIL-OPEN FOR THE SHAPE THAT CAUSES THE
+# BUG. Until this lane it read `^- :[a-z0-9_]+: ($FILEABLE_TYPES)[(:]`, which
+# REQUIRES an ASCII shortcode. `dfdae69`'s entry
+# `- 👷 ci(docs): stop uploading the Pages artifact on pull requests` declares the
+# fileable type `ci` and sits in Other — precisely what this section forbids —
+# but leads with a LITERAL EMOJI, so the pattern could not see it. Other went
+# 2 -> 3 entries while the OK line below still printed its hardcoded prose
+# "revert + one malformed subject". The prefix is now optional and admits three
+# spellings (shortcode / literal emoji / nothing at all); §8b pins the
+# regression by showing the OLD pattern miss the very probe the new one catches.
+#
+# Re-filing `dfdae69`'s rendered entry OUT of Other needs a `cliff.toml` parser
+# entry, which this lane's fence does not include (tracked as REDMAIN-N3). It is
+# therefore exempted here, by SHA, in OTHER_EXEMPT_SHAS below.
+#
+# WHY THAT LIST IS SEPARATE FROM `commit_subject_gate.sh`'s (P2-1). It was briefly
+# derived from it, and that had NO GREEN STATE after REDMAIN-N3 lands. The two
+# lists answer two different questions that DECOUPLE at exactly that moment:
+# * `LEGACY_ALLOW_SHAS` (commit_subject_gate.sh) — "is this commit's SUBJECT
+# still a literal emoji?" `dfdae69`'s subject can never change (hard rule 2),
+# so that entry is PERMANENT.
+# * `OTHER_EXEMPT_SHAS` (here) — "is this commit's RENDERED ENTRY still
+# mis-filed under Other?" REDMAIN-N3 makes that false, so this entry is
+# TEMPORARY.
+# Derived from one list, the retire message had to say "drop the SHA", and
+# following it reddened the commit-subject gate on `dfdae69` instead. Separated,
+# the N3 lane deletes ONE line — the entry below — and both gates are green.
+#
+# They are still linked, but only ONE WAY and only in the direction that cannot
+# deadlock: OTHER_EXEMPT_SHAS must be a SUBSET of LEGACY_ALLOW_SHAS, read live
+# through `--legacy-shas`. So this section can only ever excuse a line whose
+# commit is already an acknowledged, unrewritable literal-emoji subject — and the
+# empty set is a subset, which is precisely the post-N3 state.
+#
+# The exemption stays self-retiring: an exempt entry that no longer renders under
+# Other reds this section, and the remedy it prints is now executable.
echo "== 8. gitmoji subjects reach their real group, not Other (REL-14 / D-137) =="
@@ -563,6 +600,28 @@ AMBULANCE='- :ambulance: fix(forge): skip malformed bot markers with a warning i
# fits it, and inventing one is a changelog-structure change, not this fix.
FILEABLE_TYPES='feat|fix|docs|specs|refactor|style|test|chore|build|ci|perf|security'
+# The detector (REDMAIN-N2 / D-168). Any number of gitmoji-ish prefix tokens,
+# each an ASCII shortcode or a non-ASCII-leading token, separated and followed by
+# any run of spaces INCLUDING NONE. So a fileable type is seen behind every
+# spelling the classifier can be defeated by:
+# `- :bug: fix(a): …` `- 👷 ci(docs): …` `- ci(docs): …`
+# `- 👷 ci(docs): …` `- 👷ci(docs): …` `- 🚀 :rocket: feat(x): …`
+# The first version of this fix accepted exactly ONE non-ASCII token followed by
+# exactly ONE space, and review found the last three shapes slipping past — the
+# same species of shape-specific detector that REDMAIN-N2 was filed against, so
+# the quantifiers are now the general ones. The prefix alternatives stay narrow
+# on purpose: an ordinary ASCII word is neither a shortcode nor non-ASCII-leading,
+# so prose like `- some notes about a fix(thing): …` is still not matched.
+# `[^ -~]` is a BYTE class under LC_ALL=C — every byte outside printable ASCII,
+# which is every lead byte of a UTF-8 emoji. It is spelled that way because
+# `[:ascii:]` is a PCRE extension GNU grep does not implement, and because a
+# locale-dependent class would make this gate's verdict depend on the runner's
+# LANG. Every use of these two patterns therefore goes through `LC_ALL=C grep`.
+OTHER_MAPPABLE_RE="^- ((:[a-z0-9_+-]+:|[^ -~][^ ]*) *)*($FILEABLE_TYPES)[(:]"
+# The pre-D-168 pattern, kept ONLY as §8b's regression control. It is what
+# fail-open looked like; nothing outside §8b may use it.
+OTHER_MAPPABLE_RE_PREFIX_REQUIRED="^- :[a-z0-9_]+: ($FILEABLE_TYPES)[(:]"
+
group_lines "$WORK/clean-full.md" >"$WORK/clean.groups"
[[ -s "$WORK/clean.groups" ]] || fail "group extraction produced no lines — section 8's assertions would all be vacuous"
distinct_groups="$(cut -f1 "$WORK/clean.groups" | sort -u | wc -l | tr -d ' ')"
@@ -579,11 +638,72 @@ anchor_group="$(grep -F -e "$AMBULANCE" "$WORK/clean.groups" | head -1 | cut -f1
echo "OK: the :ambulance: hotfix renders under Fixes"
awk -F'\t' '$1 == "Other" { print $2 }' "$WORK/clean.groups" >"$WORK/clean.other"
-if grep -nE "^- :[a-z0-9_]+: ($FILEABLE_TYPES)[(:]" "$WORK/clean.other" >"$WORK/clean.other.mappable"; then
+# The Other block must be non-empty: an empty one would make the detector below
+# pass for the wrong reason. The EXEMPTION list, by contrast, is allowed to be
+# empty — empty is the post-REDMAIN-N3 steady state, and with no exemptions in
+# force every Other line is checked, which is strictly stronger.
+[[ -s "$WORK/clean.other" ]] \
+ || fail "no bullets extracted for the Other group — the detector assertion below would be vacuous (the awk range or the render is broken)"
+
+# SHAs whose RENDERED entry is knowingly still mis-filed under Other. TEMPORARY —
+# see the section header: this answers "is the entry still mis-filed?", NOT "is
+# the subject still a literal emoji?", and REDMAIN-N3 makes the first false while
+# the second stays true forever. When N3 lands, DELETE THE LINE BELOW and nothing
+# else; commit_subject_gate.sh's LEGACY_ALLOW_SHAS is not touched.
+# dfdae69 — `- 👷 ci(docs): stop uploading the Pages artifact on pull requests`,
+# the REDMAIN-N2 entry. cliff.toml has no parser for a literal-emoji
+# prefix, so it renders under Other; adding one is REDMAIN-N3.
+OTHER_EXEMPT_SHAS=(
+ dfdae69143c3bd5b4819df106bf6fbbad18eb4fc
+)
+
+SUBJECT_GATE="$ROOT/hack/release/commit_subject_gate.sh"
+[[ -f "$SUBJECT_GATE" ]] || fail "missing $SUBJECT_GATE — §8's subset invariant and §9 both read it (REDMAIN-N1/N2, D-168)"
+
+: >"$WORK/other.exempt"
+n_exempt=0
+if ((${#OTHER_EXEMPT_SHAS[@]} > 0)); then
+ # The one-way link that replaced the deadlocking one: every SHA excused HERE
+ # must already be an acknowledged unrewritable literal-emoji commit THERE. The
+ # empty set trivially satisfies this, which is why it cannot deadlock.
+ bash "$SUBJECT_GATE" --legacy-shas >"$WORK/legacy.shas" 2>"$WORK/legacy.err" || {
+ cat "$WORK/legacy.err" >&2
+ fail "'commit_subject_gate.sh --legacy-shas' failed — §8 cannot check that its exemptions are a subset of the acknowledged published-history set"
+ }
+ [[ -s "$WORK/legacy.shas" ]] \
+ || fail "'commit_subject_gate.sh --legacy-shas' printed nothing while §8 lists ${#OTHER_EXEMPT_SHAS[@]} exemption(s) — the subset check below would be vacuous"
+ for sha in "${OTHER_EXEMPT_SHAS[@]}"; do
+ grep -qxF -e "$sha" "$WORK/legacy.shas" \
+ || fail "§8 exempts $sha from the Other check, but it is NOT in LEGACY_ALLOW_SHAS in hack/release/commit_subject_gate.sh — this section may only excuse a rendered entry whose commit is already an acknowledged, unrewritable literal-emoji subject"
+ subject="$(git -C "$ROOT" log -1 --format=%s "$sha" 2>/dev/null || true)"
+ [[ -n "$subject" ]] \
+ || fail "§8 exempts $sha but no subject can be read for it in this repository — the exemption names a commit that is not here"
+ grep -qxF -e "- $subject" "$WORK/clean.other" \
+ || fail "the exempted entry '- $subject' ($sha) no longer renders under Other — cliff.toml files it correctly now, so REDMAIN-N3 has landed and this exemption is dead scaffolding. REMEDY: delete $sha from OTHER_EXEMPT_SHAS in hack/release/changelog_gate_test.sh §8, and NOTHING else. Do NOT touch LEGACY_ALLOW_SHAS in hack/release/commit_subject_gate.sh — that list is keyed on the commit SUBJECT, which re-filing does not change, and removing it there would red the commit-subject gate on this very commit (REQ-REDMAIN-N2-02)"
+ printf '%s\n' "- $subject" >>"$WORK/other.exempt"
+ done
+ [[ -s "$WORK/other.exempt" ]] \
+ || fail "the exemption file is empty after iterating ${#OTHER_EXEMPT_SHAS[@]} non-empty exemption(s) — the loop is broken"
+ n_exempt="$(wc -l <"$WORK/other.exempt" | tr -d ' ')"
+ grep -v -x -F -f "$WORK/other.exempt" "$WORK/clean.other" >"$WORK/clean.other.checked" || true
+else
+ cp "$WORK/clean.other" "$WORK/clean.other.checked"
+fi
+n_other="$(wc -l <"$WORK/clean.other" | tr -d ' ')"
+n_checked="$(wc -l <"$WORK/clean.other.checked" | tr -d ' ')"
+[[ "$n_checked" -eq $((n_other - n_exempt)) ]] \
+ || fail "exemption subtraction removed $((n_other - n_checked)) line(s) for $n_exempt exemption(s) — the filter is not matching whole lines"
+if ((n_exempt == 0)); then
+ echo "OK: no published-history exemption in force — all $n_checked Other line(s) are checked"
+else
+ echo "OK: $n_exempt published-history exemption(s) still render under Other, $n_checked line(s) left to check"
+fi
+
+if LC_ALL=C grep -nE "$OTHER_MAPPABLE_RE" "$WORK/clean.other.checked" >"$WORK/clean.other.mappable"; then
cat "$WORK/clean.other.mappable" >&2
- fail "the lines above declare a conventional type this repo files, yet render under Other — extend cliff.toml's type-keyed parsers (REL-14)"
+ fail "the lines above declare a conventional type this repo files, yet render under Other — extend cliff.toml's type-keyed parsers (REL-14). A literal-emoji prefix, in any spacing, is no longer a way past this check (REDMAIN-N2 / D-168)"
fi
-echo "OK: nothing in Other declares a fileable type ($(wc -l <"$WORK/clean.other" | tr -d ' ') line(s) remain in Other by design: revert + one malformed subject)"
+echo "OK: nothing in Other declares a fileable type ($n_other line(s) in Other: $n_exempt exempt published-history entr(y/ies) + $n_checked by design — revert, which has no group, and one malformed ':test(release):' subject that declares no parseable type)"
echo "== 8a. the grouping assertions can fail (mutation) =="
mutant_cfg2="$WORK/cliff.no-type-parsers.toml"
@@ -612,4 +732,252 @@ if ! cmp -s "$WORK/clean.subjects" "$WORK/mutant.subjects"; then
fi
echo "OK: identical subject multiset before and after grouping — the parsers only re-file"
-echo "PASS: changelog drift gate regenerated, wired into task check + verify.yaml, and proven at both polarities (REQ-AUD-S02-01/02); release body carries the compatibility notes and no merge subject (D-136); every fileable subject reaches its real group (REL-14 / D-137)"
+# --- 8b. the Other detector sees a fileable type behind ANY prefix (REDMAIN-N2) --
+#
+# §8's clean assertion is an ABSENCE, and an absence is only evidence when the
+# detector that produced it is known to detect. The exempt line is subtracted
+# before §8's grep, so the real Other block no longer exercises the literal-emoji
+# path at all — this section is where that path is actually proved, on a probe
+# whose expected verdict is written down line by line.
+#
+# It also pins the REGRESSION, not just the fix: the pre-D-168 pattern is run
+# against the same probe and must MISS the literal-emoji line. If someone
+# reverts OTHER_MAPPABLE_RE to the prefix-required form, the two assertions here
+# collapse into each other and this section reds.
+
+echo "== 8b. the Other detector sees a fileable type behind any prefix (REDMAIN-N2 / D-168) =="
+# The last four accept-lines are the shapes review found slipping past the first
+# version of this fix, which required exactly one non-ASCII token and exactly one
+# space. Each would render into Other with a fileable type and go unseen.
+cat >"$WORK/other.probe" <<'PROBE'
+- 👷 ci(docs): literal-emoji prefix, fileable type — the REDMAIN-N2 defect
+- :bug: fix(forge): ASCII shortcode prefix, fileable type
+- ci(docs): no prefix at all, fileable type
+- 👷 ci(docs): literal emoji then TWO spaces, fileable type
+- 👷ci(docs): literal emoji with NO space, fileable type
+- 🚀 :rocket: feat(x): literal emoji AND a shortcode, fileable type
+- 👷 :zap: ⚡ perf(core): three prefix tokens of both kinds, fileable type
+- :rewind: revert(kind): revert is deliberately not fileable — must NOT be flagged
+- :test(release): malformed subject declaring no parseable type — must NOT be flagged
+- Merge pull request #1 from org/branch — not a conventional subject at all
+- 👷 revert(kind): a literal emoji does not make revert fileable — must NOT be flagged
+- some release notes about a fix(thing): prose, not a prefix — must NOT be flagged
+PROBE
+[[ "$(wc -l <"$WORK/other.probe" | tr -d ' ')" -eq 12 ]] \
+ || fail "the §8b probe did not land with its 12 lines — the heredoc is broken and every count below is meaningless"
+
+LC_ALL=C grep -nE "$OTHER_MAPPABLE_RE" "$WORK/other.probe" >"$WORK/probe.hits" || true
+[[ "$(wc -l <"$WORK/probe.hits" | tr -d ' ')" -eq 7 ]] \
+ || { cat "$WORK/probe.hits" >&2; fail "the Other detector flags $(wc -l <"$WORK/probe.hits" | tr -d ' ') of the 12 probe lines, want exactly 7 (the seven fileable ones)"; }
+for want in \
+ 'ci(docs): literal-emoji prefix' \
+ 'fix(forge): ASCII shortcode prefix' \
+ 'ci(docs): no prefix at all' \
+ 'literal emoji then TWO spaces' \
+ 'literal emoji with NO space' \
+ 'literal emoji AND a shortcode' \
+ 'three prefix tokens of both kinds'; do
+ grep -qF -e "$want" "$WORK/probe.hits" \
+ || fail "the Other detector does NOT flag the probe line containing '$want' — a mis-filed entry of that shape would render on the Release page unseen, which is the REDMAIN-N2 defect in a different spacing"
+done
+for reject in \
+ 'revert is deliberately not fileable' \
+ 'declaring no parseable type' \
+ 'not a conventional subject at all' \
+ 'does not make revert fileable' \
+ 'prose, not a prefix'; do
+ if grep -qF -e "$reject" "$WORK/probe.hits"; then
+ fail "the Other detector flags the probe line containing '$reject' — it is over-firing on entries that belong in Other, which would make this gate unfixable"
+ fi
+done
+echo "OK: 7/12 probe lines flagged — shortcode, literal emoji (one space, two spaces, no space), emoji+shortcode, three mixed tokens and a bare type all seen; revert (with and without emoji), the malformed subject, a merge subject and prose all left alone"
+
+LC_ALL=C grep -nE "$OTHER_MAPPABLE_RE_PREFIX_REQUIRED" "$WORK/other.probe" >"$WORK/probe.hits.old" || true
+[[ "$(wc -l <"$WORK/probe.hits.old" | tr -d ' ')" -eq 1 ]] \
+ || { cat "$WORK/probe.hits.old" >&2; fail "the pre-D-168 pattern flags $(wc -l <"$WORK/probe.hits.old" | tr -d ' ') probe line(s), want exactly 1 — the regression control is not reproducing the old behaviour, so the 'this was fail-open' claim below is unproved"; }
+if grep -qF -e 'literal-emoji prefix' "$WORK/probe.hits.old"; then
+ fail "the pre-D-168 pattern flags the literal-emoji line — then REDMAIN-N2 was not a real fail-open and OTHER_MAPPABLE_RE has been reverted to the prefix-required form"
+fi
+echo "OK: regression control — the pre-D-168 pattern sees 1 of the 7, and is blind to every literal-emoji spelling that caused REDMAIN-N2"
+
+# ------- 9. a literal-emoji commit subject is rejected by a gate (REDMAIN-N1) --
+#
+# The other half of the SAME defect. §8/§8b make a mis-filed entry visible in the
+# rendered changelog; this section makes the commit that produces one impossible
+# to land unnoticed. `GUIDELINES.md` § Repository discipline mandates the ASCII
+# shortcode and, until D-168, NOTHING enforced it: there was no commit-message
+# linter anywhere in hack/** or .github/workflows/**, which is how `dfdae69`
+# reached published history and, through `cliff.toml`'s shortcode-keyed parsers,
+# the `### Other` group of the published Release page.
+#
+# The gate lives in `hack/release/commit_subject_gate.sh` and is reached two ways:
+# * this script, i.e. `task check` stage `release-changelog-gate-test` (a 22nd
+# stage was NOT added: `CHECK_STAGES` in hack/audit/exitgate_test.sh asserts
+# the Taskfile's check: list is EQUAL to it, so adding one is a change to that
+# pin — see D-168);
+# * a step of verify.yaml's `verify:` job, which is NOT guarded off
+# `pull_request` (unlike the changelog drift gate, D-125/OQ-30), so a lane's
+# literal-emoji commit reds its own PR instead of reddening main after merge.
+#
+# Polarities proved here, in order: green on real history; green on an all-ASCII
+# sandbox; RED the moment a literal-emoji commit is added to that sandbox; the
+# allowlist pinned to an exact expected content so it cannot grow unremarked; and
+# RED on real history with the published-history exemption stripped.
+
+echo "== 9. a literal-emoji commit subject is rejected by a gate (REDMAIN-N1 / D-168) =="
+
+bash "$SUBJECT_GATE" >"$WORK/subject.self" 2>&1 || {
+ cat "$WORK/subject.self" >&2
+ fail "hack/release/commit_subject_gate.sh is RED on this repository's own history — a new commit subject leads with a literal emoji, or the published-history exemption has gone stale"
+}
+grep -q '^OK: ' "$WORK/subject.self" \
+ || fail "commit_subject_gate.sh exited 0 without its OK line — it returned success without reporting a scan (output: $(head -1 "$WORK/subject.self"))"
+cat "$WORK/subject.self"
+
+echo "== 9a. wiring: verify.yaml's verify: job runs the commit-subject gate on pull_request =="
+extract_step "$WORKFLOW" verify 'commit subject' >"$WORK/step.subject"
+[[ -s "$WORK/step.subject" ]] \
+ || fail "could not extract a 'commit subject' step from verify.yaml's verify: job — the gate is defined but nothing runs it on a pull request (REQ-REDMAIN-N1-02)"
+grep -qF 'bash hack/release/commit_subject_gate.sh' "$WORK/step.subject" \
+ || fail "the extracted verify: step does not run 'bash hack/release/commit_subject_gate.sh' — extraction matched the wrong step"
+# No repo argument: the argument form is the sandbox/foreign mode, which skips the
+# published-history exemption self-checks. CI must run the self mode.
+grep -qE 'bash hack/release/commit_subject_gate\.sh[[:space:]]*$' "$WORK/step.subject" \
+ || fail "verify.yaml runs commit_subject_gate.sh WITH an argument — that is foreign mode, which skips the exemption self-checks; CI must run it with no argument"
+if grep -qF "github.event_name" "$WORK/step.subject"; then
+ fail "the commit-subject gate step carries an event guard — it must run on pull_request, which is the only place a bad subject can still be reworded (hard rule 2 forbids rewriting it afterwards)"
+fi
+echo "OK: verify: job runs the commit-subject gate, unguarded, in self mode"
+
+echo "== 9b. the wiring assertion itself can fail (mutation) =="
+mutant_wf_subject="$WORK/verify.no-subject-gate.yaml"
+grep -vF 'bash hack/release/commit_subject_gate.sh' "$WORKFLOW" >"$mutant_wf_subject"
+if grep -qF 'bash hack/release/commit_subject_gate.sh' "$mutant_wf_subject"; then
+ fail "mutation did not land: $mutant_wf_subject still runs the commit-subject gate"
+fi
+[[ "$(wc -l <"$mutant_wf_subject")" -lt "$(wc -l <"$WORKFLOW")" ]] \
+ || fail "mutation did not land: $mutant_wf_subject has the same line count as verify.yaml"
+extract_step "$mutant_wf_subject" verify 'commit subject' >"$WORK/step.subject.mutant"
+if grep -qF 'bash hack/release/commit_subject_gate.sh' "$WORK/step.subject.mutant"; then
+ fail "the wiring assertion reports the gate present in a workflow with that run line deleted — §9a is vacuous"
+fi
+echo "OK: deleting the run line from verify.yaml turns the wiring assertion red"
+
+echo "== 9c. both polarities over a sandbox repository =="
+SUBJ_SANDBOX="$WORK/subject-sandbox"
+mkdir -p "$SUBJ_SANDBOX"
+# Same self-containment discipline as §7b: GIT_DIR and friends come from the
+# ENVIRONMENT and beat `-C`, so a `task check` reached from a git hook or a CI
+# wrapper that exports them would otherwise have this block commit into the REAL
+# repository.
+ssgit() {
+ env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY \
+ -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_COMMON_DIR \
+ git -C "$SUBJ_SANDBOX" \
+ -c user.name='commit subject gate' -c user.email='gate@example.invalid' \
+ -c commit.gpgsign=false -c core.hooksPath=/dev/null \
+ -c init.defaultBranch=main -c advice.detachedHead=false "$@"
+}
+env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE -u GIT_OBJECT_DIRECTORY \
+ -u GIT_ALTERNATE_OBJECT_DIRECTORIES -u GIT_COMMON_DIR \
+ git init -q "$SUBJ_SANDBOX" >/dev/null 2>&1 || fail "could not git init the subject sandbox"
+[[ -d "$SUBJ_SANDBOX/.git" ]] \
+ || fail "subject sandbox has no .git — git init landed somewhere else (a GIT_DIR in the environment?)"
+subj_expected="$(cd "$SUBJ_SANDBOX" && pwd -P)"
+subj_actual="$(cd "$(ssgit rev-parse --show-toplevel)" && pwd -P)"
+[[ "$subj_actual" == "$subj_expected" ]] \
+ || fail "subject sandbox git commands resolve to '$subj_actual', not '$subj_expected' — the environment is redirecting them at another repository"
+ssgit symbolic-ref HEAD refs/heads/main
+
+# The legitimate shapes this gate must NEVER reject: the project convention, and
+# the two bot/forge subjects that carry no shortcode at all.
+printf 'a\n' >"$SUBJ_SANDBOX/a.txt"; ssgit add -A
+ssgit commit -q -m ':sparkles: feat(sandbox): the project convention, ASCII shortcode first'
+printf 'b\n' >"$SUBJ_SANDBOX/b.txt"; ssgit add -A
+ssgit commit -q -m 'build(deps): bump some/action from 1.2.3 to 1.2.4'
+ssgit checkout -q -b side
+printf 'c\n' >"$SUBJ_SANDBOX/c.txt"; ssgit add -A
+ssgit commit -q -m ':bug: fix(sandbox): a fix on a side branch'
+ssgit checkout -q main
+ssgit merge -q --no-ff side -m 'Merge pull request #1 from org/side'
+
+bash "$SUBJECT_GATE" "$SUBJ_SANDBOX" >"$WORK/subject.sandbox.clean" 2>&1 || {
+ cat "$WORK/subject.sandbox.clean" >&2
+ fail "the commit-subject gate is RED on an all-ASCII sandbox — it rejects Dependabot's 'build(deps): …', GitHub's 'Merge pull request …', or the project convention itself, which would make it unusable"
+}
+sandbox_n="$(ssgit rev-list --count HEAD)"
+[[ "$sandbox_n" -eq 4 ]] \
+ || fail "the sandbox has $sandbox_n commits, expected 4 — it was not built as intended and the red below would not mean what it says"
+grep -qF "$sandbox_n commit subject(s) scanned" "$WORK/subject.sandbox.clean" \
+ || fail "the gate reported success without scanning all $sandbox_n sandbox commits (said: $(cat "$WORK/subject.sandbox.clean")) — a green that skipped the history proves nothing"
+echo "OK: green on a $sandbox_n-commit all-ASCII sandbox (convention + dependabot + merge subjects all accepted)"
+
+printf 'd\n' >"$SUBJ_SANDBOX/d.txt"; ssgit add -A
+ssgit commit -q -m '👷 ci(docs): stop uploading the Pages artifact on pull requests'
+bad_sha="$(ssgit rev-parse HEAD)"
+if bash "$SUBJECT_GATE" "$SUBJ_SANDBOX" >"$WORK/subject.sandbox.bad" 2>&1; then
+ cat "$WORK/subject.sandbox.bad" >&2
+ fail "the commit-subject gate exited 0 on a sandbox containing a literal-emoji subject — REDMAIN-N1 is NOT closed (this is the exact shape of dfdae69)"
+fi
+grep -qF "$bad_sha" "$WORK/subject.sandbox.bad" \
+ || fail "the gate went red but never named the offending commit $bad_sha — an author cannot act on it (output: $(head -3 "$WORK/subject.sandbox.bad"))"
+grep -qF 'shortcode' "$WORK/subject.sandbox.bad" \
+ || fail "the gate's failure message does not tell the author to use the ASCII shortcode — it reds without a remedy"
+echo "OK: adding ONE literal-emoji commit to the same sandbox turns the gate red and names it"
+
+echo "== 9d. the published-history exemption is load-bearing, pinned, and not decoration =="
+# The exemption list is an ALLOWLIST, so its size is a security property, not a
+# detail. Without this pin a lane could land a literal-emoji commit and append its
+# SHA to LEGACY_ALLOW_SHAS in a later commit of the SAME pull request, and every
+# self-check inside the gate would still pass: the new SHA resolves, it is an
+# ancestor of HEAD, and it genuinely IS a detection. Pinning the exact content
+# here — in a different file — makes growing the allowlist a deliberate two-file
+# change a reviewer sees, the same mechanism CHECK_STAGES uses on `task check`'s
+# stage list. Adding an entry means editing BOTH lists and recording why in
+# docs/decisions/decisions.md.
+LEGACY_EXPECTED=(
+ dfdae69143c3bd5b4819df106bf6fbbad18eb4fc
+)
+bash "$SUBJECT_GATE" --legacy-shas >"$WORK/legacy.actual" 2>"$WORK/legacy.actual.err" || {
+ cat "$WORK/legacy.actual.err" >&2
+ fail "'commit_subject_gate.sh --legacy-shas' failed — the allowlist cannot be pinned"
+}
+# Non-empty on BOTH sides: a mistyped path or a silently empty mode must fail
+# loudly rather than compare two empty files and report agreement. The expected
+# side is guarded on ARRAY LENGTH, not on file size — `printf '%s\n' "${a[@]}"`
+# on an empty array still writes one blank line, so a file-size guard here would
+# be dead code that never fires (and the array expansion itself is unsafe under
+# `set -u` on bash 3.2 when empty).
+((${#LEGACY_EXPECTED[@]} > 0)) \
+ || fail "LEGACY_EXPECTED is empty — this pin would accept any allowlist at all; if LEGACY_ALLOW_SHAS is genuinely empty now, delete this whole pin deliberately rather than emptying it"
+printf '%s\n' "${LEGACY_EXPECTED[@]}" | sort >"$WORK/legacy.expected.sorted"
+sort "$WORK/legacy.actual" >"$WORK/legacy.actual.sorted"
+[[ -s "$WORK/legacy.actual.sorted" ]] \
+ || fail "'--legacy-shas' printed nothing — either LEGACY_ALLOW_SHAS is empty (then delete this pin deliberately) or the mode is broken; either way the comparison below would be vacuous"
+if ! diff -u "$WORK/legacy.expected.sorted" "$WORK/legacy.actual.sorted" >"$WORK/legacy.diff"; then
+ cat "$WORK/legacy.diff" >&2
+ fail "LEGACY_ALLOW_SHAS in hack/release/commit_subject_gate.sh does not match LEGACY_EXPECTED here (${#LEGACY_EXPECTED[@]} pinned, $(wc -l <"$WORK/legacy.actual.sorted" | tr -d ' ') actual). An exemption is a DECISION: adding one means editing both lists in the same change and recording the reason in docs/decisions/decisions.md — it must never be an unremarked append"
+fi
+echo "OK: allowlist pinned at exactly ${#LEGACY_EXPECTED[@]} exemption(s), content-identical across the two files"
+
+# If the gate were vacuous over real history, deleting the exemption would change
+# nothing. It must red, and it must red naming dfdae69 — the REDMAIN-N1 commit.
+LEGACY_ANCHOR='dfdae69143c3bd5b4819df106bf6fbbad18eb4fc'
+grep -qF "$LEGACY_ANCHOR" "$SUBJECT_GATE" \
+ || fail "the REDMAIN-N1 anchor $LEGACY_ANCHOR is not listed in $SUBJECT_GATE — the exemption this section mutates does not exist, so the mutation below would prove nothing"
+mutant_gate="$WORK/commit_subject_gate.no-exemption.sh"
+grep -v "$LEGACY_ANCHOR" "$SUBJECT_GATE" >"$mutant_gate"
+if grep -qF "$LEGACY_ANCHOR" "$mutant_gate"; then
+ fail "mutation did not land: $LEGACY_ANCHOR is still in $mutant_gate"
+fi
+[[ "$(wc -l <"$mutant_gate")" -lt "$(wc -l <"$SUBJECT_GATE")" ]] \
+ || fail "mutation did not land: $mutant_gate has the same line count as $SUBJECT_GATE"
+if bash "$mutant_gate" "$ROOT" >"$WORK/subject.no-exemption" 2>&1; then
+ cat "$WORK/subject.no-exemption" >&2
+ fail "with the exemption deleted the gate STILL passes on this repository — it is not actually scanning published history, so its green above is vacuous"
+fi
+grep -qF "$LEGACY_ANCHOR" "$WORK/subject.no-exemption" \
+ || fail "the exemption-free gate reds on this repository but does not name $LEGACY_ANCHOR — it is failing for some other reason"
+echo "OK: deleting the exemption reds the gate on real history, naming $LEGACY_ANCHOR"
+
+echo "PASS: changelog drift gate regenerated, wired into task check + verify.yaml, and proven at both polarities (REQ-AUD-S02-01/02); release body carries the compatibility notes and no merge subject (D-136); every fileable subject reaches its real group (REL-14 / D-137) behind any prefix shape, and a literal-emoji commit subject is rejected by a gate rather than by a human (REDMAIN-N1/N2 / D-168)"
diff --git a/hack/release/commit_subject_gate.sh b/hack/release/commit_subject_gate.sh
new file mode 100644
index 00000000..666d691f
--- /dev/null
+++ b/hack/release/commit_subject_gate.sh
@@ -0,0 +1,227 @@
+#!/usr/bin/env bash
+# commit_subject_gate.sh — REDMAIN-N1 / D-168.
+#
+# A commit subject must not START with a non-ASCII character.
+#
+# WHY THIS IS A GATE AND NOT A STYLE PREFERENCE
+# `GUIDELINES.md` § Repository discipline mandates the ASCII gitmoji shortcode
+# (`:construction_worker:`), and `cliff.toml`'s commit_parsers key on exactly
+# that spelling. A subject that leads with a LITERAL emoji (`👷 ci(docs): …`)
+# matches none of them and falls through the `.*` catch-all into `### Other` on
+# the published GitHub Release page — the REL-14 / D-137 defect reached through
+# a different door. `dfdae69` is the commit where the human-attention
+# enforcement failed; this file is the replacement for that attention.
+#
+# THE RULE, STATED NARROWLY ON PURPOSE
+# "first character is not ASCII" — NOT "the subject matches the full convention".
+# Two legitimate subject shapes in this repository's history do not carry a
+# shortcode at all and must not be rejected: Dependabot's `build(deps): bump …`
+# and GitHub's `Merge pull request #N from …`. Over all of this repo's history
+# the narrow rule has exactly one hit, `dfdae69`, which is the defect itself.
+# Widening this gate to the full convention is a separate decision with its own
+# evidence; do not do it by editing the pattern here.
+#
+# PUBLISHED HISTORY IS TOLERATED BY SHA, NEVER BY SHAPE
+# Hard rule 2 forbids rewriting `dfdae69`, so it is exempted — by full commit
+# SHA, in LEGACY_ALLOW_SHAS below. A SHA cannot be inherited by a future commit,
+# which a pattern-shaped exemption could be. Three self-checks keep the
+# exemption honest, all in self mode:
+# * every listed SHA must RESOLVE here and be an ancestor of HEAD;
+# * every listed SHA must ITSELF be a detection — an exempt commit that is not
+# a violation is stale scaffolding and reds the gate;
+# * the detector is re-proved on every run against a fabricated known-bad and
+# known-good subject, so a mistyped pattern fails loudly instead of matching
+# nothing and reporting success (the vacuity mode this repo has shipped
+# twice).
+# The list's LENGTH AND CONTENT are pinned from outside this file, in
+# changelog_gate_test.sh §9d — otherwise a lane could land a literal-emoji
+# commit and append its SHA in a later commit of the same PR, and all three
+# self-checks above would still pass (it resolves, it is an ancestor, it IS a
+# real detection). Growing this list is a two-file change on purpose.
+#
+# WHAT THIS LIST IS *NOT* (P2-1 — read this before deleting an entry)
+# Its predicate is "this COMMIT SUBJECT is a literal emoji and cannot be
+# rewritten". That fact is PERMANENT: `dfdae69`'s subject stays a literal emoji
+# for the life of the repository, so this entry is permanent too.
+# `changelog_gate_test.sh` §8 keeps a SEPARATE list, OTHER_EXEMPT_SHAS, whose
+# predicate is the different and TEMPORARY one "this commit's RENDERED entry is
+# still mis-filed under `### Other`". When REDMAIN-N3 teaches `cliff.toml` to
+# file the entry correctly, §8's list retires and THIS one does not. The two
+# were briefly derived from one another; that coupling had no green state after
+# N3 (keep the entry and §8 reds as stale; drop it and this gate reds on
+# `dfdae69`), so they are deliberately independent, linked only by the one-way
+# subset invariant §8 asserts through `--legacy-shas`.
+#
+# USAGE
+# commit_subject_gate.sh scan this repository (self mode)
+# commit_subject_gate.sh scan another repository (foreign mode —
+# used by changelog_gate_test.sh §9 to
+# drive both polarities over a sandbox;
+# the exemption self-checks are skipped
+# because the SHAs do not exist there)
+# commit_subject_gate.sh --legacy-shas print the exempt SHAs, one per line
+# (self mode only). §8 reads it to assert
+# its own OTHER_EXEMPT_SHAS is a SUBSET
+# of this list, and §9d reads it to pin
+# the list's exact content.
+#
+# Deliberately bash-3.2 clean (no associative arrays, no mapfile) — see
+# hack/lint/bash_version_guard_test.sh for why that matters in this tree.
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
+
+# Full 40-hex SHAs of commits in PUBLISHED history that violate the rule and
+# cannot be rewritten. Append only with a recorded reason; never a prefix, never
+# a pattern. Adding one here ALSO requires updating LEGACY_EXPECTED in
+# changelog_gate_test.sh §9d, which is what makes it a deliberate decision.
+# dfdae69 — `👷 ci(docs): stop uploading the Pages artifact on pull requests`.
+# Landed before any gate existed; REDMAIN-N1. Rewriting it would
+# rewrite published history (hard rule 2). PERMANENT — see "WHAT THIS
+# LIST IS NOT" above; REDMAIN-N3 does not retire it.
+LEGACY_ALLOW_SHAS=(
+ dfdae69143c3bd5b4819df106bf6fbbad18eb4fc
+)
+
+MODE_SHAS=0
+REPO="$ROOT"
+case "${1:-}" in
+ --legacy-shas)
+ MODE_SHAS=1
+ ;;
+ "") ;;
+ -*)
+ echo "usage: $(basename "$0") [--legacy-shas | ]" >&2
+ exit 2
+ ;;
+ *)
+ REPO="$(cd "$1" && pwd)"
+ ;;
+esac
+SELF=0
+[[ "$REPO" == "$ROOT" ]] && SELF=1
+
+fail() {
+ echo "FAIL: $*" >&2
+ exit 1
+}
+
+WORK="$(mktemp -d)"
+trap 'rm -rf "$WORK"' EXIT
+
+TAB=$'\t'
+# The detector. One definition, used for the real scan AND for the self-proof
+# below, so the two can never diverge. LC_ALL=C makes `[^ -~]` a BYTE class —
+# every byte outside printable ASCII, which is every lead byte of a UTF-8
+# emoji — instead of a locale-dependent character class (`[:ascii:]` is a PCRE
+# extension GNU grep does not have).
+SUBJECT_VIOLATION_RE="^[0-9a-f]{7,40}${TAB}[^ -~]"
+
+# detect — write the violating `\t` lines.
+# Returns 0 whether or not it found any; callers test the output file, so a
+# grep-exit-1 cannot be confused with a scan that did not run.
+detect() {
+ LC_ALL=C grep -E "$SUBJECT_VIOLATION_RE" "$1" >"$2" || true
+}
+
+# ------------------------------------------------------ detector self-proof --
+#
+# Runs on EVERY invocation, before anything is concluded from a clean scan. A
+# clean result is only evidence if the detector still detects.
+printf '%s\n' \
+ "1111111111111111111111111111111111111111${TAB}👷 ci(docs): a literal-emoji subject" \
+ "2222222222222222222222222222222222222222${TAB}:construction_worker: ci(docs): the same subject, spelled correctly" \
+ "3333333333333333333333333333333333333333${TAB}build(deps): bump something from 1 to 2" \
+ "4444444444444444444444444444444444444444${TAB}Merge pull request #1 from org/branch" \
+ >"$WORK/selfproof.log"
+detect "$WORK/selfproof.log" "$WORK/selfproof.hits"
+[[ "$(wc -l <"$WORK/selfproof.hits" | tr -d ' ')" -eq 1 ]] \
+ || fail "detector self-proof: the fabricated known-bad subject is not the ONE hit ($(wc -l <"$WORK/selfproof.hits" | tr -d ' ') hit(s)) — SUBJECT_VIOLATION_RE is broken, so a clean scan below would prove nothing"
+grep -q '^1111111111111111111111111111111111111111' "$WORK/selfproof.hits" \
+ || fail "detector self-proof: the single hit is not the known-bad line — SUBJECT_VIOLATION_RE matches the wrong thing"
+
+# ------------------------------------------------------------- the real scan --
+
+git -C "$REPO" log --format="%H${TAB}%s" HEAD >"$WORK/log" 2>"$WORK/log.err" || {
+ cat "$WORK/log.err" >&2
+ fail "could not read commit subjects from $REPO"
+}
+[[ -s "$WORK/log" ]] \
+ || fail "git log listed NO commits in $REPO, so the scan below would pass vacuously. Either the repository is empty, or HEAD is unborn, or the checkout has no history to walk (CI checks out with fetch-depth: 0 for exactly this reason — a truncated clone must not be read as a clean history)"
+
+detect "$WORK/log" "$WORK/raw"
+
+# Resolve the exemptions against THIS repository's history. The length guard is
+# not decoration: `"${arr[@]}"` on an EMPTY array is an unbound-variable error
+# under `set -u` on bash 3.2, and an empty allowlist is a legitimate future state.
+: >"$WORK/exempt.shas"
+resolved=0
+if ((${#LEGACY_ALLOW_SHAS[@]} > 0)); then
+ for sha in "${LEGACY_ALLOW_SHAS[@]}"; do
+ full="$(git -C "$REPO" rev-parse --verify --quiet "${sha}^{commit}" || true)"
+ if [[ -z "$full" ]]; then
+ # Foreign mode: the sandbox repositories §9 builds do not contain this
+ # project's commits, so an unresolvable exemption is expected there and is
+ # simply inert. In self mode it is a hard error (below).
+ continue
+ fi
+ git -C "$REPO" merge-base --is-ancestor "$full" HEAD \
+ || fail "exempt commit $sha resolves in $REPO but is NOT an ancestor of HEAD — an exemption must name published history, not a dangling object"
+ subject="$(git -C "$REPO" log -1 --format=%s "$full")"
+ [[ -n "$subject" ]] || fail "exempt commit $sha has an empty subject — refusing to exempt an unreadable commit"
+ printf '%s\n' "$full" >>"$WORK/exempt.shas"
+ resolved=$((resolved + 1))
+ done
+fi
+
+if ((SELF == 1)); then
+ ((resolved == ${#LEGACY_ALLOW_SHAS[@]})) \
+ || fail "only $resolved of ${#LEGACY_ALLOW_SHAS[@]} exempt SHA(s) resolve in this repository. Either an exemption names a commit that is not here — which exempts nothing and hides what it was for — or this checkout does not reach far enough back to contain it (CI uses fetch-depth: 0; a truncated clone must fail here rather than silently scan a shorter history)"
+ # ANTI-ROT, and simultaneously the live positive control: each exempt commit
+ # must itself be a detection. If a listed SHA stops being a violation, the
+ # exemption is dead scaffolding and must go.
+ while read -r full; do
+ grep -q "^${full}${TAB}" "$WORK/raw" \
+ || fail "exempt commit $full is NOT a violation — its subject no longer leads with a non-ASCII character, so the exemption is stale; delete it from LEGACY_ALLOW_SHAS here AND from LEGACY_EXPECTED in changelog_gate_test.sh §9d. (Note this can only happen if history was rewritten. Re-FILING the rendered changelog entry, which is what REDMAIN-N3 does, does not change any commit subject and must NOT be answered by editing this list — see 'WHAT THIS LIST IS NOT' in the header.)"
+ done <"$WORK/exempt.shas"
+fi
+
+# Subtract the exemptions. `grep -v -F -f` with an EMPTY pattern file drops
+# nothing on GNU grep but is a portability trap, so the empty case is explicit.
+if [[ -s "$WORK/exempt.shas" ]]; then
+ sed 's/$/'"$TAB"'/' "$WORK/exempt.shas" >"$WORK/exempt.prefixes"
+ grep -v -F -f "$WORK/exempt.prefixes" "$WORK/raw" >"$WORK/violations" || true
+else
+ cp "$WORK/raw" "$WORK/violations"
+fi
+
+if ((SELF == 1)) && ((MODE_SHAS == 1)); then
+ cat "$WORK/exempt.shas"
+ exit 0
+fi
+
+if [[ -s "$WORK/violations" ]]; then
+ echo "FAIL: commit subject(s) start with a non-ASCII character (REDMAIN-N1 / D-168):" >&2
+ sed 's/^/ /' "$WORK/violations" >&2
+ cat >&2 <<'MSG'
+
+ GUIDELINES.md § Repository discipline requires the ASCII gitmoji SHORTCODE:
+ :construction_worker: ci(docs): stop uploading the Pages artifact
+ not the literal emoji. cliff.toml's commit_parsers key on the shortcode, so a
+ literal-emoji subject matches none of them and is published under "### Other"
+ on the GitHub Release page (REL-14 / D-137).
+
+ Fix an UNPUBLISHED commit by rewording it (git rebase -i / git commit --amend).
+ A commit that is already on origin/main must NOT be rewritten (hard rule 2) —
+ add its full SHA to LEGACY_ALLOW_SHAS in this file with the reason, and record
+ the exemption in docs/decisions/decisions.md.
+MSG
+ exit 1
+fi
+
+n="$(wc -l <"$WORK/log" | tr -d ' ')"
+if ((SELF == 1)); then
+ echo "OK: $n commit subject(s) scanned, all ASCII-leading; $resolved published-history exemption(s), each verified to still be a real detection"
+else
+ echo "OK: $n commit subject(s) scanned in $REPO, all ASCII-leading"
+fi
diff --git a/openspec/specs/backlog.md b/openspec/specs/backlog.md
index 9c92eaf9..2f8f0385 100644
--- a/openspec/specs/backlog.md
+++ b/openspec/specs/backlog.md
@@ -837,6 +837,22 @@ class, for intra-page targets rather than site reachability. One real broken anc
(`planning/spikes/spike-secure-setup.md`) and builds green; raising the level needs that fixed
first. `DOCSNAV-R01` in the epic spec.
+## Phase 5 — REDMAIN-N changelog classifier integrity (D-168)
+
+Full spec in [p5-redmain-changelog-integrity/spec.md](p5-redmain-changelog-integrity/spec.md).
+Two INBOX items filed 2026-09-03 by the REDMAIN-F01 reviewer and left unclaimed; the INBOX
+states they are two halves of one defect and must land together. **N1 and N2 are closed; N3 and
+N4 are OPEN residuals of that lane, each tracked on its own row rather than as a sentence inside
+a closed one** — N3 because `cliff.toml` was outside the lane's fence, N4 because it is a new
+gate surface and the lane had already taken two fix rounds.
+
+| ID | Item | Status | Needs operator | Notes |
+| --- | --- | --- | --- | --- |
+| **REDMAIN-N1** | Literal emoji in a commit subject defeats `cliff.toml`'s classifier (every parser keys on the ASCII shortcode) and nothing rejected it at commit time | ✅ **DONE (D-168)** | no (agent lane) | New `hack/release/commit_subject_gate.sh`; reachable on `pull_request` as a `verify:` step and locally through the existing `release-changelog-gate-test` stage — **no new `task check` stage**, so `CHECK_STAGES` in `hack/audit/exitgate_test.sh` stays at 21. `dfdae69` is exempt **by SHA**, the exemption must itself be a real detection or the gate reds, and the allowlist's exact content is pinned from a second file (§9d) so it cannot grow unremarked |
+| **REDMAIN-N2** | `changelog_gate_test.sh` §8's `### Other` detector required an ASCII shortcode, so it was fail-open for exactly the literal-emoji shape that causes the mis-filing it exists to catch | ✅ **DONE (D-168)** | no (agent lane) | Detector now matches a fileable type behind a shortcode, behind a literal emoji (any spacing, or none), behind several mixed prefix tokens, or behind no prefix at all; §8b pins the regression on a 12-line probe with a line-by-line expected verdict, showing the pre-fix pattern miss every literal-emoji spelling. Re-filing the rendered line out of `### Other` needs a `cliff.toml` parser entry and is **not** in this lane's fence — tracked as REDMAIN-N3 |
+| **REDMAIN-N3** | `cliff.toml` has no parser for a literal-emoji subject, so `dfdae69`'s entry still renders under `### Other` on the published Release page | **OPEN** | no (agent lane) | One `commit_parsers` entry keyed on the conventional type after a non-ASCII prefix, above the `.*` catch-all (tag it `# REDMAIN-N3`, **not** `# REL-14` — §8a mutates on that tag). **The whole hand-off is one line:** when the entry lands, §8 reds and prints the remedy — delete `dfdae69…` from `OTHER_EXEMPT_SHAS` in `hack/release/changelog_gate_test.sh` §8, **and nothing else**. Do NOT touch `LEGACY_ALLOW_SHAS` in `commit_subject_gate.sh`: that list is keyed on the commit SUBJECT, which re-filing does not change, and removing it there reds the commit-subject gate on `dfdae69`. The two lists are deliberately separate for this reason (D-168 (d)); the sequence was simulated end-to-end before this row was written, and ends green |
+| **REDMAIN-N4** | §8's three REDMAIN-N2-02 properties — the `OTHER_EXEMPT_SHAS ⊆ LEGACY_ALLOW_SHAS` subset invariant, the empty-exemption-list path, and the retire message — have **no standing control**. On the real tree the subset grep always passes, the empty-list branch is never taken and the retire message is never emitted, so running `changelog_gate_test.sh` exercises none of them | **OPEN** | no (agent lane) | They were proved by one-off scratch-clone **simulation** in the D-168 lane (N3 parser added to `cliff.toml`, red observed, printed remedy followed, both gates green, plus the control that the other remedy still reds) — evidence produced once, not a probe that re-runs. Fix is a **§8c** that drives all three over a fabricated `### Other` block and a temp copy of the exemption list, the way §8b drives the detector over a probe: assert (i) a SHA absent from `--legacy-shas` reds the subset check, (ii) an empty list takes the every-line-checked branch and the detector still fires on a planted mis-filed line, (iii) an exempt entry missing from the block emits the retire message naming `OTHER_EXEMPT_SHAS` and NOT `LEGACY_ALLOW_SHAS`. **Own lane, own review** — a new gate surface, and D-168's lane had already had two fix rounds. Sized S–M, unclaimed. **Three P3s to fold in while there** (all fail-closed, none urgent, all found by D-168's second reviewer): (a) §9d's mutant gate runs in FOREIGN mode (`SELF=0`, `ROOT=/`), so the exemption self-checks are skipped — the mutation still proves what its OK line claims, so this is comment precision, not a hole; (b) `grep -v -x -F -f` removes ALL identical lines while `n_exempt` counts exemptions, so two commits sharing an exempt subject would red with the misleading "filter is not matching whole lines" message — fail-closed, and subjects are unique today; (c) `OTHER_MAPPABLE_RE` also flags non-emoji non-ASCII prefixes (`- Ünicode chore(x): …`, `- 日本語 test: …`), unreachable because `commit_subject_gate.sh` rejects non-ASCII-leading subjects at commit time, and fail-closed. The converse gap — an ASCII non-shortcode prefix such as `- WIP ci(docs): …` staying unmatched — **predates D-168** and is consistent with its "prefix alternatives stay narrow on purpose" rationale; leave it unless a case appears |
+
## Phases 3–5
Epic paragraphs (goal, ADR constraints, exit gate, story seeds) in
diff --git a/openspec/specs/p5-redmain-changelog-integrity/spec.md b/openspec/specs/p5-redmain-changelog-integrity/spec.md
new file mode 100644
index 00000000..4037a9b9
--- /dev/null
+++ b/openspec/specs/p5-redmain-changelog-integrity/spec.md
@@ -0,0 +1,156 @@
+# P5-REDMAIN-N — changelog classifier integrity: literal-emoji commit subjects
+
+**Epic ID / REQ prefix:** `REDMAIN-N` / `REQ-REDMAIN-N-`.
+
+**Origin:** the session INBOX entries `REDMAIN-N1` and `REDMAIN-N2`, both filed 2026-09-03 by
+the `REDMAIN-F01` reviewer and both left unclaimed. The INBOX states the coupling explicitly —
+*"fixing the detector without also rejecting literal emoji at commit time leaves the door open,
+and vice versa. One lane should own both."* This spec is that lane.
+
+**Decision:** [D-168](../../../docs/decisions/decisions.md).
+
+---
+
+## Problem
+
+One defect with two halves. Both were verified in-tree at `20c80cb` before anything was
+written; neither is inferred from the INBOX prose.
+
+### Half A — the `### Other` detector is fail-open for the shape that causes the bug
+
+`hack/release/changelog_gate_test.sh` §8 exists to forbid an entry that declares a **fileable**
+conventional type from rendering in the catch-all `### Other` group — the REL-14 / D-137 defect.
+Its detector was:
+
+```
+grep -nE "^- :[a-z0-9_]+: ($FILEABLE_TYPES)[(:]"
+```
+
+The leading `:[a-z0-9_]+: ` is **mandatory** in that pattern, so the detector can only see an
+entry whose subject carries an ASCII gitmoji shortcode. Rendering the changelog at `20c80cb`
+puts three entries under `### Other`:
+
+```
+- 👷 ci(docs): stop uploading the Pages artifact on pull requests
+- :rewind: revert(kind): defer durable lab; authorize via D-038
+- :test(release): add CI audit gate for single CodeQL workflow
+```
+
+The first declares the fileable type `ci` and is exactly what §8 forbids, but it begins with a
+**literal emoji**, so the pattern cannot match it. `### Other` grew 2 → 3 entries while §8 kept
+printing its hardcoded prose *"revert + one malformed subject"*. A guard that is blind to the
+one subject shape that produces the defect is not a guard.
+
+### Half B — nothing rejects a literal-emoji subject before it is published
+
+`GUIDELINES.md` § Repository discipline mandates the **ASCII shortcode**
+(`:construction_worker:`, not `👷`). `cliff.toml`'s parsers key on that shortcode, so a
+literal-emoji subject matches none of them and falls through the `.*` catch-all into `### Other`
+on the published GitHub Release page — the REL-14 defect through a different door. There is no
+commit-message gate anywhere in `hack/**` or `.github/workflows/**`: the rule was enforced by
+human attention only, and `dfdae69` is the commit where that failed.
+
+`dfdae69` is published history and hard rule 2 forbids rewriting it, so the remedy must tolerate
+it explicitly rather than pretend it is not there.
+
+## Scope / non-goals
+
+- **In scope:** a gate that rejects a commit subject whose first character is non-ASCII, and a
+ §8 detector that sees a fileable type behind an ASCII shortcode, behind a literal emoji, or
+ behind no prefix at all.
+- **Not in scope:** re-filing `dfdae69`'s rendered entry out of `### Other`. That needs a
+ `cliff.toml` parser entry and `cliff.toml` is owned by another lane in this wave; the entry is
+ exempted here by **commit SHA**, and the exemption is built to red the moment it stops being
+ needed (REQ-REDMAIN-N2-02).
+- **Not in scope:** enforcing the *whole* of the commit convention (a conventional type, a
+ scope, a shortcode present at all). `build(deps): bump …` from Dependabot and `Merge pull
+ request …` from GitHub both violate the full rule and both are legitimate; the narrow rule
+ "the subject must not START with a non-ASCII character" is the one that breaks the classifier,
+ and it is the one that flags **exactly one commit in the whole of this repository's history** —
+ `dfdae69`, the defect itself — while the full-convention rule would reject dozens of bot and
+ merge subjects it has no business rejecting. (A count of commits is deliberately not quoted
+ here: it grows with every push, and a self-dating number in a spec rots.)
+- **Not in scope:** a new `task check` stage. The stage list is pinned by `CHECK_STAGES` in
+ `hack/audit/exitgate_test.sh`, which asserts the Taskfile's `check:` list is *equal* to it — a
+ 22nd stage is a change to that pin, and it is out of this lane's fence. Both halves therefore
+ reach `task check` through the existing `release-changelog-gate-test` stage, which is where
+ §8, the sibling half of the same defect, already lives.
+
+## Requirements
+
+- **REQ-REDMAIN-N1-01** *(commit subject gate · both polarities)* — a commit whose subject's
+ first character is not ASCII is rejected by a gate, not by a human. **Given** a repository
+ whose history contains a commit with a literal-emoji subject, **when** the gate runs, **then**
+ it exits non-zero and names the offending SHA and subject; **given** a history whose subjects
+ are all ASCII-leading, **then** it exits 0. The gate self-validates its own detector on every
+ run against a fabricated known-bad and known-good subject, so a mistyped pattern fails loudly
+ instead of matching nothing. Test: `hack/release/commit_subject_gate.sh` (new) with its
+ polarity proof in `hack/release/changelog_gate_test.sh` §9; Verify: `bash
+ hack/release/changelog_gate_test.sh`; Level: L1
+- **REQ-REDMAIN-N1-02** *(reachability)* — the gate runs where a new commit is actually
+ visible: as a step of `verify.yaml`'s `verify:` job (which, unlike the changelog drift gate,
+ is **not** guarded off `pull_request`, so a lane's literal-emoji commit reds its own PR), and
+ locally inside `task check` via the `release-changelog-gate-test` stage. Deleting either
+ wiring reds the gate. Test: `hack/release/changelog_gate_test.sh` §9 wiring assertions +
+ mutation; Verify: `bash hack/release/changelog_gate_test.sh`; Level: L1
+- **REQ-REDMAIN-N1-03** *(published history is tolerated by SHA, never by shape)* — the
+ exemption for pre-existing history is a list of **commit SHAs**, so no future commit can
+ inherit it. Each exempt SHA must resolve in this repository, be an ancestor of `HEAD`, and
+ **itself be a real detection** — an exempt SHA that is not a violation reds the gate as a
+ stale exemption. Test: `hack/release/commit_subject_gate.sh` self-checks + §9 mutation (strip
+ the exemption ⇒ the gate reds naming `dfdae69`); Verify: `bash
+ hack/release/changelog_gate_test.sh`; Level: L1
+- **REQ-REDMAIN-N1-04** *(the allowlist cannot grow unremarked)* — `LEGACY_ALLOW_SHAS` is an
+ allowlist, so its **length and content are pinned from a second file**: §9d holds
+ `LEGACY_EXPECTED` and asserts set-equality against `commit_subject_gate.sh --legacy-shas`,
+ both sides guarded non-empty. **Given** a lane that lands a literal-emoji commit and appends
+ its SHA to `LEGACY_ALLOW_SHAS` in a later commit of the same PR — which passes every
+ self-check inside the gate, because the SHA resolves, is an ancestor, and genuinely *is* a
+ detection — **then** §9d reds on the pin. Growing the allowlist is a deliberate two-file
+ change plus a decision-log entry, the same ratchet `CHECK_STAGES` applies to `task check`'s
+ stage list. Test: `hack/release/changelog_gate_test.sh` §9d; Verify: `bash
+ hack/release/changelog_gate_test.sh`; Level: L1
+- **REQ-REDMAIN-N2-01** *(the `### Other` detector sees every prefix shape)* — an entry
+ rendered under `### Other` that declares a fileable conventional type is reported regardless
+ of how the type is prefixed: an ASCII gitmoji shortcode, a literal emoji (with one space, more
+ than one space, or none at all), several such tokens in any mixture, or no prefix whatever.
+ **Given** a rendered `### Other` block containing `- 👷 ci(docs): …`, **when** §8's detector
+ runs, **then** that line is reported; the pre-fix detector is shown, in the same section, not
+ to report it. The detector must NOT fire on entries that belong in `### Other` — `revert(…)`
+ with or without an emoji, the malformed `:test(release):` subject, a merge subject, or prose
+ that happens to contain `fix(thing):`. Test: `hack/release/changelog_gate_test.sh` §8 + §8b
+ (a 12-line probe whose expected verdict is asserted line by line); Verify: `bash
+ hack/release/changelog_gate_test.sh`; Level: L1
+- **REQ-REDMAIN-N2-02** *(the legacy exemption retires cleanly, into a state that is green)* —
+ §8 keeps its **own** exemption list, `OTHER_EXEMPT_SHAS`, whose predicate is *"this commit's
+ rendered entry is still mis-filed under `### Other`"*. That is a different and **temporary**
+ fact from `LEGACY_ALLOW_SHAS`'s *"this commit's subject is still a literal emoji"*, which is
+ **permanent** — the two decouple at exactly the moment REDMAIN-N3 lands, so deriving one from
+ the other leaves no green state (keep the entry and §8 reds as stale; drop it and the
+ commit-subject gate reds on `dfdae69`). The lists are linked only by the **one-way subset
+ invariant** `OTHER_EXEMPT_SHAS ⊆ LEGACY_ALLOW_SHAS`, which the empty set satisfies. **Given**
+ a `cliff.toml` that files the literal-emoji entry under Chores, **when** the gate runs,
+ **then** §8 reds naming the entry and instructs the reader to delete that one SHA from
+ `OTHER_EXEMPT_SHAS` and nothing else; **and when** that instruction is followed literally,
+ **then** both gates are green with an empty exemption list and every `### Other` line checked.
+ Test: `hack/release/changelog_gate_test.sh` §8 — **what running it actually exercises is the
+ single live path**: the one exemption in `OTHER_EXEMPT_SHAS` is checked to be a subset member,
+ to resolve, and to still render under `### Other`, and the surviving `### Other` lines are run
+ through the detector. **NOT exercised by running the script** — stated plainly rather than
+ implied by the sentence above: on the real tree the subset grep always passes, the empty-list
+ branch is never taken, and the retire message is never emitted, so the three properties this
+ requirement is really about have **no standing control**, unlike §8a/§8b/§9b/§9d which each
+ carry one. They were verified by one-off scratch-clone **simulation** (a REDMAIN-N3 parser
+ added to `cliff.toml`, `CHANGELOG.md` regenerated, the red observed, its printed remedy
+ followed literally, both gates green, plus the control that the other remedy still reds) —
+ evidence that this lane produced once, not a probe that re-runs. Closing that gap is
+ **REDMAIN-N4** (`openspec/specs/backlog.md`), deliberately deferred to its own lane because a
+ §8c is a new gate surface. Verify: `bash hack/release/changelog_gate_test.sh` (live path only;
+ the simulation above is not re-run by it); Level: L1
+
+## Verification
+
+```
+mise exec -- task check # 21 stages; stage 11 (release-changelog-gate-test) carries §8/§8b/§9
+bash hack/release/commit_subject_gate.sh
+```