@bongos/core 1.19.999 → 1.19.1000

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 (86) hide show
  1. package/.bongos-core.json +105 -90
  2. package/.claude/skills/collab-review/SKILL.md +24 -13
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +4 -0
  5. package/clients/bongos-client/index.cjs +4 -0
  6. package/clients/bongos-client/index.d.ts +7 -0
  7. package/clients/bongos-client/index.mjs +4 -0
  8. package/docs/adr/0330-an-ask-is-answered-where-it-was-asked.md +155 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +154 -4
  11. package/docs/api-reference.md +5 -3
  12. package/docs/copy-inventory.md +18 -13
  13. package/docs/copy-registry.json +70 -15
  14. package/docs/module-api-changelog.md +2 -0
  15. package/modules/hall-ui/public/collab-lib.js +79 -0
  16. package/modules/hall-ui/public/collab.css +60 -0
  17. package/modules/hall-ui/public/collab.js +134 -5
  18. package/modules/lifecycle/help-requests.js +118 -8
  19. package/modules/lifecycle/migrations/lifecycle_011_help_request_replies.sql +113 -0
  20. package/modules/lifecycle/routes/help-requests.js +93 -13
  21. package/modules/provisioning/capacity.js +3 -3
  22. package/modules/provisioning/control-plane-guard.js +1 -1
  23. package/modules/provisioning/core-upgrade.js +7 -7
  24. package/modules/provisioning/migrations/provisioning_027_cloud_host_shape_rename.sql +58 -0
  25. package/modules/provisioning/onboard-plan.js +1 -1
  26. package/modules/provisioning/paid-shape-gate.js +28 -15
  27. package/modules/provisioning/provisioning.js +18 -14
  28. package/modules/provisioning/routes/repo-private.js +1 -1
  29. package/modules/provisioning/tests/liveness.mjs +16 -8
  30. package/modules/provisioning/tests/provisioning.mjs +1 -1
  31. package/modules/public-landing/public/projects.html +26 -27
  32. package/modules/ui-design/kit/fixtures/provisioning-instances.json +1 -1
  33. package/package-lock.json +2 -2
  34. package/package.json +1 -1
  35. package/scripts/gds/init.js +12 -4
  36. package/scripts/gds/onboard.js +1 -1
  37. package/scripts/gds/provision-capacity.js +6 -1
  38. package/scripts/gds/provision-config.js +6 -3
  39. package/scripts/gds/provision-core-upgrade.js +8 -8
  40. package/scripts/gds/provision-disconnect.js +2 -2
  41. package/scripts/gds/provision-process.js +2 -2
  42. package/scripts/gds/provision-repo-private.js +1 -1
  43. package/scripts/gds/provision-repo.js +13 -3
  44. package/scripts/gds/provision-teardown.js +2 -2
  45. package/scripts/gds/provision-units.js +2 -2
  46. package/scripts/gds/provision.js +6 -6
  47. package/src/module-api.js +1 -1
  48. package/tests/collab_page.mjs +140 -7
  49. package/tests/core_upgrade_door.mjs +1 -1
  50. package/tests/core_upgrade_runner.mjs +20 -20
  51. package/tests/deploy_page_projects.mjs +5 -5
  52. package/tests/hall_collab_world.mjs +6 -2
  53. package/tests/help_requests.mjs +201 -6
  54. package/tests/hub_disconnect_ui.mjs +4 -1
  55. package/tests/init.mjs +6 -6
  56. package/tests/onboard.mjs +5 -4
  57. package/tests/project_catalog_backfill.mjs +1 -1
  58. package/tests/project_catalog_bridge.mjs +2 -2
  59. package/tests/project_catalog_routes.mjs +4 -4
  60. package/tests/projects_hub.mjs +1 -1
  61. package/tests/projects_hub_pre_uat.mjs +22 -5
  62. package/tests/projects_hub_repo_private.mjs +1 -1
  63. package/tests/provision.mjs +32 -32
  64. package/tests/provision_byo_host.mjs +1 -1
  65. package/tests/provision_caddy_wiring.mjs +1 -1
  66. package/tests/provision_disconnect.mjs +3 -3
  67. package/tests/provision_repo_private.mjs +1 -1
  68. package/tests/provision_restart.mjs +5 -5
  69. package/tests/provision_settings_apply.mjs +4 -4
  70. package/tests/provision_teardown_address.mjs +2 -2
  71. package/tests/provisioning_capacity.mjs +40 -34
  72. package/tests/provisioning_capacity_gate.mjs +5 -12
  73. package/tests/provisioning_default_domain.mjs +1 -1
  74. package/tests/provisioning_domain_attach.mjs +4 -4
  75. package/tests/provisioning_domain_unique.mjs +2 -2
  76. package/tests/provisioning_error_note_scope.mjs +1 -1
  77. package/tests/provisioning_module_selection.mjs +5 -5
  78. package/tests/provisioning_onboard_mode.mjs +2 -2
  79. package/tests/provisioning_paid_shape_gate.mjs +57 -44
  80. package/tests/provisioning_repo_private_route.mjs +1 -1
  81. package/tests/provisioning_restart.mjs +11 -4
  82. package/tests/provisioning_settings_apply.mjs +12 -5
  83. package/tests/provisioning_settings_env.mjs +1 -1
  84. package/tests/provisioning_teardown_intent.mjs +3 -3
  85. package/tests/public_landing_projects.mjs +13 -6
  86. package/tests/upgrade.mjs +2 -2
@@ -39,7 +39,7 @@ const { isValidSlug } = require('../../modules/provisioning/provisioning.js');
39
39
  // for one would be a request that is accepted and never lands. The route refuses the
40
40
  // same shape; this is the second gate, and it is the one that holds if a row's shape
41
41
  // changes between the request and the drain.
42
- const UPGRADE_SHAPES = Object.freeze(new Set(['co-tenant', 'standalone', 'control-plane']));
42
+ const UPGRADE_SHAPES = Object.freeze(new Set(['co-tenant', 'cloud-host', 'control-plane']));
43
43
 
44
44
  // The strict three-number form a core version takes. Re-stated here rather than imported
45
45
  // from the web tier's core-upgrade.js: a module may not be required from scripts/ (ADR
@@ -152,7 +152,7 @@ async function coreUpgradeInstance(inst, deps, intent) {
152
152
  const { db, provisioning, exec, log, apply, privileged } = deps;
153
153
  // A standalone's box IS the control plane's box, the same routing restartInstance and
154
154
  // settingsApplyInstance use; a co-tenant shares this checkout.
155
- const boxExec = inst.hosting_shape === 'standalone' ? (deps.controlExec || exec) : exec;
155
+ const boxExec = inst.hosting_shape === 'cloud-host' ? (deps.controlExec || exec) : exec;
156
156
  const to = intent && intent.target_version;
157
157
  log(`core-upgrade ${inst.slug} → ${to || '(no target)'} (shape=${inst.hosting_shape}, status=${inst.status})`);
158
158
 
@@ -190,7 +190,7 @@ async function coreUpgradeInstance(inst, deps, intent) {
190
190
  // ---- 2. is the target real, and permitted -----------------------------------------
191
191
  const channelMod = deps.updateChannel || require('./update-channel.js');
192
192
  const selfRoot = deps.selfInstanceRoot || selfInstanceRoot;
193
- const instanceDir = inst.hosting_shape === 'standalone' ? standaloneRoot(inst) : selfRoot();
193
+ const instanceDir = inst.hosting_shape === 'cloud-host' ? standaloneRoot(inst) : selfRoot();
194
194
  const readInstalled = deps.readInstalledCoreVersion || require('./upgrade.js').readInstalledCoreVersion;
195
195
  const installed = readInstalled(instanceDir);
196
196
  // THE PLATFORM DEFAULT, NOT A PER-INSTANCE CHANNEL — and saying so beats implying
@@ -325,7 +325,7 @@ function resolveRunAs(inst, deps, { probe: allowProbe = true } = {}) {
325
325
  // probe on `exec` would then ask the co-hosting box whether an account exists while
326
326
  // the command it is deciding for runs on the control plane — a wrong answer that
327
327
  // looks like a right one.
328
- const boxExec = inst.hosting_shape === 'standalone' ? (deps.controlExec || deps.exec) : deps.exec;
328
+ const boxExec = inst.hosting_shape === 'cloud-host' ? (deps.controlExec || deps.exec) : deps.exec;
329
329
  const r = boxExec(`id -u ${wanted}`, { allowFail: true });
330
330
  const failed = !r || r.softFailed === true || r.ok === false;
331
331
  if (!failed) return { runAs: wanted, fellBack: false };
@@ -371,7 +371,7 @@ function upgradeInvocation(inst, to, { privileged = false, dryRun = false, reque
371
371
  // just to node_modules/@bongos/core — and the door aimed at the package instead of
372
372
  // the project. So ask, rather than assume; in the legacy shape the answer is
373
373
  // REPO_ROOT and the command below is byte-identical to what it always was.
374
- const instanceDir = inst.hosting_shape === 'standalone' ? standaloneRoot(inst) : selfRoot();
374
+ const instanceDir = inst.hosting_shape === 'cloud-host' ? standaloneRoot(inst) : selfRoot();
375
375
  // WHICH ACCOUNT, AND WHY IT IS RESOLVED RATHER THAN DERIVED (task 1004128).
376
376
  //
377
377
  // Task 1004119 wrote this as `control-plane ? APP_USER : instanceUser(inst)` and was
@@ -409,7 +409,7 @@ function upgradeInvocation(inst, to, { privileged = false, dryRun = false, reque
409
409
  // is the interesting answer, not an exception: pre-flight refusing is what this is for.
410
410
  function preflightUpgrade(inst, to, deps) {
411
411
  const { exec, privileged } = deps;
412
- const boxExec = inst.hosting_shape === 'standalone' ? (deps.controlExec || exec) : exec;
412
+ const boxExec = inst.hosting_shape === 'cloud-host' ? (deps.controlExec || exec) : exec;
413
413
  // A distinct local name: `deps.selfInstanceRoot || selfInstanceRoot` with a const of
414
414
  // the same name would TDZ-shadow the import and crash every uninjected call.
415
415
  const selfRoot = deps.selfInstanceRoot || selfInstanceRoot;
@@ -718,10 +718,10 @@ async function preflightCoreUpgrade(inst, deps, intent) {
718
718
  async function snapshotCoreVersions(inst, deps) {
719
719
  const { db, exec, log } = deps;
720
720
  if (!UPGRADE_SHAPES.has(inst.hosting_shape) || inst.status !== 'active') return { ok: true, skipped: true };
721
- const boxExec = inst.hosting_shape === 'standalone' ? (deps.controlExec || exec) : exec;
721
+ const boxExec = inst.hosting_shape === 'cloud-host' ? (deps.controlExec || exec) : exec;
722
722
  const channelMod = deps.updateChannel || require('./update-channel.js');
723
723
  const selfRoot = deps.selfInstanceRoot || selfInstanceRoot;
724
- const instanceDir = inst.hosting_shape === 'standalone' ? standaloneRoot(inst) : selfRoot();
724
+ const instanceDir = inst.hosting_shape === 'cloud-host' ? standaloneRoot(inst) : selfRoot();
725
725
  try {
726
726
  const probe = boxExec(versionCmd(inst), { allowFail: true });
727
727
  const body = probe && !probe.softFailed ? (probe.stdout || '') : null;
@@ -49,7 +49,7 @@ function eraseCommands(inst, { privileged = false } = {}) {
49
49
  `${sudo}find ${CONFIG.backupDir} -maxdepth 1 -type f -name '${dumpGlob(inst)}' -delete`,
50
50
  `${sudo}rm -rf ${path.posix.dirname(webEnvPath(inst))} ${instanceStateDir(inst)}`,
51
51
  ];
52
- if (inst.hosting_shape === 'standalone') cmds.push(`${sudo}rm -rf ${standaloneRoot(inst)}`);
52
+ if (inst.hosting_shape === 'cloud-host') cmds.push(`${sudo}rm -rf ${standaloneRoot(inst)}`);
53
53
  cmds.push(`if id -u ${user} >/dev/null 2>&1; then ${sudo}userdel ${user}; fi`);
54
54
  return cmds;
55
55
  }
@@ -63,7 +63,7 @@ async function disconnectInstance(inst, deps) {
63
63
  await teardownInstance(inst, deps);
64
64
 
65
65
  if (inst.hosting_shape !== 'dedicated') {
66
- const boxExec = inst.hosting_shape === 'standalone' ? (deps.controlExec || exec) : exec;
66
+ const boxExec = inst.hosting_shape === 'cloud-host' ? (deps.controlExec || exec) : exec;
67
67
  for (const cmd of eraseCommands(inst, { privileged })) {
68
68
  try { boxExec(cmd); }
69
69
  catch (e) { return { ok: false, error: `erase step failed (${cmd.split(' ').slice(0, 3).join(' ')}): ${(e && e.message) || e}` }; }
@@ -75,13 +75,13 @@ function supervisorConfPath(inst) {
75
75
  // cannot drift: a standalone runs out of its own pinned core, anything else out of this
76
76
  // checkout.
77
77
  function programCommand(inst) {
78
- return inst.hosting_shape === 'standalone'
78
+ return inst.hosting_shape === 'cloud-host'
79
79
  ? `/usr/bin/node ${CORE_PKG_DIR}/src/platform-server.js`
80
80
  : '/usr/bin/node src/platform-server.js';
81
81
  }
82
82
 
83
83
  function supervisorProgram(inst, { etcBase = CONFIG.etcBase } = {}) {
84
- const workDir = inst.hosting_shape === 'standalone' ? standaloneRoot(inst) : REPO_ROOT;
84
+ const workDir = inst.hosting_shape === 'cloud-host' ? standaloneRoot(inst) : REPO_ROOT;
85
85
  return `[program:${inst.slug}]
86
86
  ; Cloud Bongos instance — ${inst.slug} (provisioned by provision.js, ADR 0306)
87
87
  command=${programCommand(inst)}
@@ -40,7 +40,7 @@ async function repoPrivateInstance(inst, deps) {
40
40
 
41
41
  // Only a standalone project has its own checkout to wire; any other shape reads from a
42
42
  // checkout this project does not own, so a key minted here would change nothing.
43
- if (inst.hosting_shape !== 'standalone') {
43
+ if (inst.hosting_shape !== 'cloud-host') {
44
44
  return { ok: false, error: 'This project shares its code checkout with others, so its repository cannot be made private from here.' };
45
45
  }
46
46
  if (inst.status !== 'active') {
@@ -79,8 +79,18 @@ function seedFirstVersionCmd(inst, { privileged = false } = {}) {
79
79
  return asPostgres(`psql -v ON_ERROR_STOP=1 -d ${dbName(inst)} -c '${sql}'`, privileged);
80
80
  }
81
81
 
82
- // ── STANDALONE shape (hosting_shape='standalone', ADR 0108, task 1978) ────────
83
- // A standalone instance runs from its OWN repo checkout (host content: config/,
82
+ // ── The CLOUD-HOST shape (hosting_shape='cloud-host', ADR 0108, task 1978) ────
83
+ //
84
+ // TWO SENSES OF "STANDALONE", AND THIS FILE USES BOTH. The ADR 0108 sense is a repo
85
+ // TOPOLOGY: an instance running from its own checkout with the core as an npm dependency,
86
+ // as opposed to co-tenant's shared control-plane checkout. That is what `standaloneRoot`,
87
+ // `standaloneBase` and `PROVISION_STANDALONE_BASE` name, and those keep their spelling —
88
+ // `PROVISION_STANDALONE_BASE` in particular is a live environment variable on the control
89
+ // plane and is documented in the /new-project skill. The other sense was the HOSTING SHAPE
90
+ // once stored as 'standalone'; task 1004182 renamed that value to 'cloud-host'. Only the
91
+ // stored value moved. Do not "finish the rename" by touching the topology names.
92
+ //
93
+ // A cloud-host instance runs from its OWN repo checkout (host content: config/,
84
94
  // migrations/instance/, the core PIN in package.json) with @bongos/core installed
85
95
  // as an npm DEPENDENCY — NOT the shared control-plane checkout the co-tenant shape uses.
86
96
  // It is the ADR 0108 DESTINATION model; these generators make provision.js drive the
@@ -883,7 +893,7 @@ function deployKeyPath(inst) { return path.posix.join(standaloneRoot(inst), '.gi
883
893
  async function ensurePrivateRepoAccess(inst, deps) {
884
894
  const { log, apply } = deps;
885
895
  const exec = deps.controlExec || deps.exec;
886
- if (inst.hosting_shape !== 'standalone') return { skipped: 'shape' };
896
+ if (inst.hosting_shape !== 'cloud-host') return { skipped: 'shape' };
887
897
  const repo = parseTargetRef(inst.target_ref);
888
898
  if (!repo) return { skipped: 'no_repo' };
889
899
  // Dry-run stays OFFLINE like every other plan path here (the checkDnsTokenScope
@@ -24,7 +24,7 @@ async function teardownInstance(inst, deps) {
24
24
  // local (task 2074), so disable/remove its unit HERE — not on the remote co-hosting
25
25
  // box, where it was never installed (that would allowFail-noop and leave the real
26
26
  // control-plane unit running). Co-tenant + dedicated are unchanged.
27
- const boxExec = inst.hosting_shape === 'standalone' ? controlExec : exec;
27
+ const boxExec = inst.hosting_shape === 'cloud-host' ? controlExec : exec;
28
28
  const sudoP = privileged ? 'sudo ' : ''; // systemctl / rm-under-/etc escalation (task 2066)
29
29
  log(`teardown ${inst.slug} (status=${inst.status})`);
30
30
  if (apply) await provisioning.setInstanceStatus(db, inst.id, 'tearing_down');
@@ -47,7 +47,7 @@ async function teardownInstance(inst, deps) {
47
47
  // files must NOT ride along — and a revoke skip must be LOUD: a torn-down project
48
48
  // silently keeping durable read access to the owner's private repo is the exact
49
49
  // shape a teardown exists to end. A missing token / API hiccup never blocks it.
50
- if (inst.hosting_shape === 'standalone' && inst.target_ref) {
50
+ if (inst.hosting_shape === 'cloud-host' && inst.target_ref) {
51
51
  const repo = parseTargetRef(inst.target_ref);
52
52
  if (repo) {
53
53
  try {
@@ -16,7 +16,7 @@ const { CORE_PKG_DIR, appUser, dbName, instanceDbRole, instanceStateDir, instanc
16
16
  function serviceUnit(inst, { etcBase = CONFIG.etcBase } = {}) {
17
17
  const envFile = path.posix.join(etcBase, inst.slug, 'web.env');
18
18
  const user = instanceUser(inst), stateDir = instanceStateDir(inst);
19
- const standalone = inst.hosting_shape === 'standalone';
19
+ const standalone = inst.hosting_shape === 'cloud-host';
20
20
  const workDir = standalone ? standaloneRoot(inst) : REPO_ROOT;
21
21
  const execStart = standalone
22
22
  ? `/usr/bin/node ${CORE_PKG_DIR}/src/platform-server.js`
@@ -91,7 +91,7 @@ function backupTimerPath(inst) { return `/etc/systemd/system/${backupUnitName(in
91
91
  // scripts/gds/ — mirrors serviceUnit's execStart resolution so they can't diverge.
92
92
  function backupScriptPath(inst) {
93
93
  const rel = 'scripts/gds/db-backup-nightly.sh';
94
- return inst.hosting_shape === 'standalone'
94
+ return inst.hosting_shape === 'cloud-host'
95
95
  ? path.posix.join(standaloneRoot(inst), CORE_PKG_DIR, rel)
96
96
  : path.posix.join(REPO_ROOT, rel);
97
97
  }
@@ -238,7 +238,7 @@ async function provisionInstance(inst, deps) {
238
238
  // So a standalone uses the control-plane executors; co-tenant + dedicated are
239
239
  // unchanged (boxExec === exec, boxWriteFile === writeFile). When no remoteHost is
240
240
  // set, controlExec === exec anyway, so this is a no-op for the all-local default.
241
- const standalone = inst.hosting_shape === 'standalone';
241
+ const standalone = inst.hosting_shape === 'cloud-host';
242
242
  const boxExec = standalone ? controlExec : exec;
243
243
  const boxWriteFile = standalone ? (deps.controlWriteFile || writeFile) : writeFile;
244
244
  const sudoP = privileged ? 'sudo ' : ''; // systemctl escalation prefix (task 2066)
@@ -372,7 +372,7 @@ async function provisionInstance(inst, deps) {
372
372
  // materialize config/ + .claude/ + file:-pinned core + lockfile → commit + push
373
373
  // (ADR 0108 / 0125, task 2055). Replaces the old "repo must already be cloned"
374
374
  // throw: the greenfield flow now has no manual clone step. Idempotent + dry-run-safe.
375
- if (inst.hosting_shape === 'standalone') {
375
+ if (inst.hosting_shape === 'cloud-host') {
376
376
  await scaffoldStandaloneRepo(inst, deps);
377
377
  // A PRIVATE target_ref gets its own read-only deploy key BEFORE step 2's
378
378
  // `git pull --ff-only origin main` — the pull that failed unauthenticated
@@ -740,7 +740,7 @@ async function domainAttachInstance(inst, deps) {
740
740
  const controlExec = deps.controlExec || exec;
741
741
  // Same box routing as provisionInstance: a standalone runs control-plane-local
742
742
  // (task 2074), so its env/caddy/service live HERE, not on the co-hosting box.
743
- const standalone = inst.hosting_shape === 'standalone';
743
+ const standalone = inst.hosting_shape === 'cloud-host';
744
744
  const boxExec = standalone ? controlExec : exec;
745
745
  const boxWriteFile = standalone ? (deps.controlWriteFile || writeFile) : writeFile;
746
746
  const sudoP = privileged ? 'sudo ' : '';
@@ -876,7 +876,7 @@ async function domainAttachInstance(inst, deps) {
876
876
  async function settingsApplyInstance(inst, deps) {
877
877
  const { db, provisioning, exec, writeFile, log, apply, privileged } = deps;
878
878
  // Same box routing as domainAttachInstance: a standalone runs control-plane-local.
879
- const standalone = inst.hosting_shape === 'standalone';
879
+ const standalone = inst.hosting_shape === 'cloud-host';
880
880
  const boxExec = standalone ? (deps.controlExec || exec) : exec;
881
881
  const boxWriteFile = standalone ? (deps.controlWriteFile || writeFile) : writeFile;
882
882
  const sudoP = privileged ? 'sudo ' : '';
@@ -992,7 +992,7 @@ async function settingsApplyInstance(inst, deps) {
992
992
  async function restartInstance(inst, deps) {
993
993
  const { db, provisioning, exec, log, apply, privileged } = deps;
994
994
  // Same box routing as settingsApplyInstance: a standalone runs control-plane-local.
995
- const boxExec = inst.hosting_shape === 'standalone' ? (deps.controlExec || exec) : exec;
995
+ const boxExec = inst.hosting_shape === 'cloud-host' ? (deps.controlExec || exec) : exec;
996
996
  const sudoP = privileged ? 'sudo ' : '';
997
997
  log(`restart ${inst.slug} (shape=${inst.hosting_shape}, status=${inst.status})`);
998
998
 
@@ -1124,7 +1124,7 @@ async function placeManifestCreds(inst, mrow, deps) {
1124
1124
  return { ok: false, error: 'GitHub App Manifest cred placement is not supported for a dedicated droplet yet — run oauth-secret.js on the droplet instead' };
1125
1125
  }
1126
1126
  if (!mrow.github_code) return { ok: false, error: 'manifest row carries no github_code to convert' };
1127
- const standalone = inst.hosting_shape === 'standalone';
1127
+ const standalone = inst.hosting_shape === 'cloud-host';
1128
1128
  const boxExec = standalone ? (deps.controlExec || deps.exec) : deps.exec;
1129
1129
  const boxWriteFile = standalone ? (deps.controlWriteFile || deps.writeFile) : deps.writeFile;
1130
1130
  const sudoP = deps.privileged ? 'sudo ' : '';
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.999'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.1000'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -565,12 +565,138 @@ test('the asker can settle in place, with both outcomes', () => {
565
565
  assert.match(js, /patchHelpRequestsId/, 'through the real API, not a local edit');
566
566
  });
567
567
 
568
- test('there is deliberately NO reply thread — that would reimplement the Agora', () => {
569
- // The goal forbids reimplementing the Agora to get around C06's dependency.
570
- const js = read('modules', 'hall-ui', 'public', 'collab.js');
571
- for (const f of ['reply', 'comment', 'thread', 'message']) {
572
- assert.ok(!new RegExp(`data-${f}|post${f}`, 'i').test(js), `no ${f} surface`);
568
+ // REPLACED, not deleted (task 1004216). This test used to read "there is
569
+ // deliberately NO reply thread — that would reimplement the Agora", deferring
570
+ // replies to task 1002946 under goal 1000064. That task is ABANDONED and that
571
+ // goal is ARCHIVED, so the deferral had no addressee left: the surface that was
572
+ // to own replies will never be built, and an ask stayed unanswerable to keep a
573
+ // promise to a thing that does not exist. ADR 0330 records the reversal.
574
+ //
575
+ // What the old test was PROTECTING is still real, so it is what these assert:
576
+ // a thread here must not grow into the chat surface ADR 0187 §4a declined.
577
+ test('an ask can be answered — and the answer surface stops well short of a chat', () => {
578
+ const src = read('modules', 'hall-ui', 'public', 'collab.js');
579
+ // Comments stripped before the bans are checked: this file ARGUES at length
580
+ // about the four things it is not building, and a scan that read the argument
581
+ // as the thing would fail on the very prose explaining the restraint.
582
+ const js = src.replace(/^\s*\/\/.*$/gm, '');
583
+ assert.match(js, /data-reply-form/, 'there is somewhere to write an answer');
584
+ assert.match(js, /POST', `\/help-requests\/\$\{id\}\/replies`/, 'and it posts to the replies route');
585
+
586
+ // The four things a chat has that this must not grow.
587
+ assert.ok(!/data-edit|patchReply|editReply/i.test(js), 'no edit — a reply is what was said at the time');
588
+ assert.ok(!/deleteReply|data-reply-delete/i.test(js), 'no delete');
589
+ assert.ok(!/data-react|reaction|emoji/i.test(js), 'no reactions');
590
+ assert.ok(!/parent_reply|data-reply-to|@mention|mentions/i.test(js), 'no nesting and no mentions');
591
+ });
592
+
593
+ // RUN, not grepped — this file's own convention for everything in collab-lib.js,
594
+ // and the grader was right that the first pass broke it. canClose in particular
595
+ // decides whether a close control is drawn at all, so each branch of it needs a
596
+ // test that calls it rather than a regex that agrees with a wrong answer.
597
+ test('canClose: the asker and the addressee, and nobody else', () => {
598
+ const L = lib();
599
+ const craftAsk = { requested_by: '90', needs_craft: 'artist', needs_builder_id: null };
600
+ const namedAsk = { requested_by: '90', needs_craft: null, needs_builder_id: '7' };
601
+
602
+ assert.equal(L.canClose(namedAsk, 90, []), true, 'the asker always may');
603
+ assert.equal(L.canClose(namedAsk, 7, []), true, 'so may the person it names');
604
+ assert.equal(L.canClose(craftAsk, 7, ['artist']), true, 'or a craft they belong to');
605
+
606
+ assert.equal(L.canClose(craftAsk, 7, ['builder']), false, 'a craft they do not belong to is not theirs to close');
607
+ assert.equal(L.canClose(namedAsk, 8, ['artist']), false, 'a stranger to a by-name ask');
608
+ assert.equal(L.canClose(craftAsk, 7, []), false, 'no declared craft reaches no craft ask');
609
+ });
610
+
611
+ test('canClose fails CLOSED on every missing fact', () => {
612
+ // The page reads meId from /me. A failed read leaves it null, and the correct
613
+ // answer then is "draw nothing" — never "draw it and let the server refuse".
614
+ const L = lib();
615
+ const ask = { requested_by: '90', needs_builder_id: '7' };
616
+ for (const bad of [null, undefined]) {
617
+ assert.equal(L.canClose(ask, bad, ['artist']), false, `meId ${bad} draws no control`);
618
+ }
619
+ assert.equal(L.canClose(null, 7, ['artist']), false, 'no request, no control');
620
+ assert.equal(L.canClose({ requested_by: '90', needs_craft: null }, 7, null), false,
621
+ 'a non-array craft list is not iterated into a yes');
622
+ });
623
+
624
+ test('canClose compares ids ACROSS types — the API sends strings, /me sends what it sends', () => {
625
+ // The live bug this forecloses: needs_builder_id arrives as "7" and meId as 7,
626
+ // and === between them is false, so the person a request names would silently
627
+ // lose their own close control.
628
+ const L = lib();
629
+ assert.equal(L.canClose({ requested_by: '90', needs_builder_id: '7' }, 7, []), true);
630
+ assert.equal(L.canClose({ requested_by: 90, needs_builder_id: 7 }, '7', []), true);
631
+ });
632
+
633
+ test('threadOf: oldest first, ties broken on id, and never explodes on a missing thread', () => {
634
+ const L = lib();
635
+ const out = L.threadOf({ replies: [
636
+ { id: 3, created_at: '2026-09-22T10:00:00Z' },
637
+ { id: 1, created_at: '2026-09-20T10:00:00Z' },
638
+ { id: 2, created_at: '2026-09-22T10:00:00Z' },
639
+ ] });
640
+ assert.deepEqual(out.map((r) => r.id), [1, 2, 3], 'two replies in the same second keep the order written');
641
+ assert.deepEqual(L.threadOf({}), [], 'a row with no replies field');
642
+ assert.deepEqual(L.threadOf(null), []);
643
+ assert.deepEqual(L.threadOf({ replies: 'nonsense' }), [], 'and a field that is not a list');
644
+ });
645
+
646
+ test('saidLabel is the PAST tense — an answer is not a debt', () => {
647
+ // ageLabel says "waiting 3 days", which under an answer reads as the answer
648
+ // still being owed: the exact opposite of what a reply means.
649
+ const L = lib();
650
+ const NOW2 = Date.parse('2026-09-23T12:00:00Z');
651
+ const ago = (d) => new Date(NOW2 - d * 86400000).toISOString();
652
+ assert.equal(L.saidLabel(ago(0), NOW2), 'today');
653
+ assert.equal(L.saidLabel(ago(1), NOW2), 'yesterday');
654
+ assert.equal(L.saidLabel(ago(3), NOW2), '3 days ago');
655
+ assert.equal(L.saidLabel(ago(21), NOW2), '3 weeks ago');
656
+ assert.equal(L.saidLabel('nonsense', NOW2), 'just now', 'an unknown time is not an accusation');
657
+ for (const label of [L.saidLabel(ago(3), NOW2), L.saidLabel(ago(21), NOW2)]) {
658
+ assert.ok(!/waiting/.test(label), `"${label}" must not borrow the ask's tense`);
659
+ }
660
+ });
661
+
662
+ test('replyAuthor names somebody, whatever the server sent', () => {
663
+ const L = lib();
664
+ // Neutral names on purpose: this is the portable core, and a founder's login
665
+ // baked into a fixture is what audit-authorship.sh exists to catch.
666
+ assert.equal(L.replyAuthor({ builder_name: 'Preview Alpha', builder_login: 'preview-alpha' }), 'Preview Alpha');
667
+ assert.equal(L.replyAuthor({ builder_login: 'preview-alpha' }), 'preview-alpha', 'the login when there is no display name');
668
+ assert.equal(L.replyAuthor({ builder_id: 7 }), 'builder 7', 'the bare id an older server sends');
669
+ assert.equal(L.replyAuthor({}), 'someone', 'never an empty byline');
670
+ assert.equal(L.replyAuthor(null), '');
671
+ });
672
+
673
+ test('validateReplyBody refuses before the round trip, and trims what it accepts', () => {
674
+ const L = lib();
675
+ assert.deepEqual(L.validateReplyBody(' moot '), { ok: true, body: 'moot' });
676
+ assert.equal(L.validateReplyBody(' ').ok, false);
677
+ assert.equal(L.validateReplyBody(' ').reason, 'empty');
678
+ assert.equal(L.validateReplyBody(null).reason, 'empty');
679
+ assert.equal(L.validateReplyBody('x'.repeat(L.MAX_REPLY + 1)).reason, 'too_long');
680
+ assert.equal(L.validateReplyBody('x'.repeat(L.MAX_REPLY)).ok, true, 'the cap itself is allowed');
681
+ });
682
+
683
+ test('append-only is enforced by the DATABASE, not by the routes that exist today', () => {
684
+ // A route layer that merely omits an update is one careless PR from allowing
685
+ // one. The table refuses an UPDATE or a DELETE outright, so the guarantee
686
+ // holds against a future route, a migration, or a hand-run statement.
687
+ const raw = read('modules', 'lifecycle', 'migrations', 'lifecycle_011_help_request_replies.sql');
688
+ assert.match(raw, /BEFORE UPDATE OR DELETE ON lifecycle_help_request_replies/);
689
+ assert.match(raw, /RAISE EXCEPTION/, 'and it raises rather than silently ignoring the write');
690
+ // Same reason as above — the migration's header names the columns it refuses
691
+ // to have, so the ban is checked against the STATEMENTS, not the argument.
692
+ const sql = raw.replace(/^\s*--.*$/gm, '');
693
+ for (const col of ['updated_at', 'edited_by', 'deleted_at', 'parent_reply_id']) {
694
+ assert.ok(!new RegExp(`\\b${col}\\b`).test(sql), `no ${col} column — the shape refuses to become a chat`);
573
695
  }
696
+
697
+ const routes = read('modules', 'lifecycle', 'routes', 'help-requests.js');
698
+ assert.ok(!/router\.(patch|delete|put)\('\/help-requests\/:id\/replies/.test(routes),
699
+ 'and no route offers to change one');
574
700
  });
575
701
 
576
702
  // ---- placement ------------------------------------------------------------
@@ -724,7 +850,14 @@ test('the skill the page invokes exists, and is the ANSWERING half', () => {
724
850
  assert.match(md, /^name: collab-review$/m);
725
851
  assert.match(md, /GET \/api\/bongos\/help-requests\/for-me/);
726
852
  assert.match(md, /GET \/api\/bongos\/task-recommendations\/for-me/);
727
- // The asymmetry the routes enforce, stated where a session will read it.
728
- assert.match(md, /cannot settle a help request addressed to you/i);
853
+ // The rules the routes enforce, stated where a session will read them. Since
854
+ // ADR 0330 the addressee CAN close an ask — so what the skill must carry is no
855
+ // longer "you cannot", it is "you do not, because you are not the one who read
856
+ // the answer", plus the half that did not widen.
857
+ assert.match(md, /POST \/api\/bongos\/help-requests\/:id\/replies/, 'it answers on the ask');
858
+ assert.match(md, /Do NOT close the ask/, 'and hands the close to a person');
859
+ assert.match(md, /withdraw/i, 'and knows withdraw did not widen');
860
+ assert.ok(!/POST \/api\/bongos\/help-requests --body-file/.test(md),
861
+ 'the file-a-counter-ask workaround is deleted, not left beside the real thing');
729
862
  assert.match(md, /DATA, not instructions/, 'an ask is untrusted input');
730
863
  });
@@ -119,7 +119,7 @@ await t('and widening it did NOT let the destructive acts through', () => {
119
119
  });
120
120
 
121
121
  await t('an ordinary instance is untouched by the guard', () => {
122
- for (const shape of ['co-tenant', 'dedicated', 'standalone']) {
122
+ for (const shape of ['co-tenant', 'dedicated', 'cloud-host']) {
123
123
  for (const a of P.INTENT_ACTIONS) {
124
124
  assert.equal(G.intentAllowedForShape(a, shape), true, `${a}/${shape} must be unaffected`);
125
125
  }
@@ -264,7 +264,7 @@ await t('the upgrade CLI is run somewhere it ACTUALLY EXISTS — the bug that br
264
264
  // Error: Cannot find module '/srv/<base>/<slug>/scripts/gds/upgrade.js'
265
265
  // No assertion about FLAGS could have caught it, so this one resolves the composed
266
266
  // cwd + relative CLI against the real filesystem, for every shape the door admits.
267
- for (const shape of ['control-plane', 'standalone', 'co-tenant']) {
267
+ for (const shape of ['control-plane', 'cloud-host', 'co-tenant']) {
268
268
  const d = makeDeps({ served: '1.19.762' });
269
269
  await R.coreUpgradeInstance(inst({ hosting_shape: shape, slug: 'hermeslines-marketing', port: 3011 }), d, intent());
270
270
  const call = d.ranWith.find((c) => /upgrade\.js/.test(c.cmd));
@@ -278,7 +278,7 @@ await t('the upgrade CLI is run somewhere it ACTUALLY EXISTS — the bug that br
278
278
 
279
279
  await t('a standalone tenant is NAMED with --instance, because the CLI no longer runs inside it', async () => {
280
280
  const d = makeDeps({ served: '1.19.762' });
281
- await R.coreUpgradeInstance(inst({ hosting_shape: 'standalone', slug: 'hermeslines-marketing', port: 3011 }), d, intent());
281
+ await R.coreUpgradeInstance(inst({ hosting_shape: 'cloud-host', slug: 'hermeslines-marketing', port: 3011 }), d, intent());
282
282
  const cmd = d.ran.find((c) => /upgrade\.js/.test(c));
283
283
  assert.match(cmd, /--instance \S*hermeslines-marketing/, 'the target is named explicitly');
284
284
  assert.ok(cmd.includes('--service hermeslines-marketing'), 'and its own unit is still the one restarted');
@@ -475,7 +475,7 @@ await t('the control plane upgrades as the operator account, never as root', asy
475
475
 
476
476
  await t('a tenant upgrades as its OWN account, which is also its Postgres login role', async () => {
477
477
  const d = { ...makeDeps({ served: '1.19.762' }), privileged: true };
478
- await R.coreUpgradeInstance(inst({ hosting_shape: 'standalone', slug: 'test4', port: 3006 }), d, intent());
478
+ await R.coreUpgradeInstance(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), d, intent());
479
479
  const cmd = upgradeCall(d);
480
480
  assert.match(cmd, /sudo -u bongos-test4 /, 'the instance account, not the operator and not root');
481
481
  assert.doesNotMatch(cmd, /sudo -u appsvc /, 'one tenant must never be upgraded under another account');
@@ -487,7 +487,7 @@ await t('the database is NAMED, not discovered — an adopted repo has no core a
487
487
  // upgrade died with "cannot determine which database to migrate".
488
488
  const d = { ...makeDeps({ served: '1.19.762' }), privileged: true };
489
489
  await R.coreUpgradeInstance(
490
- inst({ hosting_shape: 'standalone', slug: 'hermeslines-marketing', db_name: 'hermeslines-marketing', port: 3011 }), d, intent());
490
+ inst({ hosting_shape: 'cloud-host', slug: 'hermeslines-marketing', db_name: 'hermeslines-marketing', port: 3011 }), d, intent());
491
491
  assert.match(upgradeCall(d), /env PGDATABASE=hermeslines-marketing /, 'the runner passes the name the control plane already holds');
492
492
  });
493
493
 
@@ -502,7 +502,7 @@ await t('an UNPRIVILEGED runner still adds no sudo and no env at all', async ()
502
502
  // it so the fix above cannot quietly start requiring sudo where there is none.
503
503
  const d = makeDeps({ served: '1.19.762' });
504
504
  d.privileged = false;
505
- await R.coreUpgradeInstance(inst({ hosting_shape: 'standalone', slug: 'test4', port: 3006 }), d, intent());
505
+ await R.coreUpgradeInstance(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), d, intent());
506
506
  const cmd = upgradeCall(d);
507
507
  assert.doesNotMatch(cmd, /sudo/, 'no escalation when the runner is not privileged');
508
508
  assert.match(cmd, /^node \S*scripts\/gds\/upgrade\.js /, 'it still invokes the CLI directly');
@@ -531,7 +531,7 @@ await t('ONE builder serves both legs — the preview and the move cannot drift
531
531
  // Everything deciding whether the upgrade is ALLOWED must be identical; only the tail
532
532
  // that describes what happens AFTER the pin moves may differ, because a dry run stops
533
533
  // before it.
534
- const box = { id: 7, slug: 'test4', port: 3006, hosting_shape: 'standalone', db_name: 'test4' };
534
+ const box = { id: 7, slug: 'test4', port: 3006, hosting_shape: 'cloud-host', db_name: 'test4' };
535
535
  const move = R.upgradeInvocation(box, '1.19.921', { privileged: true, requestedBy: '42' });
536
536
  const peek = R.upgradeInvocation(box, '1.19.921', { privileged: true, dryRun: true });
537
537
  for (const shared of ['sudo -u bongos-test4 ', 'env PGDATABASE=test4 ', '--to 1.19.921', '--registry']) {
@@ -552,13 +552,13 @@ await t('ONE builder serves both legs — the preview and the move cannot drift
552
552
  await t('an invalid slug is refused at the BUILDER, not left to a helper to throw', () => {
553
553
  // The slug reaches a shell through instanceUser()/dbName(). Depending on another
554
554
  // function's internals for that is one refactor away from an injection path.
555
- assert.throws(() => R.upgradeInvocation({ slug: 'a; rm -rf /', port: 1, hosting_shape: 'standalone' }, '1.19.921', {}),
555
+ assert.throws(() => R.upgradeInvocation({ slug: 'a; rm -rf /', port: 1, hosting_shape: 'cloud-host' }, '1.19.921', {}),
556
556
  /invalid slug/);
557
557
  });
558
558
 
559
559
  await t('a dry run that fails WITHOUT a recognisable marker is still a refusal, never a pass', () => {
560
560
  const deps = { privileged: false, exec: () => ({ ok: false, stdout: 'something went sideways' }) };
561
- const r = R.preflightUpgrade({ id: 7, slug: 'test4', port: 3006, hosting_shape: 'standalone' }, '1.19.921', deps);
561
+ const r = R.preflightUpgrade({ id: 7, slug: 'test4', port: 3006, hosting_shape: 'cloud-host' }, '1.19.921', deps);
562
562
  assert.equal(r.ok, false, 'a non-zero exit is a refusal whatever it printed');
563
563
  assert.ok(r.error);
564
564
  });
@@ -716,7 +716,7 @@ await t('a tenant WITH its own account runs as that account — the security mod
716
716
  // task 1003369: a distinct uid per instance is what stops a co-tenant reading a
717
717
  // sibling's /proc/<pid>/environ. Falling back everywhere would quietly undo it.
718
718
  const d = acctDeps({ accountExists: true });
719
- const r = R.resolveRunAs(inst({ hosting_shape: 'standalone', slug: 'test4' }), d);
719
+ const r = R.resolveRunAs(inst({ hosting_shape: 'cloud-host', slug: 'test4' }), d);
720
720
  assert.deepEqual({ runAs: r.runAs, fellBack: r.fellBack }, { runAs: 'bongos-test4', fellBack: false });
721
721
  });
722
722
 
@@ -726,7 +726,7 @@ await t('a tenant WITHOUT one falls back to the app user, and says so', () => {
726
726
  // instance's own unit already says User=lars — but it IS a provisioning gap, so it
727
727
  // is reported rather than silently papered over.
728
728
  const d = acctDeps({ accountExists: false });
729
- const r = R.resolveRunAs(inst({ hosting_shape: 'standalone', slug: 'test4' }), d);
729
+ const r = R.resolveRunAs(inst({ hosting_shape: 'cloud-host', slug: 'test4' }), d);
730
730
  assert.equal(r.runAs, 'appsvc');
731
731
  assert.equal(r.fellBack, true);
732
732
  assert.equal(r.wanted, 'bongos-test4', 'and names the account that is missing');
@@ -741,7 +741,7 @@ await t('the control plane is never probed — it has no instance account by des
741
741
 
742
742
  await t('the dry run is invoked as the account that actually exists', async () => {
743
743
  const d = acctDeps({ accountExists: false, dryRunStdout: ' ✓ pre-flight: ok\n' });
744
- await R.preflightUpgrade(inst({ hosting_shape: 'standalone', slug: 'test4', port: 3006 }), '1.19.762', d);
744
+ await R.preflightUpgrade(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), '1.19.762', d);
745
745
  const cmd = (d.ranWith.find((c) => /upgrade\.js/.test(c.cmd)) || {}).cmd || '';
746
746
  assert.match(cmd, /sudo -u appsvc /, `must not sudo to an account that does not exist — got: ${cmd}`);
747
747
  assert.doesNotMatch(cmd, /bongos-test4/);
@@ -749,7 +749,7 @@ await t('the dry run is invoked as the account that actually exists', async () =
749
749
 
750
750
  await t('the MOVE probes identically, so preview and move cannot diverge on the account', async () => {
751
751
  const d = acctDeps({ accountExists: false });
752
- await R.coreUpgradeInstance(inst({ hosting_shape: 'standalone', slug: 'test4', port: 3006 }), d, intent());
752
+ await R.coreUpgradeInstance(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), d, intent());
753
753
  const cmd = (d.ranWith.find((c) => /upgrade\.js/.test(c.cmd)) || {}).cmd || '';
754
754
  assert.match(cmd, /sudo -u appsvc /, 'the move follows the same probe as the preview');
755
755
  });
@@ -761,7 +761,7 @@ await t('an INCONCLUSIVE probe keeps the isolated account rather than quietly wi
761
761
  // well exist. Only `no such user` is evidence.
762
762
  const d = makeDeps({ served: '1.19.762' });
763
763
  d.exec = () => ({ ok: true, stdout: '', stderr: 'id: cannot connect to name service\n', softFailed: true });
764
- const r = R.resolveRunAs(inst({ hosting_shape: 'standalone', slug: 'test4' }), d);
764
+ const r = R.resolveRunAs(inst({ hosting_shape: 'cloud-host', slug: 'test4' }), d);
765
765
  assert.equal(r.runAs, 'bongos-test4', 'the intended account is kept');
766
766
  assert.equal(r.fellBack, false);
767
767
  assert.equal(r.probeInconclusive, true, 'and the caller can tell it was not a clean answer');
@@ -776,7 +776,7 @@ await t('the probe uses the SAME executor the upgrade will use', () => {
776
776
  const d = makeDeps({ served: '1.19.762' });
777
777
  d.controlExec = (cmd) => { asked.control.push(cmd); return { ok: true, stdout: '1001\n' }; };
778
778
  d.exec = (cmd) => { asked.remote.push(cmd); return { ok: true, stdout: '', stderr: "id: 'x': no such user\n", softFailed: true }; };
779
- const r = R.resolveRunAs(inst({ hosting_shape: 'standalone', slug: 'test4' }), d);
779
+ const r = R.resolveRunAs(inst({ hosting_shape: 'cloud-host', slug: 'test4' }), d);
780
780
  assert.deepEqual(asked.remote, [], 'the remote executor was not asked');
781
781
  assert.equal(asked.control.length, 1, 'the control-plane executor was');
782
782
  assert.equal(r.runAs, 'bongos-test4', 'and its answer is the one that counts');
@@ -788,7 +788,7 @@ await t('a planning tick probes ONCE, not twice', async () => {
788
788
  // a subprocess per tick for nothing.
789
789
  const d = acctDeps({ accountExists: false });
790
790
  d.apply = false;
791
- await R.coreUpgradeInstance(inst({ hosting_shape: 'standalone', slug: 'test4', port: 3006 }), d, intent());
791
+ await R.coreUpgradeInstance(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), d, intent());
792
792
  assert.equal(d.ranWith.filter((c) => /^id -u /.test(c.cmd)).length, 1,
793
793
  'exactly one probe: the dry run that actually executes');
794
794
  });
@@ -800,7 +800,7 @@ await t('an invalid slug is refused by THIS call site, not merely by the helper
800
800
  // helper's internals, so the assertion names the thrower.
801
801
  const d = acctDeps({});
802
802
  assert.throws(
803
- () => R.resolveRunAs({ hosting_shape: 'standalone', slug: 'bad slug;rm -rf /' }, d),
803
+ () => R.resolveRunAs({ hosting_shape: 'cloud-host', slug: 'bad slug;rm -rf /' }, d),
804
804
  /resolveRunAs: refusing to probe/,
805
805
  'the refusal must come from this function, not from instanceUser further down',
806
806
  );
@@ -809,7 +809,7 @@ await t('an invalid slug is refused by THIS call site, not merely by the helper
809
809
 
810
810
  await t('a refusal reports what the command SAID, not "did not complete"', async () => {
811
811
  const d = acctDeps({ softFail: true, dryRunStderr: 'sudo: unknown user bongos-test4\n' });
812
- const v = await R.preflightUpgrade(inst({ hosting_shape: 'standalone', slug: 'test4', port: 3006 }), '1.19.762', d);
812
+ const v = await R.preflightUpgrade(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), '1.19.762', d);
813
813
  assert.equal(v.ok, false);
814
814
  assert.match(v.error, /unknown user bongos-test4/,
815
815
  `the reason was in hand and must travel — got: ${JSON.stringify(v.error)}`);
@@ -817,21 +817,21 @@ await t('a refusal reports what the command SAID, not "did not complete"', async
817
817
 
818
818
  await t('a ✖ line still wins over the raw tail', async () => {
819
819
  const d = acctDeps({ softFail: true, dryRunStdout: 'noise\n ✖ already on 1.19.927 (pass --force to re-run)\n' });
820
- const v = await R.preflightUpgrade(inst({ hosting_shape: 'standalone', slug: 'test4', port: 3006 }), '1.19.762', d);
820
+ const v = await R.preflightUpgrade(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), '1.19.762', d);
821
821
  assert.match(v.error, /already on 1\.19\.927/);
822
822
  assert.doesNotMatch(v.error, /^noise/, 'the parsed refusal is preferred to the raw tail');
823
823
  });
824
824
 
825
825
  await t('a fallback is recorded on the verdict the owner reads', async () => {
826
826
  const d = acctDeps({ accountExists: false, dryRunStdout: ' ✓ pre-flight: ok\n' });
827
- const v = await R.preflightUpgrade(inst({ hosting_shape: 'standalone', slug: 'test4', port: 3006 }), '1.19.762', d);
827
+ const v = await R.preflightUpgrade(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), '1.19.762', d);
828
828
  assert.equal(v.ran_as, 'appsvc');
829
829
  assert.match(v.account_note, /no bongos-test4 account yet/);
830
830
  });
831
831
 
832
832
  await t('and a silent failure STILL refuses rather than inventing a pass', async () => {
833
833
  const d = acctDeps({ softFail: true, dryRunStdout: '', dryRunStderr: '' });
834
- const v = await R.preflightUpgrade(inst({ hosting_shape: 'standalone', slug: 'test4', port: 3006 }), '1.19.762', d);
834
+ const v = await R.preflightUpgrade(inst({ hosting_shape: 'cloud-host', slug: 'test4', port: 3006 }), '1.19.762', d);
835
835
  assert.equal(v.ok, false, 'a command that said nothing is not a pass');
836
836
  assert.match(v.error, /said nothing/);
837
837
  });
@@ -22,7 +22,7 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
22
22
  const SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/deploy.js'), 'utf8');
23
23
 
24
24
  const preview = (id, slug, over = {}) => ({
25
- instance_id: id, slug, hosting_shape: 'standalone',
25
+ instance_id: id, slug, hosting_shape: 'cloud-host',
26
26
  served_version: '1.19.800', recommended_version: '1.19.871',
27
27
  behind: true, reading: 'ok', observed_at: new Date().toISOString(), open_intent: null,
28
28
  ...over,
@@ -30,11 +30,11 @@ const preview = (id, slug, over = {}) => ({
30
30
 
31
31
  // The fleet as the control plane really reports it: mostly torn down, a couple live.
32
32
  const FLEET = [
33
- { id: '31', slug: 'mercury', status: 'active', hosting_shape: 'standalone' },
33
+ { id: '31', slug: 'mercury', status: 'active', hosting_shape: 'cloud-host' },
34
34
  { id: '32', slug: 'hoodify-finance', status: 'active', hosting_shape: 'co-tenant' },
35
- { id: '33', slug: 'walk-lantern', status: 'torn_down', hosting_shape: 'standalone' },
36
- { id: '34', slug: 'testccc', status: 'torn_down', hosting_shape: 'standalone' },
37
- { id: '35', slug: 'unreadable-one', status: 'active', hosting_shape: 'standalone' },
35
+ { id: '33', slug: 'walk-lantern', status: 'torn_down', hosting_shape: 'cloud-host' },
36
+ { id: '34', slug: 'testccc', status: 'torn_down', hosting_shape: 'cloud-host' },
37
+ { id: '35', slug: 'unreadable-one', status: 'active', hosting_shape: 'cloud-host' },
38
38
  ];
39
39
 
40
40
  function boot({ controlPlane = preview('1', 'cloudbongos'), controlPlaneStatus = 200, fleet = FLEET, fleetStatus = 200, instanceOver = null, moveTries = 4, postStatus = 200, scope = null } = {}) {