@bongos/core 1.19.773 → 1.19.775

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 (96) hide show
  1. package/.bongos-core.json +195 -80
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +5 -1
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/config/modules.neutral.json +1 -1
  8. package/docs/adr/0083-modular-architecture-strangler-build-plan.md +1 -0
  9. package/docs/adr/0177-goal-membership-kind.md +26 -2
  10. package/docs/adr/0181-teardown-state-gate-and-address-retention.md +2 -0
  11. package/docs/adr/0285-a-shared-box-holds-about-twelve-projects-per-gb-and-memory-is-the-wall.md +1 -1
  12. package/docs/adr/0288-the-goal-wall-has-a-shared-floor-for-paths-no-module-owns.md +33 -0
  13. package/docs/adr/0289-an-unconsumed-contribution-is-a-ci-failure-not-a-boot-refusal.md +33 -0
  14. package/docs/adr/0290-disconnect-is-the-owner-initiated-exception-to-address-retention.md +40 -0
  15. package/docs/adr/README.md +3 -0
  16. package/docs/api/openapi.json +102 -11
  17. package/docs/api-reference.md +5 -4
  18. package/docs/copy-inventory.md +205 -190
  19. package/docs/copy-registry.json +435 -269
  20. package/docs/file-map.md +1 -0
  21. package/docs/module-api-changelog.md +4 -0
  22. package/docs/modules-contract.md +1 -1
  23. package/modules/builder-settings/render-prefs.js +281 -33
  24. package/modules/builder-settings/routes/settings.js +20 -12
  25. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  26. package/modules/dev-box/module.json +4 -0
  27. package/modules/hall-ui/public/settings-render.js +138 -0
  28. package/modules/hall-ui/public/settings.html +16 -9
  29. package/modules/hall-ui/public/settings.js +3 -70
  30. package/modules/lifecycle/ship-card.js +49 -13
  31. package/modules/platform-identity/migrations/platform_identity_021_drop_legacy_role.sql +94 -0
  32. package/modules/platform-identity/platform-identity.js +8 -19
  33. package/modules/platform-identity/project-catalog.js +24 -0
  34. package/modules/platform-identity/routes/sso.js +11 -14
  35. package/modules/platform-identity/tests/platform-identity.mjs +7 -9
  36. package/modules/provisioning/capacity-gate.js +54 -0
  37. package/modules/provisioning/catalog-bridge.js +2 -0
  38. package/modules/provisioning/disconnect.js +35 -0
  39. package/modules/provisioning/migrations/provisioning_016_disconnect.sql +21 -0
  40. package/modules/provisioning/module.json +1 -1
  41. package/modules/provisioning/provisioning.js +3 -3
  42. package/modules/provisioning/routes/disconnect.js +63 -0
  43. package/modules/provisioning/routes/provisioning.js +6 -6
  44. package/modules/provisioning/starter-bundles.js +32 -30
  45. package/modules/public-landing/public/projects.html +116 -11
  46. package/modules/public-landing/public/projects.probes.json +3 -0
  47. package/modules/ui-design/kit/serve.js +5 -5
  48. package/modules/ui-design/module.json +1 -0
  49. package/package-lock.json +2 -2
  50. package/package.json +1 -1
  51. package/scripts/gds/address-park-sweep.sh +78 -0
  52. package/scripts/gds/cli-lib.js +37 -0
  53. package/scripts/gds/fitness-migration-contraction.js +50 -0
  54. package/scripts/gds/fitness.js +18 -0
  55. package/scripts/gds/land-watch.js +8 -2
  56. package/scripts/gds/provision-config.js +4 -4
  57. package/scripts/gds/provision-disconnect.js +90 -0
  58. package/scripts/gds/provision-teardown.js +108 -0
  59. package/scripts/gds/provision.js +6 -65
  60. package/scripts/gds/seam-wiring-guard.js +273 -0
  61. package/scripts/gds/ship-finish.js +17 -10
  62. package/scripts/gds/ship-flow.js +5 -5
  63. package/scripts/gds/start.js +13 -7
  64. package/src/bongos/auth-admission.js +7 -8
  65. package/src/bongos/auth-config.js +3 -2
  66. package/src/bongos/goal-scope-check.js +23 -8
  67. package/src/bongos/module-scope-map.js +40 -0
  68. package/src/module-api.js +1 -1
  69. package/src/module-loader/manifest-schema.js +79 -1
  70. package/tests/catalog_only_client.mjs +8 -5
  71. package/tests/fitness.mjs +22 -0
  72. package/tests/goal_scope_check.mjs +63 -1
  73. package/tests/hall_settings_world.mjs +57 -1
  74. package/tests/hub_disconnect_ui.mjs +105 -0
  75. package/tests/land_watch.mjs +44 -0
  76. package/tests/membership_kind_lockstep.mjs +86 -27
  77. package/tests/module-scope-map.mjs +39 -0
  78. package/tests/module_exports_unique_keys.mjs +70 -0
  79. package/tests/module_manifest.mjs +85 -0
  80. package/tests/project_catalog_forget.mjs +83 -0
  81. package/tests/project_type_editable.mjs +8 -8
  82. package/tests/projects_hub.mjs +10 -4
  83. package/tests/projects_hub_pre_uat.mjs +5 -3
  84. package/tests/provision_disconnect.mjs +231 -0
  85. package/tests/provision_teardown_address.mjs +230 -0
  86. package/tests/provisioning_capacity_gate.mjs +192 -0
  87. package/tests/provisioning_disconnect.mjs +231 -0
  88. package/tests/provisioning_module_selection.mjs +14 -14
  89. package/tests/provisioning_recommendations.mjs +17 -25
  90. package/tests/provisioning_restart.mjs +5 -5
  91. package/tests/provisioning_starter_bundles.mjs +31 -9
  92. package/tests/public_landing_projects.mjs +4 -3
  93. package/tests/render_prefs.mjs +152 -0
  94. package/tests/seam_wiring_guard.mjs +234 -0
  95. package/tests/ship_card.mjs +67 -1
  96. package/tests/sso_checkin_legacy_field_gone.mjs +246 -0
@@ -235,12 +235,12 @@ await ta('POST /instances: the picker\'s ids ride the create request through to
235
235
 
236
236
  // Act — the body the wizard files
237
237
  const res = await post('/provisioning/instances', {
238
- slug: 'mercury', hosting_shape: 'standalone', type: 'game', modules: ['dev-box', 'discord'],
238
+ slug: 'mercury', hosting_shape: 'standalone', type: 'game', modules: ['agents', 'discord'],
239
239
  });
240
240
 
241
241
  // Assert
242
242
  assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
243
- assert.deepEqual(lastCreateArgs && lastCreateArgs.modules, ['dev-box', 'discord']);
243
+ assert.deepEqual(lastCreateArgs && lastCreateArgs.modules, ['agents', 'discord']);
244
244
  });
245
245
 
246
246
  await ta('POST /instances: an omitted modules key is still a valid create — skipping is supported, not an error', async () => {
@@ -324,17 +324,17 @@ await ta('PATCH /detail: the reply carries modules, resolved from the row the UP
324
324
  detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: null, detail: {} };
325
325
  lastUpdatedRow = null;
326
326
 
327
- // Act — the owner says they are building it alone
328
- const res = await patch('/provisioning/instances/41/detail', { detail: { team_shape: 'solo' } });
327
+ // Act — the owner says an open community is building it
328
+ const res = await patch('/provisioning/instances/41/detail', { detail: { team_shape: 'community' } });
329
329
 
330
330
  // Assert — the field is there, and it is the POST-patch resolution: a route
331
- // that answered off `inst` instead of `updated` would still say 'dev-box'.
331
+ // that answered off `inst` instead of `updated` would still omit 'discord'.
332
332
  assert.equal(res.status, 200, res.raw);
333
333
  assert.ok(lastUpdatedRow, 'the route ran the update');
334
334
  assert.deepEqual(res.body.modules, effectiveModules(lastUpdatedRow));
335
- assert.deepEqual(res.body.modules.selected, ['discord'], 'solo drops the shared dev box out of the game bundle');
335
+ assert.deepEqual(res.body.modules.selected, ['discord'], 'community keeps the gathering place in the game bundle');
336
336
  assert.equal(res.body.modules.source, 'bundle');
337
- assert.ok(res.body.modules.adjustments.some((a) => a.module === 'dev-box'), 'and the change carries its reason');
337
+ assert.deepEqual(res.body.modules.adjustments, [], 'the game bundle already carried it, so nothing is claimed');
338
338
  detailRow = null; lastUpdatedRow = null;
339
339
  });
340
340
 
@@ -387,14 +387,14 @@ await ta('PATCH /detail: an owner who skipped the picker can add one module late
387
387
  await ta('PATCH /detail: a module can be REMOVED later too — the list replaces, it never merges', async () => {
388
388
  // The half a merging write would make impossible, which is why
389
389
  // updateInstanceDetail replaces `modules` and merges `detail`.
390
- detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: ['dev-box', 'discord'], detail: {} };
390
+ detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: ['agents', 'discord'], detail: {} };
391
391
  lastUpdatedRow = null;
392
392
 
393
393
  const res = await patch('/provisioning/instances/41/detail', { modules: ['discord'] });
394
394
 
395
395
  assert.equal(res.status, 200, res.raw);
396
396
  assert.deepEqual(lastUpdatedRow.modules, ['discord']);
397
- assert.deepEqual(res.body.modules.selected, ['discord'], 'dev-box is gone, not still merged in');
397
+ assert.deepEqual(res.body.modules.selected, ['discord'], 'agents is gone, not still merged in');
398
398
  assert.equal(res.body.modules.source, 'chosen');
399
399
  detailRow = null; lastUpdatedRow = null;
400
400
  });
@@ -517,17 +517,17 @@ await ta('PATCH /detail: another builder’s project is a 404, not a module chan
517
517
  // — not merely "close to one".
518
518
 
519
519
  await ta('PATCH /detail: answer then CLEAR — the row reads back exactly as one that was never asked', async () => {
520
- // Arrange — a game project that skipped the picker, so its modules resolve from
520
+ // Arrange — a research project that skipped the picker, so its modules resolve from
521
521
  // the bundle and the interview answer is the only thing that can move them.
522
- const virgin = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: null, detail: {} };
522
+ const virgin = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'research', modules: null, detail: {} };
523
523
  const neverAnswered = effectiveModules(virgin);
524
524
  assert.equal(neverAnswered.source, 'bundle', 'the fixture really is an unanswered project');
525
525
 
526
526
  detailRow = { ...virgin };
527
527
  lastUpdatedRow = null;
528
- const answered = await patch('/provisioning/instances/41/detail', { detail: { team_shape: 'solo' } });
528
+ const answered = await patch('/provisioning/instances/41/detail', { detail: { team_shape: 'community' } });
529
529
  assert.equal(answered.status, 200, answered.raw);
530
- assert.deepEqual(answered.body.detail, { team_shape: 'solo' });
530
+ assert.deepEqual(answered.body.detail, { team_shape: 'community' });
531
531
  assert.notDeepEqual(answered.body.modules.selected, neverAnswered.selected,
532
532
  'the fixture is only meaningful if the answer really moved the bundle');
533
533
 
@@ -543,7 +543,7 @@ await ta('PATCH /detail: answer then CLEAR — the row reads back exactly as one
543
543
  assert.deepEqual(cleared.body.detail, {}, 'and the read surface says the interview is unanswered');
544
544
  assert.deepEqual(cleared.body.modules, neverAnswered, 'source, preset and adjustments are the never-answered ones');
545
545
  assert.equal(cleared.body.modules.source, 'bundle');
546
- assert.deepEqual(cleared.body.modules.selected, bundleForProjectType('game').bundle, 'the UNADJUSTED preset');
546
+ assert.deepEqual(cleared.body.modules.selected, bundleForProjectType('research').bundle, 'the UNADJUSTED preset');
547
547
  assert.deepEqual(cleared.body.modules.adjustments, [], 'no rule fired, so nothing claims to have');
548
548
  assert.deepEqual(cleared.body.publish, answered.body.publish, 'clearing an answer is not a publish change');
549
549
  detailRow = null; lastUpdatedRow = null;
@@ -101,42 +101,34 @@ test('an open community adds somewhere to gather to a bundle that lacks one', ()
101
101
  assert.ok(recs.bundle.adjustments[0].reason.length > 0, 'an adjustment always says why');
102
102
  });
103
103
 
104
- test('a small team adds the shared build environment', () => {
105
- // Arrange
106
- const answers = { type: 'research', team_shape: 'small-team' };
107
-
108
- // Act
109
- const recs = recommendationsForDetails(answers);
110
-
111
- // Assert
112
- assert.deepEqual(recs.bundle.bundle, ['agents', 'dev-box']);
113
- assert.deepEqual(recs.bundle.adjustments.map((a) => a.module), ['dev-box']);
114
- });
115
-
116
- test('a solo builder drops the shared build environment from the bundle', () => {
117
- // Arrange — the business bundle is `agents` + `dev-box`, so the removal bites.
118
- const answers = { type: 'business', team_shape: 'solo' };
104
+ test('a community adds the gathering place to a bundle that lacks it', () => {
105
+ // The surviving bundle rule's ADD path. The two rules that added and removed
106
+ // `dev-box` by team shape went with the module (task 1003886), which leaves
107
+ // `effect: 'remove'` declared in adjustBundle but named by no live rule - it is
108
+ // kept for the next rule that needs it, not because anything exercises it today.
109
+ // Arrange - the research bundle is `agents` alone, so the addition bites.
110
+ const answers = { type: 'research', team_shape: 'community' };
119
111
 
120
112
  // Act
121
113
  const recs = recommendationsForDetails(answers);
122
114
 
123
115
  // Assert
124
- assert.deepEqual(recs.bundle.bundle, ['agents']);
125
- assert.equal(recs.bundle.adjustments.length, 1);
126
- assert.equal(recs.bundle.adjustments[0].effect, 'remove');
127
- assert.equal(recs.bundle.adjustments[0].module, 'dev-box');
116
+ assert.deepEqual(recs.bundle.bundle, ['agents', 'discord']);
117
+ assert.deepEqual(recs.bundle.adjustments.map((a) => a.module), ['discord']);
118
+ assert.equal(recs.bundle.adjustments[0].effect, 'add');
128
119
  });
129
120
 
130
121
  test('a rule does not fire for a team shape it does not name', () => {
131
- // Arrange — `solo` removes dev-box; `community` must leave it exactly alone.
132
- const answers = { type: 'business', team_shape: 'community' };
122
+ // Arrange - `community` adds discord; `solo` names no rule, so the business
123
+ // bundle must come back exactly as declared.
124
+ const answers = { type: 'business', team_shape: 'solo' };
133
125
 
134
126
  // Act
135
127
  const recs = recommendationsForDetails(answers);
136
128
 
137
129
  // Assert
138
- assert.ok(recs.bundle.bundle.includes('dev-box'), 'the solo removal did not leak onto another shape');
139
- assert.deepEqual(recs.bundle.adjustments.map((a) => a.rule), ['a-community-needs-somewhere-to-gather']);
130
+ assert.deepEqual(recs.bundle.bundle, ['agents'], 'the community addition did not leak onto another shape');
131
+ assert.deepEqual(recs.bundle.adjustments, [], 'and nothing is claimed');
140
132
  });
141
133
 
142
134
  test('a rule whose module the bundle already carries records no adjustment', () => {
@@ -304,8 +296,8 @@ test('a caller may mutate the result without corrupting the declaration', () =>
304
296
 
305
297
  // Assert
306
298
  const second = recommendationsForDetails({ type: 'game', team_shape: 'small-team' });
307
- assert.deepEqual(second.bundle.bundle, ['dev-box', 'discord']);
308
- assert.deepEqual(second.bundle.modules, [...ALWAYS_ON_CORE, 'dev-box', 'discord']);
299
+ assert.deepEqual(second.bundle.bundle, ['discord']);
300
+ assert.deepEqual(second.bundle.modules, [...ALWAYS_ON_CORE, 'discord']);
309
301
  assert.equal(second.invites.seeking.limit, 3);
310
302
  });
311
303
 
@@ -56,11 +56,11 @@ t("INTENT_ACTIONS includes 'restart', in lockstep with the provisioning_011 CHEC
56
56
  const sql = readFileSync(new URL('../modules/provisioning/migrations/provisioning_011_restart.sql', import.meta.url), 'utf8');
57
57
  const m = /CHECK \(action IN \(([^)]*)\)\)/.exec(sql);
58
58
  assert.ok(m, 'the migration widens the action CHECK');
59
- const inSql = m[1].split(',').map((s) => s.trim().replace(/^'|'$/g, '')).sort();
60
- // The NEWEST migration owns the strict set equality — the CHECK a live DB
61
- // ends up with is the last one applied, so this is the assertion that would
62
- // catch an action added in code but never admitted by the schema.
63
- assert.deepEqual(inSql, [...provisioning.INTENT_ACTIONS].sort(), 'the CHECK and INTENT_ACTIONS carry the same set');
59
+ const inSql = m[1].split(',').map((s) => s.trim().replace(/^'|'$/g, ''));
60
+ assert.ok(inSql.includes('restart'), 'this migration is the one that admitted it');
61
+ // The NEWEST action migration owns the strict set equality (provisioning_016,
62
+ // tests/provisioning_disconnect.mjs); an older one is a prefix of the set.
63
+ for (const a of inSql) assert.ok(provisioning.INTENT_ACTIONS.includes(a), `${a} is still an INTENT_ACTION`);
64
64
  assert.match(sql, /INSERT INTO schema_migrations \(version\) VALUES \('provisioning_011_restart'\)/, 'self-registers');
65
65
  });
66
66
 
@@ -37,6 +37,7 @@ import {
37
37
  OPTIONAL_MODULES,
38
38
  STARTER_BUNDLES,
39
39
  HUB_ONLY_MODULES,
40
+ RETIRED_MODULES,
40
41
  RULED_TEAM_SHAPES,
41
42
  bundleForProjectType,
42
43
  allStarterBundles,
@@ -335,6 +336,9 @@ test('the opt-in catalog declared for the picker IS the on-disk opt-in set', ()
335
336
  const onDisk = manifestsOnDisk();
336
337
  const optInOnDisk = [...onDisk.values()]
337
338
  .filter((m) => m.default !== true && !HUB_ONLY_MODULES.includes(m.key))
339
+ // A RETIRED module is still on disk while its removal lands, and must already
340
+ // be gone from the catalog — so it is the one permitted absence (task 1003886).
341
+ .filter((m) => !RETIRED_MODULES.includes(m.key))
338
342
  .map((m) => m.key)
339
343
  .sort();
340
344
 
@@ -346,6 +350,24 @@ test('the opt-in catalog declared for the picker IS the on-disk opt-in set', ()
346
350
  'the picker\'s catalog has drifted from the manifests — a module was added, removed, or flipped its default');
347
351
  });
348
352
 
353
+ test('a RETIRED module is withdrawn everywhere an owner could still reach it', () => {
354
+ // The retirement is only real if it holds at every offer point at once. A key
355
+ // left in any one of them is still a module a new project can end up with,
356
+ // which is exactly what the withdrawal exists to stop (task 1003886).
357
+ for (const key of RETIRED_MODULES) {
358
+ assert.ok(!OPTIONAL_MODULES.some((m) => m.key === key),
359
+ `retired module "${key}" is still a row the creation picker offers`);
360
+ for (const [type, bundle] of Object.entries(STARTER_BUNDLES)) {
361
+ assert.ok(!bundle.modules.includes(key),
362
+ `retired module "${key}" is still preselected by the "${type}" bundle`);
363
+ }
364
+ // Posting it directly is a real answer that resolves to nothing, not a
365
+ // non-answer: the key is DROPPED, exactly as an unknown one would be.
366
+ assert.deepEqual(normalizeModuleSelection([key]), [],
367
+ `retired module "${key}" is still accepted when an owner posts it directly`);
368
+ }
369
+ });
370
+
349
371
  test('every opt-in module carries owner-facing copy, and no module jargon rides along', () => {
350
372
  for (const row of OPTIONAL_MODULES) {
351
373
  assert.ok(row.label && row.label.length <= 40, `"${row.key}" needs a short label`);
@@ -411,24 +433,24 @@ test('effectiveModules: an answered picker wins over the bundle, including "none
411
433
  });
412
434
 
413
435
  test('effectiveModules: a skipped picker after an ANSWERED interview yields the TAILORED bundle', () => {
414
- // BV1.R22 / ADR 0243 — the property ADR 0240 §2 leans the skip on ("the
436
+ // BV1.R22 / ADR 0243 — the property ADR 0240 leans the skip on ("the
415
437
  // preselect and the fallback are the same declaration"), now that the panel
416
438
  // preselects the team-shape-adjusted set. Without this the picker would show a
417
- // solo owner one thing and the project would quietly get another.
418
- const eff = effectiveModules({ type: 'game', modules: null, detail: { team_shape: 'solo' } });
439
+ // community owner one thing and the project would quietly get another.
440
+ const eff = effectiveModules({ type: 'research', modules: null, detail: { team_shape: 'community' } });
419
441
 
420
- assert.deepEqual(eff.selected, ['discord'], 'the shared environment the rule removes is not silently restored');
442
+ assert.deepEqual(eff.selected, ['agents', 'discord'], 'the gathering place the rule adds is really there');
421
443
  assert.equal(eff.source, 'bundle', 'and it is still an UNANSWERED set, not a stored one');
422
- assert.deepEqual(eff.adjustments.map((a) => a.module), ['dev-box']);
423
- assert.match(eff.adjustments[0].reason, /building this alone/, 'carrying the reason the panel showed');
444
+ assert.deepEqual(eff.adjustments.map((a) => a.module), ['discord']);
445
+ assert.match(eff.adjustments[0].reason, /open community/, 'carrying the reason the panel showed');
424
446
  });
425
447
 
426
448
  test("effectiveModules: the owner's own answer is never re-adjusted by a rule", () => {
427
449
  // A rule corrects a PRESELECT the owner did not make. Once they have toggled,
428
450
  // re-applying it would overrule them with an answer they had already refused.
429
- const eff = effectiveModules({ type: 'game', modules: ['dev-box'], detail: { team_shape: 'solo' } });
451
+ const eff = effectiveModules({ type: 'research', modules: [], detail: { team_shape: 'community' } });
430
452
 
431
- assert.deepEqual(eff.selected, ['dev-box'], 'solo removes dev-box, and the owner put it back');
453
+ assert.deepEqual(eff.selected, [], 'community adds discord, and the owner switched everything off');
432
454
  assert.equal(eff.source, 'chosen');
433
455
  assert.deepEqual(eff.adjustments, [], 'and no reason is handed back for a choice they made themselves');
434
456
  });
@@ -439,7 +461,7 @@ test('effectiveModules: a detail that says nothing usable adjusts nothing', () =
439
461
  // importing it.
440
462
  for (const detail of [undefined, null, {}, { team_shape: null }, { team_shape: 'a-huge-corporation' }, 'not-an-object', []]) {
441
463
  const eff = effectiveModules({ type: 'game', modules: null, detail });
442
- assert.deepEqual(eff.selected, ['dev-box', 'discord'], `detail=${JSON.stringify(detail)} falls back to the type's bundle`);
464
+ assert.deepEqual(eff.selected, ['discord'], `detail=${JSON.stringify(detail)} falls back to the type's bundle`);
443
465
  assert.deepEqual(eff.adjustments, []);
444
466
  }
445
467
  });
@@ -138,7 +138,8 @@ test('the delete section is served live, and never claims to erase (task 1003042
138
138
  assert.match(body, /id="mDelWhat"/, 'the teardown-vs-delete fold is served');
139
139
  assert.match(body, /does <b>not<\/b> erase the project/i,
140
140
  'the distinction survives to the wire, not just the source');
141
- assert.match(body, /no permanent-delete button/i, 'the absent capability is admitted');
141
+ assert.match(body, /use <b>Disconnect from Bongos entirely<\/b> below/, 'the true delete is its own card (task 1003249), and the fold says where');
142
+ assert.match(body, /id="mDisconnect"/, 'and that card is served');
142
143
  // the confirmation asks for the project's own name — the heaviest gate on the page
143
144
  assert.match(body, /Type the project’s name to confirm/, 'the typed confirmation is served');
144
145
  assert.match(body, /data-del-yes disabled/, 'and the act starts unreachable');
@@ -146,8 +147,8 @@ test('the delete section is served live, and never claims to erase (task 1003042
146
147
 
147
148
  test('the served teardown copy no longer claims the address goes quiet (task 1003042)', async () => {
148
149
  const { body } = await request('/projects', 'game.test.local');
149
- // teardown has no DNS or Caddy leg: the hostname keeps resolving and 502s, so
150
- // this sentence was false on the wire. Task 1002897 owns actually releasing it.
150
+ // teardown parks the hostname and removes its Caddy block (task 1002897) but
151
+ // keeps the address for a revive, so "reserved" stays and "goes quiet" never did.
151
152
  assert.doesNotMatch(body, /the address stops answering/,
152
153
  'no served copy may promise a silence the runner does not deliver');
153
154
  assert.match(body, /stays reserved for this project/,
@@ -118,5 +118,157 @@ t('describeForApi reflects an explicit OFF override', () => {
118
118
  assert.equal(d.override, false);
119
119
  });
120
120
 
121
+ // ---- the card-richness ladder (task 1002518) --------------------------------
122
+ // The boolean became four rungs, and the rung a builder gets with no override is
123
+ // DERIVED from their interaction profile. These pin both halves, plus the
124
+ // compatibility surface every pinned CLI still reads.
125
+
126
+ console.log('\nthe ladder:');
127
+
128
+ t('LEVELS is ordered least → most rich, and rank follows the order', () => {
129
+ assert.deepEqual(r.LEVELS, ['minimal', 'text', 'standard', 'rich']);
130
+ assert.ok(r.levelRank('minimal') < r.levelRank('text'));
131
+ assert.ok(r.levelRank('text') < r.levelRank('standard'));
132
+ assert.ok(r.levelRank('standard') < r.levelRank('rich'));
133
+ });
134
+
135
+ t('the predicates say what each rung draws', () => {
136
+ // minimal: nothing but the confirmation line.
137
+ assert.equal(r.showsCard('minimal'), false);
138
+ assert.equal(r.showsWidget('minimal'), false);
139
+ // text: the monospace card, no HTML.
140
+ assert.equal(r.showsCard('text'), true);
141
+ assert.equal(r.showsWidget('text'), false);
142
+ // standard: the widget, summary folded.
143
+ assert.equal(r.showsWidget('standard'), true);
144
+ assert.equal(r.widgetExpanded('standard'), false);
145
+ // rich: the widget, summary open.
146
+ assert.equal(r.showsWidget('rich'), true);
147
+ assert.equal(r.widgetExpanded('rich'), true);
148
+ });
149
+
150
+ t('cardFlags bundles the three answers a card emitter needs', () => {
151
+ assert.deepEqual(r.cardFlags('rich'), { level: 'rich', showCard: true, showWidget: true, expanded: true });
152
+ assert.deepEqual(r.cardFlags('minimal'), { level: 'minimal', showCard: false, showWidget: false, expanded: false });
153
+ });
154
+
155
+ t('an unknown level rank-resolves to standard rather than throwing', () => {
156
+ // Defensive: a level string from an instance ahead of this code must degrade,
157
+ // not crash a ship. standard is the historical default rendering.
158
+ assert.equal(r.levelRank('nonsense'), r.levelRank('standard'));
159
+ });
160
+
161
+ console.log('\nderivation from the interaction profile:');
162
+
163
+ const withProfile = (interaction) => ({ interaction });
164
+
165
+ t('the DEFAULT builder (non-technical, concise) derives the RICHEST card', () => {
166
+ // The point of the task: the reader who most needs explanation, and who by
167
+ // definition never opened Settings, must not get the terser card.
168
+ assert.equal(r.derivedLevel({}), 'rich');
169
+ assert.equal(r.derivedLevel(null), 'rich');
170
+ assert.equal(r.resolveLevel({}), 'rich');
171
+ });
172
+
173
+ t('technical_level is the primary axis', () => {
174
+ assert.equal(r.derivedLevel(withProfile({ technical_level: 'non_technical' })), 'rich');
175
+ assert.equal(r.derivedLevel(withProfile({ technical_level: 'mixed' })), 'standard');
176
+ assert.equal(r.derivedLevel(withProfile({ technical_level: 'engineer' })), 'text');
177
+ });
178
+
179
+ t('machinery:quiet drops a rung — an expert who asked for less gets terse or none', () => {
180
+ assert.equal(r.derivedLevel(withProfile({ technical_level: 'engineer', machinery: 'quiet' })), 'minimal');
181
+ assert.equal(r.derivedLevel(withProfile({ technical_level: 'non_technical', machinery: 'quiet' })), 'standard');
182
+ });
183
+
184
+ t('verbosity:detailed raises a rung', () => {
185
+ assert.equal(r.derivedLevel(withProfile({ technical_level: 'engineer', verbosity: 'detailed' })), 'standard');
186
+ });
187
+
188
+ t('verbosity:concise does NOT drop a rung (it is the default — see the derivation note)', () => {
189
+ // Taxing the default would push the accessible-by-default builder from rich
190
+ // down to standard, inverting the whole point of deriving from the profile.
191
+ assert.equal(
192
+ r.derivedLevel(withProfile({ technical_level: 'non_technical', verbosity: 'concise' })),
193
+ r.derivedLevel(withProfile({ technical_level: 'non_technical' })),
194
+ );
195
+ });
196
+
197
+ t('the ladder clamps at both ends', () => {
198
+ // non_technical (rich) + detailed would be rank 4; engineer (text) + quiet
199
+ // twice over would go below 0. Neither may fall off the ladder.
200
+ assert.equal(r.derivedLevel(withProfile({ technical_level: 'non_technical', verbosity: 'detailed' })), 'rich');
201
+ assert.equal(r.derivedLevel(withProfile({ technical_level: 'engineer', machinery: 'quiet', verbosity: 'concise' })), 'minimal');
202
+ });
203
+
204
+ t('derivedReason names the fields that actually moved the level', () => {
205
+ const plain = r.derivedReason({});
206
+ assert.match(plain, /technical level: non-technical/);
207
+ assert.ok(!/machinery/.test(plain), 'a default field is not listed as a reason');
208
+ const quiet = r.derivedReason(withProfile({ technical_level: 'engineer', machinery: 'quiet' }));
209
+ assert.match(quiet, /machinery: quiet/);
210
+ });
211
+
212
+ console.log('\noverrides beat derivation:');
213
+
214
+ t('an explicit level override wins over the profile', () => {
215
+ const eng = { interaction: { technical_level: 'engineer' }, widgets: 'rich' };
216
+ assert.equal(r.derivedLevel(eng), 'text', 'the profile would have said text');
217
+ assert.equal(r.resolveLevel(eng), 'rich', 'the override wins');
218
+ assert.equal(r.overrideLevel(eng), 'rich');
219
+ });
220
+
221
+ t('clearing the override falls back to the derived level', () => {
222
+ const cleared = r.applyPatch({ widgets: 'minimal', interaction: { technical_level: 'mixed' } }, { widgets: null });
223
+ assert.ok(!('widgets' in cleared));
224
+ assert.equal(r.resolveLevel(cleared), 'standard', 'back to what the profile says');
225
+ assert.equal(r.overrideLevel(cleared), null);
226
+ });
227
+
228
+ console.log('\nlegacy boolean compatibility (a pinned CLI / an older hall build):');
229
+
230
+ t('a stored legacy boolean keeps the exact card it always meant', () => {
231
+ assert.equal(r.overrideLevel({ widgets: true }), 'standard');
232
+ assert.equal(r.overrideLevel({ widgets: false }), 'text');
233
+ // And it still beats the profile, because it IS an explicit choice.
234
+ assert.equal(r.resolveLevel({ widgets: false, interaction: { technical_level: 'non_technical' } }), 'text');
235
+ });
236
+
237
+ t('validatePatch still accepts booleans, normalising them to a level on the way in', () => {
238
+ assert.equal(r.validatePatch({ widgets: true }).patch.widgets, 'standard');
239
+ assert.equal(r.validatePatch({ widgets: false }).patch.widgets, 'text');
240
+ });
241
+
242
+ t('validatePatch accepts every level and rejects a non-level string', () => {
243
+ for (const lvl of r.LEVELS) {
244
+ assert.equal(r.validatePatch({ widgets: lvl }).ok, true, `accept ${lvl}`);
245
+ }
246
+ const bad = r.validatePatch({ widgets: 'verbose' });
247
+ assert.equal(bad.ok, false);
248
+ assert.equal(bad.errors[0].error, 'invalid_value');
249
+ });
250
+
251
+ t('widgetsEnabled — the boolean every pinned CLI reads — tracks the ladder', () => {
252
+ assert.equal(r.widgetsEnabled({ widgets: 'minimal' }), false);
253
+ assert.equal(r.widgetsEnabled({ widgets: 'text' }), false);
254
+ assert.equal(r.widgetsEnabled({ widgets: 'standard' }), true);
255
+ assert.equal(r.widgetsEnabled({ widgets: 'rich' }), true);
256
+ });
257
+
258
+ t('describeForApi keeps the legacy keys AND adds the ladder', () => {
259
+ const d = r.describeForApi({ interaction: { technical_level: 'engineer' } });
260
+ // legacy half — an older CLI reads only these and is correct
261
+ assert.equal(d.widgets_enabled, false, 'engineer derives text, so no widget');
262
+ assert.equal(d.default_enabled, true);
263
+ assert.equal(d.override, null, 'derived, not chosen');
264
+ // ladder half
265
+ assert.equal(d.level, 'text');
266
+ assert.equal(d.derived_level, 'text');
267
+ assert.equal(d.override_level, null);
268
+ assert.match(d.derived_reason, /technical level: engineer/);
269
+ assert.deepEqual(d.levels.map((l) => l.value), r.LEVELS);
270
+ assert.ok(d.levels.every((l) => l.label && l.blurb), 'every rung is labelled for the UI');
271
+ });
272
+
121
273
  console.log(`\n${passed} passed, ${failed} failed`);
122
274
  process.exit(failed === 0 ? 0 : 1);