@bongos/core 1.19.935 → 1.19.937

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.
Files changed (40) hide show
  1. package/.bongos-core.json +74 -39
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +6 -3
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/api/openapi.json +103 -2
  8. package/docs/api-reference.md +6 -5
  9. package/docs/copy-inventory.md +36 -34
  10. package/docs/copy-registry.json +56 -38
  11. package/docs/module-api-changelog.md +4 -0
  12. package/modules/government/board.js +35 -0
  13. package/modules/government/db.js +41 -1
  14. package/modules/government/migrations/government_018_passed_items_index.sql +31 -0
  15. package/modules/government/routes/government.js +7 -0
  16. package/modules/hall-ui/public/board-room.css +6 -0
  17. package/modules/hall-ui/public/board-room.js +31 -0
  18. package/modules/hall-ui/public/ideas.css +27 -0
  19. package/modules/hall-ui/public/ideas.html +7 -0
  20. package/modules/hall-ui/public/ideas.js +99 -3
  21. package/modules/ideas/grades.js +43 -14
  22. package/modules/ideas/inbox.js +18 -0
  23. package/modules/ideas/migrations/ideas_009_recommendation.sql +62 -0
  24. package/modules/ideas/migrations/ideas_010_goal_ratification.sql +103 -0
  25. package/modules/ideas/module.json +1 -0
  26. package/modules/ideas/projection.js +45 -2
  27. package/modules/ideas/ratify.js +288 -1
  28. package/modules/ideas/routes/inbox.js +72 -1
  29. package/modules/ideas/routes/ratify-goal.js +163 -0
  30. package/modules/ideas/templates.js +16 -4
  31. package/modules/lifecycle/goal-from-idea.js +131 -0
  32. package/modules/lifecycle/lifecycle.js +14 -0
  33. package/package-lock.json +2 -2
  34. package/package.json +1 -1
  35. package/src/module-api.js +1 -1
  36. package/tests/government_board_view.mjs +5 -1
  37. package/tests/government_seed.mjs +1 -0
  38. package/tests/idea_develop_spark.mjs +6 -1
  39. package/tests/idea_goal_ratification.mjs +553 -0
  40. package/tests/idea_recommendation.mjs +381 -0
@@ -861,6 +861,10 @@ async function boardRoomView({ closedLimit = 20 } = {}) {
861
861
  field_values: r.field_values || null,
862
862
  template_key: r.template_key || null,
863
863
  completeness_score: r.completeness_score ?? null,
864
+ // BV2.R13: the author's recommendation, in front of the voter with
865
+ // the sections. A RECOMMENDATION, NOT A DECISION — the board is
866
+ // deciding it, and nothing on this path may treat it as settled.
867
+ recommendation: r.recommendation ?? null,
864
868
  } : null;
865
869
  })()
866
870
  : (() => { const r = amendmentById.get(String(item.subject_id)); return r ? { proposed: r.proposed, rationale_md: r.rationale_md, author_id: r.proposed_by == null ? null : String(r.proposed_by) } : null; })();
@@ -1037,6 +1041,36 @@ async function latestRejectionForIdea(ideaId) {
1037
1041
  });
1038
1042
  }
1039
1043
 
1044
+ // ── BV2.R17's read: did the board CARRY this idea? (task 1004096) ───────────
1045
+ //
1046
+ // The ratification half of the ideator surface turns a Full Idea into a goal,
1047
+ // and spec decision 9 is that the ideator RECOMMENDS while the board DECIDES.
1048
+ // The ideas module holds the author's recommendation on its own grade row, so
1049
+ // without this read "the board decides" would be a sentence in a comment: the
1050
+ // only fact stopping a ratification from being one person's opinion is that a
1051
+ // sitting actually closed `passed`.
1052
+ //
1053
+ // Null means "no passing decision on record", which the caller must treat as a
1054
+ // refusal and never as an absence it may work around. A returned item, an item
1055
+ // still open, and an idea that never reached the board all read the same here,
1056
+ // on purpose — none of them is a mandate, and distinguishing them would invite a
1057
+ // caller to accept one of them.
1058
+ //
1059
+ // Frozen, like latestRejectionForIdea, so a caller cannot decorate the record of
1060
+ // a decision and pass it on as though the board had said more than it did.
1061
+ async function boardPassForIdea(ideaId) {
1062
+ const id = Number(ideaId);
1063
+ if (!Number.isFinite(id) || id <= 0) return null;
1064
+ const row = await db.getLatestPassedItem('full_idea', id);
1065
+ if (!row) return null;
1066
+ return Object.freeze({
1067
+ item_id: row.item_id,
1068
+ outcome: 'passed',
1069
+ opened_at: row.opened_at,
1070
+ closed_at: row.closed_at,
1071
+ });
1072
+ }
1073
+
1040
1074
  // ── the summons: what is waiting on THIS member's vote (task 1003271) ───────
1041
1075
  //
1042
1076
  // THE PROBLEM THIS EXISTS TO FIX. Nine items sat open for two days with zero
@@ -1123,6 +1157,7 @@ module.exports = {
1123
1157
  closeItem,
1124
1158
  maybeCloseAfterVote,
1125
1159
  latestRejectionForIdea,
1160
+ boardPassForIdea,
1126
1161
  validateProposedConstitution,
1127
1162
  proposeAmendment,
1128
1163
  boardRoomView,
@@ -509,7 +509,14 @@ async function getIdeaSubjects(ideaIds) {
509
509
  // in the hall rather than on a reaction. LEFT JOIN: an idea whose grade row
510
510
  // is somehow gone still shows its title.
511
511
  const { rows } = await pool.query(
512
- `SELECT i.id, i.title, i.captured_by, g.field_values, g.template_key, g.completeness_score
512
+ // `g.recommendation` (task 1004092 / BV2.R13, spec R22): what the author
513
+ // thinks their idea turned out to be — a goal, or a criterion inside one.
514
+ // It rides with the sections for exactly the same reason they do: the board
515
+ // is being asked to decide that question, and deciding it without seeing
516
+ // what was proposed is the gap this closes. NULL for every Full Idea filed
517
+ // before the question existed, which the room renders honestly.
518
+ `SELECT i.id, i.title, i.captured_by, g.field_values, g.template_key, g.completeness_score,
519
+ g.recommendation
513
520
  FROM idea_inbox i
514
521
  LEFT JOIN ideas_grades g ON g.idea_id = i.id
515
522
  WHERE i.id = ANY($1::bigint[])`,
@@ -603,6 +610,38 @@ async function getLatestReturnedItemWithObjection(subjectType, subjectId) {
603
610
  return rows[0] || null;
604
611
  }
605
612
 
613
+ // BV2.R17's read (task 1004096): the most recent PASSED item for a subject.
614
+ //
615
+ // The mirror of getLatestReturnedItemWithObjection above, and the reason it
616
+ // exists separately rather than as a `outcome` parameter on that one: the
617
+ // returned read exists to carry an OBJECTION back to an author and joins the
618
+ // votes table to find it, while this one exists to answer a single yes/no —
619
+ // "did the board carry this?" — and must not depend on any vote row existing.
620
+ // A passed item commonly has NO votes at all (a zero-vote sitting passes under
621
+ // the absence rules, ADR 0175 §3), so the LEFT JOIN that is merely defensive
622
+ // there would be load-bearing here, which is a good sign the two are different
623
+ // questions.
624
+ //
625
+ // Ordering is closed_at DESC, id DESC: an idea returned once and resubmitted can
626
+ // have several closed items, and it is the latest decision that stands.
627
+ //
628
+ // Served by the partial index `government_board_items_passed_by_subject`
629
+ // (government_018), the mirror of government_006's index for the returned read.
630
+ // It needs its own: the table only grows, government_003's two indexes both key
631
+ // on OPEN items and government_008's keys on closed_at alone for the whole-board
632
+ // history feed, so none of them helps a lookup filtered by subject.
633
+ async function getLatestPassedItem(subjectType, subjectId) {
634
+ const { rows } = await pool.query(
635
+ `SELECT id AS item_id, closed_at, opened_at, constitution AS decided_under
636
+ FROM government_board_items
637
+ WHERE subject_type = $1 AND subject_id = $2 AND outcome = 'passed'
638
+ ORDER BY closed_at DESC, id DESC
639
+ LIMIT 1`,
640
+ [subjectType, subjectId],
641
+ );
642
+ return rows[0] || null;
643
+ }
644
+
606
645
  // The expiry sweep's read (R11): every OPEN item whose deadline has passed, on
607
646
  // the database clock, oldest deadline first. Served by the partial index
608
647
  // government_board_items_open_by_deadline (government_003 built it for exactly
@@ -721,6 +760,7 @@ module.exports = {
721
760
  getIdeaCompleteness,
722
761
  getExpiredOpenBoardItems,
723
762
  getLatestReturnedItemWithObjection,
763
+ getLatestPassedItem,
724
764
  createAmendment,
725
765
  getAmendment,
726
766
  listOpenBoardItems,
@@ -0,0 +1,31 @@
1
+ -- government_018_passed_items_index.sql — index the passed-item lookup
2
+ -- (task 1004096 / BV2.R17; a grader efficiency finding, and right — exactly as
3
+ -- government_006 was for the sibling read one outcome over).
4
+ --
5
+ -- `getLatestPassedItem` answers "did the board CARRY this subject?" and runs on
6
+ -- every Full-Idea→goal ratification. It is the mirror of
7
+ -- `getLatestReturnedItemWithObjection`, and it inherits that read's problem in
8
+ -- full: `government_board_items` only grows — one row per window, and a
9
+ -- returned idea can be resubmitted without bound — while the table's two
10
+ -- OPEN-item indexes (`government_003`) both key on `closed_at IS NULL` and the
11
+ -- one closed-item index (`government_008`) keys on `closed_at` alone, for the
12
+ -- whole-board history feed. None of them helps a lookup that filters by
13
+ -- subject. Without this, a ratification scans historical volume that scales
14
+ -- with every sitting the project has ever held.
15
+ --
16
+ -- Partial on outcome='passed' — the only outcome this read asks for — and keyed
17
+ -- (subject_type, subject_id, closed_at DESC) to match the query's WHERE and
18
+ -- ORDER BY exactly, the government_006 shape. Two narrow partial indexes rather
19
+ -- than one wider index over `outcome`: each read asks for exactly one outcome
20
+ -- and never ranges across them, so a shared index would be larger and no more
21
+ -- useful to either.
22
+ --
23
+ -- Additive, idempotent, no rows touched — safe on any database state.
24
+
25
+ BEGIN;
26
+
27
+ CREATE INDEX IF NOT EXISTS government_board_items_passed_by_subject
28
+ ON government_board_items (subject_type, subject_id, closed_at DESC)
29
+ WHERE outcome = 'passed';
30
+
31
+ COMMIT;
@@ -103,6 +103,13 @@ const governmentPort = {
103
103
  // resubmit path in modules/ideas resolves this to show what was objected to
104
104
  // and to check whether the flagged section actually changed.
105
105
  latestRejectionForIdea: board.latestRejectionForIdea,
106
+ // BV2.R17 (task 1004096): the record of the board having CARRIED an idea, or
107
+ // null. The ideas module's ratification path resolves this to check the one
108
+ // fact that makes turning a Full Idea into a goal a board decision rather
109
+ // than a ratifier's preference. Read-only, like every other entry here — there
110
+ // is no counterpart that records an outcome, and there must never be one: an
111
+ // outcome is written by a close, never by the module reading it.
112
+ boardPassForIdea: board.boardPassForIdea,
106
113
  };
107
114
 
108
115
  function registerSeams() {
@@ -40,6 +40,12 @@
40
40
  .gov-item__title .fact-pill { vertical-align: middle; margin-left: 10px; text-shadow: none; }
41
41
  .gov-item__state { display: inline-flex; align-items: center; gap: 10px; font-size: 12.5px; color: var(--ink-faint); font-variant-numeric: tabular-nums; }
42
42
  .gov-item__amend { margin: 0 0 12px; font-size: 13.5px; line-height: 1.5; color: var(--ink-soft); max-width: var(--measure); overflow-wrap: anywhere; }
43
+ /* The author's scope recommendation (BV2.R13). Sits between the ballot and the
44
+ idea's own words, at the weight of a stated fact rather than a badge — it is
45
+ what the board is deciding, not something already decided. */
46
+ .gov-item__rec { margin: 12px 0 0; font-size: 13.5px; line-height: 1.5; color: var(--ink-soft); max-width: var(--measure); }
47
+ .gov-item__rec strong { font-weight: 500; color: var(--ink); }
48
+ .gov-item__rec--none { color: var(--ink-faint); }
43
49
  .gov-item__act { margin: 8px 0 4px; }
44
50
  .gov-item__cast { margin: 0; font-size: 13.5px; color: var(--ink-soft); }
45
51
  .gov-item__meta { margin: var(--sp-sm) 0 0; border-top: var(--line) solid var(--rule-soft); }
@@ -265,6 +265,36 @@
265
265
  : emptyStateHtml('This idea has no written sections.');
266
266
  }
267
267
 
268
+ // The author's scope recommendation, in front of the voter WITH the sections
269
+ // (task 1004092 / BV2.R13, spec R22).
270
+ //
271
+ // THE WORDING CARRIES THE WHOLE POINT, so it is not shortened to a pill. The
272
+ // ideator recommends and the board decides (spec decision 9) — a bare badge
273
+ // reading "goal" next to a ballot reads as a fact already settled, which is
274
+ // the one thing this value is not. The line says who said it and who decides.
275
+ //
276
+ // AN ABSENT RECOMMENDATION IS STATED, NOT HIDDEN. Every Full Idea filed before
277
+ // this question existed carries NULL, and so does a terse CLI filing; there is
278
+ // no backfill and no default, because inventing a scope opinion is putting
279
+ // words in an author's mouth. Rendering nothing would leave a voter unable to
280
+ // tell "recommended as a criterion" from "never asked".
281
+ const RECOMMENDATION_WORDS = {
282
+ goal: 'a goal of its own',
283
+ criterion: 'a criterion inside an existing goal',
284
+ };
285
+
286
+ function recommendationHtml(item) {
287
+ if (item.subject_type !== 'full_idea' || !item.subject) return '';
288
+ const rec = item.subject.recommendation;
289
+ // An unknown value can only mean the vocabulary widened after this page
290
+ // shipped. Print it rather than dropping it — the same honesty rule the
291
+ // unlabelled-section tail follows.
292
+ const words = rec ? (RECOMMENDATION_WORDS[rec] || rec) : null;
293
+ return words
294
+ ? `<p class="gov-item__rec">Its author recommends this becomes <strong>${escapeHtml(words)}</strong>. The board decides.</p>`
295
+ : '<p class="gov-item__rec gov-item__rec--none">Its author gave no scope recommendation.</p>';
296
+ }
297
+
268
298
  // ---- the sitting's rules, in plain words (task 1003270) --------------------
269
299
 
270
300
  // The card used to carry `tally: 0 yes · 0 no — sitting under <first_ratifier>,
@@ -412,6 +442,7 @@
412
442
  </div>
413
443
  ${amendmentDetail}
414
444
  ${action ? `<div class="gov-item__act">${action}</div>` : ''}
445
+ ${isOpen ? recommendationHtml(item) : ''}
415
446
  ${isOpen ? ideaSectionsHtml(item) : ''}
416
447
  <details class="gov-item__meta">
417
448
  <summary class="gov-item__summary">${tally} · who votes, and how it ${isOpen ? 'passes' : 'passed'}</summary>
@@ -71,6 +71,33 @@
71
71
 
72
72
  .fi-note { font-size: 13.5px; color: var(--ink-faint); }
73
73
 
74
+ /* ---- the scope recommendation (BV2.R13) ----------------------------------
75
+ A pick, so it is radios — the settings family's `.prow--pick` grammar, not a
76
+ toggle: a toggle says on/off and this says which of two. The fieldset draws
77
+ no box (a bordered group inside a composer panel is the card-in-a-card the
78
+ world removed) and the legend takes the label voice the five questions wear,
79
+ so the sixth question does not read as a different kind of thing.
80
+
81
+ The 24px floor is on the LABEL, not the 13px input: the whole option row is
82
+ the target, so the box stays the size it is drawn while the thing a finger
83
+ lands on is the sentence beside it. */
84
+ .fi-rec__set { margin: 0; padding: 0; border: 0; max-width: var(--measure); }
85
+ .fi-rec__set > .fi-label { margin-bottom: 0; padding: 0; }
86
+ .fi-rec__hint { margin-bottom: 6px; }
87
+ .fi-rec__opt {
88
+ display: flex;
89
+ align-items: center;
90
+ gap: 10px;
91
+ min-height: 24px;
92
+ padding: 3px 0;
93
+ font-size: 14px;
94
+ line-height: 1.4;
95
+ color: var(--ink);
96
+ cursor: pointer;
97
+ }
98
+ .fi-rec__opt input { flex: none; margin: 0; }
99
+ .fi-rec__opt input:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; }
100
+
74
101
  /* GRAVITY (task 1004083 / spec R02) — the consequence of the picker above it,
75
102
  so it sits tight under the select rather than at the form's own rhythm, and
76
103
  reads a step louder than .fi-hint: a hint describes a control, this states
@@ -158,6 +158,13 @@
158
158
  <p class="fi-note">Loading the five questions…</p>
159
159
  </div>
160
160
 
161
+ <!-- The scope recommendation (BV2.R13). Rendered from the same
162
+ GET /inbox/template read as the fields above, and for the same
163
+ reason: the two values are a closed vocabulary the database
164
+ enforces, so a hand-written pair of radios here would be a
165
+ second copy of it, free to drift. -->
166
+ <div id="fi-rec" class="fi-rec"></div>
167
+
161
168
  <p class="fi-err" id="fi-err" role="alert"></p>
162
169
 
163
170
  <div class="fi-actions">
@@ -119,6 +119,10 @@
119
119
  template_key: template.key,
120
120
  title: titleEl().value,
121
121
  field_values: collect(),
122
+ // BV2.R13: a half-written thought includes the scope its author has in
123
+ // mind for it; losing that on navigation is the same loss as losing a
124
+ // paragraph.
125
+ recommendation: collectRecommendation(),
122
126
  at: new Date().toISOString(),
123
127
  }));
124
128
  note('Draft saved in this browser.');
@@ -258,6 +262,8 @@
258
262
  }
259
263
  titleEl().addEventListener('input', onEdit);
260
264
 
265
+ renderRecommendation(draft);
266
+
261
267
  $('#fi-meter').hidden = false;
262
268
  renderMeterSkeleton();
263
269
  }
@@ -268,6 +274,83 @@
268
274
  return out;
269
275
  }
270
276
 
277
+ // ---------------------------------------------------------------------------
278
+ // the scope recommendation (task 1004092 / BV2.R13, spec R22)
279
+ // ---------------------------------------------------------------------------
280
+ //
281
+ // NOT A SIXTH TEXTAREA, and deliberately not collected into `field_values`:
282
+ // the five answers are prose the scorer measures, and this is a two-value
283
+ // choice nothing scores. It travels as its own body field, so the meter
284
+ // above never moves when it is picked.
285
+ //
286
+ // THE OPTIONS COME FROM THE SERVER, like the fields. The vocabulary is a
287
+ // database CHECK constraint; hard-coding the pair here would be the "second
288
+ // copy of the rules" defect this whole file is organised against, and would
289
+ // silently omit any value a later migration admits.
290
+ //
291
+ // REQUIRED HERE, OPTIONAL AT THE API — which is not a contradiction. The
292
+ // hall is where the question is actually put to a person, so filing from it
293
+ // without answering would be filing an unanswered question. The route stays
294
+ // permissive because hundreds of already-filed Full Ideas carry no
295
+ // recommendation and a terse CLI filing may carry none either; every reader
296
+ // therefore has to render the absence honestly regardless, and a required
297
+ // body field would 400 every already-shipped client.
298
+
299
+ function recSpec() {
300
+ return (template && template.recommendation) || null;
301
+ }
302
+
303
+ function renderRecommendation(draft) {
304
+ const host = $('#fi-rec');
305
+ if (!host) return;
306
+ const spec = recSpec();
307
+ // An instance whose template read predates this question simply has no
308
+ // control — the composer keeps working and files without one.
309
+ if (!spec || !Array.isArray(spec.options) || !spec.options.length) {
310
+ host.innerHTML = '';
311
+ return;
312
+ }
313
+ const picked = draft && typeof draft.recommendation === 'string' ? draft.recommendation : '';
314
+ host.innerHTML = `
315
+ <fieldset class="fi-rec__set">
316
+ <legend class="fi-label">${escapeHtml(spec.label || 'What do you think this is')}</legend>
317
+ ${spec.hint ? `<p class="fi-hint fi-rec__hint">${escapeHtml(spec.hint)}</p>` : ''}
318
+ ${spec.options.map((o) => `
319
+ <label class="fi-rec__opt">
320
+ <input type="radio" name="fi-rec-choice" value="${escapeHtml(o.value)}"${o.value === picked ? ' checked' : ''}>
321
+ <span>${escapeHtml(o.label || o.value)}</span>
322
+ </label>`).join('')}
323
+ </fieldset>`;
324
+ // onEdit, not a bespoke handler: picking an option has to save the draft
325
+ // and re-evaluate the file button exactly as typing does. It cannot change
326
+ // the score, and scoreNow is a no-op for it beyond one wasted call — which
327
+ // the debounce already collapses.
328
+ for (const el of host.querySelectorAll('input[name="fi-rec-choice"]')) {
329
+ el.addEventListener('change', onEdit);
330
+ }
331
+ }
332
+
333
+ function collectRecommendation() {
334
+ const host = $('#fi-rec');
335
+ const picked = host && host.querySelector('input[name="fi-rec-choice"]:checked');
336
+ return picked ? picked.value : '';
337
+ }
338
+
339
+ function clearRecommendation() {
340
+ const host = $('#fi-rec');
341
+ if (!host) return;
342
+ for (const el of host.querySelectorAll('input[name="fi-rec-choice"]')) el.checked = false;
343
+ }
344
+
345
+ // Whether the composer may file. Only true when the server actually offered
346
+ // the choice — an instance that serves no `recommendation` spec must not have
347
+ // its file button held hostage by a control that was never rendered.
348
+ function recommendationMissing() {
349
+ const spec = recSpec();
350
+ if (!spec || !Array.isArray(spec.options) || !spec.options.length) return false;
351
+ return !collectRecommendation();
352
+ }
353
+
271
354
  // ---------------------------------------------------------------------------
272
355
  // the meter — every number in it came from the server
273
356
  // ---------------------------------------------------------------------------
@@ -316,7 +399,13 @@
316
399
  // does NOT enforce the substance floor, because a thin answer is a scoring
317
400
  // problem, not a capture error). Matching that exactly means the button never
318
401
  // promises an outcome the server will refuse, and never blocks one it accepts.
319
- const missingRequired = template.fields.some((f) => f.required && !fieldEl(f.key).value.trim());
402
+ //
403
+ // THE SCOPE RECOMMENDATION IS THE ONE PLACE THIS BUTTON IS STRICTER THAN
404
+ // THE ROUTE (BV2.R13). The route accepts a Full Idea without one, because
405
+ // the historical rows have none; the hall does not, because the hall is
406
+ // where the question was actually asked. See renderRecommendation.
407
+ const missingRequired = template.fields.some((f) => f.required && !fieldEl(f.key).value.trim())
408
+ || recommendationMissing();
320
409
  // In develop mode the title is the existing idea's and is not editable, so it
321
410
  // cannot be a reason to keep the button off.
322
411
  $('#fi-submit').disabled = filing || missingRequired || (!DEVELOP_ID && !titleEl().value.trim());
@@ -342,7 +431,8 @@
342
431
  if (lastScore) renderScore(lastScore);
343
432
  else $('#fi-submit').disabled = filing
344
433
  || (!DEVELOP_ID && !titleEl().value.trim())
345
- || template.fields.some((f) => f.required && !fieldEl(f.key).value.trim());
434
+ || template.fields.some((f) => f.required && !fieldEl(f.key).value.trim())
435
+ || recommendationMissing();
346
436
  }
347
437
 
348
438
  // ---------------------------------------------------------------------------
@@ -386,10 +476,13 @@
386
476
  // The one behavioural fork between the two modes. Developing is an act on an
387
477
  // EXISTING idea, so it carries no title and no grade: the row already has
388
478
  // both, and R146 preserves them along with its author.
479
+ // BV2.R13: the recommendation travels on BOTH paths, and only when one was
480
+ // actually picked — sending '' would be asserting an opinion nobody gave.
481
+ const rec = collectRecommendation();
389
482
  const r = DEVELOP_ID
390
483
  ? await api.inbox.postInboxIdDevelop({
391
484
  id: DEVELOP_ID,
392
- body: { field_values: collect(), template_key: template.key },
485
+ body: { field_values: collect(), template_key: template.key, ...(rec ? { recommendation: rec } : {}) },
393
486
  })
394
487
  : await api.inbox.postInbox({
395
488
  body: {
@@ -397,6 +490,7 @@
397
490
  grade: 'full',
398
491
  template_key: template.key,
399
492
  field_values: collect(),
493
+ ...(rec ? { recommendation: rec } : {}),
400
494
  },
401
495
  });
402
496
 
@@ -429,6 +523,7 @@
429
523
 
430
524
  if (!DEVELOP_ID) titleEl().value = '';
431
525
  for (const f of template.fields) fieldEl(f.key).value = '';
526
+ clearRecommendation();
432
527
  lastScore = null;
433
528
  renderScore(null);
434
529
  note('');
@@ -445,6 +540,7 @@
445
540
  clearDraft();
446
541
  titleEl().value = '';
447
542
  for (const f of template.fields) fieldEl(f.key).value = '';
543
+ clearRecommendation();
448
544
  lastScore = null;
449
545
  renderScore(null);
450
546
  showErr('');
@@ -18,6 +18,7 @@ const { pool } = api;
18
18
  const {
19
19
  IDEA_COLS_Q, GRADE_COLS_Q,
20
20
  VALID_GRADES, DEFAULT_TEMPLATE_KEY, TEMPLATE_KEY_RE, TEMPLATE_KEY_MAX, FIELD_VALUES_MAX,
21
+ VALID_RECOMMENDATIONS, isValidRecommendation,
21
22
  } = require('./projection');
22
23
  const { skillFrictionHiddenClause } = require('./skill-friction');
23
24
  // developSpark reads the whole idea back to build the idea.graded.full payload.
@@ -121,7 +122,7 @@ async function countSparkQueue({ viewerRank = null } = {}, deps = {}) {
121
122
  // Structured throws (not raw constraint violations) so the routes that land on
122
123
  // top of this — R143's Full Idea capture, R144's mark-as-spark — return a clean
123
124
  // 400/404 rather than a 500 with a Postgres message in it.
124
- function assertGradePayload({ grade, templateKey, fieldValues, completenessScore }) {
125
+ function assertGradePayload({ grade, templateKey, fieldValues, completenessScore, recommendation = null }) {
125
126
  if (!VALID_GRADES.includes(grade)) {
126
127
  throw { code: 'BAD_GRADE', grade, valid: VALID_GRADES };
127
128
  }
@@ -141,27 +142,45 @@ function assertGradePayload({ grade, templateKey, fieldValues, completenessScore
141
142
  throw { code: 'BAD_COMPLETENESS', completeness_score: completenessScore };
142
143
  }
143
144
  }
145
+ // task 1004092 / BV2.R13. NULL is legal and permanent ("the author did not
146
+ // say"); anything outside the closed vocabulary is a structured throw here so
147
+ // the route returns a named 400 rather than letting
148
+ // ideas_grades_recommendation_chk surface as a 500.
149
+ if (!isValidRecommendation(recommendation)) {
150
+ throw { code: 'BAD_RECOMMENDATION', recommendation, valid: VALID_RECOMMENDATIONS };
151
+ }
144
152
  }
145
153
 
154
+ // THE RECOMMENDATION IS INSIDE THE WHOLE-ROW CONTRACT, NOT BESIDE IT (task
155
+ // 1004092 / BV2.R13). It defaults to null, so a caller that omits it CLEARS it
156
+ // — deliberately the same rule as field_values, and for the same reason: it is
157
+ // current content the author states, not a historical fact about who acted
158
+ // (which is why developed_by/developed_at sit outside the contract instead).
159
+ // The consequence the callers must honour: POST /inbox/:id/resubmit re-states
160
+ // the whole Full Idea, so it passes the STORED recommendation forward unless
161
+ // the body names a new one — otherwise answering a board objection would
162
+ // silently withdraw the scope the board was shown.
146
163
  async function setIdeaGrade(ideaId, {
147
164
  grade,
148
165
  templateKey = DEFAULT_TEMPLATE_KEY,
149
166
  fieldValues = {},
150
167
  completenessScore = null,
168
+ recommendation = null,
151
169
  } = {}) {
152
- assertGradePayload({ grade, templateKey, fieldValues, completenessScore });
170
+ assertGradePayload({ grade, templateKey, fieldValues, completenessScore, recommendation });
153
171
  try {
154
172
  const { rows } = await pool.query(
155
- `INSERT INTO ideas_grades (idea_id, grade, template_key, field_values, completeness_score)
156
- VALUES ($1, $2, $3, $4::jsonb, $5)
173
+ `INSERT INTO ideas_grades (idea_id, grade, template_key, field_values, completeness_score, recommendation)
174
+ VALUES ($1, $2, $3, $4::jsonb, $5, $6)
157
175
  ON CONFLICT (idea_id) DO UPDATE
158
176
  SET grade = EXCLUDED.grade,
159
177
  template_key = EXCLUDED.template_key,
160
178
  field_values = EXCLUDED.field_values,
161
- completeness_score = EXCLUDED.completeness_score
179
+ completeness_score = EXCLUDED.completeness_score,
180
+ recommendation = EXCLUDED.recommendation
162
181
  RETURNING idea_id, grade, template_key, field_values, completeness_score,
163
- created_at, updated_at`,
164
- [ideaId, grade, templateKey, JSON.stringify(fieldValues), completenessScore]
182
+ recommendation, created_at, updated_at`,
183
+ [ideaId, grade, templateKey, JSON.stringify(fieldValues), completenessScore, recommendation]
165
184
  );
166
185
  return rows[0];
167
186
  } catch (err) {
@@ -185,7 +204,7 @@ async function setIdeaCompleteness(ideaId, completenessScore) {
185
204
  SET completeness_score = $2
186
205
  WHERE idea_id = $1
187
206
  RETURNING idea_id, grade, template_key, field_values, completeness_score,
188
- created_at, updated_at`,
207
+ recommendation, created_at, updated_at`,
189
208
  [ideaId, completenessScore]
190
209
  );
191
210
  return rows[0] ?? null;
@@ -237,11 +256,11 @@ async function setIdeaCompleteness(ideaId, completenessScore) {
237
256
  // work, not current content, and setIdeaGrade's ON CONFLICT SET list omits both
238
257
  // columns — so a later re-score or an author's R13 resubmission preserves the
239
258
  // developer's attribution instead of silently erasing it.
240
- async function developSpark(ideaId, { developedBy, templateKey = DEFAULT_TEMPLATE_KEY, fieldValues = {}, completenessScore = null } = {}) {
259
+ async function developSpark(ideaId, { developedBy, templateKey = DEFAULT_TEMPLATE_KEY, fieldValues = {}, completenessScore = null, recommendation = null } = {}) {
241
260
  // Reuse the R140 payload validator so a bad template key or an oversize
242
261
  // field_values map is a structured throw here rather than a constraint
243
262
  // violation surfacing as a 500 (the assertGradePayload contract).
244
- assertGradePayload({ grade: 'full', templateKey, fieldValues, completenessScore });
263
+ assertGradePayload({ grade: 'full', templateKey, fieldValues, completenessScore, recommendation });
245
264
  if (developedBy == null) throw { code: 'MISSING_DEVELOPER' };
246
265
  const { rows } = await pool.query(
247
266
  `UPDATE ideas_grades
@@ -250,15 +269,21 @@ async function developSpark(ideaId, { developedBy, templateKey = DEFAULT_TEMPLAT
250
269
  field_values = $3::jsonb,
251
270
  completeness_score = $4,
252
271
  developed_by = $5,
253
- developed_at = now()
272
+ developed_at = now(),
273
+ -- task 1004092 / BV2.R13: developing a spark IS filing the Full
274
+ -- Idea, so the developer states its scope recommendation here. A
275
+ -- spark has none to inherit (the question belongs to the full
276
+ -- grade), so an omitted value writing NULL is the honest outcome,
277
+ -- not a loss.
278
+ recommendation = $6
254
279
  WHERE idea_id = $1
255
280
  AND grade = 'spark'
256
281
  AND EXISTS (
257
282
  SELECT 1 FROM idea_inbox i WHERE i.id = $1 AND i.status = 'open'
258
283
  )
259
284
  RETURNING idea_id, grade, template_key, field_values, completeness_score,
260
- developed_by, developed_at, created_at, updated_at`,
261
- [ideaId, templateKey, JSON.stringify(fieldValues), completenessScore, developedBy]
285
+ developed_by, developed_at, recommendation, created_at, updated_at`,
286
+ [ideaId, templateKey, JSON.stringify(fieldValues), completenessScore, developedBy, recommendation]
262
287
  );
263
288
  // null = "this row was not a developable spark when we looked". THREE ways to
264
289
  // get here, and the caller re-reads to say which: it was never a spark,
@@ -288,6 +313,10 @@ async function developSpark(ideaId, { developedBy, templateKey = DEFAULT_TEMPLAT
288
313
  grade: 'full',
289
314
  completeness_score: graded.completeness_score,
290
315
  developed_by: graded.developed_by,
316
+ // Stated from the write's own RETURNING rather than left to the re-read
317
+ // above, for the same reason completeness_score is: the listener acts on
318
+ // what this transition actually wrote.
319
+ recommendation: graded.recommendation ?? null,
291
320
  });
292
321
  } catch { /* a listener, or the read that feeds it, must never cost the ideator their grade */ }
293
322
  }
@@ -299,7 +328,7 @@ async function developSpark(ideaId, { developedBy, templateKey = DEFAULT_TEMPLAT
299
328
  async function getIdeaGrade(ideaId) {
300
329
  const { rows } = await pool.query(
301
330
  `SELECT idea_id, grade, template_key, field_values, completeness_score,
302
- created_at, updated_at
331
+ recommendation, created_at, updated_at
303
332
  FROM ideas_grades
304
333
  WHERE idea_id = $1`,
305
334
  [ideaId]
@@ -31,6 +31,7 @@ const ideasConfig = require('./config');
31
31
  const {
32
32
  IDEA_COLS, IDEA_COLS_Q, GRADE_COLS_Q, IDEA_JOIN_GRADE, ungradedProjection,
33
33
  DEFAULT_GRADE, VALID_GRADES, DEFAULT_TEMPLATE_KEY, TEMPLATE_KEY_MAX, FIELD_VALUES_MAX,
34
+ VALID_RECOMMENDATIONS, isValidRecommendation, normalizeRecommendation,
34
35
  } = require('./projection');
35
36
  // The carved domains. Each is re-exported at the bottom of this file, so every
36
37
  // existing caller of `require('./inbox')` keeps working unchanged — the same
@@ -796,6 +797,12 @@ module.exports = {
796
797
  DEFAULT_TEMPLATE_KEY,
797
798
  TEMPLATE_KEY_MAX,
798
799
  FIELD_VALUES_MAX,
800
+ // the recommendation vocabulary (task 1004092 / BV2.R13) — goal | criterion,
801
+ // re-exported so the routes and the composer's template read declare it from
802
+ // the same place the SQL CHECK mirrors
803
+ VALID_RECOMMENDATIONS,
804
+ isValidRecommendation,
805
+ normalizeRecommendation,
799
806
  // skill-friction marker + rank visibility gate (V3.R70 / #289; the kind
800
807
  // itself landed in task 1003105 — both markers are live, see above)
801
808
  SKILL_FRICTION_TAG,
@@ -841,4 +848,15 @@ module.exports = {
841
848
  parseProposalBody: ratify.parseProposalBody,
842
849
  KARMA_RATIFY_DELTA: ratify.KARMA_RATIFY_DELTA,
843
850
  KARMA_RATIFY_REASON: ratify.KARMA_RATIFY_REASON,
851
+ // Full-Idea → GOAL ratification, the goal half beside it (task 1004096 /
852
+ // BV2.R17); see ratify.js on why the two halves are not symmetrical.
853
+ //
854
+ // ONLY THE TRANSACTION IS RE-EXPORTED. `composeGoalFromFullIdea` and
855
+ // `GOAL_SHAPE_BY_TEMPLATE` are deliberately NOT on the facade: nothing outside
856
+ // ratify.js calls either, and a re-export no caller needs is an unused export
857
+ // the dead-code ratchet counts. The facade's job is to keep the ~20 callers of
858
+ // the pre-carve surface working, not to publish everything a new file declares
859
+ // — anything that wants the pure composer requires ./ratify directly, as the
860
+ // test does.
861
+ ratifyFullIdeaAsGoal: ratify.ratifyFullIdeaAsGoal,
844
862
  };