@bongos/core 1.19.591 → 1.19.592

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.591",
6
- "core_contract": "1.19.591",
7
- "source_commit": "1a5d5f0eb546f0430b57a3ca5446be6ebd135fe9",
5
+ "core_version": "1.19.592",
6
+ "core_contract": "1.19.592",
7
+ "source_commit": "0a1abcd19a9b10e8a397d736bca30bf3cde53e12",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-08T02:46:21.428Z",
9
+ "built_at": "2026-09-08T02:51:06.758Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 457,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 2063,
14
+ "functional_verbatim": 2065,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2544,
20
- "tree_sha256": "8c944ccd379b18b96ad8d347ecc09ae1d5a3d163d40ca3c446e9bce07f86a002",
19
+ "file_count": 2546,
20
+ "tree_sha256": "25f1a2e82d0354d67b93d0215ffb20b0afae0240e24c02eb2683ac075c62e755",
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": "32f8765fbbc0999a25b5abfc0f58a5dc5980db09c03ff4ee127b6774387c865a"
2730
+ "sha256": "2b198dcf7147b6d736ec28fe28fb7b4c97727df4573dcb3017fd00e8efc756a8"
2731
2731
  },
2732
2732
  {
2733
2733
  "path": "docs/modules-contract.md",
@@ -3729,6 +3729,11 @@
3729
3729
  "mode": "0000644",
3730
3730
  "sha256": "5ed2ff8117e6650de621a1576062b27f2d6e84faf8860f9cb2aed45323a85aa6"
3731
3731
  },
3732
+ {
3733
+ "path": "migrations/core_233_versions_one_planning_idx.sql",
3734
+ "mode": "0000644",
3735
+ "sha256": "ca03e583bc418f600c1b1ba464bcaf4e380916033ec06cd68990cf153184d883"
3736
+ },
3732
3737
  {
3733
3738
  "path": "modules/agents/lib/validate.js",
3734
3739
  "mode": "0000644",
@@ -5792,12 +5797,12 @@
5792
5797
  {
5793
5798
  "path": "modules/lifecycle/routes/version-route-authz.js",
5794
5799
  "mode": "0000644",
5795
- "sha256": "5db4f94ca52f634f41cdb489572e43eb6785744220d2565a716cff47bfa72b30"
5800
+ "sha256": "a978720f7e0fa63626978c507e4a6a001faeab95989bc0ceb3cedd02f36b40d3"
5796
5801
  },
5797
5802
  {
5798
5803
  "path": "modules/lifecycle/routes/versions.js",
5799
5804
  "mode": "0000644",
5800
- "sha256": "5efe819220f9128844793a0c068babcd402860b434aab322fd9a99f8d2d26167"
5805
+ "sha256": "432dc7d632e2f528bc885348bd878ea19dc94d1395d5e6f51b39ebf39c5f91ca"
5801
5806
  },
5802
5807
  {
5803
5808
  "path": "modules/lifecycle/routes/visuals.js",
@@ -7602,12 +7607,12 @@
7602
7607
  {
7603
7608
  "path": "package-lock.json",
7604
7609
  "mode": "0000644",
7605
- "sha256": "b89e31e9693695643d5effcfe1386164ec4b8759e50fec6d86fe4edf19f09cb6"
7610
+ "sha256": "ef993eb06f7e089d4a9cfed05d07ec4bc19aa542517543b2bff2d8d2259242f5"
7606
7611
  },
7607
7612
  {
7608
7613
  "path": "package.json",
7609
7614
  "mode": "0000644",
7610
- "sha256": "09428614577361d939096da4a4a91a0f742a0504d97168cdea6d169ccb8931be"
7615
+ "sha256": "a8f9e814cd551d7a4ec38257da9398e9f93b361f1a27e7837c94a2cb794f693b"
7611
7616
  },
7612
7617
  {
7613
7618
  "path": "public-docs/index.html",
@@ -9322,7 +9327,7 @@
9322
9327
  {
9323
9328
  "path": "src/module-api.js",
9324
9329
  "mode": "0000644",
9325
- "sha256": "5c9d16a923a0cb30e18960d8714e1e8454940d1c4d6cef4d22fa8096a98237ab"
9330
+ "sha256": "f089d18d9173dcae6228221928c87d6d0142ca1cfdd7b0d8743ad87d67e8054d"
9326
9331
  },
9327
9332
  {
9328
9333
  "path": "src/module-loader/catalog.js",
@@ -11517,7 +11522,7 @@
11517
11522
  {
11518
11523
  "path": "tests/one_planning_version.mjs",
11519
11524
  "mode": "0000644",
11520
- "sha256": "1f8c6924dac2c922a1f88c22693b4386ec16d5f0ae346609b3d1dc0003308a70"
11525
+ "sha256": "347d8856d88246e08abcdfcaf744b491821f95f0daaab0291c4f9bb2a427ed6b"
11521
11526
  },
11522
11527
  {
11523
11528
  "path": "tests/origin_containment.mjs",
@@ -12654,6 +12659,11 @@
12654
12659
  "mode": "0000644",
12655
12660
  "sha256": "808a5fb2e14fb03ded6ed5ea0b3c0a0a7f53bfb885e853acb225f7b439a06ac3"
12656
12661
  },
12662
+ {
12663
+ "path": "tests/version_build_slot.mjs",
12664
+ "mode": "0000644",
12665
+ "sha256": "795946106f5e562a55f0ad04f3226884ee7815a86f8c20b94220e8069e192c41"
12666
+ },
12657
12667
  {
12658
12668
  "path": "tests/version_close.mjs",
12659
12669
  "mode": "0000644",
@@ -1631,5 +1631,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
1631
1631
  landed since 1.19.589 with no explicit bump. run 34180363432. (task 1002620)
1632
1632
  1.19.591 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1633
1633
  landed since 1.19.590 with no explicit bump. run 34181177013. (task 1002620)
1634
+ 1.19.592 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1635
+ landed since 1.19.591 with no explicit bump. run 34181434332. (task 1002620)
1634
1636
  ---------------------------------------------------------------------------
1635
1637
  ```
@@ -0,0 +1,47 @@
1
+ -- core_233_versions_one_planning_idx.sql — exactly one version may sit in
2
+ -- `planning` (BV1.R19, task 1003606, goal 1000086, ADR 0250 D5, ADR 0263 §9).
3
+ --
4
+ -- WHAT THIS CLOSES. R04 (task 1003592) shipped the one-planning-version rule as a
5
+ -- check-then-insert in `routes/versions.js`, and documented IN THE ROUTE that no
6
+ -- database constraint backs it: two concurrent POST /versions with status=planning
7
+ -- could both pass, because unlike the id collision above it, nothing catches this
8
+ -- one. That comment names this task as the place to fix it. This index is that fix
9
+ -- — the race now resolves the way the id collision already does, with a 23505 the
10
+ -- route translates into the same clean named 409.
11
+ --
12
+ -- WHY ONLY THE PLANNING INDEX, WHEN ADR 0263 §9 ASKS FOR TWO.
13
+ -- The ADR is right that both rules have the same shape and should be backed the
14
+ -- same way. It could not see that the live database VIOLATES the building rule
15
+ -- today: BONGOS-V1 and CB-V1 are both `building`, which is the exact violation
16
+ -- R27 (task 1003614) exists to resolve, and which R19's own brief acknowledges
17
+ -- ("R27 resolves the existing violation").
18
+ --
19
+ -- A migration is applied ONCE and tracked by filename stem (scripts/migrate.sh),
20
+ -- so the two ways to write the building index here are both wrong:
21
+ -- * unconditionally — CREATE UNIQUE INDEX raises on the duplicate rows, the
22
+ -- migration fails, and every deploy of the live instance wedges behind a data
23
+ -- cutover that has not happened yet;
24
+ -- * conditionally, skipping with a NOTICE — it lands "applied", never runs
25
+ -- again, and the constraint silently never arms. That is worse than not
26
+ -- shipping it, because the route comment would then point at a guard that
27
+ -- does not exist.
28
+ -- So the building index ships with R27, in the same migration as the data fix
29
+ -- that makes it legal. Constraint and cutover land together or neither does.
30
+ -- R19's own deliverable — "the server refuses a second building version; a named
31
+ -- 409; a test covers it" — is the ROUTE rule, which lands complete here.
32
+ --
33
+ -- ON "per project": this instance's `versions` table has no project column
34
+ -- (migration 003) — a Bongos database IS one project — so "one version plans per
35
+ -- project" and "one version plans in this table" are the same statement. If a
36
+ -- project column ever lands, this becomes a partial index on (project_id).
37
+ --
38
+ -- CORE, not modules/lifecycle/migrations/: `versions` is a CORE table (migration
39
+ -- 003), and a module migration may only touch its own <key>_-prefixed objects —
40
+ -- fitness.js checkModuleMigrationsAdditive hard-fails an `ALTER TABLE versions`
41
+ -- from a module (ADR 0083 Decision #5).
42
+ --
43
+ -- Legal on the live data as written: zero versions are `planning` today.
44
+
45
+ CREATE UNIQUE INDEX IF NOT EXISTS versions_one_planning_idx
46
+ ON versions ((status))
47
+ WHERE status = 'planning';
@@ -53,4 +53,44 @@ function authorizeVersionCreate({ status, planning = [] }) {
53
53
  };
54
54
  }
55
55
 
56
- module.exports = { authorizeVersionCreate };
56
+ // authorizeVersionBuild may a version ENTER `building` in this state?
57
+ // (BV1.R19, task 1003606, goal 1000086, ADR 0250 D5, ADR 0263 §9.)
58
+ //
59
+ // THE RULE: exactly one version builds at a time. This is the invariant every
60
+ // other rule in this goal stands on, and the one the live instance breaks today:
61
+ // BONGOS-V1 and CB-V1 are both `building`, which is why "the current version" has
62
+ // no meaning here. `currentBuildingVersionId` resolves that ambiguity with an
63
+ // `ORDER BY started_at DESC ... LIMIT 1` — it picks one and says nothing — so
64
+ // R03's gate ("is this version still in planning?") silently has two answers
65
+ // depending on which row won. Task 1003614 (R27) resolves the existing violation;
66
+ // this stops a third from ever being created.
67
+ //
68
+ // THE TWIN OF authorizeVersionCreate, and deliberately a SEPARATE function rather
69
+ // than a second branch inside it. The two guard different slots, and this one has
70
+ // a second caller coming: the close flow promotes the planning version to
71
+ // `building` in its own transaction (task 1003607, R20), where there is no
72
+ // caller-supplied `status` to branch on. One function taking a status would force
73
+ // that path to pass a value it is not setting.
74
+ //
75
+ // `building` is the versions already in that state (id + name), read by the
76
+ // caller. Passing the rows rather than a boolean lets the refusal NAME the
77
+ // version holding the slot — the caller's next move is to close it or scope onto
78
+ // it, and neither is answerable from a bare "no".
79
+ //
80
+ // Returns { ok: true } or { ok: false, status, body } — the exact HTTP status and
81
+ // JSON the route sends.
82
+ function authorizeVersionBuild({ building = [] }) {
83
+ const existing = Array.isArray(building) ? building.filter(Boolean) : [];
84
+ if (existing.length === 0) return { ok: true };
85
+ return {
86
+ ok: false,
87
+ status: 409,
88
+ body: {
89
+ error: 'building_version_exists',
90
+ message: `Version '${existing[0].id}' is already building — close it first, or scope this work onto it. One version builds at a time.`,
91
+ building: existing,
92
+ },
93
+ };
94
+ }
95
+
96
+ module.exports = { authorizeVersionCreate, authorizeVersionBuild };
@@ -23,7 +23,7 @@ const auth = api;
23
23
  const db = require('../db');
24
24
  const versionClose = require('../version-close');
25
25
  const doneWhen = require('../done-when');
26
- const { authorizeVersionCreate } = require('./version-route-authz.js');
26
+ const { authorizeVersionCreate, authorizeVersionBuild } = require('./version-route-authz.js');
27
27
  const { asyncHandler, validateOrRespond } = api;
28
28
 
29
29
  // versions.status CHECK (migration 003). A new version starts 'planning' or
@@ -128,15 +128,11 @@ module.exports = function buildVersionsRouter() {
128
128
  // sibling of R03's authorizeGoalCreate — the route gathers the facts and
129
129
  // renders the verdict, it does not carry the rule.
130
130
  //
131
- // RACE, ACCEPTED AND UNMITIGATED: this is check-then-insert, and no database
132
- // constraint backs it two concurrent POST /versions with status=planning
133
- // could both pass. Unlike the id collision above, which Postgres catches on
134
- // the primary key, nothing catches this one. The real fix is a partial unique
135
- // index (`ON versions (status) WHERE status = 'planning'`), which is a
136
- // migration this task did not reserve a number for; task 1003606 (R19) adds
137
- // the second-BUILDING-version rule of the same shape and is the natural place
138
- // to add the index for both. Until then the exposure is bounded by the route
139
- // being Archon-gated and used a few times a year — small, not zero.
131
+ // THE RACE IS CLOSED (BV1.R19, task 1003606, migration core_233): this is
132
+ // still check-then-insert, but `versions_one_planning_idx` now backs it, so a
133
+ // race-loser gets a 23505 that the catch below translates into this same
134
+ // refusal. R04 shipped this check documenting that nothing caught it; that is
135
+ // no longer true for the planning slot.
140
136
  const versionDecision = authorizeVersionCreate({
141
137
  status,
142
138
  planning: status === 'planning' ? await db.versionsWithStatus('planning') : [],
@@ -144,6 +140,27 @@ module.exports = function buildVersionsRouter() {
144
140
  if (!versionDecision.ok) {
145
141
  return res.status(versionDecision.status).json(versionDecision.body);
146
142
  }
143
+
144
+ // BV1.R19 (task 1003606, goal 1000086, ADR 0250 D5, ADR 0263 §9): the other
145
+ // half of the same rule. Exactly one version builds at a time — with two,
146
+ // "the current version" has no meaning and R03's goal-set gate has two
147
+ // answers depending on which row `currentBuildingVersionId`'s LIMIT 1 picked.
148
+ //
149
+ // NOT BACKED BY AN INDEX YET, and that is a data problem rather than an
150
+ // oversight: BONGOS-V1 and CB-V1 are both `building` on the live instance
151
+ // today, so `versions_one_building_idx` cannot be created until task 1003614
152
+ // (R27) folds them. core_233's header records why shipping it early — either
153
+ // unconditionally, which wedges the deploy, or skipped-with-a-NOTICE, which
154
+ // marks the migration applied and never arms — is worse than shipping it with
155
+ // the cutover. Until then this check-then-insert carries the rule alone, with
156
+ // the same bounded exposure R04 accepted: an Archon-gated route used a few
157
+ // times a year.
158
+ const buildDecision = authorizeVersionBuild({
159
+ building: status === 'building' ? await db.versionsWithStatus('building') : [],
160
+ });
161
+ if (!buildDecision.ok) {
162
+ return res.status(buildDecision.status).json(buildDecision.body);
163
+ }
147
164
  // Normalize criteria: each { criterion_id, criterion_md } (+ optional sort_order).
148
165
  const rawCriteria = Array.isArray(body.criteria) ? body.criteria : [];
149
166
  const criteria = [];
@@ -176,6 +193,31 @@ module.exports = function buildVersionsRouter() {
176
193
  // returns, instead of letting asyncHandler surface a 500 (hacker dispatch,
177
194
  // task 1204).
178
195
  if (err && err.code === '23505') {
196
+ // TWO unique constraints can raise here now, and they mean different
197
+ // things (BV1.R19, migration core_233). `versions_one_planning_idx` is
198
+ // the race-loser of the one-planning-version rule — the pre-check above
199
+ // passed because the winner had not committed yet — and must render as
200
+ // that rule's refusal, not as "this id is taken", which would send the
201
+ // caller off to rename a version that was never the problem. Matched on
202
+ // the constraint name rather than the message text, which is localised.
203
+ if (String(err.constraint || '') === 'versions_one_planning_idx') {
204
+ // Re-read rather than reuse the pre-check's rows: the winner has
205
+ // committed by now, so this read is what names it.
206
+ //
207
+ // The refusal is still rendered BY THE GATE, not written here — this
208
+ // route must never carry a second copy of the rule's wording, which is
209
+ // what tests/one_planning_version.mjs pins. The index only raises when
210
+ // a planning row exists, so the re-read is non-empty in every real
211
+ // case; the fallback row covers the one interleaving where the winner
212
+ // was deleted between the raise and this read, and exists solely so the
213
+ // gate cannot be handed an empty list and return `ok` with no body.
214
+ const planningNow = await db.versionsWithStatus('planning');
215
+ const raced = authorizeVersionCreate({
216
+ status: 'planning',
217
+ planning: planningNow.length ? planningNow : [{ id: '(a concurrent create)', name: '' }],
218
+ });
219
+ return res.status(raced.status).json(raced.body);
220
+ }
179
221
  return res.fail('version_exists', { status: 409, message: `Version '${id}' already exists.` });
180
222
  }
181
223
  throw err;
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.591",
3
+ "version": "1.19.592",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.591",
9
+ "version": "1.19.592",
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.591",
3
+ "version": "1.19.592",
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.591'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
58
+ const CORE_VERSION = '1.19.592'; // 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');
@@ -141,14 +141,31 @@ test('the default status is planning, so an unqualified create hits the gate', (
141
141
  assert.match(ROUTE, /const status = body\.status \|\| 'planning';/);
142
142
  });
143
143
 
144
- test('the accepted race is described as unmitigated, because it is', () => {
145
- // An earlier draft of this comment said the race was "left to a partial unique
146
- // index" which reads as though one exists. It does not, and a future reader
147
- // taking that at face value would believe two concurrent creates cannot both
148
- // slip through. Pinned so the honest wording cannot quietly regress.
149
- assert.match(ROUTE, /RACE, ACCEPTED AND UNMITIGATED/);
150
- assert.match(ROUTE, /no database\s*\n?\s*\/\/ constraint backs it/,
151
- 'the comment must say plainly that nothing catches it');
144
+ test('the planning race is now BACKED, and the comment says so honestly', () => {
145
+ // WHAT THIS TEST USED TO ASSERT, AND WHY IT FLIPPED. Until BV1.R19 (task
146
+ // 1003606) this pinned the words "RACE, ACCEPTED AND UNMITIGATED", because an
147
+ // earlier draft of the comment claimed the race was "left to a partial unique
148
+ // index" when no such index existed a future reader taking that at face
149
+ // value would have believed two concurrent creates could not both slip
150
+ // through. R19 shipped the index (migration core_233), so the old wording is
151
+ // now the dishonest one. The TEST'S PURPOSE is unchanged: the comment must
152
+ // describe the mitigation that actually exists, no more and no less.
153
+ assert.match(ROUTE, /THE RACE IS CLOSED/);
154
+ assert.match(ROUTE, /versions_one_planning_idx/,
155
+ 'the comment must name the index that backs it, so the claim is checkable');
156
+ assert.equal(/RACE, ACCEPTED AND UNMITIGATED/.test(ROUTE), false,
157
+ 'the planning race is mitigated as of R19 — the old wording would now under-claim');
158
+ });
159
+
160
+ test('the BUILDING half is still unmitigated, and the comment does not pretend otherwise', () => {
161
+ // The other half of the same honesty rule, and the one that matters now. R19
162
+ // could only ship ONE of ADR 0263 §9's two indexes: the live instance has two
163
+ // `building` versions, so `versions_one_building_idx` cannot be created until
164
+ // task 1003614 (R27) folds them. The building slot is therefore guarded by a
165
+ // check-then-insert with nothing behind it — exactly the state the planning
166
+ // slot was in before — and a reader who assumed symmetry would be wrong.
167
+ assert.match(ROUTE, /NOT BACKED BY AN INDEX YET/);
168
+ assert.match(ROUTE, /1003614/, 'the comment must name the task that makes the index legal');
152
169
  });
153
170
 
154
171
  // ---- the rule is one half of a pair ----------------------------------------
@@ -0,0 +1,157 @@
1
+ // tests/version_build_slot.mjs — exactly one version builds at a time
2
+ // (BV1.R19, task 1003606, goal 1000086, ADR 0250 D5, ADR 0263 §9).
3
+ //
4
+ // THE INVARIANT EVERY OTHER RULE IN THIS GOAL STANDS ON. R03 refuses a goal on a
5
+ // version that is not `planning`, which presumes "the version that is building"
6
+ // names ONE row. With two, it does not: `currentBuildingVersionId` resolves the
7
+ // ambiguity with `ORDER BY started_at DESC ... LIMIT 1`, so it picks one and says
8
+ // nothing, and R03's gate silently has two answers depending on which row won.
9
+ // The live instance is in exactly that state today (BONGOS-V1 and CB-V1 both
10
+ // `building`) — task 1003614 (R27) resolves it; this file pins that a third can
11
+ // never be created.
12
+ //
13
+ // WHY THE PREDICATE IS TESTED, NOT THE ROUTE. authorizeVersionBuild is pure — no
14
+ // express, no DB, no pool — so its whole truth table runs here, including the
15
+ // shapes a live-Postgres test would never bother to set up: a null in the rows, a
16
+ // non-array, an absent argument. The ROUTE's job is only to gather the facts and
17
+ // render the verdict verbatim, and the source assertions at the bottom pin that
18
+ // it does exactly that and nothing else. Inlining the rule in the handler would
19
+ // leave it testable only by string-matching the source, which passes just as
20
+ // happily on a wrong branch.
21
+ //
22
+ // Run: node --test tests/version_build_slot.mjs
23
+
24
+ import assert from 'node:assert/strict';
25
+ import { test } from 'node:test';
26
+ import { readFileSync } from 'node:fs';
27
+ import { createRequire } from 'node:module';
28
+
29
+ process.env.NODE_ENV = 'test';
30
+ const require = createRequire(import.meta.url);
31
+ const { authorizeVersionBuild, authorizeVersionCreate } =
32
+ require('../modules/lifecycle/routes/version-route-authz.js');
33
+
34
+ const src = (rel) => readFileSync(new URL('../' + rel, import.meta.url), 'utf8');
35
+ const ROUTES = src('modules/lifecycle/routes/versions.js');
36
+ const MIGRATION = src('migrations/core_233_versions_one_planning_idx.sql');
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // The rule
40
+ // ---------------------------------------------------------------------------
41
+
42
+ test('an empty building slot allows the version', () => {
43
+ assert.deepEqual(authorizeVersionBuild({ building: [] }), { ok: true });
44
+ });
45
+
46
+ test('no argument at all is allowed — the default is "nothing is building"', () => {
47
+ assert.deepEqual(authorizeVersionBuild({}), { ok: true });
48
+ });
49
+
50
+ test('one building version refuses, with a named 409', () => {
51
+ const v = authorizeVersionBuild({ building: [{ id: 'BONGOS-V1', name: 'Cloud Bongos — platform (V1)' }] });
52
+ assert.equal(v.ok, false);
53
+ assert.equal(v.status, 409);
54
+ assert.equal(v.body.error, 'building_version_exists');
55
+ });
56
+
57
+ test('the refusal NAMES the version holding the slot, in the message and the payload', () => {
58
+ const v = authorizeVersionBuild({ building: [{ id: 'BONGOS-V1', name: 'platform' }] });
59
+ // The whole reason the rows are passed instead of a boolean: a caller told only
60
+ // "no" has to go look up which version to close, and a caller who looks it up
61
+ // is one step from cutting a version to get around the gate anyway.
62
+ assert.match(v.body.message, /BONGOS-V1/);
63
+ assert.deepEqual(v.body.building, [{ id: 'BONGOS-V1', name: 'platform' }]);
64
+ });
65
+
66
+ test('the live two-building-version violation refuses, and names the first', () => {
67
+ const v = authorizeVersionBuild({
68
+ building: [{ id: 'BONGOS-V1', name: 'platform' }, { id: 'CB-V1', name: 'dogfood' }],
69
+ });
70
+ assert.equal(v.ok, false);
71
+ assert.equal(v.body.building.length, 2);
72
+ assert.match(v.body.message, /BONGOS-V1/);
73
+ });
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Shapes a live-Postgres test would not set up
77
+ // ---------------------------------------------------------------------------
78
+
79
+ test('nulls in the rows do not count as a version', () => {
80
+ assert.deepEqual(authorizeVersionBuild({ building: [null, undefined] }), { ok: true });
81
+ });
82
+
83
+ test('a non-array building is treated as empty, not as truthy', () => {
84
+ // A caller that hands over a bare count, or a bad DB read that returns an
85
+ // object, must not be able to LOOSEN the rule by accident — but neither should
86
+ // it hard-refuse a legitimate create. Both degrade to "nothing is building",
87
+ // which is the state the pre-check will then re-derive from the real table.
88
+ for (const bad of [1, 'yes', {}, null, undefined]) {
89
+ assert.deepEqual(authorizeVersionBuild({ building: bad }), { ok: true }, `building=${JSON.stringify(bad)}`);
90
+ }
91
+ });
92
+
93
+ test('the two slot rules are independent — building says nothing about planning', () => {
94
+ // Guards against a future refactor collapsing them into one status-branching
95
+ // function: the close flow (task 1003607, R20) promotes a version to `building`
96
+ // with no caller-supplied status to branch on.
97
+ assert.deepEqual(authorizeVersionBuild({ building: [] }), { ok: true });
98
+ assert.equal(authorizeVersionCreate({ status: 'planning', planning: [{ id: 'X' }] }).ok, false);
99
+ assert.equal(authorizeVersionCreate({ status: 'building', planning: [{ id: 'X' }] }).ok, true);
100
+ });
101
+
102
+ // ---------------------------------------------------------------------------
103
+ // The wiring: the route gathers the facts and renders the verdict
104
+ // ---------------------------------------------------------------------------
105
+
106
+ test('POST /versions consults the building slot, and only when status is building', () => {
107
+ assert.match(ROUTES, /authorizeVersionBuild\(\{/);
108
+ // The read is CONDITIONAL on the requested status, mirroring the planning one:
109
+ // creating a `shipped` or `frozen` row is archival bookkeeping and must not pay
110
+ // for a query, nor be refused by a rule about a slot it is not entering.
111
+ assert.match(ROUTES, /status === 'building' \? await db\.versionsWithStatus\('building'\) : \[\]/);
112
+ });
113
+
114
+ test('the route renders the refusal verbatim — it does not restate the rule', () => {
115
+ assert.match(ROUTES, /if \(!buildDecision\.ok\) \{\s*return res\.status\(buildDecision\.status\)\.json\(buildDecision\.body\);/);
116
+ // No second copy of the message anywhere in the route.
117
+ assert.equal(ROUTES.includes('One version builds at a time'), false);
118
+ });
119
+
120
+ test('a 23505 on the planning index renders as the planning rule, not "id taken"', () => {
121
+ // The race-loser's pre-check passed because the winner had not committed. If
122
+ // this fell through to `version_exists` the caller would be told to rename a
123
+ // version that was never the problem, and would rename and retry forever.
124
+ assert.match(ROUTES, /err\.constraint\s*\|\|\s*''\)\s*===\s*'versions_one_planning_idx'/);
125
+ // And it renders that refusal BY RE-RUNNING THE GATE, never by writing the
126
+ // wording a second time — tests/one_planning_version.mjs pins the absence of
127
+ // the literal `planning_version_exists` in this handler, and an earlier draft
128
+ // of this very test asserted its presence, which is how the two suites caught
129
+ // each other. The gate owns the words; the route owns only the plumbing.
130
+ assert.match(ROUTES, /const raced = authorizeVersionCreate\(\{/);
131
+ assert.match(ROUTES, /return res\.status\(raced\.status\)\.json\(raced\.body\);/);
132
+ });
133
+
134
+ // ---------------------------------------------------------------------------
135
+ // The migration
136
+ // ---------------------------------------------------------------------------
137
+
138
+ test('core_233 creates the planning index and NOT the building one', () => {
139
+ assert.match(MIGRATION, /CREATE UNIQUE INDEX IF NOT EXISTS versions_one_planning_idx/);
140
+ assert.match(MIGRATION, /WHERE status = 'planning'/);
141
+ // The deliberate omission ADR 0263 §9 asked for and the live data forbids. If a
142
+ // later edit adds it here, this test is the reminder that the migration will
143
+ // fail on any instance with two building versions — and that a skipped-with-a-
144
+ // NOTICE variant marks itself applied and never arms.
145
+ assert.equal(MIGRATION.includes('versions_one_building_idx'), false,
146
+ 'the building index ships with R27 (task 1003614), in the migration that makes it legal');
147
+ });
148
+
149
+ test('the migration records WHY the building index is not here', () => {
150
+ // The reasoning is the artifact: without it the next reader files this as an
151
+ // oversight and "fixes" it into a wedged deploy.
152
+ assert.match(MIGRATION, /1003614/);
153
+ });
154
+
155
+ test('the route points at the same reason', () => {
156
+ assert.match(ROUTES, /1003614/);
157
+ });