@bongos/core 1.19.274 → 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 +26 -16
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/api/openapi.json +4 -0
- package/docs/api-reference.md +1 -1
- package/modules/ideas/inbox.js +225 -7
- package/modules/ideas/routes/inbox.js +34 -1
- package/modules/ideas/routing.js +121 -0
- package/modules/lifecycle/db.js +2 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +3 -1
- package/tests/idea_routing.mjs +571 -0
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.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
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-21T14:
|
|
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":
|
|
14
|
+
"functional_verbatim": 1374,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
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": "
|
|
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": "
|
|
1400
|
+
"sha256": "da411a477344816c800f05b84740517a14394c9cbcf985a03ef7d4c178f55161"
|
|
1401
1401
|
},
|
|
1402
1402
|
{
|
|
1403
1403
|
"path": "docs/api/openapi.json",
|
|
1404
1404
|
"mode": "0000644",
|
|
1405
|
-
"sha256": "
|
|
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": "
|
|
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": "
|
|
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,7 +3747,7 @@
|
|
|
3742
3747
|
{
|
|
3743
3748
|
"path": "modules/lifecycle/db.js",
|
|
3744
3749
|
"mode": "0000644",
|
|
3745
|
-
"sha256": "
|
|
3750
|
+
"sha256": "64cdde411e3fb8d1263eebf9093a353dde6ee684be2977057c8800e608b52a63"
|
|
3746
3751
|
},
|
|
3747
3752
|
{
|
|
3748
3753
|
"path": "modules/lifecycle/done-when.js",
|
|
@@ -4477,12 +4482,12 @@
|
|
|
4477
4482
|
{
|
|
4478
4483
|
"path": "package-lock.json",
|
|
4479
4484
|
"mode": "0000644",
|
|
4480
|
-
"sha256": "
|
|
4485
|
+
"sha256": "eb3ac31abdc6eef09c4a19ddca7e03320280e889792f4ccbf09004695225b8a6"
|
|
4481
4486
|
},
|
|
4482
4487
|
{
|
|
4483
4488
|
"path": "package.json",
|
|
4484
4489
|
"mode": "0000644",
|
|
4485
|
-
"sha256": "
|
|
4490
|
+
"sha256": "f6d4dd4520d0b22fd2c0effa16c1aea034cb8e32a0520f11c27eb7b5461b4d73"
|
|
4486
4491
|
},
|
|
4487
4492
|
{
|
|
4488
4493
|
"path": "public-docs/index.html",
|
|
@@ -6127,7 +6132,7 @@
|
|
|
6127
6132
|
{
|
|
6128
6133
|
"path": "src/module-api.js",
|
|
6129
6134
|
"mode": "0000644",
|
|
6130
|
-
"sha256": "
|
|
6135
|
+
"sha256": "ebdca46e67df3c28972e28e78a20bf3fb6a88d96c9763cd502e29a6e7573d545"
|
|
6131
6136
|
},
|
|
6132
6137
|
{
|
|
6133
6138
|
"path": "src/module-loader/catalog.js",
|
|
@@ -7409,6 +7414,11 @@
|
|
|
7409
7414
|
"mode": "0000644",
|
|
7410
7415
|
"sha256": "51e1286235cbde1b56188f29b0e2bd12ac73a9637dbf1c16b325f10147256f27"
|
|
7411
7416
|
},
|
|
7417
|
+
{
|
|
7418
|
+
"path": "tests/idea_routing.mjs",
|
|
7419
|
+
"mode": "0000644",
|
|
7420
|
+
"sha256": "3ba1fba0951121f23aa073b886f532dc30e0ed863c2a097db019320f70856118"
|
|
7421
|
+
},
|
|
7412
7422
|
{
|
|
7413
7423
|
"path": "tests/idea_task_lineage.mjs",
|
|
7414
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 }
|
package/docs/api/openapi.json
CHANGED
|
@@ -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
|
package/docs/api-reference.md
CHANGED
|
@@ -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… |
|
package/modules/ideas/inbox.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
};
|
package/modules/lifecycle/db.js
CHANGED
|
@@ -1421,7 +1421,7 @@ async function createTask({
|
|
|
1421
1421
|
// (requiresRank stays null for the goal vector → derived purely from touches +
|
|
1422
1422
|
// sensitivity), so goal_id never influences the requires_rank clamp.
|
|
1423
1423
|
goalId = null,
|
|
1424
|
-
}) {
|
|
1424
|
+
}, deps = {}) { // deps.client enlists this INSERT in the CALLER'S transaction (task 1003065, BV1.R01); the argument for it is at modules/ideas/inbox.js routeIdeaToGoal
|
|
1425
1425
|
// task 1498 / ADR 0085: every task carries a deliberate rank floor. Derive it
|
|
1426
1426
|
// from touches + sensitivity, then let an explicit requiresRank override only
|
|
1427
1427
|
// UPWARD (highestRank) — a requested rank below the derived floor is clamped
|
|
@@ -1456,7 +1456,7 @@ async function createTask({
|
|
|
1456
1456
|
if (goalIdEffective == null) {
|
|
1457
1457
|
goalIdEffective = await generalGoalIdForVersion(versionId);
|
|
1458
1458
|
}
|
|
1459
|
-
const { rows } = await pool.query(
|
|
1459
|
+
const { rows } = await (deps.client || pool).query(
|
|
1460
1460
|
`INSERT INTO tasks (version_id, title, description, status, touches,
|
|
1461
1461
|
est_minutes, est_cost_usd, manual_degree, priority,
|
|
1462
1462
|
automation_tag, credits_reward, source, source_ref, value_summary,
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.275",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.275",
|
|
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.
|
|
3
|
+
"version": "1.19.275",
|
|
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
|
@@ -975,8 +975,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
975
975
|
// landed since 1.19.272 with no explicit bump. run 32441024959. (task 1002620)
|
|
976
976
|
// 1.19.274 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
977
977
|
// landed since 1.19.273 with no explicit bump. run 32490024209. (task 1002620)
|
|
978
|
+
// 1.19.275 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
979
|
+
// landed since 1.19.274 with no explicit bump. run 32492414871. (task 1002620)
|
|
978
980
|
// ---------------------------------------------------------------------------
|
|
979
|
-
const CORE_VERSION = '1.19.
|
|
981
|
+
const CORE_VERSION = '1.19.275'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
980
982
|
|
|
981
983
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
982
984
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
// tests/idea_routing.mjs
|
|
2
|
+
//
|
|
3
|
+
// The CAPTURE-TIME ROUTING CORE — task 1003065 (BV1.R01, goal 1000071 "Idea
|
|
4
|
+
// routing — work that has a home lands in it"). Spec:
|
|
5
|
+
// docs/specs/bongos-v1-idea-routing.md.
|
|
6
|
+
//
|
|
7
|
+
// TWO THINGS UNDER TEST, and the split is the design:
|
|
8
|
+
// 1. modules/ideas/routing.js — the landing matrix. PURE, so every cell is
|
|
9
|
+
// pinned here directly, with no Postgres and no fixture.
|
|
10
|
+
// 2. modules/ideas/inbox.js captureIdea — the ONE TRANSACTION a routed capture
|
|
11
|
+
// runs. Driven through the #538 `deps.pool` seam (NODE_ENV==='test' only)
|
|
12
|
+
// with a SQL-aware fake client, the tests/goal_tier.mjs posture: we assert
|
|
13
|
+
// the STATEMENT SEQUENCE and the bound params, because the sequence IS the
|
|
14
|
+
// contract — an idea row, a task, and a lineage row that commit together or
|
|
15
|
+
// not at all.
|
|
16
|
+
//
|
|
17
|
+
// WHY THE ROLLBACK CASES ARE THE HEART OF THIS FILE. This goal exists because
|
|
18
|
+
// Discord #bugs creates tasks with no idea row and no lineage, so the ideator
|
|
19
|
+
// credit lane (goal 1000067) has nothing to pay against. A routed capture that
|
|
20
|
+
// committed a task and lost its lineage row would manufacture that exact bug on
|
|
21
|
+
// the path whose whole promise is that attribution survives (criterion C3). So
|
|
22
|
+
// "a failure after the task insert leaves NOTHING behind" is not an edge case
|
|
23
|
+
// here — it is the property.
|
|
24
|
+
//
|
|
25
|
+
// Run: node tests/idea_routing.mjs
|
|
26
|
+
|
|
27
|
+
import { strict as assert } from 'node:assert';
|
|
28
|
+
import { createRequire } from 'node:module';
|
|
29
|
+
import { makeRunner, makeSqlAwareClient } from './helpers.mjs';
|
|
30
|
+
|
|
31
|
+
process.env.NODE_ENV = 'test';
|
|
32
|
+
|
|
33
|
+
const require = createRequire(import.meta.url);
|
|
34
|
+
const routing = require('../modules/ideas/routing.js');
|
|
35
|
+
const { decideLanding, routedPriority, DEFAULT_ROUTED_PRIORITY } = routing;
|
|
36
|
+
|
|
37
|
+
// The lifecycle port is how the ideas module reaches createTask (never an
|
|
38
|
+
// import — the one-way module rule, ADR 0083). Register a stub provider, the
|
|
39
|
+
// production wiring, exactly as tests/onboarding_route_signals.mjs does.
|
|
40
|
+
const seams = require('../src/module-seams.js');
|
|
41
|
+
seams._reset();
|
|
42
|
+
let createTaskCalls = [];
|
|
43
|
+
let createTaskImpl = async () => ({ id: 9001, status: 'backlog', goal_id: 7 });
|
|
44
|
+
seams.registerProvider('lifecycle', {
|
|
45
|
+
createTask: async (args, deps) => {
|
|
46
|
+
createTaskCalls.push({ args, deps });
|
|
47
|
+
return createTaskImpl(args, deps);
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const inbox = require('../modules/ideas/inbox.js');
|
|
52
|
+
const { captureIdea } = inbox;
|
|
53
|
+
|
|
54
|
+
const { test, summary } = makeRunner();
|
|
55
|
+
|
|
56
|
+
const norm = (sql) => String(sql).replace(/\s+/g, ' ').trim();
|
|
57
|
+
const find = (queries, re) => queries.find((q) => re.test(norm(q.sql)));
|
|
58
|
+
const seq = (queries) => queries.map((q) => norm(q.sql));
|
|
59
|
+
|
|
60
|
+
// A routable goal: open, on a version that is still building.
|
|
61
|
+
const LIVE_GOAL = { id: 7, status: 'open', version_id: 'BONGOS-V1', version_status: 'building' };
|
|
62
|
+
const IDEA_ROW = {
|
|
63
|
+
id: 555, title: 'the thing is broken', body_md: 'steps', kind: 'bug',
|
|
64
|
+
suggested_version: null, suggested_priority: null, captured_at: 'T0', captured_by: 42,
|
|
65
|
+
status: 'promoted', promoted_to_task_id: null, reviewed_at: 'T0',
|
|
66
|
+
merged_into_idea_id: null, triage_note: null, suggested_goal_id: 7,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
// A fake pool whose `connect()` and `query()` share ONE call log, so a test can
|
|
70
|
+
// assert the whole transaction in order. Mirrors tests/goal_tier.mjs's fakePool.
|
|
71
|
+
function fakePool(handler) {
|
|
72
|
+
const c = makeSqlAwareClient(handler);
|
|
73
|
+
return {
|
|
74
|
+
query: (sql, params) => c._client.query(sql, params),
|
|
75
|
+
connect: c.connect,
|
|
76
|
+
queries: c._client.queries,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// The happy-path handler: a live goal, an idea row, and empty results elsewhere.
|
|
81
|
+
function routableHandler(overrides = {}) {
|
|
82
|
+
return (s) => {
|
|
83
|
+
if (/FROM goals g JOIN versions v/.test(s)) {
|
|
84
|
+
return { rows: overrides.goalRows ?? [LIVE_GOAL] };
|
|
85
|
+
}
|
|
86
|
+
if (/INSERT INTO idea_inbox/.test(s)) return { rows: [{ ...IDEA_ROW, ...(overrides.idea || {}) }] };
|
|
87
|
+
return null;
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function reset() {
|
|
92
|
+
createTaskCalls = [];
|
|
93
|
+
createTaskImpl = async () => ({ id: 9001, status: 'backlog', goal_id: 7 });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// =========================================================================
|
|
97
|
+
// 1. The landing matrix — pure, every cell
|
|
98
|
+
// =========================================================================
|
|
99
|
+
|
|
100
|
+
await test('matrix: NO GOAL means the inbox, and that is the default path', () => {
|
|
101
|
+
for (const goalId of [null, undefined, '']) {
|
|
102
|
+
const d = decideLanding({ goalId, kind: 'bug', filerRank: 'archon' });
|
|
103
|
+
assert.equal(d.route, false, `goalId=${JSON.stringify(goalId)} must not route`);
|
|
104
|
+
assert.equal(d.reason, 'no_goal');
|
|
105
|
+
}
|
|
106
|
+
// A bug filed by an Archon is the MOST routable thing there is — proving it
|
|
107
|
+
// still stays put without a goal is what pins "routing is strictly additive".
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await test('matrix: a bug from a trusted filer HARD-LANDS at ready (criterion C1)', () => {
|
|
111
|
+
for (const rank of ['metic', 'archon', 'ARCHON', 'Metic']) {
|
|
112
|
+
const d = decideLanding({ goalId: 7, kind: 'bug', filerRank: rank });
|
|
113
|
+
assert.deepEqual(d, { route: true, status: 'ready', reason: 'bug_trusted_filer' }, `rank ${rank}`);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
await test('matrix: a bug from a sub-Metic filer soft-lands, awaiting a nod', () => {
|
|
118
|
+
for (const rank of ['xenos', 'thetes', 'XENOS']) {
|
|
119
|
+
const d = decideLanding({ goalId: 7, kind: 'bug', filerRank: rank });
|
|
120
|
+
assert.deepEqual(d, { route: true, status: 'backlog', reason: 'bug_awaiting_nod' }, `rank ${rank}`);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await test('matrix: FAIL-CLOSED — an absent or unrecognised rank never hard-lands', () => {
|
|
125
|
+
// The property that matters: no rank input can reach 'ready' except a real
|
|
126
|
+
// trusted tier. A landing decision that misfires puts unvetted work in the
|
|
127
|
+
// claimable queue, so every uncertainty resolves downward.
|
|
128
|
+
for (const rank of [null, undefined, '', 'archon-ish', 'admin', 'root', 0, {}, []]) {
|
|
129
|
+
const d = decideLanding({ goalId: 7, kind: 'bug', filerRank: rank });
|
|
130
|
+
assert.equal(d.status, 'backlog', `rank ${JSON.stringify(rank)} must not reach ready`);
|
|
131
|
+
assert.equal(d.reason, 'bug_awaiting_nod');
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
await test('matrix: every non-bug kind soft-lands, whoever files it (interview Q1)', () => {
|
|
136
|
+
// "Bugs are urgent-true; the rest gets a nod." cleanup/refactor/infra were the
|
|
137
|
+
// open question the owner settled — they soft-land like features, not like bugs.
|
|
138
|
+
for (const kind of ['feature', 'cleanup', 'refactor', 'infra', 'learning-capture',
|
|
139
|
+
'blocker-resolution', 'unclassified', 'spike']) {
|
|
140
|
+
const d = decideLanding({ goalId: 7, kind, filerRank: 'archon' });
|
|
141
|
+
assert.deepEqual(d, { route: true, status: 'backlog', reason: 'soft_landing' }, `kind ${kind}`);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
await test('matrix: a Full Idea never auto-routes — it is the Board Room\'s subject', () => {
|
|
146
|
+
for (const grade of ['full', 'FULL', 'Full']) {
|
|
147
|
+
const d = decideLanding({ goalId: 7, kind: 'feature', grade, filerRank: 'archon' });
|
|
148
|
+
assert.deepEqual(d, { route: false, reason: 'full_idea_never_routes' }, `grade ${grade}`);
|
|
149
|
+
}
|
|
150
|
+
// ...while quick/spark/absent route exactly as their kind says.
|
|
151
|
+
for (const grade of ['quick', 'spark', null, undefined]) {
|
|
152
|
+
assert.equal(decideLanding({ goalId: 7, kind: 'feature', grade }).route, true, `grade ${grade}`);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await test('matrix: judgment kinds never auto-route', () => {
|
|
157
|
+
for (const kind of ['criterion-proposal', 'decision']) {
|
|
158
|
+
assert.deepEqual(
|
|
159
|
+
decideLanding({ goalId: 7, kind, filerRank: 'archon' }),
|
|
160
|
+
{ route: false, reason: 'judgment_kind' },
|
|
161
|
+
`kind ${kind}`
|
|
162
|
+
);
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await test('matrix: decideLanding never throws, on any input', () => {
|
|
167
|
+
for (const args of [undefined, {}, null && {}, { goalId: 7 }, { goalId: 7, kind: null }]) {
|
|
168
|
+
assert.doesNotThrow(() => decideLanding(args));
|
|
169
|
+
}
|
|
170
|
+
assert.equal(decideLanding().route, false, 'no args ⇒ no route');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
await test('routedPriority: the filer\'s suggestion when sane, else the middle of the band', () => {
|
|
174
|
+
for (const p of [1, 2, 3, 4, 5]) assert.equal(routedPriority(p), p);
|
|
175
|
+
for (const p of [0, 6, -1, 99, null, undefined, 'high', 2.5, NaN]) {
|
|
176
|
+
assert.equal(routedPriority(p), DEFAULT_ROUTED_PRIORITY, `${JSON.stringify(p)} ⇒ default`);
|
|
177
|
+
}
|
|
178
|
+
assert.equal(DEFAULT_ROUTED_PRIORITY, 3);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// =========================================================================
|
|
182
|
+
// 2. captureIdea — the NON-routing path is byte-for-byte today's behaviour
|
|
183
|
+
// =========================================================================
|
|
184
|
+
|
|
185
|
+
await test('capture with no goal: ONE insert, status open, no transaction at all', async () => {
|
|
186
|
+
reset();
|
|
187
|
+
const pool = fakePool((s) => (/INSERT INTO idea_inbox/.test(s)
|
|
188
|
+
? { rows: [{ ...IDEA_ROW, status: 'open', suggested_goal_id: null, reviewed_at: null }] }
|
|
189
|
+
: null));
|
|
190
|
+
const idea = await captureIdea({ title: 'a thought', capturedBy: 42 }, { pool });
|
|
191
|
+
const sqls = seq(pool.queries);
|
|
192
|
+
assert.equal(sqls.filter((q) => /^BEGIN$/.test(q)).length, 0, 'no transaction is opened');
|
|
193
|
+
assert.equal(sqls.length, 1, 'exactly one statement');
|
|
194
|
+
assert.match(sqls[0], /INSERT INTO idea_inbox/);
|
|
195
|
+
assert.match(sqls[0], /'open'/, "status is 'open'");
|
|
196
|
+
assert.equal(createTaskCalls.length, 0, 'no task is created');
|
|
197
|
+
assert.deepEqual(idea.routing, { routed: false, reason: 'no_goal' });
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
await test('a named goal the matrix DECLINES is kept as the core_222 hint, not dropped', async () => {
|
|
201
|
+
reset();
|
|
202
|
+
const pool = fakePool((s) => (/INSERT INTO idea_inbox/.test(s)
|
|
203
|
+
? { rows: [{ ...IDEA_ROW, kind: 'decision', status: 'open' }] } : null));
|
|
204
|
+
const idea = await captureIdea(
|
|
205
|
+
{ title: 'which cache?', kind: 'decision', goalId: 7, capturedBy: 42, filerRank: 'archon' },
|
|
206
|
+
{ pool }
|
|
207
|
+
);
|
|
208
|
+
const ins = find(pool.queries, /INSERT INTO idea_inbox/);
|
|
209
|
+
assert.match(norm(ins.sql), /suggested_goal_id/, 'the hint column is written');
|
|
210
|
+
assert.equal(ins.params[6], 7, 'the declined goal is carried forward as the hint');
|
|
211
|
+
assert.equal(createTaskCalls.length, 0, 'a judgment kind mints no task');
|
|
212
|
+
assert.deepEqual(idea.routing, { routed: false, reason: 'judgment_kind' },
|
|
213
|
+
'and the reason says WHY it declined, so a filer is not left guessing');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
await test('the pre-routing validations still fire before any write', async () => {
|
|
217
|
+
reset();
|
|
218
|
+
const pool = fakePool(() => null);
|
|
219
|
+
await assert.rejects(() => captureIdea({ title: '', goalId: 7 }, { pool }),
|
|
220
|
+
(e) => e.code === 'TITLE_REQUIRED');
|
|
221
|
+
await assert.rejects(() => captureIdea({ title: 'x', kind: 'nonsense', goalId: 7 }, { pool }),
|
|
222
|
+
(e) => e.code === 'BAD_KIND');
|
|
223
|
+
await assert.rejects(() => captureIdea({ title: 'x', suggestedPriority: 9, goalId: 7 }, { pool }),
|
|
224
|
+
(e) => e.code === 'BAD_PRIORITY');
|
|
225
|
+
assert.equal(pool.queries.length, 0, 'a bad payload touches the database zero times');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// =========================================================================
|
|
229
|
+
// 3. captureIdea — the ROUTED path: one transaction, in order
|
|
230
|
+
// =========================================================================
|
|
231
|
+
|
|
232
|
+
await test('a routed bug: idea + task + lineage in ONE transaction, in FK order', async () => {
|
|
233
|
+
reset();
|
|
234
|
+
createTaskImpl = async () => ({ id: 9001, status: 'ready', goal_id: 7, version_id: 'BONGOS-V1' });
|
|
235
|
+
const pool = fakePool(routableHandler());
|
|
236
|
+
const out = await captureIdea(
|
|
237
|
+
{ title: 'the thing is broken', bodyMd: 'steps', kind: 'bug', goalId: 7, capturedBy: 42, filerRank: 'archon' },
|
|
238
|
+
{ pool }
|
|
239
|
+
);
|
|
240
|
+
const sqls = seq(pool.queries);
|
|
241
|
+
assert.equal(sqls[0], 'BEGIN', 'opens a transaction first');
|
|
242
|
+
assert.equal(sqls[sqls.length - 1], 'COMMIT', 'and commits last');
|
|
243
|
+
|
|
244
|
+
// The order is forced by the foreign keys, not by taste: lineage references
|
|
245
|
+
// BOTH rows, so idea → task → lineage is the only legal sequence.
|
|
246
|
+
const iGoal = sqls.findIndex((q) => /FROM goals g JOIN versions v/.test(q));
|
|
247
|
+
const iIdea = sqls.findIndex((q) => /INSERT INTO idea_inbox/.test(q));
|
|
248
|
+
const iLin = sqls.findIndex((q) => /INSERT INTO ideas_task_lineage/.test(q));
|
|
249
|
+
const iUpd = sqls.findIndex((q) => /UPDATE idea_inbox SET promoted_to_task_id/.test(q));
|
|
250
|
+
assert.ok(iGoal > 0 && iGoal < iIdea, 'the goal is validated BEFORE anything is written');
|
|
251
|
+
assert.ok(iIdea < iLin, 'the idea row exists before the lineage row references it');
|
|
252
|
+
assert.ok(iLin < iUpd, 'and promoted_to_task_id is closed out last');
|
|
253
|
+
|
|
254
|
+
// The idea is 'promoted' from birth — routing's claim is that it never needed
|
|
255
|
+
// a triage pass, and reviewed_at is stamped so the triage walk skips it.
|
|
256
|
+
const ins = find(pool.queries, /INSERT INTO idea_inbox/);
|
|
257
|
+
assert.match(norm(ins.sql), /'promoted'/, "status is 'promoted', not 'open'");
|
|
258
|
+
assert.match(norm(ins.sql), /reviewed_at/, 'reviewed_at is stamped');
|
|
259
|
+
|
|
260
|
+
// origin='route' — the third provenance ideas_003 widened the CHECK for.
|
|
261
|
+
const lin = find(pool.queries, /INSERT INTO ideas_task_lineage/);
|
|
262
|
+
assert.match(norm(lin.sql), /'route'/, "origin is 'route', not 'promote'");
|
|
263
|
+
assert.deepEqual(lin.params, [9001, 555], 'lineage binds (task_id, idea_id)');
|
|
264
|
+
|
|
265
|
+
assert.equal(out.routing.routed, true);
|
|
266
|
+
assert.equal(out.routing.task_id, 9001);
|
|
267
|
+
assert.equal(out.promoted_to_task_id, 9001, 'the returned row already reflects the link');
|
|
268
|
+
assert.equal(out.routed_task.id, 9001, 'and the task itself comes back');
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
await test('the task INSERT is ENLISTED in the transaction — the whole point', async () => {
|
|
272
|
+
reset();
|
|
273
|
+
const pool = fakePool(routableHandler());
|
|
274
|
+
await captureIdea(
|
|
275
|
+
{ title: 'broken', kind: 'bug', goalId: 7, capturedBy: 42, filerRank: 'metic' },
|
|
276
|
+
{ pool }
|
|
277
|
+
);
|
|
278
|
+
assert.equal(createTaskCalls.length, 1);
|
|
279
|
+
const { args, deps } = createTaskCalls[0];
|
|
280
|
+
assert.ok(deps && deps.client, 'createTask is handed the tx client, not left on the pool');
|
|
281
|
+
assert.equal(typeof deps.client.query, 'function');
|
|
282
|
+
// If this ever regresses to a pool write, the task commits independently of
|
|
283
|
+
// its lineage row and we have rebuilt the Discord #bugs bug.
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
await test('the routed task carries the goal, the matrix status, and honest provenance', async () => {
|
|
287
|
+
reset();
|
|
288
|
+
const pool = fakePool(routableHandler());
|
|
289
|
+
await captureIdea(
|
|
290
|
+
{ title: 'broken', bodyMd: 'the body', kind: 'bug', goalId: 7, capturedBy: 42, filerRank: 'archon' },
|
|
291
|
+
{ pool }
|
|
292
|
+
);
|
|
293
|
+
const { args } = createTaskCalls[0];
|
|
294
|
+
assert.equal(args.goalId, 7, 'in the goal it was filed against');
|
|
295
|
+
assert.equal(args.versionId, 'BONGOS-V1', "on the GOAL'S version, never a caller-supplied one");
|
|
296
|
+
assert.equal(args.status, 'ready', 'at the matrix status');
|
|
297
|
+
assert.equal(args.kind, 'bug');
|
|
298
|
+
assert.equal(args.title, 'broken');
|
|
299
|
+
assert.equal(args.description, 'the body');
|
|
300
|
+
assert.equal(args.source, 'idea-route');
|
|
301
|
+
assert.equal(args.sourceRef, 'idea-555', 'keyed to the idea, so one idea mints one task');
|
|
302
|
+
// Deliberately absent: an explicit rank floor and a reward. createTask derives
|
|
303
|
+
// the ADR 0084/0085 floor itself and auto-assigns the capped reward; a filer
|
|
304
|
+
// must not be able to hand either one in.
|
|
305
|
+
assert.equal(args.requiresRank, undefined, 'no filer-supplied rank floor');
|
|
306
|
+
assert.equal(args.creditsReward, undefined, 'no filer-supplied reward');
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
await test('priority: the filer\'s suggestion rides through, else the band middle', async () => {
|
|
310
|
+
reset();
|
|
311
|
+
let pool = fakePool(routableHandler({ idea: { suggested_priority: 1 } }));
|
|
312
|
+
await captureIdea({ title: 'x', kind: 'bug', goalId: 7, suggestedPriority: 1, filerRank: 'archon' }, { pool });
|
|
313
|
+
assert.equal(createTaskCalls[0].args.priority, 1);
|
|
314
|
+
|
|
315
|
+
reset();
|
|
316
|
+
pool = fakePool(routableHandler());
|
|
317
|
+
await captureIdea({ title: 'x', kind: 'bug', goalId: 7, filerRank: 'archon' }, { pool });
|
|
318
|
+
assert.equal(createTaskCalls[0].args.priority, DEFAULT_ROUTED_PRIORITY);
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// =========================================================================
|
|
322
|
+
// 4. The refusals — a named goal that is not a LIVE HOME
|
|
323
|
+
// =========================================================================
|
|
324
|
+
|
|
325
|
+
await test('a goal that does not exist: 404-shaped refusal, nothing written', async () => {
|
|
326
|
+
reset();
|
|
327
|
+
const pool = fakePool(routableHandler({ goalRows: [] }));
|
|
328
|
+
await assert.rejects(
|
|
329
|
+
() => captureIdea({ title: 'x', kind: 'bug', goalId: 404, filerRank: 'archon' }, { pool }),
|
|
330
|
+
(e) => e.code === 'GOAL_NOT_FOUND' && e.goal_id === 404
|
|
331
|
+
);
|
|
332
|
+
assert.equal(find(pool.queries, /INSERT INTO idea_inbox/), undefined, 'no idea row');
|
|
333
|
+
assert.equal(createTaskCalls.length, 0, 'no task');
|
|
334
|
+
assert.ok(find(pool.queries, /^ROLLBACK$/), 'and the transaction is rolled back');
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
await test('an achieved or archived goal is not a home', async () => {
|
|
338
|
+
for (const status of ['achieved', 'archived']) {
|
|
339
|
+
reset();
|
|
340
|
+
const pool = fakePool(routableHandler({ goalRows: [{ ...LIVE_GOAL, status }] }));
|
|
341
|
+
await assert.rejects(
|
|
342
|
+
() => captureIdea({ title: 'x', kind: 'bug', goalId: 7, filerRank: 'archon' }, { pool }),
|
|
343
|
+
(e) => e.code === 'GOAL_NOT_OPEN' && e.status === status,
|
|
344
|
+
`goal status ${status}`
|
|
345
|
+
);
|
|
346
|
+
assert.equal(createTaskCalls.length, 0);
|
|
347
|
+
assert.ok(find(pool.queries, /^ROLLBACK$/));
|
|
348
|
+
}
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
await test('THE 1000771 FAILURE: a non-building version can never receive routed work', async () => {
|
|
352
|
+
// The bug this whole goal was filed over — Discord #bugs hardcoded a SHIPPED
|
|
353
|
+
// version, so every reported bug landed in a shipped version's catch-all where
|
|
354
|
+
// nobody would look again. A route refuses rather than filing into a tomb.
|
|
355
|
+
for (const version_status of ['shipped', 'planning', 'frozen']) {
|
|
356
|
+
reset();
|
|
357
|
+
const pool = fakePool(routableHandler({ goalRows: [{ ...LIVE_GOAL, version_status }] }));
|
|
358
|
+
await assert.rejects(
|
|
359
|
+
() => captureIdea({ title: 'x', kind: 'bug', goalId: 7, filerRank: 'archon' }, { pool }),
|
|
360
|
+
(e) => e.code === 'VERSION_NOT_BUILDING' && e.version_status === version_status,
|
|
361
|
+
`version status ${version_status}`
|
|
362
|
+
);
|
|
363
|
+
assert.equal(createTaskCalls.length, 0, `no task on a ${version_status} version`);
|
|
364
|
+
}
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
// =========================================================================
|
|
368
|
+
// 5. Atomicity — the property, not an edge case
|
|
369
|
+
// =========================================================================
|
|
370
|
+
|
|
371
|
+
await test('a task-create failure leaves NO orphan idea row (rollback)', async () => {
|
|
372
|
+
reset();
|
|
373
|
+
createTaskImpl = async () => { throw new Error('tasks table is on fire'); };
|
|
374
|
+
const pool = fakePool(routableHandler());
|
|
375
|
+
await assert.rejects(
|
|
376
|
+
() => captureIdea({ title: 'x', kind: 'bug', goalId: 7, filerRank: 'archon' }, { pool }),
|
|
377
|
+
/on fire/
|
|
378
|
+
);
|
|
379
|
+
assert.ok(find(pool.queries, /INSERT INTO idea_inbox/), 'the insert was attempted...');
|
|
380
|
+
assert.ok(find(pool.queries, /^ROLLBACK$/), '...and then rolled back');
|
|
381
|
+
assert.equal(find(pool.queries, /^COMMIT$/), undefined, 'never committed');
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
await test('a LINEAGE failure rolls the task back too — attribution or nothing (C3)', async () => {
|
|
385
|
+
reset();
|
|
386
|
+
const pool = fakePool((s) => {
|
|
387
|
+
if (/INSERT INTO ideas_task_lineage/.test(s)) throw new Error('lineage check violated');
|
|
388
|
+
return routableHandler()(s);
|
|
389
|
+
});
|
|
390
|
+
await assert.rejects(
|
|
391
|
+
() => captureIdea({ title: 'x', kind: 'bug', goalId: 7, filerRank: 'archon' }, { pool }),
|
|
392
|
+
/lineage/
|
|
393
|
+
);
|
|
394
|
+
assert.ok(find(pool.queries, /^ROLLBACK$/));
|
|
395
|
+
assert.equal(find(pool.queries, /^COMMIT$/), undefined,
|
|
396
|
+
'a task with no lineage row IS the bug this goal abolishes — it must not commit');
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
await test('the goal row is LOCKED, so it cannot be archived mid-route', async () => {
|
|
400
|
+
reset();
|
|
401
|
+
const pool = fakePool(routableHandler());
|
|
402
|
+
await captureIdea({ title: 'x', kind: 'bug', goalId: 7, filerRank: 'archon' }, { pool });
|
|
403
|
+
const sel = find(pool.queries, /FROM goals g JOIN versions v/);
|
|
404
|
+
assert.match(norm(sel.sql), /FOR SHARE OF g/, 'the goal row is held for the transaction');
|
|
405
|
+
assert.match(norm(sel.sql), /v\.status AS version_status/,
|
|
406
|
+
'and the version status is read in the SAME statement, not a second round trip');
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
await test('no lifecycle port ⇒ a loud refusal, never a silent inbox row', async () => {
|
|
410
|
+
reset();
|
|
411
|
+
const saved = seams.resolveOptional('lifecycle');
|
|
412
|
+
seams._reset();
|
|
413
|
+
try {
|
|
414
|
+
const pool = fakePool(routableHandler());
|
|
415
|
+
await assert.rejects(
|
|
416
|
+
() => captureIdea({ title: 'x', kind: 'bug', goalId: 7, filerRank: 'archon' }, { pool }),
|
|
417
|
+
(e) => e.code === 'LIFECYCLE_UNAVAILABLE'
|
|
418
|
+
);
|
|
419
|
+
assert.ok(find(pool.queries, /^ROLLBACK$/));
|
|
420
|
+
// The alternative — degrading to an inbox row — would tell the filer their
|
|
421
|
+
// bug became a task when it did not. Refusing is the honest failure.
|
|
422
|
+
} finally {
|
|
423
|
+
seams._reset();
|
|
424
|
+
seams.registerProvider('lifecycle', saved);
|
|
425
|
+
}
|
|
426
|
+
});
|
|
427
|
+
|
|
428
|
+
// =========================================================================
|
|
429
|
+
// 6. THE ROUTE — POST /inbox, through the real handler
|
|
430
|
+
//
|
|
431
|
+
// The layer test above proves the transaction. This proves the CONTRACT a client
|
|
432
|
+
// sees: where the filer's rank comes from, what the refusals look like, and that
|
|
433
|
+
// omitting goal_id leaves the endpoint exactly as it was. Driven the
|
|
434
|
+
// tests/idea_full_capture.mjs way — patch the doorway pool, pull the real
|
|
435
|
+
// handler off the mounted router, stub req/res — so the same validator and the
|
|
436
|
+
// same error map a client hits are the ones under test.
|
|
437
|
+
// =========================================================================
|
|
438
|
+
|
|
439
|
+
const apiDoorway = require('../src/module-api.js');
|
|
440
|
+
|
|
441
|
+
let routeGoalRows = [LIVE_GOAL];
|
|
442
|
+
apiDoorway.pool.query = async (sql) => {
|
|
443
|
+
if (/SELECT COUNT|count\(/i.test(String(sql))) return { rows: [{ n: '3', count: '3' }] };
|
|
444
|
+
if (/INSERT INTO idea_inbox/i.test(String(sql))) {
|
|
445
|
+
return { rows: [{ ...IDEA_ROW, status: 'open', suggested_goal_id: null }] };
|
|
446
|
+
}
|
|
447
|
+
return { rows: [] };
|
|
448
|
+
};
|
|
449
|
+
apiDoorway.pool.connect = async () => makeSqlAwareClient((s) => {
|
|
450
|
+
if (/FROM goals g JOIN versions v/.test(s)) return { rows: routeGoalRows };
|
|
451
|
+
if (/INSERT INTO idea_inbox/.test(s)) return { rows: [IDEA_ROW] };
|
|
452
|
+
return null;
|
|
453
|
+
}).connect();
|
|
454
|
+
|
|
455
|
+
const postInbox = (() => {
|
|
456
|
+
const router = require('../modules/ideas/routes/inbox.js')({});
|
|
457
|
+
const layer = router.stack.find((l) => l.route && l.route.path === '/inbox' && l.route.methods.post);
|
|
458
|
+
const handlers = layer.route.stack.map((h) => h.handle);
|
|
459
|
+
return handlers[handlers.length - 1]; // past requireBuilder
|
|
460
|
+
})();
|
|
461
|
+
|
|
462
|
+
// The res double the ADR 0116 envelope needs: res.fail is what every refusal in
|
|
463
|
+
// this module calls, so a test that only implements .json would see a crash
|
|
464
|
+
// instead of the 4xx a client gets.
|
|
465
|
+
function call(body, builder = { id: '42', rank: 'archon' }) {
|
|
466
|
+
return new Promise((resolve) => {
|
|
467
|
+
const out = { status: 200, body: null };
|
|
468
|
+
const res = {
|
|
469
|
+
status(c) { out.status = c; return this; },
|
|
470
|
+
json(b) { out.body = b; resolve(out); return this; },
|
|
471
|
+
fail(code, statusOrOpts, details) {
|
|
472
|
+
const st = typeof statusOrOpts === 'number' ? statusOrOpts : (statusOrOpts && statusOrOpts.status) || 400;
|
|
473
|
+
out.status = st;
|
|
474
|
+
out.body = { error: { code, details: details || null } };
|
|
475
|
+
resolve(out);
|
|
476
|
+
return this;
|
|
477
|
+
},
|
|
478
|
+
};
|
|
479
|
+
Promise.resolve(postInbox({ body, builder, query: {} }, res)).catch((e) => {
|
|
480
|
+
out.status = 500; out.body = { threw: String(e && e.message) }; resolve(out);
|
|
481
|
+
});
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
await test('route: POST /inbox with a goal_id returns the TASK it became', async () => {
|
|
486
|
+
reset();
|
|
487
|
+
routeGoalRows = [LIVE_GOAL];
|
|
488
|
+
createTaskImpl = async () => ({ id: 9001, status: 'ready', goal_id: 7, version_id: 'BONGOS-V1' });
|
|
489
|
+
const r = await call({ title: 'the thing is broken', kind: 'bug', goal_id: 7 });
|
|
490
|
+
assert.equal(r.status, 201, JSON.stringify(r.body));
|
|
491
|
+
assert.equal(r.body.routed_task.id, 9001, 'the task is at the TOP level, not buried in the idea');
|
|
492
|
+
assert.equal(r.body.idea.routing.routed, true);
|
|
493
|
+
assert.equal(r.body.idea.routing.reason, 'bug_trusted_filer');
|
|
494
|
+
assert.equal(r.body.idea.routed_task, undefined, 'and is not duplicated inside the idea row');
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
await test('route: THE FILER\'S RANK COMES FROM AUTH, never from the body', async () => {
|
|
498
|
+
// The property that decides whether filing can outrun rank (criterion C4),
|
|
499
|
+
// asserted from both sides.
|
|
500
|
+
//
|
|
501
|
+
// (a) A forged rank in the body is refused outright — the BV1.R109c
|
|
502
|
+
// unknown-field validator answers before the handler runs. A stronger
|
|
503
|
+
// answer than ignoring it, and it means the matrix never sees the value.
|
|
504
|
+
reset();
|
|
505
|
+
routeGoalRows = [LIVE_GOAL];
|
|
506
|
+
const forged = await call({ title: 'broken', kind: 'bug', goal_id: 7, rank: 'archon' }, { id: '99', rank: 'xenos' });
|
|
507
|
+
assert.equal(forged.status, 400, JSON.stringify(forged.body));
|
|
508
|
+
assert.equal(forged.body.error.code, 'validation_failed');
|
|
509
|
+
assert.deepEqual(forged.body.error.details, [{ field: 'rank', reason: 'unknown_field' }]);
|
|
510
|
+
assert.equal(createTaskCalls.length, 0, 'and nothing was created');
|
|
511
|
+
|
|
512
|
+
// (b) With an IDENTICAL legal body, the landing differs purely by the
|
|
513
|
+
// authenticated builder's rank — which is the positive proof that
|
|
514
|
+
// req.builder.rank is what reaches the matrix.
|
|
515
|
+
const body = { title: 'broken', kind: 'bug', goal_id: 7 };
|
|
516
|
+
reset();
|
|
517
|
+
createTaskImpl = async (a) => ({ id: 1, status: a.status, goal_id: 7 });
|
|
518
|
+
const asArchon = await call({ ...body }, { id: '1', rank: 'archon' });
|
|
519
|
+
assert.equal(asArchon.status, 201, JSON.stringify(asArchon.body));
|
|
520
|
+
assert.equal(asArchon.body.idea.routing.status, 'ready');
|
|
521
|
+
|
|
522
|
+
reset();
|
|
523
|
+
createTaskImpl = async (a) => ({ id: 2, status: a.status, goal_id: 7 });
|
|
524
|
+
const asXenos = await call({ ...body }, { id: '2', rank: 'xenos' });
|
|
525
|
+
assert.equal(asXenos.status, 201, JSON.stringify(asXenos.body));
|
|
526
|
+
assert.equal(asXenos.body.idea.routing.status, 'backlog',
|
|
527
|
+
'same request, lower rank ⇒ soft landing: filing cannot outrun rank');
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
await test('route: a sub-Metic filer\'s bug lands at backlog, awaiting a nod', async () => {
|
|
531
|
+
reset();
|
|
532
|
+
routeGoalRows = [LIVE_GOAL];
|
|
533
|
+
createTaskImpl = async (a) => ({ id: 9002, status: a.status, goal_id: 7 });
|
|
534
|
+
const r = await call({ title: 'broken', kind: 'bug', goal_id: 7 }, { id: '99', rank: 'thetes' });
|
|
535
|
+
assert.equal(r.status, 201, JSON.stringify(r.body));
|
|
536
|
+
assert.equal(r.body.idea.routing.status, 'backlog');
|
|
537
|
+
assert.equal(r.body.idea.routing.reason, 'bug_awaiting_nod');
|
|
538
|
+
});
|
|
539
|
+
|
|
540
|
+
await test('route: the refusals map to 404 / 409, with the offending id echoed', async () => {
|
|
541
|
+
reset();
|
|
542
|
+
routeGoalRows = [];
|
|
543
|
+
let r = await call({ title: 'x', kind: 'bug', goal_id: 4040 });
|
|
544
|
+
assert.equal(r.status, 404, JSON.stringify(r.body));
|
|
545
|
+
assert.equal(r.body.error.code, 'GOAL_NOT_FOUND');
|
|
546
|
+
|
|
547
|
+
reset();
|
|
548
|
+
routeGoalRows = [{ ...LIVE_GOAL, status: 'archived' }];
|
|
549
|
+
r = await call({ title: 'x', kind: 'bug', goal_id: 7 });
|
|
550
|
+
assert.equal(r.status, 409, JSON.stringify(r.body));
|
|
551
|
+
assert.equal(r.body.error.code, 'GOAL_NOT_OPEN');
|
|
552
|
+
|
|
553
|
+
reset();
|
|
554
|
+
routeGoalRows = [{ ...LIVE_GOAL, version_status: 'shipped' }];
|
|
555
|
+
r = await call({ title: 'x', kind: 'bug', goal_id: 7 });
|
|
556
|
+
assert.equal(r.status, 409, JSON.stringify(r.body));
|
|
557
|
+
assert.equal(r.body.error.code, 'VERSION_NOT_BUILDING');
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
await test('route: NO goal_id is still the plain old capture — 201, no task', async () => {
|
|
561
|
+
reset();
|
|
562
|
+
const r = await call({ title: 'just a thought' });
|
|
563
|
+
assert.equal(r.status, 201, JSON.stringify(r.body));
|
|
564
|
+
assert.equal(r.body.routed_task, undefined, 'nothing routed');
|
|
565
|
+
assert.equal(createTaskCalls.length, 0, 'no task created');
|
|
566
|
+
assert.equal(r.body.idea.routing.reason, 'no_goal');
|
|
567
|
+
assert.ok('open_count' in r.body, 'and the pre-routing response shape is intact');
|
|
568
|
+
assert.ok('kind_auto' in r.body);
|
|
569
|
+
});
|
|
570
|
+
|
|
571
|
+
summary('idea_routing');
|