@bongos/core 1.19.936 → 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 +52 -27
- 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 +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +91 -2
- package/docs/api-reference.md +3 -2
- package/docs/module-api-changelog.md +2 -0
- package/modules/government/board.js +31 -0
- package/modules/government/db.js +33 -0
- package/modules/government/migrations/government_018_passed_items_index.sql +31 -0
- package/modules/government/routes/government.js +7 -0
- package/modules/ideas/inbox.js +11 -0
- package/modules/ideas/migrations/ideas_010_goal_ratification.sql +103 -0
- package/modules/ideas/module.json +1 -0
- package/modules/ideas/ratify.js +288 -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_seed.mjs +1 -0
- package/tests/idea_goal_ratification.mjs +553 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
-- ideas_010_goal_ratification.sql — the durable record of a Full Idea having
|
|
2
|
+
-- been ratified INTO A GOAL (task 1004096 / BV2.R17, spec R23 in
|
|
3
|
+
-- docs/specs/bongos-v2-ideator-surface.md; spec decision 9).
|
|
4
|
+
--
|
|
5
|
+
-- WHAT THIS TABLE IS FOR. The ratification writes a goal and a done-when
|
|
6
|
+
-- criterion into the LIFECYCLE tables and flips the idea to `promoted`. Those
|
|
7
|
+
-- rows are the outcome; none of them records the ACT. Afterwards the goal looks
|
|
8
|
+
-- exactly like a goal someone scoped in a planning session, and the idea looks
|
|
9
|
+
-- exactly like an idea that became a task. Nothing left says who ratified it,
|
|
10
|
+
-- under which board decision, with what scope, or what the author had
|
|
11
|
+
-- recommended — and "a thought is never lost between having it and learning what
|
|
12
|
+
-- it caused" (criterion `ideator-nothing-is-lost`) is not met by an outcome with
|
|
13
|
+
-- no provenance. This is the `criterion_proposal_audit` argument (migration 042)
|
|
14
|
+
-- applied to the goal half.
|
|
15
|
+
--
|
|
16
|
+
-- `idea_id` IS UNIQUE, AND THAT IS THE IDEMPOTENCY. The transaction also refuses
|
|
17
|
+
-- an idea whose status is no longer `open`, which catches the ordinary second
|
|
18
|
+
-- attempt; this constraint is what catches the two-concurrent-ratifications race
|
|
19
|
+
-- the status check cannot, because both would read `open` before either wrote.
|
|
20
|
+
-- The second INSERT trips 23505 and its whole transaction rolls back, so the
|
|
21
|
+
-- outcome is one goal, never two.
|
|
22
|
+
--
|
|
23
|
+
-- `recommendation_at_filing` IS PROVENANCE, NOT AN INPUT. It stores what the
|
|
24
|
+
-- AUTHOR recommended (ideas_009) beside what the BOARD chose, so the two can be
|
|
25
|
+
-- compared after the fact — how often does the board agree with the ideator? —
|
|
26
|
+
-- without that ever having influenced the ratification. NULL is first-class and
|
|
27
|
+
-- permanent here for the same reason it is on ideas_grades: every Full Idea
|
|
28
|
+
-- filed before BV2.R13 shipped carries no recommendation, there is no backfill,
|
|
29
|
+
-- and inventing one would put a scope opinion in an author's mouth. Nothing may
|
|
30
|
+
-- branch on this column.
|
|
31
|
+
--
|
|
32
|
+
-- FOREIGN KEYS POINT AT CORE TABLES, which is allowed and is what the module's
|
|
33
|
+
-- own precedent does: ideas_002 references `tasks(id)`, ideas_005 references
|
|
34
|
+
-- `builders(id)`. The namespacing rule (ADR 0083 §Decision #5) is about which
|
|
35
|
+
-- tables a module may CREATE and ALTER, not about what its own rows may point
|
|
36
|
+
-- at.
|
|
37
|
+
--
|
|
38
|
+
-- NONE OF THE FOUR FKs CASCADES, and that is the deliberate part. An audit row
|
|
39
|
+
-- exists to say what happened; a cascade would delete the record of a
|
|
40
|
+
-- ratification along with the goal it produced, which is precisely when someone
|
|
41
|
+
-- wants to read it. `criterion_proposal_audit` (migration 042) takes the same
|
|
42
|
+
-- posture on the same reasoning. The consequence is accepted: a hard DELETE of a
|
|
43
|
+
-- ratified goal is refused while its audit row stands, and that refusal is
|
|
44
|
+
-- correct — goals are archived, not deleted.
|
|
45
|
+
--
|
|
46
|
+
-- `scope_modules` is stored as text[] matching `goals.scope_modules`, because
|
|
47
|
+
-- WHICH WALL the goal was given is part of what was decided — a ratification
|
|
48
|
+
-- that admitted a protected module is an Archon act and the row has to say so
|
|
49
|
+
-- even after someone later widens or narrows the live goal.
|
|
50
|
+
--
|
|
51
|
+
-- Module migrations are ADDITIVE and NAMESPACED. There is no down-migration.
|
|
52
|
+
-- Idempotent: safe to re-run.
|
|
53
|
+
|
|
54
|
+
BEGIN;
|
|
55
|
+
|
|
56
|
+
CREATE TABLE IF NOT EXISTS ideas_goal_ratifications (
|
|
57
|
+
id bigserial PRIMARY KEY,
|
|
58
|
+
idea_id bigint NOT NULL REFERENCES idea_inbox(id),
|
|
59
|
+
goal_id bigint NOT NULL REFERENCES goals(id),
|
|
60
|
+
criterion_id bigint NOT NULL REFERENCES done_when_criteria(id),
|
|
61
|
+
board_item_id bigint,
|
|
62
|
+
target_version text NOT NULL,
|
|
63
|
+
scope_modules text[] NOT NULL DEFAULT '{}',
|
|
64
|
+
recommendation_at_filing text,
|
|
65
|
+
proposed_by bigint REFERENCES builders(id),
|
|
66
|
+
ratified_by bigint NOT NULL REFERENCES builders(id),
|
|
67
|
+
notes_md text NOT NULL DEFAULT '',
|
|
68
|
+
ratified_at timestamptz NOT NULL DEFAULT now()
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
-- One ratification per idea — see the header. Added separately and guarded so it
|
|
72
|
+
-- still lands on an instance where the table was created before this clause
|
|
73
|
+
-- existed.
|
|
74
|
+
DO $$
|
|
75
|
+
BEGIN
|
|
76
|
+
IF NOT EXISTS (
|
|
77
|
+
SELECT 1 FROM pg_constraint WHERE conname = 'ideas_goal_ratifications_idea_uniq'
|
|
78
|
+
) THEN
|
|
79
|
+
ALTER TABLE ideas_goal_ratifications
|
|
80
|
+
ADD CONSTRAINT ideas_goal_ratifications_idea_uniq UNIQUE (idea_id);
|
|
81
|
+
END IF;
|
|
82
|
+
END $$;
|
|
83
|
+
|
|
84
|
+
-- Mirrors ideas_grades_recommendation_chk one table over: the vocabulary is
|
|
85
|
+
-- closed, and NULL means "the author did not say".
|
|
86
|
+
DO $$
|
|
87
|
+
BEGIN
|
|
88
|
+
IF NOT EXISTS (
|
|
89
|
+
SELECT 1 FROM pg_constraint WHERE conname = 'ideas_goal_ratifications_recommendation_chk'
|
|
90
|
+
) THEN
|
|
91
|
+
ALTER TABLE ideas_goal_ratifications
|
|
92
|
+
ADD CONSTRAINT ideas_goal_ratifications_recommendation_chk
|
|
93
|
+
CHECK (recommendation_at_filing IS NULL OR recommendation_at_filing IN ('goal', 'criterion'));
|
|
94
|
+
END IF;
|
|
95
|
+
END $$;
|
|
96
|
+
|
|
97
|
+
-- "What did this goal come from" — the read the goal surface will want when it
|
|
98
|
+
-- shows a goal's origin. The idea direction is already served by the unique
|
|
99
|
+
-- constraint's index.
|
|
100
|
+
CREATE INDEX IF NOT EXISTS ideas_goal_ratifications_goal_idx
|
|
101
|
+
ON ideas_goal_ratifications (goal_id);
|
|
102
|
+
|
|
103
|
+
COMMIT;
|
package/modules/ideas/ratify.js
CHANGED
|
@@ -13,7 +13,9 @@
|
|
|
13
13
|
// Archon ratifies it into a version's scope. Keeping it beside the triage
|
|
14
14
|
// writes made the two look like the same kind of act. They are not.
|
|
15
15
|
|
|
16
|
-
const
|
|
16
|
+
const api = require('../../src/module-api');
|
|
17
|
+
const { pool } = api;
|
|
18
|
+
const { templateFor, fieldOrder } = require('./templates');
|
|
17
19
|
|
|
18
20
|
// -------------------------------------------------------------------------
|
|
19
21
|
// Criterion-proposal ratification (V3.R31 / #252)
|
|
@@ -203,9 +205,294 @@ async function ratifyCriterionProposal(ideaId, ratifierId, { notesMd = '' } = {}
|
|
|
203
205
|
}
|
|
204
206
|
}
|
|
205
207
|
|
|
208
|
+
// -------------------------------------------------------------------------
|
|
209
|
+
// THE GOAL HALF (task 1004096 / BV2.R17, spec R23, criterion
|
|
210
|
+
// `ideator-full-idea-shapes-the-space`)
|
|
211
|
+
//
|
|
212
|
+
// The criterion half above turns a `criterion-proposal` — an idea filed as JSON,
|
|
213
|
+
// explicitly to become a ring inside an existing well — into a
|
|
214
|
+
// `done_when_criteria` row. This half turns a FULL IDEA, filed as five prose
|
|
215
|
+
// answers by someone who was thinking rather than scoping, into a GOAL: a
|
|
216
|
+
// gravity well of its own, with the "what does a successful implementation look
|
|
217
|
+
// like" answer as its first done-when criterion.
|
|
218
|
+
//
|
|
219
|
+
// THE TWO HALVES ARE NOT SYMMETRICAL, and reading them as if they were is the
|
|
220
|
+
// mistake to avoid:
|
|
221
|
+
//
|
|
222
|
+
// • THE SUBJECT DIFFERS. The criterion half parses `body_md` as JSON the
|
|
223
|
+
// proposer wrote FOR ratification. This half reads a graded Full Idea's
|
|
224
|
+
// `field_values` — prose written to express a thought, which nobody wrote
|
|
225
|
+
// with a goal row in mind. So the shape has to be DERIVED, and which answer
|
|
226
|
+
// becomes which part of the goal is a decision recorded below.
|
|
227
|
+
//
|
|
228
|
+
// • THE AUTHORITY DIFFERS. A criterion proposal is one Metic's suggestion and
|
|
229
|
+
// one ratifier's judgement; there is no board in it. A Full Idea has already
|
|
230
|
+
// been through the Board Room (`government.openBoardWindowForIdea` fires when
|
|
231
|
+
// its grade lands), and spec decision 9 is that the ideator RECOMMENDS while
|
|
232
|
+
// the BOARD DECIDES. So this half is GATED on a passed sitting, and the
|
|
233
|
+
// author's own `recommendation` is read for the audit row only.
|
|
234
|
+
//
|
|
235
|
+
// • NOBODY IS PAID HERE. The criterion half awards KARMA_RATIFY_DELTA to the
|
|
236
|
+
// proposer, because nothing else ever will. A Full Idea's author was already
|
|
237
|
+
// paid at the board CLOSE — karma by `awardAuthorRatifyKarma` and credit by
|
|
238
|
+
// `awardIdeaGovernancePassCredit`, both inside the close transaction. Paying
|
|
239
|
+
// again on the ratification would pay twice for one thought, and the second
|
|
240
|
+
// payment would be invisible beside the first. This is why there is no karma
|
|
241
|
+
// INSERT below, and it is a decision rather than an omission.
|
|
242
|
+
|
|
243
|
+
// WHICH ANSWER BECOMES WHICH PART OF THE GOAL, per template vintage.
|
|
244
|
+
//
|
|
245
|
+
// THIS IS RATIFICATION POLICY, NOT TEMPLATE SHAPE, which is why it lives here
|
|
246
|
+
// and not as two more keys inside the declarations in templates.js. Those
|
|
247
|
+
// declarations are FROZEN — a vintage is never edited, because `substanceMin`
|
|
248
|
+
// feeds `completeness_score` and that feeds credits — and "this field supplies a
|
|
249
|
+
// goal's done-when" is a statement about what ratification does with an answer,
|
|
250
|
+
// not about what the question is or what it costs to answer it.
|
|
251
|
+
//
|
|
252
|
+
// `base-v1` IS DELIBERATELY ABSENT, and that is the honest answer rather than a
|
|
253
|
+
// gap. Its five questions are Purpose / Big Picture / Why did you have it / Why
|
|
254
|
+
// does it matter / How does it fit — an argument for a thought, with no answer
|
|
255
|
+
// anywhere in it describing a finished implementation. A goal needs a done-when
|
|
256
|
+
// criterion to be achievable at all (ADR 0263's close gate reads them, so a goal
|
|
257
|
+
// born without one holds its version open forever), and there is nothing in a
|
|
258
|
+
// base-v1 filing to write one FROM. Picking its closest-sounding field would be
|
|
259
|
+
// inventing a success criterion on the author's behalf — the never-invent-a-field
|
|
260
|
+
// rule, and the one place in this file where it bites. A base-v1 Full Idea is
|
|
261
|
+
// refused with TEMPLATE_HAS_NO_GOAL_SHAPE, naming the vintage; its author can
|
|
262
|
+
// resubmit against the current questions, which is what the resubmit path is for.
|
|
263
|
+
//
|
|
264
|
+
// ADDING A VINTAGE MEANS ADDING AN ENTRY HERE. Nothing falls back — an unmapped
|
|
265
|
+
// template refuses, rather than guessing at a key that happens to exist.
|
|
266
|
+
const GOAL_SHAPE_BY_TEMPLATE = Object.freeze({
|
|
267
|
+
// The five questions a Full Idea is asked today (BASE_V2_TEMPLATE). The
|
|
268
|
+
// mapping is the one the proof task (BV2.R25) states: scope from "scope it as
|
|
269
|
+
// best as you can here", done-when from "what does a successful
|
|
270
|
+
// implementation of the idea look like".
|
|
271
|
+
'base-v2': Object.freeze({ doneWhenField: 'success' }),
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
// composeGoalFromFullIdea — the five answers → the goal's prose + its first
|
|
275
|
+
// criterion. PURE: no DB, no clock, no doorway, so the route can refuse before
|
|
276
|
+
// opening a transaction and a test can exercise every branch with no harness.
|
|
277
|
+
//
|
|
278
|
+
// EVERY ANSWER LANDS IN THE DESCRIPTION, including the one that also becomes the
|
|
279
|
+
// criterion. The done-when is a one-line gate a version-close reads; the
|
|
280
|
+
// description is what a builder opening the goal actually reads, and dropping
|
|
281
|
+
// the success answer from it to avoid "duplication" would take the clearest
|
|
282
|
+
// statement of the goal's purpose out of the one place someone looks for it.
|
|
283
|
+
//
|
|
284
|
+
// ORDER COMES FROM `fieldOrder`, never from Object.keys(field_values) — jsonb
|
|
285
|
+
// does not preserve key order, so iterating the stored row shows the five
|
|
286
|
+
// answers in whatever order Postgres chose (templates.js' own header says so).
|
|
287
|
+
// LABELS come from the declaration too, so a v1 row would render under v1
|
|
288
|
+
// labels if a v1 shape were ever declared.
|
|
289
|
+
function composeGoalFromFullIdea({ ideaId, templateKey, fieldValues } = {}) {
|
|
290
|
+
const template = templateFor(templateKey);
|
|
291
|
+
if (!template) return { ok: false, reason: 'unknown_template', template_key: templateKey ?? null };
|
|
292
|
+
const shape = Object.hasOwn(GOAL_SHAPE_BY_TEMPLATE, templateKey) ? GOAL_SHAPE_BY_TEMPLATE[templateKey] : null;
|
|
293
|
+
if (!shape) return { ok: false, reason: 'template_has_no_goal_shape', template_key: templateKey };
|
|
294
|
+
if (!fieldValues || typeof fieldValues !== 'object' || Array.isArray(fieldValues)) {
|
|
295
|
+
return { ok: false, reason: 'field_values_missing' };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
const doneWhen = fieldValues[shape.doneWhenField];
|
|
299
|
+
if (typeof doneWhen !== 'string' || doneWhen.trim() === '') {
|
|
300
|
+
// A Full Idea cannot normally reach here with a blank required field —
|
|
301
|
+
// validateFieldValues refuses that at capture. It is checked anyway because
|
|
302
|
+
// the alternative is a goal whose only criterion is an empty string, which
|
|
303
|
+
// the close gate would read as satisfiable by nothing.
|
|
304
|
+
return { ok: false, reason: 'done_when_answer_missing', field: shape.doneWhenField };
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const sections = [];
|
|
308
|
+
for (const key of fieldOrder(template)) {
|
|
309
|
+
const field = template.fields.find((f) => f.key === key);
|
|
310
|
+
const value = fieldValues[key];
|
|
311
|
+
// A field the stored row simply does not carry is SKIPPED, not rendered as an
|
|
312
|
+
// empty heading. Only required fields are guaranteed present, and a heading
|
|
313
|
+
// with nothing under it reads as a question the author declined to answer
|
|
314
|
+
// rather than one the template added later.
|
|
315
|
+
if (typeof value !== 'string' || value.trim() === '') continue;
|
|
316
|
+
sections.push(`### ${field.label}\n\n${value.trim()}`);
|
|
317
|
+
}
|
|
318
|
+
if (sections.length === 0) return { ok: false, reason: 'field_values_empty' };
|
|
319
|
+
|
|
320
|
+
// The provenance line is FIRST and is prose, not a machine key: the durable
|
|
321
|
+
// machine record is the ideas_goal_ratifications row. This is so a builder
|
|
322
|
+
// opening the goal learns, without asking anyone, that its scope is somebody's
|
|
323
|
+
// thought and where to go and read it whole.
|
|
324
|
+
const origin = `> Ratified from Full Idea [#${ideaId}](/idea/${ideaId}) — its five answers, as written.`;
|
|
325
|
+
return {
|
|
326
|
+
ok: true,
|
|
327
|
+
description: [origin, ...sections].join('\n\n'),
|
|
328
|
+
criterionMd: doneWhen.trim(),
|
|
329
|
+
doneWhenField: shape.doneWhenField,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ratifyFullIdeaAsGoal — the whole act, in one transaction.
|
|
334
|
+
//
|
|
335
|
+
// a. lock the idea + its grade row e. write the ideas_goal_ratifications row
|
|
336
|
+
// b. check the board carried it f. COMMIT
|
|
337
|
+
// c. compose the goal's prose
|
|
338
|
+
// d. create the goal + its criterion (through the `lifecycle` port)
|
|
339
|
+
//
|
|
340
|
+
// WHAT THE CALLER DECIDES AND WHAT IT DOES NOT. `targetVersion`, `scopeModules`
|
|
341
|
+
// and `admitReason` are the BOARD'S choices, stated by the ratifier: no prose
|
|
342
|
+
// answer can supply which version a goal belongs to or which modules it may
|
|
343
|
+
// touch, and guessing either would be the system deciding scope — the exact
|
|
344
|
+
// thing spec decision 9 puts in the board's hands. What the caller may NOT do is
|
|
345
|
+
// decide whether the create is allowed: `actorRank` is passed through to the
|
|
346
|
+
// lifecycle port, which applies every gate POST /goals applies.
|
|
347
|
+
//
|
|
348
|
+
// THE AUTHOR'S `recommendation` IS READ AND NEVER BRANCHED ON. It is copied into
|
|
349
|
+
// the audit row beside what the board chose, so "how often does the board agree
|
|
350
|
+
// with the ideator" is answerable later. A ratification of an idea whose author
|
|
351
|
+
// recommended 'criterion' is NOT refused here — the board may well have decided
|
|
352
|
+
// the thought was bigger than its author thought, and refusing would make the
|
|
353
|
+
// recommendation a decision, which is the one thing it is not.
|
|
354
|
+
async function ratifyFullIdeaAsGoal(
|
|
355
|
+
ideaId, ratifierId,
|
|
356
|
+
{ targetVersion, scopeModules = [], admitReason = null, actorRank = null, notesMd = '' } = {}
|
|
357
|
+
) {
|
|
358
|
+
const id = Number(ideaId);
|
|
359
|
+
if (!Number.isFinite(id)) throw { code: 'BAD_ID' };
|
|
360
|
+
const rid = Number(ratifierId);
|
|
361
|
+
if (!Number.isFinite(rid)) throw { code: 'BAD_RATIFIER' };
|
|
362
|
+
if (!targetVersion || typeof targetVersion !== 'string') throw { code: 'TARGET_VERSION_REQUIRED' };
|
|
363
|
+
|
|
364
|
+
// BOTH PORTS RESOLVE BEFORE THE TRANSACTION OPENS, and both FAIL CLOSED. An
|
|
365
|
+
// instance without the lifecycle module cannot hold goals at all; an instance
|
|
366
|
+
// without government has no board, and "the board decided" cannot be true
|
|
367
|
+
// where there is no board. A missing authority system is a denial, never an
|
|
368
|
+
// admission (the requirePermission posture).
|
|
369
|
+
const lifecycle = api.resolveOptional && api.resolveOptional('lifecycle');
|
|
370
|
+
if (!lifecycle || typeof lifecycle.createRatifiedGoal !== 'function') {
|
|
371
|
+
throw { code: 'NO_LIFECYCLE', detail: 'This instance cannot create goals — the lifecycle module is not mounted.' };
|
|
372
|
+
}
|
|
373
|
+
const government = api.resolveOptional && api.resolveOptional('government');
|
|
374
|
+
if (!government || typeof government.boardPassForIdea !== 'function') {
|
|
375
|
+
throw { code: 'NO_BOARD', detail: 'This instance has no Board Room, so a Full Idea cannot be ratified.' };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const client = await pool.connect();
|
|
379
|
+
try {
|
|
380
|
+
await client.query('BEGIN');
|
|
381
|
+
|
|
382
|
+
// a. Read + lock the idea and its grade row together. LEFT JOIN, the module's
|
|
383
|
+
// standing rule: an idea with no grade row reads as 'quick', and that is a
|
|
384
|
+
// refusal below rather than a missing row here. FOR UPDATE names the idea
|
|
385
|
+
// table explicitly — a bare FOR UPDATE over an outer join is not lockable
|
|
386
|
+
// in Postgres, and locking the idea is what serialises two ratifiers.
|
|
387
|
+
const { rows: ideaRows } = await client.query(
|
|
388
|
+
`SELECT i.id, i.title, i.kind, i.status, i.captured_by,
|
|
389
|
+
COALESCE(g.grade, 'quick') AS grade,
|
|
390
|
+
g.template_key, g.field_values, g.recommendation
|
|
391
|
+
FROM idea_inbox i
|
|
392
|
+
LEFT JOIN ideas_grades g ON g.idea_id = i.id
|
|
393
|
+
WHERE i.id = $1
|
|
394
|
+
FOR UPDATE OF i`,
|
|
395
|
+
[id]
|
|
396
|
+
);
|
|
397
|
+
if (ideaRows.length === 0) throw { code: 'IDEA_NOT_FOUND' };
|
|
398
|
+
const idea = ideaRows[0];
|
|
399
|
+
if (idea.grade !== 'full') throw { code: 'NOT_A_FULL_IDEA', grade: idea.grade };
|
|
400
|
+
if (idea.status !== 'open') throw { code: 'IDEA_NOT_OPEN', status: idea.status };
|
|
401
|
+
|
|
402
|
+
// b. The board gate. Spec decision 9 in one statement: without a sitting that
|
|
403
|
+
// closed `passed`, this is a ratifier's preference and not a decision.
|
|
404
|
+
// Read through the port, outside its own transaction (a closed item is
|
|
405
|
+
// terminal, so there is nothing here for a lock to protect).
|
|
406
|
+
const pass = await government.boardPassForIdea(id);
|
|
407
|
+
if (!pass) throw { code: 'BOARD_HAS_NOT_PASSED' };
|
|
408
|
+
|
|
409
|
+
// c. Compose. Pure, and its refusals are the caller's 400s.
|
|
410
|
+
const composed = composeGoalFromFullIdea({
|
|
411
|
+
ideaId: idea.id, templateKey: idea.template_key, fieldValues: idea.field_values,
|
|
412
|
+
});
|
|
413
|
+
if (!composed.ok) throw { code: 'CANNOT_COMPOSE_GOAL', reason: composed.reason, ...composed };
|
|
414
|
+
|
|
415
|
+
// d. The goal + its first criterion, through the port, on THIS client.
|
|
416
|
+
// `criterionRef` is derived from the idea, so UNIQUE (version_id,
|
|
417
|
+
// criterion_id) is a second guard against a double ratification onto the
|
|
418
|
+
// same version — the `proposal-<idea_id>` trick, one half over.
|
|
419
|
+
//
|
|
420
|
+
// created_by IS THE RATIFIER, NOT THE AUTHOR, and that is a security
|
|
421
|
+
// decision rather than a credit one. createGoal makes created_by the
|
|
422
|
+
// goal's owner AND inserts a 'lead' membership row, and goal leads author
|
|
423
|
+
// tasks inside the scope wall. Filing an idea is open to every rank, so
|
|
424
|
+
// owning the goal your idea became would turn "have a thought that the
|
|
425
|
+
// board likes" into a grant of authority nobody reviewed. The author's
|
|
426
|
+
// claim on the thought is preserved where claims belong — the audit row
|
|
427
|
+
// below, and the origin line in the description.
|
|
428
|
+
const made = await lifecycle.createRatifiedGoal({
|
|
429
|
+
versionId: targetVersion,
|
|
430
|
+
title: idea.title,
|
|
431
|
+
description: composed.description,
|
|
432
|
+
scopeModules,
|
|
433
|
+
createdBy: rid,
|
|
434
|
+
criterionMd: composed.criterionMd,
|
|
435
|
+
criterionRef: `idea-${idea.id}`,
|
|
436
|
+
admitReason,
|
|
437
|
+
actorRank,
|
|
438
|
+
}, { client });
|
|
439
|
+
if (!made.ok) throw { code: made.code, status: made.status, ...made };
|
|
440
|
+
|
|
441
|
+
// e. Mark the idea promoted. promoted_to_task_id stays NULL — a goal is not
|
|
442
|
+
// a task, the same reading the criterion half takes.
|
|
443
|
+
await client.query(
|
|
444
|
+
`UPDATE idea_inbox
|
|
445
|
+
SET status = 'promoted',
|
|
446
|
+
promoted_to_task_id = NULL,
|
|
447
|
+
reviewed_at = now()
|
|
448
|
+
WHERE id = $1`,
|
|
449
|
+
[idea.id]
|
|
450
|
+
);
|
|
451
|
+
|
|
452
|
+
// f. The audit row — who ratified what, under which decision, with what the
|
|
453
|
+
// author had recommended beside it.
|
|
454
|
+
const { rows: auditRows } = await client.query(
|
|
455
|
+
`INSERT INTO ideas_goal_ratifications
|
|
456
|
+
(idea_id, goal_id, criterion_id, board_item_id, target_version,
|
|
457
|
+
scope_modules, recommendation_at_filing, proposed_by, ratified_by, notes_md)
|
|
458
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
|
459
|
+
RETURNING id, ratified_at`,
|
|
460
|
+
[
|
|
461
|
+
idea.id, made.goal.id, made.criterion.id, pass.item_id, targetVersion,
|
|
462
|
+
Array.isArray(scopeModules) ? scopeModules : [],
|
|
463
|
+
idea.recommendation ?? null,
|
|
464
|
+
idea.captured_by, rid, notesMd || '',
|
|
465
|
+
]
|
|
466
|
+
);
|
|
467
|
+
|
|
468
|
+
await client.query('COMMIT');
|
|
469
|
+
return {
|
|
470
|
+
goal: made.goal,
|
|
471
|
+
criterion: made.criterion,
|
|
472
|
+
admitted: made.admitted,
|
|
473
|
+
audit_id: auditRows[0].id,
|
|
474
|
+
ratified_at: auditRows[0].ratified_at,
|
|
475
|
+
board_item_id: pass.item_id,
|
|
476
|
+
// Reported back so a caller can see what the author had recommended and
|
|
477
|
+
// what the board did with it, without a second read. NULL stays NULL.
|
|
478
|
+
recommendation_at_filing: idea.recommendation ?? null,
|
|
479
|
+
done_when_from: composed.doneWhenField,
|
|
480
|
+
proposer_id: idea.captured_by,
|
|
481
|
+
};
|
|
482
|
+
} catch (err) {
|
|
483
|
+
await client.query('ROLLBACK').catch(() => {});
|
|
484
|
+
throw err;
|
|
485
|
+
} finally {
|
|
486
|
+
client.release();
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
206
490
|
module.exports = {
|
|
207
491
|
KARMA_RATIFY_DELTA,
|
|
208
492
|
KARMA_RATIFY_REASON,
|
|
209
493
|
parseProposalBody,
|
|
210
494
|
ratifyCriterionProposal,
|
|
495
|
+
GOAL_SHAPE_BY_TEMPLATE,
|
|
496
|
+
composeGoalFromFullIdea,
|
|
497
|
+
ratifyFullIdeaAsGoal,
|
|
211
498
|
};
|
|
@@ -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;
|