@bongos/core 1.19.183 → 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.
- package/.bongos-core.json +102 -92
- package/clients/bongos-client/bongos-client.global.js +19 -19
- package/clients/bongos-client/index.cjs +19 -19
- package/clients/bongos-client/index.d.ts +29 -29
- package/clients/bongos-client/index.mjs +19 -19
- package/docs/adr/0157-archon-is-rank-and-identity-only.md +2 -2
- package/docs/api/openapi.json +117 -117
- package/docs/api-reference.md +10 -10
- package/docs/canonical-permissions.md +6 -6
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +19 -19
- package/modules/{governance → government}/catalog.js +8 -8
- package/modules/{governance → government}/config.js +6 -6
- package/modules/{governance → government}/db.js +44 -44
- package/modules/government/migrations/government_001_rename_from_governance.sql +213 -0
- package/modules/government/migrations/government_002_rename_permission_keys.sql +112 -0
- package/modules/government/module.json +22 -0
- package/modules/{governance → government}/protected-surfaces.json +0 -1
- package/modules/{governance/rank-roles.js → government/ranks.js} +18 -18
- package/modules/{governance/routes/governance.js → government/routes/government.js} +109 -84
- package/modules/{governance/routes/role-sync.js → government/routes/rank-sync.js} +7 -7
- package/modules/hall-ui/public/gate.js +1 -1
- package/modules/hall-ui/public/governance.js +38 -38
- package/modules/hall-ui/public/harbor.js +2 -2
- package/modules/hall-ui/public/shell.js +3 -3
- package/modules/hall-ui/public/watch.js +1 -1
- package/modules/lifecycle/db.js +6 -6
- package/modules/lifecycle/routes/gate-approvals.js +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness.js +11 -1
- package/scripts/gds/migration-namespace.js +15 -3
- package/scripts/gds/publish-manifest.js +2 -3
- package/scripts/gds/seed-governance-tasks.js +3 -3
- package/src/bongos/auth.js +16 -16
- package/src/bongos/db.js +6 -6
- package/src/bongos/module-scope-map.js +7 -22
- package/src/bongos/permission-path-check.js +3 -3
- package/src/bongos/route-rank-check.js +15 -21
- package/src/bongos/serve-internal.js +2 -2
- package/src/module-api.js +7 -3
- package/tests/auth_page_gate.mjs +2 -2
- package/tests/criterion_authoring_gate.mjs +1 -1
- package/tests/governance_abuse_matrix.mjs +38 -38
- package/tests/governance_catalog.mjs +4 -4
- package/tests/governance_claim_eligibility.mjs +1 -1
- package/tests/governance_config.mjs +7 -7
- package/tests/governance_fold.mjs +4 -4
- package/tests/governance_fold_bench.mjs +2 -2
- package/tests/governance_live_proof.mjs +6 -6
- package/tests/governance_ownership_scope.mjs +2 -2
- package/tests/governance_page_gates.mjs +10 -10
- package/tests/governance_parity.mjs +3 -3
- package/tests/governance_principals.mjs +3 -3
- package/tests/governance_protected_surfaces.mjs +44 -19
- package/tests/governance_require_permission.mjs +11 -11
- package/tests/governance_resolver.mjs +4 -4
- package/tests/governance_role_sync.mjs +35 -35
- package/tests/governance_routes.mjs +98 -98
- package/tests/governance_seed.mjs +27 -4
- package/tests/governance_session_scope.mjs +5 -5
- package/tests/governance_vanilla_boot.mjs +4 -4
- package/tests/hall_page_gate_map.mjs +3 -3
- package/tests/memory_isolation.mjs +2 -2
- package/tests/module_loader.mjs +1 -1
- package/tests/onboarding_observability.mjs +2 -2
- package/tests/rank_tier_single_source.mjs +2 -2
- package/tests/route_permission_check.mjs +2 -2
- package/modules/governance/module.json +0 -11
- /package/config/{governance.neutral.json → government.neutral.json} +0 -0
- /package/modules/{governance → government}/CLAUDE.md +0 -0
- /package/modules/{governance → government}/migrations/governance_001_tables.sql +0 -0
- /package/modules/{governance → government}/migrations/governance_002_seed_and_backfill.sql +0 -0
- /package/modules/{governance → government}/migrations/governance_003_task_abandon_metic.sql +0 -0
- /package/modules/{governance → government}/migrations/governance_004_criterion_create.sql +0 -0
- /package/modules/{governance → government}/migrations/governance_005_resync_builder_rank_roles.sql +0 -0
- /package/modules/{governance → government}/migrations/governance_006_version_create.sql +0 -0
- /package/modules/{governance → government}/migrations/governance_007_delegate_archon_ops_to_metic.sql +0 -0
- /package/modules/{governance → government}/protected-surfaces.js +0 -0
- /package/modules/{governance → government}/resolver.js +0 -0
- /package/modules/{governance → government}/session-scopes.js +0 -0
- /package/modules/{governance → government}/session-scopes.json +0 -0
|
@@ -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
|
-
"
|
|
341
|
-
// GET /
|
|
342
|
-
|
|
343
|
-
// POST /
|
|
344
|
-
|
|
345
|
-
// DELETE /
|
|
346
|
-
|
|
347
|
-
// GET /
|
|
348
|
-
|
|
349
|
-
// GET /
|
|
350
|
-
|
|
351
|
-
// POST /
|
|
352
|
-
|
|
353
|
-
// DELETE /
|
|
354
|
-
|
|
355
|
-
// PUT /
|
|
356
|
-
|
|
357
|
-
// POST /
|
|
358
|
-
|
|
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: '
|
|
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
|
-
// `
|
|
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.
|
|
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 `
|
|
239
|
+
// write those pages reach is its own atom, and `government.manage` (the
|
|
240
240
|
// roles/permissions tab) stayed archon.
|
|
241
|
-
{ key: 'page.view.
|
|
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) ─────────
|
|
@@ -410,7 +410,7 @@ function substrateKeys() {
|
|
|
410
410
|
//
|
|
411
411
|
// The owner's 2026-08-16 decision made the three sub-Archon RANK roles editable, so
|
|
412
412
|
// "is this grantable" stopped being a property of the key alone. `role` is a
|
|
413
|
-
//
|
|
413
|
+
// government_ranks row ({ is_system, seeded_rank }); the four answers:
|
|
414
414
|
//
|
|
415
415
|
// • NEVER, to any role — a machine-principal key (held by an identity, not a role)
|
|
416
416
|
// and a SUBSTRATE key. The substrate three are inert as grant rows: no route
|
|
@@ -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
|
-
// `
|
|
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 `
|
|
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
|
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
// assignments are DB rows. This file resolves the config half, mirroring
|
|
7
7
|
// src/branding.js precedence exactly:
|
|
8
8
|
//
|
|
9
|
-
// env override -> instance config (config/
|
|
10
|
-
// starter (config/
|
|
9
|
+
// env override -> instance config (config/government.json) -> neutral
|
|
10
|
+
// starter (config/government.neutral.json)
|
|
11
11
|
//
|
|
12
12
|
// A fresh instance with NO config resolves to the PLAIN neutral role set — never to
|
|
13
13
|
// a host's labels (fail-to-vanilla, the same posture branding takes). R102 proves
|
|
@@ -17,8 +17,8 @@
|
|
|
17
17
|
// second source of authority truth, which is exactly what ADR 0016 forbids:
|
|
18
18
|
// • config — TEMPLATES/DEFAULTS: a role's display label, its description, and the
|
|
19
19
|
// permission list a starting CUSTOM role template proposes. Shapes only.
|
|
20
|
-
// • DB — LIVE STATE:
|
|
21
|
-
//
|
|
20
|
+
// • DB — LIVE STATE: government_ranks / government_rank_permissions /
|
|
21
|
+
// government_builder_ranks (R93). WHO HOLDS WHAT is a row, resolved server-side
|
|
22
22
|
// per request by resolver.js. Nothing in this file grants anything to anybody.
|
|
23
23
|
//
|
|
24
24
|
// Three fail-closed rules, in the same spirit as resolver.js's drop-unknown-key rule:
|
|
@@ -55,8 +55,8 @@ const catalog = require('./catalog');
|
|
|
55
55
|
// split lands, the INSTANCE path routes through resolveInstanceRoot() — the module
|
|
56
56
|
// doorway does not re-export the root resolvers yet.
|
|
57
57
|
const ROOT = path.resolve(__dirname, '..', '..');
|
|
58
|
-
const NEUTRAL_PATH = path.join(ROOT, 'config', '
|
|
59
|
-
const INSTANCE_PATH = path.join(ROOT, 'config', '
|
|
58
|
+
const NEUTRAL_PATH = path.join(ROOT, 'config', 'government.neutral.json');
|
|
59
|
+
const INSTANCE_PATH = path.join(ROOT, 'config', 'government.json');
|
|
60
60
|
|
|
61
61
|
const SEEDED_RANK_ROLES = new Set(catalog.RANK_ORDER);
|
|
62
62
|
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// next request — the ADR 0016 trust-boundary rule the resolver inherits.
|
|
12
12
|
|
|
13
13
|
const { pool } = require('../../src/module-api');
|
|
14
|
-
const {
|
|
14
|
+
const { planRankSync } = require('./ranks');
|
|
15
15
|
|
|
16
16
|
// Every permission_key granted to any role the builder holds. Duplicates across
|
|
17
17
|
// roles are collapsed by the DISTINCT — the resolver's UNION semantics. Returns
|
|
@@ -20,8 +20,8 @@ const { planRankRoleSync } = require('./rank-roles');
|
|
|
20
20
|
async function getPermissionKeysForBuilder(builderId) {
|
|
21
21
|
const { rows } = await pool.query(
|
|
22
22
|
`SELECT DISTINCT rp.permission_key
|
|
23
|
-
FROM
|
|
24
|
-
JOIN
|
|
23
|
+
FROM government_builder_ranks br
|
|
24
|
+
JOIN government_rank_permissions rp ON rp.rank_key = br.rank_key
|
|
25
25
|
WHERE br.builder_id = $1`,
|
|
26
26
|
[builderId],
|
|
27
27
|
);
|
|
@@ -32,16 +32,16 @@ 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
|
|
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.
|
|
38
|
+
return rows.map((r) => r.rank_key);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
// --- the rank→role WRITE half (BV1.R95b) ------------------------------------
|
|
42
42
|
//
|
|
43
43
|
// Everything above this line only READS. Until R95b the only write to
|
|
44
|
-
//
|
|
44
|
+
// government_builder_ranks in the whole repo was governance_002's run-once
|
|
45
45
|
// backfill, so a builder created after it had NO role (locked out of every
|
|
46
46
|
// permission gate) and a builder whose rank CHANGED kept their OLD rank-role
|
|
47
47
|
// (a demoted Archon still resolving Archon permissions — the ADR 0016 defect).
|
|
@@ -49,7 +49,7 @@ async function getRoleKeysForBuilder(builderId) {
|
|
|
49
49
|
// The writer below is a TRANSACTION PARTICIPANT: every caller hands it the client
|
|
50
50
|
// its own BEGIN/COMMIT is running on, so the role rotation commits — or rolls back
|
|
51
51
|
// — atomically with the rank write it accompanies. Core reaches it through the
|
|
52
|
-
// `
|
|
52
|
+
// `government.rankSync` kernel port (routes/rank-sync.js); it never imports this
|
|
53
53
|
// module (ADR 0083).
|
|
54
54
|
|
|
55
55
|
// The seeded rank-roles as the DB actually defines them. Read per call rather than
|
|
@@ -57,8 +57,8 @@ 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
|
|
61
|
-
FROM
|
|
60
|
+
`SELECT rank_key, seeded_rank, is_system
|
|
61
|
+
FROM government_ranks
|
|
62
62
|
WHERE is_system = true AND seeded_rank IS NOT NULL`,
|
|
63
63
|
);
|
|
64
64
|
return rows;
|
|
@@ -68,16 +68,16 @@ 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.
|
|
72
|
-
FROM
|
|
73
|
-
JOIN
|
|
71
|
+
`SELECT br.rank_key, r.is_system, r.seeded_rank
|
|
72
|
+
FROM government_builder_ranks br
|
|
73
|
+
JOIN government_ranks r ON r.rank_key = br.rank_key
|
|
74
74
|
WHERE br.builder_id = $1`,
|
|
75
75
|
[builderId],
|
|
76
76
|
);
|
|
77
77
|
return rows;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
//
|
|
80
|
+
// syncRank(client, { builderId, rank }) — make the builder's seeded rank-role
|
|
81
81
|
// match `rank`: add the matching one, DELETE every other seeded one, leave custom
|
|
82
82
|
// roles alone. `client` is the caller's open transaction client; pass null/undefined
|
|
83
83
|
// to run on the pool (the sign-in path, which has no surrounding transaction).
|
|
@@ -89,27 +89,27 @@ async function getRoleAssignmentsForBuilder(q, builderId) {
|
|
|
89
89
|
// assigned_by is left NULL: a rank-derived assignment is the system's, not a person's
|
|
90
90
|
// (governance_002's backfill uses the same convention; the human decision is audited
|
|
91
91
|
// in credit_log's rank_change row).
|
|
92
|
-
async function
|
|
92
|
+
async function syncRank(client, { builderId, rank } = {}) {
|
|
93
93
|
if (builderId === null || builderId === undefined || builderId === '') {
|
|
94
94
|
return { target: null, added: [], removed: [] };
|
|
95
95
|
}
|
|
96
96
|
const q = client || pool;
|
|
97
97
|
const seededRoles = await getSeededRankRoles(q);
|
|
98
98
|
const currentRoles = await getRoleAssignmentsForBuilder(q, builderId);
|
|
99
|
-
const plan =
|
|
99
|
+
const plan = planRankSync({ rank, currentRoles, seededRoles });
|
|
100
100
|
|
|
101
101
|
if (plan.remove.length > 0) {
|
|
102
102
|
await q.query(
|
|
103
|
-
`DELETE FROM
|
|
104
|
-
WHERE builder_id = $1 AND
|
|
103
|
+
`DELETE FROM government_builder_ranks
|
|
104
|
+
WHERE builder_id = $1 AND rank_key = ANY($2::text[])`,
|
|
105
105
|
[builderId, [...plan.remove]],
|
|
106
106
|
);
|
|
107
107
|
}
|
|
108
108
|
if (plan.add.length > 0) {
|
|
109
109
|
await q.query(
|
|
110
|
-
`INSERT INTO
|
|
110
|
+
`INSERT INTO government_builder_ranks (builder_id, rank_key)
|
|
111
111
|
SELECT $1, unnest($2::text[])
|
|
112
|
-
ON CONFLICT (builder_id,
|
|
112
|
+
ON CONFLICT (builder_id, rank_key) DO NOTHING`,
|
|
113
113
|
[builderId, [...plan.add]],
|
|
114
114
|
);
|
|
115
115
|
}
|
|
@@ -127,23 +127,23 @@ async function syncRankRole(client, { builderId, rank } = {}) {
|
|
|
127
127
|
|
|
128
128
|
async function listRolesWithGrants() {
|
|
129
129
|
const { rows } = await pool.query(
|
|
130
|
-
`SELECT r.
|
|
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
|
|
134
|
-
FROM
|
|
135
|
-
LEFT JOIN
|
|
134
|
+
FROM government_ranks r
|
|
135
|
+
LEFT JOIN government_rank_permissions rp ON rp.rank_key = r.rank_key
|
|
136
136
|
GROUP BY r.id
|
|
137
|
-
ORDER BY r.is_system DESC, r.
|
|
137
|
+
ORDER BY r.is_system DESC, r.rank_key`,
|
|
138
138
|
);
|
|
139
139
|
return rows;
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
async function getRoleByKey(roleKey) {
|
|
143
143
|
const { rows } = await pool.query(
|
|
144
|
-
`SELECT
|
|
145
|
-
FROM
|
|
146
|
-
WHERE
|
|
144
|
+
`SELECT rank_key, label, description, is_system, seeded_rank, created_by, created_at
|
|
145
|
+
FROM government_ranks
|
|
146
|
+
WHERE rank_key = $1`,
|
|
147
147
|
[roleKey],
|
|
148
148
|
);
|
|
149
149
|
return rows[0] ?? null;
|
|
@@ -151,9 +151,9 @@ async function getRoleByKey(roleKey) {
|
|
|
151
151
|
|
|
152
152
|
async function createRole({ roleKey, label, description, createdBy }) {
|
|
153
153
|
const { rows } = await pool.query(
|
|
154
|
-
`INSERT INTO
|
|
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
|
|
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];
|
|
@@ -161,9 +161,9 @@ async function createRole({ roleKey, label, description, createdBy }) {
|
|
|
161
161
|
|
|
162
162
|
async function grantRolePermission({ roleKey, permissionKey }) {
|
|
163
163
|
const { rowCount } = await pool.query(
|
|
164
|
-
`INSERT INTO
|
|
164
|
+
`INSERT INTO government_rank_permissions (rank_key, permission_key)
|
|
165
165
|
VALUES ($1, $2)
|
|
166
|
-
ON CONFLICT (
|
|
166
|
+
ON CONFLICT (rank_key, permission_key) DO NOTHING`,
|
|
167
167
|
[roleKey, permissionKey],
|
|
168
168
|
);
|
|
169
169
|
return rowCount > 0;
|
|
@@ -171,8 +171,8 @@ async function grantRolePermission({ roleKey, permissionKey }) {
|
|
|
171
171
|
|
|
172
172
|
async function revokeRolePermission({ roleKey, permissionKey }) {
|
|
173
173
|
const { rowCount } = await pool.query(
|
|
174
|
-
`DELETE FROM
|
|
175
|
-
WHERE
|
|
174
|
+
`DELETE FROM government_rank_permissions
|
|
175
|
+
WHERE rank_key = $1 AND permission_key = $2`,
|
|
176
176
|
[roleKey, permissionKey],
|
|
177
177
|
);
|
|
178
178
|
return rowCount > 0;
|
|
@@ -188,12 +188,12 @@ async function resetRoleGrants({ roleKey, permissionKeys }) {
|
|
|
188
188
|
const client = await pool.connect();
|
|
189
189
|
try {
|
|
190
190
|
await client.query('BEGIN');
|
|
191
|
-
await client.query(`DELETE FROM
|
|
191
|
+
await client.query(`DELETE FROM government_rank_permissions WHERE rank_key = $1`, [roleKey]);
|
|
192
192
|
if (permissionKeys.length > 0) {
|
|
193
193
|
await client.query(
|
|
194
|
-
`INSERT INTO
|
|
194
|
+
`INSERT INTO government_rank_permissions (rank_key, permission_key)
|
|
195
195
|
SELECT $1, unnest($2::text[])
|
|
196
|
-
ON CONFLICT (
|
|
196
|
+
ON CONFLICT (rank_key, permission_key) DO NOTHING`,
|
|
197
197
|
[roleKey, [...permissionKeys]],
|
|
198
198
|
);
|
|
199
199
|
}
|
|
@@ -210,21 +210,21 @@ 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.
|
|
213
|
+
br.rank_key, r.label AS role_label, r.is_system, r.seeded_rank,
|
|
214
214
|
br.assigned_by, br.assigned_at
|
|
215
|
-
FROM
|
|
215
|
+
FROM government_builder_ranks br
|
|
216
216
|
JOIN builders b ON b.id = br.builder_id
|
|
217
|
-
JOIN
|
|
218
|
-
ORDER BY br.builder_id, br.
|
|
217
|
+
JOIN government_ranks r ON r.rank_key = br.rank_key
|
|
218
|
+
ORDER BY br.builder_id, br.rank_key`,
|
|
219
219
|
);
|
|
220
220
|
return rows;
|
|
221
221
|
}
|
|
222
222
|
|
|
223
223
|
async function assignRole({ builderId, roleKey, assignedBy }) {
|
|
224
224
|
const { rowCount } = await pool.query(
|
|
225
|
-
`INSERT INTO
|
|
225
|
+
`INSERT INTO government_builder_ranks (builder_id, rank_key, assigned_by)
|
|
226
226
|
VALUES ($1, $2, $3)
|
|
227
|
-
ON CONFLICT (builder_id,
|
|
227
|
+
ON CONFLICT (builder_id, rank_key) DO NOTHING`,
|
|
228
228
|
[builderId, roleKey, assignedBy],
|
|
229
229
|
);
|
|
230
230
|
return rowCount > 0;
|
|
@@ -232,8 +232,8 @@ async function assignRole({ builderId, roleKey, assignedBy }) {
|
|
|
232
232
|
|
|
233
233
|
async function unassignRole({ builderId, roleKey }) {
|
|
234
234
|
const { rowCount } = await pool.query(
|
|
235
|
-
`DELETE FROM
|
|
236
|
-
WHERE builder_id = $1 AND
|
|
235
|
+
`DELETE FROM government_builder_ranks
|
|
236
|
+
WHERE builder_id = $1 AND rank_key = $2`,
|
|
237
237
|
[builderId, roleKey],
|
|
238
238
|
);
|
|
239
239
|
return rowCount > 0;
|
|
@@ -244,7 +244,7 @@ module.exports = {
|
|
|
244
244
|
getRoleKeysForBuilder,
|
|
245
245
|
getSeededRankRoles,
|
|
246
246
|
getRoleAssignmentsForBuilder,
|
|
247
|
-
|
|
247
|
+
syncRank,
|
|
248
248
|
listRolesWithGrants,
|
|
249
249
|
getRoleByKey,
|
|
250
250
|
createRole,
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
-- governance_008_rename_to_government_ranks.sql
|
|
2
|
+
-- Goal 1000068 R03 (task 1003004) — ADR 0174.
|
|
3
|
+
--
|
|
4
|
+
-- Renames the three authz tables, their indexes, their constraints and the
|
|
5
|
+
-- `role_key` column to the post-ADR-0174 vocabulary: the permission system is
|
|
6
|
+
-- the GOVERNMENT, and the positions in it are RANKS. "Role" is reserved for the
|
|
7
|
+
-- three crafts (Artist / Ideator / Builder).
|
|
8
|
+
--
|
|
9
|
+
-- governance_roles -> government_ranks
|
|
10
|
+
-- governance_builder_roles -> government_builder_ranks
|
|
11
|
+
-- governance_role_permissions -> government_rank_permissions
|
|
12
|
+
-- <all three>.role_key -> rank_key
|
|
13
|
+
--
|
|
14
|
+
-- ── THIS MIGRATION IS NOT SAFE TO APPLY BEFORE R04/R05 ──────────────────────
|
|
15
|
+
-- It is a BREAKING rename with no compatibility shim. The moment it applies,
|
|
16
|
+
-- code that still queries `governance_roles` fails — and that code is the
|
|
17
|
+
-- permission resolver, on every authenticated request. That is why the live
|
|
18
|
+
-- instance's core auto-upgrade sweep (`cloudbongos-core-update-subscription
|
|
19
|
+
-- .timer`) was stopped and disabled on 2026-08-16, freezing the pin at core
|
|
20
|
+
-- 1.19.179 so this migration cannot reach the live database until the renamed
|
|
21
|
+
-- code (R04 task 1003005, R05 task 1003006) is in the same release. Task
|
|
22
|
+
-- 1003011 re-enables the sweep and is dependency-gated on all three.
|
|
23
|
+
-- The owner chose the pause over compatibility views, having been shown both.
|
|
24
|
+
--
|
|
25
|
+
-- ── WHAT THIS MIGRATION DELIBERATELY DOES NOT DO ────────────────────────────
|
|
26
|
+
-- It does NOT rename the permission-key ROWS (`governance.manage` ->
|
|
27
|
+
-- `government.manage`, `page.view.governance` -> `page.view.government`). Those
|
|
28
|
+
-- belong in R05, in the SAME commit as the `catalog.js` constants, because the
|
|
29
|
+
-- resolver DROPS any grant naming a key the catalog does not define (the
|
|
30
|
+
-- fail-closed rule in modules/governance/CLAUDE.md). Renaming the rows here
|
|
31
|
+
-- would mean that between this migration and R05 every `government.manage` row
|
|
32
|
+
-- resolves to nothing — and since that key gates the Government tab itself, the
|
|
33
|
+
-- owner would be locked out of the surface used to inspect the damage. The
|
|
34
|
+
-- schema renames below pair with R04's code; the key renames pair with R05's.
|
|
35
|
+
--
|
|
36
|
+
-- ── MECHANICS ───────────────────────────────────────────────────────────────
|
|
37
|
+
-- ALTER TABLE ... RENAME is catalog-only: it rewrites no rows, holds an
|
|
38
|
+
-- ACCESS EXCLUSIVE lock for the instant it takes, and carries every index,
|
|
39
|
+
-- constraint, foreign key, sequence ownership and privilege with it (they
|
|
40
|
+
-- reference the table by OID, not by name). The same is true of RENAME COLUMN,
|
|
41
|
+
-- including for the two inbound foreign keys that point at `role_key`.
|
|
42
|
+
-- Constraint and index NAMES do not follow, however — Postgres leaves them
|
|
43
|
+
-- spelled the old way — so they are renamed explicitly below. Nothing depends
|
|
44
|
+
-- on those names at runtime; they are renamed so a reader of \d output is not
|
|
45
|
+
-- told a story about "roles" that stopped being true.
|
|
46
|
+
--
|
|
47
|
+
-- IDEMPOTENT: every statement is guarded on the old name still being present,
|
|
48
|
+
-- so a re-run after a partial apply is a no-op rather than an error. migrate.sh
|
|
49
|
+
-- applies each file once, but a hand-run during recovery should not punish you.
|
|
50
|
+
--
|
|
51
|
+
-- ── VERIFIED AGAINST REAL DATA ──────────────────────────────────────────────
|
|
52
|
+
-- Rehearsed on a scratch database restored from the live 2026-08-16 dump (not a
|
|
53
|
+
-- synthetic fixture): apply, apply-again, roll back, apply again. The
|
|
54
|
+
-- (builder, permission) fingerprint md5 fb6be239ae2b3bf811888ac90cfb6346 and the
|
|
55
|
+
-- row baseline 4 ranks / 15 assignments / 161 grants were byte-identical at
|
|
56
|
+
-- every step, and an INSERT through the renamed sequence still allocated an id.
|
|
57
|
+
-- Snapshots of the live database live at
|
|
58
|
+
-- /home/lars/mig/pre-govt-rename-{authz,full}-2026-08-16T213128Z.*
|
|
59
|
+
--
|
|
60
|
+
-- ROLLBACK — reverse every RENAME. Lossless (no row is touched). This exact
|
|
61
|
+
-- script was executed as part of the rehearsal above:
|
|
62
|
+
--
|
|
63
|
+
-- BEGIN;
|
|
64
|
+
-- ALTER TABLE government_ranks RENAME TO governance_roles;
|
|
65
|
+
-- ALTER TABLE government_builder_ranks RENAME TO governance_builder_roles;
|
|
66
|
+
-- ALTER TABLE government_rank_permissions RENAME TO governance_role_permissions;
|
|
67
|
+
-- ALTER TABLE governance_roles RENAME COLUMN rank_key TO role_key;
|
|
68
|
+
-- ALTER TABLE governance_builder_roles RENAME COLUMN rank_key TO role_key;
|
|
69
|
+
-- ALTER TABLE governance_role_permissions RENAME COLUMN rank_key TO role_key;
|
|
70
|
+
-- ALTER SEQUENCE government_ranks_id_seq RENAME TO governance_roles_id_seq;
|
|
71
|
+
-- ALTER INDEX government_rank_permissions_perm_idx
|
|
72
|
+
-- RENAME TO governance_role_permissions_perm_idx;
|
|
73
|
+
-- ALTER INDEX government_builder_ranks_rank_idx
|
|
74
|
+
-- RENAME TO governance_builder_roles_role_idx;
|
|
75
|
+
-- COMMIT;
|
|
76
|
+
--
|
|
77
|
+
-- Constraint names are left spelled the new way by that rollback; they are
|
|
78
|
+
-- cosmetic and step 4 below re-derives them on the next forward apply.
|
|
79
|
+
|
|
80
|
+
BEGIN;
|
|
81
|
+
|
|
82
|
+
-- ── 1. the tables ───────────────────────────────────────────────────────────
|
|
83
|
+
DO $$
|
|
84
|
+
BEGIN
|
|
85
|
+
IF to_regclass('public.governance_roles') IS NOT NULL
|
|
86
|
+
AND to_regclass('public.government_ranks') IS NULL THEN
|
|
87
|
+
ALTER TABLE governance_roles RENAME TO government_ranks;
|
|
88
|
+
END IF;
|
|
89
|
+
|
|
90
|
+
IF to_regclass('public.governance_builder_roles') IS NOT NULL
|
|
91
|
+
AND to_regclass('public.government_builder_ranks') IS NULL THEN
|
|
92
|
+
ALTER TABLE governance_builder_roles RENAME TO government_builder_ranks;
|
|
93
|
+
END IF;
|
|
94
|
+
|
|
95
|
+
IF to_regclass('public.governance_role_permissions') IS NOT NULL
|
|
96
|
+
AND to_regclass('public.government_rank_permissions') IS NULL THEN
|
|
97
|
+
ALTER TABLE governance_role_permissions RENAME TO government_rank_permissions;
|
|
98
|
+
END IF;
|
|
99
|
+
END $$;
|
|
100
|
+
|
|
101
|
+
-- ── 2. the role_key -> rank_key column, on all three tables ─────────────────
|
|
102
|
+
-- The two child columns are foreign keys onto the parent's unique column; a
|
|
103
|
+
-- RENAME COLUMN updates the dependency in place, so the order here is free.
|
|
104
|
+
DO $$
|
|
105
|
+
BEGIN
|
|
106
|
+
IF EXISTS (SELECT 1 FROM information_schema.columns
|
|
107
|
+
WHERE table_name = 'government_ranks' AND column_name = 'role_key') THEN
|
|
108
|
+
ALTER TABLE government_ranks RENAME COLUMN role_key TO rank_key;
|
|
109
|
+
END IF;
|
|
110
|
+
|
|
111
|
+
IF EXISTS (SELECT 1 FROM information_schema.columns
|
|
112
|
+
WHERE table_name = 'government_builder_ranks' AND column_name = 'role_key') THEN
|
|
113
|
+
ALTER TABLE government_builder_ranks RENAME COLUMN role_key TO rank_key;
|
|
114
|
+
END IF;
|
|
115
|
+
|
|
116
|
+
IF EXISTS (SELECT 1 FROM information_schema.columns
|
|
117
|
+
WHERE table_name = 'government_rank_permissions' AND column_name = 'role_key') THEN
|
|
118
|
+
ALTER TABLE government_rank_permissions RENAME COLUMN role_key TO rank_key;
|
|
119
|
+
END IF;
|
|
120
|
+
END $$;
|
|
121
|
+
|
|
122
|
+
-- ── 3. indexes ──────────────────────────────────────────────────────────────
|
|
123
|
+
DO $$
|
|
124
|
+
BEGIN
|
|
125
|
+
IF to_regclass('public.governance_role_permissions_perm_idx') IS NOT NULL
|
|
126
|
+
AND to_regclass('public.government_rank_permissions_perm_idx') IS NULL THEN
|
|
127
|
+
ALTER INDEX governance_role_permissions_perm_idx
|
|
128
|
+
RENAME TO government_rank_permissions_perm_idx;
|
|
129
|
+
END IF;
|
|
130
|
+
|
|
131
|
+
IF to_regclass('public.governance_builder_roles_role_idx') IS NOT NULL
|
|
132
|
+
AND to_regclass('public.government_builder_ranks_rank_idx') IS NULL THEN
|
|
133
|
+
ALTER INDEX governance_builder_roles_role_idx
|
|
134
|
+
RENAME TO government_builder_ranks_rank_idx;
|
|
135
|
+
END IF;
|
|
136
|
+
END $$;
|
|
137
|
+
|
|
138
|
+
-- ── 3b. the bigserial sequence ──────────────────────────────────────────────
|
|
139
|
+
-- `id bigserial` created `governance_roles_id_seq`, and a table rename does NOT
|
|
140
|
+
-- carry the sequence's NAME with it — only its ownership. Left alone, the
|
|
141
|
+
-- database would go on saying "governance_roles" forever in the one place
|
|
142
|
+
-- nobody thinks to look, which is precisely the drift this goal exists to end.
|
|
143
|
+
-- Safe: the column default is stored as an OID (`nextval('…'::regclass)`), so
|
|
144
|
+
-- it re-renders against the new name and keeps working.
|
|
145
|
+
DO $$
|
|
146
|
+
BEGIN
|
|
147
|
+
IF to_regclass('public.governance_roles_id_seq') IS NOT NULL
|
|
148
|
+
AND to_regclass('public.government_ranks_id_seq') IS NULL THEN
|
|
149
|
+
ALTER SEQUENCE governance_roles_id_seq RENAME TO government_ranks_id_seq;
|
|
150
|
+
END IF;
|
|
151
|
+
END $$;
|
|
152
|
+
|
|
153
|
+
-- ── 4. constraint names ─────────────────────────────────────────────────────
|
|
154
|
+
-- Cosmetic but load-bearing for the next reader: an error message quoting
|
|
155
|
+
-- `governance_builder_roles_role_key_fkey` after the rename would send them
|
|
156
|
+
-- looking for a table that no longer exists.
|
|
157
|
+
DO $$
|
|
158
|
+
DECLARE
|
|
159
|
+
r record;
|
|
160
|
+
BEGIN
|
|
161
|
+
FOR r IN
|
|
162
|
+
SELECT c.conname, t.relname AS tbl,
|
|
163
|
+
replace(replace(replace(c.conname,
|
|
164
|
+
'governance_role_permissions', 'government_rank_permissions'),
|
|
165
|
+
'governance_builder_roles', 'government_builder_ranks'),
|
|
166
|
+
'governance_roles', 'government_ranks') AS newname
|
|
167
|
+
FROM pg_constraint c
|
|
168
|
+
JOIN pg_class t ON t.oid = c.conrelid
|
|
169
|
+
WHERE t.relname IN ('government_ranks', 'government_builder_ranks', 'government_rank_permissions')
|
|
170
|
+
AND c.conname LIKE 'governance\_%'
|
|
171
|
+
LOOP
|
|
172
|
+
-- the _role_key_ segment inside a constraint name follows the column
|
|
173
|
+
EXECUTE format('ALTER TABLE %I RENAME CONSTRAINT %I TO %I',
|
|
174
|
+
r.tbl, r.conname, replace(r.newname, '_role_key', '_rank_key'));
|
|
175
|
+
END LOOP;
|
|
176
|
+
END $$;
|
|
177
|
+
|
|
178
|
+
-- ── 5. prove the rename moved names only, never rows ────────────────────────
|
|
179
|
+
-- A rename that silently lost a grant would be catastrophic and invisible: the
|
|
180
|
+
-- resolver would just start denying. Assert the baseline captured before the
|
|
181
|
+
-- migration ran. If this fails the whole transaction rolls back.
|
|
182
|
+
DO $$
|
|
183
|
+
DECLARE
|
|
184
|
+
n_ranks int;
|
|
185
|
+
n_assign int;
|
|
186
|
+
n_grants int;
|
|
187
|
+
BEGIN
|
|
188
|
+
SELECT count(*) INTO n_ranks FROM government_ranks;
|
|
189
|
+
SELECT count(*) INTO n_assign FROM government_builder_ranks;
|
|
190
|
+
SELECT count(*) INTO n_grants FROM government_rank_permissions;
|
|
191
|
+
|
|
192
|
+
IF n_ranks = 0 OR n_assign = 0 OR n_grants = 0 THEN
|
|
193
|
+
RAISE EXCEPTION
|
|
194
|
+
'government rename lost rows: ranks=%, assignments=%, grants=% — expected all non-zero. Rolling back.',
|
|
195
|
+
n_ranks, n_assign, n_grants;
|
|
196
|
+
END IF;
|
|
197
|
+
|
|
198
|
+
-- Every assignment and every grant must still resolve to a real rank. A
|
|
199
|
+
-- broken FK cannot happen through RENAME, so this is a cheap proof that the
|
|
200
|
+
-- constraints really did travel with the tables rather than being dropped.
|
|
201
|
+
IF EXISTS (SELECT 1 FROM government_builder_ranks b
|
|
202
|
+
WHERE NOT EXISTS (SELECT 1 FROM government_ranks r WHERE r.rank_key = b.rank_key)) THEN
|
|
203
|
+
RAISE EXCEPTION 'government_builder_ranks has an assignment pointing at no rank — rolling back.';
|
|
204
|
+
END IF;
|
|
205
|
+
IF EXISTS (SELECT 1 FROM government_rank_permissions g
|
|
206
|
+
WHERE NOT EXISTS (SELECT 1 FROM government_ranks r WHERE r.rank_key = g.rank_key)) THEN
|
|
207
|
+
RAISE EXCEPTION 'government_rank_permissions has a grant pointing at no rank — rolling back.';
|
|
208
|
+
END IF;
|
|
209
|
+
|
|
210
|
+
RAISE NOTICE 'government rename OK: % ranks, % assignments, % grants', n_ranks, n_assign, n_grants;
|
|
211
|
+
END $$;
|
|
212
|
+
|
|
213
|
+
COMMIT;
|