@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
@@ -13,6 +13,7 @@
13
13
  const api = require('../../src/module-api');
14
14
  const { pool, withTx } = api;
15
15
  const doneWhen = require('./done-when.js');
16
+ const { nonTerminalSql } = require('./task-status');
16
17
 
17
18
  async function listVersions() {
18
19
  const { rows } = await pool.query(
@@ -34,7 +35,12 @@ async function getVersion(id) {
34
35
  }
35
36
 
36
37
 
37
- async function versionProgress() {
38
+ // Accepts the #538 deps.pool test seam (honoured ONLY under NODE_ENV==='test'),
39
+ // like its siblings in this family — added by BV1.R15 (task 1003602) so the
40
+ // admissions join below is assertable without a live Postgres. Every caller
41
+ // passes nothing and is unchanged.
42
+ async function versionProgress(deps = {}) {
43
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
38
44
  // Joins the rolled-up progress view (any of completed/confirmed/shipped counts
39
45
  // as "done") with the per-state breakdown view from migration 012 so the
40
46
  // public dashboard can render a stacked three-segment progress bar.
@@ -46,14 +52,15 @@ async function versionProgress() {
46
52
  // uses criteria_count > 0 to tell a genuinely-active version from a freshly
47
53
  // stubbed "future placeholder" (e.g. a next-version row created by
48
54
  // version-close that has no criteria seeded yet) without needing a schema flag.
49
- const { rows } = await pool.query(
55
+ const { rows } = await activePool.query(
50
56
  `SELECT vp.version_id, vp.name, vp.status,
51
57
  vp.shipped_count, vp.total_count, vp.shipped_weight, vp.total_weight,
52
58
  v.done_when,
53
59
  COALESCE(dwc.criteria_count, 0) AS criteria_count,
54
60
  COALESCE(vlb.completed_count, 0) AS lifecycle_completed_count,
55
61
  COALESCE(vlb.confirmed_count, 0) AS lifecycle_confirmed_count,
56
- COALESCE(vlb.shipped_count, 0) AS lifecycle_shipped_count
62
+ COALESCE(vlb.shipped_count, 0) AS lifecycle_shipped_count,
63
+ COALESCE(adm.admission_count, 0) AS admission_count
57
64
  FROM version_progress vp
58
65
  LEFT JOIN version_lifecycle_breakdown vlb ON vlb.version_id = vp.version_id
59
66
  LEFT JOIN versions v ON v.id = vp.version_id
@@ -62,14 +69,34 @@ async function versionProgress() {
62
69
  FROM done_when_criteria
63
70
  GROUP BY version_id
64
71
  ) dwc ON dwc.version_id = vp.version_id
72
+ LEFT JOIN (
73
+ SELECT version_id, count(*) AS admission_count
74
+ FROM lifecycle_goal_version_admissions
75
+ GROUP BY version_id
76
+ ) adm ON adm.version_id = vp.version_id
65
77
  ORDER BY vp.version_id`
66
78
  );
79
+ // BV1.R15 (task 1003602, goal 1000086, ADR 0250 §3): "the overrides are
80
+ // counted and shown on the version." R07 put the count on
81
+ // `GET /versions/:id/progress` — a per-version route the roadmap never calls.
82
+ // The surface a person actually looks at reads THIS rollup, so the count rides
83
+ // here too, and an override becomes visible without anyone knowing to go
84
+ // looking for it. That is the whole mechanism: a counter nobody encounters is
85
+ // indistinguishable from no gate at all.
86
+ //
87
+ // Folded into the existing query as one more LEFT JOIN rather than a second
88
+ // round-trip or a require of db-goals.js: this file and that one are SIBLINGS
89
+ // in the module's require DAG (see modules/lifecycle/CLAUDE.md), and reaching
90
+ // sideways for one aggregate is how that DAG stops being one. COALESCE, not a
91
+ // JOIN that manufactures a zero row — a version nobody widened has no row here
92
+ // at all, which is the common case.
67
93
  return rows.map((r) => ({
68
94
  ...r,
69
95
  criteria_count: Number(r.criteria_count),
70
96
  lifecycle_completed_count: Number(r.lifecycle_completed_count),
71
97
  lifecycle_confirmed_count: Number(r.lifecycle_confirmed_count),
72
98
  lifecycle_shipped_count: Number(r.lifecycle_shipped_count),
99
+ admission_count: Number(r.admission_count),
73
100
  percent_complete:
74
101
  r.total_weight > 0 ? Math.round((Number(r.shipped_weight) / Number(r.total_weight)) * 100) : 0,
75
102
  }));
@@ -160,8 +187,173 @@ async function currentBuildingVersionId(deps = {}) {
160
187
  return rows[0] ? rows[0].id : null;
161
188
  }
162
189
 
190
+ // maintenanceGoalExemptSql — "this goal is NOT the version's maintenance goal",
191
+ // as one SQL predicate with ONE definition (BV1.R12, task 1003599; ADR 0263 §7).
192
+ //
193
+ // READ THIS BEFORE CHANGING IT. Today the maintenance goal is identified BY TITLE,
194
+ // because no column says which goal it is — `ensureMaintenanceGoal` (db-goals.js)
195
+ // matches `"<version> — maintenance"` and its own comment flags that as task
196
+ // 1003605's (R18) debt. That is a provenance wrinkle for ROUTING, where the worst
197
+ // case is a hand-titled goal receiving a bug. It is a much worse wrinkle HERE: this
198
+ // predicate decides which goals hold a VERSION open, so a hand-titled goal would
199
+ // silently exempt itself from the gate that decides when a version may close.
200
+ //
201
+ // So it is named, used in exactly one place, and R18 replaces its BODY with
202
+ // `NOT g.is_maintenance` once the column exists — one edit, no call sites to find.
203
+ // The parameter is the version id placeholder the caller has already bound.
204
+ function maintenanceGoalExemptSql(titleParam) {
205
+ return `g.title <> (${titleParam} || ' — maintenance')`;
206
+ }
207
+
208
+ // openNonMaintenanceGoals — the goals that hold a version open, id + title +
209
+ // how much unfinished work each still carries (BV1.R12).
210
+ //
211
+ // The task counts are here rather than fetched per goal because the caller's next
212
+ // move is a per-goal decision — roll it forward or cut it — and "3 open tasks" vs
213
+ // "0 open tasks" is most of that decision. Making the caller ask N more times is
214
+ // how an agent ends up deciding without looking.
215
+ async function openNonMaintenanceGoals(versionId, deps = {}) {
216
+ const exec = deps.client || ((deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool);
217
+ const { rows } = await exec.query(
218
+ `SELECT g.id::text, g.title,
219
+ (SELECT count(*)::int FROM tasks t
220
+ WHERE t.goal_id = g.id AND ${nonTerminalSql()}
221
+ AND t.title NOT LIKE '\\_\\_smoke\\_\\_%' ESCAPE '\\') AS open_tasks
222
+ FROM goals g
223
+ WHERE g.version_id = $1
224
+ AND g.status = 'open'
225
+ AND ${maintenanceGoalExemptSql('$1')}
226
+ ORDER BY g.sort_order, g.id`,
227
+ [versionId]
228
+ );
229
+ return rows;
230
+ }
231
+
232
+ // closeVersion — flip a building version to `shipped`, applying the caller's
233
+ // per-goal disposition, in ONE transaction (BV1.R12, task 1003599, ADR 0263 §5).
234
+ //
235
+ // ATOMIC FOR THE SAME REASON R14's ARCHIVE IS. A partly-closed version — some
236
+ // goals cut, some carried, the version still building — is a state no surface can
237
+ // read and no caller can tell from the outside. One transaction makes the call
238
+ // safe to retry verbatim after any error.
239
+ //
240
+ // The version row is locked FOR UPDATE first, so a goal cannot be created on it
241
+ // (or achieved into it) between the plan validating and the flip landing.
242
+ //
243
+ // WHAT THIS DOES **NOT** DO, deliberately (ADR 0263 §§4, 8, 11):
244
+ // • It does not promote the planning version. That is task 1003607 (R20), which
245
+ // must run INSIDE this transaction and BEFORE any carry-over, because
246
+ // `ensureMaintenanceGoal` returns null for a non-`building` version. The seam
247
+ // is `deps.onClosed` below rather than a later edit to this function.
248
+ // • It does not create successor goals. `roll_forward` here only ABSTAINS from
249
+ // cutting the goal; task 1003604 (R17) does the lineage write. Until it ships,
250
+ // a rolled-forward goal simply stays open on the closed version — visible and
251
+ // recoverable, which is the right failure while half the feature exists.
252
+ // • It does not write `limitations/<version>-shipped.md`. That is a repo file; a
253
+ // route cannot write one, and file I/O has no business in this transaction.
254
+ //
255
+ // `plan` is planVersionClose's validated output: [{ goalId, verb }].
256
+ async function closeVersion({ versionId, plan = [], reason }, deps = {}) {
257
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
258
+ const client = await activePool.connect();
259
+ try {
260
+ await client.query('BEGIN');
261
+
262
+ const { rows: vRows } = await client.query(
263
+ `SELECT id, name, track, status, scope_doc_path, started_at, shipped_at, done_when
264
+ FROM versions WHERE id = $1 FOR UPDATE`,
265
+ [versionId]
266
+ );
267
+ if (!vRows[0]) throw Object.assign(new Error('version not found'), { code: 'VERSION_NOT_FOUND' });
268
+ if (vRows[0].status !== 'building') {
269
+ throw Object.assign(new Error('version is not building'), {
270
+ code: 'VERSION_NOT_BUILDING', versionStatus: vRows[0].status,
271
+ });
272
+ }
273
+
274
+ const applied = [];
275
+ for (const step of plan) {
276
+ const { rows: gRows } = await client.query(
277
+ `SELECT id, title, status FROM goals WHERE id = $1 AND version_id = $2 FOR UPDATE`,
278
+ [step.goalId, versionId]
279
+ );
280
+ // The goal moved or closed itself while the caller was deciding. Not an
281
+ // error — the final re-count is what guards the invariant — but recorded so
282
+ // the response says what this call actually did.
283
+ if (!gRows[0]) { applied.push({ goal_id: String(step.goalId), verb: step.verb, result: 'skipped_not_on_version' }); continue; }
284
+ if (gRows[0].status !== 'open') {
285
+ applied.push({ goal_id: String(step.goalId), verb: step.verb, result: 'skipped_already_closed', status: gRows[0].status });
286
+ continue;
287
+ }
288
+
289
+ if (step.verb === 'abandon') {
290
+ // A goal cut by a close takes its open tasks with it, stamped with the
291
+ // close's reason — the same stamp R14 writes, so the ledger reads
292
+ // identically whichever door retired the work.
293
+ const { rows: killed } = await client.query(
294
+ `UPDATE tasks SET status = 'abandoned',
295
+ value_summary = COALESCE(NULLIF($2, ''), value_summary),
296
+ updated_at = now()
297
+ WHERE goal_id = $1 AND ${nonTerminalSql()}
298
+ AND title NOT LIKE '\\_\\_smoke\\_\\_%' ESCAPE '\\'
299
+ RETURNING id`,
300
+ [step.goalId, `Abandoned: ${reason}`]
301
+ );
302
+ await client.query(`UPDATE goals SET status = 'archived', updated_at = now() WHERE id = $1`, [step.goalId]);
303
+ applied.push({ goal_id: String(step.goalId), verb: 'abandon', result: 'archived', tasks_abandoned: killed.length });
304
+ } else {
305
+ // roll_forward: left OPEN on purpose until R17 (task 1003604) writes the
306
+ // successor + `succeeded_by_goal_id` lineage. Reported so a reader is not
307
+ // left wondering why the goal is still there.
308
+ applied.push({ goal_id: String(step.goalId), verb: 'roll_forward', result: 'carried_pending_successor' });
309
+ }
310
+ }
311
+
312
+ // The invariant, asserted against committed-in-txn state rather than against
313
+ // the plan — the one check a bug in the planner cannot fool. `roll_forward`
314
+ // goals are expected to still be open, so they are excluded by id.
315
+ const carried = plan.filter((p) => p.verb === 'roll_forward').map((p) => Number(p.goalId));
316
+ const { rows: left } = await client.query(
317
+ `SELECT count(*)::int AS n FROM goals g
318
+ WHERE g.version_id = $1 AND g.status = 'open'
319
+ AND ${maintenanceGoalExemptSql('$1')}
320
+ AND NOT (g.id = ANY($2::bigint[]))`,
321
+ [versionId, carried]
322
+ );
323
+ if ((left[0]?.n ?? 0) > 0) {
324
+ throw Object.assign(new Error('version still holds open goals after dispositions'), {
325
+ code: 'CLOSE_INCOMPLETE_AFTER_APPLY', remaining: left[0].n,
326
+ });
327
+ }
328
+
329
+ const { rows: closed } = await client.query(
330
+ `UPDATE versions SET status = 'shipped', shipped_at = now()
331
+ WHERE id = $1
332
+ RETURNING id, name, track, status, scope_doc_path, started_at, shipped_at, done_when`,
333
+ [versionId]
334
+ );
335
+
336
+ // The seam R20 (task 1003607) hooks: promotion + bug carry-over must happen
337
+ // HERE, inside this transaction and after the flip, so the successor is
338
+ // already `building` when `ensureMaintenanceGoal` is asked for it.
339
+ let after = null;
340
+ if (typeof deps.onClosed === 'function') after = await deps.onClosed(client, { versionId, plan, reason });
341
+
342
+ await client.query('COMMIT');
343
+ return { version: closed[0], applied, after };
344
+ } catch (err) {
345
+ try { await client.query('ROLLBACK'); } catch { /* the original error is the one that matters */ }
346
+ throw err;
347
+ } finally {
348
+ client.release();
349
+ }
350
+ }
351
+
163
352
  module.exports = {
353
+ closeVersion,
164
354
  createVersion,
355
+ maintenanceGoalExemptSql,
356
+ openNonMaintenanceGoals,
165
357
  currentBuildingVersionId,
166
358
  getVersion,
167
359
  listVersions,
@@ -63,7 +63,7 @@ const {
63
63
  singleRungPromotionError,
64
64
  xenosClaimAllowed,
65
65
  } = require('./db-rank-authz.js');
66
- const { createVersion, currentBuildingVersionId, getVersion, listVersions, versionProgress, versionsWithStatus } = require('./db-versions.js');
66
+ const { closeVersion, createVersion, currentBuildingVersionId, getVersion, listVersions, maintenanceGoalExemptSql, openNonMaintenanceGoals, versionProgress, versionsWithStatus } = require('./db-versions.js');
67
67
  const {
68
68
  acceptMembershipRequestAndAddMember,
69
69
  achieveGoalIfComplete,
@@ -217,7 +217,10 @@ module.exports = {
217
217
  listVersions,
218
218
  getVersion,
219
219
  versionProgress,
220
+ maintenanceGoalExemptSql,
221
+ openNonMaintenanceGoals,
220
222
  versionsWithStatus,
223
+ closeVersion,
221
224
  createVersion,
222
225
  currentBuildingVersionId,
223
226
  createGoal,
@@ -293,7 +293,7 @@ module.exports = function buildGoalsRouter() {
293
293
  try {
294
294
  const goal = await db.getGoal(id);
295
295
  if (!goal) return res.fail('goal_not_found', 404);
296
- const [members, criteria, dependencies, criterionless_task_count, admitted] = await Promise.all([
296
+ const [members, criteria, dependencies, criterionless_task_count, admitted, openTasks] = await Promise.all([
297
297
  db.listGoalMembers(id),
298
298
  db.listCriteriaForGoal(id),
299
299
  db.getGoalDependencies(id), // task 1761 — prerequisite goals + satisfied state
@@ -306,11 +306,25 @@ module.exports = function buildGoalsRouter() {
306
306
  // override is worthless as an audit trail if reading the goal never
307
307
  // shows it.
308
308
  db.getGoalAdmission(id),
309
+ // BV1.R24 (task 1003611): WHAT IS ACTUALLY HOLDING THIS GOAL OPEN.
310
+ //
311
+ // Until R08/R09 (tasks 1003595, 1003596) a goal closed on its criteria
312
+ // alone, so "N of M criteria satisfied" was the whole answer and this page
313
+ // told the truth. It is not the whole answer any more: ADR 0250 D2 added
314
+ // "AND no task in it is non-terminal", and a task linked to NO criterion
315
+ // now holds the goal open while appearing nowhere in the rollup. A builder
316
+ // reading 5-of-5 and finding the goal still open has no way to see why.
317
+ //
318
+ // The SAME read the archive refusal returns (openTasksInGoal), so the page
319
+ // and the 409 cannot disagree about what is in the way — and it is what the
320
+ // archive flow previews before collecting a disposition.
321
+ db.openTasksInGoal(id),
309
322
  ]);
310
323
  const byId = await goalCategory.categoryIndex();
311
324
  return res.json({
312
325
  goal: goalCategory.decorateGoalCategory(decorateGoalScope(goal), byId),
313
326
  members, criteria, dependencies, criterionless_task_count, admitted,
327
+ open_tasks: openTasks,
314
328
  });
315
329
  } catch (err) {
316
330
  log.error('[gds] GET /goals/:id', err);
@@ -93,4 +93,134 @@ function authorizeVersionBuild({ building = [] }) {
93
93
  };
94
94
  }
95
95
 
96
- module.exports = { authorizeVersionCreate, authorizeVersionBuild };
96
+ // planVersionClose the PURE decision behind POST /versions/:id/close
97
+ // (BV1.R12, task 1003599, goal 1000086, ADR 0250 D5, ADR 0263 §5).
98
+ //
99
+ // THE ASYMMETRY THAT SHAPES THIS (ADR 0263 §3): auto-close fires when the last
100
+ // non-maintenance goal achieves, so by construction NOTHING is left to
101
+ // disposition on that path. Every rule below therefore belongs to EARLY close —
102
+ // an Archon deliberately closing a version that still holds open goals — and the
103
+ // auto path (task 1003603, R16) reaches the same writer with an empty plan.
104
+ //
105
+ // THE SHAPE IS R14'S ARCHIVE TWO-STEP, deliberately (planGoalArchiveDispositions
106
+ // in goal-route-authz.js): refuse once, RETURNING the work; accept a total
107
+ // disposition map on the second call. Two idioms for the same interaction is how
108
+ // two surfaces drift, and agents already know this one.
109
+ //
110
+ // `openGoals` is the version's open, NON-MAINTENANCE goals — the maintenance goal
111
+ // is exempt from the count (ADR 0263 §7) and always carries forward, so it is
112
+ // never the caller's decision and must be filtered out BEFORE this is called.
113
+ //
114
+ // Returns { ok: true, plan: [{ goalId, verb }] } ordered to match `openGoals`, or
115
+ // { ok: false, status, body } — the exact HTTP status and JSON the route sends.
116
+ const VERSION_CLOSE_VERBS = new Set(['roll_forward', 'abandon']);
117
+
118
+ function planVersionClose({ version, openGoals = [], dispositions = null, reason = '' }) {
119
+ if (!version) {
120
+ return { ok: false, status: 404, body: { error: 'version_not_found', message: 'No such version.' } };
121
+ }
122
+ // Only a BUILDING version closes. Closing a `planning` one is meaningless
123
+ // (nothing was built) and closing a `shipped`/`frozen` one is a double-close;
124
+ // saying which status refused beats asserting one the caller can see is wrong.
125
+ if (version.status !== 'building') {
126
+ return {
127
+ ok: false,
128
+ status: 409,
129
+ body: {
130
+ error: 'version_not_building',
131
+ status: version.status,
132
+ message: `Version '${version.id}' is '${version.status}' — only a building version can close.`,
133
+ },
134
+ };
135
+ }
136
+
137
+ const held = Array.isArray(openGoals) ? openGoals.filter(Boolean) : [];
138
+ if (held.length === 0) return { ok: true, plan: [] };
139
+
140
+ if (!dispositions || typeof dispositions !== 'object' || Array.isArray(dispositions)) {
141
+ return {
142
+ ok: false,
143
+ status: 409,
144
+ body: {
145
+ error: 'version_holds_open_goals',
146
+ message: `Version '${version.id}' still holds ${held.length} open goal(s). Re-submit with a \`dispositions\` map saying what happens to each — {"<goal id>":{"verb":"roll_forward"}} or {"<goal id>":{"verb":"abandon"}}.`,
147
+ details: { total: held.length, shown: held.length, goals: held },
148
+ },
149
+ };
150
+ }
151
+
152
+ // A goal abandoned by a close is stamped with the close's reason, the way R14
153
+ // stamps a task. A close that disposes of someone's goal without saying why is
154
+ // the silent discard D3 forbids, one tier up.
155
+ if (typeof reason !== 'string' || reason.trim().length === 0) {
156
+ return {
157
+ ok: false,
158
+ status: 400,
159
+ body: {
160
+ error: 'close_reason_required',
161
+ message: 'Closing a version that still holds open goals records why. Pass a non-empty `reason`; it is stamped onto everything this call abandons.',
162
+ },
163
+ };
164
+ }
165
+
166
+ const plan = [];
167
+ const missing = [];
168
+ for (const g of held) {
169
+ const d = dispositions[String(g.id)];
170
+ if (!d || typeof d !== 'object') { missing.push(g); continue; }
171
+ const verb = typeof d.verb === 'string' ? d.verb : null;
172
+ if (!VERSION_CLOSE_VERBS.has(verb)) {
173
+ return {
174
+ ok: false,
175
+ status: 400,
176
+ body: {
177
+ error: 'bad_disposition',
178
+ message: `Goal #${g.id}: verb must be "roll_forward" or "abandon".`,
179
+ details: { goal_id: String(g.id), verb: d.verb ?? null, valid: Array.from(VERSION_CLOSE_VERBS) },
180
+ },
181
+ };
182
+ }
183
+ plan.push({ goalId: Number(g.id), verb });
184
+ }
185
+
186
+ if (missing.length > 0) {
187
+ return {
188
+ ok: false,
189
+ status: 400,
190
+ body: {
191
+ error: 'disposition_incomplete',
192
+ message: `${missing.length} of ${held.length} open goal(s) have no disposition. Every one needs a decision — nothing is carried or cut by omission.`,
193
+ details: { total: held.length, undecided: missing.length, goals: missing },
194
+ },
195
+ };
196
+ }
197
+ return { ok: true, plan };
198
+ }
199
+
200
+ // rollForwardNeedsPlanningVersion — a `roll_forward` needs somewhere to roll TO.
201
+ //
202
+ // REFUSED RATHER THAN AUTO-CREATED, and that is the decision worth remembering
203
+ // (ADR 0263 §5): cutting the successor version is a scope decision with its own
204
+ // criteria, and doing it implicitly inside a close is exactly the "fake hotfix
205
+ // version" that ADR 0250 §3 built the override counter to prevent.
206
+ //
207
+ // Returns null when the close may proceed, or the refusal's parts.
208
+ function rollForwardNeedsPlanningVersion({ plan = [], planning = [] }) {
209
+ if (!plan.some((p) => p.verb === 'roll_forward')) return null;
210
+ if (Array.isArray(planning) && planning.length > 0) return null;
211
+ return {
212
+ status: 409,
213
+ body: {
214
+ error: 'no_planning_version',
215
+ message: 'A goal was dispositioned `roll_forward`, but no version is in planning to receive it. Scope the next version first (POST /versions with status "planning"), or abandon the goal instead.',
216
+ },
217
+ };
218
+ }
219
+
220
+ module.exports = {
221
+ authorizeVersionCreate,
222
+ authorizeVersionBuild,
223
+ planVersionClose,
224
+ rollForwardNeedsPlanningVersion,
225
+ VERSION_CLOSE_VERBS,
226
+ };
@@ -23,7 +23,7 @@ const auth = api;
23
23
  const db = require('../db');
24
24
  const versionClose = require('../version-close');
25
25
  const doneWhen = require('../done-when');
26
- const { authorizeVersionCreate, authorizeVersionBuild } = require('./version-route-authz.js');
26
+ const { authorizeVersionCreate, authorizeVersionBuild, planVersionClose, rollForwardNeedsPlanningVersion } = require('./version-route-authz.js');
27
27
  const { asyncHandler, validateOrRespond } = api;
28
28
 
29
29
  // versions.status CHECK (migration 003). A new version starts 'planning' or
@@ -225,5 +225,83 @@ module.exports = function buildVersionsRouter() {
225
225
  res.status(201).json({ version, criteria: created });
226
226
  }, { errorCode: 'create_version_failed' }));
227
227
 
228
+
229
+ // POST /versions/:id/close — the move a version has never had (BV1.R12, task
230
+ // 1003599, goal 1000086, ADR 0250 D5, ADR 0263 §5).
231
+ //
232
+ // The hierarchy had a bottom and no top: tasks close, criteria close, goals
233
+ // close, and a version only ever moved by hand-run SQL on the droplet —
234
+ // `scripts/gds/version-close.js` still PRINTS that the API cannot do this (task
235
+ // 1003610, R23, points it here). A thing with no closing move does not close,
236
+ // which is how two versions ended up building at once.
237
+ //
238
+ // Archon-only, behind the `version.close` permission that has existed at that
239
+ // floor since BV1.R105 and that, until now, NOTHING referenced.
240
+ //
241
+ // THE TWO-STEP IS R14's ARCHIVE, in spirit and in shape: call it bare and a
242
+ // version holding open goals refuses with the GOALS ATTACHED; re-submit with a
243
+ // total `dispositions` map and it closes. Agents already know that interaction,
244
+ // and a second idiom for the same thing is how two surfaces drift apart.
245
+ //
246
+ // Auto-close (task 1003603, R16) reaches `db.closeVersion` with an EMPTY plan
247
+ // and never touches this route: by construction it fires only when the last
248
+ // non-maintenance goal achieved, so nothing is left to disposition (ADR 0263 §3).
249
+ // rank: archon — the disposition that finalises a version's scope-truth.
250
+ router.post('/versions/:id/close', auth.requireBuilder, auth.requirePermission('version.close'), asyncHandler('POST /versions/:id/close', async (req, res) => {
251
+ // ADR 0118 / R12: declare the body, so a typo'd `disposition` (singular) is
252
+ // REJECTED rather than read as "none given" — which would take the refusal
253
+ // branch and tell a caller who sent a complete map that the version holds open
254
+ // goals. Contents are validated by planVersionClose, which names every
255
+ // malformed entry.
256
+ if (validateOrRespond(req, res, {
257
+ reason: { type: 'string', maxLength: 2000 },
258
+ dispositions: { type: 'object' },
259
+ })) return;
260
+ const versionId = String(req.params.id || '').trim();
261
+ if (!versionId) return res.fail('version_id_required', 400);
262
+
263
+ const version = await db.getVersion(versionId);
264
+ const reason = typeof req.body?.reason === 'string' ? req.body.reason.slice(0, 2000) : '';
265
+ // The maintenance goal is filtered out by the READ (ADR 0263 §7): it is exempt
266
+ // from the close count and always carries forward, so it is never the caller's
267
+ // decision and must not appear in a refusal asking them to make one.
268
+ const openGoals = version ? await db.openNonMaintenanceGoals(versionId) : [];
269
+
270
+ const plan = planVersionClose({ version, openGoals, dispositions: req.body?.dispositions ?? null, reason });
271
+ if (!plan.ok) return res.status(plan.status).json(plan.body);
272
+
273
+ // A roll_forward needs a planning version to roll INTO, and its absence is a
274
+ // REFUSAL rather than an auto-create: cutting the successor is a scope
275
+ // decision with its own criteria, and doing it implicitly inside a close is
276
+ // the "fake hotfix version" ADR 0250 §3 built the override counter to prevent.
277
+ const needsPlanning = rollForwardNeedsPlanningVersion({
278
+ plan: plan.plan,
279
+ planning: plan.plan.some((p) => p.verb === 'roll_forward') ? await db.versionsWithStatus('planning') : [],
280
+ });
281
+ if (needsPlanning) return res.status(needsPlanning.status).json(needsPlanning.body);
282
+
283
+ try {
284
+ const out = await db.closeVersion({ versionId, plan: plan.plan, reason });
285
+ return res.json({ ok: true, version: out.version, applied: out.applied });
286
+ } catch (err) {
287
+ if (err && err.code === 'VERSION_NOT_FOUND') return res.fail('version_not_found', 404);
288
+ if (err && err.code === 'VERSION_NOT_BUILDING') {
289
+ return res.fail('version_not_building', {
290
+ status: 409,
291
+ message: `Version '${versionId}' changed to '${err.versionStatus}' while the close was running — nothing was applied.`,
292
+ details: { status: err.versionStatus },
293
+ });
294
+ }
295
+ if (err && err.code === 'CLOSE_INCOMPLETE_AFTER_APPLY') {
296
+ return res.fail('version_holds_open_goals', {
297
+ status: 409,
298
+ message: `Version '${versionId}' still held ${err.remaining} open goal(s) after the dispositions were applied — nothing was changed. Re-read and retry.`,
299
+ details: { remaining: err.remaining },
300
+ });
301
+ }
302
+ throw err;
303
+ }
304
+ }, { errorCode: 'close_version_failed' }));
305
+
228
306
  return router;
229
307
  };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.592",
3
+ "version": "1.19.594",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.592",
9
+ "version": "1.19.594",
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.592",
3
+ "version": "1.19.594",
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",