@bongos/core 1.19.21 → 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,11 +2,11 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.21",
6
- "core_contract": "1.19.21",
7
- "source_commit": "1bcacbc141d1103f3b8e89d9c3963369740c783d",
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-11T00:39:38.782Z",
9
+ "built_at": "2026-08-11T01:24:29.274Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 285,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 1510,
20
- "tree_sha256": "1f7f270a9f0d217360d9126213d6c7d752039999b895385c114df75fde81f14f",
20
+ "tree_sha256": "0a775991e628ae986369cd819f6b384887fa20c1095f7e7263f07f13ad0e8167",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -2797,7 +2797,7 @@
2797
2797
  {
2798
2798
  "path": "modules/governance/routes/governance.js",
2799
2799
  "mode": "0000644",
2800
- "sha256": "6f600bb237009064f18eae7af140cd8376cf5770ff7efd89d0e4071bade9eb4d"
2800
+ "sha256": "2b390c2c51812aa547f85045fe6d36be6c53086f3fa2468ab7c20c4a7d19dc08"
2801
2801
  },
2802
2802
  {
2803
2803
  "path": "modules/governance/routes/role-sync.js",
@@ -3247,12 +3247,12 @@
3247
3247
  {
3248
3248
  "path": "modules/hall-ui/public/shell.js",
3249
3249
  "mode": "0000644",
3250
- "sha256": "00a356cf20df11fd195c8ecce619ba5e4bc4138ccb3b66ebdef1522fa3f56c8e"
3250
+ "sha256": "56c1450dd72fcda19841f3a0a98b918b1b875f49971e93d60218fb62e8a21dad"
3251
3251
  },
3252
3252
  {
3253
3253
  "path": "modules/hall-ui/public/style.css",
3254
3254
  "mode": "0000644",
3255
- "sha256": "9fa49a72160d8dd156bba5f55d2300b9188ea9ae133a6dfb8ab13f3cdc2ab76d"
3255
+ "sha256": "41fbe46c9bbc6e9a4397733c070cb2d5e7bfd698d629445f786a44c44a35b0c4"
3256
3256
  },
3257
3257
  {
3258
3258
  "path": "modules/hall-ui/public/task-detail.js",
@@ -3352,7 +3352,7 @@
3352
3352
  {
3353
3353
  "path": "modules/lifecycle/db.js",
3354
3354
  "mode": "0000644",
3355
- "sha256": "44cdb3c44dcf9277e9e0661d664387f0497e7d9b11c9775e1133aeec0da9db9f"
3355
+ "sha256": "7dc50e4e88a204583c753555c76f4103df405a143c4266dbff53793c292803f9"
3356
3356
  },
3357
3357
  {
3358
3358
  "path": "modules/lifecycle/done-when.js",
@@ -4002,12 +4002,12 @@
4002
4002
  {
4003
4003
  "path": "package-lock.json",
4004
4004
  "mode": "0000644",
4005
- "sha256": "a408615d881c51b175e64ca336475849090253394df3bc7e428bb0e28d79d40e"
4005
+ "sha256": "f85ec26a3a5cbefec1aca8407043cb2eb2886120722a79126d364736f1a67584"
4006
4006
  },
4007
4007
  {
4008
4008
  "path": "package.json",
4009
4009
  "mode": "0000644",
4010
- "sha256": "e9e8deee79bedc058f9870901d7afdb266d99443d4877059ece4e9cbe4501e2f"
4010
+ "sha256": "9275da3329d188788a0e69b719231cd23ae38b3499802edbcefebded17317067"
4011
4011
  },
4012
4012
  {
4013
4013
  "path": "public-docs/index.html",
@@ -5197,7 +5197,7 @@
5197
5197
  {
5198
5198
  "path": "scripts/gds/ship.js",
5199
5199
  "mode": "0000644",
5200
- "sha256": "bea8a5d8698abf30064c005076c069d470203c86a37a1ff7a3aebc4a5ded19a6"
5200
+ "sha256": "65af2a0d6fc54ee3dbb3c7faa44796fed2a5817fa48073cebfd9d6a15837fec7"
5201
5201
  },
5202
5202
  {
5203
5203
  "path": "scripts/gds/smoke-batch-claim.sh",
@@ -5392,7 +5392,7 @@
5392
5392
  {
5393
5393
  "path": "src/bongos/auth.js",
5394
5394
  "mode": "0000644",
5395
- "sha256": "60888e8a84fc109ce258256ed7c0af13a8a453464b4f71716bc3a018a89f1b1a"
5395
+ "sha256": "95428c88621510a97a5bfaf89a836508501b1db0f0910f529a260fa2dafce3b5"
5396
5396
  },
5397
5397
  {
5398
5398
  "path": "src/bongos/cascade-dispatch.js",
@@ -5627,7 +5627,7 @@
5627
5627
  {
5628
5628
  "path": "src/module-api.js",
5629
5629
  "mode": "0000644",
5630
- "sha256": "81389daa63b2184235f45d0eb6d9aec17debbd00a9f7ffb74d7508953f0317ba"
5630
+ "sha256": "937021d749587baee02de0c83fa721b99a0e23850c5c12aa5332424673e5d6af"
5631
5631
  },
5632
5632
  {
5633
5633
  "path": "src/module-loader/loader.js",
@@ -6512,7 +6512,7 @@
6512
6512
  {
6513
6513
  "path": "tests/governance_page_gates.mjs",
6514
6514
  "mode": "0000644",
6515
- "sha256": "cf0d01b58a209d9d2ad64e2c1c999ce2338fb0f69bb5ddff43ca7f24d7bc66be"
6515
+ "sha256": "be6cc9a4070d9de107cd11bfcb7a38e074f8aeb71198839cd42438d3ea2a0c04"
6516
6516
  },
6517
6517
  {
6518
6518
  "path": "tests/governance_parity.mjs",
@@ -6532,7 +6532,7 @@
6532
6532
  {
6533
6533
  "path": "tests/governance_require_permission.mjs",
6534
6534
  "mode": "0000644",
6535
- "sha256": "58090f6e6cb0cb7e99b03675deeb80302986b053dc1402e254f64ae4126df084"
6535
+ "sha256": "2e8d7f3bb58411efd79727f6edffa448136551ef1c3c6973228fdfa7903f7cb3"
6536
6536
  },
6537
6537
  {
6538
6538
  "path": "tests/governance_resolver.mjs",
@@ -7167,7 +7167,7 @@
7167
7167
  {
7168
7168
  "path": "tests/reward_gate.mjs",
7169
7169
  "mode": "0000644",
7170
- "sha256": "ace9bb9db5a25b98de4d62f5150fb17db345d78e747aca95a6628dcd2a957afe"
7170
+ "sha256": "7ae4d9af4d389c34f0d16f921858cbbf5d0d57f3219f830d10552ebcdc43908f"
7171
7171
  },
7172
7172
  {
7173
7173
  "path": "tests/reward_mode_cost_plus_only.mjs",
@@ -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.21",
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.21",
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.21",
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",
@@ -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
@@ -469,8 +469,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
469
469
  // landed since 1.19.19 with no explicit bump. run 31347092915. (task 1002620)
470
470
  // 1.19.21 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
471
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)
472
474
  // ---------------------------------------------------------------------------
473
- const CORE_VERSION = '1.19.21'; // 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
474
476
 
475
477
  // A namespaced logger so a module's log lines are attributable + consistent.
476
478
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -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.