@bongos/core 1.19.509 → 1.19.510

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.509",
6
- "core_contract": "1.19.509",
7
- "source_commit": "9605bf28928f63695da44f2f5033d52569701f0c",
5
+ "core_version": "1.19.510",
6
+ "core_contract": "1.19.510",
7
+ "source_commit": "bf4b829a050e1e59d80f4669559d33aff1ca021e",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-03T18:27:19.784Z",
9
+ "built_at": "2026-09-03T18:34:17.437Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 427,
12
+ "docs_redacted": 428,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 1949,
14
+ "functional_verbatim": 1951,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2400,
20
- "tree_sha256": "8caa606e95cdf39a34d1b4f654b3b415dc9e699d7c1a058a9281097e297ae916",
19
+ "file_count": 2403,
20
+ "tree_sha256": "50673e10faee37607acac515c28140b876d8838c656e49115440b31f3d2ae42a",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -362,12 +362,12 @@
362
362
  {
363
363
  "path": "clients/bongos-client/README.md",
364
364
  "mode": "0000644",
365
- "sha256": "7904ca2bfec8710c90bd846edb4c0b50436eebc29fcb87897eea69fea09d1001"
365
+ "sha256": "146ce9d114ec7d5a90413713b8604843c42eac060dd943648412868e214a1a6e"
366
366
  },
367
367
  {
368
368
  "path": "clients/bongos-client/bongos-client.global.js",
369
369
  "mode": "0000644",
370
- "sha256": "4f6b441e36573c71d391a24b76247df79db02a46bf719965bee22051a90c916f"
370
+ "sha256": "0e3fb901fd7110352e0edb686cfd8824d71687b88bd7e42269d0fe855177ac9f"
371
371
  },
372
372
  {
373
373
  "path": "clients/bongos-client/examples/hello-world.mjs",
@@ -377,17 +377,17 @@
377
377
  {
378
378
  "path": "clients/bongos-client/index.cjs",
379
379
  "mode": "0000644",
380
- "sha256": "193252b5fb3be984786b23507a400564dfd6ad1899b0778b436882b8b28de39e"
380
+ "sha256": "19f11ecf8c9eda49cd1f2ac2425f1d3d9772ad46495305d9ae4bdeadf4695ea2"
381
381
  },
382
382
  {
383
383
  "path": "clients/bongos-client/index.d.ts",
384
384
  "mode": "0000644",
385
- "sha256": "2507501215b31032378a856f4a26791921b3e618baedaaa1d6934df458d3023b"
385
+ "sha256": "4ced11579608e8fdb481a07b5555c41b3cbf3018cb3a8bd7697fb58368283bf0"
386
386
  },
387
387
  {
388
388
  "path": "clients/bongos-client/index.mjs",
389
389
  "mode": "0000644",
390
- "sha256": "1bf4942a3074fa90887b3efdcff39bde587abc6d8b70b4b83a2dea149beaa85a"
390
+ "sha256": "3d5868d134bce56923b8f0270fbb2e988dea5b6a5ea1a00b96ff8bcde06bb40e"
391
391
  },
392
392
  {
393
393
  "path": "clients/bongos-client/package.json",
@@ -1689,20 +1689,25 @@
1689
1689
  "mode": "0000644",
1690
1690
  "sha256": "87b350049a0ed1b7be0c58fc2ae6ecd18a077b21ca689563a8763f925a37eef6"
1691
1691
  },
1692
+ {
1693
+ "path": "docs/adr/0237-a-starter-bundle-is-a-preset-over-the-always-on-core-keyed-by-declared-type.md",
1694
+ "mode": "0000644",
1695
+ "sha256": "10534e533c2c70baf670048fb38e18ddedb36925312806afbae9941a9c958667"
1696
+ },
1692
1697
  {
1693
1698
  "path": "docs/adr/README.md",
1694
1699
  "mode": "0000644",
1695
- "sha256": "16d284a7a9b1b70f1ba2aa3550a380ed76ace41b985c5f6a309c9899acf6b5f8"
1700
+ "sha256": "e6b0e3bc8becec5ed9c7384596e205cc5a78e6145da244c8d6d2c1d5a4514380"
1696
1701
  },
1697
1702
  {
1698
1703
  "path": "docs/api-reference.md",
1699
1704
  "mode": "0000644",
1700
- "sha256": "91a31987ff13dd0757ec86db23b6cf86ea4cb95833d702ac7070b75ed4354b49"
1705
+ "sha256": "f9a893262b5112e48a8bffa455c8e1a7ce8aed663e34141ef9c48117882a2ad7"
1701
1706
  },
1702
1707
  {
1703
1708
  "path": "docs/api/openapi.json",
1704
1709
  "mode": "0000644",
1705
- "sha256": "42d2388b2d327331b2e78f29dfdbf6db517c9625238a93606a050de74d03ecec"
1710
+ "sha256": "ec2d400cae26a7b89f644e978840927fd0fb88fc0b7b94e9f0d00dd19955aaf2"
1706
1711
  },
1707
1712
  {
1708
1713
  "path": "docs/architecture.md",
@@ -2577,7 +2582,7 @@
2577
2582
  {
2578
2583
  "path": "docs/module-api-changelog.md",
2579
2584
  "mode": "0000644",
2580
- "sha256": "52cb03d41b0eb38da33a436b0ca2912d2b087b33ab3675aca404a77767fdeb34"
2585
+ "sha256": "8a8c8b3fa575b33ef7221af26b78a3cbe649f3cbf6b23e398da4d44e2a826096"
2581
2586
  },
2582
2587
  {
2583
2588
  "path": "docs/modules-contract.md",
@@ -3947,7 +3952,7 @@
3947
3952
  {
3948
3953
  "path": "modules/dev-box/app/src/vendor/bongos-client.cjs",
3949
3954
  "mode": "0000644",
3950
- "sha256": "193252b5fb3be984786b23507a400564dfd6ad1899b0778b436882b8b28de39e"
3955
+ "sha256": "19f11ecf8c9eda49cd1f2ac2425f1d3d9772ad46495305d9ae4bdeadf4695ea2"
3951
3956
  },
3952
3957
  {
3953
3958
  "path": "modules/dev-box/app/test/unit.mjs",
@@ -5977,7 +5982,12 @@
5977
5982
  {
5978
5983
  "path": "modules/provisioning/routes/provisioning.js",
5979
5984
  "mode": "0000644",
5980
- "sha256": "aafd317ed353c3aa6b28fc6432d13758f3305fb7593aaaa8271555b658245a01"
5985
+ "sha256": "338e135f492e584aa4539d4a64d7c881b964c9ffa8428ff3359ad96d80b82a5c"
5986
+ },
5987
+ {
5988
+ "path": "modules/provisioning/starter-bundles.js",
5989
+ "mode": "0000644",
5990
+ "sha256": "760382a3e361692f9d8457f88122fda498eb6bccecd37b351837fa5c4fa5e6f4"
5981
5991
  },
5982
5992
  {
5983
5993
  "path": "modules/provisioning/tests/liveness.mjs",
@@ -7232,12 +7242,12 @@
7232
7242
  {
7233
7243
  "path": "package-lock.json",
7234
7244
  "mode": "0000644",
7235
- "sha256": "da37c28e1647b70c11a589446419029d7dd7f05218cf88b0f357599aa1903836"
7245
+ "sha256": "473153ee377e541a11f94f2b68604221d7cc56b75ce228745a71c094a6db32be"
7236
7246
  },
7237
7247
  {
7238
7248
  "path": "package.json",
7239
7249
  "mode": "0000644",
7240
- "sha256": "d468e341086d86826eccaa2a062e84c3199ae2e7169bb1e97dfa46a7e00e4589"
7250
+ "sha256": "24b60ab686b6e4edfefbbaecd284c9916d5090f8754a6020cba487a66e9c1441"
7241
7251
  },
7242
7252
  {
7243
7253
  "path": "public-docs/index.html",
@@ -8907,7 +8917,7 @@
8907
8917
  {
8908
8918
  "path": "src/module-api.js",
8909
8919
  "mode": "0000644",
8910
- "sha256": "fc08dfd1d74b0e89c903f8365f1e969286dbe2af19b75cf0a75f47e54d97fd0a"
8920
+ "sha256": "27eb92e570f6572c875f3c0ab7cffee29fca5c49395167fe4ed7a1d8e466a32e"
8911
8921
  },
8912
8922
  {
8913
8923
  "path": "src/module-loader/catalog.js",
@@ -11179,6 +11189,11 @@
11179
11189
  "mode": "0000644",
11180
11190
  "sha256": "1623d2dff20d7b5843f9fd39dd91c35e4a8ebe10b60cf798e01a7fbc4b0202cf"
11181
11191
  },
11192
+ {
11193
+ "path": "tests/provisioning_starter_bundles.mjs",
11194
+ "mode": "0000644",
11195
+ "sha256": "0d3dadff4425e8d014cca29c66c31ff094e78ffbf2fd246f56ba11c5e1cefc93"
11196
+ },
11182
11197
  {
11183
11198
  "path": "tests/provisioning_teardown_intent.mjs",
11184
11199
  "mode": "0000644",
@@ -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
- - 345 operations across 53 resource groups
8
+ - 346 operations across 53 resource groups
9
9
 
10
10
  ## Install
11
11
 
@@ -671,6 +671,8 @@ function createClient(opts = {}) {
671
671
  getProvisioningOnboardPlan: (args) => request("GET", "/provisioning/onboard-plan", { hasBody: false }, args),
672
672
  // GET /provisioning/slug-available — rank: any-builder — GET /provisioning/slug-available
673
673
  getProvisioningSlugAvailable: (args) => request("GET", "/provisioning/slug-available", { hasBody: false }, args),
674
+ // GET /provisioning/starter-bundles — rank: public — GET /provisioning/starter-bundles
675
+ getProvisioningStarterBundles: (args) => request("GET", "/provisioning/starter-bundles", { hasBody: false }, args),
674
676
  // GET /provisioning/tls-check — rank: public — GET /provisioning/tls-check
675
677
  getProvisioningTlsCheck: (args) => request("GET", "/provisioning/tls-check", { hasBody: false }, args),
676
678
  },
@@ -670,6 +670,8 @@ function createClient(opts = {}) {
670
670
  getProvisioningOnboardPlan: (args) => request("GET", "/provisioning/onboard-plan", { hasBody: false }, args),
671
671
  // GET /provisioning/slug-available — rank: any-builder — GET /provisioning/slug-available
672
672
  getProvisioningSlugAvailable: (args) => request("GET", "/provisioning/slug-available", { hasBody: false }, args),
673
+ // GET /provisioning/starter-bundles — rank: public — GET /provisioning/starter-bundles
674
+ getProvisioningStarterBundles: (args) => request("GET", "/provisioning/starter-bundles", { hasBody: false }, args),
673
675
  // GET /provisioning/tls-check — rank: public — GET /provisioning/tls-check
674
676
  getProvisioningTlsCheck: (args) => request("GET", "/provisioning/tls-check", { hasBody: false }, args),
675
677
  },
@@ -98,6 +98,7 @@ export interface GetProvisioningInstancesIdSettingsResponse { ok: boolean; setti
98
98
  export interface GetProvisioningInstancesResponse { instances: unknown }
99
99
  export interface GetProvisioningOnboardPlanResponse { mode: unknown; steps: unknown }
100
100
  export interface GetProvisioningSlugAvailableResponse { slug: unknown; available: unknown; reason: unknown; address: unknown }
101
+ export interface GetProvisioningStarterBundlesResponse { core: unknown; bundles: unknown }
101
102
  export interface GetPublicBlockersIdResponse { blocker: unknown }
102
103
  export interface GetPublicBountyTableResponse { table: unknown; currency: unknown }
103
104
  export interface GetPublicGradesResponse { window_days: unknown; rolling: unknown; trend: unknown; attempts: unknown; threshold: unknown; grader_version: unknown }
@@ -981,6 +982,8 @@ export interface BongosClient {
981
982
  getProvisioningOnboardPlan(args?: RequestArgs): Promise<GetProvisioningOnboardPlanResponse>;
982
983
  /** GET /provisioning/slug-available — rank: any-builder */
983
984
  getProvisioningSlugAvailable(args?: RequestArgs): Promise<GetProvisioningSlugAvailableResponse>;
985
+ /** GET /provisioning/starter-bundles — rank: public */
986
+ getProvisioningStarterBundles(args?: RequestArgs): Promise<GetProvisioningStarterBundlesResponse>;
984
987
  /** GET /provisioning/tls-check — rank: public */
985
988
  getProvisioningTlsCheck(args?: RequestArgs): Promise<ApiResponse>;
986
989
  };
@@ -667,6 +667,8 @@ export function createClient(opts = {}) {
667
667
  getProvisioningOnboardPlan: (args) => request("GET", "/provisioning/onboard-plan", { hasBody: false }, args),
668
668
  // GET /provisioning/slug-available — rank: any-builder — GET /provisioning/slug-available
669
669
  getProvisioningSlugAvailable: (args) => request("GET", "/provisioning/slug-available", { hasBody: false }, args),
670
+ // GET /provisioning/starter-bundles — rank: public — GET /provisioning/starter-bundles
671
+ getProvisioningStarterBundles: (args) => request("GET", "/provisioning/starter-bundles", { hasBody: false }, args),
670
672
  // GET /provisioning/tls-check — rank: public — GET /provisioning/tls-check
671
673
  getProvisioningTlsCheck: (args) => request("GET", "/provisioning/tls-check", { hasBody: false }, args),
672
674
  },
@@ -0,0 +1,137 @@
1
+ # 0237 — A starter bundle is a preset over the always-on core, keyed by the declared project type
2
+
3
+ - **Status:** Accepted
4
+ - **Date:** 2026-09-03
5
+ - **Tasks:** [#1002338](https://cloudbongos.com/builders#/task/1002338) (this decision — BV1.R19)
6
+ - **Goal:** [#1000046](https://cloudbongos.com/builders#/goal/1000046) — Project creation: visibility and
7
+ customization, criterion `proj-module-select` ("during setup a default set of modules is pre-selected…")
8
+ - **Related:** [ADR 0083](<redacted>.md) (the one-way import rule that
9
+ keeps this file pure), [ADR 0107](<redacted>.md) +
10
+ [ADR 0167](<redacted>.md) (the module catalog a bundle names into),
11
+ [ADR 0182](<redacted>.md) D4 (the publish bar a bundle may not join),
12
+ task [#1002772](https://cloudbongos.com/builders#/task/1002772) (the declared project type),
13
+ task [#1002334](https://cloudbongos.com/builders#/task/1002334) (R15, the detail interview)
14
+
15
+ ## 1. The problem
16
+
17
+ Criterion `proj-module-select` opens with "during setup a default set of modules is
18
+ pre-selected." Every Cloud Bongos instance already boots with the same always-on
19
+ core — the sixteen `default: true` modules a project cannot sensibly run without —
20
+ so "pre-selected" can only mean the **opt-in** modules layered on top. An owner
21
+ creating their first project has met none of them, and asking them to read a
22
+ twenty-one-row catalog before they have a repo is the version of customization
23
+ that gets skipped.
24
+
25
+ R19 is the data that answers it. R20 ([#1002339](https://cloudbongos.com/builders#/task/1002339))
26
+ renders the picker, R21 searches the catalog, R17/R22 recommend from the interview
27
+ answers, R23 adds modules later — four consumers, one table underneath.
28
+
29
+ ## 2. Keyed by the DECLARED type, not by anything inferred
30
+
31
+ The task text (seeded 2026-07-17) names three bundles: *Game / SaaS-Tool /
32
+ Community*. That vocabulary was superseded four weeks later. Task
33
+ [#1002772](https://cloudbongos.com/builders#/task/1002772) settled the closed
34
+ project-type set by owner decision on 2026-08-13 — **`game` · `research` ·
35
+ `business` · `non-profit` · `not-sure`** — and it is the live one: a CHECK on
36
+ `platform_identity_projects.type`, a matching CHECK on
37
+ `provisioning_instances.type`, `PROJECT_TYPES` in both modules, and five buttons in
38
+ the create wizard. "Community" survives, but as a **team shape**
39
+ (`PROJECT_DETAIL_VOCAB.team_shape ∈ solo | small-team | community`), not a project
40
+ type. Bundles key off the shipped vocabulary; the seeded triple is recorded here as
41
+ history so the divergence is not read later as an omission.
42
+
43
+ Two constraints come with that key and neither is negotiable:
44
+
45
+ - **The arrow runs type → modules, never back.** Task 1002772 explicitly rejected
46
+ deriving a project's type from its enabled module set, because flags flip all
47
+ through setup and a half-configured project would show a confidently wrong type.
48
+ - **The interview does not ask a second time.** R15 wrote that into its own
49
+ vocabulary comment: "the module bundle R19 recommends comes off the
50
+ separately-declared `type`, so the interview deliberately does not ask it a
51
+ second time." So R19 adds no key to `PROJECT_DETAIL_VOCAB` and no migration —
52
+ the `detail` jsonb column stays reserved for answers an owner actually gave.
53
+
54
+ `not-sure` and *undeclared* resolve to the same modules (the core, nothing else)
55
+ and are deliberately **not** the same answer: `type` is nullable because every row
56
+ predates the field and must never be back-filled with a guess, so `bundleForProjectType(null)`
57
+ returns `type: null` with its own copy. A picker can tell "the owner said not sure"
58
+ from "nobody asked yet"; conflating them would be the same invention the nullable
59
+ column exists to prevent.
60
+
61
+ ## 3. A declaration pinned by a test, not a read of the live catalog
62
+
63
+ The natural implementation is to resolve bundles against
64
+ `src/module-loader/catalog.js` — the one model the hall's Modules tab and
65
+ `bongos module list` both build from, so they cannot drift (ADR 0167). A module may
66
+ not import it: `src/module-api.js` is the only core file a module may require (ADR
67
+ 0083's one-way rule) and the doorway exposes no catalog.
68
+
69
+ So `modules/provisioning/starter-bundles.js` **declares** its module keys and stays
70
+ pure — no I/O, no core import, no sibling import — exactly like `onboard-plan.js`
71
+ beside it. The link back to the catalog is a test, not a runtime read:
72
+ `tests/provisioning_starter_bundles.mjs` reads every `modules/*/module.json` off
73
+ disk and fails the build if a bundle names a module that does not exist, if
74
+ `ALWAYS_ON_CORE` drifts from the manifests' `default: true` set, if a bundle names
75
+ an always-on module (a preselect that toggles nothing but reads to the owner as a
76
+ choice), or if it names one of the two hub-only modules. A test is the right
77
+ enforcement point here because the failure is a *build-time* fact — the manifests
78
+ are in the same commit as the declaration — and buying it at runtime would cost the
79
+ purity that lets a script, a route and a test all read the same answer.
80
+
81
+ ## 4. What the bundles are, and why they are short
82
+
83
+ | type | bundle (opt-in additions) |
84
+ |---|---|
85
+ | `game` | `dev-box`, `discord` |
86
+ | `research` | `agents` |
87
+ | `business` | `agents`, `dev-box` |
88
+ | `non-profit` | `discord` |
89
+ | `not-sure` | — |
90
+
91
+ Today's opt-in catalog is genuinely three modules wide (`agents`, `dev-box`,
92
+ `discord`; `platform-identity` and `provisioning` are the control plane's own and
93
+ are refused by construction). That is why this is a **declaration and not a rule
94
+ engine** — a bundle grows as the catalog does, and R17/R22 own the layer that
95
+ reasons. A preselect an owner has to audit is worse than none.
96
+
97
+ `dev-box` is preselected on two types despite costing money to *use*: enabling the
98
+ module opens the door, it provisions nothing and bills nothing until a builder asks
99
+ for a box, and the picker toggles it off in one click.
100
+
101
+ ## 5. The read surface
102
+
103
+ `GET /provisioning/starter-bundles` returns `{ core, bundles }` — the always-on
104
+ floor once, then every type resolved as
105
+ `{ type, label, summary, core, bundle, modules }`. The whole table in one read,
106
+ because the picker re-preselects as the owner flips the type question and must not
107
+ refetch to do it, and because R17's recommendations and R20's preselect reading the
108
+ same shape is what stops them naming different modules.
109
+
110
+ `rank: public`, the same (absent) gate as `GET /provisioning/onboard-plan` — the
111
+ signed-out wizard reads it while choosing a type, it writes nothing, and the module
112
+ catalog it names into is already public at `GET /public/modules`.
113
+
114
+ ## 6. Consequences
115
+
116
+ - **A bundle is never a publish precondition.** [ADR 0182](<redacted>.md)
117
+ D4 fixes the bar at identity alone — a name and a description, "not tagline, art,
118
+ modules, governance, or a completed interview." Taking or declining a bundle
119
+ cannot move a project's dark-matter state.
120
+ - **Nothing is stored yet.** R19 defines and serves; where a chosen selection lands
121
+ is R20's decision, and there is still no module-selection column on any table.
122
+ - **Adding a module to the catalog is now a two-file change** if it belongs in a
123
+ bundle — the manifest and this declaration — and the integrity test says so out
124
+ loud when a manifest flips its `default`.
125
+
126
+ ## 7. Rejected
127
+
128
+ - **Resolving against the live catalog at runtime** — costs the purity and breaks
129
+ the one-way rule for a fact that is fixed at build time.
130
+ - **A `bundle` key in `PROJECT_DETAIL_VOCAB`** — R15 already ruled the interview
131
+ must not re-ask what `type` declares.
132
+ - **Inferring the bundle from the free-text description** — that is the deferred
133
+ AI/semantic layer ([#1003500](https://cloudbongos.com/builders#/task/1003500)), and
134
+ the goal's own text pins the near term as rule-based.
135
+ - **Naming the seeded Game / SaaS-Tool / Community triple** — three of those five
136
+ live types would then have no bundle, and two of the three names match nothing an
137
+ owner can pick.
@@ -324,5 +324,6 @@ This keeps the decision history honest and traceable.
324
324
  | 0234 | [**Idea routing: capture-time promotion, the landing matrix, and the homeless-only inbox** ([task 1003064](https://cloudbongos.com/builders#/task/1003064) · goal 1000071, "Idea routing"). Idea 1000771 named the failure: Discord `#bugs` created tasks DIRECTLY against a hardcoded, already-shipped version — no `captured_by`, no lineage, so the ideator-credit lane had nothing to pay against — while every other open idea sat waiting for a human triage pass that dropped its promotion into a version catch-all, because nothing in capture ever named a goal. Records what R01/R02/R05/R07/R13 shipped. `captureIdea` (`modules/ideas/inbox.js`) stays the ONE capture chokepoint — routing is one optional `goalId` argument, never a second creation door — and when the matrix routes, `routeIdeaToGoal` writes the idea row, the task (via the `lifecycle.createTask` port) and an `ideas_task_lineage` row (`origin='route'`) in ONE transaction, so a route can never mint attribution-free work the way `#bugs` did. `modules/ideas/routing.js`'s `decideLanding` is PURE and fail-closed: a bug from a Metic+ filer hard-lands `ready`, a sub-Metic bug or any other kind with a goal soft-lands `backlog`, a Full Idea or `criterion-proposal`/`decision` never routes, no goal named stays the pre-routing inbox path. `authorizeRoutedLanding` is the BINDING gate underneath the matrix's decision — a goal reaching protected scope raises the task's rank floor to Metic and REFUSES a sub-Metic filer outright, while membership is deliberately NOT required (reporting isn't authoring). `core_222`'s `suggested_goal_id` is an advisory hint only — the real home is `tasks.goal_id`, set by construction — and `/idea-triage` (`scripts/gds/triage.js`) now sends `goal_id` on promotion, so the inbox is homeless-only. Divergence recorded: R11 (Discord `#bugs`) hard-lands a linked Metic+ builder's bug immediately through the SAME shared matrix rather than the spec's "always soft-land, pending queue" design, because a local copy of the rank split would only drift from the one definition; and R12 (Discord `#ideas` hints) is NOT YET SHIPPED, so `#ideas` carries no goal hint today despite the interview decision that it should, hint-only, never route.](<redacted>.md) | ideas / lifecycle |
325
325
  | 0235 | [**A fix category is `kind='bug'` under a goal, not a new column** ([task 1003215](https://cloudbongos.com/builders#/task/1003215) · goal 1000071, "Idea routing" — the structural half of idea 1000828; task 1003214 shipped the advisory's first quiet case, task 1003157 extended it). A fix for something that should already work needs a home under an existing goal WITHOUT a new goal being born and WITHOUT a stored column restating what `tasks.kind` already says. Not a global "Bugs" goal either: a goal spanning every module makes the [ADR 0086](<redacted>.md) scope wall either everything or nothing, and a goal that can never reach `achieved` (a bug container never finishes) pollutes the exact signal [ADR 0183](<redacted>.md)'s auto-satisfy depends on. Not [ADR 0192 "a category orients and authorises nothing"](<redacted>.md) either, despite the structural resemblance: that decision forbids any gate from branching on a goal's category, while THIS decision is two decision surfaces deliberately branching on `kind='bug'` — `adviseGoal()` (`modules/lifecycle/goal-advisory.js`) goes silent for a bug under all three of its reasons (`no_goal`, `catch_all`, `no_criterion_link` — "a fix is the goal's 'fix category', not a delivery against one of its stated outcomes," its own doc comment) and `countTasksWithoutCriterionLink()` (`modules/lifecycle/db-deps-criteria.js`, surfaced on the hall's goal detail view) excludes `kind='bug'` from its count for the same reason. No hall/status change was needed for GROUPING bugs under a goal: every task card already renders its `kind` as a visible tag and the goal detail view's Filters already offer a `Kind` lens with `bug` as a value — verified against the live code, not built new. Rejected: a `tasks.category`/`fix_category` column or enum, a global cross-module "Bugs" goal, a bug-specific grouping widget beside the general-purpose kind facility that already works.](<redacted>.md) | ideas / lifecycle |
326
326
  | 0236 | [**Many ideas → one task: the lineage key is the pair** ([task 1003515](https://cloudbongos.com/builders#/task/1003515) · goal 1000071, "Idea routing" — promoted from idea 1000815; the table was built by [task 1002997](https://cloudbongos.com/builders#/task/1002997)). `ideas_task_lineage.task_id` was the PRIMARY KEY on the reasoning that "a task descends from at most one idea", and `linkIdeaTask` wrote `ON CONFLICT (task_id) DO UPDATE SET idea_id = EXCLUDED.idea_id` — so promoting a SECOND idea into a task that already had a link **silently replaced the first idea's lineage**: no error, no warning, no log line. Two ordinary triage promotes at one task were enough, and it happened on prod on 2026-08-21 (ideas 1000811 + 1000812 → task 1003150). Because this table is the rail [ADR 0172](<redacted>.md) stream 1 pays against, an overwritten row is an **ideator not paid** for work their idea produced, with nothing recording it — and it makes merging duplicate reports into one task a LOSSY move, which is the triage the inbox exists to encourage. The R01 grader named this shape and was told it was unreachable; it was unreachable on the ROUTING path examined and fully reachable on the ordinary PROMOTE path. **Decision: many ideas → one task is a real relation, so the key is the pair** — `ideas_006` re-adds `ideas_task_lineage_pkey` as `PRIMARY KEY (task_id, idea_id)` and both INSERT sites `DO UPDATE SET origin`, never `idea_id`. Refusing the second link would turn a legitimate merge into an error; logging the replacement keeps a lossy ledger. **Where one row is still required the EARLIEST link wins** (`ORDER BY created_at, idea_id`, stated in SQL rather than left to the planner): `sourceIdeaForTask`, `credits.awardIdeaTaskShipCredit`, and the nod queue's one-row-per-task predicate — one constant shared by the list and its count, so the pill can never disagree with the rows. Consequences: a task subsumed from N ideas **counts once toward each idea's** shipped-task credit (and cannot double-count within one — the pair is unique); the per-ship payout still names one idea, the earliest, because paying every co-source ideator is a decision about total worth, not a bug fix; the migration **backfills** from `idea_inbox.promoted_to_task_id`, the weaker pointer that survived each overwrite, labelling restored rows `origin='promote'`; and `updateIdea` still swallows a lineage failure but now reports it as a `LINEAGE GAP` through `api.logger` — a silent swallow on top of a silent overwrite was two layers of one mistake.](<redacted>.md) | ideas / economy |
327
+ | 0237 | [**A starter bundle is a preset over the always-on core, keyed by the declared project type** ([task 1002338](https://cloudbongos.com/builders#/task/1002338) · goal 1000046, criterion `proj-module-select`). "During setup a default set of modules is pre-selected" can only mean the OPT-IN modules, since every instance already boots with the same sixteen `default: true` core-domain ones — so a bundle is a short list of opt-in module keys layered on that floor, preselected and freely toggled off by R20's picker ([task 1002339](https://cloudbongos.com/builders#/task/1002339)). **The key is the DECLARED type, and the seeded vocabulary is stale:** the task text (2026-07-17) names *Game / SaaS-Tool / Community*, but task [1002772](https://cloudbongos.com/builders#/task/1002772)'s owner decision (2026-08-13) fixed the live closed set as `game · research · business · non-profit · not-sure` — two CHECK constraints, two `PROJECT_TYPES` constants and five wizard buttons — and "community" survives only as a `team_shape`. Two constraints ride the key: the arrow runs **type → modules, never back** (1002772 rejected inferring type from module flags, which flip all through setup), and the R15 interview must **not re-ask** what `type` declares, so R19 adds no `PROJECT_DETAIL_VOCAB` key and no migration. `not-sure` and *undeclared* resolve to the same modules but are deliberately different answers — `type` is nullable precisely so "nobody said" is never back-filled with a guess, and the resolver returns `type: null` with its own copy. **The declaration is pinned by a test, not a runtime catalog read:** a module may not import `src/module-loader/catalog.js` (the one-way rule, [ADR 0083](<redacted>.md)), so `modules/provisioning/starter-bundles.js` stays PURE like `onboard-plan.js` beside it and `tests/provisioning_starter_bundles.mjs` reads every `modules/*/module.json` off disk — failing the build if a bundle names a module that does not exist, if `ALWAYS_ON_CORE` drifts from the manifests' `default: true` set, if a bundle names an always-on module (a preselect that toggles nothing), or if it names one of the two hub-only modules. The bundles are `game → dev-box, discord`; `research → agents`; `business → agents, dev-box`; `non-profit → discord`; `not-sure → —`, and they are SHORT because today's opt-in catalog is three modules wide and a preselect an owner must audit is worse than none. Served whole at public `GET /provisioning/starter-bundles` (`{ core, bundles }`, same absent gate as `onboard-plan`) so the picker re-preselects without a refetch and R17's recommendations read the same shape. A bundle is **never a publish precondition** — [ADR 0182](<redacted>.md) D4 fixes that bar at name + description alone. Rejected: a runtime catalog read, a `bundle` key in the interview vocabulary, inferring the bundle from the description (the deferred AI layer, [task 1003500](https://cloudbongos.com/builders#/task/1003500)), and keeping the seeded Game/SaaS-Tool/Community triple.](<redacted>.md) | provisioning / project creation |
327
328
 
328
329
  > ⚠️ **Numbering collisions are now machine-enforced, not narrated.** Twenty numbers are shared by two ADRs each — assigned in parallel sessions before anything checked. The files keep their filenames (renumbering would break every existing citation, and a number once assigned is never reused), and this table disambiguates each pair as `NNNN-a` / `NNNN-b`. The authoritative list is `LEGACY_DUPLICATE_ADRS` in [`scripts/gds/adr-namespace.js`](../../scripts/gds/adr-namespace.js), frozen by exact filename: a **new** collision — or a third file joining a legacy number, or a rename of either half — hard-fails the `unit` CI gate, as does an index row that points at no file or an ADR with no row. The list may only shrink. This paragraph used to narrate the collisions one by one and had fallen eight behind reality; see [ADR 0195](<redacted>.md) for why the check exists and why the pairs are grandfathered rather than renumbered.
@@ -11728,6 +11728,34 @@
11728
11728
  ]
11729
11729
  }
11730
11730
  },
11731
+ "/provisioning/starter-bundles": {
11732
+ "get": {
11733
+ "operationId": "get_provisioning_starter_bundles",
11734
+ "tags": [
11735
+ "provisioning"
11736
+ ],
11737
+ "summary": "GET /provisioning/starter-bundles",
11738
+ "description": "GET /provisioning/starter-bundles — the curated module preset for each declared project type (BV1.R19, task 1002338; goal 1000046 criterion `proj-module-select`). Returns the always-on core once, then one resolved bundle per type: `{ type, label, summary, core, bundle, modules }`. The whole table in one read, because the creation picker (R20, task 1002339) re-preselects as the owner flips the type question and must not refetch to do it; R17's rule engine reads the same shape so a recommendation and a preselect can never name different modules. A DECLARATION, not a query — modules/provisioning/starter-bundles.js is pure, so this route touches no database and no per-request state. rank: public — the signed-out hub wizard (/projects?view=new) reads it while choosing a type, exactly like onboard-plan above; no write, no secret, and the module catalog it names is already public at GET /public/modules.\n\n**Rank:** `public` — No authentication — any caller.",
11739
+ "x-rank": "public",
11740
+ "x-source": "modules/provisioning/routes/provisioning.js",
11741
+ "responses": {
11742
+ "200": {
11743
+ "description": "Success.",
11744
+ "content": {
11745
+ "application/json": {
11746
+ "schema": {
11747
+ "$ref": "#/components/schemas/GetProvisioningStarterBundlesResponse"
11748
+ }
11749
+ }
11750
+ }
11751
+ },
11752
+ "400": {
11753
+ "$ref": "#/components/responses/BadRequest"
11754
+ }
11755
+ },
11756
+ "security": []
11757
+ }
11758
+ },
11731
11759
  "/provisioning/tls-check": {
11732
11760
  "get": {
11733
11761
  "operationId": "get_provisioning_tls_check",
@@ -16984,6 +17012,17 @@
16984
17012
  "address"
16985
17013
  ]
16986
17014
  },
17015
+ "GetProvisioningStarterBundlesResponse": {
17016
+ "type": "object",
17017
+ "properties": {
17018
+ "core": {},
17019
+ "bundles": {}
17020
+ },
17021
+ "required": [
17022
+ "core",
17023
+ "bundles"
17024
+ ]
17025
+ },
16987
17026
  "GetPublicBlockersIdResponse": {
16988
17027
  "type": "object",
16989
17028
  "properties": {
@@ -21758,9 +21797,9 @@
21758
21797
  "description": "A required dependency/feature is not configured or is temporarily down."
21759
21798
  }
21760
21799
  },
21761
- "x-endpoint-count": 345,
21762
- "x-schema-count": 377,
21800
+ "x-endpoint-count": 346,
21801
+ "x-schema-count": 378,
21763
21802
  "x-undocumented-bodies": 9,
21764
- "x-response-schemas": 257,
21803
+ "x-response-schemas": 258,
21765
21804
  "x-generated-by": "scripts/gds/gen-api-docs.js"
21766
21805
  }
@@ -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. 345 endpoints across 56 route files.
5
+ > **Generated from the live route files** — the route file is authoritative. 346 endpoints across 56 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/<redacted>.md)): `public` < `any-builder` < `metic+archon` < `archon`.
@@ -442,7 +442,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
442
442
  | GET | `/api/bongos/projects/featured` | `public` | — | a curated public showcase; no auth by design. |
443
443
  | POST | `/api/bongos/projects/invite` | `metic+archon` | `client_id`, `github_login` | invite a builder into a project (ADR 0141 §5, task #1002115). |
444
444
 
445
- ## `provisioning` (19)
445
+ ## `provisioning` (20)
446
446
 
447
447
  | Method | Path | Rank | Body | Description |
448
448
  |---|---|---|---|---|
@@ -464,6 +464,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
464
464
  | POST | `/api/bongos/provisioning/instances/:id/teardown` | `any-builder` | — | POST /provisioning/instances/:id/teardown — request teardown of the caller's OWN instance. |
465
465
  | 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… |
466
466
  | GET | `/api/bongos/provisioning/slug-available` | `any-builder` | — | GET /provisioning/slug-available?slug=<handle> — is this handle free to create under? rank: any authenticated builder (same gate as the c… |
467
+ | GET | `/api/bongos/provisioning/starter-bundles` | `public` | — | GET /provisioning/starter-bundles — the curated module preset for each declared project type (BV1.R19, task 1002338; goal 1000046 criteri… |
467
468
  | GET | `/api/bongos/provisioning/tls-check` | `public` | — | GET /provisioning/tls-check?domain=<host> — the Caddy on-demand-TLS ALLOW-LIST (ADR 0111 §4, task P4 task 1945). |
468
469
 
469
470
  ## `public` (18)
@@ -1455,5 +1455,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
1455
1455
  landed since 1.19.507 with no explicit bump. run 33790206682. (task 1002620)
1456
1456
  1.19.509 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1457
1457
  landed since 1.19.508 with no explicit bump. run 33790542026. (task 1002620)
1458
+ 1.19.510 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1459
+ landed since 1.19.509 with no explicit bump. run 33791237400. (task 1002620)
1458
1460
  ---------------------------------------------------------------------------
1459
1461
  ```
@@ -670,6 +670,8 @@ function createClient(opts = {}) {
670
670
  getProvisioningOnboardPlan: (args) => request("GET", "/provisioning/onboard-plan", { hasBody: false }, args),
671
671
  // GET /provisioning/slug-available — rank: any-builder — GET /provisioning/slug-available
672
672
  getProvisioningSlugAvailable: (args) => request("GET", "/provisioning/slug-available", { hasBody: false }, args),
673
+ // GET /provisioning/starter-bundles — rank: public — GET /provisioning/starter-bundles
674
+ getProvisioningStarterBundles: (args) => request("GET", "/provisioning/starter-bundles", { hasBody: false }, args),
673
675
  // GET /provisioning/tls-check — rank: public — GET /provisioning/tls-check
674
676
  getProvisioningTlsCheck: (args) => request("GET", "/provisioning/tls-check", { hasBody: false }, args),
675
677
  },
@@ -21,6 +21,8 @@
21
21
  // POST /provisioning/instances/:id/force-teardown — tear down ANY instance
22
22
  // public:
23
23
  // GET /provisioning/health — liveness + core version
24
+ // GET /provisioning/onboard-plan — the canonical onboarding step sequence
25
+ // GET /provisioning/starter-bundles — the curated module preset per project type
24
26
  //
25
27
  // Reaches core ONLY through the doorway src/module-api.js (the one-way rule, ADR
26
28
  // 0083); it requires this module's own provisioning.js (same-dir), never a sibling.
@@ -33,6 +35,7 @@ const auth = api;
33
35
  const { pool, validateOrRespond, parseId } = api;
34
36
  const provisioning = require('../provisioning');
35
37
  const { onboardPlan, stepsForShape } = require('../onboard-plan');
38
+ const { ALWAYS_ON_CORE, allStarterBundles } = require('../starter-bundles');
36
39
  const { makeAskRateLimiter, clientIp } = require('../rate-limit');
37
40
  // task 1003208: structured logging (pino via the doorway) — was console.*.
38
41
  const log = api.logger('provisioning');
@@ -1263,6 +1266,22 @@ module.exports = function provisioningRoutes() {
1263
1266
  res.json({ mode, steps: stepsForShape(onboardPlan(mode, { domain }), shape) });
1264
1267
  });
1265
1268
 
1269
+ // GET /provisioning/starter-bundles — the curated module preset for each declared
1270
+ // project type (BV1.R19, task 1002338; goal 1000046 criterion `proj-module-select`).
1271
+ // Returns the always-on core once, then one resolved bundle per type:
1272
+ // `{ type, label, summary, core, bundle, modules }`. The whole table in one read,
1273
+ // because the creation picker (R20, task 1002339) re-preselects as the owner flips
1274
+ // the type question and must not refetch to do it; R17's rule engine reads the same
1275
+ // shape so a recommendation and a preselect can never name different modules.
1276
+ // A DECLARATION, not a query — modules/provisioning/starter-bundles.js is pure, so
1277
+ // this route touches no database and no per-request state.
1278
+ // rank: public — the signed-out hub wizard (/projects?view=new) reads it while
1279
+ // choosing a type, exactly like onboard-plan above; no write, no secret, and the
1280
+ // module catalog it names is already public at GET /public/modules.
1281
+ router.get('/provisioning/starter-bundles', (req, res) => {
1282
+ res.json({ core: [...ALWAYS_ON_CORE], bundles: allStarterBundles() });
1283
+ });
1284
+
1266
1285
  // GET /provisioning/tls-check?domain=<host> — the Caddy on-demand-TLS ALLOW-LIST
1267
1286
  // (ADR 0111 §4, task P4 #1945). Caddy's global `on_demand_tls { ask … }` calls
1268
1287
  // this before fetching a Let's Encrypt cert for an SNI it doesn't already have:
@@ -0,0 +1,159 @@
1
+ // modules/provisioning/starter-bundles.js — the curated STARTER BUNDLE for each
2
+ // declared project type (BV1.R19, task 1002338; goal 1000046 criterion
3
+ // `proj-module-select`).
4
+ //
5
+ // WHY. Every Cloud Bongos instance boots with the same always-on core — the
6
+ // methodology modules a project cannot sensibly run without. What differs between
7
+ // a game, a research project and a business is the handful of OPT-IN modules on
8
+ // top, and an owner creating their first project has no way to know which of
9
+ // those they want before they have used any of them. A bundle is that opinion,
10
+ // written down once: a project type maps to a short list of opt-in module keys,
11
+ // PRESELECTED for the owner and freely toggled off in the creation picker (R20,
12
+ // task 1002339). It is the answer the flow STARTS from, never a decision it makes
13
+ // on the owner's behalf, and never a thing an owner is stuck with — modules are
14
+ // added and removed after creation too (R23, task 1002342).
15
+ //
16
+ // THE ARROW RUNS TYPE -> MODULES, NEVER BACK. Task 1002772 rejected inferring a
17
+ // project's type from its enabled module set: flags flip all through setup, so a
18
+ // half-configured project would show a confidently wrong type. The type is
19
+ // DECLARED at creation and everything downstream reads off that declaration. R15
20
+ // wrote the same rule into the interview's vocabulary — "the module bundle R19
21
+ // recommends comes off the separately-declared `type`, so the interview
22
+ // deliberately does not ask it a second time" (PROJECT_DETAIL_VOCAB in
23
+ // provisioning.js beside this file).
24
+ //
25
+ // A BUNDLE IS NOT A PUBLISH PRECONDITION. ADR 0182 D4 fixes the publish bar at
26
+ // identity alone — a name and a description, "not tagline, art, modules,
27
+ // governance, or a completed interview" — so taking or declining a bundle must
28
+ // never change whether a project is on the map.
29
+ //
30
+ // PURE — no I/O, no core imports, no sibling-module imports; safe to require from
31
+ // a route, a script or a test, exactly like onboard-plan.js beside it. That purity
32
+ // is why the module keys below are DECLARED here rather than read off the live
33
+ // catalog (src/module-loader/catalog.js, which a module may not import — the
34
+ // one-way rule, ADR 0083). tests/provisioning_starter_bundles.mjs is the pin that
35
+ // keeps the declaration true: it reads every modules/*/module.json off disk and
36
+ // fails the build if a bundle names a module that does not exist, if a bundle
37
+ // names an always-on module (a preselect that toggles nothing), or if
38
+ // ALWAYS_ON_CORE drifts from the manifests' `default: true` set.
39
+
40
+ 'use strict';
41
+
42
+ // The modules every instance gets whichever type it declares — the manifests'
43
+ // `default: true` set (docs/modules-contract.md: "core-domain modules that are
44
+ // `default: true` (always on unless disabled)"). They are the FLOOR a bundle
45
+ // layers on, not part of any bundle, and they are listed here so a creation form
46
+ // can show an owner what they already have without a second round trip.
47
+ const ALWAYS_ON_CORE = Object.freeze([
48
+ 'autonomy',
49
+ 'builder-settings',
50
+ 'copy-desk',
51
+ 'economy',
52
+ 'government',
53
+ 'grading',
54
+ 'hall-ui',
55
+ 'ideas',
56
+ 'lifecycle',
57
+ 'memory',
58
+ 'onboarding',
59
+ 'public-landing',
60
+ 'security',
61
+ 'sessions',
62
+ 'status-ui',
63
+ 'ui-design',
64
+ ]);
65
+
66
+ // The control plane's own modules. `platform-identity` is the hub identity
67
+ // provider ("enabled ONLY on the hub instance") and `provisioning` is the surface
68
+ // that stands other instances up — a project created THROUGH the hub is a
69
+ // consumer of both, never a second copy of them. Named so the integrity test can
70
+ // refuse them by construction rather than by anyone remembering.
71
+ const HUB_ONLY_MODULES = Object.freeze(['platform-identity', 'provisioning']);
72
+
73
+ // The curated presets, keyed by the declared project type (the closed vocabulary
74
+ // owner-decided 2026-08-13 and mirrored in provisioning.PROJECT_TYPES + the
75
+ // provisioning_007 CHECK). `label` and `summary` are the owner-facing copy the
76
+ // picker renders — plain language, no module jargon, because the owner choosing
77
+ // here has not met any of these modules yet.
78
+ //
79
+ // The lists are SHORT on purpose. A preselect an owner has to audit is worse than
80
+ // none, and today's opt-in catalog is genuinely small (`agents`, `dev-box`,
81
+ // `discord`); a bundle grows as the catalog does, which is exactly why this is a
82
+ // declaration and not a rule engine. Enabling `dev-box` opens the door to a cloud
83
+ // dev environment — it provisions nothing and bills nothing until a builder asks
84
+ // for a box — so preselecting it costs the owner nothing but a toggle.
85
+ const STARTER_BUNDLES = Object.freeze({
86
+ game: Object.freeze({
87
+ label: 'Game starter',
88
+ summary: 'Somewhere for players to gather, and a place to walk a change through before anyone plays it.',
89
+ modules: Object.freeze(['dev-box', 'discord']),
90
+ }),
91
+ research: Object.freeze({
92
+ label: 'Research starter',
93
+ summary: 'Extra agents to run the reading and the experiments the question needs.',
94
+ modules: Object.freeze(['agents']),
95
+ }),
96
+ business: Object.freeze({
97
+ label: 'Business starter',
98
+ summary: 'A place to try a change before customers see it, and agents to look it over first.',
99
+ modules: Object.freeze(['agents', 'dev-box']),
100
+ }),
101
+ 'non-profit': Object.freeze({
102
+ label: 'Non-profit starter',
103
+ summary: 'Somewhere the community around the cause can gather and be heard.',
104
+ modules: Object.freeze(['discord']),
105
+ }),
106
+ 'not-sure': Object.freeze({
107
+ label: 'Core only',
108
+ summary: 'Just the core, and nothing else to decide today. Add modules whenever you know what you need.',
109
+ modules: Object.freeze([]),
110
+ }),
111
+ });
112
+
113
+ // The copy an undeclared project gets. Deliberately NOT one of the bundles above:
114
+ // `type` is nullable because every row predates the field and must never be
115
+ // back-filled with a guess (migration provisioning_007), so "nobody said" has to
116
+ // read differently from "the owner said not sure".
117
+ const UNDECLARED = Object.freeze({
118
+ label: 'Core only',
119
+ summary: 'No project type yet, so nothing is preselected — every project starts with the core.',
120
+ });
121
+
122
+ // Resolve one project type to what the creation flow should preselect:
123
+ // `{ type, label, summary, core, bundle, modules }`, where `bundle` is the type's
124
+ // opt-in additions, `core` the always-on floor, and `modules` the union in that
125
+ // order — what the owner ends up with if they change nothing. An unknown or
126
+ // absent type resolves to the core with `type: null`; this never throws and never
127
+ // invents a type, because a recommendation built on an answer nobody gave is
128
+ // worse than no recommendation. Arrays are fresh copies, so a caller may sort or
129
+ // splice its result without corrupting the frozen declaration.
130
+ function bundleForProjectType(type) {
131
+ const declared = typeof type === 'string' && Object.prototype.hasOwnProperty.call(STARTER_BUNDLES, type)
132
+ ? type
133
+ : null;
134
+ const preset = declared ? STARTER_BUNDLES[declared] : UNDECLARED;
135
+ const bundle = declared ? [...STARTER_BUNDLES[declared].modules] : [];
136
+ return {
137
+ type: declared,
138
+ label: preset.label,
139
+ summary: preset.summary,
140
+ core: [...ALWAYS_ON_CORE],
141
+ bundle,
142
+ modules: [...ALWAYS_ON_CORE, ...bundle],
143
+ };
144
+ }
145
+
146
+ // Every curated bundle, resolved, in declaration order — the whole table in one
147
+ // read. The creation picker wants all of them up front so flipping the type
148
+ // question re-preselects without another round trip.
149
+ function allStarterBundles() {
150
+ return Object.keys(STARTER_BUNDLES).map(bundleForProjectType);
151
+ }
152
+
153
+ module.exports = {
154
+ ALWAYS_ON_CORE,
155
+ HUB_ONLY_MODULES,
156
+ STARTER_BUNDLES,
157
+ bundleForProjectType,
158
+ allStarterBundles,
159
+ };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.509",
3
+ "version": "1.19.510",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.509",
9
+ "version": "1.19.510",
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.509",
3
+ "version": "1.19.510",
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",
package/src/module-api.js CHANGED
@@ -45,7 +45,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
45
45
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
46
46
  // the entry to that file. Look for a version's history there, not here.
47
47
  // ---------------------------------------------------------------------------
48
- const CORE_VERSION = '1.19.509'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
48
+ const CORE_VERSION = '1.19.510'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
49
49
 
50
50
  // A namespaced logger so a module's log lines are attributable + consistent.
51
51
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,268 @@
1
+ // tests/provisioning_starter_bundles.mjs — the curated starter bundles (BV1.R19,
2
+ // task 1002338; goal 1000046 criterion `proj-module-select`).
3
+ //
4
+ // Three things are pinned here, and the middle one is the reason this file exists.
5
+ //
6
+ // 1. The resolver — bundleForProjectType(type) for each declared type, and the
7
+ // core-only fallback for a type nobody declared. A creation form must never
8
+ // be handed a guess.
9
+ // 2. Bundle <-> catalog integrity — a bundle is a list of module KEYS, declared
10
+ // in a pure file that may not import the live catalog (the one-way rule, ADR
11
+ // 0083). This test IS the link: it reads every modules/*/module.json off disk
12
+ // and fails the build the moment a bundle names a module that does not exist,
13
+ // names an always-on one (a preselect that toggles nothing), or the declared
14
+ // always-on list drifts from the manifests' `default: true` set.
15
+ // 3. The read surface — GET /provisioning/starter-bundles is public and returns
16
+ // exactly what the resolver returns, so R20's picker and R17's rule engine
17
+ // cannot read a different answer than a script would.
18
+ //
19
+ // DB-free: the route is a pure read, so nothing here touches Postgres or the
20
+ // network beyond a loopback listener on port 0.
21
+ //
22
+ // Run: node --test --test-reporter=tap tests/provisioning_starter_bundles.mjs
23
+
24
+ import { test, before, after } from 'node:test';
25
+ import { strict as assert } from 'node:assert';
26
+ import fs from 'node:fs';
27
+ import path from 'node:path';
28
+ import http from 'node:http';
29
+ import { fileURLToPath } from 'node:url';
30
+ import { createRequire } from 'node:module';
31
+
32
+ // ESM named imports off the CJS module on purpose: knip cannot trace a
33
+ // createRequire() import, so a test-only export would read as dead code and move
34
+ // the knip ratchet (see the repo's knip learning).
35
+ import {
36
+ ALWAYS_ON_CORE,
37
+ STARTER_BUNDLES,
38
+ HUB_ONLY_MODULES,
39
+ bundleForProjectType,
40
+ allStarterBundles,
41
+ } from '../modules/provisioning/starter-bundles.js';
42
+
43
+ const require = createRequire(import.meta.url);
44
+
45
+ // Stub the doorway in require.cache BEFORE the route file loads (the
46
+ // access_requests_invite pattern): the route destructures `pool` at require time,
47
+ // and a real pg pool would hold the event loop open long after the assertions
48
+ // pass. Nothing here needs a database — the route under test is a pure read.
49
+ const apiPath = require.resolve('../src/module-api.js');
50
+ const realApi = require(apiPath);
51
+ require.cache[apiPath].exports = {
52
+ ...realApi,
53
+ pool: { async query() { throw new Error('the starter-bundles route must not touch the database'); } },
54
+ };
55
+
56
+ const provisioning = require('../modules/provisioning/provisioning.js');
57
+
58
+ const MODULES_DIR = fileURLToPath(new URL('../modules', import.meta.url));
59
+
60
+ // Every module key on disk, with the manifest's `default` — the live catalog as
61
+ // the loader discovers it (src/module-loader/loader.js scans exactly this).
62
+ function manifestsOnDisk() {
63
+ const out = new Map();
64
+ for (const ent of fs.readdirSync(MODULES_DIR, { withFileTypes: true })) {
65
+ if (!ent.isDirectory()) continue;
66
+ const manifestPath = path.join(MODULES_DIR, ent.name, 'module.json');
67
+ if (!fs.existsSync(manifestPath)) continue;
68
+ const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
69
+ out.set(manifest.key, manifest);
70
+ }
71
+ return out;
72
+ }
73
+
74
+ // ── the resolver ────────────────────────────────────────────────────────────
75
+
76
+ test('bundleForProjectType returns the always-on core plus the type\'s opt-in modules', () => {
77
+ // Arrange
78
+ const type = 'game';
79
+
80
+ // Act
81
+ const resolved = bundleForProjectType(type);
82
+
83
+ // Assert
84
+ assert.equal(resolved.type, 'game');
85
+ assert.deepEqual(resolved.core, [...ALWAYS_ON_CORE]);
86
+ assert.deepEqual(resolved.bundle, [...STARTER_BUNDLES.game.modules]);
87
+ assert.deepEqual(resolved.modules, [...ALWAYS_ON_CORE, ...STARTER_BUNDLES.game.modules]);
88
+ assert.ok(resolved.label && resolved.summary, 'carries owner-facing copy for the picker to render');
89
+ });
90
+
91
+ test('every declared project type resolves to a bundle', () => {
92
+ // Arrange — the shipped vocabulary (task 1002772, owner decision 2026-08-13).
93
+ const declared = provisioning.PROJECT_TYPES;
94
+
95
+ // Act + Assert
96
+ for (const type of declared) {
97
+ const resolved = bundleForProjectType(type);
98
+ assert.equal(resolved.type, type, `${type} resolves to itself, not the fallback`);
99
+ }
100
+ assert.deepEqual(
101
+ Object.keys(STARTER_BUNDLES).sort(),
102
+ [...declared].sort(),
103
+ 'the bundle table covers the declared type vocabulary exactly — no missing type, no invented one',
104
+ );
105
+ });
106
+
107
+ test('an undeclared or unknown project type falls back to the core, and never guesses a type', () => {
108
+ // A project may have no type at all: the column is nullable because every row
109
+ // predates the field and must not be back-filled with a guess (migration
110
+ // provisioning_007). The fallback must be honest about that.
111
+ for (const type of [null, undefined, '', 'saas-tool', 'GAME', 42, {}]) {
112
+ // Act
113
+ const resolved = bundleForProjectType(type);
114
+
115
+ // Assert
116
+ assert.equal(resolved.type, null, `${JSON.stringify(type)} → no declared type`);
117
+ assert.deepEqual(resolved.bundle, [], 'nothing is preselected on a guess');
118
+ assert.deepEqual(resolved.modules, [...ALWAYS_ON_CORE]);
119
+ }
120
+ });
121
+
122
+ test('"not-sure" is a real answer that resolves to the core only, distinct from an undeclared type', () => {
123
+ // Arrange + Act
124
+ const notSure = bundleForProjectType('not-sure');
125
+ const undeclared = bundleForProjectType(null);
126
+
127
+ // Assert — same modules, different provenance: one owner said "not sure", the
128
+ // other said nothing at all, and the picker should be able to tell them apart.
129
+ assert.deepEqual(notSure.modules, undeclared.modules);
130
+ assert.equal(notSure.type, 'not-sure');
131
+ assert.equal(undeclared.type, null);
132
+ assert.deepEqual(notSure.bundle, [], 'the escape hatch adds nothing — nothing depends on it');
133
+ });
134
+
135
+ test('a caller mutating a resolved bundle cannot corrupt the declaration', () => {
136
+ // Arrange
137
+ const first = bundleForProjectType('game');
138
+
139
+ // Act
140
+ first.modules.push('not-a-module');
141
+ first.bundle.length = 0;
142
+ first.core.length = 0;
143
+
144
+ // Assert
145
+ const second = bundleForProjectType('game');
146
+ assert.deepEqual(second.core, [...ALWAYS_ON_CORE]);
147
+ assert.deepEqual(second.bundle, [...STARTER_BUNDLES.game.modules]);
148
+ assert.ok(!second.modules.includes('not-a-module'));
149
+ });
150
+
151
+ test('allStarterBundles returns one resolved bundle per declared type', () => {
152
+ // Act
153
+ const all = allStarterBundles();
154
+
155
+ // Assert
156
+ assert.deepEqual(all.map((b) => b.type), Object.keys(STARTER_BUNDLES));
157
+ for (const row of all) assert.deepEqual(row, bundleForProjectType(row.type));
158
+ });
159
+
160
+ // ── bundle <-> catalog integrity ────────────────────────────────────────────
161
+
162
+ test('every module a bundle names exists in the module catalog', () => {
163
+ // Arrange
164
+ const onDisk = manifestsOnDisk();
165
+ assert.ok(onDisk.size > 0, 'read the manifests off disk');
166
+
167
+ // Act + Assert
168
+ for (const [type, bundle] of Object.entries(STARTER_BUNDLES)) {
169
+ for (const key of bundle.modules) {
170
+ assert.ok(onDisk.has(key), `bundle "${type}" names "${key}", which is not a module on disk`);
171
+ }
172
+ }
173
+ });
174
+
175
+ test('every always-on module is a real module whose manifest says default: true', () => {
176
+ // Arrange
177
+ const onDisk = manifestsOnDisk();
178
+
179
+ // Act + Assert
180
+ for (const key of ALWAYS_ON_CORE) {
181
+ assert.ok(onDisk.has(key), `ALWAYS_ON_CORE names "${key}", which is not a module on disk`);
182
+ assert.equal(onDisk.get(key).default, true, `"${key}" is listed as always-on but its manifest is not default: true`);
183
+ }
184
+ const defaultsOnDisk = [...onDisk.values()].filter((m) => m.default === true).map((m) => m.key).sort();
185
+ assert.deepEqual(
186
+ [...ALWAYS_ON_CORE].sort(),
187
+ defaultsOnDisk,
188
+ 'the declared always-on list has drifted from the manifests — a module was added, removed, or flipped its default',
189
+ );
190
+ });
191
+
192
+ test('a bundle may only add opt-in modules — never one that is already always-on', () => {
193
+ // A preselect that names an always-on module toggles nothing and reads to the
194
+ // owner as a choice they made; that is a lie the picker would render.
195
+ for (const [type, bundle] of Object.entries(STARTER_BUNDLES)) {
196
+ for (const key of bundle.modules) {
197
+ assert.ok(!ALWAYS_ON_CORE.includes(key), `bundle "${type}" names always-on module "${key}"`);
198
+ }
199
+ }
200
+ });
201
+
202
+ test('no bundle names a hub-only module', () => {
203
+ // platform-identity and provisioning are the control plane's own modules — the
204
+ // hub runs them, a project created THROUGH the hub never should.
205
+ for (const [type, bundle] of Object.entries(STARTER_BUNDLES)) {
206
+ for (const key of bundle.modules) {
207
+ assert.ok(!HUB_ONLY_MODULES.includes(key), `bundle "${type}" names hub-only module "${key}"`);
208
+ }
209
+ }
210
+ });
211
+
212
+ test('a bundle lists each module once', () => {
213
+ for (const [type, bundle] of Object.entries(STARTER_BUNDLES)) {
214
+ assert.equal(new Set(bundle.modules).size, bundle.modules.length, `bundle "${type}" repeats a module`);
215
+ }
216
+ });
217
+
218
+ // ── the read surface ────────────────────────────────────────────────────────
219
+
220
+ // Mount the real router. The route under test is a pure read, so it needs no
221
+ // session gate and no res.fail helper — which is itself the point of the
222
+ // assertion below.
223
+ const express = require('express');
224
+ const buildProvisioningRouter = require('../modules/provisioning/routes/provisioning.js');
225
+ const app = express();
226
+ app.use('/api/bongos', buildProvisioningRouter());
227
+
228
+ let server;
229
+ let port;
230
+ before(async () => {
231
+ server = http.createServer(app);
232
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
233
+ port = server.address().port;
234
+ });
235
+ after(async () => { await new Promise((resolve) => server.close(resolve)); });
236
+
237
+ function get(pathname) {
238
+ return new Promise((resolve, reject) => {
239
+ http.get({ host: '127.0.0.1', port, path: pathname }, (res) => {
240
+ let body = '';
241
+ res.on('data', (c) => { body += c; });
242
+ res.on('end', () => {
243
+ let parsed = null;
244
+ try { parsed = JSON.parse(body); } catch { /* non-JSON */ }
245
+ resolve({ status: res.statusCode, body: parsed, raw: body });
246
+ });
247
+ }).on('error', reject);
248
+ });
249
+ }
250
+
251
+ test('GET /provisioning/starter-bundles is public and serves the resolved bundles', async () => {
252
+ // Act — no session: `rank: public`, the same gate (none) the signed-out create
253
+ // wizard reads GET /provisioning/onboard-plan with.
254
+ const res = await get('/api/bongos/provisioning/starter-bundles');
255
+
256
+ // Assert
257
+ assert.equal(res.status, 200, `readable with no session (got ${res.status} ${res.raw})`);
258
+ assert.deepEqual(res.body.core, [...ALWAYS_ON_CORE]);
259
+ assert.deepEqual(res.body.bundles, allStarterBundles());
260
+ assert.equal(res.body.bundles.length, provisioning.PROJECT_TYPES.length);
261
+ });
262
+
263
+ test('the route never reaches the database — the bundles are a declaration, not a query', async () => {
264
+ // The stubbed pool throws on any query, so a 200 here IS the assertion: an
265
+ // owner picking a type in the wizard must not cost a round trip to Postgres.
266
+ const res = await get('/api/bongos/provisioning/starter-bundles');
267
+ assert.equal(res.status, 200);
268
+ });