@bongos/core 1.19.184 → 1.19.185

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.
Files changed (48) hide show
  1. package/.bongos-core.json +59 -54
  2. package/clients/bongos-client/bongos-client.global.js +19 -19
  3. package/clients/bongos-client/index.cjs +19 -19
  4. package/clients/bongos-client/index.d.ts +29 -29
  5. package/clients/bongos-client/index.mjs +19 -19
  6. package/docs/adr/0157-archon-is-rank-and-identity-only.md +2 -2
  7. package/docs/api/openapi.json +117 -117
  8. package/docs/api-reference.md +10 -10
  9. package/docs/canonical-permissions.md +6 -6
  10. package/modules/dev-box/app/src/vendor/bongos-client.cjs +19 -19
  11. package/modules/government/catalog.js +7 -7
  12. package/modules/government/db.js +8 -8
  13. package/modules/government/migrations/government_002_rename_permission_keys.sql +112 -0
  14. package/modules/government/module.json +1 -1
  15. package/modules/government/protected-surfaces.json +0 -1
  16. package/modules/government/ranks.js +13 -13
  17. package/modules/government/routes/{governance.js → government.js} +105 -80
  18. package/modules/hall-ui/public/gate.js +1 -1
  19. package/modules/hall-ui/public/governance.js +38 -38
  20. package/modules/hall-ui/public/harbor.js +2 -2
  21. package/modules/hall-ui/public/shell.js +3 -3
  22. package/modules/hall-ui/public/watch.js +1 -1
  23. package/modules/lifecycle/routes/gate-approvals.js +1 -1
  24. package/package-lock.json +2 -2
  25. package/package.json +1 -1
  26. package/scripts/gds/publish-manifest.js +0 -2
  27. package/scripts/gds/seed-governance-tasks.js +1 -1
  28. package/src/bongos/auth.js +7 -7
  29. package/src/bongos/module-scope-map.js +7 -22
  30. package/src/bongos/route-rank-check.js +12 -18
  31. package/src/bongos/serve-internal.js +2 -2
  32. package/src/module-api.js +4 -2
  33. package/tests/governance_abuse_matrix.mjs +32 -32
  34. package/tests/governance_catalog.mjs +3 -3
  35. package/tests/governance_fold.mjs +2 -2
  36. package/tests/governance_fold_bench.mjs +2 -2
  37. package/tests/governance_live_proof.mjs +4 -4
  38. package/tests/governance_ownership_scope.mjs +1 -1
  39. package/tests/governance_page_gates.mjs +4 -4
  40. package/tests/governance_principals.mjs +2 -2
  41. package/tests/governance_protected_surfaces.mjs +41 -16
  42. package/tests/governance_require_permission.mjs +5 -5
  43. package/tests/governance_resolver.mjs +2 -2
  44. package/tests/governance_role_sync.mjs +23 -23
  45. package/tests/governance_routes.mjs +94 -94
  46. package/tests/governance_seed.mjs +24 -9
  47. package/tests/governance_session_scope.mjs +4 -4
  48. package/tests/hall_page_gate_map.mjs +2 -2
@@ -214,19 +214,19 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
214
214
  | GET | `/api/bongos/goals/inbox` | `any-builder` | — | GET /goals/inbox — the caller's private-goals inbox (task 1952 / ADR 0112): { invitations: invites addressed to me (accept/decline); join… |
215
215
  | POST | `/api/bongos/goals/reorder` | `metic+archon` | `version_id`, `ordered_ids` | POST /goals/reorder — Archon drag-to-reorder: set the priority order of every goal WITHIN one version (task 1760). |
216
216
 
217
- ## `governance` (9)
217
+ ## `government` (9)
218
218
 
219
219
  | Method | Path | Rank | Body | Description |
220
220
  |---|---|---|---|---|
221
- | GET | `/api/bongos/governance/assignments` | `archon` | — | governance.manage — every builder↔role assignment row. |
222
- | POST | `/api/bongos/governance/assignments` | `archon` | `builder_id`, `role_key` | governance.manage — assign a CUSTOM role to a builder. |
223
- | DELETE | `/api/bongos/governance/assignments/:builderId/:roleKey` | `archon` | — | governance.manage — remove a custom-role assignment. |
224
- | GET | `/api/bongos/governance/permissions` | `archon` | — | governance.manage — the permission catalog + principals, straight from code (the catalog is CODE, not a table); no DB read. |
225
- | GET | `/api/bongos/governance/roles` | `archon` | — | governance.manage — the full role list with each role's grants. |
226
- | POST | `/api/bongos/governance/roles` | `archon` | `role_key`, `label`, `description` | governance.manage — create a CUSTOM role (is_system false, no seeded_rank — the seeded four exist only via governance_002). |
227
- | PUT | `/api/bongos/governance/roles/:roleKey/permissions/:permissionKey` | `archon` | — | governance.manage — grant one permission to a role (the toggle's ON half). |
228
- | DELETE | `/api/bongos/governance/roles/:roleKey/permissions/:permissionKey` | `archon` | — | governance.manage — revoke a grant (the toggle's OFF half). |
229
- | POST | `/api/bongos/governance/roles/:roleKey/reset` | `archon` | — | governance.manage — restore a SEEDED rank-role to its catalog default (ADR 0172). |
221
+ | GET | `/api/bongos/government/assignments` | `archon` | — | government.manage — every builder↔role assignment row. |
222
+ | POST | `/api/bongos/government/assignments` | `archon` | `builder_id`, `rank_key` | government.manage — assign a CUSTOM role to a builder. |
223
+ | DELETE | `/api/bongos/government/assignments/:builderId/:rankKey` | `archon` | — | government.manage — remove a custom-role assignment. |
224
+ | GET | `/api/bongos/government/permissions` | `archon` | — | government.manage — the permission catalog + principals, straight from code (the catalog is CODE, not a table); no DB read. |
225
+ | GET | `/api/bongos/government/ranks` | `archon` | — | government.manage — the full role list with each role's grants. |
226
+ | POST | `/api/bongos/government/ranks` | `archon` | `rank_key`, `label`, `description` | government.manage — create a CUSTOM role (is_system false, no seeded_rank — the seeded four exist only via governance_002). |
227
+ | PUT | `/api/bongos/government/ranks/:rankKey/permissions/:permissionKey` | `archon` | — | government.manage — grant one permission to a role (the toggle's ON half). |
228
+ | DELETE | `/api/bongos/government/ranks/:rankKey/permissions/:permissionKey` | `archon` | — | government.manage — revoke a grant (the toggle's OFF half). |
229
+ | POST | `/api/bongos/government/ranks/:rankKey/reset` | `archon` | — | government.manage — restore a SEEDED rank-role to its catalog default (ADR 0172). |
230
230
 
231
231
  ## `grades` (1)
232
232
 
@@ -60,7 +60,7 @@ forming the ordered claim-gate tier **`xenos` (1) < `thetes` (2) < `metic` (3) <
60
60
  RANK and IDENTITY, not day-to-day administration.** It is everything a Metic can do, plus: **rank**
61
61
  (grant/revoke — the single writer), **identity** (offboard/(de)activate, admit an access request, revoke
62
62
  sessions), the **four escalation keys** that would otherwise make that delegation self-reversible
63
- (grader-bypass confirm, CI gate approval, protected-module goal scope, and `governance.manage`),
63
+ (grader-bypass confirm, CI gate approval, protected-module goal scope, and `government.manage`),
64
64
  **version close**, cross-tier dependency edges, and the substrate floors (push `main`, protected paths,
65
65
  ssh — ADR 0043). Version-**level** criteria (a `done-when` with no `goal_id`, or one outside an open
66
66
  version / outside a goal they own) also stay Archon per
@@ -140,7 +140,7 @@ or missing ranks fail closed.
140
140
  (who is admitted), `POST /api/gds/auth/revoke[-all]` (whose sessions are valid).
141
141
  - **the four escalation keys**, which look operational but are not: `POST /api/gds/tasks/:id/confirm`
142
142
  (grader-bypass), `POST /api/gds/gate-approvals/:pr/approve` (CI deploy-gate override), protected-module
143
- **goal scope** ([ADR 0086](adr/<redacted>.md)), and `governance.manage` (the
143
+ **goal scope** ([ADR 0086](adr/<redacted>.md)), and `government.manage` (the
144
144
  roles/permissions tab). Delegating any one of them lets a Metic reach Archon in four steps — create a
145
145
  protected-scope goal in the permission core, claim a task there (protected paths already floor at
146
146
  *metic* per [ADR 0043](adr/<redacted>.md)), confirm
@@ -153,11 +153,11 @@ or missing ranks fail closed.
153
153
  > **Amended by [ADR 0172](adr/<redacted>.md) (owner decision, 2026-08-16).**
154
154
  > The paragraphs above describe the SEEDED ladder, which is still what a fresh instance boots with — but
155
155
  > they are no longer a guarantee about a running one. The owner made the three sub-Archon **rank roles**
156
- > editable from the governance tab and chose to include the seven in-app Archon powers, `governance.manage`
156
+ > editable from the governance tab and chose to include the seven in-app Archon powers, `government.manage`
157
157
  > and `builder.rank.set` among them. So "no system permission is grantable below archon" now reads:
158
158
  > *no system permission is grantable to a CUSTOM role, and none is seeded below archon* — an Archon may
159
159
  > nonetheless hand one to a rank role deliberately. The four-step escalation chain described above is
160
- > therefore **open by decision, not by accident**: whoever holds `governance.manage` can reach the rest.
160
+ > therefore **open by decision, not by accident**: whoever holds `government.manage` can reach the rest.
161
161
  > Two things that did NOT move, and that bound the blast radius: the **Archon** rank-role is immutable
162
162
  > (nothing clickable revokes the permission needed to reach the tab), and the three **substrate** keys stay
163
163
  > non-grantable to every role — ADR 0043's enforcers read the rank ladder and never consult this database,
@@ -192,9 +192,9 @@ or missing ranks fail closed.
192
192
 
193
193
  **[ADR 0157](adr/<redacted>.md) added the whole operational block here** —
194
194
  20 permissions that had been Archon-gated only by ADR 0016's fail-closed default, never by a decision:
195
- the governance/monitoring **pages** (`/watch`, `/harbor`, `/gate` — `page.view.governance`) and
195
+ the governance/monitoring **pages** (`/watch`, `/harbor`, `/gate` — `page.view.government`) and
196
196
  `GET /api/gds/sessions/search` (whose own `/sessions` page had no server gate at all until
197
- task 1002710 put it behind `page.view.governance` alongside its three group-mates); the
197
+ task 1002710 put it behind `page.view.government` alongside its three group-mates); the
198
198
  **dev-box + provisioning fleets** (`GET /boxes[/cost-ledger]`,
199
199
  `POST /boxes/:b/close`, `PATCH .../block`, `GET /provisioning/fleet[/cost-ledger]`,
200
200
  `POST .../force-teardown`); **security adjudication** (`GET /security/reports`,
@@ -337,25 +337,25 @@ function createClient(opts = {}) {
337
337
  // POST /goals/reorder — rank: metic+archon — POST /goals/reorder
338
338
  postGoalsReorder: (args) => request("POST", "/goals/reorder", { hasBody: true }, args),
339
339
  },
340
- "governance": {
341
- // GET /governance/assignments — rank: archon — GET /governance/assignments
342
- getGovernanceAssignments: (args) => request("GET", "/governance/assignments", { hasBody: false }, args),
343
- // POST /governance/assignments — rank: archon — POST /governance/assignments
344
- postGovernanceAssignments: (args) => request("POST", "/governance/assignments", { hasBody: true }, args),
345
- // DELETE /governance/assignments/{builderId}/{roleKey} — rank: archon — DELETE /governance/assignments/:builderId/:roleKey
346
- deleteGovernanceAssignmentsBuilderIdRoleKey: (args) => request("DELETE", "/governance/assignments/{builderId}/{roleKey}", { hasBody: false }, args),
347
- // GET /governance/permissions — rank: archon — GET /governance/permissions
348
- getGovernancePermissions: (args) => request("GET", "/governance/permissions", { hasBody: false }, args),
349
- // GET /governance/roles — rank: archon — GET /governance/roles
350
- getGovernanceRoles: (args) => request("GET", "/governance/roles", { hasBody: false }, args),
351
- // POST /governance/roles — rank: archon — POST /governance/roles
352
- postGovernanceRoles: (args) => request("POST", "/governance/roles", { hasBody: true }, args),
353
- // DELETE /governance/roles/{roleKey}/permissions/{permissionKey} — rank: archon — DELETE /governance/roles/:roleKey/permissions/:permissionKey
354
- deleteGovernanceRolesRoleKeyPermissionsPermissionKey: (args) => request("DELETE", "/governance/roles/{roleKey}/permissions/{permissionKey}", { hasBody: false }, args),
355
- // PUT /governance/roles/{roleKey}/permissions/{permissionKey} — rank: archon — PUT /governance/roles/:roleKey/permissions/:permissionKey
356
- putGovernanceRolesRoleKeyPermissionsPermissionKey: (args) => request("PUT", "/governance/roles/{roleKey}/permissions/{permissionKey}", { hasBody: true }, args),
357
- // POST /governance/roles/{roleKey}/reset — rank: archon — POST /governance/roles/:roleKey/reset
358
- postGovernanceRolesRoleKeyReset: (args) => request("POST", "/governance/roles/{roleKey}/reset", { hasBody: true }, args),
340
+ "government": {
341
+ // GET /government/assignments — rank: archon — GET /government/assignments
342
+ getGovernmentAssignments: (args) => request("GET", "/government/assignments", { hasBody: false }, args),
343
+ // POST /government/assignments — rank: archon — POST /government/assignments
344
+ postGovernmentAssignments: (args) => request("POST", "/government/assignments", { hasBody: true }, args),
345
+ // DELETE /government/assignments/{builderId}/{rankKey} — rank: archon — DELETE /government/assignments/:builderId/:rankKey
346
+ deleteGovernmentAssignmentsBuilderIdRankKey: (args) => request("DELETE", "/government/assignments/{builderId}/{rankKey}", { hasBody: false }, args),
347
+ // GET /government/permissions — rank: archon — GET /government/permissions
348
+ getGovernmentPermissions: (args) => request("GET", "/government/permissions", { hasBody: false }, args),
349
+ // GET /government/ranks — rank: archon — GET /government/ranks
350
+ getGovernmentRanks: (args) => request("GET", "/government/ranks", { hasBody: false }, args),
351
+ // POST /government/ranks — rank: archon — POST /government/ranks
352
+ postGovernmentRanks: (args) => request("POST", "/government/ranks", { hasBody: true }, args),
353
+ // DELETE /government/ranks/{rankKey}/permissions/{permissionKey} — rank: archon — DELETE /government/ranks/:rankKey/permissions/:permissionKey
354
+ deleteGovernmentRanksRankKeyPermissionsPermissionKey: (args) => request("DELETE", "/government/ranks/{rankKey}/permissions/{permissionKey}", { hasBody: false }, args),
355
+ // PUT /government/ranks/{rankKey}/permissions/{permissionKey} — rank: archon — PUT /government/ranks/:rankKey/permissions/:permissionKey
356
+ putGovernmentRanksRankKeyPermissionsPermissionKey: (args) => request("PUT", "/government/ranks/{rankKey}/permissions/{permissionKey}", { hasBody: true }, args),
357
+ // POST /government/ranks/{rankKey}/reset — rank: archon — POST /government/ranks/:rankKey/reset
358
+ postGovernmentRanksRankKeyReset: (args) => request("POST", "/government/ranks/{rankKey}/reset", { hasBody: true }, args),
359
359
  },
360
360
  "grades": {
361
361
  // GET /grades/by-builder — rank: metic+archon — GET /grades/by-builder
@@ -104,7 +104,7 @@ const PERMISSIONS = [
104
104
  { key: 'task.confirm.grade_bypass', system: true, floor: 'archon', guards: 'POST /tasks/:id/confirm — ratify past a failed grade (env-relaxable, ADR 0041)' },
105
105
  { key: 'dependency.cross_tier.manage', system: true, floor: 'archon', guards: 'POST|DELETE /dependencies — cross-tier structural edges (ADR 0086)' },
106
106
  { key: 'goal.scope.manage', system: true, floor: 'archon', guards: 'cross-tier goal-scope edges + protected-scope admission (ADR 0086)' },
107
- { key: 'governance.manage', system: true, floor: 'archon', guards: 'the governance tab routes (roles/perms/assignments CRUD, reads included — R110) — meta-escalation' },
107
+ { key: 'government.manage', system: true, floor: 'archon', guards: 'the governance tab routes (roles/perms/assignments CRUD, reads included — R110) — meta-escalation' },
108
108
  { key: 'gate_approval.decide', system: true, floor: 'archon', guards: 'POST /gate-approvals/:pr/approve — CI deploy-gate override' },
109
109
  { key: 'deploy.push_main', system: true, substrate: true, floor: 'archon', guards: 'git push origin main / the deploy — substrate-enforced (pre-push, main-audit)' },
110
110
  { key: 'path.protected.modify', system: true, substrate: true, floor: 'archon', guards: 'modifying a PROTECTED_GLOBS path below the Metic floor — substrate (ADR 0043)' },
@@ -123,7 +123,7 @@ const PERMISSIONS = [
123
123
  //
124
124
  // The four trust-boundary keys that ALSO stay Archon are system:true above
125
125
  // (`task.confirm.grade_bypass`, `gate_approval.decide`, `goal.scope.manage`,
126
- // `governance.manage`) — see the ADR's escalation-chain argument for why moving any
126
+ // `government.manage`) — see the ADR's escalation-chain argument for why moving any
127
127
  // one of them would make this delegation self-reversible.
128
128
  { key: 'builder.status.set', system: false, floor: 'archon', guards: 'PATCH /builders/:id/status — offboard / (de)activate' },
129
129
  { key: 'auth.session.revoke', system: false, floor: 'archon', guards: 'POST /auth/revoke, /auth/revoke-all' },
@@ -207,7 +207,7 @@ const PERMISSIONS = [
207
207
  { key: 'audit_log.read', system: false, floor: 'metic', guards: 'GET /audit-log (ADR 0157, was archon)' },
208
208
  // ADR 0157 §4. Split from `gate_approval.decide` (system, archon) so the Gate PAGE
209
209
  // can open to Metic while APPROVING a held PR stays Archon. Without the split, a
210
- // Metic could reach /gate via `page.view.governance` and then 403 on its only data
210
+ // Metic could reach /gate via `page.view.government` and then 403 on its only data
211
211
  // endpoint — a page that renders nothing but an error.
212
212
  { key: 'gate_approval.read', system: false, floor: 'metic', guards: 'GET /gate-approvals — the Gate page read side (approve = gate_approval.decide, archon)' },
213
213
 
@@ -236,9 +236,9 @@ const PERMISSIONS = [
236
236
  // ADR 0157: floor archon→metic, which is what opens /watch, /harbor and /gate to the
237
237
  // working rank. The gate FUNCTION was renamed to match (`requireArchonPage` →
238
238
  // `requireGovernancePage`) once the floor moved. Viewing is all this grants: every
239
- // write those pages reach is its own atom, and `governance.manage` (the
239
+ // write those pages reach is its own atom, and `government.manage` (the
240
240
  // roles/permissions tab) stayed archon.
241
- { key: 'page.view.governance', system: false, floor: 'metic', guards: 'the governance/monitoring pages — /watch, /harbor, /gate (requireGovernancePage; write = governance.manage, archon)' },
241
+ { key: 'page.view.government', system: false, floor: 'metic', guards: 'the governance/monitoring pages — /watch, /harbor, /gate (requireGovernancePage; write = government.manage, archon)' },
242
242
  ];
243
243
 
244
244
  // ── invariants asserted at module load (fail loud on an authoring typo) ─────────
@@ -418,13 +418,13 @@ function substrateKeys() {
418
418
  // success and delegate nothing. Refusing them keeps the tab honest.
419
419
  // • the ARCHON rank-role — nothing. It is immutable and already holds everything,
420
420
  // and that immutability is also the LOCKOUT GUARD: the only role carrying
421
- // `governance.manage` by seed can never have it revoked, so no sequence of
421
+ // `government.manage` by seed can never have it revoked, so no sequence of
422
422
  // clicks in this tab can lock the owner out of the tab.
423
423
  // • a SEEDED rank-role BELOW archon — everything else, the seven in-app Archon
424
424
  // powers included. This is the delegation lever the owner asked for.
425
425
  // • a CUSTOM role — operational only: the pre-existing R110 wall, deliberately NOT
426
426
  // widened. A rank-role is the owner's own coarse ladder, moved by a rank write;
427
- // a custom role is ad-hoc and assignable by anyone holding `governance.manage`,
427
+ // a custom role is ad-hoc and assignable by anyone holding `government.manage`,
428
428
  // so widening it would hand the second group the first group's lever.
429
429
  //
430
430
  // Fail-closed on everything unrecognised: unknown key, missing role, or a system role
@@ -32,10 +32,10 @@ async function getPermissionKeysForBuilder(builderId) {
32
32
  // R110/R111) and for explaining a 403. Not part of the resolve path.
33
33
  async function getRoleKeysForBuilder(builderId) {
34
34
  const { rows } = await pool.query(
35
- `SELECT rank_key AS role_key FROM government_builder_ranks WHERE builder_id = $1 ORDER BY rank_key`,
35
+ `SELECT rank_key FROM government_builder_ranks WHERE builder_id = $1 ORDER BY rank_key`,
36
36
  [builderId],
37
37
  );
38
- return rows.map((r) => r.role_key);
38
+ return rows.map((r) => r.rank_key);
39
39
  }
40
40
 
41
41
  // --- the rank→role WRITE half (BV1.R95b) ------------------------------------
@@ -57,7 +57,7 @@ async function getRoleKeysForBuilder(builderId) {
57
57
  // runs on rank writes + sign-in, never in a hot loop.
58
58
  async function getSeededRankRoles(q) {
59
59
  const { rows } = await q.query(
60
- `SELECT rank_key AS role_key, seeded_rank, is_system
60
+ `SELECT rank_key, seeded_rank, is_system
61
61
  FROM government_ranks
62
62
  WHERE is_system = true AND seeded_rank IS NOT NULL`,
63
63
  );
@@ -68,7 +68,7 @@ async function getSeededRankRoles(q) {
68
68
  // seeded rank-role from an Archon-assigned custom one.
69
69
  async function getRoleAssignmentsForBuilder(q, builderId) {
70
70
  const { rows } = await q.query(
71
- `SELECT br.rank_key AS role_key, r.is_system, r.seeded_rank
71
+ `SELECT br.rank_key, r.is_system, r.seeded_rank
72
72
  FROM government_builder_ranks br
73
73
  JOIN government_ranks r ON r.rank_key = br.rank_key
74
74
  WHERE br.builder_id = $1`,
@@ -127,7 +127,7 @@ async function syncRank(client, { builderId, rank } = {}) {
127
127
 
128
128
  async function listRolesWithGrants() {
129
129
  const { rows } = await pool.query(
130
- `SELECT r.rank_key AS role_key, r.label, r.description, r.is_system, r.seeded_rank,
130
+ `SELECT r.rank_key, r.label, r.description, r.is_system, r.seeded_rank,
131
131
  r.created_by, r.created_at,
132
132
  COALESCE(array_agg(rp.permission_key ORDER BY rp.permission_key)
133
133
  FILTER (WHERE rp.permission_key IS NOT NULL), '{}') AS permissions
@@ -141,7 +141,7 @@ async function listRolesWithGrants() {
141
141
 
142
142
  async function getRoleByKey(roleKey) {
143
143
  const { rows } = await pool.query(
144
- `SELECT rank_key AS role_key, label, description, is_system, seeded_rank, created_by, created_at
144
+ `SELECT rank_key, label, description, is_system, seeded_rank, created_by, created_at
145
145
  FROM government_ranks
146
146
  WHERE rank_key = $1`,
147
147
  [roleKey],
@@ -153,7 +153,7 @@ async function createRole({ roleKey, label, description, createdBy }) {
153
153
  const { rows } = await pool.query(
154
154
  `INSERT INTO government_ranks (rank_key, label, description, is_system, seeded_rank, created_by)
155
155
  VALUES ($1, $2, $3, false, NULL, $4)
156
- RETURNING rank_key AS role_key, label, description, is_system, seeded_rank, created_by, created_at`,
156
+ RETURNING rank_key, label, description, is_system, seeded_rank, created_by, created_at`,
157
157
  [roleKey, label, description ?? null, createdBy],
158
158
  );
159
159
  return rows[0];
@@ -210,7 +210,7 @@ async function resetRoleGrants({ roleKey, permissionKeys }) {
210
210
  async function listAssignments() {
211
211
  const { rows } = await pool.query(
212
212
  `SELECT br.builder_id, b.github_login, b.display_name, b.rank,
213
- br.rank_key AS role_key, r.label AS role_label, r.is_system, r.seeded_rank,
213
+ br.rank_key, r.label AS role_label, r.is_system, r.seeded_rank,
214
214
  br.assigned_by, br.assigned_at
215
215
  FROM government_builder_ranks br
216
216
  JOIN builders b ON b.id = br.builder_id
@@ -0,0 +1,112 @@
1
+ -- government_002_rename_permission_keys.sql
2
+ -- Goal 1000068 R05 (task 1003006) — ADR 0174.
3
+ --
4
+ -- Renames the two permission KEYS that still carried the old vocabulary, in the
5
+ -- grant rows that hold them:
6
+ --
7
+ -- governance.manage -> government.manage
8
+ -- page.view.governance -> page.view.government
9
+ --
10
+ -- These are DATA, not schema: `government_rank_permissions.permission_key` is
11
+ -- free text validated app-side against the code catalog (it is deliberately not
12
+ -- a foreign key — the catalog is CODE, not a table; see governance_001).
13
+ --
14
+ -- ── WHY THIS IS SEPARATE FROM government_001 ────────────────────────────────
15
+ -- 001 renamed the tables and pairs with the code that QUERIES them. This one
16
+ -- pairs with `modules/government/catalog.js`, which declares the key strings,
17
+ -- and it must land in the SAME release as that edit. The coupling is sharper
18
+ -- than it looks: `resolver.js` DROPS any grant naming a key the catalog does not
19
+ -- define (the fail-closed rule). So if these rows were renamed while the catalog
20
+ -- still said `governance.manage`, every renamed grant would resolve to nothing —
21
+ -- and because that key is what gates the Government tab, the owner would be
22
+ -- locked out of the very surface used to diagnose it. The inverse is equally
23
+ -- true: shipping the catalog edit without this migration leaves the live rows
24
+ -- naming keys the catalog no longer defines, with the same result.
25
+ --
26
+ -- Both halves are in this commit, and the live instance's auto-upgrade sweep
27
+ -- stays paused (task 1003011) until the whole rename is released together.
28
+ --
29
+ -- ── ROW-COUNT SAFETY ────────────────────────────────────────────────────────
30
+ -- These are PRIMARY KEY columns (rank_key, permission_key). A rank that somehow
31
+ -- held BOTH the old and the new key would collide on UPDATE, so the update is
32
+ -- written to skip any row whose target already exists, and the assertion at the
33
+ -- end proves the old keys are gone and the new counts match what was there
34
+ -- before. Live baseline captured 2026-08-16 before the rename:
35
+ -- governance.manage -> 1 grant (the archon rank only)
36
+ -- page.view.governance -> 2 grants (metic + archon)
37
+ --
38
+ -- IDEMPOTENT: re-running finds no old keys and changes nothing.
39
+ --
40
+ -- ROLLBACK: reverse the two UPDATEs (new key -> old key). Lossless.
41
+ -- UPDATE government_rank_permissions SET permission_key = 'governance.manage'
42
+ -- WHERE permission_key = 'government.manage';
43
+ -- UPDATE government_rank_permissions SET permission_key = 'page.view.governance'
44
+ -- WHERE permission_key = 'page.view.government';
45
+
46
+ BEGIN;
47
+
48
+ -- Capture the before-counts so the assertion below can prove nothing was lost.
49
+ CREATE TEMP TABLE _govt_key_rename_before ON COMMIT DROP AS
50
+ SELECT
51
+ count(*) FILTER (WHERE permission_key = 'governance.manage') AS old_manage,
52
+ count(*) FILTER (WHERE permission_key = 'page.view.governance') AS old_page,
53
+ count(*) FILTER (WHERE permission_key = 'government.manage') AS new_manage,
54
+ count(*) FILTER (WHERE permission_key = 'page.view.government') AS new_page
55
+ FROM government_rank_permissions;
56
+
57
+ UPDATE government_rank_permissions g
58
+ SET permission_key = 'government.manage'
59
+ WHERE g.permission_key = 'governance.manage'
60
+ AND NOT EXISTS (
61
+ SELECT 1 FROM government_rank_permissions x
62
+ WHERE x.rank_key = g.rank_key AND x.permission_key = 'government.manage');
63
+
64
+ UPDATE government_rank_permissions g
65
+ SET permission_key = 'page.view.government'
66
+ WHERE g.permission_key = 'page.view.governance'
67
+ AND NOT EXISTS (
68
+ SELECT 1 FROM government_rank_permissions x
69
+ WHERE x.rank_key = g.rank_key AND x.permission_key = 'page.view.government');
70
+
71
+ -- Any row the collision guard skipped would be a duplicate grant; drop it so the
72
+ -- old key cannot survive. (Expected to affect zero rows — a rank holding both
73
+ -- spellings should not exist. Written so the assertion below can be absolute.)
74
+ DELETE FROM government_rank_permissions
75
+ WHERE permission_key IN ('governance.manage', 'page.view.governance');
76
+
77
+ DO $$
78
+ DECLARE
79
+ b record;
80
+ a_manage int;
81
+ a_page int;
82
+ leftover int;
83
+ BEGIN
84
+ SELECT * INTO b FROM _govt_key_rename_before;
85
+ SELECT count(*) INTO a_manage FROM government_rank_permissions WHERE permission_key = 'government.manage';
86
+ SELECT count(*) INTO a_page FROM government_rank_permissions WHERE permission_key = 'page.view.government';
87
+ SELECT count(*) INTO leftover FROM government_rank_permissions
88
+ WHERE permission_key IN ('governance.manage', 'page.view.governance');
89
+
90
+ IF leftover <> 0 THEN
91
+ RAISE EXCEPTION 'government key rename: % row(s) still carry an old key — rolling back.', leftover;
92
+ END IF;
93
+
94
+ -- Every grant that existed under either spelling must exist under the new one.
95
+ IF a_manage <> GREATEST(b.old_manage, b.new_manage) THEN
96
+ RAISE EXCEPTION 'government.manage grant count is % but % was expected (before: old=%, new=%) — rolling back.',
97
+ a_manage, GREATEST(b.old_manage, b.new_manage), b.old_manage, b.new_manage;
98
+ END IF;
99
+ IF a_page <> GREATEST(b.old_page, b.new_page) THEN
100
+ RAISE EXCEPTION 'page.view.government grant count is % but % was expected (before: old=%, new=%) — rolling back.',
101
+ a_page, GREATEST(b.old_page, b.new_page), b.old_page, b.new_page;
102
+ END IF;
103
+
104
+ -- The lockout guard: SOMEONE must still hold the key that opens this tab.
105
+ IF a_manage = 0 THEN
106
+ RAISE EXCEPTION 'no rank holds government.manage after the rename — that is a lockout. Rolling back.';
107
+ END IF;
108
+
109
+ RAISE NOTICE 'government key rename OK: government.manage=% page.view.government=%', a_manage, a_page;
110
+ END $$;
111
+
112
+ COMMIT;
@@ -10,7 +10,7 @@
10
10
  "contributes": {
11
11
  "migrations": true,
12
12
  "routes": [
13
- "governance",
13
+ "government",
14
14
  "rank-sync"
15
15
  ]
16
16
  },
@@ -34,7 +34,6 @@
34
34
  { "glob": "modules/lifecycle/routes/", "group": "authz", "why": "BV1.R86: the lifecycle routes carved out of src/bongos/routes/ — the gates follow the routes" },
35
35
  { "glob": "src/bongos/routes.js", "group": "authz", "why": "the route MOUNT table (a new ungated route lands here)" },
36
36
  { "glob": "server.js", "group": "authz", "why": "the Express entrypoint — wires every router + host routing" },
37
- { "glob": "modules/governance/", "group": "authz", "why": "the PRE-ADR-0174 path of the authz core. The folder itself moved to modules/government/ in goal 1000068 R03/R04, so nothing matches this glob in the core tree any more — it is kept for the one-release compatibility window because an in-flight branch or a vendored instance checkout may still carry the old path, and a protected path that silently stops being protected mid-rename is the failure this whole sequence exists to avoid. Dropped by R05 (task 1003006) with the rest of the compatibility surface." },
38
37
  { "glob": "modules/government/", "group": "authz", "why": "the same authz core under its post-ADR-0174 name (goal 1000068, task 1003003). Listed BEFORE the folder moves, deliberately: `protected` is DERIVED from this registry, so a module key whose globs are absent here computes protected:false — which would silently drop the Archon gate on goal creation AND the pre-push block on these files for the whole window between the key landing and the folder arriving. The prospective-carve precedent is the modules/governance/ entry directly above (ADR 0086 §4)." },
39
38
  { "glob": "modules/dev-box/boxes.js", "group": "box-access", "why": "boxScopeForRank / provision floor — decides who gets a full source clone" },
40
39
  { "glob": "modules/dev-box/box-access.js", "group": "box-access", "why": "decideSourceAccess (rank → starter|full source)" },
@@ -42,32 +42,32 @@ function isSeededRank(row) {
42
42
  && row.seeded_rank.length > 0;
43
43
  }
44
44
 
45
- // planRankSync({ rank, currentRoles, seededRoles }) → { target, add, remove, next }
45
+ // planRankSync({ rank, currentRanks, seededRanks }) → { target, add, remove, next }
46
46
  //
47
47
  // rank the builder's rank AFTER the write being synced.
48
- // currentRoles the builder's assignments, each { role_key, is_system, seeded_rank }.
49
- // seededRoles the seeded rank-roles the DB defines, each { role_key, seeded_rank,
48
+ // currentRanks the builder's assignments, each { rank_key, is_system, seeded_rank }.
49
+ // seededRanks the seeded rank-roles the DB defines, each { rank_key, seeded_rank,
50
50
  // is_system }. Passed in rather than hardcoded so the four role keys
51
51
  // stay DB truth — this file never re-declares the seed.
52
52
  //
53
- // target the seeded role_key the builder should end up with, or null when the
53
+ // target the seeded rank_key the builder should end up with, or null when the
54
54
  // rank has no seeded role (fail-closed divergence, above).
55
55
  // add [] or [target] — empty when it is already held (idempotence).
56
56
  // remove every OTHER seeded rank-role currently held. Custom roles never appear.
57
- // next the resulting role_key set, sorted — "what the role set becomes".
58
- function planRankSync({ rank, currentRoles = [], seededRoles = [] } = {}) {
59
- const match = seededRoles.find((r) => isSeededRank(r) && r.seeded_rank === rank);
60
- const target = match ? match.role_key : null;
57
+ // next the resulting rank_key set, sorted — "what the role set becomes".
58
+ function planRankSync({ rank, currentRanks = [], seededRanks = [] } = {}) {
59
+ const match = seededRanks.find((r) => isSeededRank(r) && r.seeded_rank === rank);
60
+ const target = match ? match.rank_key : null;
61
61
 
62
- const held = currentRoles.filter((r) => r && typeof r.role_key === 'string');
62
+ const held = currentRanks.filter((r) => r && typeof r.rank_key === 'string');
63
63
  const remove = held
64
- .filter((r) => isSeededRank(r) && r.role_key !== target)
65
- .map((r) => r.role_key);
66
- const add = target && !held.some((r) => r.role_key === target) ? [target] : [];
64
+ .filter((r) => isSeededRank(r) && r.rank_key !== target)
65
+ .map((r) => r.rank_key);
66
+ const add = target && !held.some((r) => r.rank_key === target) ? [target] : [];
67
67
 
68
68
  const removed = new Set(remove);
69
69
  const next = held
70
- .map((r) => r.role_key)
70
+ .map((r) => r.rank_key)
71
71
  .filter((key) => !removed.has(key))
72
72
  .concat(add)
73
73
  .sort();