@bongos/core 1.19.586 → 1.19.588

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.586",
3
+ "version": "1.19.588",
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.586'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
58
+ const CORE_VERSION = '1.19.588'; // 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');
@@ -33,8 +33,12 @@
33
33
  // F16 file through #bugs as an UNLINKED author → an inbox row, never work
34
34
  // F13 declare touches[] outside the goal to widen blast area → the real wall refuses
35
35
  // F14 file against a SHIPPED version's goal → refused (version not building)
36
+ // F17 get a keyword-guessed 'bug' from #ideas to mint work → cannot: vector not trusted
37
+ // F18 forge/omit the vector to reach the homeless-bug door → fail-closed, an inbox row
38
+ // F19 file a homeless NON-bug hoping for a free home → inbox; only bugs get one
36
39
  // P1 a Metic+ files a bug in an ordinary goal → 'ready' (the C1 grant)
37
40
  // P2 a Metic+ files into protected scope → allowed, floored to metic
41
+ // P3 a Metic+ files a HOMELESS bug from a trusted vector → 'ready', in the maintenance goal
38
42
  //
39
43
  // AND THE ANTI-DRIFT PIN (the point of the file, really). The rows above are true
40
44
  // of the capture vectors that exist TODAY. Nothing stops a fifth being added
@@ -337,6 +341,101 @@ await test('[F16] an unlinked #bugs author gets an inbox row and CANNOT reach wo
337
341
  const code = src.split('\n').filter((l) => !l.trim().startsWith('//')).join('\n');
338
342
  assert.match(code, /if \(builder\) \{[\s\S]{0,200}suggestGoalForCapture/,
339
343
  'a goal is only ever sought for a LINKED builder');
344
+
345
+ // ADR 0262 REOPENED THIS AND IT HAD TO BE CLOSED AGAIN — a grader blocker on
346
+ // task 1003691, and the reason the vector is CONDITIONAL at the call site.
347
+ //
348
+ // The rows above pass a rank but no VECTOR, so they kept passing while the real
349
+ // path had already changed: #bugs briefly set `vector: 'discord-bugs'`
350
+ // unconditionally, which meant an unlinked poster (filerRank null, goalId null —
351
+ // the goal is only sought `if (builder)`) took the homeless-bug branch and minted
352
+ // a real backlog task. Worse, the rolling rate cap is counted per builder id and
353
+ // so does not run at all for them: unattributed task creation at no cost.
354
+ //
355
+ // Both halves are pinned now — the decision, and the call site that feeds it.
356
+ assert.equal(
357
+ decideLanding({ goalId: null, kind: 'bug', filerRank: null, vector: 'discord-bugs' }).route,
358
+ true,
359
+ 'sanity: the vector DOES open the homeless branch, so the gate below is what protects it'
360
+ );
361
+ assert.match(code, /vector:\s*builder\s*\?\s*'discord-bugs'\s*:\s*null/,
362
+ 'the trusted vector is claimed ONLY for a linked builder — never the bare string');
363
+ assert.ok(!/vector:\s*'discord-bugs'\s*,/.test(code),
364
+ 'an unconditional vector here is the blocker this row exists to catch');
365
+ });
366
+
367
+ // =========================================================================
368
+ // F17–F19 + P3 — the HOMELESS-BUG door (task 1003691, ADR 0262)
369
+ //
370
+ // ADR 0262 opened one new way for a filing to become work: a kind='bug' with NO
371
+ // goal now routes to the version's maintenance goal instead of parking in the
372
+ // inbox. A new door is a new attack surface, so it gets its own rows.
373
+ //
374
+ // THE ATTACKER'S BEST MOVE HERE is not rank — it is the VECTOR. #ideas passes no
375
+ // kind, so inbox.classify() guesses one from keywords, and its bug pattern fires
376
+ // on fix/broken/bug/crash/error anywhere in the text. If the kind alone opened the
377
+ // door, "we should fix the copy on the landing page" would mint a task, and ADR
378
+ // 0234's owner-interview decision ("#ideas files with HINTS only, never routes")
379
+ // would be dead without anyone deciding to kill it.
380
+ // =========================================================================
381
+
382
+ await test('[F17] a keyword-guessed bug from #ideas still cannot mint work', () => {
383
+ // The guess is real: prove the classifier does fire, then prove it buys nothing.
384
+ const inbox = require('../modules/ideas/inbox.js');
385
+ for (const text of ['we should fix the copy on the landing page',
386
+ 'the onboarding flow feels broken to me',
387
+ 'error messages could be friendlier']) {
388
+ assert.equal(inbox.classify(text, ''), 'bug', `classifier guesses bug for: ${text}`);
389
+ }
390
+ // …and #ideas's vector is not on the allowlist, so every one of them is an inbox row.
391
+ assert.deepEqual(
392
+ decideLanding({ goalId: null, kind: 'bug', filerRank: 'archon', vector: 'discord-ideas' }),
393
+ { route: false, reason: 'no_goal' },
394
+ 'ADR 0234 holds: #ideas files hints, never work'
395
+ );
396
+ });
397
+
398
+ await test('[F18] the vector fails CLOSED — forged, absent or unknown never opens the door', () => {
399
+ for (const vector of [undefined, null, '', 'api ', 'API', 'discord_bugs', 'cli',
400
+ 'internal', 'trusted', true, 1, {}, ['api']]) {
401
+ assert.equal(
402
+ decideLanding({ goalId: null, kind: 'bug', filerRank: 'archon', vector }).route,
403
+ false,
404
+ `vector ${JSON.stringify(vector)} must not route`
405
+ );
406
+ }
407
+ // The allowlist is exactly two members, and both are server-set at their call
408
+ // site — never read from a request body, which is what makes forging it a
409
+ // non-move rather than a merely-hard one.
410
+ assert.deepEqual([...routing.HOMELESS_ROUTING_VECTORS].sort(), ['api', 'discord-bugs']);
411
+ const routeSrc = read('modules/ideas/routes/inbox.js');
412
+ assert.match(routeSrc, /vector: 'api'/, 'the route hardcodes its own vector');
413
+ assert.ok(!/vector:\s*body\.|vector:\s*req\.body/.test(routeSrc),
414
+ 'and never takes it from the request body');
415
+ });
416
+
417
+ await test('[F19] only a BUG gets a free home — every other homeless kind stays in the inbox', () => {
418
+ // Otherwise the door empties the inbox by fabricating homes for work that
419
+ // genuinely has none, which is the opposite of what ADR 0262 decided.
420
+ for (const kind of ['feature', 'cleanup', 'refactor', 'infra', 'spike',
421
+ 'learning-capture', 'blocker-resolution', 'unclassified',
422
+ 'criterion-proposal', 'decision']) {
423
+ assert.equal(
424
+ decideLanding({ goalId: null, kind, filerRank: 'archon', vector: 'api' }).route,
425
+ false,
426
+ `kind ${kind}`
427
+ );
428
+ }
429
+ });
430
+
431
+ await test('[P3] a Metic+ homeless bug IS claimable — the grant the door exists for', () => {
432
+ const d = decideLanding({ goalId: null, kind: 'bug', filerRank: 'metic', vector: 'api' });
433
+ assert.equal(d.route, true);
434
+ assert.equal(d.status, 'ready', 'no triage stop between reported and in the queue (C1)');
435
+ assert.equal(d.homeless, true, 'and it is flagged, so the transaction knows to resolve a home');
436
+ // A sub-Metic gets the same door but not the same speed.
437
+ assert.equal(decideLanding({ goalId: null, kind: 'bug', filerRank: 'xenos', vector: 'api' }).status,
438
+ 'backlog');
340
439
  });
341
440
 
342
441
  // =========================================================================
@@ -41,11 +41,25 @@ const seams = require('../src/module-seams.js');
41
41
  seams._reset();
42
42
  let createTaskCalls = [];
43
43
  let createTaskImpl = async () => ({ id: 9001, status: 'backlog', goal_id: 7 });
44
+ // task 1003691 (ADR 0262): the homeless-bug home. Both are port calls, so the
45
+ // stub is what lets the transaction be driven without a Postgres — and the CALL
46
+ // LOG is the assertion surface: `deps.client` proving they ran inside the tx.
47
+ let maintenanceCalls = [];
48
+ let currentVersionImpl = async () => 'BONGOS-V1';
49
+ let ensureMaintenanceImpl = async () => ({ id: 77, version_id: 'BONGOS-V1', status: 'open' });
44
50
  seams.registerProvider('lifecycle', {
45
51
  createTask: async (args, deps) => {
46
52
  createTaskCalls.push({ args, deps });
47
53
  return createTaskImpl(args, deps);
48
54
  },
55
+ currentBuildingVersionId: async (deps) => {
56
+ maintenanceCalls.push({ fn: 'currentBuildingVersionId', deps });
57
+ return currentVersionImpl(deps);
58
+ },
59
+ ensureMaintenanceGoal: async (versionId, deps) => {
60
+ maintenanceCalls.push({ fn: 'ensureMaintenanceGoal', versionId, deps });
61
+ return ensureMaintenanceImpl(versionId, deps);
62
+ },
49
63
  });
50
64
 
51
65
  const inbox = require('../modules/ideas/inbox.js');
@@ -91,13 +105,21 @@ function routableHandler(overrides = {}) {
91
105
  function reset() {
92
106
  createTaskCalls = [];
93
107
  createTaskImpl = async () => ({ id: 9001, status: 'backlog', goal_id: 7 });
108
+ maintenanceCalls = [];
109
+ currentVersionImpl = async () => 'BONGOS-V1';
110
+ ensureMaintenanceImpl = async () => ({ id: 77, version_id: 'BONGOS-V1', status: 'open' });
94
111
  }
95
112
 
113
+ // The maintenance goal a homeless bug is routed into: id 77, open, on a building
114
+ // version — the same shape the goal read returns for any other routed filing, so
115
+ // everything downstream of (a0) runs unchanged.
116
+ const MAINT_GOAL = { id: 77, status: 'open', version_id: 'BONGOS-V1', version_status: 'building', scope_modules: [] };
117
+
96
118
  // =========================================================================
97
119
  // 1. The landing matrix — pure, every cell
98
120
  // =========================================================================
99
121
 
100
- await test('matrix: NO GOAL means the inbox, and that is the default path', () => {
122
+ await test('matrix: NO GOAL and no trusted vector means the inbox, and that is the default path', () => {
101
123
  for (const goalId of [null, undefined, '']) {
102
124
  const d = decideLanding({ goalId, kind: 'bug', filerRank: 'archon' });
103
125
  assert.equal(d.route, false, `goalId=${JSON.stringify(goalId)} must not route`);
@@ -105,6 +127,14 @@ await test('matrix: NO GOAL means the inbox, and that is the default path', () =
105
127
  }
106
128
  // A bug filed by an Archon is the MOST routable thing there is — proving it
107
129
  // still stays put without a goal is what pins "routing is strictly additive".
130
+ //
131
+ // TASK 1003691 (ADR 0262) NARROWED THIS PIN RATHER THAN DELETING IT, and the
132
+ // distinction matters. A homeless BUG now routes — but only from a vector on
133
+ // routing.HOMELESS_ROUTING_VECTORS, and no `vector` is passed above. So this
134
+ // case is still exactly true, and it is now pinning something sharper than it
135
+ // was: that the vector allowlist FAILS CLOSED. An absent vector is untrusted,
136
+ // so every caller that predates ADR 0262 keeps landing in the inbox, and so
137
+ // does any capture path added later that forgets to declare itself.
108
138
  });
109
139
 
110
140
  await test('matrix: a bug from a trusted filer HARD-LANDS at ready (criterion C1)', () => {
@@ -132,6 +162,83 @@ await test('matrix: FAIL-CLOSED — an absent or unrecognised rank never hard-la
132
162
  }
133
163
  });
134
164
 
165
+ // -------------------------------------------------------------------------
166
+ // 1b. THE HOMELESS BUG (task 1003691, ADR 0262) — "a bug never lands in the inbox"
167
+ //
168
+ // The hole this closed: the no-goal early return above made the kind==='bug'
169
+ // branch UNREACHABLE without a goal, so `kind` only ever chose a routed task's
170
+ // STATUS, never whether it routed. A reported defect with no goal named sat in
171
+ // idea_inbox waiting for the triage pass criterion C1 exists to remove.
172
+ // -------------------------------------------------------------------------
173
+
174
+ await test('matrix: a homeless BUG from a trusted vector routes, and keeps the rank split', () => {
175
+ for (const vector of ['api', 'discord-bugs']) {
176
+ assert.deepEqual(
177
+ decideLanding({ goalId: null, kind: 'bug', filerRank: 'archon', vector }),
178
+ { route: true, status: 'ready', homeless: true, reason: 'bug_homeless_trusted_filer' },
179
+ `trusted filer via ${vector}`
180
+ );
181
+ assert.deepEqual(
182
+ decideLanding({ goalId: null, kind: 'bug', filerRank: 'xenos', vector }),
183
+ { route: true, status: 'backlog', homeless: true, reason: 'bug_homeless_awaiting_nod' },
184
+ `sub-Metic via ${vector}`
185
+ );
186
+ }
187
+ });
188
+
189
+ await test('matrix: a homeless bug FAILS CLOSED on rank, exactly as a homed one does', () => {
190
+ // Same property as the homed case: no rank input reaches 'ready' except a real
191
+ // trusted tier. A homeless bug is filed by definition without a goal's scope
192
+ // wall in play, so this is the only rank gate standing on this path.
193
+ for (const rank of [null, undefined, '', 'archon-ish', 'admin', 'root', 0, {}, []]) {
194
+ const d = decideLanding({ goalId: null, kind: 'bug', filerRank: rank, vector: 'api' });
195
+ assert.equal(d.status, 'backlog', `rank ${JSON.stringify(rank)} must not reach ready`);
196
+ assert.equal(d.reason, 'bug_homeless_awaiting_nod');
197
+ }
198
+ });
199
+
200
+ await test('matrix: the VECTOR is the gate — an untrusted or unknown door never routes homeless', () => {
201
+ // The ADR 0234 guarantee, kept: #ideas passes no kind at all, so inbox.classify
202
+ // GUESSES one from keywords ("should we fix the copy" ⇒ 'bug'). A guess must not
203
+ // mint work, and the allowlist is what stops it. Unknown values fail closed too,
204
+ // so a typo'd or attacker-supplied vector is inert rather than trusted.
205
+ for (const vector of ['discord-ideas', null, undefined, '', 'API', 'api ', 'cli',
206
+ 'discord_bugs', 'unknown', 0, {}, []]) {
207
+ assert.deepEqual(
208
+ decideLanding({ goalId: null, kind: 'bug', filerRank: 'archon', vector }),
209
+ { route: false, reason: 'no_goal' },
210
+ `vector ${JSON.stringify(vector)} must not route a homeless bug`
211
+ );
212
+ }
213
+ });
214
+
215
+ await test('matrix: homeless routing is for BUGS only — no other kind gets a free home', () => {
216
+ // A homeless feature/cleanup/refactor is exactly the "work that has no home"
217
+ // the inbox is FOR. Widening this to every kind would empty the inbox by
218
+ // fabricating homes, which is the opposite of what ADR 0262 decided.
219
+ for (const kind of ['feature', 'cleanup', 'refactor', 'infra', 'spike',
220
+ 'learning-capture', 'blocker-resolution', 'unclassified',
221
+ 'criterion-proposal', 'decision']) {
222
+ assert.deepEqual(
223
+ decideLanding({ goalId: null, kind, filerRank: 'archon', vector: 'api' }),
224
+ { route: false, reason: 'no_goal' },
225
+ `kind ${kind} must not route without a goal`
226
+ );
227
+ }
228
+ });
229
+
230
+ await test('matrix: a homeless FULL IDEA never routes, even kinded bug from an Archon (F8 holds)', () => {
231
+ // The Board Room's subject is ratified by peers, never converted by its own
232
+ // filer — and that must not become reachable through the new door.
233
+ for (const grade of ['full', 'FULL', 'Full']) {
234
+ assert.deepEqual(
235
+ decideLanding({ goalId: null, kind: 'bug', grade, filerRank: 'archon', vector: 'api' }),
236
+ { route: false, reason: 'no_goal' },
237
+ `grade ${grade}`
238
+ );
239
+ }
240
+ });
241
+
135
242
  await test('matrix: every non-bug kind soft-lands, whoever files it (interview Q1)', () => {
136
243
  // "Bugs are urgent-true; the rest gets a nod." cleanup/refactor/infra were the
137
244
  // open question the owner settled — they soft-land like features, not like bugs.
@@ -888,4 +995,122 @@ await test('GET /inbox/awaiting-nod is registered BEFORE /inbox/:id, or it is un
888
995
  assert.ok(iNod < iParam, `literal segment must precede the wildcard (got ${iNod} vs ${iParam})`);
889
996
  });
890
997
 
998
+ // =========================================================================
999
+ // 5. THE HOMELESS-BUG TRANSACTION (task 1003691, ADR 0262)
1000
+ //
1001
+ // The matrix decides; this is the transaction that acts on it. The property
1002
+ // under test is the same one the rest of this file exists for: the idea row, the
1003
+ // TASK and its lineage commit together, and the home the task lands in is a REAL
1004
+ // open goal — so ADR 0250 D4's "every task belongs to a goal" holds on the one
1005
+ // path that has no goal to be given.
1006
+ // =========================================================================
1007
+
1008
+ await test('homeless bug: resolves the version, then its maintenance goal, INSIDE the transaction', async () => {
1009
+ reset();
1010
+ const pool = fakePool((s) => {
1011
+ if (/FROM goals g JOIN versions v/.test(s)) return { rows: [MAINT_GOAL] };
1012
+ if (/INSERT INTO idea_inbox/.test(s)) return { rows: [{ ...IDEA_ROW, suggested_goal_id: 77 }] };
1013
+ return null;
1014
+ });
1015
+ const idea = await captureIdea(
1016
+ { title: 'the build is broken', kind: 'bug', capturedBy: 42, filerRank: 'archon', vector: 'api' },
1017
+ { pool }
1018
+ );
1019
+
1020
+ const sqls = seq(pool.queries);
1021
+ assert.ok(sqls.some((q) => /^BEGIN$/.test(q)), 'a transaction IS opened — unlike the inbox path');
1022
+ assert.ok(sqls.some((q) => /^COMMIT$/.test(q)), 'and it commits');
1023
+
1024
+ // Both port calls ran, in order, ON THE TRANSACTION CLIENT. The client is the
1025
+ // whole point: a goal created on its own connection would survive a rolled-back
1026
+ // route as an empty goal nobody asked for.
1027
+ assert.deepEqual(maintenanceCalls.map((c) => c.fn),
1028
+ ['currentBuildingVersionId', 'ensureMaintenanceGoal'],
1029
+ 'version first — the order inverts here, because there is no goal to read it off');
1030
+ for (const c of maintenanceCalls) {
1031
+ assert.ok(c.deps && c.deps.client, `${c.fn} runs on the tx client, not the pool`);
1032
+ }
1033
+ assert.equal(maintenanceCalls[1].versionId, 'BONGOS-V1', 'the resolved version is the one asked about');
1034
+
1035
+ // The task landed in the maintenance goal, at the matrix's status.
1036
+ assert.equal(createTaskCalls.length, 1, 'exactly one task');
1037
+ assert.equal(createTaskCalls[0].args.goalId, 77, 'the task carries a REAL goal id — ADR 0250 D4 holds');
1038
+ assert.equal(createTaskCalls[0].args.versionId, 'BONGOS-V1', "and the goal's version, as every routed filing does");
1039
+ assert.equal(createTaskCalls[0].args.status, 'ready', 'an Archon bug is claimable immediately (C1)');
1040
+ assert.equal(createTaskCalls[0].args.kind, 'bug');
1041
+ // createTask's own catch-all escape hatch is NOT used — this vector adds no
1042
+ // third `allowCatchAll` caller, which db-tasks.js explicitly warns against.
1043
+ assert.ok(!('allowCatchAll' in createTaskCalls[0].args),
1044
+ 'allowCatchAll is never passed: the goal is real, so the fallback is unreachable');
1045
+
1046
+ const ins = find(pool.queries, /INSERT INTO idea_inbox/);
1047
+ assert.match(norm(ins.sql), /'promoted'/, 'the idea row is promoted in the same act');
1048
+ assert.equal(ins.params[6], 77, 'and records the home it actually went to, not the null it arrived with');
1049
+ assert.equal(idea.routing.routed, true);
1050
+ assert.equal(idea.routing.reason, 'bug_homeless_trusted_filer');
1051
+ });
1052
+
1053
+ await test('homeless bug: a sub-Metic filer lands at backlog, never claimable', async () => {
1054
+ reset();
1055
+ const pool = fakePool((s) => {
1056
+ if (/FROM goals g JOIN versions v/.test(s)) return { rows: [MAINT_GOAL] };
1057
+ if (/INSERT INTO idea_inbox/.test(s)) return { rows: [{ ...IDEA_ROW, suggested_goal_id: 77 }] };
1058
+ return null;
1059
+ });
1060
+ await captureIdea(
1061
+ { title: 'crash on save', kind: 'bug', capturedBy: 9, filerRank: 'xenos', vector: 'discord-bugs' },
1062
+ { pool }
1063
+ );
1064
+ assert.equal(createTaskCalls[0].args.status, 'backlog',
1065
+ 'the report becomes tracked work, but a Metic+ still says go before anyone can take it');
1066
+ });
1067
+
1068
+ await test('homeless bug: NO BUILDING VERSION refuses and leaves NOTHING behind', async () => {
1069
+ // Fail closed. There is nowhere honest to file, and inventing a version is how
1070
+ // idea 1000771 happened — every Discord report entombed in a SHIPPED version.
1071
+ reset();
1072
+ currentVersionImpl = async () => null;
1073
+ const pool = fakePool((s) => (/INSERT INTO idea_inbox/.test(s) ? { rows: [IDEA_ROW] } : null));
1074
+ await assert.rejects(
1075
+ () => captureIdea(
1076
+ { title: 'broken', kind: 'bug', capturedBy: 42, filerRank: 'archon', vector: 'api' },
1077
+ { pool }
1078
+ ),
1079
+ (e) => e && e.code === 'NO_BUILDING_VERSION'
1080
+ );
1081
+ const sqls = seq(pool.queries);
1082
+ assert.ok(sqls.some((q) => /^ROLLBACK$/.test(q)), 'the transaction rolls back');
1083
+ assert.ok(!sqls.some((q) => /^COMMIT$/.test(q)), 'and never commits');
1084
+ assert.equal(createTaskCalls.length, 0, 'no task');
1085
+ assert.ok(!sqls.some((q) => /INSERT INTO idea_inbox/.test(q)),
1086
+ 'and no idea row — the refusal happens before any write');
1087
+ });
1088
+
1089
+ await test('homeless bug: a version whose maintenance goal cannot be made refuses too', async () => {
1090
+ reset();
1091
+ ensureMaintenanceImpl = async () => null; // e.g. the version is not open to work
1092
+ const pool = fakePool((s) => (/INSERT INTO idea_inbox/.test(s) ? { rows: [IDEA_ROW] } : null));
1093
+ await assert.rejects(
1094
+ () => captureIdea(
1095
+ { title: 'broken', kind: 'bug', capturedBy: 42, filerRank: 'archon', vector: 'api' },
1096
+ { pool }
1097
+ ),
1098
+ (e) => e && e.code === 'NO_MAINTENANCE_GOAL' && e.version_id === 'BONGOS-V1'
1099
+ );
1100
+ assert.ok(seq(pool.queries).some((q) => /^ROLLBACK$/.test(q)), 'rolls back');
1101
+ assert.equal(createTaskCalls.length, 0, 'no task');
1102
+ });
1103
+
1104
+ await test('a NAMED goal never consults the maintenance path — the two doors stay separate', async () => {
1105
+ reset();
1106
+ const pool = fakePool(routableHandler());
1107
+ await captureIdea(
1108
+ { title: 'broken', kind: 'bug', goalId: 7, capturedBy: 42, filerRank: 'archon', vector: 'api' },
1109
+ { pool }
1110
+ );
1111
+ assert.deepEqual(maintenanceCalls, [],
1112
+ 'a filer who named a goal gets that goal — the system never second-guesses it');
1113
+ assert.equal(createTaskCalls[0].args.goalId, 7);
1114
+ });
1115
+
891
1116
  summary('idea_routing');
@@ -0,0 +1,191 @@
1
+ // tests/maintenance_goal.mjs
2
+ //
3
+ // The two lifecycle SQL helpers behind the HOMELESS-BUG HOME (task 1003691,
4
+ // ADR 0262): `db.ensureMaintenanceGoal` and `db.currentBuildingVersionId`.
5
+ //
6
+ // WHY THIS FILE EXISTS SEPARATELY FROM tests/idea_routing.mjs. That file drives
7
+ // the routing TRANSACTION, and to do so it registers a stub `lifecycle` port —
8
+ // so `currentBuildingVersionId` and `ensureMaintenanceGoal` are mocked functions
9
+ // there and their actual SQL never executes. Everything that makes these two
10
+ // correct lives in that SQL: the advisory lock that makes find-or-create
11
+ // race-safe, the version-status gate, and the `started_at DESC NULLS LAST`
12
+ // tiebreak. None of it was covered until this file (a grader finding on the task).
13
+ //
14
+ // DB-free, the tests/goal_tier.mjs posture for this same file family: each helper
15
+ // honours the #538 deps injection seam (NODE_ENV==='test'), so a SQL-aware fake
16
+ // client drives it with no live Postgres, and we assert the STATEMENT SHAPE and
17
+ // the bound params — because the shape IS the contract here.
18
+ //
19
+ // Run: node tests/maintenance_goal.mjs
20
+
21
+ import { strict as assert } from 'node:assert';
22
+ import { createRequire } from 'node:module';
23
+ import { makeRunner, makeSqlAwareClient } from './helpers.mjs';
24
+
25
+ process.env.NODE_ENV = 'test';
26
+
27
+ const require = createRequire(import.meta.url);
28
+ const db = require('../modules/lifecycle/db.js');
29
+ const { ensureMaintenanceGoal, currentBuildingVersionId } = db;
30
+ const { maintenanceGoalTitle, generalGoalTitle } = require('../modules/lifecycle/goal-advisory.js');
31
+
32
+ const { test, summary } = makeRunner();
33
+
34
+ function fakePool(handler) {
35
+ const c = makeSqlAwareClient(handler);
36
+ return { query: (sql, params) => c._client.query(sql, params), connect: c.connect, queries: c._client.queries };
37
+ }
38
+ const norm = (s) => String(s).replace(/\s+/g, ' ').trim();
39
+ const find = (queries, re) => queries.find((q) => re.test(norm(q.sql)));
40
+ const seq = (queries) => queries.map((q) => norm(q.sql));
41
+
42
+ const MAINT_ROW = {
43
+ id: 77, version_id: 'BONGOS-V1', title: 'BONGOS-V1 — maintenance', subtitle: null,
44
+ description: 'Where a reported defect lands…', scope_modules: [], status: 'open',
45
+ visibility: 'public', accepting_requests: true, created_by: null,
46
+ succeeded_by_goal_id: null, sort_order: 4, category_id: null, created_at: 'T0', updated_at: 'T0',
47
+ };
48
+
49
+ // =========================================================================
50
+ // The title — the lookup key, so its exact bytes are the contract
51
+ // =========================================================================
52
+
53
+ await test('the maintenance title uses an EM DASH, and is not the catch-all', () => {
54
+ // There is no boolean column saying "this is the maintenance goal": the TITLE is
55
+ // the key. An ASCII hyphen or an en dash is a different, ordinary goal that would
56
+ // never match — the same trap generalGoalTitle's own comment warns about.
57
+ assert.equal(maintenanceGoalTitle('BONGOS-V1'), 'BONGOS-V1 — maintenance');
58
+ assert.ok(maintenanceGoalTitle('X').includes('—'), 'em dash U+2014, spaced');
59
+ assert.ok(!maintenanceGoalTitle('X').includes(' - '), 'never an ASCII hyphen');
60
+ assert.ok(!maintenanceGoalTitle('X').includes('–'), 'never an en dash');
61
+ // And it is deliberately NOT the catch-all BV1.R11 is deleting.
62
+ assert.notEqual(maintenanceGoalTitle('BONGOS-V1'), generalGoalTitle('BONGOS-V1'));
63
+ });
64
+
65
+ // =========================================================================
66
+ // currentBuildingVersionId — the tiebreak, in SQL
67
+ // =========================================================================
68
+
69
+ await test('currentBuildingVersionId: building only, newest first, nulls last, id breaks ties', async () => {
70
+ const pool = fakePool(() => ({ rows: [{ id: 'BONGOS-V1' }] }));
71
+ assert.equal(await currentBuildingVersionId({ pool }), 'BONGOS-V1');
72
+ const q = norm(pool.queries[0].sql);
73
+ assert.match(q, /status = 'building'/, 'only a version open to work can receive a bug');
74
+ assert.match(q, /ORDER BY started_at DESC NULLS LAST, id/,
75
+ 'the pickDefaultVersion rule in SQL — a half-filled row must not decide the answer');
76
+ assert.match(q, /LIMIT 1/);
77
+ });
78
+
79
+ await test('currentBuildingVersionId: nothing building ⇒ null, never a guess', async () => {
80
+ // Fail closed. Inventing a version is idea 1000771's exact failure.
81
+ const pool = fakePool(() => ({ rows: [] }));
82
+ assert.equal(await currentBuildingVersionId({ pool }), null);
83
+ });
84
+
85
+ // =========================================================================
86
+ // ensureMaintenanceGoal — the version gate, the lock, find-or-create
87
+ // =========================================================================
88
+
89
+ await test('ensureMaintenanceGoal: an EXISTING goal is returned, and nothing is inserted', async () => {
90
+ const pool = fakePool((s) => {
91
+ if (/FROM versions/.test(s)) return { rows: [{ status: 'building' }] };
92
+ if (/pg_advisory_xact_lock/.test(s)) return { rows: [{}] };
93
+ if (/^SELECT .* FROM goals WHERE version_id/.test(s.trim())) return { rows: [MAINT_ROW] };
94
+ return null;
95
+ });
96
+ const goal = await ensureMaintenanceGoal('BONGOS-V1', { pool });
97
+ assert.equal(goal.id, 77);
98
+ assert.ok(!find(pool.queries, /INSERT INTO goals/), 'find-or-create must not create when it finds');
99
+ // The lookup is keyed on the exact title, bound as a parameter.
100
+ const lookup = find(pool.queries, /^SELECT .* FROM goals WHERE version_id/);
101
+ assert.deepEqual(lookup.params, ['BONGOS-V1', 'BONGOS-V1 — maintenance']);
102
+ });
103
+
104
+ await test('ensureMaintenanceGoal: the LOCK is taken BEFORE the read, or find-or-create races', async () => {
105
+ // The whole point. `goals` has no unique constraint on (version_id, title), so
106
+ // two bugs reported in the same second would both read "absent" and both insert,
107
+ // leaving the title lookup permanently ambiguous. A lock taken AFTER the read
108
+ // would serialise nothing.
109
+ const pool = fakePool((s) => {
110
+ if (/FROM versions/.test(s)) return { rows: [{ status: 'building' }] };
111
+ if (/pg_advisory_xact_lock/.test(s)) return { rows: [{}] };
112
+ if (/^INSERT INTO goals/.test(s.trim())) return { rows: [MAINT_ROW] };
113
+ if (/^SELECT .* FROM goals WHERE version_id/.test(s.trim())) return { rows: [] };
114
+ return null;
115
+ });
116
+ await ensureMaintenanceGoal('BONGOS-V1', { pool });
117
+ const sqls = seq(pool.queries);
118
+ const iLock = sqls.findIndex((q) => /pg_advisory_xact_lock/.test(q));
119
+ const iRead = sqls.findIndex((q) => /^SELECT .* FROM goals WHERE version_id/.test(q));
120
+ const iIns = sqls.findIndex((q) => /INSERT INTO goals/.test(q));
121
+ assert.ok(iLock >= 0, 'a lock is taken at all');
122
+ assert.ok(iLock < iRead && iRead < iIns, `lock → read → insert (got ${iLock}, ${iRead}, ${iIns})`);
123
+ // xact-scoped: it releases at COMMIT/ROLLBACK, so there is no unlock to forget.
124
+ assert.match(sqls[iLock], /pg_advisory_xact_lock/);
125
+ assert.ok(!sqls.some((q) => /pg_advisory_unlock/.test(q)), 'never a manual unlock');
126
+ });
127
+
128
+ await test('ensureMaintenanceGoal: the created goal is system-owned and UNSCOPED, both bound', async () => {
129
+ const pool = fakePool((s) => {
130
+ if (/FROM versions/.test(s)) return { rows: [{ status: 'building' }] };
131
+ if (/pg_advisory_xact_lock/.test(s)) return { rows: [{}] };
132
+ if (/^INSERT INTO goals/.test(s.trim())) return { rows: [MAINT_ROW] };
133
+ if (/^SELECT .* FROM goals WHERE version_id/.test(s.trim())) return { rows: [] };
134
+ return null;
135
+ });
136
+ const goal = await ensureMaintenanceGoal('BONGOS-V1', { pool });
137
+ assert.equal(goal.id, 77);
138
+ const ins = find(pool.queries, /INSERT INTO goals/);
139
+ const q = norm(ins.sql);
140
+ assert.match(q, /created_by/);
141
+ assert.match(q, /NULL/, 'system-attributed — no owner, no member row (the migration-160 posture)');
142
+ assert.match(q, /'open'/, 'it must be OPEN or routeIdeaToGoal would refuse its own home');
143
+ // scope_modules is BOUND, not a SQL literal — matching sibling createGoal.
144
+ assert.ok(!/'\{\}'/.test(q), 'scope_modules is a bound param, never an inline literal');
145
+ assert.deepEqual(ins.params[3], [], 'and it is empty: a defect can land in any module');
146
+ assert.match(q, /COALESCE\(MAX\(sort_order\) \+ 1, 0\)/, 'sort_order derived, as createGoal does');
147
+ });
148
+
149
+ await test('ensureMaintenanceGoal: a version NOT building is refused before the lock or any write', async () => {
150
+ // Filing a defect into a version closed to work is the 1000771 bug — a report
151
+ // entombed where nobody looks. Checked here rather than trusted from the caller.
152
+ for (const status of ['shipped', 'planning', 'archived', 'nonsense']) {
153
+ const pool = fakePool((s) => {
154
+ if (/FROM versions/.test(s)) return { rows: [{ status }] };
155
+ return null;
156
+ });
157
+ assert.equal(await ensureMaintenanceGoal('GDS-V3', { pool }), null, `status ${status}`);
158
+ const sqls = seq(pool.queries);
159
+ assert.ok(!sqls.some((q) => /INSERT INTO goals/.test(q)), `status ${status}: no write`);
160
+ assert.ok(!sqls.some((q) => /pg_advisory_xact_lock/.test(q)),
161
+ `status ${status}: not even a lock — the refusal is the first thing that happens`);
162
+ }
163
+ });
164
+
165
+ await test('ensureMaintenanceGoal: an absent version, or no versionId at all, refuses', async () => {
166
+ const pool = fakePool((s) => (/FROM versions/.test(s) ? { rows: [] } : null));
167
+ assert.equal(await ensureMaintenanceGoal('NOPE', { pool }), null, 'no such version');
168
+
169
+ const pool2 = fakePool(() => ({ rows: [{ status: 'building' }] }));
170
+ assert.equal(await ensureMaintenanceGoal(null, { pool: pool2 }), null, 'null versionId');
171
+ assert.equal(pool2.queries.length, 0, 'and it short-circuits before touching the DB');
172
+ });
173
+
174
+ await test('ensureMaintenanceGoal: runs on deps.client so it commits with the route, or not at all', async () => {
175
+ // A goal created on its own connection would outlive a rolled-back route as an
176
+ // empty goal nobody asked for. The client seam is what prevents that.
177
+ const c = makeSqlAwareClient((s) => {
178
+ if (/FROM versions/.test(s)) return { rows: [{ status: 'building' }] };
179
+ if (/pg_advisory_xact_lock/.test(s)) return { rows: [{}] };
180
+ if (/^SELECT .* FROM goals WHERE version_id/.test(s.trim())) return { rows: [MAINT_ROW] };
181
+ return null;
182
+ });
183
+ const client = c._client;
184
+ const poolThatMustNotBeUsed = fakePool(() => { throw new Error('the module pool was used instead of the tx client'); });
185
+ const goal = await ensureMaintenanceGoal('BONGOS-V1', { client, pool: poolThatMustNotBeUsed });
186
+ assert.equal(goal.id, 77);
187
+ assert.ok(client.queries.length >= 2, 'every statement went to the caller\'s client');
188
+ assert.equal(poolThatMustNotBeUsed.queries.length, 0);
189
+ });
190
+
191
+ summary('maintenance_goal');
@@ -186,7 +186,8 @@ test('no FIFTH vector has appeared', () => {
186
186
  // which case delete its opt-in and this line, do not extend the list.
187
187
  const roster = new Set([...OPT_INS.map(([f]) => f), 'modules/lifecycle/db-tasks.js']);
188
188
  const suspects = [
189
- 'modules/ideas/inbox.js', 'modules/lifecycle/routes/goals.js',
189
+ 'modules/ideas/inbox.js', 'modules/ideas/homeless-home.js',
190
+ 'modules/lifecycle/routes/goals.js',
190
191
  'modules/lifecycle/routes/task-write-routes.js',
191
192
  ];
192
193
  for (const f of suspects) {