@bongos/core 1.19.157 → 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.
@@ -423,21 +423,40 @@ async function upsertActivity({ clientId, githubId, credits = 0, tasksShipped =
423
423
  // The scouting rollup: one row PER BUILDER, aggregated across every project they
424
424
  // build on. Joined to the account for display. Ranked by total credits then ships —
425
425
  // a talent signal, NOT an authority. Powers the Archon-only scouting view (#1002114).
426
+ //
427
+ // The scalar aggregates MUST see exactly one row per (builder, project): joining
428
+ // unnest(disciplines) before GROUP BY multiplied every activity row by the number
429
+ // of disciplines on it, inflating project_count and the credits/ships/karma sums
430
+ // (task 1002911). So scalars aggregate in `agg` over the un-fanned table, and the
431
+ // disciplines union is computed separately in `disc`. Proven against real SQL by
432
+ // tests/scouting_rollup_fanout.mjs.
426
433
  async function listScoutingRollup({ limit = 100, pool = defaultPool } = {}) {
427
434
  const { rows } = await pool.query(
428
- `SELECT a.github_id,
435
+ `WITH agg AS (
436
+ SELECT a.github_id,
437
+ count(*)::int AS project_count,
438
+ sum(a.credits)::int AS total_credits,
439
+ sum(a.tasks_shipped)::int AS total_tasks_shipped,
440
+ sum(a.karma)::int AS total_karma,
441
+ max(a.last_active) AS last_active
442
+ FROM platform_identity_builder_project_activity a
443
+ GROUP BY a.github_id
444
+ ), disc AS (
445
+ SELECT x.github_id, array_agg(DISTINCT x.d) AS disciplines
446
+ FROM (SELECT a.github_id, unnest(a.disciplines) AS d
447
+ FROM platform_identity_builder_project_activity a) x
448
+ WHERE x.d IS NOT NULL
449
+ GROUP BY x.github_id
450
+ )
451
+ SELECT agg.github_id,
429
452
  acc.github_login, acc.display_name, acc.avatar_url,
430
- count(*)::int AS project_count,
431
- sum(a.credits)::int AS total_credits,
432
- sum(a.tasks_shipped)::int AS total_tasks_shipped,
433
- sum(a.karma)::int AS total_karma,
434
- max(a.last_active) AS last_active,
435
- array_agg(DISTINCT d) FILTER (WHERE d IS NOT NULL) AS disciplines
436
- FROM platform_identity_builder_project_activity a
437
- LEFT JOIN platform_identity_accounts acc ON acc.github_id = a.github_id
438
- LEFT JOIN LATERAL unnest(a.disciplines) AS d ON true
439
- GROUP BY a.github_id, acc.github_login, acc.display_name, acc.avatar_url
440
- ORDER BY total_credits DESC, total_tasks_shipped DESC
453
+ agg.project_count, agg.total_credits, agg.total_tasks_shipped,
454
+ agg.total_karma, agg.last_active,
455
+ disc.disciplines
456
+ FROM agg
457
+ LEFT JOIN platform_identity_accounts acc ON acc.github_id = agg.github_id
458
+ LEFT JOIN disc ON disc.github_id = agg.github_id
459
+ ORDER BY agg.total_credits DESC, agg.total_tasks_shipped DESC
441
460
  LIMIT $1`,
442
461
  [limit],
443
462
  );
@@ -342,10 +342,27 @@ await ta('listScoutingRollup aggregates per builder across projects, ranked, joi
342
342
  assert.match(c.text, /FROM platform_identity_builder_project_activity a/);
343
343
  assert.match(c.text, /GROUP BY a\.github_id/);
344
344
  assert.match(c.text, /sum\(a\.credits\)/);
345
- assert.match(c.text, /ORDER BY total_credits DESC/);
345
+ assert.match(c.text, /ORDER BY agg\.total_credits DESC, agg\.total_tasks_shipped DESC/);
346
346
  assert.deepEqual(c.params, [50]);
347
347
  });
348
348
 
349
+ await ta('listScoutingRollup scalar aggregation is fan-out-free (task 1002911)', async () => {
350
+ // The old query LEFT JOIN LATERAL unnest(a.disciplines)'d BEFORE GROUP BY, so
351
+ // sums/counts multiplied by the discipline count. Structural guard: the agg CTE
352
+ // (the scalar sums) must contain no unnest/LATERAL; unnest may appear only in
353
+ // the disc CTE. Real-SQL proof of the semantics: tests/scouting_rollup_fanout.mjs.
354
+ const pool = mockPool([{ rows: [] }]);
355
+ await P.listScoutingRollup({ pool, limit: 10 });
356
+ const sql = pool.calls[0].text;
357
+ assert.doesNotMatch(sql, /LATERAL/i, 'no lateral join anywhere in the rollup');
358
+ const aggCte = sql.slice(sql.indexOf('WITH agg AS'), sql.indexOf('disc AS'));
359
+ assert.match(aggCte, /sum\(a\.credits\)/, 'scalar sums live in the agg CTE');
360
+ assert.doesNotMatch(aggCte, /unnest/i, 'the scalar-aggregation CTE must not fan rows out');
361
+ const discCte = sql.slice(sql.indexOf('disc AS'), sql.indexOf('SELECT agg.github_id'));
362
+ assert.match(discCte, /unnest\(a\.disciplines\)/, 'disciplines union is computed separately');
363
+ assert.match(discCte, /array_agg\(DISTINCT x\.d\)/, 'disciplines are deduped across projects');
364
+ });
365
+
349
366
  console.log('\nauthorization codes:');
350
367
 
351
368
  await ta('mintAuthCode stores the HASH of the code (never the plaintext) + binds it', async () => {
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.157",
3
+ "version": "1.19.159",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.157",
9
+ "version": "1.19.159",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.157",
3
+ "version": "1.19.159",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -76,7 +76,7 @@ const INTEGRATION = new Set([
76
76
  'claim_gate_open_blocker', 'claim_gate_rebase', 'clawback_confirmed_backlog', 'discord_autojoin', 'discord_oauth',
77
77
  'governance_fold_bench', 'governance_live_proof', 'linkify_refs', 'memory_isolation',
78
78
  'memory_pull', 'needs_rebase', 'parallel-load', 'reward_mode_cost_plus_only',
79
- 'session_records', 'setup_smoke',
79
+ 'scouting_rollup_fanout', 'session_records', 'setup_smoke',
80
80
  'status-probe',
81
81
  ]);
82
82
 
@@ -414,6 +414,7 @@ const MODULE_EXPECTED_RANKS = {
414
414
  'POST /governance/roles': 'perm:governance.manage',
415
415
  'PUT /governance/roles/:roleKey/permissions/:permissionKey': 'perm:governance.manage',
416
416
  'DELETE /governance/roles/:roleKey/permissions/:permissionKey': 'perm:governance.manage',
417
+ 'POST /governance/roles/:roleKey/reset': 'perm:governance.manage',
417
418
  'POST /governance/assignments': 'perm:governance.manage',
418
419
  'DELETE /governance/assignments/:builderId/:roleKey': 'perm:governance.manage',
419
420
  },
package/src/module-api.js CHANGED
@@ -741,8 +741,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
741
741
  // landed since 1.19.155 with no explicit bump. run 31957976979. (task 1002620)
742
742
  // 1.19.157 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
743
743
  // landed since 1.19.156 with no explicit bump. run 31958139486. (task 1002620)
744
+ // 1.19.158 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
745
+ // landed since 1.19.157 with no explicit bump. run 31958753412. (task 1002620)
746
+ // 1.19.159 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
747
+ // landed since 1.19.158 with no explicit bump. run 31958999646. (task 1002620)
744
748
  // ---------------------------------------------------------------------------
745
- const CORE_VERSION = '1.19.157'; // CI auto-patch carrier (ADR 0161); see changelog above
749
+ const CORE_VERSION = '1.19.159'; // CI auto-patch carrier (ADR 0161); see changelog above
746
750
 
747
751
  // A namespaced logger so a module's log lines are attributable + consistent.
748
752
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -63,9 +63,19 @@ const SYSTEM_KEYS = catalog.systemKeys();
63
63
  const SEEDED_ROLES = catalog.RANK_ORDER; // xenos/thetes/metic/archon
64
64
 
65
65
  // The keys whose enforcement lives OUTSIDE the app entirely (git pre-push hook,
66
- // grader pre-pass, post-push main audit, credential placement — ADR 0043). The
67
- // task names deploy + protected-path; infra.ssh is the same substrate class.
68
- const SUBSTRATE_KEYS = ['deploy.push_main', 'path.protected.modify', 'infra.ssh'];
66
+ // grader pre-pass, post-push main audit, credential placement — ADR 0043). Read
67
+ // from the CATALOG since ADR 0172 made the class data: enumerating rather than
68
+ // listing means a fourth substrate key is covered by every vector below the day it
69
+ // lands. The three named ones are asserted present so the marker can't be quietly
70
+ // dropped from a row and take the coverage with it.
71
+ const SUBSTRATE_KEYS = catalog.substrateKeys();
72
+ const NAMED_SUBSTRATE = ['deploy.push_main', 'path.protected.modify', 'infra.ssh'];
73
+
74
+ // The rank-roles ADR 0172 made editable — everything below the top of the ladder.
75
+ const EDITABLE_RANK_ROLES = catalog.RANK_ORDER.filter((r) => r !== 'archon');
76
+ // The in-app Archon powers: system, but a real route reads them, so they CAN be
77
+ // delegated to a rank-role. Derived as system-minus-substrate-minus-principal.
78
+ const OWNER_POWERS = SYSTEM_KEYS.filter((k) => !catalog.isSubstrate(k) && !catalog.isPrincipalKey(k));
69
79
 
70
80
  // ===========================================================================
71
81
  // The matrix rows — sanity on what is being enumerated
@@ -73,12 +83,15 @@ const SUBSTRATE_KEYS = ['deploy.push_main', 'path.protected.modify', 'infra.ssh'
73
83
 
74
84
  test('the matrix enumerates a non-empty system set including the named powers', () => {
75
85
  assert.ok(SYSTEM_KEYS.length > 0, 'no system keys — the matrix would be vacuous');
76
- for (const k of SUBSTRATE_KEYS) {
86
+ for (const k of NAMED_SUBSTRATE) {
87
+ assert.ok(SUBSTRATE_KEYS.includes(k), `${k} must still carry substrate:true — dropping the marker would silently narrow every vector below`);
77
88
  assert.ok(SYSTEM_KEYS.includes(k), `${k} must be system:true`);
78
89
  }
79
90
  for (const k of SYSTEM_KEYS) {
80
- assert.equal(catalog.isGrantable(k), false, `${k} must be non-grantable`);
91
+ assert.equal(catalog.isGrantable(k), false, `${k} must be non-grantable to a CUSTOM role`);
81
92
  }
93
+ assert.ok(OWNER_POWERS.length > 0, 'no in-app owner powers — V2c would be vacuous');
94
+ assert.ok(EDITABLE_RANK_ROLES.length > 0, 'no editable rank-roles — V2b/V2c would be vacuous');
82
95
  });
83
96
 
84
97
  test('every system permission floors at archon or is a principal (never seeded lower)', () => {
@@ -101,6 +114,7 @@ const stub = {
101
114
  createRole: async (a) => ({ role_key: a.roleKey, label: a.label, description: a.description ?? null, is_system: false, seeded_rank: null, created_by: a.createdBy, created_at: 't' }),
102
115
  grantRolePermission: async () => true,
103
116
  revokeRolePermission: async () => true,
117
+ resetRoleGrants: async ({ permissionKeys }) => permissionKeys.length,
104
118
  assignRole: async () => true,
105
119
  unassignRole: async () => true,
106
120
  listRolesWithGrants: async () => [],
@@ -162,15 +176,67 @@ test('V1: EVERY system permission is refused onto a custom role, before the db',
162
176
  }
163
177
  });
164
178
 
165
- test('V2: EVERY seeded rank-role refuses EVERY grant (system and operational alike)', async () => {
166
- for (const role of SEEDED_ROLES) {
167
- for (const k of [...SYSTEM_KEYS, 'idea.triage']) {
179
+ // ADR 0172 split the old single V2 ("every seeded rank-role refuses every grant")
180
+ // into three, because two thirds of that wall stayed and one third was deliberately
181
+ // removed. Asserting the removed third is as important as asserting the kept ones:
182
+ // it is what stops a later "tighten the governance routes" refactor from quietly
183
+ // reinstating the wall the owner asked to take down.
184
+
185
+ test('V2a: the ARCHON rank-role refuses EVERY write — grant, revoke and reset alike', async () => {
186
+ for (const k of [...SYSTEM_KEYS, 'idea.triage']) {
187
+ for (const method of ['PUT', 'DELETE']) {
188
+ const r = await call(method, `/governance/roles/archon/permissions/${k}`);
189
+ assert.equal(r.status, 403, `archon ← ${method} ${k} must be refused`);
190
+ assert.equal(r.json.error.code, 'archon_role_immutable', `archon ← ${method} ${k}`);
191
+ assert.equal(calls.filter(([c]) => c === 'grantRolePermission' || c === 'revokeRolePermission').length, 0);
192
+ }
193
+ }
194
+ const r = await call('POST', '/governance/roles/archon/reset');
195
+ assert.equal(r.status, 403, 'reset must not be a back door into the archon role');
196
+ assert.equal(r.json.error.code, 'archon_role_immutable');
197
+ assert.equal(calls.filter(([c]) => c === 'resetRoleGrants').length, 0);
198
+ });
199
+
200
+ test('V2b: EVERY editable rank-role still refuses every SUBSTRATE and PRINCIPAL key', async () => {
201
+ const neverGrantable = [...SUBSTRATE_KEYS, ...catalog.principalKeys()];
202
+ for (const role of EDITABLE_RANK_ROLES) {
203
+ for (const k of neverGrantable) {
204
+ const r = await call('PUT', `/governance/roles/${role}/permissions/${k}`);
205
+ assert.equal(r.status, 400, `${role} ← ${k} must be refused`);
206
+ assert.equal(r.json.error.code, 'permission_not_grantable', `${role} ← ${k}`);
207
+ assert.equal(calls.filter(([c]) => c === 'grantRolePermission').length, 0,
208
+ `${role} ← ${k}: the refusal must land BEFORE any db write`);
209
+ }
210
+ }
211
+ });
212
+
213
+ test('V2c: an editable rank-role ACCEPTS the in-app owner powers (the ADR 0172 lever)', async () => {
214
+ for (const role of EDITABLE_RANK_ROLES) {
215
+ for (const k of [...OWNER_POWERS, 'idea.triage']) {
168
216
  const r = await call('PUT', `/governance/roles/${role}/permissions/${k}`);
169
- assert.equal(r.status, 403, `${role} ← ${k} must be refused`);
170
- assert.equal(r.json.error.code, 'seeded_role_immutable', `${role} ← ${k}`);
171
- assert.equal(calls.filter(([c]) => c === 'grantRolePermission').length, 0);
217
+ assert.equal(r.status, 200, `${role} ← ${k} must be ACCEPTED — this delegation is the owner's decision (ADR 0172)`);
218
+ assert.equal(calls.filter(([c]) => c === 'grantRolePermission').length, 1, `${role} ← ${k} must reach the db`);
219
+ }
220
+ }
221
+ });
222
+
223
+ test('V2d: reset restores the CATALOG seed, never anything wider', async () => {
224
+ for (const role of EDITABLE_RANK_ROLES) {
225
+ const r = await call('POST', `/governance/roles/${role}/reset`);
226
+ assert.equal(r.status, 200, `${role} must be resettable`);
227
+ const [, args] = calls.find(([c]) => c === 'resetRoleGrants');
228
+ assert.deepEqual([...args.permissionKeys].sort(), [...catalog.seedForRank(role)].sort(),
229
+ `${role} reset must write exactly the floor-derived seed`);
230
+ for (const k of args.permissionKeys) {
231
+ assert.equal(catalog.isSystem(k), false, `${role} reset wrote system key ${k} — reset must never widen`);
172
232
  }
173
233
  }
234
+ // A custom role has no catalog default, so reset is refused rather than silently
235
+ // clearing its grants (which would be a destructive verb wearing a restore's name).
236
+ const r = await call('POST', '/governance/roles/custom-role/reset');
237
+ assert.equal(r.status, 400);
238
+ assert.equal(r.json.error.code, 'role_has_no_default');
239
+ assert.equal(calls.filter(([c]) => c === 'resetRoleGrants').length, 0);
174
240
  });
175
241
 
176
242
  test('V3: EVERY seeded rank-role refuses assignment (rank is the only writer — R95b)', async () => {
@@ -213,6 +279,7 @@ test('V5: a caller without governance.manage cannot reach ANY write vector at al
213
279
  ['POST', '/governance/roles', { role_key: 'x-role', label: 'X' }],
214
280
  ['PUT', '/governance/roles/custom-role/permissions/deploy.push_main', undefined],
215
281
  ['DELETE', '/governance/roles/custom-role/permissions/idea.triage', undefined],
282
+ ['POST', '/governance/roles/metic/reset', undefined],
216
283
  ['POST', '/governance/assignments', { builder_id: 7, role_key: 'custom-role' }],
217
284
  ['DELETE', '/governance/assignments/7/custom-role', undefined],
218
285
  ]) {
@@ -46,6 +46,57 @@ test('isGrantable: operational→true, system→false, unknown→false', () => {
46
46
  assert.equal(isSystem('nope.not.a.key'), null); // unknown → null (caller fails closed)
47
47
  });
48
48
 
49
+ // ── the substrate axis + per-role grantability (ADR 0172) ────────────────────
50
+
51
+ test('substrate keys are the out-of-band three, and each implies system + archon floor', () => {
52
+ const sub = cat.substrateKeys();
53
+ assert.deepEqual([...sub].sort(), ['deploy.push_main', 'infra.ssh', 'path.protected.modify']);
54
+ for (const k of sub) {
55
+ assert.equal(cat.isSubstrate(k), true);
56
+ assert.equal(isSystem(k), true, `${k} must be system:true`);
57
+ assert.equal(byKey(k).floor, 'archon', `${k} must floor at archon`);
58
+ assert.equal(byKey(k).principal, null, `${k} must not also be a principal key`);
59
+ }
60
+ assert.equal(cat.isSubstrate('governance.manage'), false, 'an in-app archon power is not substrate');
61
+ assert.equal(cat.isSubstrate('nope.not.a.key'), false, 'unknown fails closed');
62
+ });
63
+
64
+ test('isGrantableTo: a sub-Archon rank-role takes owner powers, a custom role does not', () => {
65
+ const metic = { is_system: true, seeded_rank: 'metic' };
66
+ const archon = { is_system: true, seeded_rank: 'archon' };
67
+ const custom = { is_system: false, seeded_rank: null };
68
+
69
+ // The widening: an in-app archon power reaches a rank-role, never a custom one.
70
+ for (const k of ['governance.manage', 'builder.rank.set', 'version.close']) {
71
+ assert.equal(cat.isGrantableTo(k, metic), true, `${k} → metic rank-role`);
72
+ assert.equal(cat.isGrantableTo(k, custom), false, `${k} → custom role must stay refused`);
73
+ }
74
+ // Operational keys reach both.
75
+ assert.equal(cat.isGrantableTo('idea.triage', metic), true);
76
+ assert.equal(cat.isGrantableTo('idea.triage', custom), true);
77
+ // The two walls that did NOT move.
78
+ for (const k of [...cat.substrateKeys(), ...cat.principalKeys()]) {
79
+ assert.equal(cat.isGrantableTo(k, metic), false, `${k} must stay non-grantable to every role`);
80
+ assert.equal(cat.isGrantableTo(k, custom), false, `${k} must stay non-grantable to every role`);
81
+ }
82
+ // The Archon role takes nothing — it already holds everything, and that
83
+ // immutability is what makes lockout unreachable from the tab.
84
+ for (const k of permissionKeys()) {
85
+ assert.equal(cat.isGrantableTo(k, archon), false, `${k} → archon role must be refused`);
86
+ }
87
+ });
88
+
89
+ test('isGrantableTo fails closed on every unrecognised input', () => {
90
+ assert.equal(cat.isGrantableTo('idea.triage', null), false);
91
+ assert.equal(cat.isGrantableTo('idea.triage', undefined), false);
92
+ assert.equal(cat.isGrantableTo('idea.triage', 'metic'), false, 'a bare string is not a role row');
93
+ assert.equal(cat.isGrantableTo('nope.not.a.key', { is_system: true, seeded_rank: 'metic' }), false);
94
+ // A system role claiming a rank the ladder does not define resolves to nothing
95
+ // rather than to "not archon, therefore editable".
96
+ assert.equal(cat.isGrantableTo('idea.triage', { is_system: true, seeded_rank: 'wizard' }), false);
97
+ assert.equal(cat.isGrantableTo('idea.triage', { is_system: true, seeded_rank: null }), false);
98
+ });
99
+
49
100
  test('the four rank-roles exist; seed keys all reference real permissions', () => {
50
101
  assert.deepEqual(Object.keys(RANK_ROLE_SEED).sort(), [...RANK_ORDER].sort());
51
102
  const all = new Set(permissionKeys());
@@ -11,11 +11,13 @@
11
11
  //
12
12
  // 2. Real in-process HTTP through the actual router: express app + the real
13
13
  // requirePermission gate resolving a STUB `governance` port on the seam,
14
- // db sibling monkeypatched by property. What matters (ADR 0151 §2):
15
- // system permissions are rejected as NON-GRANTABLE server-side, seeded
16
- // rank-roles are immutable through this API (their grants derive from
17
- // floors; their assignment derives from rank — R95b), and every route
18
- // denies without the `governance.manage` grant.
14
+ // db sibling monkeypatched by property. What matters (ADR 0151 §2, as
15
+ // amended by ADR 0172): a system permission is rejected as NON-GRANTABLE to
16
+ // a CUSTOM role; the three sub-Archon rank-roles ARE grant-editable (and the
17
+ // tests assert that, so a later tightening can't quietly re-close the owner's
18
+ // lever); the ARCHON rank-role and every SUBSTRATE key stay refused; rank-role
19
+ // ASSIGNMENT is still rank-derived (R95b); and every route denies without the
20
+ // `governance.manage` grant.
19
21
  //
20
22
  // Run: node tests/governance_routes.mjs
21
23
 
@@ -55,6 +57,7 @@ test('the routes file declares the full R110 surface', () => {
55
57
  'POST /governance/roles',
56
58
  'PUT /governance/roles/:roleKey/permissions/:permissionKey',
57
59
  'DELETE /governance/roles/:roleKey/permissions/:permissionKey',
60
+ 'POST /governance/roles/:roleKey/reset',
58
61
  'POST /governance/assignments',
59
62
  'DELETE /governance/assignments/:builderId/:roleKey',
60
63
  ];
@@ -98,10 +101,19 @@ const calls = [];
98
101
  const stub = {
99
102
  listRolesWithGrants: async () => [{ role_key: 'metic', label: 'Metic', description: null, is_system: true, seeded_rank: 'metic', created_by: null, created_at: 't', permissions: ['task.create'] }],
100
103
  listAssignments: async () => [{ builder_id: 7, github_login: 'x', display_name: 'X', rank: 'metic', role_key: 'metic', role_label: 'Metic', is_system: true, seeded_rank: 'metic', assigned_by: null, assigned_at: 't' }],
101
- getRoleByKey: async (k) => (k === 'missing' ? null : { role_key: k, label: k, is_system: k === 'metic' || k === 'archon', seeded_rank: (k === 'metic' || k === 'archon') ? k : null }),
104
+ // Every rank name is a seeded rank-role; anything else is a custom one. Keyed off
105
+ // the catalog's ladder rather than a hand-listed pair, so a rank added to
106
+ // RANK_ORDER is exercised here instead of quietly falling through as "custom".
107
+ getRoleByKey: async (k) => (k === 'missing' ? null : {
108
+ role_key: k,
109
+ label: k,
110
+ is_system: catalog.RANK_ORDER.includes(k),
111
+ seeded_rank: catalog.RANK_ORDER.includes(k) ? k : null,
112
+ }),
102
113
  createRole: async (a) => ({ role_key: a.roleKey, label: a.label, description: a.description ?? null, is_system: false, seeded_rank: null, created_by: a.createdBy, created_at: 't' }),
103
114
  grantRolePermission: async () => true,
104
115
  revokeRolePermission: async () => true,
116
+ resetRoleGrants: async ({ permissionKeys }) => permissionKeys.length,
105
117
  assignRole: async () => true,
106
118
  unassignRole: async () => true,
107
119
  };
@@ -259,10 +271,37 @@ test('PUT grant: an unknown permission fails closed', async () => {
259
271
  assert.equal(r.json.error.code, 'unknown_permission');
260
272
  });
261
273
 
262
- test('PUT grant: a seeded rank-role is immutable (grants derive from floors)', async () => {
274
+ test('PUT grant: a sub-Archon rank-role is EDITABLE (ADR 0172)', async () => {
263
275
  const r = await call('PUT', '/governance/roles/metic/permissions/idea.triage');
276
+ assert.equal(r.status, 200);
277
+ const c = calls.find(([k]) => k === 'grantRolePermission');
278
+ assert.deepEqual(c[1], { roleKey: 'metic', permissionKey: 'idea.triage' });
279
+ });
280
+
281
+ test('PUT grant: an in-app owner power CAN go to a rank-role but never to a custom one', async () => {
282
+ const rank = await call('PUT', '/governance/roles/metic/permissions/builder.rank.set');
283
+ assert.equal(rank.status, 200, 'the owner may delegate an in-app Archon power to a rank');
284
+ const custom = await call('PUT', '/governance/roles/triage-helper/permissions/builder.rank.set');
285
+ assert.equal(custom.status, 400, 'a custom role keeps the R110 wall');
286
+ assert.equal(custom.json.error.code, 'permission_not_grantable');
287
+ });
288
+
289
+ test('PUT grant: a SUBSTRATE key is refused even on a rank-role (it would confer nothing)', async () => {
290
+ for (const key of ['deploy.push_main', 'path.protected.modify', 'infra.ssh']) {
291
+ const r = await call('PUT', `/governance/roles/metic/permissions/${key}`);
292
+ assert.equal(r.status, 400, `${key} must be refused`);
293
+ assert.equal(r.json.error.code, 'permission_not_grantable');
294
+ assert.match(r.json.error.details.reason || '', /substrate/i,
295
+ 'the refusal must name the substrate wall, not the custom-role one — the two are a policy choice apart');
296
+ assert.equal(calls.filter(([k]) => k === 'grantRolePermission').length, 0);
297
+ }
298
+ });
299
+
300
+ test('PUT grant: the ARCHON rank-role stays immutable (the lockout guard)', async () => {
301
+ const r = await call('PUT', '/governance/roles/archon/permissions/idea.triage');
264
302
  assert.equal(r.status, 403);
265
- assert.equal(r.json.error.code, 'seeded_role_immutable');
303
+ assert.equal(r.json.error.code, 'archon_role_immutable');
304
+ assert.equal(calls.filter(([k]) => k === 'grantRolePermission').length, 0);
266
305
  });
267
306
 
268
307
  test('PUT grant: an unknown role 404s', async () => {
@@ -278,10 +317,31 @@ test('DELETE grant revokes without consulting the catalog (stale keys stay remov
278
317
  assert.deepEqual(c[1], { roleKey: 'triage-helper', permissionKey: 'some.retired.key' });
279
318
  });
280
319
 
281
- test('DELETE grant on a seeded rank-role is refused like the PUT', async () => {
282
- const r = await call('DELETE', '/governance/roles/archon/permissions/idea.triage');
283
- assert.equal(r.status, 403);
284
- assert.equal(r.json.error.code, 'seeded_role_immutable');
320
+ test('DELETE grant: revocable on a sub-Archon rank-role, refused on the Archon one', async () => {
321
+ const metic = await call('DELETE', '/governance/roles/metic/permissions/idea.triage');
322
+ assert.equal(metic.status, 200, 'taking a permission away from a rank is the other half of editable');
323
+
324
+ const archon = await call('DELETE', '/governance/roles/archon/permissions/idea.triage');
325
+ assert.equal(archon.status, 403);
326
+ assert.equal(archon.json.error.code, 'archon_role_immutable');
327
+ assert.equal(calls.filter(([k]) => k === 'revokeRolePermission').length, 0);
328
+ });
329
+
330
+ // --- reset (ADR 0172) --------------------------------------------------------
331
+
332
+ test('POST reset: a rank-role goes back to its floor-derived catalog seed', async () => {
333
+ const r = await call('POST', '/governance/roles/thetes/reset');
334
+ assert.equal(r.status, 200);
335
+ assert.equal(r.json.seeded_rank, 'thetes');
336
+ const c = calls.find(([k]) => k === 'resetRoleGrants');
337
+ assert.deepEqual([...c[1].permissionKeys].sort(), [...catalog.seedForRank('thetes')].sort());
338
+ });
339
+
340
+ test('POST reset: refused on a custom role (no catalog default to return to)', async () => {
341
+ const r = await call('POST', '/governance/roles/triage-helper/reset');
342
+ assert.equal(r.status, 400);
343
+ assert.equal(r.json.error.code, 'role_has_no_default');
344
+ assert.equal(calls.filter(([k]) => k === 'resetRoleGrants').length, 0);
285
345
  });
286
346
 
287
347
  // --- assignments -------------------------------------------------------------
@@ -0,0 +1,113 @@
1
+ // tests/scouting_rollup_fanout.mjs — task 1002911 (real DB).
2
+ //
3
+ // listScoutingRollup used to `LEFT JOIN LATERAL unnest(a.disciplines)` BEFORE
4
+ // GROUP BY, multiplying every activity row by max(1, cardinality(disciplines)) —
5
+ // so project_count and the credits/ships/karma sums were inflated for any builder
6
+ // with 2+ disciplines on a project row. The module's own test suite is DB-free
7
+ // and can only regex-match the SQL text; THIS test proves the aggregation
8
+ // semantics against real SQL: it replays the module's actual migrations, seeds a
9
+ // multi-discipline builder across two projects, and asserts exact totals.
10
+ //
11
+ // Real-DB integration test: self-skips without a reachable Postgres; also in the
12
+ // INTEGRATION set so the default DB-free lane never runs it.
13
+ //
14
+ // Run: DATABASE_URL=... node tests/scouting_rollup_fanout.mjs
15
+
16
+ import { createRequire } from 'node:module';
17
+ import { readFileSync } from 'node:fs';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { dirname, join } from 'node:path';
20
+ import { strict as assert } from 'node:assert';
21
+ const require = createRequire(import.meta.url);
22
+ const { Pool } = require('pg');
23
+ const pi = require('../modules/platform-identity/platform-identity.js');
24
+
25
+ const here = dirname(fileURLToPath(import.meta.url));
26
+ const MIG_DIR = join(here, '..', 'modules', 'platform-identity', 'migrations');
27
+ const migrationSql = (f) => readFileSync(join(MIG_DIR, f), 'utf8');
28
+
29
+ const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
30
+ try {
31
+ await pool.query('SELECT 1');
32
+ } catch (e) {
33
+ console.log(`scouting_rollup_fanout.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
34
+ await pool.end().catch(() => {});
35
+ process.exit(0);
36
+ }
37
+
38
+ // High-numbered fixture ids so a shared DB can never collide with real data.
39
+ const MULTI = 90002911, SOLO = 90002912, GHOST = 90002913;
40
+ const ALL_IDS = [MULTI, SOLO, GHOST];
41
+ const CLIENTS = ['t1002911-p1', 't1002911-p2'];
42
+ const q = (sql, params) => pool.query(sql, params);
43
+
44
+ async function cleanup() {
45
+ await q(`DELETE FROM platform_identity_builder_project_activity WHERE github_id = ANY($1::bigint[])`, [ALL_IDS]).catch(() => {});
46
+ await q(`DELETE FROM platform_identity_accounts WHERE github_id = ANY($1::bigint[])`, [ALL_IDS]).catch(() => {});
47
+ }
48
+
49
+ let pass = 0;
50
+ const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log(` ok - ${msg}`); };
51
+
52
+ try {
53
+ // The module's actual migrations (idempotent CREATE TABLE IF NOT EXISTS).
54
+ await q(migrationSql('platform_identity_001_tables.sql'));
55
+ await q(migrationSql('platform_identity_005_builder_activity.sql'));
56
+ await cleanup();
57
+
58
+ await q(
59
+ `INSERT INTO platform_identity_accounts (github_id, github_login, display_name, avatar_url)
60
+ VALUES ($1, 'multi', 'Multi', 'a'), ($2, 'solo', 'Solo', 'b'), ($3, 'ghost', 'Ghost', 'c')`,
61
+ [MULTI, SOLO, GHOST],
62
+ );
63
+ // MULTI: two projects, 2 and 3 disciplines — the exact fan-out shape the old
64
+ // query inflated (credits would have summed 100*2 + 50*3 = 350, count 5).
65
+ // SOLO: one project, one discipline (was always correct — must stay correct).
66
+ // GHOST: one project, NO disciplines (old query kept it via LEFT JOIN + FILTER).
67
+ await q(
68
+ `INSERT INTO platform_identity_builder_project_activity
69
+ (client_id, github_id, credits, tasks_shipped, karma, disciplines, last_active)
70
+ VALUES ($1, $3, 100, 5, 3, '{engineer,ui}', '2026-01-01T00:00:00Z'),
71
+ ($2, $3, 50, 2, 1, '{artist,engineer,ui}', '2026-02-01T00:00:00Z'),
72
+ ($1, $4, 60, 4, 2, '{engineer}', '2026-03-01T00:00:00Z'),
73
+ ($2, $5, 10, 1, 0, '{}', '2026-04-01T00:00:00Z')`,
74
+ [...CLIENTS, MULTI, SOLO, GHOST],
75
+ );
76
+
77
+ const rows = (await pi.listScoutingRollup({ limit: 500, pool })).filter((r) => ALL_IDS.includes(Number(r.github_id)));
78
+ const by = Object.fromEntries(rows.map((r) => [Number(r.github_id), r]));
79
+
80
+ const m = by[MULTI];
81
+ ok(m, 'multi-discipline builder appears in the rollup');
82
+ ok(m.project_count === 2, `project_count is 2, not fanned (got ${m.project_count})`);
83
+ ok(m.total_credits === 150, `total_credits is 100+50=150, not inflated (got ${m.total_credits})`);
84
+ ok(m.total_tasks_shipped === 7, `total_tasks_shipped is 5+2=7 (got ${m.total_tasks_shipped})`);
85
+ ok(m.total_karma === 4, `total_karma is 3+1=4 (got ${m.total_karma})`);
86
+ ok(new Date(m.last_active).toISOString() === '2026-02-01T00:00:00.000Z', 'last_active is the max across projects');
87
+ assert.deepEqual([...m.disciplines].sort(), ['artist', 'engineer', 'ui']);
88
+ pass++; console.log(' ok - disciplines are the deduped union across projects');
89
+ ok(m.github_login === 'multi', 'account display fields join through');
90
+
91
+ const s = by[SOLO];
92
+ ok(s.project_count === 1 && s.total_credits === 60 && s.total_tasks_shipped === 4 && s.total_karma === 2,
93
+ 'single-discipline builder totals are unchanged');
94
+ assert.deepEqual(s.disciplines, ['engineer']);
95
+ pass++; console.log(' ok - single discipline survives as a one-element array');
96
+
97
+ const g = by[GHOST];
98
+ ok(g, 'a builder with an empty disciplines array still appears');
99
+ ok(g.disciplines === null, 'empty disciplines roll up as NULL (matches the old FILTER behavior)');
100
+ ok(g.project_count === 1 && g.total_credits === 10, 'empty-disciplines totals are correct');
101
+
102
+ const fixtureOrder = rows.map((r) => Number(r.github_id));
103
+ assert.deepEqual(fixtureOrder, [MULTI, SOLO, GHOST]);
104
+ pass++; console.log(' ok - ranked by total_credits DESC');
105
+
106
+ const limited = await pi.listScoutingRollup({ limit: 1, pool });
107
+ ok(limited.length === 1, 'LIMIT is respected');
108
+
109
+ console.log(`\nscouting_rollup_fanout.mjs: ${pass} passed`);
110
+ } finally {
111
+ await cleanup();
112
+ await pool.end().catch(() => {});
113
+ }