@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.
@@ -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. `trustedRanks` is the per-instance ignite floor (BV2.R05; ./config.js says why it is narrow); required inline because this file sits ON the 1500-line ratchet ceiling and require() is cached.
246
- const landing = decideLanding({ goalId, kind: resolvedKind, grade, filerRank, vector, trustedRanks: require('./config').TRUSTED_FILER_RANKS });
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
  };