@bongos/core 1.19.453 → 1.19.455

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.
@@ -0,0 +1,394 @@
1
+ // tests/filing_abuse_matrix.mjs — the FILING wall's ABUSE MATRIX (BV1.R06,
2
+ // task 1003073, goal 1000071 "Idea routing"). Criterion C4: "filing can't outrun
3
+ // rank — scope walls and rank floors hold on routed tasks."
4
+ //
5
+ // THE SISTER FILE. tests/goal_abuse_matrix.mjs is the same document for the
6
+ // AUTHORING wall — POST /goals/:id/tasks, where a joined member deliberately
7
+ // writes a task. This one covers the other door goal 1000071 opened: FILING, where
8
+ // somebody reports a thing and the system turns it into work on their behalf.
9
+ // Authoring is a member's privilege and is gated by membership; filing is open to
10
+ // anyone by design (criterion C5: "the filer never has to know the goal map"), so
11
+ // the safety has to come from somewhere else. This file is where "somewhere else"
12
+ // is written down and made executable.
13
+ //
14
+ // THE ATTACKER, unless a row says otherwise: a Xenos with a valid session, filing
15
+ // as fast as they can, naming whatever goal they like.
16
+ //
17
+ // # attack vector → server response
18
+ // ── ─────────────────────────────────────────────────── ────────────────────
19
+ // F1 file a bug naming an ordinary goal → routes, but 'backlog'
20
+ // F2 file a bug naming a PROTECTED-scope goal → refused (protected_scope)
21
+ // F3 claim a higher rank in the request body → ignored; rank is from auth
22
+ // F4 file a non-bug kind hoping it hard-lands → 'backlog', every kind
23
+ // F5 file with a junk kind → refused upstream (BAD_KIND)
24
+ // F5b …and if one slipped that validator → 'backlog' (fail-closed)
25
+ // F6 file with a junk/absent RANK → 'backlog' (fail-closed)
26
+ // F7 a criterion-proposal or decision (judgment kinds) → never routes at all
27
+ // F8 a Full Idea naming a goal → never routes (Board Room's)
28
+ // F9 MASS-file through Discord #bugs → capped per rolling hour
29
+ // F10 make the rate-limit COUNT fail, to slip past the cap → denied (fail-closed)
30
+ // F11 file through Discord #bugs at all, as a sub-Metic → 'backlog', never 'ready'
31
+ // F12 file through Discord #ideas naming a goal → that vector cannot route
32
+ // F15 reach #bugs' old direct-createTask bypass → it no longer exists
33
+ // F16 file through #bugs as an UNLINKED author → an inbox row, never work
34
+ // F13 declare touches[] outside the goal to widen blast area → the real wall refuses
35
+ // F14 file against a SHIPPED version's goal → refused (version not building)
36
+ // P1 a Metic+ files a bug in an ordinary goal → 'ready' (the C1 grant)
37
+ // P2 a Metic+ files into protected scope → allowed, floored to metic
38
+ //
39
+ // AND THE ANTI-DRIFT PIN (the point of the file, really). The rows above are true
40
+ // of the capture vectors that exist TODAY. Nothing stops a fifth being added
41
+ // next month that quietly skips all of it — which is exactly how the 1000771 bug
42
+ // got in. So the last section ENUMERATES the vectors from source and fails when
43
+ // the roster changes, forcing a new vector's author to come here and add its row.
44
+ //
45
+ // PURE: the decision cores only (decideLanding, authorizeRoutedLanding,
46
+ // rateLimitPerHour) — no DB, no network. The transaction that
47
+ // obeys them is proven in tests/idea_routing.mjs; the end-to-end attribution trail
48
+ // in tests/idea_route_attribution.mjs.
49
+ //
50
+ // MUTATION-VERIFIED (task 1003073). Each of these was applied to the source and the
51
+ // file confirmed RED before reverting, so the matrix is known to have teeth:
52
+ //
53
+ // METIC_PLUS widened to include xenos ....................... 4 of 23 fail
54
+ // the protected-scope refusal short-circuited to false ...... 1 of 23 fail
55
+ // NEVER_ROUTE_KINDS emptied ................................. 1 of 23 fail
56
+ // #bugs READY_RANKS widened to include thetes ............... 2 of 23 fail
57
+ // the rate cap made env-uncappable (negatives accepted) ..... 1 of 23 fail
58
+ //
59
+ // Run: node tests/filing_abuse_matrix.mjs
60
+
61
+ import { strict as assert } from 'node:assert';
62
+ import { createRequire } from 'node:module';
63
+ import { readFileSync } from 'node:fs';
64
+ import { fileURLToPath } from 'node:url';
65
+ import path from 'node:path';
66
+ import { makeRunner } from './helpers.mjs';
67
+
68
+ process.env.NODE_ENV = 'test';
69
+
70
+ const require = createRequire(import.meta.url);
71
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
72
+ const read = (rel) => readFileSync(path.join(ROOT, rel), 'utf8');
73
+
74
+ const routing = require('../modules/ideas/routing.js');
75
+ const { decideLanding, authorizeRoutedLanding, routedPriority, PROTECTED_SCOPE_FLOOR } = routing;
76
+ const bugs = require('../modules/discord/discord-bugs.js');
77
+ const { rateLimitPerHour } = bugs;
78
+ const { scopeIncludesProtected, isModuleKey } = require('../src/bongos/module-scope-map.js');
79
+
80
+ const { test, summary } = makeRunner();
81
+
82
+ // Ranks below the trust boundary. 'thetes' and 'xenos' are the real sub-Metic
83
+ // tiers; the rest are the shapes a hostile or buggy caller actually produces.
84
+ const SUB_METIC = ['xenos', 'thetes', null, undefined, '', 'METIC ', 'admin', 'root', 'archon ', 42, {}];
85
+ const TRUSTED = ['metic', 'archon', 'Metic', 'ARCHON'];
86
+
87
+ // A routed filing lands claimable ONLY when decideLanding says 'ready' AND
88
+ // authorizeRoutedLanding lets it through. Both, because either alone is half the
89
+ // gate — this helper is the composition the server actually runs.
90
+ function landing({ kind = 'bug', filerRank = 'xenos', goalId = 5, grade = null, scopeProtected = false, scopeCheck = null } = {}) {
91
+ const verdict = decideLanding({ goalId, kind, grade, filerRank });
92
+ if (!verdict.route) return { landed: false, status: null, reason: verdict.reason };
93
+ const authorized = authorizeRoutedLanding({ landing: verdict, scopeProtected, scopeCheck, filerRank });
94
+ if (!authorized.ok) return { landed: false, status: null, refused: authorized.code };
95
+ return { landed: true, status: authorized.status, requiresRank: authorized.requiresRank, reason: verdict.reason };
96
+ }
97
+
98
+ // =========================================================================
99
+ // Fixtures anchored in real facts, so the matrix cannot drift into fiction.
100
+ // =========================================================================
101
+
102
+ await test('fixtures: ideas is a real UNprotected module, lifecycle a protected one', () => {
103
+ assert.ok(isModuleKey('ideas') && isModuleKey('lifecycle'), 'both fixture modules are real');
104
+ assert.equal(scopeIncludesProtected(['ideas']), false, 'an ordinary goal scope');
105
+ assert.equal(scopeIncludesProtected(['lifecycle']), true, 'a protected one — the matrix depends on this');
106
+ });
107
+
108
+ // =========================================================================
109
+ // F1–F8 — the filing matrix: a sub-Metic can report anything and set nothing
110
+ // =========================================================================
111
+
112
+ await test('[F1] a Xenos bug in an ordinary goal routes, but lands at backlog — never claimable', () => {
113
+ const r = landing({ kind: 'bug', filerRank: 'xenos' });
114
+ assert.equal(r.landed, true, 'the report is accepted — refusing it would be the triage tax this goal removes');
115
+ assert.equal(r.status, 'backlog', 'and it waits for a nod');
116
+ assert.equal(r.reason, 'bug_awaiting_nod');
117
+ });
118
+
119
+ await test('[F2] a Xenos bug in a PROTECTED-scope goal is refused outright, not merely floored', () => {
120
+ const r = landing({ kind: 'bug', filerRank: 'xenos', scopeProtected: true });
121
+ assert.equal(r.landed, false, 'no row is minted in the permission/ship/deploy core queue at all');
122
+ assert.equal(r.refused, 'protected_scope');
123
+ });
124
+
125
+ await test('[F3] rank comes from AUTH, never the body — the route never reads a body rank', () => {
126
+ // The decision core is handed a rank; the guarantee that it is the authenticated
127
+ // one lives at the call site, so assert it there, in source.
128
+ const src = read('modules/ideas/routes/inbox.js');
129
+ assert.match(src, /filerRank:\s*req\.builder\?\.rank/,
130
+ 'POST /inbox must pass the AUTHENTICATED rank');
131
+ assert.ok(!/filerRank:\s*body\./.test(src), 'and must never take one from the request body');
132
+ assert.match(src, /capturedBy:\s*req\.builder\.id/, 'the filer identity is likewise from auth');
133
+ });
134
+
135
+ await test('[F4] no NON-BUG kind hard-lands, whoever files it — a filer cannot set the agenda', () => {
136
+ for (const kind of ['feature', 'cleanup', 'refactor', 'infra', 'chore', 'docs']) {
137
+ for (const rank of [...SUB_METIC, ...TRUSTED]) {
138
+ const r = landing({ kind, filerRank: rank });
139
+ assert.equal(r.status, 'backlog', `${kind} filed by ${JSON.stringify(rank)} must soft-land`);
140
+ }
141
+ }
142
+ });
143
+
144
+ await test('[F5] a junk kind never even reaches the matrix — captureIdea refuses it first', () => {
145
+ // The real wall for a bogus kind is upstream of routing: captureIdea resolves
146
+ // kind (or auto-classifies an absent one) and then refuses anything outside
147
+ // VALID_KINDS with BAD_KIND, before any write. So the matrix is never asked
148
+ // about 'BUG' or '__proto__' in production.
149
+ const src = read('modules/ideas/inbox.js');
150
+ assert.match(src, /const resolvedKind = kind \|\| classify\(/,
151
+ 'an absent kind is auto-classified, never passed through as null');
152
+ assert.match(src, /VALID_KINDS\.includes\(resolvedKind\)/, 'and an unknown one is rejected');
153
+ assert.match(src, /BAD_KIND/, 'by a named refusal');
154
+ });
155
+
156
+ await test('[F5b] and if one DID reach the matrix, it soft-lands — belt to that braces', () => {
157
+ // decideLanding called directly: no helper default can stand in for the input.
158
+ // Only the exact string 'bug' hard-lands; every near-miss casing and shape is
159
+ // a soft landing, so a kind that slipped a future validator still cannot make
160
+ // claimable work.
161
+ for (const kind of [null, undefined, '', 'BUG', 'bug ', 'Bug', 'bugs', '__proto__', 0, {}]) {
162
+ const v = decideLanding({ goalId: 5, kind, grade: null, filerRank: 'archon' });
163
+ assert.equal(v.route, true, 'it still finds a home');
164
+ assert.equal(v.status, 'backlog', `kind ${JSON.stringify(kind)} must not hard-land`);
165
+ }
166
+ assert.equal(decideLanding({ goalId: 5, kind: 'bug', filerRank: 'archon' }).status, 'ready',
167
+ 'only the exact kind hard-lands — otherwise this test would pass vacuously');
168
+ });
169
+
170
+ await test('[F6] a junk or absent RANK never reads as trusted (the fail-closed posture)', () => {
171
+ for (const rank of SUB_METIC) {
172
+ const r = landing({ kind: 'bug', filerRank: rank });
173
+ assert.equal(r.status, 'backlog', `rank ${JSON.stringify(rank)} must not hard-land a bug`);
174
+ }
175
+ });
176
+
177
+ await test('[F7] judgment kinds never route, so they cannot be laundered into work', () => {
178
+ for (const kind of ['criterion-proposal', 'decision']) {
179
+ for (const rank of [...SUB_METIC, ...TRUSTED]) {
180
+ const r = landing({ kind, filerRank: rank });
181
+ assert.equal(r.landed, false, `${kind} must stay in the inbox`);
182
+ assert.equal(r.reason, 'judgment_kind');
183
+ }
184
+ }
185
+ });
186
+
187
+ await test('[F8] a Full Idea never routes, even a bug-kinded one from an Archon', () => {
188
+ const r = landing({ kind: 'bug', filerRank: 'archon', grade: 'full' });
189
+ assert.equal(r.landed, false, "a Full Idea is the Board Room's subject, not its filer's task");
190
+ assert.equal(r.reason, 'full_idea_never_routes');
191
+ });
192
+
193
+ // =========================================================================
194
+ // F9–F11 — MASS FILING through Discord #bugs
195
+ // =========================================================================
196
+
197
+ await test('[F9] the #bugs vector is capped per builder per rolling hour, and the cap is sane', () => {
198
+ const n = rateLimitPerHour({});
199
+ assert.ok(Number.isInteger(n) && n > 0, 'a real positive cap exists by default');
200
+ assert.ok(n <= 50, `the default cap bounds a flood (got ${n})`);
201
+ // A hostile env cannot uncap it with a non-number, a negative, or zero.
202
+ for (const bad of ['', 'lots', '-1', '0', 'Infinity', null, undefined, '1e999']) {
203
+ const got = rateLimitPerHour({ DISCORD_BUGS_RATE_PER_HOUR: bad });
204
+ assert.ok(Number.isInteger(got) && got > 0 && got <= 50,
205
+ `env ${JSON.stringify(bad)} must fall back to a finite cap, got ${got}`);
206
+ }
207
+ });
208
+
209
+ await test('[F10] a rate-limit COUNT failure DENIES — you cannot break the counter to slip the cap', () => {
210
+ // The behaviour is proven end-to-end in tests/discord_bugs.mjs; pinned here in
211
+ // source because "fail closed" is the property this matrix is about, and a
212
+ // future refactor that flips the catch to allow would pass every other test.
213
+ const src = read('modules/discord/discord-bugs.js');
214
+ assert.match(src, /fail-closed/i, 'the deny-on-count-error intent is stated');
215
+ assert.match(src, /rate_limit_count_error/,
216
+ 'and audited under its own reason, so a broken counter is visible rather than silent');
217
+ });
218
+
219
+ await test('[F11] #bugs lands a sub-Metic at backlog and only Metic+ at ready', () => {
220
+ // Since R11 this is the SAME rule as F1/P1 — the channel has no landing logic of
221
+ // its own. Asserted through the matrix, which is now the only place it exists.
222
+ for (const rank of SUB_METIC) {
223
+ assert.equal(landing({ kind: 'bug', filerRank: rank }).status, 'backlog',
224
+ `${JSON.stringify(rank)} must not reach ready`);
225
+ }
226
+ for (const rank of TRUSTED) {
227
+ assert.equal(landing({ kind: 'bug', filerRank: rank }).status, 'ready',
228
+ `${rank} is the C1 straight-through grant`);
229
+ }
230
+ });
231
+
232
+ await test('[F9+F11] the flood, composed: at the cap, EVERY landed row is still backlog', () => {
233
+ const cap = rateLimitPerHour({});
234
+ const landedStatuses = [];
235
+ for (let i = 0; i < cap + 25; i++) {
236
+ if (i >= cap) continue; // the counter refuses past the cap (F9/F10)
237
+ landedStatuses.push(landing({ kind: 'bug', filerRank: 'xenos' }).status);
238
+ }
239
+ assert.equal(landedStatuses.length, cap, 'the cap bounds how many rows exist at all');
240
+ assert.ok(landedStatuses.every((s) => s === 'backlog'),
241
+ 'and not one of them is claimable — a flood costs a Metic+ nothing but a queue to ignore');
242
+ });
243
+
244
+ // =========================================================================
245
+ // F12 — the #ideas vector cannot route at all
246
+ // =========================================================================
247
+
248
+ await test('[F12] Discord #ideas never routes: it passes no goal, so every filing is an inbox row', () => {
249
+ const src = read('modules/discord/discord-inbound.js');
250
+ const call = src.slice(src.indexOf('deps.captureIdea({'));
251
+ assert.ok(call, 'the capture call is findable');
252
+ const body = call.slice(0, call.indexOf('});') + 3);
253
+ assert.ok(!/goalId/.test(body), 'no goalId is passed, so decideLanding returns no_goal');
254
+ assert.ok(!/filerRank/.test(body), 'and no rank, so it could not hard-land even if a goal appeared');
255
+ // Belt and braces: prove the decision, not just the absence.
256
+ assert.equal(decideLanding({ goalId: null, kind: 'bug', filerRank: 'archon' }).route, false,
257
+ 'no goal ⇒ the inbox, which is the default path every goal-unaware vector keeps');
258
+ });
259
+
260
+ // =========================================================================
261
+ // F13–F14 — the walls that are NOT the matrix's own
262
+ // =========================================================================
263
+
264
+ await test('[F13] a declared touches[] outside the goal is refused by the real scope wall, verbatim', () => {
265
+ // authorizeRoutedLanding passes the wall's verdict through unchanged — the same
266
+ // refusal an authored task gets, so there is only one definition of out-of-scope.
267
+ const refusal = { ok: false, code: 'out_of_scope', reason: 'nope', details: { offender: 'src/bongos/db.js' } };
268
+ const r = landing({ kind: 'bug', filerRank: 'archon', scopeCheck: refusal });
269
+ assert.equal(r.landed, false, 'even an Archon is bounded by the goal scope');
270
+ assert.equal(r.refused, 'out_of_scope');
271
+ });
272
+
273
+ await test('[F14] a goal on a non-building version cannot receive routed work (the 1000771 wall)', () => {
274
+ // Enforced inside the transaction, so pin it at its source: the locked read
275
+ // carries the version status and refuses anything but 'building'.
276
+ const src = read('modules/ideas/inbox.js');
277
+ assert.match(src, /VERSION_NOT_BUILDING/, 'the refusal exists and is named');
278
+ assert.match(src, /goal\.version_status !== 'building'/,
279
+ "and it admits ONLY a building version — 'shipped' is the tomb 1000771 was filed over");
280
+ });
281
+
282
+ // =========================================================================
283
+ // P1–P2 — the grants, so the matrix is not vacuously "everything is refused"
284
+ // =========================================================================
285
+
286
+ await test('[P1] a Metic+ bug in an ordinary goal IS claimable immediately — criterion C1 still holds', () => {
287
+ const r = landing({ kind: 'bug', filerRank: 'metic' });
288
+ assert.equal(r.status, 'ready', 'the whole point of the goal: no triage stop for a trusted bug report');
289
+ assert.equal(r.requiresRank, null, 'and no floor is imposed — createTask derives it as for any vector');
290
+ });
291
+
292
+ await test('[P2] a Metic+ CAN file into protected scope, and the task is floored to metic', () => {
293
+ const r = landing({ kind: 'bug', filerRank: 'archon', scopeProtected: true });
294
+ assert.equal(r.landed, true);
295
+ assert.equal(r.requiresRank, PROTECTED_SCOPE_FLOOR, 'so no sub-Metic can claim it however it got there');
296
+ });
297
+
298
+ await test('priority cannot be weaponised: an out-of-band suggestion is clamped, never honoured', () => {
299
+ for (const p of [99, -5, 0, 6, 'urgent', null, Infinity, 1.5]) {
300
+ const got = routedPriority(p);
301
+ assert.ok(Number.isInteger(got) && got >= 1 && got <= 5, `priority ${JSON.stringify(p)} → ${got}`);
302
+ }
303
+ assert.equal(routedPriority(1), 1, 'a sane suggestion still rides through');
304
+ });
305
+
306
+ // =========================================================================
307
+ // F15–F16 — the #bugs bypass is retired (task 1003067 / BV1.R11)
308
+ // =========================================================================
309
+
310
+ await test('[F15] #bugs mints NO task of its own — the bypass that lost attribution is gone', () => {
311
+ const src = read('modules/discord/discord-bugs.js');
312
+ const code = src.split('\n').filter((l) => !l.trim().startsWith('//')).join('\n');
313
+ assert.ok(!/\bdeps\.createTask\(/.test(code),
314
+ 'the vector must file through ideas.capture, so every report leaves an idea row + lineage');
315
+ assert.match(code, /deps\.captureIdea\(/, 'and it does');
316
+ });
317
+
318
+ await test('[F15b] #bugs names NO version — a vector that cannot name one cannot name a dead one', () => {
319
+ const src = read('modules/discord/discord-bugs.js');
320
+ const code = src.split('\n').filter((l) => !l.trim().startsWith('//')).join('\n');
321
+ // This is idea 1000771 in one assertion: the old default was 'GDS-V3', shipped,
322
+ // so every bug reported here landed in a closed version's catch-all.
323
+ assert.ok(!/GDS-V3|DEFAULT_BUGS_VERSION_ID|DISCORD_BUGS_VERSION_ID/.test(code),
324
+ 'no version id may be hardcoded or env-configured on this path any more');
325
+ assert.ok(!/versionId:/.test(code), 'and none is passed into the capture');
326
+ });
327
+
328
+ await test('[F16] an unlinked #bugs author gets an inbox row and CANNOT reach work', () => {
329
+ // The report is kept (it used to be dropped), but with no rank and no goal, so
330
+ // the matrix has nothing to route: structurally incapable of becoming a task.
331
+ const noRank = decideLanding({ goalId: null, kind: 'bug', filerRank: null });
332
+ assert.equal(noRank.route, false, 'no goal ⇒ the inbox');
333
+ // And even if a goal somehow reached it, an unknown filer never hard-lands:
334
+ const withGoal = decideLanding({ goalId: 5, kind: 'bug', filerRank: null });
335
+ assert.equal(withGoal.status, 'backlog', 'never claimable on an unknown filer');
336
+ const src = read('modules/discord/discord-bugs.js');
337
+ const code = src.split('\n').filter((l) => !l.trim().startsWith('//')).join('\n');
338
+ assert.match(code, /if \(builder\) \{[\s\S]{0,200}suggestGoalForCapture/,
339
+ 'a goal is only ever sought for a LINKED builder');
340
+ });
341
+
342
+ // =========================================================================
343
+ // THE ANTI-DRIFT PIN — a fifth vector cannot arrive unaudited
344
+ // =========================================================================
345
+
346
+ // Every place a FILING becomes work, and the row above that covers it. Adding a
347
+ // vector without adding it here is what this pin is for: the 1000771 bug got in
348
+ // because #bugs was written as its own path and nobody re-read the matrix.
349
+ const KNOWN_FILING_VECTORS = {
350
+ 'modules/ideas/inbox.js': 'routeIdeaToGoal — the ONE transaction every routed filing runs (F1–F8, F13, F14)',
351
+ 'modules/ideas/routes/inbox.js': 'POST /inbox — the web + CLI door onto it (F3)',
352
+ 'modules/discord/discord-inbound.js': 'Discord #ideas — files an idea, never routes (F12)',
353
+ 'modules/discord/discord-bugs.js': 'Discord #bugs — files through ideas.capture since R11 (F9–F11, F15, F16)',
354
+ };
355
+
356
+ await test('[pin] every declared vector still reaches a task-minting path', () => {
357
+ const found = Object.keys(KNOWN_FILING_VECTORS).filter((rel) => /captureIdea\(|createTask\(/.test(read(rel)));
358
+ assert.deepEqual(found.sort(), Object.keys(KNOWN_FILING_VECTORS).sort(),
359
+ 'a vector that no longer files needs its row retired, not left as decoration');
360
+ });
361
+
362
+ await test('[pin] no OTHER module file mints a task from a filing without a row here', () => {
363
+ // Scan the two filing-facing modules for task-minting call sites. A new file
364
+ // that creates work from a report has to be classified here before it ships.
365
+ const { execFileSync } = require('node:child_process');
366
+ const tracked = execFileSync('git', ['ls-files', 'modules/ideas/*.js', 'modules/ideas/**/*.js', 'modules/discord/*.js', 'modules/discord/**/*.js'], { cwd: ROOT, encoding: 'utf8' })
367
+ .split('\n').map((l) => l.trim()).filter(Boolean);
368
+
369
+ const minters = tracked.filter((rel) => {
370
+ if (rel.includes('/tests/') || rel.endsWith('.mjs')) return false;
371
+ const src = read(rel);
372
+ // A minter CALLS a task-create; declaring/forwarding the dep is not minting.
373
+ return /(?:deps|lifecycle|db|api)\.createTask\(|resolve\('lifecycle'\)[\s\S]{0,80}createTask\(/.test(src);
374
+ });
375
+
376
+ const unaudited = minters.filter((rel) => !(rel in KNOWN_FILING_VECTORS));
377
+ assert.deepEqual(unaudited, [],
378
+ `a new filing vector must be added to KNOWN_FILING_VECTORS and given a matrix row: ${unaudited.join(', ')}`);
379
+ });
380
+
381
+ await test('[pin] there is ONE trusted-tier definition, and #bugs no longer keeps a copy', () => {
382
+ // This pin used to assert that two spellings of "Metic+" AGREED — routing.js and
383
+ // discord-bugs.js each carried one. R11 deleted the second, which is the better
384
+ // outcome: agreement you have to test for is a duplication you could have removed.
385
+ const routingSrc = read('modules/ideas/routing.js');
386
+ assert.match(routingSrc, /METIC_PLUS = new Set\(\['metic', 'archon'\]\)/,
387
+ 'the one definition, in the matrix');
388
+ const bugsCode = read('modules/discord/discord-bugs.js')
389
+ .split('\n').filter((l) => !l.trim().startsWith('//')).join('\n');
390
+ assert.ok(!/READY_RANKS/.test(bugsCode), '#bugs must not reintroduce a local rank set');
391
+ assert.ok(!/decideBugLanding/.test(bugsCode), 'nor a local landing decision');
392
+ });
393
+
394
+ summary();
@@ -24,7 +24,7 @@ import { makeRunner } from './helpers.mjs';
24
24
 
25
25
  process.env.NODE_ENV = 'test';
26
26
  const require = createRequire(import.meta.url);
27
- const { suggestGoals, goalSuggestHandler, SUGGEST_LIMIT } = require('../modules/lifecycle/goal-suggest.js');
27
+ const { suggestGoals, suggestGoalForCapture, goalSuggestHandler, SUGGEST_LIMIT } = require('../modules/lifecycle/goal-suggest.js');
28
28
  const { test, summary } = makeRunner();
29
29
 
30
30
  const V = 'BONGOS-V1';
@@ -192,4 +192,110 @@ await test('the handler clamps limit into a sane range', async () => {
192
192
  }
193
193
  });
194
194
 
195
+ // =========================================================================
196
+ // suggestGoalForCapture — ONE goal, or none, for a filing with nobody to choose
197
+ // (task 1003067 / BV1.R11). The HTTP suggester offers a human five options; a
198
+ // Discord message has no human in the loop, so this collapses to a single
199
+ // confident answer and declines whenever "confident" is not available.
200
+ // =========================================================================
201
+
202
+ const fakeDb = ({ versions = [], goals = [], throws = null, calls = null } = {}) => ({
203
+ listVersions: async () => { if (throws === 'versions') throw new Error('boom'); return versions; },
204
+ listGoals: async (args) => {
205
+ if (calls) calls.push(args);
206
+ if (throws === 'goals') throw new Error('boom');
207
+ return goals;
208
+ },
209
+ });
210
+
211
+ const BUILDING = { id: 'BONGOS-V1', status: 'building' };
212
+ const SHIPPED = { id: 'GDS-V3', status: 'shipped' };
213
+ const HALL_GOAL = { id: 81, title: 'Builders hall in the world', scope_modules: ['hall-ui'], version_id: 'BONGOS-V1' };
214
+ const ECON_GOAL = { id: 78, title: 'Project money out', scope_modules: ['economy'], version_id: 'BONGOS-V1' };
215
+
216
+ await test('suggestGoalForCapture: the one building version, and the goal the words match', async () => {
217
+ const got = await suggestGoalForCapture(
218
+ { title: 'the hall sidebar collapses on mobile', body: '' },
219
+ { db: fakeDb({ versions: [BUILDING, SHIPPED], goals: [ECON_GOAL, HALL_GOAL] }) }
220
+ );
221
+ assert.equal(got.id, 81, 'the hall goal, not the economy one');
222
+ assert.equal(got.version_id, 'BONGOS-V1', 'and the version comes from the version row, never a hardcode');
223
+ assert.ok(got.score > 0);
224
+ });
225
+
226
+ await test('suggestGoalForCapture: a SHIPPED version is never the home (idea 1000771)', async () => {
227
+ const got = await suggestGoalForCapture(
228
+ { title: 'the hall sidebar collapses', body: '' },
229
+ { db: fakeDb({ versions: [SHIPPED], goals: [HALL_GOAL] }) }
230
+ );
231
+ assert.equal(got, null, 'nothing is open to work, so there is no home — the filing goes to the inbox');
232
+ });
233
+
234
+ // This instance runs BONGOS-V1 and CB-V1 concurrently. An earlier cut declined
235
+ // whenever more than one version was open, which would have returned null for
236
+ // every report ever filed — a dead feature that looked like prudence. The goal
237
+ // carries the version, so rank across all of them and let the winner say.
238
+ await test('suggestGoalForCapture: TWO open versions ⇒ rank across BOTH, winner names its version', async () => {
239
+ const CB_GOAL = { id: 99, title: 'The front door', scope_modules: ['public-landing'], version_id: 'CB-V1' };
240
+ const got = await suggestGoalForCapture(
241
+ { title: 'the front door hero is broken', body: '' },
242
+ { db: fakeDb({ versions: [BUILDING, { id: 'CB-V1', status: 'building' }], goals: [HALL_GOAL, CB_GOAL] }) }
243
+ );
244
+ assert.equal(got.id, 99, 'the goal on the OTHER open version can win');
245
+ assert.equal(got.version_id, 'CB-V1', 'and it reports its own version, not the first one open');
246
+ });
247
+
248
+ await test('suggestGoalForCapture: a goal on a CLOSED version is never a home, even if it matches best', async () => {
249
+ const DEAD_GOAL = { id: 12, title: 'the hall sidebar and hall chrome', scope_modules: ['hall-ui'], version_id: 'GDS-V3' };
250
+ const got = await suggestGoalForCapture(
251
+ { title: 'the hall sidebar collapses', body: '' },
252
+ { db: fakeDb({ versions: [BUILDING, SHIPPED], goals: [DEAD_GOAL, ECON_GOAL] }) }
253
+ );
254
+ assert.equal(got, null, 'the better-matching goal sits on a shipped version — idea 1000771 exactly');
255
+ });
256
+
257
+ await test('suggestGoalForCapture: asks for open goals across ALL versions, not one', async () => {
258
+ const calls = [];
259
+ await suggestGoalForCapture(
260
+ { title: 'the hall sidebar collapses', body: '' },
261
+ { db: fakeDb({ versions: [BUILDING], goals: [HALL_GOAL], calls }) }
262
+ );
263
+ assert.equal(calls.length, 1, 'one query, not one per version');
264
+ assert.equal(calls[0].status, 'open');
265
+ assert.equal(calls[0].versionId, undefined, 'unscoped — the open-version filter is applied in JS');
266
+ });
267
+
268
+ await test('suggestGoalForCapture: no confident match ⇒ null, not the first goal dressed up', async () => {
269
+ const got = await suggestGoalForCapture(
270
+ { title: 'zzzz qqqq nothing matches this', body: '' },
271
+ { db: fakeDb({ versions: [BUILDING], goals: [HALL_GOAL, ECON_GOAL] }) }
272
+ );
273
+ assert.equal(got, null);
274
+ });
275
+
276
+ await test('suggestGoalForCapture: empty draft ⇒ null without touching the db', async () => {
277
+ let touched = false;
278
+ const db = { listVersions: async () => { touched = true; return [BUILDING]; }, listGoals: async () => [] };
279
+ assert.equal(await suggestGoalForCapture({ title: ' ', body: '' }, { db }), null);
280
+ assert.equal(touched, false, 'nothing to rank against ⇒ no query');
281
+ });
282
+
283
+ await test('suggestGoalForCapture: a db failure SHRUGS — it never throws at a bug report', async () => {
284
+ for (const throws of ['versions', 'goals']) {
285
+ const got = await suggestGoalForCapture(
286
+ { title: 'the hall sidebar collapses', body: '' },
287
+ { db: fakeDb({ versions: [BUILDING], goals: [HALL_GOAL], throws }) }
288
+ );
289
+ assert.equal(got, null, `a ${throws} failure degrades to "no home", never to a lost report`);
290
+ }
291
+ });
292
+
293
+ await test('suggestGoalForCapture: the catch-all is never offered as a home', async () => {
294
+ const got = await suggestGoalForCapture(
295
+ { title: 'general problem in BONGOS', body: '' },
296
+ { db: fakeDb({ versions: [BUILDING], goals: [{ id: 3, title: 'BONGOS-V1 — general', scope_modules: [], version_id: 'BONGOS-V1' }] }) }
297
+ );
298
+ assert.equal(got, null, 'a bucket is not a workspace — dropped for EVERY open version, not just one');
299
+ });
300
+
195
301
  summary();