@bongos/core 1.19.936 → 1.19.938

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 (43) hide show
  1. package/.bongos-core.json +69 -44
  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 +3 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0281-an-instance-identity-is-its-own-unix-account-and-pg-role.md +2 -0
  8. package/docs/api/openapi.json +91 -2
  9. package/docs/api-reference.md +3 -2
  10. package/docs/module-api-changelog.md +4 -0
  11. package/docs/recipes/self-host.md +1 -1
  12. package/modules/government/board.js +31 -0
  13. package/modules/government/db.js +33 -0
  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/deploy.js +1 -1
  17. package/modules/ideas/inbox.js +11 -0
  18. package/modules/ideas/migrations/ideas_010_goal_ratification.sql +103 -0
  19. package/modules/ideas/module.json +1 -0
  20. package/modules/ideas/ratify.js +288 -1
  21. package/modules/ideas/routes/ratify-goal.js +163 -0
  22. package/modules/ideas/templates.js +16 -4
  23. package/modules/lifecycle/goal-from-idea.js +131 -0
  24. package/modules/lifecycle/lifecycle.js +14 -0
  25. package/modules/provisioning/core-upgrade.js +1 -1
  26. package/modules/provisioning/routes/core-upgrade.js +1 -1
  27. package/package-lock.json +2 -2
  28. package/package.json +1 -1
  29. package/scripts/gds/audit-authorship.sh +33 -0
  30. package/scripts/gds/db-backup-nightly.sh +1 -1
  31. package/scripts/gds/instance-apex.js +11 -1
  32. package/scripts/gds/provision-config.js +3 -2
  33. package/scripts/gds/provision-core-upgrade.js +7 -7
  34. package/scripts/gds/provision-process.js +2 -2
  35. package/scripts/gds/provision-repo.js +35 -10
  36. package/scripts/gds/provision-units.js +3 -3
  37. package/scripts/gds/provision.js +6 -3
  38. package/src/module-api.js +1 -1
  39. package/tests/core_upgrade_runner.mjs +10 -7
  40. package/tests/government_seed.mjs +1 -0
  41. package/tests/idea_goal_ratification.mjs +553 -0
  42. package/tests/provision.mjs +44 -7
  43. package/tests/provision_account_identity.mjs +16 -0
@@ -1041,6 +1041,36 @@ async function latestRejectionForIdea(ideaId) {
1041
1041
  });
1042
1042
  }
1043
1043
 
1044
+ // ── BV2.R17's read: did the board CARRY this idea? (task 1004096) ───────────
1045
+ //
1046
+ // The ratification half of the ideator surface turns a Full Idea into a goal,
1047
+ // and spec decision 9 is that the ideator RECOMMENDS while the board DECIDES.
1048
+ // The ideas module holds the author's recommendation on its own grade row, so
1049
+ // without this read "the board decides" would be a sentence in a comment: the
1050
+ // only fact stopping a ratification from being one person's opinion is that a
1051
+ // sitting actually closed `passed`.
1052
+ //
1053
+ // Null means "no passing decision on record", which the caller must treat as a
1054
+ // refusal and never as an absence it may work around. A returned item, an item
1055
+ // still open, and an idea that never reached the board all read the same here,
1056
+ // on purpose — none of them is a mandate, and distinguishing them would invite a
1057
+ // caller to accept one of them.
1058
+ //
1059
+ // Frozen, like latestRejectionForIdea, so a caller cannot decorate the record of
1060
+ // a decision and pass it on as though the board had said more than it did.
1061
+ async function boardPassForIdea(ideaId) {
1062
+ const id = Number(ideaId);
1063
+ if (!Number.isFinite(id) || id <= 0) return null;
1064
+ const row = await db.getLatestPassedItem('full_idea', id);
1065
+ if (!row) return null;
1066
+ return Object.freeze({
1067
+ item_id: row.item_id,
1068
+ outcome: 'passed',
1069
+ opened_at: row.opened_at,
1070
+ closed_at: row.closed_at,
1071
+ });
1072
+ }
1073
+
1044
1074
  // ── the summons: what is waiting on THIS member's vote (task 1003271) ───────
1045
1075
  //
1046
1076
  // THE PROBLEM THIS EXISTS TO FIX. Nine items sat open for two days with zero
@@ -1127,6 +1157,7 @@ module.exports = {
1127
1157
  closeItem,
1128
1158
  maybeCloseAfterVote,
1129
1159
  latestRejectionForIdea,
1160
+ boardPassForIdea,
1130
1161
  validateProposedConstitution,
1131
1162
  proposeAmendment,
1132
1163
  boardRoomView,
@@ -610,6 +610,38 @@ async function getLatestReturnedItemWithObjection(subjectType, subjectId) {
610
610
  return rows[0] || null;
611
611
  }
612
612
 
613
+ // BV2.R17's read (task 1004096): the most recent PASSED item for a subject.
614
+ //
615
+ // The mirror of getLatestReturnedItemWithObjection above, and the reason it
616
+ // exists separately rather than as a `outcome` parameter on that one: the
617
+ // returned read exists to carry an OBJECTION back to an author and joins the
618
+ // votes table to find it, while this one exists to answer a single yes/no —
619
+ // "did the board carry this?" — and must not depend on any vote row existing.
620
+ // A passed item commonly has NO votes at all (a zero-vote sitting passes under
621
+ // the absence rules, ADR 0175 §3), so the LEFT JOIN that is merely defensive
622
+ // there would be load-bearing here, which is a good sign the two are different
623
+ // questions.
624
+ //
625
+ // Ordering is closed_at DESC, id DESC: an idea returned once and resubmitted can
626
+ // have several closed items, and it is the latest decision that stands.
627
+ //
628
+ // Served by the partial index `government_board_items_passed_by_subject`
629
+ // (government_018), the mirror of government_006's index for the returned read.
630
+ // It needs its own: the table only grows, government_003's two indexes both key
631
+ // on OPEN items and government_008's keys on closed_at alone for the whole-board
632
+ // history feed, so none of them helps a lookup filtered by subject.
633
+ async function getLatestPassedItem(subjectType, subjectId) {
634
+ const { rows } = await pool.query(
635
+ `SELECT id AS item_id, closed_at, opened_at, constitution AS decided_under
636
+ FROM government_board_items
637
+ WHERE subject_type = $1 AND subject_id = $2 AND outcome = 'passed'
638
+ ORDER BY closed_at DESC, id DESC
639
+ LIMIT 1`,
640
+ [subjectType, subjectId],
641
+ );
642
+ return rows[0] || null;
643
+ }
644
+
613
645
  // The expiry sweep's read (R11): every OPEN item whose deadline has passed, on
614
646
  // the database clock, oldest deadline first. Served by the partial index
615
647
  // government_board_items_open_by_deadline (government_003 built it for exactly
@@ -728,6 +760,7 @@ module.exports = {
728
760
  getIdeaCompleteness,
729
761
  getExpiredOpenBoardItems,
730
762
  getLatestReturnedItemWithObjection,
763
+ getLatestPassedItem,
731
764
  createAmendment,
732
765
  getAmendment,
733
766
  listOpenBoardItems,
@@ -0,0 +1,31 @@
1
+ -- government_018_passed_items_index.sql — index the passed-item lookup
2
+ -- (task 1004096 / BV2.R17; a grader efficiency finding, and right — exactly as
3
+ -- government_006 was for the sibling read one outcome over).
4
+ --
5
+ -- `getLatestPassedItem` answers "did the board CARRY this subject?" and runs on
6
+ -- every Full-Idea→goal ratification. It is the mirror of
7
+ -- `getLatestReturnedItemWithObjection`, and it inherits that read's problem in
8
+ -- full: `government_board_items` only grows — one row per window, and a
9
+ -- returned idea can be resubmitted without bound — while the table's two
10
+ -- OPEN-item indexes (`government_003`) both key on `closed_at IS NULL` and the
11
+ -- one closed-item index (`government_008`) keys on `closed_at` alone, for the
12
+ -- whole-board history feed. None of them helps a lookup that filters by
13
+ -- subject. Without this, a ratification scans historical volume that scales
14
+ -- with every sitting the project has ever held.
15
+ --
16
+ -- Partial on outcome='passed' — the only outcome this read asks for — and keyed
17
+ -- (subject_type, subject_id, closed_at DESC) to match the query's WHERE and
18
+ -- ORDER BY exactly, the government_006 shape. Two narrow partial indexes rather
19
+ -- than one wider index over `outcome`: each read asks for exactly one outcome
20
+ -- and never ranges across them, so a shared index would be larger and no more
21
+ -- useful to either.
22
+ --
23
+ -- Additive, idempotent, no rows touched — safe on any database state.
24
+
25
+ BEGIN;
26
+
27
+ CREATE INDEX IF NOT EXISTS government_board_items_passed_by_subject
28
+ ON government_board_items (subject_type, subject_id, closed_at DESC)
29
+ WHERE outcome = 'passed';
30
+
31
+ COMMIT;
@@ -103,6 +103,13 @@ const governmentPort = {
103
103
  // resubmit path in modules/ideas resolves this to show what was objected to
104
104
  // and to check whether the flagged section actually changed.
105
105
  latestRejectionForIdea: board.latestRejectionForIdea,
106
+ // BV2.R17 (task 1004096): the record of the board having CARRIED an idea, or
107
+ // null. The ideas module's ratification path resolves this to check the one
108
+ // fact that makes turning a Full Idea into a goal a board decision rather
109
+ // than a ratifier's preference. Read-only, like every other entry here — there
110
+ // is no counterpart that records an outcome, and there must never be one: an
111
+ // outcome is written by a close, never by the module reading it.
112
+ boardPassForIdea: board.boardPassForIdea,
106
113
  };
107
114
 
108
115
  function registerSeams() {
@@ -6,7 +6,7 @@
6
6
  // pipeline and the ONLY act that makes shipped work visible to users — and it was
7
7
  // reachable from exactly one place, a shell on the control-plane box holding an ssh key.
8
8
  // An agent could not close the loop either: the permission classifier blocks a mutating
9
- // `sudo -u lars` over ssh by design. So the one gate that is the owner's to hold was the
9
+ // `sudo -u <app user>` over ssh by design. So the one gate that is the owner's to hold was the
10
10
  // one gate with no instrument. This page is the instrument.
11
11
  //
12
12
  // WHY THE PROJECTS ARE HERE AND NOT IN THEIR OWN HALLS. The obvious shape — each owner
@@ -848,4 +848,15 @@ module.exports = {
848
848
  parseProposalBody: ratify.parseProposalBody,
849
849
  KARMA_RATIFY_DELTA: ratify.KARMA_RATIFY_DELTA,
850
850
  KARMA_RATIFY_REASON: ratify.KARMA_RATIFY_REASON,
851
+ // Full-Idea → GOAL ratification, the goal half beside it (task 1004096 /
852
+ // BV2.R17); see ratify.js on why the two halves are not symmetrical.
853
+ //
854
+ // ONLY THE TRANSACTION IS RE-EXPORTED. `composeGoalFromFullIdea` and
855
+ // `GOAL_SHAPE_BY_TEMPLATE` are deliberately NOT on the facade: nothing outside
856
+ // ratify.js calls either, and a re-export no caller needs is an unused export
857
+ // the dead-code ratchet counts. The facade's job is to keep the ~20 callers of
858
+ // the pre-carve surface working, not to publish everything a new file declares
859
+ // — anything that wants the pure composer requires ./ratify directly, as the
860
+ // test does.
861
+ ratifyFullIdeaAsGoal: ratify.ratifyFullIdeaAsGoal,
851
862
  };
@@ -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;
@@ -10,6 +10,7 @@
10
10
  "contributes": {
11
11
  "routes": [
12
12
  "inbox",
13
+ "ratify-goal",
13
14
  "blockers"
14
15
  ],
15
16
  "pollers": [
@@ -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 { pool } = require('../../src/module-api');
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
  };