@bongos/core 1.19.452 → 1.19.454

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.452",
6
- "core_contract": "1.19.452",
7
- "source_commit": "a59941e00bdc972cede45d82263d6bb9a334d59d",
5
+ "core_version": "1.19.454",
6
+ "core_contract": "1.19.454",
7
+ "source_commit": "4b419dfb5f2bb554648dbaaa7cf5e6c1eeb05f52",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-01T19:56:39.788Z",
9
+ "built_at": "2026-09-01T20:23:17.614Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 371,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 1788,
14
+ "functional_verbatim": 1790,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2183,
20
- "tree_sha256": "d6e755d26822b3fbb320367835d28cb2e6e317309a6da4985730541e202fec63",
19
+ "file_count": 2185,
20
+ "tree_sha256": "9497797c979a0e4da67c6094cf2bd050cde6f532f00a8890f9dbe4c0a4065527",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -2092,7 +2092,7 @@
2092
2092
  {
2093
2093
  "path": "docs/module-api-changelog.md",
2094
2094
  "mode": "0000644",
2095
- "sha256": "1b44d9a28067aee44f6461a6ec2ff31dcbcd1d92aba9b5e5d53ac97dcb782cbe"
2095
+ "sha256": "1f9bf7b7d0d9e7c4ea7bc03285e12b1859443c4197dd241e3ee1aec331a32507"
2096
2096
  },
2097
2097
  {
2098
2098
  "path": "docs/modules-contract.md",
@@ -6302,12 +6302,12 @@
6302
6302
  {
6303
6303
  "path": "package-lock.json",
6304
6304
  "mode": "0000644",
6305
- "sha256": "a24646cf791a4ef41bf5fbca236cf24a27c9d05ba0f4419483054f38b849688e"
6305
+ "sha256": "3cf929a4fd6e48bffbaacc887e14004384e99789cf49751816cca91e5e12b31b"
6306
6306
  },
6307
6307
  {
6308
6308
  "path": "package.json",
6309
6309
  "mode": "0000644",
6310
- "sha256": "76ba0c0adc1241f0051796e43d8519486163d142b23a57704d60aabd56806cb6"
6310
+ "sha256": "028b1cb8d72191904fc916ca6e4c4d3b819c4b0a6b3ebadb8274d9d94a0bfa2f"
6311
6311
  },
6312
6312
  {
6313
6313
  "path": "public-docs/index.html",
@@ -7957,7 +7957,7 @@
7957
7957
  {
7958
7958
  "path": "src/module-api.js",
7959
7959
  "mode": "0000644",
7960
- "sha256": "4b7bee96fc2dfb083dcc43f811b46f135c4103cf719e68546ee2b898569cfc65"
7960
+ "sha256": "eb69000bc81b5e5947fbd10736783070e11c38d0cc63d15eea61fcccfdb37d67"
7961
7961
  },
7962
7962
  {
7963
7963
  "path": "src/module-loader/catalog.js",
@@ -8864,6 +8864,11 @@
8864
8864
  "mode": "0000644",
8865
8865
  "sha256": "387eb4455a5e79f930fccae875278841094a52900a1158617345db8b58147d1a"
8866
8866
  },
8867
+ {
8868
+ "path": "tests/filing_abuse_matrix.mjs",
8869
+ "mode": "0000644",
8870
+ "sha256": "3688a9615a05eb94de9905698c4ed9dd044e9b67ef234124c548cf9aeffa338f"
8871
+ },
8867
8872
  {
8868
8873
  "path": "tests/first_admin_bootstrap.mjs",
8869
8874
  "mode": "0000644",
@@ -9479,6 +9484,11 @@
9479
9484
  "mode": "0000644",
9480
9485
  "sha256": "51e1286235cbde1b56188f29b0e2bd12ac73a9637dbf1c16b325f10147256f27"
9481
9486
  },
9487
+ {
9488
+ "path": "tests/idea_route_attribution.mjs",
9489
+ "mode": "0000644",
9490
+ "sha256": "99a4665c1ddd97bb27238082c0e6e420eb66ab3c18ec92b543c3b35ba607ebda"
9491
+ },
9482
9492
  {
9483
9493
  "path": "tests/idea_routing.mjs",
9484
9494
  "mode": "0000644",
@@ -1341,5 +1341,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1341
1341
  landed since 1.19.450 with no explicit bump. run 33549781105. (task 1002620)
1342
1342
  1.19.452 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1343
1343
  landed since 1.19.451 with no explicit bump. run 33552353620. (task 1002620)
1344
+ 1.19.453 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1345
+ landed since 1.19.452 with no explicit bump. run 33553290345. (task 1002620)
1346
+ 1.19.454 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1347
+ landed since 1.19.453 with no explicit bump. run 33554965618. (task 1002620)
1344
1348
  ---------------------------------------------------------------------------
1345
1349
  ```
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.452",
3
+ "version": "1.19.454",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.452",
9
+ "version": "1.19.454",
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.452",
3
+ "version": "1.19.454",
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
@@ -33,7 +33,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
33
33
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
34
34
  // the entry to that file. Look for a version's history there, not here.
35
35
  // ---------------------------------------------------------------------------
36
- const CORE_VERSION = '1.19.452'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
36
+ const CORE_VERSION = '1.19.454'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
37
37
 
38
38
  // A namespaced logger so a module's log lines are attributable + consistent.
39
39
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,355 @@
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
+ // F13 declare touches[] outside the goal to widen blast area → the real wall refuses
33
+ // F14 file against a SHIPPED version's goal → refused (version not building)
34
+ // P1 a Metic+ files a bug in an ordinary goal → 'ready' (the C1 grant)
35
+ // P2 a Metic+ files into protected scope → allowed, floored to metic
36
+ //
37
+ // AND THE ANTI-DRIFT PIN (the point of the file, really). The rows above are true
38
+ // of the four capture vectors that exist TODAY. Nothing stops a fifth being added
39
+ // next month that quietly skips all of it — which is exactly how the 1000771 bug
40
+ // got in. So the last section ENUMERATES the vectors from source and fails when
41
+ // the roster changes, forcing a new vector's author to come here and add its row.
42
+ //
43
+ // PURE: the decision cores only (decideLanding, authorizeRoutedLanding,
44
+ // decideBugLanding, rateLimitPerHour) — no DB, no network. The transaction that
45
+ // obeys them is proven in tests/idea_routing.mjs; the end-to-end attribution trail
46
+ // in tests/idea_route_attribution.mjs.
47
+ //
48
+ // MUTATION-VERIFIED (task 1003073). Each of these was applied to the source and the
49
+ // file confirmed RED before reverting, so the matrix is known to have teeth:
50
+ //
51
+ // METIC_PLUS widened to include xenos ....................... 4 of 23 fail
52
+ // the protected-scope refusal short-circuited to false ...... 1 of 23 fail
53
+ // NEVER_ROUTE_KINDS emptied ................................. 1 of 23 fail
54
+ // #bugs READY_RANKS widened to include thetes ............... 2 of 23 fail
55
+ // the rate cap made env-uncappable (negatives accepted) ..... 1 of 23 fail
56
+ //
57
+ // Run: node tests/filing_abuse_matrix.mjs
58
+
59
+ import { strict as assert } from 'node:assert';
60
+ import { createRequire } from 'node:module';
61
+ import { readFileSync } from 'node:fs';
62
+ import { fileURLToPath } from 'node:url';
63
+ import path from 'node:path';
64
+ import { makeRunner } from './helpers.mjs';
65
+
66
+ process.env.NODE_ENV = 'test';
67
+
68
+ const require = createRequire(import.meta.url);
69
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
70
+ const read = (rel) => readFileSync(path.join(ROOT, rel), 'utf8');
71
+
72
+ const routing = require('../modules/ideas/routing.js');
73
+ const { decideLanding, authorizeRoutedLanding, routedPriority, PROTECTED_SCOPE_FLOOR } = routing;
74
+ const bugs = require('../modules/discord/discord-bugs.js');
75
+ const { decideBugLanding, rateLimitPerHour } = bugs;
76
+ const { scopeIncludesProtected, isModuleKey } = require('../src/bongos/module-scope-map.js');
77
+
78
+ const { test, summary } = makeRunner();
79
+
80
+ // Ranks below the trust boundary. 'thetes' and 'xenos' are the real sub-Metic
81
+ // tiers; the rest are the shapes a hostile or buggy caller actually produces.
82
+ const SUB_METIC = ['xenos', 'thetes', null, undefined, '', 'METIC ', 'admin', 'root', 'archon ', 42, {}];
83
+ const TRUSTED = ['metic', 'archon', 'Metic', 'ARCHON'];
84
+
85
+ // A routed filing lands claimable ONLY when decideLanding says 'ready' AND
86
+ // authorizeRoutedLanding lets it through. Both, because either alone is half the
87
+ // gate — this helper is the composition the server actually runs.
88
+ function landing({ kind = 'bug', filerRank = 'xenos', goalId = 5, grade = null, scopeProtected = false, scopeCheck = null } = {}) {
89
+ const verdict = decideLanding({ goalId, kind, grade, filerRank });
90
+ if (!verdict.route) return { landed: false, status: null, reason: verdict.reason };
91
+ const authorized = authorizeRoutedLanding({ landing: verdict, scopeProtected, scopeCheck, filerRank });
92
+ if (!authorized.ok) return { landed: false, status: null, refused: authorized.code };
93
+ return { landed: true, status: authorized.status, requiresRank: authorized.requiresRank, reason: verdict.reason };
94
+ }
95
+
96
+ // =========================================================================
97
+ // Fixtures anchored in real facts, so the matrix cannot drift into fiction.
98
+ // =========================================================================
99
+
100
+ await test('fixtures: ideas is a real UNprotected module, lifecycle a protected one', () => {
101
+ assert.ok(isModuleKey('ideas') && isModuleKey('lifecycle'), 'both fixture modules are real');
102
+ assert.equal(scopeIncludesProtected(['ideas']), false, 'an ordinary goal scope');
103
+ assert.equal(scopeIncludesProtected(['lifecycle']), true, 'a protected one — the matrix depends on this');
104
+ });
105
+
106
+ // =========================================================================
107
+ // F1–F8 — the filing matrix: a sub-Metic can report anything and set nothing
108
+ // =========================================================================
109
+
110
+ await test('[F1] a Xenos bug in an ordinary goal routes, but lands at backlog — never claimable', () => {
111
+ const r = landing({ kind: 'bug', filerRank: 'xenos' });
112
+ assert.equal(r.landed, true, 'the report is accepted — refusing it would be the triage tax this goal removes');
113
+ assert.equal(r.status, 'backlog', 'and it waits for a nod');
114
+ assert.equal(r.reason, 'bug_awaiting_nod');
115
+ });
116
+
117
+ await test('[F2] a Xenos bug in a PROTECTED-scope goal is refused outright, not merely floored', () => {
118
+ const r = landing({ kind: 'bug', filerRank: 'xenos', scopeProtected: true });
119
+ assert.equal(r.landed, false, 'no row is minted in the permission/ship/deploy core queue at all');
120
+ assert.equal(r.refused, 'protected_scope');
121
+ });
122
+
123
+ await test('[F3] rank comes from AUTH, never the body — the route never reads a body rank', () => {
124
+ // The decision core is handed a rank; the guarantee that it is the authenticated
125
+ // one lives at the call site, so assert it there, in source.
126
+ const src = read('modules/ideas/routes/inbox.js');
127
+ assert.match(src, /filerRank:\s*req\.builder\?\.rank/,
128
+ 'POST /inbox must pass the AUTHENTICATED rank');
129
+ assert.ok(!/filerRank:\s*body\./.test(src), 'and must never take one from the request body');
130
+ assert.match(src, /capturedBy:\s*req\.builder\.id/, 'the filer identity is likewise from auth');
131
+ });
132
+
133
+ await test('[F4] no NON-BUG kind hard-lands, whoever files it — a filer cannot set the agenda', () => {
134
+ for (const kind of ['feature', 'cleanup', 'refactor', 'infra', 'chore', 'docs']) {
135
+ for (const rank of [...SUB_METIC, ...TRUSTED]) {
136
+ const r = landing({ kind, filerRank: rank });
137
+ assert.equal(r.status, 'backlog', `${kind} filed by ${JSON.stringify(rank)} must soft-land`);
138
+ }
139
+ }
140
+ });
141
+
142
+ await test('[F5] a junk kind never even reaches the matrix — captureIdea refuses it first', () => {
143
+ // The real wall for a bogus kind is upstream of routing: captureIdea resolves
144
+ // kind (or auto-classifies an absent one) and then refuses anything outside
145
+ // VALID_KINDS with BAD_KIND, before any write. So the matrix is never asked
146
+ // about 'BUG' or '__proto__' in production.
147
+ const src = read('modules/ideas/inbox.js');
148
+ assert.match(src, /const resolvedKind = kind \|\| classify\(/,
149
+ 'an absent kind is auto-classified, never passed through as null');
150
+ assert.match(src, /VALID_KINDS\.includes\(resolvedKind\)/, 'and an unknown one is rejected');
151
+ assert.match(src, /BAD_KIND/, 'by a named refusal');
152
+ });
153
+
154
+ await test('[F5b] and if one DID reach the matrix, it soft-lands — belt to that braces', () => {
155
+ // decideLanding called directly: no helper default can stand in for the input.
156
+ // Only the exact string 'bug' hard-lands; every near-miss casing and shape is
157
+ // a soft landing, so a kind that slipped a future validator still cannot make
158
+ // claimable work.
159
+ for (const kind of [null, undefined, '', 'BUG', 'bug ', 'Bug', 'bugs', '__proto__', 0, {}]) {
160
+ const v = decideLanding({ goalId: 5, kind, grade: null, filerRank: 'archon' });
161
+ assert.equal(v.route, true, 'it still finds a home');
162
+ assert.equal(v.status, 'backlog', `kind ${JSON.stringify(kind)} must not hard-land`);
163
+ }
164
+ assert.equal(decideLanding({ goalId: 5, kind: 'bug', filerRank: 'archon' }).status, 'ready',
165
+ 'only the exact kind hard-lands — otherwise this test would pass vacuously');
166
+ });
167
+
168
+ await test('[F6] a junk or absent RANK never reads as trusted (the fail-closed posture)', () => {
169
+ for (const rank of SUB_METIC) {
170
+ const r = landing({ kind: 'bug', filerRank: rank });
171
+ assert.equal(r.status, 'backlog', `rank ${JSON.stringify(rank)} must not hard-land a bug`);
172
+ }
173
+ });
174
+
175
+ await test('[F7] judgment kinds never route, so they cannot be laundered into work', () => {
176
+ for (const kind of ['criterion-proposal', 'decision']) {
177
+ for (const rank of [...SUB_METIC, ...TRUSTED]) {
178
+ const r = landing({ kind, filerRank: rank });
179
+ assert.equal(r.landed, false, `${kind} must stay in the inbox`);
180
+ assert.equal(r.reason, 'judgment_kind');
181
+ }
182
+ }
183
+ });
184
+
185
+ await test('[F8] a Full Idea never routes, even a bug-kinded one from an Archon', () => {
186
+ const r = landing({ kind: 'bug', filerRank: 'archon', grade: 'full' });
187
+ assert.equal(r.landed, false, "a Full Idea is the Board Room's subject, not its filer's task");
188
+ assert.equal(r.reason, 'full_idea_never_routes');
189
+ });
190
+
191
+ // =========================================================================
192
+ // F9–F11 — MASS FILING through Discord #bugs
193
+ // =========================================================================
194
+
195
+ await test('[F9] the #bugs vector is capped per builder per rolling hour, and the cap is sane', () => {
196
+ const n = rateLimitPerHour({});
197
+ assert.ok(Number.isInteger(n) && n > 0, 'a real positive cap exists by default');
198
+ assert.ok(n <= 50, `the default cap bounds a flood (got ${n})`);
199
+ // A hostile env cannot uncap it with a non-number, a negative, or zero.
200
+ for (const bad of ['', 'lots', '-1', '0', 'Infinity', null, undefined, '1e999']) {
201
+ const got = rateLimitPerHour({ DISCORD_BUGS_RATE_PER_HOUR: bad });
202
+ assert.ok(Number.isInteger(got) && got > 0 && got <= 50,
203
+ `env ${JSON.stringify(bad)} must fall back to a finite cap, got ${got}`);
204
+ }
205
+ });
206
+
207
+ await test('[F10] a rate-limit COUNT failure DENIES — you cannot break the counter to slip the cap', () => {
208
+ // The behaviour is proven end-to-end in tests/discord_bugs.mjs; pinned here in
209
+ // source because "fail closed" is the property this matrix is about, and a
210
+ // future refactor that flips the catch to allow would pass every other test.
211
+ const src = read('modules/discord/discord-bugs.js');
212
+ assert.match(src, /fail-closed/i, 'the deny-on-count-error intent is stated');
213
+ assert.match(src, /rate_limit_count_error/,
214
+ 'and audited under its own reason, so a broken counter is visible rather than silent');
215
+ });
216
+
217
+ await test('[F11] #bugs lands a sub-Metic at backlog and only Metic+ at ready', () => {
218
+ for (const rank of SUB_METIC) {
219
+ assert.equal(decideBugLanding(rank), 'backlog', `${JSON.stringify(rank)} must not reach ready`);
220
+ }
221
+ for (const rank of TRUSTED) {
222
+ assert.equal(decideBugLanding(rank), 'ready', `${rank} is the C1 straight-through grant`);
223
+ }
224
+ });
225
+
226
+ await test('[F9+F11] the flood, composed: at the cap, EVERY landed row is still backlog', () => {
227
+ const cap = rateLimitPerHour({});
228
+ const landedStatuses = [];
229
+ for (let i = 0; i < cap + 25; i++) {
230
+ if (i >= cap) continue; // the counter refuses past the cap (F9/F10)
231
+ landedStatuses.push(decideBugLanding('xenos'));
232
+ }
233
+ assert.equal(landedStatuses.length, cap, 'the cap bounds how many rows exist at all');
234
+ assert.ok(landedStatuses.every((s) => s === 'backlog'),
235
+ 'and not one of them is claimable — a flood costs a Metic+ nothing but a queue to ignore');
236
+ });
237
+
238
+ // =========================================================================
239
+ // F12 — the #ideas vector cannot route at all
240
+ // =========================================================================
241
+
242
+ await test('[F12] Discord #ideas never routes: it passes no goal, so every filing is an inbox row', () => {
243
+ const src = read('modules/discord/discord-inbound.js');
244
+ const call = src.slice(src.indexOf('deps.captureIdea({'));
245
+ assert.ok(call, 'the capture call is findable');
246
+ const body = call.slice(0, call.indexOf('});') + 3);
247
+ assert.ok(!/goalId/.test(body), 'no goalId is passed, so decideLanding returns no_goal');
248
+ assert.ok(!/filerRank/.test(body), 'and no rank, so it could not hard-land even if a goal appeared');
249
+ // Belt and braces: prove the decision, not just the absence.
250
+ assert.equal(decideLanding({ goalId: null, kind: 'bug', filerRank: 'archon' }).route, false,
251
+ 'no goal ⇒ the inbox, which is the default path every goal-unaware vector keeps');
252
+ });
253
+
254
+ // =========================================================================
255
+ // F13–F14 — the walls that are NOT the matrix's own
256
+ // =========================================================================
257
+
258
+ await test('[F13] a declared touches[] outside the goal is refused by the real scope wall, verbatim', () => {
259
+ // authorizeRoutedLanding passes the wall's verdict through unchanged — the same
260
+ // refusal an authored task gets, so there is only one definition of out-of-scope.
261
+ const refusal = { ok: false, code: 'out_of_scope', reason: 'nope', details: { offender: 'src/bongos/db.js' } };
262
+ const r = landing({ kind: 'bug', filerRank: 'archon', scopeCheck: refusal });
263
+ assert.equal(r.landed, false, 'even an Archon is bounded by the goal scope');
264
+ assert.equal(r.refused, 'out_of_scope');
265
+ });
266
+
267
+ await test('[F14] a goal on a non-building version cannot receive routed work (the 1000771 wall)', () => {
268
+ // Enforced inside the transaction, so pin it at its source: the locked read
269
+ // carries the version status and refuses anything but 'building'.
270
+ const src = read('modules/ideas/inbox.js');
271
+ assert.match(src, /VERSION_NOT_BUILDING/, 'the refusal exists and is named');
272
+ assert.match(src, /goal\.version_status !== 'building'/,
273
+ "and it admits ONLY a building version — 'shipped' is the tomb 1000771 was filed over");
274
+ });
275
+
276
+ // =========================================================================
277
+ // P1–P2 — the grants, so the matrix is not vacuously "everything is refused"
278
+ // =========================================================================
279
+
280
+ await test('[P1] a Metic+ bug in an ordinary goal IS claimable immediately — criterion C1 still holds', () => {
281
+ const r = landing({ kind: 'bug', filerRank: 'metic' });
282
+ assert.equal(r.status, 'ready', 'the whole point of the goal: no triage stop for a trusted bug report');
283
+ assert.equal(r.requiresRank, null, 'and no floor is imposed — createTask derives it as for any vector');
284
+ });
285
+
286
+ await test('[P2] a Metic+ CAN file into protected scope, and the task is floored to metic', () => {
287
+ const r = landing({ kind: 'bug', filerRank: 'archon', scopeProtected: true });
288
+ assert.equal(r.landed, true);
289
+ assert.equal(r.requiresRank, PROTECTED_SCOPE_FLOOR, 'so no sub-Metic can claim it however it got there');
290
+ });
291
+
292
+ await test('priority cannot be weaponised: an out-of-band suggestion is clamped, never honoured', () => {
293
+ for (const p of [99, -5, 0, 6, 'urgent', null, Infinity, 1.5]) {
294
+ const got = routedPriority(p);
295
+ assert.ok(Number.isInteger(got) && got >= 1 && got <= 5, `priority ${JSON.stringify(p)} → ${got}`);
296
+ }
297
+ assert.equal(routedPriority(1), 1, 'a sane suggestion still rides through');
298
+ });
299
+
300
+ // =========================================================================
301
+ // THE ANTI-DRIFT PIN — a fifth vector cannot arrive unaudited
302
+ // =========================================================================
303
+
304
+ // Every place a FILING becomes work, and the row above that covers it. Adding a
305
+ // vector without adding it here is what this pin is for: the 1000771 bug got in
306
+ // because #bugs was written as its own path and nobody re-read the matrix.
307
+ const KNOWN_FILING_VECTORS = {
308
+ 'modules/ideas/inbox.js': 'routeIdeaToGoal — the ONE transaction every routed filing runs (F1–F8, F13, F14)',
309
+ 'modules/ideas/routes/inbox.js': 'POST /inbox — the web + CLI door onto it (F3)',
310
+ 'modules/discord/discord-inbound.js': 'Discord #ideas — files an idea, never routes (F12)',
311
+ 'modules/discord/discord-bugs.js': 'Discord #bugs — the direct-createTask bypass R11 retires (F9–F11)',
312
+ };
313
+
314
+ await test('[pin] every declared vector still reaches a task-minting path', () => {
315
+ const found = Object.keys(KNOWN_FILING_VECTORS).filter((rel) => /captureIdea\(|createTask\(/.test(read(rel)));
316
+ assert.deepEqual(found.sort(), Object.keys(KNOWN_FILING_VECTORS).sort(),
317
+ 'a vector that no longer files needs its row retired, not left as decoration');
318
+ });
319
+
320
+ await test('[pin] no OTHER module file mints a task from a filing without a row here', () => {
321
+ // Scan the two filing-facing modules for task-minting call sites. A new file
322
+ // that creates work from a report has to be classified here before it ships.
323
+ const { execFileSync } = require('node:child_process');
324
+ const tracked = execFileSync('git', ['ls-files', 'modules/ideas/*.js', 'modules/ideas/**/*.js', 'modules/discord/*.js', 'modules/discord/**/*.js'], { cwd: ROOT, encoding: 'utf8' })
325
+ .split('\n').map((l) => l.trim()).filter(Boolean);
326
+
327
+ const minters = tracked.filter((rel) => {
328
+ if (rel.includes('/tests/') || rel.endsWith('.mjs')) return false;
329
+ const src = read(rel);
330
+ // A minter CALLS a task-create; declaring/forwarding the dep is not minting.
331
+ return /(?:deps|lifecycle|db|api)\.createTask\(|resolve\('lifecycle'\)[\s\S]{0,80}createTask\(/.test(src);
332
+ });
333
+
334
+ const unaudited = minters.filter((rel) => !(rel in KNOWN_FILING_VECTORS));
335
+ assert.deepEqual(unaudited, [],
336
+ `a new filing vector must be added to KNOWN_FILING_VECTORS and given a matrix row: ${unaudited.join(', ')}`);
337
+ });
338
+
339
+ await test('[pin] the trusted tier is Metic+ in BOTH decision cores — one boundary, not two', () => {
340
+ // routing.js and discord-bugs.js each spell the tier locally (both are pure and
341
+ // import-free by design). They must agree, or "Metic+" means two things.
342
+ const routingSrc = read('modules/ideas/routing.js');
343
+ const bugsSrc = read('modules/discord/discord-bugs.js');
344
+ assert.match(routingSrc, /METIC_PLUS = new Set\(\['metic', 'archon'\]\)/);
345
+ assert.match(bugsSrc, /READY_RANKS = new Set\(\['metic', 'archon'\]\)/);
346
+ // And behaviourally, which is what actually matters:
347
+ for (const rank of [...SUB_METIC, ...TRUSTED]) {
348
+ const viaRouting = landing({ kind: 'bug', filerRank: rank }).status;
349
+ const viaBugs = decideBugLanding(rank);
350
+ assert.equal(viaRouting, viaBugs,
351
+ `the two vectors must agree on ${JSON.stringify(rank)} (routing=${viaRouting}, bugs=${viaBugs})`);
352
+ }
353
+ });
354
+
355
+ summary();
@@ -0,0 +1,330 @@
1
+ // tests/idea_route_attribution.mjs
2
+ //
3
+ // THE PROOF — task 1003072 (BV1.R04, goal 1000071 "Idea routing"). Criterion
4
+ // C3, "no route loses the person who thought of it":
5
+ //
6
+ // (1) every routed task has an ideas_task_lineage row (origin='route') back to
7
+ // an idea_inbox row with captured_by;
8
+ // (3) sourceIdeaForTask on a routed, SHIPPED task returns the filer — the trail
9
+ // goal 1000067's ideator credit will pay against;
10
+ // (4) an unlinked Discord author still yields an idea row (captured_by null)
11
+ // rather than an attribution-free task.
12
+ //
13
+ // WHY THIS FILE EXISTS ALONGSIDE tests/idea_routing.mjs. That file proves the
14
+ // WRITE: a routed capture emits idea + task + lineage in one transaction, in FK
15
+ // order, and rolls all three back together. tests/idea_task_lineage.mjs proves
16
+ // the READ: given a hand-seeded lineage row, sourceIdeaForTask names the idea and
17
+ // its author. Neither proves the two HALVES COMPOSE — that the row routing
18
+ // actually writes is the row the credit hook actually finds. A typo'd origin, a
19
+ // task id written where an idea id was meant, a captured_by dropped on the way
20
+ // in: each passes both existing files and still loses the ideator their credit,
21
+ // silently, months before anyone tries to pay them.
22
+ //
23
+ // So this file runs the write and the read against ONE store. Nothing is
24
+ // hand-seeded: every row the assertions read was put there by captureIdea.
25
+ //
26
+ // THE STORE, not a matcher table. The other two files answer per-statement with
27
+ // canned rows, which is right for asserting statement SEQUENCE. Here the point is
28
+ // end-to-end agreement, so the fake speaks the three tables as maps and the
29
+ // queries genuinely join across them. A canned answer would prove nothing: it
30
+ // would be the test asserting its own fixture.
31
+ //
32
+ // MUTATION-VERIFIED (task 1003072). A proof that has never failed is a rumour, so
33
+ // each of these was applied to modules/ideas/inbox.js and the file was confirmed RED
34
+ // before being reverted:
35
+ //
36
+ // origin 'route' -> 'promote' in the lineage insert ......... 4 of 9 fail
37
+ // createdBy: capturedBy -> null on the task ................. 1 of 9 fail
38
+ // lineage params [task.id, idea.id] swapped ................. 5 of 9 fail
39
+ // captured_by -> null on the idea insert .................... 3 of 9 fail
40
+ // the lineage INSERT deleted (the #bugs bypass shape) ....... 5 of 9 fail
41
+ //
42
+ // Re-run that check if this file is ever rewritten: the store fake is expressive
43
+ // enough that a careless edit could make it answer its own questions.
44
+ //
45
+ // Run: node tests/idea_route_attribution.mjs
46
+
47
+ import { strict as assert } from 'node:assert';
48
+ import { createRequire } from 'node:module';
49
+ import { makeRunner } from './helpers.mjs';
50
+
51
+ process.env.NODE_ENV = 'test';
52
+
53
+ const require = createRequire(import.meta.url);
54
+ const api = require('../src/module-api.js');
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // The store: idea_inbox, tasks, ideas_task_lineage as three maps.
58
+ // ---------------------------------------------------------------------------
59
+
60
+ const GOAL = {
61
+ id: 7, status: 'open', version_id: 'BONGOS-V1', version_status: 'building', scope_modules: ['ideas'],
62
+ };
63
+
64
+ function makeStore() {
65
+ return {
66
+ ideas: new Map(), // id -> row
67
+ tasks: new Map(), // id -> row
68
+ lineage: new Map(), // task_id -> { idea_id, origin }
69
+ nextIdeaId: 500,
70
+ nextTaskId: 9000,
71
+ goal: { ...GOAL },
72
+ };
73
+ }
74
+
75
+ // One SQL handler over the store, shared by the transaction client and the
76
+ // module-level pool — so the write path and the read path cannot disagree about
77
+ // what is in the database.
78
+ function handlerFor(store) {
79
+ return async (s, params = []) => {
80
+ // the locked goal read (routeIdeaToGoal step a)
81
+ if (/FROM goals g JOIN versions v/.test(s)) {
82
+ return { rows: store.goal ? [store.goal] : [] };
83
+ }
84
+ // the idea row (step b)
85
+ if (/INSERT INTO idea_inbox/.test(s)) {
86
+ const [title, bodyMd, kind, suggestedVersion, suggestedPriority, capturedBy, suggestedGoalId] = params;
87
+ const id = store.nextIdeaId++;
88
+ const row = {
89
+ id,
90
+ title,
91
+ body_md: bodyMd,
92
+ kind,
93
+ suggested_version: suggestedVersion ?? null,
94
+ suggested_priority: suggestedPriority ?? null,
95
+ // captured_by is THE field under test: it must survive the write
96
+ // unmodified, including when it is null (the unlinked-Discord case).
97
+ captured_by: capturedBy ?? null,
98
+ status: 'promoted',
99
+ suggested_goal_id: suggestedGoalId ?? null,
100
+ promoted_to_task_id: null,
101
+ reviewed_at: 'T0',
102
+ merged_into_idea_id: null,
103
+ triage_note: null,
104
+ captured_at: 'T0',
105
+ };
106
+ store.ideas.set(String(id), row);
107
+ return { rows: [row] };
108
+ }
109
+ // the lineage row (step d)
110
+ if (/INSERT INTO ideas_task_lineage/.test(s)) {
111
+ const [taskId, ideaId] = params;
112
+ // origin is a literal in the statement, not a param — read it from the SQL
113
+ // so a change to that literal is caught here rather than assumed.
114
+ const origin = (s.match(/VALUES \(\$1, \$2, '(\w+)'\)/) || [])[1] ?? null;
115
+ store.lineage.set(String(taskId), { idea_id: String(ideaId), origin });
116
+ return { rowCount: 1, rows: [] };
117
+ }
118
+ // closing the FK loop (step e)
119
+ if (/UPDATE idea_inbox SET promoted_to_task_id/.test(s)) {
120
+ const [ideaId, taskId] = params;
121
+ const row = store.ideas.get(String(ideaId));
122
+ if (row) row.promoted_to_task_id = taskId;
123
+ return { rows: row ? [row] : [] };
124
+ }
125
+ // sourceIdeaForTask — the read the ship hook does
126
+ if (/l\.idea_id, l\.origin, i\.captured_by/.test(s)) {
127
+ const link = store.lineage.get(String(params[0]));
128
+ if (!link) return { rows: [] };
129
+ const idea = store.ideas.get(String(link.idea_id));
130
+ if (!idea) return { rows: [] }; // an INNER JOIN finds nothing
131
+ return { rows: [{ idea_id: link.idea_id, origin: link.origin, captured_by: idea.captured_by }] };
132
+ }
133
+ // countShippedTasksForIdea
134
+ if (/COUNT\(\*\)::int AS n/.test(s)) {
135
+ const ideaId = String(params[0]);
136
+ let n = 0;
137
+ for (const [taskId, link] of store.lineage) {
138
+ if (link.idea_id === ideaId && (store.tasks.get(taskId) || {}).status === 'shipped') n++;
139
+ }
140
+ return { rows: [{ n }] };
141
+ }
142
+ return { rows: [] }; // BEGIN / COMMIT / ROLLBACK and anything unmatched
143
+ };
144
+ }
145
+
146
+ // A pool whose query() and connect().query() both run the same handler against
147
+ // the same store, and which records every statement for ordering assertions.
148
+ function poolFor(store) {
149
+ const handler = handlerFor(store);
150
+ const queries = [];
151
+ const client = {
152
+ queries,
153
+ async query(sql, params) {
154
+ queries.push({ sql: String(sql).replace(/\s+/g, ' ').trim(), params });
155
+ return handler(String(sql).replace(/\s+/g, ' ').trim(), params);
156
+ },
157
+ release() {},
158
+ };
159
+ return {
160
+ queries,
161
+ query: client.query,
162
+ connect: async () => client,
163
+ };
164
+ }
165
+
166
+ // ---------------------------------------------------------------------------
167
+ // Wiring: the lifecycle port (createTask) writes into the SAME store.
168
+ // ---------------------------------------------------------------------------
169
+
170
+ const seams = require('../src/module-seams.js');
171
+ seams._reset();
172
+
173
+ let activeStore = makeStore();
174
+
175
+ seams.registerProvider('lifecycle', {
176
+ createTask: async (args) => {
177
+ const id = activeStore.nextTaskId++;
178
+ const row = {
179
+ id,
180
+ status: args.status,
181
+ goal_id: args.goalId,
182
+ version_id: args.versionId,
183
+ title: args.title,
184
+ kind: args.kind,
185
+ source: args.source,
186
+ source_ref: args.sourceRef,
187
+ created_by: args.createdBy ?? null,
188
+ };
189
+ activeStore.tasks.set(String(id), row);
190
+ return row;
191
+ },
192
+ });
193
+
194
+ const inbox = require('../modules/ideas/inbox.js');
195
+ const { captureIdea, sourceIdeaForTask, countShippedTasksForIdea } = inbox;
196
+
197
+ // sourceIdeaForTask and countShippedTasksForIdea read the MODULE-LEVEL pool (no
198
+ // deps seam), so point that at the live store too. Same object the module
199
+ // captured at load, so replacing the method is enough.
200
+ api.pool.query = async (sql, params) => handlerFor(activeStore)(String(sql).replace(/\s+/g, ' ').trim(), params);
201
+
202
+ const { test, summary } = makeRunner();
203
+
204
+ // Route one bug and hand back everything the assertions need.
205
+ async function routeABug({ capturedBy = 42, filerRank = 'metic', title = 'the thing is broken' } = {}) {
206
+ activeStore = makeStore();
207
+ const pool = poolFor(activeStore);
208
+ const idea = await captureIdea(
209
+ { title, bodyMd: 'steps to reproduce', kind: 'bug', goalId: GOAL.id, capturedBy, filerRank },
210
+ { pool }
211
+ );
212
+ return { idea, pool, store: activeStore };
213
+ }
214
+
215
+ const ship = (store, taskId) => { store.tasks.get(String(taskId)).status = 'shipped'; };
216
+
217
+ // =========================================================================
218
+ // C3 (1) — the routed task HAS a lineage row, and it points at a real idea
219
+ // =========================================================================
220
+
221
+ await test('a routed bug writes lineage origin=route from the task back to the idea', async () => {
222
+ const { idea, store } = await routeABug();
223
+ const taskId = idea.routing.task_id;
224
+
225
+ assert.ok(taskId, 'the capture became a task');
226
+ const link = store.lineage.get(String(taskId));
227
+ assert.ok(link, 'a lineage row exists for the task that was just created');
228
+ assert.equal(link.origin, 'route', "origin is 'route' — not 'promote', which would misattribute the lane");
229
+ assert.equal(link.idea_id, String(idea.id), 'and it points at the idea this very capture wrote');
230
+ assert.ok(store.ideas.has(String(idea.id)), 'that idea id is a real row, not a dangling reference');
231
+ });
232
+
233
+ await test('the idea row carries the filer, and the task records them as its creator', async () => {
234
+ const { idea, store } = await routeABug({ capturedBy: 42 });
235
+ assert.equal(store.ideas.get(String(idea.id)).captured_by, 42, 'the filer is on the idea row');
236
+ assert.equal(store.tasks.get(String(idea.routing.task_id)).created_by, 42,
237
+ 'and on the task — routing an idea attributes the work to whoever reported it');
238
+ });
239
+
240
+ // =========================================================================
241
+ // C3 (3) — THE COMPOSITION. What routing wrote is what the ship hook reads.
242
+ // =========================================================================
243
+
244
+ await test('sourceIdeaForTask on the routed, SHIPPED task returns the filer', async () => {
245
+ const { idea, store } = await routeABug({ capturedBy: 42 });
246
+ const taskId = idea.routing.task_id;
247
+ ship(store, taskId);
248
+
249
+ const src = await sourceIdeaForTask(taskId);
250
+ assert.ok(src, 'the ship hook finds a source idea — nothing to pay is the failure this goal exists to prevent');
251
+ assert.equal(String(src.idea_id), String(idea.id), 'it names the idea the filing created');
252
+ assert.equal(src.origin, 'route', 'and says the route is where it came from');
253
+ assert.equal(src.captured_by, 42, 'and names the person to pay');
254
+ });
255
+
256
+ await test('the shipped-task count for that idea is exactly one — the credit lane can meter it', async () => {
257
+ const { idea, store } = await routeABug();
258
+ assert.equal(await countShippedTasksForIdea(idea.id), 0, 'nothing is owed before it ships');
259
+ ship(store, idea.routing.task_id);
260
+ assert.equal(await countShippedTasksForIdea(idea.id), 1, 'and exactly one afterwards — never double-paid');
261
+ });
262
+
263
+ await test('the trail survives with NOTHING hand-seeded — every row came from the capture', async () => {
264
+ const { idea, store } = await routeABug({ capturedBy: 77 });
265
+ // The point of the whole file, asserted directly: three tables, one act.
266
+ assert.equal(store.ideas.size, 1, 'one idea');
267
+ assert.equal(store.tasks.size, 1, 'one task');
268
+ assert.equal(store.lineage.size, 1, 'one lineage row');
269
+ ship(store, idea.routing.task_id);
270
+ const src = await sourceIdeaForTask(idea.routing.task_id);
271
+ assert.deepEqual(
272
+ { idea: String(src.idea_id), origin: src.origin, who: src.captured_by },
273
+ { idea: String(idea.id), origin: 'route', who: 77 }
274
+ );
275
+ });
276
+
277
+ // =========================================================================
278
+ // C3 (4) — an unlinked author still leaves a trail
279
+ // =========================================================================
280
+
281
+ await test('an UNLINKED filer (captured_by null) still yields an idea row, not an attribution-free task', async () => {
282
+ // A Discord author with no Bongos account. The report must not become a task
283
+ // that exists with no idea behind it — that is precisely the #bugs bypass this
284
+ // goal retires. The trail exists and is honestly empty at the person-shaped
285
+ // end, which is recoverable later; a missing lineage row never is.
286
+ const { idea, store } = await routeABug({ capturedBy: null, filerRank: 'metic' });
287
+ const taskId = idea.routing.task_id;
288
+
289
+ assert.ok(store.lineage.get(String(taskId)), 'the lineage row exists even with no known author');
290
+ ship(store, taskId);
291
+ const src = await sourceIdeaForTask(taskId);
292
+ assert.ok(src, 'the ship hook still finds the idea');
293
+ assert.equal(src.origin, 'route');
294
+ assert.equal(src.captured_by, null, 'unattributed, and honestly so — never guessed');
295
+ assert.equal(store.tasks.get(String(taskId)).created_by, null, 'the task does not invent a creator either');
296
+ });
297
+
298
+ // =========================================================================
299
+ // The negative: a task with no route has no trail, so a passing test above
300
+ // cannot be the fake answering itself.
301
+ // =========================================================================
302
+
303
+ await test('a task that was never routed has no source idea (the read is not vacuously true)', async () => {
304
+ activeStore = makeStore();
305
+ activeStore.tasks.set('4242', { id: 4242, status: 'shipped' });
306
+ assert.equal(await sourceIdeaForTask(4242), null,
307
+ 'no lineage row → null; if this returned a row the store would be answering blind');
308
+ });
309
+
310
+ await test('lineage pointing at a missing idea reads as no source, never as a half-row', async () => {
311
+ activeStore = makeStore();
312
+ activeStore.tasks.set('4243', { id: 4243, status: 'shipped' });
313
+ activeStore.lineage.set('4243', { idea_id: '999', origin: 'route' }); // idea absent
314
+ assert.equal(await sourceIdeaForTask(4243), null,
315
+ "the INNER JOIN drops it — the credit lane must never see an author-less 'source'");
316
+ });
317
+
318
+ // =========================================================================
319
+ // The provenance pair, so one idea can only ever mint one routed task
320
+ // =========================================================================
321
+
322
+ await test('the routed task carries the idempotency provenance the create-vector read keys on', async () => {
323
+ const { idea, store } = await routeABug();
324
+ const task = store.tasks.get(String(idea.routing.task_id));
325
+ assert.equal(task.source, 'idea-route', 'source names the vector');
326
+ assert.equal(task.source_ref, `idea-${idea.id}`,
327
+ 'and source_ref names the idea — db.findTaskBySourceRef is what stops a second task being minted');
328
+ });
329
+
330
+ summary();