@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
@@ -78,17 +78,23 @@ t('ceilingFor: an explicit override wins; an unmeasured box falls to the ADR sma
78
78
  assert.equal(DEFAULT_CEILING, 15);
79
79
  });
80
80
 
81
- t('placementBox: standalone → control plane always; co-tenant → the remote box only under remote-exec; dedicated → no shared box', () => {
82
- assert.deepEqual(placementBox('standalone', {}), { box: 'control-plane', shapes: ['standalone', 'co-tenant'], fixedMb: 750 });
83
- assert.deepEqual(placementBox('standalone', { remoteHost: 'cohost' }), { box: 'control-plane', shapes: ['standalone'], fixedMb: 750 });
84
- assert.deepEqual(placementBox('co-tenant', {}), { box: 'control-plane', shapes: ['standalone', 'co-tenant'], fixedMb: 750 });
81
+ t('placementBox: cloud-host → control plane always; co-tenant → the remote box only under remote-exec; byo-host → no box of ours', () => {
82
+ assert.deepEqual(placementBox('cloud-host', {}), { box: 'control-plane', shapes: ['cloud-host', 'co-tenant'], fixedMb: 750 });
83
+ assert.deepEqual(placementBox('cloud-host', { remoteHost: 'cohost' }), { box: 'control-plane', shapes: ['cloud-host'], fixedMb: 750 });
84
+ assert.deepEqual(placementBox('co-tenant', {}), { box: 'control-plane', shapes: ['cloud-host', 'co-tenant'], fixedMb: 750 });
85
85
  assert.deepEqual(placementBox('co-tenant', { remoteHost: 'cohost' }), { box: 'overflow', shapes: ['co-tenant'], fixedMb: 630 });
86
- assert.equal(placementBox('dedicated', {}), null);
87
- // ADR 0323: a byo-host runs on the owner's own machine, so it has no box of ours and
88
- // no shared ceiling — null for a STRONGER reason than dedicated's (dedicated is our
89
- // droplet, just not a shared one). remoteHost must not change that answer.
86
+ // ADR 0323: a byo-host runs on the owner's own machine, so it has no box of ours and no
87
+ // shared ceiling. This used to be one of TWO null answers — 'dedicated' was the other,
88
+ // null for a weaker reason (our droplet, just not a shared one) — until ADR 0327
89
+ // decision 4 removed that shape. remoteHost must not change the answer.
90
90
  assert.equal(placementBox('byo-host', {}), null);
91
91
  assert.equal(placementBox('byo-host', { remoteHost: 'cohost' }), null);
92
+ // Everything else still falls through to the control-plane box — including the
93
+ // 'control-plane' row itself, which is unchanged by this rename. So 'byo-host' is now the
94
+ // ONLY null answer, where there used to be two; the skip reason it produces is named for
95
+ // the question ('no-shared-box') rather than for the shape, so removing the other one did
96
+ // not leave a label naming a shape that no longer exists.
97
+ assert.deepEqual(placementBox('control-plane', {}), { box: 'control-plane', shapes: ['cloud-host', 'co-tenant'], fixedMb: 750 });
92
98
  });
93
99
 
94
100
  t('placementVerdict: the ceiling refuses at N of N, the floor refuses under 256 MB free, an unmeasured floor cannot refuse', () => {
@@ -119,14 +125,14 @@ t('capacitySummary: resize_due at 80% (12 of 15), remaining never negative, verd
119
125
 
120
126
  await ta('countPlaced counts ONLY provisioning + active rows of the box\'s shapes, and can exclude the row being re-run', async () => {
121
127
  const db = fakeDb(10);
122
- assert.equal(await countPlaced(db, ['standalone']), 10);
123
- assert.deepEqual(db.queries[0].params, [['standalone'], PLACED_STATES]);
128
+ assert.equal(await countPlaced(db, ['cloud-host']), 10);
129
+ assert.deepEqual(db.queries[0].params, [['cloud-host'], PLACED_STATES]);
124
130
  assert.deepEqual(PLACED_STATES, ['provisioning', 'active'], 'error rows hold no running unit (live fleet, 2026-09-16) and must not take a slot');
125
131
  assert.doesNotMatch(db.queries[0].sql, /id <>/, 'no exclusion unless asked');
126
- await countPlaced(db, ['standalone', 'co-tenant'], { excludeId: 42 });
132
+ await countPlaced(db, ['cloud-host', 'co-tenant'], { excludeId: 42 });
127
133
  assert.match(db.queries[1].sql, /id <> \$3/);
128
- assert.deepEqual(db.queries[1].params, [['standalone', 'co-tenant'], PLACED_STATES, 42]);
129
- assert.equal(await countPlaced({ query: async () => ({ rows: [] }) }, ['standalone']), 0, 'a recording fake with no rows reads 0, not NaN');
134
+ assert.deepEqual(db.queries[1].params, [['cloud-host', 'co-tenant'], PLACED_STATES, 42]);
135
+ assert.equal(await countPlaced({ query: async () => ({ rows: [] }) }, ['cloud-host']), 0, 'a recording fake with no rows reads 0, not NaN');
130
136
  });
131
137
 
132
138
  await ta('fleetCapacity (the API + CLI summary) measures THIS host and counts the control plane\'s shapes', async () => {
@@ -135,7 +141,7 @@ await ta('fleetCapacity (the API + CLI summary) measures THIS host and counts th
135
141
  const c = await fleetCapacity(db, { meminfo: MEMINFO_2GB, remoteHost: null });
136
142
  assert.equal(c.box, 'control-plane'); assert.equal(c.placed, 10); assert.equal(c.ceiling, 15); assert.equal(c.source, 'measured');
137
143
  assert.equal(c.mem_available_mb, 1108); assert.equal(c.resize_due, false); assert.equal(c.verdict.ok, true);
138
- assert.deepEqual(db.queries[0].params[0], ['standalone', 'co-tenant']);
144
+ assert.deepEqual(db.queries[0].params[0], ['cloud-host', 'co-tenant']);
139
145
  const remote = await fleetCapacity(fakeDb(3), { meminfo: MEMINFO_4GB, remoteHost: 'cohost' });
140
146
  assert.equal(remote.ceiling, 39, 'the control plane\'s own 4 GB ceiling, not the overflow box\'s');
141
147
  // The env override reaches the summary, and is read per call.
@@ -147,7 +153,7 @@ await ta('fleetCapacity (the API + CLI summary) measures THIS host and counts th
147
153
 
148
154
  console.log('\nthe runner preflight (scripts/gds/provision-capacity.js):');
149
155
 
150
- const standalone = { id: 7, slug: 'lantern', hosting_shape: 'standalone', status: 'requested', owner_builder_id: 3, port: null, db_name: 'lantern', domain: null };
156
+ const cloudHost = { id: 7, slug: 'lantern', hosting_shape: 'cloud-host', status: 'requested', owner_builder_id: 3, port: null, db_name: 'lantern', domain: null };
151
157
 
152
158
  // Recording deps: which executor was asked, what was logged, which events landed.
153
159
  function preflightDeps({ apply = true, placed = 10, controlMeminfo = MEMINFO_2GB, boxMeminfo = MEMINFO_4GB } = {}) {
@@ -164,11 +170,11 @@ function preflightDeps({ apply = true, placed = 10, controlMeminfo = MEMINFO_2GB
164
170
  };
165
171
  }
166
172
 
167
- await ta('a standalone with room: ok, measured on the CONTROL executor, the count excludes its own row', async () => {
173
+ await ta('a cloud-host with room: ok, measured on the CONTROL executor, the count excludes its own row', async () => {
168
174
  const h = preflightDeps({ placed: 10 });
169
- const r = await capacityPreflight(standalone, h.deps);
175
+ const r = await capacityPreflight(cloudHost, h.deps);
170
176
  assert.equal(r.ok, true);
171
- assert.deepEqual(h.asked, [['control', MEMINFO_CMD]], 'a standalone runs control-plane-local, so its box is measured there');
177
+ assert.deepEqual(h.asked, [['control', MEMINFO_CMD]], 'a cloud-host runs control-plane-local, so its box is measured there');
172
178
  assert.equal(r.ceiling, 15); assert.equal(r.source, 'measured'); assert.equal(r.placed, 10);
173
179
  assert.deepEqual(h.deps.db.queries[0].params[2], 7, 'the row being provisioned never counts itself');
174
180
  assert.equal(h.events.length, 0, 'nothing to record when it fits');
@@ -177,7 +183,7 @@ await ta('a standalone with room: ok, measured on the CONTROL executor, the coun
177
183
 
178
184
  await ta('a full box REFUSES with the owner-readable reason, records placement-refused, and returns (never throws)', async () => {
179
185
  const h = preflightDeps({ placed: 15 });
180
- const r = await capacityPreflight(standalone, h.deps);
186
+ const r = await capacityPreflight(cloudHost, h.deps);
181
187
  assert.equal(r.ok, false);
182
188
  assert.match(r.error, /15 of 15 project slots are taken/);
183
189
  assert.equal(h.events.length, 1);
@@ -189,7 +195,7 @@ await ta('a full box REFUSES with the owner-readable reason, records placement-r
189
195
 
190
196
  await ta('the live memory floor refuses even with slots to spare (a stray unit the ledger cannot see)', async () => {
191
197
  const h = preflightDeps({ placed: 10, controlMeminfo: MEMINFO_TIGHT });
192
- const r = await capacityPreflight(standalone, h.deps);
198
+ const r = await capacityPreflight(cloudHost, h.deps);
193
199
  assert.equal(r.ok, false);
194
200
  assert.equal(r.verdict.wall, 'memory');
195
201
  assert.match(r.error, /117 MB of memory free/);
@@ -198,7 +204,7 @@ await ta('the live memory floor refuses even with slots to spare (a stray unit t
198
204
 
199
205
  await ta('at 80% it still places, but the log names the resize trigger', async () => {
200
206
  const h = preflightDeps({ placed: 12 });
201
- const r = await capacityPreflight(standalone, h.deps);
207
+ const r = await capacityPreflight(cloudHost, h.deps);
202
208
  assert.equal(r.ok, true); assert.equal(r.resize_due, true);
203
209
  assert.ok(h.logs.some((l) => /at or past 80%: schedule the resize \(task 1003923\)/.test(l)), h.logs.join(' | '));
204
210
  });
@@ -209,35 +215,35 @@ await ta('a co-tenant under remote-exec is measured on the BOX executor against
209
215
  CONFIG.remoteHost = 'cohost';
210
216
  try {
211
217
  const h = preflightDeps({ placed: 40 });
212
- const r = await capacityPreflight({ ...standalone, hosting_shape: 'co-tenant' }, h.deps);
218
+ const r = await capacityPreflight({ ...cloudHost, hosting_shape: 'co-tenant' }, h.deps);
213
219
  assert.deepEqual(h.asked, [['box', MEMINFO_CMD]]);
214
220
  assert.equal(r.box, 'overflow'); assert.equal(r.ceiling, 41); assert.equal(r.ok, true);
215
221
  assert.deepEqual(h.deps.db.queries[0].params[0], ['co-tenant'], 'only the shapes that share that box count');
216
222
  } finally { CONFIG.remoteHost = prior; }
217
223
  });
218
224
 
219
- await ta('a dedicated droplet is its own box: no measurement, no count, no refusal', async () => {
225
+ await ta('a shape with no box of ours: no measurement, no count, no refusal', async () => {
220
226
  const h = preflightDeps({ placed: 99 });
221
- const r = await capacityPreflight({ ...standalone, hosting_shape: 'dedicated' }, h.deps);
222
- assert.deepEqual(r, { ok: true, skipped: 'dedicated' });
227
+ const r = await capacityPreflight({ ...cloudHost, hosting_shape: 'byo-host' }, h.deps);
228
+ assert.deepEqual(r, { ok: true, skipped: 'no-shared-box' });
223
229
  assert.equal(h.asked.length, 0); assert.equal(h.deps.db.queries.length, 0);
224
230
  });
225
231
 
226
232
  await ta('dry-run measures nothing and refuses nothing, even on a full box; an unmeasured --apply falls to the default and warns', async () => {
227
233
  const dry = preflightDeps({ apply: false, placed: 15 });
228
- const r = await capacityPreflight(standalone, dry.deps);
234
+ const r = await capacityPreflight(cloudHost, dry.deps);
229
235
  assert.equal(r.ok, true); assert.equal(r.dryRun, true);
230
236
  assert.equal(dry.events.length, 0);
231
237
  assert.ok(dry.logs.some((l) => /would refuse if full/.test(l)));
232
238
  // --apply with an executor that answers no stdout (a fake, or a box without procfs)
233
239
  const blind = preflightDeps({ placed: 3, controlMeminfo: '' });
234
- const b = await capacityPreflight(standalone, blind.deps);
240
+ const b = await capacityPreflight(cloudHost, blind.deps);
235
241
  assert.equal(b.ok, true); assert.equal(b.source, 'default'); assert.equal(b.ceiling, DEFAULT_CEILING);
236
242
  assert.ok(blind.logs.some((l) => /could not measure the box.*PROVISION_BOX_CEILING/.test(l)), blind.logs.join(' | '));
237
243
  // …and an executor that throws is the same as one that answers nothing.
238
244
  const throwing = preflightDeps({ placed: 3 });
239
245
  throwing.deps.controlExec = () => { throw new Error('ssh down'); };
240
- assert.equal((await capacityPreflight(standalone, throwing.deps)).source, 'default');
246
+ assert.equal((await capacityPreflight(cloudHost, throwing.deps)).source, 'default');
241
247
  });
242
248
 
243
249
  await ta('statusCapacityLine prints "box at N of M" for the CLI', async () => {
@@ -272,7 +278,7 @@ function recorder({ apply, placed, meminfo = MEMINFO_2GB }) {
272
278
 
273
279
  await ta('provisionInstance(--apply) on a full box returns ok:false BEFORE any status write or standup command', async () => {
274
280
  const h = recorder({ apply: true, placed: 15 });
275
- const r = await P.provisionInstance(standalone, h.deps);
281
+ const r = await P.provisionInstance(cloudHost, h.deps);
276
282
  assert.equal(r.ok, false);
277
283
  assert.match(r.error, /15 of 15 project slots/);
278
284
  assert.deepEqual(h.statuses, [], 'never flipped to provisioning — a project that was not placed is not "provisioning"');
@@ -283,7 +289,7 @@ await ta('provisionInstance(--apply) on a full box returns ok:false BEFORE any s
283
289
 
284
290
  await ta('provisionInstance(--apply) with room proceeds into the runbook exactly as before', async () => {
285
291
  const h = recorder({ apply: true, placed: 10 });
286
- const r = await P.provisionInstance({ ...standalone, hosting_shape: 'co-tenant', port: 3003 }, h.deps);
292
+ const r = await P.provisionInstance({ ...cloudHost, hosting_shape: 'co-tenant', port: 3003 }, h.deps);
287
293
  assert.equal(r.ok, true);
288
294
  assert.equal(h.statuses[0], 'provisioning');
289
295
  assert.equal(h.cmds[0], MEMINFO_CMD, 'measured first');
@@ -292,7 +298,7 @@ await ta('provisionInstance(--apply) with room proceeds into the runbook exactly
292
298
 
293
299
  await ta('provisionInstance(dry-run) on a full box still prints the whole plan — dry-run places nothing, so it refuses nothing', async () => {
294
300
  const h = recorder({ apply: false, placed: 15 });
295
- const r = await P.provisionInstance({ ...standalone, hosting_shape: 'co-tenant', port: 3003 }, h.deps);
301
+ const r = await P.provisionInstance({ ...cloudHost, hosting_shape: 'co-tenant', port: 3003 }, h.deps);
296
302
  assert.equal(r.ok, true);
297
303
  assert.ok(h.cmds.some((c) => /createdb lantern/.test(c)));
298
304
  });
@@ -306,7 +312,7 @@ await ta('cmdRunIntents: a refused placement retires onto the card as error_note
306
312
  db: fakeDb(15),
307
313
  provisioning: {
308
314
  claimNextIntent: async () => (claims++ === 0 ? { id: 9, instance_id: 7, action: 'provision', attempts: P.MAX_INTENT_ATTEMPTS } : null),
309
- getInstanceById: async () => ({ ...standalone }),
315
+ getInstanceById: async () => ({ ...cloudHost }),
310
316
  resolveIntent: async (_db, id, state, err) => { resolved.push({ id, state, err }); },
311
317
  setInstanceStatus: async (_db, id, status, patch) => { statusWrites.push({ id, status, patch }); },
312
318
  recordEvent: async (_db, e) => { events.push(e); },
@@ -335,7 +341,7 @@ await ta('cmdRunIntents: below the attempt cap a refusal goes back to pending wi
335
341
  apply: true, log: () => {}, db,
336
342
  provisioning: {
337
343
  claimNextIntent: async () => (claims++ === 0 ? { id: 9, instance_id: 7, action: 'provision', attempts: 1 } : null),
338
- getInstanceById: async () => ({ ...standalone }),
344
+ getInstanceById: async () => ({ ...cloudHost }),
339
345
  resolveIntent: async () => { throw new Error('must not resolve on a retryable refusal'); },
340
346
  setInstanceStatus: async () => { throw new Error('must not touch the instance on a retryable refusal'); },
341
347
  recordEvent: async () => {},
@@ -357,7 +363,7 @@ console.log('\nthe fleet API carries the same summary:');
357
363
  // Stub the route's data access at the same seams tests/provisioning_paid_shape_gate.mjs
358
364
  // uses; the capacity module object is shared with the route through the require cache.
359
365
  const provisioning = require('../modules/provisioning/provisioning.js');
360
- provisioning.listAllInstances = async () => [{ id: 1, owner_builder_id: 7, slug: 'lantern', status: 'active', hosting_shape: 'standalone', settings: {}, detail: {}, github_login: 'o', display_name: 'O', rank: 'archon' }];
366
+ provisioning.listAllInstances = async () => [{ id: 1, owner_builder_id: 7, slug: 'lantern', status: 'active', hosting_shape: 'cloud-host', settings: {}, detail: {}, github_login: 'o', display_name: 'O', rank: 'archon' }];
361
367
  const api = require('../src/module-api.js');
362
368
  api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
363
369
  api.requirePermission = () => (_req, _res, next) => next();
@@ -8,7 +8,7 @@
8
8
  // naming N of M, before any row or intent exists.
9
9
  //
10
10
  // What these tests pin: the refusal lands before createInstance; a box the web tier cannot
11
- // count (dedicated, a remote co-host) is never refused here; a re-request of a row that
11
+ // count (a remote co-host, or byo-host) is never refused here; a re-request of a row that
12
12
  // already holds its slot stays idempotent, while a revive (which takes a slot) is counted;
13
13
  // the live-memory floor stays the runner's; and a capacity read that fails ADMITS, because
14
14
  // the runner remains the wall.
@@ -100,7 +100,7 @@ function reset({ count = 0, row = null, mem = MEMINFO_2GB, fails = false } = {})
100
100
  delete process.env.PROVISION_REMOTE_HOST;
101
101
  }
102
102
 
103
- const WIZARD = { slug: 'lantern', hosting_shape: 'standalone', no_address: true };
103
+ const WIZARD = { slug: 'lantern', hosting_shape: 'cloud-host', no_address: true };
104
104
 
105
105
  // ── the wall ──────────────────────────────────────────────────────────────────────
106
106
 
@@ -141,13 +141,6 @@ await ta('an absent shape takes the byo-host default, so a full box does NOT ref
141
141
 
142
142
  // ── boxes the web tier cannot count are the runner's ──────────────────────────────
143
143
 
144
- await ta('dedicated is its own box: never refused here, and the count is never read', async () => {
145
- reset({ count: 15 });
146
- const res = await post({ slug: 'lantern', hosting_shape: 'dedicated', no_address: true });
147
- assert.equal(res.status, 201, res.raw);
148
- assert.equal(capacityReads, 0);
149
- });
150
-
151
144
  await ta('byo-host runs on the OWNER\'s machine: never refused here, and the count is never read', async () => {
152
145
  // ADR 0323's new shape, and the strongest form of "not our box": there is no machine
153
146
  // of ours to place it on at all, so a full control plane must not refuse it and the
@@ -176,14 +169,14 @@ await ta('the live-memory floor is left to the runner: slots free + memory tight
176
169
  // ── re-requests ───────────────────────────────────────────────────────────────────
177
170
 
178
171
  await ta('a re-request of the owner\'s ACTIVE project on a full box stays idempotent (no box_full)', async () => {
179
- reset({ count: 15, row: { id: 42, owner_builder_id: 7, slug: 'lantern', hosting_shape: 'standalone', status: 'active' } });
172
+ reset({ count: 15, row: { id: 42, owner_builder_id: 7, slug: 'lantern', hosting_shape: 'cloud-host', status: 'active' } });
180
173
  const res = await post(WIZARD);
181
174
  assert.notEqual(res.status, 409, res.raw);
182
175
  assert.ok(res.status < 300, res.raw);
183
176
  });
184
177
 
185
178
  await ta('a revive of a torn-down project takes a slot, so a full box refuses it', async () => {
186
- reset({ count: 15, row: { id: 42, owner_builder_id: 7, slug: 'lantern', hosting_shape: 'standalone', status: 'torn_down' } });
179
+ reset({ count: 15, row: { id: 42, owner_builder_id: 7, slug: 'lantern', hosting_shape: 'cloud-host', status: 'torn_down' } });
187
180
  const res = await post(WIZARD);
188
181
  assert.equal(res.status, 409, res.raw);
189
182
  assert.equal(res.body.error.code, 'box_full');
@@ -199,7 +192,7 @@ await ta('a capacity read that fails ADMITS — the runner remains the wall', as
199
192
  });
200
193
 
201
194
  await ta('a malformed slug or shape gets the handler\'s 400, not box_full', async () => {
202
- for (const body of [{ slug: 'Bad Slug!', hosting_shape: 'standalone' }, { slug: 'lantern', hosting_shape: 'giant' }]) {
195
+ for (const body of [{ slug: 'Bad Slug!', hosting_shape: 'cloud-host' }, { slug: 'lantern', hosting_shape: 'giant' }]) {
203
196
  reset({ count: 15 });
204
197
  const res = await post(body);
205
198
  assert.equal(res.status, 400, `${JSON.stringify(body)} → ${res.raw}`);
@@ -133,7 +133,7 @@ function post(path, body = {}) {
133
133
  }
134
134
 
135
135
  const freshRow = (over = {}) => ({
136
- id: 41, owner_builder_id: 7, slug: 'spike', status: 'requested', hosting_shape: 'standalone',
136
+ id: 41, owner_builder_id: 7, slug: 'spike', status: 'requested', hosting_shape: 'cloud-host',
137
137
  domain: 'spike.cloudbongos.com', onboard_mode: 'greenfield', type: null, created_at: '', updated_at: '', ...over,
138
138
  });
139
139
 
@@ -192,7 +192,7 @@ function reset({ inst, holder } = {}) {
192
192
  console.log('\nPOST /provisioning/instances/:id/domain — give an existing instance its address:');
193
193
 
194
194
  await ta('ACTIVE domainless instance: domain is validated, recorded, and a domain-attach intent queues', async () => {
195
- reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'standalone', domain: null } });
195
+ reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'cloud-host', domain: null } });
196
196
  const res = await post('/provisioning/instances/18/domain', { domain: 'Spike.CloudBongos.com.' });
197
197
  assert.equal(res.status, 200, res.raw);
198
198
  assert.equal(res.body.ok, true);
@@ -419,7 +419,7 @@ console.log('\nPOST /provisioning/instances — slug re-requests are truthful ab
419
419
  await ta('re-request with a domain on an existing instance SAYS the domain was ignored (and where to send it)', async () => {
420
420
  reset();
421
421
  createResult = { instance: { id: 18, owner_builder_id: 7, slug: 'spike', status: 'active', domain: null }, created: false };
422
- const res = await post('/provisioning/instances', { slug: 'spike', hosting_shape: 'standalone', domain: 'spike.cloudbongos.com' });
422
+ const res = await post('/provisioning/instances', { slug: 'spike', hosting_shape: 'cloud-host', domain: 'spike.cloudbongos.com' });
423
423
  assert.equal(res.status, 200, res.raw);
424
424
  assert.ok(res.body.domain_ignored, 'the drop is surfaced, not silent');
425
425
  assert.equal(res.body.domain_ignored.requested, 'spike.cloudbongos.com');
@@ -429,7 +429,7 @@ await ta('re-request with a domain on an existing instance SAYS the domain was i
429
429
  await ta('re-request with the SAME domain the instance already has → no ignored flag (nothing was dropped)', async () => {
430
430
  reset();
431
431
  createResult = { instance: { id: 18, owner_builder_id: 7, slug: 'spike', status: 'active', domain: 'spike.cloudbongos.com' }, created: false };
432
- const res = await post('/provisioning/instances', { slug: 'spike', hosting_shape: 'standalone', domain: 'spike.cloudbongos.com' });
432
+ const res = await post('/provisioning/instances', { slug: 'spike', hosting_shape: 'cloud-host', domain: 'spike.cloudbongos.com' });
433
433
  assert.equal(res.status, 200, res.raw);
434
434
  assert.equal(res.body.domain_ignored, undefined);
435
435
  });
@@ -437,7 +437,7 @@ await ta('re-request with the SAME domain the instance already has → no ignore
437
437
  await ta('a fresh create with a domain is untouched (the domain is stored at creation — nothing ignored)', async () => {
438
438
  reset();
439
439
  createResult = { instance: { id: 43, owner_builder_id: 7, slug: 'new', status: 'requested', domain: 'new.cloudbongos.com' }, created: true };
440
- const res = await post('/provisioning/instances', { slug: 'new', hosting_shape: 'standalone', domain: 'new.cloudbongos.com' });
440
+ const res = await post('/provisioning/instances', { slug: 'new', hosting_shape: 'cloud-host', domain: 'new.cloudbongos.com' });
441
441
  assert.equal(res.status, 201, res.raw);
442
442
  assert.equal(res.body.domain_ignored, undefined);
443
443
  });
@@ -249,7 +249,7 @@ console.log('\nPOST /provisioning/instances/:id/domain — the index refusing a
249
249
 
250
250
  await ta('the index catching the race answers domain_taken 409, and nothing is queued', async () => {
251
251
  reset();
252
- instance = { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'standalone', domain: null };
252
+ instance = { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'cloud-host', domain: null };
253
253
  updateThrows = DOMAIN_VIOLATION();
254
254
  const res = await post('/provisioning/instances/18/domain', { domain: 'mercury.example.com' });
255
255
  assert.equal(res.status, 409, res.raw);
@@ -260,7 +260,7 @@ await ta('the index catching the race answers domain_taken 409, and nothing is q
260
260
 
261
261
  await ta('any OTHER failure on attach is still the honest 500', async () => {
262
262
  reset();
263
- instance = { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'standalone', domain: null };
263
+ instance = { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'cloud-host', domain: null };
264
264
  updateThrows = Object.assign(new Error('connection terminated'), { code: '57P01' });
265
265
  const res = await post('/provisioning/instances/18/domain', { domain: 'mercury.example.com' });
266
266
  assert.equal(res.status, 500, res.raw);
@@ -30,7 +30,7 @@ const provisioning = require('../modules/provisioning/provisioning.js');
30
30
  const { test, summary } = makeRunner();
31
31
 
32
32
  const activeStandalone = {
33
- id: 18, slug: 'spike', hosting_shape: 'standalone', status: 'active', owner_builder_id: 7,
33
+ id: 18, slug: 'spike', hosting_shape: 'cloud-host', status: 'active', owner_builder_id: 7,
34
34
  port: 3010, db_name: 'spike', domain: null, target_ref: 'jaxri/spike-harbor',
35
35
  };
36
36
 
@@ -74,7 +74,7 @@ await ta('createInstance: a chosen set is stored as a jsonb array, in catalog or
74
74
 
75
75
  // Act
76
76
  await provisioning.createInstance(db, {
77
- ownerBuilderId: 7, slug: 'demo', hostingShape: 'standalone', type: 'game',
77
+ ownerBuilderId: 7, slug: 'demo', hostingShape: 'cloud-host', type: 'game',
78
78
  modules: ['discord', 'agents'],
79
79
  });
80
80
 
@@ -90,7 +90,7 @@ await ta('createInstance: NO answer stores NULL — the skip has to stay readabl
90
90
  for (const modules of [undefined, null, 'discord', 42]) {
91
91
  const db = recordingDb();
92
92
  await provisioning.createInstance(db, {
93
- ownerBuilderId: 7, slug: 'demo', hostingShape: 'standalone', type: 'game', modules,
93
+ ownerBuilderId: 7, slug: 'demo', hostingShape: 'cloud-host', type: 'game', modules,
94
94
  });
95
95
  const params = insertParams(db);
96
96
  assert.equal(params[13], null, `modules=${JSON.stringify(modules)} stores NULL, not an empty list`);
@@ -100,7 +100,7 @@ await ta('createInstance: NO answer stores NULL — the skip has to stay readabl
100
100
  await ta('createInstance: an explicit empty answer is stored, and is NOT the same as absent', async () => {
101
101
  const db = recordingDb();
102
102
  await provisioning.createInstance(db, {
103
- ownerBuilderId: 7, slug: 'demo', hostingShape: 'standalone', type: 'game', modules: [],
103
+ ownerBuilderId: 7, slug: 'demo', hostingShape: 'cloud-host', type: 'game', modules: [],
104
104
  });
105
105
  assert.equal(insertParams(db)[13], '[]', 'the owner said none of them, and the row says so');
106
106
  });
@@ -110,7 +110,7 @@ await ta('createInstance: the always-on core is never written into the selection
110
110
  // the column disagree with ALWAYS_ON_CORE the day a manifest flips its default.
111
111
  const db = recordingDb();
112
112
  await provisioning.createInstance(db, {
113
- ownerBuilderId: 7, slug: 'demo', hostingShape: 'standalone', modules: ['memory', 'discord', 'provisioning'],
113
+ ownerBuilderId: 7, slug: 'demo', hostingShape: 'cloud-host', modules: ['memory', 'discord', 'provisioning'],
114
114
  });
115
115
  assert.equal(insertParams(db)[13], JSON.stringify(['discord']));
116
116
  });
@@ -164,7 +164,7 @@ provisioning.createInstance = async (_db, args) => {
164
164
  lastCreateArgs = args;
165
165
  return existingRow
166
166
  ? { instance: existingRow, created: false }
167
- : { instance: { id: 41, owner_builder_id: 7, slug: args.slug, status: 'requested', hosting_shape: 'standalone', type: args.type || null, detail: {} }, created: true };
167
+ : { instance: { id: 41, owner_builder_id: 7, slug: args.slug, status: 'requested', hosting_shape: 'cloud-host', type: args.type || null, detail: {} }, created: true };
168
168
  };
169
169
  provisioning.enqueueIntent = async (_db, id, action) => ({ intent: { id: 9, action, state: 'pending' }, created: true });
170
170
  provisioning.getOpenIntent = async () => null;
@@ -53,7 +53,7 @@ await ta('createInstance: onboardMode "adopt" is persisted', async () => {
53
53
  const db = recordingDb();
54
54
  await provisioning.createInstance(db, {
55
55
  ownerBuilderId: 7, slug: 'demo', targetRef: 'example-owner/demo',
56
- hostingShape: 'standalone', onboardMode: 'adopt',
56
+ hostingShape: 'cloud-host', onboardMode: 'adopt',
57
57
  });
58
58
  const insert = db.calls.find((c) => /INSERT INTO provisioning_instances/i.test(c.text));
59
59
  assert.ok(insert, 'issued an INSERT');
@@ -68,7 +68,7 @@ await ta('createInstance: absent / unknown onboardMode → greenfield, the SAFE
68
68
  for (const mode of [undefined, null, '', 'ADOPT', 'brownfield', 'nonsense']) {
69
69
  const db = recordingDb();
70
70
  await provisioning.createInstance(db, {
71
- ownerBuilderId: 7, slug: 'demo', hostingShape: 'standalone', onboardMode: mode,
71
+ ownerBuilderId: 7, slug: 'demo', hostingShape: 'cloud-host', onboardMode: mode,
72
72
  });
73
73
  const insert = db.calls.find((c) => /INSERT INTO provisioning_instances/i.test(c.text));
74
74
  assert.ok(insert.params.includes('greenfield'), `onboardMode=${JSON.stringify(mode)} → greenfield`);
@@ -3,13 +3,18 @@
3
3
  // The paid-shape gate on POST /provisioning/instances (task 1003682).
4
4
  //
5
5
  // Before this, the create route's only gate was requireBuilder, and `hosting_shape` was
6
- // caller-supplied and validated only against the allowed set. `dedicated` is the ONE shape
7
- // whose runner leg creates a real DigitalOcean droplet (scripts/gds/provision.js — billed
8
- // monthly, defaulting to s-2vcpu-4gb), so any authenticated builder — a Xenos included —
9
- // could queue recurring infrastructure spend the owner never approved. The route that
10
- // SPENDS was less gated than force-teardown, which stops spending and sits behind
6
+ // caller-supplied and validated only against the allowed set. `dedicated` was then the ONE
7
+ // shape whose runner leg created a real DigitalOcean droplet (scripts/gds/provision.js —
8
+ // billed monthly, defaulting to s-2vcpu-4gb), so any authenticated builder — a Xenos
9
+ // included — could queue recurring infrastructure spend the owner never approved. The route
10
+ // that SPENDS was less gated than force-teardown, which stops spending and sits behind
11
11
  // provisioning.fleet.manage.
12
12
  //
13
+ // THAT SHAPE IS GONE (ADR 0327 decision 4, task 1004182, migration provisioning_027): on the
14
+ // owner's own platform account a bigger plan is a setting they choose, not a shape we model.
15
+ // Its rows folded into 'cloud-host'. The gate's reasoning did not move with it — see the
16
+ // 'dedicated is no longer requestable' case below, which is what holds the removal.
17
+ //
13
18
  // The fix gates the SHAPE, not the route: ordinary project creation must not require a rank,
14
19
  // so the front door — `byo-host` since ADR 0323, `co-tenant` before it — stays open to any
15
20
  // authenticated builder. Everything else
@@ -21,10 +26,10 @@
21
26
  // subject. The original gate named the one shape it refused and admitted the rest, and two
22
27
  // shapes stayed open underneath that reasoning:
23
28
  //
24
- // standalone — judged harmless because it costs $0. True of the invoice; false of the
25
- // trust. The unit's WorkingDirectory is the caller's own checkout and the
26
- // loader discovers modules from process.cwd(), so the platform require()s
27
- // a repo the caller named.
29
+ // cloud-host — then stored as 'standalone'. Judged harmless because it costs $0. True
30
+ // of the invoice; false of the trust. The unit's WorkingDirectory is the
31
+ // caller's own checkout and the loader discovers modules from
32
+ // process.cwd(), so the platform require()s a repo the caller named.
28
33
  // control-plane — did not exist when the gate was written, and was permitted by omission
29
34
  // the moment task 1003159 added it. A probe returned 201 and a second
30
35
  // control-plane row, while this file's own comment asserted it could not.
@@ -173,41 +178,41 @@ await ta('the refusal speaks WIRE vocabulary — no owner-facing label is transl
173
178
  const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: shape, no_address: true });
174
179
  assert.equal(res.status, 403, `expected 403 for ${shape}, got ${res.status} ${res.raw}`);
175
180
  const message = res.body.error.message || '';
176
- for (const label of ['bring your own host', 'cloud host', 'dedicated cloud host']) {
181
+ for (const label of ['bring your own host', 'your own host', 'shared server']) {
177
182
  assert.ok(!message.toLowerCase().includes(label),
178
183
  `the ${shape} refusal translates a stored value into the owner-facing label "${label}" — that map lives only in projects.html's SHAPE_LABEL (ADR 0323): ${message}`);
179
184
  }
180
185
  }
181
186
  // And the caller is still told what they CAN ask for, in the vocabulary they sent.
182
187
  const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'co-tenant', no_address: true });
183
- assert.deepEqual([...res.body.error.details.requestable].sort(), ['byo-host', 'dedicated', 'standalone'],
188
+ assert.deepEqual([...res.body.error.details.requestable].sort(), ['byo-host', 'cloud-host'],
184
189
  'a refusal that names no alternative is a dead end — the stored values are the alternative');
185
190
  });
186
191
 
187
- // ── standalone runs the caller's OWN code on the platform's box (task 1003370) ────
192
+ // ── cloud-host runs the caller's OWN code on the platform's box (task 1003370) ────
188
193
 
189
- await ta('standalone: REFUSED for a builder without provisioning.fleet.manage', async () => {
194
+ await ta('cloud-host: REFUSED for a builder without provisioning.fleet.manage', async () => {
190
195
  // Task 1003682 left this shape open, reasoning that a $0 shape hands the caller
191
196
  // nothing. That is true of the invoice and false of the trust: the systemd unit's
192
197
  // WorkingDirectory is the caller's own checkout and the module loader discovers
193
- // `modules/` from process.cwd(), so a standalone request is "please require() this
198
+ // `modules/` from process.cwd(), so a cloud-host request is "please require() this
194
199
  // repo on your control plane". The uid split (task 1003369) bounds the blast radius;
195
200
  // it does not make the default acceptable.
196
201
  held = [];
197
202
  lastCreateArgs = null;
198
- const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'standalone', no_address: true });
203
+ const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'cloud-host', no_address: true });
199
204
  assert.equal(res.status, 403, `expected 403, got ${res.status} ${res.raw}`);
200
205
  assert.equal(res.body.error.code, 'permission_forbidden');
201
206
  assert.deepEqual(res.body.error.details.required, ['provisioning.fleet.manage']);
202
207
  assert.equal(lastCreateArgs, null, 'no row is created when the shape is refused');
203
208
  });
204
209
 
205
- await ta('standalone: ADMITTED for a builder holding provisioning.fleet.manage', async () => {
210
+ await ta('cloud-host: ADMITTED for a builder holding provisioning.fleet.manage', async () => {
206
211
  held = ['provisioning.fleet.manage'];
207
212
  lastCreateArgs = null;
208
- const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'standalone', no_address: true });
213
+ const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'cloud-host', no_address: true });
209
214
  assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
210
- assert.equal(lastCreateArgs && lastCreateArgs.hostingShape, 'standalone');
215
+ assert.equal(lastCreateArgs && lastCreateArgs.hostingShape, 'cloud-host');
211
216
  });
212
217
 
213
218
  // ── control-plane is not requestable AT ALL (task 1003370) ────────────────────────
@@ -239,45 +244,46 @@ await ta('control-plane: still refused for a builder holding EVERY permission',
239
244
  assert.equal(lastCreateArgs, null, 'no row is created');
240
245
  });
241
246
 
242
- // ── dedicated is the metered one ──────────────────────────────────────────────────
243
-
244
- await ta('dedicated: REFUSED for a builder without provisioning.fleet.manage', async () => {
245
- held = [];
246
- lastCreateArgs = null;
247
- const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'dedicated', no_address: true });
248
- assert.equal(res.status, 403, `expected 403, got ${res.status} ${res.raw}`);
249
- assert.equal(res.body.error.code, 'permission_forbidden');
250
- // requirePermission reports the gate's literal list under `details` (the same envelope
251
- // requireRank uses), so the denial names the atom a caller has to be granted.
252
- assert.deepEqual(res.body.error.details.required, ['provisioning.fleet.manage']);
253
- assert.equal(lastCreateArgs, null, 'no row is created when the shape is refused');
254
- });
247
+ // ── the removed shape stays removed ───────────────────────────────────────────────
255
248
 
256
- await ta('dedicated: ADMITTED for a builder holding provisioning.fleet.manage', async () => {
257
- held = ['provisioning.fleet.manage'];
258
- lastCreateArgs = null;
259
- const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'dedicated', no_address: true });
260
- assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
261
- assert.equal(lastCreateArgs && lastCreateArgs.hostingShape, 'dedicated');
249
+ await ta('dedicated is no longer requestable AT ALL — not by permission, not by anyone', async () => {
250
+ // This is the assertion that holds ADR 0327 decision 4. 'dedicated' used to be the metered
251
+ // shape: refused without provisioning.fleet.manage, ADMITTED with it. Task 1004182 removed
252
+ // the value from HOSTING_SHAPES and migration provisioning_027 folded its rows into
253
+ // 'cloud-host', so it is now an unknown shape — the gate DEFERS it and the validator
254
+ // refuses it, whatever the caller holds.
255
+ //
256
+ // Asserted for BOTH permission states deliberately. Testing only the unprivileged case
257
+ // would still pass if the shape had merely gone back to being gated, which is exactly the
258
+ // state this removal is distinguishable from.
259
+ for (const permissions of [[], ['provisioning.fleet.manage']]) {
260
+ held = permissions;
261
+ lastCreateArgs = null;
262
+ const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'dedicated', no_address: true });
263
+ assert.ok(res.status === 400 || res.status === 403,
264
+ `holding ${JSON.stringify(permissions)}: expected a refusal, got ${res.status} ${res.raw}`);
265
+ assert.equal(lastCreateArgs, null,
266
+ `holding ${JSON.stringify(permissions)}: a removed shape must never reach createInstance`);
267
+ }
262
268
  });
263
269
 
264
- await ta('the gate refuses BEFORE the row exists — a denied dedicated create leaves nothing behind', async () => {
270
+ await ta('the gate refuses BEFORE the row exists — a denied create leaves nothing behind', async () => {
265
271
  // The denial must land ahead of createInstance, not after it: a refused-but-inserted row
266
272
  // would still be drained by the runner, which reads the column, not the HTTP status.
267
273
  held = [];
268
274
  lastCreateArgs = null;
269
- await post('/provisioning/instances', { slug: 'spendy', hosting_shape: 'dedicated', tier: 's-4vcpu-8gb' });
275
+ await post('/provisioning/instances', { slug: 'spendy', hosting_shape: 'cloud-host', no_address: true });
270
276
  assert.equal(lastCreateArgs, null);
271
277
  });
272
278
 
273
279
  // ── the gate cannot be walked around ──────────────────────────────────────────────
274
280
 
275
- await ta('a case-variant shape does not slip past the gate INTO a dedicated row', async () => {
281
+ await ta('a case-variant shape does not slip past the gate INTO a privileged row', async () => {
276
282
  // The gate matches the exact wire value, and so does the shape validator — so a variant
277
- // is refused as a bad shape rather than admitted as a cheap one and stored as dedicated.
278
- // The property that matters is not WHICH error comes back, it is that no dedicated row
283
+ // is refused as a bad shape rather than admitted as a cheap one and stored as a gated
284
+ // shape. The property that matters is not WHICH error comes back, it is that no gated row
279
285
  // is ever created without the permission.
280
- for (const shape of ['DEDICATED', 'Dedicated', ' dedicated', 'dedicated ']) {
286
+ for (const shape of ['CLOUD-HOST', 'Cloud-Host', ' cloud-host', 'cloud-host ']) {
281
287
  held = [];
282
288
  lastCreateArgs = null;
283
289
  const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: shape, no_address: true });
@@ -307,8 +313,15 @@ await ta('HOSTING_SHAPES is pinned, so a new shape forces a written decision abo
307
313
  // no caller may request: the value stays storable so one torn-down row remains
308
314
  // readable, and the door is shut in UNREQUESTABLE_SHAPES instead. Dropping it from
309
315
  // HOSTING_SHAPES would make that historical row violate its own CHECK constraint.
316
+ //
317
+ // It fired a THIRD time at task 1004182, which is the first time it fired for a shape
318
+ // going AWAY rather than arriving. 'dedicated' left the set entirely (ADR 0327 decision 4)
319
+ // and 'standalone' was renamed to 'cloud-host' so the stored value matches the
320
+ // owner-facing word; migration provisioning_027 moved both. A shape LEAVING needs the same
321
+ // written decision as one arriving, and more care: the rows carrying the old value have to
322
+ // land somewhere legal before the CHECK constraint narrows under them.
310
323
  assert.deepEqual([...provisioning.HOSTING_SHAPES].sort(),
311
- ['byo-host', 'co-tenant', 'control-plane', 'dedicated', 'standalone']);
324
+ ['byo-host', 'cloud-host', 'co-tenant', 'control-plane']);
312
325
  });
313
326
 
314
327
  await ta('an unknown shape is answered by the validator, not by the gate', () => {
@@ -83,7 +83,7 @@ function post(path, body) {
83
83
  });
84
84
  }
85
85
 
86
- const LIVE = { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'standalone', target_ref: 'example-owner/spike' };
86
+ const LIVE = { owner_builder_id: 7, slug: 'spike', status: 'active', hosting_shape: 'cloud-host', target_ref: 'example-owner/spike' };
87
87
  const OWNER = { id: 7, github_login: 'owner', rank: 'thetes' };
88
88
  function reset(patch = {}) {
89
89
  instance = { ...LIVE, ...patch };