@bongos/core 1.19.508 → 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.
Files changed (30) hide show
  1. package/.bongos-core.json +57 -32
  2. package/.claude/skills/new-project/SKILL.md +2 -0
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +2 -0
  5. package/clients/bongos-client/index.cjs +2 -0
  6. package/clients/bongos-client/index.d.ts +4 -1
  7. package/clients/bongos-client/index.mjs +2 -0
  8. package/docs/adr/0182-project-visibility-two-axes.md +1 -0
  9. package/docs/adr/0237-a-starter-bundle-is-a-preset-over-the-always-on-core-keyed-by-declared-type.md +137 -0
  10. package/docs/adr/README.md +1 -0
  11. package/docs/api/openapi.json +47 -6
  12. package/docs/api-reference.md +4 -3
  13. package/docs/copy-inventory.md +210 -207
  14. package/docs/copy-registry.json +297 -269
  15. package/docs/module-api-changelog.md +4 -0
  16. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  17. package/modules/platform-identity/module.json +1 -1
  18. package/modules/platform-identity/platform-identity.js +30 -36
  19. package/modules/platform-identity/publish-gate.js +103 -0
  20. package/modules/platform-identity/routes/projects.js +31 -4
  21. package/modules/platform-identity/tests/platform-identity.mjs +7 -2
  22. package/modules/provisioning/routes/provisioning.js +70 -5
  23. package/modules/provisioning/starter-bundles.js +159 -0
  24. package/modules/public-landing/public/projects.html +157 -0
  25. package/package-lock.json +2 -2
  26. package/package.json +1 -1
  27. package/src/module-api.js +1 -1
  28. package/tests/projects_hub.mjs +62 -0
  29. package/tests/provisioning_starter_bundles.mjs +268 -0
  30. package/tests/publish_gate.mjs +167 -0
@@ -1453,5 +1453,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1453
1453
  landed since 1.19.506 with no explicit bump. run 33788726591. (task 1002620)
1454
1454
  1.19.508 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1455
1455
  landed since 1.19.507 with no explicit bump. run 33790206682. (task 1002620)
1456
+ 1.19.509 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
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)
1456
1460
  ---------------------------------------------------------------------------
1457
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
  },
@@ -14,6 +14,6 @@
14
14
  "migrations": true,
15
15
  "pollers": ["code-sweep"]
16
16
  },
17
- "provides": ["platform-identity.hub"],
17
+ "provides": ["platform-identity.hub", "project.publishGate"],
18
18
  "consumes": []
19
19
  }
@@ -300,38 +300,18 @@ const PROJECT_TEMPLATE_WALK = ['blob-tint', 'pair-gold-wash', 'bead-obsidian-pla
300
300
  // `private` and let two sources of truth for one question drift apart.
301
301
  const PROJECT_VISIBILITIES = ['public', 'private', 'stealth'];
302
302
 
303
- // The publish gate is IDENTITY ONLY (ADR 0182 D4, owner interview on R16): a name and a
304
- // description. Not tagline, art, modules, governance, or a completed interview — the bar
305
- // exists to keep empty husks off the map, not to enforce completeness.
306
- const PUBLISH_GATE_FIELDS = ['name', 'description'];
307
-
308
- // Is this project on the map at all? Returns the verdict AND what is missing, because
309
- // ADR 0182 D4 requires the gate to NAME what is missing rather than merely refuse: a
310
- // project that cannot be published and is not told why is indistinguishable from a
311
- // broken one. R16 (task 1002335) renders `missing`; R06 (task 1002325) reads `publishable`.
312
- function projectPublishability(project) {
313
- const missing = PUBLISH_GATE_FIELDS.filter((f) => !String(project?.[f] ?? '').trim());
314
- return { publishable: missing.length === 0, missing };
315
- }
316
-
317
- // The boolean alone, for predicates that do not care which field is absent.
318
- function projectIsPublishable(project) {
319
- return projectPublishability(project).publishable;
320
- }
321
-
322
- // The SAME gate as a SQL fragment, so the feed predicate (R06) and the owner-facing
323
- // refusal (R16) cannot drift from projectPublishability() or from each other. `alias`
324
- // qualifies the columns for a joined query (e.g. publishableSql('p')).
325
- function publishableSql(alias) {
326
- // The alias is INTERPOLATED, so it is the one thing here that could carry SQL. Callers
327
- // pass a literal, but a helper that builds SQL should refuse anything that is not a
328
- // bare identifier rather than trust every future caller to remember that.
329
- if (alias && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(String(alias))) {
330
- throw new Error('publishableSql: alias must be a bare SQL identifier');
331
- }
332
- const q = alias ? `${alias}.` : '';
333
- return `(btrim(coalesce(${q}name, '')) <> '' AND btrim(coalesce(${q}description, '')) <> '')`;
334
- }
303
+ // The publish gate — the derived "is this project on the map at all" bar — lives in
304
+ // its own file (ADR 0182 D4; the account-visibility.js precedent). Every symbol is
305
+ // re-exported below, so this module's public surface is exactly what it was before
306
+ // the split and no caller has to know about it.
307
+ const {
308
+ PUBLISH_GATE_FIELDS,
309
+ projectPublishability,
310
+ projectIsPublishable,
311
+ publishableSql,
312
+ missingPhrase,
313
+ publishGatePort,
314
+ } = require('./publish-gate');
335
315
 
336
316
  // Upsert a catalog row (keyed by origin). Called when a client registers (so every
337
317
  // federated project is catalogued, unfeatured) and by the Archon create/curate route.
@@ -412,14 +392,26 @@ async function listAllProjects({ pool = defaultPool } = {}) {
412
392
  return rows.map((r) => ({ ...r, missing: projectPublishability(r).missing }));
413
393
  }
414
394
 
415
- // Archon-only: flip the Explore feature flag on a catalog row.
395
+ // Archon-only: flip the Explore feature flag — BOUNDED BY THE PUBLISH GATE (ADR 0182
396
+ // D3, task 1002335). Featuring may rank among projects ALREADY on the map, never put
397
+ // one there. The bound rides the UPDATE's own WHERE, not a read-then-write, so a
398
+ // description cleared mid-flight cannot slip a husk on; un-featuring is never blocked,
399
+ // since an already-featured husk must stay removable. Answers a VERDICT rather than a
400
+ // row — { project } | { blocked, missing } | { notFound } — because a bare null
401
+ // collapsed "the gate refused" into "no such row", and a curator is owed each.
416
402
  async function setProjectFeatured(id, featured, { pool = defaultPool } = {}) {
403
+ const on = !!featured;
417
404
  const { rows } = await pool.query(
418
405
  `UPDATE platform_identity_projects SET featured = $2, updated_at = now()
419
- WHERE id = $1 RETURNING id, origin, name, featured, status`,
420
- [id, !!featured],
406
+ WHERE id = $1 AND ($2 = false OR ${publishableSql()})
407
+ RETURNING id, origin, name, featured, status`,
408
+ [id, on],
421
409
  );
422
- return rows[0] || null;
410
+ if (rows[0]) return { project: rows[0] };
411
+ // Re-read only to tell the two failures apart — and, when it was the gate, to name
412
+ // the identity field that is still missing (D4: never merely refuse).
413
+ const project = await getProject(id, { pool });
414
+ return project ? { blocked: true, missing: project.missing } : { notFound: true };
423
415
  }
424
416
 
425
417
  // -------------------------------------------------------------------------
@@ -1435,6 +1427,8 @@ module.exports = {
1435
1427
  projectPublishability,
1436
1428
  projectIsPublishable,
1437
1429
  publishableSql,
1430
+ missingPhrase,
1431
+ publishGatePort,
1438
1432
  upsertProject,
1439
1433
  PROJECT_TYPES,
1440
1434
  PROJECT_TEMPLATES,
@@ -0,0 +1,103 @@
1
+ // modules/platform-identity/publish-gate.js — THE project publish gate, published
2
+ // in all three forms it is asked in, so no two of them can drift (ADR 0182 D4; the
3
+ // account-visibility.js precedent, which took this file's shape for the same reason).
4
+ //
5
+ // WHAT IT ANSWERS: "is this project on the public map at all?" — and, when the answer
6
+ // is no, WHICH identity field is absent. The bar is IDENTITY ONLY: a `name` and a
7
+ // `description`. Not tagline, art, modules, governance, or a completed interview. It
8
+ // exists to keep empty husks off the map, never to enforce completeness — a higher bar
9
+ // would make skipping the optional project-detail step (task 1002334) a dead end
10
+ // rather than the supported default the goal explicitly wants.
11
+ //
12
+ // "Dark matter" — off the map and un-joinable — is the ABSENCE of a met bar, derived
13
+ // here and never stored. A stored fourth visibility value would compete with `private`
14
+ // and give one question two sources of truth that can drift apart (ADR 0182 D1).
15
+ //
16
+ // THE THREE FORMS, and why they live together:
17
+ // • projectPublishability(row) — the verdict + `missing`, for JS callers
18
+ // • publishableSql(alias) — the identical predicate as SQL, for the feed
19
+ // • publishGatePort — the kernel seam port, for another module
20
+ // A caller that reached only one of these could not have drifted; a caller that
21
+ // re-implements a two-field rule inevitably does. That is the whole point of the file.
22
+ //
23
+ // WHY ITS OWN FILE. Same reason account-visibility.js is: platform-identity.js sits at
24
+ // the file-size ratchet (scripts/gds/fitness-ratchets.js), and R16 (task 1002335) added
25
+ // the port + the phrase. platform-identity.js re-exports every symbol here, so the
26
+ // module's public surface is unchanged and no caller has to know about the split.
27
+ //
28
+ // WHAT IT IS NOT. Not the join door: `visibility ∈ (public|private|stealth)` is a
29
+ // stored, owner-set policy and all three of its values are VISIBLE (ADR 0182 D2). Not
30
+ // curation: `featured` may rank among projects already on the map, never put one there
31
+ // (D3) — setProjectFeatured enforces that bound with publishableSql(). Not authority.
32
+ 'use strict';
33
+
34
+ // The two fields, and only these two (ADR 0182 D4, owner interview on R16).
35
+ const PUBLISH_GATE_FIELDS = Object.freeze(['name', 'description']);
36
+
37
+ // Is this project on the map at all? Returns the verdict AND what is missing, because
38
+ // ADR 0182 D4 requires the gate to NAME what is missing rather than merely refuse: a
39
+ // project that cannot be published and is not told why is indistinguishable from a
40
+ // broken one. R16 (task 1002335) renders `missing`; R06 (task 1002325) reads `publishable`.
41
+ function projectPublishability(project) {
42
+ const missing = PUBLISH_GATE_FIELDS.filter((f) => !String(project?.[f] ?? '').trim());
43
+ return { publishable: missing.length === 0, missing };
44
+ }
45
+
46
+ // The boolean alone, for predicates that do not care which field is absent.
47
+ function projectIsPublishable(project) {
48
+ return projectPublishability(project).publishable;
49
+ }
50
+
51
+ // The SAME gate as a SQL fragment, so the feed predicate (R06) and the owner-facing
52
+ // refusal (R16) cannot drift from projectPublishability() or from each other. `alias`
53
+ // qualifies the columns for a joined query (e.g. publishableSql('p')).
54
+ function publishableSql(alias) {
55
+ // The alias is INTERPOLATED, so it is the one thing here that could carry SQL. Callers
56
+ // pass a literal, but a helper that builds SQL should refuse anything that is not a
57
+ // bare identifier rather than trust every future caller to remember that.
58
+ if (alias && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(String(alias))) {
59
+ throw new Error('publishableSql: alias must be a bare SQL identifier');
60
+ }
61
+ const q = alias ? `${alias}.` : '';
62
+ return `(btrim(coalesce(${q}name, '')) <> '' AND btrim(coalesce(${q}description, '')) <> '')`;
63
+ }
64
+
65
+ // What is missing, as a PHRASE, so every surface that refuses says it the same way
66
+ // (task 1002335). ADR 0182 D4 requires the gate to NAME what is absent, and a list
67
+ // rendered ad hoc at each call site is how two surfaces end up telling one owner two
68
+ // different stories about one rule. ['description'] → "no description";
69
+ // ['name','description'] → "no name and no description". PURE.
70
+ function missingPhrase(missing) {
71
+ const list = (missing || []).filter(Boolean);
72
+ if (!list.length) return '';
73
+ if (list.length === 1) return `no ${list[0]}`;
74
+ return `no ${list.slice(0, -1).join(', no ')} and no ${list[list.length - 1]}`;
75
+ }
76
+
77
+ // The gate as a KERNEL SEAM PORT — `project.publishGate` (R16, task 1002335),
78
+ // registered by routes/projects.js and declared in module.json.
79
+ //
80
+ // The module one-way rule (ADR 0083) forbids `provisioning` from importing this file,
81
+ // and provisioning is the plane an owner actually stands on BEFORE their project is
82
+ // ever catalogued here — so without a port it would have to keep its own copy of the
83
+ // rule, which is exactly the drift publishableSql() exists to prevent. One definition,
84
+ // reached through the kernel switchboard.
85
+ //
86
+ // `evaluate` is deliberately tolerant of a FOREIGN row shape: a consumer maps its own
87
+ // columns onto { name, description } — provisioning's `slug` IS its project's name —
88
+ // so the gate never learns another module's schema. `fields` is published so a consumer
89
+ // can render the bar without hardcoding it.
90
+ const publishGatePort = Object.freeze({
91
+ fields: PUBLISH_GATE_FIELDS,
92
+ evaluate: (project) => projectPublishability(project),
93
+ missingPhrase,
94
+ });
95
+
96
+ module.exports = {
97
+ PUBLISH_GATE_FIELDS,
98
+ projectPublishability,
99
+ projectIsPublishable,
100
+ publishableSql,
101
+ missingPhrase,
102
+ publishGatePort,
103
+ };
@@ -24,7 +24,23 @@ function hostOfOrigin(origin) {
24
24
  try { return new URL(String(origin)).hostname.replace(/^www\./, '').toLowerCase(); } catch { return ''; }
25
25
  }
26
26
 
27
+ // Register this module's publish-gate seam (R16, task 1002335; the
28
+ // registerProvisioningSeams precedent). Idempotent — the loader calls the factory
29
+ // once at boot, but tests instantiate the router repeatedly, and a port is
30
+ // single-provider so a second registration throws.
31
+ //
32
+ // • PROVIDES port `project.publishGate` → { fields, evaluate(project) }.
33
+ // Lets provisioning — the plane an owner stands on before their project is
34
+ // catalogued here — ask THIS module's gate whether a project is on the map,
35
+ // without importing it (the ADR 0083 one-way rule) and without keeping a
36
+ // second copy of the rule that could drift from publishableSql().
37
+ function registerPublishGateSeam() {
38
+ if (api.hasProvider('project.publishGate')) return;
39
+ api.registerProvider('project.publishGate', pi.publishGatePort);
40
+ }
41
+
27
42
  module.exports = function projectsRoutes() {
43
+ registerPublishGateSeam();
28
44
  const router = express.Router();
29
45
 
30
46
  // rank: metic+archon — the full curation list (includes unfeatured + owner attribution).
@@ -99,15 +115,26 @@ module.exports = function projectsRoutes() {
99
115
  }
100
116
  });
101
117
 
102
- // rank: archon — the feature / unfeature control (the ADR 0141 §5 curation gate).
118
+ // rank: archon — the feature / unfeature control (the ADR 0141 §5 curation gate),
119
+ // BOUNDED BY THE PUBLISH GATE (ADR 0182 D3, task 1002335). Featuring ranks among
120
+ // projects already on the map; it is not a way ONTO it, so featuring a dark-matter
121
+ // row is refused — and the refusal names the identity field that is missing rather
122
+ // than merely saying no (D4). Un-featuring is never refused.
103
123
  router.patch('/projects/:id/featured', requireBuilder, requirePermission('project.curate'), async (req, res) => {
104
124
  const id = parseId(req, res, { code: 'bad_project_id', positiveInt: true });
105
125
  if (id === null) return;
106
126
  if (validateOrRespond(req, res, { featured: { required: true, type: 'boolean' } })) return;
107
127
  try {
108
- const project = await pi.setProjectFeatured(id, req.body.featured, { pool });
109
- if (!project) return res.fail('project_not_found', 404);
110
- res.json({ project });
128
+ const out = await pi.setProjectFeatured(id, req.body.featured, { pool });
129
+ if (out.notFound) return res.fail('project_not_found', 404);
130
+ if (out.blocked) {
131
+ return res.fail('not_publishable', {
132
+ status: 409,
133
+ message: `This project is not on the public map yet, so it cannot be featured — it still has ${pi.missingPhrase(out.missing)}. Featuring promotes a project that is already published; it cannot publish one.`,
134
+ details: { missing: out.missing },
135
+ });
136
+ }
137
+ res.json({ project: out.project });
111
138
  } catch (err) {
112
139
  console.error('[platform-identity] PATCH /projects/:id/featured', err);
113
140
  res.fail('feature_toggle_failed', { status: 500, message: 'internal error' });
@@ -286,12 +286,17 @@ await ta('listFeaturedProjects returns a PUBLIC projection (featured+active only
286
286
  assert.doesNotMatch(sel, /updated_at/, 'updated_at is curation noise — never projected');
287
287
  });
288
288
 
289
- await ta('setProjectFeatured flips the flag (Archon curation)', async () => {
289
+ await ta('setProjectFeatured flips the flag (Archon curation) — and answers with a VERDICT', async () => {
290
290
  const pool = mockPool([{ rows: [{ id: '1', featured: true }] }]);
291
291
  const out = await P.setProjectFeatured(1, true, { pool });
292
- assert.equal(out.featured, true);
292
+ assert.equal(out.project.featured, true);
293
293
  assert.match(pool.calls[0].text, /UPDATE platform_identity_projects SET featured = \$2/);
294
294
  assert.deepEqual(pool.calls[0].params, [1, true]);
295
+ // Bounded by the publish gate since task 1002335 (ADR 0182 D3): featuring may
296
+ // promote among projects already on the map, never put one there. The full
297
+ // refusal contract — blocked vs notFound, and the un-feature escape — lives in
298
+ // tests/publish_gate.mjs.
299
+ assert.ok(pool.calls[0].text.includes(P.publishableSql()), 'the gate rides in the WHERE');
295
300
  });
296
301
 
297
302
  console.log('\nproject visibility: the stored door + the DERIVED publish gate (BV1.R04, ADR 0182):');
@@ -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');
@@ -262,12 +265,48 @@ function registerProvisioningSeams() {
262
265
  api.registerProvider('provisioning.projectPlanets', (hosts) => provisioning.planetChoicesForHosts(pool, hosts));
263
266
  }
264
267
 
268
+ // Is this project on the public map, and if not, what is it still missing?
269
+ // (R16, task 1002335 — the surface R15 pointed at by name.)
270
+ //
271
+ // The rule itself is ADR 0182 D4's and lives in `platform-identity`; this module
272
+ // reaches it through the kernel port `project.publishGate` rather than keeping a
273
+ // copy, because a second copy of a two-field rule is exactly the drift that
274
+ // module's single SQL definition exists to prevent (ADR 0083 forbids the import).
275
+ //
276
+ // resolveOptional, not resolve, and NOT declared in `consumes` (the lifecycle →
277
+ // reward precedent): platform-identity is hub-only (`default: false`), and the
278
+ // public map is a HUB concept — an instance running provisioning without it has no
279
+ // map to be on. The honest answer there is "no verdict" (null), never a locally
280
+ // invented one, and every surface below hides the section rather than guessing.
281
+ //
282
+ // The MAPPING is the one part this module owns: an instance's `slug` IS its
283
+ // project's name on this plane — there is no `name` column and a slug can never be
284
+ // empty — so the only identity field this plane can be missing is the description
285
+ // the optional About step (task 1002334) asks for.
286
+ function publishVerdict(row) {
287
+ const gate = api.resolveOptional('project.publishGate');
288
+ if (!gate || !row) return null;
289
+ const { publishable, missing } = gate.evaluate({ name: row.slug, description: row.description });
290
+ // The phrase travels with the verdict so the owner-facing surfaces and the hub's
291
+ // own refusal cannot word one rule two different ways.
292
+ return { publishable, missing, missing_phrase: gate.missingPhrase(missing) };
293
+ }
294
+
295
+ // The owner-facing projection every instance read returns: the allow-listed row
296
+ // plus the derived publish verdict. One helper so a new read cannot ship the row
297
+ // without the verdict and leave an owner unable to see why they are dark matter.
298
+ function ownerInstance(row) {
299
+ return { ...provisioning.publicInstance(row), publish: publishVerdict(row) };
300
+ }
301
+
265
302
  // Project an instance row for the fleet view — the owner identity + the same
266
- // allow-listed public projection an owner sees.
303
+ // projection an owner sees, verdict included: "why is this project not on the map"
304
+ // is a support question, and an operator who has to re-derive the answer will
305
+ // eventually derive a different one.
267
306
  function fleetInstance(row) {
268
307
  return {
269
308
  owner: { id: row.owner_builder_id, github_login: row.github_login, display_name: row.display_name, rank: row.rank },
270
- ...provisioning.publicInstance(row),
309
+ ...ownerInstance(row),
271
310
  };
272
311
  }
273
312
 
@@ -575,7 +614,7 @@ module.exports = function provisioningRoutes() {
575
614
  };
576
615
  }
577
616
  res.status(created ? 201 : 200).json({
578
- instance: provisioning.publicInstance(instance),
617
+ instance: ownerInstance(instance),
579
618
  open_intent: intent ? { action: intent.action, state: intent.state } : null,
580
619
  queued: intentCreated,
581
620
  created,
@@ -647,7 +686,7 @@ module.exports = function provisioningRoutes() {
647
686
  for (const row of rows) {
648
687
  const open = await provisioning.getOpenIntent(pool, row.id);
649
688
  instances.push({
650
- ...provisioning.publicInstance(row),
689
+ ...ownerInstance(row),
651
690
  open_intent: open ? { action: open.action, state: open.state } : null,
652
691
  });
653
692
  }
@@ -684,7 +723,7 @@ module.exports = function provisioningRoutes() {
684
723
  // panel recovers it from server state after a reload — not from sessionStorage.
685
724
  const manifest = await provisioning.getLatestOAuthManifestForInstance(pool, id);
686
725
  res.json({
687
- instance: provisioning.publicInstance(inst),
726
+ instance: ownerInstance(inst),
688
727
  open_intent: open ? { action: open.action, state: open.state } : null,
689
728
  github_app: provisioning.publicGithubApp(manifest),
690
729
  });
@@ -746,6 +785,11 @@ module.exports = function provisioningRoutes() {
746
785
  description: updated.description || null,
747
786
  detail: provisioning.projectDetail(updated),
748
787
  options: provisioning.PROJECT_DETAIL_VOCAB,
788
+ // The whole point of the step, answered in the same breath (R16, task
789
+ // 1002335): an owner who just wrote a description learns HERE that the
790
+ // project is now on the map, and one who cleared it learns it just left.
791
+ // Re-deriving it from a follow-up GET would let the two answers disagree.
792
+ publish: publishVerdict(updated),
749
793
  });
750
794
  } catch (err) {
751
795
  log.error('[provisioning] PATCH /provisioning/instances/:id/detail', err);
@@ -1222,6 +1266,22 @@ module.exports = function provisioningRoutes() {
1222
1266
  res.json({ mode, steps: stepsForShape(onboardPlan(mode, { domain }), shape) });
1223
1267
  });
1224
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
+
1225
1285
  // GET /provisioning/tls-check?domain=<host> — the Caddy on-demand-TLS ALLOW-LIST
1226
1286
  // (ADR 0111 §4, task P4 #1945). Caddy's global `on_demand_tls { ask … }` calls
1227
1287
  // this before fetching a Let's Encrypt cert for an SNI it doesn't already have:
@@ -1342,3 +1402,8 @@ module.exports = function provisioningRoutes() {
1342
1402
 
1343
1403
  // Re-export the seam registrar for tests that assert the port is registered.
1344
1404
  module.exports.registerProvisioningSeams = registerProvisioningSeams;
1405
+ // …and the publish-gate projection (task 1002335), so a test can pin the one
1406
+ // thing this module owns about the gate — the slug-is-the-name mapping — and
1407
+ // prove it answers with the REAL port rather than a copy of the rule.
1408
+ module.exports.publishVerdict = publishVerdict;
1409
+ module.exports.ownerInstance = ownerInstance;
@@ -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
+ };