@bongos/core 1.19.999 → 1.19.1001

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 (89) hide show
  1. package/.bongos-core.json +116 -91
  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 +155 -5
  11. package/docs/api-reference.md +6 -4
  12. package/docs/copy-inventory.md +18 -13
  13. package/docs/copy-registry.json +70 -15
  14. package/docs/module-api-changelog.md +4 -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/cost-ledger.js +74 -0
  25. package/modules/provisioning/migrations/provisioning_027_cloud_host_shape_rename.sql +58 -0
  26. package/modules/provisioning/onboard-plan.js +1 -1
  27. package/modules/provisioning/paid-shape-gate.js +28 -15
  28. package/modules/provisioning/provisioning.js +20 -39
  29. package/modules/provisioning/routes/provisioning.js +4 -3
  30. package/modules/provisioning/routes/repo-private.js +1 -1
  31. package/modules/provisioning/tests/liveness.mjs +16 -8
  32. package/modules/provisioning/tests/provisioning.mjs +1 -1
  33. package/modules/public-landing/public/projects.html +26 -27
  34. package/modules/ui-design/kit/fixtures/provisioning-instances.json +1 -1
  35. package/package-lock.json +2 -2
  36. package/package.json +1 -1
  37. package/scripts/gds/init.js +12 -4
  38. package/scripts/gds/onboard.js +1 -1
  39. package/scripts/gds/provision-capacity.js +6 -1
  40. package/scripts/gds/provision-config.js +6 -3
  41. package/scripts/gds/provision-core-upgrade.js +8 -8
  42. package/scripts/gds/provision-disconnect.js +2 -2
  43. package/scripts/gds/provision-process.js +2 -2
  44. package/scripts/gds/provision-repo-private.js +1 -1
  45. package/scripts/gds/provision-repo.js +13 -3
  46. package/scripts/gds/provision-teardown.js +2 -2
  47. package/scripts/gds/provision-units.js +2 -2
  48. package/scripts/gds/provision.js +6 -6
  49. package/src/module-api.js +1 -1
  50. package/tests/collab_page.mjs +140 -7
  51. package/tests/core_upgrade_door.mjs +1 -1
  52. package/tests/core_upgrade_runner.mjs +20 -20
  53. package/tests/deploy_page_projects.mjs +5 -5
  54. package/tests/hall_collab_world.mjs +6 -2
  55. package/tests/help_requests.mjs +201 -6
  56. package/tests/hub_disconnect_ui.mjs +4 -1
  57. package/tests/init.mjs +6 -6
  58. package/tests/onboard.mjs +5 -4
  59. package/tests/project_catalog_backfill.mjs +1 -1
  60. package/tests/project_catalog_bridge.mjs +2 -2
  61. package/tests/project_catalog_routes.mjs +4 -4
  62. package/tests/projects_hub.mjs +1 -1
  63. package/tests/projects_hub_pre_uat.mjs +22 -5
  64. package/tests/projects_hub_repo_private.mjs +1 -1
  65. package/tests/provision.mjs +32 -32
  66. package/tests/provision_byo_host.mjs +1 -1
  67. package/tests/provision_caddy_wiring.mjs +1 -1
  68. package/tests/provision_disconnect.mjs +3 -3
  69. package/tests/provision_repo_private.mjs +1 -1
  70. package/tests/provision_restart.mjs +5 -5
  71. package/tests/provision_settings_apply.mjs +4 -4
  72. package/tests/provision_teardown_address.mjs +2 -2
  73. package/tests/provisioning_capacity.mjs +40 -34
  74. package/tests/provisioning_capacity_gate.mjs +5 -12
  75. package/tests/provisioning_cost_ledger.mjs +98 -0
  76. package/tests/provisioning_default_domain.mjs +1 -1
  77. package/tests/provisioning_domain_attach.mjs +4 -4
  78. package/tests/provisioning_domain_unique.mjs +2 -2
  79. package/tests/provisioning_error_note_scope.mjs +1 -1
  80. package/tests/provisioning_module_selection.mjs +5 -5
  81. package/tests/provisioning_onboard_mode.mjs +2 -2
  82. package/tests/provisioning_paid_shape_gate.mjs +57 -44
  83. package/tests/provisioning_repo_private_route.mjs +1 -1
  84. package/tests/provisioning_restart.mjs +11 -4
  85. package/tests/provisioning_settings_apply.mjs +12 -5
  86. package/tests/provisioning_settings_env.mjs +1 -1
  87. package/tests/provisioning_teardown_intent.mjs +3 -3
  88. package/tests/public_landing_projects.mjs +13 -6
  89. package/tests/upgrade.mjs +2 -2
@@ -75,11 +75,11 @@ function parseMeminfo(text) {
75
75
  // consume a slot that a cloud-host would otherwise get — which is what returning null
76
76
  // here buys, since PLACED_STATES counting keys off the shapes a box declares.
77
77
  function placementBox(hostingShape, { remoteHost = null } = {}) {
78
- if (hostingShape === 'dedicated' || hostingShape === 'byo-host') return null;
78
+ if (hostingShape === 'byo-host') return null;
79
79
  if (hostingShape === 'co-tenant' && remoteHost) {
80
80
  return { box: 'overflow', shapes: ['co-tenant'], fixedMb: FIXED_MB.overflow };
81
81
  }
82
- return { box: 'control-plane', shapes: remoteHost ? ['standalone'] : ['standalone', 'co-tenant'], fixedMb: FIXED_MB['control-plane'] };
82
+ return { box: 'control-plane', shapes: remoteHost ? ['cloud-host'] : ['cloud-host', 'co-tenant'], fixedMb: FIXED_MB['control-plane'] };
83
83
  }
84
84
 
85
85
  // ceilingFor — the slot count for a box: an explicit override wins, else the ADR
@@ -148,7 +148,7 @@ function readLocalMeminfo() {
148
148
  // for tests; `remoteHost` mirrors the runner's PROVISION_REMOTE_HOST so the two
149
149
  // count the same shapes (set it in web.env too if co-tenants ever run remotely).
150
150
  async function fleetCapacity(db, { meminfo = readLocalMeminfo(), remoteHost = process.env.PROVISION_REMOTE_HOST || null } = {}) {
151
- const target = placementBox('standalone', { remoteHost });
151
+ const target = placementBox('cloud-host', { remoteHost });
152
152
  const { override, floorMb } = envOverrides();
153
153
  const mem = parseMeminfo(meminfo);
154
154
  const { ceiling, source } = ceilingFor({ override, memTotalMb: mem.memTotalMb, fixedMb: target.fixedMb });
@@ -11,7 +11,7 @@
11
11
  //
12
12
  // It also puts a 'teardown' button one query away from the platform itself. That
13
13
  // is the risk this file exists to close, and it is the reason the shape is a
14
- // distinct VALUE rather than a reuse of 'standalone': a distinct value is something
14
+ // distinct VALUE rather than a reuse of 'cloud-host': a distinct value is something
15
15
  // every destructive leg can refuse BY NAME.
16
16
  //
17
17
  // AN ALLOW-LIST, NOT A DENY-LIST. Listing today's destructive actions and refusing
@@ -38,13 +38,13 @@ const CORE_UPGRADE_FROM = Object.freeze(new Set(['active']));
38
38
  // The shapes whose box the runner can actually reach to perform an upgrade.
39
39
  // Mirrors RESTART_SHAPES and adds the control plane (which the runner IS on).
40
40
  //
41
- // A 'dedicated' droplet is stood up by cloud-init and the runner has no post-hoc
42
- // exec path to it — the same fact the restart route refuses on. Queueing an
43
- // upgrade for one would be the F16 lie: a request that is accepted and never
44
- // lands. This is a SEPARATE question from the control-plane guard, which asks
45
- // what may be aimed at the platform itself; a first cut conflated the two and
46
- // silently accepted upgrades for dedicated droplets.
47
- const UPGRADE_SHAPES = Object.freeze(new Set(['co-tenant', 'standalone', 'control-plane']));
41
+ // 'byo-host' is the shape this set excludes: the owner runs it, so there is nothing
42
+ // for the runner to upgrade and queueing one would be the F16 lie — a request that is
43
+ // accepted and never lands. This is a SEPARATE question from the control-plane guard,
44
+ // which asks what may be aimed at the platform itself; a first cut conflated the two.
45
+ // ('dedicated' was excluded here for the same reachability reason until ADR 0327
46
+ // decision 4 removed the shape and provisioning_027 folded its value into 'cloud-host'.)
47
+ const UPGRADE_SHAPES = Object.freeze(new Set(['co-tenant', 'cloud-host', 'control-plane']));
48
48
 
49
49
  // The shape of a core version as the pin records it. Deliberately strict and
50
50
  // three-part: the column's own CHECK carries the same pattern, so a value that
@@ -0,0 +1,74 @@
1
+ 'use strict';
2
+ // The fleet cost ledger (GET /provisioning/fleet/cost-ledger) — VISIBILITY only (ADR 0111 §6).
3
+ //
4
+ // It asks two questions, one column each (task 1004148, ADR 0327). Before, one column
5
+ // asked both and reported a confident $0 for every project:
6
+ // platform_monthly_usd — what a row costs US. A cloud-host runs on its owner's own
7
+ // account, so $0 is correct; the one thing we pay for is the control plane's box.
8
+ // owner_monthly_usd — an ESTIMATE of the owner's own hosting bill, never ours.
9
+ // null in either column means UNPRICED — the row names no plan we can price — and is
10
+ // never folded into a total as 0.
11
+
12
+ const { HOSTING_SHAPES, TIER_MONTHLY_USD } = require('./provisioning');
13
+
14
+ // What a cloud-host's owner pays Render per month, keyed by the plan stored in `tier`:
15
+ // web service + Postgres, ~$13 at the default plans (ADR 0327). No row names a plan yet;
16
+ // the Render standup (task 1004183) writes one, and the pilot (task 1004142) confirms
17
+ // these names and prices.
18
+ const RENDER_PLAN_MONTHLY_USD = Object.freeze({
19
+ 'starter+basic-256mb': 13,
20
+ });
21
+
22
+ function priceOf(table, key) {
23
+ return typeof key === 'string' && Object.hasOwn(table, key) ? table[key] : null;
24
+ }
25
+
26
+ function platformMonthlyUsd({ hostingShape, tier } = {}) {
27
+ // The box the projects share today is priced once, here, by its droplet size.
28
+ if (hostingShape === 'control-plane') return priceOf(TIER_MONTHLY_USD, tier);
29
+ return HOSTING_SHAPES.includes(hostingShape) ? 0 : null;
30
+ }
31
+
32
+ function ownerMonthlyUsd({ hostingShape, tier } = {}) {
33
+ if (hostingShape === 'cloud-host') return priceOf(RENDER_PLAN_MONTHLY_USD, tier);
34
+ // co-tenant shares our box and the control plane is ours: no owner is billed for either.
35
+ if (hostingShape === 'co-tenant' || hostingShape === 'control-plane') return 0;
36
+ return null; // byo-host runs on the owner's own machine, whose bill we cannot see
37
+ }
38
+
39
+ function columnTotal(instances, key) {
40
+ const priced = instances.map((i) => i[key]).filter((v) => v !== null);
41
+ const sum = priced.reduce((a, v) => a + v, 0);
42
+ return { monthly_usd: Number(sum.toFixed(4)), unpriced: instances.length - priced.length };
43
+ }
44
+
45
+ // Per-instance costs + a total per column, over the instances not torn down.
46
+ async function costLedger(db, { limit = 500 } = {}) {
47
+ const { rows } = await db.query(
48
+ `SELECT pi.id, pi.slug, pi.hosting_shape, pi.tier, pi.status, b.github_login
49
+ FROM provisioning_instances pi
50
+ JOIN builders b ON b.id = pi.owner_builder_id
51
+ WHERE pi.status <> 'torn_down'
52
+ ORDER BY pi.updated_at DESC
53
+ LIMIT $1`,
54
+ [Math.min(Math.max(Number(limit) || 500, 1), 2000)]
55
+ );
56
+ const instances = rows.map((r) => {
57
+ const shape = { hostingShape: r.hosting_shape, tier: r.tier };
58
+ return {
59
+ id: r.id, slug: r.slug, owner: r.github_login,
60
+ hosting_shape: r.hosting_shape, tier: r.tier, status: r.status,
61
+ platform_monthly_usd: platformMonthlyUsd(shape),
62
+ owner_monthly_usd: ownerMonthlyUsd(shape),
63
+ };
64
+ });
65
+ return {
66
+ instances,
67
+ totals: {
68
+ platform: columnTotal(instances, 'platform_monthly_usd'),
69
+ owner: columnTotal(instances, 'owner_monthly_usd'),
70
+ },
71
+ };
72
+ }
73
+
74
+ module.exports = { RENDER_PLAN_MONTHLY_USD, platformMonthlyUsd, ownerMonthlyUsd, costLedger };
@@ -0,0 +1,58 @@
1
+ -- provisioning_027 — the stored hosting shapes take their owner-facing names
2
+ -- (ADR 0323 §1, ADR 0327 decision 4, task 1004182).
3
+ --
4
+ -- provisioning_021 deliberately left the two managed shapes at their old stored
5
+ -- spellings, on the grounds that task 1004142 would rename them "with those files
6
+ -- already open". That task has since split; the rename is task 1004182 and this is it.
7
+ --
8
+ -- 'standalone' -> 'cloud-host' the shape ADR 0323 renamed owner-facing
9
+ -- 'dedicated' -> 'cloud-host' the shape ADR 0327 decision 4 REMOVED
10
+ -- 'co-tenant' unchanged retired, but still storable -- see below
11
+ -- 'byo-host' unchanged the free front door, and the column DEFAULT
12
+ -- 'control-plane' unchanged the platform's own row, unrequestable
13
+ --
14
+ -- WHY 'dedicated' FOLDS INTO 'cloud-host' RATHER THAN BEING DROPPED. ADR 0327 decision 4
15
+ -- removes the shape because "on the owner's own platform account a larger plan is a
16
+ -- setting they choose, not a hosting shape we model", and its consequences say the value
17
+ -- "must be migrated, not merely orphaned". A dedicated instance was a cloud-host on a
18
+ -- bigger box, so cloud-host is where it belongs. Measured on the live fleet 2026-09-23
19
+ -- there are ZERO 'dedicated' rows, so this is expected to update nothing -- but it is
20
+ -- written to do the right thing rather than to assume the measurement still holds at
21
+ -- the moment it runs, which is the whole reason it is an UPDATE and not an assertion.
22
+ --
23
+ -- 'co-tenant' STAYS IN THE SET, for the reason provisioning_021 gave and this migration
24
+ -- does not revisit: retirement closes the door, not the record. One torn-down row still
25
+ -- carries it, and dropping it from the constraint would make a historical row violate
26
+ -- its own table. The door stays closed in code (paid-shape-gate.js lists it
27
+ -- UNREQUESTABLE), never in the constraint.
28
+ --
29
+ -- THIS IS A NARROWING, WHICH THE PRECEDING FOUR CONSTRAINT REWRITES WERE NOT. fitness.js
30
+ -- (isConstraintWidening) permits a `DROP CONSTRAINT IF EXISTS` paired with a re-`ADD
31
+ -- CONSTRAINT` of the same name, and its comment justifies that as additive -- "no
32
+ -- existing row changes and no data is lost". That justification does NOT describe this
33
+ -- migration: rows change, and two values leave the set. It is safe for a different
34
+ -- reason, which is the ORDER below.
35
+ --
36
+ -- THE ORDER IS DROP -> UPDATE -> ADD, AND ALL THREE POSITIONS MATTER. The obvious
37
+ -- arrangement -- move the rows onto a legal value first, then narrow the constraint over
38
+ -- them -- does not work, and fails immediately rather than subtly: the OLD constraint is
39
+ -- still in force during that UPDATE and it does not admit 'cloud-host', so the very first
40
+ -- row rewritten violates it. (Verified: that arrangement was written first and refused on
41
+ -- a probe database with `new row ... violates check constraint`.) Dropping first leaves a
42
+ -- window with NO constraint, which is exactly what the UPDATE needs; adding last closes it
43
+ -- over rows that are already legal. Postgres runs a migration file in one implicit
44
+ -- transaction, so the unconstrained window is never visible to another session.
45
+
46
+ -- 1. Take the old constraint off. Nothing may constrain the column while it is rewritten.
47
+ ALTER TABLE provisioning_instances DROP CONSTRAINT IF EXISTS provisioning_instances_hosting_shape_check;
48
+
49
+ -- 2. Move the rows. Both old spellings land on the one surviving managed shape.
50
+ UPDATE provisioning_instances SET hosting_shape = 'cloud-host'
51
+ WHERE hosting_shape IN ('standalone', 'dedicated');
52
+
53
+ -- 3. Put the narrowed constraint back, now that nothing violates it.
54
+ ALTER TABLE provisioning_instances ADD CONSTRAINT provisioning_instances_hosting_shape_check
55
+ CHECK (hosting_shape IN ('byo-host', 'co-tenant', 'cloud-host', 'control-plane'));
56
+
57
+ -- The DEFAULT is already 'byo-host' (provisioning_021) and stays valid, so it is not
58
+ -- touched here. Naming it is cheaper than the next reader re-deriving that it is safe.
@@ -44,7 +44,7 @@
44
44
  // for one thing is how a filter starts silently matching nothing. The shape is absent
45
45
  // from CONTROL_PLANE_SHAPES, which is what drops the runner-performed legs from its plan
46
46
  // BY CONSTRUCTION rather than by a list someone has to remember to update.
47
- const CONTROL_PLANE_SHAPES = ['standalone', 'co-tenant', 'dedicated'];
47
+ const CONTROL_PLANE_SHAPES = ['cloud-host', 'co-tenant'];
48
48
  const ALL_SHAPES = [...CONTROL_PLANE_SHAPES, 'byo-host'];
49
49
 
50
50
  function onboardPlan(mode = 'greenfield', opts = {}) {
@@ -22,17 +22,29 @@
22
22
  // co-tenant — RETIRED (ADR 0323). Was the free front door; ran on the platform's
23
23
  // own box, which is why the name could not be carried over to
24
24
  // bring-your-own-host. Now UNREQUESTABLE — see below.
25
- // dedicated — allocates a real droplet (`scripts/gds/provision.js`), billed
26
- // monthly, defaulting to s-2vcpu-4gb when no tier is named.
27
- // standalone — runs the caller's OWN repo on the platform's box. The unit's
28
- // WorkingDirectory is that checkout (`scripts/gds/provision-units.js`)
29
- // and the module loader discovers modules from `resolveInstanceRoot()`,
30
- // which is process.cwd() (`src/module-loader/loader.js`). So the
31
- // caller names a repo and the platform require()s its `modules/` —
32
- // arbitrary code execution on the control plane's own machine. Task
33
- // 1003369 gave each instance its own uid, which bounds the blast
34
- // radius to that account; it does not make "any Xenos may run code
35
- // here" an acceptable default.
25
+ // cloud-host — runs the caller's OWN repo on a host the platform stands up. TODAY
26
+ // that host is still the platform's own box, and that is what this
27
+ // entry gates: the unit's WorkingDirectory is that checkout
28
+ // (`scripts/gds/provision-units.js`) and the module loader discovers
29
+ // modules from `resolveInstanceRoot()`, which is process.cwd()
30
+ // (`src/module-loader/loader.js`). So the caller names a repo and the
31
+ // platform require()s its `modules/` — arbitrary code execution on the
32
+ // control plane's own machine. Task 1003369 gave each instance its own
33
+ // uid, which bounds the blast radius to that account; it does not make
34
+ // "any Xenos may run code here" an acceptable default.
35
+ //
36
+ // Once ADR 0327 lands, a cloud-host stands up on the OWNER's platform
37
+ // account instead and that specific argument dissolves — but the gate
38
+ // does NOT automatically follow, because the platform is still doing
39
+ // the standing-up and still spending its own runner on it. Re-decide
40
+ // this entry when the move happens (task 1004184); do not assume the
41
+ // shape becomes open just because the box stops being ours.
42
+ //
43
+ // Stored as 'standalone' until task 1004182 renamed it to match the
44
+ // owner-facing name (migration provisioning_027). The old 'dedicated'
45
+ // value folded into this one: ADR 0327 decision 4 deleted that shape,
46
+ // because on someone else's account a bigger plan is a setting, not a
47
+ // hosting shape we model.
36
48
  // control-plane — the platform's OWN row (ADR 0293 D2, task 1003159), enrolled by
37
49
  // `scripts/gds/enroll-control-plane.js` so the owner's deploy door has
38
50
  // something to point at. Never a thing a builder asks for. See below.
@@ -45,10 +57,11 @@
45
57
  // The set named below is the set that needs NO authority. Every other shape, including
46
58
  // one added after this file was last read, requires the permission by omission. The
47
59
  // previous shape of this gate listed the one shape that was gated and admitted the rest,
48
- // and that is exactly how `standalone` and `control-plane` stayed open: `standalone`
49
- // because it was reasoned about as "$0, therefore harmless" (true of the invoice, false
50
- // of the trust), and `control-plane` because it did not exist yet when the gate was
51
- // written. Permitted-by-omission is the failure mode this inversion removes.
60
+ // and that is exactly how `cloud-host` and `control-plane` stayed open: `cloud-host`
61
+ // (then stored as `standalone`) because it was reasoned about as "$0, therefore harmless"
62
+ // — true of the invoice, false of the trust — and `control-plane` because it did not
63
+ // exist yet when the gate was written. Permitted-by-omission is the failure mode this
64
+ // inversion removes.
52
65
  //
53
66
  // The atom is `provisioning.fleet.manage` (floor: metic), deliberately the same one that
54
67
  // already guards the fleet cost-ledger and force-teardown. Spending on the fleet and
@@ -24,8 +24,9 @@ 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
- // 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'];
27
+ // Shapes + why 'co-tenant' is storable but unrequestable: this module's CLAUDE.md.
28
+ // Stored values match the owner-facing names since task 1004182 (provisioning_027).
29
+ const HOSTING_SHAPES = ['byo-host', 'co-tenant', 'cloud-host', 'control-plane'];
29
30
  // How the instance's repo was onboarded (migration provisioning_004, ADR 0121):
30
31
  // greenfield — a fresh EMPTY repo; the runner scaffolds it and pushes the first commit
31
32
  // adopt — a repo the owner ALREADY has; the runner must LAYER onto it, preserving
@@ -102,11 +103,9 @@ const MAX_RELEASED_DOMAINS = 8;
102
103
  // provision intent cannot stand in for it: PROVISION_ENQUEUE_FROM deliberately
103
104
  // excludes 'active' (task 1002691).
104
105
  const SETTINGS_APPLY_FROM = new Set(['active']);
105
- // The hosting shapes the runner has a write path to. A 'dedicated' instance is
106
- // its own droplet, stood up by cloud-init — the runner has no post-hoc exec path
107
- // to it (the placeManifestCreds refusal) — so the route refuses the push HONESTLY
108
- // rather than queue one that never lands.
109
- const SETTINGS_APPLY_SHAPES = new Set(['co-tenant', 'standalone']);
106
+ // The hosting shapes the runner has a write path to. 'byo-host' is absent (the owner runs
107
+ // it, we reach into nothing) and so is 'control-plane' (the platform's own row).
108
+ const SETTINGS_APPLY_SHAPES = new Set(['co-tenant', 'cloud-host']);
110
109
  // The ONE state a restart is meaningful from (task 1003238). Liveness is an
111
110
  // OVERLAY, not a seventh state — an instance the heartbeat cannot reach is still
112
111
  // 'active', and 'active' is exactly where a restart applies. Every earlier state
@@ -116,10 +115,9 @@ const SETTINGS_APPLY_SHAPES = new Set(['co-tenant', 'standalone']);
116
115
  // SETTINGS_APPLY_FROM: each action declaring its own gate is this module's
117
116
  // convention, and the two answer different questions.
118
117
  const RESTART_FROM = new Set(['active']);
119
- // Same reachability rule as the settings push, for the same reason: a 'dedicated'
120
- // droplet is stood up by cloud-init and the runner has no post-hoc exec path to
121
- // it, so the route refuses HONESTLY rather than queue a restart that never lands.
122
- const RESTART_SHAPES = new Set(['co-tenant', 'standalone']);
118
+ // Same reachability rule as the settings push, and the same set for the same reason:
119
+ // the runner can only restart what it runs.
120
+ const RESTART_SHAPES = new Set(['co-tenant', 'cloud-host']);
123
121
 
124
122
  // Per-project settings (task 1003040, goal 1000058): the known keys + their
125
123
  // vocabularies, enforced at the API layer — the jsonb `settings` column
@@ -303,9 +301,16 @@ function isValidSlug(slug) {
303
301
  return true;
304
302
  }
305
303
 
306
- // Monthly USD estimate for a shape+tier. Co-tenant + standalone are free (they share
307
- // the box / run on the owner's own infra); only a dedicated droplet costs its tier's
308
- // rate (0 if the tier is unknown/unset). PURE.
304
+ // Monthly USD estimate of what a shape costs THE PLATFORM. PURE.
305
+ //
306
+ // SINCE task 1004182 THIS RETURNS 0 FOR EVERY STORED ROW, and that is correct, not broken:
307
+ // 'dedicated' was the only shape that priced above zero and provisioning_027 folded it into
308
+ // 'cloud-host', so no row can carry it. ADR 0327 says a ledger of OUR spend "should read
309
+ // ~$0 per cloud-host and be *correct*"; what an owner is worth telling is THEIR platform
310
+ // cost, a different column — ownerMonthlyUsd in cost-ledger.js, which the fleet ledger
311
+ // reads instead of this stored value. The 'dedicated' arm and the tier table stay because
312
+ // this is pure and its tests call it with that value; the droplet code path is task
313
+ // 1004185's to delete.
309
314
  function costEstimateUsd({ hostingShape, tier } = {}) {
310
315
  if (hostingShape !== 'dedicated') return 0;
311
316
  // An ABSENT tier is not an unknown one. The runner substitutes its own default
@@ -1430,30 +1435,6 @@ async function planetChoicesForHosts(db, hosts) {
1430
1435
  return out;
1431
1436
  }
1432
1437
 
1433
- // Fleet cost ledger (Archon): per-instance monthly estimate + a total, over the
1434
- // instances that still cost something (not torn_down). VISIBILITY only (ADR 0111 §6).
1435
- async function costLedger(db, { limit = 500 } = {}) {
1436
- const { rows } = await db.query(
1437
- `SELECT pi.id, pi.slug, pi.hosting_shape, pi.tier, pi.status,
1438
- pi.cost_estimate_usd, b.github_login
1439
- FROM provisioning_instances pi
1440
- JOIN builders b ON b.id = pi.owner_builder_id
1441
- WHERE pi.status <> 'torn_down'
1442
- ORDER BY pi.cost_estimate_usd DESC, pi.updated_at DESC
1443
- LIMIT $1`,
1444
- [Math.min(Math.max(Number(limit) || 500, 1), 2000)]
1445
- );
1446
- const totalMonthlyUsd = rows.reduce((a, r) => a + Number(r.cost_estimate_usd || 0), 0);
1447
- return {
1448
- instances: rows.map((r) => ({
1449
- id: r.id, slug: r.slug, owner: r.github_login,
1450
- hosting_shape: r.hosting_shape, tier: r.tier, status: r.status,
1451
- cost_estimate_usd: Number(r.cost_estimate_usd || 0),
1452
- })),
1453
- total_monthly_usd: Number(totalMonthlyUsd.toFixed(4)),
1454
- };
1455
- }
1456
-
1457
1438
  module.exports = {
1458
1439
  // constants
1459
1440
  INSTANCE_STATES, HOSTING_SHAPES, ONBOARD_MODES, PROJECT_TYPES, INTENT_ACTIONS, TEARDOWN_FROM,
@@ -1484,7 +1465,7 @@ module.exports = {
1484
1465
  recordSettingsPushOwed,
1485
1466
  createInstance, setInstanceStatus, updateInstanceDomain, clearReleasedDomains, updateInstanceSettings, updateInstanceDetail,
1486
1467
  enqueueIntent, enqueueTeardown, enqueueSettingsApply, enqueueRestart, getOpenIntent, claimNextIntent, resolveIntent,
1487
- recordEvent, instanceStateForSlug, planetChoicesForHosts, projectOwnedBy, costLedger,
1468
+ recordEvent, instanceStateForSlug, planetChoicesForHosts, projectOwnedBy,
1488
1469
  // data access — GitHub App Manifest attempts (task 2080)
1489
1470
  createOAuthManifest, getOAuthManifestByState, attachManifestCode,
1490
1471
  getLatestOAuthManifestForInstance, claimNextOAuthManifest, resolveOAuthManifest,
@@ -29,7 +29,7 @@
29
29
  // GET /provisioning/instances/:id/invite-suggestions — who is worth inviting (R28, ADR 0251)
30
30
  // Archon-scoped (requireRank('archon') — the fleet, the /boxes precedent):
31
31
  // GET /provisioning/fleet — the full instance roster + the box's slot `capacity` (ADR 0285)
32
- // GET /provisioning/fleet/cost-ledger — per-instance + total cost estimate
32
+ // GET /provisioning/fleet/cost-ledger — per-instance cost to us + to its owner, with totals
33
33
  // POST /provisioning/instances/:id/force-teardown — tear down ANY instance
34
34
  // public:
35
35
  // GET /provisioning/health — liveness + core version
@@ -49,6 +49,7 @@ const express = require('express');
49
49
  const api = require('../../../src/module-api');
50
50
  const auth = api, { pool, validateOrRespond, parseId } = api;
51
51
  const provisioning = require('../provisioning');
52
+ const { costLedger } = require('../cost-ledger');
52
53
  const { onboardPlan, stepsForShape } = require('../onboard-plan');
53
54
  const { ALWAYS_ON_CORE, OPTIONAL_MODULES, starterBundleAnswerTable, optionalModules, effectiveModules } = require('../starter-bundles');
54
55
  const { recommendationsForDetails } = require('../recommendations');
@@ -214,14 +215,14 @@ module.exports = function provisioningRoutes() {
214
215
  }
215
216
  });
216
217
 
217
- // GET /provisioning/fleet/cost-ledger — per-instance monthly estimate + total.
218
+ // GET /provisioning/fleet/cost-ledger — per-instance monthly cost to us + estimated cost to its owner, with totals.
218
219
  // rank: archon — cost oversight across the fleet (the /boxes/cost-ledger precedent).
219
220
  // Declared among the archon routes (archon-first) for correct auditor classification.
220
221
  router.get('/provisioning/fleet/cost-ledger', auth.requireBuilder, auth.requirePermission('provisioning.fleet.manage'), async (req, res) => {
221
222
  try {
222
223
  // R14 (#2001 / ADR 0119): shared helper; per-endpoint cap preserved (default 500, max 2000).
223
224
  const { limit } = api.parsePagination(req, { defaultLimit: 500, maxLimit: 2000 });
224
- const out = await provisioning.costLedger(pool, { limit });
225
+ const out = await costLedger(pool, { limit });
225
226
  res.json(out);
226
227
  } catch (err) {
227
228
  log.error('[provisioning] GET /provisioning/fleet/cost-ledger', err);
@@ -26,7 +26,7 @@ function unavailableReason(inst) {
26
26
  if (inst.hosting_shape === 'byo-host') {
27
27
  return 'This project runs on your own host, so the platform never reads its code — you can make the repository private on GitHub directly.';
28
28
  }
29
- if (inst.hosting_shape !== 'standalone') {
29
+ if (inst.hosting_shape !== 'cloud-host') {
30
30
  return 'Only a project on a Cloud host can have its repository made private from here.';
31
31
  }
32
32
  if (inst.status !== 'active') {
@@ -307,15 +307,23 @@ await ta('a redirecting healthz is DOWN — the probe never follows a redirect (
307
307
 
308
308
  console.log('\nshape-aware onboard plan (direction §7 — no promises a shape cannot keep):');
309
309
 
310
- t('runner-performed legs are absent from the self-host plan; control-plane shapes keep everything', () => {
311
- const { onboardPlan, stepsForShape } = require('../onboard-plan.js');
310
+ t('runner-performed legs are absent from the byo-host plan; control-plane shapes keep everything', () => {
311
+ // The shape here was 'self-host' until task 1004182 noticed it had stopped meaning
312
+ // anything. ADR 0323 (task 1004141) replaced that plan-only spelling with 'byo-host' in
313
+ // ALL_SHAPES, so 'self-host' became an UNKNOWN shape — and an unknown shape is
314
+ // deliberately unfiltered (the last assertion in this case), which handed the full plan
315
+ // back and made every "absent on self-host" assertion below fail. It has been red since,
316
+ // masked because the DB-backed cases in this file fail first on any machine without a
317
+ // local Postgres. Pinning the live spelling is what makes the filter actually tested.
318
+ const { onboardPlan, stepsForShape, ALL_SHAPES } = require('../onboard-plan.js');
319
+ assert.ok(ALL_SHAPES.includes('byo-host'), 'the filtered shape must be one the plan knows, or this case tests nothing');
312
320
  const all = onboardPlan('greenfield');
313
- const standalone = stepsForShape(all, 'standalone');
314
- assert.equal(standalone.length, all.length, 'a control-plane shape gets the full sequence');
315
- const selfHost = stepsForShape(all, 'self-host');
321
+ const cloudHost = stepsForShape(all, 'cloud-host');
322
+ assert.equal(cloudHost.length, all.length, 'a control-plane shape gets the full sequence');
323
+ const selfHost = stepsForShape(all, 'byo-host');
316
324
  const selfKeys = selfHost.map((s) => s.key);
317
325
  for (const runnerLeg of ['provision', 'oauth-place', 'dns-preflight']) {
318
- assert.ok(!selfKeys.includes(runnerLeg), `${runnerLeg} assumes the platform runner — absent on self-host`);
326
+ assert.ok(!selfKeys.includes(runnerLeg), `${runnerLeg} assumes the platform runner — absent on byo-host`);
319
327
  }
320
328
  for (const human of ['github-repo', 'oauth-app', 'first-sign-in', 'enter-hall']) {
321
329
  assert.ok(selfKeys.includes(human), `${human} is the owner's own leg — present on every shape`);
@@ -374,7 +382,7 @@ console.log('\nAPI exposure — the overlay rides the instance reads:');
374
382
 
375
383
  await ta('GET /provisioning/instances carries last_verified_at + last_verified_up', async () => {
376
384
  instance = {
377
- id: 18, owner_builder_id: 7, slug: 'mercury', status: 'active', hosting_shape: 'standalone',
385
+ id: 18, owner_builder_id: 7, slug: 'mercury', status: 'active', hosting_shape: 'cloud-host',
378
386
  domain: 'mercury.cloudbongos.com', port: 3101,
379
387
  last_verified_at: '2026-08-12T10:00:00.000Z', last_verified_up: true,
380
388
  };
@@ -403,7 +411,7 @@ t("PROVISION_ENQUEUE_FROM allows 'error' (the state a snagged card is in)", () =
403
411
  await ta("POST /provisioning/instances on an 'error' instance re-enqueues a provision intent", async () => {
404
412
  enqueueCalls.length = 0;
405
413
  createResult = {
406
- instance: { id: 20, owner_builder_id: 7, slug: 'snagged', status: 'error', hosting_shape: 'standalone', domain: null },
414
+ instance: { id: 20, owner_builder_id: 7, slug: 'snagged', status: 'error', hosting_shape: 'cloud-host', domain: null },
407
415
  created: false,
408
416
  };
409
417
  const res = await req('POST', '/provisioning/instances', { slug: 'snagged' });
@@ -172,7 +172,7 @@ t('an UNRECOGNISED tier is still $0 — unpriced rather than wrong', () => {
172
172
  });
173
173
 
174
174
  t('every non-dedicated shape is $0 — it co-tenants on infrastructure already paid for', () => {
175
- for (const shape of ['standalone', 'co-tenant', 'shared', undefined]) {
175
+ for (const shape of ['cloud-host', 'co-tenant', 'shared', undefined]) {
176
176
  assert.equal(P.costEstimateUsd({ hostingShape: shape, tier: 's-4vcpu-8gb' }), 0,
177
177
  `shape ${JSON.stringify(shape)} is never metered, whatever tier rides along`);
178
178
  }
@@ -3959,8 +3959,8 @@ summary{min-height:24px;padding:3px 0;}
3959
3959
  ['Address', resolvedDomain()
3960
3960
  ? '<code>' + esc(resolvedDomain()) + '</code>'
3961
3961
  : '<em style="font-weight:600;color:var(--warn)">none — sign-in stays locked until it has one</em>'],
3962
- /* The wizard creates hosting_shape 'standalone', whose owner-facing name is
3963
- "cloud host" (ADR 0323) — we stand it up and run it for them. */
3962
+ /* The wizard creates hosting_shape 'cloud-host' — we stand it up and run it for
3963
+ them. Since task 1004182 the stored value IS the owner-facing name (ADR 0323). */
3964
3964
  ['Shape', 'Cloud host — we stand it up and run it for you, on the platform’s current release']
3965
3965
  ];
3966
3966
  $('reviewList').innerHTML = rows.map(function (r) {
@@ -4050,7 +4050,7 @@ summary{min-height:24px;padding:3px 0;}
4050
4050
  the server then applies the SAME starter bundle the panel showed,
4051
4051
  so a skip and an accept land the same modules */
4052
4052
  modules: modulesForCreate(),
4053
- hosting_shape: 'standalone',
4053
+ hosting_shape: 'cloud-host',
4054
4054
  /* ONLY a bring-your-own domain travels (task 1002898 N2). The server owns
4055
4055
  the assigned <slug>.<apex> default (task 1002704) and applies it to an
4056
4056
  omitted domain, so re-sending our own derived copy could only disagree
@@ -4144,9 +4144,9 @@ summary{min-height:24px;padding:3px 0;}
4144
4144
  var domain = resolvedDomain() || '';
4145
4145
  var key = state.mode + '|' + domain;
4146
4146
  if (!planPromises[key]) {
4147
- /* shape=standalone: this wizard only creates control-plane standalone
4147
+ /* shape=cloud-host: this wizard only creates control-plane cloud-host
4148
4148
  instances, and the plan is shape-filtered server-side (direction §7) */
4149
- planPromises[key] = fetch(API + '/provisioning/onboard-plan?mode=' + encodeURIComponent(state.mode) + '&shape=standalone' + (domain ? '&domain=' + encodeURIComponent(domain) : ''))
4149
+ planPromises[key] = fetch(API + '/provisioning/onboard-plan?mode=' + encodeURIComponent(state.mode) + '&shape=cloud-host' + (domain ? '&domain=' + encodeURIComponent(domain) : ''))
4150
4150
  .then(function (res) { if (!res.ok) throw new Error('plan'); return res.json(); })
4151
4151
  .then(function (d) { return (d && d.steps) || []; })
4152
4152
  .catch(function () {
@@ -4601,8 +4601,7 @@ summary{min-height:24px;padding:3px 0;}
4601
4601
  } else if (s === 'tearing_down') {
4602
4602
  /* a deliberate end is calm, never styled as a failure (direction §5) —
4603
4603
  and it speaks the teardown vocabulary, not the standup's (F22) */
4604
- setProgress(progressMarkup(1, 1, 'Taking the server offline. The repository stays yours; ' +
4605
- (inst.hosting_shape === 'dedicated' ? 'its database goes with the machine.' : 'the database is kept.'), '', 'teardown'));
4604
+ setProgress(progressMarkup(1, 1, 'Taking the server offline. The repository stays yours; the database is kept.', '', 'teardown'));
4606
4605
  } else if (s === 'torn_down') {
4607
4606
  setProgress(progressMarkup(-1, 3, 'This instance was torn down. Bring it back any time from ' +
4608
4607
  projectPageLink('its own page') + ', or start another below.', '', 'teardown'));
@@ -5063,8 +5062,7 @@ summary{min-height:24px;padding:3px 0;}
5063
5062
  brand-new project never grows a Restart button it has no use for. A door on
5064
5063
  every healthy card would only invite needless downtime. */
5065
5064
  function canRestart(inst) {
5066
- return inst.status === 'active' && inst.last_verified_up === false &&
5067
- inst.hosting_shape !== 'dedicated' && !inst.open_intent;
5065
+ return inst.status === 'active' && inst.last_verified_up === false && !inst.open_intent;
5068
5066
  }
5069
5067
  /* keep watching while the platform is doing something to this instance */
5070
5068
  function shouldWatch(inst) {
@@ -5172,8 +5170,7 @@ summary{min-height:24px;padding:3px 0;}
5172
5170
  : 'Raising the server, database, and address — nothing needed from you.');
5173
5171
  }
5174
5172
  if (s === 'tearing_down') {
5175
- return stripMarkup('teardown', 1, 1, 'Taking the server offline. The repository stays yours; ' +
5176
- (inst.hosting_shape === 'dedicated' ? 'its database goes with the machine.' : 'the database is kept.'));
5173
+ return stripMarkup('teardown', 1, 1, 'Taking the server offline. The repository stays yours; the database is kept.');
5177
5174
  }
5178
5175
  if (inst.open_intent) {
5179
5176
  var a = inst.open_intent.action;
@@ -5242,16 +5239,22 @@ summary{min-height:24px;padding:3px 0;}
5242
5239
  /* ── rendering ───────────────────────────────────────────────────────── */
5243
5240
 
5244
5241
  /* The owner-facing name of each hosting shape (ADR 0323). This map IS the translation
5245
- between the stored value and the word the owner reads: 'standalone' and 'dedicated'
5246
- keep their old spellings in the database on purpose (task 1004142 renames the data),
5247
- so this is the ONLY place that difference should ever be visible. Read as a noun
5248
- phrase — it lands in a meta list and in the "server" ledger row.
5249
- 'co-tenant' is retired and unrequestable, but one torn-down row still carries it,
5250
- so it keeps a label rather than falling through to the raw value. */
5242
+ between the stored value and the word the owner reads. Read as a noun phrase — it
5243
+ lands in a meta list and in the "server" ledger row.
5244
+
5245
+ Since task 1004182 (migration provisioning_027) the stored values ARE the
5246
+ owner-facing words, so most of this map is now an identity. It is kept, rather than
5247
+ collapsed into a bare `inst.hosting_shape`, for the two entries that still translate
5248
+ — 'byo-host' and 'co-tenant' — and because the callers already fall through to the
5249
+ raw value for an unknown shape, which is the behaviour a future shape should get.
5250
+
5251
+ 'co-tenant' is retired and unrequestable, but one torn-down row still carries it, so
5252
+ it keeps a label rather than falling through. 'dedicated' is gone entirely (ADR 0327
5253
+ decision 4) and its rows folded into 'cloud-host', so it needs no label: a row that
5254
+ could show one no longer exists. */
5251
5255
  var SHAPE_LABEL = {
5252
5256
  'byo-host': 'your own host',
5253
- standalone: 'cloud host',
5254
- dedicated: 'dedicated cloud host',
5257
+ 'cloud-host': 'cloud host',
5255
5258
  'co-tenant': 'shared server (retired)',
5256
5259
  'control-plane': 'the control plane'
5257
5260
  };
@@ -5269,9 +5272,7 @@ summary{min-height:24px;padding:3px 0;}
5269
5272
  and provisioning keep the built-world text on purpose — a run that failed
5270
5273
  partway may already have a database, a port and a DNS record. */
5271
5274
  if (inst && inst.status === 'requested') return 'Nothing has been stood up yet — this cancels the standup. Anything already on GitHub is untouched, and the name and web address stay reserved for this project.';
5272
- return inst && inst.hosting_shape === 'dedicated'
5273
- ? 'This project has a machine to itself, so the machine goes away and its database goes with it. Your repository is untouched. Its web address stays reserved for this project, and nothing answers there until you bring it back. Taking it offline erases nothing.'
5274
- : 'The server goes away. Your repository is untouched. The database is kept, so bringing it back later restores its memory. Its web address stays reserved for this project, and nothing answers there until you bring it back. Taking it offline erases nothing.';
5275
+ return 'The server goes away. Your repository is untouched. The database is kept, so bringing it back later restores its memory. Its web address stays reserved for this project, and nothing answers there until you bring it back. Taking it offline erases nothing.';
5275
5276
  }
5276
5277
 
5277
5278
  /* onManage: the same facts, but the pointers name the controls THIS surface
@@ -5371,9 +5372,7 @@ summary{min-height:24px;padding:3px 0;}
5371
5372
  /* with a revive queued, the strip tells the story — this paragraph would
5372
5373
  contradict it (F22, the same guard the error paragraph has) */
5373
5374
  body += '<div class="projMsg">Torn down' + (inst.torn_down_at ? ' ' + esc(shortDate(inst.torn_down_at)) : '') +
5374
- (inst.hosting_shape === 'dedicated'
5375
- ? '. The repository is still yours. Bringing it back stands up a fresh machine with an empty database. Or <a href="?view=new">start another</a>.</div>'
5376
- : '. The repository is still yours, and its database is kept — bringing it back takes one click. Or <a href="?view=new">start another</a>.</div>');
5375
+ '. The repository is still yours, and its database is kept — bringing it back takes one click. Or <a href="?view=new">start another</a>.</div>';
5377
5376
  }
5378
5377
  var lm = lastMsg[inst.id];
5379
5378
  /* a live region: an action's result is announced where the pointer is,
@@ -5663,7 +5662,7 @@ summary{min-height:24px;padding:3px 0;}
5663
5662
  var box = $('mMsg');
5664
5663
  box.className = 'projMsg'; box.textContent = verb === 'revive' ? 'Asking the platform to bring it back…' : 'Asking the platform to try again…';
5665
5664
  $('mActs').querySelectorAll('button').forEach(function (b) { b.disabled = true; });
5666
- var body = { slug: inst.slug, hosting_shape: inst.hosting_shape || 'standalone' };
5665
+ var body = { slug: inst.slug, hosting_shape: inst.hosting_shape || 'cloud-host' };
5667
5666
  if (inst.onboard_mode) body.onboard_mode = inst.onboard_mode;
5668
5667
  if (inst.target_ref) body.target_ref = inst.target_ref;
5669
5668
  if (inst.domain) body.domain = inst.domain;
@@ -5983,7 +5982,7 @@ summary{min-height:24px;padding:3px 0;}
5983
5982
  function repoPrivDoor(inst, repo) {
5984
5983
  if (!inst) return '';
5985
5984
  var why = inst.hosting_shape === 'byo-host' ? 'This project runs on your own host, so the platform never reads its code — you can make the repository private on GitHub directly.'
5986
- : inst.hosting_shape !== 'standalone' ? 'Only a project on a Cloud host can have its repository made private from here.'
5985
+ : inst.hosting_shape !== 'cloud-host' ? 'Only a project on a Cloud host can have its repository made private from here.'
5987
5986
  : inst.status !== 'active' ? 'You can make it private once the project is running.'
5988
5987
  : !repo.admin ? 'Your GitHub account can’t change this repository’s settings.'
5989
5988
  : '';
@@ -25,7 +25,7 @@
25
25
  "status": "provisioning", "planet_template": "blob-frost-tint", "planet_accent": "sun-lit",
26
26
  "domain": null,
27
27
  "target_ref": "builder/harbor",
28
- "hosting_shape": "standalone",
28
+ "hosting_shape": "cloud-host",
29
29
  "onboard_mode": "scaffold",
30
30
  "created_at": "$now-6m",
31
31
  "provisioned_at": null,