@bongos/core 1.19.514 → 1.19.516

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.
Files changed (38) hide show
  1. package/.bongos-core.json +81 -36
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +12 -0
  4. package/clients/bongos-client/index.cjs +12 -0
  5. package/clients/bongos-client/index.d.ts +22 -2
  6. package/clients/bongos-client/index.mjs +12 -0
  7. package/docs/adr/0240-a-skipped-module-picker-stores-nothing-and-resolves-the-bundle-on-read.md +157 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +362 -6
  10. package/docs/api-reference.md +12 -2
  11. package/docs/copy-inventory.md +224 -218
  12. package/docs/copy-registry.json +357 -291
  13. package/docs/module-api-changelog.md +4 -0
  14. package/modules/dev-box/app/src/vendor/bongos-client.cjs +12 -0
  15. package/modules/government/catalog.js +6 -0
  16. package/modules/government/migrations/government_009_mingle_manage.sql +55 -0
  17. package/modules/lifecycle/migrations/lifecycle_007_mingle.sql +134 -0
  18. package/modules/lifecycle/mingle.js +337 -0
  19. package/modules/lifecycle/module.json +1 -0
  20. package/modules/lifecycle/routes/mingle.js +116 -0
  21. package/modules/provisioning/migrations/provisioning_014_module_selection.sql +60 -0
  22. package/modules/provisioning/provisioning.js +24 -4
  23. package/modules/provisioning/routes/provisioning.js +59 -2
  24. package/modules/provisioning/starter-bundles.js +91 -0
  25. package/modules/public-landing/public/assets/cosmos.css +1 -1
  26. package/modules/public-landing/public/projects.html +230 -35
  27. package/modules/public-landing/public/projects.probes.json +2 -2
  28. package/modules/public-landing/public/projects.states.json +5 -4
  29. package/modules/ui-design/kit/serve.js +32 -0
  30. package/package-lock.json +2 -2
  31. package/package.json +1 -1
  32. package/src/module-api.js +1 -1
  33. package/tests/government_seed.mjs +35 -0
  34. package/tests/mingle.mjs +370 -0
  35. package/tests/projects_hub.mjs +72 -7
  36. package/tests/projects_hub_module_picker.mjs +369 -0
  37. package/tests/provisioning_module_selection.mjs +286 -0
  38. package/tests/provisioning_starter_bundles.mjs +131 -0
@@ -182,3 +182,38 @@ test('migration objects are all governance_-namespaced (ADR 0083 §5)', () => {
182
182
  }
183
183
  }
184
184
  });
185
+
186
+ // THE GAP THE DRIFT GUARD ABOVE CANNOT SEE (found by the task 1003177 grader).
187
+ //
188
+ // The union model is a regex over `SELECT '<rank>', unnest(ARRAY[…])`. It reads
189
+ // the KEYS and never looks at the table those keys are inserted INTO — so a new
190
+ // grant migration copied from a `governance_*` sibling passes it while writing to
191
+ // `governance_role_permissions (role_key, …)`, the PRE-rename names.
192
+ //
193
+ // government_001_rename_from_governance renamed that table to
194
+ // `government_rank_permissions` and its `role_key` column to `rank_key`, with no
195
+ // compatibility shim. Migrations apply in filename order, so on any database —
196
+ // a fresh install included — a `government_*`-prefixed file writes AFTER the
197
+ // rename and must use the new names or error at apply time. The permission would
198
+ // then never resolve, and its routes would 403 for every rank, Archon included.
199
+ //
200
+ // Scoped to the `government_`-prefixed files on purpose: the historical
201
+ // `governance_*` migrations legitimately name the pre-rename tables (they ran
202
+ // before the rename, and renaming an applied migration re-runs it — the standing
203
+ // rule in modules/government/CLAUDE.md).
204
+ test('a post-rename grant migration writes to the RENAMED table, not the old one', () => {
205
+ const postRename = grantSqlByFile.filter(([f]) => f.startsWith('government_'));
206
+ assert.ok(postRename.length > 0, 'there is at least one government_-prefixed grant migration');
207
+
208
+ for (const [file, sql] of postRename) {
209
+ const ddl = sql.replace(/^\s*--.*$/gm, ''); // a comment may CITE the old name
210
+ assert.ok(!/governance_role_permissions/.test(ddl),
211
+ `${file}: writes to governance_role_permissions — renamed to government_rank_permissions by government_001`);
212
+ assert.ok(!/\brole_key\b/.test(ddl),
213
+ `${file}: names the role_key column — renamed to rank_key by government_001`);
214
+ if (/INSERT INTO/.test(ddl)) {
215
+ assert.match(ddl, /INSERT INTO government_rank_permissions \(rank_key, permission_key\)/,
216
+ `${file}: a grant insert must target government_rank_permissions (rank_key, permission_key)`);
217
+ }
218
+ }
219
+ });
@@ -0,0 +1,370 @@
1
+ // tests/mingle.mjs — mingle is two switches, cross-craft, and a decline that
2
+ // costs nothing (task 1003177 / C07, goal 1000075; criterion
3
+ // collab-mingle-two-switches, ADR 0187 §5).
4
+ //
5
+ // The criterion names three testable things, and the owner attached the third as
6
+ // a condition of the feature rather than a follow-up:
7
+ // 1. a pair is only ever CROSS-CRAFT;
8
+ // 2. disabling EITHER switch prevents pairing;
9
+ // 3. a decline carries NO PENALTY — "and that has to be verifiable, not just
10
+ // stated, or 'without penalty' is a claim rather than a property".
11
+ //
12
+ // (3) is the reason this file drives the REAL functions against a RECORDING pool
13
+ // rather than asserting over prose. "No penalty" is a claim about what the code
14
+ // does NOT do, and the only honest way to test an absence is to capture every
15
+ // statement the code issues and assert on the whole set.
16
+ import { strict as assert } from 'node:assert';
17
+ import { test } from 'node:test';
18
+ import { createRequire } from 'node:module';
19
+ import fs from 'node:fs';
20
+ import path from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+
23
+ const require = createRequire(import.meta.url);
24
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
25
+ const mingle = require(path.join(ROOT, 'modules', 'lifecycle', 'mingle.js'));
26
+ const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), 'utf8');
27
+
28
+ const SRC = read('modules', 'lifecycle', 'mingle.js');
29
+ const ROUTES = read('modules', 'lifecycle', 'routes', 'mingle.js');
30
+ const MIGRATION = read('modules', 'lifecycle', 'migrations', 'lifecycle_007_mingle.sql');
31
+
32
+ // A pool that answers the module's real queries and RECORDS every one of them.
33
+ // The record is what makes the no-penalty test possible: it is an assertion about
34
+ // the complete set of statements a decline issues, not about the one we expected.
35
+ function recordingPool({ eligible = [], openPairs = [], pairRow = null } = {}) {
36
+ const queries = [];
37
+ return {
38
+ queries,
39
+ async query(sql, params) {
40
+ queries.push({ sql, params });
41
+ if (/FROM lifecycle_mingle_optin o/.test(sql)) {
42
+ return { rows: eligible.map((b) => ({ builder_id: b.builder_id, crafts: b.crafts })) };
43
+ }
44
+ if (/SELECT opted_in, updated_at FROM lifecycle_mingle_optin/.test(sql)) {
45
+ return { rows: [{ opted_in: true, updated_at: '2026-09-03T00:00:00Z' }] };
46
+ }
47
+ if (/INSERT INTO lifecycle_mingle_optin/.test(sql)) {
48
+ return { rows: [{ builder_id: params[0], opted_in: params[1], updated_at: 'now' }] };
49
+ }
50
+ if (/SELECT builder_lo::text AS lo/.test(sql)) {
51
+ return { rows: openPairs.map(([lo, hi]) => ({ lo, hi })) };
52
+ }
53
+ if (/INSERT INTO lifecycle_mingle_pairs/.test(sql)) {
54
+ // The round writes ONE bulk insert via unnest(), so the params are four
55
+ // parallel arrays rather than four scalars.
56
+ const [los, his, cl, ch] = params;
57
+ return {
58
+ rows: los.map((lo, i) => ({
59
+ id: i + 1, builder_lo: lo, builder_hi: his[i], craft_lo: cl[i], craft_hi: ch[i],
60
+ status: 'proposed', created_at: 'now',
61
+ })),
62
+ };
63
+ }
64
+ if (/UPDATE lifecycle_mingle_pairs/.test(sql)) {
65
+ return { rows: pairRow ? [pairRow] : [] };
66
+ }
67
+ if (/FROM lifecycle_mingle_pairs p/.test(sql)) return { rows: [] };
68
+ throw new Error(`unexpected query: ${sql.slice(0, 70)}`);
69
+ },
70
+ };
71
+ }
72
+
73
+ const on = () => async () => ({ value: 'true' });
74
+ const off = () => async () => null;
75
+
76
+ // ── 1. A PAIR IS ONLY EVER CROSS-CRAFT ──────────────────────────────────────
77
+
78
+ test('THE RULE: crafts must be DISJOINT, not merely different', () => {
79
+ assert.equal(mingle.isCrossCraft(['builder'], ['artist']), true);
80
+ assert.equal(mingle.isCrossCraft(['builder'], ['builder']), false);
81
+ // The case a "different primary craft" rule would get wrong: someone who is
82
+ // both shares a craft with a pure engineer, so pairing them puts two of the
83
+ // same in a room and calls it cross-craft.
84
+ assert.equal(mingle.isCrossCraft(['artist', 'builder'], ['builder']), false);
85
+ assert.equal(mingle.isCrossCraft(['artist', 'builder'], ['ideator']), true);
86
+ });
87
+
88
+ test('a builder with NO craft is never pairable', () => {
89
+ // Belonging to a craft is a claim about yourself the system must not assume —
90
+ // the same fail-closed answer help-requests gives.
91
+ assert.equal(mingle.isCrossCraft([], ['artist']), false);
92
+ assert.equal(mingle.isCrossCraft(['artist'], []), false);
93
+ const pairs = mingle.planPairs([
94
+ { builder_id: '1', crafts: [] },
95
+ { builder_id: '2', crafts: ['artist'] },
96
+ ]);
97
+ assert.deepEqual(pairs, []);
98
+ });
99
+
100
+ test('EVERY pair the planner emits is cross-craft', () => {
101
+ const eligible = [
102
+ { builder_id: '10', crafts: ['builder'] },
103
+ { builder_id: '20', crafts: ['builder'] },
104
+ { builder_id: '30', crafts: ['artist'] },
105
+ { builder_id: '40', crafts: ['ideator'] },
106
+ { builder_id: '50', crafts: ['artist', 'builder'] },
107
+ ];
108
+ const byId = new Map(eligible.map((b) => [b.builder_id, b.crafts]));
109
+ const pairs = mingle.planPairs(eligible);
110
+ assert.ok(pairs.length > 0, 'the planner found pairs at all');
111
+ for (const p of pairs) {
112
+ assert.equal(mingle.isCrossCraft(byId.get(p.builder_lo), byId.get(p.builder_hi)), true,
113
+ `${p.builder_lo}/${p.builder_hi} share a craft`);
114
+ assert.notEqual(p.craft_lo, p.craft_hi, 'the recorded crafts differ');
115
+ }
116
+ });
117
+
118
+ test('nobody is put in two pairs in one round', () => {
119
+ const pairs = mingle.planPairs([
120
+ { builder_id: '1', crafts: ['builder'] },
121
+ { builder_id: '2', crafts: ['artist'] },
122
+ { builder_id: '3', crafts: ['ideator'] },
123
+ { builder_id: '4', crafts: ['ui'] },
124
+ ]);
125
+ const seen = new Set();
126
+ for (const p of pairs) {
127
+ for (const id of [p.builder_lo, p.builder_hi]) {
128
+ assert.ok(!seen.has(id), `${id} appears twice`);
129
+ seen.add(id);
130
+ }
131
+ }
132
+ });
133
+
134
+ test('CROSS-CRAFT IS ALSO A DATABASE CONSTRAINT, not only a matcher rule', () => {
135
+ // Enforcing it solely in the matcher leaves any future writer — a backfill, a
136
+ // second caller, a fixture — free to violate the property the feature is named
137
+ // for.
138
+ assert.match(MIGRATION, /CHECK \(craft_lo <> craft_hi\)/);
139
+ assert.match(MIGRATION, /CHECK \(builder_lo <> builder_hi\)/);
140
+ });
141
+
142
+ test('the matching rule is RECORDED, and the route serves it verbatim', () => {
143
+ // "chosen deliberately and recorded" — a reader can fetch the rule rather than
144
+ // infer it from SQL.
145
+ const m = mingle.matchingModel();
146
+ assert.equal(m.rule, 'cross_craft');
147
+ assert.equal(m.overlap, 'none');
148
+ assert.ok(m.description && m.consent && m.decline);
149
+ assert.match(m.rejected, /same_craft/, 'the road not taken is recorded too');
150
+ assert.match(ROUTES, /matching: mingle\.matchingModel\(\)/);
151
+ });
152
+
153
+ // ── 2. DISABLING EITHER SWITCH PREVENTS PAIRING ─────────────────────────────
154
+
155
+ test('SWITCH 1 OFF: the project disabled means no pairing, and no write at all', async () => {
156
+ const pool = recordingPool({ eligible: [
157
+ { builder_id: '1', crafts: ['builder'] },
158
+ { builder_id: '2', crafts: ['artist'] },
159
+ ] });
160
+ const out = await mingle.runMingleRound({}, { pool, getSetting: off() });
161
+ assert.deepEqual(out.paired, []);
162
+ assert.equal(out.skipped, 'project_disabled');
163
+ // Not "paired then discarded" — it never got as far as reading who is eligible.
164
+ assert.equal(pool.queries.length, 0, 'a disabled project issues no query');
165
+ });
166
+
167
+ test('SWITCH 2 OFF: eligibility is opted-in only, enforced in SQL', async () => {
168
+ const pool = recordingPool({ eligible: [] });
169
+ await mingle.eligibleBuilders({ pool });
170
+ const sql = pool.queries[0].sql;
171
+ assert.match(sql, /o\.opted_in = true/, 'only people who opted in');
172
+ assert.match(sql, /b\.status = 'active'/, 'and only active builders');
173
+ });
174
+
175
+ test('BOTH ON is the only state that pairs', async () => {
176
+ const pool = recordingPool({ eligible: [
177
+ { builder_id: '1', crafts: ['builder'] },
178
+ { builder_id: '2', crafts: ['artist'] },
179
+ ] });
180
+ const out = await mingle.runMingleRound({}, { pool, getSetting: on() });
181
+ assert.equal(out.skipped, null);
182
+ assert.equal(out.paired.length, 1);
183
+ assert.equal(out.paired[0].builder_lo, '1');
184
+ });
185
+
186
+ test('a round is ONE insert, so a partial round cannot be written', () => {
187
+ const pool = recordingPool({ eligible: [
188
+ { builder_id: '1', crafts: ['builder'] },
189
+ { builder_id: '2', crafts: ['artist'] },
190
+ { builder_id: '3', crafts: ['ideator'] },
191
+ { builder_id: '4', crafts: ['ui'] },
192
+ ] });
193
+ return mingle.runMingleRound({}, { pool, getSetting: on() }).then((out) => {
194
+ assert.equal(out.paired.length, 2, 'two pairs from four builders');
195
+ const inserts = pool.queries.filter((q) => /INSERT INTO lifecycle_mingle_pairs/.test(q.sql));
196
+ assert.equal(inserts.length, 1, 'one statement for the whole round, not one per pair');
197
+ assert.match(inserts[0].sql, /unnest\(\$1::bigint\[\]/);
198
+ });
199
+ });
200
+
201
+ test('an empty plan writes nothing at all', async () => {
202
+ // Two builders of the SAME craft are not a pair, so there is nothing to insert
203
+ // — and no empty INSERT should be issued to discover that.
204
+ const pool = recordingPool({ eligible: [
205
+ { builder_id: '1', crafts: ['builder'] },
206
+ { builder_id: '2', crafts: ['builder'] },
207
+ ] });
208
+ const out = await mingle.runMingleRound({}, { pool, getSetting: on() });
209
+ assert.deepEqual(out.paired, []);
210
+ assert.equal(pool.queries.filter((q) => /INSERT/.test(q.sql)).length, 0);
211
+ });
212
+
213
+ test('FAIL CLOSED: the project switch opens only on an explicit yes', () => {
214
+ for (const yes of ['true', 'TRUE', ' on ', '1', 'yes']) {
215
+ assert.equal(mingle.parseEnabled(yes), true, `${yes} is a yes`);
216
+ }
217
+ // Absent, blank, garbage, and a value that was valid before someone tightened
218
+ // this are all NO. A consent gate that opens on an unreadable value is not a gate.
219
+ for (const no of [null, undefined, '', 'false', 'maybe', 'enabled', '2', 'off']) {
220
+ assert.equal(mingle.parseEnabled(no), false, `${String(no)} is not a yes`);
221
+ }
222
+ });
223
+
224
+ test('FAIL CLOSED: an unreadable project switch is not permission', async () => {
225
+ const enabled = await mingle.projectEnabled({ getSetting: async () => { throw new Error('db down'); } });
226
+ assert.equal(enabled, false);
227
+ });
228
+
229
+ test('the two switches are reported SEPARATELY, so a builder can see which is off', async () => {
230
+ // "You cannot mingle" with no reason is a dead end for the person who could fix
231
+ // half of it.
232
+ const pool = recordingPool();
233
+ const state = await mingle.mingleStateFor(7, { pool, getSetting: off() });
234
+ assert.equal(state.enabled, false);
235
+ assert.equal(state.project_enabled, false);
236
+ assert.equal(state.opted_in, true, 'my half is still reported as mine');
237
+ });
238
+
239
+ // ── 3. A DECLINE CARRIES NO PENALTY ─────────────────────────────────────────
240
+
241
+ // Anything that would make declining cost something. Matched against the SQL a
242
+ // decline actually issues AND against the module source.
243
+ const PENALTY_SURFACES = /credit|karma|rank|streak|achievement|reward|score|penalt|cooldown|strike/i;
244
+
245
+ test('THE NO-PENALTY PROPERTY: a decline writes ONE row in ONE table', async () => {
246
+ const pool = recordingPool({ pairRow: { id: 5, builder_lo: '1', builder_hi: '2', craft_lo: 'builder', craft_hi: 'artist', status: 'declined', decided_by: '1', decided_at: 'now' } });
247
+ const out = await mingle.decidePair(5, 1, 'declined', { pool });
248
+ assert.equal(out.pair.status, 'declined');
249
+
250
+ assert.equal(pool.queries.length, 1, 'exactly one statement');
251
+ const { sql } = pool.queries[0];
252
+ assert.match(sql, /UPDATE lifecycle_mingle_pairs/);
253
+ // The whole point: nothing else was touched. This is an assertion about the
254
+ // COMPLETE set of statements, which is the only honest way to test an absence.
255
+ assert.ok(!PENALTY_SURFACES.test(sql), `a decline touched a penalty surface: ${sql}`);
256
+ });
257
+
258
+ test('NO FUTURE MATCHING WEIGHT: the eligibility read cannot see pair history', async () => {
259
+ // This is the structural half, and it is what makes "no penalty" a property
260
+ // rather than a promise: the read that decides who may be paired has no way to
261
+ // learn that someone declined, so a decline cannot lower their odds.
262
+ const pool = recordingPool({ eligible: [] });
263
+ await mingle.eligibleBuilders({ pool });
264
+ const sql = pool.queries[0].sql;
265
+ assert.ok(!/lifecycle_mingle_pairs/.test(sql),
266
+ 'eligibleBuilders must never join the pairs table');
267
+ assert.ok(!PENALTY_SURFACES.test(sql), 'eligibility reads no penalty surface either');
268
+ });
269
+
270
+ test('a declined pair may be introduced again — a decline is not a permanent split', () => {
271
+ // The only thing pair history gates is a second OPEN proposal for the same two
272
+ // people (the unique index's job). A settled pair is absent from that set, so
273
+ // it can come back. A permanent exclusion would be a consequence, and declining
274
+ // is free.
275
+ assert.match(MIGRATION, /CREATE UNIQUE INDEX[\s\S]{0,200}lifecycle_mingle_pairs_open_uniq[\s\S]{0,120}WHERE status = 'proposed'/);
276
+ const eligible = [
277
+ { builder_id: '1', crafts: ['builder'] },
278
+ { builder_id: '2', crafts: ['artist'] },
279
+ ];
280
+ // With the pair already OPEN, it is skipped...
281
+ assert.deepEqual(mingle.planPairs(eligible, { excludePairs: new Set(['1:2']) }), []);
282
+ // ...and once settled it is not in the exclude set at all, so it returns.
283
+ assert.equal(mingle.planPairs(eligible, { excludePairs: new Set() }).length, 1);
284
+ });
285
+
286
+ test('the exclude set is built from OPEN pairs only, never from declines', async () => {
287
+ const pool = recordingPool({ openPairs: [['1', '2']] });
288
+ await mingle.openPairKeys({ pool });
289
+ assert.match(pool.queries[0].sql, /WHERE status = 'proposed'/);
290
+ assert.ok(!/declined/.test(pool.queries[0].sql), 'declines are not read back');
291
+ });
292
+
293
+ test('NOTHING in the module reaches a penalty surface', () => {
294
+ // The source-level backstop for the two behavioural tests above: a future
295
+ // "small" addition — bump a counter, dock some karma — fails here even if it
296
+ // never runs in a test.
297
+ //
298
+ // TWO EXEMPTIONS, AND THEY ARE THE SAME ONE. Comments discuss penalties, and so
299
+ // does the MATCHING literal — its whole job is to record, in words a surface can
300
+ // render, WHICH standing a decline does not touch. Naming a surface in order to
301
+ // disclaim it is the opposite of reaching for it, so both are stripped before
302
+ // the scan. This is the codebase's standing convention: a line that CITES a rule
303
+ // is the only thing exempt from it (scripts/gds/fitness.js says so of the
304
+ // category-advisory guard). Everything else — every identifier, every line of
305
+ // SQL — is scanned.
306
+ const code = SRC
307
+ .replace(/^\s*\/\/.*$/gm, '')
308
+ .replace(/const MATCHING = Object\.freeze\(\{[\s\S]*?\n\}\);/, '');
309
+ assert.ok(!/MATCHING = Object\.freeze/.test(code), 'the MATCHING literal was actually stripped');
310
+ assert.ok(!/credits|karma|streak|achievement/i.test(code),
311
+ 'mingle.js must not touch a credit/karma/streak/achievement surface');
312
+ });
313
+
314
+ test('the schema carries no penalty column', () => {
315
+ // A table that recorded a decline anywhere but in its own status column would be
316
+ // the penalty the criterion forbids — the same posture lifecycle_005 takes.
317
+ const ddl = MIGRATION.replace(/^\s*--.*$/gm, '');
318
+ assert.ok(!/credit|karma|streak|cooldown|penalt|count/i.test(ddl),
319
+ 'no counter, cooldown or penalty column');
320
+ });
321
+
322
+ // ── the routes ──────────────────────────────────────────────────────────────
323
+
324
+ test('consent is keyed by the SESSION, never a body field', () => {
325
+ // Consent you can give on someone else's behalf is not consent.
326
+ assert.match(ROUTES, /mingle\.setOptIn\(req\.builder\.id/);
327
+ assert.ok(!/setOptIn\(\s*req\.body/.test(ROUTES));
328
+ });
329
+
330
+ test('the operator routes gate on the ATOM; my own switch gates on nothing', () => {
331
+ // BV1.R105: a modules/*/routes gate is a permission atom, never a rank —
332
+ // tests/government_require_permission fails the build on a new requireRank, and
333
+ // the two files still spelling it that way are documented holdouts.
334
+ const projectRoute = ROUTES.slice(ROUTES.indexOf("router.put('/mingle/project'"));
335
+ assert.match(projectRoute.slice(0, 220), /requirePermission\('mingle\.manage'\)/);
336
+ const roundRoute = ROUTES.slice(ROUTES.indexOf("router.post('/mingle/round'"));
337
+ assert.match(roundRoute.slice(0, 220), /requirePermission\('mingle\.manage'\)/);
338
+
339
+ const optIn = ROUTES.slice(ROUTES.indexOf("router.put('/mingle/opt-in'"), ROUTES.indexOf("router.put('/mingle/project'"));
340
+ assert.ok(!/requirePermission|requireRank/.test(optIn),
341
+ 'opting YOURSELF in is not a privileged act');
342
+ });
343
+
344
+ test('the atom is declared in the catalog, and a permission no route consumes does not exist', () => {
345
+ const catalog = read('modules', 'government', 'catalog.js');
346
+ assert.match(catalog, /key: 'mingle\.manage'/, 'the atom is declared');
347
+ assert.match(catalog, /key: 'mingle\.manage'[^\n]*floor: 'metic'/, 'at the discord.channels.manage floor');
348
+ assert.match(catalog, /key: 'mingle\.manage'[^\n]*guards: '[^']*\/mingle\/project/, 'and it names the routes it guards');
349
+ });
350
+
351
+ test('you can only answer an introduction you are in, and the check is in SQL', () => {
352
+ const fn = SRC.slice(SRC.indexOf('async function decidePair'));
353
+ const body = fn.slice(0, fn.indexOf('\n}'));
354
+ assert.match(body, /builder_lo = \$2 OR builder_hi = \$2/,
355
+ 'membership is enforced by the UPDATE, not by a check a later caller could skip');
356
+ assert.match(body, /status = 'proposed'/, 'and a settled pair cannot be re-decided');
357
+ });
358
+
359
+ test('the route is registered in the module manifest', () => {
360
+ const manifest = JSON.parse(read('modules', 'lifecycle', 'module.json'));
361
+ assert.ok(manifest.contributes.routes.includes('mingle'));
362
+ });
363
+
364
+ test('the craft vocabulary is CONSUMED from help-requests, never redefined', () => {
365
+ // Two spellings of the same craft ('builder' the craft is 'engineer' the
366
+ // discipline) would silently match nobody.
367
+ assert.match(SRC, /require\('\.\/help-requests'\)/);
368
+ assert.ok(!/CRAFT_TO_DISCIPLINE\s*=|DISCIPLINE_TO_CRAFT\s*=/.test(SRC),
369
+ 'mingle must not declare its own craft map');
370
+ });
@@ -706,14 +706,15 @@ test('the About answers travel on the create request, and an empty one is OMITTE
706
706
  });
707
707
 
708
708
  test('a renumbering of the wizard invalidates old drafts — the key carries the version', () => {
709
- // task 1002772 renumbered once and bumped to -v2; task 1002334 renumbered again.
710
- // A draft replayed under a different numbering lands on the wrong panel with the
711
- // rail already marking the skipped steps done.
709
+ // task 1002772 renumbered once and bumped to -v2; task 1002334 renumbered again;
710
+ // task 1002339 (the module picker) is the third. A draft replayed under a
711
+ // different numbering lands on the wrong panel with the rail already marking
712
+ // the skipped steps done.
712
713
  const key = (SCRIPT.match(/var DRAFT_KEY = '([^']+)'/) || [, ''])[1];
713
714
  assert.match(key, /-v\d+$/, 'the draft key is versioned');
714
715
  const panels = (SCRIPT.match(/var panels = \{([^}]*)\}/) || [, ''])[1];
715
716
  const count = [...panels.matchAll(/\d+:\s*'panel-/g)].length;
716
- assert.equal(count, 8, 'gate + six numbered steps + done — bump DRAFT_KEY when this changes');
717
+ assert.equal(count, 9, 'gate + seven numbered steps + done — bump DRAFT_KEY when this changes');
717
718
  });
718
719
 
719
720
  test('every row lays the same five parts — the ledger has no ragged row', () => {
@@ -2058,7 +2059,9 @@ test('the wizard takes the world by scope: no card ground, the six-step strip as
2058
2059
  assert.ok(at > -1, 'the #view-new block exists in cosmos.css');
2059
2060
  const block = COSMOS.slice(at);
2060
2061
  assert.match(block, /#view-new \.card\{background:none;border-radius:0;box-shadow:none;/, 'no card ground');
2061
- assert.match(block, /#view-new \.steps\{display:grid;grid-template-columns:repeat\(6,1fr\);/, 'the strip lays its six steps on six tracks');
2062
+ // seven tracks since task 1002339 added the Modules step; the count is the
2063
+ // rail's own, so it moves with the panels map rather than being a magic number
2064
+ assert.match(block, /#view-new \.steps\{display:grid;grid-template-columns:repeat\(7,1fr\);/, 'the strip lays a track per numbered step');
2062
2065
  assert.match(block, /#view-new \.steps li::before\{content:"";position:absolute;top:-4px;left:0;right:auto;width:7px;height:7px;/, 'the connector becomes the 7px dot');
2063
2066
  assert.match(block, /#view-new \.steps li\.now::before\{background:var\(--accent\);border-color:var\(--accent\);outline:4px solid/, 'the lit step carries the accent and an outline halo');
2064
2067
  assert.match(block, /#view-new \.panel h1\{font-weight:300;[^}]*text-transform:lowercase;/, 'the step title in the display voice, lowercase');
@@ -2105,7 +2108,7 @@ test('the wizard\'s own structures wear the world: hairline fields, cells of one
2105
2108
  assert.match(block, /\.addrBig code\{display:block;font-family:var\(--font-display\);/, 'the address stands in the display voice');
2106
2109
  assert.doesNotMatch(block, /linear-gradient/, 'no tinted plates');
2107
2110
  assert.doesNotMatch(block, /box-shadow:(?!none)/, 'no box shadow in the wizard block');
2108
- assert.match(HUB, /<div class="nav">\s*<button type="button" class="btn ghost" data-back="5">← Back<\/button>\s*<button type="button" class="btn big" id="doCreate">Create my project<\/button>/, 'the review\'s doors sit on one row, back then the primary');
2111
+ assert.match(HUB, /<div class="nav">\s*<button type="button" class="btn ghost" data-back="6">← Back<\/button>\s*<button type="button" class="btn big" id="doCreate">Create my project<\/button>/, 'the review\'s doors sit on one row, back then the primary');
2109
2112
  assert.match(HUB, /<h2 class="stepsHead">Your steps — the parts only you can play<\/h2>/, 'the steps heading is the label face by class, not an inline style');
2110
2113
  // what is enqueued is unchanged (ADR 0111): the filing body the control plane reads
2111
2114
  assert.match(SCRIPT, /body: JSON\.stringify\(\{\s*slug: state\.slug,/, 'the filing still carries the slug first');
@@ -2114,7 +2117,69 @@ test('the wizard\'s own structures wear the world: hairline fields, cells of one
2114
2117
  assert.match(SCRIPT, /onboard_mode: state\.mode === 'adopt' \? 'adopt' : 'greenfield'/, 'the onboard mode is unchanged');
2115
2118
  // the kit walks every step and files against the stub
2116
2119
  const states = JSON.parse(fs.readFileSync(path.join(LANDING, 'projects.states.json'), 'utf8')).states;
2117
- for (const s of ['type', 'about', 'home', 'address', 'review', 'done']) assert.ok(states['in-new-' + s], `the ${s} step has a kit state`);
2120
+ for (const s of ['type', 'about', 'modules', 'home', 'address', 'review', 'done']) assert.ok(states['in-new-' + s], `the ${s} step has a kit state`);
2118
2121
  const probes = JSON.parse(fs.readFileSync(path.join(LANDING, 'projects.probes.json'), 'utf8')).probes;
2119
2122
  assert.ok(probes.some((p) => /the wizard driver \(task 1003346\)/.test(p.name) && p.steps.some((st) => st[1] === '#doCreate')), 'the wizard driver files a project end to end');
2120
2123
  });
2124
+
2125
+ test('the Modules step ships as real markup and is skippable like About (task 1002339)', () => {
2126
+ const panel = (HUB.match(/<section class="panel" id="panel-modules"[\s\S]*?<\/section>/) || [''])[0];
2127
+ assert.ok(panel, '#panel-modules ships as real markup, not built by script');
2128
+ assert.match(panel, /id="modCards"/, 'the band the picker paints its rows into');
2129
+ assert.match(panel, /class="typeCards"/, 'and it is the SAME band the type and team questions use — no new grammar');
2130
+ assert.match(panel, /id="modCoreNote"/, 'the always-on core is stated on the panel');
2131
+ // the skip must be its own control, not "leave it and press Next" — the rule
2132
+ // task 1002334 wrote for the one other optional step
2133
+ assert.match(panel, /id="skipModules"/, 'the skip is a control, not an inference');
2134
+ assert.match(panel, /Optional/, 'and the panel says so before it asks');
2135
+ // nothing in validateStep may gate it
2136
+ const validate = SCRIPT.slice(SCRIPT.indexOf('function validateStep('));
2137
+ const map = SCRIPT.match(/var panels = \{([^}]*)\}/)[1];
2138
+ const modStep = Number((map.match(/(\d+):\s*'panel-modules'/) || [, 0])[1]);
2139
+ assert.ok(modStep > 0, 'the Modules panel is in the map');
2140
+ assert.ok(!validate.slice(0, validate.indexOf('function ', 1)).includes(`if (step === ${modStep}) {`),
2141
+ 'the optional step has no validation clause — Next past an untouched picker is the point');
2142
+ });
2143
+
2144
+ test('the picker reads ONE public declaration, and it is the one the bundles come from (task 1002339)', () => {
2145
+ // A second source for "what may be chosen" is exactly what would let a
2146
+ // preselect name a module the panel has no row for (ADR 0237 §5).
2147
+ assert.match(SCRIPT, /fetch\(API \+ '\/provisioning\/starter-bundles'/, 'the picker reads the starter-bundles route');
2148
+ assert.equal((SCRIPT.match(/'\/provisioning\/starter-bundles'/g) || []).length, 1, 'exactly once');
2149
+ // and it is a public read — no other module-catalog call joins it
2150
+ assert.doesNotMatch(SCRIPT, /fetch\(API \+ '\/modules'/, 'no signed-in catalog read from a signed-out wizard');
2151
+ });
2152
+
2153
+ test('the picker\'s answer rides the create request, and an untouched picker sends nothing (task 1002339)', () => {
2154
+ // absent means SKIPPED, and the server then applies the same starter bundle
2155
+ // the panel preselected — the same contract `description` and `detail` carry
2156
+ const body = SCRIPT.slice(SCRIPT.indexOf('body: JSON.stringify({'));
2157
+ assert.ok(body.includes('modules: modulesForCreate(),'), 'the create body carries the picker\'s ids');
2158
+ assert.match(SCRIPT, /function modulesForCreate\(\) \{\s*return state\.modules === null \? undefined : state\.modules\.slice\(\);/,
2159
+ 'and modulesForCreate() omits the key while nothing has been said');
2160
+ });
2161
+
2162
+ test('the review names what the project will actually come with (task 1002339)', () => {
2163
+ // The picker is optional, so the last screen before Create must show the set
2164
+ // the owner is about to get — their picks, or the preselect they walked past.
2165
+ const render = SCRIPT.slice(SCRIPT.indexOf('function renderReview()'));
2166
+ const rows = render.slice(0, render.indexOf('$(\'reviewList\').innerHTML'));
2167
+ assert.match(rows, /\['Extras', extrasRow\(\)\]/, 'the ledger carries an Extras row');
2168
+ // What that row SAYS has three cases (picked / preselect / catalog not loaded)
2169
+ // and they live with the picker's own rules, executed by
2170
+ // tests/projects_hub_module_picker.mjs. This pins that the review defers to them.
2171
+ assert.match(SCRIPT, /function extrasRow\(\) \{/, 'and it is the picker that decides what it says');
2172
+ assert.match(SCRIPT, /'None — just the core'/, 'an empty selection reads as a fact, not a blank');
2173
+ });
2174
+
2175
+ test('the kit stub answers the picker\'s read, so the panel renders in the design kit (task 1002339)', () => {
2176
+ // The stub is not the server (this module's CLAUDE.md trap): it serves
2177
+ // fixtures. A 404 here would have rendered the couldn't-load clause in every
2178
+ // state and probe, and nobody would have seen the picker at all.
2179
+ const stub = fs.readFileSync(path.join(ROOT, 'modules', 'ui-design', 'kit', 'serve.js'), 'utf8');
2180
+ assert.match(stub, /rest === '\/provisioning\/starter-bundles'/, 'the stub serves the picker\'s read');
2181
+ const probes = JSON.parse(fs.readFileSync(path.join(LANDING, 'projects.probes.json'), 'utf8')).probes;
2182
+ assert.ok(probes.some((p) => /the wizard driver \(task 1003346\)/.test(p.name)
2183
+ && p.steps.some((st) => st[1] === '#panel-modules button[type=submit]')),
2184
+ 'and the end-to-end driver walks the new step rather than stopping at it');
2185
+ });