@bongos/core 1.19.592 → 1.19.594

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 (39) hide show
  1. package/.bongos-core.json +61 -41
  2. package/.claude/skills/planning-session/SKILL.md +2 -2
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +3 -1
  5. package/clients/bongos-client/index.cjs +3 -1
  6. package/clients/bongos-client/index.d.ts +6 -2
  7. package/clients/bongos-client/index.mjs +3 -1
  8. package/docs/adr/0157-archon-is-rank-and-identity-only.md +2 -0
  9. package/docs/api/openapi.json +103 -7
  10. package/docs/api-reference.md +4 -3
  11. package/docs/copy-inventory.md +135 -116
  12. package/docs/copy-registry.json +319 -138
  13. package/docs/module-api-changelog.md +4 -0
  14. package/modules/dev-box/app/src/vendor/bongos-client.cjs +3 -1
  15. package/modules/government/catalog.js +20 -11
  16. package/modules/government/migrations/government_012_version_create_archon.sql +45 -0
  17. package/modules/hall-ui/public/goals-render.js +182 -6
  18. package/modules/hall-ui/public/goals.css +51 -0
  19. package/modules/hall-ui/public/roadmap.css +5 -0
  20. package/modules/hall-ui/public/roadmap.js +25 -0
  21. package/modules/hall-ui/public/task-detail.js +22 -11
  22. package/modules/lifecycle/db-goals.js +34 -2
  23. package/modules/lifecycle/db-versions.js +195 -3
  24. package/modules/lifecycle/db.js +4 -1
  25. package/modules/lifecycle/routes/goals.js +15 -1
  26. package/modules/lifecycle/routes/version-route-authz.js +131 -1
  27. package/modules/lifecycle/routes/versions.js +79 -1
  28. package/package-lock.json +2 -2
  29. package/package.json +1 -1
  30. package/scripts/gds/triage.js +60 -26
  31. package/src/module-api.js +1 -1
  32. package/tests/goal_archive_hall.mjs +198 -0
  33. package/tests/government_require_permission.mjs +7 -3
  34. package/tests/government_seed.mjs +101 -9
  35. package/tests/promote_goal_id.mjs +85 -30
  36. package/tests/publish_reconciler.mjs +35 -5
  37. package/tests/task_detail_ui.mjs +36 -9
  38. package/tests/version_close_route.mjs +261 -0
  39. package/tests/version_override_visibility.mjs +176 -0
@@ -122,29 +122,61 @@ await test('BEHAVIOUR: a non-ok or empty response is "none", distinct from "unav
122
122
  assert.equal(notOk.source, 'none', 'the two are reported differently because the operator advice differs');
123
123
  });
124
124
 
125
- await test('BEHAVIOUR: blank ACCEPTS the default; only "-"/"none" declines', () => {
126
- assert.deepEqual(triage.applyGoalAnswer(1000071, ''), { goalId: 1000071, warn: null });
127
- assert.deepEqual(triage.applyGoalAnswer(1000071, ' '), { goalId: 1000071, warn: null },
125
+ await test('BEHAVIOUR: blank ACCEPTS the default when there is one', () => {
126
+ assert.deepEqual(triage.applyGoalAnswer(1000071, ''), { ok: true, goalId: 1000071, warn: null });
127
+ assert.deepEqual(triage.applyGoalAnswer(1000071, ' '), { ok: true, goalId: 1000071, warn: null },
128
128
  'whitespace is a bare Enter, not a decision');
129
- assert.deepEqual(triage.applyGoalAnswer(1000071, '-'), { goalId: null, warn: null });
130
- assert.deepEqual(triage.applyGoalAnswer(1000071, 'none'), { goalId: null, warn: null });
131
- assert.deepEqual(triage.applyGoalAnswer(1000071, 'NONE'), { goalId: null, warn: null });
132
- assert.deepEqual(triage.applyGoalAnswer(1000071, '1000058'), { goalId: 1000058, warn: null },
129
+ assert.deepEqual(triage.applyGoalAnswer(1000071, '1000058'), { ok: true, goalId: 1000058, warn: null },
133
130
  'an explicit id overrides the default');
134
131
  });
135
132
 
136
- await test('BEHAVIOUR: junk clears the goal and SAYS SO rather than landing on a wrong one', () => {
133
+ // BV1.R22 (task 1003609). These three used to resolve to `goalId: null`, which was
134
+ // fine while a goal-less create fell into the version's "<version> — general"
135
+ // catch-all. R11 (task 1003598) deleted that bucket and made goal_id required, so
136
+ // null is now a server 400 arriving AFTER the operator has typed a title, a
137
+ // description and a priority — and losing all of it. Each is a local refusal now.
138
+
139
+ await test('BEHAVIOUR: "-"/"none" no longer opt out — the bucket they opted into is gone', () => {
140
+ for (const decline of ['-', 'none', 'NONE']) {
141
+ const r = triage.applyGoalAnswer(1000071, decline);
142
+ assert.equal(r.ok, false, `"${decline}" must not resolve`);
143
+ assert.equal(r.goalId, null);
144
+ assert.match(r.warn, /no longer exists|must name a real goal/,
145
+ `"${decline}" must say the bucket is gone, not merely refuse`);
146
+ assert.doesNotMatch(r.warn, /falling back/, 'nothing falls back any more');
147
+ }
148
+ });
149
+
150
+ await test('BEHAVIOUR: blank with NO default refuses rather than sending null', () => {
151
+ const r = triage.applyGoalAnswer(null, '');
152
+ assert.equal(r.ok, false, 'no default and no answer is not a decision');
153
+ assert.match(r.warn, /a goal is required/);
154
+ });
155
+
156
+ await test('BEHAVIOUR: junk REFUSES rather than downgrading a typo into a decision', () => {
137
157
  for (const junk of ['abc', '12.5', '-4', '0', 'goal 7']) {
138
158
  const r = triage.applyGoalAnswer(1000071, junk);
139
- assert.equal(r.goalId, null, `"${junk}" must not resolve to a goal`);
140
- assert.match(r.warn, /general bucket/, `"${junk}" must say where it will land instead`);
159
+ assert.equal(r.ok, false, `"${junk}" must not resolve to a goal`);
160
+ assert.equal(r.goalId, null);
161
+ assert.match(r.warn, /not a goal id/, `"${junk}" must name the problem`);
162
+ assert.doesNotMatch(r.warn, /general bucket/, 'and must not offer a bucket that no longer exists');
141
163
  }
142
164
  // Number('') is 0 and Number(' ') is 0 — both would silently become goal 0
143
165
  // without the blank check above running first. Guarding the pair together.
144
166
  assert.equal(triage.applyGoalAnswer(9, '').goalId, 9);
145
167
  });
146
168
 
147
- await test('BEHAVIOUR: createTask puts goal_id on the body, and omits it entirely when null', async () => {
169
+ await test('BEHAVIOUR: createTask refuses to post without a goal, at its own call site', async () => {
170
+ // The prompt loop re-asks until it has a real id, so the only route here with
171
+ // null is a NEW call site that forgot — which should fail at its author rather
172
+ // than as a 400 in production.
173
+ await assert.rejects(
174
+ () => triage.createTask({ versionId: 'BV1', title: 't', description: '', priority: 3, sourceRef: null }),
175
+ /goal_id is required/,
176
+ );
177
+ });
178
+
179
+ await test('BEHAVIOUR: createTask puts goal_id on the body, and REFUSES when it is null', async () => {
148
180
  const seen = [];
149
181
  const fakeClient = {
150
182
  tasks: { postTasks: async (req) => { seen.push(req.body); return { ok: true, data: { task: { id: 7 } } }; } },
@@ -157,12 +189,18 @@ await test('BEHAVIOUR: createTask puts goal_id on the body, and omits it entirel
157
189
  // object cannot reach it — re-require in a fresh registry instead.
158
190
  const fresh = freshTriage();
159
191
  await fresh.createTask({ versionId: 'BV1', title: 't', description: 'd', priority: 3, sourceRef: 'idea:1', goalId: 1000071 });
160
- await fresh.createTask({ versionId: 'BV1', title: 't', description: 'd', priority: 3, sourceRef: 'idea:2' });
161
192
  assert.equal(seen[0].goal_id, 1000071, 'the goal must reach the server');
162
- assert.ok(!('goal_id' in seen[1]),
163
- 'and opting out must send NO goal_id key — the server\'s catch-all only applies on absent');
164
193
  assert.equal(seen[0].source, 'idea_inbox', 'provenance still travels');
165
194
  assert.equal(seen[0].status, 'backlog');
195
+ // BV1.R22 (task 1003609): omitting the goal used to send no goal_id key and let
196
+ // the server file it in the version catch-all. R11 deleted that catch-all, so a
197
+ // goal-less call is now a caller bug — and it must fail HERE, not as a 400 after
198
+ // the operator has typed everything.
199
+ await assert.rejects(
200
+ () => fresh.createTask({ versionId: 'BV1', title: 't', description: 'd', priority: 3, sourceRef: 'idea:2' }),
201
+ /goal_id is required/,
202
+ );
203
+ assert.equal(seen.length, 1, 'and nothing was posted for the goal-less call');
166
204
  } finally {
167
205
  cliLib.cliClient = realCliClient;
168
206
  }
@@ -192,8 +230,10 @@ await test('BEHAVIOUR: a failed create throws with the status, it does not retur
192
230
  // ---- 4. both paths send goal_id -------------------------------------------
193
231
 
194
232
  await test('BOTH PATHS: the CLI createTask and the hall payload each send goal_id', () => {
195
- assert.match(TRIAGE, /if \(goalId != null\) body\.goal_id = Number\(goalId\);/,
233
+ assert.match(TRIAGE, /body\.goal_id = Number\(goalId\);/,
196
234
  'CLI: goal_id must reach the create body');
235
+ assert.doesNotMatch(TRIAGE, /if \(goalId != null\) body\.goal_id/,
236
+ 'CLI: and unconditionally — a conditional send is what made a goal-less create look survivable');
197
237
  assert.match(TRIAGE, /async function createTask\(\{[^}]*goalId/,
198
238
  'CLI: createTask must accept it rather than ignore the caller');
199
239
  assert.match(HALL, /payload\.goal_id = Number\(form\.goal\.value\)/,
@@ -234,31 +274,46 @@ await test('the suggester default shows score + matched terms so it can be judge
234
274
 
235
275
  // ---- 2. opting out is explicit ------------------------------------------
236
276
 
237
- await test('OPT-OUT: the prompt itself offers the way out, and the branch delegates the meaning', () => {
277
+ // BV1.R22 (task 1003609) removed the opt-out from BOTH surfaces, because the
278
+ // bucket it opted into no longer exists. These three tests pinned the affordance;
279
+ // they now pin its ABSENCE, which is the assertion that actually protects the
280
+ // invariant — a re-added opt-out would silently start producing 400s again.
281
+
282
+ await test('OPT-OUT: the CLI prompt RE-ASKS instead of offering a bucket that is gone', () => {
238
283
  const branch = TRIAGE.slice(TRIAGE.indexOf('const gd = await resolveGoalDefault'), TRIAGE.indexOf('const task = await createTask'));
239
- assert.match(branch, /general bucket/, 'the prompt has to say where opting out lands');
284
+ assert.doesNotMatch(branch, /general bucket/, 'there is no general bucket to offer');
240
285
  assert.match(branch, /applyGoalAnswer\(gd\.goalId, goalAns\)/,
241
286
  'the branch must delegate to the tested helper, not re-implement the parsing inline');
242
- assert.match(branch, /picked\.warn/, 'and surface its warning');
287
+ assert.match(branch, /picked\.ok/, 'and act on its verdict');
288
+ assert.match(branch, /picked\.warn/, 'and surface its reason');
289
+ assert.match(branch, /for \(;;\)/, 'a refusal re-asks rather than falling through');
290
+ assert.match(branch, /'s'/, "and skipping stays available, so one unplaceable idea cannot wedge the run");
243
291
  assert.match(branch, /resolveGoalDefault\(\{ \.\.\.idea, title \}, versionId\)/,
244
292
  'the suggester must see the title the operator just confirmed, not the filed one');
245
- // The semantics themselves are covered by the BEHAVIOUR block above — this
246
- // only pins that main() is actually wired to them.
247
293
  });
248
294
 
249
- await test('OPT-OUT: the hall names where opting out actually lands, and only defaults to it last', () => {
295
+ await test('OPT-OUT: the hall offers no empty goal, and refuses one before it posts', () => {
250
296
  const fn = HALL.slice(HALL.indexOf('async function goalOptionsFor'), HALL.indexOf('async function renderPromote'));
251
- // "— no goal —" would be a lie: the server files a goal_id-less create under
252
- // the version's catch-all. The label has to say so.
253
- assert.match(fn, /general bucket/);
254
- assert.ok(!/— no goal —/.test(fn), 'the honest label replaced the misleading one');
255
- assert.match(fn, /opts\.length === 0 \? ' selected' : ''/, 'the opt-out is the fallback, not the default');
256
- assert.match(fn, /a choice rather than the silent default/, 'and the intent is stated');
297
+ // Scoped to the emitted <option> markup: the code comment above it explains WHY
298
+ // the bucket is gone and must be allowed to name it.
299
+ const optionMarkup = fn.split('\n').filter((l) => !l.trim().startsWith('//')).join('\n');
300
+ assert.doesNotMatch(optionMarkup, /general bucket/, 'the bucket is gone from the label too');
301
+ assert.ok(!/— no goal —/.test(fn), 'and no misleading "no goal" option came back');
302
+ // What remains when nothing is suggested must be a PLACEHOLDER, not a value:
303
+ // `disabled` is what stops a blank-looking select from being submittable.
304
+ assert.match(fn, /selected disabled/, 'the empty row is an unsubmittable placeholder');
305
+ const submit = HALL.slice(HALL.indexOf('async function submitPromote'), HALL.indexOf('function renderMerge'));
306
+ assert.match(submit, /form\.goal\.value === ''/, 'and the submit refuses an empty goal');
307
+ assert.match(submit, /payload\.goal_id = Number\(form\.goal\.value\)/,
308
+ 'goal_id is sent unconditionally now, not behind an if');
309
+ assert.doesNotMatch(submit, /if \(form\.goal && form\.goal\.value !== ''\) payload\.goal_id/,
310
+ 'the conditional send is what made an empty select look survivable');
257
311
  });
258
312
 
259
- await test('the CLI result line names the goal, or says the task fell into the general bucket', () => {
260
- assert.match(TRIAGE, /goal \$\{goalId\}` : ', → general bucket'/,
261
- 'where it landed must be visible in the output, both ways');
313
+ await test('the CLI result line names the goal it landed in', () => {
314
+ assert.match(TRIAGE, /P\$\{priority\}, goal \$\{goalId\}/,
315
+ 'where it landed must be visible in the output');
316
+ assert.doesNotMatch(TRIAGE, /→ general bucket/, 'and there is no second way for it to land');
262
317
  });
263
318
 
264
319
  // ---- 3. degrades, never blocks -----------------------------------------
@@ -1651,13 +1651,43 @@ test('vector 2: shipped_at is stamped ONLY by shipTask, and shipTask requires co
1651
1651
  // task 1003204: the lifecycle data layer is db.js + db-*.js now — read the family.
1652
1652
  const dbSrc = lifecycleDbSource();
1653
1653
 
1654
- // Every statement that writes shipped_at, ignoring SELECT projections and comments.
1655
- const writes = [...dbSrc.matchAll(/shipped_at\s*=\s*now\(\)/g)];
1656
- assert.equal(writes.length, 1,
1657
- `shipped_at is written in ${writes.length} places a second stamp site is how the grader-bypass vector reopens`);
1654
+ // Every statement that writes a TASK's shipped_at, ignoring SELECT projections
1655
+ // and comments.
1656
+ //
1657
+ // THE TABLE MATTERS, and this guard learned that the hard way (BV1.R12, task
1658
+ // 1003599). `versions` has a `shipped_at` too, and the version-close route
1659
+ // stamps it — a legitimate write on a different table that has nothing to do
1660
+ // with the grader-bypass vector. A bare /shipped_at = now()/ over the whole
1661
+ // lifecycle db family counted it as a second TASK stamp site and reddened main.
1662
+ //
1663
+ // Widening the count to 2 would have been the wrong repair: it buys silence
1664
+ // now and re-opens the hole the moment a third site appears anywhere. What this
1665
+ // guard is actually about is stated in its own comment above — "no other write
1666
+ // path can stamp shipped_at ON A TASK" — so the fix is to say `tasks`, not to
1667
+ // raise the number. Each match is attributed to the nearest preceding UPDATE
1668
+ // target, and only `tasks` counts.
1669
+ const stamps = [...dbSrc.matchAll(/shipped_at\s*=\s*now\(\)/g)];
1670
+ const taskStamps = stamps.filter((m) => {
1671
+ const before = dbSrc.slice(0, m.index);
1672
+ const lastUpdate = before.lastIndexOf('UPDATE ');
1673
+ if (lastUpdate === -1) return false;
1674
+ return /^UPDATE\s+tasks\b/.test(before.slice(lastUpdate));
1675
+ });
1676
+ assert.equal(taskStamps.length, 1,
1677
+ `a task's shipped_at is written in ${taskStamps.length} places — a second stamp site is how the grader-bypass vector reopens`);
1678
+ // And the attribution itself must be working: if the regex above ever stopped
1679
+ // matching the real site, `taskStamps` would be 0 and the assert would still be
1680
+ // meaningful — but a silent 0 total would mean the family moved and this guard
1681
+ // is now watching nothing at all.
1682
+ assert.ok(stamps.length >= 1, 'no shipped_at stamp found anywhere — the lifecycle db family moved and this guard is blind');
1658
1683
 
1659
1684
  // …and that one site is guarded on the confirmed state.
1660
- const stampIdx = dbSrc.indexOf('shipped_at = now()');
1685
+ // Anchored on the ATTRIBUTED task stamp, not `indexOf` with two shipped_at
1686
+ // sites in the family, "the first one in file order" is whichever file the
1687
+ // carve happens to concatenate first, and that is not a property anyone is
1688
+ // maintaining. The versions stamp has no `status = 'confirmed'` guard and
1689
+ // should not: it is a different table with a different rule.
1690
+ const stampIdx = taskStamps[0].index;
1661
1691
  const window = dbSrc.slice(stampIdx, stampIdx + 500);
1662
1692
  assert.match(window, /WHERE id = \$1 AND status = 'confirmed'/,
1663
1693
  'the stamp must refuse any task not already confirmed — that is what stops a confirm-time ship');
@@ -259,7 +259,7 @@ await test('opening Promote loads open versions only, and preselects the idea\'s
259
259
  assert.equal(vsel.value, 'BV2', 'the idea\'s suggested_version is honoured');
260
260
  });
261
261
 
262
- await test('the goal picker leads with the STORED hint, then the suggester, then the opt-out', async () => {
262
+ await test('the goal picker leads with the STORED hint, then the suggester and offers NO opt-out', async () => {
263
263
  const h = await boot();
264
264
  await h.clickGo('promote');
265
265
  const opts = h.goalSelect().querySelectorAll('option');
@@ -268,7 +268,14 @@ await test('the goal picker leads with the STORED hint, then the suggester, then
268
268
  assert.match(opts[0].textContent, /carried from the filing/);
269
269
  assert.equal(opts[1].attrs.value, '1000062', 'the suggester fills in below it');
270
270
  assert.match(opts[1].textContent, /matched: promote, merge/, 'with the evidence for judging it');
271
- assert.equal(opts[opts.length - 1].attrs.value, '', 'the opt-out is always available');
271
+ // BV1.R22 (task 1003609) DELETED the trailing opt-out, and this assertion
272
+ // flipped with it. R11 (task 1003598) removed db.createTask's catch-all
273
+ // fallback and made goal_id required, so an empty selection stopped meaning
274
+ // "file it in the version's general bucket" and started meaning a server 400
275
+ // landing on a builder who had already filled the form. A choice that always
276
+ // fails is worse than no choice.
277
+ assert.equal(opts.filter((o) => o.attrs.value === '').length, 0,
278
+ 'no empty-valued option may be offered once a goal is required — it could only ever 400');
272
279
  });
273
280
 
274
281
  await test('with no stored hint the top suggestion is preselected instead', async () => {
@@ -277,12 +284,19 @@ await test('with no stored hint the top suggestion is preselected instead', asyn
277
284
  assert.equal(h.goalSelect().value, '1000062', 'the suggester supplies the default when nothing was named');
278
285
  });
279
286
 
280
- await test('a suggester outage degrades to the opt-out rather than blocking the verdict', async () => {
287
+ await test('a suggester outage degrades to a DISABLED placeholder that says what to do', async () => {
281
288
  const h = await boot({ suggestThrows: true, idea: { ...DEFAULT_IDEA, suggested_goal_id: null } });
282
289
  await h.clickGo('promote');
283
290
  const opts = h.goalSelect().querySelectorAll('option');
284
- assert.equal(opts.length, 1, 'only the opt-out remains');
285
- assert.equal(h.goalSelect().value, '', 'and it is selected, so the form is still submittable');
291
+ assert.equal(opts.length, 1, 'only the placeholder remains');
292
+ // Before BV1.R22 this row was a submittable opt-out and this test said so. It
293
+ // is now a PLACEHOLDER, not a value: `disabled`, so the select can never be
294
+ // blank-but-valid-looking, and the text tells the builder where to go instead.
295
+ // The old assertion passed either way, because it never checked `disabled` —
296
+ // which is exactly how a contract change hides inside a green test.
297
+ assert.ok('disabled' in opts[0].attrs, 'the placeholder must not be submittable');
298
+ assert.match(opts[0].textContent, /open the goal board and pick one/,
299
+ 'a builder with no suggestion is told what to do, not handed a choice that fails');
286
300
  });
287
301
 
288
302
  await test('promote with a goal chosen POSTs goal_id, then marks the idea promoted', async () => {
@@ -320,14 +334,27 @@ await test('the title survives escaping — what the form renders is what gets c
320
334
  'quotes and angle brackets must round-trip through the value attribute unmangled');
321
335
  });
322
336
 
323
- await test('promote opting OUT of a goal sends no goal_id key at all', async () => {
337
+ await test('there is no way to promote WITHOUT a goal any more', async () => {
338
+ // THIS TEST'S PREMISE INVERTED at BV1.R22 (task 1003609). It used to assert
339
+ // that clearing the picker sent no `goal_id` key, because an absent key meant
340
+ // "the version's general bucket". R11 (task 1003598) deleted that bucket and
341
+ // made `goal_id` required, so the behaviour it pinned is now a 400 — and the
342
+ // test itself crashed with a TypeError once the option it selected stopped
343
+ // existing, which is what surfaced this.
344
+ //
345
+ // What replaces it is the invariant that actually matters now: the form cannot
346
+ // be made to submit without a goal. Deleting the test outright would leave that
347
+ // unguarded.
324
348
  const h = await boot();
325
349
  await h.clickGo('promote');
326
- h.goalSelect().value = '';
350
+ const opts = h.goalSelect().querySelectorAll('option');
351
+ const submittableBlank = opts.filter((o) => o.attrs.value === '' && !('disabled' in o.attrs));
352
+ assert.equal(submittableBlank.length, 0, 'no blank option may be both offered and submittable');
353
+ // And the picker's default is a real goal, so an untouched form is already valid.
354
+ assert.ok(h.goalSelect().value, 'the picker opens on a real goal, never on nothing');
327
355
  await h.submitForm('promote');
328
356
  const body = h.requestsTo('POST', /\/tasks$/)[0].body;
329
- assert.ok(!('goal_id' in body),
330
- 'the version\'s general bucket applies only on an ABSENT key — sending null or "" would be a different create');
357
+ assert.ok('goal_id' in body && body.goal_id, 'every promote now names a goal');
331
358
  });
332
359
 
333
360
  await test('an empty title is refused in the browser, before a doomed request', async () => {
@@ -0,0 +1,261 @@
1
+ // tests/version_close_route.mjs — a version can finally close
2
+ // (BV1.R12, task 1003599, goal 1000086, ADR 0250 D5, ADR 0263 §5).
3
+ //
4
+ // THE HOLE. Tasks close, criteria close, goals close — and a version only ever
5
+ // moved by hand-run SQL on the droplet. `scripts/gds/version-close.js` still
6
+ // prints that the API exposes no version status transitions, and the
7
+ // `version.close` permission has sat at the archon floor since BV1.R105 with
8
+ // NOTHING referencing it. A thing with no closing move does not close, which is
9
+ // how two versions ended up building on one project at the same time.
10
+ //
11
+ // Two things this file is careful about, because both are places a correct
12
+ // planner and a correct writer still add up to a broken endpoint:
13
+ //
14
+ // THE MAINTENANCE GOAL IS FILTERED BY THE READ, not by the planner. It is
15
+ // exempt from the close count and always carries forward (ADR 0263 §7), so it
16
+ // must never appear in a refusal asking the caller to decide about it — asking
17
+ // someone to disposition a goal they are not allowed to disposition is worse
18
+ // than not asking.
19
+ //
20
+ // ROLL_FORWARD WITHOUT A PLANNING VERSION IS A REFUSAL, never an auto-create.
21
+ // Cutting the successor implicitly inside a close is exactly the "fake hotfix
22
+ // version" ADR 0250 §3 built the override counter to prevent.
23
+ //
24
+ // Run: node tests/version_close_route.mjs
25
+
26
+ import { strict as assert } from 'node:assert';
27
+ import { createRequire } from 'node:module';
28
+ import { readFileSync } from 'node:fs';
29
+ import { makeRunner, makeSqlAwareClient } from './helpers.mjs';
30
+
31
+ process.env.NODE_ENV = 'test';
32
+ const require = createRequire(import.meta.url);
33
+ const { planVersionClose, rollForwardNeedsPlanningVersion } = require('../modules/lifecycle/routes/version-route-authz.js');
34
+ const { closeVersion, maintenanceGoalExemptSql, openNonMaintenanceGoals } = require('../modules/lifecycle/db.js');
35
+ const { test, summary } = makeRunner();
36
+
37
+ const src = (rel) => readFileSync(new URL('../' + rel, import.meta.url), 'utf8');
38
+ const BUILDING = { id: 'BONGOS-V1', status: 'building' };
39
+ const GOALS = (...n) => n.map((i) => ({ id: String(i), title: `goal ${i}`, open_tasks: 0 }));
40
+ const plan = (over = {}) => planVersionClose({ version: BUILDING, openGoals: GOALS(1, 2), reason: 'V1 is done', ...over });
41
+
42
+ function txPool(handler) {
43
+ const c = makeSqlAwareClient(handler);
44
+ return { connect: c.connect, query: (sql, p) => c._client.query(sql, p), queries: c._client.queries };
45
+ }
46
+ const flat = (q) => String(q.sql).replace(/\s+/g, ' ').trim();
47
+
48
+ // ---- only a building version closes ----------------------------------------
49
+
50
+ await test('a missing version is a 404, not a refusal about its state', () => {
51
+ const r = planVersionClose({ version: null });
52
+ assert.equal(r.ok, false); assert.equal(r.status, 404); assert.equal(r.body.error, 'version_not_found');
53
+ });
54
+
55
+ await test('only a BUILDING version closes — every other status is refused BY NAME', () => {
56
+ for (const status of ['planning', 'shipped', 'frozen', 'nonsense', null]) {
57
+ const r = planVersionClose({ version: { id: 'V', status } });
58
+ assert.equal(r.ok, false, `'${status}' must not close`);
59
+ assert.equal(r.body.error, 'version_not_building');
60
+ assert.equal(r.body.status, status, 'the refusal says WHICH status refused, rather than asserting one');
61
+ }
62
+ });
63
+
64
+ await test('a building version holding nothing open closes in ONE call', () => {
65
+ const r = planVersionClose({ version: BUILDING, openGoals: [] });
66
+ assert.equal(r.ok, true);
67
+ assert.deepEqual(r.plan, [], 'and needs no reason — nothing is being cut');
68
+ });
69
+
70
+ // ---- the two-step ----------------------------------------------------------
71
+
72
+ await test('open goals refuse with a NAMED 409 that RETURNS the goals', () => {
73
+ const r = plan({ dispositions: null });
74
+ assert.equal(r.status, 409);
75
+ assert.equal(r.body.error, 'version_holds_open_goals');
76
+ assert.deepEqual(r.body.details.goals.map((g) => g.id), ['1', '2'],
77
+ 'the caller decides per goal, so the goals travel with the refusal');
78
+ });
79
+
80
+ await test('a partial map is refused — nothing is carried or cut by omission', () => {
81
+ const r = plan({ dispositions: { 1: { verb: 'abandon' } } });
82
+ assert.equal(r.ok, false);
83
+ assert.equal(r.body.error, 'disposition_incomplete');
84
+ assert.deepEqual(r.body.details.goals.map((g) => g.id), ['2'], 'and it names which');
85
+ });
86
+
87
+ await test('cutting a goal without a reason is refused — the silent discard, one tier up', () => {
88
+ for (const reason of ['', ' ', null, undefined]) {
89
+ const r = plan({ dispositions: { 1: { verb: 'abandon' }, 2: { verb: 'abandon' } }, reason });
90
+ assert.equal(r.ok, false, `reason ${JSON.stringify(reason)} must not pass`);
91
+ assert.equal(r.body.error, 'close_reason_required');
92
+ }
93
+ });
94
+
95
+ await test('only roll_forward and abandon are verbs', () => {
96
+ for (const verb of ['carry', 'ROLL_FORWARD', 'delete', '', null, 7]) {
97
+ const r = plan({ dispositions: { 1: { verb }, 2: { verb: 'abandon' } } });
98
+ assert.equal(r.ok, false, `verb ${JSON.stringify(verb)} must not pass`);
99
+ assert.equal(r.body.error, 'bad_disposition');
100
+ }
101
+ });
102
+
103
+ await test('a complete map plans one step per goal, in the order the goals came in', () => {
104
+ const r = plan({ dispositions: { 1: { verb: 'roll_forward' }, 2: { verb: 'abandon' } } });
105
+ assert.equal(r.ok, true);
106
+ assert.deepEqual(r.plan, [{ goalId: 1, verb: 'roll_forward' }, { goalId: 2, verb: 'abandon' }]);
107
+ });
108
+
109
+ // ---- roll_forward needs somewhere to go ------------------------------------
110
+
111
+ await test('roll_forward with NO planning version is refused, never auto-created', () => {
112
+ const r = rollForwardNeedsPlanningVersion({ plan: [{ goalId: 1, verb: 'roll_forward' }], planning: [] });
113
+ assert.ok(r, 'it must refuse');
114
+ assert.equal(r.status, 409);
115
+ assert.equal(r.body.error, 'no_planning_version');
116
+ assert.match(r.body.message, /Scope the next version first/,
117
+ 'and say what to do — auto-cutting one here is the escape hatch ADR 0250 closed');
118
+ });
119
+
120
+ await test('an all-abandon close needs no planning version at all', () => {
121
+ assert.equal(rollForwardNeedsPlanningVersion({ plan: [{ goalId: 1, verb: 'abandon' }], planning: [] }), null);
122
+ });
123
+
124
+ await test('roll_forward WITH a planning version proceeds', () => {
125
+ assert.equal(rollForwardNeedsPlanningVersion({
126
+ plan: [{ goalId: 1, verb: 'roll_forward' }], planning: [{ id: 'BONGOS-V2' }],
127
+ }), null);
128
+ });
129
+
130
+ // ---- the maintenance exemption ---------------------------------------------
131
+
132
+ await test('the exemption has ONE definition, and the open-goals read is its only caller', () => {
133
+ // R18 (task 1003605) swaps this body for `NOT g.is_maintenance` once the column
134
+ // exists. One call site is what makes that a one-line change instead of a hunt.
135
+ assert.equal(maintenanceGoalExemptSql('$1'), "g.title <> ($1 || ' — maintenance')");
136
+ const dbv = src('modules/lifecycle/db-versions.js');
137
+ const uses = (dbv.match(/maintenanceGoalExemptSql\(/g) || []).length;
138
+ assert.equal(uses, 3, 'declaration + the open-goals read + the post-apply re-count, and nothing else');
139
+ });
140
+
141
+ await test('the open-goals read excludes the maintenance goal and counts unfinished work', async () => {
142
+ const pool = txPool(() => ({ rows: [] }));
143
+ await openNonMaintenanceGoals('BONGOS-V1', { pool });
144
+ const q = flat(pool.queries[0]);
145
+ assert.match(q, /maintenance/, 'the maintenance goal must not be offered for disposition');
146
+ assert.match(q, /status = 'open'/);
147
+ assert.match(q, /open_tasks/, 'each goal carries how much work it still holds — most of the decision');
148
+ assert.match(q, /NOT IN \('shipped', 'abandoned'\)/, 'and counts it with the shared terminal set');
149
+ });
150
+
151
+ // ---- the write is one transaction ------------------------------------------
152
+
153
+ function closePool({ status = 'building', goalStatus = 'open', remaining = 0 } = {}) {
154
+ return txPool((sql) => {
155
+ const q = String(sql).replace(/\s+/g, ' ');
156
+ if (/FROM versions WHERE id = \$1 FOR UPDATE/.test(q)) return { rows: [{ id: 'V1', status }] };
157
+ if (/FROM goals WHERE id = \$1 AND version_id = \$2 FOR UPDATE/.test(q)) return { rows: [{ id: 1, title: 'g', status: goalStatus }] };
158
+ if (/UPDATE tasks SET status = 'abandoned'/.test(q)) return { rows: [{ id: 11 }, { id: 12 }] };
159
+ if (/count\(\*\)::int AS n FROM goals/.test(q)) return { rows: [{ n: remaining }] };
160
+ if (/UPDATE versions SET status = 'shipped'/.test(q)) return { rows: [{ id: 'V1', status: 'shipped' }] };
161
+ return { rows: [] };
162
+ });
163
+ }
164
+
165
+ await test('the whole close runs inside ONE transaction, version locked first', async () => {
166
+ const pool = closePool();
167
+ const out = await closeVersion({ versionId: 'V1', plan: [{ goalId: 1, verb: 'abandon' }], reason: 'done' }, { pool });
168
+ assert.equal(out.version.status, 'shipped');
169
+ assert.deepEqual(out.applied, [{ goal_id: '1', verb: 'abandon', result: 'archived', tasks_abandoned: 2 }]);
170
+ const sqls = pool.queries.map(flat);
171
+ assert.equal(sqls.filter((q) => q === 'BEGIN').length, 1);
172
+ assert.equal(sqls.filter((q) => q === 'COMMIT').length, 1);
173
+ const lock = sqls.findIndex((q) => /FROM versions WHERE id = \$1 FOR UPDATE/.test(q));
174
+ const firstWrite = sqls.findIndex((q) => /^UPDATE tasks/.test(q));
175
+ assert.ok(lock !== -1 && lock < firstWrite, 'lock the version, then apply — a goal must not be created mid-close');
176
+ assert.ok(sqls.findIndex((q) => /UPDATE versions SET status = 'shipped'/.test(q)) < sqls.indexOf('COMMIT'));
177
+ });
178
+
179
+ await test('an abandoned goal takes its open tasks with it, stamped with the close reason', async () => {
180
+ const pool = closePool();
181
+ await closeVersion({ versionId: 'V1', plan: [{ goalId: 1, verb: 'abandon' }], reason: 'cut in the ten-areas review' }, { pool });
182
+ const kill = pool.queries.find((q) => /UPDATE tasks SET status = 'abandoned'/.test(flat(q)));
183
+ assert.ok(kill, 'the tasks must not survive their goal');
184
+ assert.equal(kill.params[1], 'Abandoned: cut in the ten-areas review',
185
+ 'the same stamp R14 writes — the ledger reads identically whichever door retired the work');
186
+ assert.match(flat(kill), /NOT IN \('shipped', 'abandoned'\)/, 'and only the unfinished ones');
187
+ });
188
+
189
+ await test('a roll_forward abandons NOTHING and says the successor is still pending', async () => {
190
+ const pool = closePool();
191
+ const out = await closeVersion({ versionId: 'V1', plan: [{ goalId: 1, verb: 'roll_forward' }], reason: 'r' }, { pool });
192
+ assert.deepEqual(out.applied, [{ goal_id: '1', verb: 'roll_forward', result: 'carried_pending_successor' }]);
193
+ assert.equal(pool.queries.some((q) => /UPDATE tasks SET status = 'abandoned'/.test(flat(q))), false,
194
+ 'carrying a goal forward must never cut its work');
195
+ assert.equal(pool.queries.some((q) => /UPDATE goals SET status = 'archived'/.test(flat(q))), false);
196
+ });
197
+
198
+ await test('a carried goal is EXCLUDED from the post-apply re-count, or a close could never carry anything', async () => {
199
+ const pool = closePool();
200
+ await closeVersion({ versionId: 'V1', plan: [{ goalId: 1, verb: 'roll_forward' }], reason: 'r' }, { pool });
201
+ const recount = pool.queries.find((q) => /count\(\*\)::int AS n FROM goals/.test(flat(q)));
202
+ assert.match(flat(recount), /NOT \(g\.id = ANY\(\$2::bigint\[\]\)\)/);
203
+ assert.deepEqual(recount.params[1], [1], 'the carried goal is expected to still be open');
204
+ });
205
+
206
+ await test('the re-check runs against committed state and rolls back rather than half-closing', async () => {
207
+ const pool = closePool({ remaining: 2 });
208
+ await assert.rejects(
209
+ closeVersion({ versionId: 'V1', plan: [{ goalId: 1, verb: 'abandon' }], reason: 'r' }, { pool }),
210
+ (e) => e.code === 'CLOSE_INCOMPLETE_AFTER_APPLY' && e.remaining === 2
211
+ );
212
+ const sqls = pool.queries.map(flat);
213
+ assert.ok(sqls.includes('ROLLBACK'));
214
+ assert.equal(sqls.some((q) => /UPDATE versions SET status = 'shipped'/.test(q)), false, 'the version is not closed');
215
+ });
216
+
217
+ await test('a version that stopped building mid-call rolls back', async () => {
218
+ const pool = closePool({ status: 'shipped' });
219
+ await assert.rejects(
220
+ closeVersion({ versionId: 'V1', plan: [], reason: 'r' }, { pool }),
221
+ (e) => e.code === 'VERSION_NOT_BUILDING' && e.versionStatus === 'shipped'
222
+ );
223
+ assert.ok(pool.queries.map(flat).includes('ROLLBACK'));
224
+ });
225
+
226
+ await test('the promotion seam runs INSIDE the transaction, after the flip', async () => {
227
+ // R20 (task 1003607) hooks here. ensureMaintenanceGoal returns null for a
228
+ // non-building version, so a carry-over run before the flip silently carries
229
+ // nothing — the ordering is load-bearing, not stylistic (ADR 0263 §8).
230
+ const pool = closePool();
231
+ let sawStatus = null; let ranAt = -1;
232
+ await closeVersion({ versionId: 'V1', plan: [], reason: 'r' }, {
233
+ pool,
234
+ onClosed: async (client) => { ranAt = pool.queries.length; sawStatus = 'called'; await client.query('SELECT 1 AS seam'); return { promoted: null }; },
235
+ });
236
+ const sqls = pool.queries.map(flat);
237
+ assert.equal(sawStatus, 'called', 'the seam must be offered');
238
+ assert.ok(sqls.findIndex((q) => /UPDATE versions SET status = 'shipped'/.test(q)) < ranAt, 'after the flip');
239
+ assert.ok(sqls.indexOf('SELECT 1 AS seam') < sqls.indexOf('COMMIT'), 'and before the commit');
240
+ });
241
+
242
+ // ---- the route is wired to all of it ---------------------------------------
243
+
244
+ await test('the route declares its body, so a typo cannot read as "no map given"', () => {
245
+ const V = src('modules/lifecycle/routes/versions.js');
246
+ const route = V.slice(V.indexOf("router.post('/versions/:id/close'"));
247
+ assert.match(route, /validateOrRespond\(req, res, \{[\s\S]*?dispositions: \{ type: 'object' \}/);
248
+ assert.match(route, /requirePermission\('version\.close'\)/, 'behind the permission nothing referenced until now');
249
+ assert.match(route, /db\.openNonMaintenanceGoals\(versionId\)/, 'the maintenance goal is filtered by the READ');
250
+ assert.match(route, /rollForwardNeedsPlanningVersion/);
251
+ const planIdx = route.indexOf('planVersionClose(');
252
+ const writeIdx = route.indexOf('db.closeVersion(');
253
+ assert.ok(planIdx !== -1 && writeIdx !== -1 && planIdx < writeIdx, 'decide, then write');
254
+ });
255
+
256
+ await test('version-close.js no longer being the only door is R23; this route exists for it to call', () => {
257
+ const V = src('modules/lifecycle/routes/versions.js');
258
+ assert.match(V, /router\.post\('\/versions\/:id\/close'/, 'the route the CLI will drive');
259
+ });
260
+
261
+ summary();