@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.
- package/.bongos-core.json +74 -39
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +6 -3
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +103 -2
- package/docs/api-reference.md +6 -5
- package/docs/copy-inventory.md +36 -34
- package/docs/copy-registry.json +56 -38
- package/docs/module-api-changelog.md +4 -0
- package/modules/government/board.js +35 -0
- package/modules/government/db.js +41 -1
- package/modules/government/migrations/government_018_passed_items_index.sql +31 -0
- package/modules/government/routes/government.js +7 -0
- package/modules/hall-ui/public/board-room.css +6 -0
- package/modules/hall-ui/public/board-room.js +31 -0
- package/modules/hall-ui/public/ideas.css +27 -0
- package/modules/hall-ui/public/ideas.html +7 -0
- package/modules/hall-ui/public/ideas.js +99 -3
- package/modules/ideas/grades.js +43 -14
- package/modules/ideas/inbox.js +18 -0
- package/modules/ideas/migrations/ideas_009_recommendation.sql +62 -0
- package/modules/ideas/migrations/ideas_010_goal_ratification.sql +103 -0
- package/modules/ideas/module.json +1 -0
- package/modules/ideas/projection.js +45 -2
- package/modules/ideas/ratify.js +288 -1
- package/modules/ideas/routes/inbox.js +72 -1
- package/modules/ideas/routes/ratify-goal.js +163 -0
- package/modules/ideas/templates.js +16 -4
- package/modules/lifecycle/goal-from-idea.js +131 -0
- package/modules/lifecycle/lifecycle.js +14 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/government_board_view.mjs +5 -1
- package/tests/government_seed.mjs +1 -0
- package/tests/idea_develop_spark.mjs +6 -1
- package/tests/idea_goal_ratification.mjs +553 -0
- 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
|
|
39
|
-
// (declared there for the route validators, mirroring the DB
|
|
40
|
-
// source, so this file can never drift from what the store
|
|
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('./
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 () => {
|