@bongos/core 1.19.21 → 1.19.23

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.23",
6
+ "core_contract": "1.19.23",
7
+ "source_commit": "fcdc2c0100a2b96183b34980f7c33572b67c7d1f",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-11T00:39:38.782Z",
9
+ "built_at": "2026-08-11T01:44:34.493Z",
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": "80499ca716f5acc869706f3832aba22ffa0facdf31abd6eca71baec9b273d628",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -352,7 +352,7 @@
352
352
  {
353
353
  "path": "clients/bongos-client/index.d.ts",
354
354
  "mode": "0000644",
355
- "sha256": "2e321ea71ad8b24a5562b97dc76bedae62e3a313fd8cbb72f415fa575bd686a1"
355
+ "sha256": "7e6732aedaf7aaa3c657fc05e2dfd1dcc63e0fb06586d4077248d79d6198297e"
356
356
  },
357
357
  {
358
358
  "path": "clients/bongos-client/index.mjs",
@@ -1277,12 +1277,12 @@
1277
1277
  {
1278
1278
  "path": "docs/api-reference.md",
1279
1279
  "mode": "0000644",
1280
- "sha256": "c2e909c0f62a643dc2ff4c705cc6ffccf279dcc588945d1b39147d09d70c5f5b"
1280
+ "sha256": "e28575936d1cb0cce684b20723743fd0d801ccd0a7ec58fff5360e160b23b5e7"
1281
1281
  },
1282
1282
  {
1283
1283
  "path": "docs/api/openapi.json",
1284
1284
  "mode": "0000644",
1285
- "sha256": "43b270dbfaa283c2c793d8f779b497cfd0c6da4f4a4cef07f4516d1c4e582946"
1285
+ "sha256": "b232ca69ae97842be312b76f6ac49ca66090c5c8fef392f609bb857e5f5c2979"
1286
1286
  },
1287
1287
  {
1288
1288
  "path": "docs/architecture.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",
@@ -2882,7 +2882,7 @@
2882
2882
  {
2883
2883
  "path": "modules/grading/grader.js",
2884
2884
  "mode": "0000644",
2885
- "sha256": "b421b3749396e14bbfa203519a0d25083f540c222deccfc755ad6ca808239859"
2885
+ "sha256": "a08e9c017a07bffa311a9de6df007c30d9f3766c86d0ce2ec88660fed60d37aa"
2886
2886
  },
2887
2887
  {
2888
2888
  "path": "modules/grading/module.json",
@@ -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",
@@ -3437,7 +3437,7 @@
3437
3437
  {
3438
3438
  "path": "modules/lifecycle/routes/done-when.js",
3439
3439
  "mode": "0000644",
3440
- "sha256": "f77651b5418d6eb99e78ecf4c864f239ba75a149444a88eae59a5c3082f48cae"
3440
+ "sha256": "cec4dce0ab8d11b16de73dbee8adb782f47c12a32d203dd1ff726afd23caadf3"
3441
3441
  },
3442
3442
  {
3443
3443
  "path": "modules/lifecycle/routes/gate-approvals.js",
@@ -3457,7 +3457,7 @@
3457
3457
  {
3458
3458
  "path": "modules/lifecycle/routes/tasks.js",
3459
3459
  "mode": "0000644",
3460
- "sha256": "14320bb5a3257a422854c41362f010042ed390bdd7efcf1b4959ed72f0ee1f9f"
3460
+ "sha256": "add6acd00adc842e90348ca789da0a473aa2dc68d949cf2b5586c68ef5eaa1be"
3461
3461
  },
3462
3462
  {
3463
3463
  "path": "modules/lifecycle/routes/versions.js",
@@ -4002,12 +4002,12 @@
4002
4002
  {
4003
4003
  "path": "package-lock.json",
4004
4004
  "mode": "0000644",
4005
- "sha256": "a408615d881c51b175e64ca336475849090253394df3bc7e428bb0e28d79d40e"
4005
+ "sha256": "969211290cee8ccee357109c325ff4741c8cce3319d520b22506126099beea7e"
4006
4006
  },
4007
4007
  {
4008
4008
  "path": "package.json",
4009
4009
  "mode": "0000644",
4010
- "sha256": "e9e8deee79bedc058f9870901d7afdb266d99443d4877059ece4e9cbe4501e2f"
4010
+ "sha256": "b00d5423d5019e305bb488af793202ccb4d3089da6c129851e32f85ea4219904"
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": "085a7feeff06fa7d2b833c483795161e2c4a6ee79630e2f09d7ae2f06eeb253c"
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": "0e4203ec0098beb9a54a4ea56b3c5701cfca5bb02b6676114a9257f5b353aff5"
5631
5631
  },
5632
5632
  {
5633
5633
  "path": "src/module-loader/loader.js",
@@ -6137,7 +6137,7 @@
6137
6137
  {
6138
6138
  "path": "tests/criterion_authoring_gate.mjs",
6139
6139
  "mode": "0000644",
6140
- "sha256": "23d8c31f310e7ffb840dce3850c1eb2eab59b5513489bde19edb22c5bab6eb75"
6140
+ "sha256": "c3cafa042f7ac9715569b9c3312e27b920e88497047415cb653bb576cf5380a9"
6141
6141
  },
6142
6142
  {
6143
6143
  "path": "tests/criterion_progress.mjs",
@@ -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",
@@ -6582,12 +6582,12 @@
6582
6582
  {
6583
6583
  "path": "tests/grade_server_authoritative.mjs",
6584
6584
  "mode": "0000644",
6585
- "sha256": "c582e967a183ac9fcff99c2059543ac34f2dd98b39f21b5053d2c463287f01e6"
6585
+ "sha256": "c650bd3c79d469f3ffb1cdada6f23521dd09c97844affd5b9e7f340d1796daa6"
6586
6586
  },
6587
6587
  {
6588
6588
  "path": "tests/grade_unavailable_display.mjs",
6589
6589
  "mode": "0000644",
6590
- "sha256": "c6082286a017edc4369e4766b346d8ea981667a4270839868d7b846cde154782"
6590
+ "sha256": "82746689da273a8bd9d53d80204b15147d87574d2331c3a70d736d95a6126a09"
6591
6591
  },
6592
6592
  {
6593
6593
  "path": "tests/grader.mjs",
@@ -6992,7 +6992,7 @@
6992
6992
  {
6993
6993
  "path": "tests/override_request_ownership.mjs",
6994
6994
  "mode": "0000644",
6995
- "sha256": "da77422fac6c4624315b8c51bd8974a93a5f64f0c72c1ef1a3665744e42dc15c"
6995
+ "sha256": "dac9d9891eac706432aff97ce35e27692064a2791dc1e546215c74e68b81e8a8"
6996
6996
  },
6997
6997
  {
6998
6998
  "path": "tests/package_core.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",
@@ -7502,7 +7502,7 @@
7502
7502
  {
7503
7503
  "path": "tests/task_ownership_gate.mjs",
7504
7504
  "mode": "0000644",
7505
- "sha256": "0a0aa9bd7147256422f348567a67e206bafeefe88430195834ddebf405b59bdd"
7505
+ "sha256": "cfb57e35ce65f991309e4de64c03f9252e19117a1ad406cf9bb04c1716f57746"
7506
7506
  },
7507
7507
  {
7508
7508
  "path": "tests/touches_overlap.mjs",
@@ -131,7 +131,7 @@ export interface PatchMeSkillPrefsResponse { prefs: unknown; effective: unknown;
131
131
  export interface PatchMeSoundPrefsResponse { prefs: unknown; effective: unknown; effective_map: unknown; volume: unknown; defaults: unknown; known_sounds: unknown }
132
132
  export interface PatchProjectsIdFeaturedRequest { featured: boolean }
133
133
  export interface PatchProjectsIdFeaturedResponse { project: unknown }
134
- export interface PatchTasksIdRequest { kind?: string; discipline?: string; requires_rank?: string; module_key?: string; goal_id?: number; parent_task_id?: StringifiableId; parallel_safe?: unknown; newcomer_friendly?: boolean; xenos_claimable?: boolean; security_sensitive?: boolean; automation_tag?: string }
134
+ export interface PatchTasksIdRequest { kind?: string; discipline?: string; requires_rank?: string; module_key?: string; goal_id?: number; parent_task_id?: StringifiableId; parallel_safe?: unknown; newcomer_friendly?: boolean; xenos_claimable?: boolean; security_sensitive?: boolean; automation_tag?: string; status?: unknown }
135
135
  export interface PostAccessRequestsRequest { github_login: string; display_name?: string; note?: string }
136
136
  export interface PostAccessRequestsResponse { ok: boolean; request?: unknown; already_pending?: boolean }
137
137
  export interface PostAuthAppPairApproveRequest { pair_code: string }
@@ -12008,7 +12008,7 @@
12008
12008
  "versions"
12009
12009
  ],
12010
12010
  "summary": "POST /versions/:id/done-when",
12011
- "description": "Auth (Metic+, then the goal's own authority): create a done-when criterion at RUNTIME. ADR 0105 moved scope-truth seeds (versions/goals/criteria) out of the normal migration path, so a brand-new criterion on a RUNNING instance can no longer be added by a plain migration (migrations/instance/ applies only under the owner opt-in GDS_APPLY_INSTANCE_SEEDS=1, a DR step). This is the vector a planning session uses to add a criterion to a live version WITHOUT a droplet migrate. ADR 0154 — authority over a GOAL's criteria follows GOAL OWNERSHIP, not global rank. A Metic who may create the goal (POST /goals) and author the tasks under it (POST /tasks, ADR 0090) may now write the criteria those tasks gate — but only inside a goal they own or manage. Deliberately NOT a flat re-rank: - An unsatisfied criterion permanently blocks version close (version-close.js counts satisfied === total per VERSION, regardless of goal) and goal auto-achieve (db.achieveGoalIfComplete). There is NO delete route, so it cannot be undone via the API — the same backward ratchet as unsatisfy, which stays Archon. - So a non-Archon must name a goal_id (version-level criteria stay Archon), must be that goal's OWNER or MANAGER (isGoalOwnerOrManager — the same predicate behind visibility/invite/role management), and the goal must be open on a planning/building version. That bounds the blast radius to a workspace they already hold. An unauthorized caller is masked behind bad_goal_id so the route is not an existence oracle (ADR 0112). Pinned in route-rank-check.MODULE_EXPECTED_RANKS. rank: metic+archon — criterion create, walled by goal ownership (ADR 0154).\n\n**Rank:** `metic+archon` — Metic or Archon rank (review/triage powers).\n\n**Permissions:** `criterion.create` (all required).",
12011
+ "description": "Auth (Metic+, then the goal's own authority): create a done-when criterion at RUNTIME. ADR 0105 moved scope-truth seeds (versions/goals/criteria) out of the normal migration path, so a brand-new criterion on a RUNNING instance can no longer be added by a plain migration (migrations/instance/ applies only under the owner opt-in GDS_APPLY_INSTANCE_SEEDS=1, a DR step). This is the vector a planning session uses to add a criterion to a live version WITHOUT a droplet migrate. ADR 0154 — authority over a GOAL's criteria follows GOAL OWNERSHIP, not global rank. A Metic who may create the goal (POST /goals) and author the tasks under it (POST /tasks, ADR 0090) may now write the criteria those tasks gate — but only inside a goal they own or manage. Deliberately NOT a flat re-rank: - An unsatisfied criterion permanently blocks version close (version-close.js counts satisfied === total per VERSION, regardless of goal) and goal auto-achieve (db.achieveGoalIfComplete). There is NO delete route, so it cannot be undone via the API — the same backward ratchet as unsatisfy, which stays Archon. - So a non-Archon must name a goal_id (version-level criteria stay Archon), must be that goal's OWNER or MANAGER (isGoalOwnerOrManager — the same predicate behind visibility/invite/role management), and the goal must be open on a planning/building version. That bounds the blast radius to a workspace they already hold. An unauthorized caller gets an honest 403 goal_not_yours (task 1002614 — goal reads are open to any builder, so there is no existence oracle for a mask to protect). Pinned in route-rank-check.MODULE_EXPECTED_RANKS. rank: metic+archon — criterion create, walled by goal ownership (ADR 0154).\n\n**Rank:** `metic+archon` — Metic or Archon rank (review/triage powers).\n\n**Permissions:** `criterion.create` (all required).",
12012
12012
  "x-rank": "metic+archon",
12013
12013
  "x-source": "modules/lifecycle/routes/done-when.js",
12014
12014
  "x-permissions": [
@@ -13788,7 +13788,8 @@
13788
13788
  "automation_tag": {
13789
13789
  "type": "string",
13790
13790
  "maxLength": 64
13791
- }
13791
+ },
13792
+ "status": {}
13792
13793
  },
13793
13794
  "additionalProperties": false
13794
13795
  },
@@ -463,7 +463,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
463
463
  | GET | `/api/bongos/tasks` | `any-builder` | — | reads (filtered task list); preserved per CRITICAL §4. |
464
464
  | POST | `/api/bongos/tasks` | `metic+archon` | `version_id`, `title`, `description`, `status`, `touches`, `est_minutes`, `est_cost_usd`, `manual_degree`, `priority`, `automation_tag`, `credits_reward`, `source`, `source_ref`, `value_summary`, `kind`, `discipline`, `parallel_safe`, `newcomer_friendly`, `security_sensitive`, `requires_rank`, `auto_detect_touches`, `criterion_ids`, `module_key`, `goal_id` | Task create/promote/edit (plan/scope mutation) is Metic+ as of ADR 0090 (owner decision 2026-06-26): a Metic who can run idea-triage may … |
465
465
  | GET | `/api/bongos/tasks/:id` | `any-builder` | — | single-task read; preserved per CRITICAL §4 (supervisor hot path). |
466
- | PATCH | `/api/bongos/tasks/:id` | `metic+archon` | `kind`, `discipline`, `requires_rank`, `module_key`, `goal_id`, `parent_task_id`, `parallel_safe`, `newcomer_friendly`, `xenos_claimable`, `security_sensitive`, `automation_tag` | PATCH /tasks/:id — supports body.parent_task_id (V2 C2), body.kind (V2 B1), body.discipline (mig 014), body.parallel_safe (mig 028), and … |
466
+ | PATCH | `/api/bongos/tasks/:id` | `metic+archon` | `kind`, `discipline`, `requires_rank`, `module_key`, `goal_id`, `parent_task_id`, `parallel_safe`, `newcomer_friendly`, `xenos_claimable`, `security_sensitive`, `automation_tag`, `status` | PATCH /tasks/:id — supports body.parent_task_id (V2 C2), body.kind (V2 B1), body.discipline (mig 014), body.parallel_safe (mig 028), and … |
467
467
  | POST | `/api/bongos/tasks/:id/abandon` | `metic+archon` | `reason` | POST /tasks/:id/abandon — terminal "retire this task" transition (task 987). |
468
468
  | POST | `/api/bongos/tasks/:id/attest-gate` | `any-builder` | `head_sha` | POST /tasks/:id/attest-gate — body: { head_sha }. |
469
469
  | POST | `/api/bongos/tasks/:id/confirm` | `any-builder` | `verified` | |
@@ -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,
@@ -734,6 +734,12 @@ function serverSideGuards(gradeResult, { committedFiles, builderRank, diffStats,
734
734
  downgraded_to: downgradeTo,
735
735
  client_passed: clientPassed,
736
736
  tripped: guardsTripped,
737
+ // task 1002614 (map P-2): a permission_path trip is an AUTHORIZATION
738
+ // refusal of the builder's rank over protected paths — not a verdict on
739
+ // the code. Named here so clients render it as a wall, not a quality
740
+ // FAIL the builder should fix-and-retry (the envelope used to teach
741
+ // exactly that wrong lesson). route_rank stays a code finding.
742
+ authorization_refusal: guardsTripped.some((g) => g.guard === 'permission_path'),
737
743
  },
738
744
  },
739
745
  notes_md: `${base.notes_md || ''}${guardNote}`,
@@ -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
@@ -26,11 +26,11 @@ const { validateOrRespond, parseId, asyncHandler, LIMITS } = api;
26
26
  // pinned by a DB-free unit test (the authorizeGoalTaskCreate precedent in
27
27
  // routes/goals.js). The route resolves goal/version first — their absence is a
28
28
  // 404/400 there — so this gate only decides AUTHORITY, in this order:
29
- // archon → allow · goal named? · owner|manager? (MASKED) · goal open? · version open?
30
- // Authority sits ahead of both state checks on purpose — see the no-leak note below.
31
- // An Archon short-circuits: version-level (goal-less) criteria and any goal in any
32
- // state stay theirs. Returns { ok:true }, { ok:false, status, body }, or
33
- // { ok:false, maskAsBadGoalId:true } (the route renders the identical 400).
29
+ // archon → allow · goal named? · owner|manager? (403) · goal open? · version open?
30
+ // Authority sits ahead of both state checks on purpose — an outsider never learns
31
+ // goal state from the 409s below. An Archon short-circuits: version-level
32
+ // (goal-less) criteria and any goal in any state stay theirs. Returns
33
+ // { ok:true } or { ok:false, status, body }.
34
34
  //
35
35
  // "Version open" = `planning` or `building`. NOT just `building`: a planning session
36
36
  // scopes a version at status='planning', which is the main moment criteria get
@@ -47,16 +47,18 @@ function authorizeCriterionCreate({ goal, version, actorId, actorRank, actorMemb
47
47
  message: 'Name the goal this criterion belongs to — a version-level criterion is Archon-only (ADR 0154).',
48
48
  } };
49
49
  }
50
- // NO-LEAK ORDERING — authority BEFORE state, and the refusal is MASKED.
51
- // Before ADR 0154 this route was Archon-only, and an Archon can already read
52
- // every goal, so no probe existed. Now a Metic may call it, and distinguishable
53
- // refusals (400 "no such goal" vs 403 "not yours" vs 409 "not open") would turn
54
- // it into an oracle for which goal ids exist on a version — including PRIVATE
55
- // goals (ADR 0112). So a caller who is not the owner/manager gets byte-identical
56
- // `bad_goal_id` to a caller naming a goal that does not exist. Only someone who
57
- // already holds the goal learns anything further about it.
50
+ // Authority BEFORE state (an outsider still never sees goal state via the 409
51
+ // below). Until task 1002614 the refusal here was MASKED behind the same
52
+ // `bad_goal_id` 400 as an absent goal, as an anti-oracle for goal ids (ADR
53
+ // 0112). The mask asserted a falsehood about a goal that exists and bought no
54
+ // secrecy: ADR 0112 privacy is join-gating, not read-hiding — GET /goals/:id
55
+ // serves any authenticated builder the goal, its members, criteria and state
56
+ // (choke-point map D-3). So the refusal now names its wall.
58
57
  if (!isGoalOwnerOrManager({ goal, actorId, actorRank, actorMembership })) {
59
- return { ok: false, maskAsBadGoalId: true };
58
+ return { ok: false, status: 403, body: {
59
+ error: 'goal_not_yours',
60
+ message: `Criteria on goal ${goal.id} can be authored only by the goal's owner or a manager (ADR 0154). Ask them to add it — or to grant you the manager role.`,
61
+ } };
60
62
  }
61
63
  if (goal.status !== 'open') {
62
64
  return { ok: false, status: 409, body: {
@@ -112,8 +114,9 @@ module.exports = function buildDoneWhenRouter() {
112
114
  // must be that goal's OWNER or MANAGER (isGoalOwnerOrManager — the same
113
115
  // predicate behind visibility/invite/role management), and the goal must be
114
116
  // open on a planning/building version. That bounds the blast radius to a
115
- // workspace they already hold. An unauthorized caller is masked behind
116
- // bad_goal_id so the route is not an existence oracle (ADR 0112).
117
+ // workspace they already hold. An unauthorized caller gets an honest 403
118
+ // goal_not_yours (task 1002614 — goal reads are open to any builder, so
119
+ // there is no existence oracle for a mask to protect).
117
120
  // Pinned in route-rank-check.MODULE_EXPECTED_RANKS.
118
121
  // rank: metic+archon — criterion create, walled by goal ownership (ADR 0154).
119
122
  router.post('/versions/:id/done-when', auth.requireBuilder, auth.requirePermission('criterion.create'), asyncHandler('POST /versions/:id/done-when', async (req, res) => {
@@ -127,9 +130,9 @@ module.exports = function buildDoneWhenRouter() {
127
130
  })) return;
128
131
  const version = await db.getVersion(versionId);
129
132
  if (!version) return res.fail('version_not_found', { status: 404, message: `No version '${versionId}'.` });
130
- // The ONE bad_goal_id refusal, used both for a genuinely absent/foreign goal
131
- // and for the masked unauthorized case below — same code, same message, same
132
- // envelope, so the two are indistinguishable on the wire.
133
+ // bad_goal_id now means exactly what it says: the goal is absent or belongs
134
+ // to another version. (The unauthorized case rendered through this same
135
+ // helper until task 1002614 — see the mask note in authorizeCriterionCreate.)
133
136
  const badGoalId = () => res.fail('bad_goal_id', { status: 400, message: `goal_id ${req.body.goal_id} does not exist on version '${versionId}'.` });
134
137
  // A goal_id, if given, must exist AND belong to this version.
135
138
  let goal = null;
@@ -148,7 +151,6 @@ module.exports = function buildDoneWhenRouter() {
148
151
  goal, version, actorId: req.builder.id, actorRank: req.builder.rank, actorMembership,
149
152
  });
150
153
  if (!decision.ok) {
151
- if (decision.maskAsBadGoalId) return badGoalId();
152
154
  return res.status(decision.status).json(decision.body);
153
155
  }
154
156
  try {
@@ -129,6 +129,15 @@ async function gateTaskOwnership(req, res, taskId, { allowSupervisor = false } =
129
129
  if (allowSupervisor && rank === 'metic') return true;
130
130
  const holder = await db.getLastClaimHolder(taskId);
131
131
  if (holder && String(holder.builder_id) === String(req.builder.id)) return true;
132
+ // task 1002614 (map N-2): a mistyped id used to earn the same not_your_task
133
+ // 403 as someone else's work — a misattribution that sent builders debugging
134
+ // ownership. An ABSENT task is the clean 404 an Archon already saw (they pass
135
+ // above and hit the handler's own lookup). A never-claimed task that EXISTS
136
+ // still fails closed with the 403 below (the L2 credit-theft block).
137
+ if (!holder && !(await db.getTask(taskId))) {
138
+ res.fail('task_not_found', 404);
139
+ return false;
140
+ }
132
141
  res.fail('not_your_task', { status: 403, message: 'Only the task\'s claim holder (or an Archon) may grade/confirm/ship it.' });
133
142
  return false;
134
143
  }
@@ -764,6 +773,11 @@ module.exports = function buildTasksRouter() {
764
773
  xenos_claimable: { type: 'boolean' }, // pre-rename alias for newcomer_friendly
765
774
  security_sensitive: { type: 'boolean' }, // task 990 — Archon-only ship broadcast
766
775
  automation_tag: { type: 'string', maxLength: 64 },
776
+ // task 1002614 (map D-18): DECLARED so strict-mode admits the request far
777
+ // enough to reach the deliberate status_not_patchable hint below (#514) —
778
+ // the hint had been unreachable dead code behind unknown_field since the
779
+ // R11 strict-validation sweep landed after it.
780
+ status: {},
767
781
  })) return;
768
782
  const id = parseId(req, res, { code: 'bad_task_id', positiveInt: true });
769
783
  if (id === null) return;
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.23",
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.23",
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.23",
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",
@@ -2776,6 +2776,36 @@ function formatGradeOutcome(g, taskId) {
2776
2776
  return out.join('\n');
2777
2777
  }
2778
2778
 
2779
+ // task 1002614 (map P-2): a protected-path refusal used to render as an
2780
+ // ordinary quality FAIL — "fix the issues and --regrade" — although no rank of
2781
+ // the builder's can land that diff. Present it as the authorization wall it is
2782
+ // (the #943 grader-unavailable branch is the precedent for a distinct shape).
2783
+ // Keyed off the guard name so it also reads grades from servers predating the
2784
+ // authorization_refusal marker.
2785
+ const guardTripped = g.signals?.server_guard?.tripped || [];
2786
+ const permRefusal = g.signals?.server_guard?.authorization_refusal === true
2787
+ || guardTripped.some((t) => t.guard === 'permission_path');
2788
+ if (!g.passed && permRefusal) {
2789
+ out.push('');
2790
+ out.push('Subagent grade: REFUSED (authorization) · not a quality verdict — your work was not judged and rejected');
2791
+ out.push('');
2792
+ out.push('The committed diff touches PROTECTED surfaces above your rank, so the server');
2793
+ out.push('refused to accept it — no re-attempt at your rank can land this diff as-is.');
2794
+ if ((g.issues || []).length > 0) {
2795
+ out.push('');
2796
+ out.push('What was refused (file · rule):');
2797
+ for (const it of g.issues.filter((i) => String(i.worker || '').includes('permission-path'))) {
2798
+ out.push(` ${it.file || '?'} — ${it.summary || ''}`);
2799
+ }
2800
+ }
2801
+ out.push('');
2802
+ out.push('Ways forward:');
2803
+ out.push(' - Split the protected files out of your diff and re-ship the rest, or');
2804
+ out.push(' - hand this task to a builder at/above the floor the refusal names.');
2805
+ out.push(`(--regrade re-runs the same server check and will refuse again unless the diff changes.)`);
2806
+ return out.join('\n');
2807
+ }
2808
+
2779
2809
  const verdict = g.passed ? 'PASS' : 'FAIL';
2780
2810
  out.push('');
2781
2811
  out.push(`Subagent grade: ${verdict} · blended ${g.score}/10 · threshold ${g.threshold} (both axes)`);
@@ -3780,6 +3810,27 @@ async function shipMain() {
3780
3810
  console.error(`(Your work is committed and the task is at 'completed' — re-running resumes, task 941.)`);
3781
3811
  process.exit(1);
3782
3812
  }
3813
+ // task 1002613 (map P-1): the one genuine permission wall on the ship path
3814
+ // used to surface as a raw envelope dump — an atom name is not a rank, and
3815
+ // no remedy printed at the one station where self-service exists. Interpret
3816
+ // it: name the wall, the floor (from the server's floors map — never
3817
+ // hardcoded), and both ways forward.
3818
+ const errObj = confirm.data && confirm.data.error;
3819
+ const errCode = typeof errObj === 'string' ? errObj : (errObj && errObj.code);
3820
+ if (confirm.status === 403 && errCode === 'permission_forbidden') {
3821
+ const details = (errObj && typeof errObj === 'object' && errObj.details) || {};
3822
+ const floor = details.floors && details.floors['task.confirm.grade_bypass'];
3823
+ const floorNote = floor ? ` (floor: ${floor})` : '';
3824
+ console.error('');
3825
+ console.error(`✖ Confirming without a passing grade is permission-gated: task.confirm.grade_bypass${floorNote}.`);
3826
+ console.error(' This is an authorization wall, not a verdict on your work.');
3827
+ console.error(' Self-service: re-run WITHOUT --skip-grade to use the subagent grader, and if a');
3828
+ console.error(' grade fails, fix its findings and re-run (unlimited, no attempt cap):');
3829
+ console.error(` node scripts/gds/ship.js ${taskId} --regrade`);
3830
+ console.error(` If the grade genuinely cannot pass, ask a builder whose role holds`);
3831
+ console.error(` task.confirm.grade_bypass${floorNote} to POST /api/gds/tasks/${taskId}/confirm.`);
3832
+ process.exit(1);
3833
+ }
3783
3834
  console.error(`manual-confirm failed (${confirm.status}):`, confirm.data);
3784
3835
  process.exit(1);
3785
3836
  }
@@ -3817,7 +3868,8 @@ async function shipMain() {
3817
3868
  console.error('Task stays at completed. Manual options:');
3818
3869
  console.error(` - Re-run /builder-ship ${taskId} to try grading again, or`);
3819
3870
  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.`);
3871
+ console.error(` - POST /api/gds/tasks/${taskId}/confirm to manual-confirm (last resort —`);
3872
+ console.error(' permission-gated on task.confirm.grade_bypass).');
3821
3873
  process.exit(1);
3822
3874
  }
3823
3875
 
@@ -4000,7 +4052,8 @@ async function regradeMain() {
4000
4052
  });
4001
4053
  if (!gradePost.ok) {
4002
4054
  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.');
4055
+ console.error('Task stays at completed. Fix the issue and re-run --regrade (unlimited, no attempt cap).');
4056
+ console.error('(POST /confirm is a last resort — it is permission-gated on task.confirm.grade_bypass.)');
4004
4057
  process.exit(1);
4005
4058
  }
4006
4059
 
@@ -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,12 @@ 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)
474
+ // 1.19.23 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
475
+ // landed since 1.19.22 with no explicit bump. run 31450197816. (task 1002620)
472
476
  // ---------------------------------------------------------------------------
473
- const CORE_VERSION = '1.19.21'; // CI auto-patch carrier (ADR 0161); see changelog above
477
+ const CORE_VERSION = '1.19.23'; // CI auto-patch carrier (ADR 0161); see changelog above
474
478
 
475
479
  // A namespaced logger so a module's log lines are attributable + consistent.
476
480
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -84,13 +84,15 @@ await test('a non-Archon with NO goal_id is refused — version-level criteria s
84
84
  await test('a plain MEMBER may not author — membership is not management', () => {
85
85
  const d = ask({ goal: G_OPEN, actorId: MEMBER, actorMembership: PLAIN });
86
86
  assert.equal(d.ok, false);
87
- assert.equal(d.maskAsBadGoalId, true);
87
+ assert.equal(d.status, 403);
88
+ assert.equal(d.body.error, 'goal_not_yours');
88
89
  });
89
90
 
90
91
  await test('a STRANGER (no membership at all) may not author', () => {
91
92
  const d = ask({ goal: G_OPEN, actorId: STRANGER, actorMembership: null });
92
93
  assert.equal(d.ok, false);
93
- assert.equal(d.maskAsBadGoalId, true);
94
+ assert.equal(d.status, 403);
95
+ assert.equal(d.body.error, 'goal_not_yours');
94
96
  });
95
97
 
96
98
  await test('an ACHIEVED goal is closed to new criteria (409) — no reopening settled scope', () => {
@@ -107,38 +109,43 @@ await test('an ARCHIVED goal is closed to new criteria (409)', () => {
107
109
  assert.equal(d.body.error, 'goal_not_open');
108
110
  });
109
111
 
110
- // ---- NO-LEAK: the route must not become a goal-existence oracle (ADR 0112) ----
111
- // Before ADR 0154 this route was Archon-only and Archons read every goal, so no
112
- // probe existed. Now a Metic may call it — so an unauthorized caller must not be
113
- // able to tell "this goal id exists but isn't mine" from "no such goal".
112
+ // ---- the refusal is HONEST (task 1002614, map D-3) ----
113
+ // Until task 1002614 an unauthorized caller was MASKED behind the same
114
+ // bad_goal_id 400 as an absent goal, as an anti-oracle for goal ids (ADR 0112).
115
+ // The mask asserted a falsehood about a goal that exists and protected nothing:
116
+ // ADR 0112 privacy is join-gating, not read-hiding — GET /goals/:id serves any
117
+ // authenticated builder the goal, members, criteria and state. So the refusal
118
+ // now names its wall, and these pins hold it honest.
114
119
 
115
- await test('an unauthorized caller is MASKED — no status/body of its own to compare', () => {
120
+ await test('an unauthorized caller gets an honest 403 naming the wall — not a false bad_goal_id', () => {
116
121
  const d = ask({ goal: G_OPEN, actorId: STRANGER, actorMembership: null });
117
- assert.equal(d.maskAsBadGoalId, true);
118
- assert.equal(d.status, undefined, 'the gate must not pick a distinguishable status');
119
- assert.equal(d.body, undefined, 'the route renders the identical bad_goal_id 400');
122
+ assert.equal(d.status, 403);
123
+ assert.equal(d.body.error, 'goal_not_yours');
124
+ assert.match(d.body.message, /owner or a manager/, 'the refusal names who CAN author');
125
+ assert.match(d.body.message, /ADR 0154/, 'and where the rule comes from');
120
126
  });
121
127
 
122
- await test('the mask is IDENTICAL across every unauthorized shape — open, achieved, archived, private-ish', () => {
128
+ await test('the refusal is uniform across goal states — authority still precedes state', () => {
129
+ // A stranger probing open/achieved/archived/system goals sees the SAME 403 —
130
+ // goal state stays behind the authority wall on this route (it is readable via
131
+ // GET /goals/:id, but this write route does not volunteer it).
123
132
  const probes = [G_OPEN, G_ACHIEVED, G_ARCHIVED, G_SYSTEM].map((goal) =>
124
133
  ask({ goal, actorId: STRANGER, actorMembership: null }));
125
- for (const d of probes) assert.deepEqual(d, { ok: false, maskAsBadGoalId: true });
126
- // A goal's STATE must not leak either: an outsider cannot learn that a goal id
127
- // exists-but-is-archived, which `goal_not_open` would have told them.
128
- assert.equal(new Set(probes.map((d) => JSON.stringify(d))).size, 1);
134
+ for (const d of probes) {
135
+ assert.equal(d.status, 403);
136
+ assert.equal(d.body.error, 'goal_not_yours');
137
+ }
129
138
  });
130
139
 
131
- await test('authority is checked BEFORE goal state — the ordering that makes the mask total', () => {
132
- // An owner on an archived goal DOES get goal_not_open (useful, and they already
133
- // know the goal exists). A stranger on the same goal gets the mask instead.
140
+ await test('authority is checked BEFORE goal state — an owner sees goal_not_open, a stranger never does', () => {
134
141
  assert.equal(ask({ goal: G_ARCHIVED, actorId: OWNER, actorMembership: LEAD }).body.error, 'goal_not_open');
135
- assert.equal(ask({ goal: G_ARCHIVED, actorId: STRANGER, actorMembership: null }).maskAsBadGoalId, true);
142
+ assert.equal(ask({ goal: G_ARCHIVED, actorId: STRANGER, actorMembership: null }).body.error, 'goal_not_yours');
136
143
  });
137
144
 
138
- await test('the route renders the mask through the SAME bad_goal_id helper as a truly absent goal', () => {
139
- assert.match(doneWhenSrc, /const badGoalId = \(\) => res\.fail\('bad_goal_id'/, 'one helper builds the refusal');
145
+ await test('bad_goal_id now means exactly what it says — the mask branch is gone from the route', () => {
146
+ assert.match(doneWhenSrc, /const badGoalId = \(\) => res\.fail\('bad_goal_id'/, 'one helper builds the absent/foreign refusal');
140
147
  assert.match(doneWhenSrc, /if \(!goal \|\| String\(goal\.version_id\) !== String\(versionId\)\) return badGoalId\(\);/, 'absent/foreign goal path');
141
- assert.match(doneWhenSrc, /if \(decision\.maskAsBadGoalId\) return badGoalId\(\);/, 'masked path — same helper, so same code+message+envelope');
148
+ assert.doesNotMatch(doneWhenSrc, /maskAsBadGoalId/, 'no code path renders an authorization refusal as bad_goal_id');
142
149
  });
143
150
 
144
151
  await test('a SHIPPED version is refused (409) — closed scope', () => {
@@ -171,7 +178,7 @@ await test('a PLANNING version is ALLOWED — that is when criteria get written'
171
178
  await test('a system goal (created_by NULL) has no owner — a Metic cannot claim it', () => {
172
179
  const d = ask({ goal: G_SYSTEM, actorId: OWNER, actorMembership: null });
173
180
  assert.equal(d.ok, false);
174
- assert.equal(d.maskAsBadGoalId, true);
181
+ assert.equal(d.body.error, 'goal_not_yours');
175
182
  // ...but a manager row still admits, and an Archon always does (below).
176
183
  assert.equal(ask({ goal: G_SYSTEM, actorId: OWNER, actorMembership: LEAD }).ok, true);
177
184
  });
@@ -215,7 +222,7 @@ await test('ordering: goal_id_required precedes the version check', () => {
215
222
 
216
223
  await test('ordering: an unauthorized caller never reaches the goal-state branch', () => {
217
224
  const d = ask({ goal: G_ARCHIVED, actorId: STRANGER, actorMembership: null });
218
- assert.equal(d.maskAsBadGoalId, true, 'goal_not_open would confirm the goal exists');
225
+ assert.equal(d.body.error, 'goal_not_yours', 'the refusal is the authority wall, never goal_not_open');
219
226
  });
220
227
 
221
228
  // ---- the wall is the SAME one the sibling goal routes use ----
@@ -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);
@@ -106,6 +106,11 @@ await test('sub-Metic forged PASS touching protected path → hard FAIL', () =>
106
106
  assert.equal(g.signals.confidence, 0, 'fail forwards confidence 0 so the server gate also fails');
107
107
  assert.ok(g.signals.server_guard.tripped.some((t) => t.guard === 'permission_path'));
108
108
  assert.ok(g.issues.some((i) => i.worker === 'server-permission-path'));
109
+ // task 1002614 (map P-2): this FAIL is an AUTHORIZATION refusal, and the
110
+ // envelope must say so — a quality-verdict shape taught builders to
111
+ // fix-and-retry a diff no rank of theirs can land.
112
+ assert.equal(g.signals.server_guard.authorization_refusal, true,
113
+ 'a permission_path trip is named an authorization refusal, not a quality verdict');
109
114
  });
110
115
 
111
116
  await test('xenos forged PASS touching migrations/ → hard FAIL', () => {
@@ -129,6 +134,8 @@ await test('route-rank hard-fail (forged routes dir) flips a client PASS to FAIL
129
134
  assert.equal(g.passed, false, 'an ungated write route in the audited routes hard-fails the grade');
130
135
  assert.equal(g.signals.server_guard.downgraded_to, 'fail');
131
136
  assert.ok(g.signals.server_guard.tripped.some((t) => t.guard === 'route_rank'));
137
+ assert.equal(g.signals.server_guard.authorization_refusal, false,
138
+ 'route_rank is a CODE finding — only permission_path is an authorization refusal (task 1002614)');
132
139
  } finally {
133
140
  fs.rmSync(dir, { recursive: true, force: true });
134
141
  }
@@ -50,6 +50,34 @@ test('a real FAIL still prints the FAIL block + fix-the-issues guidance (#943 re
50
50
  assert.doesNotMatch(out, /GRADER UNAVAILABLE/);
51
51
  });
52
52
 
53
+ test('an authorization refusal (permission_path guard) → REFUSED, not a quality FAIL (task 1002614 / P-2)', () => {
54
+ // The server guard refused a sub-Metic diff over protected paths. The old
55
+ // rendering was a plain FAIL + "fix the issues" — teaching a re-attempt no
56
+ // rank of the builder's could ever land.
57
+ const g = {
58
+ passed: false, score: 0, threshold: 6, notes_md: '',
59
+ issues: [{ severity: 'blocker', file: 'migrations/099_x.sql',
60
+ summary: '[server permission-path] protected surface — an Archon must own it', worker: 'server-permission-path' }],
61
+ signals: { server_guard: { downgraded_to: 'fail', authorization_refusal: true,
62
+ tripped: [{ guard: 'permission_path', severity: 'blocker', summary: 'protected machinery' }] } },
63
+ };
64
+ const out = formatGradeOutcome(g, 1002614);
65
+ assert.match(out, /REFUSED \(authorization\)/, 'must label the outcome as an authorization refusal');
66
+ assert.match(out, /not a quality verdict/, 'must say the work was not judged and rejected');
67
+ assert.match(out, /migrations\/099_x\.sql/, 'names the refused file');
68
+ assert.doesNotMatch(out, /Fix the issues above/, 'must NOT teach fix-and-retry against a wall');
69
+ assert.doesNotMatch(out, /blended .*\/10/, 'a refusal is not a score');
70
+ });
71
+
72
+ test('the guard-name fallback also triggers the refusal shape (grades from pre-marker servers)', () => {
73
+ const g = {
74
+ passed: false, score: 0, threshold: 6, issues: [],
75
+ signals: { server_guard: { tripped: [{ guard: 'permission_path' }] } },
76
+ };
77
+ const out = formatGradeOutcome(g, 1);
78
+ assert.match(out, /REFUSED \(authorization\)/);
79
+ });
80
+
53
81
  test('a PASS prints the PASS block, no failure guidance (#943 regression guard)', () => {
54
82
  const g = {
55
83
  passed: true, score: 9, threshold: 6,
@@ -71,8 +71,12 @@ const TASK = { id: 996, title: 'fixture task', credits_reward: 15, kind: 'featur
71
71
  // ===========================================================================
72
72
 
73
73
  const savedGetLastClaimHolder = db.getLastClaimHolder;
74
- function stubHolder(holderId) {
74
+ const savedGetTask = db.getTask;
75
+ // taskExists backs the N-2 absent-task 404 (task 1002614): the gate consults
76
+ // db.getTask only when no claim holder exists.
77
+ function stubHolder(holderId, { taskExists = true } = {}) {
75
78
  db.getLastClaimHolder = async () => (holderId == null ? null : { builder_id: holderId });
79
+ db.getTask = async () => (taskExists ? { id: TASK.id } : undefined);
76
80
  }
77
81
  function mkReq(id, rank) {
78
82
  return { builder: { id, rank, github_login: `b${id}` } };
@@ -133,9 +137,10 @@ await test('gate (L2): an Archon may still file an override-request on a never-c
133
137
  assert.equal(res.statusCode, null);
134
138
  });
135
139
 
136
- // Restore the real lookup before the attribution layer (which uses the deps.pool
137
- // seam and never touches getLastClaimHolder).
140
+ // Restore the real lookups before the attribution layer (which uses the deps.pool
141
+ // seam and never touches getLastClaimHolder/getTask).
138
142
  db.getLastClaimHolder = savedGetLastClaimHolder;
143
+ db.getTask = savedGetTask;
139
144
 
140
145
  // ===========================================================================
141
146
  // Layer 2 — on approval, credit + tasks.shipped_by go to the CLAIM HOLDER.
@@ -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.
@@ -41,8 +41,12 @@ const WORKER = 25; // the claim holder who did the work
41
41
  const OTHER = 99; // an unrelated builder
42
42
 
43
43
  // Stub the claim-holder lookup. holderId === null → no claim on record.
44
- function stubHolder(holderId) {
44
+ // taskExists backs the N-2 absent-task 404 (task 1002614): the gate consults
45
+ // db.getTask only when no claim holder exists, to tell a mistyped id from a
46
+ // never-claimed task.
47
+ function stubHolder(holderId, { taskExists = true } = {}) {
45
48
  db.getLastClaimHolder = async () => (holderId == null ? null : { builder_id: holderId });
49
+ db.getTask = async () => (taskExists ? { id: 123 } : undefined);
46
50
  }
47
51
 
48
52
  function mkReq(id, rank) {
@@ -108,6 +112,27 @@ await test('L2: a never-claimed task FAILS CLOSED for a non-Archon (the bypass-c
108
112
  assert.equal(res.body.error.code, 'not_your_task');
109
113
  });
110
114
 
115
+ await test('N-2 (task 1002614): a NONEXISTENT task id is a clean 404, not not_your_task', async () => {
116
+ // A sub-Metic who mistypes an id used to be told the task was not theirs —
117
+ // the same 403 as someone else's task — while an Archon saw a clean 404.
118
+ stubHolder(null, { taskExists: false });
119
+ const res = mkRes();
120
+ assert.equal(await gateTaskOwnership(mkReq(OTHER, 'thetes'), res, 999999), false);
121
+ assert.equal(res.statusCode, 404);
122
+ assert.equal(res.body.error.code, 'task_not_found');
123
+ });
124
+
125
+ await test('N-2: the 404 never overrides ownership — someone ELSE\'s task stays 403 even if getTask errs', async () => {
126
+ // The existence lookup runs ONLY when no claim holder exists: with a holder
127
+ // on record the gate must answer not_your_task without consulting getTask.
128
+ db.getLastClaimHolder = async () => ({ builder_id: WORKER });
129
+ db.getTask = async () => { throw new Error('must not be called when a holder exists'); };
130
+ const res = mkRes();
131
+ assert.equal(await gateTaskOwnership(mkReq(OTHER, 'thetes'), res, 123), false);
132
+ assert.equal(res.statusCode, 403);
133
+ assert.equal(res.body.error.code, 'not_your_task');
134
+ });
135
+
111
136
  await test('L2: a never-claimed task is still landable by an Archon (legit /confirm /grade path)', async () => {
112
137
  stubHolder(null);
113
138
  const res = mkRes();