@bongos/core 1.19.950 → 1.19.951

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.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.950",
6
- "core_contract": "1.19.950",
7
- "source_commit": "b332b91c550367626b416cf9edd7e689d7e5f061",
5
+ "core_version": "1.19.951",
6
+ "core_contract": "1.19.951",
7
+ "source_commit": "52686c08170a959adbd2fff6681162b3b3e248ae",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-22T03:31:42.605Z",
9
+ "built_at": "2026-09-22T04:02:59.605Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 513,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2347,
14
+ "functional_verbatim": 2350,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2886,
20
- "tree_sha256": "0faab48c30a2ae5ab6fce64aec38fc54571056bfe7f3fa566f4336818100fd1c",
19
+ "file_count": 2889,
20
+ "tree_sha256": "4c573e93b9c8147a0c7657ef1f9c43b7e3a0e37d6254e65502e205f91a77fdb0",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2687,7 +2687,7 @@
2687
2687
  {
2688
2688
  "path": "docs/module-api-changelog.md",
2689
2689
  "mode": "0000644",
2690
- "sha256": "1b1d7162b5d86bf679ed7a8bbf6d0a354eb382f88ef896651211856ab7fda2dc"
2690
+ "sha256": "707238093af0e4b56e36139173b6d46d7ffd33a86c348181a40dc74083821738"
2691
2691
  },
2692
2692
  {
2693
2693
  "path": "docs/modules-contract.md",
@@ -6517,12 +6517,12 @@
6517
6517
  {
6518
6518
  "path": "modules/provisioning/capacity-gate.js",
6519
6519
  "mode": "0000644",
6520
- "sha256": "28dfe87a6aa6a6a7ef15dfda3a845c5d6cb442ff7489c851308eb474c1043836"
6520
+ "sha256": "5d0d3eb34c68620b785803a40df416872e6a606cf6e144a9d2793f88bc231cb8"
6521
6521
  },
6522
6522
  {
6523
6523
  "path": "modules/provisioning/capacity.js",
6524
6524
  "mode": "0000644",
6525
- "sha256": "1a2b3bd97a2e0c0068c6200a71dabb692e90723ee1ff63a56775f2b91e1cb208"
6525
+ "sha256": "56a2be9188fa0d8785174e6e91547c10266c3b6859fcad00aebd5739de4f366f"
6526
6526
  },
6527
6527
  {
6528
6528
  "path": "modules/provisioning/catalog-bridge.js",
@@ -6649,6 +6649,11 @@
6649
6649
  "mode": "0000644",
6650
6650
  "sha256": "cdc3e8992d1e4273a472b826f208736235c8a3b16a700de33a7705cdae99ef9e"
6651
6651
  },
6652
+ {
6653
+ "path": "modules/provisioning/migrations/provisioning_021_byo_host_shape.sql",
6654
+ "mode": "0000644",
6655
+ "sha256": "cde8a3a4c66c17d17c4e18866f11600a06073377de41d5ed70cd93185cd78d6d"
6656
+ },
6652
6657
  {
6653
6658
  "path": "modules/provisioning/module.json",
6654
6659
  "mode": "0000644",
@@ -6657,12 +6662,12 @@
6657
6662
  {
6658
6663
  "path": "modules/provisioning/onboard-plan.js",
6659
6664
  "mode": "0000644",
6660
- "sha256": "3c36bb1b163a35492ccd3d231e50039bded945fbccac076166c70a67767a687d"
6665
+ "sha256": "c67f592415bb9c81e3eef658a7352c2bc47dfb4056b2cdef52f772e5021d28cf"
6661
6666
  },
6662
6667
  {
6663
6668
  "path": "modules/provisioning/paid-shape-gate.js",
6664
6669
  "mode": "0000644",
6665
- "sha256": "7c08e627e228bec400e9e2dc204418a6995a4668eb6c1817ae59f20f77cbe71e"
6670
+ "sha256": "cceda07080d1175a3629ebb2105e36220a2c2728768f2c6eb691f0269479a002"
6666
6671
  },
6667
6672
  {
6668
6673
  "path": "modules/provisioning/pollers/catalog-backfill.js",
@@ -6682,7 +6687,7 @@
6682
6687
  {
6683
6688
  "path": "modules/provisioning/provisioning.js",
6684
6689
  "mode": "0000644",
6685
- "sha256": "cfd57a109b12ecc629422c16a4db85fe2daf1700dcae60df2885701e5eecd5cc"
6690
+ "sha256": "82bb265d63bb882468bbb609faa60a783ccf7dfeec5cd4595550586c37131bda"
6686
6691
  },
6687
6692
  {
6688
6693
  "path": "modules/provisioning/rate-limit.js",
@@ -6717,7 +6722,7 @@
6717
6722
  {
6718
6723
  "path": "modules/provisioning/routes/provisioning.js",
6719
6724
  "mode": "0000644",
6720
- "sha256": "63919aad35ff1f47c4981e5e5c1f1c08d79eb0de9588e0c3ba2c5ed488167211"
6725
+ "sha256": "a10d9ac50f6be584c33a6bb559ad9d0431c2ac7f4655c23fcdf47baedf540cd2"
6721
6726
  },
6722
6727
  {
6723
6728
  "path": "modules/provisioning/seams.js",
@@ -7242,7 +7247,7 @@
7242
7247
  {
7243
7248
  "path": "modules/public-landing/public/projects.html",
7244
7249
  "mode": "0000644",
7245
- "sha256": "8de1601446961430e081632ad410a22a21665749d9acbc0b6f7f9c7a6b9d92c9"
7250
+ "sha256": "dab97906f2db723c0ec00aaf1a7930b7e0051098e6102704f65a6d0c705543ff"
7246
7251
  },
7247
7252
  {
7248
7253
  "path": "modules/public-landing/public/projects.probes.json",
@@ -8042,12 +8047,12 @@
8042
8047
  {
8043
8048
  "path": "package-lock.json",
8044
8049
  "mode": "0000644",
8045
- "sha256": "ef28356c6251d661ff78b1446e9ddc3e13770144c862634da5a8d91ef7ba6e51"
8050
+ "sha256": "14465332b60fa42b702654bd0dd552e8b332b3c17cc969bfe08c3cd8a720320a"
8046
8051
  },
8047
8052
  {
8048
8053
  "path": "package.json",
8049
8054
  "mode": "0000644",
8050
- "sha256": "fe67ef68895ddb7bc5c2a507a7f57fa4d3b683611e557cba82be40f3641013e3"
8055
+ "sha256": "f7f40ce54ba7eddac00919f6407ee95888c15a87e3fc526450b64a00365cd913"
8051
8056
  },
8052
8057
  {
8053
8058
  "path": "public-docs/index.html",
@@ -8767,7 +8772,7 @@
8767
8772
  {
8768
8773
  "path": "scripts/gds/init.js",
8769
8774
  "mode": "0000644",
8770
- "sha256": "f24475957efcba217133ef05c297e5a9f11d78ae884a6e7286084723d9daf912"
8775
+ "sha256": "d18b93dc0ac9136cfae79d98cd3506cb12d94fce30829a39dfb15f880770b93e"
8771
8776
  },
8772
8777
  {
8773
8778
  "path": "scripts/gds/install-git-hooks.js",
@@ -8974,6 +8979,11 @@
8974
8979
  "mode": "0000644",
8975
8980
  "sha256": "b0e2c14720e8258eba4d4ef7f69cc2bcbb26c3be312deb891383dce7a1dbf71e"
8976
8981
  },
8982
+ {
8983
+ "path": "scripts/gds/provision-byo-host.js",
8984
+ "mode": "0000644",
8985
+ "sha256": "92ef1fa760b90ece9781abe6d3c75abf84ff39cbb8b42194cca367bab7821269"
8986
+ },
8977
8987
  {
8978
8988
  "path": "scripts/gds/provision-capacity.js",
8979
8989
  "mode": "0000644",
@@ -9027,7 +9037,7 @@
9027
9037
  {
9028
9038
  "path": "scripts/gds/provision.js",
9029
9039
  "mode": "0000644",
9030
- "sha256": "e879686d5fcadfd32eb1dfc4e1640fc229cc4f0afc78615681964484e8457c1f"
9040
+ "sha256": "1d92b6cbbfb544328a8bacd29cb492445508617298c251872d846bb7ab5a180a"
9031
9041
  },
9032
9042
  {
9033
9043
  "path": "scripts/gds/publish-credential-check.js",
@@ -10047,7 +10057,7 @@
10047
10057
  {
10048
10058
  "path": "src/module-api.js",
10049
10059
  "mode": "0000644",
10050
- "sha256": "107e4e25c1f869c19259c01752fecc19c138790c60981256651663823345c049"
10060
+ "sha256": "156e1f0be121fb3fa396c32374f1ab8d0540f1d6c42a8aee28d05b5429a14115"
10051
10061
  },
10052
10062
  {
10053
10063
  "path": "src/module-loader/catalog.js",
@@ -12277,7 +12287,7 @@
12277
12287
  {
12278
12288
  "path": "tests/init.mjs",
12279
12289
  "mode": "0000644",
12280
- "sha256": "26629f6a3882b35f6844b8f0f9af9221e53a0906bb89401e8e23b3e5aee8d1f0"
12290
+ "sha256": "9feeee7dbc2f1ae59a0af7eadbb521b74f114ce83e3f45ffdef59c1f8fdf25ec"
12281
12291
  },
12282
12292
  {
12283
12293
  "path": "tests/instance_config.mjs",
@@ -13062,7 +13072,7 @@
13062
13072
  {
13063
13073
  "path": "tests/projects_hub_pre_uat.mjs",
13064
13074
  "mode": "0000644",
13065
- "sha256": "5095bb2c890d89337c819fc3fc07fe26507f0bd78607b2b49239bccf23419aa3"
13075
+ "sha256": "19e1b68463ddccb1a1155e1adfb3d4c130711745030183fb1d27650f1bfe5444"
13066
13076
  },
13067
13077
  {
13068
13078
  "path": "tests/promote_deps_gate.mjs",
@@ -13104,6 +13114,11 @@
13104
13114
  "mode": "0000644",
13105
13115
  "sha256": "9f62eea1b43a478334a83df52d0e81a3b3b67f4c4eea54680d2d8db3b25ee513"
13106
13116
  },
13117
+ {
13118
+ "path": "tests/provision_byo_host.mjs",
13119
+ "mode": "0000644",
13120
+ "sha256": "79c33863862966ef0d7a0f4d9dc2290596a4f98da2796083b9319c24a6bfc26e"
13121
+ },
13107
13122
  {
13108
13123
  "path": "tests/provision_caddy_wiring.mjs",
13109
13124
  "mode": "0000644",
@@ -13137,12 +13152,12 @@
13137
13152
  {
13138
13153
  "path": "tests/provisioning_capacity.mjs",
13139
13154
  "mode": "0000644",
13140
- "sha256": "bcb976ab17508d1af34ea0c775d25765f336f534d79de9442b2fbc6712fa2a28"
13155
+ "sha256": "7a48fb54dce89efa207d7dd254a8a5ef70c6b1958b6ed2be198a73e76bb74327"
13141
13156
  },
13142
13157
  {
13143
13158
  "path": "tests/provisioning_capacity_gate.mjs",
13144
13159
  "mode": "0000644",
13145
- "sha256": "d95439f89702862e45cf1c0b238aa5a463e81b584d7dca70d91863547bee6642"
13160
+ "sha256": "363405d0f78714e9e3c29ee6ce64a1735a96fdaa95c631930743019daf8e26ff"
13146
13161
  },
13147
13162
  {
13148
13163
  "path": "tests/provisioning_credential_preflight.mjs",
@@ -13182,17 +13197,17 @@
13182
13197
  {
13183
13198
  "path": "tests/provisioning_module_selection.mjs",
13184
13199
  "mode": "0000644",
13185
- "sha256": "540e9b5b158a5623525924403bcef444f87fe0364591cd9c6b9e0a55f4f6295c"
13200
+ "sha256": "8eaa3b40b38cbc294836ac3c4905f6a0a7c4a04e9c68d443e503f479a735c839"
13186
13201
  },
13187
13202
  {
13188
13203
  "path": "tests/provisioning_onboard_mode.mjs",
13189
13204
  "mode": "0000644",
13190
- "sha256": "f7a01aa6ed190c1ad544d91312909b081446a7fc2b6ef339f3ba8fa4d59f0306"
13205
+ "sha256": "96c7ea0d646151393767061f042082f006007390ff652c7c649fd9151ab2ed61"
13191
13206
  },
13192
13207
  {
13193
13208
  "path": "tests/provisioning_paid_shape_gate.mjs",
13194
13209
  "mode": "0000644",
13195
- "sha256": "19961880545fe8f6c9eaf79095aa0d08f7862409109d581cac828f0f20b1be69"
13210
+ "sha256": "859a44a139cfc7a52bc534dac8dc52f1203d60d5bdaac34e64834b6838e128fe"
13196
13211
  },
13197
13212
  {
13198
13213
  "path": "tests/provisioning_recommendations.mjs",
@@ -2374,5 +2374,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2374
2374
  landed since 1.19.948 with no explicit bump. run 35675336642. (task 1002620)
2375
2375
  1.19.950 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2376
2376
  landed since 1.19.949 with no explicit bump. run 35683452297. (task 1002620)
2377
+ 1.19.951 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2378
+ landed since 1.19.950 with no explicit bump. run 35685346231. (task 1002620)
2377
2379
  ---------------------------------------------------------------------------
2378
2380
  ```
@@ -26,7 +26,13 @@ function requireRoomOnBox(db, log) {
26
26
  return async function capacityGate(req, res, next) {
27
27
  const body = req.body || {};
28
28
  // The handler's own default and validation: a malformed request gets its 400 there.
29
- const shape = body.hosting_shape || 'co-tenant';
29
+ // The absent-shape default MUST match createInstance's coercion and the paid-shape
30
+ // gate's, or this gate prices a request against a shape the row will not take. It
31
+ // was 'co-tenant' until ADR 0323; that shape lands on the control plane, so after the
32
+ // default moved to 'byo-host' — which runs on the OWNER's machine and takes no slot
33
+ // here — a shapeless create on a full box would have been refused box_full for
34
+ // capacity it never consumes. Three places default this; they agree deliberately.
35
+ const shape = body.hosting_shape || 'byo-host';
30
36
  const slug = String(body.slug || '').trim().toLowerCase();
31
37
  if (!provisioning.HOSTING_SHAPES.includes(shape) || !provisioning.isValidSlug(slug)) return next();
32
38
  const target = capacity.placementBox(shape, { remoteHost: process.env.PROVISION_REMOTE_HOST || null });
@@ -68,8 +68,14 @@ function parseMeminfo(text) {
68
68
  // local (task 2074); a co-tenant runs on the remote co-hosting box only when the
69
69
  // runner is in remote-exec mode (ADR 0130), else here. A dedicated droplet is its
70
70
  // own box and has no shared ceiling → null (no preflight).
71
+ //
72
+ // A 'byo-host' (ADR 0323) also answers null, for a stronger reason than dedicated's:
73
+ // the owner supplies the machine, so the platform has no box to place it on and no
74
+ // memory of its own to spend. It can never be refused for capacity, and it must never
75
+ // consume a slot that a cloud-host would otherwise get — which is what returning null
76
+ // here buys, since PLACED_STATES counting keys off the shapes a box declares.
71
77
  function placementBox(hostingShape, { remoteHost = null } = {}) {
72
- if (hostingShape === 'dedicated') return null;
78
+ if (hostingShape === 'dedicated' || hostingShape === 'byo-host') return null;
73
79
  if (hostingShape === 'co-tenant' && remoteHost) {
74
80
  return { box: 'overflow', shapes: ['co-tenant'], fixedMb: FIXED_MB.overflow };
75
81
  }
@@ -0,0 +1,37 @@
1
+ -- provisioning_021 — admit the 'byo-host' hosting shape (ADR 0323, task 1004141).
2
+ --
3
+ -- "Bring your own host": the OWNER runs the instance on their own machine, VPS or
4
+ -- platform account. The platform stands up nothing and reaches into nothing, so the
5
+ -- row is a record of an instance we do not operate — liveness is the ADR 0252 pull
6
+ -- against an owner-supplied origin.
7
+ --
8
+ -- A CHECK-ENUM WIDENING, and nothing else. `DROP CONSTRAINT IF EXISTS` paired with a
9
+ -- re-`ADD CONSTRAINT` of the SAME name in the SAME migration only ever PERMITS more
10
+ -- values: no existing row changes and no data is lost, which is why fitness.js
11
+ -- (isConstraintWidening) treats this exact shape as additive rather than destructive.
12
+ -- The precedent is provisioning_002, which admitted 'standalone' the same way.
13
+ --
14
+ -- 'co-tenant' STAYS IN THE SET even though ADR 0323 retires it. Retirement closes the
15
+ -- DOOR, not the record: one torn-down row still carries that value, and dropping it
16
+ -- from the constraint would make a historical row violate its own table. The door is
17
+ -- closed in code instead — paid-shape-gate.js lists it UNREQUESTABLE, so no new row can
18
+ -- ever take it, while the old one stays readable. A shape that is storable but never
19
+ -- grantable again is exactly what that set is for.
20
+ --
21
+ -- The two managed shapes keep their OLD stored spellings ('standalone', 'dedicated')
22
+ -- on purpose. ADR 0323 renames them owner-facing to "cloud host" and "dedicated cloud
23
+ -- host", but about half the code reading those values is landlord machinery that task
24
+ -- 1004142 deletes when it moves them to a managed platform, and renaming a call site a
25
+ -- task before deleting it is work done twice. That task carries the data rename with
26
+ -- those files already open.
27
+
28
+ ALTER TABLE provisioning_instances DROP CONSTRAINT IF EXISTS provisioning_instances_hosting_shape_check;
29
+ ALTER TABLE provisioning_instances ADD CONSTRAINT provisioning_instances_hosting_shape_check
30
+ CHECK (hosting_shape IN ('byo-host', 'co-tenant', 'dedicated', 'standalone', 'control-plane'));
31
+
32
+ -- The COLUMN default was 'co-tenant' (provisioning_001), which is now a retired shape.
33
+ -- Every insert today goes through createInstance, which always passes an explicit shape,
34
+ -- so nothing currently relies on this default — which is exactly why it is worth moving
35
+ -- now rather than leaving a loaded gun for the first insert that ever omits the column.
36
+ -- Changing a DEFAULT rewrites no existing row.
37
+ ALTER TABLE provisioning_instances ALTER COLUMN hosting_shape SET DEFAULT 'byo-host';
@@ -34,11 +34,18 @@
34
34
  // `shapes` (task 1002696; direction §7 — a surface renders only steps somebody real
35
35
  // will perform on THIS instance's shape). The control-plane shapes (standalone /
36
36
  // co-tenant / dedicated) have the platform's runner performing every 'auto' leg;
37
- // 'self-host' has no runner, so the runner-performed legs are absent from its plan —
38
- // a self-host form must never promise them (QA walk finding, task 1002717). Steps
39
- // with no `shapes` apply everywhere. Filter with stepsForShape().
37
+ // 'byo-host' has no runner, so the runner-performed legs are absent from its plan —
38
+ // a bring-your-own-host form must never promise them (QA walk finding, task 1002717).
39
+ // Steps with no `shapes` apply everywhere. Filter with stepsForShape().
40
+ //
41
+ // 'byo-host' REPLACED the plan-only spelling 'self-host' here (ADR 0323, task 1004141).
42
+ // That value was this file's own invention and had no callers anywhere else, so it was
43
+ // a second vocabulary for a concept the hosting shapes now name directly — and two words
44
+ // for one thing is how a filter starts silently matching nothing. The shape is absent
45
+ // from CONTROL_PLANE_SHAPES, which is what drops the runner-performed legs from its plan
46
+ // BY CONSTRUCTION rather than by a list someone has to remember to update.
40
47
  const CONTROL_PLANE_SHAPES = ['standalone', 'co-tenant', 'dedicated'];
41
- const ALL_SHAPES = [...CONTROL_PLANE_SHAPES, 'self-host'];
48
+ const ALL_SHAPES = [...CONTROL_PLANE_SHAPES, 'byo-host'];
42
49
 
43
50
  function onboardPlan(mode = 'greenfield', opts = {}) {
44
51
  const adopt = mode === 'adopt';
@@ -13,8 +13,15 @@
13
13
  // the shapes it accepts is actually that front door. The others each hand the caller
14
14
  // something the platform pays for or trusts:
15
15
  //
16
- // co-tenant — the front door. Co-tenants on infrastructure the platform already
17
- // runs; `costEstimateUsd` is 0. No authority required, ever.
16
+ // byo-host — the front door since ADR 0323. The OWNER supplies the host, so the
17
+ // platform pays nothing and runs none of the caller's code. No
18
+ // authority required, ever. This is the shape that replaced 'co-tenant'
19
+ // as the free option, and it is a STRONGER front door than co-tenant
20
+ // was: co-tenant was free of charge but still ran on our box, whereas
21
+ // this one never touches it.
22
+ // co-tenant — RETIRED (ADR 0323). Was the free front door; ran on the platform's
23
+ // own box, which is why the name could not be carried over to
24
+ // bring-your-own-host. Now UNREQUESTABLE — see below.
18
25
  // dedicated — allocates a real droplet (`scripts/gds/provision.js`), billed
19
26
  // monthly, defaulting to s-2vcpu-4gb when no tier is named.
20
27
  // standalone — runs the caller's OWN repo on the platform's box. The unit's
@@ -61,11 +68,11 @@
61
68
  const provisioning = require('./provisioning.js');
62
69
 
63
70
  // The shapes any authenticated builder may request with no permission at all. This is
64
- // the whole allow-list, and it is deliberately one entry long: co-tenant is the front
71
+ // the whole allow-list, and it is deliberately one entry long: byo-host is the front
65
72
  // door, and the front door is the only thing that should be free. `tests/
66
73
  // provisioning_paid_shape_gate.mjs` pins HOSTING_SHAPES against this set so a new shape
67
74
  // cannot be added without someone deciding, in writing, which side of the line it is on.
68
- const OPEN_SHAPES = Object.freeze(new Set(['co-tenant']));
75
+ const OPEN_SHAPES = Object.freeze(new Set(['byo-host']));
69
76
 
70
77
  // Shapes that are not requestable through this route AT ALL — no permission admits them,
71
78
  // because there is no rank at which "ask the API for a second control plane" is a
@@ -75,7 +82,13 @@ const OPEN_SHAPES = Object.freeze(new Set(['co-tenant']));
75
82
  // `WHERE hosting_shape = 'control-plane' LIMIT 1`, so a second row makes "which machine
76
83
  // is the control plane" a question with a caller-influenced answer. Refused by name, at
77
84
  // the door, for the same reason control-plane-guard.js refuses by name.
78
- const UNREQUESTABLE_SHAPES = Object.freeze(new Set(['control-plane']));
85
+ // 'co-tenant' joins it for a different reason (ADR 0323): it is RETIRED, not secret.
86
+ // It remains a valid stored value because a torn-down row still carries it, so it cannot
87
+ // simply leave HOSTING_SHAPES without making that row's history unreadable — and a shape
88
+ // that is storable but must never be granted again is exactly what 'unrequestable' means.
89
+ // Refusing it here rather than deleting the value is what makes the retirement complete
90
+ // without rewriting the past.
91
+ const UNREQUESTABLE_SHAPES = Object.freeze(new Set(['control-plane', 'co-tenant']));
79
92
 
80
93
  // shapeAuthority — what does this wire value demand? PURE. Returns one of:
81
94
  // 'open' → admit with no permission
@@ -88,7 +101,7 @@ const UNREQUESTABLE_SHAPES = Object.freeze(new Set(['control-plane']));
88
101
  //
89
102
  // 'defer' is safe despite looking like a hole: the validator runs immediately after this
90
103
  // middleware and refuses anything outside HOSTING_SHAPES, and `createInstance` coerces an
91
- // unrecognised shape to 'co-tenant' rather than storing it. There is no path by which a
104
+ // unrecognised shape to 'byo-host' rather than storing it. There is no path by which a
92
105
  // deferred value becomes a privileged row.
93
106
  //
94
107
  // Matches the exact wire value — a case or whitespace variant of a privileged shape is
@@ -96,7 +109,7 @@ const UNREQUESTABLE_SHAPES = Object.freeze(new Set(['control-plane']));
96
109
  // admitted as a cheap one.
97
110
  function shapeAuthority(rawShape) {
98
111
  const shape = rawShape === undefined || rawShape === null || rawShape === ''
99
- ? 'co-tenant' // an absent shape is the documented default, and it is the open one
112
+ ? 'byo-host' // an absent shape is the documented default, and it is the open one
100
113
  : rawShape;
101
114
  if (typeof shape !== 'string') return 'defer';
102
115
  if (!provisioning.HOSTING_SHAPES.includes(shape)) return 'defer';
@@ -115,9 +128,25 @@ function requireAuthorityForShape(requirePermission) {
115
128
  const verdict = shapeAuthority(req.body && req.body.hosting_shape);
116
129
  if (verdict === 'open' || verdict === 'defer') return next();
117
130
  if (verdict === 'unrequestable') {
131
+ // Two shapes land here for different reasons, so the message names which.
132
+ //
133
+ // It names STORED VALUES and never an owner-facing label, deliberately. The
134
+ // stored value → owner-facing name translation lives in exactly one place —
135
+ // SHAPE_LABEL in modules/public-landing/public/projects.html (ADR 0323, and this
136
+ // module's CLAUDE.md says so) — and an API error string is the worst possible
137
+ // second copy of it: the next person to rename the product renames the label map
138
+ // and a wizard heading, and this sentence keeps selling a name that no longer
139
+ // exists. The right register for a 403 is the wire vocabulary the caller sent,
140
+ // which is also what `details.requestable` already hands them; a UI that wants to
141
+ // say it in product words has SHAPE_LABEL for exactly that. The guard against it
142
+ // creeping back is an assertion, not this comment — see
143
+ // ../../tests/provisioning_paid_shape_gate.mjs.
144
+ const retired = req.body.hosting_shape === 'co-tenant';
118
145
  return res.fail('shape_not_requestable', {
119
146
  status: 403,
120
- message: 'The control plane enrolls its own row; it cannot be requested through this route.',
147
+ message: retired
148
+ ? "That hosting shape has been retired and can no longer be requested. Ask for one of the shapes in `requestable` instead: `byo-host` runs on a host you supply, and the rest are stood up for you."
149
+ : 'The control plane enrolls its own row; it cannot be requested through this route.',
121
150
  details: { hosting_shape: req.body.hosting_shape, requestable: [...provisioning.HOSTING_SHAPES].filter((s) => !UNREQUESTABLE_SHAPES.has(s)) },
122
151
  });
123
152
  }
@@ -24,11 +24,8 @@ const { normalizeModuleSelection, effectiveModules } = require('./starter-bundle
24
24
  // Constants (mirror the CHECK constraints in provisioning_001_tables.sql)
25
25
  // ---------------------------------------------------------------------------
26
26
  const INSTANCE_STATES = ['requested', 'provisioning', 'active', 'tearing_down', 'torn_down', 'error'];
27
- // co-tenant — a process + DB on THIS control-plane checkout (shared box, $0)
28
- // dedicated — its own DigitalOcean droplet (priced by tier)
29
- // standalone — its OWN repo checkout + pinned @cloudbongos/core npm dep, co-located on
30
- // the box (ADR 0108 destination model; owner-hosted, $0). See provision.js.
31
- const HOSTING_SHAPES = ['co-tenant', 'dedicated', 'standalone', 'control-plane'];
27
+ // Shapes, owner-facing names + why the spellings differ: ADR 0323 + this module's CLAUDE.md.
28
+ const HOSTING_SHAPES = ['byo-host', 'co-tenant', 'dedicated', 'standalone', 'control-plane'];
32
29
  // How the instance's repo was onboarded (migration provisioning_004, ADR 0121):
33
30
  // greenfield — a fresh EMPTY repo; the runner scaffolds it and pushes the first commit
34
31
  // adopt — a repo the owner ALREADY has; the runner must LAYER onto it, preserving
@@ -1061,7 +1058,10 @@ async function listAllInstances(db, { status } = {}) {
1061
1058
  // decide (same owner → idempotent re-request; different owner → 409). Never
1062
1059
  // silently reassigns ownership.
1063
1060
  async function createInstance(db, { ownerBuilderId, slug, targetRef, hostingShape, domain, dbName, port, tier, onboardMode, type, description, detail, modules }) {
1064
- const shape = HOSTING_SHAPES.includes(hostingShape) ? hostingShape : 'co-tenant';
1061
+ // Unknown/absent coerces to the shape that costs nothing and trusts nothing. Was
1062
+ // 'co-tenant' (then the free front door); that shape is now retired + unrequestable,
1063
+ // so coercing to it would mint a row no caller is allowed to ask for.
1064
+ const shape = HOSTING_SHAPES.includes(hostingShape) ? hostingShape : 'byo-host';
1065
1065
  // Unknown/absent → 'greenfield', matching the column default. Falling back to the
1066
1066
  // SAFE mode matters: greenfield refuses to push over existing history (no --force
1067
1067
  // anywhere), whereas defaulting an unknown value to 'adopt' would put the layering
@@ -12,10 +12,12 @@
12
12
  //
13
13
  // own-scoped (requireBuilder — the caller's OWN instances):
14
14
  // POST /provisioning/instances — request a new instance
15
- // (only `co-tenant` is free; every other
16
- // shape ALSO needs provisioning.fleet.manage,
17
- // and `control-plane` is not requestable at
18
- // all; ../paid-shape-gate.js.
15
+ // (only `byo-host` is free — ADR 0323 made it
16
+ // the front door; every other shape ALSO
17
+ // needs provisioning.fleet.manage, and
18
+ // `control-plane` and the RETIRED `co-tenant`
19
+ // are not requestable at all;
20
+ // ../paid-shape-gate.js.
19
21
  // An `adopt` ALSO passes the credential
20
22
  // preflight — ../credential-preflight.js,
21
23
  // task 1002909; greenfield is exempt; a full box → 409, ../capacity-gate.js)
@@ -331,7 +333,15 @@ module.exports = function provisioningRoutes() {
331
333
  if (!provisioning.isValidSlug(slug)) {
332
334
  return res.fail('bad_slug', 400, { reason: 'lowercase, 2–40 chars of [a-z0-9-], no leading/trailing/double dash' });
333
335
  }
334
- const shape = body.hosting_shape || 'co-tenant';
336
+ // FOURTH and last place that resolves an absent shape, and they must all agree:
337
+ // this route, paid-shape-gate.js's authority lookup, capacity-gate.js's placement
338
+ // lookup, and createInstance's coercion. Until ADR 0323 they agreed on 'co-tenant'.
339
+ // When that shape was retired this line was the hole: the gate admits an absent
340
+ // shape as 'byo-host' (open), and then a stale default here would hand createInstance
341
+ // a literal 'co-tenant' — which IS in HOSTING_SHAPES, so the coercion never fires and
342
+ // the validator never complains, and a shapeless create quietly stores the one shape
343
+ // no caller is allowed to request. Nothing would have errored.
344
+ const shape = body.hosting_shape || 'byo-host';
335
345
  if (!provisioning.HOSTING_SHAPES.includes(shape)) {
336
346
  return res.fail('bad_hosting_shape', 400, { valid: provisioning.HOSTING_SHAPES });
337
347
  }
@@ -3934,7 +3934,9 @@ summary{min-height:24px;padding:3px 0;}
3934
3934
  ['Address', resolvedDomain()
3935
3935
  ? '<code>' + esc(resolvedDomain()) + '</code>'
3936
3936
  : '<em style="font-weight:600;color:var(--warn)">none — sign-in stays locked until it has one</em>'],
3937
- ['Shape', 'Its own server, running the platform’s current release']
3937
+ /* The wizard creates hosting_shape 'standalone', whose owner-facing name is
3938
+ "cloud host" (ADR 0323) — we stand it up and run it for them. */
3939
+ ['Shape', 'Cloud host — we stand it up and run it for you, on the platform’s current release']
3938
3940
  ];
3939
3941
  $('reviewList').innerHTML = rows.map(function (r) {
3940
3942
  return '<li><span class="k">' + r[0] + '</span><span class="v">' + r[1] + '</span></li>';
@@ -5205,7 +5207,20 @@ summary{min-height:24px;padding:3px 0;}
5205
5207
 
5206
5208
  /* ── rendering ───────────────────────────────────────────────────────── */
5207
5209
 
5208
- var SHAPE_LABEL = { standalone: 'its own server', 'co-tenant': 'shared server', dedicated: 'its own server' };
5210
+ /* The owner-facing name of each hosting shape (ADR 0323). This map IS the translation
5211
+ between the stored value and the word the owner reads: 'standalone' and 'dedicated'
5212
+ keep their old spellings in the database on purpose (task 1004142 renames the data),
5213
+ so this is the ONLY place that difference should ever be visible. Read as a noun
5214
+ phrase — it lands in a meta list and in the "server" ledger row.
5215
+ 'co-tenant' is retired and unrequestable, but one torn-down row still carries it,
5216
+ so it keeps a label rather than falling through to the raw value. */
5217
+ var SHAPE_LABEL = {
5218
+ 'byo-host': 'your own host',
5219
+ standalone: 'cloud host',
5220
+ dedicated: 'dedicated cloud host',
5221
+ 'co-tenant': 'shared server (retired)',
5222
+ 'control-plane': 'the control plane'
5223
+ };
5209
5224
  /* what a teardown ACTUALLY does, per hosting shape (F17 — the DECIDED policy:
5210
5225
  retention is deliberate on shared boxes; a dedicated machine takes its
5211
5226
  database with it). Copy follows the runner, never the other way around. */
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.950",
3
+ "version": "1.19.951",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.950",
9
+ "version": "1.19.951",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.950",
3
+ "version": "1.19.951",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -87,7 +87,13 @@ const KNOWN_MODULES = ['game', 'art-pipeline', 'discord', 'dev-box'];
87
87
  // request (POST /provisioning/instances validates slug/shape, and returns 404 when
88
88
  // the module isn't enabled on the target instance — init then falls back to filing
89
89
  // the manual production-topology task). ADR 0111 §3.
90
- const HOSTING_SHAPES = ['co-tenant', 'dedicated', 'standalone'];
90
+ // ADR 0323: 'byo-host' is the free front door and the default; 'co-tenant' is RETIRED
91
+ // and the server refuses it outright, so it is absent here DELIBERATELY. Leaving it in
92
+ // would let init write a spec the create route then 403s — a local list that accepts
93
+ // more than the server does is worse than no list, because the refusal arrives a step
94
+ // later with no useful message. Dropping it means `--shape co-tenant` fails here, naming
95
+ // the shapes that work.
96
+ const HOSTING_SHAPES = ['byo-host', 'dedicated', 'standalone'];
91
97
 
92
98
  // The Cloud Bongos core npm package an instance pins as a dependency (ADR 0108).
93
99
  // `bongos init --adopt` LAYERS this onto an existing repo's package.json; `bongos
@@ -205,11 +211,11 @@ function provisioningSlug(spec) {
205
211
  // block). THE ADR 0111 §3 ARM: infra answers become a real provisioning REQUEST (a
206
212
  // provisioning_intents row the control-plane runner drains) instead of only a manual
207
213
  // runbook task. Pure — no I/O; the caller POSTs it. Shape defaults to the cheapest
208
- // co-tenant ($0) per the budget DNA (ADR 0111 §6).
214
+ // byo-host ($0 to us — the owner supplies the host) per the budget DNA (ADR 0323).
209
215
  function provisioningRequest(spec) {
210
216
  const p = spec && spec.provisioning;
211
217
  if (!isObj(p)) return null;
212
- const shape = HOSTING_SHAPES.includes(p.shape) ? p.shape : 'co-tenant';
218
+ const shape = HOSTING_SHAPES.includes(p.shape) ? p.shape : 'byo-host';
213
219
  const body = { slug: provisioningSlug(spec), hosting_shape: shape };
214
220
  if (p.domain) body.domain = String(p.domain).trim();
215
221
  if (p.targetRef) body.target_ref = String(p.targetRef).trim();
@@ -334,12 +340,12 @@ function normalizeSpec(raw = {}) {
334
340
  spec.modules = Array.isArray(spec.modules) ? spec.modules.filter((m) => KNOWN_MODULES.includes(m)) : [];
335
341
  if (!spec.version.status) spec.version.status = 'building';
336
342
  // Provisioning (ADR 0111 §3) — a block ⇒ the owner opted in. Coerce the shape to a
337
- // known value (default the cheapest co-tenant) and trim the free-text refs; drop the
343
+ // known value (default byo-host, the free front door) and trim the free-text refs; drop the
338
344
  // block entirely when absent so provisioningRequest() reads it as "not requested".
339
345
  if (isObj(spec.provisioning)) {
340
346
  const p = spec.provisioning;
341
347
  spec.provisioning = {
342
- shape: HOSTING_SHAPES.includes(p.shape) ? p.shape : 'co-tenant',
348
+ shape: HOSTING_SHAPES.includes(p.shape) ? p.shape : 'byo-host',
343
349
  ...(p.slug ? { slug: String(p.slug).trim() } : {}),
344
350
  ...(p.domain ? { domain: String(p.domain).trim() } : {}),
345
351
  ...(p.targetRef ? { targetRef: String(p.targetRef).trim() } : {}),
@@ -446,13 +452,20 @@ async function interview({ adopt = false } = {}) {
446
452
  let provisioning;
447
453
  const wantProv = (await ask(rl, ' Provision production hosting now? (y/N)', 'N')).toLowerCase();
448
454
  if (wantProv === 'y' || wantProv === 'yes') {
449
- const shape = (await ask(rl, ' Hosting shape? (co-tenant = share an existing box, $0 / dedicated = its own droplet / standalone = its own repo + pinned @bongos/core, ADR 0108)', 'co-tenant')).toLowerCase();
455
+ // Describes each shape by WHAT IT DOES and never by its owner-facing product
456
+ // name. The stored value → owner-facing label translation lives in exactly one
457
+ // place — SHAPE_LABEL in modules/public-landing/public/projects.html (ADR 0323,
458
+ // and this module's CLAUDE.md says so) — so glossing '"cloud host"' here would
459
+ // be a second copy of it, in a file no designer renaming the product would
460
+ // think to open. A CLI prompt naming the stored value it is about to write is
461
+ // the right register anyway: this asks for a column value, not for a purchase.
462
+ const shape = (await ask(rl, ' Hosting shape? (byo-host = you run it on your own host / standalone = its own repo + pinned @bongos/core on the platform\'s box / dedicated = the same on its own droplet, priced by tier)', 'byo-host')).toLowerCase();
450
463
  const domain = await ask(rl, ' Domain you own — bring-your-own (blank = the platform assigns <slug>.<its apex> when configured)?');
451
464
  const targetRef = await ask(rl, ' Target credential ref? (which control-plane creds the runner should use; optional)');
452
465
  let tier = '';
453
466
  if (shape === 'dedicated') tier = await ask(rl, ' Droplet tier? (e.g. s-1vcpu-2gb; optional)');
454
467
  provisioning = {
455
- shape: HOSTING_SHAPES.includes(shape) ? shape : 'co-tenant',
468
+ shape: HOSTING_SHAPES.includes(shape) ? shape : 'byo-host',
456
469
  domain: domain || undefined,
457
470
  targetRef: targetRef || undefined,
458
471
  tier: tier || undefined,
@@ -0,0 +1,68 @@
1
+ 'use strict';
2
+
3
+ // scripts/gds/provision-byo-host.js — the runner's answer for a 'byo-host' instance:
4
+ // stand up NOTHING (ADR 0323, task 1004141).
5
+ //
6
+ // "Bring your own host" means the OWNER runs the instance — their machine, their VPS,
7
+ // their own platform account. The platform's only job is to hold the record, so the
8
+ // runner's job here is defined ENTIRELY BY WHAT IT MUST NOT DO: no port allocation, no
9
+ // database or role, no `web.env` under /etc, no systemd unit, no Caddy snippet, no DNS
10
+ // record, no cloud API call, no shell on anybody's box.
11
+ //
12
+ // WHY IT IS A GUARD AND NOT A CASE IN THE SWITCH. `provisionInstance` runs four
13
+ // preflights before it mutates anything — box capacity, ingress, unix accounts, Caddy
14
+ // wiring — and every one of them asks a question about a machine the platform owns. On
15
+ // a byo-host row the honest answer to each is not "pass" or "fail" but "not ours to
16
+ // ask", so the shape has to return ABOVE them, not be handled after them. That is why
17
+ // the caller's line sits immediately under the already-active idempotency check.
18
+ //
19
+ // WHAT IT PREVENTED. Before this existed the shape fell through to the shared
20
+ // co-tenant/standalone standup. `standalone` is false for it, so boxExec was the
21
+ // CONTROL PLANE's own shell: the runner would have allocated a port, run createdb,
22
+ // written env under /etc, installed a unit and a Caddy snippet and reloaded the proxy —
23
+ // standing up, on our box, the one shape whose entire promise is that we never do.
24
+ // Nothing would have errored. A silent success is the failure mode, which is why the
25
+ // proof (`tests/provision_byo_host.mjs`) asserts ABSENCE — no exec, no write, no port,
26
+ // no database — instead of asserting that the right steps ran.
27
+ //
28
+ // WHY THE ROW STILL GOES 'active'. `active` records that the platform has nothing left
29
+ // to do, not that the platform is running it. Liveness for this shape is the ADR 0252
30
+ // pull against the owner-supplied origin: the ~5-minute liveness sweep probes it by
31
+ // domain and only ever writes the overlay columns, never `status`, so the card earns its
32
+ // verdict from an observation rather than from our runner's say-so.
33
+ //
34
+ // Its own file because scripts/gds/provision.js sits at the 1500-line oversized-file
35
+ // ratchet — the same reason provision-net.js, provision-units.js and
36
+ // provision-core-upgrade.js were carved out.
37
+
38
+ // The stored value. A literal, not a regex or a prefix match: a near-miss must reach the
39
+ // standup's own validation rather than be quietly treated as the shape we skip.
40
+ const BYO_HOST_SHAPE = 'byo-host';
41
+
42
+ function isByoHost(hostingShape) {
43
+ return hostingShape === BYO_HOST_SHAPE;
44
+ }
45
+
46
+ // Record a byo-host instance as provisioned without touching any box. Reaches the
47
+ // database ONLY through the injected `provisioning` port (the status flip + the audit
48
+ // event) — it takes no executor and no writeFile, so there is nothing here that could
49
+ // mutate a machine even by mistake.
50
+ async function recordByoHost(inst, { db, provisioning, log, apply }) {
51
+ log(' [byo-host] the owner runs this one — no port, no database, no unit, no Caddy snippet, no DNS');
52
+ if (!apply) {
53
+ log(' [byo-host] would mark it active without touching any box');
54
+ return { ok: true, byoHost: true, stoodUp: false };
55
+ }
56
+ // error_note: null — a stale note from an earlier failed run must not ride into the
57
+ // active state (the same clearing the co-tenant/standalone tail does).
58
+ await provisioning.setInstanceStatus(db, inst.id, 'active', { error_note: null, provisioned_at: new Date().toISOString() });
59
+ await provisioning.recordEvent(db, {
60
+ instanceId: inst.id, ownerBuilderId: inst.owner_builder_id, event: 'active',
61
+ detail: 'byo-host — the owner supplies the host; the platform stood up nothing',
62
+ actor: 'runner',
63
+ });
64
+ log(` ✓ ${inst.slug} recorded (byo-host) — nothing was stood up`);
65
+ return { ok: true, byoHost: true, stoodUp: false };
66
+ }
67
+
68
+ module.exports = { BYO_HOST_SHAPE, isByoHost, recordByoHost };
@@ -59,6 +59,7 @@ const { installedCoreVersion, runnerBanner } = require('./runner-drift.js');
59
59
  const { teardownInstance } = require('./provision-teardown.js');
60
60
  const { disconnectInstance } = require('./provision-disconnect.js');
61
61
  const { coreUpgradeInstance, preflightCoreUpgrade, snapshotAllCoreVersions } = require('./provision-core-upgrade.js'); // the owner's deploy door, ADR 0293
62
+ const { isByoHost, recordByoHost } = require('./provision-byo-host.js'); // stand up NOTHING for a byo-host row, ADR 0323
62
63
 
63
64
  // WHAT supervises an instance's service (ADR 0306). systemd on every box we run — the
64
65
  // seam exists so a plane without systemd is not a dead end. Injectable for tests.
@@ -250,6 +251,7 @@ async function provisionInstance(inst, deps) {
250
251
  // Idempotency: an already-active instance is a no-op success (re-drained intent).
251
252
  if (inst.status === 'active') { log(' already active — nothing to do'); return { ok: true, already: true }; }
252
253
 
254
+ if (isByoHost(inst.hosting_shape)) return recordByoHost(inst, deps); // stand up NOTHING, ABOVE every preflight — reasoning in provision-byo-host.js's header (ADR 0323)
253
255
  // ── preflight: DNS token scope (task 1980) ─────────────────────────────────
254
256
  // BEFORE committing to the address (allocating a port / creating a droplet / the
255
257
  // step-5 DNS UPSERT), verify the control-plane Cloudflare token can actually reach
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.950'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.951'; // 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');
package/tests/init.mjs CHANGED
@@ -239,14 +239,16 @@ test('provisioningSlug: derives from productName, clamps to 40, strips trailing
239
239
  test('provisioningRequest: null unless opted in; builds the POST body when opted in', () => {
240
240
  assert.equal(init.provisioningRequest(fullSpec()), null); // no block → not requested
241
241
  const co = init.provisioningRequest(provSpec({})); // empty block = opted in, defaults
242
- assert.deepEqual(co, { slug: 'cloud-bongos', hosting_shape: 'co-tenant' });
242
+ assert.deepEqual(co, { slug: 'cloud-bongos', hosting_shape: 'byo-host' });
243
243
  const full = init.provisioningRequest(provSpec({ shape: 'dedicated', domain: 'example.com', targetRef: 'do:main', tier: 's-1vcpu-2gb' }));
244
244
  assert.deepEqual(full, { slug: 'cloud-bongos', hosting_shape: 'dedicated', domain: 'example.com', target_ref: 'do:main', tier: 's-1vcpu-2gb' });
245
245
  });
246
246
 
247
- test('provisioningRequest: unknown shape falls back to the cheapest co-tenant', () => {
248
- // normalizeSpec coerces a bad shape → co-tenant (the budget default, ADR 0111 §6).
249
- assert.equal(init.provisioningRequest(provSpec({ shape: 'kubernetes' })).hosting_shape, 'co-tenant');
247
+ test('provisioningRequest: unknown shape falls back to byo-host, the free front door', () => {
248
+ // normalizeSpec coerces a bad shape → byo-host (ADR 0323; was co-tenant, which that
249
+ // decision retired — the server now refuses it, so falling back to it would write a
250
+ // spec the create route rejects a step later).
251
+ assert.equal(init.provisioningRequest(provSpec({ shape: 'kubernetes' })).hosting_shape, 'byo-host');
250
252
  });
251
253
 
252
254
  test('kickoffTasks: provisioning requested → production-topology is AUTOMATED (dropped), manual steps stay', () => {
@@ -264,7 +266,7 @@ test('kickoffTasks: provisioning requested → production-topology is AUTOMATED
264
266
  test('normalizeSpec: coerces the provisioning block; drops it when absent', () => {
265
267
  assert.equal(init.normalizeSpec({}).provisioning, undefined);
266
268
  const n = init.normalizeSpec({ provisioning: { shape: 'DEDICATED', domain: ' Example.com ', junk: 1 } });
267
- assert.equal(n.provisioning.shape, 'co-tenant'); // unknown shape (case) → default
269
+ assert.equal(n.provisioning.shape, 'byo-host'); // unknown shape (case) → default
268
270
  assert.equal(n.provisioning.domain, 'Example.com'); // trimmed
269
271
  assert.equal(n.provisioning.junk, undefined); // stray field dropped
270
272
  });
@@ -651,14 +653,14 @@ async function runSeedTests() {
651
653
 
652
654
  await at('seedViaApi: provisioning 404 (module off) → falls back to the manual production-topology task', async () => {
653
655
  const api = fakeApi([['/provisioning/instances', { ok: false, status: 404, data: { error: 'not_found' } }]]);
654
- const out = await init.seedViaApi(provSpec({ shape: 'co-tenant' }), { log: () => {}, api });
656
+ const out = await init.seedViaApi(provSpec({ shape: 'byo-host' }), { log: () => {}, api });
655
657
  assert.equal(out.provisioned, false);
656
658
  assert.ok(findTaskPost(api, /Production topology/), 'filed the manual fallback task');
657
659
  });
658
660
 
659
661
  await at('seedViaApi: provisioning error → also files the fallback task', async () => {
660
662
  const api = fakeApi([['/provisioning/instances', { ok: false, status: 500, data: {} }]]);
661
- const out = await init.seedViaApi(provSpec({ shape: 'co-tenant' }), { log: () => {}, api });
663
+ const out = await init.seedViaApi(provSpec({ shape: 'byo-host' }), { log: () => {}, api });
662
664
  assert.equal(out.provisioned, false);
663
665
  assert.ok(findTaskPost(api, /Production topology/), 'filed the manual fallback task on error');
664
666
  });
@@ -237,7 +237,11 @@ test('no wall-clock promises, no operator vocabulary on the stranger’s path',
237
237
  assert.doesNotMatch(HUB, /about five minutes/, 'the empty state');
238
238
  assert.doesNotMatch(HUB, /a runner does the real work/, 'the mine fine print');
239
239
  assert.doesNotMatch(SCRIPT, /pinned platform core/, 'the review Shape row');
240
- assert.match(SCRIPT, /\['Shape', 'Its own server, running the platform’s current release'\]/);
240
+ /* The wizard creates hosting_shape 'standalone', whose owner-facing name became
241
+ "cloud host" at ADR 0323 — "its own server" was ambiguous between that shape and
242
+ a dedicated one, and said nothing about WHO runs it. The assertion still guards the
243
+ same property this test is about: owner vocabulary, and no wall-clock promise. */
244
+ assert.match(SCRIPT, /\['Shape', 'Cloud host — we stand it up and run it for you, on the platform’s current release'\]/);
241
245
  assert.doesNotMatch(HUB, /\/new-project<\/code> assistant/, 'the done panel’s closing line');
242
246
  assert.match(HUB, /Sign-in switches on after the GitHub steps|switch sign-in on/, 'the address step does not promise sign-in "just works"');
243
247
  assert.doesNotMatch(HUB, /Sign-in, sharing, everything just works/);
@@ -0,0 +1,122 @@
1
+ // tests/provision_byo_host.mjs — the runner stands up NOTHING for a 'byo-host'
2
+ // instance (ADR 0323, task 1004141).
3
+ //
4
+ // PROVES ABSENCE, not that the right steps ran, and that is the whole point. The
5
+ // shape's promise is negative — the owner supplies the host, so the platform must
6
+ // allocate no port, create no database, write no file, install no unit, touch no
7
+ // proxy and publish no DNS record. Asserting "the byo-host path ran" would pass
8
+ // just as happily if the co-tenant standup ran underneath it, which is exactly the
9
+ // bug this file exists to catch: before the branch, 'byo-host' fell through to the
10
+ // shared standup on the CONTROL PLANE's own shell and nothing errored.
11
+ //
12
+ // Hermetic: every executor is a spy that RECORDS instead of acting, so a single
13
+ // stray command shows up as a failed assertion rather than as a mutated box.
14
+ import assert from 'node:assert/strict';
15
+ import { test } from 'node:test';
16
+ import { createRequire } from 'node:module';
17
+
18
+ const require = createRequire(import.meta.url);
19
+ const P = require('../scripts/gds/provision.js');
20
+
21
+ const byoHost = {
22
+ id: 42, slug: 'mercury', hosting_shape: 'byo-host', status: 'requested',
23
+ owner_builder_id: 7, port: null, db_name: null, domain: 'mercury.example.com',
24
+ };
25
+
26
+ // Spies for every way provisionInstance can reach a machine or a record. `exec`
27
+ // and `writeFile` THROW rather than return, so a leaked step fails loudly with the
28
+ // command in the message instead of being silently collected and forgotten.
29
+ function harness({ apply = true } = {}) {
30
+ const queries = [], statuses = [], events = [], logs = [];
31
+ const boom = (what) => (arg) => { throw new Error(`byo-host must not ${what}: ${String(arg).slice(0, 200)}`); };
32
+ const deps = {
33
+ apply, log: (m) => logs.push(String(m)), sleep: async () => {},
34
+ db: { query: async (sql, params) => { queries.push({ sql, params }); return { rows: [] }; } },
35
+ provisioning: {
36
+ setInstanceStatus: async (_db, id, status, patch = {}) => { statuses.push({ id, status, patch }); },
37
+ recordEvent: async (_db, e) => { events.push(e); },
38
+ recordLivenessProbe: async () => { throw new Error('byo-host must not record a loopback liveness probe'); },
39
+ recordDnsNote: async () => { throw new Error('byo-host must not write a DNS note'); },
40
+ clearReleasedDomains: async () => { throw new Error('byo-host must not release domains'); },
41
+ allocatePort: async () => { throw new Error('byo-host must not allocate a port'); },
42
+ },
43
+ exec: boom('run a command'),
44
+ controlExec: boom('run a control-plane command'),
45
+ writeFile: boom('write a file'),
46
+ controlWriteFile: boom('write a control-plane file'),
47
+ doApi: () => { throw new Error('byo-host must not call a cloud API'); },
48
+ checkDnsTokenScope: async () => { throw new Error('byo-host must not preflight a DNS zone'); },
49
+ checkCaddyWiring: () => { throw new Error('byo-host must not inspect Caddy'); },
50
+ ingressPlaneGap: () => { throw new Error('byo-host must not probe this plane for ingress'); },
51
+ accountPlaneGap: () => { throw new Error('byo-host must not probe this plane for unix accounts'); },
52
+ getGithubToken: async () => { throw new Error('byo-host must not need a repo credential'); },
53
+ };
54
+ return { deps, queries, statuses, events, logs };
55
+ }
56
+
57
+ test('a byo-host provision touches no box: no exec, no file, no port, no database, no DNS', async () => {
58
+ const h = harness();
59
+ const r = await P.provisionInstance(byoHost, h.deps);
60
+ assert.equal(r.ok, true);
61
+ assert.equal(r.stoodUp, false, 'the result says plainly that nothing was stood up');
62
+ assert.equal(r.byoHost, true);
63
+ // Absence: the only DB work is the status flip + the event, both through the
64
+ // provisioning port. No raw query — a port allocation, a createdb or a
65
+ // capacity count would each have to go through db.query to happen at all.
66
+ assert.deepEqual(h.queries, [], 'no direct SQL — capacity counting, port allocation and the DB steps all query');
67
+ assert.equal(r.port, undefined, 'no port is reported because none is allocated');
68
+ });
69
+
70
+ test('the row reaches active with provisioned_at set, and nothing else is patched', async () => {
71
+ const h = harness();
72
+ await P.provisionInstance(byoHost, h.deps);
73
+ assert.equal(h.statuses.length, 1);
74
+ const [{ id, status, patch }] = h.statuses;
75
+ assert.equal(id, 42);
76
+ assert.equal(status, 'active', "'active' means the platform has nothing left to do, not that it runs it");
77
+ assert.equal(patch.error_note, null, 'a stale note from an earlier failed run must not ride into active');
78
+ assert.ok(patch.provisioned_at, 'the record carries when the platform finished with it');
79
+ assert.equal(patch.port, undefined, 'no port is stored');
80
+ assert.equal(patch.db_name, undefined, 'no database is named');
81
+ assert.equal(h.events.length, 1);
82
+ assert.equal(h.events[0].event, 'active');
83
+ assert.match(h.events[0].detail, /byo-host/, 'the audit trail says which shape, so the absence is readable later');
84
+ assert.equal(h.events[0].actor, 'runner');
85
+ });
86
+
87
+ test('a dry run writes nothing at all — not even the status', async () => {
88
+ const h = harness({ apply: false });
89
+ const r = await P.provisionInstance(byoHost, h.deps);
90
+ assert.equal(r.ok, true);
91
+ assert.equal(r.stoodUp, false);
92
+ assert.deepEqual(h.statuses, []);
93
+ assert.deepEqual(h.events, []);
94
+ assert.ok(h.logs.some((l) => /would mark it active/.test(l)), 'a dry run still says what it would do');
95
+ });
96
+
97
+ test('the branch is keyed on the shape, not on a missing port — a domainless row behaves the same', async () => {
98
+ const h = harness();
99
+ const r = await P.provisionInstance({ ...byoHost, domain: null }, h.deps);
100
+ assert.equal(r.ok, true);
101
+ assert.equal(r.stoodUp, false);
102
+ assert.equal(h.statuses[0].status, 'active');
103
+ });
104
+
105
+ test('an already-active byo-host row stays a plain idempotent no-op', async () => {
106
+ const h = harness();
107
+ const r = await P.provisionInstance({ ...byoHost, status: 'active' }, h.deps);
108
+ assert.deepEqual(r, { ok: true, already: true });
109
+ assert.deepEqual(h.statuses, [], 're-draining an intent must not re-stamp provisioned_at');
110
+ });
111
+
112
+ test('the shape is absent from the runner shapes that DO get stood up', () => {
113
+ // The guard is above every preflight, so the proof that it cannot be reordered
114
+ // by accident is that the co-tenant/standalone standup is unreachable for it.
115
+ // Any other shape still reaches the first executor — that is the control.
116
+ const h = harness();
117
+ return assert.rejects(
118
+ P.provisionInstance({ ...byoHost, hosting_shape: 'standalone', domain: null }, h.deps),
119
+ /byo-host must not/,
120
+ 'a non-byo-host shape still reaches the box steps — the branch is shape-scoped, not a global off switch',
121
+ );
122
+ });
@@ -84,6 +84,11 @@ t('placementBox: standalone → control plane always; co-tenant → the remote b
84
84
  assert.deepEqual(placementBox('co-tenant', {}), { box: 'control-plane', shapes: ['standalone', 'co-tenant'], fixedMb: 750 });
85
85
  assert.deepEqual(placementBox('co-tenant', { remoteHost: 'cohost' }), { box: 'overflow', shapes: ['co-tenant'], fixedMb: 630 });
86
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.
90
+ assert.equal(placementBox('byo-host', {}), null);
91
+ assert.equal(placementBox('byo-host', { remoteHost: 'cohost' }), null);
87
92
  });
88
93
 
89
94
  t('placementVerdict: the ceiling refuses at N of N, the floor refuses under 256 MB free, an unmeasured floor cannot refuse', () => {
@@ -122,11 +122,21 @@ await ta('a box with one slot left admits the create', async () => {
122
122
  assert.equal(lastCreateArgs && lastCreateArgs.slug, 'lantern');
123
123
  });
124
124
 
125
- await ta('an absent shape (the co-tenant default) on a local box is counted too', async () => {
125
+ await ta('an absent shape takes the byo-host default, so a full box does NOT refuse it', async () => {
126
+ // This assertion INVERTED at task 1004141 (ADR 0323), and the inversion is the point.
127
+ // The default used to be 'co-tenant', which lands on the control plane, so a shapeless
128
+ // create was counted against the ceiling and could be refused box_full. The default is
129
+ // now 'byo-host', which runs on the owner's own machine and consumes nothing here — so
130
+ // refusing it for OUR capacity would be refusing a project that costs us no memory.
131
+ //
132
+ // The real trap this pins: THREE places resolve an absent shape — createInstance's
133
+ // coercion, paid-shape-gate's authority lookup, and this gate's placement lookup. If
134
+ // they disagree, the gate prices a request against a shape the row will not take, and
135
+ // nothing errors; you just get a 409 on a create that should have succeeded.
126
136
  reset({ count: 15 });
127
137
  const res = await post({ slug: 'lantern', no_address: true });
128
- assert.equal(res.status, 409, res.raw);
129
- assert.equal(res.body.error.code, 'box_full');
138
+ assert.equal(res.status, 201, res.raw);
139
+ assert.equal(capacityReads, 0, 'a shape with no box of ours must not even read the count');
130
140
  });
131
141
 
132
142
  // ── boxes the web tier cannot count are the runner's ──────────────────────────────
@@ -138,15 +148,25 @@ await ta('dedicated is its own box: never refused here, and the count is never r
138
148
  assert.equal(capacityReads, 0);
139
149
  });
140
150
 
141
- await ta('a co-tenant under remote-exec lands on a box this host cannot measure: admitted', async () => {
151
+ await ta('byo-host runs on the OWNER\'s machine: never refused here, and the count is never read', async () => {
152
+ // ADR 0323's new shape, and the strongest form of "not our box": there is no machine
153
+ // of ours to place it on at all, so a full control plane must not refuse it and the
154
+ // count must not even be read. A full box turning away the one shape that costs us
155
+ // nothing would be the worst possible refusal.
142
156
  reset({ count: 15 });
143
- process.env.PROVISION_REMOTE_HOST = 'overflow.example';
144
- const res = await post({ slug: 'lantern', hosting_shape: 'co-tenant', no_address: true });
157
+ const res = await post({ slug: 'lantern', hosting_shape: 'byo-host', no_address: true });
145
158
  assert.equal(res.status, 201, res.raw);
146
159
  assert.equal(capacityReads, 0);
147
- delete process.env.PROVISION_REMOTE_HOST;
148
160
  });
149
161
 
162
+ // The co-tenant-under-remote-exec case this file used to assert HERE is now unreachable
163
+ // through the route: ADR 0323 retires that shape and paid-shape-gate.js refuses it
164
+ // before the capacity gate runs, so a route-level assertion would only ever be proving
165
+ // the 403. The placement rule itself is still live — the runner reads it for the rows
166
+ // that already carry the shape — and stays pinned at unit level in
167
+ // tests/provisioning_capacity.mjs ('placementBox: … co-tenant → the remote box only
168
+ // under remote-exec'). Moved rather than deleted, per the live-code rule.
169
+
150
170
  await ta('the live-memory floor is left to the runner: slots free + memory tight still admits', async () => {
151
171
  reset({ count: 3, mem: MEMINFO_TIGHT });
152
172
  const res = await post(WIZARD);
@@ -235,7 +235,7 @@ await ta('POST /instances: the picker\'s ids ride the create request through to
235
235
 
236
236
  // Act — the body the wizard files
237
237
  const res = await post('/provisioning/instances', {
238
- slug: 'mercury', hosting_shape: 'co-tenant', type: 'game', modules: ['agents', 'discord'],
238
+ slug: 'mercury', hosting_shape: 'byo-host', type: 'game', modules: ['agents', 'discord'],
239
239
  });
240
240
 
241
241
  // Assert
@@ -245,7 +245,7 @@ await ta('POST /instances: the picker\'s ids ride the create request through to
245
245
 
246
246
  await ta('POST /instances: an omitted modules key is still a valid create — skipping is supported, not an error', async () => {
247
247
  lastCreateArgs = null; existingRow = null;
248
- const res = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: 'co-tenant', type: 'game' });
248
+ const res = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: 'byo-host', type: 'game' });
249
249
  assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
250
250
  assert.equal(lastCreateArgs.modules, undefined, 'nothing is invented on the owner\'s behalf');
251
251
  // …and the receipt already carries the set they will actually get.
@@ -255,7 +255,7 @@ await ta('POST /instances: an omitted modules key is still a valid create — sk
255
255
 
256
256
  await ta('POST /instances: an explicit empty selection is accepted as an answer', async () => {
257
257
  lastCreateArgs = null; existingRow = null;
258
- const res = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: 'co-tenant', type: 'game', modules: [] });
258
+ const res = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: 'byo-host', type: 'game', modules: [] });
259
259
  assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
260
260
  assert.deepEqual(lastCreateArgs.modules, []);
261
261
  });
@@ -266,7 +266,7 @@ await ta('POST /instances: an unknown module is REFUSED and named, never silentl
266
266
  // prevent, and the reason badProjectDetail was written beside this.
267
267
  existingRow = null;
268
268
  const res = await post('/provisioning/instances', {
269
- slug: 'mercury', hosting_shape: 'co-tenant', modules: ['discrod'],
269
+ slug: 'mercury', hosting_shape: 'byo-host', modules: ['discrod'],
270
270
  });
271
271
  assert.equal(res.status, 400, res.raw);
272
272
  assert.equal(res.body.error.code, 'bad_modules');
@@ -277,7 +277,7 @@ await ta('POST /instances: an unknown module is REFUSED and named, never silentl
277
277
  await ta('POST /instances: naming an always-on module is refused with the reason, not the generic miss', async () => {
278
278
  existingRow = null;
279
279
  const res = await post('/provisioning/instances', {
280
- slug: 'mercury', hosting_shape: 'co-tenant', modules: ['memory'],
280
+ slug: 'mercury', hosting_shape: 'byo-host', modules: ['memory'],
281
281
  });
282
282
  assert.equal(res.status, 400, res.raw);
283
283
  assert.equal(res.body.error.code, 'bad_modules');
@@ -286,11 +286,11 @@ await ta('POST /instances: naming an always-on module is refused with the reason
286
286
 
287
287
  await ta('POST /instances: modules must be an array of strings', async () => {
288
288
  existingRow = null;
289
- const notArray = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: 'co-tenant', modules: 'discord' });
289
+ const notArray = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: 'byo-host', modules: 'discord' });
290
290
  assert.equal(notArray.status, 400, notArray.raw);
291
291
  assert.equal(notArray.body.error.code, 'validation_failed');
292
292
 
293
- const notStrings = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: 'co-tenant', modules: [7] });
293
+ const notStrings = await post('/provisioning/instances', { slug: 'mercury', hosting_shape: 'byo-host', modules: [7] });
294
294
  assert.equal(notStrings.status, 400, notStrings.raw);
295
295
  assert.equal(notStrings.body.error.code, 'validation_failed');
296
296
  });
@@ -301,7 +301,7 @@ await ta('POST /instances: a re-request never changes an existing project\'s mod
301
301
  // to hunt for one.
302
302
  existingRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: ['agents'], detail: {} };
303
303
  const res = await post('/provisioning/instances', {
304
- slug: 'mercury', hosting_shape: 'co-tenant', modules: ['discord'],
304
+ slug: 'mercury', hosting_shape: 'byo-host', modules: ['discord'],
305
305
  });
306
306
  assert.equal(res.status, 200, res.raw);
307
307
  assert.ok(res.body.modules_ignored, 'the dropped answer is reported, not swallowed');
@@ -589,7 +589,7 @@ await ta('POST /instances: a null answer at CREATION is accepted and stores noth
589
589
  lastCreateArgs = null; existingRow = null;
590
590
 
591
591
  const res = await post('/provisioning/instances', {
592
- slug: 'mercury', hosting_shape: 'co-tenant', type: 'game', detail: { team_shape: null },
592
+ slug: 'mercury', hosting_shape: 'byo-host', type: 'game', detail: { team_shape: null },
593
593
  });
594
594
 
595
595
  assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
@@ -150,7 +150,7 @@ function post(path, body) {
150
150
  await ta('POST /instances: onboard_mode=adopt is threaded through to createInstance', async () => {
151
151
  lastCreateArgs = null;
152
152
  const res = await post('/provisioning/instances', {
153
- slug: 'demo', hosting_shape: 'co-tenant', target_ref: 'example-owner/demo', onboard_mode: 'adopt',
153
+ slug: 'demo', hosting_shape: 'byo-host', target_ref: 'example-owner/demo', onboard_mode: 'adopt',
154
154
  });
155
155
  assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
156
156
  assert.equal(lastCreateArgs && lastCreateArgs.onboardMode, 'adopt');
@@ -158,7 +158,7 @@ await ta('POST /instances: onboard_mode=adopt is threaded through to createInsta
158
158
 
159
159
  await ta('POST /instances: onboard_mode absent → greenfield (unchanged behavior for every existing caller)', async () => {
160
160
  lastCreateArgs = null;
161
- const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'co-tenant' });
161
+ const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'byo-host' });
162
162
  assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
163
163
  assert.equal(lastCreateArgs && lastCreateArgs.onboardMode, 'greenfield');
164
164
  });
@@ -167,7 +167,7 @@ await ta('POST /instances: an unknown onboard_mode is REFUSED (never silently co
167
167
  // Coercing here would be worse than refusing: the caller believes it asked for one thing
168
168
  // and the runner does another. The route names the valid set so the client can fix itself.
169
169
  const res = await post('/provisioning/instances', {
170
- slug: 'demo', hosting_shape: 'co-tenant', target_ref: 'a/b', onboard_mode: 'brownfield',
170
+ slug: 'demo', hosting_shape: 'byo-host', target_ref: 'a/b', onboard_mode: 'brownfield',
171
171
  });
172
172
  assert.equal(res.status, 400);
173
173
  assert.equal(res.body.error.code, 'bad_onboard_mode');
@@ -204,8 +204,8 @@ await ta('POST /instances: adopt WITHOUT target_ref is refused — there is no r
204
204
  // Without target_ref the runner would fall back to greenfield behavior on a row labelled
205
205
  // adopt. Refuse at the door rather than store a lie.
206
206
  for (const body of [
207
- { slug: 'demo', hosting_shape: 'co-tenant', onboard_mode: 'adopt' },
208
- { slug: 'demo', hosting_shape: 'co-tenant', onboard_mode: 'adopt', target_ref: ' ' },
207
+ { slug: 'demo', hosting_shape: 'byo-host', onboard_mode: 'adopt' },
208
+ { slug: 'demo', hosting_shape: 'byo-host', onboard_mode: 'adopt', target_ref: ' ' },
209
209
  ]) {
210
210
  const res = await post('/provisioning/instances', body);
211
211
  assert.equal(res.status, 400, `expected 400 for ${JSON.stringify(body)}`);
@@ -11,7 +11,8 @@
11
11
  // provisioning.fleet.manage.
12
12
  //
13
13
  // The fix gates the SHAPE, not the route: ordinary project creation must not require a rank,
14
- // so `co-tenant` — the front door — stays open to any authenticated builder. Everything else
14
+ // so the front door — `byo-host` since ADR 0323, `co-tenant` before it — stays open to any
15
+ // authenticated builder. Everything else
15
16
  // demands provisioning.fleet.manage, deliberately the same atom that already guards the fleet
16
17
  // cost-ledger and force-teardown, since spending on the fleet and managing the fleet are one
17
18
  // authority.
@@ -110,17 +111,79 @@ function post(path, body) {
110
111
 
111
112
  // ── the front door stays open — this is the half a careless fix would break ───────
112
113
 
113
- await ta('co-tenant (and an absent shape, which defaults to it) stays open with no permission', async () => {
114
+ await ta('byo-host (and an absent shape, which defaults to it) stays open with no permission', async () => {
115
+ // ADR 0323 moved the front door from 'co-tenant' to 'byo-host'. It is a STRONGER
116
+ // front door: co-tenant cost nothing to invoice but still ran the caller's process on
117
+ // our box, whereas byo-host runs on the owner's own machine and never touches ours.
118
+ // The absent-shape case is the one a careless fix breaks — createInstance coerces an
119
+ // unknown or missing shape to byo-host, and if the gate's own default disagreed with
120
+ // that coercion the wizard's shapeless create would 403 instead of 201.
114
121
  for (const body of [
115
- { slug: 'demo', hosting_shape: 'co-tenant', no_address: true },
122
+ { slug: 'demo', hosting_shape: 'byo-host', no_address: true },
116
123
  { slug: 'demo', no_address: true },
117
124
  ]) {
118
125
  held = [];
126
+ lastCreateArgs = null;
119
127
  const res = await post('/provisioning/instances', body);
120
128
  assert.equal(res.status, 201, `expected 201 for ${JSON.stringify(body)}, got ${res.status} ${res.raw}`);
129
+ // Assert the STORED shape, not just the status. A 201 alone does not prove the row is
130
+ // right: the create route resolves an absent shape independently of this gate, and at
131
+ // task 1004141 that default was briefly still 'co-tenant' — the gate admitted the
132
+ // request as byo-host and the route stored the retired shape, with no error anywhere.
133
+ // Checking the status only is what let that through.
134
+ assert.equal(lastCreateArgs && lastCreateArgs.hostingShape, 'byo-host',
135
+ `${JSON.stringify(body)} must STORE byo-host, got ${lastCreateArgs && lastCreateArgs.hostingShape}`);
121
136
  }
122
137
  });
123
138
 
139
+ await ta('co-tenant is RETIRED: refused for every caller, and not as a permission problem', async () => {
140
+ // ADR 0323 retires the shape whose name meant "a process on OUR box" — the opposite
141
+ // of bring-your-own-host, so the name could not be carried over. It stays a valid
142
+ // STORED value (one torn-down row carries it, and HOSTING_SHAPES still lists it) but
143
+ // becomes UNREQUESTABLE, which is the difference between closing a door and rewriting
144
+ // the record. The refusal must NOT be a 403-for-want-of-permission: no rank admits a
145
+ // retired shape, so granting fleet.manage must not open it either.
146
+ held = [];
147
+ lastCreateArgs = null;
148
+ let res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'co-tenant', no_address: true });
149
+ assert.equal(res.status, 403, `expected 403, got ${res.status} ${res.raw}`);
150
+ assert.equal(res.body.error.code, 'shape_not_requestable');
151
+ assert.equal(lastCreateArgs, null, 'a retired shape must not create a row');
152
+
153
+ held = ['provisioning.fleet.manage'];
154
+ res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'co-tenant', no_address: true });
155
+ assert.equal(res.status, 403, `every permission still must not admit it, got ${res.status} ${res.raw}`);
156
+ assert.equal(res.body.error.code, 'shape_not_requestable');
157
+ });
158
+
159
+ await ta('the refusal speaks WIRE vocabulary — no owner-facing label is translated here', async () => {
160
+ // The single-translation invariant, ENFORCED rather than stated. ADR 0323 puts the
161
+ // stored value → owner-facing name map in exactly one place (SHAPE_LABEL in
162
+ // modules/public-landing/public/projects.html), and an API error string is the worst
163
+ // place for a second copy: whoever renames the product next will change the label map
164
+ // and a wizard heading, and a sentence buried in a 403 will go on selling a name that
165
+ // no longer exists, with nothing failing. This assertion is what fails instead.
166
+ //
167
+ // It checks BOTH unrequestable shapes, because the retired one is the tempting case —
168
+ // it is the refusal that wants to suggest an alternative, which is exactly where a
169
+ // product name creeps in. Suggesting one is fine; `details.requestable` already does
170
+ // it in stored values, and the message points at that field.
171
+ held = [];
172
+ for (const shape of ['co-tenant', 'control-plane']) {
173
+ const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: shape, no_address: true });
174
+ assert.equal(res.status, 403, `expected 403 for ${shape}, got ${res.status} ${res.raw}`);
175
+ const message = res.body.error.message || '';
176
+ for (const label of ['bring your own host', 'cloud host', 'dedicated cloud host']) {
177
+ assert.ok(!message.toLowerCase().includes(label),
178
+ `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
+ }
180
+ }
181
+ // And the caller is still told what they CAN ask for, in the vocabulary they sent.
182
+ 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'],
184
+ 'a refusal that names no alternative is a dead end — the stored values are the alternative');
185
+ });
186
+
124
187
  // ── standalone runs the caller's OWN code on the platform's box (task 1003370) ────
125
188
 
126
189
  await ta('standalone: REFUSED for a builder without provisioning.fleet.manage', async () => {
@@ -232,20 +295,27 @@ await ta('HOSTING_SHAPES is pinned, so a new shape forces a written decision abo
232
295
  // POST for a second control plane until task 1003370.
233
296
  //
234
297
  // So the question this list now forces is the one that generalises. The gate is an
235
- // allow-list: `co-tenant` needs nothing, `control-plane` is refused outright, and
298
+ // allow-list: `byo-host` needs nothing, `control-plane` and `co-tenant` are refused
299
+ // outright (for different reasons — never-yours vs retired), and
236
300
  // EVERY other shape — including one added after this file was last read — demands
237
301
  // provisioning.fleet.manage by omission. A fifth shape therefore arrives already
238
302
  // gated, and this assertion is the prompt to confirm that is what was wanted
239
303
  // rather than the thing that has to catch the hole.
304
+ //
305
+ // It fired a SECOND time at task 1004141 (ADR 0323), which added 'byo-host' and
306
+ // retired 'co-tenant'. That retirement is why the list below still carries a shape
307
+ // no caller may request: the value stays storable so one torn-down row remains
308
+ // readable, and the door is shut in UNREQUESTABLE_SHAPES instead. Dropping it from
309
+ // HOSTING_SHAPES would make that historical row violate its own CHECK constraint.
240
310
  assert.deepEqual([...provisioning.HOSTING_SHAPES].sort(),
241
- ['co-tenant', 'control-plane', 'dedicated', 'standalone']);
311
+ ['byo-host', 'co-tenant', 'control-plane', 'dedicated', 'standalone']);
242
312
  });
243
313
 
244
314
  await ta('an unknown shape is answered by the validator, not by the gate', () => {
245
315
  // The gate DEFERS anything outside HOSTING_SHAPES so a typo gets `bad_hosting_shape`
246
316
  // naming the valid set, rather than a 403 about a permission the caller would not
247
317
  // have needed. Deferring is safe: the validator refuses it microseconds later, and
248
- // createInstance coerces an unrecognised shape to co-tenant rather than storing it,
318
+ // createInstance coerces an unrecognised shape to byo-host rather than storing it,
249
319
  // so no deferred value can become a privileged row.
250
320
  return post('/provisioning/instances', { slug: 'demo', hosting_shape: 'co-tenamt', no_address: true })
251
321
  .then((res) => {