@bongos/core 1.19.879 → 1.19.881

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.
@@ -2232,5 +2232,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2232
2232
  landed since 1.19.877 with no explicit bump. run 35439004814. (task 1002620)
2233
2233
  1.19.879 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2234
2234
  landed since 1.19.878 with no explicit bump. run 35439529685. (task 1002620)
2235
+ 1.19.880 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2236
+ landed since 1.19.879 with no explicit bump. run 35453118533. (task 1002620)
2237
+ 1.19.881 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2238
+ landed since 1.19.880 with no explicit bump. run 35455207976. (task 1002620)
2235
2239
  ---------------------------------------------------------------------------
2236
2240
  ```
@@ -21,8 +21,8 @@ Keep it durable framing, not live state (live state belongs in the build platfor
21
21
  do not apply to this project. Delete this comment block when done.
22
22
  -->
23
23
 
24
- <One paragraph: what this product is, who it's for, and the single idea that makes it distinct. The full
25
- "two tracks / how work is sorted" framing lives in [`CLAUDE.md`](../CLAUDE.md) §1.>
24
+ <One paragraph: what this product is, who it's for, and the single idea that makes it distinct. How work is
25
+ sorted — versions, and the role each task carries — is framed in [`CLAUDE.md`](../CLAUDE.md) §8.>
26
26
 
27
27
  ---
28
28
 
@@ -146,7 +146,10 @@
146
146
  for (const k of list) {
147
147
  const opt = document.createElement('option');
148
148
  opt.value = k;
149
- opt.textContent = label(k);
149
+ // The unset bucket is named for a reader on EVERY surface, filters included
150
+ // — `label` is the caller's choice for the real kinds, never for this one
151
+ // (task 1004038). roleOptions() names the role twin the same way.
152
+ opt.textContent = kindIsUnset(k) ? kindLabel(k) : label(k);
150
153
  sel.appendChild(opt);
151
154
  }
152
155
  // A refill must not drop the reader's pick; `selected` is the filing default.
@@ -154,10 +157,23 @@
154
157
  if (want) sel.value = want;
155
158
  }
156
159
 
160
+ // A task with no kind carries the DB default 'unclassified' — the twin of
161
+ // ROLE_UNSET one axis over (task 1004038, filed by task 1003936 which fixed the
162
+ // role axis and left this one). A word a column chose is not a word a person
163
+ // should read (docs/voice.md V2), so a picker shows "No kind yet" and a row tag
164
+ // renders NOTHING. kindIsUnset() is how a caller asks. The STORED value and the
165
+ // filter VALUE stay 'unclassified'; only what is read changes.
166
+ const KIND_UNSET = 'unclassified';
167
+ function kindIsUnset(k) { return !k || String(k) === KIND_UNSET; }
168
+
169
+ // NOT ESCAPE-SAFE: like roleLabel() below, this returns a transformed copy of
170
+ // its input, so a caller rendering it into markup must escapeHtml() the result.
171
+ // Every caller today assigns it to textContent, which escapes for them.
157
172
  // How a kind is named in a picker — 'blocker-resolution' → 'Blocker resolution'.
158
173
  // The same shape roleLabel takes below, and it reproduces every label the
159
174
  // hand-written <option> list in ideas.html carried, so no copy changes.
160
175
  function kindLabel(k) {
176
+ if (kindIsUnset(k)) return 'No kind yet';
161
177
  const s = String(k || '').replace(/-/g, ' ');
162
178
  return s ? s.charAt(0).toUpperCase() + s.slice(1) : '';
163
179
  }
@@ -407,7 +423,7 @@
407
423
  escapeHtml, $, fmtNum, num, fmtInt, fmtCompact, fmtDate, rankLabel, toast, moduleOn, disciplines,
408
424
  // task 1003936 — ONE word for the role axis, all six surfaces
409
425
  roleAxisLabel, roleLabel, roleOptions, roleIsUnset, ROLE_UNSET,
410
- fillKindFilter, kindLabel, // task 1003453 — one Kind filler, all three surfaces
426
+ fillKindFilter, kindLabel, kindIsUnset, KIND_UNSET, // task 1003453 — one Kind filler, all three surfaces
411
427
  errCode, errMessage, errDetails, // task 1002462 — ADR 0116 envelope readers
412
428
  contributeSettingsPanel, settingsPanels,
413
429
  contributeHallWidget, hallWidgets, hallWidgetIds,
@@ -321,6 +321,11 @@
321
321
  const disciplineTag = window.OTB.roleIsUnset(t.discipline)
322
322
  ? ''
323
323
  : `<span class="task__tag">${escapeHtml(window.OTB.roleLabel(t.discipline))}</span>`;
324
+ // A kind nobody set is no tag at all, not the column's default word — the
325
+ // role tag above is the precedent this follows (task 1004038).
326
+ const kindTag = window.OTB.kindIsUnset(t.kind)
327
+ ? ''
328
+ : `<span class="task__tag">${escapeHtml(t.kind)}</span>`;
324
329
  const newcomerTag = t.newcomer_friendly ? '<span class="task__tag task__tag--newcomer">newcomer</span>' : '';
325
330
  const rankTag = rankTagHtml(t.requires_rank);
326
331
  const touches = Array.isArray(t.touches) ? t.touches : [];
@@ -361,7 +366,7 @@
361
366
  <p class="task__title"><a class="task__link" href="${escapeHtml(taskHref(id))}">${escapeHtml(t.title || '')}</a></p>
362
367
  <div class="task__meta">
363
368
  <span class="task__tag">${escapeHtml(t.version_id || '?')}</span>
364
- <span class="task__tag">${escapeHtml(t.kind || 'unclassified')}</span>
369
+ ${kindTag}
365
370
  ${disciplineTag}
366
371
  ${newcomerTag}
367
372
  ${rankTag}
@@ -403,7 +408,7 @@
403
408
  <tr class="ledger__row--task" data-task-id="${escapeHtml(String(t.id))}" style="cursor:pointer" title="Open task #${escapeHtml(String(t.id))}">
404
409
  <td class="ledger__num">#${escapeHtml(String(t.id))}</td>
405
410
  <td>${escapeHtml(t.title || '')}</td>
406
- <td>${escapeHtml(t.kind || '—')}</td>
411
+ <td>${window.OTB.kindIsUnset(t.kind) ? '—' : escapeHtml(t.kind)}</td>
407
412
  <td class="ledger__drachmae">${fmtNum(creditVal(t))}${taskSettled(t) ? '' : ' <span class="ledger__est" title="Estimate — credits settle when the task is confirmed">est.</span>'}</td>
408
413
  <td class="ledger__date">${fmtDate(t.shipped_at)}</td>
409
414
  </tr>`).join('');
@@ -502,7 +507,7 @@
502
507
  const sortBy = $('#f-goal-sort')?.value || 'cpm';
503
508
  let list = state.work.todo.filter((t) => {
504
509
  if (!matchesTodoSearch(t, sQuery)) return false;
505
- if (kFilter && (t.kind || 'unclassified') !== kFilter) return false;
510
+ if (kFilter && (t.kind || window.OTB.KIND_UNSET) !== kFilter) return false;
506
511
  if (dFilter && (t.discipline || window.OTB.ROLE_UNSET) !== dFilter) return false;
507
512
  if (nFilter === 'yes' && !t.newcomer_friendly) return false;
508
513
  return true;
@@ -42,7 +42,6 @@
42
42
  // doesn't pick up the wrong picture. The image paths resolve at
43
43
  // /builders/diagrams/* (mounted in server.js).
44
44
  const DIAGRAM_AT_HEADING = [
45
- { match: 'two tracks', src: '/builders/diagrams/05-two-track-versioning.png', alt: 'The two tracks: product (the game) and internal (the dev system)' },
46
45
  { match: 'claim → ship lifecycle', src: '/builders/diagrams/01-task-lifecycle.png', alt: 'The full task lifecycle from backlog through shipped' },
47
46
  { match: 'rank ladder', src: '/builders/diagrams/02-rank-ladder.png', alt: 'The rank ladder — the ranks a builder can hold and how to rise' },
48
47
  { match: 'drachmae and karma', src: '/builders/diagrams/03-drachmae-karma.png', alt: 'How the reward currency and karma differ — pay for shipped work vs. reputation signals' },
@@ -38,17 +38,6 @@ body[data-page="roadmap"] {
38
38
  /* ---- shared atoms ----
39
39
  The chips are the deck's label voice: they name a KIND, so they take the
40
40
  label tokens rather than a hand-written uppercase of their own. */
41
- .rm-chip { display:inline-flex; align-items:center; gap:5px;
42
- font-family:var(--label-face); font-size:var(--label-size); font-weight:400;
43
- letter-spacing:var(--label-space); text-transform:var(--label-case);
44
- padding:4px 9px; border-radius:var(--radius-pill); white-space:nowrap; }
45
- .rm-chip .rm-sq { width:7px; height:7px; border-radius:2px; }
46
- .rm-chip--product { background:var(--accent-tint); color:var(--accent-ink); }
47
- .rm-chip--product .rm-sq { background:var(--accent); }
48
- .rm-chip--internal { background:var(--info-weak); color:var(--info); }
49
- .rm-chip--internal .rm-sq { background:var(--sea); }
50
- .rm-chip--dogfood { background:var(--rm-terra-weak); color:color-mix(in srgb, var(--terracotta) 82%, var(--ink)); }
51
- .rm-chip--dogfood .rm-sq { background:var(--terracotta); }
52
41
 
53
42
  .rm-phase { display:inline-flex; align-items:center; gap:6px; font-size:11.5px; font-weight:500;
54
43
  padding:4px 10px; border-radius:var(--radius-pill); white-space:nowrap; border:var(--line) solid transparent; }
@@ -13,7 +13,7 @@
13
13
  // links to.
14
14
  //
15
15
  // Reads (all GET): /public/progress (lifecycle rollup + counts + %, public),
16
- // /public/versions (track + started/shipped dates, public), and — for every
16
+ // /public/versions (started/shipped dates, public), and — for every
17
17
  // non-shipped version only — /goals?version=X + /goals/:id (per-goal criteria to
18
18
  // split in-progress from not-started). Shipped versions render as a pill strip
19
19
  // with no goal fetch. No writes. Server page gate: SIGNEDIN_BUILDERS_PAGE_RE.
@@ -37,11 +37,6 @@
37
37
  }
38
38
 
39
39
  const MON = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
40
- const TRACK = {
41
- product: { label: 'Product', cls: 'product' },
42
- internal: { label: 'Platform', cls: 'internal' },
43
- default: { label: 'Dogfood', cls: 'dogfood' },
44
- };
45
40
  const PHASE = {
46
41
  building: { label: 'In flight', cls: 'building' },
47
42
  planning: { label: 'Staged', cls: 'planning' },
@@ -60,9 +55,7 @@
60
55
 
61
56
  const pctOf = (n, d) => (d > 0 ? (n / d) * 100 : 0);
62
57
  const ym = (s) => { if (!s) return ''; const d = new Date(s); return Number.isNaN(d.getTime()) ? '' : `${MON[d.getMonth()]} ${d.getFullYear()}`; };
63
- const trackOf = (v) => TRACK[v.track] || TRACK.internal;
64
58
 
65
- const chip = (v) => { const t = trackOf(v); return `<span class="rm-chip rm-chip--${t.cls}"><span class="rm-sq"></span>${t.label}</span>`; };
66
59
  const phase = (v) => { const p = PHASE[v.status]; return p ? `<span class="rm-phase rm-phase--${p.cls}"><span class="rm-pd"></span>${p.label}</span>` : ''; };
67
60
 
68
61
  function ring(v) {
@@ -162,7 +155,7 @@
162
155
  <div class="rm-featgrid__l">
163
156
  <div class="rm-feat-top">
164
157
  <div class="rt">
165
- <div class="rm-row">${chip(v)}${phase(v)}<span class="rm-id">${escapeHtml(v.id)}</span></div>
158
+ <div class="rm-row">${phase(v)}<span class="rm-id">${escapeHtml(v.id)}</span></div>
166
159
  <h3 class="rm-name">${escapeHtml(v.name)}</h3>
167
160
  <p class="rm-desc rm-desc--clamp">${escapeHtml(v.desc)}</p>
168
161
  </div>
@@ -183,7 +176,7 @@
183
176
  return `<article class="rm-card">
184
177
  <div class="rm-feat-top">
185
178
  <div class="rt">
186
- <div class="rm-row">${chip(v)}${phase(v)}<span class="rm-id">${escapeHtml(v.id)}</span></div>
179
+ <div class="rm-row">${phase(v)}<span class="rm-id">${escapeHtml(v.id)}</span></div>
187
180
  <h3 class="rm-name">${escapeHtml(v.name)}</h3>
188
181
  <p class="rm-desc rm-desc--clamp">${escapeHtml(v.desc)}</p>
189
182
  </div>
@@ -225,7 +218,7 @@
225
218
  return `<article class="rm-card rm-card--staged" style="grid-column:1 / -1">
226
219
  <div class="rm-featgrid">
227
220
  <div class="rm-featgrid__l">
228
- <div class="rm-row">${chip(v)}${phase(v)}<span class="rm-id">${escapeHtml(v.id)}</span></div>
221
+ <div class="rm-row">${phase(v)}<span class="rm-id">${escapeHtml(v.id)}</span></div>
229
222
  <h3 class="rm-name">${escapeHtml(v.name)}</h3>
230
223
  <p class="rm-desc rm-desc--clamp">${escapeHtml(v.desc)}</p>
231
224
  <div class="rm-staged-state">
@@ -243,7 +236,7 @@
243
236
  return `<article class="rm-card rm-card--parked" style="grid-column:1 / -1">
244
237
  <div class="rm-feat-top">
245
238
  <div class="rt">
246
- <div class="rm-row">${chip(v)}${phase(v)}<span class="rm-id">${escapeHtml(v.id)}</span></div>
239
+ <div class="rm-row">${phase(v)}<span class="rm-id">${escapeHtml(v.id)}</span></div>
247
240
  <h3 class="rm-name">${escapeHtml(v.name)}</h3>
248
241
  <p class="rm-desc rm-desc--clamp">${escapeHtml(v.desc)}</p>
249
242
  </div>
@@ -353,8 +346,8 @@
353
346
  }
354
347
  }
355
348
 
356
- // Merge /public/progress (counts, %, lifecycle) with /public/versions (track,
357
- // dates) into one version model the renderers consume.
349
+ // Merge /public/progress (counts, %, lifecycle) with /public/versions (dates)
350
+ // into one version model the renderers consume.
358
351
  function mergeVersions(progress, versions) {
359
352
  const meta = new Map();
360
353
  (versions || []).forEach((v) => meta.set(v.id, v));
@@ -363,7 +356,6 @@
363
356
  return {
364
357
  id: p.version_id,
365
358
  name: p.name || m.name || p.version_id,
366
- track: m.track || 'internal',
367
359
  status: p.status || m.status || 'building',
368
360
  desc: (p.done_when || m.done_when || '').trim(),
369
361
  shipped: Number(p.shipped_count) || 0,
@@ -536,8 +536,12 @@
536
536
  // word is the thing the round's stat-tile reference exists to fix.
537
537
  const meta = [];
538
538
  if (t.version_id) meta.push(`<b>${escapeHtml(t.version_id)}</b>`);
539
- if (t.kind) meta.push(escapeHtml(t.kind));
540
- if (t.discipline && t.discipline !== 'unclassified') meta.push(escapeHtml(t.discipline));
539
+ // Neither axis says the word its column defaults to (task 1004038). `if
540
+ // (t.kind)` was never a guard for it: 'unclassified' is a truthy string, so
541
+ // the run read it out loud. The role line below asked the same question with
542
+ // a literal — it is right, but a copy of roleIsUnset() is free to drift.
543
+ if (!window.OTB.kindIsUnset(t.kind)) meta.push(escapeHtml(t.kind));
544
+ if (!window.OTB.roleIsUnset(t.discipline)) meta.push(escapeHtml(t.discipline));
541
545
  // rankText() answers "any rank" for an open task, which read as
542
546
  // "min rank any rank" once the label was prefixed. An open floor is not a
543
547
  // minimum, so it is said as the absence of one.
@@ -429,6 +429,8 @@
429
429
  const disciplineTag = window.OTB.roleIsUnset(t.discipline)
430
430
  ? ''
431
431
  : `<span class="task__tag">${escapeHtml(window.OTB.roleLabel(t.discipline))}</span>`;
432
+ // A kind nobody set is no tag at all, like the role above (task 1004038).
433
+ const kindTag = window.OTB.kindIsUnset(t.kind) ? '' : `<span class="task__tag">${escapeHtml(t.kind)}</span>`;
432
434
  const newcomerTag = t.newcomer_friendly
433
435
  ? '<span class="task__tag task__tag--newcomer">newcomer</span>'
434
436
  : '';
@@ -459,7 +461,7 @@
459
461
  <p class="task__title"><a class="task__link" href="${escapeHtml(taskHref(t.id))}">${escapeHtml(t.title)}</a></p>
460
462
  <div class="task__meta">
461
463
  <span class="task__tag">${escapeHtml(t.version_id || '?')}</span>
462
- <span class="task__tag">${escapeHtml(t.kind || 'unclassified')}</span>
464
+ ${kindTag}
463
465
  ${disciplineTag}
464
466
  ${newcomerTag}
465
467
  ${rankTag}
@@ -1142,7 +1144,7 @@
1142
1144
  <div class="history__row" data-task-id="${escapeHtml(String(t.id))}" title="Open task #${escapeHtml(String(t.id))}">
1143
1145
  <span class="history__id">#${escapeHtml(String(t.id))}</span>
1144
1146
  <span class="history__title">${escapeHtml(t.title || '(untitled)')} ${ver} ${disciplineTag} ${rankTagHtml(t.requires_rank)}<br>${goalTag}</span>
1145
- <span class="history__when">${escapeHtml(t.kind || '')}</span>
1147
+ <span class="history__when">${window.OTB.kindIsUnset(t.kind) ? '' : escapeHtml(t.kind)}</span>
1146
1148
  </div>`;
1147
1149
  }).join('');
1148
1150
  }
@@ -23,7 +23,7 @@ const { createGoal } = require('./db-goals.js');
23
23
 
24
24
  async function listVersions() {
25
25
  const { rows } = await pool.query(
26
- `SELECT id, name, track, status, scope_doc_path, started_at, shipped_at, done_when
26
+ `SELECT id, name, status, scope_doc_path, started_at, shipped_at, done_when
27
27
  FROM versions
28
28
  ORDER BY started_at`
29
29
  );
@@ -46,7 +46,7 @@ async function getVersion(id, deps = {}) {
46
46
  throw new Error('getVersion({ forUpdate }) requires deps.client — a row lock outside a transaction holds nothing');
47
47
  }
48
48
  const { rows } = await executorFor(deps, pool).query(
49
- `SELECT id, name, track, status, scope_doc_path, started_at, shipped_at, done_when
49
+ `SELECT id, name, status, scope_doc_path, started_at, shipped_at, done_when
50
50
  FROM versions WHERE id = $1${deps.forUpdate ? ' FOR UPDATE' : ''}`,
51
51
  [id]
52
52
  );
@@ -167,10 +167,14 @@ async function versionsWithStatus(status, deps = {}) {
167
167
  // goal-less criteria, and ADR 0183 criteria satisfy themselves off LINKED tasks.
168
168
  // Criteria nobody can link a task to can never close. Stamping goal_id here is
169
169
  // what gives them something to hang from.
170
- async function createVersion({ id, name, status = 'planning', track = 'product', scopeDocPath = null, doneWhen = null, criteria = [], firstGoal = null, createdBy = null }, deps = {}) {
171
- // versions.track is NOT NULL with no DB default (migration 003) — omitting it
172
- // here made every create 500. An instance's first version is its product
173
- // track, so that's the default; 'internal' is the platform's own track.
170
+ async function createVersion({ id, name, status = 'planning', scopeDocPath = null, doneWhen = null, criteria = [], firstGoal = null, createdBy = null }, deps = {}) {
171
+ // No `track`: the two-track (product/internal) model was retired by task
172
+ // 1002006 and its code unwound by task 1003665. versions.track survives as a
173
+ // column, NOT NULL but carrying DEFAULT 'product' since core_205, so an INSERT
174
+ // that omits it is legal — which is what lets this path stop naming it. The
175
+ // comment that used to sit here said the column had NO default; that was true
176
+ // when it was written and stale by core_205, and believing it is why five
177
+ // insert paths kept supplying a value nobody read.
174
178
  //
175
179
  // deps.pool is the module's existing test seam (listVersions, versionProgress):
176
180
  // honoured ONLY under NODE_ENV=test, so production can never be pointed at an
@@ -179,10 +183,10 @@ async function createVersion({ id, name, status = 'planning', track = 'product',
179
183
  const txPool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
180
184
  return withTx(async (client) => {
181
185
  const { rows } = await client.query(
182
- `INSERT INTO versions (id, name, status, track, scope_doc_path, done_when)
183
- VALUES ($1, $2, $3, $4, $5, $6)
184
- RETURNING id, name, status, track, scope_doc_path, started_at, shipped_at, done_when`,
185
- [id, name, status, track, scopeDocPath, doneWhen]
186
+ `INSERT INTO versions (id, name, status, scope_doc_path, done_when)
187
+ VALUES ($1, $2, $3, $4, $5)
188
+ RETURNING id, name, status, scope_doc_path, started_at, shipped_at, done_when`,
189
+ [id, name, status, scopeDocPath, doneWhen]
186
190
  );
187
191
  const version = rows[0];
188
192
  // The first goal. Titled for the version it opens unless the caller names one —
@@ -340,7 +344,7 @@ async function promotePlanningVersion(exec) {
340
344
  SET status = 'building', started_at = COALESCE(started_at, now())
341
345
  WHERE id = (SELECT id FROM versions WHERE status = 'planning' ORDER BY id LIMIT 1)
342
346
  AND NOT EXISTS (SELECT 1 FROM versions WHERE status = 'building')
343
- RETURNING id, name, track, status, started_at`
347
+ RETURNING id, name, status, started_at`
344
348
  );
345
349
  return rows[0] ?? null;
346
350
  }
@@ -352,7 +356,7 @@ async function closeVersion({ versionId, plan = [], reason, planningVersionId =
352
356
  await client.query('BEGIN');
353
357
 
354
358
  const { rows: vRows } = await client.query(
355
- `SELECT id, name, track, status, scope_doc_path, started_at, shipped_at, done_when
359
+ `SELECT id, name, status, scope_doc_path, started_at, shipped_at, done_when
356
360
  FROM versions WHERE id = $1 FOR UPDATE`,
357
361
  [versionId]
358
362
  );
@@ -576,7 +580,7 @@ async function closeVersion({ versionId, plan = [], reason, planningVersionId =
576
580
  const { rows: closed } = await client.query(
577
581
  `UPDATE versions SET status = 'shipped', shipped_at = now()
578
582
  WHERE id = $1
579
- RETURNING id, name, track, status, scope_doc_path, started_at, shipped_at, done_when`,
583
+ RETURNING id, name, status, scope_doc_path, started_at, shipped_at, done_when`,
580
584
  [versionId]
581
585
  );
582
586
 
@@ -30,10 +30,6 @@ const { asyncHandler, validateOrRespond } = api;
30
30
  // 'building'; 'shipped'/'frozen' are end states reached via the close flow.
31
31
  const VALID_VERSION_STATUSES = new Set(['planning', 'building', 'shipped', 'frozen']);
32
32
 
33
- // versions.track CHECK (migration 003) — NOT NULL with no DB default, so the
34
- // create path must always supply one. 'product' is the caller-facing default:
35
- // an instance seeding its first version is seeding its product track.
36
- const VALID_VERSION_TRACKS = new Set(['product', 'internal']);
37
33
 
38
34
  module.exports = function buildVersionsRouter() {
39
35
  const router = express.Router();
@@ -99,7 +95,6 @@ module.exports = function buildVersionsRouter() {
99
95
  id: { required: true, type: 'string', maxLength: 64, minLength: 1 },
100
96
  name: { required: true, type: 'string', maxLength: 200, minLength: 1 },
101
97
  status: { type: 'string', maxLength: 32 },
102
- track: { type: 'string', maxLength: 16 },
103
98
  done_when: { type: 'string', maxLength: 8000 },
104
99
  scope_doc_path: { type: 'string', maxLength: 256 },
105
100
  criteria: { type: 'array', maxItems: 100 },
@@ -116,11 +111,6 @@ module.exports = function buildVersionsRouter() {
116
111
  return res.fail('bad_status', 400, { valid: Array.from(VALID_VERSION_STATUSES) });
117
112
  }
118
113
 
119
- const track = body.track || 'product';
120
- if (!VALID_VERSION_TRACKS.has(track)) {
121
- return res.fail('bad_track', 400, { valid: Array.from(VALID_VERSION_TRACKS) });
122
- }
123
-
124
114
  if (await db.getVersion(id)) {
125
115
  return res.fail('version_exists', { status: 409, message: `Version '${id}' already exists.` });
126
116
  }
@@ -187,7 +177,7 @@ module.exports = function buildVersionsRouter() {
187
177
  let version, goal, created;
188
178
  try {
189
179
  ({ version, goal, criteria: created } = await db.createVersion({
190
- id, name, status, track,
180
+ id, name, status,
191
181
  doneWhen: body.done_when ?? null,
192
182
  scopeDocPath: body.scope_doc_path ?? null,
193
183
  criteria,
@@ -65,7 +65,7 @@ async function autoCloseVersionIfComplete(exec, versionId) {
65
65
  AND g.status = 'open'
66
66
  AND ${NOT_MAINTENANCE}
67
67
  )
68
- RETURNING v.id, v.name, v.track, v.status, v.shipped_at`,
68
+ RETURNING v.id, v.name, v.status, v.shipped_at`,
69
69
  [versionId]
70
70
  );
71
71
  return rows[0] ?? null;
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.879",
3
+ "version": "1.19.881",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.879",
9
+ "version": "1.19.881",
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.879",
3
+ "version": "1.19.881",
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",
@@ -391,9 +391,6 @@ function versionPayload(spec) {
391
391
  id: v.id,
392
392
  name: v.name,
393
393
  status: v.status || 'building',
394
- // versions.track is NOT NULL (no DB default) — always send it explicitly.
395
- // An instance's first version is its product track unless the spec says otherwise.
396
- track: v.track === 'internal' ? 'internal' : 'product',
397
394
  done_when: v.done_when,
398
395
  criteria: [{ criterion_id: v.criterion_id ? slugify(v.criterion_id) : 'first-goal', criterion_md: v.done_when }],
399
396
  };
@@ -55,8 +55,9 @@ function migrateCmd(inst, { privileged = false } = {}) {
55
55
  // ONLY IF the versions table is empty: idempotent, safe to re-run, and it never
56
56
  // disturbs a project that already has versions. Mirrors seedViaApi's shape — V1
57
57
  // (status 'building') + one done-when criterion so /status has a finish line from day
58
- // one. `track` is set EXPLICITLY: versions.track is NOT NULL with no default (mig 112
59
- // dropped the CHECK), and 'product' matches the canonical V1 seed in 003_pms.sql.
58
+ // one. No `track` column: the two-track model was retired (task 1002006, unwound
59
+ // by task 1003665). The seed runs on a freshly-MIGRATED database, so core_205's
60
+ // DEFAULT 'product' is in place and an INSERT that omits the column is legal.
60
61
  //
61
62
  // The id/name/done_when come from instanceInitSpec so the starter version has ONE
62
63
  // source of truth (the same spec the greenfield `bongos init` seed uses). SQL string
@@ -68,8 +69,8 @@ function seedFirstVersionCmd(inst, { privileged = false } = {}) {
68
69
  const v = instanceInitSpec(inst).version;
69
70
  const q = (s) => `$v$${s}$v$`; // dollar-quoted literal (no value contains a single quote or $)
70
71
  const sql =
71
- `INSERT INTO versions (id, name, track, status, done_when) ` +
72
- `SELECT ${q(v.id)}, ${q(v.name)}, $v$product$v$, $v$building$v$, ${q(v.done_when)} ` +
72
+ `INSERT INTO versions (id, name, status, done_when) ` +
73
+ `SELECT ${q(v.id)}, ${q(v.name)}, $v$building$v$, ${q(v.done_when)} ` +
73
74
  `WHERE NOT EXISTS (SELECT 1 FROM versions); ` +
74
75
  `INSERT INTO done_when_criteria (version_id, criterion_id, criterion_md, sort_order) ` +
75
76
  `SELECT ${q(v.id)}, $v$first-goal$v$, ${q(v.done_when)}, 10 ` +
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.879'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.881'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -354,6 +354,11 @@ function renderTask(task, { lastClaim = null, viewer = null, goal = null, goalsL
354
354
  roleOptions: () => [{ value: '', label: '— any —' }, { value: 'engineer', label: 'Engineer' }, { value: 'artist', label: 'Artist' }],
355
355
  roleIsUnset: (d) => !d || d === 'unclassified',
356
356
  ROLE_UNSET: 'unclassified',
357
+ // task 1004038 — the kind axis asks the same question. Stubbed for the
358
+ // reason the comment above gives: an undefined helper throws mid-render,
359
+ // and the page then renders its "feed unavailable" card instead.
360
+ kindIsUnset: (k) => !k || k === 'unclassified',
361
+ KIND_UNSET: 'unclassified',
357
362
  escapeHtml,
358
363
  $: (sel) => documentObj.querySelector(sel),
359
364
  fmtDate: (iso) => String(iso).slice(0, 10),
package/tests/init.mjs CHANGED
@@ -204,16 +204,18 @@ test('versionPayload: explicit criterion_id is slugified', () => {
204
204
  assert.equal(init.versionPayload(spec).criteria[0].criterion_id, 'prove-the-loop');
205
205
  });
206
206
 
207
- // versions.track is NOT NULL with no DB default — leaving it off the payload is
208
- // exactly the 1.18.18 bug (every POST /versions 500ed, blocking init outright).
209
- test('versionPayload: always sends an explicit, valid track', () => {
210
- assert.equal(init.versionPayload(fullSpec()).track, 'product'); // instance's first version = product
211
- const internal = fullSpec();
212
- internal.version.track = 'internal';
213
- assert.equal(init.versionPayload(internal).track, 'internal'); // explicit internal respected
214
- const junk = fullSpec();
215
- junk.version.track = 'bogus';
216
- assert.equal(init.versionPayload(junk).track, 'product'); // junk coerced, never forwarded
207
+ // The inverse of the case this replaces. That case pinned "always send an
208
+ // explicit track", guarding the 1.18.18 bug where omitting it 500ed every
209
+ // POST /versions against a NOT NULL column with no default. core_205 gave the
210
+ // column DEFAULT 'product' and task 1003665 retired the concept, so the payload
211
+ // must now NOT carry it — and a spec that still names one must not smuggle it
212
+ // through to a route that no longer accepts the field.
213
+ test('versionPayload: sends no track, and drops one a spec still names', () => {
214
+ assert.equal('track' in init.versionPayload(fullSpec()), false);
215
+ const legacy = fullSpec();
216
+ legacy.version.track = 'internal';
217
+ assert.equal('track' in init.versionPayload(legacy), false,
218
+ 'an old spec file cannot reintroduce the field');
217
219
  });
218
220
 
219
221
  // ---- provisioning arm (ADR 0111 §3, task 1946) ----
@@ -661,12 +663,13 @@ async function runSeedTests() {
661
663
  assert.ok(findTaskPost(api, /Production topology/), 'filed the manual fallback task on error');
662
664
  });
663
665
 
664
- await at('seedViaApi: version POST carries the explicit track (the NOT NULL column)', async () => {
666
+ await at('seedViaApi: the version POST carries NO track (task 1003665)', async () => {
665
667
  const api = fakeApi();
666
668
  await init.seedViaApi(provSpec({}), { log: () => {}, api });
667
669
  const vpost = api.calls.find((c) => c.path.endsWith('/versions'));
668
670
  assert.ok(vpost, 'POSTed the version');
669
- assert.equal(vpost.body.track, 'product');
671
+ assert.equal('track' in vpost.body, false,
672
+ 'POST /versions no longer accepts the field; the DB default supplies the column');
670
673
  });
671
674
 
672
675
  await at('seedViaApi: the ideator kickoff task POSTs kind=decision + discipline=ideator', async () => {