@bongos/core 1.21.81 → 1.21.82

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.
@@ -207,6 +207,50 @@ async function resetRankGrants({ rankKey, permissionKeys }) {
207
207
  return permissionKeys.length;
208
208
  }
209
209
 
210
+ // Delete a CUSTOM rank (task 1004481) — or refuse, naming who still holds it.
211
+ // The rank row is locked FOR UPDATE before the holders are read: an assignment's
212
+ // foreign-key check takes a KEY SHARE lock on that same row, so a concurrent
213
+ // assign either finishes first (and is seen here as a holder) or waits until the
214
+ // rank is gone (and then fails its FK). Grants go with the rank by CASCADE.
215
+ // A seeded rank never matches (`is_system = false`), whatever the caller sent.
216
+ // Returns { deleted: true } | { deleted: false, reason: 'not_found' | 'held', holders }.
217
+ async function deleteCustomRank(rankKey) {
218
+ const client = await pool.connect();
219
+ try {
220
+ await client.query('BEGIN');
221
+ const { rows: locked } = await client.query(
222
+ `SELECT rank_key FROM government_ranks
223
+ WHERE rank_key = $1 AND is_system = false
224
+ FOR UPDATE`,
225
+ [rankKey],
226
+ );
227
+ if (locked.length === 0) {
228
+ await client.query('ROLLBACK');
229
+ return { deleted: false, reason: 'not_found', holders: [] };
230
+ }
231
+ const { rows: holders } = await client.query(
232
+ `SELECT br.builder_id, b.github_login, b.display_name
233
+ FROM government_builder_ranks br
234
+ JOIN builders b ON b.id = br.builder_id
235
+ WHERE br.rank_key = $1
236
+ ORDER BY br.builder_id`,
237
+ [rankKey],
238
+ );
239
+ if (holders.length > 0) {
240
+ await client.query('ROLLBACK');
241
+ return { deleted: false, reason: 'held', holders };
242
+ }
243
+ await client.query(`DELETE FROM government_ranks WHERE rank_key = $1 AND is_system = false`, [rankKey]);
244
+ await client.query('COMMIT');
245
+ return { deleted: true, holders: [] };
246
+ } catch (err) {
247
+ await client.query('ROLLBACK').catch(() => {});
248
+ throw err;
249
+ } finally {
250
+ client.release();
251
+ }
252
+ }
253
+
210
254
  async function listAssignments() {
211
255
  const { rows } = await pool.query(
212
256
  `SELECT br.builder_id, b.github_login, b.display_name, b.rank,
@@ -885,6 +929,7 @@ module.exports = {
885
929
  grantRankPermission,
886
930
  revokeRankPermission,
887
931
  resetRankGrants,
932
+ deleteCustomRank,
888
933
  listAssignments,
889
934
  assignRank,
890
935
  unassignRank,
@@ -28,6 +28,8 @@ const board = require('../board');
28
28
  const boardWithdraw = require('../board-withdraw');
29
29
  const recommendations = require('../recommendations');
30
30
  const docket = require('../docket');
31
+ const governmentConfig = require('../config');
32
+ const membershipPredicate = require('../membership-predicate');
31
33
  // task 1003208: structured logging (pino via the doorway) — was console.*.
32
34
  const log = api.logger('government');
33
35
 
@@ -476,6 +478,75 @@ module.exports = function buildGovernmentRouter() {
476
478
  }
477
479
  });
478
480
 
481
+ // rank: perm government.manage — delete a CUSTOM rank (task 1004481). Ranks
482
+ // added from a recommended set used to be permanent; the owner hesitated to
483
+ // try one for exactly that reason. Three refusals, each naming what to do:
484
+ // - a seeded rank, never (`seeded_rank_immutable`) — the ladder is the system;
485
+ // - a rank someone still holds (`rank_still_held`, with the holders) — owner
486
+ // decision 2026-10-08: no auto-unassign, so nobody loses a rank as a side
487
+ // effect of a cleanup;
488
+ // - a rank the constitution or the who-decides table seats
489
+ // (`rank_in_use_by_government`) — deleting it would leave that seat empty,
490
+ // and only an amendment may change who decides.
491
+ router.delete('/government/ranks/:rankKey', api.requireBuilder, api.requirePermission('government.manage'), async (req, res) => {
492
+ const rankKey = rankKeyParam(req, res);
493
+ if (rankKey === null) return;
494
+ try {
495
+ const rank = await db.getRankByKey(rankKey);
496
+ if (!rank) return res.fail('rank_not_found', 404, { rank_key: rankKey });
497
+ if (rank.is_system) {
498
+ return res.fail('seeded_rank_immutable', 403, { rank_key: rankKey, reason: 'a ladder rank is part of the system and can never be deleted' });
499
+ }
500
+ const seatedIn = await seatsNamingRank(rankKey);
501
+ if (seatedIn.length > 0) {
502
+ return res.fail('rank_in_use_by_government', 409, {
503
+ rank_key: rankKey, seated_in: seatedIn,
504
+ reason: 'this rank decides something; propose an amendment that seats someone else first',
505
+ });
506
+ }
507
+ const out = await db.deleteCustomRank(rankKey);
508
+ if (out.reason === 'not_found') return res.fail('rank_not_found', 404, { rank_key: rankKey });
509
+ if (out.reason === 'held') {
510
+ return res.fail('rank_still_held', 409, {
511
+ rank_key: rankKey,
512
+ holders: out.holders.map((h) => ({ builder_id: h.builder_id, github_login: h.github_login, display_name: h.display_name })),
513
+ reason: 'remove this rank from everyone who holds it, then delete it',
514
+ });
515
+ }
516
+ res.json({ ok: true, rank_key: rankKey, deleted: true });
517
+ } catch (err) {
518
+ log.error('[gds] DELETE /government/ranks/:rankKey', err);
519
+ res.fail('delete_failed', { status: 500, message: 'internal error' });
520
+ }
521
+ });
522
+
523
+ // Where the live government seats a rank by name: the constitution's board
524
+ // membership and every who-decides row that names a sitting.
525
+ //
526
+ // loadGovernmentConfig() reads two small files synchronously, which config.js
527
+ // says belongs at boot. This route is the documented exception, as board.js's
528
+ // window-opening is: an Archon deleting a rank is rare and never a hot path, and
529
+ // the answer must be the constitution IN FORCE NOW (an amendment applied a moment
530
+ // ago rewrites the file), which a boot-time copy would not be.
531
+ //
532
+ // FAIL-CLOSED on an unparseable predicate: it seats nobody today, but if it
533
+ // spells this rank's key it is refused, so a typo someone later fixes cannot
534
+ // point at a rank that was deleted in the meantime.
535
+ async function seatsNamingRank(rankKey) {
536
+ const names = (predicate) => {
537
+ const parsed = membershipPredicate.parseMembershipPredicate(predicate);
538
+ if (parsed) return parsed.rankKeys.includes(rankKey);
539
+ return typeof predicate === 'string' && predicate.includes(rankKey);
540
+ };
541
+ const out = [];
542
+ const { board: live } = governmentConfig.loadGovernmentConfig();
543
+ if (live && names(live.membership)) out.push('the board');
544
+ for (const row of await db.listDecisionRules()) {
545
+ if (names(row.decided_by)) out.push(`who decides at ${row.area}`);
546
+ }
547
+ return out;
548
+ }
549
+
479
550
  // rank: perm government.manage — assign a CUSTOM rank to a builder.
480
551
  // assigned_by is ALWAYS the session builder, never input (the audit trail's
481
552
  // "who did this" must be the authenticated actor).
@@ -268,6 +268,15 @@
268
268
  btn.addEventListener('click', () => resetRank(rank, btn));
269
269
  resetBox.appendChild(btn);
270
270
  }
271
+ // A custom rank can be deleted (task 1004481); the ladder never.
272
+ if (rank && !rank.is_system) {
273
+ const btn = document.createElement('button');
274
+ btn.type = 'button';
275
+ btn.className = 'btn-ghost';
276
+ btn.textContent = 'Delete rank';
277
+ btn.addEventListener('click', () => deleteRank(rank, btn));
278
+ resetBox.appendChild(btn);
279
+ }
271
280
 
272
281
  grid.innerHTML = '';
273
282
  // Principals are rendered in their own section further down the page; a machine
@@ -303,6 +312,35 @@
303
312
  }
304
313
  }
305
314
 
315
+ // Delete a custom rank. The server refuses while anyone holds it and names
316
+ // them, so the toast can say exactly whose rank to remove first.
317
+ async function deleteRank(rank, btn) {
318
+ const label = rank.label || rank.rank_key;
319
+ if (!window.confirm(`Delete the ${label} rank?\n\nIts permissions go with it. This cannot be undone, though you can create a rank with the same name again.`)) return;
320
+ btn.disabled = true;
321
+ const res = await api.request('DELETE', `${API}/government/ranks/${encodeURIComponent(rank.rank_key)}`);
322
+ if (res.status === 401) { window.location.assign(`${API}/auth/web/start`); return; }
323
+ if (res.ok) {
324
+ toast(`${label} deleted.`);
325
+ await reloadRanks();
326
+ return;
327
+ }
328
+ btn.disabled = false;
329
+ toast(deleteRefusal((res.data || {}).error || {}, label));
330
+ }
331
+ function deleteRefusal(err, label) {
332
+ const d = err.details || {};
333
+ if (err.code === 'rank_still_held' && Array.isArray(d.holders) && d.holders.length) {
334
+ const names = d.holders.map((h) => h.display_name || h.github_login || `builder ${h.builder_id}`);
335
+ const shown = names.length > 3 ? `${names.slice(0, 3).join(', ')} and ${names.length - 3} more` : names.join(', ');
336
+ return `${label} is still held by ${shown}. Remove it from them below, then delete it.`;
337
+ }
338
+ if (err.code === 'rank_in_use_by_government' && Array.isArray(d.seated_in) && d.seated_in.length) {
339
+ return `${label} still decides something (${d.seated_in.join(', ')}). Propose an amendment that seats someone else first.`;
340
+ }
341
+ return `Could not delete: ${friendlyError(err.code) || err.message || 'the server refused'}`;
342
+ }
343
+
306
344
  async function toggleGrant(rank, permissionKey, grant, btn) {
307
345
  btn.disabled = true;
308
346
  try {
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.81",
3
+ "version": "1.21.82",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.81",
9
+ "version": "1.21.82",
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.21.81",
3
+ "version": "1.21.82",
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",
@@ -9312,5 +9312,11 @@
9312
9312
  "id": "1004442",
9313
9313
  "text": "When the board turns someone's idea into a goal, every task later finished under that goal now pays the idea's author, as promised. Before this, only work promoted straight from the idea paid them, so a goal full of finished w"
9314
9314
  }
9315
+ ],
9316
+ "1.21.82": [
9317
+ {
9318
+ "id": "1004481",
9319
+ "text": "Custom ranks can now be deleted from the Government page, so trying a recommended rank set is no longer a permanent choice. A rank someone still holds can't be deleted, and the page says who holds it. The four built-in ranks c"
9320
+ }
9315
9321
  ]
9316
9322
  }
@@ -261,6 +261,7 @@ const GOVERNMENT_PINS = {
261
261
  'PUT /government/ranks/:rankKey/permissions/:permissionKey': 'perm:government.manage',
262
262
  'DELETE /government/ranks/:rankKey/permissions/:permissionKey': 'perm:government.manage',
263
263
  'POST /government/ranks/:rankKey/reset': 'perm:government.manage',
264
+ 'DELETE /government/ranks/:rankKey': 'perm:government.manage',
264
265
  'POST /government/rank-sets/apply': 'perm:government.manage',
265
266
  'POST /government/assignments': 'perm:government.manage',
266
267
  'DELETE /government/assignments/:builderId/:rankKey': 'perm:government.manage',
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.81'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.82'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -35,6 +35,7 @@ const api = require(path.join(ROOT, 'src', 'module-api.js'));
35
35
  const seams = require(path.join(ROOT, 'src', 'module-seams.js'));
36
36
  const catalog = require(path.join(ROOT, 'modules', 'government', 'catalog.js'));
37
37
  const govDb = require(path.join(ROOT, 'modules', 'government', 'db.js'));
38
+ const governmentConfig = require(path.join(ROOT, 'modules', 'government', 'config.js'));
38
39
  const rrc = require(path.join(ROOT, 'src', 'bongos', 'route-rank-check.js'));
39
40
  const { attachFail } = require(path.join(ROOT, 'src', 'bongos', 'middleware', 'error-envelope.js'));
40
41
  const express = require('express');
@@ -58,6 +59,7 @@ test('the routes file declares the full R110 surface', () => {
58
59
  'PUT /government/ranks/:rankKey/permissions/:permissionKey',
59
60
  'DELETE /government/ranks/:rankKey/permissions/:permissionKey',
60
61
  'POST /government/ranks/:rankKey/reset',
62
+ 'DELETE /government/ranks/:rankKey',
61
63
  'POST /government/assignments',
62
64
  'DELETE /government/assignments/:builderId/:rankKey',
63
65
  'POST /government/rank-sets/apply',
@@ -125,6 +127,7 @@ test('checkRouteRanks passes over the real tree (no governance findings)', () =>
125
127
 
126
128
  // The db stub: every function the routes call, recording args. Reset per test.
127
129
  const calls = [];
130
+ let deleteAnswer = { deleted: true, holders: [] };
128
131
  const stub = {
129
132
  listRanksWithGrants: async () => [{ rank_key: 'metic', label: 'Metic', description: null, is_system: true, seeded_rank: 'metic', created_by: null, created_at: 't', permissions: ['task.create'] }],
130
133
  listAssignments: async () => [{ builder_id: 7, github_login: 'x', display_name: 'X', rank: 'metic', rank_key: 'metic', role_label: 'Metic', is_system: true, seeded_rank: 'metic', assigned_by: null, assigned_at: 't' }],
@@ -141,6 +144,8 @@ const stub = {
141
144
  grantRankPermission: async () => true,
142
145
  revokeRankPermission: async () => true,
143
146
  resetRankGrants: async ({ permissionKeys }) => permissionKeys.length,
147
+ // task 1004481: `deleteAnswer` scripts what the locked delete found.
148
+ deleteCustomRank: async () => deleteAnswer,
144
149
  assignRank: async () => true,
145
150
  unassignRank: async () => true,
146
151
  // task 1004529: who decides at People. [] = the as-today default row.
@@ -412,6 +417,73 @@ test('POST reset: refused on a custom role (no catalog default to return to)', a
412
417
  assert.equal(calls.filter(([k]) => k === 'resetRankGrants').length, 0);
413
418
  });
414
419
 
420
+ // --- delete a custom rank (task 1004481) --------------------------------------
421
+
422
+ test('DELETE a rank: a custom rank nobody holds is deleted', async () => {
423
+ deleteAnswer = { deleted: true, holders: [] };
424
+ const r = await call('DELETE', '/government/ranks/triage-helper');
425
+ assert.equal(r.status, 200);
426
+ assert.equal(r.json.deleted, true);
427
+ const c = calls.find(([k]) => k === 'deleteCustomRank');
428
+ assert.equal(c[1], 'triage-helper');
429
+ });
430
+
431
+ test('DELETE a rank: refused while held, and the refusal names who holds it', async () => {
432
+ deleteAnswer = { deleted: false, reason: 'held', holders: [{ builder_id: 9, github_login: 'ana', display_name: 'Ana', extra: 'x' }] };
433
+ const r = await call('DELETE', '/government/ranks/triage-helper');
434
+ assert.equal(r.status, 409);
435
+ assert.equal(r.json.error.code, 'rank_still_held');
436
+ assert.deepEqual(r.json.error.details.holders, [{ builder_id: 9, github_login: 'ana', display_name: 'Ana' }], 'names them, and only those fields');
437
+ assert.equal(calls.filter(([k]) => k === 'unassignRank').length, 0, 'never unassigns on the way');
438
+ deleteAnswer = { deleted: true, holders: [] };
439
+ });
440
+
441
+ test('DELETE a rank: every seeded rank is refused before the db is asked', async () => {
442
+ for (const k of catalog.RANK_ORDER) {
443
+ const r = await call('DELETE', `/government/ranks/${k}`);
444
+ assert.equal(r.status, 403, k);
445
+ assert.equal(r.json.error.code, 'seeded_rank_immutable');
446
+ assert.equal(calls.filter(([n]) => n === 'deleteCustomRank').length, 0);
447
+ }
448
+ });
449
+
450
+ test('DELETE a rank: a missing rank 404s, from the read or from the locked delete', async () => {
451
+ assert.equal((await call('DELETE', '/government/ranks/missing')).status, 404);
452
+ deleteAnswer = { deleted: false, reason: 'not_found', holders: [] };
453
+ assert.equal((await call('DELETE', '/government/ranks/triage-helper')).status, 404);
454
+ deleteAnswer = { deleted: true, holders: [] };
455
+ });
456
+
457
+ test('DELETE a rank: refused while the board or a who-decides row seats it', async () => {
458
+ const realLoad = governmentConfig.loadGovernmentConfig;
459
+ try {
460
+ governmentConfig.loadGovernmentConfig = () => ({ board: { membership: 'rank:archon,triage-helper' } });
461
+ let r = await call('DELETE', '/government/ranks/triage-helper');
462
+ assert.equal(r.status, 409);
463
+ assert.equal(r.json.error.code, 'rank_in_use_by_government');
464
+ assert.deepEqual(r.json.error.details.seated_in, ['the board']);
465
+ assert.equal(calls.filter(([k]) => k === 'deleteCustomRank').length, 0);
466
+
467
+ governmentConfig.loadGovernmentConfig = () => ({ board: { membership: 'rank:archon' } });
468
+ stub.listDecisionRules = async () => [{ area: 'people', decided_by: 'rank:triage-helper' }, { area: 'treasury', decided_by: 'gate' }];
469
+ r = await call('DELETE', '/government/ranks/triage-helper');
470
+ assert.equal(r.status, 409);
471
+ assert.deepEqual(r.json.error.details.seated_in, ['who decides at people']);
472
+ r = await call('DELETE', '/government/ranks/other-rank');
473
+ assert.equal(r.status, 200, 'a rank nothing seats is still deletable');
474
+
475
+ // fail-closed: an unparseable predicate that spells the key still refuses
476
+ stub.listDecisionRules = async () => [];
477
+ governmentConfig.loadGovernmentConfig = () => ({ board: { membership: 'rank:triage-helper+' } });
478
+ r = await call('DELETE', '/government/ranks/triage-helper');
479
+ assert.equal(r.status, 409, 'rank:<custom>+ is unparseable, and still names the rank');
480
+ assert.deepEqual(r.json.error.details.seated_in, ['the board']);
481
+ } finally {
482
+ governmentConfig.loadGovernmentConfig = realLoad;
483
+ stub.listDecisionRules = async () => [];
484
+ }
485
+ });
486
+
415
487
  // --- assignments -------------------------------------------------------------
416
488
 
417
489
  test('POST /government/assignments assigns a custom role, assigned_by = the session builder', async () => {