@bongos/core 1.19.20 → 1.19.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.20",
6
- "core_contract": "1.19.20",
7
- "source_commit": "d355706e8d215aa4e9b9ee249ef71670aa067e17",
5
+ "core_version": "1.19.22",
6
+ "core_contract": "1.19.22",
7
+ "source_commit": "e089ef90ffdf1b4da4f4a6b7f70d0fb10426bf42",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-10T01:27:05.376Z",
9
+ "built_at": "2026-08-11T01:24:29.274Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 284,
12
+ "docs_redacted": 285,
13
13
  "agent_docs_stubbed": 21,
14
- "functional_verbatim": 1203,
14
+ "functional_verbatim": 1204,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1508,
20
- "tree_sha256": "793f4a63c1a41cbfbc18422b85bf8f21249f8fe051e8cd6a738b84f0139f5089",
19
+ "file_count": 1510,
20
+ "tree_sha256": "0a775991e628ae986369cd819f6b384887fa20c1095f7e7263f07f13ad0e8167",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -1264,10 +1264,15 @@
1264
1264
  "mode": "0000644",
1265
1265
  "sha256": "056633fe7521143b0e8ba89297426ffd7253deb9ae5aa2fad65c3dfbffddc44f"
1266
1266
  },
1267
+ {
1268
+ "path": "docs/adr/0162-review-gates-retired-rank-consistent-ci-only.md",
1269
+ "mode": "0000644",
1270
+ "sha256": "f6176799c7731da3d4970b75e882e0ab88e31b1a412c87957b9a5085bfc799c7"
1271
+ },
1267
1272
  {
1268
1273
  "path": "docs/adr/README.md",
1269
1274
  "mode": "0000644",
1270
- "sha256": "165327e0c447cf9e549ba9389bc7eed6406a64631a9e2d714f7eb3ee80af8be0"
1275
+ "sha256": "b3ed6866c5042d2ee2f1c5680af8d6ef85d36c70a4f9e208eac2e5922461f6dc"
1271
1276
  },
1272
1277
  {
1273
1278
  "path": "docs/api-reference.md",
@@ -1297,7 +1302,7 @@
1297
1302
  {
1298
1303
  "path": "docs/canonical-permissions.md",
1299
1304
  "mode": "0000644",
1300
- "sha256": "f57f118f5b863a82895129c70311a7c87e5424e22a793b82c637db2ee7e345a0"
1305
+ "sha256": "dd85972912629d370d8fc79eff1661001e6b4ba9abe9866181c14fb0782c3131"
1301
1306
  },
1302
1307
  {
1303
1308
  "path": "docs/design-contract.md",
@@ -2792,7 +2797,7 @@
2792
2797
  {
2793
2798
  "path": "modules/governance/routes/governance.js",
2794
2799
  "mode": "0000644",
2795
- "sha256": "6f600bb237009064f18eae7af140cd8376cf5770ff7efd89d0e4071bade9eb4d"
2800
+ "sha256": "2b390c2c51812aa547f85045fe6d36be6c53086f3fa2468ab7c20c4a7d19dc08"
2796
2801
  },
2797
2802
  {
2798
2803
  "path": "modules/governance/routes/role-sync.js",
@@ -3242,12 +3247,12 @@
3242
3247
  {
3243
3248
  "path": "modules/hall-ui/public/shell.js",
3244
3249
  "mode": "0000644",
3245
- "sha256": "00a356cf20df11fd195c8ecce619ba5e4bc4138ccb3b66ebdef1522fa3f56c8e"
3250
+ "sha256": "56c1450dd72fcda19841f3a0a98b918b1b875f49971e93d60218fb62e8a21dad"
3246
3251
  },
3247
3252
  {
3248
3253
  "path": "modules/hall-ui/public/style.css",
3249
3254
  "mode": "0000644",
3250
- "sha256": "9fa49a72160d8dd156bba5f55d2300b9188ea9ae133a6dfb8ab13f3cdc2ab76d"
3255
+ "sha256": "41fbe46c9bbc6e9a4397733c070cb2d5e7bfd698d629445f786a44c44a35b0c4"
3251
3256
  },
3252
3257
  {
3253
3258
  "path": "modules/hall-ui/public/task-detail.js",
@@ -3347,7 +3352,7 @@
3347
3352
  {
3348
3353
  "path": "modules/lifecycle/db.js",
3349
3354
  "mode": "0000644",
3350
- "sha256": "44cdb3c44dcf9277e9e0661d664387f0497e7d9b11c9775e1133aeec0da9db9f"
3355
+ "sha256": "7dc50e4e88a204583c753555c76f4103df405a143c4266dbff53793c292803f9"
3351
3356
  },
3352
3357
  {
3353
3358
  "path": "modules/lifecycle/done-when.js",
@@ -3997,12 +4002,12 @@
3997
4002
  {
3998
4003
  "path": "package-lock.json",
3999
4004
  "mode": "0000644",
4000
- "sha256": "d4a29f817b3eaff3cfc617413d909abe57556a519646db6f8e2bc0baeaeb01da"
4005
+ "sha256": "f85ec26a3a5cbefec1aca8407043cb2eb2886120722a79126d364736f1a67584"
4001
4006
  },
4002
4007
  {
4003
4008
  "path": "package.json",
4004
4009
  "mode": "0000644",
4005
- "sha256": "b13a7ca8bef83027862f8b1aad071c74ec362bd85a1246050e7bc434fd96d346"
4010
+ "sha256": "9275da3329d188788a0e69b719231cd23ae38b3499802edbcefebded17317067"
4006
4011
  },
4007
4012
  {
4008
4013
  "path": "public-docs/index.html",
@@ -4502,7 +4507,7 @@
4502
4507
  {
4503
4508
  "path": "scripts/gds/bump-version.js",
4504
4509
  "mode": "0000644",
4505
- "sha256": "91664a0a1338ded41b780179c16040e2c686f6ab92304891cb1bc6ef3ca805b6"
4510
+ "sha256": "9709fcffab1d386c174666788687b1dfd974f141ab552ffd5641abd690259619"
4506
4511
  },
4507
4512
  {
4508
4513
  "path": "scripts/gds/capture.js",
@@ -4692,7 +4697,7 @@
4692
4697
  {
4693
4698
  "path": "scripts/gds/gating.js",
4694
4699
  "mode": "0000644",
4695
- "sha256": "5de3e4129006035e844efd4c5a2147060228fe6c73fe0bbbf1ead72af2cc405c"
4700
+ "sha256": "a304965e79b5ab717626bfb9c74358334576c69f5aefcefa059d46a092f46172"
4696
4701
  },
4697
4702
  {
4698
4703
  "path": "scripts/gds/gds-db-backup.service",
@@ -4782,7 +4787,7 @@
4782
4787
  {
4783
4788
  "path": "scripts/gds/init.js",
4784
4789
  "mode": "0000644",
4785
- "sha256": "a58a741f11ea7d34396dcb9e242825bf88326ca2709b7c726a91b06f60f9c24c"
4790
+ "sha256": "e9fbb7ae477a445252f1171851be93fca14f58e4906d030c93d09b37d517ac20"
4786
4791
  },
4787
4792
  {
4788
4793
  "path": "scripts/gds/install-git-hooks.js",
@@ -5192,7 +5197,7 @@
5192
5197
  {
5193
5198
  "path": "scripts/gds/ship.js",
5194
5199
  "mode": "0000644",
5195
- "sha256": "bea8a5d8698abf30064c005076c069d470203c86a37a1ff7a3aebc4a5ded19a6"
5200
+ "sha256": "65af2a0d6fc54ee3dbb3c7faa44796fed2a5817fa48073cebfd9d6a15837fec7"
5196
5201
  },
5197
5202
  {
5198
5203
  "path": "scripts/gds/smoke-batch-claim.sh",
@@ -5387,7 +5392,7 @@
5387
5392
  {
5388
5393
  "path": "src/bongos/auth.js",
5389
5394
  "mode": "0000644",
5390
- "sha256": "60888e8a84fc109ce258256ed7c0af13a8a453464b4f71716bc3a018a89f1b1a"
5395
+ "sha256": "95428c88621510a97a5bfaf89a836508501b1db0f0910f529a260fa2dafce3b5"
5391
5396
  },
5392
5397
  {
5393
5398
  "path": "src/bongos/cascade-dispatch.js",
@@ -5622,7 +5627,7 @@
5622
5627
  {
5623
5628
  "path": "src/module-api.js",
5624
5629
  "mode": "0000644",
5625
- "sha256": "8a34aa5b02db59b8ba5e821be8a0f38c8e6a66f10fccf728187f150ce87e86b3"
5630
+ "sha256": "937021d749587baee02de0c83fa721b99a0e23850c5c12aa5332424673e5d6af"
5626
5631
  },
5627
5632
  {
5628
5633
  "path": "src/module-loader/loader.js",
@@ -5997,7 +6002,7 @@
5997
6002
  {
5998
6003
  "path": "tests/bump_version.mjs",
5999
6004
  "mode": "0000644",
6000
- "sha256": "d8493f101a0009dd550c1ccf126e5ceb6433013257aa4f307ca68b00788dc77e"
6005
+ "sha256": "98ec16d513074b30de8cd897143e4a3df1b3e54132a09d015b99e13e870de384"
6001
6006
  },
6002
6007
  {
6003
6008
  "path": "tests/cap_newcomer_tasks.mjs",
@@ -6359,6 +6364,11 @@
6359
6364
  "mode": "0000644",
6360
6365
  "sha256": "a5ca654f46407a3172cfa981f399c159c2769b440058eca509f568111614b00c"
6361
6366
  },
6367
+ {
6368
+ "path": "tests/gating_review_retired.mjs",
6369
+ "mode": "0000644",
6370
+ "sha256": "53cd6df5521ee48909b217483163bdf52ecfcc9aa5267f0b8a213c2b4b0f76bc"
6371
+ },
6362
6372
  {
6363
6373
  "path": "tests/gen_freshness.mjs",
6364
6374
  "mode": "0000644",
@@ -6502,7 +6512,7 @@
6502
6512
  {
6503
6513
  "path": "tests/governance_page_gates.mjs",
6504
6514
  "mode": "0000644",
6505
- "sha256": "cf0d01b58a209d9d2ad64e2c1c999ce2338fb0f69bb5ddff43ca7f24d7bc66be"
6515
+ "sha256": "be6cc9a4070d9de107cd11bfcb7a38e074f8aeb71198839cd42438d3ea2a0c04"
6506
6516
  },
6507
6517
  {
6508
6518
  "path": "tests/governance_parity.mjs",
@@ -6522,7 +6532,7 @@
6522
6532
  {
6523
6533
  "path": "tests/governance_require_permission.mjs",
6524
6534
  "mode": "0000644",
6525
- "sha256": "58090f6e6cb0cb7e99b03675deeb80302986b053dc1402e254f64ae4126df084"
6535
+ "sha256": "2e8d7f3bb58411efd79727f6edffa448136551ef1c3c6973228fdfa7903f7cb3"
6526
6536
  },
6527
6537
  {
6528
6538
  "path": "tests/governance_resolver.mjs",
@@ -7157,7 +7167,7 @@
7157
7167
  {
7158
7168
  "path": "tests/reward_gate.mjs",
7159
7169
  "mode": "0000644",
7160
- "sha256": "ace9bb9db5a25b98de4d62f5150fb17db345d78e747aca95a6628dcd2a957afe"
7170
+ "sha256": "7ae4d9af4d389c34f0d16f921858cbbf5d0d57f3219f830d10552ebcdc43908f"
7161
7171
  },
7162
7172
  {
7163
7173
  "path": "tests/reward_mode_cost_plus_only.mjs",
@@ -0,0 +1,67 @@
1
+ # ADR 0162 — Review gates retired: rank-consistent CI checks only on `main`
2
+
3
+ **Date:** 2026-08-10
4
+ **Context:** Owner directive (jaxri, Archon), recorded live 2026-08-08–10; idea 1000657 → task 1002635. Amends ADR 0159 (which introduced CODEOWNERS) and the gating posture of ADR 0058/0042.
5
+ **Status:** Accepted. The live half (dropping `require_code_owner_reviews` from branch protection) was applied by the repo-admin account on 2026-08-10 and verified behaviorally (a probe PR touching a formerly-owned path read `mergeStateStatus: CLEAN` on a green `unit` check — PR 32, closed unmerged). This ADR lands the repo half so no re-arm path resurrects the gate.
6
+
7
+ ## The incident that forced the decision
8
+
9
+ Wave 3 of the rank choke-point remediation built the merge gates inert (required `unit`
10
+ check, `gating.js`, a CODEOWNERS file scoped to the gate-review HARD_FLOOR_GLOBS); task
11
+ 1002619 armed them. Within a day, the code-owner review requirement had stranded two
12
+ graded-PASS wave-4 ships (PR 28 sat `BLOCKED` behind a review only the `example-owner` account
13
+ could give) and then blocked the fix for the strand it caused (the ship.js ci-land fix,
14
+ task 1002629, and the release-cascade fix, task 1002636, both touch owned paths).
15
+
16
+ The mechanics are structural, not incidental. GitHub can only express the gate as
17
+ **accounts** — it has no concept of GDS ranks; PR authors cannot approve their own PRs;
18
+ and on this instance nearly every PR is authored by agent sessions running under the
19
+ owner-archon's daily login. So "code-owner review" degenerates into "one specific
20
+ GitHub account must click on every gate-surface PR," which is:
21
+
22
+ 1. **Identity-pinned, not rank-based** — against the instance's governance principle
23
+ (ADR 0016; the no-hardcoded-Archon-identity rule).
24
+ 2. **The opposite of the remediation's goal.** The owner's words: builders should have
25
+ *fewer* rank-related shipping problems, not more; a code-owners layer "can only
26
+ create shipping problems for builders."
27
+
28
+ ## Decision
29
+
30
+ Review requirements on `main` are **retired permanently**:
31
+
32
+ - Branch protection carries **required status checks only** (today: `unit`; the
33
+ `secret-scan` / `gate-review` contexts join when their workflows ship — ADR 0058).
34
+ - `gating.js` `DEFAULT_PROTECTION` sets `required_pull_request_reviews: null`, and
35
+ `cmdOn` **normalizes both restore paths** (snapshot and default) to null — a snapshot
36
+ taken while the review gate was live must not resurrect it on an off/on cycle.
37
+ - The `CODEOWNERS` file is **deleted**. The `CODEOWNERS` *glob* stays in
38
+ `gate-review.js` `HARD_FLOOR_GLOBS`, `protected-surfaces.json`, and the publish
39
+ denylist — so reintroducing the file is itself a flagged gate-surface change, and the
40
+ hard-floor path list keeps protecting the same surfaces where it actually enforces
41
+ (the sandbox gate).
42
+
43
+ ## What now guards the safety machinery
44
+
45
+ The compensating controls are all rank-consistent and automatic:
46
+
47
+ | Control | When it bites | Fails |
48
+ |---|---|---|
49
+ | required `unit` check (suite + fitness) | before every merge | closed |
50
+ | gate-review sandbox (task 1002607) | agent ship flow on hard-floor paths | closed |
51
+ | subagent grader panel | every GDS ship | closed |
52
+ | post-merge main-audit (task 1002612, every push) | after any merge, incl. a bypass | audit + revert |
53
+
54
+ ## Consequences
55
+
56
+ - **Positive:** no human click anywhere in the land path; a graded-PASS ship lands on
57
+ CI alone; the wave-4/-5 strand class cannot recur; the gate posture matches the
58
+ rank-first governance principle.
59
+ - **Negative / accepted trade:** there is **no pre-merge human approval on
60
+ safety-machinery changes**. A compromised or prompt-injected session whose work
61
+ passes the unit check reaches `main`, and is caught *after* merge by the main-audit
62
+ rather than before. The owner chose this trade explicitly (2026-08-08, reaffirmed
63
+ 2026-08-10) with the mitigations above in place.
64
+ - **Reversal path:** re-add a CODEOWNERS file, restore a `required_pull_request_reviews`
65
+ block in `gating.js`, remove the `cmdOn` normalization, and re-apply protection from
66
+ an admin account. All four are deliberate steps — the gate cannot come back by
67
+ accident, which is the point of the normalization.
@@ -233,5 +233,6 @@ This keeps the decision history honest and traceable.
233
233
 
234
234
  | 0160 | [**The authoritative main audit gets a repo-verifiable caller** ([task 1002612](https://cloudbongos.com/builders#/task/1002612)). Findings G-3 + G-8, honest limit S-1: `main-audit.js` — the post-push layer [ADR 0043](<redacted>.md) designates authoritative — had two declared invokers that never existed in this git history, and `audit-authorship.sh` (the no-hardcoded-Archon-identity enforcer) had no caller either and turned out red with 340 pre-existing hits on untouched main (idea 1000653). Now a `main-audit` workflow audits every push to `main` over exactly the pushed `before..after` range (clamped-window fallback); the roster comes from the public leaderboard when no session exists (server-authoritative, login-only keying, an absent builder resolves as unresolved — never falsely trusted); new `--require-roster` makes an empty roster a loud DID-NOT-RUN exit 2 (an audit that cannot fire a blocker must not exit green); the lane runs non-strict per the audit's own zero-false-positive calibration; and `audit-authorship.sh` rides as an advisory job until the 1000653 triage. Detective lane, not a merge gate — deliberately outside the required PR contexts. Rider: the known-failing ratchet caught task 1002611's publish_manifest fix exactly as designed — entry removed, ceiling ratcheted 3 → 2, remaining entries re-owned to [task 1002617](https://cloudbongos.com/builders#/task/1002617), idea 1000542 superseded. Rejected: a GDS token secret (silent-fail dependency the devcontainer lane warned about), `--strict` in CI (permanent red rots into wallpaper), enforcing authorship before triage.](<redacted>.md) | governance / build pipeline |
235
235
  | 0161 | [**Publish on merge: every merge to core `main` auto-publishes `@bongos/core`** (task 1002620). Supersedes ADR 0137's manual owner-gated publish after its own "when to revisit" clause fired twice in three weeks: 171 commits/10 days, then 104 commits/5 days, sat on `main` reachable by no pin while every layer reported success. A `publish` workflow rides behind the required `unit` check (`workflow_run`, success-gated): when HEAD's `CORE_VERSION` is already on npm, CI bumps a PATCH via the shape-preserving `bump-version.js` (+ changelog line; pinned by `tests/bump_version.mjs`) and pushes the carrier commit; `package-core --self-verify --publish --live` builds through the fail-closed no-leak gate; the release is tagged `core-v<v>`. A MINOR/MAJOR stays a human in-task edit — it decides what patch-channel subscribers receive unattended (ADR 0136), a call CI must not make. Token custody knowingly reverses (owner-accepted): `NPM_TOKEN` write + `RELEASE_PUSH_TOKEN` PAT as Actions secrets, mitigated by the lane shipping INERT behind the `PUBLISH_ON_MERGE=1` repo variable — an owner kill-switch symmetrical to `gating.js`. Idempotent by version-compare; npm duplicate rejection is the backstop; queued no-cancel concurrency collapses merge races.](<redacted>.md) | build pipeline / distribution |
236
+ | 0162 | [**Review gates retired: rank-consistent CI checks only on `main`** ([task 1002635](https://cloudbongos.com/builders#/task/1002635); owner directive, idea 1000657). Amends ADR 0159. Within a day of task 1002619 arming the merge gates, the code-owner review requirement had stranded two graded-PASS wave-4 ships (PR 28 sat BLOCKED behind a review only the repo-owner account could give) and then blocked the fixes for the strands it caused — structurally, not incidentally: GitHub can only express the gate as *accounts* (no rank concept, no self-approval), so on a solo-archon instance it degenerates into "one named login must click every gate-surface PR" — identity-pinned authority, against ADR 0016's rank principle and the remediation's own goal. Decision: branch protection carries required status checks ONLY; `gating.js` `DEFAULT_PROTECTION` sets `required_pull_request_reviews: null` and `cmdOn` normalizes BOTH restore paths so a pre-retirement snapshot cannot resurrect the gate (`tests/gating_review_retired.mjs` pins it with a counter-example); the CODEOWNERS *file* is deleted while its *glob* stays in the hard-floor lists so reintroducing it is itself gate-flagged. Accepted trade, chosen explicitly by the owner: no pre-merge human approval on safety-machinery changes — the unit check, the fail-closed gate-review sandbox, the grader panel, and the every-push main-audit are the net, catching a compromised session's merge after the fact rather than before.](<redacted>.md) | governance / build pipeline |
236
237
 
237
238
  > ⚠️ **Numbering collision (known):** two ADRs share `0008`. The numbers were assigned in parallel sessions before the lock could be enforced. The files are kept under their existing filenames to preserve git history, but the table disambiguates them as `0008-a` / `0008-b`. New ADRs should resume at `0040` to avoid further collisions. (`0024` is also a real on-disk duplicate: the cloneable-repo/local-first-memory ADR and the multi-agent-system-architecture ADR were both numbered `0024` in parallel; both keep their filenames, disambiguated as `0024-a` / `0024-b` like `0008`. Likewise `0025` ended up with two on-disk files — the offsite-backup ADR and the structured-criterion↔task-link ADR — both kept under their existing filenames and disambiguated as `0025-a` / `0025-b`; separately, the BFG ADR had also claimed `0025` on 2026-05-30 and yielded to `0026`. A third happened 2026-06-06: `0036` was claimed in parallel by the builders-subdomain ADR ([#726](https://example.com/builders#/task/726)) and this Discord-channels ADR ([#727](https://example.com/builders#/task/727)); the Discord-channels ADR yielded to `0037`. A fourth landed 2026-06-10: `0044` was claimed in parallel by the palette-replacement ADR and the per-box game-preview ADR ([#897](https://example.com/builders#/task/897)); both keep their filenames, disambiguated as `0044-a` / `0044-b` like `0008`. A fifth was caught pre-land 2026-06-13: `0050` was claimed in parallel by the device-flow-admission ADR ([#1000](https://example.com/builders#/task/1000)) and the sandbox-for-everyone ADR ([#1001](https://example.com/builders#/task/1001)); the latter yielded to `0052` before merging, so there is no `0050` duplicate. A sixth was caught pre-land 2026-06-21: the tool-agnostic-design-layer ADR ([#1375](https://example.com/builders#/task/1375)) first collided at `0079` with the value-gated-autonomous-engagement ADR, then at `0080` with the llm-cache-api-transport ADR (both part of the 0075–0080 LLM-cost-reduction series, landed on `main` in parallel); it yielded to `0081` before merging, so there is no `0079`/`0080` design-layer duplicate. A seventh landed 2026-06-23: `0084` was claimed in parallel by the auto-derived-per-task-rank-floor ADR and the remove-default-spend-cap ADR (task 1499); the rank-floor ADR landed on `main` first, so the spend-cap ADR yielded to `0085`, leaving no `0084` duplicate. An eighth landed 2026-06-27: `0095` was claimed in parallel by the borrowed-memory-and-retrieval-concepts ADR (PR #459, landed first) and the cross-agent-context-management ADR ([#1651](https://example.com/builders#/task/1651), PR #460); both keep their filenames, disambiguated as `0095-a` / `0095-b` like `0008`. These two are companions — `0095-a` adopts the memory/retrieval concepts and explicitly defers the grep-vs-semantic question to `0095-b`.)
@@ -250,11 +250,14 @@ Authority over *the code itself* rides the same ladder, enforced server-side fro
250
250
 
251
251
  ADR 0016's per-request rank check covers `/api/gds/*` only. Two surfaces it does **not** cover —
252
252
  `git push` to `main` and `ssh` to the droplet — were used in the Builder 25 incident to land an
253
- un-graded tiering change straight on prod `main`. The repo is a free-plan private repo, so GitHub
254
- branch protection is unavailable; the enforcement is therefore code-side, in three layers (ADR 0043).
255
- **Verified still true 2026-07-24** — no classic protection, no rulesets, no CODEOWNERS, and a
256
- non-admin merged PR #19 to `main` with zero required checks; see ADR 0043 §"Branch-protection status"
257
- for the probes. `main-audit.js` is therefore still the *authoritative* layer, not a backup one:
253
+ un-graded tiering change straight on prod `main`. **Since 2026-08-08 (task 1002619), `main` HAS
254
+ live branch protection: the required `unit` status check blocks every direct push, so all work
255
+ lands via PR.** The protection is *checks-only* — a code-owner review requirement was briefly
256
+ enforced and then deliberately retired by the owner (ADR 0162, task 1002635): gates are
257
+ rank-consistent CI checks, never a named human's approval. (The 2026-07-24 state — no protection
258
+ at all, PR #19 merged with zero checks — is preserved in ADR 0043 §"Branch-protection status" as
259
+ history.) `main-audit.js` remains the authoritative *post-merge* layer — it catches what any
260
+ pre-merge gate misses, independent of the builder's cooperation:
258
261
 
259
262
  - A builder **below Metic** (`xenos`, `thetes`) may **not modify permission-sensitive paths** — the
260
263
  rank/authz machinery, the ship/grade/deploy pipeline, DB `migrations/`, `infra/`, and the
@@ -62,6 +62,9 @@ const governancePort = {
62
62
  isSystem: catalog.isSystem,
63
63
  isGrantable: catalog.isGrantable,
64
64
  permissionKeys: catalog.permissionKeys,
65
+ // The seeded floor of one atom, for denial text that names the rank instead of
66
+ // only the atom (task 1002613). Null for principal keys and unknown keys.
67
+ permissionFloor: (key) => (catalog.byKey(key) || {}).floor || null,
65
68
  resolvePrincipalPermissions: resolver.resolvePrincipalPermissions,
66
69
  principalHasPermission: resolver.principalHasPermission,
67
70
  isPrincipalKey: catalog.isPrincipalKey,
@@ -360,6 +360,36 @@
360
360
  applyZoom();
361
361
  }
362
362
 
363
+ // task 1002613 (map N-1): a server page-gate denial lands here with
364
+ // ?denied=<permission>[&reason=unavailable]. Render the refusal — "you are
365
+ // signed in; you lack X" — then strip the query so a refresh or a shared link
366
+ // doesn't re-show a stale notice. Cosmetic only; the gate already ran.
367
+ function renderDeniedNotice() {
368
+ // Hand-parsed (no URLSearchParams): shell.js is dependency-free by design,
369
+ // and the hall unit tests boot it in a bare vm context.
370
+ var search = (window.location && window.location.search) || '';
371
+ var m = /[?&]denied=([^&]+)/.exec(search);
372
+ if (!m) return;
373
+ var denied = decodeURIComponent(m[1]);
374
+ if (!/^[a-z0-9_.-]{1,80}$/i.test(denied)) return;
375
+ var unavailable = /[?&]reason=unavailable(?:&|$)/.test(search);
376
+ var topbar = document.getElementById('app-topbar');
377
+ if (!topbar) return;
378
+ var el = document.createElement('div');
379
+ el.className = 'denied-notice';
380
+ el.setAttribute('role', 'status');
381
+ el.innerHTML =
382
+ '<span>' + (unavailable
383
+ ? 'You’re signed in, but access to that page couldn’t be verified just now (the permission system didn’t answer), so it stayed closed. Try again in a minute.'
384
+ : 'You’re signed in, but that page needs the <code>' + esc(denied) + '</code> permission — your current role doesn’t hold it.') +
385
+ '</span>' +
386
+ '<button type="button" class="denied-notice__close" aria-label="Dismiss">×</button>';
387
+ el.querySelector('.denied-notice__close').addEventListener('click', function () { el.remove(); });
388
+ topbar.insertAdjacentElement('afterend', el);
389
+ var rest = search.replace(/[?&](?:denied|reason)=[^&]*/g, '').replace(/^&/, '?');
390
+ history.replaceState(null, '', window.location.pathname + rest + window.location.hash);
391
+ }
392
+
363
393
  function renderFoot() {
364
394
  var el = document.getElementById('app-foot');
365
395
  if (!el) return;
@@ -615,6 +645,7 @@
615
645
 
616
646
  renderSidebar();
617
647
  renderTopbar();
648
+ renderDeniedNotice();
618
649
  renderFoot();
619
650
  applyTheme();
620
651
  applyZoom();
@@ -455,6 +455,30 @@ button { font-family: var(--font-body); }
455
455
  overflow: hidden;
456
456
  text-overflow: ellipsis;
457
457
  }
458
+ /* Page-gate denial notice (task 1002613, map N-1) — rendered by shell.js from
459
+ the ?denied= query a refused page redirect carries. */
460
+ .denied-notice {
461
+ display: flex;
462
+ align-items: baseline;
463
+ gap: 12px;
464
+ padding: 10px 24px;
465
+ font-size: 13px;
466
+ color: var(--warn);
467
+ background: var(--warn-weak);
468
+ border-bottom: 1px solid var(--border);
469
+ }
470
+ .denied-notice code {
471
+ font-size: 12px;
472
+ }
473
+ .denied-notice__close {
474
+ margin-left: auto;
475
+ border: 0;
476
+ background: none;
477
+ color: inherit;
478
+ font-size: 16px;
479
+ line-height: 1;
480
+ cursor: pointer;
481
+ }
458
482
  .topbar-menu {
459
483
  display: none;
460
484
  align-items: center;
@@ -42,7 +42,7 @@ const doneWhen = require('./done-when.js');
42
42
  const { generalGoalTitle } = require('./goal-advisory.js');
43
43
  // BV1.R106 (ADR 0151): the permission-expressed QUEUE axis of claim eligibility.
44
44
  // Kept deliberately separate from the per-task requires_rank floor below.
45
- const { claimEligibilityAllows } = require('./claim-eligibility.js');
45
+ const { claimEligibilityAllows, CLAIM_ANY, CLAIM_NEWCOMER } = require('./claim-eligibility.js');
46
46
  // Pure kernel-adjacent matchers via the doorway (never the core file).
47
47
  const { matchProtected } = api.permissionPathCheck;
48
48
  const { touchesOverlap } = api.pathMatch;
@@ -2572,7 +2572,23 @@ async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSess
2572
2572
  // task.claim.any / task.claim.newcomer — see ./claim-eligibility.js.
2573
2573
  const newcomerFriendly = task.newcomer_friendly;
2574
2574
  if (!claimEligibilityAllows({ heldPermissions, newcomerFriendly, rankRuleAllows: xenosClaimAllowed(builderRank, newcomerFriendly) })) {
2575
- throw { code: 'rank_too_low_for_task', legacy_code: 'XENOS_RESTRICTED' };
2575
+ // task 1002613 (map §4.4): the bare code reported a PERMISSION decision as
2576
+ // a rank one — no axis, no remedy. Copy INSUFFICIENT_RANK's shape below:
2577
+ // name both sides of the wall and the way forward.
2578
+ const opened = newcomerFriendly === true;
2579
+ throw {
2580
+ code: 'rank_too_low_for_task',
2581
+ legacy_code: 'XENOS_RESTRICTED',
2582
+ required: opened ? [CLAIM_ANY, CLAIM_NEWCOMER] : [CLAIM_ANY],
2583
+ actual: builderRank,
2584
+ newcomer_friendly: opened,
2585
+ message: opened
2586
+ ? `claiming this task needs ${CLAIM_ANY} or ${CLAIM_NEWCOMER}, and your role holds neither`
2587
+ : `this task is not opened to newcomers (newcomer_friendly is not set) and your role lacks ${CLAIM_ANY}`,
2588
+ hint: opened
2589
+ ? 'your builder appears to hold no claim permission at all — ask for your rank-role to be re-synced'
2590
+ : 'pick a newcomer_friendly task from the claimable feed (/builder-start), or a builder whose role holds task.claim.any can claim this one',
2591
+ };
2576
2592
  }
2577
2593
 
2578
2594
  // V3.R82 (#301): the inverse gate — a task can RAISE its minimum required
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.20",
3
+ "version": "1.19.22",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.20",
9
+ "version": "1.19.22",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.20",
3
+ "version": "1.19.22",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -72,5 +72,19 @@ function main() {
72
72
  console.log(JSON.stringify({ from, to, applied: apply }));
73
73
  }
74
74
 
75
- module.exports = { bumpPatch, CONST_RE };
75
+ // The commit subject THIS lane writes for its own bump commits — publish.yml's
76
+ // `git commit -m` mirrors it verbatim (tests/bump_version.mjs pins the two
77
+ // against each other). isReleaseCommit is the cascade terminator (task 1002636):
78
+ // publish.yml no-ops when current main HEAD matches, because everything at that
79
+ // HEAD was already published by the run that created the commit. Deliberately
80
+ // narrow — a HUMAN release commit ("release: core 1.19.2 — carrier for the
81
+ // init-blocker fixes") must NOT match: that version is new and still needs
82
+ // publishing.
83
+ const RELEASE_COMMIT_RE = /^release: core \d+\.\d+\.\d+ — CI auto-patch carrier \(publish-on-merge/;
84
+
85
+ function isReleaseCommit(subject) {
86
+ return RELEASE_COMMIT_RE.test(String(subject == null ? '' : subject));
87
+ }
88
+
89
+ module.exports = { bumpPatch, CONST_RE, RELEASE_COMMIT_RE, isReleaseCommit };
76
90
  if (require.main === module) main();
@@ -2,13 +2,17 @@
2
2
  // scripts/gds/gating.js — owner kill-switch for main-branch merge gating.
3
3
  //
4
4
  // What "gating" means here — TWO independent levers, both toggled together:
5
- // 1. GitHub branch protection on `main`: required status checks + required
6
- // code-owner review (CODEOWNERS). The required contexts are ONLY checks
7
- // that exist and can pass today — currently `unit` (the suite + fitness
8
- // lane, task 1002610). The planned `secret-scan` and `gate-review`
9
- // contexts (ADR 0058 phase 3; CORE_EXPECTED_CHECKS in adopt-preflight.js)
10
- // join DEFAULT_PROTECTION when their workflows ship — requiring a context
11
- // nothing reports blocks every merge indefinitely (D-12, ADR 0159).
5
+ // 1. GitHub branch protection on `main`: required status checks ONLY — no
6
+ // review requirement of any kind (ADR 0162, task 1002635: the owner
7
+ // retired the code-owner/human-approval layer after it stranded graded
8
+ // work; gates are rank-consistent CI checks, and the normalization in
9
+ // cmdOn keeps a pre-retirement snapshot from resurrecting it). The
10
+ // required contexts are ONLY checks that exist and can pass today —
11
+ // currently `unit` (the suite + fitness lane, task 1002610). The planned
12
+ // `secret-scan` and `gate-review` contexts (ADR 0058 phase 3;
13
+ // CORE_EXPECTED_CHECKS in adopt-preflight.js) join DEFAULT_PROTECTION
14
+ // when their workflows ship — requiring a context nothing reports blocks
15
+ // every merge indefinitely (D-12, ADR 0159).
12
16
  // 2. The application-layer trust gates that run on every PR — the gate-review
13
17
  // and grade-gate CI workflows (ADR 0058 phases 1-3; NOT YET IN THE TREE —
14
18
  // workflowState() reports them 'absent' and the toggles below no-op until
@@ -17,7 +21,8 @@
17
21
  // (it escalates gate-surface changes — infra/, migrations/, CODEOWNERS,
18
22
  // scripts/gds plumbing — to owner approval). Branch protection alone does
19
23
  // NOT neutralize these, which is why `off` also `gh workflow disable`s them.
20
- // With gating ON, a gate-surface PR needs owner approval / --admin to merge.
24
+ // With gating ON, every PR — gate-surface or not — merges on the required
25
+ // status checks alone; no human approval is ever required (ADR 0162).
21
26
  // With gating OFF, ordinary `gh pr merge N` (and every builder / autonomous
22
27
  // session) merges with no checks and no approval. `unit` keeps running either
23
28
  // way — a real safety net that forces no approval.
@@ -78,12 +83,11 @@ const DEFAULT_PROTECTION = {
78
83
  ],
79
84
  },
80
85
  enforce_admins: false,
81
- required_pull_request_reviews: {
82
- dismiss_stale_reviews: false,
83
- require_code_owner_reviews: true,
84
- required_approving_review_count: 0,
85
- require_last_push_approval: false,
86
- },
86
+ // ADR 0162 (task 1002635): review gates are RETIRED — the owner chose
87
+ // rank-consistent CI checks over human-approval gates after the code-owner
88
+ // requirement stranded graded-PASS work (PR 28). Never reintroduce a
89
+ // required_pull_request_reviews block here; cmdOn also normalizes snapshots.
90
+ required_pull_request_reviews: null,
87
91
  restrictions: null,
88
92
  };
89
93
 
@@ -302,6 +306,10 @@ function cmdOn(repo) {
302
306
  putBody = DEFAULT_PROTECTION;
303
307
  console.log('⚠ branch protection: no snapshot — restoring the baked-in known-good default.');
304
308
  }
309
+ // ADR 0162 (task 1002635): review gates are retired. Normalize BOTH restore
310
+ // paths — a snapshot taken while the code-owner requirement was live would
311
+ // otherwise silently resurrect it on the next off/on cycle.
312
+ putBody = { ...putBody, required_pull_request_reviews: null };
305
313
  putProtection(repo, putBody);
306
314
  const after = getProtection(repo);
307
315
  if (!after) fail('PUT returned but protection still reads as OFF — check `gh` auth / admin rights');
@@ -362,7 +362,7 @@ function kickoffTasks(spec) {
362
362
  title: 'Arm the merge gate before a second builder joins (ci deploy mode)',
363
363
  kind: 'infra',
364
364
  description: 'For a shared instance on ci deploy mode, enable branch protection on main + required checks ' +
365
- '(unit / secret-scan / gate-review) + CODEOWNERS, so untrusted builders cannot push straight to production. Skippable for a solo/local instance.',
365
+ '(unit / secret-scan / gate-review), so untrusted builders cannot push straight to production. No review requirement — gates are CI checks, not human approvals (ADR 0162). Skippable for a solo/local instance.',
366
366
  },
367
367
  ];
368
368
  // Production topology is either AUTOMATED (the owner opted into provisioning — the
@@ -3780,6 +3780,27 @@ async function shipMain() {
3780
3780
  console.error(`(Your work is committed and the task is at 'completed' — re-running resumes, task 941.)`);
3781
3781
  process.exit(1);
3782
3782
  }
3783
+ // task 1002613 (map P-1): the one genuine permission wall on the ship path
3784
+ // used to surface as a raw envelope dump — an atom name is not a rank, and
3785
+ // no remedy printed at the one station where self-service exists. Interpret
3786
+ // it: name the wall, the floor (from the server's floors map — never
3787
+ // hardcoded), and both ways forward.
3788
+ const errObj = confirm.data && confirm.data.error;
3789
+ const errCode = typeof errObj === 'string' ? errObj : (errObj && errObj.code);
3790
+ if (confirm.status === 403 && errCode === 'permission_forbidden') {
3791
+ const details = (errObj && typeof errObj === 'object' && errObj.details) || {};
3792
+ const floor = details.floors && details.floors['task.confirm.grade_bypass'];
3793
+ const floorNote = floor ? ` (floor: ${floor})` : '';
3794
+ console.error('');
3795
+ console.error(`✖ Confirming without a passing grade is permission-gated: task.confirm.grade_bypass${floorNote}.`);
3796
+ console.error(' This is an authorization wall, not a verdict on your work.');
3797
+ console.error(' Self-service: re-run WITHOUT --skip-grade to use the subagent grader, and if a');
3798
+ console.error(' grade fails, fix its findings and re-run (unlimited, no attempt cap):');
3799
+ console.error(` node scripts/gds/ship.js ${taskId} --regrade`);
3800
+ console.error(` If the grade genuinely cannot pass, ask a builder whose role holds`);
3801
+ console.error(` task.confirm.grade_bypass${floorNote} to POST /api/gds/tasks/${taskId}/confirm.`);
3802
+ process.exit(1);
3803
+ }
3783
3804
  console.error(`manual-confirm failed (${confirm.status}):`, confirm.data);
3784
3805
  process.exit(1);
3785
3806
  }
@@ -3817,7 +3838,8 @@ async function shipMain() {
3817
3838
  console.error('Task stays at completed. Manual options:');
3818
3839
  console.error(` - Re-run /builder-ship ${taskId} to try grading again, or`);
3819
3840
  console.error(` - node scripts/gds/ship.js ${taskId} --regrade after fixing findings, or`);
3820
- console.error(` - POST /api/gds/tasks/${taskId}/confirm to manual-confirm.`);
3841
+ console.error(` - POST /api/gds/tasks/${taskId}/confirm to manual-confirm (last resort —`);
3842
+ console.error(' permission-gated on task.confirm.grade_bypass).');
3821
3843
  process.exit(1);
3822
3844
  }
3823
3845
 
@@ -4000,7 +4022,8 @@ async function regradeMain() {
4000
4022
  });
4001
4023
  if (!gradePost.ok) {
4002
4024
  console.error(`grade POST failed (${gradePost.status}):`, gradePost.data);
4003
- console.error('Task stays at completed. Fix the issue and re-run --regrade, or POST /confirm to manual-confirm.');
4025
+ console.error('Task stays at completed. Fix the issue and re-run --regrade (unlimited, no attempt cap).');
4026
+ console.error('(POST /confirm is a last resort — it is permission-gated on task.confirm.grade_bypass.)');
4004
4027
  process.exit(1);
4005
4028
  }
4006
4029
 
@@ -1502,7 +1502,12 @@ async function runPageGate(name, req, res, next) {
1502
1502
  }
1503
1503
  const held = await resolveHeldPermissions(session.builder_id, session.raw_permission_keys);
1504
1504
  if (held === null || !held.has(permission)) {
1505
- res.redirect(302, deniedTo);
1505
+ // N-1 (task 1002613): a bare deniedTo rendered nothing — every denial read as
1506
+ // "I signed in and got dumped at the home page". Name the permission (and an
1507
+ // authority outage, distinctly) in the query so the hall shell can say why.
1508
+ // Same destination, same audience — only the redirect learned to speak.
1509
+ const reason = held === null ? '&reason=unavailable' : '';
1510
+ res.redirect(302, `${deniedTo}?denied=${encodeURIComponent(permission)}${reason}`);
1506
1511
  return;
1507
1512
  }
1508
1513
  next();
@@ -1693,11 +1698,38 @@ function requireRank(...allowed) {
1693
1698
  // null` analogue). Reporting only the intersection explains the denial without
1694
1699
  // dumping the builder's entire permission set into an error body.
1695
1700
  //
1701
+ // The body also carries a `message` and a `floors` map (missing atom → its
1702
+ // catalog floor rank) so the denial names the rank, not just the atom (task
1703
+ // 1002613, choke-point map §4.4 P-1). Floors resolve through the port at deny
1704
+ // time — never hardcoded prose, which goes stale when a floor moves (the
1705
+ // D-16/N-3 failure shape ADR 0157 left behind).
1706
+ //
1696
1707
  // Usage: router.post('/roles', auth.requireBuilder, auth.requirePermission('governance.manage'), handler)
1697
1708
  function requirePermission(...perms) {
1698
1709
  const required = Object.freeze([...perms]);
1699
1710
  return async function permissionGate(req, res, next) {
1700
- const deny = (held) => res.fail('permission_forbidden', 403, { required, held });
1711
+ const deny = (held) => {
1712
+ const missing = held === null ? [...required] : required.filter((key) => !held.includes(key));
1713
+ const floors = {};
1714
+ const governance = seams.resolveOptional('governance');
1715
+ if (governance && typeof governance.permissionFloor === 'function') {
1716
+ for (const key of missing) {
1717
+ const floor = governance.permissionFloor(key);
1718
+ if (floor) floors[key] = floor;
1719
+ }
1720
+ }
1721
+ const floorNote = Object.keys(floors).length > 0
1722
+ ? ` (floor: ${[...new Set(Object.values(floors))].join(', ')})`
1723
+ : '';
1724
+ const message = held === null
1725
+ ? `authority could not be established — denied fail-closed (ADR 0016); this route requires ${required.join(', ') || 'a permission list'}${floorNote}`
1726
+ : `missing permission${missing.length === 1 ? '' : 's'}: ${missing.join(', ')}${floorNote}`;
1727
+ return res.fail('permission_forbidden', {
1728
+ status: 403,
1729
+ message,
1730
+ details: { required, held, floors: Object.keys(floors).length > 0 ? floors : null },
1731
+ });
1732
+ };
1701
1733
  const builderId = req.builder && req.builder.id;
1702
1734
  if (!builderId) return deny(null);
1703
1735
  if (required.length === 0) return deny(null);
package/src/module-api.js CHANGED
@@ -467,8 +467,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
467
467
  // landed since 1.19.18 with no explicit bump. run 31346996715. (task 1002620)
468
468
  // 1.19.20 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
469
469
  // landed since 1.19.19 with no explicit bump. run 31347092915. (task 1002620)
470
+ // 1.19.21 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
471
+ // landed since 1.19.20 with no explicit bump. run 31446766597. (task 1002620)
472
+ // 1.19.22 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
473
+ // landed since 1.19.21 with no explicit bump. run 31449168428. (task 1002620)
470
474
  // ---------------------------------------------------------------------------
471
- const CORE_VERSION = '1.19.20'; // CI auto-patch carrier (ADR 0161); see changelog above
475
+ const CORE_VERSION = '1.19.22'; // CI auto-patch carrier (ADR 0161); see changelog above
472
476
 
473
477
  // A namespaced logger so a module's log lines are attributable + consistent.
474
478
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -69,3 +69,45 @@ test('the REAL src/module-api.js is bumpable right now (shape not drifted)', ()
69
69
  assert.match(from, /^\d+\.\d+\.\d+$/);
70
70
  assert.ok(text.includes(`const CORE_VERSION = '${to}';`));
71
71
  });
72
+
73
+ // ---- isReleaseCommit: the cascade terminator (task 1002636) ----
74
+ //
75
+ // The bug: publish.yml's bump+publish push re-fired the workflow, which found
76
+ // the just-published version "taken" — the BUMP trigger, not a no-op — so every
77
+ // release armed the next one (core 1.19.6 → 1.19.20+ from two work merges).
78
+ // The terminator no-ops the run when main HEAD is this lane's own release
79
+ // commit. These tests pin the predicate to the EXACT subject the workflow
80
+ // writes (extracted from publish.yml, not retyped — the two can't drift apart)
81
+ // and pin that a HUMAN release commit never matches (skipping one would
82
+ // swallow its publish entirely).
83
+
84
+ const { isReleaseCommit, RELEASE_COMMIT_RE } = require(path.join(ROOT, 'scripts', 'gds', 'bump-version.js'));
85
+ const PUBLISH_YML = fs.readFileSync(path.join(ROOT, '.github', 'workflows', 'publish.yml'), 'utf8');
86
+
87
+ test('matches the exact -m subject publish.yml writes (extracted, not retyped)', () => {
88
+ const m = PUBLISH_YML.match(/git commit -m "([^"]+)"/);
89
+ assert.ok(m, 'publish.yml must contain the bump git commit -m literal');
90
+ const subject = m[1].replace('${TO}', '9.9.9');
91
+ assert.ok(isReleaseCommit(subject), `predicate must match the workflow's own subject: ${subject}`);
92
+ });
93
+
94
+ test('a HUMAN release commit never matches — skipping it would swallow its publish', () => {
95
+ assert.equal(isReleaseCommit('release: core 1.19.2 — carrier for the init-blocker fixes (task 1002624)'), false);
96
+ assert.equal(isReleaseCommit('release: core 2.0.0 — the doorway break (ADR 9999)'), false);
97
+ });
98
+
99
+ test('work-merge subjects, junk, and empties never match', () => {
100
+ assert.equal(isReleaseCommit('Merge pull request #30 from lars589/claude/cloudbongos-npm-registry-b9a57f'), false);
101
+ assert.equal(isReleaseCommit('fix(ship): protection-aware deploy mode'), false);
102
+ assert.equal(isReleaseCommit(''), false);
103
+ assert.equal(isReleaseCommit(null), false);
104
+ assert.equal(isReleaseCommit(undefined), false);
105
+ assert.equal(RELEASE_COMMIT_RE.source.startsWith('^'), true, 'anchored — a subject QUOTING a release message must not match');
106
+ });
107
+
108
+ test('publish.yml wires the terminator: guard step present, every armed step gated', () => {
109
+ assert.ok(PUBLISH_YML.includes('id: relhead'), 'the Already released? guard step exists');
110
+ assert.match(PUBLISH_YML, /isReleaseCommit/, 'the guard delegates to bump-version.js — single source');
111
+ const gates = (PUBLISH_YML.match(/steps\.relhead\.outputs\.release_head == 'false'/g) || []).length;
112
+ assert.ok(gates >= 7, `every post-guard armed step must carry the gate (setup-node, install, resolve, bump, pack, publish, tag) — found ${gates}`);
113
+ });
@@ -0,0 +1,63 @@
1
+ // tests/gating_review_retired.mjs — review gates stay retired (ADR 0162, task 1002635).
2
+ //
3
+ // The owner retired the code-owner/human-approval layer after it stranded
4
+ // graded-PASS work (PR 28) — gates are rank-consistent CI checks only. Two
5
+ // resurrection paths existed and both are pinned shut here:
6
+ // 1. gating.js DEFAULT_PROTECTION carrying a required_pull_request_reviews
7
+ // block (the baked-in re-arm), and
8
+ // 2. cmdOn replaying a pre-retirement SNAPSHOT whose put_body still holds
9
+ // require_code_owner_reviews: true.
10
+ // gating.js is a CLI (main() runs on require), so these are source-shape pins
11
+ // (the goal_advisory.mjs pattern), with a counter-example proving the checker
12
+ // catches the old shape.
13
+ //
14
+ // Run: node --test tests/gating_review_retired.mjs
15
+
16
+ import assert from 'node:assert/strict';
17
+ import { test } from 'node:test';
18
+ import fs from 'node:fs';
19
+ import path from 'node:path';
20
+ import { fileURLToPath } from 'node:url';
21
+
22
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
23
+ const GATING = fs.readFileSync(path.join(ROOT, 'scripts', 'gds', 'gating.js'), 'utf8');
24
+
25
+ // The retirement invariant, as a checkable predicate over the source.
26
+ function reviewGateRetired(src) {
27
+ const defaultHasNull = /required_pull_request_reviews:\s*null/.test(src);
28
+ const noRequireTrue = !/require_code_owner_reviews:\s*true/.test(src);
29
+ const cmdOnNormalizes = /putBody\s*=\s*\{\s*\.\.\.putBody,\s*required_pull_request_reviews:\s*null\s*\}/.test(src);
30
+ return { defaultHasNull, noRequireTrue, cmdOnNormalizes, ok: defaultHasNull && noRequireTrue && cmdOnNormalizes };
31
+ }
32
+
33
+ test('DEFAULT_PROTECTION requires no reviews and no code-owner block exists anywhere', () => {
34
+ const r = reviewGateRetired(GATING);
35
+ assert.ok(r.defaultHasNull, 'DEFAULT_PROTECTION must set required_pull_request_reviews: null');
36
+ assert.ok(r.noRequireTrue, 'no require_code_owner_reviews: true may exist in gating.js (ADR 0162)');
37
+ });
38
+
39
+ test('cmdOn normalizes BOTH restore paths — a pre-retirement snapshot cannot resurrect the gate', () => {
40
+ const r = reviewGateRetired(GATING);
41
+ assert.ok(r.cmdOnNormalizes, 'cmdOn must overwrite required_pull_request_reviews to null before putProtection');
42
+ });
43
+
44
+ test('the CODEOWNERS file is gone; its GLOB guards remain where they enforce', () => {
45
+ assert.ok(!fs.existsSync(path.join(ROOT, 'CODEOWNERS')), 'CODEOWNERS must not exist (ADR 0162)');
46
+ const gateReview = fs.readFileSync(path.join(ROOT, 'scripts', 'gds', 'gate-review.js'), 'utf8');
47
+ assert.match(gateReview, /'CODEOWNERS'/, 'the HARD_FLOOR glob stays — reintroducing the file is itself gate-flagged');
48
+ });
49
+
50
+ test('the checker actually CATCHES the pre-retirement shape (not a no-op)', () => {
51
+ const oldShape = `
52
+ const DEFAULT_PROTECTION = {
53
+ required_pull_request_reviews: {
54
+ dismiss_stale_reviews: false,
55
+ require_code_owner_reviews: true,
56
+ required_approving_review_count: 0,
57
+ },
58
+ };
59
+ function cmdOn(repo) { putProtection(repo, putBody); }`;
60
+ const r = reviewGateRetired(oldShape);
61
+ assert.equal(r.ok, false, 'the old review-gated shape must fail the predicate');
62
+ assert.equal(r.noRequireTrue, false, 'require_code_owner_reviews: true must be the detected offender');
63
+ });
@@ -74,12 +74,22 @@ const PRE_R107_AUDIENCE = {
74
74
  requireGovernancePage: { xenos: false, thetes: false, metic: true, archon: true },
75
75
  };
76
76
  // Where a SIGNED-IN builder who fails the gate is sent. Never the sign-in flow —
77
- // they already have a session, so that would loop.
77
+ // they already have a session, so that would loop. Since task 1002613 (map N-1)
78
+ // the redirect carries ?denied=<permission> — and &reason=unavailable when the
79
+ // authority system itself never answered — so the hall shell can render WHY
80
+ // instead of silently dumping the builder at the home page.
78
81
  const DENIED_TO = {
79
82
  requireBuilderPage: '/builders',
80
83
  requireNonXenosPage: '/builders/not-ready',
81
84
  requireGovernancePage: '/builders',
82
85
  };
86
+ const GATE_PERMISSION = {
87
+ requireBuilderPage: 'page.view.public',
88
+ requireNonXenosPage: 'page.view.builder',
89
+ requireGovernancePage: 'page.view.governance',
90
+ };
91
+ const deniedLoc = (gate, { unavailable = false } = {}) =>
92
+ `${DENIED_TO[gate]}?denied=${GATE_PERMISSION[gate]}${unavailable ? '&reason=unavailable' : ''}`;
83
93
  // The equivalent rank floor, for the independent cross-check below.
84
94
  const GATE_FLOOR = {
85
95
  requireBuilderPage: 'xenos',
@@ -166,8 +176,8 @@ test('A2: a denied SIGNED-IN builder is redirected to an ungated page, not JSON-
166
176
  if (PRE_R107_AUDIENCE[gate][rank]) continue;
167
177
  const { res, nexted } = await runGate(gate, { rank });
168
178
  assert.equal(nexted, false, `${gate}/${rank} must not admit`);
169
- assert.deepEqual(res._redirect, { code: 302, loc: DENIED_TO[gate] },
170
- `${gate}/${rank} must 302 to ${DENIED_TO[gate]}`);
179
+ assert.deepEqual(res._redirect, { code: 302, loc: deniedLoc(gate) },
180
+ `${gate}/${rank} must 302 to ${deniedLoc(gate)} — naming the permission it lacked`);
171
181
  assert.equal(res._status, null, `${gate}/${rank} must not set an HTTP error status`);
172
182
  assert.equal(res._json, null, `${gate}/${rank} must not answer a JSON body`);
173
183
  assert.notEqual(res._redirect.loc, SIGN_IN,
@@ -211,7 +221,8 @@ test('B2: an UNREGISTERED governance port denies every gate, it does not admit',
211
221
  for (const gate of GATES) {
212
222
  const { res, nexted } = await runGate(gate, { rank: 'archon', port: false });
213
223
  assert.equal(nexted, false, `${gate}: a missing authority system must never open a page gate`);
214
- assert.equal(res._redirect.loc, DENIED_TO[gate]);
224
+ assert.equal(res._redirect.loc, deniedLoc(gate, { unavailable: true }),
225
+ `${gate}: an authority outage must say so (&reason=unavailable), distinct from a permission denial`);
215
226
  assert.equal(res._json, null);
216
227
  }
217
228
  });
@@ -223,7 +234,7 @@ test('B3: a throwing resolver denies (a DB blip must not open the page)', async
223
234
  resolve: async () => { throw new Error('connection terminated'); },
224
235
  });
225
236
  assert.equal(nexted, false, `${gate} must fail CLOSED on a resolve failure`);
226
- assert.equal(res._redirect.loc, DENIED_TO[gate]);
237
+ assert.equal(res._redirect.loc, deniedLoc(gate, { unavailable: true }));
227
238
  }
228
239
  });
229
240
 
@@ -247,7 +258,7 @@ test('B4: the page gates resolve per request — no memoised authority (ADR 0016
247
258
  seams._reset();
248
259
  assert.equal(calls, 2, 'the resolver must be consulted on every request');
249
260
  assert.equal(admittedAgain, false, 'a revoked role must deny on the very next request');
250
- assert.equal(second._redirect.loc, '/builders');
261
+ assert.equal(second._redirect.loc, deniedLoc('requireGovernancePage'));
251
262
  });
252
263
 
253
264
  test('B5: every permission a page gate names is a real catalog key', async () => {
@@ -83,6 +83,38 @@ test('multiple permissions are AND, not OR — holding one of two still denies',
83
83
  assert.equal(both.nexted, true, 'holding both must admit');
84
84
  });
85
85
 
86
+ test('the denial names the floor rank, not just the atom (task 1002613, map P-1)', async () => {
87
+ // With the real port shape (permissionFloor present) the body carries a floors
88
+ // map and a message naming the rank. The floor is read from the catalog here so
89
+ // this test moves WITH a floor change instead of pinning stale prose — the
90
+ // D-16/N-3 failure shape the fix exists to avoid.
91
+ seams._reset();
92
+ seams.registerProvider('governance', {
93
+ resolveBuilderPermissions: holding('idea.triage'),
94
+ permissionFloor: (key) => (catalog.byKey(key) || {}).floor || null,
95
+ });
96
+ const res = fakeRes();
97
+ await auth.requirePermission('task.confirm.grade_bypass')({ builder: BUILDER }, res, () => {});
98
+ seams._reset();
99
+ assert.equal(res._status, 403);
100
+ const err = res._json.error;
101
+ const floor = catalog.byKey('task.confirm.grade_bypass').floor;
102
+ assert.deepEqual(err.details.floors, { 'task.confirm.grade_bypass': floor },
103
+ 'floors maps each MISSING atom to its seeded floor');
104
+ assert.ok(err.message.includes('task.confirm.grade_bypass'), 'the message names the atom');
105
+ assert.ok(err.message.includes(floor), 'the message names the rank — an atom name is not a rank');
106
+ });
107
+
108
+ test('a port without permissionFloor still denies with a message; floors is null', async () => {
109
+ const { res } = await run(
110
+ auth.requirePermission('governance.manage'), { builder: BUILDER }, holding('idea.triage'),
111
+ );
112
+ assert.equal(res._status, 403);
113
+ assert.equal(res._json.error.details.floors, null);
114
+ assert.ok(res._json.error.message.includes('governance.manage'),
115
+ 'the message names the missing atom even when no floor is resolvable');
116
+ });
117
+
86
118
  test('no req.builder fails closed (gate mounted without requireBuilder)', async () => {
87
119
  const { res, nexted } = await run(auth.requirePermission('idea.triage'), {}, holding('idea.triage'));
88
120
  assert.equal(nexted, false);
@@ -162,8 +162,10 @@ t('claim path AUTO-ASSIGNS inside the FOR UPDATE transaction (never refuses)', (
162
162
  // The window is a slice heuristic, not a contract: it only has to cover the
163
163
  // pre-flight half of claimTask (which spans ~13k chars). Widened 5000 -> 6000 in
164
164
  // BV1.R106, when the permission-expressed claim gate pushed the reward-gate block
165
- // past the old edge. Keep it comfortably below the claimTasksBatch boundary.
166
- const body = dbSrc.slice(start, start + 6000);
165
+ // past the old edge; 6000 -> 7000 in task 1002613, when the enriched
166
+ // rank_too_low_for_task denial did the same. Keep it comfortably below the
167
+ // claimTasksBatch boundary (~15.5k chars in).
168
+ const body = dbSrc.slice(start, start + 7000);
167
169
  assert.match(body, /rewardGateAssignment\(task\)/, 'claimTask must consult the auto-assign decision');
168
170
  assert.match(body, /UPDATE tasks SET credits_reward/, 'claimTask must persist the assigned reward in-tx');
169
171
  // it must SELECT the columns the gate needs.