@bongos/core 1.19.600 → 1.19.602

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.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.600",
6
- "core_contract": "1.19.600",
7
- "source_commit": "8e02f02563986161d1b77b266027bab29d5534aa",
5
+ "core_version": "1.19.602",
6
+ "core_contract": "1.19.602",
7
+ "source_commit": "ca03701e78c9390c176c73f0dec2b563083b1e43",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-08T04:42:02.346Z",
9
+ "built_at": "2026-09-08T05:15:57.913Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 457,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 2075,
14
+ "functional_verbatim": 2077,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2556,
20
- "tree_sha256": "a008ff8e9029ba8be4b741e6ce36012a90a005bad10e76f4fed030cee2404487",
19
+ "file_count": 2558,
20
+ "tree_sha256": "13e4a26b146d93eb6acf0b25727174b402c29e627a2a522f800f30be739dab2f",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -392,7 +392,7 @@
392
392
  {
393
393
  "path": "clients/bongos-client/index.d.ts",
394
394
  "mode": "0000644",
395
- "sha256": "16af99cbe6df845f0f7a90f4569f77550635af0967dce552e2ef07a7b4ef0080"
395
+ "sha256": "877ca14cd017c1392fcc5206c2f3603fb24becdc32670aa1e2ad0de9d930dfca"
396
396
  },
397
397
  {
398
398
  "path": "clients/bongos-client/index.mjs",
@@ -1852,7 +1852,7 @@
1852
1852
  {
1853
1853
  "path": "docs/api/openapi.json",
1854
1854
  "mode": "0000644",
1855
- "sha256": "f2433a337aed6aa2f0f1a1c2d61b17103b493a028fa03c2944b92b5f3bb5e609"
1855
+ "sha256": "81485c0440de636511dc9ee723e18af89f313b175ea626723d0beeeed31bba2f"
1856
1856
  },
1857
1857
  {
1858
1858
  "path": "docs/architecture.md",
@@ -2727,7 +2727,7 @@
2727
2727
  {
2728
2728
  "path": "docs/module-api-changelog.md",
2729
2729
  "mode": "0000644",
2730
- "sha256": "251ae2f82d65d325f220649d865bc5413a3febbf493db6994b6f3e9713968ac8"
2730
+ "sha256": "bd7f11eca7a06de98c0f11aad60e8128cda215ac91d9bc108af94b54e9c7df91"
2731
2731
  },
2732
2732
  {
2733
2733
  "path": "docs/modules-contract.md",
@@ -5567,12 +5567,12 @@
5567
5567
  {
5568
5568
  "path": "modules/lifecycle/db-versions.js",
5569
5569
  "mode": "0000644",
5570
- "sha256": "53f87c20f89c33a3d14ee4410bafa0f3122fb7534708ce5bdacb8cc1098a487c"
5570
+ "sha256": "2c10a17f86506788ab2f9db25c23024120ec9fddb3c7c4894e65d8354cd46869"
5571
5571
  },
5572
5572
  {
5573
5573
  "path": "modules/lifecycle/db.js",
5574
5574
  "mode": "0000644",
5575
- "sha256": "33fc8959d522bcf18c445b1a5710940379f84d1e938eecdc52cdf52bec9c4b3d"
5575
+ "sha256": "b4b2d6d7890ec67e52fc00718a814bf01a64f4432ed7380a175dca8bc794b0e4"
5576
5576
  },
5577
5577
  {
5578
5578
  "path": "modules/lifecycle/dead-deps.js",
@@ -5817,7 +5817,7 @@
5817
5817
  {
5818
5818
  "path": "modules/lifecycle/routes/versions.js",
5819
5819
  "mode": "0000644",
5820
- "sha256": "35919f2cdd1e9a5da11a89adc9731818aa6b1b292c0754d9123a4cd8324bd970"
5820
+ "sha256": "014de34389dbf26b4b089b677466c9c7cfd81f79b9e1714241be12f589086c0f"
5821
5821
  },
5822
5822
  {
5823
5823
  "path": "modules/lifecycle/routes/visuals.js",
@@ -7627,12 +7627,12 @@
7627
7627
  {
7628
7628
  "path": "package-lock.json",
7629
7629
  "mode": "0000644",
7630
- "sha256": "d3456f52cc8555eb1a20f884dc2d32240032138193c5d525ea455d07423e9656"
7630
+ "sha256": "4f6f3fe27c26565c9b2b010a7970bfe1322e4d91d53d6bb8c4f5cfd592c8c7d3"
7631
7631
  },
7632
7632
  {
7633
7633
  "path": "package.json",
7634
7634
  "mode": "0000644",
7635
- "sha256": "107d899ff6ec92f0f2dd863f62ab2a68eaa82c7f60d9e172c243b9b5ff421f52"
7635
+ "sha256": "43b5dfed26f44044bbb3730cd8dd0ac08f251e95a2c0942d440d1dd8c5ca5633"
7636
7636
  },
7637
7637
  {
7638
7638
  "path": "public-docs/index.html",
@@ -8932,7 +8932,7 @@
8932
8932
  {
8933
8933
  "path": "scripts/gds/version-close.js",
8934
8934
  "mode": "0000644",
8935
- "sha256": "32e0c1f2a955d4cabeae00d3749b283abd70f05f34b5fcbd84be3f35e2e655c8"
8935
+ "sha256": "2db9a69b8a64873eee7cd473ce2915df51ccfffb79f289ec01e0419d2861ba3e"
8936
8936
  },
8937
8937
  {
8938
8938
  "path": "scripts/gds/version-literal-guard.js",
@@ -9347,7 +9347,7 @@
9347
9347
  {
9348
9348
  "path": "src/module-api.js",
9349
9349
  "mode": "0000644",
9350
- "sha256": "fa5a67fefe06f235502a19ee017a75467ab1f5cadf77c2b004edd4109b397ece"
9350
+ "sha256": "ecb3569e4061f1cb6cb82d9d88eeb00e0cf7eaf56796ee5c1bacba73ca6b2ef1"
9351
9351
  },
9352
9352
  {
9353
9353
  "path": "src/module-loader/catalog.js",
@@ -12704,6 +12704,11 @@
12704
12704
  "mode": "0000644",
12705
12705
  "sha256": "372c80d42e122a70a4fae8fcfbaaa47459575d3e0a2b80fff8876dfc512c5aeb"
12706
12706
  },
12707
+ {
12708
+ "path": "tests/version_close_cli.mjs",
12709
+ "mode": "0000644",
12710
+ "sha256": "7f7f4cc397bea0d319d5549b50aea178507dfeb2bdaf680fd42f4c80f56509ac"
12711
+ },
12707
12712
  {
12708
12713
  "path": "tests/version_close_route.mjs",
12709
12714
  "mode": "0000644",
@@ -12719,6 +12724,11 @@
12719
12724
  "mode": "0000644",
12720
12725
  "sha256": "692261c26e3da46519757733a7fcf9a9998e0942bc5890688b66b79590d82c27"
12721
12726
  },
12727
+ {
12728
+ "path": "tests/version_promotion.mjs",
12729
+ "mode": "0000644",
12730
+ "sha256": "43ba25381f3d02d7ecaaa8a2639bb8f1f72587d6966e6498db1c887b289148eb"
12731
+ },
12722
12732
  {
12723
12733
  "path": "tests/version_roll_forward.mjs",
12724
12734
  "mode": "0000644",
@@ -390,7 +390,7 @@ export interface PostTasksNewcomerRestockResponse { ok: boolean; stamped: unknow
390
390
  export interface PostTasksRequest { version_id: string; title: string; description?: string; status?: string; touches?: string[]; est_minutes?: number; est_cost_usd?: number; manual_degree?: number; priority?: number; automation_tag?: string; credits_reward?: number; source?: string; source_ref?: string; value_summary?: string; kind?: string; discipline?: string; parallel_safe?: unknown; newcomer_friendly?: boolean; security_sensitive?: boolean; requires_rank?: string; auto_detect_touches?: boolean; criterion_ids?: unknown[]; module_key?: string; goal_id?: number }
391
391
  export interface PostTasksResponse { task: unknown; needs_migration: unknown; criteria: unknown; idempotent: boolean }
392
392
  export interface PostVersionsIdCloseRequest { reason?: string; dispositions?: Record<string, unknown> }
393
- export interface PostVersionsIdCloseResponse { ok: boolean; version: unknown; applied: unknown }
393
+ export interface PostVersionsIdCloseResponse { ok: boolean; version: unknown; applied: unknown; promoted: unknown; carried: unknown }
394
394
  export interface PostVersionsIdDoneWhenRequest { criterion_id: string; criterion_md: string; goal_id?: number; sort_order?: number }
395
395
  export interface PostVersionsIdDoneWhenResponse { ok: boolean; criterion: unknown }
396
396
  export interface PostVersionsRequest { id: string; name: string; status?: string; track?: string; done_when?: string; scope_doc_path?: string; criteria?: unknown[] }
@@ -22494,12 +22494,16 @@
22494
22494
  "type": "boolean"
22495
22495
  },
22496
22496
  "version": {},
22497
- "applied": {}
22497
+ "applied": {},
22498
+ "promoted": {},
22499
+ "carried": {}
22498
22500
  },
22499
22501
  "required": [
22500
22502
  "ok",
22501
22503
  "version",
22502
- "applied"
22504
+ "applied",
22505
+ "promoted",
22506
+ "carried"
22503
22507
  ]
22504
22508
  },
22505
22509
  "PostVersionsIdDoneWhenRequest": {
@@ -1649,5 +1649,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1649
1649
  landed since 1.19.598 with no explicit bump. run 34187136306. (task 1002620)
1650
1650
  1.19.600 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1651
1651
  landed since 1.19.599 with no explicit bump. run 34187881031. (task 1002620)
1652
+ 1.19.601 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1653
+ landed since 1.19.600 with no explicit bump. run 34188958832. (task 1002620)
1654
+ 1.19.602 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1655
+ landed since 1.19.601 with no explicit bump. run 34189930315. (task 1002620)
1652
1656
  ---------------------------------------------------------------------------
1653
1657
  ```
@@ -245,14 +245,51 @@ async function openNonMaintenanceGoals(versionId, deps = {}) {
245
245
  // must run INSIDE this transaction and BEFORE any carry-over, because
246
246
  // `ensureMaintenanceGoal` returns null for a non-`building` version. The seam
247
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.
248
+ // • It DOES create successor goals now task 1003604 (R17) shipped, so
249
+ // `roll_forward` writes the successor, moves the unfinished tasks and records
250
+ // `succeeded_by_goal_id`. This note used to say the opposite; it is kept,
251
+ // corrected, because a reader who met the old wording would otherwise go
252
+ // looking for the abstention it described.
252
253
  // • It does not write `limitations/<version>-shipped.md`. That is a repo file; a
253
254
  // route cannot write one, and file I/O has no business in this transaction.
254
255
  //
255
256
  // `plan` is planVersionClose's validated output: [{ goalId, verb }].
257
+ // promotePlanningVersion — the single `planning` version becomes the live train
258
+ // (BV1.R20, task 1003607, goal 1000086, ADR 0263 §8).
259
+ //
260
+ // WHY IT RUNS IN THE CLOSE'S TRANSACTION. A project with zero building versions
261
+ // is a BROKEN state, not an intermediate one: `currentBuildingVersionId` returns
262
+ // null there and every routing path closed over it fails, so nothing is
263
+ // claimable. Promoting in a second transaction would leave exactly that window
264
+ // open, however briefly.
265
+ //
266
+ // ZERO PLANNING VERSIONS IS NOT AN ERROR. The close succeeds and the project sits
267
+ // with no live train until someone cuts one. Refusing the close instead would
268
+ // trap a finished version open because nobody had scoped the next one yet — and
269
+ // the close is the thing that makes scoping the next one worth doing.
270
+ //
271
+ // SELF-GUARDING IN SQL rather than by a read-then-write. The `NOT EXISTS`
272
+ // sub-select is what keeps this from creating the second building version R19
273
+ // forbids: it is evaluated inside the same statement, so a concurrent close
274
+ // cannot slip a promotion past a check that has already run. `ORDER BY id LIMIT 1`
275
+ // makes the pick deterministic; R04 guarantees there is at most one to pick.
276
+ //
277
+ // `started_at` is stamped only if it was never set — a version's start is when it
278
+ // began building, and a re-promotion (which cannot happen, but the COALESCE costs
279
+ // nothing) must not rewrite history.
280
+ //
281
+ // Returns the promoted row, or null when there was nothing to promote.
282
+ async function promotePlanningVersion(exec) {
283
+ const { rows } = await exec.query(
284
+ `UPDATE versions
285
+ SET status = 'building', started_at = COALESCE(started_at, now())
286
+ WHERE id = (SELECT id FROM versions WHERE status = 'planning' ORDER BY id LIMIT 1)
287
+ AND NOT EXISTS (SELECT 1 FROM versions WHERE status = 'building')
288
+ RETURNING id, name, track, status, started_at`
289
+ );
290
+ return rows[0] ?? null;
291
+ }
292
+
256
293
  async function closeVersion({ versionId, plan = [], reason, planningVersionId = null }, deps = {}) {
257
294
  const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
258
295
  const client = await activePool.connect();
@@ -437,6 +474,7 @@ async function closeVersion({ versionId, plan = [], reason, planningVersionId =
437
474
 
438
475
  module.exports = {
439
476
  closeVersion,
477
+ promotePlanningVersion,
440
478
  createVersion,
441
479
  maintenanceGoalExemptSql,
442
480
  openNonMaintenanceGoals,
@@ -63,7 +63,7 @@ const {
63
63
  singleRungPromotionError,
64
64
  xenosClaimAllowed,
65
65
  } = require('./db-rank-authz.js');
66
- const { closeVersion, createVersion, currentBuildingVersionId, getVersion, listVersions, maintenanceGoalExemptSql, openNonMaintenanceGoals, versionProgress, versionsWithStatus } = require('./db-versions.js');
66
+ const { closeVersion, promotePlanningVersion, createVersion, currentBuildingVersionId, getVersion, listVersions, maintenanceGoalExemptSql, openNonMaintenanceGoals, versionProgress, versionsWithStatus } = require('./db-versions.js');
67
67
  const {
68
68
  acceptMembershipRequestAndAddMember,
69
69
  achieveGoalIfComplete,
@@ -222,6 +222,7 @@ module.exports = {
222
222
  openNonMaintenanceGoals,
223
223
  versionsWithStatus,
224
224
  closeVersion,
225
+ promotePlanningVersion,
225
226
  createVersion,
226
227
  currentBuildingVersionId,
227
228
  createGoal,
@@ -287,12 +287,53 @@ module.exports = function buildVersionsRouter() {
287
287
  try {
288
288
  const out = await db.closeVersion({
289
289
  versionId, plan: plan.plan, reason,
290
+ // BV1.R20 (task 1003607, ADR 0263 §8): the close's last act, inside its
291
+ // own transaction. A project with zero building versions is a BROKEN
292
+ // state — `currentBuildingVersionId` returns null and nothing is
293
+ // claimable — so promoting in a second transaction would leave exactly
294
+ // that window open.
295
+ //
296
+ // ORDER IS LOAD-BEARING: promote, THEN carry the bugs forward.
297
+ // `ensureMaintenanceGoal` returns null for a version that is not yet
298
+ // `building`, so a carry-over run first silently carries nothing and the
299
+ // defect queue stays entombed on the version that just shipped.
300
+ //
301
+ // COMPOSED HERE rather than inside closeVersion because the two halves
302
+ // live in different db units (versions and goals, siblings in the
303
+ // module's require DAG) and the route is the one place already holding
304
+ // the whole facade — reaching sideways between the units is how that DAG
305
+ // stops being one.
306
+ //
307
+ // Zero planning versions is NOT an error: the close succeeds and the
308
+ // project sits idle until someone cuts the next version.
290
309
  // R04 guarantees at most one planning version, so [0] is THE successor
291
310
  // rather than a pick. Null when nothing rolls forward, which is the
292
311
  // common close.
293
312
  planningVersionId: planning[0]?.id ?? null,
313
+ }, {
314
+ // NOTE THE SECOND ARGUMENT. `closeVersion` reads this hook from `deps`,
315
+ // not from its options object — putting it in the first one is silently
316
+ // ignored, the hook never runs, and the close still returns 200 having
317
+ // promoted nothing. Caught here by running the real close against a real
318
+ // Postgres; no fake would have noticed.
319
+ onClosed: async (client, { versionId: closedId }) => {
320
+ const promoted = await db.promotePlanningVersion(client);
321
+ const carried = promoted
322
+ ? await db.carryBugsForward({ fromVersionId: closedId, toVersionId: promoted.id }, { client })
323
+ : null;
324
+ return { promoted, carried };
325
+ },
326
+ });
327
+ // `promoted` and `carried` ride the response because a close now changes
328
+ // WHICH version is live, and a caller that has to go and re-read
329
+ // /versions to discover that has been told half the outcome. `promoted:
330
+ // null` is the honest answer for a project with nothing scoped next, not
331
+ // an omission.
332
+ return res.json({
333
+ ok: true, version: out.version, applied: out.applied,
334
+ promoted: out.after?.promoted ?? null,
335
+ carried: out.after?.carried ?? null,
294
336
  });
295
- return res.json({ ok: true, version: out.version, applied: out.applied });
296
337
  } catch (err) {
297
338
  if (err && err.code === 'VERSION_NOT_FOUND') return res.fail('version_not_found', 404);
298
339
  if (err && err.code === 'VERSION_NOT_BUILDING') {
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.600",
3
+ "version": "1.19.602",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.600",
9
+ "version": "1.19.602",
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.600",
3
+ "version": "1.19.602",
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",
@@ -11,12 +11,15 @@
11
11
  // 3. Identifies carry-over candidates (backlog + ready) and drafts a
12
12
  // next-version scope stub (e.g. limitations/pms-v3.md) with a
13
13
  // "## Carried Over from <VID>" section.
14
- // 4. Prints the SQL needed to (a) flip the closing version to 'shipped',
15
- // (b) insert the next-version row with status='building', and
16
- // (c) rebind carry-over task version_id values. The GDS API does not
17
- // yet expose endpoints for these operations; per task #43 spec this
18
- // ships with explicit "MANUAL STEP REQUIRED" output rather than
19
- // extending routes.js (out of touches[]).
14
+ // 4. CLOSES the version through POST /versions/:id/close (BV1.R23, task
15
+ // 1003610). This step used to print a "MANUAL STEP REQUIRED" block of SQL
16
+ // to paste into psql on the droplet, because at the time the API genuinely
17
+ // exposed no version status transition. BV1.R12 (task 1003599) built the
18
+ // route, so that text became false and by then the SQL was actively
19
+ // harmful, since it bypasses the disposition two-step, the closure
20
+ // invariant, the successor lineage, the promotion and the bug carry-forward.
21
+ // Closing a version by hand now silently strands everything the route
22
+ // carries.
20
23
  // 5. Prints a suggested git commit message.
21
24
  //
22
25
  // Every destructive write (file create, INSERT, UPDATE) prompts y/N first.
@@ -38,7 +41,7 @@ const REPO_ROOT = path.resolve(__dirname, '..', '..');
38
41
  // -------------------------------------------------------------------------
39
42
 
40
43
  function parseArgs(argv) {
41
- const out = { versionId: null, yes: false, nextId: null, noNextStub: false };
44
+ const out = { versionId: null, yes: false, nextId: null, noNextStub: false, reason: null, dispositions: {} };
42
45
  for (let i = 2; i < argv.length; i++) {
43
46
  const a = argv[i];
44
47
  if (a === '--yes' || a === '-y') {
@@ -49,6 +52,16 @@ function parseArgs(argv) {
49
52
  out.nextId = argv[++i];
50
53
  } else if (a.startsWith('--next=')) {
51
54
  out.nextId = a.slice('--next='.length);
55
+ } else if (a === '--reason') {
56
+ out.reason = argv[++i];
57
+ } else if (a.startsWith('--reason=')) {
58
+ out.reason = a.slice('--reason='.length);
59
+ } else if (a === '--disposition' || a.startsWith('--disposition=')) {
60
+ // `<goalId>=<verb>`, repeatable. Parsed into the exact map shape
61
+ // POST /versions/:id/close expects, so the operator never hand-writes JSON.
62
+ const raw = a.startsWith('--disposition=') ? a.slice('--disposition='.length) : argv[++i];
63
+ const eq = String(raw || '').indexOf('=');
64
+ if (eq > 0) out.dispositions[raw.slice(0, eq)] = { verb: raw.slice(eq + 1) };
52
65
  } else if (a === '-h' || a === '--help') {
53
66
  out.help = true;
54
67
  } else if (!out.versionId && !a.startsWith('-')) {
@@ -62,8 +75,15 @@ function usage() {
62
75
  return [
63
76
  'Usage: node scripts/gds/version-close.js <VERSION_ID> [--yes|-y] [--next <ID>] [--no-next-stub]',
64
77
  '',
65
- 'Drafts <scope>-shipped.md + next-version stub, prints SQL for the',
66
- 'destructive parts. Always interactive without --yes.',
78
+ 'Drafts <scope>-shipped.md + next-version stub, then CLOSES the version',
79
+ 'through POST /versions/:id/close. Always interactive without --yes.',
80
+ '',
81
+ ' --reason <text> recorded on the close; stamped on any task a',
82
+ ' disposition abandons. Required by the route',
83
+ ' whenever a goal is dispositioned.',
84
+ ' --disposition <id>=<verb> roll_forward | abandon, repeatable — one per',
85
+ ' open goal. Without them the close REFUSES and',
86
+ ' lists the goals, so nothing is cut by omission.',
67
87
  '',
68
88
  ' --no-next-stub Skip drafting the next-version scope stub entirely',
69
89
  ' (enforces the guided-review-pass memory rule). The',
@@ -74,6 +94,8 @@ function usage() {
74
94
  ' node scripts/gds/version-close.js PMS-V2 --next GDS-V3',
75
95
  ' node scripts/gds/version-close.js PMS-V2 --yes',
76
96
  ' node scripts/gds/version-close.js PMS-V2 --no-next-stub',
97
+ ' node scripts/gds/version-close.js PMS-V2 --disposition 44=roll_forward \\',
98
+ ' --disposition 51=abandon --reason "V2 is done; 51 is not coming back"',
77
99
  ].join('\n');
78
100
  }
79
101
 
@@ -277,74 +299,66 @@ function buildNextVersionStub({ nextVersionId, prevVersionId, carryOverTasks })
277
299
  }
278
300
 
279
301
  // -------------------------------------------------------------------------
280
- // SQL builders (no API endpoints exist for these print as manual steps)
302
+ // The close itself a ROUTE call, not printed SQL (BV1.R23, task 1003610)
281
303
  // -------------------------------------------------------------------------
304
+ //
305
+ // THIS FILE USED TO PRINT A `-- MANUAL STEP REQUIRED` BLOCK and tell the operator
306
+ // to run three UPDATEs on the production droplet, on the entirely true grounds
307
+ // that "the GDS API does not currently expose endpoints for version status
308
+ // transitions". BV1.R12 (task 1003599) built `POST /versions/:id/close`, so that
309
+ // sentence became false — and a script that keeps printing it is worse than one
310
+ // that never offered anything, because the SQL path BYPASSES every rule this goal
311
+ // exists to enforce: the disposition two-step (R12/R14), the closure invariant
312
+ // (R08/R09), the successor lineage (R17), the promotion (R20) and the bug
313
+ // carry-forward (R18). An operator pasting that SQL would close a version and
314
+ // silently strand everything the route would have carried.
315
+ //
316
+ // The advisory half of this script — `suggestClose`, the shipped archive, the
317
+ // next-version scope stub — is unchanged and still the reason to run it.
282
318
 
283
- function sqlEscape(s) {
284
- return String(s).replace(/'/g, "''");
319
+ async function closeVersionViaApi({ versionId, reason, dispositions }) {
320
+ const api = await cliClient();
321
+ const body = { reason };
322
+ if (dispositions && Object.keys(dispositions).length > 0) body.dispositions = dispositions;
323
+ const { ok, status, data } = await api.versions.postVersionsIdClose({ path: { id: versionId }, body });
324
+ return { ok, status, data };
285
325
  }
286
326
 
287
- function buildManualSql({ versionId, versionName, nextVersionId, nextScopeDocPath, carryOverTasks, nextVersionExists }) {
327
+ // The route answers the R12 two-step: called with open goals and no disposition
328
+ // it REFUSES and returns them, so the operator can decide per goal rather than
329
+ // being told only that something is in the way. Rendered here rather than dumped
330
+ // as JSON because the next thing the operator types is the re-run.
331
+ function renderOpenGoalsRefusal(versionId, details) {
332
+ const goals = (details && details.goals) || [];
288
333
  const lines = [
289
- '-- MANUAL STEP REQUIRED -----------------------------------------------',
290
- '-- The GDS API does not currently expose endpoints for: version status',
291
- '-- transitions, version creation from CLI, or task version_id rebinding.',
292
- '-- Run the SQL below on the production droplet (psql amazonprimea).',
293
- '-- ---------------------------------------------------------------------',
294
- 'BEGIN;',
334
+ `[refused] ${versionId} still holds ${goals.length} open goal(s). Nothing was changed.`,
295
335
  '',
296
- `-- 1. Close ${versionId}.`,
297
- `UPDATE versions`,
298
- ` SET status = 'shipped'`,
299
- ` , shipped_at = now()`,
300
- ` WHERE id = '${sqlEscape(versionId)}'`,
301
- ` AND status = 'building';`,
336
+ 'Decide what happens to each, then re-run with --disposition:',
302
337
  '',
303
338
  ];
304
-
305
- if (nextVersionExists) {
306
- lines.push(`-- 2. ${nextVersionId} already exists in the versions table — no INSERT needed.`);
307
- lines.push('');
308
- } else {
309
- lines.push(`-- 2. Insert ${nextVersionId} stub with status='building'.`);
310
- lines.push(`INSERT INTO versions (id, name, track, status, scope_doc_path, started_at)`);
311
- lines.push(`VALUES ('${sqlEscape(nextVersionId)}',`);
312
- lines.push(` '${sqlEscape(nextVersionId)}',`);
313
- lines.push(` (SELECT track FROM versions WHERE id = '${sqlEscape(versionId)}'),`);
314
- lines.push(` 'building',`);
315
- lines.push(` '${sqlEscape(nextScopeDocPath)}',`);
316
- lines.push(` now())`);
317
- lines.push(`ON CONFLICT (id) DO NOTHING;`);
318
- lines.push('');
339
+ for (const g of goals) {
340
+ const open = g.open_tasks ? ` (${g.open_tasks} unfinished task${g.open_tasks === 1 ? '' : 's'})` : '';
341
+ lines.push(` #${g.id} ${g.title || ''}${open}`);
319
342
  }
320
-
321
- if (carryOverTasks.length > 0) {
322
- const ids = carryOverTasks.map((t) => t.id).join(', ');
323
- lines.push(`-- 3. Rebind ${carryOverTasks.length} carry-over task(s) to ${nextVersionId}.`);
324
- lines.push(`UPDATE tasks`);
325
- lines.push(` SET version_id = '${sqlEscape(nextVersionId)}'`);
326
- lines.push(` , updated_at = now()`);
327
- lines.push(` WHERE id IN (${ids})`);
328
- lines.push(` AND version_id = '${sqlEscape(versionId)}'`);
329
- lines.push(` AND status IN ('backlog', 'ready');`);
330
- lines.push('');
331
- } else {
332
- lines.push(`-- 3. No carry-over tasks — skip rebind step.`);
333
- lines.push('');
334
- }
335
-
336
- lines.push('COMMIT;');
337
- lines.push('-- ---------------------------------------------------------------------');
343
+ lines.push('');
344
+ lines.push(' --disposition <goalId>=roll_forward carry it into the planning version');
345
+ lines.push(' --disposition <goalId>=abandon cut it, stamping the close reason on its tasks');
346
+ lines.push('');
347
+ lines.push(` e.g. node scripts/gds/version-close.js ${versionId} \\`);
348
+ const example = goals.slice(0, 2).map((g) => `--disposition ${g.id}=roll_forward`).join(' ');
349
+ lines.push(` ${example || '--disposition <goalId>=roll_forward'} --reason "…"`);
338
350
  return lines.join('\n');
339
351
  }
340
352
 
353
+
341
354
  function buildCommitMessage({ versionId, nextVersionId, shippedCount, carryCount }) {
342
355
  const subject = `pms: close ${versionId}, open ${nextVersionId}`;
343
356
  const body = [
344
357
  '',
345
358
  `- Archive ${versionId} scope to <scope>-shipped.md (${shippedCount} shipped task(s)).`,
346
359
  `- Open ${nextVersionId} scope stub${carryCount ? ` with ${carryCount} carry-over task(s)` : ' (clean close)'}.`,
347
- '- Run the printed SQL on the droplet to flip version status + rebind tasks.',
360
+ '- Version status, task rebinding and the successor promotion were done by',
361
+ ' POST /versions/:id/close (BV1.R23) — this commit is the drafted docs only.',
348
362
  ].join('\n');
349
363
  return `${subject}\n${body}\n`;
350
364
  }
@@ -500,34 +514,85 @@ async function main() {
500
514
  } else {
501
515
  await writeFileWithConfirm(prompter, nextScopeDocPath, nextStubMd, `${nextVersionId} scope stub`);
502
516
  }
517
+
518
+ // Step 8 runs INSIDE this try so the prompter is still open for its confirm
519
+ // and is closed on every exit — including the refusal paths, which return
520
+ // early. A leaked readline keeps the process alive forever, which on a CLI
521
+ // reads as a hang rather than a failure.
522
+ return await closeStep();
503
523
  } finally {
504
524
  prompter.close();
505
525
  }
506
526
 
507
- // Step 8: print manual SQL + commit message.
508
- const sql = buildManualSql({
509
- versionId,
510
- versionName: target.name,
511
- nextVersionId,
512
- nextScopeDocPath: nextScopeDocRel,
513
- carryOverTasks: carryOver,
514
- nextVersionExists,
515
- });
527
+ // Step 8: CLOSE THE VERSION THROUGH THE ROUTE (BV1.R23, task 1003610).
528
+ //
529
+ // Everything above this point is advisory — it drafts documents and reports the
530
+ // gate. This is the one destructive act, and it now goes through
531
+ // `POST /versions/:id/close` so it obeys the disposition two-step, the closure
532
+ // invariant, the successor lineage, the promotion and the bug carry-forward.
533
+ // The `--yes` flag the drafting steps honour gates it too: without it the
534
+ // operator is asked, because closing a version cannot be undone through the API.
535
+ async function closeStep() {
516
536
  console.log('');
517
- console.log(sql);
537
+ if (!args.yes) {
538
+ const go = await prompter.ask(`Close ${versionId} now via POST /versions/${versionId}/close?`);
539
+ if (!go) {
540
+ console.log(`[skip] ${versionId} left building. Re-run with --yes, or close it from the hall.`);
541
+ printCommit();
542
+ return;
543
+ }
544
+ }
518
545
 
519
- const commit = buildCommitMessage({
520
- versionId,
521
- nextVersionId,
522
- shippedCount: shipped.length,
523
- carryCount: carryOver.length,
524
- });
525
- console.log('');
526
- console.log('--- suggested git commit message ---');
527
- console.log(commit);
528
- console.log('--- end commit message ---');
546
+ const closeReason = args.reason || `Closing ${versionId} (${shipped.length} shipped, ${carryOver.length} carry-over).`;
547
+ const res = await closeVersionViaApi({ versionId, reason: closeReason, dispositions: args.dispositions });
548
+
549
+ if (!res.ok) {
550
+ const err = (res.data && res.data.error) || `http_${res.status}`;
551
+ if (err === 'version_holds_open_goals' || err === 'disposition_incomplete') {
552
+ console.log(renderOpenGoalsRefusal(versionId, res.data && res.data.details));
553
+ process.exitCode = 1;
554
+ return;
555
+ }
556
+ // Every other refusal is already NAMED by the route (ADR 0250 §7), so the
557
+ // honest thing is to relay it rather than re-word it into something the
558
+ // operator cannot search for.
559
+ console.log(`[refused] ${err}: ${(res.data && res.data.message) || ''}`);
560
+ process.exitCode = 1;
561
+ return;
562
+ }
563
+
564
+ console.log(`[closed] ${versionId} → ${res.data.version.status}`);
565
+ for (const a of (res.data.applied || [])) {
566
+ const extra = a.successor_goal_id ? ` → successor goal #${a.successor_goal_id} (${a.tasks_moved} task(s) moved)`
567
+ : (a.tasks_abandoned != null ? ` (${a.tasks_abandoned} task(s) abandoned)` : '');
568
+ console.log(` goal #${a.goal_id}: ${a.verb} — ${a.result}${extra}`);
569
+ }
570
+ if (res.data.promoted) {
571
+ console.log(`[promoted] ${res.data.promoted.id} is now building.`);
572
+ if (res.data.carried && res.data.carried.moved) {
573
+ console.log(`[carried] ${res.data.carried.moved} open defect(s) moved to its maintenance goal.`);
574
+ }
575
+ } else {
576
+ console.log('[note] no version was in planning — the project has no live train until one is cut.');
577
+ }
578
+
579
+ printCommit();
529
580
  console.log('');
530
- console.log(`[done] ${versionId} close drafted. Run the SQL above on the droplet to finalize.`);
581
+ console.log(`[done] ${versionId} closed. Commit the drafted docs with the message above.`);
582
+ }
583
+
584
+ function printCommit() {
585
+ const commit = buildCommitMessage({
586
+ versionId,
587
+ nextVersionId,
588
+ shippedCount: shipped.length,
589
+ carryCount: carryOver.length,
590
+ });
591
+ console.log('');
592
+ console.log('--- suggested git commit message ---');
593
+ console.log(commit);
594
+ console.log('--- end commit message ---');
595
+ }
531
596
  }
532
597
 
533
598
  if (require.main === module) {
@@ -539,11 +604,11 @@ if (require.main === module) {
539
604
 
540
605
  module.exports = {
541
606
  parseArgs,
607
+ usage,
542
608
  deriveNextVersionId,
543
609
  deriveNextScopePath,
544
610
  deriveShippedPath,
545
611
  buildShippedArchive,
546
612
  buildNextVersionStub,
547
- buildManualSql,
548
613
  buildCommitMessage,
549
614
  };
package/src/module-api.js CHANGED
@@ -55,7 +55,7 @@ const { buildInfo } = require('./build-info');
55
55
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
56
56
  // the entry to that file. Look for a version's history there, not here.
57
57
  // ---------------------------------------------------------------------------
58
- const CORE_VERSION = '1.19.600'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
58
+ const CORE_VERSION = '1.19.602'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
59
59
 
60
60
  // A namespaced logger so a module's log lines are attributable + consistent.
61
61
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,164 @@
1
+ // tests/version_close_cli.mjs — version-close.js drives the ROUTE, and no longer
2
+ // prints SQL for a human to paste into the production droplet
3
+ // (BV1.R23, task 1003610, goal 1000086).
4
+ //
5
+ // WHAT THIS SCRIPT USED TO DO, AND WHY IT WAS RIGHT AT THE TIME. It ended with a
6
+ // `-- MANUAL STEP REQUIRED` block: three UPDATEs to flip the version, insert the
7
+ // successor and rebind carry-over tasks, with the instruction to run them on the
8
+ // droplet. Its own header explained why — "the GDS API does not currently expose
9
+ // endpoints for version status transitions" — and that was simply true.
10
+ //
11
+ // WHY IT IS NOW THE MOST DANGEROUS THING IN THE FILE. BV1.R12 (task 1003599)
12
+ // built `POST /versions/:id/close`, so the claim became false. Worse, by then the
13
+ // route had accumulated everything this goal exists to enforce: the disposition
14
+ // two-step (R12/R14), the closure invariant (R08/R09), the successor lineage
15
+ // (R17), the promotion (R20) and the bug carry-forward (R18). An operator pasting
16
+ // that SQL would close a version and silently strand every one of them — a goal
17
+ // left open on a shipped version, a defect queue entombed where nothing routes,
18
+ // no successor promoted. The bypass was printed by the tool meant to prevent it.
19
+ //
20
+ // Run: node --test tests/version_close_cli.mjs
21
+
22
+ import assert from 'node:assert/strict';
23
+ import { test } from 'node:test';
24
+ import { readFileSync } from 'node:fs';
25
+ import { createRequire } from 'node:module';
26
+
27
+ process.env.NODE_ENV = 'test';
28
+ const require = createRequire(import.meta.url);
29
+ const { parseArgs, usage } = require('../scripts/gds/version-close.js');
30
+
31
+ const SRC = readFileSync(new URL('../scripts/gds/version-close.js', import.meta.url), 'utf8');
32
+ // Comments explain the history on purpose, so the prohibitions below read CODE.
33
+ const CODE = SRC.replace(/\/\/[^\n]*/g, '').replace(/\/\*[\s\S]*?\*\//g, '');
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // The bypass is gone
37
+ // ---------------------------------------------------------------------------
38
+
39
+ test('the script no longer BUILDS any version-mutating SQL', () => {
40
+ assert.equal(/buildManualSql/.test(CODE), false, 'the SQL builder must be deleted, not merely unused');
41
+ for (const bad of [/UPDATE\s+versions/i, /INSERT INTO versions/i, /UPDATE\s+tasks/i]) {
42
+ assert.equal(bad.test(CODE), false, `the script must not emit ${bad} — that is the path around every rule this goal built`);
43
+ }
44
+ });
45
+
46
+ test('it does not tell the operator to run anything on the droplet', () => {
47
+ assert.equal(/MANUAL STEP REQUIRED/.test(CODE), false);
48
+ assert.equal(/psql/i.test(CODE), false);
49
+ assert.equal(/droplet/i.test(CODE), false);
50
+ });
51
+
52
+ test('the commit message no longer promises SQL that will not be run', () => {
53
+ // It said "Run the printed SQL on the droplet to flip version status" — a
54
+ // commit body describing a step that no longer exists is a instruction someone
55
+ // eventually follows.
56
+ assert.equal(/Run the printed SQL/.test(CODE), false);
57
+ assert.match(SRC, /this commit is the drafted docs only/);
58
+ });
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // It calls the route
62
+ // ---------------------------------------------------------------------------
63
+
64
+ test('the close goes through POST /versions/:id/close', () => {
65
+ assert.match(CODE, /postVersionsIdClose\(\{ path: \{ id: versionId \}, body \}\)/);
66
+ });
67
+
68
+ test('a disposition map is only sent when there is one', () => {
69
+ // The route treats an EMPTY map as "the caller dispositioned nothing", which is
70
+ // a different call from "the caller sent no map at all" — the first can satisfy
71
+ // a close of a version holding no open goals, the second is the two-step's
72
+ // first leg. Sending `{}` unconditionally would blur them.
73
+ assert.match(CODE, /if \(dispositions && Object\.keys\(dispositions\)\.length > 0\) body\.dispositions = dispositions;/);
74
+ });
75
+
76
+ test('the advisory half is kept — this is still the script that drafts the archive', () => {
77
+ // The brief is explicit that suggestClose stays. Only the destructive tail moved.
78
+ assert.match(CODE, /fetchCloseSuggestions/);
79
+ assert.match(CODE, /buildShippedArchive|shippedDocPath/);
80
+ });
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // The two-step, rendered for a human
84
+ // ---------------------------------------------------------------------------
85
+
86
+ test('an open-goals refusal lists the goals and the exact re-run', () => {
87
+ // R12 refuses and RETURNS the goals precisely so the caller can decide per
88
+ // goal. Dumping that JSON would make the operator translate it themselves.
89
+ assert.match(CODE, /renderOpenGoalsRefusal/);
90
+ assert.match(SRC, /--disposition <goalId>=roll_forward/);
91
+ assert.match(SRC, /--disposition <goalId>=abandon/);
92
+ });
93
+
94
+ test('both incomplete-disposition refusals are handled, not just one', () => {
95
+ // The route answers `version_holds_open_goals` when nothing was sent and
96
+ // `disposition_incomplete` when a partial map was. Handling only the first
97
+ // leaves the commoner second attempt printing a bare error code.
98
+ assert.match(CODE, /version_holds_open_goals/);
99
+ assert.match(CODE, /disposition_incomplete/);
100
+ });
101
+
102
+ test('any other refusal is RELAYED by name, not re-worded', () => {
103
+ // ADR 0250 §7: the codes are named so an agent can act on them. Re-phrasing one
104
+ // into prose makes it unsearchable.
105
+ assert.match(CODE, /\[refused\] \$\{err\}/);
106
+ });
107
+
108
+ test('a refusal exits non-zero', () => {
109
+ assert.match(CODE, /process\.exitCode = 1;/);
110
+ });
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // The flags
114
+ // ---------------------------------------------------------------------------
115
+
116
+ test('--disposition parses into the exact map shape the route expects', () => {
117
+ const a = parseArgs(['node', 'x', 'BONGOS-V1', '--disposition', '44=roll_forward', '--disposition=51=abandon']);
118
+ assert.deepEqual(a.dispositions, { 44: { verb: 'roll_forward' }, 51: { verb: 'abandon' } },
119
+ 'so the operator never hand-writes JSON');
120
+ assert.equal(a.versionId, 'BONGOS-V1');
121
+ });
122
+
123
+ test('--reason survives both spellings', () => {
124
+ assert.equal(parseArgs(['n', 'x', 'V', '--reason', 'because']).reason, 'because');
125
+ assert.equal(parseArgs(['n', 'x', 'V', '--reason=because']).reason, 'because');
126
+ });
127
+
128
+ test('a malformed --disposition is dropped rather than sent as nonsense', () => {
129
+ // `--disposition 44` with no verb would otherwise reach the route as
130
+ // {44: {verb: undefined}} and come back a bad_disposition the operator has to
131
+ // decode. Dropping it means the close refuses with the goal still listed.
132
+ const a = parseArgs(['n', 'x', 'V', '--disposition', '44', '--disposition', '=abandon']);
133
+ assert.deepEqual(a.dispositions, {});
134
+ });
135
+
136
+ test('no flags means no dispositions — nothing is cut by omission', () => {
137
+ assert.deepEqual(parseArgs(['n', 'x', 'V']).dispositions, {});
138
+ assert.equal(parseArgs(['n', 'x', 'V']).reason, null);
139
+ });
140
+
141
+ test('usage documents both new flags', () => {
142
+ const u = usage();
143
+ assert.match(u, /--reason/);
144
+ assert.match(u, /--disposition/);
145
+ assert.match(u, /POST \/versions\/:id\/close/);
146
+ assert.equal(/SQL/.test(u), false, 'the usage must not still advertise printed SQL');
147
+ });
148
+
149
+ // ---------------------------------------------------------------------------
150
+ // The close is still a confirmed, destructive act
151
+ // ---------------------------------------------------------------------------
152
+
153
+ test('without --yes the operator is asked before the version closes', () => {
154
+ // Closing cannot be undone through the API (ADR 0263 §2: nothing un-closes a
155
+ // version), so this is the one prompt that must not be dropped.
156
+ assert.match(CODE, /if \(!args\.yes\) \{[\s\S]{0,200}prompter\.ask\(`Close \$\{versionId\}/);
157
+ });
158
+
159
+ test('the prompter is closed on every exit, including the refusal returns', () => {
160
+ // Step 8 returns early on a refusal. A leaked readline keeps the process alive,
161
+ // which on a CLI reads as a hang rather than a failure — so the close step runs
162
+ // inside the same try whose finally closes it.
163
+ assert.match(CODE, /return await closeStep\(\);\s*\}\s*finally \{\s*prompter\.close\(\);/);
164
+ });
@@ -0,0 +1,143 @@
1
+ // tests/version_promotion.mjs — the planning version promotes itself when the
2
+ // building version closes (BV1.R20, task 1003607, goal 1000086, ADR 0263 §8).
3
+ //
4
+ // WHY IT RUNS INSIDE THE CLOSE'S TRANSACTION. A project with zero building
5
+ // versions is a BROKEN state, not an intermediate one: `currentBuildingVersionId`
6
+ // returns null there and every routing path closed over it fails, so nothing is
7
+ // claimable. Promoting in a second transaction leaves exactly that window open,
8
+ // however briefly.
9
+ //
10
+ // AND WHY THE ORDER IS LOAD-BEARING. Promote, THEN carry the bugs forward.
11
+ // `ensureMaintenanceGoal` returns null for a version that is not yet `building`,
12
+ // so a carry-over run first silently carries nothing and the defect queue stays
13
+ // entombed on the version that just shipped — the 1000771 shape, arriving through
14
+ // the close instead of through routing.
15
+ //
16
+ // ONE MORE THING THIS FILE PINS, because it is the bug that actually happened
17
+ // while building R20: `closeVersion` reads the hook from its SECOND argument
18
+ // (`deps`), not from its options object. Passing `onClosed` in the first one is
19
+ // silently ignored — the hook never runs, nothing is promoted, and the close
20
+ // still answers 200. No fake caught it; running the real close against a real
21
+ // Postgres did.
22
+ //
23
+ // Run: node --test tests/version_promotion.mjs
24
+
25
+ import assert from 'node:assert/strict';
26
+ import { test } from 'node:test';
27
+ import { readFileSync } from 'node:fs';
28
+ import { createRequire } from 'node:module';
29
+ import { makeSqlAwareClient } from './helpers.mjs';
30
+
31
+ process.env.NODE_ENV = 'test';
32
+ const require = createRequire(import.meta.url);
33
+ const { promotePlanningVersion } = require('../modules/lifecycle/db-versions.js');
34
+
35
+ const src = (rel) => readFileSync(new URL('../' + rel, import.meta.url), 'utf8');
36
+ const ROUTES = src('modules/lifecycle/routes/versions.js');
37
+ const DBV = src('modules/lifecycle/db-versions.js');
38
+
39
+ const recorder = (handler) => {
40
+ const seen = [];
41
+ const c = makeSqlAwareClient((sql, params) => { seen.push({ sql: String(sql).replace(/\s+/g, ' '), params }); return handler(sql, params); });
42
+ return { seen, exec: { query: (sql, params) => c._client.query(sql, params) } };
43
+ };
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // The promotion itself
47
+ // ---------------------------------------------------------------------------
48
+
49
+ test('promotion is ONE self-guarding statement, not a read-then-write', async () => {
50
+ // A read-then-write leaves a window in which a concurrent close promotes too,
51
+ // producing the second building version R19 forbids. The NOT EXISTS is
52
+ // evaluated inside the same statement, so it cannot be raced past.
53
+ const r = recorder(() => ({ rows: [] }));
54
+ await promotePlanningVersion(r.exec);
55
+ assert.equal(r.seen.length, 1, 'one statement — a separate check could be raced');
56
+ const sql = r.seen[0].sql;
57
+ assert.match(sql, /UPDATE versions SET status = 'building'/);
58
+ assert.match(sql, /WHERE status = 'planning' ORDER BY id LIMIT 1/);
59
+ assert.match(sql, /NOT EXISTS \(SELECT 1 FROM versions WHERE status = 'building'\)/,
60
+ 'the guard against creating a second building version must be in the statement');
61
+ });
62
+
63
+ test('started_at is stamped only if it was never set', async () => {
64
+ const r = recorder(() => ({ rows: [] }));
65
+ await promotePlanningVersion(r.exec);
66
+ assert.match(r.seen[0].sql, /started_at = COALESCE\(started_at, now\(\)\)/,
67
+ "a version's start is when it began building; a re-promotion must not rewrite history");
68
+ });
69
+
70
+ test('nothing to promote returns null — an idle project is not an error', async () => {
71
+ const r = recorder(() => ({ rows: [] }));
72
+ assert.equal(await promotePlanningVersion(r.exec), null);
73
+ });
74
+
75
+ test('the promoted row is returned so the caller can carry bugs into it', async () => {
76
+ const r = recorder(() => ({ rows: [{ id: 'V2', name: 'two', status: 'building', started_at: 't' }] }));
77
+ const out = await promotePlanningVersion(r.exec);
78
+ assert.equal(out.id, 'V2');
79
+ assert.equal(out.status, 'building');
80
+ });
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // The wiring — where the bug was
84
+ // ---------------------------------------------------------------------------
85
+
86
+ test('onClosed is passed as the DEPS argument, not in the options object', () => {
87
+ // THE BUG THIS PINS. `closeVersion({...opts}, deps)` reads `deps.onClosed`.
88
+ // Putting the hook in the first object is silently ignored: no promotion, no
89
+ // carry-over, and a 200 response that looks correct.
90
+ const call = ROUTES.slice(ROUTES.indexOf('const out = await db.closeVersion('), ROUTES.indexOf('return res.json({ ok: true, version: out.version'));
91
+ const optsEnd = call.indexOf('}, {');
92
+ assert.ok(optsEnd !== -1, 'the close must be called with a second (deps) argument');
93
+ assert.equal(call.slice(0, optsEnd).includes('onClosed'), false,
94
+ 'onClosed in the options object is a silent no-op — it belongs in deps');
95
+ assert.ok(call.slice(optsEnd).includes('onClosed'), 'and it must actually be in deps');
96
+ });
97
+
98
+ test('the hook promotes BEFORE it carries — the ordering trap', () => {
99
+ const hook = ROUTES.slice(ROUTES.indexOf('onClosed: async (client'), ROUTES.indexOf('return { promoted, carried };'));
100
+ const promoteAt = hook.indexOf('promotePlanningVersion');
101
+ const carryAt = hook.indexOf('carryBugsForward');
102
+ assert.ok(promoteAt !== -1 && carryAt !== -1);
103
+ assert.ok(promoteAt < carryAt,
104
+ 'ensureMaintenanceGoal returns null for a non-building version, so carrying first silently carries nothing');
105
+ });
106
+
107
+ test('no promotion means no carry attempt at all', () => {
108
+ // Not "carry into null and hope" — there is genuinely nowhere to put them, and
109
+ // the honest answer is that the bugs stay findable on the closed version until
110
+ // someone cuts the next one.
111
+ assert.match(ROUTES, /const carried = promoted\s*\n?\s*\?\s*await db\.carryBugsForward/);
112
+ assert.match(ROUTES, /:\s*null;/);
113
+ });
114
+
115
+ test('the close reports what it promoted and carried', () => {
116
+ // A close now changes WHICH version is live. A caller made to re-read /versions
117
+ // to discover that has been told half the outcome.
118
+ assert.match(ROUTES, /promoted: out\.after\?\.promoted \?\? null/);
119
+ assert.match(ROUTES, /carried: out\.after\?\.carried \?\? null/);
120
+ });
121
+
122
+ // ---------------------------------------------------------------------------
123
+ // Composition
124
+ // ---------------------------------------------------------------------------
125
+
126
+ test('the hook is composed in the ROUTE, not inside closeVersion', () => {
127
+ // The two halves live in different db units — versions and goals, siblings in
128
+ // the module's require DAG — and the route is the one place already holding the
129
+ // whole facade. Reaching sideways between the units is how that DAG stops being
130
+ // one.
131
+ assert.equal(/carryBugsForward/.test(DBV), false,
132
+ 'db-versions.js must not reach into db-goals.js');
133
+ assert.match(ROUTES, /db\.promotePlanningVersion\(client\)/);
134
+ assert.match(ROUTES, /db\.carryBugsForward\(/);
135
+ });
136
+
137
+ test('the stale R17 note in closeVersion was corrected, not left to mislead', () => {
138
+ // It said roll_forward "only ABSTAINS from cutting the goal ... until R17
139
+ // ships". R17 shipped; a reader meeting the old wording would go looking for
140
+ // an abstention that is no longer there.
141
+ assert.equal(/Until it ships/.test(DBV), false);
142
+ assert.match(DBV, /It DOES create successor goals now/);
143
+ });