@bongos/core 1.19.596 → 1.19.598

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.596",
6
- "core_contract": "1.19.596",
7
- "source_commit": "25b1abaa5a60deab7277d0a2179e58dfc482b6b0",
5
+ "core_version": "1.19.598",
6
+ "core_contract": "1.19.598",
7
+ "source_commit": "765987c9bfd5277ee8bd4a6dc5c73f6aab9224bc",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-08T04:01:26.614Z",
9
+ "built_at": "2026-09-08T04:22:59.900Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 457,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 2071,
14
+ "functional_verbatim": 2074,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2552,
20
- "tree_sha256": "fd2460b94c293de3373d6e8b25a51f6cfc3c851ef13536d9ae525b90d0ae5d2c",
19
+ "file_count": 2555,
20
+ "tree_sha256": "2193f9634af7589ddd47ef00b03be7824679582ec0ca3688709c68ffe2e78d7b",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -2727,7 +2727,7 @@
2727
2727
  {
2728
2728
  "path": "docs/module-api-changelog.md",
2729
2729
  "mode": "0000644",
2730
- "sha256": "8cd803732ddfd0a2b36f63b63b6a280665a86bfed256198589f00c4a2c288609"
2730
+ "sha256": "162a1e6f165acd0b744497fd19ecc34fa10a9229dca13c7b1d35c795511a85b4"
2731
2731
  },
2732
2732
  {
2733
2733
  "path": "docs/modules-contract.md",
@@ -3739,6 +3739,11 @@
3739
3739
  "mode": "0000644",
3740
3740
  "sha256": "92964dc3578fb2f4b1b7547867d0f53b2998c30bdb39271336e9b9aa348a1b37"
3741
3741
  },
3742
+ {
3743
+ "path": "migrations/core_235_one_maintenance_goal_per_version.sql",
3744
+ "mode": "0000644",
3745
+ "sha256": "c10c4bbbead4a3bafde336f3433a1432818880751f7a21436a6162f0dbde1abf"
3746
+ },
3742
3747
  {
3743
3748
  "path": "modules/agents/lib/validate.js",
3744
3749
  "mode": "0000644",
@@ -5527,7 +5532,7 @@
5527
5532
  {
5528
5533
  "path": "modules/lifecycle/db-goals.js",
5529
5534
  "mode": "0000644",
5530
- "sha256": "433f3ef7e9c81190b7d7fa7cc71e74e46c768d54240f6d89678b2437dd67496b"
5535
+ "sha256": "c3e06daaf78911c9dc44f72aa7a104f269624c92f343cd808b2962076a641a23"
5531
5536
  },
5532
5537
  {
5533
5538
  "path": "modules/lifecycle/db-grade.js",
@@ -5562,12 +5567,12 @@
5562
5567
  {
5563
5568
  "path": "modules/lifecycle/db-versions.js",
5564
5569
  "mode": "0000644",
5565
- "sha256": "9d909149be4152335f6d259a32621c2e3917e98c2595ac1c8ff0d2202891dbcb"
5570
+ "sha256": "53f87c20f89c33a3d14ee4410bafa0f3122fb7534708ce5bdacb8cc1098a487c"
5566
5571
  },
5567
5572
  {
5568
5573
  "path": "modules/lifecycle/db.js",
5569
5574
  "mode": "0000644",
5570
- "sha256": "33fc8959d522bcf18c445b1a5710940379f84d1e938eecdc52cdf52bec9c4b3d"
5575
+ "sha256": "14c05469abcf349c9173a80059e7fa0dd880b1b5a0b25680ba0d9e524e46d5d9"
5571
5576
  },
5572
5577
  {
5573
5578
  "path": "modules/lifecycle/dead-deps.js",
@@ -5812,7 +5817,7 @@
5812
5817
  {
5813
5818
  "path": "modules/lifecycle/routes/versions.js",
5814
5819
  "mode": "0000644",
5815
- "sha256": "e13f46e32e43c84bfb2a4d3bc46a90da191c04f516fb7493cfe0cc19d05726d7"
5820
+ "sha256": "35919f2cdd1e9a5da11a89adc9731818aa6b1b292c0754d9123a4cd8324bd970"
5816
5821
  },
5817
5822
  {
5818
5823
  "path": "modules/lifecycle/routes/visuals.js",
@@ -7617,12 +7622,12 @@
7617
7622
  {
7618
7623
  "path": "package-lock.json",
7619
7624
  "mode": "0000644",
7620
- "sha256": "e7a26121f535ff4cdd5eec05579d16849da238adb5932a4f94815e67d5de4745"
7625
+ "sha256": "a20139fc06badf4713e2ad023c865eb4339b49d1caabfa79d5ea8282c1e13cea"
7621
7626
  },
7622
7627
  {
7623
7628
  "path": "package.json",
7624
7629
  "mode": "0000644",
7625
- "sha256": "4f2e2854f84a88237284dbbe30261670e3fe6683fdb46dd7c24b09f18ebf9f82"
7630
+ "sha256": "cfc6a9c8fd26745544ca7256e40c41929174e617e199cf972e1df59f68e98e51"
7626
7631
  },
7627
7632
  {
7628
7633
  "path": "public-docs/index.html",
@@ -9337,7 +9342,7 @@
9337
9342
  {
9338
9343
  "path": "src/module-api.js",
9339
9344
  "mode": "0000644",
9340
- "sha256": "ad5397fd512a3a25f3ad6a50a96f628ba1f43b6330ce2799f34c9cb099d70cc2"
9345
+ "sha256": "922414a017882732619bc7ae7c14ec5c056c0bd2f950447ca9abac23d0b3a687"
9341
9346
  },
9342
9347
  {
9343
9348
  "path": "src/module-loader/catalog.js",
@@ -11284,6 +11289,11 @@
11284
11289
  "mode": "0000644",
11285
11290
  "sha256": "9a1813d6036886ea07d1563e71a466e66c28554b75b22605464c795fd9fa2ebb"
11286
11291
  },
11292
+ {
11293
+ "path": "tests/maintenance_carry_forward.mjs",
11294
+ "mode": "0000644",
11295
+ "sha256": "4ac9164b84ad7bf8e9c1b45e7cb5e157762c992ddde16041410a528a534e60f5"
11296
+ },
11287
11297
  {
11288
11298
  "path": "tests/maintenance_goal.mjs",
11289
11299
  "mode": "0000644",
@@ -12692,7 +12702,7 @@
12692
12702
  {
12693
12703
  "path": "tests/version_close_route.mjs",
12694
12704
  "mode": "0000644",
12695
- "sha256": "9a5b8f753c4b898b447b257d64c0034a83fe257c23208ce714e2e469fcaf735d"
12705
+ "sha256": "56c480792cf88946eb3e3663a23e0431e6d8df3cf8219fdf9b71283b772355bb"
12696
12706
  },
12697
12707
  {
12698
12708
  "path": "tests/version_literals.mjs",
@@ -12704,6 +12714,11 @@
12704
12714
  "mode": "0000644",
12705
12715
  "sha256": "692261c26e3da46519757733a7fcf9a9998e0942bc5890688b66b79590d82c27"
12706
12716
  },
12717
+ {
12718
+ "path": "tests/version_roll_forward.mjs",
12719
+ "mode": "0000644",
12720
+ "sha256": "fa0f079b8dff816806788bc34b6beb31e3366d5c7166e531108fe4ea40890373"
12721
+ },
12707
12722
  {
12708
12723
  "path": "tests/versions_route.mjs",
12709
12724
  "mode": "0000644",
@@ -1641,5 +1641,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1641
1641
  landed since 1.19.594 with no explicit bump. run 34185042811. (task 1002620)
1642
1642
  1.19.596 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1643
1643
  landed since 1.19.595 with no explicit bump. run 34185477798. (task 1002620)
1644
+ 1.19.597 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1645
+ landed since 1.19.596 with no explicit bump. run 34186368179. (task 1002620)
1646
+ 1.19.598 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1647
+ landed since 1.19.597 with no explicit bump. run 34186758247. (task 1002620)
1644
1648
  ---------------------------------------------------------------------------
1645
1649
  ```
@@ -0,0 +1,41 @@
1
+ -- core_235_one_maintenance_goal_per_version.sql — make "one maintenance goal per
2
+ -- version" a CONSTRAINT rather than a convention (task 1003708, BV1.R18b,
3
+ -- goal 1000086, ADR 0263 §7).
4
+ --
5
+ -- WHAT core_234 LEFT OPEN. That migration added `goals.is_maintenance` and gave it
6
+ -- a partial index for lookup speed, which is correct and is why this one is small.
7
+ -- But the index is non-unique, so two goals on one version can both carry the flag
8
+ -- — and `ensureMaintenanceGoal` (db-goals.js) is a find-or-create whose read is
9
+ -- `WHERE version_id = $1 AND is_maintenance LIMIT 1`. With two flagged rows that
10
+ -- `LIMIT 1` returns whichever the planner picks, and every homeless bug files into
11
+ -- an arbitrary one of them, permanently.
12
+ --
13
+ -- That is EXACTLY the failure `pg_advisory_xact_lock` was taken for in task
14
+ -- 1003691 — two bugs reported in the same second both reading "absent" and both
15
+ -- inserting — arriving through a different door. The lock serialises the RACE; it
16
+ -- cannot stop a second maintenance goal created any other way (a hand-set flag, a
17
+ -- backfill on a version whose title convention drifted, a future feature). Moving
18
+ -- from title to flag was the opportunity to make it a real constraint, because
19
+ -- `goals` never had a unique constraint on (version_id, title) and the lock had
20
+ -- been covering for that absence all along.
21
+ --
22
+ -- THE UPGRADE MAY FAIL, AND THAT IS THE POINT. If any version already has two
23
+ -- flagged goals, this index cannot be created and the migration stops — loudly,
24
+ -- before an ambiguous find-or-create becomes load-bearing. core_234's backfill
25
+ -- keys on an exact title, so a duplicate is unlikely; if one exists it is a real
26
+ -- data defect and the right response is to fix the rows, not to weaken the index.
27
+ -- (Verified against live before writing this: no version has more than one.)
28
+ --
29
+ -- Replaces core_234's index rather than adding a second on the same expression:
30
+ -- a UNIQUE index serves every read the plain one did, so keeping both would be
31
+ -- two indexes doing one job.
32
+
33
+ BEGIN;
34
+
35
+ CREATE UNIQUE INDEX IF NOT EXISTS goals_one_maintenance_per_version_idx
36
+ ON goals (version_id)
37
+ WHERE is_maintenance;
38
+
39
+ DROP INDEX IF EXISTS goals_maintenance_by_version_idx;
40
+
41
+ COMMIT;
@@ -251,6 +251,61 @@ async function carryBugsForward({ fromVersionId, toVersionId }, deps = {}) {
251
251
  return { fromGoalId: String(from[0].id), toGoalId: String(to.id), moved: moved.length };
252
252
  }
253
253
 
254
+ // carryMaintenanceForward — move a closing version's unfinished maintenance work
255
+ // into the successor's maintenance goal (BV1.R18, task 1003605, ADR 0263 §7).
256
+ //
257
+ // THE ONE GOAL THAT LEGITIMATELY SPANS VERSIONS. Every other goal either finishes
258
+ // or is dispositioned at close (roll forward, or abandon — R12). A maintenance
259
+ // goal is neither: it holds defects against already-shipped work, and those arrive
260
+ // on their own schedule rather than on the version's. Cutting them because a
261
+ // version ended would delete a bug queue for a reason that has nothing to do with
262
+ // the bugs; making them need an override to survive would put a counted escape
263
+ // hatch on the ordinary path, which is what ADR 0250 §3 built the counter to
264
+ // prevent. So they move.
265
+ //
266
+ // RUNS ON THE CLOSE'S OWN CLIENT, inside its transaction, via the `deps.onClosed`
267
+ // seam R12 left in closeVersion. Two things follow, both load-bearing:
268
+ //
269
+ // The successor must ALREADY be `building` when this runs. `ensureMaintenanceGoal`
270
+ // returns null for a version in any other state, so a carry-over run before R20's
271
+ // promotion silently carries nothing and reports success. That is why the seam
272
+ // fires after the flip and why this asserts rather than assumes.
273
+ //
274
+ // Nothing here may fail the close for want of a destination. A version with no
275
+ // successor (no planning version was scoped) closes with its maintenance work
276
+ // left where it is — visible on a shipped version, recoverable, and honest —
277
+ // rather than the close failing or the bugs being cut. Returns a summary saying
278
+ // which happened.
279
+ //
280
+ // The source goal is left ARCHIVED with nothing open, so it passes R10's own
281
+ // check on the way out rather than being special-cased around it.
282
+ async function carryMaintenanceForward({ fromVersionId, toVersionId }, deps = {}) {
283
+ const exec = deps.client || ((deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool);
284
+ const { rows: src } = await exec.query(
285
+ `SELECT id FROM goals WHERE version_id = $1 AND is_maintenance LIMIT 1`, [fromVersionId]
286
+ );
287
+ if (!src[0]) return { carried: 0, from: null, to: null, reason: 'no_maintenance_goal' };
288
+ if (!toVersionId) return { carried: 0, from: String(src[0].id), to: null, reason: 'no_successor' };
289
+
290
+ const dest = await ensureMaintenanceGoal(toVersionId, { client: exec });
291
+ // Null means the successor is not `building` — see the ordering note above. Do
292
+ // not "fix" this by relaxing ensureMaintenanceGoal's version gate: that gate is
293
+ // what stops a defect being filed into a shipped version's goal (the 1000771 bug).
294
+ if (!dest) return { carried: 0, from: String(src[0].id), to: null, reason: 'successor_not_building' };
295
+
296
+ const { rows: moved } = await exec.query(
297
+ `UPDATE tasks SET goal_id = $2, version_id = $3, updated_at = now()
298
+ WHERE goal_id = $1 AND ${nonTerminalSql()}
299
+ AND ${SMOKE_NOT_LIKE.replace(/title/, 'tasks.title')}
300
+ RETURNING id`,
301
+ [src[0].id, dest.id, toVersionId]
302
+ );
303
+ // The emptied goal closes behind them. Archived rather than achieved: it was
304
+ // never "done", it stopped being the live queue.
305
+ await exec.query(`UPDATE goals SET status = 'archived', updated_at = now() WHERE id = $1`, [src[0].id]);
306
+ return { carried: moved.length, from: String(src[0].id), to: String(dest.id), reason: null };
307
+ }
308
+
254
309
  // listVersionAdmissions (BV1.R07, task 1003594) — every goal admitted into ONE
255
310
  // version, newest first. ADR 0250 §3: "the overrides are counted and shown on the
256
311
  // version" — a visible counter is what keeps the exception honest, because an
@@ -1173,6 +1228,7 @@ module.exports = {
1173
1228
  carryBugsForward,
1174
1229
  achieveGoalIfComplete,
1175
1230
  archiveGoalWithDispositions,
1231
+ carryMaintenanceForward,
1176
1232
  addGoalMember,
1177
1233
  addGoalScopeModules,
1178
1234
  createGoal,
@@ -253,7 +253,7 @@ async function openNonMaintenanceGoals(versionId, deps = {}) {
253
253
  // route cannot write one, and file I/O has no business in this transaction.
254
254
  //
255
255
  // `plan` is planVersionClose's validated output: [{ goalId, verb }].
256
- async function closeVersion({ versionId, plan = [], reason }, deps = {}) {
256
+ async function closeVersion({ versionId, plan = [], reason, planningVersionId = null }, deps = {}) {
257
257
  const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
258
258
  const client = await activePool.connect();
259
259
  try {
@@ -291,10 +291,19 @@ async function closeVersion({ versionId, plan = [], reason }, deps = {}) {
291
291
  // close's reason — the same stamp R14 writes, so the ledger reads
292
292
  // identically whichever door retired the work.
293
293
  const { rows: killed } = await client.query(
294
+ // `nonTerminalSql('status')`, NOT the default. The default is `t.status`,
295
+ // which is right for the SELECTs in this file (they alias `tasks t`) and
296
+ // is a HARD SQL ERROR here — an `UPDATE tasks` declares no alias, so
297
+ // Postgres answers `missing FROM-clause entry for table "t"` and the
298
+ // whole close transaction throws. Every `abandon` disposition was
299
+ // failing on this (shipped in R12, task 1003599); nothing caught it
300
+ // because the suite drives this path with a SQL-AWARE FAKE that records
301
+ // statements instead of parsing them. Found by running the rendered
302
+ // statement against a real Postgres while building R17.
294
303
  `UPDATE tasks SET status = 'abandoned',
295
304
  value_summary = COALESCE(NULLIF($2, ''), value_summary),
296
305
  updated_at = now()
297
- WHERE goal_id = $1 AND ${nonTerminalSql()}
306
+ WHERE goal_id = $1 AND ${nonTerminalSql('status')}
298
307
  AND title NOT LIKE '\\_\\_smoke\\_\\_%' ESCAPE '\\'
299
308
  RETURNING id`,
300
309
  [step.goalId, `Abandoned: ${reason}`]
@@ -302,23 +311,100 @@ async function closeVersion({ versionId, plan = [], reason }, deps = {}) {
302
311
  await client.query(`UPDATE goals SET status = 'archived', updated_at = now() WHERE id = $1`, [step.goalId]);
303
312
  applied.push({ goal_id: String(step.goalId), verb: 'abandon', result: 'archived', tasks_abandoned: killed.length });
304
313
  } 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' });
314
+ // roll_forward (BV1.R17, task 1003604, ADR 0263 §6): the goal survives the
315
+ // version boundary as a NEW goal on the planning version, and the old one
316
+ // archives behind it with its lineage recorded.
317
+ //
318
+ // THE COLUMN IS `succeeded_by_goal_id`, and it points the other way from
319
+ // what ADR 0250 and this task's own brief said. Migration 160 put it on
320
+ // the OLD goal pointing at the NEW one; `succeeds_goal_id` has never
321
+ // existed. The design pass caught that, which is why there is no
322
+ // migration here.
323
+ if (!planningVersionId) {
324
+ // The route refuses this before calling (rollForwardNeedsPlanningVersion),
325
+ // so reaching it means a caller bypassed the route. Fail rather than
326
+ // silently leaving the goal on a shipped version.
327
+ throw Object.assign(new Error('roll_forward with no planning version'), {
328
+ code: 'NO_PLANNING_VERSION', goalId: String(step.goalId),
329
+ });
330
+ }
331
+ // The successor carries what makes the goal itself — its prose, its scope
332
+ // wall, its category and its visibility. It does NOT carry `is_maintenance`
333
+ // (the successor version gets its own via ensureMaintenanceGoal) and it
334
+ // does not carry admissions: an override that widened THIS version's scope
335
+ // is spent, and re-creating the row would launder a one-time exception into
336
+ // a permanent one.
337
+ const { rows: made } = await client.query(
338
+ `WITH next_order AS (
339
+ SELECT COALESCE(MAX(sort_order) + 1, 0) AS n FROM goals WHERE version_id = $2
340
+ )
341
+ INSERT INTO goals (version_id, title, subtitle, description, scope_modules,
342
+ status, visibility, accepting_requests, created_by, sort_order, category_id)
343
+ SELECT $2, g.title, g.subtitle, g.description, g.scope_modules,
344
+ 'open', g.visibility, g.accepting_requests, g.created_by, next_order.n, g.category_id
345
+ FROM goals g, next_order
346
+ WHERE g.id = $1
347
+ RETURNING id`,
348
+ [step.goalId, planningVersionId]
349
+ );
350
+ const successorId = made[0].id;
351
+
352
+ // MEMBERSHIP TRAVELS, because a goal without its people is a new goal
353
+ // wearing the old one's title: the builders who were carrying the work are
354
+ // exactly the ones who should still find it on their board after the
355
+ // boundary. `membership_kind` is copied verbatim so the manager stays the
356
+ // manager (ADR 0177 — never the word `role` here).
357
+ await client.query(
358
+ `INSERT INTO goal_members (goal_id, builder_id, membership_kind)
359
+ SELECT $2, builder_id, membership_kind FROM goal_members WHERE goal_id = $1
360
+ ON CONFLICT DO NOTHING`,
361
+ [step.goalId, successorId]
362
+ );
363
+
364
+ // The unfinished work moves; the finished work stays. A shipped task is
365
+ // the record of what THIS version delivered and must not be re-pointed at
366
+ // the next one, or every version's history migrates forward with it.
367
+ // Smoke fixtures are excluded on the same terms as the abandon branch, so
368
+ // both doors agree about what "unfinished" means.
369
+ const { rows: moved } = await client.query(
370
+ `UPDATE tasks SET goal_id = $2, updated_at = now()
371
+ WHERE goal_id = $1 AND ${nonTerminalSql('status')}
372
+ AND title NOT LIKE '\\_\\_smoke\\_\\_%' ESCAPE '\\'
373
+ RETURNING id`,
374
+ [step.goalId, successorId]
375
+ );
376
+
377
+ await client.query(
378
+ `UPDATE goals SET succeeded_by_goal_id = $2, updated_at = now() WHERE id = $1`,
379
+ [step.goalId, successorId]
380
+ );
381
+ // Archived LAST, and only now legitimately: the predecessor holds zero
382
+ // unfinished tasks at this point, so it satisfies R10's own closure check
383
+ // on the way out rather than being special-cased around it (the brief's
384
+ // explicit ask).
385
+ await client.query(`UPDATE goals SET status = 'archived', updated_at = now() WHERE id = $1`, [step.goalId]);
386
+
387
+ applied.push({
388
+ goal_id: String(step.goalId), verb: 'roll_forward', result: 'succeeded',
389
+ successor_goal_id: String(successorId), tasks_moved: moved.length,
390
+ });
309
391
  }
310
392
  }
311
393
 
312
394
  // 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));
395
+ // the plan — the one check a bug in the planner cannot fool.
396
+ //
397
+ // R17 (task 1003604) REMOVED THE CARVE-OUT. This used to exclude the
398
+ // `roll_forward` goals by id, because they were left open pending a successor;
399
+ // now they archive behind their successor like any other closed goal, so the
400
+ // check is the whole truth again: after a close, NO non-maintenance goal on
401
+ // this version is still open. An exclusion list is a hole in an invariant, and
402
+ // this one no longer has a reason to exist.
316
403
  const { rows: left } = await client.query(
317
404
  `SELECT count(*)::int AS n FROM goals g
318
405
  WHERE g.version_id = $1 AND g.status = 'open'
319
- AND ${maintenanceGoalExemptSql('$1')}
320
- AND NOT (g.id = ANY($2::bigint[]))`,
321
- [versionId, carried]
406
+ AND ${maintenanceGoalExemptSql('$1')}`,
407
+ [versionId]
322
408
  );
323
409
  if ((left[0]?.n ?? 0) > 0) {
324
410
  throw Object.assign(new Error('version still holds open goals after dispositions'), {
@@ -83,6 +83,7 @@ const {
83
83
  listActiveClaimsForTasks,
84
84
  listCriteriaForGoal,
85
85
  archiveGoalWithDispositions,
86
+ carryMaintenanceForward,
86
87
  openTasksInGoal,
87
88
  listGoalMembers,
88
89
  listGoals,
@@ -259,6 +260,7 @@ module.exports = {
259
260
  listPendingRequestsForGoal,
260
261
  listCriteriaForGoal,
261
262
  archiveGoalWithDispositions,
263
+ carryMaintenanceForward,
262
264
  openTasksInGoal,
263
265
  createTask,
264
266
  generalGoalIdForVersion,
@@ -274,14 +274,24 @@ module.exports = function buildVersionsRouter() {
274
274
  // REFUSAL rather than an auto-create: cutting the successor is a scope
275
275
  // decision with its own criteria, and doing it implicitly inside a close is
276
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
- });
277
+ // Read ONCE and keep it: the same rows decide the refusal AND name the
278
+ // destination the successors are created on (BV1.R17, task 1003604). Reading
279
+ // it twice would leave a window in which the refusal passed against one
280
+ // planning version and the write landed on another.
281
+ const planning = plan.plan.some((p) => p.verb === 'roll_forward')
282
+ ? await db.versionsWithStatus('planning')
283
+ : [];
284
+ const needsPlanning = rollForwardNeedsPlanningVersion({ plan: plan.plan, planning });
281
285
  if (needsPlanning) return res.status(needsPlanning.status).json(needsPlanning.body);
282
286
 
283
287
  try {
284
- const out = await db.closeVersion({ versionId, plan: plan.plan, reason });
288
+ const out = await db.closeVersion({
289
+ versionId, plan: plan.plan, reason,
290
+ // R04 guarantees at most one planning version, so [0] is THE successor
291
+ // rather than a pick. Null when nothing rolls forward, which is the
292
+ // common close.
293
+ planningVersionId: planning[0]?.id ?? null,
294
+ });
285
295
  return res.json({ ok: true, version: out.version, applied: out.applied });
286
296
  } catch (err) {
287
297
  if (err && err.code === 'VERSION_NOT_FOUND') return res.fail('version_not_found', 404);
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.596",
3
+ "version": "1.19.598",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.596",
9
+ "version": "1.19.598",
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.596",
3
+ "version": "1.19.598",
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",
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.596'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
58
+ const CORE_VERSION = '1.19.598'; // 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,122 @@
1
+ // tests/maintenance_carry_forward.mjs — the one goal that spans versions
2
+ // (BV1.R18b, task 1003708, goal 1000086, ADR 0263 §7).
3
+ //
4
+ // WHY THIS GOT A COLUMN. Since task 1003691 the maintenance goal was found by
5
+ // matching `'<version> — maintenance'`. For the ROUTING path that was a
6
+ // provenance wrinkle — a hand-titled goal receives a bug. For the CLOSING path
7
+ // it was a way through the gate: `maintenanceGoalExemptSql` decides which goals
8
+ // hold a VERSION open, so anyone who named a goal `'BONGOS-V1 — maintenance'`
9
+ // exempted it from the rule that decides when a version may close.
10
+ //
11
+ // A naming convention is not an access control. That sentence is the whole task,
12
+ // and most of this file is that sentence made mechanical.
13
+ //
14
+ // Run: node tests/maintenance_flag.mjs
15
+
16
+ import { strict as assert } from 'node:assert';
17
+ import { createRequire } from 'node:module';
18
+ import { readFileSync } from 'node:fs';
19
+ import { makeRunner, makeSqlAwareClient } from './helpers.mjs';
20
+
21
+ process.env.NODE_ENV = 'test';
22
+ const require = createRequire(import.meta.url);
23
+ const { maintenanceGoalExemptSql, ensureMaintenanceGoal, carryMaintenanceForward, openNonMaintenanceGoals } = require('../modules/lifecycle/db.js');
24
+ const { test, summary } = makeRunner();
25
+
26
+ const src = (rel) => readFileSync(new URL('../' + rel, import.meta.url), 'utf8');
27
+ const MIG = src('migrations/core_235_one_maintenance_goal_per_version.sql');
28
+ const MIG234 = src('migrations/core_234_goals_is_maintenance.sql');
29
+ const flat = (q) => String(q.sql).replace(/\s+/g, ' ').trim();
30
+ function pool(handler) {
31
+ const c = makeSqlAwareClient(handler);
32
+ return { connect: c.connect, query: (sql, p) => c._client.query(sql, p), queries: c._client.queries };
33
+ }
34
+
35
+ // ---- one per version is a CONSTRAINT ---------------------------------------
36
+
37
+ await test('the per-version uniqueness is enforced by the DATABASE, not a convention', () => {
38
+ // core_234 gave the flag a partial index for lookup speed, which is right. But a
39
+ // NON-unique one leaves two flagged goals on one version possible, and
40
+ // ensureMaintenanceGoal's read is `WHERE version_id = $1 AND is_maintenance
41
+ // LIMIT 1` — with two rows that returns whichever the planner picks, and every
42
+ // homeless bug files into an arbitrary one of them, permanently.
43
+ assert.match(MIG, /CREATE UNIQUE INDEX IF NOT EXISTS goals_one_maintenance_per_version_idx[\s\S]*?ON goals \(version_id\)[\s\S]*?WHERE is_maintenance/);
44
+ assert.match(MIG, /DROP INDEX IF EXISTS goals_maintenance_by_version_idx/,
45
+ 'and it replaces the plain one — a UNIQUE index serves every read the plain one did');
46
+ assert.match(MIG, /BEGIN;[\s\S]*COMMIT;/);
47
+ });
48
+
49
+ await test('it closes the door the advisory lock could not', () => {
50
+ // The lock (task 1003691) serialises the RACE. It cannot stop a second
51
+ // maintenance goal created any other way, and `goals` never had a unique
52
+ // constraint on (version_id, title) for the lock to fall back on.
53
+ const dbg = src('modules/lifecycle/db-goals.js');
54
+ assert.match(dbg, /pg_advisory_xact_lock/, 'the lock stays — it solves a different problem');
55
+ assert.match(MIG234, /is_maintenance boolean NOT NULL DEFAULT false/, 'core_234 still owns the column');
56
+ });
57
+
58
+ // ---- carry forward ---------------------------------------------------------
59
+
60
+ function carryPool({ src = { id: 5 }, destStatus = 'building', destFound = { id: 9 } } = {}) {
61
+ return pool((sql) => {
62
+ const q = String(sql).replace(/\s+/g, ' ');
63
+ if (/SELECT id FROM goals WHERE version_id = \$1 AND is_maintenance LIMIT 1/.test(q)) return { rows: src ? [src] : [] };
64
+ if (/SELECT status FROM versions/.test(q)) return { rows: [{ status: destStatus }] };
65
+ if (/pg_advisory_xact_lock/.test(q)) return { rows: [] };
66
+ if (/FROM goals WHERE version_id = \$1 AND is_maintenance/.test(q)) return { rows: destFound ? [destFound] : [] };
67
+ if (/UPDATE tasks SET goal_id/.test(q)) return { rows: [{ id: 1 }, { id: 2 }, { id: 3 }] };
68
+ return { rows: [] };
69
+ });
70
+ }
71
+
72
+ await test('open bugs move to the successor, and the emptied goal archives behind them', async () => {
73
+ const p = carryPool();
74
+ const out = await carryMaintenanceForward({ fromVersionId: 'V1', toVersionId: 'V2' }, { pool: p });
75
+ assert.deepEqual(out, { carried: 3, from: '5', to: '9', reason: null });
76
+ const mv = p.queries.find((x) => /UPDATE tasks SET goal_id/.test(flat(x)));
77
+ assert.match(flat(mv), /NOT IN \('shipped', 'abandoned'\)/, 'only the unfinished ones travel');
78
+ assert.equal(mv.params[2], 'V2', 'and their version_id moves with them, or they are orphaned across the boundary');
79
+ assert.ok(p.queries.some((x) => /UPDATE goals SET status = 'archived'/.test(flat(x))));
80
+ });
81
+
82
+ await test('NO successor: the bugs stay put and the close still succeeds', async () => {
83
+ // A version with nothing scoped after it must not fail its close, and its bug
84
+ // queue must not be cut for a reason that has nothing to do with the bugs.
85
+ const p = carryPool();
86
+ const out = await carryMaintenanceForward({ fromVersionId: 'V1', toVersionId: null }, { pool: p });
87
+ assert.equal(out.carried, 0);
88
+ assert.equal(out.reason, 'no_successor');
89
+ assert.equal(p.queries.some((x) => /UPDATE tasks/.test(flat(x))), false, 'nothing moved');
90
+ assert.equal(p.queries.some((x) => /UPDATE goals SET status = 'archived'/.test(flat(x))), false,
91
+ 'and the source goal is NOT closed — the queue is still live');
92
+ });
93
+
94
+ await test('a successor that is not yet BUILDING carries nothing, and says so', async () => {
95
+ // The ordering trap ADR 0263 §8 names: ensureMaintenanceGoal returns null for a
96
+ // non-building version, so a carry-over run before R20's promotion would report
97
+ // success while moving nothing.
98
+ const p = carryPool({ destStatus: 'planning', destFound: null });
99
+ const out = await carryMaintenanceForward({ fromVersionId: 'V1', toVersionId: 'V2' }, { pool: p });
100
+ assert.equal(out.carried, 0);
101
+ assert.equal(out.reason, 'successor_not_building', 'a silent zero would be indistinguishable from success');
102
+ assert.equal(p.queries.some((x) => /UPDATE tasks/.test(flat(x))), false);
103
+ });
104
+
105
+ await test('a version with no maintenance goal at all is a clean no-op', async () => {
106
+ const p = carryPool({ src: null });
107
+ const out = await carryMaintenanceForward({ fromVersionId: 'V1', toVersionId: 'V2' }, { pool: p });
108
+ assert.equal(out.reason, 'no_maintenance_goal');
109
+ assert.equal(out.carried, 0);
110
+ });
111
+
112
+ await test('it runs on the CALLER exec, so it joins the close transaction', () => {
113
+ // R12 left `deps.onClosed(client, …)` for exactly this. Opening its own
114
+ // connection would commit the carry-over independently of the close it belongs to.
115
+ const dbg = src('modules/lifecycle/db-goals.js');
116
+ const fn = dbg.slice(dbg.indexOf('async function carryMaintenanceForward'), dbg.indexOf('// listVersionAdmissions'));
117
+ assert.match(fn, /const exec = deps\.client \|\|/, 'it must accept the caller\'s client');
118
+ assert.doesNotMatch(fn, /\.connect\(\)/, 'and must never open its own');
119
+ assert.doesNotMatch(fn, /BEGIN|COMMIT/, 'nor manage a transaction it does not own');
120
+ });
121
+
122
+ summary();
@@ -163,6 +163,11 @@ function closePool({ status = 'building', goalStatus = 'open', remaining = 0 } =
163
163
  if (/FROM versions WHERE id = \$1 FOR UPDATE/.test(q)) return { rows: [{ id: 'V1', status }] };
164
164
  if (/FROM goals WHERE id = \$1 AND version_id = \$2 FOR UPDATE/.test(q)) return { rows: [{ id: 1, title: 'g', status: goalStatus }] };
165
165
  if (/UPDATE tasks SET status = 'abandoned'/.test(q)) return { rows: [{ id: 11 }, { id: 12 }] };
166
+ // R17 (task 1003604) — the roll_forward writes: the successor INSERT, the
167
+ // membership copy, and the task re-point.
168
+ if (/INSERT INTO goals/.test(q)) return { rows: [{ id: 99 }] };
169
+ if (/INSERT INTO goal_members/.test(q)) return { rows: [] };
170
+ if (/UPDATE tasks SET goal_id/.test(q)) return { rows: [{ id: 21 }, { id: 22 }] };
166
171
  if (/count\(\*\)::int AS n FROM goals/.test(q)) return { rows: [{ n: remaining }] };
167
172
  if (/UPDATE versions SET status = 'shipped'/.test(q)) return { rows: [{ id: 'V1', status: 'shipped' }] };
168
173
  return { rows: [] };
@@ -193,21 +198,48 @@ await test('an abandoned goal takes its open tasks with it, stamped with the clo
193
198
  assert.match(flat(kill), /NOT IN \('shipped', 'abandoned'\)/, 'and only the unfinished ones');
194
199
  });
195
200
 
196
- await test('a roll_forward abandons NOTHING and says the successor is still pending', async () => {
201
+ await test('a roll_forward abandons NOTHING it creates the successor and moves the work', async () => {
202
+ // BOTH HALVES OF THIS FILE'S roll_forward EXPECTATION FLIPPED AT R17
203
+ // (task 1003604). R12 left the verb as a placeholder that reported
204
+ // `carried_pending_successor` and wrote nothing; the successor, the lineage
205
+ // and the task move land now. What has NOT changed, and is the assertion
206
+ // worth keeping, is that carrying a goal forward never CUTS its work.
197
207
  const pool = closePool();
198
- const out = await closeVersion({ versionId: 'V1', plan: [{ goalId: 1, verb: 'roll_forward' }], reason: 'r' }, { pool });
199
- assert.deepEqual(out.applied, [{ goal_id: '1', verb: 'roll_forward', result: 'carried_pending_successor' }]);
208
+ const out = await closeVersion(
209
+ { versionId: 'V1', plan: [{ goalId: 1, verb: 'roll_forward' }], reason: 'r', planningVersionId: 'V2' },
210
+ { pool }
211
+ );
212
+ assert.equal(out.applied[0].verb, 'roll_forward');
213
+ assert.equal(out.applied[0].result, 'succeeded');
214
+ assert.ok('successor_goal_id' in out.applied[0], 'the response must name the goal that carries on');
200
215
  assert.equal(pool.queries.some((q) => /UPDATE tasks SET status = 'abandoned'/.test(flat(q))), false,
201
216
  'carrying a goal forward must never cut its work');
202
- assert.equal(pool.queries.some((q) => /UPDATE goals SET status = 'archived'/.test(flat(q))), false);
203
217
  });
204
218
 
205
- await test('a carried goal is EXCLUDED from the post-apply re-count, or a close could never carry anything', async () => {
219
+ await test('a roll_forward with no planning version throws rather than stranding the goal', async () => {
220
+ // The route refuses this first (rollForwardNeedsPlanningVersion), so reaching
221
+ // the writer means a caller bypassed the route. Failing beats silently leaving
222
+ // the goal open on a version that is about to ship.
206
223
  const pool = closePool();
207
- await closeVersion({ versionId: 'V1', plan: [{ goalId: 1, verb: 'roll_forward' }], reason: 'r' }, { pool });
224
+ await assert.rejects(
225
+ closeVersion({ versionId: 'V1', plan: [{ goalId: 1, verb: 'roll_forward' }], reason: 'r' }, { pool }),
226
+ (e) => e.code === 'NO_PLANNING_VERSION'
227
+ );
228
+ });
229
+
230
+ await test('the post-apply re-count has NO carve-out — every non-maintenance goal must be closed', async () => {
231
+ // This used to assert the opposite: carried goals were excluded by id, because
232
+ // they were left open pending a successor. They archive now, so the check is
233
+ // the whole truth again. An exclusion list is a hole in an invariant, and this
234
+ // one no longer has a reason to exist.
235
+ const pool = closePool();
236
+ await closeVersion(
237
+ { versionId: 'V1', plan: [{ goalId: 1, verb: 'roll_forward' }], reason: 'r', planningVersionId: 'V2' },
238
+ { pool }
239
+ );
208
240
  const recount = pool.queries.find((q) => /count\(\*\)::int AS n FROM goals/.test(flat(q)));
209
- assert.match(flat(recount), /NOT \(g\.id = ANY\(\$2::bigint\[\]\)\)/);
210
- assert.deepEqual(recount.params[1], [1], 'the carried goal is expected to still be open');
241
+ assert.equal(/ANY\(\$2::bigint\[\]\)/.test(flat(recount)), false, 'the carve-out must be gone');
242
+ assert.equal(recount.params.length, 1, 'only the version id is bound now');
211
243
  });
212
244
 
213
245
  await test('the re-check runs against committed state and rolls back rather than half-closing', async () => {
@@ -0,0 +1,166 @@
1
+ // tests/version_roll_forward.mjs — a goal that is not finished survives the
2
+ // version boundary as a NEW goal, with its lineage recorded
3
+ // (BV1.R17, task 1003604, goal 1000086, ADR 0263 §6).
4
+ //
5
+ // THE COLUMN POINTS THE OTHER WAY from what ADR 0250 and R17's own brief said.
6
+ // Both name `succeeds_goal_id`, which has never existed; migration 160 gave
7
+ // `goals` a **`succeeded_by_goal_id`** — on the OLD goal, pointing at the NEW
8
+ // one. The design pass caught it, which is why R17 reserved no migration. This
9
+ // file pins the direction so the wrong one cannot come back through a reader who
10
+ // trusts the prose over the schema.
11
+ //
12
+ // AND A NOTE ON WHY THIS FILE EXISTS AT ALL, beyond the feature. The close path
13
+ // is driven in tests/version_close_route.mjs by a SQL-AWARE FAKE that records
14
+ // statements rather than parsing them. That fake happily accepted
15
+ // `UPDATE tasks … WHERE t.status NOT IN (…)` — a hard `missing FROM-clause entry
16
+ // for table "t"` on a real Postgres, which meant every `abandon` disposition
17
+ // threw in production from the day R12 shipped. Nothing caught it because
18
+ // nothing ever ran it. The structural assertions at the bottom are the DB-free
19
+ // guard for that whole CLASS of bug: a rendered predicate whose alias does not
20
+ // match the statement it was spliced into.
21
+ //
22
+ // Run: node --test tests/version_roll_forward.mjs
23
+
24
+ import assert from 'node:assert/strict';
25
+ import { test } from 'node:test';
26
+ import { readFileSync } from 'node:fs';
27
+ import { createRequire } from 'node:module';
28
+
29
+ process.env.NODE_ENV = 'test';
30
+ const require = createRequire(import.meta.url);
31
+ const { nonTerminalSql } = require('../modules/lifecycle/task-status.js');
32
+
33
+ const src = (rel) => readFileSync(new URL('../' + rel, import.meta.url), 'utf8');
34
+ const DBV = src('modules/lifecycle/db-versions.js');
35
+ const ROUTES = src('modules/lifecycle/routes/versions.js');
36
+
37
+ // The roll_forward branch, sliced so assertions land on the right code.
38
+ const CLOSE = DBV.slice(DBV.indexOf('async function closeVersion'));
39
+ const ROLL = CLOSE.slice(CLOSE.indexOf("verb: 'roll_forward'") - 4000, CLOSE.indexOf('// The invariant'));
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // The lineage
43
+ // ---------------------------------------------------------------------------
44
+
45
+ test('the lineage column is succeeded_by_goal_id, written on the PREDECESSOR', () => {
46
+ assert.match(ROLL, /UPDATE goals SET succeeded_by_goal_id = \$2[\s\S]{0,80}WHERE id = \$1/);
47
+ // The spelling that does not exist must never reach the SQL. Comments are
48
+ // stripped first: the code deliberately NAMES `succeeds_goal_id` in prose to
49
+ // explain that it was never created, and a check that read the explanation
50
+ // would fail on the warning rather than on a real use.
51
+ const code = DBV.replace(/\/\/[^\n]*/g, '');
52
+ assert.equal(/succeeds_goal_id/.test(code), false,
53
+ 'ADR 0250 and the task brief both name a column migration 160 never created');
54
+ });
55
+
56
+ test('no migration was reserved — the column already exists', () => {
57
+ // R17's brief says this in capitals, because reserving a number for a column
58
+ // that is already there is the mistake the design pass was written to prevent.
59
+ assert.match(DBV, /succeeded_by_goal_id/);
60
+ });
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // What the successor carries, and what it deliberately does not
64
+ // ---------------------------------------------------------------------------
65
+
66
+ test('the successor carries the goal\'s identity, scope and category', () => {
67
+ for (const col of ['title', 'subtitle', 'description', 'scope_modules', 'category_id', 'visibility']) {
68
+ assert.match(ROLL, new RegExp(`g\\.${col}`), `the successor must carry ${col}`);
69
+ }
70
+ });
71
+
72
+ test('membership travels with it', () => {
73
+ // A goal without its people is a new goal wearing the old one's title: the
74
+ // builders carrying the work are exactly who should still find it after the
75
+ // boundary. membership_kind is copied verbatim so a manager stays a manager.
76
+ assert.match(ROLL, /INSERT INTO goal_members \(goal_id, builder_id, membership_kind\)/);
77
+ assert.match(ROLL, /SELECT \$2, builder_id, membership_kind FROM goal_members WHERE goal_id = \$1/);
78
+ });
79
+
80
+ test('the successor does NOT inherit is_maintenance or an admission', () => {
81
+ const insert = ROLL.slice(ROLL.indexOf('INSERT INTO goals'), ROLL.indexOf('RETURNING id'));
82
+ assert.equal(/is_maintenance/.test(insert), false,
83
+ 'the successor version gets its own maintenance goal via ensureMaintenanceGoal');
84
+ assert.equal(/admission/i.test(insert), false,
85
+ 'an override that widened THIS version\'s scope is spent — re-creating the row would launder a one-time exception into a permanent one');
86
+ });
87
+
88
+ // ---------------------------------------------------------------------------
89
+ // What moves, and what stays
90
+ // ---------------------------------------------------------------------------
91
+
92
+ test('only UNFINISHED tasks are re-pointed; shipped work stays with the version that delivered it', () => {
93
+ const move = ROLL.slice(ROLL.indexOf('UPDATE tasks SET goal_id'));
94
+ assert.match(move, /nonTerminalSql\('status'\)/);
95
+ assert.match(move, /smoke/, 'smoke fixtures are excluded on the same terms as the abandon branch');
96
+ });
97
+
98
+ test('the predecessor is archived LAST, after its work has left', () => {
99
+ // The brief's explicit ask: it archives holding zero unfinished tasks, so it
100
+ // passes R10's own closure check on the way out rather than being
101
+ // special-cased around it.
102
+ const moveAt = ROLL.indexOf('UPDATE tasks SET goal_id');
103
+ const lineageAt = ROLL.indexOf('succeeded_by_goal_id = $2');
104
+ const archiveAt = ROLL.indexOf("status = 'archived'");
105
+ assert.ok(moveAt !== -1 && lineageAt !== -1 && archiveAt !== -1);
106
+ assert.ok(moveAt < archiveAt, 'the tasks must leave before the goal closes');
107
+ assert.ok(lineageAt < archiveAt, 'the lineage must be written before the goal closes');
108
+ });
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // The destination
112
+ // ---------------------------------------------------------------------------
113
+
114
+ test('the route reads the planning version ONCE and passes it to the close', () => {
115
+ // Reading it twice leaves a window where the refusal passed against one
116
+ // planning version and the write landed on another.
117
+ assert.match(ROUTES, /const planning = plan\.plan\.some/);
118
+ assert.match(ROUTES, /rollForwardNeedsPlanningVersion\(\{ plan: plan\.plan, planning \}\)/);
119
+ assert.match(ROUTES, /planningVersionId: planning\[0\]\?\.id \?\? null/);
120
+ });
121
+
122
+ test('closeVersion refuses a roll_forward with no destination rather than stranding the goal', () => {
123
+ assert.match(ROLL, /NO_PLANNING_VERSION/);
124
+ });
125
+
126
+ // ---------------------------------------------------------------------------
127
+ // The invariant lost its carve-out
128
+ // ---------------------------------------------------------------------------
129
+
130
+ test('the post-apply re-count no longer excludes rolled-forward goals', () => {
131
+ // They used to be left open pending a successor, so the check excluded them by
132
+ // id. They archive now, so the check is the whole truth again: after a close no
133
+ // non-maintenance goal on this version is still open. An exclusion list is a
134
+ // hole in an invariant.
135
+ const recount = CLOSE.slice(CLOSE.indexOf('// The invariant'), CLOSE.indexOf('CLOSE_INCOMPLETE_AFTER_APPLY'));
136
+ assert.equal(/ANY\(\$2::bigint\[\]\)/.test(recount), false, 'the carve-out must be gone');
137
+ assert.equal(/carried/.test(recount), false, 'and its variable with it');
138
+ });
139
+
140
+ // ---------------------------------------------------------------------------
141
+ // The alias class-of-bug guard (the reason the abandon branch was broken)
142
+ // ---------------------------------------------------------------------------
143
+
144
+ test('every UPDATE in this file splices an UNALIASED predicate', () => {
145
+ // `nonTerminalSql()` defaults to `t.status`, which is correct for the SELECTs
146
+ // here (they alias `tasks t`) and a hard SQL error inside an `UPDATE tasks`,
147
+ // which declares no alias. R12 shipped exactly that and every abandon
148
+ // disposition threw `missing FROM-clause entry for table "t"` in production.
149
+ // The fake-client suite could not see it, so this reads the source instead.
150
+ const updates = [...DBV.matchAll(/UPDATE (tasks|goals)\b[\s\S]{0,600}?(?=RETURNING|`,)/g)];
151
+ assert.ok(updates.length >= 3, 'expected the close path\'s UPDATE statements');
152
+ for (const m of updates) {
153
+ assert.equal(/\$\{nonTerminalSql\(\)\}/.test(m[0]), false,
154
+ 'an UPDATE must call nonTerminalSql(\'status\') — the default t.status has no FROM-clause entry here');
155
+ assert.equal(/\bt\.status\b/.test(m[0]), false, `an UPDATE references t.status: ${m[0].slice(0, 90)}`);
156
+ }
157
+ });
158
+
159
+ test('the SELECTs that DO alias tasks may keep the default', () => {
160
+ // The other half of the rule — this is not "never use the default", it is
161
+ // "match the statement". openNonMaintenanceGoals aliases `tasks t` and is right
162
+ // to use it, and a fix that changed every call site would have been wrong.
163
+ assert.match(DBV, /FROM tasks t\s*\n?\s*WHERE t\.goal_id = g\.id AND \$\{nonTerminalSql\(\)\}/);
164
+ assert.equal(nonTerminalSql(), "t.status NOT IN ('shipped', 'abandoned')");
165
+ assert.equal(nonTerminalSql('status'), "status NOT IN ('shipped', 'abandoned')");
166
+ });