@bongos/core 1.19.274 → 1.19.276
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 +39 -24
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +4 -1
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +59 -3
- package/docs/api-reference.md +4 -3
- package/docs/copy-inventory.md +6 -4
- package/docs/copy-registry.json +23 -5
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/hall-ui/public/goal-inbox.js +81 -3
- package/modules/ideas/inbox.js +332 -7
- package/modules/ideas/migrations/ideas_004_lineage_route_index.sql +37 -0
- package/modules/ideas/routes/inbox.js +78 -1
- package/modules/ideas/routing.js +232 -0
- package/modules/lifecycle/db.js +2 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +5 -1
- package/tests/idea_routing.mjs +858 -0
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, authorizeRoutedLanding, 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,18 @@ 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
|
+
// task 1003066 (BV1.R05): OPTIONAL declared blast radius. A human reporting a
|
|
231
|
+
// bug never supplies this — demanding it as the price of reporting is the
|
|
232
|
+
// triage tax this goal removes — but a programmatic filer that DOES know
|
|
233
|
+
// (a hall filing surface, a Discord classifier) can, and when it does the
|
|
234
|
+
// goal's scope wall is applied to it verbatim. See ./routing.js.
|
|
235
|
+
touches = null,
|
|
236
|
+
}, deps = {}) {
|
|
180
237
|
if (!title || typeof title !== 'string') {
|
|
181
238
|
throw { code: 'TITLE_REQUIRED' };
|
|
182
239
|
}
|
|
@@ -190,17 +247,222 @@ async function captureIdea({
|
|
|
190
247
|
) {
|
|
191
248
|
throw { code: 'BAD_PRIORITY', priority: suggestedPriority };
|
|
192
249
|
}
|
|
193
|
-
const
|
|
250
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
251
|
+
|
|
252
|
+
// The decision, before any write. Pure — see ./routing.js.
|
|
253
|
+
const landing = decideLanding({ goalId, kind: resolvedKind, grade, filerRank });
|
|
254
|
+
if (landing.route) {
|
|
255
|
+
return routeIdeaToGoal({
|
|
256
|
+
title, bodyMd, resolvedKind, suggestedVersion, suggestedPriority,
|
|
257
|
+
capturedBy, goalId, landing, filerRank, touches,
|
|
258
|
+
}, activePool);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// NOT routed: the pre-routing path, plus the advisory hint. `suggested_goal_id`
|
|
262
|
+
// is written even when a goal WAS named — that is the whole reason the column
|
|
263
|
+
// exists (core_222): a Full Idea or a criterion-proposal that named a goal
|
|
264
|
+
// should carry the guess forward to triage rather than drop it on the floor.
|
|
265
|
+
const { rows } = await activePool.query(
|
|
194
266
|
`INSERT INTO idea_inbox
|
|
195
|
-
(title, body_md, kind, suggested_version, suggested_priority, captured_by, status
|
|
196
|
-
|
|
267
|
+
(title, body_md, kind, suggested_version, suggested_priority, captured_by, status,
|
|
268
|
+
suggested_goal_id)
|
|
269
|
+
VALUES ($1, $2, $3, $4, $5, $6, 'open',
|
|
270
|
+
$7)
|
|
197
271
|
RETURNING ${IDEA_COLS}`,
|
|
198
|
-
[title, bodyMd, resolvedKind, suggestedVersion, suggestedPriority, capturedBy
|
|
272
|
+
[title, bodyMd, resolvedKind, suggestedVersion, suggestedPriority, capturedBy,
|
|
273
|
+
goalId ?? null]
|
|
199
274
|
);
|
|
200
275
|
// A brand-new idea has no ideas_grades row, so it is quick by definition —
|
|
201
276
|
// no join needed to know that. Grading it (R143's Full Idea capture) is a
|
|
202
277
|
// separate setIdeaGrade write against the id this returns.
|
|
203
|
-
|
|
278
|
+
const idea = rows[0] ? { ...ungradedProjection(), ...rows[0] } : rows[0];
|
|
279
|
+
// The reason is echoed so a caller (and the R15 sweep) can tell "no goal was
|
|
280
|
+
// named" from "a goal was named and the matrix declined it" without re-running
|
|
281
|
+
// the matrix. Non-enumerable would be cleaner but this row is JSON-serialised
|
|
282
|
+
// straight onto the 201 body, and the filer deserves to see why.
|
|
283
|
+
if (idea) idea.routing = { routed: false, reason: landing.reason };
|
|
284
|
+
return idea;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// routeIdeaToGoal — the ONE transaction behind a routed capture (task 1003065,
|
|
288
|
+
// BV1.R01). Private: `captureIdea` is the only legal way in, so the matrix can
|
|
289
|
+
// never be skipped.
|
|
290
|
+
//
|
|
291
|
+
// Order is forced by the foreign keys, not by preference: the lineage row
|
|
292
|
+
// references BOTH the idea and the task, so the idea must exist before the task
|
|
293
|
+
// (which carries no idea reference) and both before the lineage. That leaves
|
|
294
|
+
// `promoted_to_task_id` unknowable at insert time, hence the second UPDATE —
|
|
295
|
+
// the same two-step ratifyCriterionProposal takes.
|
|
296
|
+
async function routeIdeaToGoal({
|
|
297
|
+
title, bodyMd, resolvedKind, suggestedVersion, suggestedPriority,
|
|
298
|
+
capturedBy, goalId, landing, filerRank = null, touches = null,
|
|
299
|
+
}, activePool) {
|
|
300
|
+
const client = await activePool.connect();
|
|
301
|
+
try {
|
|
302
|
+
await client.query('BEGIN');
|
|
303
|
+
|
|
304
|
+
// a. The goal must be a REAL, LIVE HOME. All three checks are inside the
|
|
305
|
+
// transaction and the row is locked FOR SHARE, so a goal cannot be
|
|
306
|
+
// archived out from under a route that is mid-flight.
|
|
307
|
+
//
|
|
308
|
+
// version status='building' is the load-bearing one, and it is the
|
|
309
|
+
// 1000771 failure this goal was filed over: Discord #bugs hardcoded a
|
|
310
|
+
// SHIPPED version, so every reported bug landed in a shipped version's
|
|
311
|
+
// catch-all where nobody would ever look again. A closed version cannot
|
|
312
|
+
// receive new work — the route refuses rather than filing into a tomb.
|
|
313
|
+
const { rows: goalRows } = await client.query(
|
|
314
|
+
`SELECT g.id, g.status, g.version_id, g.scope_modules, v.status AS version_status
|
|
315
|
+
FROM goals g
|
|
316
|
+
JOIN versions v ON v.id = g.version_id
|
|
317
|
+
WHERE g.id = $1
|
|
318
|
+
FOR SHARE OF g`,
|
|
319
|
+
[goalId]
|
|
320
|
+
);
|
|
321
|
+
if (goalRows.length === 0) throw { code: 'GOAL_NOT_FOUND', goal_id: goalId };
|
|
322
|
+
const goal = goalRows[0];
|
|
323
|
+
if (goal.status !== 'open') {
|
|
324
|
+
throw { code: 'GOAL_NOT_OPEN', goal_id: goalId, status: goal.status };
|
|
325
|
+
}
|
|
326
|
+
if (goal.version_status !== 'building') {
|
|
327
|
+
throw {
|
|
328
|
+
code: 'VERSION_NOT_BUILDING',
|
|
329
|
+
goal_id: goalId,
|
|
330
|
+
version_id: goal.version_id,
|
|
331
|
+
version_status: goal.version_status,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// b. The idea row — 'promoted' from birth, because it WAS promoted, in this
|
|
336
|
+
// same act. reviewed_at is stamped so the triage walk skips it: routing's
|
|
337
|
+
// entire claim is that this row never needed a triage pass.
|
|
338
|
+
const { rows: ideaRows } = await client.query(
|
|
339
|
+
`INSERT INTO idea_inbox
|
|
340
|
+
(title, body_md, kind, suggested_version, suggested_priority, captured_by, status,
|
|
341
|
+
suggested_goal_id, reviewed_at)
|
|
342
|
+
VALUES ($1, $2, $3, $4, $5, $6, 'promoted',
|
|
343
|
+
$7, now())
|
|
344
|
+
RETURNING ${IDEA_COLS}`,
|
|
345
|
+
[title, bodyMd, resolvedKind, suggestedVersion, suggestedPriority, capturedBy,
|
|
346
|
+
goalId]
|
|
347
|
+
);
|
|
348
|
+
const idea = ideaRows[0];
|
|
349
|
+
|
|
350
|
+
// b2. THE BINDING GATE (task 1003066, BV1.R05). The matrix said what status;
|
|
351
|
+
// this says whether the filing is allowed at all and what rank floor the
|
|
352
|
+
// task is born with. Both facts come off the goal row we just LOCKED, so
|
|
353
|
+
// a rescope cannot land between the check and the insert.
|
|
354
|
+
//
|
|
355
|
+
// scopeIncludesProtected + goalScopeCheck both come through the published
|
|
356
|
+
// doorway — the SAME protected roster the pre-push hook, the grader and
|
|
357
|
+
// main-audit read (permission-path-check), so this wall cannot drift from
|
|
358
|
+
// them. The wall only runs when touches were declared; see ./routing.js
|
|
359
|
+
// for why an absent touches[] moves the protection to the rank floor
|
|
360
|
+
// instead of refusing the report.
|
|
361
|
+
const declaredTouches = Array.isArray(touches) ? touches.filter(Boolean) : [];
|
|
362
|
+
const authorized = authorizeRoutedLanding({
|
|
363
|
+
landing,
|
|
364
|
+
filerRank,
|
|
365
|
+
scopeProtected: api.moduleScopeMap.scopeIncludesProtected(goal.scope_modules),
|
|
366
|
+
scopeCheck: declaredTouches.length
|
|
367
|
+
? api.goalScopeCheck.check(goal.scope_modules, declaredTouches, filerRank)
|
|
368
|
+
: null,
|
|
369
|
+
});
|
|
370
|
+
if (!authorized.ok) {
|
|
371
|
+
throw {
|
|
372
|
+
code: 'ROUTE_NOT_AUTHORIZED',
|
|
373
|
+
goal_id: goalId,
|
|
374
|
+
gate: authorized.code,
|
|
375
|
+
reason: authorized.reason,
|
|
376
|
+
details: authorized.details ?? null,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// c. The task, through the `lifecycle` PORT — never an import of
|
|
381
|
+
// modules/lifecycle/ (the one-way module rule, ADR 0083). Resolved at call
|
|
382
|
+
// time; the port registers at boot. Passing `client` enlists the INSERT in
|
|
383
|
+
// THIS transaction (the seam task 1003065 added to db.createTask).
|
|
384
|
+
//
|
|
385
|
+
// The version is the GOAL'S, never a caller-supplied one, so
|
|
386
|
+
// "goal belongs to the version" is true by construction rather than by
|
|
387
|
+
// validation. (An idea's own `suggested_version` is kept on the idea row
|
|
388
|
+
// as filed and does NOT override it — the goal is the binding home.) One
|
|
389
|
+
// read stays outside this transaction by design: createTask's catch-all
|
|
390
|
+
// goal fallback, which only runs when goalId is absent and so never on
|
|
391
|
+
// this path.
|
|
392
|
+
//
|
|
393
|
+
// `requiresRank` is passed ONLY as the R05 protected-scope floor computed
|
|
394
|
+
// above — never from anything the filer said. createTask still derives its
|
|
395
|
+
// own floor and takes the HIGHER of the two, so this can raise the bar and
|
|
396
|
+
// never lower it. Deliberately NOT passed at all: `creditsReward` (server
|
|
397
|
+
// default — createTask auto-assigns the capped suggestion) and
|
|
398
|
+
// `discipline` (left 'unclassified', exactly as the other non-authored
|
|
399
|
+
// create vectors leave it).
|
|
400
|
+
const lifecycle = api.resolveOptional && api.resolveOptional('lifecycle');
|
|
401
|
+
if (!lifecycle || !lifecycle.createTask) {
|
|
402
|
+
// A vanilla instance without the lifecycle module cannot hold tasks at all,
|
|
403
|
+
// so there is nothing to route INTO. Refuse loudly rather than silently
|
|
404
|
+
// degrading to an inbox row the caller was told became a task.
|
|
405
|
+
throw { code: 'LIFECYCLE_UNAVAILABLE' };
|
|
406
|
+
}
|
|
407
|
+
const task = await lifecycle.createTask({
|
|
408
|
+
versionId: goal.version_id,
|
|
409
|
+
title,
|
|
410
|
+
description: bodyMd,
|
|
411
|
+
status: authorized.status,
|
|
412
|
+
kind: resolvedKind,
|
|
413
|
+
priority: routedPriority(suggestedPriority),
|
|
414
|
+
goalId: goal.id,
|
|
415
|
+
touches: declaredTouches,
|
|
416
|
+
// task 1003066 (BV1.R05): the protected-scope floor. null on an ordinary
|
|
417
|
+
// goal, so createTask derives the floor exactly as for every other vector;
|
|
418
|
+
// 'metic' when the goal reaches protected territory, and highestRank()
|
|
419
|
+
// clamps UPWARD only — this can raise a floor, never drop one.
|
|
420
|
+
...(authorized.requiresRank ? { requiresRank: authorized.requiresRank } : {}),
|
|
421
|
+
// The provenance pair the create-vector idempotency read keys on
|
|
422
|
+
// (db.findTaskBySourceRef): one idea can only ever mint one routed task.
|
|
423
|
+
source: 'idea-route',
|
|
424
|
+
sourceRef: `idea-${idea.id}`,
|
|
425
|
+
}, { client });
|
|
426
|
+
|
|
427
|
+
// d. The lineage row — origin='route' (ideas_003 widened the CHECK for
|
|
428
|
+
// exactly this). THIS is the row goal 1000067's ideator credit will pay
|
|
429
|
+
// against, and the reason all of this is one transaction.
|
|
430
|
+
await client.query(
|
|
431
|
+
`INSERT INTO ideas_task_lineage (task_id, idea_id, origin)
|
|
432
|
+
VALUES ($1, $2, 'route')
|
|
433
|
+
ON CONFLICT (task_id) DO UPDATE
|
|
434
|
+
SET idea_id = EXCLUDED.idea_id, origin = EXCLUDED.origin`,
|
|
435
|
+
[task.id, idea.id]
|
|
436
|
+
);
|
|
437
|
+
|
|
438
|
+
// e. Close the loop the FKs forced open in (b).
|
|
439
|
+
await client.query(
|
|
440
|
+
`UPDATE idea_inbox SET promoted_to_task_id = $2 WHERE id = $1`,
|
|
441
|
+
[idea.id, task.id]
|
|
442
|
+
);
|
|
443
|
+
|
|
444
|
+
await client.query('COMMIT');
|
|
445
|
+
return {
|
|
446
|
+
...ungradedProjection(),
|
|
447
|
+
...idea,
|
|
448
|
+
promoted_to_task_id: task.id,
|
|
449
|
+
routing: {
|
|
450
|
+
routed: true,
|
|
451
|
+
reason: landing.reason,
|
|
452
|
+
status: authorized.status,
|
|
453
|
+
requires_rank: authorized.requiresRank,
|
|
454
|
+
goal_id: goal.id,
|
|
455
|
+
version_id: goal.version_id,
|
|
456
|
+
task_id: task.id,
|
|
457
|
+
},
|
|
458
|
+
routed_task: task,
|
|
459
|
+
};
|
|
460
|
+
} catch (err) {
|
|
461
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
462
|
+
throw err;
|
|
463
|
+
} finally {
|
|
464
|
+
client.release();
|
|
465
|
+
}
|
|
204
466
|
}
|
|
205
467
|
|
|
206
468
|
// -------------------------------------------------------------------------
|
|
@@ -418,6 +680,66 @@ async function sourceIdeaForTask(taskId) {
|
|
|
418
680
|
return rows[0] || null;
|
|
419
681
|
}
|
|
420
682
|
|
|
683
|
+
// listRoutedAwaitingNod — the NOD QUEUE (task 1003066, BV1.R05).
|
|
684
|
+
//
|
|
685
|
+
// A routed filing that soft-landed is a task sitting at 'backlog' in a real goal,
|
|
686
|
+
// one Metic+ promote from claimable. Owner interview Q3 settled where that queue
|
|
687
|
+
// lives: the EXISTING "Needs your attention" card and the EXISTING
|
|
688
|
+
// POST /tasks/:id/promote — no new surface, no new permission. This is the read
|
|
689
|
+
// that card was missing, and without it the soft landing is a silent drop: the
|
|
690
|
+
// filing looks accepted, nothing tells anyone it needs a nod, and it sits at
|
|
691
|
+
// backlog forever. Criterion C2's "one Metic+ promote away" is only true if
|
|
692
|
+
// somebody can SEE it.
|
|
693
|
+
//
|
|
694
|
+
// Scoped by the lineage row (origin='route'), not by status alone — a task can be
|
|
695
|
+
// at backlog for a dozen unrelated reasons, and this queue is specifically "a
|
|
696
|
+
// person reported this and the system is waiting on a human". It reads the FILER
|
|
697
|
+
// too (captured_by), because Q3's copy calls the pending state "awaiting a nod"
|
|
698
|
+
// and shows kind + who filed it.
|
|
699
|
+
//
|
|
700
|
+
// Deliberately NOT filtered by goal membership or by the viewer: any Metic+ may
|
|
701
|
+
// give the nod (Q3 rejected goal-member-first review as surface cost), so every
|
|
702
|
+
// Metic+ sees one queue. The route is what gates rank; this is a plain read.
|
|
703
|
+
async function listRoutedAwaitingNod({ limit = 20 } = {}, deps = {}) {
|
|
704
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
705
|
+
const { rows } = await activePool.query(
|
|
706
|
+
`SELECT t.id AS task_id, t.title, t.kind, t.status, t.goal_id, t.version_id,
|
|
707
|
+
t.requires_rank, t.created_at,
|
|
708
|
+
l.idea_id, i.captured_by, b.github_login AS filed_by_login,
|
|
709
|
+
g.title AS goal_title
|
|
710
|
+
FROM ideas_task_lineage l
|
|
711
|
+
JOIN tasks t ON t.id = l.task_id
|
|
712
|
+
JOIN idea_inbox i ON i.id = l.idea_id
|
|
713
|
+
LEFT JOIN builders b ON b.id = i.captured_by
|
|
714
|
+
LEFT JOIN goals g ON g.id = t.goal_id
|
|
715
|
+
WHERE l.origin = 'route'
|
|
716
|
+
AND t.status = 'backlog'
|
|
717
|
+
ORDER BY t.created_at ASC
|
|
718
|
+
LIMIT $1`,
|
|
719
|
+
// The origin filter rides idx_ideas_task_lineage_route (ideas_004): a PARTIAL
|
|
720
|
+
// index on origin='route' keyed by task_id, so this walks the routed rows and
|
|
721
|
+
// probes tasks from the same structure. Without it this was a full scan of a
|
|
722
|
+
// monotonically-growing ledger on EVERY Home load for every Metic+ builder.
|
|
723
|
+
[limit]
|
|
724
|
+
);
|
|
725
|
+
return rows;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// The count behind the card's pill — same predicate as the list, so the number a
|
|
729
|
+
// viewer is shown can never disagree with the rows they can see (the
|
|
730
|
+
// countOpenIdeas precedent two functions up).
|
|
731
|
+
async function countRoutedAwaitingNod(deps = {}) {
|
|
732
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
733
|
+
const { rows } = await activePool.query(
|
|
734
|
+
`SELECT COUNT(*)::int AS n
|
|
735
|
+
FROM ideas_task_lineage l
|
|
736
|
+
JOIN tasks t ON t.id = l.task_id
|
|
737
|
+
WHERE l.origin = 'route' AND t.status = 'backlog'`
|
|
738
|
+
// Same predicate ⇒ same index (ideas_004) as the list above.
|
|
739
|
+
);
|
|
740
|
+
return rows[0] ? rows[0].n : 0;
|
|
741
|
+
}
|
|
742
|
+
|
|
421
743
|
// -------------------------------------------------------------------------
|
|
422
744
|
// Grade writes (task 1002916 / BV1.R140)
|
|
423
745
|
//
|
|
@@ -741,6 +1063,9 @@ module.exports = {
|
|
|
741
1063
|
linkIdeaTask,
|
|
742
1064
|
countShippedTasksForIdea,
|
|
743
1065
|
sourceIdeaForTask,
|
|
1066
|
+
// the nod queue (task 1003066, BV1.R05)
|
|
1067
|
+
listRoutedAwaitingNod,
|
|
1068
|
+
countRoutedAwaitingNod,
|
|
744
1069
|
// criterion-proposal ratification (V3.R31 / #252)
|
|
745
1070
|
ratifyCriterionProposal,
|
|
746
1071
|
parseProposalBody,
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
-- ideas_004_lineage_route_index.sql — index the ROUTE lineage lookup
|
|
2
|
+
-- (task 1003066, BV1.R05, goal 1000071 "Idea routing").
|
|
3
|
+
--
|
|
4
|
+
-- WHY. R05 added the nod queue: GET /inbox/awaiting-nod lists routed filings that
|
|
5
|
+
-- soft-landed, filtering `ideas_task_lineage.origin = 'route'` and joining tasks
|
|
6
|
+
-- for status='backlog'. ideas_002 indexes only task_id (the PK) and idea_id —
|
|
7
|
+
-- nothing on `origin` — so that filter was a full scan of a table that grows
|
|
8
|
+
-- monotonically with every promoted or routed idea, forever. And it is not a rare
|
|
9
|
+
-- query: the hall's "Needs your attention" card issues it on EVERY Home page load
|
|
10
|
+
-- for EVERY Metic+ builder, so the scan cost arrives per page view, not per triage
|
|
11
|
+
-- session. (Raised by the grading panel's efficiency worker on the R05 diff.)
|
|
12
|
+
--
|
|
13
|
+
-- WHY PARTIAL, AND WHY KEYED ON task_id. 'route' is one of three origins and will
|
|
14
|
+
-- stay the minority for as long as triage-promotion exists, so `WHERE origin =
|
|
15
|
+
-- 'route'` keeps the index proportional to the routed rows rather than the whole
|
|
16
|
+
-- table. Keying it on task_id means the index serves the JOIN to tasks directly
|
|
17
|
+
-- from the same structure that satisfies the filter — the planner can walk the
|
|
18
|
+
-- routed rows and probe tasks without touching the heap for a discarded origin.
|
|
19
|
+
--
|
|
20
|
+
-- WHY NOT CONCURRENTLY. Same reasoning core_222 recorded for its own index:
|
|
21
|
+
-- CREATE INDEX CONCURRENTLY cannot run inside a transaction block, and this file
|
|
22
|
+
-- is wrapped BEGIN/COMMIT so it lands atomically. The lock is acceptable HERE
|
|
23
|
+
-- specifically because ideas_task_lineage is a low-volume human-filing ledger —
|
|
24
|
+
-- one row per promoted/routed idea, arriving at human pace — so the build is
|
|
25
|
+
-- milliseconds against a small table and no write path of consequence blocks.
|
|
26
|
+
-- This does NOT generalise to a write-hot table.
|
|
27
|
+
--
|
|
28
|
+
-- Module migrations are ADDITIVE and NAMESPACED: every object here is
|
|
29
|
+
-- ideas_-prefixed, no down-migration, idempotent (safe to re-run).
|
|
30
|
+
|
|
31
|
+
BEGIN;
|
|
32
|
+
|
|
33
|
+
CREATE INDEX IF NOT EXISTS idx_ideas_task_lineage_route
|
|
34
|
+
ON ideas_task_lineage (task_id)
|
|
35
|
+
WHERE origin = 'route';
|
|
36
|
+
|
|
37
|
+
COMMIT;
|
|
@@ -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,42 @@ 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
|
+
// task 1003066 (BV1.R05): the landing gate refused. Either the goal's
|
|
233
|
+
// scope reaches protected territory and the filer is below Metic
|
|
234
|
+
// (gate 'protected_scope'), or they DECLARED touches the wall rejected
|
|
235
|
+
// (gate 'out_of_scope' / 'protected_path'). 403 and not 400 — the
|
|
236
|
+
// request is well-formed, the FILER lacks the authority — matching what
|
|
237
|
+
// POST /goals/:id/tasks returns for the same wall, so one refusal has
|
|
238
|
+
// one status. `gate` names which rule bit, and the reason tells the
|
|
239
|
+
// filer the way through: file it without a goal.
|
|
240
|
+
err.code === 'ROUTE_NOT_AUTHORIZED' ? 403 :
|
|
241
|
+
err.code === 'LIFECYCLE_UNAVAILABLE' ? 503 :
|
|
200
242
|
500;
|
|
201
243
|
return res.fail(err.code, status, { ...err });
|
|
202
244
|
}
|
|
@@ -235,6 +277,41 @@ module.exports = function buildInboxRouter() {
|
|
|
235
277
|
}
|
|
236
278
|
});
|
|
237
279
|
|
|
280
|
+
// GET /inbox/awaiting-nod — the NOD QUEUE (task 1003066, BV1.R05).
|
|
281
|
+
//
|
|
282
|
+
// REGISTERED BEFORE /inbox/:id ON PURPOSE. Express matches in registration
|
|
283
|
+
// order, so a literal segment declared after the wildcard is unreachable —
|
|
284
|
+
// this would arrive as /inbox/:id with id='awaiting-nod' and 400 on parseId.
|
|
285
|
+
//
|
|
286
|
+
// rank: metic+ via the EXISTING idea.triage permission, deliberately reused
|
|
287
|
+
// rather than minting a new one. Owner interview Q3: "any Metic+ gives the nod,
|
|
288
|
+
// in the existing Needs-your-attention slice. No new surface, no new
|
|
289
|
+
// permission." The act this queue leads to is POST /tasks/:id/promote, itself
|
|
290
|
+
// gated task.promote (Metic+), so a reader who can see this queue can act on it
|
|
291
|
+
// — the two gates agree by construction instead of by coincidence.
|
|
292
|
+
//
|
|
293
|
+
// Placed on /inbox rather than /tasks because the row is defined by its LINEAGE
|
|
294
|
+
// (origin='route'), and ideas_task_lineage is an ideas-owned table — the
|
|
295
|
+
// lifecycle module must not query it, and this module must not query it from
|
|
296
|
+
// there. "A task at backlog that a person reported" is an ideas question.
|
|
297
|
+
router.get('/inbox/awaiting-nod', api.requireBuilder, api.requirePermission('idea.triage'), async (req, res) => {
|
|
298
|
+
try {
|
|
299
|
+
const limitRaw = Number(req.query.limit);
|
|
300
|
+
const limit = Number.isInteger(limitRaw) && limitRaw > 0 && limitRaw <= 100 ? limitRaw : 20;
|
|
301
|
+
const [awaiting, count] = await Promise.all([
|
|
302
|
+
inbox.listRoutedAwaitingNod({ limit }),
|
|
303
|
+
inbox.countRoutedAwaitingNod(),
|
|
304
|
+
]);
|
|
305
|
+
res.json({ awaiting, count });
|
|
306
|
+
} catch (err) {
|
|
307
|
+
// Through the published logger seam, not a raw console call: a module logs
|
|
308
|
+
// via the doorway, and the console_call_count ratchet holds at its floor
|
|
309
|
+
// (the task 1003148 precedent).
|
|
310
|
+
api.logger('ideas').error('GET /inbox/awaiting-nod', err && err.message);
|
|
311
|
+
res.fail('awaiting_nod_failed', { status: 500, message: 'internal error' });
|
|
312
|
+
}
|
|
313
|
+
});
|
|
314
|
+
|
|
238
315
|
// rank: any-builder — single idea read for the "#NNN" deep-link resolver
|
|
239
316
|
// (#1047). Mirrors GET /blockers/:id. The hall resolves /#/idea/N by id, but
|
|
240
317
|
// the /inbox LIST returns only the open-first-50, so a closed/older idea ref
|