@bongos/core 1.19.569 → 1.19.570

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.569",
6
- "core_contract": "1.19.569",
7
- "source_commit": "5cdee5559949c616ec9d68e725884fb87424850e",
5
+ "core_version": "1.19.570",
6
+ "core_contract": "1.19.570",
7
+ "source_commit": "7f686cc8bf351a08ac85f77eea54525fa067509b",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-06T20:34:30.476Z",
9
+ "built_at": "2026-09-06T21:14:46.455Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 449,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 2038,
14
+ "functional_verbatim": 2039,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2511,
20
- "tree_sha256": "5c3c486ba0465ca763a11d15487bb2b540558af8430a93ea27a07323d75352e1",
19
+ "file_count": 2512,
20
+ "tree_sha256": "e0096a8f2ab3ba5363c72d452787c803202d6f43c78964e74a36a079ff086f2e",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -2687,7 +2687,7 @@
2687
2687
  {
2688
2688
  "path": "docs/module-api-changelog.md",
2689
2689
  "mode": "0000644",
2690
- "sha256": "5942f6c9fae093d6fbb85032ca496e5f0549cc5044d8ffa4ecb90c3c9d2695dd"
2690
+ "sha256": "bfa90a0b7ff77b9befa37207824797d0268260fd83f960fb8e2f5ff52bf97e63"
2691
2691
  },
2692
2692
  {
2693
2693
  "path": "docs/modules-contract.md",
@@ -3842,7 +3842,7 @@
3842
3842
  {
3843
3843
  "path": "modules/copy-desk/routes/copy-desk.js",
3844
3844
  "mode": "0000644",
3845
- "sha256": "7231f021bb49bd78cb247a6d8e8a0f6e9256ade52f2f9bb1438b54b6ddf400f8"
3845
+ "sha256": "63ed245f47d15758ef0926a489ad1fc7ef56d8539e836ed9e319e09b99a4a38e"
3846
3846
  },
3847
3847
  {
3848
3848
  "path": "modules/copy-desk/tests/copy_flags.mjs",
@@ -5407,7 +5407,7 @@
5407
5407
  {
5408
5408
  "path": "modules/lifecycle/cascade.js",
5409
5409
  "mode": "0000644",
5410
- "sha256": "2192c61c606a37b4782db0646242ac969bcd318d4305b1247adf028b14f2e484"
5410
+ "sha256": "d609ecb540af92f84e365fc7c4233b0b73517665939bcffe21d068de04167a6d"
5411
5411
  },
5412
5412
  {
5413
5413
  "path": "modules/lifecycle/claim-eligibility.js",
@@ -5492,7 +5492,7 @@
5492
5492
  {
5493
5493
  "path": "modules/lifecycle/db-tasks.js",
5494
5494
  "mode": "0000644",
5495
- "sha256": "cf646edc2f71e39aee26cb5648cc25915b5688c7d6356345c0365ada37fdb400"
5495
+ "sha256": "e978cadb879a57d8d7e3bb84657a787ac7e83125666fa2d31013ac64b37b0d7a"
5496
5496
  },
5497
5497
  {
5498
5498
  "path": "modules/lifecycle/db-versions.js",
@@ -5577,7 +5577,7 @@
5577
5577
  {
5578
5578
  "path": "modules/lifecycle/kickoff-seed.js",
5579
5579
  "mode": "0000644",
5580
- "sha256": "f26a507ea55169df9f4841edb2347610f3dea9b7eac729c034c038a0edd1c7af"
5580
+ "sha256": "d92124875d601a0ff7ad06bf140cdbce2f1160eb53ea950ccbb509b604733d1c"
5581
5581
  },
5582
5582
  {
5583
5583
  "path": "modules/lifecycle/lifecycle.js",
@@ -5727,7 +5727,7 @@
5727
5727
  {
5728
5728
  "path": "modules/lifecycle/routes/task-write-routes.js",
5729
5729
  "mode": "0000644",
5730
- "sha256": "f8fb76f7aaf035974472c011a379b450ee001adc33c01c56ed8a38a3dc4a8fc8"
5730
+ "sha256": "87843c62d8ce7af3d7d6aa5772791bd52823afe5f2c4b9b7427ca9f00dd14c11"
5731
5731
  },
5732
5732
  {
5733
5733
  "path": "modules/lifecycle/routes/tasks.js",
@@ -5872,7 +5872,7 @@
5872
5872
  {
5873
5873
  "path": "modules/onboarding/newcomer-restock.js",
5874
5874
  "mode": "0000644",
5875
- "sha256": "533933c98d500dcbad222308a0ec6a9ee8a5c88e1a3fdc3af238f3cc98c9ba10"
5875
+ "sha256": "65f8f2de9ac4582a6672ddeddc78b23e98d9b32865f7748efe58057145712161"
5876
5876
  },
5877
5877
  {
5878
5878
  "path": "modules/onboarding/onboarding-state.js",
@@ -7537,12 +7537,12 @@
7537
7537
  {
7538
7538
  "path": "package-lock.json",
7539
7539
  "mode": "0000644",
7540
- "sha256": "e2279fd35b142a93a9037638445d5e6e616c69cd8322d05fad2aa228a5de3515"
7540
+ "sha256": "6460f782c667dc03846bfbc1dbf299a80e2825488f4eafa664b39767a5c78c47"
7541
7541
  },
7542
7542
  {
7543
7543
  "path": "package.json",
7544
7544
  "mode": "0000644",
7545
- "sha256": "1ba4dccdc1e8d591a783bdac3b0ac4f0d121ad687fef73347ac56e32e69524f7"
7545
+ "sha256": "e0c2ff1988ca94bcc1f36b6defa3e098b12db1fdcbcc1b7616441e492628bffe"
7546
7546
  },
7547
7547
  {
7548
7548
  "path": "public-docs/index.html",
@@ -9242,7 +9242,7 @@
9242
9242
  {
9243
9243
  "path": "src/module-api.js",
9244
9244
  "mode": "0000644",
9245
- "sha256": "6c1005562c9950b7cee858382e3cfd643f5c2be27918859de46927c2fbd29c06"
9245
+ "sha256": "2679c7764d5c874f7f1ecc546e552276b9e540bb66ee9f40fb3a281b9294543f"
9246
9246
  },
9247
9247
  {
9248
9248
  "path": "src/module-loader/catalog.js",
@@ -11837,7 +11837,7 @@
11837
11837
  {
11838
11838
  "path": "tests/reward_gate.mjs",
11839
11839
  "mode": "0000644",
11840
- "sha256": "02236ab849caabdf886f99aff4821c813b9f7548839ab9ee3b548fa522b9f3fb"
11840
+ "sha256": "1ed242c879472dd872c1327c8e7c91de61886dcb108ece3fa455ae6be4b4e025"
11841
11841
  },
11842
11842
  {
11843
11843
  "path": "tests/reward_mode_cost_plus_only.mjs",
@@ -12364,6 +12364,11 @@
12364
12364
  "mode": "0000644",
12365
12365
  "sha256": "93a5d1e91bfea7af8487b409e60cd609ea25a690df9099d278ebd35f253abcb8"
12366
12366
  },
12367
+ {
12368
+ "path": "tests/task_goal_required.mjs",
12369
+ "mode": "0000644",
12370
+ "sha256": "915711866bc194d977225326511c55ac5084b52d10a45a855bc9d6da815f4b8b"
12371
+ },
12367
12372
  {
12368
12373
  "path": "tests/task_lib.mjs",
12369
12374
  "mode": "0000644",
@@ -1587,5 +1587,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
1587
1587
  landed since 1.19.567 with no explicit bump. run 34058174866. (task 1002620)
1588
1588
  1.19.569 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1589
1589
  landed since 1.19.568 with no explicit bump. run 34058387853. (task 1002620)
1590
+ 1.19.570 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1591
+ landed since 1.19.569 with no explicit bump. run 34060442118. (task 1002620)
1590
1592
  ---------------------------------------------------------------------------
1591
1593
  ```
@@ -415,6 +415,10 @@ module.exports = function buildCopyDeskRouter() {
415
415
  const body = proposals.proposalTaskBody(patch);
416
416
  const task = await lifecycle.createTask({
417
417
  versionId: building.id,
418
+ // BV1.R11 (task 1003598): no goal to name until the per-version maintenance
419
+ // goal exists (task 1003605, R18). Declared out loud rather than relying on
420
+ // a silent default — see createTask.
421
+ allowCatchAll: true,
418
422
  title: proposals.proposalTaskTitle(patch),
419
423
  description: flagRef
420
424
  ? `${body}\n\nRaised from copy-desk flag ${flagRef}.`
@@ -242,7 +242,18 @@ function cascadeTaskSpec(rule, task) {
242
242
  + `the rule rather than only this task.`;
243
243
  return {
244
244
  versionId: task.version_id,
245
+ // A cascade INHERITS its parent's goal — the follow-up belongs wherever the
246
+ // work that triggered it belongs.
245
247
  goalId: task.goal_id != null ? Number(task.goal_id) : null,
248
+ // BV1.R11 (task 1003598): …and when the parent has none, says so. A shipped
249
+ // task can still be goal-less — the 53 legacy catch-all rows, or any task
250
+ // whose version had no general goal to fall into — and under the new guard a
251
+ // null goalId THROWS. That throw is caught by the per-rule try/catch below,
252
+ // so the ship is never at risk, but the cascade task would vanish with only a
253
+ // console line: a silent goal-less gap, which is the exact thing this rule
254
+ // exists to close. Declared only when inheritance actually failed, so a
255
+ // cascade off a normal task still names a real goal.
256
+ allowCatchAll: task.goal_id == null,
246
257
  title: String(rule.title(task) || '').trim(),
247
258
  description: `${preamble}\n\n${body}`,
248
259
  discipline: rule.discipline,
@@ -116,6 +116,13 @@ async function createTask({
116
116
  // path with no builder in the request (a seed, a migration backfill) passes
117
117
  // nothing and the row honestly reads unattributed, never guessed.
118
118
  createdBy = null,
119
+ // BV1.R11 (task 1003598): the ESCAPE HATCH, and it is meant to be loud. A
120
+ // create with no goalId throws unless this is true. It exists for the two
121
+ // in-repo vectors that have no goal to name until the per-version
122
+ // maintenance goal lands (task 1003605, R18) — newcomer-restock and
123
+ // copy-desk. Grep for it before adding a third: the answer is almost always
124
+ // to name a goal instead.
125
+ allowCatchAll = false,
119
126
  }, deps = {}) { // deps.client enlists this INSERT in the CALLER'S transaction (task 1003065, BV1.R01); the argument for it is at modules/ideas/inbox.js routeIdeaToGoal
120
127
  // task 1498 / ADR 0085: every task carries a deliberate rank floor. Derive it
121
128
  // from touches + sensitivity, then let an explicit requiresRank override only
@@ -140,15 +147,38 @@ async function createTask({
140
147
  if (rewardMissing(creditsRewardEffective)) {
141
148
  creditsRewardEffective = suggestCreditsReward({ est_minutes: estMinutes, kind });
142
149
  }
143
- // task 1763: every task belongs to a goal. The goal-authored vector (POST
144
- // /goals/:id/tasks) passes goalId; every OTHER vector (POST /tasks, capture,
145
- // newcomer-restock, discord-bugs, seeds) omits it so default a goal-less task
146
- // into its version's catch-all "<version>general" goal here, at the lowest
147
- // create layer, so the invariant holds for all of them without touching each
148
- // caller. If the version has no general goal the fallback is null and the task
149
- // is created goal-less exactly as before — a creator must never be broken.
150
+ // BV1.R11 (task 1003598, goal 1000086, ADR 0250 D4): a goal-less create is no
151
+ // longer SILENT. Task 1763 defaulted any create without a goalId into its
152
+ // version's catch-all "<version> general" goal, here at the lowest layer, so
153
+ // no caller had to think about it and that is exactly how 53 open tasks
154
+ // accumulated in two catch-alls that nobody reads. Unscoped work did not sneak
155
+ // past the rule; the rule quietly filed it out of sight.
156
+ //
157
+ // Now a caller either NAMES a goal or says out loud that it cannot, by passing
158
+ // `allowCatchAll: true`. Same landing place, same never-break-a-creator
159
+ // promise — but the exceptions are a one-command grep instead of an absence,
160
+ // and a new create vector added next year gets a thrown error at its first
161
+ // test run rather than a silent home in a bucket.
162
+ //
163
+ // WHY NOT DELETE THE FALLBACK OUTRIGHT, which is what R11 literally asks: two
164
+ // in-repo callers have no goal to name yet. `newcomer-restock` stamps the
165
+ // beginner queue from templates and `copy-desk` files copy proposals; the
166
+ // honest home for both is the per-version MAINTENANCE goal, which is task
167
+ // 1003605 (R18) and still behind R06 and R12. Deleting the fallback today
168
+ // would stop both — the newcomer queue would drain — so the silent half is
169
+ // removed now and the true deletion belongs to R18, which is when the last two
170
+ // opt-ins can name a real goal. Owner's call, recorded here so the remaining
171
+ // gap is not mistaken for an oversight.
150
172
  let goalIdEffective = goalId;
151
173
  if (goalIdEffective == null) {
174
+ if (allowCatchAll !== true) {
175
+ throw new Error(
176
+ 'createTask: goalId is required — every task belongs to a goal (BV1.R11). ' +
177
+ 'A vector that genuinely cannot name one yet must pass allowCatchAll: true, ' +
178
+ 'which files into the version catch-all and is greppable until R18 gives it ' +
179
+ 'a maintenance goal.'
180
+ );
181
+ }
152
182
  goalIdEffective = await generalGoalIdForVersion(versionId);
153
183
  }
154
184
  const { rows } = await (deps.client || pool).query(
@@ -60,6 +60,11 @@ async function seedKickoffIfEmpty({ builderId = null } = {}, deps = {}) {
60
60
  discipline: t.discipline || 'engineer',
61
61
  source: 'first-sign-in',
62
62
  createdBy: builderId,
63
+ // BV1.R11 (task 1003598): this runs on an instance with zero tasks and, in
64
+ // the scaffold's own seed order, zero goals — there is literally nothing to
65
+ // name yet. Declared rather than defaulted; the founder's first board is the
66
+ // one place a catch-all is the honest answer.
67
+ allowCatchAll: true,
63
68
  });
64
69
  seeded += 1;
65
70
  }
@@ -165,6 +165,18 @@ module.exports = function registerTaskWriteRoutes(router) {
165
165
  // Validate an explicit goal_id: it must exist AND belong to THIS version (a task
166
166
  // can't be parented under another version's goal). Absent → the default-goal
167
167
  // trigger files it under the version's catch-all goal, exactly as before.
168
+ // BV1.R11 (task 1003598, goal 1000086, ADR 0250 D4): goal_id is REQUIRED on
169
+ // this vector. It used to be optional and a goal-less task was filed into the
170
+ // version catch-all, which is how 53 open tasks ended up in two buckets
171
+ // nobody reads. This is the human/agent door, and it always has a goal to
172
+ // name — /builder-start lists work by goal, and POST /goals/:id/tasks has
173
+ // required one since BV1.R60.
174
+ if (body.goal_id == null) {
175
+ return res.fail('goal_id_required', {
176
+ status: 400,
177
+ message: 'goal_id is required — every task belongs to a goal. List them at GET /goals?version=<id>, or author under one directly with POST /goals/:id/tasks.',
178
+ });
179
+ }
168
180
  if (body.goal_id != null) {
169
181
  const goal = await db.getGoal(body.goal_id);
170
182
  if (!goal || String(goal.version_id) !== String(body.version_id)) {
@@ -156,6 +156,10 @@ async function restockTemplate(discipline, template, floors) {
156
156
  const seq = maxSeq + 1 + i;
157
157
  const task = await db.createTask({
158
158
  versionId: template.version_id,
159
+ // BV1.R11 (task 1003598): no goal to name until the per-version maintenance
160
+ // goal exists (task 1003605, R18). Declared out loud rather than relying on
161
+ // a silent default — see createTask.
162
+ allowCatchAll: true,
159
163
  title: template.title,
160
164
  description: buildDescription(template),
161
165
  status: 'ready',
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.569",
3
+ "version": "1.19.570",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.569",
9
+ "version": "1.19.570",
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.569",
3
+ "version": "1.19.570",
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
@@ -49,7 +49,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
49
49
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
50
50
  // the entry to that file. Look for a version's history there, not here.
51
51
  // ---------------------------------------------------------------------------
52
- const CORE_VERSION = '1.19.569'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
52
+ const CORE_VERSION = '1.19.570'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
53
53
 
54
54
  // A namespaced logger so a module's log lines are attributable + consistent.
55
55
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -210,7 +210,7 @@ async function withStubbedCreatePool(run) {
210
210
 
211
211
  t('a task CREATED with NO reward is born with a positive, capped value', async () => {
212
212
  await withStubbedCreatePool(async (captured) => {
213
- const row = await db.createTask({ versionId: 1, title: 't', kind: 'feature', estMinutes: 40 });
213
+ const row = await db.createTask({ versionId: 1, goalId: 77, title: 't', kind: 'feature', estMinutes: 40 });
214
214
  assert.ok(captured.creditsReward > 0, 'INSERT must bind a positive credits_reward');
215
215
  assert.equal(captured.creditsReward, 40, 'feature @40min -> 40 drachmae');
216
216
  assert.ok(captured.creditsReward <= db.REWARD_SUGGESTION_CAP, 'must respect the cap');
@@ -220,7 +220,7 @@ t('a task CREATED with NO reward is born with a positive, capped value', async (
220
220
 
221
221
  t('a task CREATED with credits_reward 0 (the route default) is auto-assigned', async () => {
222
222
  await withStubbedCreatePool(async (captured) => {
223
- await db.createTask({ versionId: 1, title: 't', creditsReward: 0, kind: 'bug', estMinutes: 40 });
223
+ await db.createTask({ versionId: 1, goalId: 77, title: 't', creditsReward: 0, kind: 'bug', estMinutes: 40 });
224
224
  // bug weight 1.25 -> 50; 0 is treated as "unset" so the default kicks in.
225
225
  assert.equal(captured.creditsReward, 50);
226
226
  });
@@ -228,14 +228,14 @@ t('a task CREATED with credits_reward 0 (the route default) is auto-assigned', a
228
228
 
229
229
  t('a huge-est new task is CAPPED at creation, not ballooned', async () => {
230
230
  await withStubbedCreatePool(async (captured) => {
231
- await db.createTask({ versionId: 1, title: 't', kind: 'feature', estMinutes: 9999 });
231
+ await db.createTask({ versionId: 1, goalId: 77, title: 't', kind: 'feature', estMinutes: 9999 });
232
232
  assert.equal(captured.creditsReward, db.REWARD_SUGGESTION_CAP);
233
233
  });
234
234
  });
235
235
 
236
236
  t('an EXPLICIT positive credits_reward is PRESERVED (never overridden)', async () => {
237
237
  await withStubbedCreatePool(async (captured) => {
238
- await db.createTask({ versionId: 1, title: 't', creditsReward: 17, kind: 'feature', estMinutes: 40 });
238
+ await db.createTask({ versionId: 1, goalId: 77, title: 't', creditsReward: 17, kind: 'feature', estMinutes: 40 });
239
239
  assert.equal(captured.creditsReward, 17, 'author-set positive value must survive untouched');
240
240
  });
241
241
  });
@@ -0,0 +1,223 @@
1
+ // tests/task_goal_required.mjs — a goal-less create is no longer SILENT
2
+ // (BV1.R11, task 1003598, goal 1000086, ADR 0250 D4).
3
+ //
4
+ // WHAT WAS ACTUALLY WRONG. Task 1763 made `createTask` default any create with no
5
+ // `goalId` into its version's catch-all "<version> — general" goal, at the lowest
6
+ // layer, so that no caller had to think about it. The invariant "every task
7
+ // belongs to a goal" then held — technically — while 53 open tasks accumulated in
8
+ // two buckets nobody reads. Unscoped work never snuck past the rule; the rule
9
+ // filed it out of sight.
10
+ //
11
+ // So the defect is the SILENCE, not the bucket. A caller now either names a goal
12
+ // or says out loud that it cannot, by passing `allowCatchAll: true`: same landing
13
+ // place, same never-break-a-creator promise, but the exceptions are a one-command
14
+ // grep instead of an absence, and a create vector added next year gets a thrown
15
+ // error at its first test run rather than a quiet home in a bucket.
16
+ //
17
+ // NOT FULLY DELETED, and that is a recorded decision rather than an oversight.
18
+ // R11 literally asks for the fallback's deletion, but two in-repo vectors have no
19
+ // goal to name yet — `newcomer-restock` (the beginner queue) and `copy-desk`
20
+ // (copy proposals) — whose honest home is the per-version maintenance goal, task
21
+ // 1003605 (R18), still behind R06 and R12. Deleting today would drain the
22
+ // newcomer queue. The owner chose to remove the silent half now and leave the
23
+ // true deletion to R18. This file pins the opt-in roster so it cannot grow
24
+ // quietly in the meantime.
25
+ //
26
+ // Run: node --test tests/task_goal_required.mjs
27
+
28
+ import assert from 'node:assert/strict';
29
+ import { test } from 'node:test';
30
+ import { readFileSync } from 'node:fs';
31
+ import { createRequire } from 'node:module';
32
+
33
+ process.env.NODE_ENV = 'test';
34
+ const require = createRequire(import.meta.url);
35
+ const { createTask } = require('../modules/lifecycle/db.js');
36
+
37
+ const src = (rel) => readFileSync(new URL('../' + rel, import.meta.url), 'utf8');
38
+
39
+ // A pool that would happily create the row, so a rejection can only come from the
40
+ // guard and never from a database that was never reached.
41
+ const permissivePool = {
42
+ query: async () => ({ rows: [{ id: '1', goal_id: null, title: 'x' }] }),
43
+ connect: async () => ({ query: async () => ({ rows: [] }), release() {} }),
44
+ };
45
+
46
+ // ---- the guard --------------------------------------------------------------
47
+
48
+ test('a create with no goalId THROWS instead of quietly filing into a bucket', async () => {
49
+ await assert.rejects(
50
+ () => createTask({ versionId: 'BONGOS-V1', title: 't' }, { pool: permissivePool }),
51
+ /goalId is required/,
52
+ 'the old behaviour was to succeed and say nothing');
53
+ });
54
+
55
+ test('the error tells the caller both ways out', async () => {
56
+ const err = await createTask({ versionId: 'BONGOS-V1', title: 't' }, { pool: permissivePool })
57
+ .then(() => null, (e) => e);
58
+ assert.ok(err, 'must reject');
59
+ assert.match(err.message, /BV1\.R11/, 'names the rule, so the reason is findable');
60
+ assert.match(err.message, /allowCatchAll: true/, 'and the escape hatch, spelled exactly');
61
+ });
62
+
63
+ test('only a literal true opts in — a truthy value still throws', async () => {
64
+ for (const v of [1, 'yes', {}, 'true']) {
65
+ await assert.rejects(
66
+ () => createTask({ versionId: 'BONGOS-V1', title: 't', allowCatchAll: v }, { pool: permissivePool }),
67
+ /goalId is required/,
68
+ `${JSON.stringify(v)} must not open the hatch`);
69
+ }
70
+ });
71
+
72
+ test('naming a goal needs no flag at all', async () => {
73
+ // The ordinary path must stay ordinary: a caller with a goal never learns this
74
+ // parameter exists.
75
+ await createTask(
76
+ { versionId: 'BONGOS-V1', title: 't', goalId: 77 },
77
+ { pool: permissivePool, client: permissivePool },
78
+ );
79
+ });
80
+
81
+ // ---- the route --------------------------------------------------------------
82
+
83
+ const WRITE = src('modules/lifecycle/routes/task-write-routes.js');
84
+ const POST_TASKS = (() => {
85
+ const a = WRITE.indexOf("router.post('/tasks'");
86
+ assert.notEqual(a, -1);
87
+ return WRITE.slice(a, WRITE.indexOf("router.patch('/tasks/:id'"));
88
+ })();
89
+
90
+ test('POST /tasks refuses a body with no goal_id, as a 400 that says where to look', () => {
91
+ assert.match(POST_TASKS, /if \(body\.goal_id == null\) \{/);
92
+ assert.match(POST_TASKS, /res\.fail\('goal_id_required', \{[\s\S]*?status: 400/,
93
+ 'a missing required field is the caller\'s mistake, not a conflict');
94
+ assert.match(POST_TASKS, /GET \/goals\?version=/,
95
+ 'a refusal that does not say how to comply gets worked around');
96
+ });
97
+
98
+ test('the route refuses BEFORE it validates the goal it was not given', () => {
99
+ const required = POST_TASKS.indexOf("'goal_id_required'");
100
+ const validate = POST_TASKS.indexOf("'bad_goal_id'");
101
+ const create = POST_TASKS.indexOf('await db.createTask(');
102
+ assert.ok(required !== -1 && validate !== -1 && create !== -1);
103
+ assert.ok(required < validate, 'absent and wrong are different answers');
104
+ assert.ok(required < create);
105
+ });
106
+
107
+ test('POST /tasks does NOT pass the escape hatch — this door always has a goal', () => {
108
+ assert.equal(/allowCatchAll/.test(POST_TASKS), false,
109
+ '/builder-start lists work by goal; the human/agent vector can always name one');
110
+ });
111
+
112
+ // ---- the opt-in roster, pinned ----------------------------------------------
113
+
114
+ const OPT_INS = [
115
+ ['modules/onboarding/newcomer-restock.js', 'the beginner queue, stamped from templates'],
116
+ ['modules/copy-desk/routes/copy-desk.js', 'copy-change proposals'],
117
+ ['modules/lifecycle/kickoff-seed.js', "the founder's first board, on an instance with no goals at all"],
118
+ ['modules/lifecycle/cascade.js', 'a follow-up off a parent task that is itself goal-less'],
119
+ ];
120
+
121
+ test('exactly four vectors opt in, and each says why', () => {
122
+ for (const [file, why] of OPT_INS) {
123
+ const body = src(file);
124
+ assert.match(body, /allowCatchAll/, `${file} should opt in (${why})`);
125
+ assert.match(body, /BV1\.R11|1003605|R18/,
126
+ `${file} must explain itself — an undocumented opt-in is the silence again`);
127
+ }
128
+ });
129
+
130
+ test('cascade opts in ONLY when inheritance actually failed', () => {
131
+ // The others opt in unconditionally because they have no goal to name at all.
132
+ // A cascade normally inherits its parent's goal and must keep doing so — the
133
+ // hatch is for a goal-less PARENT (a legacy catch-all row), not for cascades in
134
+ // general. A blanket `true` here would quietly re-open the hole for every
135
+ // follow-up task the system files.
136
+ const body = src('modules/lifecycle/cascade.js');
137
+ assert.match(body, /allowCatchAll: task\.goal_id == null,/);
138
+ assert.equal(/allowCatchAll: true/.test(body), false,
139
+ 'an unconditional hatch here would cover every cascade, not just the legacy ones');
140
+ assert.match(body, /goalId: task\.goal_id != null \? Number\(task\.goal_id\) : null,/,
141
+ 'inheritance stays the primary path');
142
+ });
143
+
144
+ test('a goal-less parent does not silently lose its cascade task', async () => {
145
+ // The regression this pair of fixes closes: under the guard, a null goalId
146
+ // throws; the throw is caught per-rule so the ship is safe, and the cascade
147
+ // task would have vanished with only a console line — a silent goal-less gap,
148
+ // which is the exact thing this rule exists to close.
149
+ const { runCascadesForShip } = require('../modules/lifecycle/cascade.js');
150
+ const specs = [];
151
+ const report = await runCascadesForShip({ id: 5 }, {
152
+ getTask: async () => ({
153
+ id: 5, version_id: 'BONGOS-V1', goal_id: null, status: 'shipped',
154
+ discipline: 'artist', kind: 'feature', title: 'legacy', touches: [],
155
+ }),
156
+ findTaskBySourceRef: async () => null,
157
+ createTask: async (spec) => { specs.push(spec); return { id: 9 }; },
158
+ });
159
+ assert.deepEqual(report.errors, [], 'no rule may error on a goal-less parent');
160
+ for (const spec of specs) {
161
+ assert.equal(spec.goalId, null, 'nothing to inherit');
162
+ assert.equal(spec.allowCatchAll, true, 'so it declares the catch-all rather than throwing');
163
+ }
164
+ });
165
+
166
+ test('a cascade off a NORMAL parent inherits and does not open the hatch', async () => {
167
+ const { runCascadesForShip } = require('../modules/lifecycle/cascade.js');
168
+ const specs = [];
169
+ await runCascadesForShip({ id: 5 }, {
170
+ getTask: async () => ({
171
+ id: 5, version_id: 'BONGOS-V1', goal_id: 77, status: 'shipped',
172
+ discipline: 'artist', kind: 'feature', title: 'normal', touches: [],
173
+ }),
174
+ findTaskBySourceRef: async () => null,
175
+ createTask: async (spec) => { specs.push(spec); return { id: 9 }; },
176
+ });
177
+ for (const spec of specs) {
178
+ assert.equal(spec.goalId, 77, 'the follow-up belongs where the work belongs');
179
+ assert.equal(spec.allowCatchAll, false, 'and the hatch stays shut');
180
+ }
181
+ });
182
+
183
+ test('no FIFTH vector has appeared', () => {
184
+ // The roster is the point: if this fails, either a new create vector took the
185
+ // easy way out, or R18 landed and one of these can now name a real goal — in
186
+ // which case delete its opt-in and this line, do not extend the list.
187
+ const roster = new Set([...OPT_INS.map(([f]) => f), 'modules/lifecycle/db-tasks.js']);
188
+ const suspects = [
189
+ 'modules/ideas/inbox.js', 'modules/lifecycle/routes/goals.js',
190
+ 'modules/lifecycle/routes/task-write-routes.js',
191
+ ];
192
+ for (const f of suspects) {
193
+ if (roster.has(f)) continue;
194
+ assert.equal(/allowCatchAll/.test(src(f)), false,
195
+ `${f} must name a goal rather than opting out`);
196
+ }
197
+ });
198
+ test('the vectors that CAN name a goal still do', () => {
199
+ assert.match(src('modules/ideas/inbox.js'), /goalId: goal\.id/,
200
+ 'idea routing names the goal it routed to');
201
+ assert.match(src('modules/lifecycle/routes/goals.js'), /db\.createTask\(\{/,
202
+ 'and the goal-authored vector has stamped its goal since BV1.R60');
203
+ });
204
+
205
+ // ---- the fallback itself ----------------------------------------------------
206
+
207
+ test('the catch-all resolver survives, reachable only through the declared hatch', () => {
208
+ const dbTasks = src('modules/lifecycle/db-tasks.js');
209
+ assert.match(dbTasks, /generalGoalIdForVersion\(versionId\)/,
210
+ 'R18 is when this goes; deleting it today would drain the newcomer queue');
211
+ const guard = dbTasks.slice(dbTasks.indexOf('let goalIdEffective = goalId;'));
212
+ const hatch = guard.indexOf('allowCatchAll !== true');
213
+ const resolve = guard.indexOf('generalGoalIdForVersion(versionId)');
214
+ assert.ok(hatch !== -1 && hatch < resolve,
215
+ 'the throw comes first — the resolver is unreachable without the flag');
216
+ });
217
+
218
+ test('the remaining gap is written down where the next reader will stand', () => {
219
+ const dbTasks = src('modules/lifecycle/db-tasks.js');
220
+ assert.match(dbTasks, /WHY NOT DELETE THE FALLBACK OUTRIGHT/,
221
+ 'so the partial job reads as a decision, not an unfinished edit');
222
+ assert.match(dbTasks, /1003605/, 'and names the task that finishes it');
223
+ });