@bongos/core 1.19.163 → 1.19.164

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.163",
6
- "core_contract": "1.19.163",
7
- "source_commit": "cf29eabb9874ecd380580fa762e09cf7ab412a1e",
5
+ "core_version": "1.19.164",
6
+ "core_contract": "1.19.164",
7
+ "source_commit": "779753af3493b89014e91088a53931afe771ec00",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-16T16:52:34.284Z",
9
+ "built_at": "2026-08-16T17:05:29.044Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 305,
13
13
  "agent_docs_stubbed": 21,
14
- "functional_verbatim": 1262,
14
+ "functional_verbatim": 1263,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1588,
20
- "tree_sha256": "5c874eb035676d192575fc9366d0ddb5dac5a4af849f8c7034694ddb591d421c",
19
+ "file_count": 1589,
20
+ "tree_sha256": "ea5a8e73501fb69d4e1d6e808d63a241df8b2ccf76728a49282a328b14b63337",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -367,7 +367,7 @@
367
367
  {
368
368
  "path": "clients/bongos-client/index.d.ts",
369
369
  "mode": "0000644",
370
- "sha256": "bca3cfecb85aa504c645ef360eb2370e72c417e34d171b40936b6e01d0d94e95"
370
+ "sha256": "4245c4fb82c1c4f5c87788030dc4640987fe22e0d588072d4549e9982cea7ecc"
371
371
  },
372
372
  {
373
373
  "path": "clients/bongos-client/index.mjs",
@@ -1347,12 +1347,12 @@
1347
1347
  {
1348
1348
  "path": "docs/api-reference.md",
1349
1349
  "mode": "0000644",
1350
- "sha256": "600c7a58bb59803bea046d379bf1f9c42a03a1c5f05514f4b2edac3b71956b12"
1350
+ "sha256": "879ef6fd06bde11c040c5ef67ccf495f0be6eb95e11be6f8979c9d560a33da64"
1351
1351
  },
1352
1352
  {
1353
1353
  "path": "docs/api/openapi.json",
1354
1354
  "mode": "0000644",
1355
- "sha256": "0b2a7a1534b1bc4b92b3a2e939e5d5e6a7b8907fa0cd7513e5a3a6311d4318e1"
1355
+ "sha256": "9382373e7be837c0ce4e26f23c3cd32bf20ab09858650bdee318dd90a06dedbe"
1356
1356
  },
1357
1357
  {
1358
1358
  "path": "docs/architecture.md",
@@ -3482,12 +3482,12 @@
3482
3482
  {
3483
3483
  "path": "modules/ideas/routes/inbox.js",
3484
3484
  "mode": "0000644",
3485
- "sha256": "6c57608d1eae4159e967f9b471b20cb4c518c2b4acdf9d5ab88cad2afaf7c197"
3485
+ "sha256": "8604c627617cc90fa3197dcf3776ce708546d754b9acc2d45c16dfea97a478cb"
3486
3486
  },
3487
3487
  {
3488
3488
  "path": "modules/ideas/templates.js",
3489
3489
  "mode": "0000644",
3490
- "sha256": "d2816db988207f9f2c9305971ebca9bcd87d62e594facaea115d9f6db4b5d9f0"
3490
+ "sha256": "eade9a2d07da0a39af17c086d188a195be00c544889d61454ed3c6368d39ea04"
3491
3491
  },
3492
3492
  {
3493
3493
  "path": "modules/lifecycle/CLAUDE.md",
@@ -4202,12 +4202,12 @@
4202
4202
  {
4203
4203
  "path": "package-lock.json",
4204
4204
  "mode": "0000644",
4205
- "sha256": "572ae700360b73fdbc62135f01397fd335bcb5a735d113962a8343377672268c"
4205
+ "sha256": "098af85c69e5a9f9aa4dcfd69292d9bbdecfa6bb4294a344740568344fd36673"
4206
4206
  },
4207
4207
  {
4208
4208
  "path": "package.json",
4209
4209
  "mode": "0000644",
4210
- "sha256": "1460066c89ad9a7db9dc62ae0b6766c5e11cc18d8268fb95a4ae8767325569c5"
4210
+ "sha256": "6efe95b7773147ac8801e5d03fe75b7143f1eda728d123dfa6a19dd41deb5f27"
4211
4211
  },
4212
4212
  {
4213
4213
  "path": "public-docs/index.html",
@@ -5802,7 +5802,7 @@
5802
5802
  {
5803
5803
  "path": "src/module-api.js",
5804
5804
  "mode": "0000644",
5805
- "sha256": "4e7edb74be33ea3e2b126dadb6a51dce083c4df6eb8c0521c0c7f484997edbe4"
5805
+ "sha256": "575799bb117e5a9166aabc3abbba42f9b737fda008bc08e1461147a57831683a"
5806
5806
  },
5807
5807
  {
5808
5808
  "path": "src/module-loader/catalog.js",
@@ -6914,6 +6914,11 @@
6914
6914
  "mode": "0000644",
6915
6915
  "sha256": "e7b6af463185ea77f40d62408026c55c7fb077f18999b972564b3b5bc7656ca5"
6916
6916
  },
6917
+ {
6918
+ "path": "tests/idea_full_capture.mjs",
6919
+ "mode": "0000644",
6920
+ "sha256": "95b53f1acfa61f723741b4d33b5e882525903ccc549ae01f3094a788251a60ec"
6921
+ },
6917
6922
  {
6918
6923
  "path": "tests/idea_grades.mjs",
6919
6924
  "mode": "0000644",
@@ -6922,12 +6927,12 @@
6922
6927
  {
6923
6928
  "path": "tests/idea_quick_guard.mjs",
6924
6929
  "mode": "0000644",
6925
- "sha256": "9be4414bbbbb92513a7737d4494265b24c5f5db71a67e840016cc275442df1fb"
6930
+ "sha256": "729c511f9bb3d4a9b7fb76c7b3843e1900c8d4c9cfa30fe08e482d1e41113839"
6926
6931
  },
6927
6932
  {
6928
6933
  "path": "tests/idea_templates.mjs",
6929
6934
  "mode": "0000644",
6930
- "sha256": "3360dcfcccd6ada6375124108a2570d84172977046aa47e1704d4be303ad41f3"
6935
+ "sha256": "943afa2366f92c4d6188ce9f8d68f499aac8d67930f91d6860a2cb83020642ab"
6931
6936
  },
6932
6937
  {
6933
6938
  "path": "tests/idea_triage.mjs",
@@ -222,8 +222,7 @@ export interface PostGovernanceRolesRequest { role_key: string; label: string; d
222
222
  export interface PostGovernanceRolesResponse { role: unknown }
223
223
  export interface PostGovernanceRolesRoleKeyResetResponse { ok: boolean; role_key: unknown; seeded_rank: unknown; permission_count: unknown }
224
224
  export interface PostInboxIdRatifyRequest { notes_md?: string }
225
- export interface PostInboxRequest { title: string; body_md?: string; kind?: string; suggested_version?: string; suggested_priority?: number }
226
- export interface PostInboxResponse { idea: unknown; open_count: unknown; kind_auto: unknown }
225
+ export interface PostInboxRequest { title: string; body_md?: string; kind?: string; suggested_version?: string; suggested_priority?: number; grade?: string; template_key?: string; field_values?: Record<string, unknown> }
227
226
  export interface PostLearningsRequest { title: string; body_md: string; tags?: string[]; related_files?: string[]; source?: string; source_ref?: string; task_id?: number }
228
227
  export interface PostLearningsResponse { skipped?: boolean; reason?: unknown; learning?: unknown }
229
228
  export interface PostLlmCacheLookupRequest { cache_key?: unknown; tokens_saved_est?: unknown }
@@ -607,7 +606,7 @@ export interface BongosClient {
607
606
  /** GET /inbox — rank: any-builder */
608
607
  getInbox(args?: RequestArgs): Promise<GetInboxResponse>;
609
608
  /** POST /inbox — rank: any-builder */
610
- postInbox(args: RequestArgs & { body: PostInboxRequest }): Promise<PostInboxResponse>;
609
+ postInbox(args: RequestArgs & { body: PostInboxRequest }): Promise<ApiResponse>;
611
610
  /** GET /inbox/{id} — rank: any-builder */
612
611
  getInboxId(args?: RequestArgs): Promise<GetInboxIdResponse>;
613
612
  /** PATCH /inbox/{id} — rank: metic+archon */
@@ -5686,14 +5686,7 @@
5686
5686
  },
5687
5687
  "responses": {
5688
5688
  "200": {
5689
- "description": "Success.",
5690
- "content": {
5691
- "application/json": {
5692
- "schema": {
5693
- "$ref": "#/components/schemas/PostInboxResponse"
5694
- }
5695
- }
5696
- }
5689
+ "description": "Success."
5697
5690
  },
5698
5691
  "400": {
5699
5692
  "$ref": "#/components/responses/ValidationFailed"
@@ -15422,6 +15415,16 @@
15422
15415
  "type": "integer",
15423
15416
  "minimum": 0,
15424
15417
  "maximum": 100
15418
+ },
15419
+ "grade": {
15420
+ "type": "string",
15421
+ "maxLength": 16
15422
+ },
15423
+ "template_key": {
15424
+ "type": "string"
15425
+ },
15426
+ "field_values": {
15427
+ "type": "object"
15425
15428
  }
15426
15429
  },
15427
15430
  "required": [
@@ -15429,19 +15432,6 @@
15429
15432
  ],
15430
15433
  "additionalProperties": false
15431
15434
  },
15432
- "PostInboxResponse": {
15433
- "type": "object",
15434
- "properties": {
15435
- "idea": {},
15436
- "open_count": {},
15437
- "kind_auto": {}
15438
- },
15439
- "required": [
15440
- "idea",
15441
- "open_count",
15442
- "kind_auto"
15443
- ]
15444
- },
15445
15435
  "PostLearningsRequest": {
15446
15436
  "type": "object",
15447
15437
  "properties": {
@@ -17109,8 +17099,8 @@
17109
17099
  }
17110
17100
  },
17111
17101
  "x-endpoint-count": 271,
17112
- "x-schema-count": 304,
17102
+ "x-schema-count": 303,
17113
17103
  "x-undocumented-bodies": 8,
17114
- "x-response-schemas": 206,
17104
+ "x-response-schemas": 205,
17115
17105
  "x-generated-by": "scripts/gds/gen-api-docs.js"
17116
17106
  }
@@ -245,7 +245,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
245
245
  | Method | Path | Rank | Body | Description |
246
246
  |---|---|---|---|---|
247
247
  | GET | `/api/bongos/inbox` | `any-builder` | — | read open ideas; skill-friction rows filtered by viewer rank. |
248
- | POST | `/api/bongos/inbox` | `any-builder` | `title`, `body_md`, `kind`, `suggested_version`, `suggested_priority` | for ordinary ideas; metic+ for kind='criterion-proposal'. |
248
+ | POST | `/api/bongos/inbox` | `any-builder` | `title`, `body_md`, `kind`, `suggested_version`, `suggested_priority`, `grade`, `template_key`, `field_values` | for ordinary ideas; metic+ for kind='criterion-proposal'. |
249
249
  | GET | `/api/bongos/inbox/:id` | `any-builder` | — | single idea read for the "#NNN" deep-link resolver (task 1047). |
250
250
  | PATCH | `/api/bongos/inbox/:id` | `metic+archon` | `status`, `kind`, `promoted_to_task_id`, `reviewed_at`, `merged_into_idea_id`, `triage_note` | Metic+ (task 360 / ADR 0018): idea triage (promote/discard/merge) shapes the backlog. |
251
251
  | POST | `/api/bongos/inbox/:id/ratify` | `metic+archon` | `notes_md` | V3.R31 (task 252) — Archon ratifies a criterion-proposal idea, atomically: - INSERT done_when_criteria row on the target version - UPDATE… |
@@ -4,6 +4,10 @@ const express = require('express');
4
4
  // sibling — a module never requires a core internal.
5
5
  const api = require('../../../src/module-api');
6
6
  const inbox = require('../inbox');
7
+ // Full Idea capture (task 1002919 / BV1.R143): the template validator (R141)
8
+ // and the deterministic scorer (R142) — both pure, both walk the declaration.
9
+ const { validateFieldValues } = require('../templates');
10
+ const { completenessScore } = require('../completeness');
7
11
  const { validateOrRespond, LIMITS, parseId } = api;
8
12
 
9
13
  const INBOX_STATUS_VALUES = ['open', 'promoted', 'discarded', 'merged'];
@@ -42,6 +46,12 @@ module.exports = function buildInboxRouter() {
42
46
  kind: { type: 'string', maxLength: 64 },
43
47
  suggested_version: { type: 'string', maxLength: 64 },
44
48
  suggested_priority: { type: 'integer', min: 0, max: 100 },
49
+ // Full Idea capture (R143) — ALL OPTIONAL: omitting every one of these is
50
+ // exactly the pre-grade quick capture (tests/idea_quick_guard.mjs pins
51
+ // that title stays the only required field).
52
+ grade: { type: 'string', maxLength: 16 },
53
+ template_key: { type: 'string', maxLength: inbox.TEMPLATE_KEY_MAX },
54
+ field_values: { type: 'object' },
45
55
  })) return;
46
56
  const body = req.body || {};
47
57
  if (body.kind === 'criterion-proposal') {
@@ -50,6 +60,40 @@ module.exports = function buildInboxRouter() {
50
60
  return res.fail('rank_forbidden', 403, { required: ['metic', 'archon'], actual: rank, reason: "kind='criterion-proposal' is a planning-session write; Metic+ only." });
51
61
  }
52
62
  }
63
+ // Full Idea capture (task 1002919 / BV1.R143). Only 'full' means anything
64
+ // at capture: 'quick' is the default (explicit or omitted — no grade row
65
+ // either way, the R140 absence-is-quick design), and 'spark' is REFUSED
66
+ // here because sparking is the author's later, deliberate act on an
67
+ // EXISTING quick idea (R144's surface) — an idea does not arrive pre-marked.
68
+ const grade = body.grade ?? null;
69
+ if (grade !== null && grade !== 'quick' && grade !== 'full') {
70
+ return res.fail('bad_grade_at_capture', 400, {
71
+ grade, allowed: ['quick', 'full'],
72
+ reason: "capture accepts grade 'quick' (the default) or 'full'; a spark is made from an existing idea, not captured as one.",
73
+ });
74
+ }
75
+ const templateKey = body.template_key ?? undefined;
76
+ if (grade === 'full') {
77
+ // Validate BEFORE any write, so a bad payload is a named 400 and no
78
+ // orphan idea lands. Each error carries { field, reason } — the
79
+ // "clear error naming the missing field" the task's done-when demands.
80
+ //
81
+ // WRAPPED IN try/catch as defence in depth. templateFor's own-property
82
+ // guard already keeps validateFieldValues from throwing on a hostile
83
+ // template_key (the R143 grader's blocker), but this validation runs
84
+ // OUTSIDE the capture try/catch below, so a future consumer that
85
+ // reintroduces a throw would again escape as an unhandled rejection and
86
+ // crash the process. Catching here means the boundary holds regardless:
87
+ // a validator that throws is a 400, never a downed server.
88
+ let v;
89
+ try {
90
+ v = validateFieldValues(body.field_values ?? {}, templateKey ? { templateKey } : undefined);
91
+ } catch (validationErr) {
92
+ console.error('[gds] POST /inbox field-values validation threw:', validationErr);
93
+ return res.fail('invalid_field_values', 400, { errors: [{ field: null, reason: 'unvalidatable' }], template_key: templateKey ?? null });
94
+ }
95
+ if (!v.ok) return res.fail('invalid_field_values', 400, { errors: v.errors, template_key: templateKey ?? null });
96
+ }
53
97
  try {
54
98
  const idea = await inbox.captureIdea({
55
99
  title: body.title,
@@ -59,8 +103,30 @@ module.exports = function buildInboxRouter() {
59
103
  suggestedPriority: body.suggested_priority ?? null,
60
104
  capturedBy: req.builder.id,
61
105
  });
106
+ let grade_warning;
107
+ if (grade === 'full') {
108
+ // The idea is captured; from here every failure is FAIL-OPEN (the
109
+ // task's contract: a scoring/grading hiccup never blocks capture —
110
+ // the idea still lands, as quick, with a named warning).
111
+ try {
112
+ // completenessScore never throws (R142's contract) — null = unscorable.
113
+ const row = await inbox.setIdeaGrade(idea.id, {
114
+ grade: 'full',
115
+ ...(templateKey ? { templateKey } : {}),
116
+ fieldValues: body.field_values,
117
+ completenessScore: completenessScore(body.field_values, templateKey ? { templateKey } : undefined),
118
+ });
119
+ idea.grade = row.grade;
120
+ idea.template_key = row.template_key;
121
+ idea.field_values = row.field_values;
122
+ idea.completeness_score = row.completeness_score;
123
+ } catch (gradeErr) {
124
+ console.error('[gds] POST /inbox full-grade write failed (idea captured, landing as quick):', gradeErr);
125
+ grade_warning = 'captured_but_ungraded';
126
+ }
127
+ }
62
128
  const open_count = await inbox.countOpenIdeas({ viewerRank: req.builder?.rank ?? null });
63
- res.status(201).json({ idea, open_count, kind_auto: !body.kind });
129
+ res.status(201).json({ idea, open_count, kind_auto: !body.kind, ...(grade_warning ? { grade_warning } : {}) });
64
130
  } catch (err) {
65
131
  if (err && err.code) {
66
132
  const status =
@@ -74,7 +74,21 @@ if (BASE_TEMPLATE.key !== DEFAULT_TEMPLATE_KEY) {
74
74
  // whether that is a 400 (capture) or a render-as-raw fallback (display).
75
75
  function templateFor(key, registry = BUILTIN_TEMPLATES) {
76
76
  if (typeof key !== 'string' || !key) return null;
77
- return registry[key] || null;
77
+ // OWN-PROPERTY LOOKUP, never a bare `registry[key]`. `template_key` is
78
+ // attacker-controlled at capture (POST /inbox, R143) and only length-checked,
79
+ // so a key like 'constructor' / '__proto__' / 'toString' would otherwise
80
+ // resolve up the prototype chain to an Object.prototype member — a truthy
81
+ // non-template whose `.fields` is undefined, and fieldOrder(template) then
82
+ // throws a synchronous TypeError from inside the pure validator, OUTSIDE the
83
+ // route's try/catch → an unhandled rejection that crashes the process. One
84
+ // authenticated request, any rank. Object.hasOwn confines resolution to keys
85
+ // the registry actually declares. (Found by the R143 grader.)
86
+ if (!Object.hasOwn(registry, key)) return null;
87
+ const t = registry[key];
88
+ // Defence in depth: even an own key must resolve to a real template shape
89
+ // before any consumer reads `.fields`. A malformed entry is a null, never a
90
+ // throw.
91
+ return t && Array.isArray(t.fields) ? t : null;
78
92
  }
79
93
 
80
94
  // The one legal source of field display order (see header). Returns key order
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.163",
3
+ "version": "1.19.164",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.163",
9
+ "version": "1.19.164",
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.163",
3
+ "version": "1.19.164",
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
@@ -753,8 +753,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
753
753
  // landed since 1.19.161 with no explicit bump. run 31959496815. (task 1002620)
754
754
  // 1.19.163 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
755
755
  // landed since 1.19.162 with no explicit bump. run 31959864781. (task 1002620)
756
+ // 1.19.164 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
757
+ // landed since 1.19.163 with no explicit bump. run 31960518012. (task 1002620)
756
758
  // ---------------------------------------------------------------------------
757
- const CORE_VERSION = '1.19.163'; // CI auto-patch carrier (ADR 0161); see changelog above
759
+ const CORE_VERSION = '1.19.164'; // CI auto-patch carrier (ADR 0161); see changelog above
758
760
 
759
761
  // A namespaced logger so a module's log lines are attributable + consistent.
760
762
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,146 @@
1
+ // tests/idea_full_capture.mjs
2
+ //
3
+ // task 1002919 (BV1.R143, goal 1000062) — POST /inbox accepts a Full Idea.
4
+ //
5
+ // The done-when, as assertions: a Full Idea round-trips with its fields and
6
+ // score intact; an invalid field set is a 400 NAMING the field, refused BEFORE
7
+ // any write; a grade-write failure never blocks capture (fail-open: the idea
8
+ // lands as quick with a named warning); 'spark' is refused at capture (sparking
9
+ // is R144's act on an EXISTING idea); and the quick path stays byte-identical
10
+ // (the deep version of that lives in tests/idea_quick_guard.mjs).
11
+ //
12
+ // DB-free the idea_grades.mjs way (pool.query patched on the doorway before the
13
+ // modules load), driving the REAL route handler through a stub req/res — the
14
+ // same validator, the same error map a client sees.
15
+ //
16
+ // Run: node tests/idea_full_capture.mjs
17
+
18
+ import { strict as assert } from 'node:assert';
19
+ import { createRequire } from 'node:module';
20
+
21
+ const require = createRequire(import.meta.url);
22
+ const api = require('../src/module-api.js');
23
+
24
+ let queries = [];
25
+ let failNextGradeWrite = false;
26
+ api.pool.query = async (sql, params = []) => {
27
+ queries.push({ sql, params });
28
+ if (/INSERT INTO ideas_grades/i.test(sql)) {
29
+ if (failNextGradeWrite) { failNextGradeWrite = false; throw new Error('synthetic grade-write outage'); }
30
+ return { rows: [{ idea_id: params[0], grade: params[1], template_key: params[2], field_values: JSON.parse(params[3]), completeness_score: params[4] }] };
31
+ }
32
+ if (/INSERT INTO idea_inbox/i.test(sql)) {
33
+ return { rows: [{ id: '77', title: params[0], status: 'open' }] };
34
+ }
35
+ if (/SELECT COUNT/i.test(sql) || /count/i.test(sql)) return { rows: [{ n: '3', count: '3' }] };
36
+ return { rows: [] };
37
+ };
38
+
39
+ const { BASE_TEMPLATE } = require('../modules/ideas/templates.js');
40
+ const routesFactory = require('../modules/ideas/routes/inbox.js');
41
+
42
+ // Pull the real POST /inbox handler off the mounted router.
43
+ const router = routesFactory({});
44
+ const layer = router.stack.find((l) => l.route && l.route.path === '/inbox' && l.route.methods.post);
45
+ const handlers = layer.route.stack.map((s) => s.handle);
46
+ const postInbox = handlers[handlers.length - 1]; // after requireBuilder
47
+
48
+ function call(bodyIn) {
49
+ return new Promise((resolve) => {
50
+ const req = { body: bodyIn, builder: { id: '9', rank: 'metic' }, query: {} };
51
+ const res = {
52
+ statusCode: 200,
53
+ status(c) { this.statusCode = c; return this; },
54
+ json(payload) { resolve({ status: this.statusCode, payload }); },
55
+ fail(code, status, extra) {
56
+ const s = typeof status === 'number' ? status : (status && status.status) || 500;
57
+ resolve({ status: s, payload: { error: code, ...(extra || {}) } });
58
+ },
59
+ };
60
+ postInbox(req, res);
61
+ });
62
+ }
63
+
64
+ const FULL = Object.fromEntries(
65
+ BASE_TEMPLATE.fields.map((f) => [f.key, `A substantive answer for ${f.key}, long enough to clear the floor.`]),
66
+ );
67
+
68
+ let passed = 0;
69
+ let failed = 0;
70
+ async function test(name, fn) {
71
+ queries = [];
72
+ try { await fn(); passed++; console.log(` ok ${name}`); }
73
+ catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
74
+ }
75
+
76
+ await test('a Full Idea round-trips: 201 with grade, fields and score intact', async () => {
77
+ const r = await call({ title: 'a full idea', grade: 'full', field_values: FULL });
78
+ assert.equal(r.status, 201);
79
+ assert.equal(r.payload.idea.grade, 'full');
80
+ assert.deepEqual(r.payload.idea.field_values, FULL);
81
+ assert.equal(r.payload.idea.completeness_score, 100, 'scored via R142 at capture');
82
+ assert.ok(queries.some((q) => /INSERT INTO idea_inbox/i.test(q.sql)));
83
+ assert.ok(queries.some((q) => /INSERT INTO ideas_grades/i.test(q.sql)));
84
+ });
85
+
86
+ await test('an invalid field set is a 400 naming the field — and NOTHING is written', async () => {
87
+ const r = await call({ title: 'bad full', grade: 'full', field_values: { ...FULL, rogue: 'x' } });
88
+ assert.equal(r.status, 400);
89
+ assert.equal(r.payload.error, 'invalid_field_values');
90
+ assert.deepEqual(r.payload.errors, [{ field: 'rogue', reason: 'unknown_field' }]);
91
+ assert.equal(queries.length, 0, 'refused before any write — no orphan idea');
92
+ });
93
+
94
+ await test('a missing required field is named the same way', async () => {
95
+ const { fit, ...four } = FULL;
96
+ const r = await call({ title: 'incomplete full', grade: 'full', field_values: four });
97
+ assert.equal(r.status, 400);
98
+ assert.ok(r.payload.errors.some((e) => e.field === 'fit' && e.reason === 'missing_required'));
99
+ });
100
+
101
+ await test("'spark' is refused at capture — sparking is an act on an existing idea (R144)", async () => {
102
+ const r = await call({ title: 'pre-marked?', grade: 'spark' });
103
+ assert.equal(r.status, 400);
104
+ assert.equal(r.payload.error, 'bad_grade_at_capture');
105
+ assert.deepEqual(r.payload.allowed, ['quick', 'full']);
106
+ assert.equal(queries.length, 0);
107
+ });
108
+
109
+ await test('a grade-write failure never blocks capture: idea lands, named warning returned', async () => {
110
+ failNextGradeWrite = true;
111
+ const r = await call({ title: 'resilient full', grade: 'full', field_values: FULL });
112
+ assert.equal(r.status, 201, 'the capture still succeeds');
113
+ assert.equal(r.payload.grade_warning, 'captured_but_ungraded');
114
+ assert.ok(queries.some((q) => /INSERT INTO idea_inbox/i.test(q.sql)), 'the idea landed');
115
+ });
116
+
117
+ await test('the quick path is untouched: a bare capture writes idea_inbox only', async () => {
118
+ const r = await call({ title: 'just a thought' });
119
+ assert.equal(r.status, 201);
120
+ // since R140 every new idea reads grade='quick' BY DEFINITION (the absence
121
+ // design) — what the guard forbids is a grade-table statement, not the label
122
+ assert.equal(r.payload.idea.grade, 'quick');
123
+ assert.ok(!queries.some((q) => /ideas_grades/i.test(q.sql)), 'zero grade statements');
124
+ });
125
+
126
+ await test("an explicit grade:'quick' is the same no-op quick capture", async () => {
127
+ const r = await call({ title: 'explicitly quick', grade: 'quick' });
128
+ assert.equal(r.status, 201);
129
+ assert.ok(!queries.some((q) => /ideas_grades/i.test(q.sql)));
130
+ });
131
+
132
+ await test('SECURITY (R143 grader blocker): a hostile template_key is a 400, the process survives', async () => {
133
+ // The exact crash the grader found: template_key:'constructor' with grade:full.
134
+ // Pre-fix this threw a synchronous TypeError out of the async handler (an
135
+ // unhandled rejection → process down). Now it is a clean 400 and NOTHING is
136
+ // written. Proven at the route layer, over the real handler.
137
+ for (const hostile of ['constructor', '__proto__', 'toString']) {
138
+ const r = await call({ title: 'attack', grade: 'full', field_values: FULL, template_key: hostile });
139
+ assert.equal(r.status, 400, `template_key='${hostile}' must be a 400, not a crash`);
140
+ assert.equal(r.payload.error, 'invalid_field_values');
141
+ assert.equal(queries.length, 0, 'refused before any write');
142
+ }
143
+ });
144
+
145
+ console.log(`\nidea_full_capture: ${passed} passed, ${failed} failed`);
146
+ if (failed > 0) process.exit(1);
@@ -13,9 +13,10 @@
13
13
  // title is the only requirement, and its INSERT touches idea_inbox alone:
14
14
  // ZERO statements against ideas_grades. A quick idea is quick because no
15
15
  // grade row exists (the R140 design), not because one was written.
16
- // 2. THE ROUTE SCHEMA — POST /inbox's validator declares exactly the pre-grade
17
- // field set. No grade / template_key / field_values key exists in it, so no
18
- // client is asked for (or may smuggle) grade-era input on the quick path.
16
+ // 2. THE ROUTE SCHEMA — the five pre-grade keys survive and title stays the
17
+ // ONLY required field. (R143 later added grade-era keys as OPTIONALS —
18
+ // the promise was never "the schema can't grow", it is "no new REQUIRED
19
+ // field appears on the quick path".)
19
20
  // 3. THE READ — an idea with no grade row reads back grade='quick' with null
20
21
  // template/score, via the LEFT JOIN + COALESCE (idea_grades.mjs owns the
21
22
  // deep version of this; here it is asserted once as part of the contract).
@@ -97,15 +98,18 @@ await test('capture: the title requirement is the SAME refusal it always was', a
97
98
 
98
99
  // ── 2. the route schema ─────────────────────────────────────────────────────
99
100
 
100
- await test('POST /inbox declares the pre-grade field set — nothing grade-era, nothing newly required', () => {
101
+ await test('POST /inbox: the pre-grade fields survive and title stays the ONLY requirement', () => {
102
+ // R143 legitimately added grade / template_key / field_values as OPTIONAL
103
+ // keys — the promise this guard protects (R150's done-when) was never "the
104
+ // schema can't grow", it is "no new REQUIRED field appears on the quick
105
+ // path". So the pin is: the five original keys remain, and required is
106
+ // exactly ['title'] — a grade-era key turning required fails here by name.
101
107
  const src = fs.readFileSync(path.join(__dirname, '..', 'modules', 'ideas', 'routes', 'inbox.js'), 'utf8');
102
108
  const post = src.slice(src.indexOf("router.post('/inbox'"), src.indexOf('captureIdea('));
103
109
  const schema = post.slice(post.indexOf('{', post.indexOf('validateOrRespond')), post.indexOf('})') + 1);
104
110
  for (const key of ['title', 'body_md', 'kind', 'suggested_version', 'suggested_priority']) {
105
111
  assert.ok(schema.includes(key), `the quick path still accepts ${key}`);
106
112
  }
107
- assert.doesNotMatch(schema, /grade|template|field_values|completeness/,
108
- 'no grade-era key in the quick capture schema');
109
113
  const required = [...schema.matchAll(/(\w+):\s*\{[^}]*required:\s*true/g)].map((m) => m[1]);
110
114
  assert.deepEqual(required, ['title'], 'title is the ONLY required field, as it always was');
111
115
  });
@@ -62,6 +62,22 @@ test('templateFor: base key resolves, unknown/blank/non-string do not', () => {
62
62
  assert.equal(T.templateFor(null), null);
63
63
  });
64
64
 
65
+ test('SECURITY (R143 grader blocker): a prototype-chain key resolves to null, never a crash', () => {
66
+ // template_key is attacker-controlled at capture and only length-checked, so
67
+ // a bare registry[key] would resolve 'constructor' / '__proto__' / 'toString'
68
+ // up the prototype chain to a truthy Object.prototype member — a non-template
69
+ // whose .fields is undefined — and validateFieldValues would then throw a
70
+ // synchronous TypeError, crashing the process. Own-property lookup returns
71
+ // null for every one of these, and the validator path stays a clean refusal.
72
+ for (const hostile of ['constructor', '__proto__', 'toString', 'valueOf', 'hasOwnProperty', 'isPrototypeOf']) {
73
+ assert.equal(T.templateFor(hostile), null, `templateFor('${hostile}') must be null`);
74
+ assert.doesNotThrow(() => T.validateFieldValues({}, { templateKey: hostile }),
75
+ `validateFieldValues must not throw on template_key='${hostile}'`);
76
+ assert.deepEqual(T.validateFieldValues({}, { templateKey: hostile }).errors,
77
+ [{ field: null, reason: 'unknown_template' }]);
78
+ }
79
+ });
80
+
65
81
  // ── validation follows the declaration ──────────────────────────────────────
66
82
 
67
83
  test('a complete five-field payload validates', () => {