@bongos/core 1.19.595 → 1.19.596

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.596",
6
+ "core_contract": "1.19.596",
7
+ "source_commit": "25b1abaa5a60deab7277d0a2179e58dfc482b6b0",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-08T03:53:33.643Z",
9
+ "built_at": "2026-09-08T04:01:26.614Z",
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": 2071,
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": 2552,
20
+ "tree_sha256": "fd2460b94c293de3373d6e8b25a51f6cfc3c851ef13536d9ae525b90d0ae5d2c",
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": "8cd803732ddfd0a2b36f63b63b6a280665a86bfed256198589f00c4a2c288609"
2731
2731
  },
2732
2732
  {
2733
2733
  "path": "docs/modules-contract.md",
@@ -3734,6 +3734,11 @@
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
+ },
3737
3742
  {
3738
3743
  "path": "modules/agents/lib/validate.js",
3739
3744
  "mode": "0000644",
@@ -5522,7 +5527,7 @@
5522
5527
  {
5523
5528
  "path": "modules/lifecycle/db-goals.js",
5524
5529
  "mode": "0000644",
5525
- "sha256": "6e8235a4025951f82644f4a058a56ee4052d33e3b8b68a7c27d8ce42a0b81b19"
5530
+ "sha256": "433f3ef7e9c81190b7d7fa7cc71e74e46c768d54240f6d89678b2437dd67496b"
5526
5531
  },
5527
5532
  {
5528
5533
  "path": "modules/lifecycle/db-grade.js",
@@ -5557,12 +5562,12 @@
5557
5562
  {
5558
5563
  "path": "modules/lifecycle/db-versions.js",
5559
5564
  "mode": "0000644",
5560
- "sha256": "09513924d1a97c4e5c2c84c3e55bcadb91159bf309ece479555b6b7a833188c7"
5565
+ "sha256": "9d909149be4152335f6d259a32621c2e3917e98c2595ac1c8ff0d2202891dbcb"
5561
5566
  },
5562
5567
  {
5563
5568
  "path": "modules/lifecycle/db.js",
5564
5569
  "mode": "0000644",
5565
- "sha256": "75a42d39fe676e0447cead0717a4f832b5296308e786037f5d44ee35593234c6"
5570
+ "sha256": "33fc8959d522bcf18c445b1a5710940379f84d1e938eecdc52cdf52bec9c4b3d"
5566
5571
  },
5567
5572
  {
5568
5573
  "path": "modules/lifecycle/dead-deps.js",
@@ -7612,12 +7617,12 @@
7612
7617
  {
7613
7618
  "path": "package-lock.json",
7614
7619
  "mode": "0000644",
7615
- "sha256": "4245af0512df1399ba99da46a0e4da17e409cef4d34e1e79e80ed8925ba23ce4"
7620
+ "sha256": "e7a26121f535ff4cdd5eec05579d16849da238adb5932a4f94815e67d5de4745"
7616
7621
  },
7617
7622
  {
7618
7623
  "path": "package.json",
7619
7624
  "mode": "0000644",
7620
- "sha256": "39e40d2363abc4576a965c2ba46946f62687ce1351ce962341924fd334cc7941"
7625
+ "sha256": "4f2e2854f84a88237284dbbe30261670e3fe6683fdb46dd7c24b09f18ebf9f82"
7621
7626
  },
7622
7627
  {
7623
7628
  "path": "public-docs/index.html",
@@ -9332,7 +9337,7 @@
9332
9337
  {
9333
9338
  "path": "src/module-api.js",
9334
9339
  "mode": "0000644",
9335
- "sha256": "50aed74cd31d7a8390994722d3a7ccbbbb056aff57b6324d7204e179170293da"
9340
+ "sha256": "ad5397fd512a3a25f3ad6a50a96f628ba1f43b6330ce2799f34c9cb099d70cc2"
9336
9341
  },
9337
9342
  {
9338
9343
  "path": "src/module-loader/catalog.js",
@@ -10722,7 +10727,7 @@
10722
10727
  {
10723
10728
  "path": "tests/government_seed.mjs",
10724
10729
  "mode": "0000644",
10725
- "sha256": "f798fe7edf69245324938587dca3682874495b7bf950f0c0e5165b2bb0eadd4e"
10730
+ "sha256": "6fa11446c88018ab6e62a72756f96f176bfaf6cd6099dc231746645ba0f538d2"
10726
10731
  },
10727
10732
  {
10728
10733
  "path": "tests/government_session_scope.mjs",
@@ -11282,7 +11287,12 @@
11282
11287
  {
11283
11288
  "path": "tests/maintenance_goal.mjs",
11284
11289
  "mode": "0000644",
11285
- "sha256": "645fe3e9093abcb1425a59602d6a92a6d90669775aa3503ae435a54c79eca5c1"
11290
+ "sha256": "aa0d19a1d9c2f5ae9e89a77ef8e5c485f0f759e6a36dab988c8c49f0f2b59d27"
11291
+ },
11292
+ {
11293
+ "path": "tests/maintenance_goal_flag.mjs",
11294
+ "mode": "0000644",
11295
+ "sha256": "37356bcd78ff92537a5512b60f2274d573b6c90bb30f5deca006a14d4841fc95"
11286
11296
  },
11287
11297
  {
11288
11298
  "path": "tests/manage_manifest_shared_read.mjs",
@@ -12682,7 +12692,7 @@
12682
12692
  {
12683
12693
  "path": "tests/version_close_route.mjs",
12684
12694
  "mode": "0000644",
12685
- "sha256": "11c77cc535ecd86bc50ea962835b74672ff3a0d8093d8ce3007586a5873b4201"
12695
+ "sha256": "9a5b8f753c4b898b447b257d64c0034a83fe257c23208ce714e2e469fcaf735d"
12686
12696
  },
12687
12697
  {
12688
12698
  "path": "tests/version_literals.mjs",
@@ -1639,5 +1639,7 @@ 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)
1642
1644
  ---------------------------------------------------------------------------
1643
1645
  ```
@@ -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;
@@ -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,87 @@ 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
+
176
254
  // listVersionAdmissions (BV1.R07, task 1003594) — every goal admitted into ONE
177
255
  // version, newest first. ADR 0250 §3: "the overrides are counted and shown on the
178
256
  // version" — a visible counter is what keeps the exception honest, because an
@@ -1092,6 +1170,7 @@ async function listPendingRequestsForGoal(goalId, deps = {}) {
1092
1170
  module.exports = {
1093
1171
  acceptMembershipRequestAndAddMember,
1094
1172
  ensureMaintenanceGoal,
1173
+ carryBugsForward,
1095
1174
  achieveGoalIfComplete,
1096
1175
  archiveGoalWithDispositions,
1097
1176
  addGoalMember,
@@ -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,
@@ -225,6 +226,7 @@ module.exports = {
225
226
  currentBuildingVersionId,
226
227
  createGoal,
227
228
  ensureMaintenanceGoal,
229
+ carryBugsForward,
228
230
  getGoal,
229
231
  getGoalAdmission,
230
232
  listVersionAdmissions,
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.596",
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.596",
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.596",
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.596'; // 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
@@ -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');