@bongos/core 1.19.935 → 1.19.937

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/.bongos-core.json +74 -39
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +6 -3
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/api/openapi.json +103 -2
  8. package/docs/api-reference.md +6 -5
  9. package/docs/copy-inventory.md +36 -34
  10. package/docs/copy-registry.json +56 -38
  11. package/docs/module-api-changelog.md +4 -0
  12. package/modules/government/board.js +35 -0
  13. package/modules/government/db.js +41 -1
  14. package/modules/government/migrations/government_018_passed_items_index.sql +31 -0
  15. package/modules/government/routes/government.js +7 -0
  16. package/modules/hall-ui/public/board-room.css +6 -0
  17. package/modules/hall-ui/public/board-room.js +31 -0
  18. package/modules/hall-ui/public/ideas.css +27 -0
  19. package/modules/hall-ui/public/ideas.html +7 -0
  20. package/modules/hall-ui/public/ideas.js +99 -3
  21. package/modules/ideas/grades.js +43 -14
  22. package/modules/ideas/inbox.js +18 -0
  23. package/modules/ideas/migrations/ideas_009_recommendation.sql +62 -0
  24. package/modules/ideas/migrations/ideas_010_goal_ratification.sql +103 -0
  25. package/modules/ideas/module.json +1 -0
  26. package/modules/ideas/projection.js +45 -2
  27. package/modules/ideas/ratify.js +288 -1
  28. package/modules/ideas/routes/inbox.js +72 -1
  29. package/modules/ideas/routes/ratify-goal.js +163 -0
  30. package/modules/ideas/templates.js +16 -4
  31. package/modules/lifecycle/goal-from-idea.js +131 -0
  32. package/modules/lifecycle/lifecycle.js +14 -0
  33. package/package-lock.json +2 -2
  34. package/package.json +1 -1
  35. package/src/module-api.js +1 -1
  36. package/tests/government_board_view.mjs +5 -1
  37. package/tests/government_seed.mjs +1 -0
  38. package/tests/idea_develop_spark.mjs +6 -1
  39. package/tests/idea_goal_ratification.mjs +553 -0
  40. package/tests/idea_recommendation.mjs +381 -0
@@ -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
@@ -0,0 +1,163 @@
1
+ // modules/ideas/routes/ratify-goal.js — POST /inbox/:id/ratify-goal, the route
2
+ // that turns a Full Idea the board has carried into a GOAL (task 1004096 /
3
+ // BV2.R17, spec R23, goal 1000095, criterion `ideator-full-idea-shapes-the-space`).
4
+ //
5
+ // WHY IT IS ITS OWN FILE, and not one more route in inbox.js. That file sits at
6
+ // the 1,500-line size ratchet (`scripts/gds/fitness-ratchets.js` OVERSIZE_LINES)
7
+ // and this route pushed it 24 lines over. Trimming back under would have left it
8
+ // at ~1,497 — a landmine for whoever adds the next line — so the route moved
9
+ // out instead. It is the same call `trail-descent.js`, `trail-size.js` and
10
+ // `projection.js` were carved out on, and projection.js's own header names the
11
+ // failure mode being avoided: a ceiling that has "started deciding the design
12
+ // rather than the domain".
13
+ //
14
+ // It is also a clean cut on the domain, not only on the line count. Everything
15
+ // in inbox.js is INTAKE — a thought entering the system and becoming work, or
16
+ // not. This is the opposite end: a thought that has already been through the
17
+ // Board Room, becoming the structure other people's work lands in. The
18
+ // criterion half of ratification (`POST /inbox/:id/ratify`) stays in inbox.js
19
+ // where it has always been; moving it would be a rename with no argument behind
20
+ // it, and the two halves are documented as deliberately asymmetrical anyway
21
+ // (see `../ratify.js`).
22
+ //
23
+ // ROUTE ORDER IS NOT AT RISK HERE, unlike the literal paths inbox.js warns
24
+ // about. Express matches whole paths, so `/inbox/:id` cannot swallow
25
+ // `/inbox/:id/ratify-goal` however the two files are mounted. A literal segment
26
+ // AFTER a param — the `/tasks/removal-proposals` trap the lifecycle map
27
+ // records — is a different shape from a literal segment after a param plus a
28
+ // further segment, which is what this is.
29
+ //
30
+ // MODULE BOUNDARY (ADR 0093 §1): auth gates and request validators reach through
31
+ // the published doorway `src/module-api.js`; `../inbox` is an intra-module
32
+ // sibling. Never a core internal, never another module.
33
+
34
+ const express = require('express');
35
+ const api = require('../../../src/module-api');
36
+ const inbox = require('../inbox');
37
+
38
+ const log = api.logger('ideas');
39
+ const { validateOrRespond, LIMITS, parseId } = api;
40
+
41
+ module.exports = function buildRatifyGoalRouter() {
42
+ const router = express.Router();
43
+
44
+ // The fields a ratify-goal refusal may put on the wire — an ALLOWLIST, not a
45
+ // `{ ...err }` spread. Every shape thrown by `ratifyFullIdeaAsGoal` today is a
46
+ // plain typed refusal this code constructs itself, so a spread is harmless
47
+ // TODAY; what it is not is harmless tomorrow. A later call site that let a
48
+ // real exception carrying a `.code` fall into this catch would echo whatever
49
+ // else that object holds, and the refusals here already spread two internal
50
+ // objects into themselves (the composer's result, the lifecycle port's
51
+ // verdict) — so `ok`, `description` and `criterionMd` are all one edit away
52
+ // from the response body. Naming them explicitly costs nothing and means a
53
+ // new internal field is invisible by default rather than published by default.
54
+ const RATIFY_GOAL_DETAIL_FIELDS = [
55
+ 'message', // a gate's own sentence, written to be read by the caller
56
+ 'reason', // why the composer refused
57
+ 'field', // which answer was missing
58
+ 'template_key', // which vintage has no goal shape
59
+ 'grade', // what the idea actually is, when it is not Full
60
+ 'status', // the idea's status, when it is no longer open
61
+ 'version_status', 'unknown', 'valid', 'scope_modules', 'detail',
62
+ ];
63
+ const ratifyGoalDetail = (err) => {
64
+ const out = {};
65
+ for (const k of RATIFY_GOAL_DETAIL_FIELDS) {
66
+ if (err[k] !== undefined) out[k] = err[k];
67
+ }
68
+ return out;
69
+ };
70
+
71
+ // BV2.R17 (task 1004096, spec R23, goal 1000095) — ratify a FULL IDEA into a
72
+ // GOAL, atomically:
73
+ // - INSERT goals (title from the idea, description = its five answers)
74
+ // - INSERT done_when_criteria on that goal from the "what does a successful
75
+ // implementation look like" answer
76
+ // - UPDATE idea_inbox SET status='promoted', reviewed_at=now()
77
+ // - INSERT ideas_goal_ratifications — who ratified, under which board
78
+ // decision, with the author's own recommendation recorded beside it
79
+ // All of it in one transaction; a partial failure rolls back to an open idea.
80
+ //
81
+ // A SEPARATE ROUTE FROM /ratify, not a mode of it. The two take different
82
+ // subjects (a `criterion-proposal` idea whose body_md is JSON vs. a graded Full
83
+ // Idea whose answers are prose), produce different rows, and differ on whether
84
+ // the board has a say. One route with a discriminator would have to document
85
+ // which half of its own body applies, and a caller who got the discriminator
86
+ // wrong would be told about a field rather than about the act.
87
+ //
88
+ // THE SCOPE AND THE VERSION COME FROM THE REQUEST because nothing else can
89
+ // supply them: no prose answer says which version a goal belongs to or which
90
+ // modules it may touch. THE GATES DO NOT come from the request — the handler
91
+ // passes the caller's LIVE rank through to the lifecycle port, which applies
92
+ // every check POST /goals applies (unknown module key, protected scope →
93
+ // Archon, a building version's goal set closed without a named Archon
94
+ // admission). `idea.ratify`'s floor is Metic, so those gates are load-bearing
95
+ // here rather than ceremonial.
96
+ //
97
+ // Statuses: 400 a bad body / an unratifiable idea / an unmappable template ·
98
+ // 403 a rank gate · 404 no such idea · 409 the idea is not open, the board has
99
+ // not passed it, the version will not take a goal, or it is already ratified.
100
+ router.post(
101
+ '/inbox/:id/ratify-goal',
102
+ api.requireBuilder,
103
+ api.requirePermission('idea.ratify'),
104
+ async (req, res) => {
105
+ if (validateOrRespond(req, res, {
106
+ target_version: { required: true, type: 'string', minLength: 1, maxLength: 64 },
107
+ scope_modules: { type: 'array', maxItems: 64, itemsType: 'string' },
108
+ // Mirrors POST /goals: the PRESENCE of a reason IS the override, so one
109
+ // cannot be requested without stating a cause, and minLength 8 because
110
+ // 'ok' is not a reason.
111
+ admit_reason: { type: 'string', minLength: 8, maxLength: 500 },
112
+ notes_md: { type: 'string', maxLength: LIMITS.NOTES },
113
+ })) return;
114
+ const id = parseId(req, res, { code: 'bad_id' });
115
+ if (id === null) return;
116
+ try {
117
+ const result = await inbox.ratifyFullIdeaAsGoal(id, req.builder.id, {
118
+ targetVersion: req.body.target_version,
119
+ scopeModules: req.body.scope_modules || [],
120
+ admitReason: (req.body.admit_reason || '').trim() || null,
121
+ // The live rank read off the authenticated request, never a body field
122
+ // (ADR 0016) — the port denies when it is absent.
123
+ actorRank: req.builder.rank,
124
+ notesMd: typeof req.body.notes_md === 'string' ? req.body.notes_md : '',
125
+ });
126
+ res.status(201).json({ ok: true, ...result });
127
+ } catch (err) {
128
+ if (err && err.code) {
129
+ // A refusal that already carries its own status is one the lifecycle
130
+ // port named (the goal-creation gates) — rendered as given rather than
131
+ // re-mapped here, so the two files cannot disagree about what a gate
132
+ // returns.
133
+ if (Number.isInteger(err.status)) {
134
+ return res.fail(err.code, err.status, ratifyGoalDetail(err));
135
+ }
136
+ const status =
137
+ err.code === 'IDEA_NOT_FOUND' ? 404 :
138
+ err.code === 'NOT_A_FULL_IDEA' ? 400 :
139
+ err.code === 'CANNOT_COMPOSE_GOAL' ? 400 :
140
+ err.code === 'TARGET_VERSION_REQUIRED' ? 400 :
141
+ err.code === 'BAD_ID' ? 400 :
142
+ err.code === 'BAD_RATIFIER' ? 400 :
143
+ err.code === 'IDEA_NOT_OPEN' ? 409 :
144
+ err.code === 'BOARD_HAS_NOT_PASSED' ? 409 :
145
+ err.code === 'NO_LIFECYCLE' ? 503 :
146
+ err.code === 'NO_BOARD' ? 503 :
147
+ 500;
148
+ if (status !== 500) return res.fail(err.code, status, ratifyGoalDetail(err));
149
+ }
150
+ // The UNIQUE trips are the double-ratification guards: one on the audit
151
+ // row's idea_id, one on (version_id, criterion_id). Either means this
152
+ // idea has already become a goal.
153
+ if (err && err.code === '23505') {
154
+ return res.fail('IDEA_ALREADY_RATIFIED', 409, { detail: 'This idea has already been ratified into a goal.' });
155
+ }
156
+ log.error('[gds] POST /inbox/:id/ratify-goal', err);
157
+ res.fail('ratify_goal_failed', { status: 500, message: 'internal error' });
158
+ }
159
+ }
160
+ );
161
+
162
+ return router;
163
+ };
@@ -35,13 +35,25 @@
35
35
  // resolve its own registry and pass it through; nothing below cares where a
36
36
  // registry came from. No customization UI, no config plumbing — just the seam.
37
37
  //
38
- // Same-module import: DEFAULT_TEMPLATE_KEY and the payload cap live in inbox.js
39
- // (declared there for the route validators, mirroring the DB constraints) — one
40
- // source, so this file can never drift from what the store accepts.
38
+ // Same-module import: DEFAULT_TEMPLATE_KEY and the payload cap live in
39
+ // projection.js (declared there for the route validators, mirroring the DB
40
+ // constraints) — one source, so this file can never drift from what the store
41
+ // accepts.
42
+ //
43
+ // IT READS THEM FROM projection.js, NOT FROM inbox.js, and that is load-bearing
44
+ // rather than tidy. Both names are the same constants either way — inbox.js only
45
+ // re-exports what the 1004115 carve moved down to projection.js — but inbox.js
46
+ // requires ./ratify, so importing them from there makes this file part of a
47
+ // require CYCLE the moment anything in the ratify half wants a template
48
+ // (BV2.R17 wants exactly that: a Full Idea's answers are ordered by the
49
+ // declaration here). projection.js requires NOTHING, by design — its own header
50
+ // calls that out as the property that keeps the module acyclic — so reading the
51
+ // constants from the file that declares them is both shorter and the only
52
+ // spelling that stays acyclic.
41
53
 
42
54
  'use strict';
43
55
 
44
- const { DEFAULT_TEMPLATE_KEY, FIELD_VALUES_MAX } = require('./inbox');
56
+ const { DEFAULT_TEMPLATE_KEY, FIELD_VALUES_MAX } = require('./projection');
45
57
 
46
58
  // The five base fields, verbatim from the owner's brief. `key` is the stable
47
59
  // snake_case identity `field_values` is stored under (renaming a label is free;
@@ -0,0 +1,131 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/goal-from-idea.js — creating the GOAL a ratified Full Idea
4
+ // becomes, inside somebody else's transaction (task 1004096 / BV2.R17, spec R23,
5
+ // goal 1000095, criterion `ideator-full-idea-shapes-the-space`).
6
+ //
7
+ // WHY THIS LIVES IN LIFECYCLE AND NOT IN modules/ideas. `goals`, `versions` and
8
+ // `done_when_criteria` are this module's tables, and the ideas module may not
9
+ // import lifecycle (the one-way module rule, ADR 0083). The alternative was to
10
+ // widen the `lifecycle` port with four primitives — getVersion, createGoal,
11
+ // createCriterion, authorizeGoalCreate — and let the ideas module compose them.
12
+ // That is the shape to avoid: composing them IS the policy, and the policy is
13
+ // "may a goal be created here, by this rank, with this scope", which is a
14
+ // lifecycle judgement. Handing a caller the four pieces hands them four chances
15
+ // to leave one out, and the one most easily left out is the gate.
16
+ //
17
+ // SO THIS IS ONE PORT CALL THAT EITHER CREATES BOTH ROWS OR NAMES ITS REFUSAL.
18
+ // The ideas module supplies the DATA a ratification carries (title, the five
19
+ // answers, the version and scope the board chose) and the actor's live rank; it
20
+ // never gets to decide whether the create is allowed.
21
+ //
22
+ // IT RUNS IN THE CALLER'S TRANSACTION (`deps.client`, required — see below), for
23
+ // the ensureMaintenanceGoal reason: a goal committed on its own connection would
24
+ // survive a rolled-back ratification as an empty goal nobody asked for, pointing
25
+ // at an idea still sitting `open` in the inbox.
26
+ //
27
+ // EVERY GATE POST /goals APPLIES IS APPLIED HERE, and that is the point of the
28
+ // file rather than a courtesy. `idea.ratify`'s floor is METIC (ADR 0157 lowered
29
+ // it from archon), while `POST /goals` refuses a protected-`scope_modules` goal
30
+ // to anyone below Archon and refuses to grow a BUILDING version's goal set at
31
+ // all without a named Archon admission. A ratification path that skipped either
32
+ // would be a Metic-reachable way around both — the scope wall and the R03 gate
33
+ // would still be in the code and no longer in the way of anything.
34
+
35
+ const api = require('../../src/module-api');
36
+ const db = require('./db.js');
37
+ const { authorizeGoalCreate } = require('./routes/goal-route-authz.js');
38
+
39
+ const { isModuleKey, scopeIncludesProtected, moduleKeys } = api.moduleScopeMap;
40
+
41
+ // A typed refusal, never a throw: the caller is inside a transaction it has to
42
+ // roll back itself, and an exception thrown across a port is the thing that
43
+ // makes a rollback easy to forget. `status` travels with the refusal so the
44
+ // route renders it without a second mapping table.
45
+ function refuse(code, status, extra = {}) {
46
+ return { ok: false, code, status, ...extra };
47
+ }
48
+
49
+ // createRatifiedGoal — the goal + its first done-when criterion, in the caller's
50
+ // transaction, or a named refusal.
51
+ //
52
+ // `criterionMd` is REQUIRED and is not a convenience. The criterion is what makes
53
+ // the created row a goal in this project's sense rather than an empty container:
54
+ // ADR 0263's close gate reads done-when criteria, so a goal born with none can
55
+ // never be achieved and holds its version open forever. BV2.R17's whole claim is
56
+ // that a Full Idea's "what does a successful implementation look like" answer IS
57
+ // that criterion — so the two rows are one act, and this function will not make
58
+ // the first without the second.
59
+ //
60
+ // `criterionRef` is the stable `criterion_id` text. The caller derives it from
61
+ // the source idea (`idea-<n>`), which makes the UNIQUE (version_id,
62
+ // criterion_id) constraint a second, database-level guard against ratifying the
63
+ // same idea twice onto the same version — the `proposal-<idea_id>` trick
64
+ // ratifyCriterionProposal already uses, for the same reason.
65
+ async function createRatifiedGoal(
66
+ { versionId, title, description = null, subtitle = null, scopeModules = [], createdBy = null,
67
+ criterionMd, criterionRef, admitReason = null, actorRank = null },
68
+ deps = {}
69
+ ) {
70
+ if (!deps.client) {
71
+ // Not a policy refusal — a programming error, and one whose consequence is a
72
+ // half-committed ratification. It throws rather than returning, because no
73
+ // caller can sensibly handle it and none should try.
74
+ throw new Error('createRatifiedGoal requires deps.client — it must run in the ratification transaction');
75
+ }
76
+ if (typeof title !== 'string' || !title.trim()) return refuse('TITLE_REQUIRED', 400);
77
+ if (typeof criterionMd !== 'string' || !criterionMd.trim()) return refuse('CRITERION_REQUIRED', 400);
78
+ if (typeof criterionRef !== 'string' || !criterionRef.trim()) return refuse('CRITERION_REF_REQUIRED', 400);
79
+
80
+ const scope = Array.isArray(scopeModules) ? scopeModules : [];
81
+ // Fail closed on an unknown module key, POST /goals' first gate: a scope wall
82
+ // that can name a module which does not exist is not a wall.
83
+ const unknown = scope.filter((k) => !isModuleKey(k));
84
+ if (unknown.length) return refuse('UNKNOWN_MODULE', 400, { unknown, valid: moduleKeys() });
85
+ // The two RANK gates. `actorRank` is the caller's LIVE rank read (ADR 0016) —
86
+ // never a value a request body carried — and an absent rank denies, because a
87
+ // rank we were not told is not a rank we may assume.
88
+ if (scopeIncludesProtected(scope) && actorRank !== 'archon') {
89
+ return refuse('ARCHON_REQUIRED_FOR_PROTECTED_SCOPE', 403, { scope_modules: scope });
90
+ }
91
+ const reason = typeof admitReason === 'string' ? admitReason.trim() || null : null;
92
+ // Refused by RANK before the version is fetched, the POST /goals ordering: a
93
+ // Metic must not learn whether the override would have worked.
94
+ if (reason !== null && actorRank !== 'archon') {
95
+ return refuse('ARCHON_REQUIRED_FOR_ADMISSION', 403);
96
+ }
97
+
98
+ // FOR UPDATE, so a version freeze committing between this read and this
99
+ // transaction's COMMIT cannot leave the goal and its criterion on a version
100
+ // that closed underneath them (getVersion's own note explains why a plain read
101
+ // is not enough under READ COMMITTED).
102
+ const version = await db.getVersion(versionId, { client: deps.client, forUpdate: true });
103
+ const decision = authorizeGoalCreate({ version, override: reason !== null });
104
+ if (!decision.ok) {
105
+ return refuse(decision.body.error, decision.status, { message: decision.body.message, version_status: decision.body.status });
106
+ }
107
+
108
+ const goal = await db.createGoal({
109
+ versionId,
110
+ title: title.trim(),
111
+ subtitle,
112
+ description,
113
+ scopeModules: scope,
114
+ createdBy,
115
+ // The admission row is written ONLY when the override actually opened the
116
+ // gate — `overrideApplied`, not "a reason was supplied". A reason on a
117
+ // planning version changed nothing and must not be filed as a skipped gate.
118
+ admission: decision.overrideApplied ? { admittedBy: createdBy, reason } : null,
119
+ }, { client: deps.client });
120
+
121
+ const criterion = await db.createCriterion({
122
+ versionId,
123
+ criterionId: criterionRef.trim(),
124
+ criterionMd,
125
+ goalId: goal.id,
126
+ }, { client: deps.client });
127
+
128
+ return { ok: true, goal, criterion, admitted: decision.overrideApplied };
129
+ }
130
+
131
+ module.exports = { createRatifiedGoal };
@@ -19,6 +19,7 @@
19
19
  const db = require('./db');
20
20
  const classifier = require('./task-classifier');
21
21
  const goalSuggest = require('./goal-suggest');
22
+ const goalFromIdea = require('./goal-from-idea');
22
23
  const helpRequests = require('./help-requests');
23
24
  const taskRecommendations = require('./task-recommendations');
24
25
  const activity = require('./activity');
@@ -66,6 +67,19 @@ module.exports = {
66
67
  // lifecycle tables and the ideas module may not import lifecycle (ADR 0083).
67
68
  ensureMaintenanceGoal: (...a) => db.ensureMaintenanceGoal(...a),
68
69
  currentBuildingVersionId: (...a) => db.currentBuildingVersionId(...a),
70
+ // createRatifiedGoal (task 1004096 / BV2.R17): the GOAL a ratified Full Idea
71
+ // becomes, plus the done-when criterion its "what does a successful
72
+ // implementation look like" answer supplies — both rows, in the ideas module's
73
+ // ratification transaction, or one named refusal.
74
+ //
75
+ // ONE CALL RATHER THAN FOUR PRIMITIVES, deliberately. The ideas module could
76
+ // have been handed getVersion + createGoal + createCriterion +
77
+ // authorizeGoalCreate and left to compose them, but composing them IS the
78
+ // goal-creation policy — the scope wall, the rank gates, the R03 building-
79
+ // version gate — and that policy is a lifecycle judgement. `idea.ratify`'s
80
+ // floor is Metic, so a caller free to leave the gate out would be a
81
+ // Metic-reachable way around it. See modules/lifecycle/goal-from-idea.js.
82
+ createRatifiedGoal: (...a) => goalFromIdea.createRatifiedGoal(...a),
69
83
 
70
84
  // --- work-tracking READS the staying-core read routes resolve --------------
71
85
  // public.js (the status surface): version/ship feed + grade trend.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.935",
3
+ "version": "1.19.937",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.935",
9
+ "version": "1.19.937",
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.935",
3
+ "version": "1.19.937",
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.935'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.937'; // 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');
@@ -42,6 +42,7 @@ const NON_GRANT_MIGRATIONS = new Set([
42
42
  'government_007_amendments.sql', // R14 amendment proposals table (task 1003027)
43
43
  'government_008_closed_items_index.sql', // R16 closed-history index (task 1003029)
44
44
  'government_017_board_section_keys_v2.sql', // BV2.R06 board section_key CHECK widening (task 1004085)
45
+ 'government_018_passed_items_index.sql', // BV2.R17 passed-item lookup index (task 1004096)
45
46
  ]);
46
47
 
47
48
  // Permission keys RENAMED after the grant migrations that first granted them.
@@ -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 () => {