@bongos/core 1.19.604 → 1.19.606

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.604",
6
- "core_contract": "1.19.604",
7
- "source_commit": "a0b8d2452aa627d45214f54b63305c41150dfffd",
5
+ "core_version": "1.19.606",
6
+ "core_contract": "1.19.606",
7
+ "source_commit": "01995576a9a5691914eaf92601a2babcb7f80436",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-08T23:04:26.027Z",
9
+ "built_at": "2026-09-08T23:43:27.112Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 457,
12
+ "docs_redacted": 458,
13
13
  "agent_docs_stubbed": 24,
14
14
  "functional_verbatim": 2077,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2558,
20
- "tree_sha256": "e207bd28e7b6dfedc243c12724dc4419850b2fd1b573af52c057014c148408ee",
19
+ "file_count": 2559,
20
+ "tree_sha256": "b6468d5e9ecfea1fc1343a754b406f526858051bbb096a22aaa58915cf7673ab",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -1839,10 +1839,15 @@
1839
1839
  "mode": "0000644",
1840
1840
  "sha256": "03f1cf19a181d6726066ba6fb72156add0cb8ede004ebc7ddf8ac3859a335596"
1841
1841
  },
1842
+ {
1843
+ "path": "docs/adr/0265-work-with-no-goal-and-the-order-the-cut-needs.md",
1844
+ "mode": "0000644",
1845
+ "sha256": "c9cd485b1ffce55fea15fd5b544a01e5753309e291c88bd223d33e6c11c4ebec"
1846
+ },
1842
1847
  {
1843
1848
  "path": "docs/adr/README.md",
1844
1849
  "mode": "0000644",
1845
- "sha256": "b38a32c8557012f9e5e761c5e219901ad628af9b63d2cb190d32e783dbf9aaa9"
1850
+ "sha256": "c4bbd4c0ff9163a435564b56c4aac1d2c08e2b129d32b7f24779a60674096b47"
1846
1851
  },
1847
1852
  {
1848
1853
  "path": "docs/api-reference.md",
@@ -2727,7 +2732,7 @@
2727
2732
  {
2728
2733
  "path": "docs/module-api-changelog.md",
2729
2734
  "mode": "0000644",
2730
- "sha256": "e2b518f8b64d95ee83ea4821e25942fc0b37e6ac3c3ebf0c2351ffad064f7020"
2735
+ "sha256": "70528d71799dfa6f699267c439e32e65b2b922b768a6b7ff3b5b2f4d7aae8bd4"
2731
2736
  },
2732
2737
  {
2733
2738
  "path": "docs/modules-contract.md",
@@ -5567,7 +5572,7 @@
5567
5572
  {
5568
5573
  "path": "modules/lifecycle/db-versions.js",
5569
5574
  "mode": "0000644",
5570
- "sha256": "51b170cdd34a90c49070ec79723b5250dffbc8c61d5ed114d6a560bcc93cadb7"
5575
+ "sha256": "8d8f8a16443e9f193829d8ac127b522e92984bcafc9a48a27c7deea5da2900b7"
5571
5576
  },
5572
5577
  {
5573
5578
  "path": "modules/lifecycle/db.js",
@@ -5817,7 +5822,7 @@
5817
5822
  {
5818
5823
  "path": "modules/lifecycle/routes/versions.js",
5819
5824
  "mode": "0000644",
5820
- "sha256": "014de34389dbf26b4b089b677466c9c7cfd81f79b9e1714241be12f589086c0f"
5825
+ "sha256": "a8cdb97b220c2de738396986e8cd326218f688a0277b4c75784420cfbdd0d369"
5821
5826
  },
5822
5827
  {
5823
5828
  "path": "modules/lifecycle/routes/visuals.js",
@@ -7627,12 +7632,12 @@
7627
7632
  {
7628
7633
  "path": "package-lock.json",
7629
7634
  "mode": "0000644",
7630
- "sha256": "5641c682f6618871cdcb765ee962b6b7ce884bad1786e6ee4a24c8bf0a43f8c0"
7635
+ "sha256": "e1c65152d1b74b121478122824125a5b194f81391d291bded7b7f19a546cd072"
7631
7636
  },
7632
7637
  {
7633
7638
  "path": "package.json",
7634
7639
  "mode": "0000644",
7635
- "sha256": "a725aa33a88eadd39a01540bd819b5856c541d3d10f6386bcd8ee05b70497377"
7640
+ "sha256": "32c321ede296ec490893a1ca5e120cd82d19424fdb1073242921b6d1b7b1217c"
7636
7641
  },
7637
7642
  {
7638
7643
  "path": "public-docs/index.html",
@@ -9347,7 +9352,7 @@
9347
9352
  {
9348
9353
  "path": "src/module-api.js",
9349
9354
  "mode": "0000644",
9350
- "sha256": "ec2543ebf2e3bd98d8497238975cec5bccc13cbff7f8835b42bec4757ae078f9"
9355
+ "sha256": "887427fe5187c116f958ad5c23a0974c1a44fa00a02e43ad6ea5ef6b02f31888"
9351
9356
  },
9352
9357
  {
9353
9358
  "path": "src/module-loader/catalog.js",
@@ -12712,7 +12717,7 @@
12712
12717
  {
12713
12718
  "path": "tests/version_close_route.mjs",
12714
12719
  "mode": "0000644",
12715
- "sha256": "56c480792cf88946eb3e3663a23e0431e6d8df3cf8219fdf9b71283b772355bb"
12720
+ "sha256": "f972971a1f668a085c5f5b4c5ccba616e4b77a3f3308f9a48af33e43ecd62a8c"
12716
12721
  },
12717
12722
  {
12718
12723
  "path": "tests/version_literals.mjs",
@@ -0,0 +1,125 @@
1
+ # 0265 — Work with no goal survives a version close, and the order the ten-area cut actually needs
2
+
3
+ - **Status:** Accepted
4
+ - **Date:** 2026-09-08
5
+ - **Tasks:** [#1003713](https://cloudbongos.com/builders#/task/1003713) (this record + the fix). Found while re-basing the cutover plan in [#1003711](https://cloudbongos.com/builders#/task/1003711).
6
+ - **Goal:** [#1000086](https://cloudbongos.com/builders#/goal/1000086) — Strict versioning, criteria `sv-every-task-has-a-goal` and `sv-cutover-proven-live`.
7
+ - **Builds on:** [ADR 0250](<redacted>.md) (D3, D4, D5), [ADR 0263](<redacted>.md) (§5–§8), [ADR 0264](<redacted>.md) (the cut this order executes).
8
+
9
+ ## 1. The defect
10
+
11
+ **A version can close while still holding unfinished work, because the closing
12
+ invariant counts goals and not tasks.**
13
+
14
+ `closeVersion` applies each goal's disposition and then asserts, against
15
+ committed in-transaction state, that no non-maintenance goal on the version is
16
+ still `open`. That is the whole check. Every task write inside the close is
17
+ scoped `WHERE goal_id = $1` — the abandon branch and the roll-forward move both.
18
+
19
+ So a task belonging to **no** goal is reached by nothing: no disposition touches
20
+ it, the goal re-count cannot see it, and the version flips to `shipped` with the
21
+ task still stamped with it. It is then permanently non-terminal on a version that
22
+ is over, and invisible to every surface that reasons per goal.
23
+
24
+ [ADR 0250](<redacted>.md) D3 shut this door one tier down — R10 and R14 made a *goal* refuse to
25
+ close while holding unfinished tasks. The version tier never got the same
26
+ treatment, and D4 ("every task has a goal") was written as a property of the data
27
+ rather than as a gate anything enforced at the boundary.
28
+
29
+ ### It has already happened
30
+
31
+ Measured on the live instance 2026-09-08: **six non-terminal tasks sit on shipped
32
+ versions** — five on GDS-V3 under goal 1000002 and one on GDS-V4 under goal
33
+ 1000004. Those versions shipped and took the work with them.
34
+
35
+ The next batch was already queued. **Forty-seven tasks on BONGOS-V1 carry
36
+ `goal_id IS NULL`** (measured in task 1003711): R11 made `goal_id` required going
37
+ forward but never backfilled the rows that predated it, because every catch-all
38
+ lookup matches the exact title `<VERSION> — general` and BONGOS-V1's catch-all is
39
+ titled "Work with no goal yet". Closing BONGOS-V1 would have stranded all 47 by
40
+ exactly this path, and R30's live proof would have found it *after* the close,
41
+ with no way back.
42
+
43
+ ## 2. The decision
44
+
45
+ **A version refuses to close while any non-terminal task remains on it,** as a
46
+ second invariant beside the goal re-count, in the same transaction, against
47
+ committed state. It fails with `CLOSE_LEAVES_STRANDED_TASKS` carrying the count
48
+ and up to fifty ids, and the route renders that as a 409 naming them — the
49
+ caller's only move is to go and home those tasks, and making them run a query to
50
+ discover which ones is how forty-seven went unnoticed.
51
+
52
+ Two exemptions, both forced rather than chosen:
53
+
54
+ - **The maintenance goal's tasks.** `carryBugsForward` runs in the `onClosed`
55
+ hook, which fires *after* this check and after the flip ([ADR 0263](<redacted>.md) §8), so
56
+ maintenance work is legitimately still on the version at check time. Exempted
57
+ through `maintenanceGoalExemptSql` — the **same** predicate the goal count
58
+ uses, never a second copy. R18 replaced that function's body once already (a
59
+ title comparison became `is_maintenance`, closing a spoofing hole); a
60
+ hand-inlined copy here would have survived that edit and quietly re-opened it.
61
+ - **Smoke fixtures**, on the same `__smoke__` terms the abandon and roll-forward
62
+ branches already use. Three doors now decide what "unfinished" means and they
63
+ have to agree — a test fixture that wedges a production close is worse than the
64
+ bug it was pinning.
65
+
66
+ The probe runs **after** the goal re-count and **before** the flip. Order is part
67
+ of the decision: open goals are the bigger and more actionable problem, and
68
+ reporting stray tasks first would send a caller chasing rows the goal
69
+ dispositions were about to move anyway.
70
+
71
+ ## 3. The consequence: the cut needs roll-forward, *then* fold
72
+
73
+ Verifying the above surfaced two facts that together determine how
74
+ [ADR 0264](<redacted>.md)'s thirty-eight-goals-to-ten cut can be executed at all:
75
+
76
+ 1. **A task cannot cross a version boundary by hand.** `PATCH /tasks/:id { goal_id }`
77
+ refuses with 409 `goal_version_mismatch` — "a task can only join a goal in its
78
+ own version". The version → goal → task rollup is why: a cross-version link
79
+ drops the task out of its version's totals.
80
+ 2. **`roll_forward` creates, it does not merge.** It writes a *new* goal on the
81
+ planning version with the predecessor's prose, membership and scope, archives
82
+ the predecessor behind it with `succeeded_by_goal_id`, and moves the unfinished
83
+ tasks' `goal_id` **and** `version_id` together (task 1003710). There is no
84
+ disposition that folds a goal into an existing one.
85
+
86
+ So neither obvious order works. Closing BONGOS-V1 and rolling its goals forward
87
+ would mint roughly thirty-three successor goals on BONGOS-V2 — the same sprawl,
88
+ one version later. And homing the tasks into the ten area goals first is refused,
89
+ because the area goals live on BONGOS-V2 and the tasks live on BONGOS-V1.
90
+
91
+ **The order that works, with no new code and no Archon overrides:**
92
+
93
+ 1. **Give every homeless task a goal on its own version.** The 47 null-goal rows
94
+ and the two catch-alls' populations go to any real goal on the version they
95
+ are already on — bugs to that version's maintenance goal ([ADR 0262](<redacted>.md)), the rest
96
+ to whichever existing goal fits. This is the step the new invariant makes
97
+ mandatory rather than optional.
98
+ 2. **Close the building version, rolling every goal that still holds work
99
+ forward.** Tasks arrive on BONGOS-V2 carrying both `goal_id` and `version_id`.
100
+ 3. **Fold on BONGOS-V2 while it is still `planning`.** Now each task and its
101
+ destination area goal share a version, so `PATCH /tasks/:id { goal_id }` is
102
+ legal; move the work into the ten areas and archive the emptied successors
103
+ through R14's disposition flow.
104
+
105
+ Step 3 has to happen before BONGOS-V2 starts building, because D1 closes a
106
+ version's goal set the moment it does. That is not an awkward constraint — it is
107
+ the rule doing its job: the fold *is* scoping, and scoping belongs in `planning`.
108
+
109
+ ## 4. Alternatives rejected
110
+
111
+ - **Add a `fold_into` disposition to the close.** It would collapse steps 2 and 3
112
+ into one call, but it puts a scope decision — which area absorbs which goal —
113
+ inside a transaction that also ships a version, and it invents a second way to
114
+ move a task between goals beside the one that already exists. The two-step
115
+ costs one extra pass over the data and keeps folding where a human can see it.
116
+ - **Create the six new area goals on BONGOS-V1 using R07's `admit_reason`
117
+ override.** Legal, and it would let the whole fold happen before the close. But
118
+ it spends ten Archon admissions to work around a rule those admissions exist to
119
+ make expensive, and it grows the building version's goal set to forty-three on
120
+ the way to shrinking it. ADR 0250 §3 built the override counter precisely so
121
+ this shape would be visible; using it here would be reading the counter as a
122
+ budget.
123
+ - **Backfill the 47 with a migration instead of gating the close.** A backfill
124
+ fixes today's rows and nothing else; the gate is what stops the forty-eighth.
125
+ Both are wanted, but only one of them is a rule.
@@ -356,3 +356,4 @@ This keeps the decision history honest and traceable.
356
356
  | 0262 | [**A bug never lands in the inbox: the system names the home the reporter didn't** ([task 1003691](https://cloudbongos.com/builders#/task/1003691) · goal 1000086 — *Strict versioning*). `routing.js` returned early on a goal-less filing, so its own `kind='bug'` hard-land branch was UNREACHABLE without a goal — `kind` only ever chose a routed task's STATUS, never whether it routed — and a reported defect sat in `idea_inbox` awaiting the triage pass criterion C1 exists to remove. The premise that [ADR 0250](<redacted>.md) D4 and [ADR 0235](<redacted>.md) contradict each other is FALSE: 0235's exemption is an advisory silence in `goal-advisory.adviseGoal`, and its actual decision already requires a real `goal_id`. So nothing is amended; this is the explicit decision 0235 §6 said would be needed. **D1** a homeless bug gets the version's MAINTENANCE goal (R18's design), find-or-created under an advisory lock inside the route's own transaction — not the catch-all R11 is deleting, and adding no `allowCatchAll` caller. **D2** the gate is the VECTOR (`api`, `discord-bugs`), not the kind: `#ideas` passes no kind, so the classifier guesses one from keywords, and gating on kind alone would silently overturn [ADR 0234](<redacted>.md)'s owner-interview decision. Rejected: goal-less bug tasks (breaks D4 a day after it landed); rejecting the filing (Discord cannot retry, so the report is lost); the catch-all (repopulates the bucket being drained); building R18 whole first.](<redacted>.md) | work intake / idea routing / the version-goal invariant |
357
357
  | 0263 | [**How a version closes: auto, early, roll-forward, and the maintenance exemption** ([task 1003593](https://cloudbongos.com/builders#/task/1003593) · goal 1000086 — *Strict versioning*). [ADR 0250](<redacted>.md) D5 decided a version *can* close; this is the design pass that makes its five build tasks mechanical, so they do not each invent their own refusal codes and payload shapes and meet only in production. **The asymmetry that shrinks the feature:** auto-close fires when the last non-maintenance goal achieves — so by construction every non-maintenance goal is already closed and the set needing a disposition is EMPTY. All the machinery (the disposition map, the refusal that returns the goals, successor lineage) belongs to EARLY close alone; R16 is small and R12 carries the weight. **Auto-close hangs off BOTH achievement writers** (`db-goals.achieveGoalIfComplete` and the `done-when` cascade — the deliberate duplicate D2 already governs, a require cycle being the reason they are not one helper), on the caller `exec` so it sees the ship's uncommitted flip, and **may never fail a ship**: wrapped and swallowed like `closeCompletedWorkForShip`, idempotent under `WHERE status='building'`, so a failure leaves a version un-closed (self-healing on the next reconciler sweep) and never half-closed. **Early close** is the R10 archive two-step verbatim in spirit — refuse with `version_holds_open_goals` **returning the goals**, then proceed with a per-goal `roll_forward`/`abandon` map and a required `reason`; six named codes, because the callers are agents. **Two briefs are corrected by measurement:** the lineage column is `succeeded_by_goal_id` (migration 160, on the OLD row pointing forward) — not the `succeeds_goal_id` both 0250 and R17's brief name, so R17 needs NO migration; and R18's `is_maintenance` flag is load-bearing rather than cosmetic, because answering the close count with a `title NOT LIKE` would let a hand-titled goal silently exempt itself from the gate that decides when a VERSION closes. Promotion runs inside the close txn and BEFORE roll-forward (`ensureMaintenanceGoal` returns null for a non-`building` version, so a carry-over run first silently carries nothing); zero planning versions is not an error; R19's one migration should carry the `building` AND `planning` partial unique indexes so the promotion race resolves like a 23505 rather than a prayer. Rejected: auto-creating the successor version inside a close (the "fake hotfix version" escape hatch 0250 built the override counter to prevent), writing `limitations/<version>-shipped.md` from the route (file I/O in a ship transaction — it stays R23's CLI work), and a version reopen.](<redacted>.md) | version lifecycle / scope closure |
358
358
  | 0264 | [**The ten working areas: one goal per area, held until 5,000 builders** ([task 1003696](https://cloudbongos.com/builders#/task/1003696) · goal 1000086 — *Strict versioning*, criterion `sv-cutover-proven-live`). The owner and the full team ran a manual goal review on 2026-09-07 and cut **38 open goals down to 10**, one per working area, each with a named owner and a described 5,000-builder end state: project creation (Rini), account/community (Rini), human project management (Scott), core distribution (Lars/Will), module distribution & economy (Will), the four-role experience incl. agents (Masterqua/Rini), government (Will/Masterqua), the credit economy (Scott), platform analytics (Lars), security (everyone bar Nils and Scott). **Why the rules were not enough on their own:** [ADR 0250](<redacted>.md) D1 closes the goal set of a BUILDING version — which freezes 38 goals exactly as effectively as it would freeze 10. Enforcement cannot shrink a scope that is already too large, so the rules and the cut are two halves of one move; the horizon is a SCALE (5,000 builders), not a date, because that is the question that decides when this set stops being the right set. **The finding that reorders the goal:** none of the cut can be executed today. R10 ([task 1003597](https://cloudbongos.com/builders#/task/1003597)) made `POST /goals/:id/archive` refuse a goal holding unfinished tasks and demand a disposition — correct, and exactly D3 — but the vector that SUPPLIES a disposition is R14 ([task 1003601](https://cloudbongos.com/builders#/task/1003601)), unshipped. Every one of the 12 deletes and every fold goes through that door, so **R14 is the keystone of the whole cut**, not one feature among thirteen, and is built before the rest of the version-close chain rather than in rank order. It looks like a bug and is not: the refusal is the design, landed one task ahead of its own remedy. "Delete" means `archived`, never a row removal (`goals.status` is open|achieved|archived, migration 160; reversible via `/reopen`) — preserving the rows keeps shipped-task attribution and credit history a delete would strand. Three open goals were NOT covered by the review and are surfaced as open questions rather than guessed: 1000003 (the catch-all — already R25's target), 1000065 (assumed to be area 2's community half), and 1000088 (repo cleanup — maps to no area, needs an owner decision). Deliberately does not decide: the areas' priority order, criteria for the six goals R29 must create (inventing criteria for someone else's area is how a goal acquires scope its owner never agreed to), or any task rehoming (R25/R26).](<redacted>.md) | scope / goal set / owner decision |
359
+ | 0265 | [**Work with no goal survives a version close, and the order the ten-area cut actually needs** ([task 1003713](https://cloudbongos.com/builders#/task/1003713) · goal 1000086 — *Strict versioning*, criteria `sv-every-task-has-a-goal` + `sv-cutover-proven-live`). `closeVersion`'s post-apply invariant counts GOALS, and every task write inside the close is scoped `WHERE goal_id = $1` — so a task belonging to no goal is reached by no disposition, counted by no re-count, and ships stranded on a version that is over. Not hypothetical: six such tasks already sat on shipped GDS-V3/GDS-V4, and 47 rows on BONGOS-V1 carried a null `goal_id` (R11 required the column going forward but never backfilled, because every catch-all lookup matches the exact title `<VERSION> — general` and BONGOS-V1's is titled 'Work with no goal yet'). Decision: a second invariant in the same transaction refuses a close leaving ANY non-terminal task on the version, failing `CLOSE_LEAVES_STRANDED_TASKS` with the ids; exempting only the maintenance goal (its carry-forward runs in the later `onClosed` hook) — through the SAME `maintenanceGoalExemptSql` the goal count uses, never a second copy — and `__smoke__` fixtures. Consequence recorded here too: because `PATCH /tasks/:id {goal_id}` refuses a cross-version move (409 `goal_version_mismatch`) and `roll_forward` CREATES a successor rather than merging into one, [ADR 0264](<redacted>.md)'s cut must run home-then-close-then-**fold on the planning version**, where task and destination finally share a version. Rejected: a `fold_into` disposition (puts scope decisions inside a shipping transaction), creating the six new areas on the building version via R07 admissions (spends ten overrides to route around the rule they price), and a backfill instead of a gate (fixes today's rows, not the forty-eighth).](<redacted>.md) | lifecycle / version boundary |
@@ -1657,5 +1657,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1657
1657
  landed since 1.19.602 with no explicit bump. run 34191126290. (task 1002620)
1658
1658
  1.19.604 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1659
1659
  landed since 1.19.603 with no explicit bump. run 34288910914. (task 1002620)
1660
+ 1.19.605 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1661
+ landed since 1.19.604 with no explicit bump. run 34290892691. (task 1002620)
1662
+ 1.19.606 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1663
+ landed since 1.19.605 with no explicit bump. run 34291868670. (task 1002620)
1660
1664
  ---------------------------------------------------------------------------
1661
1665
  ```
@@ -14,6 +14,7 @@ const api = require('../../src/module-api');
14
14
  const { pool, withTx } = api;
15
15
  const doneWhen = require('./done-when.js');
16
16
  const { nonTerminalSql } = require('./task-status');
17
+ const { SMOKE_NOT_LIKE } = require('./db-shared.js');
17
18
 
18
19
  async function listVersions() {
19
20
  const { rows } = await pool.query(
@@ -465,6 +466,59 @@ async function closeVersion({ versionId, plan = [], reason, planningVersionId =
465
466
  });
466
467
  }
467
468
 
469
+ // THE SAME INVARIANT, ONE TIER DOWN (BV1.R32, task 1003713, ADR 0265).
470
+ //
471
+ // The check above counts GOALS, and every task write in this transaction is
472
+ // scoped `WHERE goal_id = $1` — the abandon branch and the roll-forward move
473
+ // both. So a task belonging to NO goal is touched by nothing and counted by
474
+ // nothing: no disposition reaches it, the goal re-count cannot see it, and
475
+ // the version ships with the task still stamped with it. It is then
476
+ // permanently non-terminal on a version that is over, and invisible to every
477
+ // surface that reasons per goal.
478
+ //
479
+ // NOT HYPOTHETICAL. Six such tasks were already on shipped versions when this
480
+ // was written (five on GDS-V3, one on GDS-V4), and 47 tasks on BONGOS-V1
481
+ // carried a null `goal_id` waiting for the same fate at its close. R10/R14
482
+ // shut this door for a GOAL holding unfinished tasks; the version tier never
483
+ // got the same treatment. This is that treatment.
484
+ //
485
+ // TWO EXEMPTIONS, both forced rather than chosen:
486
+ // The maintenance goal's work is legitimately still here. `carryBugsForward`
487
+ // runs in the `onClosed` hook, which fires AFTER this check and after the
488
+ // flip (R20, task 1003607), so maintenance tasks have not moved yet and
489
+ // must not trip the gate. Exempted through `maintenanceGoalExemptSql` — the
490
+ // SAME predicate the goal count uses, so the two halves cannot drift into
491
+ // disagreeing about which goal is exempt.
492
+ //
493
+ // Smoke fixtures, on the same terms the abandon and roll-forward branches
494
+ // already use. Three doors now decide what "unfinished" means and they have
495
+ // to agree; a fixture that blocks a close is a test artifact wedging prod.
496
+ //
497
+ // LEFT JOIN, not INNER: a null `goal_id` is the whole point, and an inner
498
+ // join would silently drop exactly the rows this exists to catch.
499
+ const { rows: stranded } = await client.query(
500
+ `SELECT t.id FROM tasks t
501
+ LEFT JOIN goals g ON g.id = t.goal_id
502
+ WHERE t.version_id = $1
503
+ AND ${nonTerminalSql('t.status')}
504
+ AND t.${SMOKE_NOT_LIKE}
505
+ AND (g.id IS NULL OR ${maintenanceGoalExemptSql('$1')})
506
+ ORDER BY t.id
507
+ LIMIT 51`,
508
+ [versionId]
509
+ );
510
+ if (stranded.length > 0) {
511
+ throw Object.assign(new Error('version still holds non-terminal tasks after dispositions'), {
512
+ code: 'CLOSE_LEAVES_STRANDED_TASKS',
513
+ remaining: stranded.length,
514
+ // Capped at 51 so the sample can say "more than 50" without counting the
515
+ // whole table; the caller's next move is to go and home them, and a
516
+ // sample plus a floor is enough to start.
517
+ truncated: stranded.length > 50,
518
+ task_ids: stranded.slice(0, 50).map((r) => String(r.id)),
519
+ });
520
+ }
521
+
468
522
  const { rows: closed } = await client.query(
469
523
  `UPDATE versions SET status = 'shipped', shipped_at = now()
470
524
  WHERE id = $1
@@ -350,6 +350,19 @@ module.exports = function buildVersionsRouter() {
350
350
  details: { remaining: err.remaining },
351
351
  });
352
352
  }
353
+ // BV1.R32 (task 1003713, ADR 0265): work with no goal. Dispositioning every
354
+ // goal is not the same as accounting for every task, and a task belonging to
355
+ // no goal is reached by no disposition. The ids ride the refusal because the
356
+ // caller's only move is to go and home them, and making them run a query to
357
+ // find out which ones is how 47 orphans went unnoticed on BONGOS-V1.
358
+ if (err && err.code === 'CLOSE_LEAVES_STRANDED_TASKS') {
359
+ const count = err.truncated ? `more than ${err.task_ids.length}` : String(err.remaining);
360
+ return res.fail('version_holds_stranded_tasks', {
361
+ status: 409,
362
+ message: `Version '${versionId}' would ship holding ${count} unfinished task(s) that belong to no goal on it — nothing was changed. Give each one a goal on this version (or abandon it), then close.`,
363
+ details: { remaining: err.remaining, truncated: err.truncated, task_ids: err.task_ids },
364
+ });
365
+ }
353
366
  throw err;
354
367
  }
355
368
  }, { errorCode: 'close_version_failed' }));
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.604",
3
+ "version": "1.19.606",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.604",
9
+ "version": "1.19.606",
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.604",
3
+ "version": "1.19.606",
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.604'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
58
+ const CORE_VERSION = '1.19.606'; // 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');
@@ -129,7 +129,7 @@ await test('roll_forward WITH a planning version proceeds', () => {
129
129
 
130
130
  // ---- the maintenance exemption ---------------------------------------------
131
131
 
132
- await test('the exemption has ONE definition, and the open-goals read is its only caller', () => {
132
+ await test('the exemption has ONE definition, and every gate calls it rather than re-spelling it', () => {
133
133
  // R18 (task 1003605, migration core_234) SWAPPED this body, exactly as R12
134
134
  // predicted here — the single definition is what made it a one-line change
135
135
  // instead of a hunt, and this assertion moved with it.
@@ -142,7 +142,12 @@ await test('the exemption has ONE definition, and the open-goals read is its onl
142
142
  'no title comparison may decide which goals hold a version open');
143
143
  const dbv = src('modules/lifecycle/db-versions.js');
144
144
  const uses = (dbv.match(/maintenanceGoalExemptSql\(/g) || []).length;
145
- assert.equal(uses, 3, 'declaration + the open-goals read + the post-apply re-count, and nothing else');
145
+ // Four since BV1.R32 (task 1003713): the stranded-task probe reuses the same
146
+ // predicate rather than re-spelling it. That is the POINT of the ratchet — a
147
+ // new caller is fine, a second hand-written copy of the rule is not. Bump this
148
+ // number only alongside a call site that calls the function.
149
+ assert.equal(uses, 4,
150
+ 'declaration + the open-goals read + the post-apply re-count + the stranded-task probe, and nothing else');
146
151
  });
147
152
 
148
153
  await test('the open-goals read excludes the maintenance goal and counts unfinished work', async () => {
@@ -157,7 +162,7 @@ await test('the open-goals read excludes the maintenance goal and counts unfinis
157
162
 
158
163
  // ---- the write is one transaction ------------------------------------------
159
164
 
160
- function closePool({ status = 'building', goalStatus = 'open', remaining = 0 } = {}) {
165
+ function closePool({ status = 'building', goalStatus = 'open', remaining = 0, stranded = 0 } = {}) {
161
166
  return txPool((sql) => {
162
167
  const q = String(sql).replace(/\s+/g, ' ');
163
168
  if (/FROM versions WHERE id = \$1 FOR UPDATE/.test(q)) return { rows: [{ id: 'V1', status }] };
@@ -169,6 +174,11 @@ function closePool({ status = 'building', goalStatus = 'open', remaining = 0 } =
169
174
  if (/INSERT INTO goal_members/.test(q)) return { rows: [] };
170
175
  if (/UPDATE tasks SET goal_id/.test(q)) return { rows: [{ id: 21 }, { id: 22 }] };
171
176
  if (/count\(\*\)::int AS n FROM goals/.test(q)) return { rows: [{ n: remaining }] };
177
+ // BV1.R32 (task 1003713) — the second invariant: work left on the version
178
+ // that belongs to no goal on it. `stranded` is a count of fake task ids.
179
+ if (/FROM tasks t LEFT JOIN goals g/.test(q)) {
180
+ return { rows: Array.from({ length: stranded }, (_, i) => ({ id: 900 + i })) };
181
+ }
172
182
  if (/UPDATE versions SET status = 'shipped'/.test(q)) return { rows: [{ id: 'V1', status: 'shipped' }] };
173
183
  return { rows: [] };
174
184
  });
@@ -297,4 +307,74 @@ await test('version-close.js no longer being the only door is R23; this route ex
297
307
  assert.match(V, /router\.post\('\/versions\/:id\/close'/, 'the route the CLI will drive');
298
308
  });
299
309
 
310
+
311
+ // ---- BV1.R32: a task with no goal must not ride a close out ------------------
312
+ // The goal re-count above is not the whole invariant. Every task write in the
313
+ // close is scoped `WHERE goal_id = $1`, so a task belonging to NO goal is
314
+ // touched by nothing and counted by nothing — and six such tasks were already
315
+ // sitting on shipped versions (GDS-V3, GDS-V4) when this was written.
316
+
317
+ await test('R32: a non-terminal task with no goal blocks the close and rolls back', async () => {
318
+ const pool = closePool({ stranded: 3 });
319
+ await assert.rejects(
320
+ closeVersion({ versionId: 'V1', plan: [{ goalId: 1, verb: 'abandon' }], reason: 'r' }, { pool }),
321
+ (e) => e.code === 'CLOSE_LEAVES_STRANDED_TASKS'
322
+ && e.remaining === 3
323
+ && e.truncated === false
324
+ && Array.isArray(e.task_ids) && e.task_ids.length === 3 && e.task_ids[0] === '900'
325
+ );
326
+ const sqls = pool.queries.map(flat);
327
+ assert.ok(sqls.includes('ROLLBACK'));
328
+ assert.equal(sqls.some((q) => /UPDATE versions SET status = 'shipped'/.test(q)), false,
329
+ 'the version must not ship while it still holds unhomed work');
330
+ });
331
+
332
+ await test('R32: the stranded probe runs AFTER the goal re-count and BEFORE the flip', async () => {
333
+ // Order matters for the message the caller gets: open goals are the bigger,
334
+ // more actionable problem, and reporting stray tasks first would send them
335
+ // chasing rows that the goal dispositions were about to move anyway.
336
+ const pool = closePool();
337
+ await closeVersion({ versionId: 'V1', plan: [{ goalId: 1, verb: 'abandon' }], reason: 'r' }, { pool });
338
+ const sqls = pool.queries.map(flat);
339
+ const goalCount = sqls.findIndex((q) => /count\(\*\)::int AS n FROM goals/.test(q));
340
+ const strandProbe = sqls.findIndex((q) => /FROM tasks t LEFT JOIN goals g/.test(q));
341
+ const flip = sqls.findIndex((q) => /UPDATE versions SET status = 'shipped'/.test(q));
342
+ assert.ok(goalCount !== -1 && strandProbe !== -1 && flip !== -1, 'all three steps must run');
343
+ assert.ok(goalCount < strandProbe, 'open goals are reported before stray tasks');
344
+ assert.ok(strandProbe < flip, 'the probe must gate the flip, not follow it');
345
+ });
346
+
347
+ await test('R32: the maintenance exemption is the SAME predicate as the goal count', async () => {
348
+ // The two halves of the invariant must never disagree about which goal is
349
+ // exempt. R18 replaced maintenanceGoalExemptSql's body once already (a title
350
+ // match became `is_maintenance`); a second, hand-inlined copy here would have
351
+ // survived that edit and silently re-opened the title-spoofing hole.
352
+ const pool = closePool();
353
+ await closeVersion({ versionId: 'V1', plan: [{ goalId: 1, verb: 'abandon' }], reason: 'r' }, { pool });
354
+ const probe = flat(pool.queries.find((q) => /FROM tasks t LEFT JOIN goals g/.test(flat(q))));
355
+ assert.ok(probe.includes(maintenanceGoalExemptSql('$1')),
356
+ 'the task probe must reuse maintenanceGoalExemptSql, not re-spell it');
357
+ assert.ok(/LEFT JOIN goals g/.test(probe),
358
+ 'an INNER join would drop the null-goal rows this exists to catch');
359
+ assert.ok(/g\.id IS NULL OR/.test(probe),
360
+ 'a task with no goal row at all is the primary case');
361
+ });
362
+
363
+ await test('R32: smoke fixtures never wedge a real close', async () => {
364
+ const pool = closePool();
365
+ await closeVersion({ versionId: 'V1', plan: [{ goalId: 1, verb: 'abandon' }], reason: 'r' }, { pool });
366
+ const probe = flat(pool.queries.find((q) => /FROM tasks t LEFT JOIN goals g/.test(flat(q))));
367
+ assert.ok(/smoke/.test(probe), 'the probe must exclude __smoke__ fixtures like the other two doors do');
368
+ });
369
+
370
+ await test('R32: the sample is capped and says so rather than counting the table', async () => {
371
+ const pool = closePool({ stranded: 51 });
372
+ await assert.rejects(
373
+ closeVersion({ versionId: 'V1', plan: [{ goalId: 1, verb: 'abandon' }], reason: 'r' }, { pool }),
374
+ (e) => e.code === 'CLOSE_LEAVES_STRANDED_TASKS' && e.truncated === true && e.task_ids.length === 50
375
+ );
376
+ const probe = flat(pool.queries.find((q) => /FROM tasks t LEFT JOIN goals g/.test(flat(q))));
377
+ assert.ok(/LIMIT 51/.test(probe), 'read one past the cap so "more than 50" is knowable without a count');
378
+ });
379
+
300
380
  summary();