@bongos/core 1.19.934 → 1.19.936

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.
@@ -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,
@@ -0,0 +1,62 @@
1
+ -- ideas_009_recommendation.sql — a Full Idea carries a RECOMMENDATION: goal or
2
+ -- criterion (task 1004092 / BV2.R13, spec R22 in
3
+ -- docs/specs/bongos-v2-ideator-surface.md; spec decision 9).
4
+ --
5
+ -- WHAT THIS IS, AND WHAT IT IS NOT. It is the ideator's answer to "how big did
6
+ -- this turn out to be" — a goal (a gravity well of its own) or a criterion (a
7
+ -- ring inside an existing one). It is NOT a decision. The owner's recorded
8
+ -- reasoning: scope is the thing you learn last, so the person who had the
9
+ -- thought writes down what they think it is and the board, reading it, decides.
10
+ -- Nothing in the system may branch on this column as if it were an outcome —
11
+ -- the ratification half (BV2.R17) reads what the BOARD chose, and this column
12
+ -- only travels alongside it so voters can see what was proposed.
13
+ --
14
+ -- WHY A COLUMN AND NOT A SIXTH TEMPLATE FIELD. The five questions are prose an
15
+ -- author writes and the completeness scorer measures; this is a closed
16
+ -- two-value vocabulary that nothing scores. Putting it in `field_values` would
17
+ -- make it a scored field (`completeness.js` walks every declared field), would
18
+ -- change what a Full Idea costs to file, and would have to be declared once per
19
+ -- vintage — while the recommendation is the same question under any template.
20
+ -- It is a property OF the grade row, beside `completeness_score`, not one of
21
+ -- the answers inside it.
22
+ --
23
+ -- NULLABLE, AND THAT IS PERMANENT. Every Full Idea filed before today has no
24
+ -- recommendation, and inventing one for them would put words in an author's
25
+ -- mouth — the never-invent-a-field rule (the Ideator pack) is exactly about
26
+ -- this. So NULL means "the author did not say", every reader must render that
27
+ -- honestly, and there is no backfill. The hall composer requires a choice
28
+ -- going forward (that is where the question is actually asked); the API keeps
29
+ -- it optional, because a reader that can handle NULL for the historical rows
30
+ -- can handle NULL for a terse CLI filing too, and a required body field would
31
+ -- 400 every already-shipped client against an upgraded instance.
32
+ --
33
+ -- Text + CHECK rather than a Postgres enum TYPE, matching
34
+ -- `ideas_grades_grade_chk` one column over: the vocabulary is pinned closed
35
+ -- today (an unknown recommendation is refused, never silently stored) and
36
+ -- widening it later is a one-line constraint change in a subsequent ideas_*
37
+ -- migration rather than an enum-type migration.
38
+ --
39
+ -- Module migrations are ADDITIVE and NAMESPACED (ADR 0083 §Decision #5): this
40
+ -- touches only `ideas_grades`, a table this module owns. There is no
41
+ -- down-migration. Idempotent: safe to re-run.
42
+
43
+ BEGIN;
44
+
45
+ ALTER TABLE ideas_grades
46
+ ADD COLUMN IF NOT EXISTS recommendation text;
47
+
48
+ -- Added separately and guarded, because ADD COLUMN IF NOT EXISTS skips the
49
+ -- whole clause on a re-run — an inline CONSTRAINT would then never be created
50
+ -- on an instance where the column already landed without it.
51
+ DO $$
52
+ BEGIN
53
+ IF NOT EXISTS (
54
+ SELECT 1 FROM pg_constraint WHERE conname = 'ideas_grades_recommendation_chk'
55
+ ) THEN
56
+ ALTER TABLE ideas_grades
57
+ ADD CONSTRAINT ideas_grades_recommendation_chk
58
+ CHECK (recommendation IS NULL OR recommendation IN ('goal', 'criterion'));
59
+ END IF;
60
+ END $$;
61
+
62
+ COMMIT;
@@ -80,6 +80,40 @@ const TEMPLATE_KEY_MAX = 64;
80
80
  // payload gets a structured 400 instead of a constraint-violation 500.
81
81
  const FIELD_VALUES_MAX = 32768;
82
82
 
83
+ // -------------------------------------------------------------------------
84
+ // The recommendation (task 1004092 / BV2.R13, spec R22)
85
+ //
86
+ // What the author thinks their Full Idea turned out to be: a goal (a gravity
87
+ // well of its own) or a criterion (a ring inside an existing one). A CLOSED
88
+ // vocabulary, mirroring ideas_grades_recommendation_chk so an unknown value is
89
+ // a named 400 at the route rather than a constraint-violation 500 in the
90
+ // driver.
91
+ //
92
+ // NULL IS A FIRST-CLASS VALUE AND ALWAYS WILL BE — "the author did not say".
93
+ // Every Full Idea filed before this shipped has no recommendation, and there is
94
+ // no backfill, because inventing one would put a scope opinion in someone's
95
+ // mouth. Every reader must render the absence honestly; nothing may substitute
96
+ // a default.
97
+ //
98
+ // IT IS A RECOMMENDATION, NEVER A DECISION (spec decision 9). The ideator
99
+ // recommends; the board decides. Nothing may branch on this column as if it
100
+ // were the outcome — BV2.R17's ratification reads what the board chose.
101
+ const VALID_RECOMMENDATIONS = ['goal', 'criterion'];
102
+
103
+ // The one predicate. `undefined` and `null` both mean "not given" and are
104
+ // legal; anything else must be a declared member. Pure, so a route can refuse
105
+ // before any write and a test can exercise it with no harness.
106
+ function isValidRecommendation(v) {
107
+ return v === null || v === undefined || VALID_RECOMMENDATIONS.includes(v);
108
+ }
109
+
110
+ // Normalise a request-supplied value to what the column stores. Written once
111
+ // because three routes (capture, develop, resubmit) all take the same field and
112
+ // all have to turn "absent" into NULL rather than into the string 'undefined'.
113
+ function normalizeRecommendation(v) {
114
+ return v === undefined || v === '' ? null : v;
115
+ }
116
+
83
117
  // The grade half of an idea read: the COALESCE above, plus the template-shaped
84
118
  // store and the score. `g.*` columns are NULL for an ungraded idea, which is
85
119
  // exactly right — it has no template and no score, it just has a default grade.
@@ -89,16 +123,22 @@ const FIELD_VALUES_MAX = 32768;
89
123
  // hall detail view (R153) and the R147 credit split all want it. NULL for the
90
124
  // overwhelming majority of rows — a quick idea and a self-authored Full Idea were
91
125
  // never developed by anyone — which is the honest answer, not a missing one.
126
+ //
127
+ // `recommendation` (ideas_009, task 1004092 / BV2.R13) rides along for the same
128
+ // reason developed_by does: it is part of a Full Idea's identity from filing
129
+ // onwards, and the board item, the detail read and the author's own trail all
130
+ // want it without a second query. NULL for every quick idea and for every Full
131
+ // Idea filed before the question existed — the honest answer, not a missing one.
92
132
  const GRADE_COLS_Q = `COALESCE(g.grade, '${DEFAULT_GRADE}') AS grade,
93
133
  g.template_key, g.field_values, g.completeness_score,
94
- g.developed_by, g.developed_at`;
134
+ g.developed_by, g.developed_at, g.recommendation`;
95
135
  const IDEA_JOIN_GRADE = `LEFT JOIN ideas_grades g ON g.idea_id = i.id`;
96
136
 
97
137
  // The same projection for a row that provably has no grade row yet (a just-
98
138
  // INSERTed idea), so INSERT…RETURNING does not need a second round trip to
99
139
  // return the same shape every other read returns.
100
140
  function ungradedProjection() {
101
- return { grade: DEFAULT_GRADE, template_key: null, field_values: null, completeness_score: null, developed_by: null, developed_at: null };
141
+ return { grade: DEFAULT_GRADE, template_key: null, field_values: null, completeness_score: null, developed_by: null, developed_at: null, recommendation: null };
102
142
  }
103
143
 
104
144
  module.exports = {
@@ -110,6 +150,9 @@ module.exports = {
110
150
  TEMPLATE_KEY_RE,
111
151
  TEMPLATE_KEY_MAX,
112
152
  FIELD_VALUES_MAX,
153
+ VALID_RECOMMENDATIONS,
154
+ isValidRecommendation,
155
+ normalizeRecommendation,
113
156
  GRADE_COLS_Q,
114
157
  IDEA_JOIN_GRADE,
115
158
  ungradedProjection,
@@ -122,6 +122,26 @@ function boardWindowPayload(item) {
122
122
  };
123
123
  }
124
124
 
125
+ // THE RECOMMENDATION A FULL IDEA CARRIES (task 1004092 / BV2.R13, spec R22).
126
+ // 'goal' or 'criterion' — the author's reading of how big the thought turned
127
+ // out, carried from filing to the board item so voters see what was proposed.
128
+ // IT IS NOT A DECISION (spec decision 9): the board decides, and no branch in
129
+ // this module may treat it as the outcome.
130
+ //
131
+ // Spelled once because all three filing paths take the same field — capture,
132
+ // develop and resubmit — and a second copy of the refusal is a second place for
133
+ // the vocabulary to drift from ideas_grades_recommendation_chk. Returns true
134
+ // when it has already answered the request.
135
+ function refuseBadRecommendation(res, raw) {
136
+ if (inbox.isValidRecommendation(raw)) return false;
137
+ res.fail('bad_recommendation', 400, {
138
+ recommendation: raw,
139
+ allowed: inbox.VALID_RECOMMENDATIONS,
140
+ reason: 'a Full Idea recommends a goal or a criterion; the board decides which it becomes.',
141
+ });
142
+ return true;
143
+ }
144
+
125
145
  module.exports = function buildInboxRouter() {
126
146
  registerSeams();
127
147
  const router = express.Router();
@@ -152,6 +172,10 @@ module.exports = function buildInboxRouter() {
152
172
  grade: { type: 'string', maxLength: 16 },
153
173
  template_key: { type: 'string', maxLength: inbox.TEMPLATE_KEY_MAX },
154
174
  field_values: { type: 'object' },
175
+ // task 1004092 / BV2.R13 — the author's own reading of how big this
176
+ // turned out: 'goal' or 'criterion'. OPTIONAL, permanently (see the
177
+ // refusal below and ideas_009's header).
178
+ recommendation: { type: 'string', maxLength: 16 },
155
179
  })) return;
156
180
  const body = req.body || {};
157
181
  if (body.kind === 'criterion-proposal') {
@@ -180,6 +204,18 @@ module.exports = function buildInboxRouter() {
180
204
  });
181
205
  }
182
206
  const templateKey = body.template_key ?? undefined;
207
+ // task 1004092 / BV2.R13. Checked BEFORE any write, like the field values
208
+ // below — and REFUSED on a non-full capture rather than dropped, because a
209
+ // quick idea writes no grade row at all, so silently accepting one would
210
+ // return 201 for a recommendation that was never stored anywhere.
211
+ if (refuseBadRecommendation(res, body.recommendation)) return;
212
+ if (body.recommendation !== undefined && grade !== 'full') {
213
+ return res.fail('recommendation_needs_full_grade', 400, {
214
+ grade: grade ?? 'quick',
215
+ reason: 'only a Full Idea recommends a scope — a quick idea has no grade row to carry one.',
216
+ });
217
+ }
218
+ const recommendation = inbox.normalizeRecommendation(body.recommendation);
183
219
  if (grade === 'full') {
184
220
  // Validate BEFORE any write, so a bad payload is a named 400 and no
185
221
  // orphan idea lands. Each error carries { field, reason } — the
@@ -236,11 +272,13 @@ module.exports = function buildInboxRouter() {
236
272
  ...(templateKey ? { templateKey } : {}),
237
273
  fieldValues: body.field_values,
238
274
  completenessScore: completenessScore(body.field_values, templateKey ? { templateKey } : undefined),
275
+ recommendation,
239
276
  });
240
277
  idea.grade = row.grade;
241
278
  idea.template_key = row.template_key;
242
279
  idea.field_values = row.field_values;
243
280
  idea.completeness_score = row.completeness_score;
281
+ idea.recommendation = row.recommendation ?? null;
244
282
 
245
283
  // Stream 2 (ADR 0172 / IC2): a Full Idea that clears the completeness
246
284
  // bar pays its author, once, on the IDEATOR credit lane — a separate
@@ -502,6 +540,22 @@ module.exports = function buildInboxRouter() {
502
540
  substance_min: f.substanceMin || 0,
503
541
  };
504
542
  }),
543
+ // task 1004092 / BV2.R13. The recommendation is NOT a sixth field — it
544
+ // is not prose, it is not scored, and it is the same closed question
545
+ // under every vintage — so it rides beside `fields` rather than in it.
546
+ // Served from here for the same reason the fields are: a hard-coded
547
+ // radio pair in the browser is the "second copy of the rules" defect
548
+ // one step earlier, and it would offer a vocabulary the CHECK
549
+ // constraint could already have widened past.
550
+ recommendation: {
551
+ label: 'What do you think this is',
552
+ hint: 'Your reading of how big it turned out. The board decides — this is what they read while deciding.',
553
+ required: false,
554
+ options: inbox.VALID_RECOMMENDATIONS.map((value) => ({
555
+ value,
556
+ label: value === 'goal' ? 'A goal of its own' : 'A criterion inside an existing goal',
557
+ })),
558
+ },
505
559
  },
506
560
  });
507
561
  }, { errorCode: 'template_read_failed' }));
@@ -748,7 +802,9 @@ module.exports = function buildInboxRouter() {
748
802
  if (validateOrRespond(req, res, {
749
803
  field_values: { required: true, type: 'object' },
750
804
  template_key: { type: 'string', maxLength: 64 },
805
+ recommendation: { type: 'string', maxLength: 16 },
751
806
  })) return;
807
+ if (refuseBadRecommendation(res, req.body.recommendation)) return;
752
808
  try {
753
809
  const idea = await inbox.getIdea(id);
754
810
  if (!idea) return res.fail('idea_not_found', 404);
@@ -798,8 +854,15 @@ module.exports = function buildInboxRouter() {
798
854
  ...(templateKey ? { templateKey } : {}),
799
855
  fieldValues: req.body.field_values,
800
856
  completenessScore: completenessScore(req.body.field_values, templateKey ? { templateKey } : undefined),
857
+ // task 1004092 / BV2.R13. CARRIED FORWARD FROM THE STORED ROW when the
858
+ // body names none — the same `?? idea.…` shape template_key uses two
859
+ // lines up, and for a sharper reason. setIdeaGrade writes the WHOLE
860
+ // row, so defaulting to null here would mean that answering a board
861
+ // objection silently withdrew the scope recommendation the board had
862
+ // been shown, on a request that never mentioned it.
863
+ recommendation: inbox.normalizeRecommendation(req.body.recommendation ?? idea.recommendation ?? undefined),
801
864
  });
802
- const out = { ...idea, grade: row.grade, template_key: row.template_key, field_values: row.field_values, completeness_score: row.completeness_score };
865
+ const out = { ...idea, grade: row.grade, template_key: row.template_key, field_values: row.field_values, completeness_score: row.completeness_score, recommendation: row.recommendation ?? null };
803
866
 
804
867
  try {
805
868
  const reward = api.resolveOptional && api.resolveOptional('reward');
@@ -995,7 +1058,9 @@ module.exports = function buildInboxRouter() {
995
1058
  if (validateOrRespond(req, res, {
996
1059
  field_values: { required: true, type: 'object' },
997
1060
  template_key: { type: 'string', maxLength: 64 },
1061
+ recommendation: { type: 'string', maxLength: 16 },
998
1062
  })) return;
1063
+ if (refuseBadRecommendation(res, req.body.recommendation)) return;
999
1064
 
1000
1065
  const idea = await inbox.getIdea(id);
1001
1066
  if (!idea) return res.fail('idea_not_found', 404);
@@ -1040,6 +1105,11 @@ module.exports = function buildInboxRouter() {
1040
1105
  ...(templateKey ? { templateKey } : {}),
1041
1106
  fieldValues: req.body.field_values,
1042
1107
  completenessScore: completenessScore(req.body.field_values, templateKey ? { templateKey } : undefined),
1108
+ // task 1004092 / BV2.R13. No `?? idea.recommendation` fallback here, and
1109
+ // that is not an omission: a spark has no recommendation to inherit (the
1110
+ // question belongs to the full grade), so the developer is the first
1111
+ // person ever asked it on this row.
1112
+ recommendation: inbox.normalizeRecommendation(req.body.recommendation),
1043
1113
  });
1044
1114
  // Zero rows means the guard fired: somebody else developed this spark between
1045
1115
  // the read above and this write. Their work stands; this caller is told so
@@ -1070,6 +1140,7 @@ module.exports = function buildInboxRouter() {
1070
1140
  completeness_score: row.completeness_score,
1071
1141
  developed_by: row.developed_by,
1072
1142
  developed_at: row.developed_at,
1143
+ recommendation: row.recommendation ?? null,
1073
1144
  };
1074
1145
 
1075
1146
  // Fail-open, exactly like the capture and resubmit paths: the development has
@@ -82,20 +82,48 @@ const BASE_V1_TEMPLATE = Object.freeze({
82
82
  // the v1 label. `purpose`, `big_picture` and `why_matters` are NOT carried over —
83
83
  // they belong to v1 and stay there.
84
84
  //
85
- // THE FLOORS ARE FLAT 40, MATCHING v1, AND THAT IS TEMPORARY. The new questions
86
- // are not interchangeable ("scope it as best as you can" deserves more room than
87
- // "what made you have the idea"), but the score feeds credits, so choosing the
88
- // numbers is its own deliberate act — task 1004091 (BV2.R12) owns it. Shipping a
89
- // guess here would have been an unreviewed change to what a thought pays.
85
+ // THE FLOORS, CHOSEN PER FIELD (task 1004091 / BV2.R12, spec R21). v2 shipped at
86
+ // a placeholder flat 40 because picking the numbers is its own deliberate act:
87
+ // `completeness_score` is the mean of per-field ratios, and that number gates
88
+ // both the ideator credit (economy's IDEA_FULL_PASS_BAR = 60) and whether a Full
89
+ // Idea reaches the board at all (government's FULL_IDEA_PASS_BAR). A floor is not
90
+ // a style note; it is a price.
91
+ //
92
+ // THE TOTAL IS HELD AT 200, EXACTLY v1's FIVE × 40. What a thought costs to file
93
+ // is an economic decision the owner has already made once; this task was asked to
94
+ // distribute that cost across questions that are not interchangeable, not to
95
+ // re-price filing. Redistributing inside a fixed total is the change that needs no
96
+ // owner ruling. (It also keeps the template clear of ADR 0186's interim karma-
97
+ // eligibility guard — ≥3 required fields and ≥120 total `substanceMin`.)
98
+ //
99
+ // WHY EACH NUMBER, IN ONE LINE EACH. The rule is how much text the question
100
+ // HONESTLY takes, never how much we would like to receive:
101
+ // why_had 25 — the anti-ghost-writing field, and the shortest honest
102
+ // answer is a sentence. Nobody but the filer can say what
103
+ // made them have the idea, so this is precisely the floor
104
+ // that must NOT push an agent to pad; the never-invent-a-
105
+ // field rule (the Ideator pack) is strongest here.
106
+ // scope 65 — "as best as you can" is an invitation to the longest
107
+ // answer in the set, and this field is the one that becomes
108
+ // the work. The highest floor in the template.
109
+ // build_context 45 — a prior-knowledge dump: real but bounded, and legitimately
110
+ // short when there genuinely is little to know.
111
+ // fit 30 — an argument, and a good one is compact. Deliberately low:
112
+ // a long justification is usually a weak one.
113
+ // success 35 — a concrete picture of the finished thing; a few sentences.
114
+ //
115
+ // EACH RATIO IS CAPPED AT 1, SO THE TOTAL IS NOT A BUDGET. A 600-character scope
116
+ // answer cannot buy a blank `why_had`; the floors bind field by field, which is
117
+ // the whole reason they differ.
90
118
  const BASE_V2_TEMPLATE = Object.freeze({
91
119
  key: 'base-v2',
92
120
  label: 'Full Idea',
93
121
  fields: Object.freeze([
94
- Object.freeze({ key: 'why_had', label: 'What made you have the idea', required: true, substanceMin: 40 }),
95
- Object.freeze({ key: 'scope', label: 'Scope it as best as you can here', required: true, substanceMin: 40 }),
96
- Object.freeze({ key: 'build_context', label: 'What should an engineer or artist know prior to building', required: true, substanceMin: 40 }),
97
- Object.freeze({ key: 'fit', label: 'How is this long term justified in the project', required: true, substanceMin: 40 }),
98
- Object.freeze({ key: 'success', label: 'What does a successful implementation of the idea look like', required: true, substanceMin: 40 }),
122
+ Object.freeze({ key: 'why_had', label: 'What made you have the idea', required: true, substanceMin: 25 }),
123
+ Object.freeze({ key: 'scope', label: 'Scope it as best as you can here', required: true, substanceMin: 65 }),
124
+ Object.freeze({ key: 'build_context', label: 'What should an engineer or artist know prior to building', required: true, substanceMin: 45 }),
125
+ Object.freeze({ key: 'fit', label: 'How is this long term justified in the project', required: true, substanceMin: 30 }),
126
+ Object.freeze({ key: 'success', label: 'What does a successful implementation of the idea look like', required: true, substanceMin: 35 }),
99
127
  ]),
100
128
  });
101
129
 
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.934",
3
+ "version": "1.19.936",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.934",
9
+ "version": "1.19.936",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.934",
3
+ "version": "1.19.936",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.934'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.936'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -69,7 +69,11 @@ test('the assembled view: enrichment, the open ballot, the snapshot, the databas
69
69
 
70
70
  const idea = view.open[0];
71
71
  assert.deepEqual(idea.subject,
72
- { title: 'A big idea', author_id: '9', field_values: { purpose: 'p' }, template_key: 'base-v1', completeness_score: 82 },
72
+ // `recommendation: null` is the stored row's own answer, not a filler: this
73
+ // stub idea has no recommendation column set, and BV2.R13's whole posture is
74
+ // that "the author did not say" must reach the room as itself rather than
75
+ // being dropped or defaulted to one of the two values.
76
+ { title: 'A big idea', author_id: '9', field_values: { purpose: 'p' }, template_key: 'base-v1', completeness_score: 82, recommendation: null },
73
77
  'an idea shows its title, author, AND its five sections — the voter decides with the idea in front of them (R17)');
74
78
  assert.equal(idea.remaining_seconds, 540, 'the DATABASE clock, passed through — the client never does clock math');
75
79
  assert.deepEqual(idea.constitution, MONARCHY, 'the SNAPSHOT rides along — the room describes the rule the item runs under');
@@ -40,6 +40,11 @@ const COMPLETE = Object.fromEntries(
40
40
  // the current template's keys is quietly asserting the template never changes.
41
41
  const [FIELD_1, FIELD_2, , FIELD_4] = BASE_TEMPLATE.fields.map((f) => f.key);
42
42
 
43
+ // Since task 1004091 (BV2.R12) the floors DIFFER per field, so a fixture may no
44
+ // longer assume "40" — or, worse, assume that equal text in two sections is equal
45
+ // credit. Every expectation below is derived from the declaration.
46
+ const floorOf = (key) => BASE_TEMPLATE.fields.find((f) => f.key === key).substanceMin;
47
+
43
48
  test('a complete idea scores 100, an empty one 0', () => {
44
49
  assert.equal(C.completenessScore(COMPLETE), 100);
45
50
  assert.equal(C.completenessScore({}), 0);
@@ -51,13 +56,25 @@ test('same input, same score — byte-for-byte determinism', () => {
51
56
  assert.deepEqual(C.scoreFieldValues(COMPLETE), C.scoreFieldValues(COMPLETE));
52
57
  });
53
58
 
54
- test('a thin answer earns its proportion, not the full share', () => {
55
- const oneThin = { ...COMPLETE, [FIELD_1]: 'ten chars.' }; // 10 of the 40-char floor
59
+ test('a thin answer earns its proportion of ITS OWN floor, not a flat share', () => {
60
+ const TEN = 'ten chars.'; // exactly 10 characters of substance
61
+ const oneThin = { ...COMPLETE, [FIELD_1]: TEN };
56
62
  const s = C.completenessScore(oneThin);
57
- assert.ok(s > 80 && s < 100, `four full + one quarter-credit field ≈ 85, got ${s}`);
63
+ assert.ok(s > 80 && s < 100, `four full fields + one part-credit field, got ${s}`);
58
64
  const breakdown = C.scoreFieldValues(oneThin).fields.find((f) => f.key === FIELD_1);
59
65
  assert.equal(breakdown.substance, 10);
60
- assert.equal(breakdown.ratio, 0.25);
66
+ assert.equal(breakdown.floor, floorOf(FIELD_1), 'the breakdown publishes the field\'s own floor');
67
+ assert.equal(breakdown.ratio, 10 / floorOf(FIELD_1));
68
+
69
+ // BV2.R12's actual deliverable: the same ten characters are worth DIFFERENT
70
+ // credit in different sections, because the questions are not interchangeable.
71
+ // A test that only ever looked at one field would pass just as happily against
72
+ // the flat-40 placeholder this task replaced.
73
+ const other = C.scoreFieldValues({ ...COMPLETE, [FIELD_2]: TEN })
74
+ .fields.find((f) => f.key === FIELD_2);
75
+ assert.equal(other.ratio, 10 / floorOf(FIELD_2));
76
+ assert.notEqual(other.ratio, breakdown.ratio,
77
+ 'identical text scores lower where the question asks for more');
61
78
  });
62
79
 
63
80
  test('padding earns exactly nothing: whitespace and blank lines are not substance', () => {
@@ -111,7 +128,16 @@ test('the scorer walks the declaration — a different template scores by ITS fi
111
128
  // ── the sub-bar rejection payload (BV1.R09 of goal 1000069, criterion C2) ───
112
129
 
113
130
  test('the weakest section is the LOWEST ratio, with ties going to template order', () => {
114
- const values = { ...COMPLETE, [FIELD_2]: 'thin', [FIELD_4]: 'thin' }; // two equally thin
131
+ // A GENUINE tie takes different amounts of text in each field now that the
132
+ // floors differ (BV2.R12): two-fifths of each field's OWN floor is the same
133
+ // ratio, where two identical strings would not be. The fixture asserts it is
134
+ // really a tie before asserting how the tie breaks — otherwise this case
135
+ // quietly stops testing tie-breaking at all.
136
+ const twoFifths = (key) => 'x'.repeat(Math.round(floorOf(key) * 0.4));
137
+ const values = { ...COMPLETE, [FIELD_2]: twoFifths(FIELD_2), [FIELD_4]: twoFifths(FIELD_4) };
138
+ const broken = C.scoreFieldValues(values).fields;
139
+ assert.equal(broken.find((f) => f.key === FIELD_2).ratio, broken.find((f) => f.key === FIELD_4).ratio,
140
+ 'the fixture really is a tie');
115
141
  const worst = C.weakestField(values);
116
142
  assert.equal(worst.key, FIELD_2, 'the earlier of two equal-thinnest fields, by template order');
117
143
  // strictly-thinnest wins regardless of position
@@ -145,7 +145,12 @@ await test('a DISTINCT developer is recorded, and both parties are named in the
145
145
  assert.equal(r.payload.developer_id, '42');
146
146
  assert.equal(r.payload.author_id, '9');
147
147
  assert.equal(r.payload.self_developed, false);
148
- assert.equal(updateQ().params.at(-1), '42', 'the developer is the AUTHENTICATED builder, not a body field');
148
+ // The developer is $5 in developSpark's UPDATE. Pinned by INDEX, not by
149
+ // `.at(-1)`: task 1004092 appended `recommendation` as $6, and a pin that
150
+ // meant "the developer" while reading "the last parameter" then failed for a
151
+ // reason that had nothing to do with what it was asserting.
152
+ assert.equal(updateQ().params[4], '42', 'the developer is the AUTHENTICATED builder, not a body field');
153
+ assert.equal(updateQ().params.length, 6, 'developSpark binds six parameters — a new one must be placed deliberately');
149
154
  });
150
155
 
151
156
  await test('self-development is legal and reported as such', async () => {