@bongos/core 1.19.146 → 1.19.148

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.146",
6
- "core_contract": "1.19.146",
7
- "source_commit": "1363fd9197780c96da219f2f1f211ccdde0d7eae",
5
+ "core_version": "1.19.148",
6
+ "core_contract": "1.19.148",
7
+ "source_commit": "7e339c95065a311ed8124d03557ded14bca9446a",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-15T19:30:54.439Z",
9
+ "built_at": "2026-08-16T14:24:46.637Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 304,
13
13
  "agent_docs_stubbed": 21,
14
- "functional_verbatim": 1252,
14
+ "functional_verbatim": 1254,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1577,
20
- "tree_sha256": "9c0baec37d19b01ae748459e7d1faaf8308002ba71fa1a6a44da74e0b4617b14",
19
+ "file_count": 1579,
20
+ "tree_sha256": "8cab3bae4120d167d296a253ea3e0b6064a55de4ea9188fa52a09217898cd086",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -1472,7 +1472,7 @@
1472
1472
  {
1473
1473
  "path": "docs/file-map.md",
1474
1474
  "mode": "0000644",
1475
- "sha256": "f76f816dbbab7668c7dbd1ff7406dee095e043f23262a2c6afd900ff9c59b337"
1475
+ "sha256": "e4e3ab72c8b570e35357554e3070f6e2f78aa087811a6166e71b03c4f3e23b81"
1476
1476
  },
1477
1477
  {
1478
1478
  "path": "docs/handoff-template.md",
@@ -3452,7 +3452,12 @@
3452
3452
  {
3453
3453
  "path": "modules/ideas/inbox.js",
3454
3454
  "mode": "0000644",
3455
- "sha256": "8178f4f74492a933b716d6bed5a79a741d18fab14c346e7a299c61d78332434a"
3455
+ "sha256": "5f4b91ab9eb47ca4c34b7163a2e46934c648654c6909f9623385daf3429c9a48"
3456
+ },
3457
+ {
3458
+ "path": "modules/ideas/migrations/ideas_001_grades.sql",
3459
+ "mode": "0000644",
3460
+ "sha256": "4bdd4ebf495d19562fdb8faf508df3a4f577cb0fcc8a879a7eeb1dffd3699caa"
3456
3461
  },
3457
3462
  {
3458
3463
  "path": "modules/ideas/module.json",
@@ -4182,12 +4187,12 @@
4182
4187
  {
4183
4188
  "path": "package-lock.json",
4184
4189
  "mode": "0000644",
4185
- "sha256": "92ba433ccabd633bd48e20e6b2d28314ce3c83165e0250ffe943e648bb093571"
4190
+ "sha256": "354de77e768f98c77062278cead6271a3249cfe4ed35d811fb76b497f3028d8b"
4186
4191
  },
4187
4192
  {
4188
4193
  "path": "package.json",
4189
4194
  "mode": "0000644",
4190
- "sha256": "9b6febaf8e44a6a540f3c248efeaf98c41a04ac649cdf609d1eb3ac5ad0f3e64"
4195
+ "sha256": "23c72c88771c78d76a61334a4710e6ce77a146331de749c944f6bf2890602162"
4191
4196
  },
4192
4197
  {
4193
4198
  "path": "public-docs/index.html",
@@ -4427,7 +4432,7 @@
4427
4432
  {
4428
4433
  "path": "public-landing/assets/cosmos.css",
4429
4434
  "mode": "0000644",
4430
- "sha256": "4641a5c0c0222c0948ecdf57db9bb3cd88b5500089bbbbe735597abd4d44d4da"
4435
+ "sha256": "611050e8599be5791e89f9711b76ef6efc5b1e8bd703e2badc6a6418a9ed432b"
4431
4436
  },
4432
4437
  {
4433
4438
  "path": "public-landing/buddha.png",
@@ -4437,12 +4442,12 @@
4437
4442
  {
4438
4443
  "path": "public-landing/index.html",
4439
4444
  "mode": "0000644",
4440
- "sha256": "1fa06d66fc4c1bbe3849e6fbf402852caea8b7cfc80a4c69ecad26d921d4264d"
4445
+ "sha256": "f011cbcc82b95284f00dae9aa63600ea664f9edc5c7b79c533501c2c0c54495c"
4441
4446
  },
4442
4447
  {
4443
4448
  "path": "public-landing/projects.html",
4444
4449
  "mode": "0000644",
4445
- "sha256": "deae8db82f897ee77633ffb2b7f5144960d9961ba4cdb9e052346c6cb127b0c9"
4450
+ "sha256": "5edfc22fd5d85453df0d83a531fd085b3d5d079b66c7e3f58dcb9a0261c6b4a6"
4446
4451
  },
4447
4452
  {
4448
4453
  "path": "public-landing/world/assets/clouds/cloud-island-large.png",
@@ -5777,7 +5782,7 @@
5777
5782
  {
5778
5783
  "path": "src/module-api.js",
5779
5784
  "mode": "0000644",
5780
- "sha256": "1ee746f8bc9aef75aaf278c5ca67dc6734ceb214a5febc44d6e27556b42dd70a"
5785
+ "sha256": "f6ad6d7407077ad6166646472601151847a96f338de366e0de4daa692957b205"
5781
5786
  },
5782
5787
  {
5783
5788
  "path": "src/module-loader/catalog.js",
@@ -6884,10 +6889,15 @@
6884
6889
  "mode": "0000644",
6885
6890
  "sha256": "7d384cd84cdc93b8e7b57df291a4600a40f43346fd2a52168324ecde219f1388"
6886
6891
  },
6892
+ {
6893
+ "path": "tests/idea_grades.mjs",
6894
+ "mode": "0000644",
6895
+ "sha256": "07cacc97990f0e775342f8b30171c87128e0acb1abd38d64c683c3f257927ebd"
6896
+ },
6887
6897
  {
6888
6898
  "path": "tests/idea_triage.mjs",
6889
6899
  "mode": "0000644",
6890
- "sha256": "c5bd3ece3b4193b92b6f3437a9e570bc3084def4da8d8e687079c5db1dbc16e0"
6900
+ "sha256": "836ee90dd24736c84fede53394d0d724915f73540c9b99c41762da1d37c447b2"
6891
6901
  },
6892
6902
  {
6893
6903
  "path": "tests/idp_device_exchange.mjs",
package/docs/file-map.md CHANGED
@@ -113,6 +113,7 @@
113
113
  │ ├── grading/ ← CORE-DOMAIN carve (BV1.R73, default-on): the grade-a-diff adversarial worker panel + deterministic pre-passes + rubric; registers the `grade` kernel port. CLAUDE.md, module.json, routes/grade.js, grader.js, grader-rubric.json.
114
114
  │ ├── economy/ ← CORE-DOMAIN carve (BV1.R77, default-on): credit allocation + cost ledger + streaks + achievements + leaderboard; registers the `reward` kernel port. CLAUDE.md, module.json, routes/{cost,leaderboard,reward}.js, credits.js, cost.js, cost-classify.js, cost-summary-shape.js, streak.js, achievements.js, reward.js.
115
115
  │ ├── ideas/ ← CORE-DOMAIN carve (BV1.R78, default-on): the idea_inbox capture/triage/criterion-proposal flow + the blockers queue; registers the `ideas.capture` kernel port (retired the `captureIdea` doorway leak). CLAUDE.md, module.json, routes/{inbox,blockers}.js, inbox.js, blockers.js.
116
+ │ │ └── migrations/ ← module-owned schema (BV1.R140): ideas_001_grades.sql — the `ideas_grades` side table (quick|spark|full + the Full Idea template store + completeness score), LEFT JOINed onto the idea read path so an idea with no row reads as `quick`.
116
117
  │ ├── security/ ← CORE-DOMAIN carve (BV1.R79, default-on): red-team/vulnerability report intake + the env-aware bounty payout table (Metic+); registers the `security` kernel port the core /public/bounty-table resolves. <redacted> (permission-path-check, route-rank-check) stays kernel — this is <redacted>. CLAUDE.md, module.json, routes/{reports,scans}.js, security.js, secret-scrub.js, scan-store.js. **artifact-scan/** (goal 1000055, /scan-before-install): fetch.js (hardened clone), quarantine.js (holding pen), manifest.js (content hash), mirror.js (scrubbed reading-room), fence-local.js (vendored scrub/fence), floor.js + detectors/ (zero-LLM deterministic floor — npm-lifecycle, hooks, mcp, permissions, credentials, network, injection, dependencies, capabilities; persisted before any model reads).
117
118
  │ ├── builder-settings/ ← CORE-DOMAIN carve (BV1.R80, default-on): per-builder prefs — wandering level, per-skill model ceilings, sound/render toggles — + builder-needs. `GET /me` resolves the `builder-settings` kernel port for these slices. CLAUDE.md, module.json, routes/settings.js, wandering-prefs.js, skill-prefs.js, sound-prefs.js, event-sound-prefs.js, render-prefs.js, model-alloc-prefs.js, interaction-prefs.js, display-name.js, builder-needs.js.
118
119
  │ ├── onboarding/ ← CORE-DOMAIN carve (BV1.R81, default-on): admission (access-request → Archon approval), the server-ticked onboarding state machine, the friction analyzer, and evergreen newcomer-chore restock; registers the `onboarding` kernel port. CLAUDE.md, module.json, routes/access-requests.js, onboarding.js, onboarding-state.js, newcomer-restock.js, approval-broadcast.js.
@@ -4,6 +4,8 @@
4
4
  // idea_inbox(id, title, body_md, kind, suggested_version, suggested_priority,
5
5
  // captured_at, captured_by, status, promoted_to_task_id, reviewed_at,
6
6
  // merged_into_idea_id, triage_note)
7
+ // ideas_grades(idea_id, grade, template_key, field_values, completeness_score,
8
+ // created_at, updated_at) — module-owned, ideas_001_grades.sql
7
9
  //
8
10
  // kind values match tasks.kind (feature | bug | infra | refactor | spike |
9
11
  // learning-capture | cleanup | decision | blocker-resolution | unclassified).
@@ -39,14 +41,65 @@ const VALID_STATUSES = ['open', 'promoted', 'discarded', 'merged'];
39
41
 
40
42
  // The one column list every idea read returns. Four copies had to stay in step
41
43
  // (capture / list / get / update-RETURNING); core_195 added two columns to each,
42
- // so it is now a constant rather than four places to forget.
43
- const IDEA_COLS = `id, title, body_md, kind, suggested_version, suggested_priority,
44
- captured_at, captured_by, status, promoted_to_task_id, reviewed_at,
45
- merged_into_idea_id, triage_note`;
44
+ // so it is now a constant rather than four places to forget. Kept as names rather
45
+ // than a string so the qualified form (task 1002916 joined the grade table in, and
46
+ // an unqualified `id` is ambiguous across a join) is derived, not a fifth copy.
47
+ const IDEA_COL_NAMES = [
48
+ 'id', 'title', 'body_md', 'kind', 'suggested_version', 'suggested_priority',
49
+ 'captured_at', 'captured_by', 'status', 'promoted_to_task_id', 'reviewed_at',
50
+ 'merged_into_idea_id', 'triage_note',
51
+ ];
52
+ const IDEA_COLS = IDEA_COL_NAMES.join(', ');
53
+ const IDEA_COLS_Q = IDEA_COL_NAMES.map((c) => `i.${c}`).join(', ');
46
54
 
47
55
  // A discard reason / merge note is a sentence, not a document.
48
56
  const TRIAGE_NOTE_MAX = 1000;
49
57
 
58
+ // -------------------------------------------------------------------------
59
+ // Idea grades (task 1002916 / BV1.R140, goal 1000062)
60
+ //
61
+ // Three grades, from the owner's brief:
62
+ // quick — title + description, open to every role, aimed at turning a thought
63
+ // into a task fast. NOT a draft Full Idea.
64
+ // spark — a Quick Idea whose AUTHOR marked it as needing development. The mark
65
+ // is the consent that makes cross-builder development fair (R144).
66
+ // full — carries the base template's five fields, scored on completeness.
67
+ //
68
+ // AN IDEA WITH NO ideas_grades ROW READS AS QUICK. That is the design, not a
69
+ // fallback: every idea captured before this shipped keeps grade='quick' without
70
+ // a row being written or a column being backfilled. The COALESCE below is where
71
+ // that rule lives, and it is the reason the reads LEFT JOIN rather than JOIN.
72
+ // An explicit quick row is equally legal (un-sparking writes one) and reads the
73
+ // same, so callers never have to care which of the two they are looking at.
74
+ const DEFAULT_GRADE = 'quick';
75
+ const VALID_GRADES = ['quick', 'spark', 'full'];
76
+
77
+ // Which template `field_values` was written against. Stored per row so a project
78
+ // that later defines its own shape (R156) does not retro-reinterpret ideas
79
+ // written against the base one. R141 declares what 'base-v1' actually contains —
80
+ // this module only records which template a row used.
81
+ const DEFAULT_TEMPLATE_KEY = 'base-v1';
82
+ const TEMPLATE_KEY_RE = /^[a-z][a-z0-9-]*$/;
83
+ const TEMPLATE_KEY_MAX = 64;
84
+
85
+ // Mirrors ideas_grades_field_values_size_chk. Declared here too so an oversize
86
+ // payload gets a structured 400 instead of a constraint-violation 500.
87
+ const FIELD_VALUES_MAX = 32768;
88
+
89
+ // The grade half of an idea read: the COALESCE above, plus the template-shaped
90
+ // store and the score. `g.*` columns are NULL for an ungraded idea, which is
91
+ // exactly right — it has no template and no score, it just has a default grade.
92
+ const GRADE_COLS_Q = `COALESCE(g.grade, '${DEFAULT_GRADE}') AS grade,
93
+ g.template_key, g.field_values, g.completeness_score`;
94
+ const IDEA_JOIN_GRADE = `LEFT JOIN ideas_grades g ON g.idea_id = i.id`;
95
+
96
+ // The same projection for a row that provably has no grade row yet (a just-
97
+ // INSERTed idea), so INSERT…RETURNING does not need a second round trip to
98
+ // return the same shape every other read returns.
99
+ function ungradedProjection() {
100
+ return { grade: DEFAULT_GRADE, template_key: null, field_values: null, completeness_score: null };
101
+ }
102
+
50
103
  // -------------------------------------------------------------------------
51
104
  // Skill-friction tagging (task V3.R70 / #289)
52
105
  //
@@ -144,7 +197,10 @@ async function captureIdea({
144
197
  RETURNING ${IDEA_COLS}`,
145
198
  [title, bodyMd, resolvedKind, suggestedVersion, suggestedPriority, capturedBy]
146
199
  );
147
- return rows[0];
200
+ // A brand-new idea has no ideas_grades row, so it is quick by definition —
201
+ // no join needed to know that. Grading it (R143's Full Idea capture) is a
202
+ // separate setIdeaGrade write against the id this returns.
203
+ return rows[0] ? { ...ungradedProjection(), ...rows[0] } : rows[0];
148
204
  }
149
205
 
150
206
  // -------------------------------------------------------------------------
@@ -160,21 +216,22 @@ async function captureIdea({
160
216
  // without a migration.
161
217
  async function listOpenIdeas({ kind = null, limit = 50, viewerRank = null } = {}) {
162
218
  const params = [];
163
- const where = [`status = 'open'`];
219
+ const where = [`i.status = 'open'`];
164
220
  if (kind) {
165
221
  params.push(kind);
166
- where.push(`kind = $${params.length}`);
222
+ where.push(`i.kind = $${params.length}`);
167
223
  }
168
224
  if (!rankCanSeeSkillFriction(viewerRank)) {
169
225
  params.push(`${SKILL_FRICTION_TAG}%`);
170
- where.push(`title NOT LIKE $${params.length}`);
226
+ where.push(`i.title NOT LIKE $${params.length}`);
171
227
  }
172
228
  params.push(limit);
173
229
  const { rows } = await pool.query(
174
- `SELECT ${IDEA_COLS}
175
- FROM idea_inbox
230
+ `SELECT ${IDEA_COLS_Q}, ${GRADE_COLS_Q}
231
+ FROM idea_inbox i
232
+ ${IDEA_JOIN_GRADE}
176
233
  WHERE ${where.join(' AND ')}
177
- ORDER BY captured_at DESC
234
+ ORDER BY i.captured_at DESC
178
235
  LIMIT $${params.length}`,
179
236
  params
180
237
  );
@@ -183,9 +240,10 @@ async function listOpenIdeas({ kind = null, limit = 50, viewerRank = null } = {}
183
240
 
184
241
  async function getIdea(id) {
185
242
  const { rows } = await pool.query(
186
- `SELECT ${IDEA_COLS}
187
- FROM idea_inbox
188
- WHERE id = $1`,
243
+ `SELECT ${IDEA_COLS_Q}, ${GRADE_COLS_Q}
244
+ FROM idea_inbox i
245
+ ${IDEA_JOIN_GRADE}
246
+ WHERE i.id = $1`,
189
247
  [id]
190
248
  );
191
249
  return rows[0] ?? null;
@@ -279,17 +337,124 @@ async function updateIdea(id, { status, kind, promoted_to_task_id, reviewed_at,
279
337
  if (sets.length === 0) {
280
338
  return getIdea(id);
281
339
  }
340
+ // The UPDATE is wrapped in a CTE so the row comes back with its grade attached
341
+ // in ONE round trip. A triage verdict never changes the grade, but the idea may
342
+ // already BE a spark or a full — returning the ungraded default here would be a
343
+ // lie, and re-reading afterwards would be a second query and a second chance to
344
+ // race. `upd i` aliases the returned row so IDEA_COLS_Q applies unchanged.
282
345
  const { rows } = await pool.query(
283
- `UPDATE idea_inbox
284
- SET ${sets.join(', ')}
285
- WHERE id = $1
286
- RETURNING ${IDEA_COLS}`,
346
+ `WITH upd AS (
347
+ UPDATE idea_inbox
348
+ SET ${sets.join(', ')}
349
+ WHERE id = $1
350
+ RETURNING ${IDEA_COLS}
351
+ )
352
+ SELECT ${IDEA_COLS_Q}, ${GRADE_COLS_Q}
353
+ FROM upd i
354
+ ${IDEA_JOIN_GRADE}`,
287
355
  params
288
356
  );
289
357
  if (rows.length === 0) throw { code: 'IDEA_NOT_FOUND' };
290
358
  return rows[0];
291
359
  }
292
360
 
361
+ // -------------------------------------------------------------------------
362
+ // Grade writes (task 1002916 / BV1.R140)
363
+ //
364
+ // setIdeaGrade writes the WHOLE grade row — it does not merge with what is
365
+ // already there. That is deliberate: a partial-merge upsert reads innocently at
366
+ // the call site and silently preserves stale field_values from a previous grade,
367
+ // which is the bug that would make "develop this spark into a full idea" (R146)
368
+ // quietly inherit whatever was there before. Callers that only want to move the
369
+ // score have setIdeaCompleteness below; anyone changing the grade states the
370
+ // whole row.
371
+ //
372
+ // Structured throws (not raw constraint violations) so the routes that land on
373
+ // top of this — R143's Full Idea capture, R144's mark-as-spark — return a clean
374
+ // 400/404 rather than a 500 with a Postgres message in it.
375
+ function assertGradePayload({ grade, templateKey, fieldValues, completenessScore }) {
376
+ if (!VALID_GRADES.includes(grade)) {
377
+ throw { code: 'BAD_GRADE', grade, valid: VALID_GRADES };
378
+ }
379
+ if (typeof templateKey !== 'string' || !TEMPLATE_KEY_RE.test(templateKey) || templateKey.length > TEMPLATE_KEY_MAX) {
380
+ throw { code: 'BAD_TEMPLATE_KEY', template_key: templateKey };
381
+ }
382
+ // A map, never a scalar or an array — the scorer and the read path walk it by
383
+ // key. `typeof null === 'object'`, hence the explicit null check.
384
+ if (fieldValues === null || typeof fieldValues !== 'object' || Array.isArray(fieldValues)) {
385
+ throw { code: 'BAD_FIELD_VALUES' };
386
+ }
387
+ if (JSON.stringify(fieldValues).length > FIELD_VALUES_MAX) {
388
+ throw { code: 'FIELD_VALUES_TOO_LARGE', max: FIELD_VALUES_MAX };
389
+ }
390
+ if (completenessScore !== null) {
391
+ if (!Number.isInteger(completenessScore) || completenessScore < 0 || completenessScore > 100) {
392
+ throw { code: 'BAD_COMPLETENESS', completeness_score: completenessScore };
393
+ }
394
+ }
395
+ }
396
+
397
+ async function setIdeaGrade(ideaId, {
398
+ grade,
399
+ templateKey = DEFAULT_TEMPLATE_KEY,
400
+ fieldValues = {},
401
+ completenessScore = null,
402
+ } = {}) {
403
+ assertGradePayload({ grade, templateKey, fieldValues, completenessScore });
404
+ try {
405
+ const { rows } = await pool.query(
406
+ `INSERT INTO ideas_grades (idea_id, grade, template_key, field_values, completeness_score)
407
+ VALUES ($1, $2, $3, $4::jsonb, $5)
408
+ ON CONFLICT (idea_id) DO UPDATE
409
+ SET grade = EXCLUDED.grade,
410
+ template_key = EXCLUDED.template_key,
411
+ field_values = EXCLUDED.field_values,
412
+ completeness_score = EXCLUDED.completeness_score
413
+ RETURNING idea_id, grade, template_key, field_values, completeness_score,
414
+ created_at, updated_at`,
415
+ [ideaId, grade, templateKey, JSON.stringify(fieldValues), completenessScore]
416
+ );
417
+ return rows[0];
418
+ } catch (err) {
419
+ // 23503 = FK violation on idea_id: the idea does not exist. Worth its own
420
+ // code because "you graded a missing idea" is a 404, not a 500.
421
+ if (err && err.code === '23503') throw { code: 'IDEA_NOT_FOUND', idea_id: ideaId };
422
+ throw err;
423
+ }
424
+ }
425
+
426
+ // The score-only write, so the R142 scorer never has to restate the grade and
427
+ // the fields just to move a number (and so it cannot wipe them by forgetting).
428
+ // Returns null when the idea has no grade row: an ungraded idea is quick, and a
429
+ // quick idea has nothing to score — that is a no-op, not an error.
430
+ async function setIdeaCompleteness(ideaId, completenessScore) {
431
+ if (completenessScore !== null && (!Number.isInteger(completenessScore) || completenessScore < 0 || completenessScore > 100)) {
432
+ throw { code: 'BAD_COMPLETENESS', completeness_score: completenessScore };
433
+ }
434
+ const { rows } = await pool.query(
435
+ `UPDATE ideas_grades
436
+ SET completeness_score = $2
437
+ WHERE idea_id = $1
438
+ RETURNING idea_id, grade, template_key, field_values, completeness_score,
439
+ created_at, updated_at`,
440
+ [ideaId, completenessScore]
441
+ );
442
+ return rows[0] ?? null;
443
+ }
444
+
445
+ // The grade row on its own. null = no row, which reads as grade='quick'
446
+ // everywhere the idea itself is read (see DEFAULT_GRADE).
447
+ async function getIdeaGrade(ideaId) {
448
+ const { rows } = await pool.query(
449
+ `SELECT idea_id, grade, template_key, field_values, completeness_score,
450
+ created_at, updated_at
451
+ FROM ideas_grades
452
+ WHERE idea_id = $1`,
453
+ [ideaId]
454
+ );
455
+ return rows[0] ?? null;
456
+ }
457
+
293
458
  // -------------------------------------------------------------------------
294
459
  // Criterion-proposal ratification (V3.R31 / #252)
295
460
  //
@@ -489,6 +654,13 @@ module.exports = {
489
654
  VALID_STATUSES,
490
655
  // the triage-note cap, so the route validator declares it from one place
491
656
  TRIAGE_NOTE_MAX,
657
+ // idea grades (task 1002916 / BV1.R140) — the vocabulary + caps the routes
658
+ // and the R141 template declare against, from one place
659
+ VALID_GRADES,
660
+ DEFAULT_GRADE,
661
+ DEFAULT_TEMPLATE_KEY,
662
+ TEMPLATE_KEY_MAX,
663
+ FIELD_VALUES_MAX,
492
664
  // skill-friction tagging + rank visibility gate (V3.R70 / #289)
493
665
  SKILL_FRICTION_TAG,
494
666
  XENOS_RANK,
@@ -499,8 +671,11 @@ module.exports = {
499
671
  listOpenIdeas,
500
672
  getIdea,
501
673
  countOpenIdeas,
674
+ getIdeaGrade,
502
675
  // mutate
503
676
  updateIdea,
677
+ setIdeaGrade,
678
+ setIdeaCompleteness,
504
679
  // criterion-proposal ratification (V3.R31 / #252)
505
680
  ratifyCriterionProposal,
506
681
  parseProposalBody,
@@ -0,0 +1,131 @@
1
+ -- ideas_001_grades.sql — the grade axis for ideas (task 1002916, BV1.R140, goal 1000062).
2
+ --
3
+ -- Three grades of idea, from the owner's Holy Workshop Trinity brief:
4
+ -- quick — title + description, open to every role. Its job is turning a thought
5
+ -- into a task fast. It is NOT a draft Full Idea.
6
+ -- spark — a Quick Idea whose AUTHOR has explicitly marked it as needing
7
+ -- development. That mark is the consent that makes cross-builder
8
+ -- development fair (task 1002920 / R144).
9
+ -- full — a developed idea carrying the base template's fields, scored on
10
+ -- completeness (tasks 1002917 / R141, 1002918 / R142).
11
+ --
12
+ -- ONE TABLE, KEYED BY IDEA, AND NO ROW MEANS QUICK.
13
+ -- `ideas_grades` is a SIDE table joined to `idea_inbox` by id — never columns on
14
+ -- idea_inbox itself. Two reasons, and only the second is a preference:
15
+ --
16
+ -- 1. It is the only shape that is legal here. `idea_inbox` is a CORE table
17
+ -- (migrations/006_pms_v2.sql), and this goal's scope wall is `ideas` +
18
+ -- `hall-ui`. Repo-root migrations/ resolves to the PROTECTED `lifecycle`
19
+ -- key, so a core migration is out; and a module migration may not ALTER a
20
+ -- table outside its own `ideas_` namespace (ADR 0083 §Decision #5, enforced
21
+ -- by fitness.js Check 11). A namespaced side table is what is left, and it
22
+ -- is also what is right. (Task 1002953 corrected the original instruction
23
+ -- on this task, which asked for something neither gate would accept.)
24
+ --
25
+ -- 2. An idea with NO row here reads as grade='quick'. That is the DESIGN, not
26
+ -- a fallback: every idea captured before today keeps grade='quick' without
27
+ -- a single row being written or a column being defaulted, so the task's
28
+ -- "no data change" done-when is met exactly rather than by a backfill. The
29
+ -- COALESCE lives in modules/ideas/inbox.js's LEFT JOIN; a quick row may
30
+ -- also exist explicitly (un-sparking writes one), and both read the same.
31
+ --
32
+ -- THE TEMPLATE IS DATA, NOT COLUMNS. The five base fields (Purpose / Big Picture
33
+ -- / Why you had it / Why it matters / How it fits) are stored as `field_values`
34
+ -- jsonb against a `template_key`, not as five hard-coded columns. The owner's
35
+ -- design philosophy for this platform, recorded on goal 1000062: ship the best
36
+ -- possible simple base, already designed to be altered — so a project founder can
37
+ -- define its own idea shape (task 1002932 / R156) without a schema change. R141
38
+ -- declares what 'base-v1' means; this migration only stores which template a row
39
+ -- was written against.
40
+ --
41
+ -- COMPLETENESS IS STORED, NOT COMPUTED HERE. `completeness_score` is written by
42
+ -- the deterministic scorer (R142) — field presence + substance, no LLM, so the
43
+ -- number is free, instant and reproducible. Nullable: scoring must never block
44
+ -- capture, so an unscored full idea is a legal row, not an error.
45
+ --
46
+ -- Module migrations are ADDITIVE and NAMESPACED (ADR 0083 §Decision #5): every
47
+ -- object is ideas_-prefixed, there is no down-migration, and the filename carries
48
+ -- the module key so its stem never collides across repos (ADR 0108 §5).
49
+ -- Idempotent: safe to re-run.
50
+
51
+ BEGIN;
52
+
53
+ CREATE TABLE IF NOT EXISTS ideas_grades (
54
+ -- The idea this grades. PRIMARY KEY, not just a FK: one grade per idea, and
55
+ -- the join in inbox.js is a lookup rather than a scan. CASCADE because a grade
56
+ -- for a deleted idea is not history worth keeping — unlike a ledger row, it
57
+ -- describes the idea rather than recording an event.
58
+ idea_id bigint PRIMARY KEY REFERENCES idea_inbox(id) ON DELETE CASCADE,
59
+
60
+ -- The axis. Text + CHECK rather than a Postgres enum TYPE: the vocabulary is
61
+ -- pinned closed today (an unknown grade is refused, never silently stored),
62
+ -- and widening it later is a one-line constraint change in a subsequent
63
+ -- ideas_* migration instead of an enum-type migration.
64
+ grade text NOT NULL,
65
+
66
+ -- WHICH shape `field_values` was written against. Stored per row, so a project
67
+ -- that later defines its own template does not retro-reinterpret ideas written
68
+ -- against the base one.
69
+ template_key text NOT NULL DEFAULT 'base-v1',
70
+
71
+ -- The template-shaped store. An object of field-key → text. Empty for a quick
72
+ -- or spark row; the five base fields for a full one.
73
+ --
74
+ -- jsonb, NOT json — so the CHECKs below can inspect it and a later query can
75
+ -- index into it. The one consequence to know: jsonb does not preserve key
76
+ -- order (values round-trip byte-identical; the ORDER does not). DISPLAY ORDER
77
+ -- MUST THEREFORE COME FROM THE TEMPLATE DECLARATION (R141), never from
78
+ -- Object.keys() over what comes back — otherwise the five fields render in
79
+ -- whatever order Postgres chose.
80
+ field_values jsonb NOT NULL DEFAULT '{}'::jsonb,
81
+
82
+ -- 0–100, written by the R142 scorer. NULL = not scored yet, which is a legal
83
+ -- state by design (scoring never gates capture).
84
+ completeness_score integer,
85
+
86
+ created_at timestamptz NOT NULL DEFAULT now(),
87
+ updated_at timestamptz NOT NULL DEFAULT now(),
88
+
89
+ CONSTRAINT ideas_grades_grade_chk
90
+ CHECK (grade IN ('quick', 'spark', 'full')),
91
+ -- Same safe-in-a-filename/URL/CLI shape a module key must take, so a
92
+ -- project-defined template key stays addressable.
93
+ CONSTRAINT ideas_grades_template_key_fmt_chk
94
+ CHECK (template_key ~ '^[a-z][a-z0-9-]*$' AND length(template_key) <= 64),
95
+ -- field_values is a MAP, never a scalar or an array — the read path and the
96
+ -- scorer both walk it by key.
97
+ CONSTRAINT ideas_grades_field_values_shape_chk
98
+ CHECK (jsonb_typeof(field_values) = 'object'),
99
+ -- Defense in depth with the domain-layer cap: these are five prose fields
100
+ -- authored by a person and later rendered to other people, not a payload.
101
+ CONSTRAINT ideas_grades_field_values_size_chk
102
+ CHECK (length(field_values::text) <= 32768),
103
+ CONSTRAINT ideas_grades_completeness_range_chk
104
+ CHECK (completeness_score IS NULL OR completeness_score BETWEEN 0 AND 100)
105
+ );
106
+
107
+ -- The two reads this table exists to serve, from one index:
108
+ -- • "what sparks are awaiting development?" — the spark queue (R145)
109
+ -- • "order the triage queue so well-formed thinking surfaces first" (R148)
110
+ -- NULLS LAST so an unscored idea sorts behind a scored one rather than ahead of
111
+ -- everything, which is what a naive DESC would do.
112
+ CREATE INDEX IF NOT EXISTS ideas_grades_grade_score_idx
113
+ ON ideas_grades (grade, completeness_score DESC NULLS LAST);
114
+
115
+ -- A grade row IS mutated after insert — a quick idea is marked as a spark, a
116
+ -- spark is developed into a full idea, a full idea is re-scored. Without a real
117
+ -- trigger updated_at silently equals created_at forever and "when was this last
118
+ -- developed" becomes unanswerable. Idempotent: OR REPLACE + DROP IF EXISTS.
119
+ CREATE OR REPLACE FUNCTION ideas_touch_updated_at() RETURNS trigger AS $$
120
+ BEGIN
121
+ NEW.updated_at = now();
122
+ RETURN NEW;
123
+ END;
124
+ $$ LANGUAGE plpgsql;
125
+
126
+ DROP TRIGGER IF EXISTS ideas_grades_updated_at ON ideas_grades;
127
+ CREATE TRIGGER ideas_grades_updated_at
128
+ BEFORE UPDATE ON ideas_grades
129
+ FOR EACH ROW EXECUTE FUNCTION ideas_touch_updated_at();
130
+
131
+ COMMIT;
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.146",
3
+ "version": "1.19.148",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.146",
9
+ "version": "1.19.148",
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.146",
3
+ "version": "1.19.148",
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",
@@ -218,6 +218,10 @@ body{font-family:var(--font-body);color:var(--ink);background:var(--page-bg);min
218
218
  .top-right{display:flex;align-items:center;gap:clamp(10px,2vw,18px);}
219
219
  .who{display:flex;align-items:center;gap:10px;font-size:14px;font-weight:600;color:var(--chrome-ink-dim);min-height:34px;text-shadow:var(--chrome-glow);}
220
220
  .who img{width:30px;height:30px;border-radius:50%;border:2px solid rgba(255,255,255,0.85);box-shadow:var(--ava-shadow);}
221
+ /* sign-out rides inside the identity chip, so it exists exactly when a session
222
+ does — the same glass voice as .btn-space, one size down (task 1002952) */
223
+ .who .signout{font-family:var(--font-display);font-weight:700;font-size:12.5px;color:var(--glass-ink);background:var(--glass-bg);border:1.5px solid var(--glass-border);border-radius:999px;padding:5px 12px;text-decoration:none;text-shadow:var(--chrome-glow);backdrop-filter:blur(4px);}
224
+ .who .signout:hover{border-color:var(--chrome-accent);color:var(--chrome-accent);}
221
225
  .btn-space{display:inline-flex;align-items:center;gap:8px;cursor:pointer;font-family:var(--font-display);font-weight:700;font-size:13.5px;color:var(--glass-ink);background:var(--glass-bg);border:1.5px solid var(--glass-border);border-radius:999px;padding:8px 16px;text-decoration:none;text-shadow:var(--chrome-glow);backdrop-filter:blur(4px);}
222
226
  .btn-space:hover{border-color:var(--chrome-accent);color:var(--chrome-accent);}
223
227
  /* the light/dark switch — same glass voice as .btn-space, icon-only */
@@ -483,9 +483,30 @@ h1.big{font-family:var(--font-display);font-weight:700;font-size:clamp(33px,5.4v
483
483
 
484
484
  var API = '/api/bongos';
485
485
 
486
+ /* ── sign out. The logout endpoint is POST + cookie-gated, so a plain-anchor
487
+ GET navigation would 404 and leave the session live — the exact hall bug
488
+ task 1002245 fixed; its shell.js doLogout is the contract mirrored here.
489
+ The POST is also the only place the federated single-logout fan-out fires
490
+ (ADR 0144). Whether it lands or the network drops, still expire both
491
+ session cookies in BOTH the host-only and apex-scoped Domain forms (#726 —
492
+ a Max-Age=0 only matches the cookie whose Domain attribute it names), so
493
+ this browser at least is signed out, then reload into the signed-out page. ── */
494
+ function doLogout() {
495
+ var finish = function () {
496
+ var apex = location.hostname.split('.').slice(-2).join('.');
497
+ ['gds_session', 'pms_session'].forEach(function (name) {
498
+ document.cookie = name + '=; Max-Age=0; path=/';
499
+ document.cookie = name + '=; Max-Age=0; path=/; Domain=.' + apex;
500
+ });
501
+ window.location.assign(location.pathname);
502
+ };
503
+ fetch(API + '/auth/logout', { method: 'POST', credentials: 'same-origin' }).then(finish, finish);
504
+ }
505
+
486
506
  /* ── the identity chip. Signed out (401) leaves it empty — the header's own
487
507
  doors are the signed-out affordance. Built with createElement/textContent,
488
- never innerHTML: the same rule the status UI keeps. ── */
508
+ never innerHTML: the same rule the status UI keeps. Signed in it carries
509
+ the one session control the page owes its visitor: Sign out (task 1002952). ── */
489
510
  fetch(API + '/me', { credentials: 'same-origin' })
490
511
  .then(function (res) { return res.ok ? res.json() : null; })
491
512
  .then(function (data) {
@@ -498,6 +519,12 @@ h1.big{font-family:var(--font-display);font-weight:700;font-size:clamp(33px,5.4v
498
519
  host.appendChild(img);
499
520
  }
500
521
  host.appendChild(document.createTextNode(me.github_login));
522
+ var out = document.createElement('a');
523
+ out.className = 'signout';
524
+ out.href = API + '/auth/logout'; /* honest hover target; the click upgrades it to the real POST */
525
+ out.textContent = 'Sign out';
526
+ out.addEventListener('click', function (e) { e.preventDefault(); doLogout(); });
527
+ host.appendChild(out);
501
528
  })
502
529
  .catch(function () { /* signed out or offline — the chip stays empty */ });
503
530
 
@@ -3904,13 +3904,44 @@ h1[tabindex="-1"]:focus{outline:none;}
3904
3904
  function renderWho() {
3905
3905
  var w = $('who');
3906
3906
  if (me) {
3907
+ /* the chip carries the one session control this page owes its visitor:
3908
+ Sign out (task 1002952). The anchor's href is the honest hover target;
3909
+ the delegated click below upgrades it to the real POST. */
3907
3910
  w.innerHTML = (me.avatar_url ? '<img src="' + esc(me.avatar_url) + '" alt="">' : '') +
3908
- '<span>' + esc(me.github_login || 'signed in') + '</span>';
3911
+ '<span>' + esc(me.github_login || 'signed in') + '</span>' +
3912
+ '<a class="signout" href="' + API + '/auth/logout">Sign out</a>';
3909
3913
  } else {
3910
3914
  w.innerHTML = '';
3911
3915
  }
3912
3916
  }
3913
3917
 
3918
+ /* Sign out. The logout endpoint is POST + cookie-gated, so a plain-anchor GET
3919
+ would 404 and leave the session live — the exact hall bug task 1002245
3920
+ fixed; its shell.js doLogout is the contract mirrored here. The POST is
3921
+ also the only place the federated single-logout fan-out fires (ADR 0144).
3922
+ Whether it lands or the network drops, still expire both session cookies
3923
+ in BOTH the host-only and apex-scoped Domain forms (#726 — a Max-Age=0
3924
+ only matches the cookie whose Domain attribute it names), then reload the
3925
+ bare path: the boot re-runs and paints the signed-out shell. Delegated on
3926
+ the chip because renderWho rewrites its innerHTML. */
3927
+ function doLogout() {
3928
+ var finish = function () {
3929
+ var apex = location.hostname.split('.').slice(-2).join('.');
3930
+ ['gds_session', 'pms_session'].forEach(function (name) {
3931
+ document.cookie = name + '=; Max-Age=0; path=/';
3932
+ document.cookie = name + '=; Max-Age=0; path=/; Domain=.' + apex;
3933
+ });
3934
+ window.location.assign(location.pathname);
3935
+ };
3936
+ fetch(API + '/auth/logout', { method: 'POST', credentials: 'same-origin' }).then(finish, finish);
3937
+ }
3938
+ $('who').addEventListener('click', function (ev) {
3939
+ var a = ev.target.closest && ev.target.closest('a.signout');
3940
+ if (!a) return;
3941
+ ev.preventDefault();
3942
+ doLogout();
3943
+ });
3944
+
3914
3945
  function load() {
3915
3946
  fetch(API + '/provisioning/instances', { credentials: 'same-origin' })
3916
3947
  .then(function (res) {
package/src/module-api.js CHANGED
@@ -719,8 +719,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
719
719
  // landed since 1.19.144 with no explicit bump. run 31895007353. (task 1002620)
720
720
  // 1.19.146 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
721
721
  // landed since 1.19.145 with no explicit bump. run 31904091158. (task 1002620)
722
+ // 1.19.147 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
723
+ // landed since 1.19.146 with no explicit bump. run 31950807165. (task 1002620)
724
+ // 1.19.148 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
725
+ // landed since 1.19.147 with no explicit bump. run 31952592623. (task 1002620)
722
726
  // ---------------------------------------------------------------------------
723
- const CORE_VERSION = '1.19.146'; // CI auto-patch carrier (ADR 0161); see changelog above
727
+ const CORE_VERSION = '1.19.148'; // CI auto-patch carrier (ADR 0161); see changelog above
724
728
 
725
729
  // A namespaced logger so a module's log lines are attributable + consistent.
726
730
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,225 @@
1
+ // tests/idea_grades.mjs
2
+ //
3
+ // task 1002916 (BV1.R140, goal 1000062) — the grade axis on ideas.
4
+ //
5
+ // The rule these tests exist to protect: AN IDEA WITH NO ideas_grades ROW READS
6
+ // AS grade='quick'. That is what lets the grade axis ship without touching a
7
+ // single existing row, and it is a claim about the SQL — a LEFT JOIN with a
8
+ // COALESCE. A plain JOIN, or a COALESCE dropped from one of the read paths,
9
+ // would make every pre-existing idea vanish from the inbox or come back with a
10
+ // null grade, and nothing else in the suite would notice.
11
+ //
12
+ // The other half is the payload guards on setIdeaGrade: they are what stand
13
+ // between an API caller and a raw Postgres constraint violation, so they must
14
+ // refuse BEFORE the query runs, not after.
15
+ //
16
+ // DB-free, the same way tests/idea_triage.mjs is: modules/ideas/inbox.js
17
+ // destructures `pool` off the doorway at require time, so patching pool.query on
18
+ // that object BEFORE requiring inbox.js intercepts every query.
19
+ //
20
+ // Run: node tests/idea_grades.mjs
21
+
22
+ import { strict as assert } from 'node:assert';
23
+ import { createRequire } from 'node:module';
24
+
25
+ const require = createRequire(import.meta.url);
26
+
27
+ const api = require('../src/module-api.js');
28
+
29
+ let queries = [];
30
+ // What the fake DB hands back. Set per-test; defaults to one plausible row so a
31
+ // read has something to return.
32
+ let nextRows = [];
33
+
34
+ api.pool.query = async (sql, params = []) => {
35
+ queries.push({ sql, params });
36
+ return { rows: nextRows };
37
+ };
38
+
39
+ const inbox = require('../modules/ideas/inbox.js');
40
+
41
+ let passed = 0;
42
+ let failed = 0;
43
+
44
+ async function test(name, fn) {
45
+ queries = [];
46
+ nextRows = [{ id: '1', title: 'an idea', status: 'open', grade: 'quick' }];
47
+ try {
48
+ await fn();
49
+ passed += 1;
50
+ console.log(` ok ${name}`);
51
+ } catch (err) {
52
+ failed += 1;
53
+ console.log(` FAIL ${name}\n ${err.message}`);
54
+ }
55
+ }
56
+
57
+ async function rejectsWith(code, fn) {
58
+ try {
59
+ await fn();
60
+ } catch (err) {
61
+ assert.equal(err.code, code, `expected ${code}, got ${err.code || err.message}`);
62
+ return err;
63
+ }
64
+ throw new Error(`expected ${code}, but the call resolved`);
65
+ }
66
+
67
+ // A LEFT JOIN onto ideas_grades whose grade is COALESCEd to 'quick'. Asserted as
68
+ // one shape rather than three loose substring checks, because it is the
69
+ // combination that carries the rule: an inner join loses ungraded ideas, and a
70
+ // bare g.grade returns null for them.
71
+ function assertReadsGradeWithDefault(sql, where) {
72
+ assert.match(sql, /LEFT JOIN\s+ideas_grades\s+g\s+ON\s+g\.idea_id\s*=\s*i\.id/i,
73
+ `${where}: not a LEFT JOIN onto ideas_grades — ungraded ideas would drop out of the result`);
74
+ assert.match(sql, /COALESCE\(\s*g\.grade\s*,\s*'quick'\s*\)\s+AS\s+grade/i,
75
+ `${where}: grade is not COALESCEd to 'quick' — an ungraded idea would read back as null`);
76
+ assert.match(sql, /g\.template_key/i, `${where}: template_key missing from the projection`);
77
+ assert.match(sql, /g\.field_values/i, `${where}: field_values missing from the projection`);
78
+ assert.match(sql, /g\.completeness_score/i, `${where}: completeness_score missing from the projection`);
79
+ }
80
+
81
+ // -- the read paths carry the grade ----------------------------------------
82
+
83
+ await test('getIdea reads the grade, defaulting an ungraded idea to quick', async () => {
84
+ await inbox.getIdea('1');
85
+ assertReadsGradeWithDefault(queries[0].sql, 'getIdea');
86
+ });
87
+
88
+ await test('listOpenIdeas reads the grade, defaulting an ungraded idea to quick', async () => {
89
+ nextRows = [];
90
+ await inbox.listOpenIdeas({ limit: 5 });
91
+ assertReadsGradeWithDefault(queries[0].sql, 'listOpenIdeas');
92
+ });
93
+
94
+ // The join makes a bare `id`/`status` ambiguous. This is the failure that would
95
+ // only ever show up against a real Postgres, so it is worth pinning in SQL text.
96
+ await test('the joined reads qualify every idea column', async () => {
97
+ nextRows = [];
98
+ await inbox.listOpenIdeas({ kind: 'feature', limit: 5, viewerRank: 'xenos' });
99
+ const { sql } = queries[0];
100
+ assert.match(sql, /i\.id/, 'id is unqualified — ambiguous across the join');
101
+ assert.match(sql, /i\.status\s*=\s*'open'/i, 'the status filter is unqualified');
102
+ assert.match(sql, /i\.kind\s*=\s*\$/i, 'the kind filter is unqualified');
103
+ assert.match(sql, /i\.title NOT LIKE/i, 'the skill-friction filter is unqualified');
104
+ assert.match(sql, /ORDER BY\s+i\.captured_at/i, 'the ordering column is unqualified');
105
+ });
106
+
107
+ await test('a triage update returns the row with its grade attached, in one query', async () => {
108
+ await inbox.updateIdea('1', { status: 'discarded' });
109
+ assert.equal(queries.length, 1, 'the update took more than one round trip');
110
+ assert.match(queries[0].sql, /WITH upd AS/i, 'the update is not CTE-wrapped');
111
+ assert.match(queries[0].sql, /UPDATE\s+idea_inbox/i);
112
+ assertReadsGradeWithDefault(queries[0].sql, 'updateIdea');
113
+ });
114
+
115
+ // A just-INSERTed idea provably has no grade row, so capture skips the join —
116
+ // but it must still return the SAME shape every other read returns, or a caller
117
+ // that reads `.grade` off a POST response gets undefined.
118
+ await test('a freshly captured idea comes back as quick without a second query', async () => {
119
+ nextRows = [{ id: '9', title: 'brand new', status: 'open' }];
120
+ const idea = await inbox.captureIdea({ title: 'brand new', kind: 'feature' });
121
+ assert.equal(queries.length, 1, 'capture took a second round trip to learn it was quick');
122
+ assert.equal(idea.grade, 'quick');
123
+ assert.equal(idea.template_key, null);
124
+ assert.equal(idea.field_values, null);
125
+ assert.equal(idea.completeness_score, null);
126
+ assert.equal(idea.title, 'brand new', 'the real row did not survive the projection merge');
127
+ });
128
+
129
+ // -- setIdeaGrade: the guards run BEFORE the DB does -----------------------
130
+
131
+ await test('a full idea round-trips its field values', async () => {
132
+ const fields = {
133
+ purpose: 'give the ideator craft a real output',
134
+ big_picture: 'a scope of work others can deliver against',
135
+ why_you_had_it: 'ideas kept dying as one-liners',
136
+ why_it_matters: 'no re-interviewing the person who had the thought',
137
+ how_it_fits: 'the first shipped goal of the ABI set',
138
+ };
139
+ nextRows = [{ idea_id: '1', grade: 'full', template_key: 'base-v1', field_values: fields, completeness_score: null }];
140
+ const row = await inbox.setIdeaGrade('1', { grade: 'full', fieldValues: fields });
141
+ const { sql, params } = queries[0];
142
+ assert.match(sql, /INSERT INTO ideas_grades/i);
143
+ assert.match(sql, /ON CONFLICT \(idea_id\) DO UPDATE/i, 'grading twice would violate the PK instead of updating');
144
+ // The jsonb goes down as a serialized string with an explicit ::jsonb cast —
145
+ // handing pg a bare JS object binds it as a Postgres record, not json.
146
+ assert.match(sql, /\$4::jsonb/, 'field_values is not cast to jsonb');
147
+ assert.equal(params[3], JSON.stringify(fields));
148
+ assert.equal(params[2], 'base-v1', 'the template key did not default to base-v1');
149
+ assert.deepEqual(row.field_values, fields);
150
+ });
151
+
152
+ await test('an unknown grade is refused before it reaches the DB', async () => {
153
+ const err = await rejectsWith('BAD_GRADE', () => inbox.setIdeaGrade('1', { grade: 'brilliant' }));
154
+ assert.deepEqual(err.valid, ['quick', 'spark', 'full']);
155
+ assert.equal(queries.length, 0, 'a rejected grade still hit the database');
156
+ });
157
+
158
+ await test('a malformed template key is refused before it reaches the DB', async () => {
159
+ await rejectsWith('BAD_TEMPLATE_KEY', () =>
160
+ inbox.setIdeaGrade('1', { grade: 'full', templateKey: 'Base V1' }));
161
+ assert.equal(queries.length, 0);
162
+ });
163
+
164
+ // typeof null === 'object' and typeof [] === 'object', so both need naming.
165
+ await test('field_values must be a map, not a scalar or an array', async () => {
166
+ await rejectsWith('BAD_FIELD_VALUES', () => inbox.setIdeaGrade('1', { grade: 'full', fieldValues: null }));
167
+ await rejectsWith('BAD_FIELD_VALUES', () => inbox.setIdeaGrade('1', { grade: 'full', fieldValues: ['a'] }));
168
+ await rejectsWith('BAD_FIELD_VALUES', () => inbox.setIdeaGrade('1', { grade: 'full', fieldValues: 'purpose' }));
169
+ assert.equal(queries.length, 0);
170
+ });
171
+
172
+ await test('an oversize field_values payload is refused before it reaches the DB', async () => {
173
+ const err = await rejectsWith('FIELD_VALUES_TOO_LARGE', () =>
174
+ inbox.setIdeaGrade('1', { grade: 'full', fieldValues: { purpose: 'x'.repeat(40000) } }));
175
+ assert.equal(err.max, 32768, 'the cap reported to the caller drifted from the DB constraint');
176
+ assert.equal(queries.length, 0);
177
+ });
178
+
179
+ await test('an out-of-range completeness score is refused before it reaches the DB', async () => {
180
+ await rejectsWith('BAD_COMPLETENESS', () => inbox.setIdeaGrade('1', { grade: 'full', completenessScore: 101 }));
181
+ await rejectsWith('BAD_COMPLETENESS', () => inbox.setIdeaGrade('1', { grade: 'full', completenessScore: -1 }));
182
+ await rejectsWith('BAD_COMPLETENESS', () => inbox.setIdeaGrade('1', { grade: 'full', completenessScore: 55.5 }));
183
+ assert.equal(queries.length, 0);
184
+ });
185
+
186
+ // Grading an idea that does not exist trips the FK. It is a 404's worth of
187
+ // information, so it must not surface as an opaque 500.
188
+ await test('grading a missing idea surfaces as IDEA_NOT_FOUND, not a raw FK error', async () => {
189
+ api.pool.query = async () => { const e = new Error('violates foreign key constraint'); e.code = '23503'; throw e; };
190
+ await rejectsWith('IDEA_NOT_FOUND', () => inbox.setIdeaGrade('999', { grade: 'spark' }));
191
+ api.pool.query = async (sql, params = []) => { queries.push({ sql, params }); return { rows: nextRows }; };
192
+ });
193
+
194
+ // -- setIdeaCompleteness: the score-only write ----------------------------
195
+
196
+ await test('setIdeaCompleteness moves only the score', async () => {
197
+ nextRows = [{ idea_id: '1', grade: 'full', completeness_score: 80 }];
198
+ await inbox.setIdeaCompleteness('1', 80);
199
+ const { sql, params } = queries[0];
200
+ assert.match(sql, /UPDATE ideas_grades/i);
201
+ // Scoped to the SET clause: field_values and grade are legitimately in
202
+ // RETURNING (the caller gets the whole row back), so a whole-statement scan
203
+ // would pass for the wrong reason. What must not happen is either column
204
+ // being ASSIGNED — that is the R146 "develop a spark" wipe footgun.
205
+ const setClause = sql.slice(sql.search(/\bSET\b/i), sql.search(/\bWHERE\b/i));
206
+ assert.match(setClause, /completeness_score = \$2/i);
207
+ assert.doesNotMatch(setClause, /field_values/i, 'a score-only write assigns field_values — the R146 wipe footgun');
208
+ assert.doesNotMatch(setClause, /\bgrade\s*=/i, 'a score-only write reassigns the grade');
209
+ assert.deepEqual(params, ['1', 80]);
210
+ });
211
+
212
+ // An idea with no grade row is quick, and a quick idea has nothing to score.
213
+ // That is a no-op, not an error — scoring must never block anything.
214
+ await test('scoring an ungraded idea is a no-op, not an error', async () => {
215
+ nextRows = [];
216
+ assert.equal(await inbox.setIdeaCompleteness('1', 80), null);
217
+ });
218
+
219
+ await test('an out-of-range score is refused by the score-only write too', async () => {
220
+ await rejectsWith('BAD_COMPLETENESS', () => inbox.setIdeaCompleteness('1', 200));
221
+ assert.equal(queries.length, 0);
222
+ });
223
+
224
+ console.log(`\nIdea grade tests: ${passed} passed, ${failed} failed`);
225
+ process.exit(failed === 0 ? 0 : 1);
@@ -34,8 +34,12 @@ api.pool.query = async (sql, params = []) => {
34
34
  const row = rows.get(String(params[0]));
35
35
  return { rows: row ? [row] : [] };
36
36
  }
37
- // UPDATE … RETURNING — apply nothing, just echo a plausible row back so the
38
- // caller's return value is shaped right.
37
+ // The update — apply nothing, just echo a plausible row back so the caller's
38
+ // return value is shaped right. Since task 1002916 the update arrives as
39
+ // `WITH upd AS (UPDATE … RETURNING …) SELECT … LEFT JOIN ideas_grades`, so it
40
+ // does NOT start with SELECT and correctly falls through to here; the
41
+ // assertions below match `UPDATE idea_inbox` rather than anchoring at the
42
+ // start of the statement.
39
43
  const row = rows.get(String(params[0]));
40
44
  return { rows: row ? [{ ...row }] : [] };
41
45
  };
@@ -89,7 +93,7 @@ await test('merging into an already-merged idea is refused (no chains)', async (
89
93
 
90
94
  await test('merging into a live idea writes both the status and the target', async () => {
91
95
  await inbox.updateIdea('1', { status: 'merged', merged_into_idea_id: 2, triage_note: 'same ask' });
92
- const update = queries.find((q) => /^\s*UPDATE/i.test(q.sql));
96
+ const update = queries.find((q) => /UPDATE\s+idea_inbox/i.test(q.sql));
93
97
  assert.ok(update, 'no UPDATE ran');
94
98
  assert.match(update.sql, /merged_into_idea_id = \$\d+/);
95
99
  assert.match(update.sql, /triage_note = \$\d+/);
@@ -102,7 +106,7 @@ await test('merging into a live idea writes both the status and the target', asy
102
106
  await test('clearing the merge link with null skips the target guards', async () => {
103
107
  await inbox.updateIdea('1', { status: 'open', merged_into_idea_id: null });
104
108
  assert.equal(queries.filter((q) => /^\s*SELECT/i.test(q.sql)).length, 0, 'looked up a null target');
105
- const update = queries.find((q) => /^\s*UPDATE/i.test(q.sql));
109
+ const update = queries.find((q) => /UPDATE\s+idea_inbox/i.test(q.sql));
106
110
  assert.ok(update.params.includes(null));
107
111
  });
108
112
 
@@ -131,7 +135,7 @@ await test('the no-chains guard reads the target COLUMN, not the target status',
131
135
 
132
136
  await test('a discard reason is written as the triage note', async () => {
133
137
  await inbox.updateIdea('1', { status: 'discarded', triage_note: 'superseded by the new gate' });
134
- const update = queries.find((q) => /^\s*UPDATE/i.test(q.sql));
138
+ const update = queries.find((q) => /UPDATE\s+idea_inbox/i.test(q.sql));
135
139
  assert.ok(update.params.includes('superseded by the new gate'));
136
140
  });
137
141