@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.
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.453",
6
- "core_contract": "1.19.453",
7
- "source_commit": "d608d7888d16f919cc2775428848dcfff82d333b",
5
+ "core_version": "1.19.455",
6
+ "core_contract": "1.19.455",
7
+ "source_commit": "73e271f9813d11dde6c2ceaca0eedeb4cf71ed4e",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-01T20:06:17.187Z",
9
+ "built_at": "2026-09-01T21:02:15.357Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 371,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 1789,
14
+ "functional_verbatim": 1790,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2184,
20
- "tree_sha256": "b32b4f4ed373293772fa27594b928449ab80778248125d70b8401f71e0b34ac3",
19
+ "file_count": 2185,
20
+ "tree_sha256": "65ad277694ad3b8aed5a0a975bc77754be62f4e305062a558e5608dda4d49bbc",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -682,7 +682,7 @@
682
682
  {
683
683
  "path": "docs/adr/0047-discord-bugs-intake-anti-abuse.md",
684
684
  "mode": "0000644",
685
- "sha256": "06eda1262e9b5f2b44b57d6ee97edd64ba9f12fcab7a7dea1e97a88ac41f9115"
685
+ "sha256": "013c11580a411d12691c1d79a60e54483faea08c056a71ebd953364e4eca4754"
686
686
  },
687
687
  {
688
688
  "path": "docs/adr/0048-works-in-progress-ia-split.md",
@@ -2092,7 +2092,7 @@
2092
2092
  {
2093
2093
  "path": "docs/module-api-changelog.md",
2094
2094
  "mode": "0000644",
2095
- "sha256": "9626b5a975e660041681a9d58615224db812a74a705251d12f53cd51bcc88f1f"
2095
+ "sha256": "4b48503d7e83ce2f3678aa1e17f57f859a3d659ac12438deb3ad9382acd2ea47"
2096
2096
  },
2097
2097
  {
2098
2098
  "path": "docs/modules-contract.md",
@@ -3472,7 +3472,7 @@
3472
3472
  {
3473
3473
  "path": "modules/discord/db.js",
3474
3474
  "mode": "0000644",
3475
- "sha256": "8c7f64ab027fa52885e047d47b68c3f6ed2cba79f55aaf8303266973a5370cf8"
3475
+ "sha256": "e265e853cf153a76e7b84f304e6f4338bd2bbfc2098383c358a0df7ac02a6817"
3476
3476
  },
3477
3477
  {
3478
3478
  "path": "modules/discord/discord-approvals.js",
@@ -3487,12 +3487,12 @@
3487
3487
  {
3488
3488
  "path": "modules/discord/discord-bot.js",
3489
3489
  "mode": "0000644",
3490
- "sha256": "7bdd8892cf1dcc7edddcc2e8f95d3174b7f902be83fd0a34ecfc2b7cc998ce55"
3490
+ "sha256": "15d3bf047731fa50cca8bc3b06ef1c2b27e9b8a0067f3151a00974f99eb2ac3d"
3491
3491
  },
3492
3492
  {
3493
3493
  "path": "modules/discord/discord-bugs.js",
3494
3494
  "mode": "0000644",
3495
- "sha256": "6f2e61eb3a0722405ffce08aba52cc4569a6dc1c9da3897bb83f29f658d3b5d0"
3495
+ "sha256": "083569e4fe2a621dc6c8663fafc5ec1d6f5e1e1893a3b4a9bdb74ecf4854cd24"
3496
3496
  },
3497
3497
  {
3498
3498
  "path": "modules/discord/discord-channels.js",
@@ -3527,7 +3527,7 @@
3527
3527
  {
3528
3528
  "path": "modules/discord/module.json",
3529
3529
  "mode": "0000644",
3530
- "sha256": "38d30f5a250d708cbbbee1c84a9a417d2a44d51dda1e6e4030f6b577d941491c"
3530
+ "sha256": "6800aead0d24b615912d2711dbe90ef284e8a31579da7c8a8ce642de81274307"
3531
3531
  },
3532
3532
  {
3533
3533
  "path": "modules/discord/pollers/discord-bot.js",
@@ -4712,7 +4712,7 @@
4712
4712
  {
4713
4713
  "path": "modules/lifecycle/goal-suggest.js",
4714
4714
  "mode": "0000644",
4715
- "sha256": "5fac15e501d6e5639b9e14d08ab34723b60e3569af913f4d15bb703a4e126a77"
4715
+ "sha256": "85117db645accd023f4f61735f9f22a8c5f6ef08a29c61db99fbade51d081147"
4716
4716
  },
4717
4717
  {
4718
4718
  "path": "modules/lifecycle/help-requests.js",
@@ -4737,7 +4737,7 @@
4737
4737
  {
4738
4738
  "path": "modules/lifecycle/lifecycle.js",
4739
4739
  "mode": "0000644",
4740
- "sha256": "2d683f62f6813d7894dd54657a70b027cf686dfa76879541adc84544f03d4b51"
4740
+ "sha256": "8631c6ce8a2d33631f9dc549a47102cdb8121484b98cb759151e10e2be9af4c5"
4741
4741
  },
4742
4742
  {
4743
4743
  "path": "modules/lifecycle/merge-lock.js",
@@ -6302,12 +6302,12 @@
6302
6302
  {
6303
6303
  "path": "package-lock.json",
6304
6304
  "mode": "0000644",
6305
- "sha256": "a9e7b10b99360f563204364b2a444ff18b701669723ef0a9a76a04a0046df317"
6305
+ "sha256": "f797395e1506e7142c1e0e200fce54996022fbe3547bcf44cab043b7da5cdc2c"
6306
6306
  },
6307
6307
  {
6308
6308
  "path": "package.json",
6309
6309
  "mode": "0000644",
6310
- "sha256": "ea1047b8ac17de0376d21a3a07d9f22f2854e20397b72b0626c896e4129bbfdf"
6310
+ "sha256": "977df81d28e616a4f9dafd80e10288b68712c590dbf87201ab959e52b9c6630a"
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": "118d1bcac62c551bf53344abd1b77785f9875d4a6e6c29a86123f4587ac79906"
7960
+ "sha256": "bb1314a41a5f8447e2251d885dd2983c6be5e7bbfee1043e1e2dbb26c67742f0"
7961
7961
  },
7962
7962
  {
7963
7963
  "path": "src/module-loader/catalog.js",
@@ -8757,7 +8757,7 @@
8757
8757
  {
8758
8758
  "path": "tests/discord_bugs.mjs",
8759
8759
  "mode": "0000644",
8760
- "sha256": "60fc6a8e44541337e80ffe5ced53801fa1e5245ce7e993e813afdbd59cc3985a"
8760
+ "sha256": "488c68953274278e7569136705307f89f25d4bd4436839a699cbcef07d789730"
8761
8761
  },
8762
8762
  {
8763
8763
  "path": "tests/discord_channels.mjs",
@@ -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": "dd63216406ff8d76a6250944c03fb8e0f2028c599d01ee4720ba3dd79d547a63"
8871
+ },
8867
8872
  {
8868
8873
  "path": "tests/first_admin_bootstrap.mjs",
8869
8874
  "mode": "0000644",
@@ -9047,7 +9052,7 @@
9047
9052
  {
9048
9053
  "path": "tests/goal_suggest.mjs",
9049
9054
  "mode": "0000644",
9050
- "sha256": "7fc1d81afc49311a3c8525b644319e4831bff295bef8be244220fea5ebfbbbd1"
9055
+ "sha256": "d61e2bd5b5a65239f1410011a5035b124bf8e20c41eac08018635ec8cd3a49c7"
9051
9056
  },
9052
9057
  {
9053
9058
  "path": "tests/goal_tier.mjs",
@@ -32,5 +32,6 @@ Every filing is audited with `surface='discord_bot'` (`route='discord:bugs'`), s
32
32
 
33
33
  - **Config / go-live:** inert until `DISCORD_BUGS_CHANNEL_ID` (env, or `bugs_channel_id` in `~/.config/otb/discord-bot.json`) is set **and** the bot is enabled (`DISCORD_BOT_ENABLED=true`) with the Message Content intent — exactly like `#ideas`. The channel itself is created/edited by the ADR 0037 channel reconciler from `channels.json`.
34
34
  - **Version:** filed bugs default to `DISCORD_BUGS_VERSION_ID` (default `GDS-V3`, the open internal version, since most builder-filed bugs are about the dev system). An Archon can re-home a game bug to a product version. Reward defaults to `DISCORD_BUGS_CREDITS` (10).
35
+ - **SUPERSEDED by task 1003067 (BV1.R11, goal 1000071).** `GDS-V3` shipped, and this default kept filing live bug reports into a closed version's catch-all goal — the failure idea 1000771 was raised over. Both env knobs are gone: #bugs now files through the ideas capture-time routing path, so the version comes from the goal the report routes into and the reward auto-assigns at the create layer (ADR 0096). Guard 1 below (linked-builder-only) is also superseded — see `modules/discord/discord-bugs.js` for the guard set as it now stands.
35
36
  - **Trust boundary intact (ADR 0016):** the bot asserts only *who* posted; the landing state is derived from the live GDS rank, never trusted from the message. The bot grants nothing.
36
37
  - **Deferred:** a dedicated abuse-flag widget on the `/watch` red-team view. The audit trail (`surface='discord_bot'`, the 429 rejections) already makes abuse queryable; a bespoke view is a follow-up if volume warrants it.
@@ -1343,5 +1343,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1343
1343
  landed since 1.19.451 with no explicit bump. run 33552353620. (task 1002620)
1344
1344
  1.19.453 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1345
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)
1348
+ 1.19.455 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1349
+ landed since 1.19.454 with no explicit bump. run 33558738299. (task 1002620)
1346
1350
  ---------------------------------------------------------------------------
1347
1351
  ```
@@ -143,9 +143,6 @@ module.exports = {
143
143
  markBoardMessageEdited,
144
144
  // kernel db helpers, re-exported off the doorway (one `db.*` namespace)
145
145
  insertAuditLog: (...a) => api.insertAuditLog(...a),
146
- // createTask is a LIFECYCLE write — resolve the `lifecycle` kernel port at call
147
- // time (the doorway leak retired at BV1.R86); used by discord-bugs (#bugs → task).
148
- createTask: (...a) => api.resolveOptional('lifecycle').createTask(...a),
149
146
  getBuilderById: (...a) => api.getBuilderById(...a),
150
147
  get LIVE_RANK_LADDER() { return api.LIVE_RANK_LADDER; },
151
148
  // the shared pool, for any module file that wants it via db.pool
@@ -348,7 +348,14 @@ async function start() {
348
348
  // real filing path (recordInboundBug). Built once at startup.
349
349
  const bugAggregator = bugs.createBugAggregator({
350
350
  onFlush: (combined) => bugs.recordInboundBug(combined).then((result) => {
351
- log.info(`[discord-bot] #bugs burst → ${result.outcome}${result.taskId ? ` (task #${result.taskId}, ${result.status}, ${result.images || 0} img)` : (result.reason ? ` (${result.reason})` : '')}`);
351
+ // task 1003067: a filing now always yields an IDEA; whether it also became
352
+ // a task depends on the landing matrix. Log both, so a homeless report is
353
+ // traceable instead of showing as a bare "filed" with no id to chase.
354
+ log.info(`[discord-bot] #bugs burst → ${result.outcome}${
355
+ result.ideaId
356
+ ? ` (idea ${result.ideaId}${result.taskId ? `, task ${result.taskId}, ${result.status}` : ', inbox'}, ${result.images || 0} img)`
357
+ : (result.reason ? ` (${result.reason})` : '')
358
+ }`);
352
359
  return result;
353
360
  }),
354
361
  });
@@ -1,28 +1,45 @@
1
1
  'use strict';
2
2
 
3
- // Discord #bugs → GDS tasks (kind='bug') — Phase 3 F8 (ADR 0033 §F8, ADR 0047).
3
+ // Discord #bugs → a routed filing — Phase 3 F8 (ADR 0033 §F8, ADR 0047),
4
+ // retired onto the capture-time routing path by task 1003067 (BV1.R11).
4
5
  //
5
- // The bot turns a message posted in the designated #bugs channel into a GDS
6
- // task with kind='bug', STRAIGHT THROUGH — no idea_inbox, no triage step. This
7
- // is the deliberate difference from #ideas (discord-inbound.js): a bug is a
8
- // small fix a builder wants to drop without context-switching, and it becomes
9
- // claimable work directly.
6
+ // WHAT CHANGED AND WHY. This path used to call createTask DIRECTLY, against a
7
+ // hardcoded version id. Two things followed from that, and both were bugs:
10
8
  //
11
- // ANTI-ABUSE GUARD (ADR 0047 — the red-team resolution). Creating claimable
12
- // work with no triage gate is an injection surface, so this path is guarded in
13
- // depth, all server-side, all fail-closed:
14
- // 1. LINKED-BUILDER-ONLY. An unlinked Discord author cannot create a task —
15
- // their message is ignored (not filed). Unlike #ideas (which captures
16
- // unattributed), making *claimable work* requires being an accountable,
17
- // known builder. This is the single biggest lever.
18
- // 2. RANK-AWARE LANDING. A linked Metic+ builder's bug lands 'ready'
19
- // (instantly claimable — the true straight-through path their trust earns).
20
- // A linked Xenos/Thetes builder's bug lands 'backlog' (needs a one-click
21
- // Metic+ promote, like any other backlog task). Mirrors the trust ladder
22
- // used everywhere else in the GDS.
23
- // 3. PER-BUILDER RATE-LIMIT. At most N bug-tasks per builder per rolling hour
24
- // (counted from audit_log). Over-limit filings are ignored AND audited so
25
- // a spammer surfaces in forensics / the red-team view.
9
+ // * The hardcoded id was GDS-V3, a SHIPPED version. Every bug reported through
10
+ // Discord resolved into that version's catch-all goal — a bucket nobody ever
11
+ // opens again. That is idea 1000771, the report goal 1000071 was filed over.
12
+ // * Creating the task directly meant NO idea row and NO lineage, so the person
13
+ // who reported the bug vanished from the record the moment it became work.
14
+ // The ideator credit lane (goal 1000067) pays against that lineage, so this
15
+ // vector was quietly minting unattributable work.
16
+ //
17
+ // Now the message becomes a CAPTURE (ideas.capture, the same port #ideas uses)
18
+ // with a suggested goal, and the ideas module's landing matrix decides the rest.
19
+ // The version is no longer named here at all: the goal carries it, and lifecycle
20
+ // resolves which version is open to work (suggestGoalForCapture). A vector that
21
+ // does not know a version id cannot hardcode a dead one.
22
+ //
23
+ // ANTI-ABUSE GUARD (ADR 0047 — the red-team resolution), as it stands now:
24
+ // 1. AN UNLINKED AUTHOR CANNOT CREATE WORK. The original lever was
25
+ // linked-builder-ONLY: an unlinked message was dropped entirely, because
26
+ // this path minted claimable work with no triage gate. It no longer mints
27
+ // anything — an unlinked author's report becomes an idea_inbox row with
28
+ // captured_by NULL and NO goal, which is precisely what #ideas has always
29
+ // done for the same person. The security property ADR 0047 actually bought
30
+ // ("an unlinked author cannot inject claimable work") is intact and now
31
+ // structural; what is gone is the silent drop of a real bug report, which
32
+ // only ever protected us from reading it.
33
+ // 2. RANK-AWARE LANDING, now the ideas landing matrix rather than a local
34
+ // copy of it: a linked Metic+ builder's bug lands 'ready', a linked
35
+ // sub-Metic builder's lands 'backlog' awaiting a nod. Same outcomes as
36
+ // before, one definition instead of two (pinned in
37
+ // tests/filing_abuse_matrix.mjs).
38
+ // 3. PER-BUILDER RATE-LIMIT. At most N filings per LINKED builder per rolling
39
+ // hour (counted from audit_log). Over-limit filings are ignored AND audited
40
+ // so a spammer surfaces in forensics / the red-team view. An unlinked author
41
+ // has no builder id to count against, so they are held to the #ideas posture
42
+ // — an inbox row, never a task — rather than a cap this table cannot express.
26
43
  // 4. BODY SCREEN. Empty / too-short / text-free messages are ignored before
27
44
  // any DB write; title + body are capped to the GDS limits.
28
45
  //
@@ -40,11 +57,15 @@ const { auditDiscordBot } = require('./discord-audit');
40
57
  const auth = require('./auth');
41
58
  const attachments = require('./bug-attachments');
42
59
  const { combineParts, createMessageAggregator } = require('./discord-message-buffer');
43
- const { LIMITS } = require('../../src/module-api');
60
+ const api = require('../../src/module-api');
61
+ const { LIMITS } = api;
62
+ const log = api.logger('discord');
44
63
 
45
- // Ranks whose bugs land 'ready' (instantly claimable). Everyone else linked
46
- // lands 'backlog'. Metic+ — matches REQUIRES_RANK_TIER >= metic in db.js.
47
- const READY_RANKS = new Set(['metic', 'archon']);
64
+ // NO LOCAL RANK RULE LIVES HERE (task 1003067). READY_RANKS + decideBugLanding
65
+ // used to decide ready-vs-backlog for this channel alone. The ideas landing matrix
66
+ // (modules/ideas/routing.js) now decides it for EVERY filing vector, so a second
67
+ // copy here could only ever drift from it — which is what
68
+ // tests/filing_abuse_matrix.mjs exists to prevent.
48
69
 
49
70
  // Smallest message we'll treat as a real bug report. Anything shorter is noise.
50
71
  const MIN_BUG_CHARS = 8;
@@ -53,13 +74,11 @@ const MIN_BUG_CHARS = 8;
53
74
  // DISCORD_BUGS_RATE_PER_HOUR overrides (a number); falls back to this.
54
75
  const DEFAULT_RATE_PER_HOUR = 5;
55
76
 
56
- // Default GDS version a filed bug is filed under. Most builder-filed bugs are
57
- // about the dev system; an Archon can re-home a game bug to a product version.
58
- // Env DISCORD_BUGS_VERSION_ID overrides.
59
- const DEFAULT_BUGS_VERSION_ID = 'GDS-V3';
60
-
61
- // Modest reward for fixing a drive-by bug. Env DISCORD_BUGS_CREDITS overrides.
62
- const DEFAULT_BUGS_CREDITS = 10;
77
+ // NO DEFAULT VERSION LIVES HERE ANY MORE (task 1003067). DEFAULT_BUGS_VERSION_ID
78
+ // used to be 'GDS-V3' and DISCORD_BUGS_CREDITS set a per-task reward. The version
79
+ // is now carried by the goal the filing routes into — a vector that cannot name a
80
+ // version cannot name a dead one, which is idea 1000771 in one sentence — and the
81
+ // reward auto-assigns at the lowest create layer like every other vector (ADR 0096).
63
82
 
64
83
  // Auto-pairing window: consecutive #bugs messages from the SAME author within
65
84
  // this window are combined into ONE bug-task (so "screenshot" + "here's what
@@ -75,27 +94,11 @@ function rateLimitPerHour(env = process.env) {
75
94
  return Number.isFinite(n) && n > 0 ? Math.floor(n) : DEFAULT_RATE_PER_HOUR;
76
95
  }
77
96
 
78
- function bugsVersionId(env = process.env) {
79
- return env.DISCORD_BUGS_VERSION_ID || DEFAULT_BUGS_VERSION_ID;
80
- }
81
-
82
- function bugsCredits(env = process.env) {
83
- const n = Number(env.DISCORD_BUGS_CREDITS);
84
- return Number.isFinite(n) && n >= 0 ? Math.floor(n) : DEFAULT_BUGS_CREDITS;
85
- }
86
-
87
97
  function pairWindowMs(env = process.env) {
88
98
  const n = Number(env.DISCORD_BUGS_PAIR_WINDOW_MS);
89
99
  return Number.isFinite(n) && n > 0 ? Math.floor(n) : DEFAULT_PAIR_WINDOW_MS;
90
100
  }
91
101
 
92
- // PURE — decide where a bug lands from the reporter's LIVE rank. Metic+ →
93
- // 'ready' (straight-through, claimable now); any other linked rank → 'backlog'
94
- // (a Metic+ promotes it, the lightweight confirm gate). Unit-tested.
95
- function decideBugLanding(rank) {
96
- return READY_RANKS.has(String(rank || '').toLowerCase()) ? 'ready' : 'backlog';
97
- }
98
-
99
102
  // PURE — screen + shape a raw message into { ok, title, bodyMd } or
100
103
  // { ok:false, reason }. The body screen rejects blank / too-short / text-free
101
104
  // (no letters) messages before any DB write — UNLESS the message carries
@@ -127,7 +130,18 @@ function screenBug(content, { hasImages = false } = {}) {
127
130
  const realDeps = {
128
131
  getBuilderByDiscordUserId: db.getBuilderByDiscordUserId,
129
132
  countRecentDiscordBugFilings: db.countRecentDiscordBugFilings,
130
- createTask: db.createTask,
133
+ // The two kernel ports this vector files through (never an import — the one-way
134
+ // module rule, ADR 0083/0093). `ideas.capture` is the same door #ideas uses;
135
+ // `lifecycle` answers which goal, on which still-open version.
136
+ captureIdea: (...a) => api.resolve('ideas.capture')(...a),
137
+ // resolveOptional, not resolve: an instance running without the lifecycle module
138
+ // has no goals to suggest, and that is a reason to file the report WITHOUT a
139
+ // home — never a reason to lose it. Returns null, which lands it in the inbox.
140
+ suggestGoalForCapture: async (...a) => {
141
+ const lifecycle = api.resolveOptional && api.resolveOptional('lifecycle');
142
+ if (!lifecycle || !lifecycle.suggestGoalForCapture) return null;
143
+ return lifecycle.suggestGoalForCapture(...a);
144
+ },
131
145
  insertAuditLog: db.insertAuditLog,
132
146
  persistImages: attachments.persistImages,
133
147
  };
@@ -144,14 +158,13 @@ async function recordInboundBug({ discordUserId, content, imageUrls = [], messag
144
158
  if (!screened.ok) return { outcome: 'ignored', reason: screened.reason };
145
159
 
146
160
  const limit = config.rateLimitPerHour ?? rateLimitPerHour();
147
- const versionId = config.versionId ?? bugsVersionId();
148
- const credits = config.credits ?? bugsCredits();
149
161
 
150
162
  try {
151
- // (1) LINKED-BUILDER-ONLY. No linked builder → reject (do not file). An
152
- // unlinked author cannot inject claimable work.
163
+ // (1) WHO POSTED. A linked builder files with their rank and is rate-limited;
164
+ // an unlinked author still gets their report recorded, but only ever as an
165
+ // inbox row (no goal, no rank ⇒ the matrix cannot route it into work). See
166
+ // the guard note in the header for why this replaced the outright drop.
153
167
  const builder = await deps.getBuilderByDiscordUserId(discordUserId);
154
- if (!builder) return { outcome: 'ignored', reason: 'unlinked' };
155
168
 
156
169
  // (3) PER-BUILDER RATE-LIMIT. Count this builder's bug filings in the last
157
170
  // hour; over the cap → reject AND audit (so a spammer is visible in
@@ -160,7 +173,7 @@ async function recordInboundBug({ discordUserId, content, imageUrls = [], messag
160
173
  let recent = 0;
161
174
  let countFailed = false;
162
175
  try {
163
- recent = await deps.countRecentDiscordBugFilings(builder.id, sinceIso);
176
+ if (builder) recent = await deps.countRecentDiscordBugFilings(builder.id, sinceIso);
164
177
  } catch (e) {
165
178
  // L4 (red-team 2026-06-12): FAIL CLOSED on a count error. The old behaviour
166
179
  // proceeded ("fail-open"), which let a DB error be used to slip past the
@@ -170,7 +183,7 @@ async function recordInboundBug({ discordUserId, content, imageUrls = [], messag
170
183
  console.error('[discord-bot] bug rate-limit count failed (denying — fail-closed):', e.message);
171
184
  countFailed = true;
172
185
  }
173
- if (countFailed || recent >= limit) {
186
+ if (builder && (countFailed || recent >= limit)) {
174
187
  const reason = countFailed ? 'rate_limit_count_error' : 'rate_limited';
175
188
  await auditDiscordBot(deps, {
176
189
  builderId: builder.id,
@@ -182,8 +195,11 @@ async function recordInboundBug({ discordUserId, content, imageUrls = [], messag
182
195
  return { outcome: 'ignored', reason, recent };
183
196
  }
184
197
 
185
- // (2) RANK-AWARE LANDING — decided from the LIVE rank, never the message.
186
- const status = decideBugLanding(builder.rank);
198
+ // (2) RANK-AWARE LANDING is no longer decided here. The rank still comes from
199
+ // the LIVE builder row and never from the message — it is simply handed to the
200
+ // ideas landing matrix, which owns the ready-vs-backlog split for every filing
201
+ // vector. One definition, so this channel cannot drift from the others.
202
+ const filerRank = builder ? builder.rank : null;
187
203
 
188
204
  // Re-host any images on our server (durable — Discord CDN links expire). The
189
205
  // guard (host allowlist + content-type + magic-byte sniff + size/count caps)
@@ -201,7 +217,9 @@ async function recordInboundBug({ discordUserId, content, imageUrls = [], messag
201
217
  // Provenance footer so a claimer knows where the bug came from + who to ask.
202
218
  // The raw Discord snowflake is NOT embedded (it lives in the Archon-only
203
219
  // audit_log); only the builder login. Then the durable attachment links.
204
- const origin = `\n\n— reported from Discord #bugs by ${builder.github_login}`;
220
+ const origin = builder
221
+ ? `\n\n— reported from Discord #bugs by ${builder.github_login}`
222
+ : '\n\n— reported from Discord #bugs (unlinked author)';
205
223
  let description = `${screened.bodyMd}${origin}`;
206
224
  if (stored.length > 0) {
207
225
  const imgs = stored.map((s, i) => `![attachment ${i + 1}](${s.url})`).join('\n');
@@ -209,27 +227,71 @@ async function recordInboundBug({ discordUserId, content, imageUrls = [], messag
209
227
  }
210
228
  description = description.slice(0, LIMITS.DESCRIPTION);
211
229
 
212
- const task = await deps.createTask({
213
- versionId,
230
+ // THE HOME. Only a linked builder's report is offered one: an unlinked author
231
+ // stays strictly less privileged than any signed-in builder (the #ideas rule),
232
+ // and with no goal the matrix files them to the inbox, which is the point.
233
+ // A null suggestion is the ordinary case and lands in the inbox too.
234
+ let goal = null;
235
+ if (builder) {
236
+ goal = await deps.suggestGoalForCapture({ title: screened.title, body: screened.bodyMd });
237
+ }
238
+
239
+ // The capture. One transaction inside the ideas module writes the idea row,
240
+ // the task (when the matrix routes it) and the lineage that keeps the reporter
241
+ // attached to the work — the whole reason this vector stopped calling
242
+ // createTask. kind is forced to 'bug' because the channel IS the classification.
243
+ const capture = {
214
244
  title: screened.title,
215
- description,
216
- status,
245
+ bodyMd: description,
217
246
  kind: 'bug',
218
- priority: 3,
219
- creditsReward: credits,
220
- source: 'discord:bugs',
221
- sourceRef: `discord:${messageId}`,
222
- });
247
+ capturedBy: builder ? builder.id : null,
248
+ filerRank,
249
+ goalId: goal ? goal.id : null,
250
+ suggestedPriority: 3,
251
+ };
252
+
253
+ // THE HOME IS A SUGGESTION, NOT A CONDITION OF BEING HEARD. routeIdeaToGoal is
254
+ // ONE transaction, so a goal that refuses the landing — protected scope, a
255
+ // scope-wall verdict, a goal archived mid-flight — rolls the idea row back too
256
+ // and the report is gone. That is the exact failure this task exists to end, and
257
+ // it would be caused by a goal WE guessed at, not by anything the reporter did.
258
+ // So a refusal drops the guess and files the report homeless: it lands in the
259
+ // inbox for triage, which is strictly what would have happened before we tried.
260
+ let idea;
261
+ try {
262
+ idea = await deps.captureIdea(capture);
263
+ } catch (routeErr) {
264
+ if (!capture.goalId) throw routeErr; // nothing to drop — a real failure
265
+ log.warn({ err: routeErr && (routeErr.code || routeErr.message), goalId: capture.goalId },
266
+ 'routed capture refused — filing the report to the inbox instead');
267
+ idea = await deps.captureIdea({ ...capture, goalId: null });
268
+ }
223
269
 
270
+ const routed = idea && idea.routing && idea.routing.routed ? idea.routing : null;
224
271
  await auditDiscordBot(deps, {
225
- builderId: builder.id,
272
+ builderId: builder ? builder.id : null,
226
273
  route: 'discord:bugs',
227
- requestBodyRedacted: { discord_user_id: discordUserId, message_id: messageId, task_id: task.id, status, images: stored.length },
274
+ requestBodyRedacted: {
275
+ discord_user_id: discordUserId,
276
+ message_id: messageId,
277
+ idea_id: idea.id,
278
+ task_id: routed ? routed.task_id : null,
279
+ goal_id: routed ? routed.goal_id : null,
280
+ status: routed ? routed.status : null,
281
+ images: stored.length,
282
+ },
228
283
  responseStatus: 201,
229
284
  logLabel: 'bug audit-log write failed',
230
285
  });
231
286
 
232
- return { outcome: 'filed', taskId: task.id, status, builderId: builder.id, images: stored.length };
287
+ return {
288
+ outcome: 'filed',
289
+ ideaId: idea.id,
290
+ taskId: routed ? routed.task_id : null,
291
+ status: routed ? routed.status : null,
292
+ builderId: builder ? builder.id : null,
293
+ images: stored.length,
294
+ };
233
295
  } catch (err) {
234
296
  console.error('[discord-bot] recordInboundBug failed:', err.message);
235
297
  return { outcome: 'error', error: err.message };
@@ -245,13 +307,10 @@ function createBugAggregator({ onFlush, windowMs = pairWindowMs(), timers = glob
245
307
 
246
308
  module.exports = {
247
309
  screenBug,
248
- decideBugLanding,
249
310
  recordInboundBug,
250
311
  combineParts,
251
312
  createBugAggregator,
252
313
  realDeps,
253
314
  rateLimitPerHour,
254
- bugsVersionId,
255
- bugsCredits,
256
315
  pairWindowMs,
257
316
  };
@@ -12,6 +12,6 @@
12
12
  "uiSections": []
13
13
  },
14
14
  "provides": ["discord.isLinked"],
15
- "consumes": ["ideas.capture", "government"],
15
+ "consumes": ["ideas.capture", "government", "lifecycle"],
16
16
  "dependencies": { "discord.js": "^14.26.4" }
17
17
  }
@@ -26,10 +26,14 @@
26
26
  // really does belong with a goal scoped to `hall-ui`.
27
27
 
28
28
  const { tokenize } = require('./criterion-suggest.js');
29
- const { candidateGoals } = require('./goal-advisory.js');
29
+ const { versionAcceptsNewWork } = require('./db-tasks.js');
30
+ const { candidateGoals, generalGoalTitle } = require('./goal-advisory.js');
30
31
  const api = require('../../src/module-api');
31
32
  // Through the published logger seam, not a raw console call (est-advisory.js precedent).
32
33
  const proseErr = (err) => api.logger('lifecycle').error({ err: err && err.message }, 'GET /goals/suggestions failed');
34
+ // The capture-time twin has its own message: it is not an HTTP route, and a log
35
+ // line that names the wrong caller is a wrong turn taken during an incident.
36
+ const captureErr = (err) => api.logger('lifecycle').error({ err: err && err.message }, 'suggestGoalForCapture failed — filing without a goal');
33
37
 
34
38
  // Matches suggestCriteria's default so the two suggesters feel the same.
35
39
  const SUGGEST_LIMIT = 5;
@@ -82,6 +86,70 @@ function suggestGoals(goals, draft = {}, opts = {}) {
82
86
  }
83
87
 
84
88
 
89
+ // suggestGoalForCapture — ONE goal for a filing that arrived with no goal named
90
+ // (task 1003067 / BV1.R11). The HTTP suggester above hands a human five options to
91
+ // choose from; a Discord message has nobody to choose, so this collapses the same
92
+ // ranking to a single confident answer or none at all.
93
+ //
94
+ // WHY IT RESOLVES THE VERSION ITSELF. The vector this was built for — Discord
95
+ // #bugs — used to carry a hardcoded version id, and because that id was a SHIPPED
96
+ // version every bug reported through it landed in a tomb nobody reads (idea
97
+ // 1000771, the failure goal 1000071 exists to fix). A filing vector should not be
98
+ // in the business of naming a version at all, so it does not: the GOAL carries the
99
+ // version, and this picks the goal.
100
+ //
101
+ // It therefore ranks across EVERY version still open to work (versionAcceptsNewWork,
102
+ // task 1003061), not one. An earlier cut of this declined whenever more than one
103
+ // version was open — prudent-looking, and a dead feature: this instance runs two
104
+ // building versions concurrently, so it would have returned null for every report
105
+ // ever filed. "Which version" is not a question worth asking when the answer is
106
+ // already attached to the goal that won.
107
+ //
108
+ // Catch-all goals are excluded for every one of those versions. candidateGoals
109
+ // drops the catch-all for ONE version id, which is the wrong shape here, so the
110
+ // filtering happens up front — a "<version> — general" bucket is never a home
111
+ // (that is R03's fallback question, a different one).
112
+ //
113
+ // Returns { id, title, version_id, score } or NULL. Null is the ordinary case and
114
+ // a good one: no goal means the filing lands in the inbox, exactly as every
115
+ // goal-unaware capture always has. Never throws — a suggester that takes down a
116
+ // bug report is worse than one that shrugs.
117
+ async function suggestGoalForCapture({ title = '', body = '' } = {}, deps = {}) {
118
+ const db = deps.db || require('./db.js');
119
+ try {
120
+ if (!String(title || '').trim() && !String(body || '').trim()) return null;
121
+
122
+ // Independent reads — issued together rather than in sequence.
123
+ const [versions, allGoals] = await Promise.all([db.listVersions(), db.listGoals({ status: 'open' })]);
124
+ const openIds = new Set(
125
+ (versions || []).filter((v) => versionAcceptsNewWork(v && v.status)).map((v) => String(v.id))
126
+ );
127
+ // Nothing is being built ⇒ nowhere for new work to go ⇒ the inbox.
128
+ if (openIds.size === 0) return null;
129
+
130
+ const goals = allGoals || [];
131
+ const homes = goals.filter((g) => g
132
+ && openIds.has(String(g.version_id))
133
+ && g.title !== generalGoalTitle(g.version_id));
134
+ if (homes.length === 0) return null;
135
+
136
+ // versionId is passed null: the catch-alls are already gone, and passing one
137
+ // version's id here would only re-filter for that version. includeZero stays
138
+ // false, so a filing that matches nothing gets NO goal rather than the
139
+ // first one dressed up as a suggestion.
140
+ const [best] = suggestGoals(homes, { title, body }, { versionId: null, limit: 1 });
141
+ if (!best) return null;
142
+
143
+ // suggestGoals normalises to { id, title, scope_modules, … } and drops
144
+ // version_id, so read it back off the row that won.
145
+ const row = homes.find((g) => String(g.id) === String(best.id));
146
+ return { id: best.id, title: best.title, version_id: row ? row.version_id : null, score: best.score };
147
+ } catch (err) {
148
+ captureErr(err);
149
+ return null;
150
+ }
151
+ }
152
+
85
153
  // goalSuggestHandler — GET /goals/suggestions. Handler lives here, not inline in
86
154
  // routes/goals.js, because that file is over the fitness oversize threshold and
87
155
  // the ratchet is shrink-only; collaborators are injected so this module still
@@ -168,4 +236,4 @@ function criteriaSuggestHandler({ db, suggestCriteria, parseId }) {
168
236
  };
169
237
  }
170
238
 
171
- module.exports = { suggestGoals, goalSuggestHandler, criteriaSuggestHandler, SUGGEST_LIMIT };
239
+ module.exports = { suggestGoals, suggestGoalForCapture, goalSuggestHandler, criteriaSuggestHandler, SUGGEST_LIMIT };
@@ -18,6 +18,7 @@
18
18
 
19
19
  const db = require('./db');
20
20
  const classifier = require('./task-classifier');
21
+ const goalSuggest = require('./goal-suggest');
21
22
  const helpRequests = require('./help-requests');
22
23
  const taskRecommendations = require('./task-recommendations');
23
24
  const activity = require('./activity');
@@ -35,6 +36,12 @@ module.exports = {
35
36
  // (consumer: core cascade-dispatch.js, the dormant System-3 lib).
36
37
  get VOTABLE_TASK_STATUSES() { return classifier.VOTABLE_TASK_STATUSES; },
37
38
  classifyTaskKind: (...a) => classifier.classifyTaskKind(...a),
39
+ // suggestGoalForCapture: ONE goal for a filing that named none, or null (task
40
+ // 1003067). Consumer: the discord #bugs vector, which files through the ideas
41
+ // routing path and needs a home to offer it. Lives behind the port because
42
+ // "which goal, on which version" is a lifecycle judgement and discord may not
43
+ // import lifecycle (the one-way module rule).
44
+ suggestGoalForCapture: (...a) => goalSuggest.suggestGoalForCapture(...a),
38
45
 
39
46
  // --- work-tracking READS the staying-core read routes resolve --------------
40
47
  // public.js (the status surface): version/ship feed + grade trend.