@bongos/core 1.19.595 → 1.19.597

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.595",
6
- "core_contract": "1.19.595",
7
- "source_commit": "a1220b0dc87149660de2f175e060db4787c57d95",
5
+ "core_version": "1.19.597",
6
+ "core_contract": "1.19.597",
7
+ "source_commit": "1015d778d266d3357eaffb989262715110662ad1",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-08T03:53:33.643Z",
9
+ "built_at": "2026-09-08T04:16:25.146Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 457,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 2069,
14
+ "functional_verbatim": 2073,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2550,
20
- "tree_sha256": "1730277de39b60195174936924ed1d436cd316ee03525ed50ffb9a1f7b471b88",
19
+ "file_count": 2554,
20
+ "tree_sha256": "47e446b7dc9ebe46479e9138f5c45722dd3aa0c70e847abc587415af364f4d0b",
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": "f099ee2077bfb6280355522ce1784f9ebbd78af3ea86334c47cba35745de576b"
2730
+ "sha256": "adfc4f65fbe9fc3265ec5599eb55dbe4c25063ae382006530df8c96ed930fc67"
2731
2731
  },
2732
2732
  {
2733
2733
  "path": "docs/modules-contract.md",
@@ -3734,6 +3734,16 @@
3734
3734
  "mode": "0000644",
3735
3735
  "sha256": "ca03e583bc418f600c1b1ba464bcaf4e380916033ec06cd68990cf153184d883"
3736
3736
  },
3737
+ {
3738
+ "path": "migrations/core_234_goals_is_maintenance.sql",
3739
+ "mode": "0000644",
3740
+ "sha256": "92964dc3578fb2f4b1b7547867d0f53b2998c30bdb39271336e9b9aa348a1b37"
3741
+ },
3742
+ {
3743
+ "path": "migrations/core_235_one_maintenance_goal_per_version.sql",
3744
+ "mode": "0000644",
3745
+ "sha256": "c10c4bbbead4a3bafde336f3433a1432818880751f7a21436a6162f0dbde1abf"
3746
+ },
3737
3747
  {
3738
3748
  "path": "modules/agents/lib/validate.js",
3739
3749
  "mode": "0000644",
@@ -5522,7 +5532,7 @@
5522
5532
  {
5523
5533
  "path": "modules/lifecycle/db-goals.js",
5524
5534
  "mode": "0000644",
5525
- "sha256": "6e8235a4025951f82644f4a058a56ee4052d33e3b8b68a7c27d8ce42a0b81b19"
5535
+ "sha256": "c3e06daaf78911c9dc44f72aa7a104f269624c92f343cd808b2962076a641a23"
5526
5536
  },
5527
5537
  {
5528
5538
  "path": "modules/lifecycle/db-grade.js",
@@ -5557,12 +5567,12 @@
5557
5567
  {
5558
5568
  "path": "modules/lifecycle/db-versions.js",
5559
5569
  "mode": "0000644",
5560
- "sha256": "09513924d1a97c4e5c2c84c3e55bcadb91159bf309ece479555b6b7a833188c7"
5570
+ "sha256": "9d909149be4152335f6d259a32621c2e3917e98c2595ac1c8ff0d2202891dbcb"
5561
5571
  },
5562
5572
  {
5563
5573
  "path": "modules/lifecycle/db.js",
5564
5574
  "mode": "0000644",
5565
- "sha256": "75a42d39fe676e0447cead0717a4f832b5296308e786037f5d44ee35593234c6"
5575
+ "sha256": "14c05469abcf349c9173a80059e7fa0dd880b1b5a0b25680ba0d9e524e46d5d9"
5566
5576
  },
5567
5577
  {
5568
5578
  "path": "modules/lifecycle/dead-deps.js",
@@ -7612,12 +7622,12 @@
7612
7622
  {
7613
7623
  "path": "package-lock.json",
7614
7624
  "mode": "0000644",
7615
- "sha256": "4245af0512df1399ba99da46a0e4da17e409cef4d34e1e79e80ed8925ba23ce4"
7625
+ "sha256": "0318db53e76bb62b788fce5bf7dfd540e606e3fdcd1ab1e205732deba3a4bf03"
7616
7626
  },
7617
7627
  {
7618
7628
  "path": "package.json",
7619
7629
  "mode": "0000644",
7620
- "sha256": "39e40d2363abc4576a965c2ba46946f62687ce1351ce962341924fd334cc7941"
7630
+ "sha256": "8d5d6869dab12ea697b5e0fae60883aabd464ae4568a47bc997a21cb8933cb59"
7621
7631
  },
7622
7632
  {
7623
7633
  "path": "public-docs/index.html",
@@ -9332,7 +9342,7 @@
9332
9342
  {
9333
9343
  "path": "src/module-api.js",
9334
9344
  "mode": "0000644",
9335
- "sha256": "50aed74cd31d7a8390994722d3a7ccbbbb056aff57b6324d7204e179170293da"
9345
+ "sha256": "2cb11336e7f6bde05dafd46c2ea3b94d9e5c7ae1f9be772a49b793e17baf7f0d"
9336
9346
  },
9337
9347
  {
9338
9348
  "path": "src/module-loader/catalog.js",
@@ -10722,7 +10732,7 @@
10722
10732
  {
10723
10733
  "path": "tests/government_seed.mjs",
10724
10734
  "mode": "0000644",
10725
- "sha256": "f798fe7edf69245324938587dca3682874495b7bf950f0c0e5165b2bb0eadd4e"
10735
+ "sha256": "6fa11446c88018ab6e62a72756f96f176bfaf6cd6099dc231746645ba0f538d2"
10726
10736
  },
10727
10737
  {
10728
10738
  "path": "tests/government_session_scope.mjs",
@@ -11279,10 +11289,20 @@
11279
11289
  "mode": "0000644",
11280
11290
  "sha256": "9a1813d6036886ea07d1563e71a466e66c28554b75b22605464c795fd9fa2ebb"
11281
11291
  },
11292
+ {
11293
+ "path": "tests/maintenance_carry_forward.mjs",
11294
+ "mode": "0000644",
11295
+ "sha256": "4ac9164b84ad7bf8e9c1b45e7cb5e157762c992ddde16041410a528a534e60f5"
11296
+ },
11282
11297
  {
11283
11298
  "path": "tests/maintenance_goal.mjs",
11284
11299
  "mode": "0000644",
11285
- "sha256": "645fe3e9093abcb1425a59602d6a92a6d90669775aa3503ae435a54c79eca5c1"
11300
+ "sha256": "aa0d19a1d9c2f5ae9e89a77ef8e5c485f0f759e6a36dab988c8c49f0f2b59d27"
11301
+ },
11302
+ {
11303
+ "path": "tests/maintenance_goal_flag.mjs",
11304
+ "mode": "0000644",
11305
+ "sha256": "37356bcd78ff92537a5512b60f2274d573b6c90bb30f5deca006a14d4841fc95"
11286
11306
  },
11287
11307
  {
11288
11308
  "path": "tests/manage_manifest_shared_read.mjs",
@@ -12682,7 +12702,7 @@
12682
12702
  {
12683
12703
  "path": "tests/version_close_route.mjs",
12684
12704
  "mode": "0000644",
12685
- "sha256": "11c77cc535ecd86bc50ea962835b74672ff3a0d8093d8ce3007586a5873b4201"
12705
+ "sha256": "9a5b8f753c4b898b447b257d64c0034a83fe257c23208ce714e2e469fcaf735d"
12686
12706
  },
12687
12707
  {
12688
12708
  "path": "tests/version_literals.mjs",
@@ -1639,5 +1639,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1639
1639
  landed since 1.19.593 with no explicit bump. run 34184562228. (task 1002620)
1640
1640
  1.19.595 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1641
1641
  landed since 1.19.594 with no explicit bump. run 34185042811. (task 1002620)
1642
+ 1.19.596 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
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)
1642
1646
  ---------------------------------------------------------------------------
1643
1647
  ```
@@ -0,0 +1,57 @@
1
+ -- core_234_goals_is_maintenance.sql — say which goal is the version's maintenance
2
+ -- goal in a COLUMN, not in its title (BV1.R18, task 1003605, goal 1000086,
3
+ -- ADR 0263 §7).
4
+ --
5
+ -- WHY THIS IS LOAD-BEARING AND NOT COSMETIC. Until now the maintenance goal was
6
+ -- matched by title — `ensureMaintenanceGoal` (task 1003691) looks up
7
+ -- `"<version> — maintenance"` and its own comment flagged this task as the debt.
8
+ -- For ROUTING a bug that is a provenance wrinkle: the worst case is a
9
+ -- hand-titled goal receiving a defect. For the CLOSE COUNT it is far worse.
10
+ -- `maintenanceGoalExemptSql` decides which goals hold a VERSION open, so with a
11
+ -- title match any Metic+ able to create a goal could name it
12
+ -- `"BONGOS-V1 — maintenance"` and silently exempt it from the gate that decides
13
+ -- when a version may close — widening scope by choosing a string. A boolean
14
+ -- column cannot be spelled into existence.
15
+ --
16
+ -- THE BACKFILL IS BY TITLE, ONCE, and that is the only place the title match is
17
+ -- allowed to survive: the rows that already exist were created by the title
18
+ -- convention, so the convention is how they are identified — exactly once, here,
19
+ -- with the schema then carrying the fact forward. `ensureMaintenanceGoal` stops
20
+ -- reading titles in the same task.
21
+ --
22
+ -- NOT UNIQUE PER VERSION, deliberately. A partial unique index on
23
+ -- (version_id) WHERE is_maintenance would be the tidy thing and would FAIL on any
24
+ -- database that already has two title-matching goals for one version — which is
25
+ -- possible, because `goals` has no unique constraint on (version_id, title) and
26
+ -- the pre-lock window in migration 160's era could produce one. The find-or-create
27
+ -- is already serialised by pg_advisory_xact_lock, and R18 makes it read the flag
28
+ -- with LIMIT 1, so a duplicate is survivable rather than fatal. Adding the
29
+ -- constraint is a follow-up that must first prove the data allows it — the same
30
+ -- lesson core_233 recorded about the building-version index.
31
+ --
32
+ -- CORE, not modules/lifecycle/migrations/: `goals` is a CORE table (migration
33
+ -- 160), and a module migration may only touch its own <key>_-prefixed objects —
34
+ -- fitness.js checkModuleMigrationsAdditive hard-fails an `ALTER TABLE goals` from
35
+ -- a module (ADR 0083 Decision #5).
36
+ --
37
+ -- Forward-safe: one additive column with a NOT NULL default, so old code on the
38
+ -- new schema neither sees nor needs it (every reader SELECTs an explicit column
39
+ -- list). A rollback leaves the column populated and unread.
40
+
41
+ ALTER TABLE goals
42
+ ADD COLUMN IF NOT EXISTS is_maintenance boolean NOT NULL DEFAULT false;
43
+
44
+ -- Backfill: every goal whose title is the maintenance convention for its own
45
+ -- version. Anchored on the version id rather than a bare LIKE '%maintenance%',
46
+ -- so a goal legitimately titled "Maintenance of the art pipeline" is untouched.
47
+ UPDATE goals
48
+ SET is_maintenance = true
49
+ WHERE is_maintenance = false
50
+ AND title = version_id || ' — maintenance';
51
+
52
+ -- Finding the version's maintenance goal is now a flag read on a hot-ish path
53
+ -- (every homeless bug, every close count), so give it an index. Partial: the
54
+ -- overwhelming majority of goals are not maintenance goals.
55
+ CREATE INDEX IF NOT EXISTS goals_maintenance_by_version_idx
56
+ ON goals (version_id)
57
+ WHERE is_maintenance;
@@ -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;
@@ -34,8 +34,13 @@ const { SMOKE_NOT_LIKE } = require('./db-shared.js');
34
34
 
35
35
  // Shared column list so every goal read/return shapes a row identically.
36
36
 
37
+ // `is_maintenance` (BV1.R18, task 1003605, migration core_234) rides the shared
38
+ // list because it is a fact ABOUT the goal that several surfaces need to read —
39
+ // the close count's exemption, and the hall, which should not render the one goal
40
+ // nobody chose as though a person had scoped it. Additive: every consumer selects
41
+ // this list, so nothing had to be found and changed.
37
42
  const GOAL_COLS =
38
- 'id, version_id, title, subtitle, description, scope_modules, status, visibility, accepting_requests, created_by, succeeded_by_goal_id, sort_order, category_id, created_at, updated_at';
43
+ 'id, version_id, title, subtitle, description, scope_modules, status, visibility, accepting_requests, created_by, succeeded_by_goal_id, sort_order, category_id, is_maintenance, created_at, updated_at';
39
44
 
40
45
  // Shared column list for a goal_membership_requests row (task 1951 / ADR 0112) —
41
46
  // the invite / join-request queue behind private goals. Every helper below shapes
@@ -128,19 +133,26 @@ async function ensureMaintenanceGoal(versionId, deps = {}) {
128
133
 
129
134
  await exec.query(`SELECT pg_advisory_xact_lock(hashtext($1))`, [title]);
130
135
 
131
- // MATCHED BY TITLE, because there is no column that says "this is the
132
- // maintenance goal" the same shape generalGoalIdForVersion uses for the
133
- // catch-all, and it carries the same caveat: were a Metic+/Archon to hand-create
134
- // a goal titled exactly this, routing would reuse THEIR goal and its scope
135
- // rather than the zero-scope bucket below. That needs an already-trusted actor,
136
- // so it is a provenance wrinkle rather than a way in, and task 1003605 (BV1.R18)
137
- // is where a real flag belongs when the feature gets its own column.
136
+ // MATCHED BY FLAG since BV1.R18 (task 1003605, migration core_234). This used
137
+ // to match by TITLE, and its own comment called that "a provenance wrinkle
138
+ // rather than a way in" true for routing, where the worst case was a
139
+ // hand-titled goal receiving a bug. It stopped being true once the same
140
+ // identification decided which goals hold a VERSION open
141
+ // (`maintenanceGoalExemptSql`): a Metic+ could then exempt a goal from the
142
+ // close gate by naming it. `goals.is_maintenance` cannot be spelled.
138
143
  //
139
- // No index backs (version_id, title): goals are per-version and few, and this
140
- // runs once per homeless bug. Revisit with R18 if either stops being true.
144
+ // LIMIT 1 with no unique constraint behind it is deliberate see core_234 on
145
+ // why the partial unique index is a follow-up rather than part of this
146
+ // migration. The advisory lock above is what keeps this find-or-create from
147
+ // racing itself; the LIMIT is what keeps a pre-existing duplicate survivable
148
+ // instead of fatal. Ordered by id so the answer is at least stable.
149
+ //
150
+ // `goals_maintenance_by_version_idx` (core_234) backs this read.
141
151
  const { rows: found } = await exec.query(
142
- `SELECT ${GOAL_COLS} FROM goals WHERE version_id = $1 AND title = $2 LIMIT 1`,
143
- [versionId, title]
152
+ `SELECT ${GOAL_COLS} FROM goals
153
+ WHERE version_id = $1 AND is_maintenance
154
+ ORDER BY id LIMIT 1`,
155
+ [versionId]
144
156
  );
145
157
  if (found[0]) return found[0];
146
158
 
@@ -158,21 +170,142 @@ async function ensureMaintenanceGoal(versionId, deps = {}) {
158
170
  // split (sub-Metic lands 'backlog', never 'ready') and createTask's own derived
159
171
  // floor. Do not "fix" this by inventing a scope list — narrow the GRANT instead,
160
172
  // and write down why.
173
+ // is_maintenance = true is what makes this goal exempt from the version-close
174
+ // count (ADR 0263 §7) — set HERE, in the same statement that creates it, so a
175
+ // maintenance goal cannot exist unflagged for even one transaction. The title
176
+ // is still written to the convention, because it is what a person reads on the
177
+ // board; nothing decides anything from it any more.
161
178
  const { rows: made } = await exec.query(
162
- `INSERT INTO goals (version_id, title, description, scope_modules, status, created_by, sort_order)
163
- SELECT $1, $2, $3, $4, 'open', NULL, COALESCE(MAX(sort_order) + 1, 0)
179
+ `INSERT INTO goals (version_id, title, description, scope_modules, status, created_by, sort_order, is_maintenance)
180
+ SELECT $1, $2, $3, $4, 'open', NULL, COALESCE(MAX(sort_order) + 1, 0), true
164
181
  FROM goals WHERE version_id = $1
165
182
  RETURNING ${GOAL_COLS}`,
166
183
  [versionId, title,
167
184
  `Where a reported defect lands when its reporter named no goal (ADR 0262). ` +
168
- `Auto-created by the bug-routing path; task 1003605 (BV1.R18) owns the rest of ` +
169
- `the maintenance-goal feature the close-exempt flag and carrying open bugs ` +
170
- `forward to the successor version.`,
185
+ `Auto-created by the bug-routing path, flagged \`is_maintenance\` so it is exempt ` +
186
+ `from the version-close count and never holds a version open (ADR 0263 §7). Its ` +
187
+ `unfinished bugs carry forward to the successor version's maintenance goal.`,
171
188
  MAINTENANCE_SCOPE_MODULES]
172
189
  );
173
190
  return made[0] || null;
174
191
  }
175
192
 
193
+ // carryBugsForward — move a closing version's unfinished defects into the
194
+ // successor's maintenance goal (BV1.R18, task 1003605, goal 1000086, ADR 0263 §7).
195
+ //
196
+ // THE ONE GOAL THAT LEGITIMATELY SPANS VERSIONS. Every other goal is scoped to a
197
+ // version and dies with it; the maintenance goal is where a defect against
198
+ // already-shipped work lands, and defects do not respect a version boundary. ADR
199
+ // 0250 §3 makes that a pressure valve — a bug must never need an Archon override
200
+ // to be filed — and ADR 0263 §7 points out the valve only works if the queue
201
+ // SURVIVES the boundary. A version close that left the open bugs in a shipped
202
+ // version's goal would entomb them exactly where nobody looks, which is the 1000771
203
+ // bug this whole path was built to end.
204
+ //
205
+ // RUNS ON THE CALLER'S CLIENT, inside the close transaction, and AFTER the
206
+ // successor has been promoted to `building` — `ensureMaintenanceGoal` returns null
207
+ // for a version that is not building, so a carry-over run before the promotion
208
+ // silently carries nothing. That ordering is R20's (task 1003607) to keep; this
209
+ // function fails loudly rather than quietly if it is broken.
210
+ //
211
+ // IT DOES NOT REFUSE AN ACTIVELY-CLAIMED TASK, and that is a deliberate divergence
212
+ // from `archiveGoalWithDispositions` (R14), which does. The two are different acts:
213
+ // archiving ABANDONS a task out from under whoever is working it, so refusing
214
+ // protects them. Carrying forward only re-points which goal the task belongs to —
215
+ // the claim references the task and is untouched, the builder loses nothing — and
216
+ // refusing here would let one in-flight bug fix block a version from ever closing.
217
+ // A close must not be hostage to a claim on a defect.
218
+ //
219
+ // Returns { fromGoalId, toGoalId, moved } — `moved` is 0 whenever there is nothing
220
+ // to carry, which is the common case and is not an error.
221
+ async function carryBugsForward({ fromVersionId, toVersionId }, deps = {}) {
222
+ const exec = deps.client || ((deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool);
223
+ if (fromVersionId == null || toVersionId == null) return { fromGoalId: null, toGoalId: null, moved: 0 };
224
+
225
+ // The source goal is read by FLAG, not created: a version that never received a
226
+ // homeless bug has no maintenance goal, and inventing one on the way out would
227
+ // leave an empty goal on a shipped version forever.
228
+ const { rows: from } = await exec.query(
229
+ `SELECT id FROM goals WHERE version_id = $1 AND is_maintenance ORDER BY id LIMIT 1`,
230
+ [fromVersionId]
231
+ );
232
+ if (!from[0]) return { fromGoalId: null, toGoalId: null, moved: 0 };
233
+
234
+ const to = await ensureMaintenanceGoal(toVersionId, deps);
235
+ if (!to) {
236
+ throw Object.assign(new Error('successor version cannot hold a maintenance goal'), {
237
+ code: 'CARRY_FORWARD_NO_DESTINATION', toVersionId,
238
+ hint: 'ensureMaintenanceGoal returns null unless the destination version is `building` — promote it before carrying forward (ADR 0263 §8).',
239
+ });
240
+ }
241
+
242
+ // Only the UNFINISHED ones move. A shipped or abandoned defect is history and
243
+ // belongs to the version it was fixed in — dragging it forward would make every
244
+ // successor's maintenance goal a growing archive of everything ever reported.
245
+ const { rows: moved } = await exec.query(
246
+ `UPDATE tasks SET goal_id = $2, updated_at = now()
247
+ WHERE goal_id = $1 AND ${nonTerminalSql('status')}
248
+ RETURNING id::text`,
249
+ [from[0].id, to.id]
250
+ );
251
+ return { fromGoalId: String(from[0].id), toGoalId: String(to.id), moved: moved.length };
252
+ }
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
+
176
309
  // listVersionAdmissions (BV1.R07, task 1003594) — every goal admitted into ONE
177
310
  // version, newest first. ADR 0250 §3: "the overrides are counted and shown on the
178
311
  // version" — a visible counter is what keeps the exception honest, because an
@@ -1092,8 +1225,10 @@ async function listPendingRequestsForGoal(goalId, deps = {}) {
1092
1225
  module.exports = {
1093
1226
  acceptMembershipRequestAndAddMember,
1094
1227
  ensureMaintenanceGoal,
1228
+ carryBugsForward,
1095
1229
  achieveGoalIfComplete,
1096
1230
  archiveGoalWithDispositions,
1231
+ carryMaintenanceForward,
1097
1232
  addGoalMember,
1098
1233
  addGoalScopeModules,
1099
1234
  createGoal,
@@ -190,19 +190,19 @@ async function currentBuildingVersionId(deps = {}) {
190
190
  // maintenanceGoalExemptSql — "this goal is NOT the version's maintenance goal",
191
191
  // as one SQL predicate with ONE definition (BV1.R12, task 1003599; ADR 0263 §7).
192
192
  //
193
- // READ THIS BEFORE CHANGING IT. Today the maintenance goal is identified BY TITLE,
194
- // because no column says which goal it is `ensureMaintenanceGoal` (db-goals.js)
195
- // matches `"<version> — maintenance"` and its own comment flags that as task
196
- // 1003605's (R18) debt. That is a provenance wrinkle for ROUTING, where the worst
197
- // case is a hand-titled goal receiving a bug. It is a much worse wrinkle HERE: this
198
- // predicate decides which goals hold a VERSION open, so a hand-titled goal would
199
- // silently exempt itself from the gate that decides when a version may close.
193
+ // R18 (task 1003605) REPLACED THE BODY, exactly as R12 said it would, and the
194
+ // seam is why that was a one-line change with no call sites to hunt. It used to
195
+ // read `g.title <> ($1 || ' — maintenance')`, which decided by STRING which goals
196
+ // hold a version open so any Metic+ able to create a goal could name it
197
+ // `"BONGOS-V1 maintenance"` and silently exempt it from the gate that decides
198
+ // when a version may close. Widening scope by choosing a title is not a wrinkle,
199
+ // it is a way in. `goals.is_maintenance` (migration core_234) cannot be spelled
200
+ // into existence.
200
201
  //
201
- // So it is named, used in exactly one place, and R18 replaces its BODY with
202
- // `NOT g.is_maintenance` once the column exists one edit, no call sites to find.
203
- // The parameter is the version id placeholder the caller has already bound.
204
- function maintenanceGoalExemptSql(titleParam) {
205
- return `g.title <> (${titleParam} || ' — maintenance')`;
202
+ // The parameter is now unused and kept ONLY so the call sites do not change
203
+ // shape; drop it the next time this file is touched for another reason.
204
+ function maintenanceGoalExemptSql(_titleParam) {
205
+ return 'NOT g.is_maintenance';
206
206
  }
207
207
 
208
208
  // openNonMaintenanceGoals — the goals that hold a version open, id + title +
@@ -71,6 +71,7 @@ const {
71
71
  addGoalScopeModules,
72
72
  createGoal,
73
73
  ensureMaintenanceGoal,
74
+ carryBugsForward,
74
75
  createMembershipRequest,
75
76
  getGoal,
76
77
  getGoalAdmission,
@@ -82,6 +83,7 @@ const {
82
83
  listActiveClaimsForTasks,
83
84
  listCriteriaForGoal,
84
85
  archiveGoalWithDispositions,
86
+ carryMaintenanceForward,
85
87
  openTasksInGoal,
86
88
  listGoalMembers,
87
89
  listGoals,
@@ -225,6 +227,7 @@ module.exports = {
225
227
  currentBuildingVersionId,
226
228
  createGoal,
227
229
  ensureMaintenanceGoal,
230
+ carryBugsForward,
228
231
  getGoal,
229
232
  getGoalAdmission,
230
233
  listVersionAdmissions,
@@ -257,6 +260,7 @@ module.exports = {
257
260
  listPendingRequestsForGoal,
258
261
  listCriteriaForGoal,
259
262
  archiveGoalWithDispositions,
263
+ carryMaintenanceForward,
260
264
  openTasksInGoal,
261
265
  createTask,
262
266
  generalGoalIdForVersion,
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.595",
3
+ "version": "1.19.597",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.595",
9
+ "version": "1.19.597",
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.595",
3
+ "version": "1.19.597",
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.595'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
58
+ const CORE_VERSION = '1.19.597'; // 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');
@@ -90,16 +90,6 @@ function grantBlocksForRole(role) {
90
90
  const REVOKE_RE =
91
91
  /DELETE\s+FROM\s+govern(?:ance_role|ment_rank)_permissions\s+WHERE\s+(?:rank_key|role_key)\s*=\s*'([^']+)'\s+AND\s+permission_key\s*=\s*'([^']+)'/gi;
92
92
 
93
- function revokeBlocksForRole(role) {
94
- const blocks = [];
95
- for (const [file, sql] of grantSqlByFile) {
96
- const code = sql.replace(/--[^\n]*/g, '');
97
- for (const m of code.matchAll(new RegExp(REVOKE_RE.source, 'gi'))) {
98
- if (m[1] === role) blocks.push({ file, key: m[2] });
99
- }
100
- }
101
- return blocks;
102
- }
103
93
 
104
94
  // The EFFECTIVE grant set: walk the migrations in filename order, adding each
105
95
  // file's grants and removing its revocations, so a key granted early and revoked
@@ -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();
@@ -96,9 +96,15 @@ await test('ensureMaintenanceGoal: an EXISTING goal is returned, and nothing is
96
96
  const goal = await ensureMaintenanceGoal('BONGOS-V1', { pool });
97
97
  assert.equal(goal.id, 77);
98
98
  assert.ok(!find(pool.queries, /INSERT INTO goals/), 'find-or-create must not create when it finds');
99
- // The lookup is keyed on the exact title, bound as a parameter.
99
+ // BV1.R18 (task 1003605, migration core_234): the lookup is keyed on the
100
+ // VERSION and the `is_maintenance` flag — the title is no longer a parameter,
101
+ // because the title no longer decides anything. It used to bind
102
+ // ['BONGOS-V1', 'BONGOS-V1 — maintenance'].
100
103
  const lookup = find(pool.queries, /^SELECT .* FROM goals WHERE version_id/);
101
- assert.deepEqual(lookup.params, ['BONGOS-V1', 'BONGOS-V1 — maintenance']);
104
+ assert.deepEqual(lookup.params, ['BONGOS-V1']);
105
+ assert.match(lookup.sql, /is_maintenance/);
106
+ assert.equal(/title/.test(lookup.sql.replace(/SELECT[\s\S]*?FROM/, 'SELECT FROM')), false,
107
+ 'the title must not appear in the WHERE clause — a goal named into the convention would otherwise be adopted');
102
108
  });
103
109
 
104
110
  await test('ensureMaintenanceGoal: the LOCK is taken BEFORE the read, or find-or-create races', async () => {
@@ -0,0 +1,223 @@
1
+ // tests/maintenance_goal_flag.mjs — which goal is the maintenance goal is a
2
+ // COLUMN, not a title (BV1.R18, task 1003605, goal 1000086, ADR 0263 §7).
3
+ //
4
+ // WHY THIS MATTERS MORE THAN IT LOOKS. The maintenance goal was identified by
5
+ // matching the title `"<version> — maintenance"`. For ROUTING a homeless bug that
6
+ // was a provenance wrinkle: the worst case is a hand-titled goal receiving a
7
+ // defect. Then R12 made the SAME identification decide the version-close count —
8
+ // `maintenanceGoalExemptSql` answers "which goals hold this version open" — and a
9
+ // wrinkle became a way in: any Metic+ able to create a goal could exempt it from
10
+ // the gate that decides when a version may close, by choosing a string.
11
+ // `goals.is_maintenance` (migration core_234) cannot be spelled into existence.
12
+ //
13
+ // The BACKFILL is the one place the title match legitimately survives: the rows
14
+ // that already exist were made by the convention, so the convention identifies
15
+ // them — once, in the migration, with the schema carrying the fact afterwards.
16
+ //
17
+ // Run: node --test tests/maintenance_goal_flag.mjs
18
+
19
+ import assert from 'node:assert/strict';
20
+ import { test } from 'node:test';
21
+ import { readFileSync } from 'node:fs';
22
+ import { createRequire } from 'node:module';
23
+ import { makeSqlAwareClient } from './helpers.mjs';
24
+
25
+ process.env.NODE_ENV = 'test';
26
+ const require = createRequire(import.meta.url);
27
+ const { ensureMaintenanceGoal, carryBugsForward } = require('../modules/lifecycle/db.js');
28
+ const { maintenanceGoalExemptSql } = require('../modules/lifecycle/db-versions.js');
29
+
30
+ const src = (rel) => readFileSync(new URL('../' + rel, import.meta.url), 'utf8');
31
+ const MIGRATION = src('migrations/core_234_goals_is_maintenance.sql');
32
+ const DB_GOALS = src('modules/lifecycle/db-goals.js');
33
+
34
+ function recorder(handler) {
35
+ const seen = [];
36
+ const c = makeSqlAwareClient((sql, params) => { seen.push({ sql, params }); return handler(sql, params); });
37
+ return { client: { query: (sql, params) => c._client.query(sql, params) }, seen };
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // The predicate that decides which goals hold a version open
42
+ // ---------------------------------------------------------------------------
43
+
44
+ test('the close-count exemption reads the FLAG, never a title', () => {
45
+ const sql = maintenanceGoalExemptSql('$1');
46
+ assert.equal(sql, 'NOT g.is_maintenance');
47
+ // The specific regression: any form of title comparison here lets a goal
48
+ // exempt itself from the version-close gate by being named a certain way.
49
+ assert.equal(/title/i.test(sql), false,
50
+ 'a title comparison here is a scope widening anyone who can name a goal could perform');
51
+ assert.equal(/maintenance'/.test(sql), false, 'no string literal decides this');
52
+ });
53
+
54
+ // ---------------------------------------------------------------------------
55
+ // find-or-create
56
+ // ---------------------------------------------------------------------------
57
+
58
+ test('ensureMaintenanceGoal FINDS by flag, not by title', async () => {
59
+ const r = recorder((sql) => {
60
+ if (/FROM versions/.test(sql)) return { rows: [{ status: 'building' }] };
61
+ if (/pg_advisory_xact_lock/.test(sql)) return { rows: [] };
62
+ if (/^SELECT .* FROM goals/m.test(sql)) return { rows: [{ id: 7, is_maintenance: true }] };
63
+ return { rows: [] };
64
+ });
65
+ const got = await ensureMaintenanceGoal('BONGOS-V1', { client: r.client });
66
+ assert.equal(got.id, 7);
67
+ const lookup = r.seen.find((q) => /FROM goals/.test(q.sql) && /SELECT/.test(q.sql));
68
+ assert.match(lookup.sql, /WHERE version_id = \$1 AND is_maintenance/);
69
+ assert.equal(/title = \$2/.test(lookup.sql), false, 'the title must no longer select the goal');
70
+ });
71
+
72
+ test('the advisory lock is still taken BEFORE the read', async () => {
73
+ // The lock is what makes find-or-create safe without a unique constraint, and
74
+ // it is keyed on the title purely as a stable per-version string. Moving the
75
+ // identification to a flag must not accidentally drop the serialisation.
76
+ const r = recorder((sql) => {
77
+ if (/FROM versions/.test(sql)) return { rows: [{ status: 'building' }] };
78
+ return { rows: [{ id: 7 }] };
79
+ });
80
+ await ensureMaintenanceGoal('BONGOS-V1', { client: r.client });
81
+ const lockAt = r.seen.findIndex((q) => /pg_advisory_xact_lock/.test(q.sql));
82
+ const readAt = r.seen.findIndex((q) => /FROM goals/.test(q.sql) && /is_maintenance/.test(q.sql));
83
+ assert.ok(lockAt !== -1, 'the advisory lock must still be taken');
84
+ assert.ok(lockAt < readAt, 'the lock must precede the read, or two writers both see "none"');
85
+ });
86
+
87
+ test('a created maintenance goal is flagged in the SAME statement that creates it', () => {
88
+ // Not a follow-up UPDATE: a maintenance goal must never exist unflagged, even
89
+ // for one transaction, or a concurrent close count would not exempt it.
90
+ const insert = DB_GOALS.slice(DB_GOALS.indexOf('INSERT INTO goals (version_id, title, description'));
91
+ assert.match(insert.slice(0, 400), /is_maintenance/);
92
+ assert.match(insert.slice(0, 400), /true/);
93
+ });
94
+
95
+ test('a version that cannot hold work gets no maintenance goal', async () => {
96
+ for (const status of ['shipped', 'planning', 'frozen']) {
97
+ const r = recorder(() => ({ rows: [{ status }] }));
98
+ assert.equal(await ensureMaintenanceGoal('V', { client: r.client }), null, `status=${status}`);
99
+ }
100
+ });
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // The carry-over — the one goal that legitimately spans versions
104
+ // ---------------------------------------------------------------------------
105
+
106
+ test('carryBugsForward moves only NON-TERMINAL tasks', async () => {
107
+ const r = recorder((sql) => {
108
+ // `SELECT id FROM goals` is carryBugsForward's own source read;
109
+ // `SELECT id, version_id, …` is ensureMaintenanceGoal's GOAL_COLS read.
110
+ // The sql-aware client collapses whitespace, so the two must be told apart
111
+ // by their projection rather than by line breaks.
112
+ if (/SELECT id FROM goals/.test(sql)) return { rows: [{ id: 1 }] };
113
+ if (/FROM versions/.test(sql)) return { rows: [{ status: 'building' }] };
114
+ if (/pg_advisory_xact_lock/.test(sql)) return { rows: [] };
115
+ if (/SELECT id, version_id/.test(sql)) return { rows: [{ id: 5 }] };
116
+ if (/UPDATE tasks/.test(sql)) return { rows: [{ id: '11' }, { id: '12' }] };
117
+ return { rows: [] };
118
+ });
119
+ const out = await carryBugsForward({ fromVersionId: 'V1', toVersionId: 'V2' }, { client: r.client });
120
+ assert.deepEqual(out, { fromGoalId: '1', toGoalId: '5', moved: 2 });
121
+ const move = r.seen.find((q) => /UPDATE tasks/.test(q.sql));
122
+ assert.match(move.sql, /status NOT IN \('shipped', 'abandoned'\)/,
123
+ 'a fixed or cut defect belongs to the version it was resolved in — carrying it forward would make every successor an archive');
124
+ });
125
+
126
+ test('a version with no maintenance goal carries nothing, and that is not an error', async () => {
127
+ const r = recorder(() => ({ rows: [] }));
128
+ const out = await carryBugsForward({ fromVersionId: 'V1', toVersionId: 'V2' }, { client: r.client });
129
+ assert.deepEqual(out, { fromGoalId: null, toGoalId: null, moved: 0 });
130
+ assert.equal(r.seen.some((q) => /UPDATE tasks/.test(q.sql)), false, 'nothing is written');
131
+ });
132
+
133
+ test('carrying into a version that is not building FAILS LOUDLY', async () => {
134
+ // ADR 0263 §8: promotion must happen BEFORE the carry-over, because
135
+ // ensureMaintenanceGoal returns null for a version that is not `building`. A
136
+ // silent no-op here would strand the bug queue on a shipped version — the
137
+ // exact entombment this whole path exists to end — and look like success.
138
+ const r = recorder((sql) => {
139
+ if (/SELECT id FROM goals/.test(sql)) return { rows: [{ id: 1 }] };
140
+ if (/FROM versions/.test(sql)) return { rows: [{ status: 'planning' }] };
141
+ return { rows: [] };
142
+ });
143
+ await assert.rejects(
144
+ () => carryBugsForward({ fromVersionId: 'V1', toVersionId: 'V2' }, { client: r.client }),
145
+ (e) => e.code === 'CARRY_FORWARD_NO_DESTINATION'
146
+ );
147
+ });
148
+
149
+ test('carryBugsForward does NOT refuse an actively-claimed task', () => {
150
+ // A deliberate divergence from archiveGoalWithDispositions (R14), which does.
151
+ // Archiving abandons a task out from under whoever holds it; carrying forward
152
+ // only re-points its goal, and the claim references the task, so nothing is
153
+ // taken from anyone. Refusing here would let one in-flight bug fix block a
154
+ // version from ever closing.
155
+ const fn = DB_GOALS.slice(DB_GOALS.indexOf('async function carryBugsForward'));
156
+ const body = fn.slice(0, fn.indexOf('\n}\n'));
157
+ assert.equal(/FROM claims/.test(body), false, 'a close must not be hostage to a claim on a defect');
158
+ });
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // The property the whole task exists for: the flag has no user write path
162
+ // ---------------------------------------------------------------------------
163
+
164
+ test('NOTHING but the system create can set is_maintenance', () => {
165
+ // THE POINT OF THE COLUMN. Moving the identification from a title to a flag
166
+ // only closes the vector if the flag itself cannot be set by whoever could
167
+ // previously choose the title. Every `UPDATE goals` in the lifecycle names its
168
+ // columns explicitly — there is no generic passthrough — so this asserts that
169
+ // none of them names this one, and that the sole writer is the system
170
+ // find-or-create (`created_by NULL`) plus the one-time migration backfill.
171
+ //
172
+ // If a future edit adds a goal-field update that accepts arbitrary columns,
173
+ // this is what should stop it.
174
+ const files = [
175
+ 'modules/lifecycle/db-goals.js',
176
+ 'modules/lifecycle/db-versions.js',
177
+ 'modules/lifecycle/goal-edits.js',
178
+ 'modules/lifecycle/goal-category.js',
179
+ 'modules/lifecycle/done-when.js',
180
+ ];
181
+ for (const rel of files) {
182
+ const code = src(rel).replace(/\/\/[^\n]*/g, '');
183
+ for (const m of code.matchAll(/UPDATE goals[\s\S]{0,400}?(?=RETURNING|WHERE id|;|`)/g)) {
184
+ assert.equal(/\bis_maintenance\s*=/.test(m[0]), false,
185
+ `${rel}: an UPDATE sets is_maintenance — the flag must have no write path a caller can reach, or naming a goal is replaced by flagging one`);
186
+ }
187
+ }
188
+ });
189
+
190
+ // ---------------------------------------------------------------------------
191
+ // The migration
192
+ // ---------------------------------------------------------------------------
193
+
194
+ test('the column is NOT NULL with a false default, so no goal is ambiguously flagged', () => {
195
+ assert.match(MIGRATION, /ADD COLUMN IF NOT EXISTS is_maintenance boolean NOT NULL DEFAULT false/);
196
+ });
197
+
198
+ test('the backfill is anchored on the version id, not a bare LIKE', () => {
199
+ // `title LIKE '%maintenance%'` would have flagged a goal legitimately called
200
+ // "Maintenance of the art pipeline" and silently exempted it from the close
201
+ // gate — the very failure the column exists to remove.
202
+ assert.match(MIGRATION, /title = version_id \|\| ' — maintenance'/);
203
+ // Comments stripped first: this file's own header explains why a bare
204
+ // `LIKE '%maintenance%'` would be wrong, and a check that read the prose
205
+ // would fail on the explanation rather than on the SQL.
206
+ const sqlOnly = MIGRATION.replace(/--[^\n]*/g, '');
207
+ assert.equal(/LIKE\s*'%maintenance/i.test(sqlOnly), false);
208
+ });
209
+
210
+ test('the migration records WHY there is no unique constraint yet', () => {
211
+ // A partial unique index would be tidy and would FAIL on any database that
212
+ // already holds two title-matching goals for one version — which is possible,
213
+ // because `goals` has no unique constraint on (version_id, title). Same lesson
214
+ // core_233 recorded about the building-version index.
215
+ assert.match(MIGRATION, /NOT UNIQUE PER VERSION/);
216
+ assert.match(MIGRATION, /core_233/, 'the precedent is named so the reasoning is findable');
217
+ });
218
+
219
+ test('the find-or-create survives a pre-existing duplicate rather than throwing', () => {
220
+ const fn = DB_GOALS.slice(DB_GOALS.indexOf('async function ensureMaintenanceGoal'));
221
+ assert.match(fn.slice(0, 2500), /ORDER BY id LIMIT 1/,
222
+ 'with no unique constraint, a duplicate must be survivable and the answer stable');
223
+ });
@@ -130,9 +130,16 @@ await test('roll_forward WITH a planning version proceeds', () => {
130
130
  // ---- the maintenance exemption ---------------------------------------------
131
131
 
132
132
  await test('the exemption has ONE definition, and the open-goals read is its only caller', () => {
133
- // R18 (task 1003605) swaps this body for `NOT g.is_maintenance` once the column
134
- // exists. One call site is what makes that a one-line change instead of a hunt.
135
- assert.equal(maintenanceGoalExemptSql('$1'), "g.title <> ($1 || ' — maintenance')");
133
+ // R18 (task 1003605, migration core_234) SWAPPED this body, exactly as R12
134
+ // predicted here the single definition is what made it a one-line change
135
+ // instead of a hunt, and this assertion moved with it.
136
+ //
137
+ // The old body was `g.title <> ($1 || ' — maintenance')`, which decided by
138
+ // STRING which goals hold a version open: anyone able to name a goal could
139
+ // exempt it from the close gate. A boolean column cannot be spelled.
140
+ assert.equal(maintenanceGoalExemptSql('$1'), 'NOT g.is_maintenance');
141
+ assert.equal(/title/i.test(maintenanceGoalExemptSql('$1')), false,
142
+ 'no title comparison may decide which goals hold a version open');
136
143
  const dbv = src('modules/lifecycle/db-versions.js');
137
144
  const uses = (dbv.match(/maintenanceGoalExemptSql\(/g) || []).length;
138
145
  assert.equal(uses, 3, 'declaration + the open-goals read + the post-apply re-count, and nothing else');