@bongos/core 1.19.515 → 1.19.517

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 (33) hide show
  1. package/.bongos-core.json +56 -36
  2. package/clients/bongos-client/index.d.ts +2 -2
  3. package/docs/adr/0240-a-skipped-module-picker-stores-nothing-and-resolves-the-bundle-on-read.md +157 -0
  4. package/docs/adr/README.md +1 -0
  5. package/docs/api/openapi.json +12 -3
  6. package/docs/api-reference.md +1 -1
  7. package/docs/copy-inventory.md +237 -229
  8. package/docs/copy-registry.json +387 -303
  9. package/docs/module-api-changelog.md +4 -0
  10. package/modules/hall-ui/public/drachmae.html +3 -0
  11. package/modules/hall-ui/public/drachmae.js +138 -2
  12. package/modules/hall-ui/public/hall-kit.js +45 -7
  13. package/modules/hall-ui/public/profile-charts.js +6 -13
  14. package/modules/hall-ui/public/profile.css +6 -35
  15. package/modules/hall-ui/public/style.css +35 -0
  16. package/modules/provisioning/migrations/provisioning_014_module_selection.sql +60 -0
  17. package/modules/provisioning/provisioning.js +24 -4
  18. package/modules/provisioning/routes/provisioning.js +59 -2
  19. package/modules/provisioning/starter-bundles.js +91 -0
  20. package/modules/public-landing/public/assets/cosmos.css +1 -1
  21. package/modules/public-landing/public/projects.html +230 -35
  22. package/modules/public-landing/public/projects.probes.json +2 -2
  23. package/modules/public-landing/public/projects.states.json +5 -4
  24. package/modules/ui-design/kit/serve.js +32 -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/hall_kit_charts.mjs +49 -0
  29. package/tests/hall_reference_world.mjs +129 -0
  30. package/tests/projects_hub.mjs +72 -7
  31. package/tests/projects_hub_module_picker.mjs +369 -0
  32. package/tests/provisioning_module_selection.mjs +286 -0
  33. package/tests/provisioning_starter_bundles.mjs +131 -0
@@ -1467,5 +1467,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1467
1467
  landed since 1.19.513 with no explicit bump. run 33796959752. (task 1002620)
1468
1468
  1.19.515 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1469
1469
  landed since 1.19.514 with no explicit bump. run 33797825811. (task 1002620)
1470
+ 1.19.516 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1471
+ landed since 1.19.515 with no explicit bump. run 33798954769. (task 1002620)
1472
+ 1.19.517 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1473
+ landed since 1.19.516 with no explicit bump. run 33799755382. (task 1002620)
1470
1474
  ---------------------------------------------------------------------------
1471
1475
  ```
@@ -102,6 +102,9 @@
102
102
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
103
103
  <script src="/builders/palette.js" defer></script>
104
104
  <script src="/builders/md-reader.js?v=2026-09-02-mdreader-2"></script>
105
+ <!-- The chart primitive, for the treasury section (task 1003446). Before
106
+ drachmae.js, which reads window.OTBKit when it inserts that section. -->
107
+ <script src="/builders/hall-kit.js" defer></script>
105
108
  <script src="/builders/drachmae.js?v=2026-09-02-drachmae-2" defer></script>
106
109
  <script src="/builders/copy-on-click.js?v=2026-06-01-copy-1" defer></script>
107
110
  </body>
@@ -31,6 +31,15 @@
31
31
  // The doc lives at docs/onboarding/, so relative links resolve against it.
32
32
  const DOC_DIR = 'docs/onboarding/';
33
33
  const BOUNTY_URL = '/api/gds/public/bounty-table';
34
+ // task 1003446: the treasury feed. NOT a new endpoint — this is the same
35
+ // /public/cost-summary the signed-out status page already draws as a
36
+ // cumulative line and the Work board already fetches to read ONE scalar off
37
+ // (`total_value_credits`). Its own route comment calls `daily[]` "the
38
+ // treasury chart's cumulative line"; the series was in the payload the whole
39
+ // time, and the stranger got the chart while the builder got the number.
40
+ const TREASURY_URL = '/api/gds/public/cost-summary';
41
+ const TREASURY_SECTION_ID = 'the-treasury';
42
+ const TREASURY_SECTION_TITLE = 'The treasury, so far';
34
43
  const BOUNTY_SECTION_ID = 'security-bounty-rates';
35
44
  const BOUNTY_SECTION_TITLE = 'Security bounty rates';
36
45
  // The section the live table is inserted AHEAD of: the doc's closing links.
@@ -109,6 +118,129 @@
109
118
  return true;
110
119
  }
111
120
 
121
+ // ---- the treasury (task 1003446) -----------------------------------------
122
+
123
+ // One cumulative figure: the plot, the number it ends on, and the table twin
124
+ // that keeps it out of picture-only territory. `fmt` renders a value for a
125
+ // human; `rows` is the twin's own text, so the money and the credits can read
126
+ // differently without the chart knowing about either.
127
+ function treasuryFigure({ id, title, points, fmt, foot }) {
128
+ const kit = window.OTBKit;
129
+ const esc = window.OTBMdReader.escapeHtml;
130
+ if (!points.length) return '';
131
+ const last = points[points.length - 1];
132
+ const series = kit.timeStepSeriesHtml(points, {
133
+ x0: points[0].x,
134
+ x1: last.x,
135
+ yMax: last.value,
136
+ });
137
+ const { endX, endY } = series.geometry;
138
+ const day = (t) => new Date(t).toISOString().slice(0, 10);
139
+ // Every 7th day plus the last: a twin with one row per calendar day since
140
+ // May would be ~120 rows behind a disclosure nobody finishes reading.
141
+ const twinRows = points.filter((p, i) => i % 7 === 0 || i === points.length - 1);
142
+ return `<figure class="pchart" id="${esc(id)}">
143
+ <figcaption class="pchart__title">${esc(title)}</figcaption>
144
+ ${kit.plotSvgHtml({
145
+ series: series.body,
146
+ // No readout is wired here, so no focus stop and no dead crosshair.
147
+ interactive: false,
148
+ ariaLabel: `${title}: ${fmt(last.value)} as of ${day(last.x)}`,
149
+ label: kit.plotValueLabelHtml(endX, endY - 8, fmt(last.value), 'end'),
150
+ leftTick: day(points[0].x),
151
+ rightTick: day(last.x),
152
+ })}
153
+ <details class="pchart__twin">
154
+ <summary>Read it as a table</summary>
155
+ <table class="ledger pchart__table"><thead><tr><th>Day</th><th>${esc(title)}</th></tr></thead><tbody>
156
+ ${twinRows.map((p) => `<tr><td>${esc(day(p.x))}</td><td>${esc(fmt(p.value))}</td></tr>`).join('')}
157
+ </tbody></table>
158
+ </details>
159
+ <p class="pchart__foot">${esc(foot)}</p>
160
+ </figure>`;
161
+ }
162
+
163
+ // The live treasury as a real section of the article, ahead of the rate card.
164
+ // Returns false when the summary can't be read: the prose stays and no empty
165
+ // panel or outline entry that leads nowhere is rendered — the same contract
166
+ // insertBountySection keeps.
167
+ async function insertTreasurySection(article) {
168
+ if (!window.OTBKit) {
169
+ console.warn('[drachmae] OTBKit missing — the treasury section needs the chart primitive');
170
+ return false;
171
+ }
172
+ let payload;
173
+ try {
174
+ const r = await fetch(TREASURY_URL, { credentials: 'same-origin' });
175
+ if (!r.ok) throw new Error(`${TREASURY_URL} → ${r.status}`);
176
+ payload = await r.json();
177
+ } catch (err) {
178
+ console.warn('[drachmae] cost summary unavailable:', err.message);
179
+ return false;
180
+ }
181
+ const esc = window.OTBMdReader.escapeHtml;
182
+ const kit = window.OTBKit;
183
+ const currency = (window.__BRANDING__ && window.__BRANDING__.currency
184
+ && window.__BRANDING__.currency.label) || 'credits';
185
+ const Currency = currency.charAt(0).toUpperCase() + currency.slice(1);
186
+ const usd = (n) => `$${Number(n || 0).toLocaleString(undefined, { maximumFractionDigits: 0 })}`;
187
+ const num = (n) => Number(n || 0).toLocaleString();
188
+
189
+ // The server already ran generate_series, so every calendar day is present
190
+ // and a flat run is a real flat run rather than a missing sample.
191
+ const spend = kit.cumulateByDay(payload.daily, { valueOf: (r) => r.amount_usd });
192
+ const earned = kit.cumulateByDay(payload.daily_credits, { valueOf: (r) => r.credits });
193
+ // Both series empty is a treasury with nothing in it yet — one honest line,
194
+ // not two empty plots.
195
+ if (!spend.length && !earned.length) return false;
196
+
197
+ const figures = [
198
+ treasuryFigure({
199
+ id: 'treasury-spend',
200
+ title: 'Spent, cumulative',
201
+ points: spend,
202
+ fmt: usd,
203
+ foot: 'Every recorded cost — API, infra, compute, art — added up day by day.',
204
+ }),
205
+ treasuryFigure({
206
+ id: 'treasury-earned',
207
+ title: `${Currency} awarded, cumulative`,
208
+ points: earned,
209
+ fmt: num,
210
+ foot: `What the project has paid builders, in ${esc(currency)}, day by day.`,
211
+ }),
212
+ ].filter(Boolean).join('');
213
+
214
+ // Where the money went, as the kit's bar series rather than a fourth
215
+ // hand-rolled one. Categories arrive sorted biggest-first.
216
+ const byCategory = (payload.by_category || []).slice(0, 8);
217
+ const categories = byCategory.length
218
+ ? `<h3 id="treasury-by-category">Where it went</h3>`
219
+ + kit.barSeriesHtml(
220
+ byCategory.map((c) => ({ label: c.category, value: c.total_usd, valueLabel: usd(c.total_usd) })),
221
+ { list: true, ariaLabel: 'Spend by category', valueWidth: '84px' },
222
+ )
223
+ : '';
224
+
225
+ const section = document.createElement('section');
226
+ section.innerHTML =
227
+ `<h2 id="${esc(TREASURY_SECTION_ID)}">${esc(TREASURY_SECTION_TITLE)}</h2>`
228
+ + `<p>These are this project's real numbers, read from the server when you `
229
+ + `opened this page. It has spent <strong>${esc(usd(payload.total_usd))}</strong> `
230
+ + `and awarded <strong>${esc(num(payload.total_value_credits))} ${esc(currency)}</strong> `
231
+ + `for the work that money bought.</p>`
232
+ + `<div class="pcharts">${figures}</div>`
233
+ + categories;
234
+
235
+ const bounty = article.querySelector(`#${BOUNTY_SECTION_ID}`);
236
+ const closing = [...article.querySelectorAll('h2')].find((h) => CLOSING_H2.test(h.textContent.trim()));
237
+ // Ahead of the rate card if it landed first, else ahead of the closing
238
+ // links, else appended: the doc keeps ending on "dig deeper" either way.
239
+ const before = (bounty && bounty.parentElement === article ? bounty : null) || closing;
240
+ if (before) article.insertBefore(section, before); else article.appendChild(section);
241
+ return true;
242
+ }
243
+
112
244
  // The outline is read from the RENDERED headings rather than the parser's raw
113
245
  // text, so `*not*` and a [link](…) in a heading arrive as plain words, and so
114
246
  // the live section inserted above is simply one more heading in order.
@@ -276,9 +408,13 @@
276
408
  article.innerHTML = parsed.html.replace(/^\s*<h1\b[^>]*>[\s\S]*?<\/h1>\s*/, '');
277
409
  article.removeAttribute('aria-busy');
278
410
  show('article');
279
- // Awaited before the outline is built so the rate card gets its entry and a
280
- // scrollspy target; the article itself is already on screen by now.
411
+ // Awaited before the outline is built so both live sections get their
412
+ // entries and scrollspy targets; the article itself is already on screen by
413
+ // now. The rate card goes in FIRST so the treasury can land ahead of it by
414
+ // id — the doc then reads prose, then what actually happened, then the
415
+ // published rates, then "dig deeper".
281
416
  await insertBountySection(article);
417
+ await insertTreasurySection(article);
282
418
  addAnchors(article);
283
419
  setupTocToggle();
284
420
  setupScrollspy(renderToc(collectHeadings(article)));
@@ -663,6 +663,35 @@
663
663
  return chartNums(values).map((v) => (run += v));
664
664
  }
665
665
 
666
+ // Timestamped rows → [{ x, value }] ascending, one point per calendar day
667
+ // that HAS a row, value cumulative. The third consumer of this shape put it
668
+ // here (task 1003446): the profile's works and credits lines, and
669
+ // /drachmae's treasury. Two callers is a coincidence; three is a primitive.
670
+ //
671
+ // `dateOf`/`valueOf` are the caller's, because only the caller knows which
672
+ // column is the date — `shipped_at` on an activity row, `recorded_at` on a
673
+ // credits row, `day` on a gap-filled daily feed. A row whose date will not
674
+ // parse is DROPPED rather than counted at the epoch, where it would stretch
675
+ // the axis back to 1970 and flatten every real point against the floor.
676
+ function cumulateByDay(rows, opts = {}) {
677
+ const dateOf = opts.dateOf || ((r) => r && r.day);
678
+ const valueOf = opts.valueOf || ((r) => r && r.value);
679
+ const byDay = new Map();
680
+ for (const r of rows || []) {
681
+ const t = Date.parse(dateOf(r));
682
+ if (!Number.isFinite(t)) continue;
683
+ // The epoch-day floor — UTC midnight, so two rows on one day land on one
684
+ // point. Same value as setUTCHours(0,0,0,0) without a Date per row, and
685
+ // the same expression profile-charts.js's own `midnight` used before this
686
+ // moved, so no series shifts by a day.
687
+ const d = Math.floor(t / 86400000) * 86400000;
688
+ byDay.set(d, (byDay.get(d) || 0) + (Number(valueOf(r)) || 0));
689
+ }
690
+ const days = [...byDay.keys()].sort((a, b) => a - b);
691
+ const totals = cumulate(days.map((d) => byDay.get(d)));
692
+ return days.map((d, i) => ({ x: d, value: totals[i] }));
693
+ }
694
+
666
695
  // The line geometry, separated from the markup so a chart that needs the
667
696
  // maths without this SVG can take just this. Returns { points, x, y, nums }.
668
697
  //
@@ -867,21 +896,30 @@
867
896
  return { body, xs, base, py, max };
868
897
  }
869
898
 
870
- // The plot itself: the series a geometry built, on the axis, with the hidden
871
- // crosshair a caller's readout moves and the two end ticks. `tabindex="0"` is
872
- // deliberate and paired with the readout — the chart is walkable by arrow
873
- // key, so it has to be reachable by tab.
899
+ // The plot itself: the series a geometry built, on the axis, with the two end
900
+ // ticks — and, when the caller wires a readout, the hidden crosshair it moves
901
+ // plus the `tabindex` that makes an arrow-key-walkable chart reachable by tab.
902
+ //
903
+ // `interactive: false` (task 1003446) drops BOTH of those, and the pairing is
904
+ // the point: a `role="img"` with a `tabindex="0"` is a focus stop a keyboard
905
+ // reader lands on with nothing to do there, and a crosshair nothing moves is
906
+ // markup that only looks like a feature. A plot whose whole reading is its
907
+ // aria-label and its table twin says so.
874
908
  function plotSvgHtml(opts = {}) {
875
909
  const { width, height, pad, base } = plotBox(opts);
910
+ const interactive = opts.interactive !== false;
876
911
  const tick = (x, text, anchor) => (text
877
912
  ? `<text class="kit-plot__tick" x="${x}" y="${height - 6}"`
878
913
  + `${anchor ? ` text-anchor="${anchor}"` : ''}>${esc(text)}</text>`
879
914
  : '');
880
- return `<svg class="kit-plot" viewBox="0 0 ${width} ${height}" role="img" tabindex="0"`
915
+ return `<svg class="kit-plot" viewBox="0 0 ${width} ${height}" role="img"`
916
+ + (interactive ? ' tabindex="0"' : '')
881
917
  + ` aria-label="${esc(opts.ariaLabel || '')}">`
882
918
  + (opts.series || '')
883
919
  + `<line class="kit-plot__axis" x1="${pad.l}" y1="${base}" x2="${width - pad.r}" y2="${base}"></line>`
884
- + `<line class="kit-plot__hair" x1="0" y1="${pad.t}" x2="0" y2="${base}" style="visibility:hidden"></line>`
920
+ + (interactive
921
+ ? `<line class="kit-plot__hair" x1="0" y1="${pad.t}" x2="0" y2="${base}" style="visibility:hidden"></line>`
922
+ : '')
885
923
  + (opts.label || '')
886
924
  + tick(pad.l, opts.leftTick)
887
925
  + tick(width - pad.r, opts.rightTick, 'end')
@@ -918,7 +956,7 @@
918
956
  // charts (task 1003445) — the ONE place the hall draws a series.
919
957
  // linePoints/cumulate are the maths on their own, for a chart that wants
920
958
  // the geometry without this SVG (the profile's timeChart).
921
- sparklineHtml, barSeriesHtml, chartCardHtml, linePoints, cumulate,
959
+ sparklineHtml, barSeriesHtml, chartCardHtml, linePoints, cumulate, cumulateByDay,
922
960
  // plotted charts: the axis frame + the two series shapes that ride it
923
961
  plotSvgHtml, plotValueLabelHtml, timeStepSeriesHtml, timeStepGeometry, columnSeriesHtml,
924
962
  // plotBox is exported so a caller mapping pointer x back into viewBox
@@ -101,20 +101,13 @@
101
101
  // point; a row with no parsable date is skipped, not zeroed. The lookback
102
102
  // clips this for drawing (clipToWindow) rather than re-accumulating, so the
103
103
  // running total stays true.
104
+ // The grouping AND the running total are the kit's since task 1003446, when
105
+ // /drachmae's treasury became the third caller of this shape. This wrapper
106
+ // survives only to keep the `{ day, value }` key this page's callers and
107
+ // clipToWindow already read; the arithmetic has one home.
104
108
  function cumulativeByDay(rows, valueOf, dateOf = (r) => r && r.shipped_at) {
105
- const byDay = new Map();
106
- for (const r of rows || []) {
107
- const t = Date.parse(dateOf(r));
108
- if (!Number.isFinite(t)) continue;
109
- const d = midnight(t);
110
- byDay.set(d, (byDay.get(d) || 0) + Number(valueOf(r)) || 0);
111
- }
112
- // The grouping is this function's; the running total is the kit's
113
- // (task 1003445), so the hall has ONE accumulator rather than two that
114
- // could disagree about what a hole in the series means.
115
- const days = [...byDay.keys()].sort((a, b) => a - b);
116
- const totals = window.OTBKit.cumulate(days.map((d) => byDay.get(d)));
117
- return days.map((d, i) => ({ day: d, value: totals[i] }));
109
+ return window.OTBKit.cumulateByDay(rows, { dateOf, valueOf })
110
+ .map((p) => ({ day: p.x, value: p.value }));
118
111
  }
119
112
 
120
113
  // Clip a cumulative series to [windowStart, ∞) for drawing. The line must
@@ -119,41 +119,12 @@
119
119
  (glass on the card, like settings' quote), the series in the accent and every
120
120
  word in ink. The SVG scales with its card; the axis band is inside the
121
121
  viewBox, so the card grows with its content and never scrolls. */
122
- .pcharts {
123
- display: grid;
124
- grid-template-columns: repeat(2, minmax(0, 1fr));
125
- gap: var(--sp-sm);
126
- }
127
- .pchart {
128
- margin: 0;
129
- padding: var(--sp-xs) var(--sp-sm) var(--sp-xs);
130
- background: var(--glass);
131
- border: var(--line) solid var(--glass-edge);
132
- border-radius: var(--radius-sm);
133
- min-width: 0;
134
- }
135
- .pchart__title { margin: 0; font-size: 13.5px; font-weight: 500; color: var(--ink); }
136
- .pchart__read { margin: 2px 0 var(--sp-hair); font-family: var(--label-face); font-size: var(--label-size); letter-spacing: var(--label-space); text-transform: var(--label-case); color: var(--ink-soft); min-height: 1.4em; }
137
- /* The plot itself — the svg, the line, the columns, the axis, the crosshair,
138
- the ticks — is OTBKit's `.kit-plot*` in style.css (task 1003445). It was
139
- written twice in this file's own script, and the goal's next charts want it
140
- again. The dataviz spec moved verbatim; what stays below is this page's
141
- FIGURE: the caption, the live readout, the table twin and the foot. */
142
- .pchart__twin { margin-top: var(--sp-hair); }
143
- .pchart__twin > summary { display: inline-flex; align-items: center; min-height: 24px; padding: 0 4px; margin: 0 -4px; font-family: var(--label-face); font-size: var(--label-size); letter-spacing: var(--label-space); text-transform: var(--label-case); color: var(--ink-soft); cursor: pointer; border-radius: var(--radius-sm); }
144
- .pchart__twin > summary:hover { color: var(--ink); background: var(--glass-strong); }
145
- .pchart__table { margin-top: var(--sp-hair); font-size: 12.5px; }
146
- .pchart__table td, .pchart__table th { padding: 3px 6px; }
147
- .pchart__foot { margin: var(--sp-hair) 0 0; font-size: 12px; color: var(--ink-faint); }
148
- .pchart__note { margin: 0; font-size: 14px; color: var(--ink-soft); }
149
- /* the craft mix: label, bar, count — a row is its own table row */
150
- /* The craft mix is OTBKit.barSeriesHtml (task 1003445) — the .pmix family was
151
- the hall's third copy of one horizontal bar. Only the vertical air around it
152
- is this page's; the row, the track and the 24px floor are the kit's. */
153
- .pchart .kit-bars { margin: var(--sp-hair) 0; }
154
- @media (max-width: 720px) {
155
- .pcharts { grid-template-columns: minmax(0, 1fr); }
156
- }
122
+ /* The plot is OTBKit's `.kit-plot*` (task 1003445) and the FIGURE around it —
123
+ `.pcharts` / `.pchart` / `__title` / `__read` / `__twin` / `__table` /
124
+ `__foot` — moved to style.css beside it in task 1003446, when /drachmae's
125
+ treasury became its second consumer. The names did not change: they are
126
+ spelled into profile.js AND drachmae.js. What stays in this sheet is the one
127
+ thing only this page has, the lookback control below. */
157
128
 
158
129
  /* the lookback control (task 1003489, owner request) — one segmented row above
159
130
  the grid, scoping every chart. The pressed side takes the accent TINT the
@@ -2047,6 +2047,41 @@ main [id] { scroll-margin-top: calc(var(--topbar-h) + 16px); }
2047
2047
  .kit-plot__end { font-family: var(--font-body); font-size: 11.5px; font-weight: 500; fill: var(--ink); }
2048
2048
  .kit-plot__tick { font-family: var(--label-face); font-size: 10px; letter-spacing: var(--label-space); text-transform: var(--label-case); fill: var(--ink-faint); }
2049
2049
 
2050
+ /* The FIGURE a plot sits in — a card one tonal step down from the panel (glass
2051
+ on the card, like settings' quote), its caption, its live readout, its
2052
+ <details> table twin and its foot. Moved here from profile.css in task
2053
+ 1003446, when /drachmae's treasury became the second consumer; the `.pchart`
2054
+ names are unchanged because they are spelled into profile.js and drachmae.js.
2055
+ The kit does NOT emit these — a page composes its own figure and the kit
2056
+ draws the series inside it. */
2057
+ .pcharts {
2058
+ display: grid;
2059
+ grid-template-columns: repeat(2, minmax(0, 1fr));
2060
+ gap: var(--sp-sm);
2061
+ }
2062
+ .pchart {
2063
+ margin: 0;
2064
+ padding: var(--sp-xs) var(--sp-sm) var(--sp-xs);
2065
+ background: var(--glass);
2066
+ border: var(--line) solid var(--glass-edge);
2067
+ border-radius: var(--radius-sm);
2068
+ min-width: 0;
2069
+ }
2070
+ .pchart__title { margin: 0; font-size: 13.5px; font-weight: 500; color: var(--ink); }
2071
+ .pchart__read { margin: 2px 0 var(--sp-hair); font-family: var(--label-face); font-size: var(--label-size); letter-spacing: var(--label-space); text-transform: var(--label-case); color: var(--ink-soft); min-height: 1.4em; }
2072
+ .pchart__twin { margin-top: var(--sp-hair); }
2073
+ .pchart__twin > summary { display: inline-flex; align-items: center; min-height: 24px; padding: 0 4px; margin: 0 -4px; font-family: var(--label-face); font-size: var(--label-size); letter-spacing: var(--label-space); text-transform: var(--label-case); color: var(--ink-soft); cursor: pointer; border-radius: var(--radius-sm); }
2074
+ .pchart__twin > summary:hover { color: var(--ink); background: var(--glass-strong); }
2075
+ .pchart__table { margin-top: var(--sp-hair); font-size: 12.5px; }
2076
+ .pchart__table td, .pchart__table th { padding: 3px 6px; }
2077
+ .pchart__foot { margin: var(--sp-hair) 0 0; font-size: 12px; color: var(--ink-faint); }
2078
+ .pchart__note { margin: 0; font-size: 14px; color: var(--ink-soft); }
2079
+ /* The bar series inside a figure keeps the figure's own vertical air. */
2080
+ .pchart .kit-bars { margin: var(--sp-hair) 0; }
2081
+ @media (max-width: 720px) {
2082
+ .pcharts { grid-template-columns: minmax(0, 1fr); }
2083
+ }
2084
+
2050
2085
  .curve__kinds { display: flex; flex-direction: column; gap: 6px; margin-top: 16px; }
2051
2086
 
2052
2087
  /* ---------- onboarding checklist ---------- */
@@ -0,0 +1,60 @@
1
+ -- provisioning_014_module_selection.sql — where the creation picker's chosen
2
+ -- module set lands (BV1.R20, task 1002339; goal 1000046 criterion
3
+ -- `proj-module-select`).
4
+ --
5
+ -- R19 (task 1002338, ADR 0237) declared the starter bundles and served them, and
6
+ -- closed with "where a chosen selection lands is R20's decision — there is still
7
+ -- no module-selection column on any table". This is that column.
8
+ --
9
+ -- ONE column, jsonb, NULLABLE — and the nullability is the contract, not an
10
+ -- oversight:
11
+ --
12
+ -- NULL the owner never answered. They skipped the picker, or the request was
13
+ -- filed by an API caller / the CLI that does not ask. The set they get
14
+ -- is resolved at READ time from the type's starter bundle
15
+ -- (starter-bundles.effectiveModules), so skipping yields exactly the
16
+ -- preselect the panel showed — the same declaration on both ends.
17
+ -- '[]' the owner answered "none of the opt-ins". A real choice, and a
18
+ -- different fact from never having been asked. A NOT NULL DEFAULT '[]'
19
+ -- would have collapsed the two and made a skip unreadable forever.
20
+ -- '[…]' the keys the owner picked.
21
+ --
22
+ -- This is the `detail` rule from provisioning_013 applied to a list: absent stays
23
+ -- absent, skipped stays skipped, and nothing is ever back-filled with a guess.
24
+ --
25
+ -- WHY NOT `detail` ITSELF. provisioning_013's comment named R19 as a reason the
26
+ -- column was jsonb, but ADR 0237 §2 then ruled the other way: `detail` holds the
27
+ -- INTERVIEW's answers under PROJECT_DETAIL_VOCAB, a closed map of scalar
28
+ -- vocabularies that `normalizeProjectDetail` filters key-by-value. A list of
29
+ -- module keys is not that shape — carrying it there would have meant teaching the
30
+ -- interview's normalizer, its 400-ing route guard and its read projection about a
31
+ -- value type none of them have, to store something the interview deliberately
32
+ -- does not ask (ADR 0237 §2: "the interview does not ask a second time").
33
+ --
34
+ -- WHY NOT `settings`. A setting is a POLICY the running instance obeys — it rides
35
+ -- web.env and queues a `settings-apply` intent plus a restart. A module selection
36
+ -- is a declaration made before the instance exists; there is nothing running to
37
+ -- apply it to.
38
+ --
39
+ -- WHAT THE COLUMN IS AND IS NOT. It is the owner's DECLARATION, held on the
40
+ -- platform row until a catalog row materializes — the same standing `type` and
41
+ -- `description` have (provisioning_007 / _013). It is NOT yet an input to the
42
+ -- control-plane runner: `provisioning_intents` carries no payload, and turning a
43
+ -- stored selection into the child instance's own config/modules.json is a
44
+ -- separate piece of work (see the ADR's Consequences).
45
+ --
46
+ -- A module selection is NEVER a publish precondition — ADR 0182 D4 fixes that bar
47
+ -- at identity alone, and ADR 0237 §6 restates it for bundles.
48
+ --
49
+ -- Additive + provisioning_-namespaced (ADR 0083 §5): ADD COLUMN only, so the
50
+ -- previous code release still runs against this schema. Idempotent: safe to re-run.
51
+
52
+ BEGIN;
53
+
54
+ ALTER TABLE provisioning_instances
55
+ ADD COLUMN IF NOT EXISTS modules jsonb;
56
+
57
+ COMMENT ON COLUMN provisioning_instances.modules IS
58
+ 'The opt-in modules the owner chose in the creation picker (BV1.R20), as a jsonb array of module keys. NULLABLE and three-valued: NULL = never answered (the picker was skipped, or the caller does not ask) and the set resolves at read time from the declared type''s starter bundle; ''[]'' = the owner chose none of them, which is a real answer; ''["dev-box",…]'' = what they picked. The always-on core is never listed here — it is the floor every project gets (starter-bundles.ALWAYS_ON_CORE), not a choice. Read through starter-bundles.effectiveModules, never raw.';
59
+
60
+ COMMIT;
@@ -12,6 +12,14 @@
12
12
  // unit-testable with a fake `{ query }`. The route factory passes the doorway pool.
13
13
  'use strict';
14
14
 
15
+ // The one require in this file, and it stays inside the module: starter-bundles.js
16
+ // is the pure declaration beside it (ADR 0237 — no I/O, no core import), so the
17
+ // "no core require / fake `{ query }`" property above is untouched. It is here
18
+ // because the module SELECTION a row carries (BV1.R20, task 1002339) is three-
19
+ // valued, and the value it resolves to when nobody answered is the type's starter
20
+ // bundle — so storage and the read projection both have to read that declaration.
21
+ const { normalizeModuleSelection, effectiveModules } = require('./starter-bundles');
22
+
15
23
  // ---------------------------------------------------------------------------
16
24
  // Constants (mirror the CHECK constraints in provisioning_001_tables.sql)
17
25
  // ---------------------------------------------------------------------------
@@ -352,6 +360,13 @@ function publicInstance(row) {
352
360
  // missing" from a plain instance read, without a second endpoint.
353
361
  description: row.description || null,
354
362
  detail: projectDetail(row),
363
+ // The creation picker's module set (task 1002339): the always-on core, what
364
+ // the owner ends up with, and whether that came from their own toggles or
365
+ // from the type's starter bundle because they never answered. Resolved, not
366
+ // raw — a caller must never have to re-derive the skipped-means-the-bundle
367
+ // rule, which is the one way two surfaces would come to name different
368
+ // modules for the same project.
369
+ modules: effectiveModules(row),
355
370
  // The verification overlay (task 1002696, redesign direction §3): what the
356
371
  // heartbeat last OBSERVED, which is NOT what `status` claims. null = never
357
372
  // probed — a different claim from last_verified_up=false, and the UI renders
@@ -800,7 +815,7 @@ async function listAllInstances(db, { status } = {}) {
800
815
  // re-fetches the existing row and returns { created:false } so the ROUTE can
801
816
  // decide (same owner → idempotent re-request; different owner → 409). Never
802
817
  // silently reassigns ownership.
803
- async function createInstance(db, { ownerBuilderId, slug, targetRef, hostingShape, domain, dbName, port, tier, onboardMode, type, description, detail }) {
818
+ async function createInstance(db, { ownerBuilderId, slug, targetRef, hostingShape, domain, dbName, port, tier, onboardMode, type, description, detail, modules }) {
804
819
  const shape = HOSTING_SHAPES.includes(hostingShape) ? hostingShape : 'co-tenant';
805
820
  // Unknown/absent → 'greenfield', matching the column default. Falling back to the
806
821
  // SAFE mode matters: greenfield refuses to push over existing history (no --force
@@ -815,16 +830,21 @@ async function createInstance(db, { ownerBuilderId, slug, targetRef, hostingShap
815
830
  // updateInstanceDetail is the way back.
816
831
  const told = normalizeDescription(description);
817
832
  const answers = normalizeProjectDetail(detail);
833
+ // The creation picker's selection (task 1002339). NULL when the owner never
834
+ // answered — the column is nullable precisely so a skip stays readable as a
835
+ // skip, and effectiveModules then resolves the type's bundle at read time. An
836
+ // explicit empty array is a real answer and is stored as one.
837
+ const picked = normalizeModuleSelection(modules);
818
838
  const cost = costEstimateUsd({ hostingShape: shape, tier });
819
839
  try {
820
840
  const { rows } = await db.query(
821
841
  `INSERT INTO provisioning_instances
822
- (owner_builder_id, slug, target_ref, hosting_shape, domain, db_name, port, tier, cost_estimate_usd, onboard_mode, type, description, detail)
823
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb)
842
+ (owner_builder_id, slug, target_ref, hosting_shape, domain, db_name, port, tier, cost_estimate_usd, onboard_mode, type, description, detail, modules)
843
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13::jsonb, $14::jsonb)
824
844
  RETURNING *`,
825
845
  [ownerBuilderId, slug, targetRef ?? null, shape, domain ?? null,
826
846
  dbName ?? null, port ?? null, tier ?? null, cost, mode, declaredType,
827
- told, JSON.stringify(answers)]
847
+ told, JSON.stringify(answers), picked === null ? null : JSON.stringify(picked)]
828
848
  );
829
849
  return { instance: rows[0], created: true };
830
850
  } catch (e) {
@@ -36,7 +36,7 @@ const auth = api;
36
36
  const { pool, validateOrRespond, parseId } = api;
37
37
  const provisioning = require('../provisioning');
38
38
  const { onboardPlan, stepsForShape } = require('../onboard-plan');
39
- const { ALWAYS_ON_CORE, allStarterBundles } = require('../starter-bundles');
39
+ const { ALWAYS_ON_CORE, OPTIONAL_MODULES, allStarterBundles, optionalModules } = require('../starter-bundles');
40
40
  const { recommendationsForDetails } = require('../recommendations');
41
41
  const { makeAskRateLimiter, clientIp } = require('../rate-limit');
42
42
  // task 1003208: structured logging (pino via the doorway) — was console.*.
@@ -115,6 +115,30 @@ function badProjectDetail(detail) {
115
115
  return null;
116
116
  }
117
117
 
118
+ // The creation picker's module selection, validated LOUDLY (BV1.R20, task
119
+ // 1002339) — badProjectDetail's rule applied to a list. The strict body validator
120
+ // checks that `modules` is an array of strings; it cannot check MEMBERSHIP, and
121
+ // normalizeModuleSelection drops an unknown key silently on the way to storage.
122
+ // Silence is the wrong answer at the boundary: an API caller who names a module
123
+ // that does not exist, or one of the always-on / hub-only ones, has made a
124
+ // mistake and would otherwise be told nothing and handed a project without it.
125
+ // Returns null when clean, or a { code, message } to fail with.
126
+ function badModuleSelection(modules) {
127
+ if (modules === undefined || modules === null) return null;
128
+ if (!Array.isArray(modules)) {
129
+ return { code: 'bad_modules', message: 'modules must be an array of module keys.' };
130
+ }
131
+ const offered = OPTIONAL_MODULES.map((m) => m.key);
132
+ for (const key of modules) {
133
+ if (offered.includes(key)) continue;
134
+ if (ALWAYS_ON_CORE.includes(key)) {
135
+ return { code: 'bad_modules', message: `"${key}" is always on for every project — it is not one of the modules to choose.` };
136
+ }
137
+ return { code: 'bad_modules', message: `Unknown module "${key}" — the choices are: ${offered.join(', ')}.` };
138
+ }
139
+ return null;
140
+ }
141
+
118
142
  // Minimal HTML escaper for the callback landing page (no template engine here).
119
143
  function esc(s) {
120
144
  return String(s == null ? '' : s).replace(/[&<>"']/g, (c) => (
@@ -463,10 +487,20 @@ module.exports = function provisioningRoutes() {
463
487
  // (ADR 0182 D4), and PATCH …/detail is the way back.
464
488
  description: { type: 'string', maxLength: provisioning.DESCRIPTION_MAX },
465
489
  detail: { type: 'object' },
490
+ // The creation picker's chosen module set (BV1.R20, task 1002339) — the
491
+ // opt-in keys, never the always-on core. ABSENT means the owner skipped
492
+ // the picker, which is a supported default: the row stores NULL and the
493
+ // read resolves the declared type's starter bundle, so a skip yields
494
+ // exactly the set the panel preselected. An explicit [] is the different
495
+ // answer "none of them". maxItems is a sanity ceiling well above a catalog
496
+ // that is three modules wide today.
497
+ modules: { type: 'array', itemsType: 'string', maxItems: 50 },
466
498
  })) return;
467
499
  const body = req.body || {};
468
500
  const detailFault = badProjectDetail(body.detail);
469
501
  if (detailFault) return res.fail(detailFault.code, { status: 400, message: detailFault.message });
502
+ const modulesFault = badModuleSelection(body.modules);
503
+ if (modulesFault) return res.fail(modulesFault.code, { status: 400, message: modulesFault.message });
470
504
  const slug = String(body.slug || '').trim().toLowerCase();
471
505
  if (!provisioning.isValidSlug(slug)) {
472
506
  return res.fail('bad_slug', 400, { reason: 'lowercase, 2–40 chars of [a-z0-9-], no leading/trailing/double dash' });
@@ -552,6 +586,9 @@ module.exports = function provisioningRoutes() {
552
586
  // The optional detail step's answers (task 1002334) — absent means the
553
587
  // owner skipped it, which is the supported default, not an error.
554
588
  description: body.description, detail: body.detail,
589
+ // The picker's selection (task 1002339) — absent for the same reason and
590
+ // with the same standing: skipped, and resolved from the bundle on read.
591
+ modules: body.modules,
555
592
  });
556
593
  // Slug already taken by ANOTHER builder — refuse (never reassign ownership).
557
594
  if (!created && String(instance.owner_builder_id) !== String(req.builder.id)) {
@@ -615,6 +652,16 @@ module.exports = function provisioningRoutes() {
615
652
  hint: `a re-request never changes an existing instance's project detail — PATCH /provisioning/instances/${instance.id}/detail instead`,
616
653
  };
617
654
  }
655
+ // And the same for the picker's module set (task 1002339). Like `type` —
656
+ // and unlike detail — there is no mutation route yet (R23, task 1002342 is
657
+ // "add modules later"), so the receipt says the answer was kept rather
658
+ // than naming a way back that does not exist.
659
+ let modulesIgnored;
660
+ if (!created && body.modules !== undefined) {
661
+ modulesIgnored = {
662
+ hint: "a re-request never changes an existing instance's module selection",
663
+ };
664
+ }
618
665
  res.status(created ? 201 : 200).json({
619
666
  instance: ownerInstance(instance),
620
667
  open_intent: intent ? { action: intent.action, state: intent.state } : null,
@@ -624,6 +671,7 @@ module.exports = function provisioningRoutes() {
624
671
  ...(noAddressIgnored ? { no_address_ignored: noAddressIgnored } : {}),
625
672
  ...(typeIgnored ? { type_ignored: typeIgnored } : {}),
626
673
  ...(detailIgnored ? { detail_ignored: detailIgnored } : {}),
674
+ ...(modulesIgnored ? { modules_ignored: modulesIgnored } : {}),
627
675
  });
628
676
  } catch (err) {
629
677
  log.error('[provisioning] POST /provisioning/instances', err);
@@ -1280,8 +1328,17 @@ module.exports = function provisioningRoutes() {
1280
1328
  // rank: public — the signed-out hub wizard (/projects?view=new) reads it while
1281
1329
  // choosing a type, exactly like onboard-plan above; no write, no secret, and the
1282
1330
  // module catalog it names is already public at GET /public/modules.
1331
+ //
1332
+ // `optional` joined the payload for R20's picker (task 1002339): the opt-in
1333
+ // catalog with the owner-facing label + one-line summary each row renders. It
1334
+ // lives here rather than at a module-catalog route because the two things a
1335
+ // picker needs — what is preselected and what may be chosen — have to arrive
1336
+ // in ONE read of ONE declaration, or the panel can preselect a key it has no
1337
+ // row for. The alternatives were both worse: `GET /modules` needs a session
1338
+ // (this wizard is read signed-out) and `GET /public/modules` serves booleans,
1339
+ // not copy. Additive — `core` and `bundles` keep their shape.
1283
1340
  router.get('/provisioning/starter-bundles', (req, res) => {
1284
- res.json({ core: [...ALWAYS_ON_CORE], bundles: allStarterBundles() });
1341
+ res.json({ core: [...ALWAYS_ON_CORE], bundles: allStarterBundles(), optional: optionalModules() });
1285
1342
  });
1286
1343
 
1287
1344
  // GET /provisioning/recommendations?type=<project type>&team_shape=<interview answer>