@bongos/core 1.20.28 → 1.20.29

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.20.28",
6
- "core_contract": "1.20.28",
7
- "source_commit": "64dc694277165d8cd1227e09df2fda3dd99dd396",
5
+ "core_version": "1.20.29",
6
+ "core_contract": "1.20.29",
7
+ "source_commit": "c6152c075da2e7e656381b46f3f9b561cf65f457",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-30T20:23:58.237Z",
9
+ "built_at": "2026-09-30T20:49:43.614Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 562,
13
13
  "agent_docs_stubbed": 25,
14
- "functional_verbatim": 2659,
14
+ "functional_verbatim": 2660,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 3247,
20
- "tree_sha256": "06c076a09a9c706567f32a447e0d1600e2e07a868b9bdd4be62088a290714985",
19
+ "file_count": 3248,
20
+ "tree_sha256": "91a5ebee3fc044967031c6f7dbb8e2a86b98fe305e68353f39c023c5ab042df9",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -327,12 +327,12 @@
327
327
  {
328
328
  "path": "clients/bongos-client/README.md",
329
329
  "mode": "0000644",
330
- "sha256": "8dde50025e6ac0a8e9742455d62495b72c1803b87044455c48b13b95209f59a2"
330
+ "sha256": "08c6eaeee75d4fa5a447578107738e5357ffe088bde7f64e946a5f82d1c06106"
331
331
  },
332
332
  {
333
333
  "path": "clients/bongos-client/bongos-client.global.js",
334
334
  "mode": "0000644",
335
- "sha256": "77f3e9375343c45fd538d179928628a7422858ddea7540cc60cfbdfba95a8545"
335
+ "sha256": "808fd2048e5260dd4cc518d0e4697636b99ec393c925caa1d858976da2590baf"
336
336
  },
337
337
  {
338
338
  "path": "clients/bongos-client/examples/hello-world.mjs",
@@ -342,17 +342,17 @@
342
342
  {
343
343
  "path": "clients/bongos-client/index.cjs",
344
344
  "mode": "0000644",
345
- "sha256": "2eea3743862e9ec173c63517b6f79073b25e71aa3cb2d0a2a19ecce95ef78ca0"
345
+ "sha256": "d6484208dc9acee624ed01503c36ad9fa4f06bd1e7b51738e2c58c1fe251ace8"
346
346
  },
347
347
  {
348
348
  "path": "clients/bongos-client/index.d.ts",
349
349
  "mode": "0000644",
350
- "sha256": "065abbfa7d625aa5811ae9e6c27101ca5b9c824422a6a74bba22d92e3f5d53ae"
350
+ "sha256": "6298f90c0b7849ca342dd5e5235df57686e36b666cedba8db2d9018e8acb60c1"
351
351
  },
352
352
  {
353
353
  "path": "clients/bongos-client/index.mjs",
354
354
  "mode": "0000644",
355
- "sha256": "e2714285309adfdb0644e82319363fdcc6fc19ad9f5a2cf0e71e203da8c5e188"
355
+ "sha256": "eded947e375da51b79857e9d3452f21fbfca2136e774b2a3907d1a4d51436aa9"
356
356
  },
357
357
  {
358
358
  "path": "clients/bongos-client/package.json",
@@ -2262,12 +2262,12 @@
2262
2262
  {
2263
2263
  "path": "docs/api-reference.md",
2264
2264
  "mode": "0000644",
2265
- "sha256": "db03fd5370b7b592769a806607b9cd0880a56b53c64d8909ca24364feeff818e"
2265
+ "sha256": "60243a4cf43ee8c37a06dda28bac07a9797c96eaef126cbcbda4d89c9ec9b986"
2266
2266
  },
2267
2267
  {
2268
2268
  "path": "docs/api/openapi.json",
2269
2269
  "mode": "0000644",
2270
- "sha256": "27b8f6be81f9703a96a68f917dbb01a65048cc02f13adf308b414e96fb022668"
2270
+ "sha256": "847818c90b206c7223114b868e32b1eb50e0cc9955ae4dd6b67c5614d1ab51d8"
2271
2271
  },
2272
2272
  {
2273
2273
  "path": "docs/architecture.md",
@@ -2802,7 +2802,7 @@
2802
2802
  {
2803
2803
  "path": "docs/module-api-changelog.md",
2804
2804
  "mode": "0000644",
2805
- "sha256": "8de783d8efc2109b60de3758da0dfd4fecaf4d4a65dc364dacd1c17e8dac4a7d"
2805
+ "sha256": "7fab447c04933dbbe8ee8f154f854f15eb834163ae656799e5ff3c8f3756fdfc"
2806
2806
  },
2807
2807
  {
2808
2808
  "path": "docs/modules-contract.md",
@@ -7282,7 +7282,7 @@
7282
7282
  {
7283
7283
  "path": "modules/provisioning/core-upgrade.js",
7284
7284
  "mode": "0000644",
7285
- "sha256": "ff2f256a550c5d83a83deb47448590fc9a8f4cfd30c52fd4e90a441223b2aaa7"
7285
+ "sha256": "8adf8842f6058224aeac88f54202cf4e7ed26f0021c44cf9bf1f94098b12ce07"
7286
7286
  },
7287
7287
  {
7288
7288
  "path": "modules/provisioning/cost-ledger.js",
@@ -7454,6 +7454,11 @@
7454
7454
  "mode": "0000644",
7455
7455
  "sha256": "9726682ff47916ca8ef999fe70ed5995c3bad7680b0821b87e4cab8a74d9f867"
7456
7456
  },
7457
+ {
7458
+ "path": "modules/provisioning/migrations/provisioning_029_update_channel.sql",
7459
+ "mode": "0000644",
7460
+ "sha256": "b667ec5165e611bd6f0d86b09465c5d9e632de853becce139bac49f5a88e5c1e"
7461
+ },
7457
7462
  {
7458
7463
  "path": "modules/provisioning/module.json",
7459
7464
  "mode": "0000644",
@@ -7547,7 +7552,7 @@
7547
7552
  {
7548
7553
  "path": "modules/provisioning/routes/core-upgrade.js",
7549
7554
  "mode": "0000644",
7550
- "sha256": "5e74192eb8c4543da8af94a362d7516b54adb8fcbe114953923905d382e5f616"
7555
+ "sha256": "980f25a157dfde5bda6ed903c727fab944b922abdc34be05dc0ccdb5fb3650cc"
7551
7556
  },
7552
7557
  {
7553
7558
  "path": "modules/provisioning/routes/disconnect.js",
@@ -8972,12 +8977,12 @@
8972
8977
  {
8973
8978
  "path": "package-lock.json",
8974
8979
  "mode": "0000644",
8975
- "sha256": "cf64ad1f380ccd8f68a30f1f5b72aa8c03681fc37f044093ae435b7886529cc8"
8980
+ "sha256": "eb1cd0418a763ae11ad580a788df78980c6f19e4953d543e2111f4543d53a8ac"
8976
8981
  },
8977
8982
  {
8978
8983
  "path": "package.json",
8979
8984
  "mode": "0000644",
8980
- "sha256": "d79bd6231aed473960ba8f6c644b69f2c930850a0937a504635e657aa733ea89"
8985
+ "sha256": "3aefd305535d6f2fc5f8a09ca30b7a6ae323e7f120008618e01d1c772198a363"
8981
8986
  },
8982
8987
  {
8983
8988
  "path": "public-docs/index.html",
@@ -8997,7 +9002,7 @@
8997
9002
  {
8998
9003
  "path": "release-notes.json",
8999
9004
  "mode": "0000644",
9000
- "sha256": "2ec3010abecc3e0ed0d2a9bda13ccad173d34e91d80a13bc4ff4aecace0944e8"
9005
+ "sha256": "84ae8d8349b19fc436952c1455536f608f5804eca0a4b934d0b66555f72baef0"
9001
9006
  },
9002
9007
  {
9003
9008
  "path": "scripts/bongos-mcp.js",
@@ -9957,7 +9962,7 @@
9957
9962
  {
9958
9963
  "path": "scripts/gds/provision-core-upgrade.js",
9959
9964
  "mode": "0000644",
9960
- "sha256": "53b301ac91be7028d6b1f633eb0e8cdb105d34b0e6b8c6a2cc667f1ba77e8a85"
9965
+ "sha256": "3adfb1d93ac59f1a057bcfc55fbeb1ac1cfcb6b8148c4ce6551de9bf743c878b"
9961
9966
  },
9962
9967
  {
9963
9968
  "path": "scripts/gds/provision-disconnect.js",
@@ -11117,7 +11122,7 @@
11117
11122
  {
11118
11123
  "path": "src/module-api.js",
11119
11124
  "mode": "0000644",
11120
- "sha256": "40910d211c287582e7066d72f651ff79862d9f5776be6086cd2c931e648136fd"
11125
+ "sha256": "e8f28de6e2a1dbb5dead24503e6da1e013dd36a4a27c0dea9382480fdfdbb7e6"
11121
11126
  },
11122
11127
  {
11123
11128
  "path": "src/module-loader/catalog.js",
@@ -12062,7 +12067,7 @@
12062
12067
  {
12063
12068
  "path": "tests/core_upgrade_door.mjs",
12064
12069
  "mode": "0000644",
12065
- "sha256": "52b55a04a99870a1501f2a422096337e3426a7624ec6e1728f291e426eb9567b"
12070
+ "sha256": "0bbd0f6bcecfb8c88e69256470610f40eb64ec555a67502b3172d69a220fc12b"
12066
12071
  },
12067
12072
  {
12068
12073
  "path": "tests/core_upgrade_owner_door.mjs",
@@ -12072,7 +12077,7 @@
12072
12077
  {
12073
12078
  "path": "tests/core_upgrade_runner.mjs",
12074
12079
  "mode": "0000644",
12075
- "sha256": "c8b2ecca1681427e2e800f78961ba322443e4a2e5502d5d81fd5c2f20aeda711"
12080
+ "sha256": "f55d142db2f3207eb1b1dc7e06c978cb22a282ff0d16792808d212948993640a"
12076
12081
  },
12077
12082
  {
12078
12083
  "path": "tests/cosmos_token_contrast.mjs",
@@ -5,7 +5,7 @@ A **generated**, zero-dependency typed client for the Bongos API — produced fr
5
5
  by hand; it regenerates when the spec changes, so it can never drift from the routes.
6
6
 
7
7
  - API version: **v1** (served at `/api/bongos/v1`)
8
- - 465 operations across 70 resource groups
8
+ - 466 operations across 70 resource groups
9
9
 
10
10
  ## Use it from your project
11
11
 
@@ -843,6 +843,8 @@ function createClient(opts = {}) {
843
843
  patchProvisioningInstancesIdSettings: (args) => request("PATCH", "/provisioning/instances/{id}/settings", { hasBody: true }, args),
844
844
  // POST /provisioning/instances/{id}/teardown — rank: any-builder — POST /provisioning/instances/:id/teardown
845
845
  postProvisioningInstancesIdTeardown: (args) => request("POST", "/provisioning/instances/{id}/teardown", { hasBody: true }, args),
846
+ // POST /provisioning/instances/{id}/update-channel — rank: archon — POST /provisioning/instances/:id/update-channel
847
+ postProvisioningInstancesIdUpdateChannel: (args) => request("POST", "/provisioning/instances/{id}/update-channel", { hasBody: true }, args),
846
848
  // GET /provisioning/onboard-plan — rank: public — GET /provisioning/onboard-plan
847
849
  getProvisioningOnboardPlan: (args) => request("GET", "/provisioning/onboard-plan", { hasBody: false }, args),
848
850
  // GET /provisioning/recommendations — rank: public — GET /provisioning/recommendations
@@ -842,6 +842,8 @@ function createClient(opts = {}) {
842
842
  patchProvisioningInstancesIdSettings: (args) => request("PATCH", "/provisioning/instances/{id}/settings", { hasBody: true }, args),
843
843
  // POST /provisioning/instances/{id}/teardown — rank: any-builder — POST /provisioning/instances/:id/teardown
844
844
  postProvisioningInstancesIdTeardown: (args) => request("POST", "/provisioning/instances/{id}/teardown", { hasBody: true }, args),
845
+ // POST /provisioning/instances/{id}/update-channel — rank: archon — POST /provisioning/instances/:id/update-channel
846
+ postProvisioningInstancesIdUpdateChannel: (args) => request("POST", "/provisioning/instances/{id}/update-channel", { hasBody: true }, args),
845
847
  // GET /provisioning/onboard-plan — rank: public — GET /provisioning/onboard-plan
846
848
  getProvisioningOnboardPlan: (args) => request("GET", "/provisioning/onboard-plan", { hasBody: false }, args),
847
849
  // GET /provisioning/recommendations — rank: public — GET /provisioning/recommendations
@@ -1274,6 +1274,8 @@ export interface BongosClient {
1274
1274
  patchProvisioningInstancesIdSettings(args?: RequestArgs): Promise<PatchProvisioningInstancesIdSettingsResponse>;
1275
1275
  /** POST /provisioning/instances/{id}/teardown — rank: any-builder */
1276
1276
  postProvisioningInstancesIdTeardown(args?: RequestArgs): Promise<PostProvisioningInstancesIdTeardownResponse>;
1277
+ /** POST /provisioning/instances/{id}/update-channel — rank: archon */
1278
+ postProvisioningInstancesIdUpdateChannel(args?: RequestArgs): Promise<ApiResponse>;
1277
1279
  /** GET /provisioning/onboard-plan — rank: public */
1278
1280
  getProvisioningOnboardPlan(args?: RequestArgs): Promise<GetProvisioningOnboardPlanResponse>;
1279
1281
  /** GET /provisioning/recommendations — rank: public */
@@ -839,6 +839,8 @@ export function createClient(opts = {}) {
839
839
  patchProvisioningInstancesIdSettings: (args) => request("PATCH", "/provisioning/instances/{id}/settings", { hasBody: true }, args),
840
840
  // POST /provisioning/instances/{id}/teardown — rank: any-builder — POST /provisioning/instances/:id/teardown
841
841
  postProvisioningInstancesIdTeardown: (args) => request("POST", "/provisioning/instances/{id}/teardown", { hasBody: true }, args),
842
+ // POST /provisioning/instances/{id}/update-channel — rank: archon — POST /provisioning/instances/:id/update-channel
843
+ postProvisioningInstancesIdUpdateChannel: (args) => request("POST", "/provisioning/instances/{id}/update-channel", { hasBody: true }, args),
842
844
  // GET /provisioning/onboard-plan — rank: public — GET /provisioning/onboard-plan
843
845
  getProvisioningOnboardPlan: (args) => request("GET", "/provisioning/onboard-plan", { hasBody: false }, args),
844
846
  // GET /provisioning/recommendations — rank: public — GET /provisioning/recommendations
@@ -15676,6 +15676,54 @@
15676
15676
  ]
15677
15677
  }
15678
15678
  },
15679
+ "/provisioning/instances/{id}/update-channel": {
15680
+ "post": {
15681
+ "operationId": "post_provisioning_instances_id_update_channel",
15682
+ "tags": [
15683
+ "provisioning"
15684
+ ],
15685
+ "summary": "POST /provisioning/instances/:id/update-channel",
15686
+ "description": "POST /provisioning/instances/:id/update-channel — set the project's update rule (task 1004445). Body: { channel: \"pinned\" | \"patch\" | \"minor\" }. rank: `core.pin.move`, OR THE PROJECT'S OWN OWNER — the same two doors as the move, because the rule decides which moves the runner will perform: whoever may press Move may choose how far it reaches, and nobody else may. The platform's own row stays on the atom alone (OWNER_SHAPES excludes it), exactly as for the move.\n\n**Rank:** `archon` — Archon only (rank and identity management + the escalation keys — the trust boundary).\n\n**Permissions:** `core.pin.move` (all required).",
15687
+ "x-rank": "archon",
15688
+ "x-source": "modules/provisioning/routes/core-upgrade.js",
15689
+ "x-permissions": [
15690
+ "core.pin.move"
15691
+ ],
15692
+ "parameters": [
15693
+ {
15694
+ "name": "id",
15695
+ "in": "path",
15696
+ "required": true,
15697
+ "schema": {
15698
+ "type": "string"
15699
+ },
15700
+ "description": "Path parameter `id`."
15701
+ }
15702
+ ],
15703
+ "responses": {
15704
+ "200": {
15705
+ "description": "Success."
15706
+ },
15707
+ "400": {
15708
+ "$ref": "#/components/responses/BadRequest"
15709
+ },
15710
+ "401": {
15711
+ "$ref": "#/components/responses/Unauthorized"
15712
+ },
15713
+ "403": {
15714
+ "$ref": "#/components/responses/Forbidden"
15715
+ },
15716
+ "404": {
15717
+ "$ref": "#/components/responses/NotFound"
15718
+ }
15719
+ },
15720
+ "security": [
15721
+ {
15722
+ "builderSession": []
15723
+ }
15724
+ ]
15725
+ }
15726
+ },
15679
15727
  "/provisioning/onboard-plan": {
15680
15728
  "get": {
15681
15729
  "operationId": "get_provisioning_onboard_plan",
@@ -29763,7 +29811,7 @@
29763
29811
  "description": "A required dependency/feature is not configured or is temporarily down."
29764
29812
  }
29765
29813
  },
29766
- "x-endpoint-count": 465,
29814
+ "x-endpoint-count": 466,
29767
29815
  "x-schema-count": 498,
29768
29816
  "x-undocumented-bodies": 12,
29769
29817
  "x-response-schemas": 337,
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Bongos API reference
4
4
 
5
- > **Generated from the live route files** — the route file is authoritative. 465 endpoints across 83 route files.
5
+ > **Generated from the live route files** — the route file is authoritative. 466 endpoints across 83 route files.
6
6
  > Machine-readable spec: [`docs/api/openapi.json`](api/openapi.json) (OpenAPI 3.1). Rendered docs site: **`/docs`** (e.g. `cloudbongos.com/docs`).
7
7
 
8
8
  Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/0016-trust-boundary-server-enforced-permissions.md)): `public` < `any-builder` < `metic+archon` < `archon`.
@@ -562,7 +562,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/0016-trust
562
562
  | GET | `/api/bongos/projects/featured` | `public` | — | the public map of projects; no auth by design. |
563
563
  | POST | `/api/bongos/projects/invite` | `metic+archon` | `client_id`, `github_login` | own-or-metic in effect: where to invite a builder into a project, asked by a `project.curate` holder OR the project's OWN OWNER (task 100… |
564
564
 
565
- ## `provisioning` (34)
565
+ ## `provisioning` (35)
566
566
 
567
567
  | Method | Path | Rank | Body | Description |
568
568
  |---|---|---|---|---|
@@ -594,6 +594,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/0016-trust
594
594
  | GET | `/api/bongos/provisioning/instances/:id/settings` | `any-builder` | — | GET /provisioning/instances/:id/settings — read a project's effective settings + the allowed values (so a form can render its choices). |
595
595
  | PATCH | `/api/bongos/provisioning/instances/:id/settings` | `any-builder` | validated | PATCH /provisioning/instances/:id/settings — change a project's settings. |
596
596
  | POST | `/api/bongos/provisioning/instances/:id/teardown` | `any-builder` | — | POST /provisioning/instances/:id/teardown — request teardown of the caller's OWN instance. |
597
+ | POST | `/api/bongos/provisioning/instances/:id/update-channel` | `archon` | — | POST /provisioning/instances/:id/update-channel — set the project's update rule (task 1004445). |
597
598
  | GET | `/api/bongos/provisioning/onboard-plan` | `public` | — | GET /provisioning/onboard-plan?mode=greenfield\|adopt&domain=<host> — the CANONICAL onboarding step sequence (task 2085; goal 35 / criteri… |
598
599
  | GET | `/api/bongos/provisioning/recommendations` | `public` | — | GET /provisioning/recommendations?type=<project type>&team_shape=<interview answer> — the RULE-BASED recommendation for one set of creati… |
599
600
  | POST | `/api/bongos/provisioning/render/lookup` | `any-builder` | `key`, `owner_id` | POST /provisioning/render/lookup — rank: any authenticated builder. |
@@ -2713,5 +2713,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2713
2713
  landed since 1.20.26 with no explicit bump. run 36768642814. (task 1002620)
2714
2714
  1.20.28 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2715
2715
  landed since 1.20.27 with no explicit bump. run 36772266063. (task 1002620)
2716
+ 1.20.29 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2717
+ landed since 1.20.28 with no explicit bump. run 36775277990. (task 1002620)
2716
2718
  ---------------------------------------------------------------------------
2717
2719
  ```
@@ -318,6 +318,9 @@ function previewFromSnapshot(inst, snap, openIntent, lastMove) {
318
318
  slug: inst.slug,
319
319
  hosting_shape: inst.hosting_shape,
320
320
  served_version: served,
321
+ // The rule the runner bounds this project's moves by (task 1004445) — what the page
322
+ // shows beside the offer, so "nothing to deploy" can say WHICH rule made it so.
323
+ update_channel: updateChannelOf(inst),
321
324
  recommended_version: recommended,
322
325
  available_count: available.length,
323
326
  behind: !!behind,
@@ -429,5 +432,71 @@ function makeControlPlaneHandler({ provisioning, pool, log }) {
429
432
  };
430
433
  }
431
434
 
432
- module.exports = { CORE_UPGRADE_FROM, lastCoreMove, lastMoveSuperseded, parseRepoNote, preflightForOffer, UPGRADE_SHAPES, VERSION_RE, isCoreVersion, coreUpgradeFault, enqueueCoreUpgrade, makeCoreUpgradeHandler, previewFromSnapshot, makeCoreUpgradePreviewHandler, makeControlPlaneHandler };
435
+ // ---- the project's update rule (task 1004445) -------------------------------------
436
+ //
437
+ // RE-STATED, NOT REQUIRED. The list is update-channel.js VALID_CHANNELS / DEFAULT_CHANNEL,
438
+ // which lives in scripts/gds/ and a module may not require (ADR 0083) — the same reason
439
+ // parseRepoNote re-states the runner's note shape. tests/core_upgrade_door.mjs asserts the
440
+ // two lists are equal, so they cannot drift apart silently; migration provisioning_029's
441
+ // CHECK is the third copy, and the database refuses anything outside it.
442
+ const UPDATE_CHANNELS = Object.freeze(['pinned', 'patch', 'minor']);
443
+ const DEFAULT_UPDATE_CHANNEL = 'patch';
444
+
445
+ /** The row's update rule, or the default for a row read before the column existed. PURE. */
446
+ function updateChannelOf(inst) {
447
+ const c = inst && typeof inst.update_channel === 'string' ? inst.update_channel : '';
448
+ return UPDATE_CHANNELS.includes(c) ? c : DEFAULT_UPDATE_CHANNEL;
449
+ }
450
+
451
+ /**
452
+ * POST /provisioning/instances/:id/update-channel — set how far this project's core
453
+ * may move (task 1004445). Body: { channel: 'pinned' | 'patch' | 'minor' }.
454
+ *
455
+ * WHY IT EXISTS. The runner's channel refusal told the owner to "change the channel
456
+ * first", and until this route there was no channel to change: every project was held to
457
+ * patch-only, so a step-up release (1.19 → 1.20) could never land through the door.
458
+ *
459
+ * It changes a RULE, never the core: nothing is queued and nothing restarts. The next
460
+ * runner tick re-resolves what is on offer under the new rule, which is why the reply
461
+ * says the offer updates on the next check rather than claiming it already has.
462
+ */
463
+ function makeUpdateChannelHandler({ provisioning, pool, parseId, validateOrRespond, log }) {
464
+ return async function updateChannelHandler(req, res) {
465
+ const id = parseId(req, res);
466
+ if (!id) return;
467
+ if (validateOrRespond(req, res, { channel: { type: 'string', maxLength: 16 } }, { strict: true })) return;
468
+ try {
469
+ const inst = await provisioning.getInstanceById(pool, id);
470
+ if (!inst) return res.fail('instance_not_found', 404);
471
+ const channel = req.body && typeof req.body.channel === 'string' ? req.body.channel.trim().toLowerCase() : '';
472
+ if (!UPDATE_CHANNELS.includes(channel)) {
473
+ return res.fail('bad_update_channel', {
474
+ status: 400,
475
+ message: `Choose one of: ${UPDATE_CHANNELS.join(', ')} — pinned moves nothing, patch takes fixes within the same release line, minor also takes the next step up.`,
476
+ details: { valid: UPDATE_CHANNELS },
477
+ });
478
+ }
479
+ const was = updateChannelOf(inst);
480
+ if (was !== channel) {
481
+ await pool.query('UPDATE provisioning_instances SET update_channel = $2, updated_at = now() WHERE id = $1', [id, channel]);
482
+ const actor = require('./core-upgrade-authz').coreUpgradeActor(req);
483
+ await provisioning.recordEvent(pool, {
484
+ instanceId: id, ownerBuilderId: inst.owner_builder_id, event: 'update-channel-changed',
485
+ detail: `update rule changed from ${was} to ${channel}`, actor,
486
+ }).catch(() => {});
487
+ }
488
+ return res.json({
489
+ ok: true, update_channel: channel, previous: was, changed: was !== channel,
490
+ message: was === channel
491
+ ? `This project already follows the '${channel}' rule — nothing changed.`
492
+ : `This project now follows the '${channel}' rule. What it can move to updates on the next check, within a few minutes.`,
493
+ });
494
+ } catch (err) {
495
+ log.error('[provisioning] POST /provisioning/instances/:id/update-channel', err);
496
+ return failFrom(res, err, 'update_channel_failed');
497
+ }
498
+ };
499
+ }
500
+
501
+ module.exports = { UPDATE_CHANNELS, DEFAULT_UPDATE_CHANNEL, updateChannelOf, makeUpdateChannelHandler, CORE_UPGRADE_FROM, lastCoreMove, lastMoveSuperseded, parseRepoNote, preflightForOffer, UPGRADE_SHAPES, VERSION_RE, isCoreVersion, coreUpgradeFault, enqueueCoreUpgrade, makeCoreUpgradeHandler, previewFromSnapshot, makeCoreUpgradePreviewHandler, makeControlPlaneHandler };
433
502
 
@@ -0,0 +1,24 @@
1
+ -- provisioning_029_update_channel.sql — each project's own core update rule (task 1004445).
2
+ --
3
+ -- WHY. The deploy runner bounded every project by the platform default (update-channel.js
4
+ -- DEFAULT_CHANNEL, patch-only), because nothing per-project existed to read. So a step-up
5
+ -- release (1.19.x -> 1.20.0) could never reach ANY project through the deploy door: the
6
+ -- runner refused it with "outside this project's 'patch' update channel ... Change the
7
+ -- channel first", and there was no channel anywhere to change. This column is that channel.
8
+ --
9
+ -- Values are update-channel.js VALID_CHANNELS: pinned (the door moves nothing), patch
10
+ -- (same major.minor — the old behaviour, hence the default), minor (any higher minor within
11
+ -- the major). A major bump is never admitted by any channel. The CHECK mirrors the list so a
12
+ -- hand-written UPDATE cannot store a value the runner would silently normalise away.
13
+ --
14
+ -- Additive and namespaced (ADR 0083): no down-migration, idempotent — safe to re-run.
15
+
16
+ BEGIN;
17
+
18
+ ALTER TABLE provisioning_instances ADD COLUMN IF NOT EXISTS update_channel text NOT NULL DEFAULT 'patch';
19
+
20
+ ALTER TABLE provisioning_instances DROP CONSTRAINT IF EXISTS provisioning_instances_update_channel_check;
21
+ ALTER TABLE provisioning_instances ADD CONSTRAINT provisioning_instances_update_channel_check
22
+ CHECK (update_channel IN ('pinned', 'patch', 'minor'));
23
+
24
+ COMMIT;
@@ -25,7 +25,7 @@
25
25
  const express = require('express');
26
26
  const api = require('../../../src/module-api');
27
27
  const provisioning = require('../provisioning');
28
- const { makeCoreUpgradeHandler, makeCoreUpgradePreviewHandler, makeControlPlaneHandler } = require('../core-upgrade');
28
+ const { makeCoreUpgradeHandler, makeCoreUpgradePreviewHandler, makeControlPlaneHandler, makeUpdateChannelHandler } = require('../core-upgrade');
29
29
  const { requireOwnProjectOrPin } = require('../core-upgrade-authz');
30
30
 
31
31
  const log = api.logger('provisioning');
@@ -86,6 +86,26 @@ module.exports = function buildCoreUpgradeRouter() {
86
86
  makeCoreUpgradePreviewHandler({ provisioning, pool: api.pool, parseId: api.parseId, log }),
87
87
  );
88
88
 
89
+ // POST /provisioning/instances/:id/update-channel — set the project's update rule
90
+ // (task 1004445). Body: { channel: "pinned" | "patch" | "minor" }.
91
+ //
92
+ // rank: `core.pin.move`, OR THE PROJECT'S OWN OWNER — the same two doors as the move,
93
+ // because the rule decides which moves the runner will perform: whoever may press
94
+ // Move may choose how far it reaches, and nobody else may. The platform's own row
95
+ // stays on the atom alone (OWNER_SHAPES excludes it), exactly as for the move.
96
+ router.post(
97
+ '/provisioning/instances/:id/update-channel',
98
+ api.requireBuilder,
99
+ requireOwnProjectOrPin({ pinGate: api.requirePermission('core.pin.move'), provisioning, pool: api.pool, log }),
100
+ makeUpdateChannelHandler({
101
+ provisioning,
102
+ pool: api.pool,
103
+ parseId: api.parseId,
104
+ validateOrRespond: api.validateOrRespond,
105
+ log,
106
+ }),
107
+ );
108
+
89
109
  // GET /provisioning/control-plane — "which row is this platform, and where does it
90
110
  // stand?" The deploy page's first call: the self row is created once by hand at
91
111
  // install time, so its id is per-install and nobody's to memorise. The atom ALONE
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.28",
3
+ "version": "1.20.29",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.28",
9
+ "version": "1.20.29",
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.20.28",
3
+ "version": "1.20.29",
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",
@@ -8240,5 +8240,11 @@
8240
8240
  "id": "1002969",
8241
8241
  "text": "An invitation to join a project now expires after 30 days; the person who sent it can simply invite again."
8242
8242
  }
8243
+ ],
8244
+ "1.20.29": [
8245
+ {
8246
+ "id": "1004445",
8247
+ "text": "Each project now has its own update rule. A project can be allowed to take bigger step-up updates (like 1.19 to 1.20) instead of being stuck on small fixes only, and its owner can change the rule."
8248
+ }
8243
8249
  ]
8244
8250
  }
@@ -141,6 +141,15 @@ function targetFault(to, available, installed, channel, channelAllows) {
141
141
  return null;
142
142
  }
143
143
 
144
+ // The update channel that bounds a move for THIS instance (task 1004445): the row's own
145
+ // `update_channel`, normalised by update-channel.js so anything unknown or absent (a row
146
+ // read before migration provisioning_029) falls back to DEFAULT_CHANNEL. PURE. One helper
147
+ // for the move and the snapshot, so the button's offer and the runner's refusal cannot
148
+ // disagree about which rule applies.
149
+ function instanceChannel(inst, channelMod) {
150
+ return channelMod.normalizeChannel(inst && inst.update_channel != null ? inst.update_channel : channelMod.DEFAULT_CHANNEL);
151
+ }
152
+
144
153
  // The builder id a queued move is attributed to, from the actor the web tier recorded.
145
154
  // PURE. Two doors reach the move (ADR 0339): an Archon (`api:archon:<id>`) and, since
146
155
  // task 1004174, the project's own owner (`api:owner:<id>`). Both carry the id, because
@@ -209,15 +218,12 @@ async function coreUpgradeInstance(inst, deps, intent) {
209
218
  const instanceDir = inst.hosting_shape === 'cloud-host' ? standaloneRoot(inst) : selfRoot();
210
219
  const readInstalled = deps.readInstalledCoreVersion || require('./upgrade.js').readInstalledCoreVersion;
211
220
  const installed = readInstalled(instanceDir);
212
- // THE PLATFORM DEFAULT, NOT A PER-INSTANCE CHANNEL — and saying so beats implying
213
- // otherwise. provisioning_instances carries no channel column; the subscription
214
- // roster update-channel.js reads is a control-plane file, not a per-row setting. So
215
- // this bounds every instance by the same policy (DEFAULT_CHANNEL: patch-only), which
216
- // is a real bound — it refuses a prerelease or a major jump — and is the honest one
217
- // available today. When a per-instance channel lands, this line is where it is read;
218
- // writing `inst.update_channel ||` now would only have created a guard that reads as
219
- // present and can never fire, which is the trap the web-tier half already documents.
220
- const channel = channelMod.normalizeChannel(channelMod.DEFAULT_CHANNEL);
221
+ // THIS PROJECT'S OWN UPDATE RULE (task 1004445, migration provisioning_029). Before it
222
+ // every instance was bounded by DEFAULT_CHANNEL (patch-only), so a step-up release
223
+ // could reach no project through the door and the refusal's "change the channel first"
224
+ // pointed at nothing. A row from before the column (undefined) still normalises to that
225
+ // default, so an old runner schema reads exactly as it always did.
226
+ const channel = instanceChannel(inst, channelMod);
221
227
  const listed = channelMod.listAvailableVersions({ cwd: instanceDir });
222
228
  const available = (listed && listed.versions) || [];
223
229
  const fault = targetFault(to, available, installed, channel, channelMod.channelAllows);
@@ -977,7 +983,7 @@ async function snapshotCoreVersions(inst, deps) {
977
983
  // admits nothing newer — "already current" and "nothing to take" are the same
978
984
  // answer here, and both correctly leave `recommended` NULL.
979
985
  const recommended = served
980
- ? channelMod.resolveChannelTarget({ installed: served, channel: channelMod.DEFAULT_CHANNEL, available })
986
+ ? channelMod.resolveChannelTarget({ installed: served, channel: instanceChannel(inst, channelMod), available })
981
987
  : null;
982
988
  // THE TICK DOES NOT DRY-RUN. The verdict is produced on demand (preflightCoreUpgrade),
983
989
  // because a check re-run every tick for a box that has been behind for hours spends a
@@ -1021,4 +1027,4 @@ async function snapshotAllCoreVersions(deps) {
1021
1027
  return { seen };
1022
1028
  }
1023
1029
 
1024
- module.exports = { pushUpgradePin, UPGRADE_SHAPES, CHANGE_CAP, REPO_NOTES, repoNote, requestedByOf, parseChangeRecords, readInstalledNotes, readPackagedNotes, changesBetween, coreCheckoutCandidates, findCoreCheckout, gitFault, resolveRunAs, versionCmd, parseServedVersion, parseSchemaPending, targetFault, parsePreflight, upgradeInvocation, preflightUpgrade, preflightCoreUpgrade, coreUpgradeInstance, snapshotCoreVersions, snapshotAllCoreVersions };
1030
+ module.exports = { pushUpgradePin, UPGRADE_SHAPES, instanceChannel, CHANGE_CAP, REPO_NOTES, repoNote, requestedByOf, parseChangeRecords, readInstalledNotes, readPackagedNotes, changesBetween, coreCheckoutCandidates, findCoreCheckout, gitFault, resolveRunAs, versionCmd, parseServedVersion, parseSchemaPending, targetFault, parsePreflight, upgradeInvocation, preflightUpgrade, preflightCoreUpgrade, coreUpgradeInstance, snapshotCoreVersions, snapshotAllCoreVersions };
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.20.28'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.29'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -18,7 +18,7 @@ import { createRequire } from 'node:module';
18
18
  import { readFileSync, readdirSync } from 'node:fs';
19
19
  import { fileURLToPath } from 'node:url';
20
20
  import { dirname, join } from 'node:path';
21
- import { parseRepoNote } from '../modules/provisioning/core-upgrade.js';
21
+ import { parseRepoNote, UPDATE_CHANNELS, DEFAULT_UPDATE_CHANNEL, updateChannelOf, makeUpdateChannelHandler as updateChannelHandlerFor } from '../modules/provisioning/core-upgrade.js';
22
22
 
23
23
  const require = createRequire(import.meta.url);
24
24
  const P = require('../modules/provisioning/provisioning.js');
@@ -885,5 +885,84 @@ await t('lastMoveSuperseded computes no version ORDERING — only identity', ()
885
885
  assert.equal(CU.lastMoveSuperseded('1.19.921', '1.19.900', '1.19.949'), true);
886
886
  });
887
887
 
888
+ console.log('\nthe project\'s update rule (task 1004445):');
889
+
890
+ const channelHandler = (inst, calls) => updateChannelHandlerFor({
891
+ provisioning: {
892
+ getInstanceById: async () => inst,
893
+ recordEvent: async (_db, e) => { calls.push({ event: e.event, detail: e.detail, actor: e.actor }); },
894
+ },
895
+ pool: { query: async (sql, params) => { calls.push({ sql, params }); return { rows: [] }; } },
896
+ parseId: () => 7, validateOrRespond: () => false, log: { error() {} },
897
+ });
898
+ const runChannel = async (inst, body, calls = []) => {
899
+ let out = null;
900
+ const res = { json: (b) => { out = b; return res; }, fail: (code, o) => { out = { code, ...o }; return res; } };
901
+ await channelHandler(inst, calls)({ builder: { id: 42 }, params: { id: '7' }, body }, res);
902
+ return out;
903
+ };
904
+
905
+ await t('the web tier\'s channel list is the runner\'s, and the migration\'s CHECK carries the same values', () => {
906
+ const policy = require('../scripts/gds/update-channel.js');
907
+ assert.deepEqual([...UPDATE_CHANNELS], policy.VALID_CHANNELS, 're-stated in the module (ADR 0083) — so it must be equal');
908
+ assert.equal(DEFAULT_UPDATE_CHANNEL, policy.DEFAULT_CHANNEL);
909
+ const sql = read('modules', 'provisioning', 'migrations', 'provisioning_029_update_channel.sql');
910
+ assert.match(sql, /DEFAULT 'patch'/);
911
+ for (const c of policy.VALID_CHANNELS) assert.match(sql, new RegExp(`'${c}'`), `the CHECK admits ${c}`);
912
+ });
913
+
914
+ await t('updateChannelOf reads the row and falls back to patch for a pre-column row', () => {
915
+ assert.equal(updateChannelOf({ update_channel: 'minor' }), 'minor');
916
+ assert.equal(updateChannelOf({}), 'patch');
917
+ assert.equal(updateChannelOf({ update_channel: 'everything' }), 'patch');
918
+ });
919
+
920
+ await t('setting the rule writes the row and audits who changed it, from what to what', async () => {
921
+ const calls = [];
922
+ const out = await runChannel({ ...ACTIVE, update_channel: 'patch' }, { channel: 'minor' }, calls);
923
+ assert.equal(out.ok, true);
924
+ assert.equal(out.update_channel, 'minor');
925
+ assert.equal(out.changed, true);
926
+ const w = calls.find((c) => c.sql && /UPDATE provisioning_instances SET update_channel/.test(c.sql));
927
+ assert.ok(w, 'the row was written');
928
+ assert.deepEqual(w.params, [7, 'minor']);
929
+ const ev = calls.find((c) => c.event === 'update-channel-changed');
930
+ assert.ok(ev, 'and an event recorded');
931
+ assert.match(ev.detail, /from patch to minor/);
932
+ assert.equal(ev.actor, 'api:archon:42');
933
+ assert.ok(!calls.some((c) => c.sql && /provisioning_intents/.test(c.sql)), 'nothing is queued — a rule change moves no core');
934
+ });
935
+
936
+ await t('an unknown rule is a 400 naming the valid ones, and writes nothing', async () => {
937
+ const calls = [];
938
+ const out = await runChannel(ACTIVE, { channel: 'major' }, calls);
939
+ assert.equal(out.code, 'bad_update_channel');
940
+ assert.equal(out.status, 400);
941
+ assert.deepEqual(out.details.valid, ['pinned', 'patch', 'minor']);
942
+ assert.equal(calls.length, 0);
943
+ });
944
+
945
+ await t('re-sending the current rule changes nothing and says so', async () => {
946
+ const calls = [];
947
+ const out = await runChannel({ ...ACTIVE, update_channel: 'minor' }, { channel: 'MINOR' }, calls);
948
+ assert.equal(out.ok, true);
949
+ assert.equal(out.changed, false);
950
+ assert.equal(calls.length, 0, 'no write, no event');
951
+ });
952
+
953
+ await t('the preview carries the rule, so the page can say which rule shaped the offer', () => {
954
+ assert.equal(CU.previewFromSnapshot({ ...ACTIVE, update_channel: 'minor' }, null, null, null).update_channel, 'minor');
955
+ assert.equal(CU.previewFromSnapshot(ACTIVE, null, null, null).update_channel, 'patch');
956
+ });
957
+
958
+ await t('the route is gated like the move: the owner door OR core.pin.move', () => {
959
+ const src = read('modules', 'provisioning', 'routes', 'core-upgrade.js');
960
+ const i = src.indexOf("'/provisioning/instances/:id/update-channel'");
961
+ assert.ok(i > 0, 'the route exists');
962
+ const block = src.slice(i, src.indexOf(');', i));
963
+ assert.match(block, /api\.requireBuilder/);
964
+ assert.match(block, /requireOwnProjectOrPin\(\{ pinGate: api\.requirePermission\('core\.pin\.move'\)/);
965
+ });
966
+
888
967
  console.log(`\ncore_upgrade_door: ${passed} passed, ${failed} failed`);
889
968
  if (failed) process.exit(1);
@@ -31,7 +31,7 @@ import { createRequire } from 'node:module';
31
31
  import fs from 'node:fs';
32
32
  import path from 'node:path';
33
33
  // An ESM named import, not R.parseSchemaPending: knip credits a CJS export only when a test imports it this way.
34
- import { parseSchemaPending, repoNote, REPO_NOTES } from '../scripts/gds/provision-core-upgrade.js';
34
+ import { parseSchemaPending, repoNote, REPO_NOTES, instanceChannel } from '../scripts/gds/provision-core-upgrade.js';
35
35
  import { parseRepoNote } from '../modules/provisioning/core-upgrade.js';
36
36
 
37
37
  const require = createRequire(import.meta.url);
@@ -1396,5 +1396,58 @@ await t('the DRAIN LOOP resolves a done-with-a-note move WITH its note, still as
1396
1396
  assert.equal(statusWrites.length, 0, 'a healthy instance is not marked anything');
1397
1397
  });
1398
1398
 
1399
+ console.log('\nthe project\'s own update rule (task 1004445):');
1400
+
1401
+ // The REAL policy module, not the stub makeDeps hands every other case: these cases are
1402
+ // about which rule the runner applies, so a stub that admits everything would prove nothing.
1403
+ const realChannel = require('../scripts/gds/update-channel.js');
1404
+ const withRealChannel = (over) => {
1405
+ const d = makeDeps(over);
1406
+ d.updateChannel = { ...realChannel, listAvailableVersions: () => ({ ok: true, versions: over.available }) };
1407
+ return d;
1408
+ };
1409
+
1410
+ await t('instanceChannel reads the row, and a row from before the column falls back to patch', () => {
1411
+ assert.equal(instanceChannel({ update_channel: 'minor' }, realChannel), 'minor');
1412
+ assert.equal(instanceChannel({ update_channel: 'pinned' }, realChannel), 'pinned');
1413
+ assert.equal(instanceChannel({}, realChannel), 'patch', 'no column yet → the old default');
1414
+ assert.equal(instanceChannel({ update_channel: 'bogus' }, realChannel), 'patch', 'unknown → the default, never a wider rule');
1415
+ });
1416
+
1417
+ await t('a project on PATCH still refuses a step-up release (1.19 → 1.20)', async () => {
1418
+ const d = withRealChannel({ installed: '1.19.1081', served: '1.19.1081', available: ['1.19.1081', '1.20.1'] });
1419
+ const r = await R.coreUpgradeInstance(inst({ update_channel: 'patch' }), d, intent({ target_version: '1.20.1' }));
1420
+ assert.equal(r.ok, false);
1421
+ assert.match(r.error, /outside this project's 'patch' update channel/);
1422
+ assert.ok(!d.ran.some((c) => /upgrade\.js/.test(c)), 'and nothing was run');
1423
+ });
1424
+
1425
+ await t('a project on MINOR takes that same step-up release', async () => {
1426
+ const d = withRealChannel({ installed: '1.19.1081', served: '1.20.1', available: ['1.19.1081', '1.20.1'] });
1427
+ const r = await R.coreUpgradeInstance(inst({ update_channel: 'minor' }), d, intent({ target_version: '1.20.1' }));
1428
+ assert.ok(!(r.error && /update channel/.test(r.error)), `the channel must not refuse it: ${r.error}`);
1429
+ assert.ok(d.ran.some((c) => /upgrade\.js/.test(c) && /--to 1\.20\.1/.test(c)), 'the upgrade was invoked for 1.20.1');
1430
+ });
1431
+
1432
+ await t('MINOR still refuses a MAJOR jump — no rule makes 2.0 automatic', async () => {
1433
+ const d = withRealChannel({ installed: '1.19.1081', served: '1.19.1081', available: ['1.19.1081', '2.0.0'] });
1434
+ const r = await R.coreUpgradeInstance(inst({ update_channel: 'minor' }), d, intent({ target_version: '2.0.0' }));
1435
+ assert.equal(r.ok, false);
1436
+ assert.match(r.error, /outside this project's 'minor' update channel/);
1437
+ });
1438
+
1439
+ await t('the snapshot OFFERS what the project\'s rule admits — the button and the runner agree', async () => {
1440
+ const available = ['1.19.1081', '1.19.1082', '1.20.1', '1.20.11'];
1441
+ const offered = async (update_channel) => {
1442
+ const queries = [];
1443
+ await R.snapshotCoreVersions(inst({ update_channel }), withRealChannel({ served: '1.19.1081', available, queries }));
1444
+ return queries.find((q) => /provisioning_core_version_snapshot/.test(q.sql)).params[3];
1445
+ };
1446
+ assert.equal(await offered('patch'), '1.19.1082', 'patch offers the newest fix on the same line');
1447
+ assert.equal(await offered('minor'), '1.20.11', 'minor offers the step up');
1448
+ assert.equal(await offered('pinned'), null, 'pinned offers nothing');
1449
+ assert.equal(await offered(undefined), '1.19.1082', 'a pre-column row behaves exactly as before');
1450
+ });
1451
+
1399
1452
  console.log(`\ncore_upgrade_runner: ${passed} passed, ${failed} failed`);
1400
1453
  if (failed) process.exit(1);