@bongos/core 1.19.158 → 1.19.159

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.
@@ -9,14 +9,23 @@
9
9
  // docs/governance/authority-sweep-2026-07-24.md.
10
10
  //
11
11
  // Two orthogonal axes per permission:
12
- // • system — true = trust-boundary, NON-GRANTABLE. The governance API (R110)
13
- // rejects adding it to a CUSTOM role and the tab renders it LOCKED;
14
- // it is deploy/protected-path/rank-set/version-close/grade-bypass/
15
- // cross-tier/principal power, enforced by the out-of-band substrate
16
- // (ADR 0043) or reserved to Archon. The built-in `archon` rank-role
17
- // legitimately HOLDS the archon-floored system perms — "non-grantable"
18
- // constrains custom roles, not the seed.
12
+ // • system — true = trust-boundary. The governance API (R110) rejects adding it
13
+ // to a CUSTOM role and the tab renders it LOCKED there; it is deploy/
14
+ // protected-path/rank-set/version-close/grade-bypass/cross-tier/
15
+ // principal power, enforced by the out-of-band substrate (ADR 0043)
16
+ // or reserved to Archon. The built-in `archon` rank-role legitimately
17
+ // HOLDS the archon-floored system perms.
19
18
  // — false = operational, freely composable into a custom role.
19
+ // `system` is NOT the whole grantability answer any more — see the
20
+ // `substrate` axis below and `isGrantableTo()`, which is per-ROLE.
21
+ // • substrate — true = this key's authority is enforced OUTSIDE the app, by the
22
+ // pre-push hook / main-audit / the rank floor the git and ssh layers
23
+ // read (ADR 0043). A grant row for one is INERT: no route consumes it
24
+ // through requirePermission, so granting it would be a toggle that
25
+ // looks like it worked and changes nothing. Therefore non-grantable to
26
+ // EVERY role, seeded rank-roles included — the one wall ADR 0172's
27
+ // widening deliberately did not move, because moving it would make the
28
+ // tab lie rather than delegate. Implies system:true + floor 'archon'.
20
29
  // • floor — the rank that holds this permission TODAY via the compat seed
21
30
  // ('xenos' < 'thetes' < 'metic' < 'archon'), or null for a machine
22
31
  // PRINCIPAL permission held by NO rank-role (gated on exact
@@ -84,7 +93,12 @@ const RANK_ORDER = Object.freeze(['xenos', 'thetes', 'metic', 'archon']);
84
93
  // (the exhaustive 1:1 list lives in the authority sweep). Order: system first, then
85
94
  // operational by floor (archon → metic → thetes/own/any), matching the ADR appendix.
86
95
  const PERMISSIONS = [
87
- // ── system: true — non-grantable (substrate- or Archon-enforced) ──────────────
96
+ // ── system: true — trust-boundary (substrate- or Archon-enforced) ─────────────
97
+ //
98
+ // The seven WITHOUT `substrate` are in-app Archon powers: a real route reads them
99
+ // through requirePermission, so granting one to a sub-Archon rank-role genuinely
100
+ // delegates it (ADR 0172, the owner's lever). The three WITH `substrate: true` are
101
+ // enforced out of band and can never be delegated by a row in this database.
88
102
  { key: 'builder.rank.set', system: true, floor: 'archon', guards: 'PATCH /builders/:id/rank — the single rank writer' },
89
103
  { key: 'version.close', system: true, floor: 'archon', guards: 'version close disposition (never delegated below Archon)' },
90
104
  { key: 'task.confirm.grade_bypass', system: true, floor: 'archon', guards: 'POST /tasks/:id/confirm — ratify past a failed grade (env-relaxable, ADR 0041)' },
@@ -92,9 +106,9 @@ const PERMISSIONS = [
92
106
  { key: 'goal.scope.manage', system: true, floor: 'archon', guards: 'cross-tier goal-scope edges + protected-scope admission (ADR 0086)' },
93
107
  { key: 'governance.manage', system: true, floor: 'archon', guards: 'the governance tab routes (roles/perms/assignments CRUD, reads included — R110) — meta-escalation' },
94
108
  { key: 'gate_approval.decide', system: true, floor: 'archon', guards: 'POST /gate-approvals/:pr/approve — CI deploy-gate override' },
95
- { key: 'deploy.push_main', system: true, floor: 'archon', guards: 'git push origin main / the deploy — substrate-enforced (pre-push, main-audit)' },
96
- { key: 'path.protected.modify', system: true, floor: 'archon', guards: 'modifying a PROTECTED_GLOBS path below the Metic floor — substrate (ADR 0043)' },
97
- { key: 'infra.ssh', system: true, floor: 'archon', guards: 'ssh to the droplet / operator surface — substrate (credential placement)' },
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
+ { key: 'path.protected.modify', system: true, substrate: true, floor: 'archon', guards: 'modifying a PROTECTED_GLOBS path below the Metic floor — substrate (ADR 0043)' },
111
+ { key: 'infra.ssh', system: true, substrate: true, floor: 'archon', guards: 'ssh to the droplet / operator surface — substrate (credential placement)' },
98
112
  { key: 'principal.bfg', system: true, floor: null, principal: 'bfg', guards: '/memory/bfg/* — requireBfgPrincipal (exact system_role=bfg; NO rank-role holds it)' },
99
113
  { key: 'principal.discord_bot', system: true, floor: null, principal: 'discord_bot', guards: 'Discord bot routes — requireDiscordBot (exact system_role; dormant)' },
100
114
 
@@ -256,6 +270,23 @@ for (const p of PERMISSIONS) {
256
270
  if (!p.system) throw new Error(`governance catalog: ${p.key} is a principal permission and must be system:true (never grantable to a role)`);
257
271
  if (_byPrincipal.has(principal)) throw new Error(`governance catalog: duplicate principal identity "${principal}"`);
258
272
  }
273
+ // ── the SUBSTRATE axis (ADR 0172) ─────────────────────────────────────────
274
+ // A substrate key is enforced by the out-of-band layer (pre-push hook, main-audit,
275
+ // the rank floor git/ssh read), so no role row can confer it. Three shape rules,
276
+ // each closing a way the marker could quietly stop meaning that:
277
+ const substrate = p.substrate ?? false;
278
+ if (typeof substrate !== 'boolean') throw new Error(`governance catalog: ${p.key} has a non-boolean 'substrate'`);
279
+ if (substrate) {
280
+ // Substrate power is trust-boundary power by definition — a substrate key that
281
+ // was somehow operational would be freely grantable to a custom role.
282
+ if (!p.system) throw new Error(`governance catalog: ${p.key} is substrate-enforced and must be system:true`);
283
+ // A substrate key belongs to no machine identity: the two are different reasons
284
+ // a key is unreachable, and collapsing them would hide one behind the other.
285
+ if (principal !== null) throw new Error(`governance catalog: ${p.key} cannot be both substrate-enforced and a machine principal`);
286
+ // The out-of-band enforcers all compare against the top of the ladder. A lower
287
+ // floor would seed the key into a rank-role that the substrate then still denies.
288
+ if (p.floor !== 'archon') throw new Error(`governance catalog: ${p.key} is substrate-enforced and must floor at 'archon' (the substrate compares against the top of the ladder)`);
289
+ }
259
290
  // ── the OWNERSHIP axis (R100) ─────────────────────────────────────────────
260
291
  const scope = p.scope ?? null;
261
292
  const resource = p.resource ?? null;
@@ -283,7 +314,7 @@ for (const p of PERMISSIONS) {
283
314
  if (scope !== null && p.floor === null) {
284
315
  throw new Error(`governance catalog: ${p.key} is ownership-scoped but has no rank floor — the ownership axis narrows a ROLE permission, never an identity one`);
285
316
  }
286
- const frozen = Object.freeze({ ...p, principal, scope, resource });
317
+ const frozen = Object.freeze({ ...p, principal, scope, resource, substrate });
287
318
  _byKey.set(p.key, frozen);
288
319
  if (principal !== null) _byPrincipal.set(principal, frozen);
289
320
  if (resource !== null) {
@@ -352,6 +383,8 @@ function isSystem(key) {
352
383
  return p ? p.system : null; // null = unknown key (caller fails closed)
353
384
  }
354
385
  // Grantable to a CUSTOM role = exists and not system. Unknown key → false (fail-closed).
386
+ // This is the NARROW question — the custom-role wall, unchanged by ADR 0172 and
387
+ // still what config.js strips against. For the per-role answer use isGrantableTo().
355
388
  function isGrantable(key) {
356
389
  const p = _byKey.get(key);
357
390
  return !!p && !p.system;
@@ -362,6 +395,49 @@ function systemKeys() {
362
395
  function grantableKeys() {
363
396
  return PERMISSIONS.filter((p) => !p.system).map((p) => p.key);
364
397
  }
398
+ // Enforced outside the app (pre-push, main-audit, the rank floor git/ssh read).
399
+ // Unknown key → false, but note the callers all pair it with a byKey() existence
400
+ // check first — an unknown key is refused as unknown, never as "not substrate".
401
+ function isSubstrate(key) {
402
+ const p = _byKey.get(key);
403
+ return !!p && p.substrate;
404
+ }
405
+ function substrateKeys() {
406
+ return PERMISSIONS.filter((p) => p.substrate).map((p) => p.key);
407
+ }
408
+
409
+ // ── grantability, per ROLE (ADR 0172) ──────────────────────────────────────────
410
+ //
411
+ // The owner's 2026-08-16 decision made the three sub-Archon RANK roles editable, so
412
+ // "is this grantable" stopped being a property of the key alone. `role` is a
413
+ // governance_roles row ({ is_system, seeded_rank }); the four answers:
414
+ //
415
+ // • NEVER, to any role — a machine-principal key (held by an identity, not a role)
416
+ // and a SUBSTRATE key. The substrate three are inert as grant rows: no route
417
+ // consumes them through requirePermission, so a toggle for one would report
418
+ // success and delegate nothing. Refusing them keeps the tab honest.
419
+ // • the ARCHON rank-role — nothing. It is immutable and already holds everything,
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
422
+ // clicks in this tab can lock the owner out of the tab.
423
+ // • a SEEDED rank-role BELOW archon — everything else, the seven in-app Archon
424
+ // powers included. This is the delegation lever the owner asked for.
425
+ // • a CUSTOM role — operational only: the pre-existing R110 wall, deliberately NOT
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`,
428
+ // so widening it would hand the second group the first group's lever.
429
+ //
430
+ // Fail-closed on everything unrecognised: unknown key, missing role, or a system role
431
+ // whose seeded_rank is not a real rank all answer false.
432
+ function isGrantableTo(key, role) {
433
+ const p = _byKey.get(key);
434
+ if (!p || !role || typeof role !== 'object') return false;
435
+ if (p.principal !== null || p.substrate) return false;
436
+ if (role.is_system) {
437
+ return RANK_ORDER.includes(role.seeded_rank) && role.seeded_rank !== 'archon';
438
+ }
439
+ return !p.system;
440
+ }
365
441
 
366
442
  // ── the principal axis (identity, not roles) ────────────────────────────────────
367
443
  // Reads only. Unknown/blank input fails closed to null/false — the same posture the
@@ -448,6 +524,9 @@ module.exports = {
448
524
  byKey,
449
525
  isSystem,
450
526
  isGrantable,
527
+ isGrantableTo,
528
+ isSubstrate,
529
+ substrateKeys,
451
530
  systemKeys,
452
531
  grantableKeys,
453
532
  seedForRank,
@@ -178,6 +178,35 @@ async function revokeRolePermission({ roleKey, permissionKey }) {
178
178
  return rowCount > 0;
179
179
  }
180
180
 
181
+ // Replace a role's entire grant set in ONE transaction (ADR 0172's reset). The
182
+ // delete+insert pair must be atomic: a crash between them would leave the role
183
+ // holding NOTHING, and for a rank-role that is every builder at that rank losing
184
+ // every permission until someone noticed. The caller supplies the keys (the route
185
+ // derives them from catalog.seedForRank), so this stays a dumb write like its
186
+ // neighbours — the authority decision is made against the catalog, in the route.
187
+ async function resetRoleGrants({ roleKey, permissionKeys }) {
188
+ const client = await pool.connect();
189
+ try {
190
+ await client.query('BEGIN');
191
+ await client.query(`DELETE FROM governance_role_permissions WHERE role_key = $1`, [roleKey]);
192
+ if (permissionKeys.length > 0) {
193
+ await client.query(
194
+ `INSERT INTO governance_role_permissions (role_key, permission_key)
195
+ SELECT $1, unnest($2::text[])
196
+ ON CONFLICT (role_key, permission_key) DO NOTHING`,
197
+ [roleKey, [...permissionKeys]],
198
+ );
199
+ }
200
+ await client.query('COMMIT');
201
+ } catch (err) {
202
+ await client.query('ROLLBACK').catch(() => {});
203
+ throw err;
204
+ } finally {
205
+ client.release();
206
+ }
207
+ return permissionKeys.length;
208
+ }
209
+
181
210
  async function listAssignments() {
182
211
  const { rows } = await pool.query(
183
212
  `SELECT br.builder_id, b.github_login, b.display_name, b.rank,
@@ -221,6 +250,7 @@ module.exports = {
221
250
  createRole,
222
251
  grantRolePermission,
223
252
  revokeRolePermission,
253
+ resetRoleGrants,
224
254
  listAssignments,
225
255
  assignRole,
226
256
  unassignRole,
@@ -108,14 +108,28 @@ function registerSeams() {
108
108
  // Every route — the three reads included — gates on the `governance.manage`
109
109
  // atom (system, floor archon): this surface EDITS the role/permission graph
110
110
  // itself, the meta-escalation the catalog flags non-grantable, and even listing
111
- // it exposes the full authority map. Two invariants are enforced HERE, against
112
- // the catalog, not in the db layer (governance_001's app-side-validation split):
113
- // • a `system:true` permission is NON-GRANTABLE to a custom role (R94's wall —
114
- // "The governance API (R110) rejects granting it");
115
- // • the four seeded rank-roles are IMMUTABLE through this API: their grants
116
- // derive from catalog floors (a change is a migration — R94) and their
117
- // assignment derives from builders.rank via the R95b sync. A second writer
118
- // for either would fork authority truth.
111
+ // it exposes the full authority map. The invariants are enforced HERE, against
112
+ // the catalog, not in the db layer (governance_001's app-side-validation split).
113
+ //
114
+ // ADR 0172 (owner decision, 2026-08-16) split what used to be ONE wall — "a seeded
115
+ // rank-role is immutable" — into two, because grants and assignment are immutable
116
+ // for different reasons and only one of those reasons still holds:
117
+ //
118
+ // • GRANTS on the three sub-Archon rank-roles are now EDITABLE. Their rows were
119
+ // always ordinary `governance_role_permissions` rows (the governance_002 seed
120
+ // writes them); the old refusal was policy, not structure. What a rank means is
121
+ // now the owner's to set from the tab, and `catalog.isGrantableTo()` answers
122
+ // which keys per role. The ARCHON rank-role stays immutable — it holds
123
+ // everything, and its immutability is what makes lockout impossible.
124
+ // • ASSIGNMENT of every seeded rank-role is still refused, unchanged: it derives
125
+ // from `builders.rank` via the R95b sync, so a second writer here would fork
126
+ // authority truth and the sync would silently undo it on the next rank write.
127
+ // Changing who holds a rank-role is still `PATCH /builders/:id/rank`.
128
+ //
129
+ // The custom-role wall is unchanged: a `system:true` key stays non-grantable there
130
+ // (R94's wall — "The governance API (R110) rejects granting it"), and the three
131
+ // SUBSTRATE keys stay non-grantable to everything, rank-roles included, because a
132
+ // grant row for one delegates nothing (ADR 0043 enforces them out of band).
119
133
  const { validateOrRespond, LIMITS, parseId } = api;
120
134
  const ROLE_KEY_RE = /^[a-z][a-z0-9-]*$/;
121
135
  const ROLE_KEY_MAX = 64;
@@ -136,21 +150,39 @@ module.exports = function buildGovernanceRouter() {
136
150
  return roleKey;
137
151
  }
138
152
 
139
- // Load a role that this API may MUTATE: 404 on a missing role, refuse a seeded
140
- // rank-role with the caller's `immutableCode`. Returns null after responding.
141
- async function loadMutableRole(res, roleKey, immutableCode, reason) {
153
+ // Load a role whose GRANTS this API may edit (ADR 0172): 404 on a missing role,
154
+ // refuse only the Archon rank-role. Every other role — the three sub-Archon
155
+ // rank-roles and every custom one — is grant-editable; WHICH keys is then
156
+ // `catalog.isGrantableTo(key, role)`, per role. Returns null after responding.
157
+ async function loadGrantEditableRole(res, roleKey) {
158
+ const role = await db.getRoleByKey(roleKey);
159
+ if (!role) {
160
+ res.fail('role_not_found', 404, { role_key: roleKey });
161
+ return null;
162
+ }
163
+ if (role.is_system && role.seeded_rank === ARCHON) {
164
+ res.fail('archon_role_immutable', 403, { role_key: roleKey, reason: ARCHON_REASON });
165
+ return null;
166
+ }
167
+ return role;
168
+ }
169
+
170
+ // Load a role this API may ASSIGN. Unchanged from R110: every seeded rank-role is
171
+ // refused, because assignment derives from builders.rank via the R95b sync.
172
+ async function loadAssignableRole(res, roleKey) {
142
173
  const role = await db.getRoleByKey(roleKey);
143
174
  if (!role) {
144
175
  res.fail('role_not_found', 404, { role_key: roleKey });
145
176
  return null;
146
177
  }
147
178
  if (role.is_system) {
148
- res.fail(immutableCode, 403, { role_key: roleKey, reason });
179
+ res.fail('seeded_role_assignment_forbidden', 403, { role_key: roleKey, reason: ASSIGN_REASON });
149
180
  return null;
150
181
  }
151
182
  return role;
152
183
  }
153
- const GRANTS_REASON = 'a seeded rank-role\'s grants derive from catalog floors; changing one is a migration (R94), never an API write';
184
+ const ARCHON = 'archon';
185
+ const ARCHON_REASON = 'the Archon rank-role holds every permission and is immutable — it is the role that can never be locked out of this tab (ADR 0172)';
154
186
  const ASSIGN_REASON = 'a seeded rank-role is assigned by rank (R95b sync); change the builder\'s rank instead';
155
187
 
156
188
  // rank: perm governance.manage — the full role list with each role's grants.
@@ -165,12 +197,19 @@ module.exports = function buildGovernanceRouter() {
165
197
  });
166
198
 
167
199
  // rank: perm governance.manage — the permission catalog + principals, straight
168
- // from code (the catalog is CODE, not a table); no DB read. `grantable` is the
169
- // flag the tab renders LOCKED from; principals are read-only by construction.
200
+ // from code (the catalog is CODE, not a table); no DB read. Principals are
201
+ // read-only by construction. Three flags the tab renders its locks from, and the
202
+ // tab must read all three rather than just `grantable`, because since ADR 0172
203
+ // grantability depends on WHICH role is selected:
204
+ // • `grantable` — the CUSTOM-role answer (system:false). Unchanged meaning.
205
+ // • `system` — an in-app Archon power: locked on a custom role, togglable on
206
+ // a sub-Archon rank-role.
207
+ // • `substrate` — locked EVERYWHERE, on every role, forever (ADR 0043).
170
208
  router.get('/governance/permissions', api.requireBuilder, api.requirePermission('governance.manage'), (req, res) => {
171
209
  const permissions = catalog.PERMISSIONS.map((p) => ({
172
210
  key: p.key,
173
211
  system: p.system,
212
+ substrate: p.substrate,
174
213
  floor: p.floor,
175
214
  guards: p.guards,
176
215
  scope: p.scope,
@@ -221,23 +260,31 @@ module.exports = function buildGovernanceRouter() {
221
260
  }
222
261
  });
223
262
 
224
- // rank: perm governance.manage — grant one OPERATIONAL permission to a custom
225
- // role (the toggle's ON half). The order of refusals is the authority order:
226
- // role exists → role is custom → the key exists → the key is grantable.
263
+ // rank: perm governance.manage — grant one permission to a role (the toggle's ON
264
+ // half). The order of refusals is the authority order: role exists → role is not
265
+ // the Archon one → the key exists → the key is grantable TO THIS ROLE.
227
266
  router.put('/governance/roles/:roleKey/permissions/:permissionKey', api.requireBuilder, api.requirePermission('governance.manage'), async (req, res) => {
228
267
  const roleKey = roleKeyParam(req, res);
229
268
  if (roleKey === null) return;
230
269
  const permissionKey = req.params.permissionKey;
231
270
  try {
232
- const role = await loadMutableRole(res, roleKey, 'seeded_role_immutable', GRANTS_REASON);
271
+ const role = await loadGrantEditableRole(res, roleKey);
233
272
  if (role === null) return;
234
273
  if (!catalog.byKey(permissionKey)) {
235
274
  return res.fail('unknown_permission', 400, { permission_key: permissionKey });
236
275
  }
237
- if (!catalog.isGrantable(permissionKey)) {
276
+ if (!catalog.isGrantableTo(permissionKey, role)) {
238
277
  return res.fail('permission_not_grantable', 400, {
239
278
  permission_key: permissionKey,
240
- reason: 'system (trust-boundary) permissions are non-grantable to a custom role (ADR 0151)',
279
+ role_key: roleKey,
280
+ // Name the ACTUAL wall. "Not grantable" now has three distinct causes and
281
+ // a caller that cannot tell them apart cannot tell a policy choice
282
+ // (widen the role) from a structural one (nothing will ever work).
283
+ reason: catalog.isSubstrate(permissionKey)
284
+ ? 'substrate-enforced (ADR 0043) — enforced outside the app by the pre-push hook / main-audit / the rank floor, so a grant row here would confer nothing. Non-grantable to EVERY role.'
285
+ : catalog.isPrincipalKey(permissionKey)
286
+ ? 'a machine-principal permission — held by an exact builders.system_role identity, never by a role (ADR 0151 §3)'
287
+ : 'a system (trust-boundary) permission is non-grantable to a CUSTOM role; only a sub-Archon rank-role may carry one (ADR 0172)',
241
288
  });
242
289
  }
243
290
  const granted = await db.grantRolePermission({ roleKey, permissionKey });
@@ -257,7 +304,7 @@ module.exports = function buildGovernanceRouter() {
257
304
  if (roleKey === null) return;
258
305
  const permissionKey = req.params.permissionKey;
259
306
  try {
260
- const role = await loadMutableRole(res, roleKey, 'seeded_role_immutable', GRANTS_REASON);
307
+ const role = await loadGrantEditableRole(res, roleKey);
261
308
  if (role === null) return;
262
309
  const removed = await db.revokeRolePermission({ roleKey, permissionKey });
263
310
  res.json({ ok: true, role_key: roleKey, permission_key: permissionKey, removed });
@@ -267,6 +314,41 @@ module.exports = function buildGovernanceRouter() {
267
314
  }
268
315
  });
269
316
 
317
+ // rank: perm governance.manage — restore a SEEDED rank-role to its catalog
318
+ // default (ADR 0172). The counterweight to making the rank-roles editable: a
319
+ // rank's grant set is ~60 rows, so an owner who drifts one has no practical way
320
+ // back by clicking, and "what does Trusted mean by default" is a question only
321
+ // the catalog can answer. Restores catalog.seedForRank(role.seeded_rank) — the
322
+ // same derivation governance_002 seeded from, so the answer cannot drift from
323
+ // the floors even after later catalog edits.
324
+ //
325
+ // Deliberately NOT offered for a CUSTOM role: a custom role has no catalog
326
+ // default to return to (its "default" is the empty set it was created with), so
327
+ // the honest verb there is revoking the grants, which the toggle already does.
328
+ router.post('/governance/roles/:roleKey/reset', api.requireBuilder, api.requirePermission('governance.manage'), async (req, res) => {
329
+ const roleKey = roleKeyParam(req, res);
330
+ if (roleKey === null) return;
331
+ try {
332
+ const role = await loadGrantEditableRole(res, roleKey);
333
+ if (role === null) return;
334
+ if (!role.is_system || !catalog.RANK_ORDER.includes(role.seeded_rank)) {
335
+ return res.fail('role_has_no_default', 400, {
336
+ role_key: roleKey,
337
+ reason: 'only a seeded rank-role has a catalog default to reset to; revoke a custom role\'s grants instead',
338
+ });
339
+ }
340
+ // The seed is the floor derivation, which never contains a principal key and
341
+ // — below archon — never a system one. Filtering by isGrantableTo anyway keeps
342
+ // reset from being a back door that writes what the toggle refuses.
343
+ const keys = catalog.seedForRank(role.seeded_rank).filter((k) => catalog.isGrantableTo(k, role));
344
+ const count = await db.resetRoleGrants({ roleKey, permissionKeys: keys });
345
+ res.json({ ok: true, role_key: roleKey, seeded_rank: role.seeded_rank, permission_count: count });
346
+ } catch (err) {
347
+ console.error('[gds] POST /governance/roles/:roleKey/reset', err);
348
+ res.fail('reset_failed', { status: 500, message: 'internal error' });
349
+ }
350
+ });
351
+
270
352
  // rank: perm governance.manage — assign a CUSTOM role to a builder.
271
353
  // assigned_by is ALWAYS the session builder, never input (the audit trail's
272
354
  // "who did this" must be the authenticated actor).
@@ -280,7 +362,7 @@ module.exports = function buildGovernanceRouter() {
280
362
  return res.fail('bad_role_key', 400, { role_key: roleKey, expected: 'a kebab-case slug (^[a-z][a-z0-9-]*$)' });
281
363
  }
282
364
  try {
283
- const role = await loadMutableRole(res, roleKey, 'seeded_role_assignment_forbidden', ASSIGN_REASON);
365
+ const role = await loadAssignableRole(res, roleKey);
284
366
  if (role === null) return;
285
367
  const assigned = await db.assignRole({
286
368
  builderId: req.body.builder_id,
@@ -305,7 +387,7 @@ module.exports = function buildGovernanceRouter() {
305
387
  const roleKey = roleKeyParam(req, res);
306
388
  if (roleKey === null) return;
307
389
  try {
308
- const role = await loadMutableRole(res, roleKey, 'seeded_role_assignment_forbidden', ASSIGN_REASON);
390
+ const role = await loadAssignableRole(res, roleKey);
309
391
  if (role === null) return;
310
392
  const removed = await db.unassignRole({ builderId, roleKey });
311
393
  res.json({ ok: true, builder_id: builderId, role_key: roleKey, removed });
@@ -6,6 +6,9 @@
6
6
  .gov-h { color: var(--ink); font-size: 16px; font-weight: 600; margin: 22px 0 8px; }
7
7
  .gov-h--minor { font-size: 13px; margin: 18px 0 6px; }
8
8
  .gov-grid-note { color: var(--ink-soft); font-size: 12.5px; margin: 0 0 12px; }
9
+ /* Holds the rank-role reset button. Empty for every other role, so it collapses
10
+ rather than reserving a gap — hence no margin on the empty box itself. */
11
+ .gov-grid-actions:not(:empty) { margin: 0 0 14px; }
9
12
  .gov-sealed, .gov-empty { color: var(--ink-soft); padding: 16px 0; }
10
13
 
11
14
  /* Two columns on wide screens: role rail left, permission grid right. */
@@ -42,6 +42,7 @@
42
42
  <div class="gov-grid-col">
43
43
  <h2 class="gov-h" id="gov-grid-title">Permissions</h2>
44
44
  <p class="gov-grid-note" id="gov-grid-note"></p>
45
+ <div class="gov-grid-actions" id="gov-reset"></div>
45
46
  <div id="gov-grid"></div>
46
47
  </div>
47
48
  </div>
@@ -1,16 +1,24 @@
1
1
  // public-builders/governance.js — Governance (/builders/governance).
2
2
  //
3
3
  // The roles/permissions/assignments tab (BV1.R111, ADR 0151 §2) over the R110
4
- // CRUD routes. Discord-style: pick a role on the left, toggle its OPERATIONAL
5
- // permissions in the grid, assign custom roles to builders below. Two walls the
6
- // SERVER enforces and this page only renders honestly (ADR 0016 — nothing here
7
- // grants anything):
8
- // • `system` (trust-boundary) permissions render LOCKED — visible, never
9
- // togglable; the API refuses them as `permission_not_grantable`.
10
- // • the four seeded rank-roles are read-only — their grants derive from
11
- // catalog floors and their assignment from builders.rank (R95b), so both
12
- // toggle + assign are disabled and the API refuses `seeded_role_immutable`
13
- // / `seeded_role_assignment_forbidden`.
4
+ // CRUD routes. Discord-style: pick a role on the left, toggle its permissions in
5
+ // the grid, assign custom roles to builders below.
6
+ //
7
+ // Since ADR 0172 grantability depends on WHICH role is selected, so this page's
8
+ // central job is rendering three different locks honestly. The SERVER enforces all
9
+ // of them (ADR 0016 — nothing here grants anything; a disabled button is a
10
+ // courtesy, `isGrantableTo` on the route is the wall):
11
+ // • SUBSTRATE permissions (deploy / protected paths / ssh) render LOCKED for
12
+ // every role, always. They are enforced outside the app (ADR 0043), so a grant
13
+ // row confers nothing — showing a working toggle would be the UI lying.
14
+ // • OWNER POWERS (the seven in-app `system` keys) render LOCKED on a custom role
15
+ // and TOGGLABLE on a sub-Archon rank-role. That asymmetry is the whole of the
16
+ // owner's 2026-08-16 decision, and the section header says so out loud.
17
+ // • the ARCHON rank-role is read-only in full: it holds everything, and its
18
+ // immutability is what makes it impossible to click your way into locking the
19
+ // owner out of this tab. The API refuses it as `archon_role_immutable`.
20
+ // ASSIGNMENT of any rank-role is still refused (`seeded_role_assignment_forbidden`)
21
+ // — you get a rank-role by holding the rank, so that dial is the rank itself.
14
22
  //
15
23
  // Like The Gate, this reads /api/bongos/me for a friendly Sealed notice —
16
24
  // DEFENCE IN DEPTH only. Every /governance/* endpoint (reads included) is ALSO
@@ -55,8 +63,9 @@
55
63
 
56
64
  // The R110 refusal codes, translated for the person clicking.
57
65
  const ERROR_COPY = {
58
- permission_not_grantable: 'That is a trust-boundary permission — it can never be granted to a custom role.',
59
- seeded_role_immutable: 'Rank roles are read-only: their permissions derive from the catalog, not from this page.',
66
+ permission_not_grantable: 'That permission can’t be granted to this role. (The server’s reply says which of the three walls applied.)',
67
+ archon_role_immutable: 'The Archon role is read-only — it holds every permission, which is what keeps you from locking yourself out of this page.',
68
+ role_has_no_default: 'Only a rank role has a catalog default to reset to. Revoke a custom role’s permissions individually instead.',
60
69
  seeded_role_assignment_forbidden: 'Rank roles are assigned by rank itself — change the builder’s rank instead.',
61
70
  role_exists: 'A role with that key already exists.',
62
71
  role_not_found: 'That role no longer exists — refreshing.',
@@ -83,9 +92,13 @@
83
92
  const list = document.getElementById('gov-role-list');
84
93
  list.innerHTML = '';
85
94
  for (const role of roles) {
86
- const pill = role.is_system
87
- ? `<span class="gov-pill gov-pill--seeded">rank role</span>`
88
- : `<span class="gov-pill gov-pill--custom">custom</span>`;
95
+ // The Archon row is called out as read-only, because it is the one role in
96
+ // this list a click will not change and the reader should know before trying.
97
+ const pill = isArchonRole(role)
98
+ ? `<span class="gov-pill gov-pill--locked">rank role · read-only</span>`
99
+ : role.is_system
100
+ ? `<span class="gov-pill gov-pill--seeded">rank role</span>`
101
+ : `<span class="gov-pill gov-pill--custom">custom</span>`;
89
102
  const btn = document.createElement('button');
90
103
  btn.type = 'button';
91
104
  btn.className = 'gov-role';
@@ -100,20 +113,38 @@
100
113
 
101
114
  // ---- render: permission grid --------------------------------------------
102
115
 
116
+ // The client-side mirror of catalog.isGrantableTo(). It exists to render the
117
+ // right control, NEVER to decide authority — the route runs the real one and
118
+ // refuses regardless of what this returns. Kept in the same order as the server's
119
+ // so a future catalog change lands in one obvious place on each side.
120
+ const isArchonRole = (role) => !!role && role.is_system && role.seeded_rank === 'archon';
121
+ const isRankRole = (role) => !!role && role.is_system;
122
+ function grantableTo(p, role) {
123
+ if (!role || p.substrate) return false;
124
+ if (role.is_system) return role.seeded_rank !== 'archon';
125
+ return !!p.grantable;
126
+ }
127
+
103
128
  function permRow(p, role) {
104
129
  const held = !!role && (role.permissions || []).includes(p.key);
105
130
  const row = document.createElement('div');
106
131
  row.className = 'gov-perm';
107
132
 
133
+ // Why locked, in the words that tell the reader whether it is a policy they
134
+ // could change or a fact they cannot. Three causes, three sentences.
135
+ const lockReason = p.substrate
136
+ ? 'Enforced outside the app (the pre-push hook, the deploy audit, the server itself). Granting it here would change nothing, so it is locked for every role.'
137
+ : isArchonRole(role)
138
+ ? 'The Archon role holds everything and is read-only.'
139
+ : 'An owner-level permission. It can be handed to a rank role, but never to a custom one.';
140
+
108
141
  let control;
109
- if (!p.grantable) {
110
- // The R110 wall, rendered: visible, never togglable.
111
- control = `<span class="gov-pill gov-pill--locked" title="Trust-boundary permission — non-grantable (ADR 0151)">locked</span>`;
112
- } else if (!role) {
142
+ if (!role) {
113
143
  control = '';
114
- } else if (role.is_system) {
115
- // Seeded rank-role: show what it holds, allow nothing.
116
- control = held ? `<span class="gov-held-note">held via rank</span>` : '';
144
+ } else if (!grantableTo(p, role)) {
145
+ control = `<span class="gov-pill gov-pill--locked" title="${escapeHtml(lockReason)}">locked</span>`;
146
+ // The Archon role is read-only rather than empty-handed, so say what it holds.
147
+ if (held && isArchonRole(role)) control = `<span class="gov-held-note">held</span>${control}`;
117
148
  } else {
118
149
  control = `<button type="button" class="gov-toggle" aria-pressed="${held ? 'true' : 'false'}" data-perm="${escapeHtml(p.key)}">${held ? 'granted' : 'grant'}</button>`;
119
150
  }
@@ -139,23 +170,71 @@
139
170
  return el;
140
171
  }
141
172
 
173
+ function gridNote(role) {
174
+ if (!role) return 'Pick a role to see and edit what it may do.';
175
+ if (isArchonRole(role)) {
176
+ return 'The Archon role holds every permission and is read-only — that is what makes it impossible to lock yourself out of this page. To give a power away, hand it to one of the ranks below instead.';
177
+ }
178
+ if (isRankRole(role)) {
179
+ return `Everything a ${role.label || role.role_key} can do, and yours to change — every builder at this rank is affected immediately. “Reset to default” puts it back to the shipped ladder.`;
180
+ }
181
+ return 'Toggle the everyday permissions this role carries. Owner powers can only go to a rank role, not to a custom one.';
182
+ }
183
+
142
184
  function renderGrid() {
143
185
  const grid = document.getElementById('gov-grid');
144
186
  const role = selectedRole();
145
187
  document.getElementById('gov-grid-title').textContent = role
146
188
  ? `Permissions — ${role.label || role.role_key}` : 'Permissions';
147
- document.getElementById('gov-grid-note').textContent = !role
148
- ? 'Pick a role to see and edit what it may do.'
149
- : role.is_system
150
- ? 'A rank role’s permissions derive from the catalog and cannot be edited here.'
151
- : 'Toggle the everyday permissions this role carries. Locked rows are trust-boundary powers no custom role can ever hold.';
189
+ document.getElementById('gov-grid-note').textContent = gridNote(role);
190
+
191
+ // The reset escape hatch — only a rank role has a catalog default, and the
192
+ // Archon one never drifts from it, so it shows on exactly the three editable ones.
193
+ const resetBox = document.getElementById('gov-reset');
194
+ resetBox.innerHTML = '';
195
+ if (isRankRole(role) && !isArchonRole(role)) {
196
+ const btn = document.createElement('button');
197
+ btn.type = 'button';
198
+ btn.className = 'gov-btn gov-btn--ghost gov-btn--small';
199
+ btn.textContent = 'Reset to default';
200
+ btn.addEventListener('click', () => resetRole(role, btn));
201
+ resetBox.appendChild(btn);
202
+ }
152
203
 
153
204
  grid.innerHTML = '';
205
+ // Principals are rendered in their own section further down the page; a machine
206
+ // identity is not a row anyone can act on here.
154
207
  const humanPerms = permissions.filter((p) => !principals.some((pr) => pr.permission === p.key));
155
- const locked = humanPerms.filter((p) => !p.grantable);
156
- const operational = humanPerms.filter((p) => p.grantable);
157
- grid.appendChild(section(`Trust boundary — locked (${locked.length})`, locked.map((p) => permRow(p, role))));
208
+ // Three groups, split by WHY a row is or isn't editable rather than by the old
209
+ // single `grantable` flag — which since ADR 0172 answers only the custom-role case.
210
+ const substrate = humanPerms.filter((p) => p.substrate);
211
+ const ownerPowers = humanPerms.filter((p) => p.system && !p.substrate);
212
+ const operational = humanPerms.filter((p) => !p.system);
213
+
214
+ const ownerHead = !role || isRankRole(role)
215
+ ? `Owner powers (${ownerPowers.length})`
216
+ : `Owner powers — locked for custom roles (${ownerPowers.length})`;
217
+
158
218
  grid.appendChild(section(`Operational (${operational.length})`, operational.map((p) => permRow(p, role))));
219
+ grid.appendChild(section(ownerHead, ownerPowers.map((p) => permRow(p, role))));
220
+ grid.appendChild(section(`Enforced outside the app — always locked (${substrate.length})`, substrate.map((p) => permRow(p, role))));
221
+ }
222
+
223
+ // Restore a rank role to the shipped ladder. Confirmed because it is the one
224
+ // action here that discards work in bulk — a drifted role can be ~60 rows.
225
+ async function resetRole(role, btn) {
226
+ const label = role.label || role.role_key;
227
+ if (!window.confirm(`Reset ${label} to its default permissions?\n\nEvery change you've made to this rank is discarded, and it takes effect for every builder at this rank on their next request.`)) return;
228
+ btn.disabled = true;
229
+ try {
230
+ const out = await sendJson('POST', `/governance/roles/${encodeURIComponent(role.role_key)}/reset`);
231
+ toast(`${label} reset to its default ${out.permission_count} permissions.`);
232
+ await reloadRoles();
233
+ } catch (err) {
234
+ if (err && err.kind === '401') return;
235
+ toast(`Could not reset: ${err.message || err}`);
236
+ btn.disabled = false;
237
+ }
159
238
  }
160
239
 
161
240
  async function toggleGrant(role, permissionKey, grant, btn) {
@@ -337,7 +416,7 @@
337
416
  const rank = (me && (me.builder || me).rank || '').toLowerCase();
338
417
  if (rank !== 'archon') { showSealed(); return; }
339
418
 
340
- document.getElementById('gov-sub').textContent = 'Roles, permissions, and who holds them — the trust boundary stays locked.';
419
+ document.getElementById('gov-sub').textContent = 'Roles, permissions, and who holds them. Ranks below Archon are yours to shape; what the substrate enforces stays locked.';
341
420
  document.getElementById('gov-body').hidden = false;
342
421
  wireForms();
343
422
  try {