@bongos/core 1.19.915 → 1.19.916
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 -14
- package/docs/module-api-changelog.md +2 -0
- package/modules/government/board.js +1 -1
- package/modules/ideas/grades.js +317 -0
- package/modules/ideas/inbox.js +65 -721
- package/modules/ideas/projection.js +103 -0
- package/modules/ideas/ratify.js +211 -0
- package/modules/ideas/reads.js +96 -0
- package/modules/ideas/trail.js +111 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/idea_cascade.mjs +3 -3
package/modules/ideas/inbox.js
CHANGED
|
@@ -22,6 +22,24 @@ const { pool } = api;
|
|
|
22
22
|
const { decideLanding, authorizeRoutedLanding, routedPriority } = require('./routing');
|
|
23
23
|
// The home a goal-less bug lands in (task 1003691, ADR 0262).
|
|
24
24
|
const { resolveHomelessHome } = require('./homeless-home');
|
|
25
|
+
// The two per-instance ideator dials (task 1004084, BV2.R05). Only the ignite
|
|
26
|
+
// floor is read here; see the decideLanding call below for why that is narrow.
|
|
27
|
+
const ideasConfig = require('./config');
|
|
28
|
+
// The shape every idea read returns, and the grade vocabulary that rides on it
|
|
29
|
+
// (task 1004115). The bottom of the module: it requires nothing, which is what
|
|
30
|
+
// keeps the carve acyclic.
|
|
31
|
+
const {
|
|
32
|
+
IDEA_COLS, IDEA_COLS_Q, GRADE_COLS_Q, IDEA_JOIN_GRADE, ungradedProjection,
|
|
33
|
+
DEFAULT_GRADE, VALID_GRADES, DEFAULT_TEMPLATE_KEY, TEMPLATE_KEY_MAX, FIELD_VALUES_MAX,
|
|
34
|
+
} = require('./projection');
|
|
35
|
+
// The carved domains. Each is re-exported at the bottom of this file, so every
|
|
36
|
+
// existing caller of `require('./inbox')` keeps working unchanged — the same
|
|
37
|
+
// facade rule docs/design/demonolith-plan.md sets for every split, and the same
|
|
38
|
+
// one ./skill-friction.js already follows below.
|
|
39
|
+
const { listOpenIdeas, getIdea, countOpenIdeas } = require('./reads');
|
|
40
|
+
const trail = require('./trail');
|
|
41
|
+
const grades = require('./grades');
|
|
42
|
+
const ratify = require('./ratify');
|
|
25
43
|
|
|
26
44
|
const VALID_KINDS = [
|
|
27
45
|
'feature',
|
|
@@ -51,81 +69,9 @@ const VALID_KINDS = [
|
|
|
51
69
|
];
|
|
52
70
|
const VALID_STATUSES = ['open', 'promoted', 'discarded', 'merged'];
|
|
53
71
|
|
|
54
|
-
// The one column list every idea read returns. Four copies had to stay in step
|
|
55
|
-
// (capture / list / get / update-RETURNING); core_195 added two columns to each,
|
|
56
|
-
// so it is now a constant rather than four places to forget. Kept as names rather
|
|
57
|
-
// than a string so the qualified form (task 1002916 joined the grade table in, and
|
|
58
|
-
// an unqualified `id` is ambiguous across a join) is derived, not a fifth copy.
|
|
59
|
-
const IDEA_COL_NAMES = [
|
|
60
|
-
'id', 'title', 'body_md', 'kind', 'suggested_version', 'suggested_priority',
|
|
61
|
-
'captured_at', 'captured_by', 'status', 'promoted_to_task_id', 'reviewed_at',
|
|
62
|
-
'merged_into_idea_id', 'triage_note',
|
|
63
|
-
// core_222 (task 1003060, BV1.R02): the routing HINT — "this probably belongs
|
|
64
|
-
// to goal N" — on a row that did NOT convert. Added to the shared list because
|
|
65
|
-
// R02 shipped the column and nothing read it: /idea-triage (R13) cannot walk
|
|
66
|
-
// homeless-only work if the read that feeds it never returns the hint. Strictly
|
|
67
|
-
// advisory: no rank check, no scope wall, and no payout may read it (see the
|
|
68
|
-
// migration's own header). The BINDING home for "which goal" is tasks.goal_id.
|
|
69
|
-
'suggested_goal_id',
|
|
70
|
-
];
|
|
71
|
-
const IDEA_COLS = IDEA_COL_NAMES.join(', ');
|
|
72
|
-
const IDEA_COLS_Q = IDEA_COL_NAMES.map((c) => `i.${c}`).join(', ');
|
|
73
|
-
|
|
74
72
|
// A discard reason / merge note is a sentence, not a document.
|
|
75
73
|
const TRIAGE_NOTE_MAX = 1000;
|
|
76
74
|
|
|
77
|
-
// -------------------------------------------------------------------------
|
|
78
|
-
// Idea grades (task 1002916 / BV1.R140, goal 1000062)
|
|
79
|
-
//
|
|
80
|
-
// Three grades, from the owner's brief:
|
|
81
|
-
// quick — title + description, open to every role, aimed at turning a thought
|
|
82
|
-
// into a task fast. NOT a draft Full Idea.
|
|
83
|
-
// spark — a Quick Idea whose AUTHOR marked it as needing development. The mark
|
|
84
|
-
// is the consent that makes cross-builder development fair (R144).
|
|
85
|
-
// full — carries the base template's five fields, scored on completeness.
|
|
86
|
-
//
|
|
87
|
-
// AN IDEA WITH NO ideas_grades ROW READS AS QUICK. That is the design, not a
|
|
88
|
-
// fallback: every idea captured before this shipped keeps grade='quick' without
|
|
89
|
-
// a row being written or a column being backfilled. The COALESCE below is where
|
|
90
|
-
// that rule lives, and it is the reason the reads LEFT JOIN rather than JOIN.
|
|
91
|
-
// An explicit quick row is equally legal (un-sparking writes one) and reads the
|
|
92
|
-
// same, so callers never have to care which of the two they are looking at.
|
|
93
|
-
const DEFAULT_GRADE = 'quick';
|
|
94
|
-
const VALID_GRADES = ['quick', 'spark', 'full'];
|
|
95
|
-
|
|
96
|
-
// Which template `field_values` was written against. Stored per row so a project
|
|
97
|
-
// that later defines its own shape (R156) does not retro-reinterpret ideas
|
|
98
|
-
// written against the base one. R141 declares what 'base-v1' actually contains —
|
|
99
|
-
// this module only records which template a row used.
|
|
100
|
-
const DEFAULT_TEMPLATE_KEY = 'base-v1';
|
|
101
|
-
const TEMPLATE_KEY_RE = /^[a-z][a-z0-9-]*$/;
|
|
102
|
-
const TEMPLATE_KEY_MAX = 64;
|
|
103
|
-
|
|
104
|
-
// Mirrors ideas_grades_field_values_size_chk. Declared here too so an oversize
|
|
105
|
-
// payload gets a structured 400 instead of a constraint-violation 500.
|
|
106
|
-
const FIELD_VALUES_MAX = 32768;
|
|
107
|
-
|
|
108
|
-
// The grade half of an idea read: the COALESCE above, plus the template-shaped
|
|
109
|
-
// store and the score. `g.*` columns are NULL for an ungraded idea, which is
|
|
110
|
-
// exactly right — it has no template and no score, it just has a default grade.
|
|
111
|
-
// developed_by/developed_at (ideas_005, task 1002922 / BV1.R146) ride along on
|
|
112
|
-
// EVERY idea read rather than needing a second query: "who developed this" is
|
|
113
|
-
// part of an idea's identity once it has been developed, and the R145 queue, the
|
|
114
|
-
// hall detail view (R153) and the R147 credit split all want it. NULL for the
|
|
115
|
-
// overwhelming majority of rows — a quick idea and a self-authored Full Idea were
|
|
116
|
-
// never developed by anyone — which is the honest answer, not a missing one.
|
|
117
|
-
const GRADE_COLS_Q = `COALESCE(g.grade, '${DEFAULT_GRADE}') AS grade,
|
|
118
|
-
g.template_key, g.field_values, g.completeness_score,
|
|
119
|
-
g.developed_by, g.developed_at`;
|
|
120
|
-
const IDEA_JOIN_GRADE = `LEFT JOIN ideas_grades g ON g.idea_id = i.id`;
|
|
121
|
-
|
|
122
|
-
// The same projection for a row that provably has no grade row yet (a just-
|
|
123
|
-
// INSERTed idea), so INSERT…RETURNING does not need a second round trip to
|
|
124
|
-
// return the same shape every other read returns.
|
|
125
|
-
function ungradedProjection() {
|
|
126
|
-
return { grade: DEFAULT_GRADE, template_key: null, field_values: null, completeness_score: null, developed_by: null, developed_at: null };
|
|
127
|
-
}
|
|
128
|
-
|
|
129
75
|
// -------------------------------------------------------------------------
|
|
130
76
|
// Skill-friction: the marker a row carries and the rank gate that hides it.
|
|
131
77
|
// Pure, and its own file (./skill-friction.js) for the reason written there —
|
|
@@ -242,8 +188,39 @@ async function captureIdea({
|
|
|
242
188
|
}
|
|
243
189
|
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
244
190
|
|
|
245
|
-
// The decision, before any write. Pure — see ./routing.js.
|
|
246
|
-
|
|
191
|
+
// The decision, before any write. Pure — see ./routing.js.
|
|
192
|
+
//
|
|
193
|
+
// `trustedRanks` is the PER-INSTANCE IGNITE FLOOR (task 1004084, BV2.R05):
|
|
194
|
+
// who may turn a reported bug straight into claimable work rather than into
|
|
195
|
+
// a row awaiting a nod. A project of one wants every thought claimable; a
|
|
196
|
+
// project of fifty wants the nod. So it is a dial, resolved in ./config.js.
|
|
197
|
+
//
|
|
198
|
+
// IT IS DELIBERATELY NARROW, AND THAT IS THE WHOLE POINT OF THE DIAL
|
|
199
|
+
// (ADR 0316 — "the ignite floor is not the trusted tier"). routing.js spells
|
|
200
|
+
// METIC_PLUS once but READS it for four different gates: the two decideLanding
|
|
201
|
+
// bug branches (this one), `mayProposeCriterion`, and the protected-scope wall.
|
|
202
|
+
// Only the first two are the floor. Had the constant itself been made
|
|
203
|
+
// configurable — which is what BV2.R05 was written as — an instance opening its
|
|
204
|
+
// floor to `xenos` would ALSO have gained the right to file criterion-proposals
|
|
205
|
+
// and to route work into the permission / ship / deploy core. So the floor is
|
|
206
|
+
// passed IN as a ready-made Set and routing.js stays pure and never reads
|
|
207
|
+
// config, while METIC_PLUS stays a fixed constant for the other two gates.
|
|
208
|
+
// tests/filing_abuse_matrix.mjs pins that literal precisely so a later change
|
|
209
|
+
// cannot quietly widen them; a failure there is almost certainly this mistake
|
|
210
|
+
// being made again.
|
|
211
|
+
//
|
|
212
|
+
// The default is a true no-op: floor `metic` resolves to exactly the METIC_PLUS
|
|
213
|
+
// set, so an instance that never touches a dial behaves rank-for-rank as before.
|
|
214
|
+
//
|
|
215
|
+
// This was an inline `require('./config')` INSIDE the call expression until task
|
|
216
|
+
// 1004115, with the whole argument above compressed onto one line — not because
|
|
217
|
+
// either was better, but because inbox.js sat at exactly the 1,500-line ratchet
|
|
218
|
+
// ceiling and a top-level require would have cost the line that redded CI. The
|
|
219
|
+
// carve bought the headroom; this is it being spent.
|
|
220
|
+
const landing = decideLanding({
|
|
221
|
+
goalId, kind: resolvedKind, grade, filerRank, vector,
|
|
222
|
+
trustedRanks: ideasConfig.TRUSTED_FILER_RANKS,
|
|
223
|
+
});
|
|
247
224
|
if (landing.route) {
|
|
248
225
|
return routeIdeaToGoal({
|
|
249
226
|
title, bodyMd, resolvedKind, suggestedVersion, suggestedPriority,
|
|
@@ -479,168 +456,6 @@ async function routeIdeaToGoal({
|
|
|
479
456
|
}
|
|
480
457
|
}
|
|
481
458
|
|
|
482
|
-
// -------------------------------------------------------------------------
|
|
483
|
-
// Read
|
|
484
|
-
// -------------------------------------------------------------------------
|
|
485
|
-
|
|
486
|
-
// viewerRank gates skill-friction visibility (V3.R70 / #289): Xenos must not
|
|
487
|
-
// see skill-friction rows; Metic+ (or unknown rank, pre-R04) see everything.
|
|
488
|
-
// We filter in SQL when the viewer is positively Xenos so the LIMIT still
|
|
489
|
-
// returns up to `limit` *visible* rows rather than `limit` rows that then get
|
|
490
|
-
// thinned out in JS. The marker is a kind OR a legacy title prefix — see
|
|
491
|
-
// skillFrictionHiddenClause, which is the only place that knows that.
|
|
492
|
-
async function listOpenIdeas({ kind = null, limit = 50, viewerRank = null } = {}) {
|
|
493
|
-
const params = [];
|
|
494
|
-
const where = [`i.status = 'open'`];
|
|
495
|
-
if (kind) {
|
|
496
|
-
params.push(kind);
|
|
497
|
-
where.push(`i.kind = $${params.length}`);
|
|
498
|
-
}
|
|
499
|
-
const hideFriction = skillFrictionHiddenClause(viewerRank, params, 'i');
|
|
500
|
-
if (hideFriction) where.push(hideFriction);
|
|
501
|
-
params.push(limit);
|
|
502
|
-
const { rows } = await pool.query(
|
|
503
|
-
`SELECT ${IDEA_COLS_Q}, ${GRADE_COLS_Q}
|
|
504
|
-
FROM idea_inbox i
|
|
505
|
-
${IDEA_JOIN_GRADE}
|
|
506
|
-
WHERE ${where.join(' AND ')}
|
|
507
|
-
ORDER BY i.captured_at DESC
|
|
508
|
-
LIMIT $${params.length}`,
|
|
509
|
-
params
|
|
510
|
-
);
|
|
511
|
-
return rows;
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
// The by-id read. It carries the two people's LOGINS as well as their ids
|
|
515
|
-
// (task 1002929 / R153): an idea's attribution is a claim about humans, and
|
|
516
|
-
// "captured_by: 41" is not attribution to anyone reading it. Named to match the
|
|
517
|
-
// R145 spark queue's `filed_by_login` so the two idea surfaces agree.
|
|
518
|
-
//
|
|
519
|
-
// Both joins are LEFT: a builder row can be missing (an FK that went
|
|
520
|
-
// ON DELETE SET NULL), and an idea nobody developed has no developer at all —
|
|
521
|
-
// which is the common case, not an error. A plain JOIN here would drop most of
|
|
522
|
-
// the inbox, the same trap the grade join documents above.
|
|
523
|
-
async function getIdea(id) {
|
|
524
|
-
const { rows } = await pool.query(
|
|
525
|
-
`SELECT ${IDEA_COLS_Q}, ${GRADE_COLS_Q},
|
|
526
|
-
ba.github_login AS filed_by_login,
|
|
527
|
-
bd.github_login AS developed_by_login
|
|
528
|
-
FROM idea_inbox i
|
|
529
|
-
${IDEA_JOIN_GRADE}
|
|
530
|
-
LEFT JOIN builders ba ON ba.id = i.captured_by
|
|
531
|
-
LEFT JOIN builders bd ON bd.id = g.developed_by
|
|
532
|
-
WHERE i.id = $1`,
|
|
533
|
-
[id]
|
|
534
|
-
);
|
|
535
|
-
return rows[0] ?? null;
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
// viewerRank applies the SAME skill-friction visibility gate as listOpenIdeas
|
|
539
|
-
// (V3.R70 / #289) so the count a viewer is shown matches the rows they can see:
|
|
540
|
-
// a Xenos must not be told there are N open ideas when [skill-friction] rows
|
|
541
|
-
// are filtered out of their list. Default null preserves the old "count
|
|
542
|
-
// everything" behaviour for callers that don't have (or don't gate on) a rank.
|
|
543
|
-
async function countOpenIdeas({ viewerRank = null } = {}) {
|
|
544
|
-
const params = [];
|
|
545
|
-
const where = [`status = 'open'`];
|
|
546
|
-
const hideFriction = skillFrictionHiddenClause(viewerRank, params);
|
|
547
|
-
if (hideFriction) where.push(hideFriction);
|
|
548
|
-
const { rows } = await pool.query(
|
|
549
|
-
`SELECT count(*)::int AS n FROM idea_inbox WHERE ${where.join(' AND ')}`,
|
|
550
|
-
params
|
|
551
|
-
);
|
|
552
|
-
return rows[0].n;
|
|
553
|
-
}
|
|
554
|
-
|
|
555
|
-
// -------------------------------------------------------------------------
|
|
556
|
-
// The idea TRAIL — every idea one builder filed, and what became of it
|
|
557
|
-
// (task 1003281, goal 1000072, criterion legibility-activity-by-role).
|
|
558
|
-
//
|
|
559
|
-
// THE READ THAT EXISTS NOWHERE ELSE, and the reason the criterion was written:
|
|
560
|
-
// the owner could file an idea and then never find out what happened to it.
|
|
561
|
-
// Every other idea read is scoped to what is STILL OPEN — listOpenIdeas, the
|
|
562
|
-
// spark queue, the nod queue — so a promoted, discarded or merged idea drops
|
|
563
|
-
// out of every surface the moment it is triaged, which is exactly when its
|
|
564
|
-
// outcome becomes interesting. This read is scoped to a PERSON instead of to a
|
|
565
|
-
// status, so nothing they filed ever leaves it.
|
|
566
|
-
//
|
|
567
|
-
// NO NEW STORAGE. The four outcomes are already on the row: `status` says which
|
|
568
|
-
// one, `promoted_to_task_id` + ideas_task_lineage say which task a promotion
|
|
569
|
-
// produced, `merged_into_idea_id` says which idea absorbed a merge, and
|
|
570
|
-
// `triage_note` carries the triager's sentence (core_195, task 1002447). The
|
|
571
|
-
// joins below turn those ids into names so a reader gets "became task #1003281
|
|
572
|
-
// — See what you did, by role" rather than a number they must go look up.
|
|
573
|
-
//
|
|
574
|
-
// AN OPEN IDEA IS AN OUTCOME. It reads as "still open", never as an absence —
|
|
575
|
-
// one of the four the criterion names. That is why status is returned verbatim
|
|
576
|
-
// and the surface renders it, rather than the read filtering open rows out.
|
|
577
|
-
//
|
|
578
|
-
// LINEAGE, NOT JUST THE LEGACY COLUMN. One idea can become MANY tasks
|
|
579
|
-
// (ideas_task_lineage, task 1002997), while `promoted_to_task_id` holds only the
|
|
580
|
-
// first. Both are returned: the lineage array is the complete answer, the legacy
|
|
581
|
-
// column is the only answer for promotions that predate the lineage table. The
|
|
582
|
-
// LATERAL is bounded by the page window, so this stays one query, not an N+1.
|
|
583
|
-
const IDEA_TRAIL_DEFAULT_LIMIT = 20;
|
|
584
|
-
const IDEA_TRAIL_MAX_LIMIT = 100;
|
|
585
|
-
|
|
586
|
-
// The WHERE this list and its count share, so the pager can never claim rows the
|
|
587
|
-
// list will not show. viewerRank applies the SAME skill-friction gate as every
|
|
588
|
-
// other idea read (V3.R70 / #289) — including on a builder reading their OWN
|
|
589
|
-
// trail: the gate is about what a rank may see, not about authorship.
|
|
590
|
-
function ideaTrailWhere(builderId, viewerRank, params) {
|
|
591
|
-
params.push(builderId);
|
|
592
|
-
const where = [`i.captured_by = $${params.length}`];
|
|
593
|
-
const hideFriction = skillFrictionHiddenClause(viewerRank, params, 'i');
|
|
594
|
-
if (hideFriction) where.push(hideFriction);
|
|
595
|
-
return where.join(' AND ');
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
async function listIdeaTrailForBuilder(builderId, { limit = IDEA_TRAIL_DEFAULT_LIMIT, offset = 0, viewerRank = null } = {}, deps = {}) {
|
|
599
|
-
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
600
|
-
const lim = Math.min(Math.max(Number.isInteger(Number(limit)) ? Number(limit) : IDEA_TRAIL_DEFAULT_LIMIT, 1), IDEA_TRAIL_MAX_LIMIT);
|
|
601
|
-
const off = Number.isInteger(Number(offset)) && Number(offset) > 0 ? Number(offset) : 0;
|
|
602
|
-
const params = [];
|
|
603
|
-
const where = ideaTrailWhere(builderId, viewerRank, params);
|
|
604
|
-
params.push(lim, off);
|
|
605
|
-
const { rows } = await activePool.query(
|
|
606
|
-
`SELECT ${IDEA_COLS_Q}, ${GRADE_COLS_Q},
|
|
607
|
-
pt.title AS promoted_task_title,
|
|
608
|
-
pt.status AS promoted_task_status,
|
|
609
|
-
mi.title AS merged_into_idea_title,
|
|
610
|
-
mi.status AS merged_into_idea_status,
|
|
611
|
-
COALESCE(lin.tasks, '[]'::json) AS lineage_tasks
|
|
612
|
-
FROM idea_inbox i
|
|
613
|
-
${IDEA_JOIN_GRADE}
|
|
614
|
-
LEFT JOIN tasks pt ON pt.id = i.promoted_to_task_id
|
|
615
|
-
LEFT JOIN idea_inbox mi ON mi.id = i.merged_into_idea_id
|
|
616
|
-
LEFT JOIN LATERAL (
|
|
617
|
-
SELECT json_agg(json_build_object(
|
|
618
|
-
'task_id', l.task_id, 'origin', l.origin,
|
|
619
|
-
'title', lt.title, 'status', lt.status
|
|
620
|
-
) ORDER BY l.task_id) AS tasks
|
|
621
|
-
FROM ideas_task_lineage l
|
|
622
|
-
JOIN tasks lt ON lt.id = l.task_id
|
|
623
|
-
WHERE l.idea_id = i.id
|
|
624
|
-
) lin ON true
|
|
625
|
-
WHERE ${where}
|
|
626
|
-
ORDER BY i.captured_at DESC, i.id DESC
|
|
627
|
-
LIMIT $${params.length - 1} OFFSET $${params.length}`,
|
|
628
|
-
params
|
|
629
|
-
);
|
|
630
|
-
return rows;
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
async function countIdeaTrailForBuilder(builderId, { viewerRank = null } = {}, deps = {}) {
|
|
634
|
-
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
635
|
-
const params = [];
|
|
636
|
-
const where = ideaTrailWhere(builderId, viewerRank, params);
|
|
637
|
-
const { rows } = await activePool.query(
|
|
638
|
-
`SELECT count(*)::int AS n FROM idea_inbox i WHERE ${where}`,
|
|
639
|
-
params
|
|
640
|
-
);
|
|
641
|
-
return rows[0].n;
|
|
642
|
-
}
|
|
643
|
-
|
|
644
459
|
// -------------------------------------------------------------------------
|
|
645
460
|
// Update / merge
|
|
646
461
|
// -------------------------------------------------------------------------
|
|
@@ -963,477 +778,6 @@ async function countRoutedAwaitingNod(deps = {}) {
|
|
|
963
778
|
return rows[0] ? rows[0].n : 0;
|
|
964
779
|
}
|
|
965
780
|
|
|
966
|
-
// -------------------------------------------------------------------------
|
|
967
|
-
// The spark queue (task 1002921 / BV1.R145, goal 1000062)
|
|
968
|
-
//
|
|
969
|
-
// "What Big Idea Sparks are awaiting development?" — the ideator craft's pickup
|
|
970
|
-
// surface, and the first place in this codebase where one craft sources work
|
|
971
|
-
// from another craft's output.
|
|
972
|
-
//
|
|
973
|
-
// THE PREDICATE IS grade='spark' AND status='open', AND THAT IS THE WHOLE
|
|
974
|
-
// QUEUE. There is deliberately no `developed` flag and no separate queue table:
|
|
975
|
-
// R146 develops a spark into a Full Idea IN PLACE on the same idea_inbox row
|
|
976
|
-
// (same id, same captured_by), which flips its grade spark→full — so a developed
|
|
977
|
-
// spark leaves this queue as a CONSEQUENCE of being developed, not because
|
|
978
|
-
// anything remembered to remove it. A second source of truth for "still
|
|
979
|
-
// waiting" is a thing that can disagree with the grade; this cannot.
|
|
980
|
-
//
|
|
981
|
-
// AN INNER JOIN, WHICH INVERTS THIS MODULE'S USUAL RULE ON PURPOSE. Every other
|
|
982
|
-
// idea read LEFT JOINs ideas_grades and COALESCEs to 'quick', because an
|
|
983
|
-
// un-graded idea must still appear. Here the opposite is correct and safe:
|
|
984
|
-
// grade='spark' can never be implicit — absence of a row means quick — so a
|
|
985
|
-
// spark ALWAYS has a real row, and starting from ideas_grades lets
|
|
986
|
-
// ideas_grades_grade_score_idx (ideas_001, written for this very read) drive the
|
|
987
|
-
// scan instead of filtering a full idea_inbox pass afterwards.
|
|
988
|
-
//
|
|
989
|
-
// ORDERING — "newest-or-best first", and only the newest half is real. A spark
|
|
990
|
-
// carries no completeness score by construction (it has no template fields to
|
|
991
|
-
// score), so there is no "best" signal to sort by: upvotes live on builders as
|
|
992
|
-
// karma, not per-idea, and inventing a quality metric for an undeveloped thought
|
|
993
|
-
// is exactly the guess this queue should not be making. captured_at DESC, id
|
|
994
|
-
// DESC as a stable tiebreak for same-timestamp rows. When a real per-idea
|
|
995
|
-
// signal exists, this ORDER BY is the one line that changes.
|
|
996
|
-
//
|
|
997
|
-
// The skill-friction visibility gate (V3.R70) is applied here too, on the same
|
|
998
|
-
// title-prefix rule listOpenIdeas uses. A routine-filed friction row is unlikely
|
|
999
|
-
// to ever be sparked (marking requires an author), but the two idea listings
|
|
1000
|
-
// agreeing by construction beats them agreeing by accident.
|
|
1001
|
-
function sparkQueueWhere(viewerRank, params) {
|
|
1002
|
-
const where = [`g.grade = 'spark'`, `i.status = 'open'`];
|
|
1003
|
-
const hideFriction = skillFrictionHiddenClause(viewerRank, params, 'i');
|
|
1004
|
-
if (hideFriction) where.push(hideFriction);
|
|
1005
|
-
return where;
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
|
-
async function listSparkQueue({ limit = 50, viewerRank = null } = {}, deps = {}) {
|
|
1009
|
-
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
1010
|
-
const params = [];
|
|
1011
|
-
const where = sparkQueueWhere(viewerRank, params);
|
|
1012
|
-
params.push(limit);
|
|
1013
|
-
const { rows } = await activePool.query(
|
|
1014
|
-
// `b.github_login` because attribution is the POINT of this surface: an
|
|
1015
|
-
// ideator picking work up should see whose thought they are developing, and
|
|
1016
|
-
// R146 has to preserve that name. LEFT JOIN — a builder row can be missing.
|
|
1017
|
-
`SELECT ${IDEA_COLS_Q}, ${GRADE_COLS_Q},
|
|
1018
|
-
b.github_login AS filed_by_login
|
|
1019
|
-
FROM ideas_grades g
|
|
1020
|
-
JOIN idea_inbox i ON i.id = g.idea_id
|
|
1021
|
-
LEFT JOIN builders b ON b.id = i.captured_by
|
|
1022
|
-
WHERE ${where.join(' AND ')}
|
|
1023
|
-
ORDER BY i.captured_at DESC, i.id DESC
|
|
1024
|
-
LIMIT $${params.length}`,
|
|
1025
|
-
params
|
|
1026
|
-
);
|
|
1027
|
-
return rows;
|
|
1028
|
-
}
|
|
1029
|
-
|
|
1030
|
-
// The count behind the queue's pill — the SAME predicate as the list (the
|
|
1031
|
-
// countRoutedAwaitingNod / countOpenIdeas precedent), so the number a viewer is
|
|
1032
|
-
// shown can never disagree with the rows they can actually see. It deliberately
|
|
1033
|
-
// takes viewerRank too: a count that ignored the visibility gate would tell a
|
|
1034
|
-
// Xenos there are sparks they cannot find.
|
|
1035
|
-
async function countSparkQueue({ viewerRank = null } = {}, deps = {}) {
|
|
1036
|
-
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
1037
|
-
const params = [];
|
|
1038
|
-
const where = sparkQueueWhere(viewerRank, params);
|
|
1039
|
-
const { rows } = await activePool.query(
|
|
1040
|
-
`SELECT COUNT(*)::int AS n
|
|
1041
|
-
FROM ideas_grades g
|
|
1042
|
-
JOIN idea_inbox i ON i.id = g.idea_id
|
|
1043
|
-
WHERE ${where.join(' AND ')}`,
|
|
1044
|
-
params
|
|
1045
|
-
);
|
|
1046
|
-
return rows[0] ? rows[0].n : 0;
|
|
1047
|
-
}
|
|
1048
|
-
|
|
1049
|
-
// -------------------------------------------------------------------------
|
|
1050
|
-
// Grade writes (task 1002916 / BV1.R140)
|
|
1051
|
-
//
|
|
1052
|
-
// setIdeaGrade writes the WHOLE grade row — it does not merge with what is
|
|
1053
|
-
// already there. That is deliberate: a partial-merge upsert reads innocently at
|
|
1054
|
-
// the call site and silently preserves stale field_values from a previous grade,
|
|
1055
|
-
// which is the bug that would make "develop this spark into a full idea" (R146)
|
|
1056
|
-
// quietly inherit whatever was there before. Callers that only want to move the
|
|
1057
|
-
// score have setIdeaCompleteness below; anyone changing the grade states the
|
|
1058
|
-
// whole row.
|
|
1059
|
-
//
|
|
1060
|
-
// Structured throws (not raw constraint violations) so the routes that land on
|
|
1061
|
-
// top of this — R143's Full Idea capture, R144's mark-as-spark — return a clean
|
|
1062
|
-
// 400/404 rather than a 500 with a Postgres message in it.
|
|
1063
|
-
function assertGradePayload({ grade, templateKey, fieldValues, completenessScore }) {
|
|
1064
|
-
if (!VALID_GRADES.includes(grade)) {
|
|
1065
|
-
throw { code: 'BAD_GRADE', grade, valid: VALID_GRADES };
|
|
1066
|
-
}
|
|
1067
|
-
if (typeof templateKey !== 'string' || !TEMPLATE_KEY_RE.test(templateKey) || templateKey.length > TEMPLATE_KEY_MAX) {
|
|
1068
|
-
throw { code: 'BAD_TEMPLATE_KEY', template_key: templateKey };
|
|
1069
|
-
}
|
|
1070
|
-
// A map, never a scalar or an array — the scorer and the read path walk it by
|
|
1071
|
-
// key. `typeof null === 'object'`, hence the explicit null check.
|
|
1072
|
-
if (fieldValues === null || typeof fieldValues !== 'object' || Array.isArray(fieldValues)) {
|
|
1073
|
-
throw { code: 'BAD_FIELD_VALUES' };
|
|
1074
|
-
}
|
|
1075
|
-
if (JSON.stringify(fieldValues).length > FIELD_VALUES_MAX) {
|
|
1076
|
-
throw { code: 'FIELD_VALUES_TOO_LARGE', max: FIELD_VALUES_MAX };
|
|
1077
|
-
}
|
|
1078
|
-
if (completenessScore !== null) {
|
|
1079
|
-
if (!Number.isInteger(completenessScore) || completenessScore < 0 || completenessScore > 100) {
|
|
1080
|
-
throw { code: 'BAD_COMPLETENESS', completeness_score: completenessScore };
|
|
1081
|
-
}
|
|
1082
|
-
}
|
|
1083
|
-
}
|
|
1084
|
-
|
|
1085
|
-
async function setIdeaGrade(ideaId, {
|
|
1086
|
-
grade,
|
|
1087
|
-
templateKey = DEFAULT_TEMPLATE_KEY,
|
|
1088
|
-
fieldValues = {},
|
|
1089
|
-
completenessScore = null,
|
|
1090
|
-
} = {}) {
|
|
1091
|
-
assertGradePayload({ grade, templateKey, fieldValues, completenessScore });
|
|
1092
|
-
try {
|
|
1093
|
-
const { rows } = await pool.query(
|
|
1094
|
-
`INSERT INTO ideas_grades (idea_id, grade, template_key, field_values, completeness_score)
|
|
1095
|
-
VALUES ($1, $2, $3, $4::jsonb, $5)
|
|
1096
|
-
ON CONFLICT (idea_id) DO UPDATE
|
|
1097
|
-
SET grade = EXCLUDED.grade,
|
|
1098
|
-
template_key = EXCLUDED.template_key,
|
|
1099
|
-
field_values = EXCLUDED.field_values,
|
|
1100
|
-
completeness_score = EXCLUDED.completeness_score
|
|
1101
|
-
RETURNING idea_id, grade, template_key, field_values, completeness_score,
|
|
1102
|
-
created_at, updated_at`,
|
|
1103
|
-
[ideaId, grade, templateKey, JSON.stringify(fieldValues), completenessScore]
|
|
1104
|
-
);
|
|
1105
|
-
return rows[0];
|
|
1106
|
-
} catch (err) {
|
|
1107
|
-
// 23503 = FK violation on idea_id: the idea does not exist. Worth its own
|
|
1108
|
-
// code because "you graded a missing idea" is a 404, not a 500.
|
|
1109
|
-
if (err && err.code === '23503') throw { code: 'IDEA_NOT_FOUND', idea_id: ideaId };
|
|
1110
|
-
throw err;
|
|
1111
|
-
}
|
|
1112
|
-
}
|
|
1113
|
-
|
|
1114
|
-
// The score-only write, so the R142 scorer never has to restate the grade and
|
|
1115
|
-
// the fields just to move a number (and so it cannot wipe them by forgetting).
|
|
1116
|
-
// Returns null when the idea has no grade row: an ungraded idea is quick, and a
|
|
1117
|
-
// quick idea has nothing to score — that is a no-op, not an error.
|
|
1118
|
-
async function setIdeaCompleteness(ideaId, completenessScore) {
|
|
1119
|
-
if (completenessScore !== null && (!Number.isInteger(completenessScore) || completenessScore < 0 || completenessScore > 100)) {
|
|
1120
|
-
throw { code: 'BAD_COMPLETENESS', completeness_score: completenessScore };
|
|
1121
|
-
}
|
|
1122
|
-
const { rows } = await pool.query(
|
|
1123
|
-
`UPDATE ideas_grades
|
|
1124
|
-
SET completeness_score = $2
|
|
1125
|
-
WHERE idea_id = $1
|
|
1126
|
-
RETURNING idea_id, grade, template_key, field_values, completeness_score,
|
|
1127
|
-
created_at, updated_at`,
|
|
1128
|
-
[ideaId, completenessScore]
|
|
1129
|
-
);
|
|
1130
|
-
return rows[0] ?? null;
|
|
1131
|
-
}
|
|
1132
|
-
|
|
1133
|
-
// -------------------------------------------------------------------------
|
|
1134
|
-
// developSpark — turn a Big Idea Spark into a Full Idea IN PLACE
|
|
1135
|
-
// (task 1002922 / BV1.R146, goal 1000062)
|
|
1136
|
-
//
|
|
1137
|
-
// The pickup half of the spark handoff: R144 recorded the author's consent, R145
|
|
1138
|
-
// made the spark findable, and this is another builder filling in the five
|
|
1139
|
-
// template fields.
|
|
1140
|
-
//
|
|
1141
|
-
// IN PLACE, ON THE SAME ROW, AND THAT IS A DONE-WHEN. idea_inbox.id,
|
|
1142
|
-
// captured_by and captured_at are NOT touched — only the grade row moves. A new
|
|
1143
|
-
// row for the developed version would break the hall's /#/idea/N deep link,
|
|
1144
|
-
// ideas_task_lineage.idea_id, and the per-idea credit_log reason key. The
|
|
1145
|
-
// author's claim on their own thought is precisely what must survive being
|
|
1146
|
-
// developed by someone else; it is what the R147 split pays against.
|
|
1147
|
-
//
|
|
1148
|
-
// ONE GUARDED UPDATE, WHICH IS WHERE "ONLY ONCE" ACTUALLY LIVES. The predicate
|
|
1149
|
-
// carries `AND grade = 'spark'`, so the database decides whether this row was
|
|
1150
|
-
// still developable. A read-then-write ("is it a spark? ok, develop it") looks
|
|
1151
|
-
// identical in a test and loses a race in production: two builders picking the
|
|
1152
|
-
// same spark off the R145 queue at the same moment would both pass the check and
|
|
1153
|
-
// the second would overwrite the first's work AND their attribution. Here the
|
|
1154
|
-
// second UPDATE matches zero rows and the route turns that into a 409. Zero rows
|
|
1155
|
-
// is therefore NOT an error condition to log — it is the answer.
|
|
1156
|
-
//
|
|
1157
|
-
// It is also why this is not expressed as setIdeaGrade + a second write:
|
|
1158
|
-
// two statements cannot carry one guard, and a Full Idea with no recorded
|
|
1159
|
-
// developer is the exact state the done-when forbids.
|
|
1160
|
-
//
|
|
1161
|
-
// THE STATUS HALF OF THE GUARD, AND WHY IT IS NOT REDUNDANT WITH THE ROUTE'S
|
|
1162
|
-
// PRE-CHECK. Triage (`updateIdea`) writes idea_inbox.status and NEVER touches
|
|
1163
|
-
// ideas_grades — so a spark that a Metic+ triager discards, merges or promotes
|
|
1164
|
-
// keeps grade='spark' forever. Grade alone therefore does NOT mean "still
|
|
1165
|
-
// developable", and a develop that trusted it would resurrect content the triage
|
|
1166
|
-
// flow already decided was dead, opening a fresh board ratification window for
|
|
1167
|
-
// it. The R145 queue already filters on status and the R144 mark already requires
|
|
1168
|
-
// it; this closes the same hole on the write. In the PREDICATE rather than only
|
|
1169
|
-
// in the handler because a triager discarding an idea mid-request is the same
|
|
1170
|
-
// class of race as two developers colliding — and the fix for a race is never a
|
|
1171
|
-
// check that ran earlier.
|
|
1172
|
-
//
|
|
1173
|
-
// NOTE ON setIdeaGrade's whole-row contract: developed_by/developed_at are
|
|
1174
|
-
// deliberately OUTSIDE it. That contract exists so a grade change cannot inherit
|
|
1175
|
-
// stale field_values; but a developer is a historical FACT about who did the
|
|
1176
|
-
// work, not current content, and setIdeaGrade's ON CONFLICT SET list omits both
|
|
1177
|
-
// columns — so a later re-score or an author's R13 resubmission preserves the
|
|
1178
|
-
// developer's attribution instead of silently erasing it.
|
|
1179
|
-
async function developSpark(ideaId, { developedBy, templateKey = DEFAULT_TEMPLATE_KEY, fieldValues = {}, completenessScore = null } = {}) {
|
|
1180
|
-
// Reuse the R140 payload validator so a bad template key or an oversize
|
|
1181
|
-
// field_values map is a structured throw here rather than a constraint
|
|
1182
|
-
// violation surfacing as a 500 (the assertGradePayload contract).
|
|
1183
|
-
assertGradePayload({ grade: 'full', templateKey, fieldValues, completenessScore });
|
|
1184
|
-
if (developedBy == null) throw { code: 'MISSING_DEVELOPER' };
|
|
1185
|
-
const { rows } = await pool.query(
|
|
1186
|
-
`UPDATE ideas_grades
|
|
1187
|
-
SET grade = 'full',
|
|
1188
|
-
template_key = $2,
|
|
1189
|
-
field_values = $3::jsonb,
|
|
1190
|
-
completeness_score = $4,
|
|
1191
|
-
developed_by = $5,
|
|
1192
|
-
developed_at = now()
|
|
1193
|
-
WHERE idea_id = $1
|
|
1194
|
-
AND grade = 'spark'
|
|
1195
|
-
AND EXISTS (
|
|
1196
|
-
SELECT 1 FROM idea_inbox i WHERE i.id = $1 AND i.status = 'open'
|
|
1197
|
-
)
|
|
1198
|
-
RETURNING idea_id, grade, template_key, field_values, completeness_score,
|
|
1199
|
-
developed_by, developed_at, created_at, updated_at`,
|
|
1200
|
-
[ideaId, templateKey, JSON.stringify(fieldValues), completenessScore, developedBy]
|
|
1201
|
-
);
|
|
1202
|
-
// null = "this row was not a developable spark when we looked". THREE ways to
|
|
1203
|
-
// get here, and the caller re-reads to say which: it was never a spark,
|
|
1204
|
-
// somebody else developed it first, or a triager closed the idea underneath us.
|
|
1205
|
-
const graded = rows[0] ?? null;
|
|
1206
|
-
|
|
1207
|
-
// task 1003856: a Full Idea is the ideator's hand-off point, and the
|
|
1208
|
-
// cross-role cascade that turns it into an engineer's scoping request needs a
|
|
1209
|
-
// transition to hang on. Emitted only on a REAL transition — the UPDATE's
|
|
1210
|
-
// `AND grade = 'spark'` means a no-op re-grade returns null and fires nothing,
|
|
1211
|
-
// so the event cannot repeat for one idea.
|
|
1212
|
-
//
|
|
1213
|
-
// Wrapped and awaited-free by the same reasoning as every other kernel emit
|
|
1214
|
-
// here: a listener must never be able to fail the write that preceded it. The
|
|
1215
|
-
// cascade runner catches everything of its own accord as well.
|
|
1216
|
-
if (graded) {
|
|
1217
|
-
// The payload carries the WHOLE idea, not just its id. The listener lives in
|
|
1218
|
-
// the lifecycle module, which owns no idea table and has no read port for one
|
|
1219
|
-
// (ideas provides only `ideas.capture`) — so the read happens here, inside the
|
|
1220
|
-
// module that owns the row, rather than by widening another module's surface.
|
|
1221
|
-
try {
|
|
1222
|
-
const idea = await getIdea(ideaId);
|
|
1223
|
-
api.emit('idea.graded.full', {
|
|
1224
|
-
...(idea || {}),
|
|
1225
|
-
id: ideaId,
|
|
1226
|
-
idea_id: ideaId,
|
|
1227
|
-
grade: 'full',
|
|
1228
|
-
completeness_score: graded.completeness_score,
|
|
1229
|
-
developed_by: graded.developed_by,
|
|
1230
|
-
});
|
|
1231
|
-
} catch { /* a listener, or the read that feeds it, must never cost the ideator their grade */ }
|
|
1232
|
-
}
|
|
1233
|
-
return graded;
|
|
1234
|
-
}
|
|
1235
|
-
|
|
1236
|
-
// The grade row on its own. null = no row, which reads as grade='quick'
|
|
1237
|
-
// everywhere the idea itself is read (see DEFAULT_GRADE).
|
|
1238
|
-
async function getIdeaGrade(ideaId) {
|
|
1239
|
-
const { rows } = await pool.query(
|
|
1240
|
-
`SELECT idea_id, grade, template_key, field_values, completeness_score,
|
|
1241
|
-
created_at, updated_at
|
|
1242
|
-
FROM ideas_grades
|
|
1243
|
-
WHERE idea_id = $1`,
|
|
1244
|
-
[ideaId]
|
|
1245
|
-
);
|
|
1246
|
-
return rows[0] ?? null;
|
|
1247
|
-
}
|
|
1248
|
-
|
|
1249
|
-
// -------------------------------------------------------------------------
|
|
1250
|
-
// Criterion-proposal ratification (V3.R31 / #252)
|
|
1251
|
-
//
|
|
1252
|
-
// Atomically promotes an idea_inbox row with kind='criterion-proposal' into a
|
|
1253
|
-
// real done_when_criteria row, marks the idea as promoted, awards karma to
|
|
1254
|
-
// the proposer (delta=+3, reason='proposal_ratified'), and writes an audit
|
|
1255
|
-
// row capturing who proposed / who ratified / when / what.
|
|
1256
|
-
//
|
|
1257
|
-
// Called by POST /api/bongos/inbox/:id/ratify (Archon-only, gated by requireRank
|
|
1258
|
-
// in the router). The body_md of the source idea is parsed as JSON with the
|
|
1259
|
-
// shape { target_version, criterion_md, sort_order_hint }; malformed bodies
|
|
1260
|
-
// throw BAD_BODY so the route can return a structured 400.
|
|
1261
|
-
//
|
|
1262
|
-
// Karma amount is tunable; V3.R28 (#248) sets it to +3 to recognize good civic
|
|
1263
|
-
// input without overweighting it relative to peer-upvote signals that arrive
|
|
1264
|
-
// in volume. criterion_id on the new done_when_criteria row defaults to
|
|
1265
|
-
// "proposal-<idea_id>" so the (version_id, criterion_id) UNIQUE constraint
|
|
1266
|
-
// fires if someone ratifies the same idea twice — the second attempt fails the
|
|
1267
|
-
// transaction rather than creating a duplicate criterion silently.
|
|
1268
|
-
const KARMA_RATIFY_DELTA = 3;
|
|
1269
|
-
const KARMA_RATIFY_REASON = 'proposal_ratified';
|
|
1270
|
-
|
|
1271
|
-
function parseProposalBody(bodyMd) {
|
|
1272
|
-
if (!bodyMd || typeof bodyMd !== 'string') {
|
|
1273
|
-
throw { code: 'BAD_BODY', reason: 'body_md_empty' };
|
|
1274
|
-
}
|
|
1275
|
-
let parsed;
|
|
1276
|
-
try {
|
|
1277
|
-
parsed = JSON.parse(bodyMd);
|
|
1278
|
-
} catch (err) {
|
|
1279
|
-
throw { code: 'BAD_BODY', reason: 'body_md_not_json', detail: err.message };
|
|
1280
|
-
}
|
|
1281
|
-
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
1282
|
-
throw { code: 'BAD_BODY', reason: 'body_md_not_object' };
|
|
1283
|
-
}
|
|
1284
|
-
const { target_version, criterion_md, sort_order_hint } = parsed;
|
|
1285
|
-
if (!target_version || typeof target_version !== 'string') {
|
|
1286
|
-
throw { code: 'BAD_BODY', reason: 'target_version_required' };
|
|
1287
|
-
}
|
|
1288
|
-
if (!criterion_md || typeof criterion_md !== 'string') {
|
|
1289
|
-
throw { code: 'BAD_BODY', reason: 'criterion_md_required' };
|
|
1290
|
-
}
|
|
1291
|
-
let hint = null;
|
|
1292
|
-
if (sort_order_hint != null) {
|
|
1293
|
-
const n = Number(sort_order_hint);
|
|
1294
|
-
if (!Number.isFinite(n) || !Number.isInteger(n)) {
|
|
1295
|
-
throw { code: 'BAD_BODY', reason: 'sort_order_hint_not_integer' };
|
|
1296
|
-
}
|
|
1297
|
-
hint = n;
|
|
1298
|
-
}
|
|
1299
|
-
return { target_version, criterion_md, sort_order_hint: hint };
|
|
1300
|
-
}
|
|
1301
|
-
|
|
1302
|
-
async function ratifyCriterionProposal(ideaId, ratifierId, { notesMd = '' } = {}) {
|
|
1303
|
-
const id = Number(ideaId);
|
|
1304
|
-
if (!Number.isFinite(id)) {
|
|
1305
|
-
throw { code: 'BAD_ID' };
|
|
1306
|
-
}
|
|
1307
|
-
const rid = Number(ratifierId);
|
|
1308
|
-
if (!Number.isFinite(rid)) {
|
|
1309
|
-
throw { code: 'BAD_RATIFIER' };
|
|
1310
|
-
}
|
|
1311
|
-
const client = await pool.connect();
|
|
1312
|
-
try {
|
|
1313
|
-
await client.query('BEGIN');
|
|
1314
|
-
|
|
1315
|
-
// a. Read + lock the idea row.
|
|
1316
|
-
const { rows: ideaRows } = await client.query(
|
|
1317
|
-
`SELECT id, title, body_md, kind, status, captured_by
|
|
1318
|
-
FROM idea_inbox
|
|
1319
|
-
WHERE id = $1
|
|
1320
|
-
FOR UPDATE`,
|
|
1321
|
-
[id]
|
|
1322
|
-
);
|
|
1323
|
-
if (ideaRows.length === 0) throw { code: 'IDEA_NOT_FOUND' };
|
|
1324
|
-
const idea = ideaRows[0];
|
|
1325
|
-
if (idea.kind !== 'criterion-proposal') {
|
|
1326
|
-
throw { code: 'WRONG_KIND', kind: idea.kind, expected: 'criterion-proposal' };
|
|
1327
|
-
}
|
|
1328
|
-
if (idea.status !== 'open') {
|
|
1329
|
-
throw { code: 'IDEA_NOT_OPEN', status: idea.status };
|
|
1330
|
-
}
|
|
1331
|
-
|
|
1332
|
-
// b. Parse body_md.
|
|
1333
|
-
const proposal = parseProposalBody(idea.body_md);
|
|
1334
|
-
|
|
1335
|
-
// c. Verify target version exists. (FK on done_when_criteria.version_id
|
|
1336
|
-
// would catch this too, but giving a 400 instead of a 23503 is friendlier.)
|
|
1337
|
-
const { rows: verRows } = await client.query(
|
|
1338
|
-
`SELECT id FROM versions WHERE id = $1`,
|
|
1339
|
-
[proposal.target_version]
|
|
1340
|
-
);
|
|
1341
|
-
if (verRows.length === 0) {
|
|
1342
|
-
throw { code: 'VERSION_NOT_FOUND', version_id: proposal.target_version };
|
|
1343
|
-
}
|
|
1344
|
-
|
|
1345
|
-
// d. Resolve sort_order: prefer the hint; otherwise max(existing)+1, or 100
|
|
1346
|
-
// if no rows exist yet (matches the migration 006 default).
|
|
1347
|
-
let sortOrder = proposal.sort_order_hint;
|
|
1348
|
-
if (sortOrder == null) {
|
|
1349
|
-
const { rows: maxRows } = await client.query(
|
|
1350
|
-
`SELECT COALESCE(MAX(sort_order), 0) + 1 AS next
|
|
1351
|
-
FROM done_when_criteria
|
|
1352
|
-
WHERE version_id = $1`,
|
|
1353
|
-
[proposal.target_version]
|
|
1354
|
-
);
|
|
1355
|
-
sortOrder = maxRows[0].next;
|
|
1356
|
-
}
|
|
1357
|
-
|
|
1358
|
-
// e. INSERT done_when_criteria with criterion_id = 'proposal-<idea_id>'.
|
|
1359
|
-
// The UNIQUE (version_id, criterion_id) constraint provides natural
|
|
1360
|
-
// idempotency — re-ratifying the same idea would 23505 here.
|
|
1361
|
-
const criterionRefId = `proposal-${idea.id}`;
|
|
1362
|
-
const { rows: critRows } = await client.query(
|
|
1363
|
-
`INSERT INTO done_when_criteria
|
|
1364
|
-
(version_id, criterion_id, criterion_md, sort_order)
|
|
1365
|
-
VALUES ($1, $2, $3, $4)
|
|
1366
|
-
RETURNING id, version_id, criterion_id, criterion_md, sort_order`,
|
|
1367
|
-
[proposal.target_version, criterionRefId, proposal.criterion_md, sortOrder]
|
|
1368
|
-
);
|
|
1369
|
-
const criterion = critRows[0];
|
|
1370
|
-
|
|
1371
|
-
// f. Mark the idea promoted. promoted_to_task_id stays NULL — a criterion
|
|
1372
|
-
// is not a task. reviewed_at is stamped to now() so triage views skip it.
|
|
1373
|
-
await client.query(
|
|
1374
|
-
`UPDATE idea_inbox
|
|
1375
|
-
SET status = 'promoted',
|
|
1376
|
-
promoted_to_task_id = NULL,
|
|
1377
|
-
reviewed_at = now()
|
|
1378
|
-
WHERE id = $1`,
|
|
1379
|
-
[idea.id]
|
|
1380
|
-
);
|
|
1381
|
-
|
|
1382
|
-
// g. Award karma to the proposer. captured_by may be NULL on legacy rows;
|
|
1383
|
-
// skip the karma insert in that case (no proposer to credit) but still
|
|
1384
|
-
// record the audit row.
|
|
1385
|
-
let karmaAwarded = 0;
|
|
1386
|
-
if (idea.captured_by != null) {
|
|
1387
|
-
await client.query(
|
|
1388
|
-
`INSERT INTO karma_log (builder_id, delta, reason, granted_by)
|
|
1389
|
-
VALUES ($1, $2, $3, $4)`,
|
|
1390
|
-
[idea.captured_by, KARMA_RATIFY_DELTA, KARMA_RATIFY_REASON, rid]
|
|
1391
|
-
);
|
|
1392
|
-
karmaAwarded = KARMA_RATIFY_DELTA;
|
|
1393
|
-
|
|
1394
|
-
// Note: prior to 2026-05-30 this also ticked a 'first_vote_received'
|
|
1395
|
-
// onboarding stage. That stage was removed (migration 059) — graduation
|
|
1396
|
-
// no longer waits on a peer vote — so there is nothing to mark here now.
|
|
1397
|
-
}
|
|
1398
|
-
|
|
1399
|
-
// h. Audit row — durable record of the ratification event.
|
|
1400
|
-
const { rows: auditRows } = await client.query(
|
|
1401
|
-
`INSERT INTO criterion_proposal_audit
|
|
1402
|
-
(idea_id, criterion_id, proposed_by, ratified_by,
|
|
1403
|
-
target_version, criterion_md, sort_order_hint, notes_md)
|
|
1404
|
-
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
|
1405
|
-
RETURNING id, ratified_at`,
|
|
1406
|
-
[
|
|
1407
|
-
idea.id,
|
|
1408
|
-
criterion.id,
|
|
1409
|
-
idea.captured_by,
|
|
1410
|
-
rid,
|
|
1411
|
-
proposal.target_version,
|
|
1412
|
-
proposal.criterion_md,
|
|
1413
|
-
proposal.sort_order_hint,
|
|
1414
|
-
notesMd || '',
|
|
1415
|
-
]
|
|
1416
|
-
);
|
|
1417
|
-
|
|
1418
|
-
await client.query('COMMIT');
|
|
1419
|
-
return {
|
|
1420
|
-
criterion_id: criterion.id,
|
|
1421
|
-
criterion_ref: criterion.criterion_id,
|
|
1422
|
-
version_id: criterion.version_id,
|
|
1423
|
-
sort_order: criterion.sort_order,
|
|
1424
|
-
audit_id: auditRows[0].id,
|
|
1425
|
-
ratified_at: auditRows[0].ratified_at,
|
|
1426
|
-
karma_awarded: karmaAwarded,
|
|
1427
|
-
proposer_id: idea.captured_by,
|
|
1428
|
-
};
|
|
1429
|
-
} catch (err) {
|
|
1430
|
-
await client.query('ROLLBACK').catch(() => {});
|
|
1431
|
-
throw err;
|
|
1432
|
-
} finally {
|
|
1433
|
-
client.release();
|
|
1434
|
-
}
|
|
1435
|
-
}
|
|
1436
|
-
|
|
1437
781
|
// -------------------------------------------------------------------------
|
|
1438
782
|
// Helpers used by CLI defaults
|
|
1439
783
|
// -------------------------------------------------------------------------
|
|
@@ -1465,14 +809,14 @@ module.exports = {
|
|
|
1465
809
|
listOpenIdeas,
|
|
1466
810
|
getIdea,
|
|
1467
811
|
countOpenIdeas,
|
|
1468
|
-
getIdeaGrade,
|
|
812
|
+
getIdeaGrade: grades.getIdeaGrade,
|
|
1469
813
|
// mutate
|
|
1470
814
|
updateIdea,
|
|
1471
815
|
updateIdeaBody,
|
|
1472
|
-
setIdeaGrade,
|
|
1473
|
-
setIdeaCompleteness,
|
|
816
|
+
setIdeaGrade: grades.setIdeaGrade,
|
|
817
|
+
setIdeaCompleteness: grades.setIdeaCompleteness,
|
|
1474
818
|
// develop a spark into a Full Idea, in place (task 1002922, BV1.R146)
|
|
1475
|
-
developSpark,
|
|
819
|
+
developSpark: grades.developSpark,
|
|
1476
820
|
// idea → task lineage (task 1002997 / BV1.R173) — the one-idea-many-tasks
|
|
1477
821
|
// link stream 1 (IC4) pays against
|
|
1478
822
|
linkIdeaTask,
|
|
@@ -1482,18 +826,18 @@ module.exports = {
|
|
|
1482
826
|
listRoutedAwaitingNod,
|
|
1483
827
|
countRoutedAwaitingNod,
|
|
1484
828
|
// the spark queue (task 1002921, BV1.R145) — sparks awaiting development
|
|
1485
|
-
listSparkQueue,
|
|
1486
|
-
countSparkQueue,
|
|
829
|
+
listSparkQueue: grades.listSparkQueue,
|
|
830
|
+
countSparkQueue: grades.countSparkQueue,
|
|
1487
831
|
// the idea TRAIL (task 1003281) — every idea one builder filed, with what
|
|
1488
832
|
// became of it. Scoped to a person, not to a status, so a triaged idea never
|
|
1489
833
|
// drops out of the one surface that answers "what happened to my idea".
|
|
1490
|
-
listIdeaTrailForBuilder,
|
|
1491
|
-
countIdeaTrailForBuilder,
|
|
1492
|
-
IDEA_TRAIL_DEFAULT_LIMIT,
|
|
1493
|
-
IDEA_TRAIL_MAX_LIMIT,
|
|
834
|
+
listIdeaTrailForBuilder: trail.listIdeaTrailForBuilder,
|
|
835
|
+
countIdeaTrailForBuilder: trail.countIdeaTrailForBuilder,
|
|
836
|
+
IDEA_TRAIL_DEFAULT_LIMIT: trail.IDEA_TRAIL_DEFAULT_LIMIT,
|
|
837
|
+
IDEA_TRAIL_MAX_LIMIT: trail.IDEA_TRAIL_MAX_LIMIT,
|
|
1494
838
|
// criterion-proposal ratification (V3.R31 / #252)
|
|
1495
|
-
ratifyCriterionProposal,
|
|
1496
|
-
parseProposalBody,
|
|
1497
|
-
KARMA_RATIFY_DELTA,
|
|
1498
|
-
KARMA_RATIFY_REASON,
|
|
839
|
+
ratifyCriterionProposal: ratify.ratifyCriterionProposal,
|
|
840
|
+
parseProposalBody: ratify.parseProposalBody,
|
|
841
|
+
KARMA_RATIFY_DELTA: ratify.KARMA_RATIFY_DELTA,
|
|
842
|
+
KARMA_RATIFY_REASON: ratify.KARMA_RATIFY_REASON,
|
|
1499
843
|
};
|