@bongos/core 1.19.182 → 1.19.184
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 +81 -76
- package/docs/api/openapi.json +9 -9
- package/modules/{governance → government}/catalog.js +1 -1
- package/modules/{governance → government}/config.js +6 -6
- package/modules/{governance → government}/db.js +43 -43
- package/modules/government/migrations/government_001_rename_from_governance.sql +213 -0
- package/modules/government/module.json +22 -0
- package/modules/{governance → government}/protected-surfaces.json +2 -1
- package/modules/{governance/rank-roles.js → government/ranks.js} +9 -9
- package/modules/{governance → government}/routes/governance.js +5 -5
- package/modules/{governance/routes/role-sync.js → government/routes/rank-sync.js} +7 -7
- package/modules/lifecycle/db.js +6 -6
- 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 +5 -3
- package/scripts/gds/seed-governance-tasks.js +2 -2
- package/src/bongos/auth.js +9 -9
- package/src/bongos/db.js +6 -6
- package/src/bongos/module-scope-map.js +22 -1
- package/src/bongos/permission-path-check.js +3 -3
- package/src/bongos/route-rank-check.js +40 -21
- package/src/module-api.js +6 -2
- package/tests/auth_page_gate.mjs +2 -2
- package/tests/criterion_authoring_gate.mjs +1 -1
- package/tests/governance_abuse_matrix.mjs +7 -7
- package/tests/governance_catalog.mjs +1 -1
- 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_live_proof.mjs +2 -2
- package/tests/governance_ownership_scope.mjs +1 -1
- package/tests/governance_page_gates.mjs +6 -6
- package/tests/governance_parity.mjs +3 -3
- package/tests/governance_principals.mjs +1 -1
- package/tests/governance_protected_surfaces.mjs +63 -7
- package/tests/governance_require_permission.mjs +6 -6
- package/tests/governance_resolver.mjs +2 -2
- package/tests/governance_role_sync.mjs +14 -14
- package/tests/governance_routes.mjs +6 -6
- package/tests/governance_seed.mjs +11 -3
- package/tests/governance_session_scope.mjs +1 -1
- package/tests/governance_vanilla_boot.mjs +4 -4
- package/tests/hall_page_gate_map.mjs +1 -1
- 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
|
@@ -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,7 +32,7 @@ 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 role_key FROM
|
|
35
|
+
`SELECT rank_key AS role_key FROM government_builder_ranks WHERE builder_id = $1 ORDER BY rank_key`,
|
|
36
36
|
[builderId],
|
|
37
37
|
);
|
|
38
38
|
return rows.map((r) => r.role_key);
|
|
@@ -41,7 +41,7 @@ async function getRoleKeysForBuilder(builderId) {
|
|
|
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 role_key, seeded_rank, is_system
|
|
61
|
-
FROM
|
|
60
|
+
`SELECT rank_key AS role_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.role_key, r.is_system, r.seeded_rank
|
|
72
|
-
FROM
|
|
73
|
-
JOIN
|
|
71
|
+
`SELECT br.rank_key AS role_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.role_key, r.label, r.description, r.is_system, r.seeded_rank,
|
|
130
|
+
`SELECT r.rank_key AS role_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 role_key, label, description, is_system, seeded_rank, created_by, created_at
|
|
145
|
-
FROM
|
|
146
|
-
WHERE
|
|
144
|
+
`SELECT rank_key AS role_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 role_key, label, description, is_system, seeded_rank, created_by, created_at`,
|
|
156
|
+
RETURNING rank_key AS role_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.role_key, r.label AS role_label, r.is_system, r.seeded_rank,
|
|
213
|
+
br.rank_key AS role_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;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"key": "government",
|
|
3
|
+
"title": "Government (configurable ranks & permissions)",
|
|
4
|
+
"description": "Discord-style configurable ranks and permissions (ADR 0151, BONGOS-V1 goal 1000030; renamed governance\u2192government by ADR 0174). Permissions are the atomic authority unit and the four ranks (xenos/thetes/metic/archon) are the positions in the government; this module owns the code-defined permission catalog (each key flagged `system` = trust-boundary, `substrate` = enforced out of band, or operational = delegable) plus the compatibility seed that reproduces the rank thresholds exactly. A core-domain module, default-on: a Cloud Bongos instance ships configurable ranks the way it ships its status page. The word `role` is deliberately absent here \u2014 it is reserved for the three crafts (Artist, Ideator, Builder). Preserves ADR 0016 (authority resolved server-side, per request, uncached).",
|
|
5
|
+
"coreVersion": "^1.18.0",
|
|
6
|
+
"default": true,
|
|
7
|
+
"maintenance": {
|
|
8
|
+
"status": "core-maintained"
|
|
9
|
+
},
|
|
10
|
+
"contributes": {
|
|
11
|
+
"migrations": true,
|
|
12
|
+
"routes": [
|
|
13
|
+
"governance",
|
|
14
|
+
"rank-sync"
|
|
15
|
+
]
|
|
16
|
+
},
|
|
17
|
+
"provides": [
|
|
18
|
+
"government",
|
|
19
|
+
"government.rankSync"
|
|
20
|
+
],
|
|
21
|
+
"consumes": []
|
|
22
|
+
}
|
|
@@ -34,7 +34,8 @@
|
|
|
34
34
|
{ "glob": "modules/lifecycle/routes/", "group": "authz", "why": "BV1.R86: the lifecycle routes carved out of src/bongos/routes/ — the gates follow the routes" },
|
|
35
35
|
{ "glob": "src/bongos/routes.js", "group": "authz", "why": "the route MOUNT table (a new ungated route lands here)" },
|
|
36
36
|
{ "glob": "server.js", "group": "authz", "why": "the Express entrypoint — wires every router + host routing" },
|
|
37
|
-
{ "glob": "modules/governance/", "group": "authz", "why": "the
|
|
37
|
+
{ "glob": "modules/governance/", "group": "authz", "why": "the PRE-ADR-0174 path of the authz core. The folder itself moved to modules/government/ in goal 1000068 R03/R04, so nothing matches this glob in the core tree any more — it is kept for the one-release compatibility window because an in-flight branch or a vendored instance checkout may still carry the old path, and a protected path that silently stops being protected mid-rename is the failure this whole sequence exists to avoid. Dropped by R05 (task 1003006) with the rest of the compatibility surface." },
|
|
38
|
+
{ "glob": "modules/government/", "group": "authz", "why": "the same authz core under its post-ADR-0174 name (goal 1000068, task 1003003). Listed BEFORE the folder moves, deliberately: `protected` is DERIVED from this registry, so a module key whose globs are absent here computes protected:false — which would silently drop the Archon gate on goal creation AND the pre-push block on these files for the whole window between the key landing and the folder arriving. The prospective-carve precedent is the modules/governance/ entry directly above (ADR 0086 §4)." },
|
|
38
39
|
{ "glob": "modules/dev-box/boxes.js", "group": "box-access", "why": "boxScopeForRank / provision floor — decides who gets a full source clone" },
|
|
39
40
|
{ "glob": "modules/dev-box/box-access.js", "group": "box-access", "why": "decideSourceAccess (rank → starter|full source)" },
|
|
40
41
|
{ "glob": "modules/dev-box/box-credential.js", "group": "box-access", "why": "mints the box source credential" },
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// modules/governance/
|
|
3
|
+
// modules/governance/ranks.js — the PURE rank→role rotation decision
|
|
4
4
|
// (ADR 0151, BV1.R95b).
|
|
5
5
|
//
|
|
6
6
|
// A builder holds exactly ONE seeded rank-role. governance_002 seeds four of them
|
|
7
7
|
// (is_system=true, seeded_rank set) and backfills each builder the one matching
|
|
8
8
|
// their rank — but that backfill is run-once, so nothing kept it true afterwards.
|
|
9
|
-
// This is the decision the writer (db.
|
|
9
|
+
// This is the decision the writer (db.syncRank) executes to keep it true:
|
|
10
10
|
// given a builder's CURRENT assignments and a rank, which seeded role to ADD and
|
|
11
11
|
// which to REMOVE.
|
|
12
12
|
//
|
|
@@ -31,18 +31,18 @@
|
|
|
31
31
|
// Pure: no DB, no I/O, no module state. The writer reads the two row sets and
|
|
32
32
|
// executes this verdict, so the unit tests exercise production logic directly.
|
|
33
33
|
|
|
34
|
-
// A row (an assignment joined to
|
|
34
|
+
// A row (an assignment joined to government_ranks, or a role row) is a SEEDED
|
|
35
35
|
// rank-role only when it is both a system role and names the rank it reproduces.
|
|
36
36
|
// Both halves are required: is_system alone would admit a future non-rank system
|
|
37
37
|
// role, and seeded_rank alone would trust a column a custom role could carry.
|
|
38
|
-
function
|
|
38
|
+
function isSeededRank(row) {
|
|
39
39
|
return !!row
|
|
40
40
|
&& row.is_system === true
|
|
41
41
|
&& typeof row.seeded_rank === 'string'
|
|
42
42
|
&& row.seeded_rank.length > 0;
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
//
|
|
45
|
+
// planRankSync({ rank, currentRoles, seededRoles }) → { target, add, remove, next }
|
|
46
46
|
//
|
|
47
47
|
// rank the builder's rank AFTER the write being synced.
|
|
48
48
|
// currentRoles the builder's assignments, each { role_key, is_system, seeded_rank }.
|
|
@@ -55,13 +55,13 @@ function isSeededRankRole(row) {
|
|
|
55
55
|
// add [] or [target] — empty when it is already held (idempotence).
|
|
56
56
|
// remove every OTHER seeded rank-role currently held. Custom roles never appear.
|
|
57
57
|
// next the resulting role_key set, sorted — "what the role set becomes".
|
|
58
|
-
function
|
|
59
|
-
const match = seededRoles.find((r) =>
|
|
58
|
+
function planRankSync({ rank, currentRoles = [], seededRoles = [] } = {}) {
|
|
59
|
+
const match = seededRoles.find((r) => isSeededRank(r) && r.seeded_rank === rank);
|
|
60
60
|
const target = match ? match.role_key : null;
|
|
61
61
|
|
|
62
62
|
const held = currentRoles.filter((r) => r && typeof r.role_key === 'string');
|
|
63
63
|
const remove = held
|
|
64
|
-
.filter((r) =>
|
|
64
|
+
.filter((r) => isSeededRank(r) && r.role_key !== target)
|
|
65
65
|
.map((r) => r.role_key);
|
|
66
66
|
const add = target && !held.some((r) => r.role_key === target) ? [target] : [];
|
|
67
67
|
|
|
@@ -80,4 +80,4 @@ function planRankRoleSync({ rank, currentRoles = [], seededRoles = [] } = {}) {
|
|
|
80
80
|
});
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
-
module.exports = {
|
|
83
|
+
module.exports = { planRankSync, isSeededRank };
|
|
@@ -81,8 +81,8 @@ const governancePort = {
|
|
|
81
81
|
};
|
|
82
82
|
|
|
83
83
|
function registerSeams() {
|
|
84
|
-
if (!api.hasProvider('
|
|
85
|
-
api.registerProvider('
|
|
84
|
+
if (!api.hasProvider('government')) {
|
|
85
|
+
api.registerProvider('government', governancePort);
|
|
86
86
|
}
|
|
87
87
|
// The session-load fold (task 1002556): the kernel's lookupSession embeds this
|
|
88
88
|
// fragment so the SAME round-trip carries the builder's raw grant keys, and
|
|
@@ -96,8 +96,8 @@ function registerSeams() {
|
|
|
96
96
|
api.registerProvider('session-grants-sql', {
|
|
97
97
|
selectFragment: `,
|
|
98
98
|
(SELECT COALESCE(array_agg(DISTINCT rp.permission_key), '{}')
|
|
99
|
-
FROM
|
|
100
|
-
JOIN
|
|
99
|
+
FROM government_builder_ranks br
|
|
100
|
+
JOIN government_rank_permissions rp ON rp.role_key = br.role_key
|
|
101
101
|
WHERE br.builder_id = s.builder_id) AS raw_permission_keys`,
|
|
102
102
|
});
|
|
103
103
|
}
|
|
@@ -116,7 +116,7 @@ function registerSeams() {
|
|
|
116
116
|
// for different reasons and only one of those reasons still holds:
|
|
117
117
|
//
|
|
118
118
|
// • GRANTS on the three sub-Archon rank-roles are now EDITABLE. Their rows were
|
|
119
|
-
// always ordinary `
|
|
119
|
+
// always ordinary `government_rank_permissions` rows (the governance_002 seed
|
|
120
120
|
// writes them); the old refusal was policy, not structure. What a rank means is
|
|
121
121
|
// now the owner's to set from the tab, and `catalog.isGrantableTo()` answers
|
|
122
122
|
// which keys per role. The ARCHON rank-role stays immutable — it holds
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// • this one is resolved by exactly three writers (the two _applyRankChangeTx
|
|
12
12
|
// twins + the sign-in path) and is a TRANSACTION PARTICIPANT — it takes the
|
|
13
13
|
// caller's client so the role rotation commits or rolls back with the rank write.
|
|
14
|
-
// The port name is namespaced (`
|
|
14
|
+
// The port name is namespaced (`government.rankSync`) like the `ideas.capture` /
|
|
15
15
|
// `art.getBuilderGeminiKeyRow` / `grade.builderRollingAvgGrade` ports.
|
|
16
16
|
//
|
|
17
17
|
// Registered from a route factory for the same reason routes/governance.js is:
|
|
@@ -28,16 +28,16 @@ const express = require('express');
|
|
|
28
28
|
const api = require('../../../src/module-api');
|
|
29
29
|
const db = require('../db');
|
|
30
30
|
|
|
31
|
-
// DELEGATE, never spread — a test that stubs db.
|
|
31
|
+
// DELEGATE, never spread — a test that stubs db.syncRank must still bite at call
|
|
32
32
|
// time (the modules/onboarding/port.js precedent; a spread captures the original
|
|
33
33
|
// reference at module-load).
|
|
34
|
-
const
|
|
35
|
-
|
|
34
|
+
const rankSyncPort = {
|
|
35
|
+
syncRank: (...a) => db.syncRank(...a),
|
|
36
36
|
};
|
|
37
37
|
|
|
38
38
|
function registerSeams() {
|
|
39
|
-
if (!api.hasProvider('
|
|
40
|
-
api.registerProvider('
|
|
39
|
+
if (!api.hasProvider('government.rankSync')) {
|
|
40
|
+
api.registerProvider('government.rankSync', rankSyncPort);
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
@@ -46,4 +46,4 @@ module.exports = function buildGovernanceRoleSyncRouter() {
|
|
|
46
46
|
return express.Router();
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
-
module.exports.
|
|
49
|
+
module.exports.rankSyncPort = rankSyncPort;
|