@bongos/core 1.19.273 → 1.19.275

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.273",
6
- "core_contract": "1.19.273",
7
- "source_commit": "5537eeef23f8f0daa60fcb4a167c9a95ec085a17",
5
+ "core_version": "1.19.275",
6
+ "core_contract": "1.19.275",
7
+ "source_commit": "0f1493b2557fe16041d30d15fb064fc7c15448d0",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-21T02:47:17.902Z",
9
+ "built_at": "2026-08-21T14:30:08.138Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 318,
13
13
  "agent_docs_stubbed": 22,
14
- "functional_verbatim": 1370,
14
+ "functional_verbatim": 1374,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1710,
20
- "tree_sha256": "6446c48d86f464c04e57b8332758031a3427ca7756c6dc01ec7933c989635dde",
19
+ "file_count": 1714,
20
+ "tree_sha256": "4f04f6243896e821ad5b920694493985f5a485aaf9684204a398105333baadb3",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -367,7 +367,7 @@
367
367
  {
368
368
  "path": "clients/bongos-client/index.d.ts",
369
369
  "mode": "0000644",
370
- "sha256": "96bbd222f3ccbc2d57bce28e0ac89584252b0e3213bc79f5c8aad54ce10cc845"
370
+ "sha256": "d78827e8ec5ad4e27dfd5b0ad33fd3a9e9ef48f3fa62f28320374ba7fcc78422"
371
371
  },
372
372
  {
373
373
  "path": "clients/bongos-client/index.mjs",
@@ -1397,12 +1397,12 @@
1397
1397
  {
1398
1398
  "path": "docs/api-reference.md",
1399
1399
  "mode": "0000644",
1400
- "sha256": "3eb89e224eea00629fb69752c7606e9ffed34171ab16fcb82120e030e5db8476"
1400
+ "sha256": "da411a477344816c800f05b84740517a14394c9cbcf985a03ef7d4c178f55161"
1401
1401
  },
1402
1402
  {
1403
1403
  "path": "docs/api/openapi.json",
1404
1404
  "mode": "0000644",
1405
- "sha256": "2f14ac090adc4b7de69f20948172cee0cf002e1224262d5a51ddcada95ad9489"
1405
+ "sha256": "09784dfef8f31071e4611374c3fc592793d0194492ac51896974debbcfecdeec"
1406
1406
  },
1407
1407
  {
1408
1408
  "path": "docs/architecture.md",
@@ -3682,7 +3682,7 @@
3682
3682
  {
3683
3683
  "path": "modules/ideas/inbox.js",
3684
3684
  "mode": "0000644",
3685
- "sha256": "082e2598eec5645a84182d0b56f6e2ba9917d54c0af81bea3196d531099d4249"
3685
+ "sha256": "e132a1ebab662aba91113f8bfe60c7df6a8818f4d3743b5819903904fdef52fb"
3686
3686
  },
3687
3687
  {
3688
3688
  "path": "modules/ideas/migrations/ideas_001_grades.sql",
@@ -3712,7 +3712,12 @@
3712
3712
  {
3713
3713
  "path": "modules/ideas/routes/inbox.js",
3714
3714
  "mode": "0000644",
3715
- "sha256": "f19fa968ad6fd08b65931039ecf3b8d4b31b1aabef56b42f62cd1a4fbd06328e"
3715
+ "sha256": "f4f1003c7cdc0d103fb052f1dc50b76291362b53ddd6b76b561c6d872d2d7a91"
3716
+ },
3717
+ {
3718
+ "path": "modules/ideas/routing.js",
3719
+ "mode": "0000644",
3720
+ "sha256": "25ecd991b874353b757fcd9b8773d6135a76af37f699c4f854c66e196caf06f0"
3716
3721
  },
3717
3722
  {
3718
3723
  "path": "modules/ideas/templates.js",
@@ -3742,13 +3747,18 @@
3742
3747
  {
3743
3748
  "path": "modules/lifecycle/db.js",
3744
3749
  "mode": "0000644",
3745
- "sha256": "b35b53b3badf7f465fe97d317cd32653518f14d98a76a03b18374c49b31db65a"
3750
+ "sha256": "64cdde411e3fb8d1263eebf9093a353dde6ee684be2977057c8800e608b52a63"
3746
3751
  },
3747
3752
  {
3748
3753
  "path": "modules/lifecycle/done-when.js",
3749
3754
  "mode": "0000644",
3750
3755
  "sha256": "4e6f6af1cdaf026466155c1b0656a0e89872aa2bb264f36c50997ec4f069e808"
3751
3756
  },
3757
+ {
3758
+ "path": "modules/lifecycle/est-advisory.js",
3759
+ "mode": "0000644",
3760
+ "sha256": "23ee20e57c67adb5d2ec29c654d2bc469434cafa010b375a8a4452ced1df49e3"
3761
+ },
3752
3762
  {
3753
3763
  "path": "modules/lifecycle/github-push.js",
3754
3764
  "mode": "0000644",
@@ -3847,7 +3857,7 @@
3847
3857
  {
3848
3858
  "path": "modules/lifecycle/routes/tasks.js",
3849
3859
  "mode": "0000644",
3850
- "sha256": "95193b64dcf1121f10fbc871a07cb7997a27a64d3f7b2176957abdae28979729"
3860
+ "sha256": "0600b76953e28b2c5828301606c72e2490756d43a2eb74b4bc92dca9d795cb77"
3851
3861
  },
3852
3862
  {
3853
3863
  "path": "modules/lifecycle/routes/versions.js",
@@ -4472,12 +4482,12 @@
4472
4482
  {
4473
4483
  "path": "package-lock.json",
4474
4484
  "mode": "0000644",
4475
- "sha256": "18002184d4180e23e816413d0ea52da34989862b7ad7021b1df84790efed6602"
4485
+ "sha256": "eb3ac31abdc6eef09c4a19ddca7e03320280e889792f4ccbf09004695225b8a6"
4476
4486
  },
4477
4487
  {
4478
4488
  "path": "package.json",
4479
4489
  "mode": "0000644",
4480
- "sha256": "db5a0de515b3ba49f58b4fa629136f0ff9327bfaa45c5ac8fd994751f2390c07"
4490
+ "sha256": "f6d4dd4520d0b22fd2c0effa16c1aea034cb8e32a0520f11c27eb7b5461b4d73"
4481
4491
  },
4482
4492
  {
4483
4493
  "path": "public-docs/index.html",
@@ -6122,7 +6132,7 @@
6122
6132
  {
6123
6133
  "path": "src/module-api.js",
6124
6134
  "mode": "0000644",
6125
- "sha256": "c8c489bd626bd990b5d2230f8f535350abb2cf754910fd3bc7869e56fb1444ce"
6135
+ "sha256": "ebdca46e67df3c28972e28e78a20bf3fb6a88d96c9763cd502e29a6e7573d545"
6126
6136
  },
6127
6137
  {
6128
6138
  "path": "src/module-loader/catalog.js",
@@ -6904,6 +6914,11 @@
6904
6914
  "mode": "0000644",
6905
6915
  "sha256": "89e05751271dd18b96a2319d624403353bdc710b2e8b5c0cf6181b125cd17b76"
6906
6916
  },
6917
+ {
6918
+ "path": "tests/est_advisory.mjs",
6919
+ "mode": "0000644",
6920
+ "sha256": "171f5ed03eecea4502fddbaa5c12377c1a923aa63c3e3242ad47588d63a23f1a"
6921
+ },
6907
6922
  {
6908
6923
  "path": "tests/event_sound_prefs.mjs",
6909
6924
  "mode": "0000644",
@@ -7027,7 +7042,7 @@
7027
7042
  {
7028
7043
  "path": "tests/goal_advisory.mjs",
7029
7044
  "mode": "0000644",
7030
- "sha256": "c4eb5f6a36faed5c90b500c20bc84944dba62a0af06d4c88c08eaa684bf96aad"
7045
+ "sha256": "f5a6985502bc85589d688bf96b3e9242d84efec39d52f5a451e92c23cbb9175c"
7031
7046
  },
7032
7047
  {
7033
7048
  "path": "tests/goal_cli.mjs",
@@ -7399,6 +7414,11 @@
7399
7414
  "mode": "0000644",
7400
7415
  "sha256": "51e1286235cbde1b56188f29b0e2bd12ac73a9637dbf1c16b325f10147256f27"
7401
7416
  },
7417
+ {
7418
+ "path": "tests/idea_routing.mjs",
7419
+ "mode": "0000644",
7420
+ "sha256": "3ba1fba0951121f23aa073b886f532dc30e0ed863c2a097db019320f70856118"
7421
+ },
7402
7422
  {
7403
7423
  "path": "tests/idea_task_lineage.mjs",
7404
7424
  "mode": "0000644",
@@ -244,7 +244,7 @@ export interface PostGovernmentRanksRequest { rank_key: string; label: string; d
244
244
  export interface PostGovernmentRanksResponse { rank: unknown }
245
245
  export interface PostInboxIdRatifyRequest { notes_md?: string }
246
246
  export interface PostInboxIdResubmitRequest { field_values: Record<string, unknown>; template_key?: string }
247
- export interface PostInboxRequest { title: string; body_md?: string; kind?: string; suggested_version?: string; suggested_priority?: number; grade?: string; template_key?: string; field_values?: Record<string, unknown> }
247
+ export interface PostInboxRequest { title: string; body_md?: string; kind?: string; suggested_version?: string; suggested_priority?: number; goal_id?: number; grade?: string; template_key?: string; field_values?: Record<string, unknown> }
248
248
  export interface PostLearningsRequest { title: string; body_md: string; tags?: string[]; related_files?: string[]; source?: string; source_ref?: string; task_id?: number }
249
249
  export interface PostLearningsResponse { skipped?: boolean; reason?: unknown; learning?: unknown }
250
250
  export interface PostLlmCacheLookupRequest { cache_key?: unknown; tokens_saved_est?: unknown }
@@ -16610,6 +16610,10 @@
16610
16610
  "minimum": 0,
16611
16611
  "maximum": 100
16612
16612
  },
16613
+ "goal_id": {
16614
+ "type": "integer",
16615
+ "minimum": 1
16616
+ },
16613
16617
  "grade": {
16614
16618
  "type": "string",
16615
16619
  "maxLength": 16
@@ -260,7 +260,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
260
260
  | Method | Path | Rank | Body | Description |
261
261
  |---|---|---|---|---|
262
262
  | GET | `/api/bongos/inbox` | `any-builder` | — | read open ideas; skill-friction rows filtered by viewer rank. |
263
- | POST | `/api/bongos/inbox` | `any-builder` | `title`, `body_md`, `kind`, `suggested_version`, `suggested_priority`, `grade`, `template_key`, `field_values` | for ordinary ideas; metic+ for kind='criterion-proposal'. |
263
+ | POST | `/api/bongos/inbox` | `any-builder` | `title`, `body_md`, `kind`, `suggested_version`, `suggested_priority`, `goal_id`, `grade`, `template_key`, `field_values` | for ordinary ideas; metic+ for kind='criterion-proposal'. |
264
264
  | GET | `/api/bongos/inbox/:id` | `any-builder` | — | single idea read for the "#NNN" deep-link resolver (task 1047). |
265
265
  | PATCH | `/api/bongos/inbox/:id` | `metic+archon` | `status`, `kind`, `promoted_to_task_id`, `reviewed_at`, `merged_into_idea_id`, `triage_note` | Metic+ (task 360 / ADR 0018): idea triage (promote/discard/merge) shapes the backlog. |
266
266
  | POST | `/api/bongos/inbox/:id/ratify` | `metic+archon` | `notes_md` | V3.R31 (task 252) — Archon ratifies a criterion-proposal idea, atomically: - INSERT done_when_criteria row on the target version - UPDATE… |
@@ -15,7 +15,11 @@
15
15
  // the shared pool through the published doorway (src/module-api.js — the only
16
16
  // core file a module may require, ADR 0083). OTB byte-identical.
17
17
 
18
- const { pool } = require('../../src/module-api');
18
+ const api = require('../../src/module-api');
19
+ const { pool } = api;
20
+ // The landing matrix (task 1003065, BV1.R01) — pure, DB-free, its own file so
21
+ // every cell is unit-testable without a Postgres. See ./routing.js.
22
+ const { decideLanding, routedPriority } = require('./routing');
19
23
 
20
24
  const VALID_KINDS = [
21
25
  'feature',
@@ -48,6 +52,13 @@ const IDEA_COL_NAMES = [
48
52
  'id', 'title', 'body_md', 'kind', 'suggested_version', 'suggested_priority',
49
53
  'captured_at', 'captured_by', 'status', 'promoted_to_task_id', 'reviewed_at',
50
54
  'merged_into_idea_id', 'triage_note',
55
+ // core_222 (task 1003060, BV1.R02): the routing HINT — "this probably belongs
56
+ // to goal N" — on a row that did NOT convert. Added to the shared list because
57
+ // R02 shipped the column and nothing read it: /idea-triage (R13) cannot walk
58
+ // homeless-only work if the read that feeds it never returns the hint. Strictly
59
+ // advisory: no rank check, no scope wall, and no payout may read it (see the
60
+ // migration's own header). The BINDING home for "which goal" is tasks.goal_id.
61
+ 'suggested_goal_id',
51
62
  ];
52
63
  const IDEA_COLS = IDEA_COL_NAMES.join(', ');
53
64
  const IDEA_COLS_Q = IDEA_COL_NAMES.map((c) => `i.${c}`).join(', ');
@@ -169,6 +180,41 @@ function classify(title, body = '') {
169
180
  // Capture
170
181
  // -------------------------------------------------------------------------
171
182
 
183
+ // captureIdea — THE ONE CAPTURE CHOKEPOINT, and since task 1003065 (BV1.R01,
184
+ // goal 1000071) also the routing one.
185
+ //
186
+ // WHY ROUTING LIVES HERE AND NOT IN A NEW SIBLING FUNCTION. Every filing vector
187
+ // already funnels through this call — the hall, `capture.js`, the Discord #ideas
188
+ // inbound (via the `ideas.capture` port), agents, seeds. Putting the conversion
189
+ // here means all of them inherit routing by passing ONE new argument, and none of
190
+ // them can acquire a second, attribution-free path to a task. A `captureAndRoute`
191
+ // sibling would have been the same code with an invitation to bypass it.
192
+ //
193
+ // TWO SHAPES, ONE ENTRY POINT:
194
+ // * goalId absent, or the matrix says don't route → EXACTLY today's behaviour:
195
+ // one INSERT, status='open', the idea sits in the inbox. A named-but-unrouted
196
+ // goal is kept as the core_222 `suggested_goal_id` HINT so /idea-triage (R13)
197
+ // inherits the guess instead of re-deriving it.
198
+ // * the matrix says route → ONE TRANSACTION writes the idea row (status
199
+ // 'promoted'), the task (in the goal, at the matrix's status), and the
200
+ // ideas_task_lineage row (origin='route').
201
+ //
202
+ // THE TRANSACTION IS THE POINT, not an optimisation. This goal exists because
203
+ // Discord #bugs creates tasks DIRECTLY — no captured_by, no lineage — so the
204
+ // ideator-credit lane (goal 1000067) has nothing to pay against. A route that
205
+ // committed the task but lost the lineage row would manufacture exactly that
206
+ // bug, at scale, on the path whose whole promise is that attribution survives
207
+ // (criterion C3). So all three writes commit together or none do.
208
+ //
209
+ // `filerRank`/`grade` are READ-ONLY INPUTS TO THE DECISION. This function never
210
+ // checks authority: the rank floor and the goal's scope wall are separate,
211
+ // load-bearing server-side gates (R05). A caller lying about filerRank can move a
212
+ // bug from 'backlog' to 'ready' and nothing else — which is why R06's abuse matrix
213
+ // tests the route, not this function.
214
+ //
215
+ // `deps` carries the #538 test seam (`deps.pool`, NODE_ENV==='test' only) so the
216
+ // whole transaction is drivable by a SQL-aware fake client with no live Postgres
217
+ // (tests/idea_routing.mjs).
172
218
  async function captureIdea({
173
219
  title,
174
220
  bodyMd = '',
@@ -176,7 +222,12 @@ async function captureIdea({
176
222
  suggestedVersion = null,
177
223
  suggestedPriority = null,
178
224
  capturedBy = null,
179
- }) {
225
+ // task 1003065 (BV1.R01) — the routing inputs. All three default to the
226
+ // pre-routing behaviour, so every existing caller is untouched.
227
+ goalId = null,
228
+ filerRank = null,
229
+ grade = null,
230
+ }, deps = {}) {
180
231
  if (!title || typeof title !== 'string') {
181
232
  throw { code: 'TITLE_REQUIRED' };
182
233
  }
@@ -190,17 +241,184 @@ async function captureIdea({
190
241
  ) {
191
242
  throw { code: 'BAD_PRIORITY', priority: suggestedPriority };
192
243
  }
193
- const { rows } = await pool.query(
244
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
245
+
246
+ // The decision, before any write. Pure — see ./routing.js.
247
+ const landing = decideLanding({ goalId, kind: resolvedKind, grade, filerRank });
248
+ if (landing.route) {
249
+ return routeIdeaToGoal({
250
+ title, bodyMd, resolvedKind, suggestedVersion, suggestedPriority,
251
+ capturedBy, goalId, landing,
252
+ }, activePool);
253
+ }
254
+
255
+ // NOT routed: the pre-routing path, plus the advisory hint. `suggested_goal_id`
256
+ // is written even when a goal WAS named — that is the whole reason the column
257
+ // exists (core_222): a Full Idea or a criterion-proposal that named a goal
258
+ // should carry the guess forward to triage rather than drop it on the floor.
259
+ const { rows } = await activePool.query(
194
260
  `INSERT INTO idea_inbox
195
- (title, body_md, kind, suggested_version, suggested_priority, captured_by, status)
196
- VALUES ($1, $2, $3, $4, $5, $6, 'open')
261
+ (title, body_md, kind, suggested_version, suggested_priority, captured_by, status,
262
+ suggested_goal_id)
263
+ VALUES ($1, $2, $3, $4, $5, $6, 'open',
264
+ $7)
197
265
  RETURNING ${IDEA_COLS}`,
198
- [title, bodyMd, resolvedKind, suggestedVersion, suggestedPriority, capturedBy]
266
+ [title, bodyMd, resolvedKind, suggestedVersion, suggestedPriority, capturedBy,
267
+ goalId ?? null]
199
268
  );
200
269
  // A brand-new idea has no ideas_grades row, so it is quick by definition —
201
270
  // no join needed to know that. Grading it (R143's Full Idea capture) is a
202
271
  // separate setIdeaGrade write against the id this returns.
203
- return rows[0] ? { ...ungradedProjection(), ...rows[0] } : rows[0];
272
+ const idea = rows[0] ? { ...ungradedProjection(), ...rows[0] } : rows[0];
273
+ // The reason is echoed so a caller (and the R15 sweep) can tell "no goal was
274
+ // named" from "a goal was named and the matrix declined it" without re-running
275
+ // the matrix. Non-enumerable would be cleaner but this row is JSON-serialised
276
+ // straight onto the 201 body, and the filer deserves to see why.
277
+ if (idea) idea.routing = { routed: false, reason: landing.reason };
278
+ return idea;
279
+ }
280
+
281
+ // routeIdeaToGoal — the ONE transaction behind a routed capture (task 1003065,
282
+ // BV1.R01). Private: `captureIdea` is the only legal way in, so the matrix can
283
+ // never be skipped.
284
+ //
285
+ // Order is forced by the foreign keys, not by preference: the lineage row
286
+ // references BOTH the idea and the task, so the idea must exist before the task
287
+ // (which carries no idea reference) and both before the lineage. That leaves
288
+ // `promoted_to_task_id` unknowable at insert time, hence the second UPDATE —
289
+ // the same two-step ratifyCriterionProposal takes.
290
+ async function routeIdeaToGoal({
291
+ title, bodyMd, resolvedKind, suggestedVersion, suggestedPriority,
292
+ capturedBy, goalId, landing,
293
+ }, activePool) {
294
+ const client = await activePool.connect();
295
+ try {
296
+ await client.query('BEGIN');
297
+
298
+ // a. The goal must be a REAL, LIVE HOME. All three checks are inside the
299
+ // transaction and the row is locked FOR SHARE, so a goal cannot be
300
+ // archived out from under a route that is mid-flight.
301
+ //
302
+ // version status='building' is the load-bearing one, and it is the
303
+ // 1000771 failure this goal was filed over: Discord #bugs hardcoded a
304
+ // SHIPPED version, so every reported bug landed in a shipped version's
305
+ // catch-all where nobody would ever look again. A closed version cannot
306
+ // receive new work — the route refuses rather than filing into a tomb.
307
+ const { rows: goalRows } = await client.query(
308
+ `SELECT g.id, g.status, g.version_id, v.status AS version_status
309
+ FROM goals g
310
+ JOIN versions v ON v.id = g.version_id
311
+ WHERE g.id = $1
312
+ FOR SHARE OF g`,
313
+ [goalId]
314
+ );
315
+ if (goalRows.length === 0) throw { code: 'GOAL_NOT_FOUND', goal_id: goalId };
316
+ const goal = goalRows[0];
317
+ if (goal.status !== 'open') {
318
+ throw { code: 'GOAL_NOT_OPEN', goal_id: goalId, status: goal.status };
319
+ }
320
+ if (goal.version_status !== 'building') {
321
+ throw {
322
+ code: 'VERSION_NOT_BUILDING',
323
+ goal_id: goalId,
324
+ version_id: goal.version_id,
325
+ version_status: goal.version_status,
326
+ };
327
+ }
328
+
329
+ // b. The idea row — 'promoted' from birth, because it WAS promoted, in this
330
+ // same act. reviewed_at is stamped so the triage walk skips it: routing's
331
+ // entire claim is that this row never needed a triage pass.
332
+ const { rows: ideaRows } = await client.query(
333
+ `INSERT INTO idea_inbox
334
+ (title, body_md, kind, suggested_version, suggested_priority, captured_by, status,
335
+ suggested_goal_id, reviewed_at)
336
+ VALUES ($1, $2, $3, $4, $5, $6, 'promoted',
337
+ $7, now())
338
+ RETURNING ${IDEA_COLS}`,
339
+ [title, bodyMd, resolvedKind, suggestedVersion, suggestedPriority, capturedBy,
340
+ goalId]
341
+ );
342
+ const idea = ideaRows[0];
343
+
344
+ // c. The task, through the `lifecycle` PORT — never an import of
345
+ // modules/lifecycle/ (the one-way module rule, ADR 0083). Resolved at call
346
+ // time; the port registers at boot. Passing `client` enlists the INSERT in
347
+ // THIS transaction (the seam task 1003065 added to db.createTask).
348
+ //
349
+ // The version is the GOAL'S, never a caller-supplied one, so
350
+ // "goal belongs to the version" is true by construction rather than by
351
+ // validation. (An idea's own `suggested_version` is kept on the idea row
352
+ // as filed and does NOT override it — the goal is the binding home.) One
353
+ // read stays outside this transaction by design: createTask's catch-all
354
+ // goal fallback, which only runs when goalId is absent and so never on
355
+ // this path.
356
+ //
357
+ // What is deliberately NOT passed: `requiresRank` (createTask derives the
358
+ // ADR 0084/0085 floor itself from touches + sensitivity, and an explicit
359
+ // value could only raise it — a filer must not be able to lower a floor),
360
+ // `creditsReward` (server default — createTask auto-assigns the capped
361
+ // suggestion), and `discipline` (left 'unclassified', exactly as the other
362
+ // non-authored create vectors leave it).
363
+ const lifecycle = api.resolveOptional && api.resolveOptional('lifecycle');
364
+ if (!lifecycle || !lifecycle.createTask) {
365
+ // A vanilla instance without the lifecycle module cannot hold tasks at all,
366
+ // so there is nothing to route INTO. Refuse loudly rather than silently
367
+ // degrading to an inbox row the caller was told became a task.
368
+ throw { code: 'LIFECYCLE_UNAVAILABLE' };
369
+ }
370
+ const task = await lifecycle.createTask({
371
+ versionId: goal.version_id,
372
+ title,
373
+ description: bodyMd,
374
+ status: landing.status,
375
+ kind: resolvedKind,
376
+ priority: routedPriority(suggestedPriority),
377
+ goalId: goal.id,
378
+ // The provenance pair the create-vector idempotency read keys on
379
+ // (db.findTaskBySourceRef): one idea can only ever mint one routed task.
380
+ source: 'idea-route',
381
+ sourceRef: `idea-${idea.id}`,
382
+ }, { client });
383
+
384
+ // d. The lineage row — origin='route' (ideas_003 widened the CHECK for
385
+ // exactly this). THIS is the row goal 1000067's ideator credit will pay
386
+ // against, and the reason all of this is one transaction.
387
+ await client.query(
388
+ `INSERT INTO ideas_task_lineage (task_id, idea_id, origin)
389
+ VALUES ($1, $2, 'route')
390
+ ON CONFLICT (task_id) DO UPDATE
391
+ SET idea_id = EXCLUDED.idea_id, origin = EXCLUDED.origin`,
392
+ [task.id, idea.id]
393
+ );
394
+
395
+ // e. Close the loop the FKs forced open in (b).
396
+ await client.query(
397
+ `UPDATE idea_inbox SET promoted_to_task_id = $2 WHERE id = $1`,
398
+ [idea.id, task.id]
399
+ );
400
+
401
+ await client.query('COMMIT');
402
+ return {
403
+ ...ungradedProjection(),
404
+ ...idea,
405
+ promoted_to_task_id: task.id,
406
+ routing: {
407
+ routed: true,
408
+ reason: landing.reason,
409
+ status: landing.status,
410
+ goal_id: goal.id,
411
+ version_id: goal.version_id,
412
+ task_id: task.id,
413
+ },
414
+ routed_task: task,
415
+ };
416
+ } catch (err) {
417
+ await client.query('ROLLBACK').catch(() => {});
418
+ throw err;
419
+ } finally {
420
+ client.release();
421
+ }
204
422
  }
205
423
 
206
424
  // -------------------------------------------------------------------------
@@ -46,6 +46,11 @@ module.exports = function buildInboxRouter() {
46
46
  kind: { type: 'string', maxLength: 64 },
47
47
  suggested_version: { type: 'string', maxLength: 64 },
48
48
  suggested_priority: { type: 'integer', min: 0, max: 100 },
49
+ // task 1003065 (BV1.R01, goal 1000071) — CAPTURE-TIME ROUTING. Naming a
50
+ // goal is what turns a filing into a task in that goal, in this one
51
+ // request. Optional by design: omit it and this endpoint behaves exactly
52
+ // as it did before routing existed.
53
+ goal_id: { type: 'integer', min: 1 },
49
54
  // Full Idea capture (R143) — ALL OPTIONAL: omitting every one of these is
50
55
  // exactly the pre-grade quick capture (tests/idea_quick_guard.mjs pins
51
56
  // that title stays the only required field).
@@ -102,6 +107,14 @@ module.exports = function buildInboxRouter() {
102
107
  suggestedVersion: body.suggested_version ?? null,
103
108
  suggestedPriority: body.suggested_priority ?? null,
104
109
  capturedBy: req.builder.id,
110
+ // task 1003065 (BV1.R01): the routing inputs. The rank comes from the
111
+ // AUTHENTICATED builder — never from the body — so a filer cannot claim
112
+ // a tier they do not hold to hard-land a bug. `grade` is read by the
113
+ // matrix only (a Full Idea never auto-routes); the grade ROW is still
114
+ // written below, unchanged.
115
+ goalId: body.goal_id ?? null,
116
+ filerRank: req.builder?.rank ?? null,
117
+ grade,
105
118
  });
106
119
  let grade_warning;
107
120
  if (grade === 'full') {
@@ -190,13 +203,33 @@ module.exports = function buildInboxRouter() {
190
203
  }
191
204
  }
192
205
  const open_count = await inbox.countOpenIdeas({ viewerRank: req.builder?.rank ?? null });
193
- res.status(201).json({ idea, open_count, kind_auto: !body.kind, ...(grade_warning ? { grade_warning } : {}) });
206
+ // task 1003065 (BV1.R01): a routed capture returns the TASK it became, at
207
+ // the top level, so a client does not have to dig it out of the idea row to
208
+ // link to it. `idea.routing` carries the verdict either way — including the
209
+ // reason a named goal did NOT route, which is what makes a declined route
210
+ // debuggable instead of mysterious.
211
+ const routed_task = idea && idea.routed_task ? idea.routed_task : undefined;
212
+ if (idea) delete idea.routed_task;
213
+ res.status(201).json({
214
+ idea, open_count, kind_auto: !body.kind,
215
+ ...(routed_task ? { routed_task } : {}),
216
+ ...(grade_warning ? { grade_warning } : {}),
217
+ });
194
218
  } catch (err) {
195
219
  if (err && err.code) {
196
220
  const status =
197
221
  err.code === 'TITLE_REQUIRED' ? 400 :
198
222
  err.code === 'BAD_KIND' ? 400 :
199
223
  err.code === 'BAD_PRIORITY' ? 400 :
224
+ // task 1003065 (BV1.R01) — the routing refusals. A named goal that is
225
+ // not a live home is the FILER'S mistake to fix (pick another goal, or
226
+ // omit it and file to the inbox), so these are 4xx with the offending
227
+ // id echoed: 404 for "no such goal", 409 for "that goal/version is
228
+ // closed" (a conflict with live state, not a malformed request).
229
+ err.code === 'GOAL_NOT_FOUND' ? 404 :
230
+ err.code === 'GOAL_NOT_OPEN' ? 409 :
231
+ err.code === 'VERSION_NOT_BUILDING' ? 409 :
232
+ err.code === 'LIFECYCLE_UNAVAILABLE' ? 503 :
200
233
  500;
201
234
  return res.fail(err.code, status, { ...err });
202
235
  }
@@ -0,0 +1,121 @@
1
+ 'use strict';
2
+
3
+ // modules/ideas/routing.js — THE LANDING MATRIX (task 1003065, BV1.R01,
4
+ // goal 1000071 "Idea routing — work that has a home lands in it").
5
+ // Spec: docs/specs/bongos-v1-idea-routing.md §"Landing matrix (C1/C2/C4)".
6
+ //
7
+ // THE PROBLEM THIS ANSWERS. Most filed "ideas" are really bug tasks or
8
+ // goal-bound feature tasks. Today they park in idea_inbox and wait for a human
9
+ // triage pass that, when it finally promotes them, drops the task into a version
10
+ // catch-all — because nothing in the idea path ever names a goal. Routing makes
11
+ // the home real: work that HAS a home lands in it at filing time, and the inbox
12
+ // keeps only work that doesn't.
13
+ //
14
+ // This file is the DECISION and nothing else: given what was filed and who filed
15
+ // it, does it route, and if so at what status. It is PURE — no DB, no clock, no
16
+ // rank lookup — so every cell of the matrix is pinned by a DB-free unit test
17
+ // (tests/idea_routing.mjs). The transaction that acts on the decision lives in
18
+ // inbox.captureIdea; keeping them apart is what makes the matrix auditable
19
+ // without a Postgres.
20
+ //
21
+ // FAIL-CLOSED IS THE POSTURE HERE, and it is the opposite of the advisory
22
+ // modules' fail-open. An advisory that misfires prints a wrong sentence; a
23
+ // landing decision that misfires puts UNVETTED WORK IN THE CLAIMABLE QUEUE.
24
+ // So every uncertainty resolves toward "don't route" or "route to backlog",
25
+ // never toward 'ready': an unrecognised kind soft-lands, an unknown/absent
26
+ // filer rank soft-lands, and anything the matrix has no opinion about stays in
27
+ // the inbox exactly as it does today.
28
+
29
+ // The one rule the owner interview (2026-08-17, Q1) settled: BUGS ARE
30
+ // URGENT-TRUE, THE REST GETS A NOD. A bug filed by a trusted builder is
31
+ // claimable the moment it is reported — that is the whole "no triage stop
32
+ // between reported and in the work queue" claim of criterion C1. Everything
33
+ // else — feature, cleanup, refactor, infra — soft-lands at 'backlog', one
34
+ // Metic+ promote from claimable, because those reshape what gets built and a
35
+ // filer should not be able to set the queue's agenda alone.
36
+ const HARD_LAND_KINDS = new Set(['bug']);
37
+
38
+ // Kinds that are JUDGMENT WORK and must never auto-route, no matter who files
39
+ // them with what goal. A criterion-proposal reshapes a version's scope-truth and
40
+ // is ratified by an Archon (POST /inbox/:id/ratify); a decision is a question
41
+ // awaiting an answer, not a unit of work. Converting either into a task would
42
+ // turn "we should think about this" into "someone should go do this".
43
+ const NEVER_ROUTE_KINDS = new Set(['criterion-proposal', 'decision']);
44
+
45
+ // The trusted tier, spelled here rather than imported so this file stays pure and
46
+ // DB-free. It is the same Metic+ boundary docs/canonical-permissions.md draws and
47
+ // the routes enforce; this module only READS a rank string it was handed. It
48
+ // grants nothing — the server-side rank floor and the goal's scope wall are
49
+ // separate, load-bearing gates (R05), and this decision is subordinate to both.
50
+ const METIC_PLUS = new Set(['metic', 'archon']);
51
+
52
+ // A Full Idea (grade='full') is the Board Room's subject — it is ratified by
53
+ // peers (goal 1000069), not converted into a task by its own filer. 'spark' and
54
+ // 'quick' carry no such meaning and route normally.
55
+ const NEVER_ROUTE_GRADE = 'full';
56
+
57
+ // decideLanding — the matrix, as one pure function.
58
+ //
59
+ // Returns { route: false, reason } when the thing belongs in the inbox, or
60
+ // { route: true, status, reason } when it becomes a task. `reason` is a stable
61
+ // machine token (not prose): the route echoes it, /idea-triage reads it, and the
62
+ // R15 reclassification sweep counts by it. Never throws.
63
+ function decideLanding(input) {
64
+ // Destructured defaults only fire on `undefined`, so an explicit `null` — what
65
+ // a caller spreading an absent object hands over — would throw. A landing
66
+ // decision that CRASHES is the worst of the three outcomes: it takes down the
67
+ // capture that was supposed to fail closed. Normalise first, decide after.
68
+ const { goalId = null, kind = null, grade = null, filerRank = null } = input || {};
69
+ // No goal named ⇒ the inbox is exactly right, and this is the DEFAULT PATH.
70
+ // Every capture vector that never learns about goals keeps working unchanged;
71
+ // routing is strictly additive (criterion C1 testable #5).
72
+ if (goalId == null || goalId === '') return { route: false, reason: 'no_goal' };
73
+
74
+ if (String(grade || '').toLowerCase() === NEVER_ROUTE_GRADE) {
75
+ return { route: false, reason: 'full_idea_never_routes' };
76
+ }
77
+ if (NEVER_ROUTE_KINDS.has(kind)) {
78
+ return { route: false, reason: 'judgment_kind' };
79
+ }
80
+
81
+ if (HARD_LAND_KINDS.has(kind)) {
82
+ // The rank split. An absent/unknown rank is NOT treated as trusted — see the
83
+ // fail-closed note above. This is the DECISION only; R05 adds the
84
+ // server-side enforcement (auto-derived requires_rank floor + scope wall).
85
+ return METIC_PLUS.has(String(filerRank || '').toLowerCase())
86
+ ? { route: true, status: 'ready', reason: 'bug_trusted_filer' }
87
+ : { route: true, status: 'backlog', reason: 'bug_awaiting_nod' };
88
+ }
89
+
90
+ // Everything else with a home: it lands IN that home, at backlog. Note this
91
+ // deliberately swallows an unrecognised kind into the soft landing rather than
92
+ // refusing it — a task in a real goal at backlog is strictly better than a row
93
+ // rotting in the inbox, and 'backlog' means a human still says go.
94
+ return { route: true, status: 'backlog', reason: 'soft_landing' };
95
+ }
96
+
97
+ // The default priority for a routed task when the filer suggested none. 3 is the
98
+ // middle of the 1–5 band — a routed task is not automatically urgent, and not
99
+ // automatically bottom-of-queue either. (Decided without interview and recorded
100
+ // in the spec: "routed-task priority defaults to the idea's suggested_priority
101
+ // else 3".)
102
+ const DEFAULT_ROUTED_PRIORITY = 3;
103
+
104
+ // routedPriority — the idea's own suggestion, clamped to the task band. An idea's
105
+ // suggested_priority column is validated 1..5 at capture, but a routed idea can
106
+ // arrive from a vector that never validated it (Discord inbound, a seed), so this
107
+ // re-clamps rather than trusting the input. Out-of-band or absent ⇒ the default.
108
+ function routedPriority(suggestedPriority) {
109
+ const p = Number(suggestedPriority);
110
+ if (!Number.isInteger(p) || p < 1 || p > 5) return DEFAULT_ROUTED_PRIORITY;
111
+ return p;
112
+ }
113
+
114
+ module.exports = {
115
+ decideLanding,
116
+ routedPriority,
117
+ DEFAULT_ROUTED_PRIORITY,
118
+ HARD_LAND_KINDS,
119
+ NEVER_ROUTE_KINDS,
120
+ METIC_PLUS,
121
+ };