@bongos/core 1.21.8 → 1.21.10

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.
@@ -528,7 +528,7 @@ async function getIdeaSubjects(ideaIds) {
528
528
  async function getAmendmentSubjects(amendmentIds) {
529
529
  if (!amendmentIds || !amendmentIds.length) return [];
530
530
  const { rows } = await pool.query(
531
- `SELECT id, proposed, rationale_md, proposed_by FROM government_amendments WHERE id = ANY($1::bigint[])`,
531
+ `SELECT id, proposed, rationale_md, proposed_by, kind FROM government_amendments WHERE id = ANY($1::bigint[])`,
532
532
  [amendmentIds],
533
533
  );
534
534
  return rows;
@@ -554,7 +554,7 @@ async function countActiveBuilders() {
554
554
  async function listAmendmentHistory(limit = 50) {
555
555
  const { rows } = await pool.query(
556
556
  `SELECT i.id AS item_id, i.opened_at, i.closed_at, i.outcome, i.constitution AS decided_under,
557
- a.id AS amendment_id, a.proposed, a.rationale_md, a.proposed_by,
557
+ a.id AS amendment_id, a.proposed, a.rationale_md, a.proposed_by, a.kind,
558
558
  b.github_login AS proposer_login, b.display_name AS proposer_name
559
559
  FROM government_board_items i
560
560
  JOIN government_amendments a ON a.id = i.subject_id
@@ -569,16 +569,86 @@ async function listAmendmentHistory(limit = 50) {
569
569
 
570
570
  // --- amendments (BV1.R14 of goal 1000069) ------------------------------------
571
571
 
572
- async function createAmendment({ proposed, rationaleMd = null, proposedBy }) {
572
+ // `kind` (government_023, task 1004529): 'constitution' for a board-block
573
+ // amendment (every row before that task), 'decision_rules' for a change to who
574
+ // decides what at a building. Same table, same subject type, same pass path.
575
+ async function createAmendment({ proposed, rationaleMd = null, proposedBy, kind = 'constitution' }) {
573
576
  const { rows } = await pool.query(
574
- `INSERT INTO government_amendments (proposed, rationale_md, proposed_by)
575
- VALUES ($1::jsonb, $2, $3)
577
+ `INSERT INTO government_amendments (proposed, rationale_md, proposed_by, kind)
578
+ VALUES ($1::jsonb, $2, $3, $4)
576
579
  RETURNING *`,
577
- [JSON.stringify(proposed), rationaleMd, proposedBy],
580
+ [JSON.stringify(proposed), rationaleMd, proposedBy, kind],
578
581
  );
579
582
  return rows[0];
580
583
  }
581
584
 
585
+ // --- who decides what (task 1004529, government_023) -------------------------
586
+
587
+ // Every building's row. Nine rows at most; read per request, uncached (ADR 0016
588
+ // — a passed amendment takes effect on the very next request).
589
+ async function listDecisionRules() {
590
+ const { rows } = await pool.query(
591
+ `SELECT area, decided_by, rule, who, set_by_item_id, updated_at
592
+ FROM government_decision_rules`,
593
+ );
594
+ return rows;
595
+ }
596
+
597
+ // THE ONE WRITE, and it runs only INSIDE the transaction that closes a
598
+ // decision-rules amendment as passed (board.closeItem's afterClose — `client` is
599
+ // that transaction). Two guards besides the caller's: the statement writes
600
+ // nothing unless the named item is a passed decision-rules amendment (the WHERE
601
+ // EXISTS), and government_023's trigger refuses any write that is not — so a
602
+ // change that skipped the board cannot land even through hand-written SQL.
603
+ // Throws when a row did not land, which rolls the close back: the item stays
604
+ // open and the decision is retried rather than recorded as passed-but-unapplied.
605
+ async function applyDecisionRules(client, { itemId, rules }) {
606
+ let written = 0;
607
+ for (const r of rules) {
608
+ const { rowCount } = await client.query(
609
+ `INSERT INTO government_decision_rules (area, decided_by, rule, who, set_by_item_id, updated_at)
610
+ SELECT $1::text, $2::text, $3::text, $4::text, $5::bigint, now()
611
+ WHERE EXISTS (
612
+ SELECT 1 FROM government_board_items i
613
+ JOIN government_amendments a ON a.id = i.subject_id
614
+ WHERE i.id = $5::bigint AND i.subject_type = 'constitutional_amendment'
615
+ AND i.outcome = 'passed' AND a.kind = 'decision_rules')
616
+ ON CONFLICT (area) DO UPDATE
617
+ SET decided_by = EXCLUDED.decided_by, rule = EXCLUDED.rule, who = EXCLUDED.who,
618
+ set_by_item_id = EXCLUDED.set_by_item_id, updated_at = now()`,
619
+ [r.area, r.decided_by, r.rule, r.who, itemId],
620
+ );
621
+ if (rowCount !== 1) throw new Error(`decision rule for ${r.area} not written (item ${itemId} is not a passed decision-rules amendment)`);
622
+ written += 1;
623
+ }
624
+ return written;
625
+ }
626
+
627
+ // A matter put to a building's sitting. Immutable once filed, like an amendment.
628
+ async function createMatter({ area, title, detailMd = null, action = null, proposedBy }) {
629
+ const { rows } = await pool.query(
630
+ `INSERT INTO government_matters (area, title, detail_md, action, proposed_by)
631
+ VALUES ($1, $2, $3, $4::jsonb, $5)
632
+ RETURNING *`,
633
+ [area, title, detailMd, action == null ? null : JSON.stringify(action), proposedBy],
634
+ );
635
+ return rows[0];
636
+ }
637
+
638
+ async function getMatter(id) {
639
+ const { rows } = await pool.query(`SELECT * FROM government_matters WHERE id = $1`, [id]);
640
+ return rows[0] || null;
641
+ }
642
+
643
+ async function getMatterSubjects(matterIds) {
644
+ if (!matterIds || !matterIds.length) return [];
645
+ const { rows } = await pool.query(
646
+ `SELECT id, area, title, detail_md, action, proposed_by FROM government_matters WHERE id = ANY($1::bigint[])`,
647
+ [matterIds],
648
+ );
649
+ return rows;
650
+ }
651
+
582
652
  async function getAmendment(id) {
583
653
  const { rows } = await pool.query(
584
654
  `SELECT * FROM government_amendments WHERE id = $1`,
@@ -804,6 +874,11 @@ module.exports = {
804
874
  getLatestPassedItem,
805
875
  createAmendment,
806
876
  getAmendment,
877
+ listDecisionRules,
878
+ applyDecisionRules,
879
+ createMatter,
880
+ getMatter,
881
+ getMatterSubjects,
807
882
  listOpenBoardItems,
808
883
  listClosedBoardItems,
809
884
  getVotesForItems,
@@ -0,0 +1,300 @@
1
+ 'use strict';
2
+
3
+ // modules/government/decision-board.js — the board's side of WHO DECIDES WHAT
4
+ // (task 1004529, goal 1000125; ADR 0363). decision-rules.js is the pure half
5
+ // (the buildings, the rules, validation, how a row reads); this file is where
6
+ // the table meets the board: proposing a change as an amendment, writing a
7
+ // passed one, putting a building's matter to its sitting, acting on a passed
8
+ // matter, and the check a building's own route runs before one builder acts
9
+ // alone. Split out of board.js so the board's close/vote file stays readable;
10
+ // board.js calls in here and never the other way round (no require cycle).
11
+ //
12
+ // The window-open nudge (board.item.opened) is emitted by board.js's wrappers
13
+ // around proposeDecisionRules and openMatter, beside every other open.
14
+
15
+ const api = require('../../src/module-api');
16
+ const db = require('./db');
17
+ const { loadGovernmentConfig, isClockSafe } = require('./config');
18
+ const { isBoardMember } = require('./board-membership');
19
+ const decisionRules = require('./decision-rules');
20
+
21
+ const log = api.logger('government');
22
+
23
+ // ── who decides what: the amendment-only write (task 1004529) ───────────────
24
+ //
25
+ // THE GUARD. The decision-rules table is changed ONLY here, and only when the
26
+ // row that just closed is a PASSED constitutional amendment whose amendment is
27
+ // of kind 'decision_rules' and is the item's own subject. db.applyDecisionRules
28
+ // carries the same condition in its SQL, and government_023's trigger refuses
29
+ // any write that is not — three layers, so no route, script or hand-written
30
+ // UPDATE can change who decides without the board having decided it.
31
+ // Runs INSIDE the closing transaction (`client`); a throw rolls the close back.
32
+ function decisionRulesWriteAllowed(closedRow, amendment) {
33
+ return !!closedRow && !!amendment
34
+ && closedRow.subject_type === 'constitutional_amendment'
35
+ && closedRow.outcome === 'passed'
36
+ && amendment.kind === 'decision_rules'
37
+ && String(amendment.id) === String(closedRow.subject_id);
38
+ }
39
+
40
+ async function applyPassedDecisionRules(client, closedRow, amendment) {
41
+ if (!decisionRulesWriteAllowed(closedRow, amendment)) {
42
+ throw new Error(`[government] decision rules may only be written by a passed decision-rules amendment (item ${closedRow && closedRow.id})`);
43
+ }
44
+ // Re-validated as a belt: the row was validated strictly at propose time and
45
+ // is immutable since, so a refusal here means the stored row was altered.
46
+ const v = decisionRules.validateDecisionRulesProposal((amendment.proposed || {}).decision_rules);
47
+ if (!v.ok) throw new Error(`[government] passed decision-rules amendment ${amendment.id} no longer validates (${v.reason}) — NOT applied`);
48
+ await db.applyDecisionRules(client, { itemId: closedRow.id, rules: v.rules });
49
+ return v.rules;
50
+ }
51
+
52
+ // A passed matter: act where the building can act by itself, and build the
53
+ // announcement. Never throws — the decision already stands.
54
+ async function actOnPassedMatter(closed) {
55
+ const area = decisionRules.areaForSubjectType(closed.subject_type);
56
+ let matter = null;
57
+ let result = { area, matter_id: String(closed.subject_id), action: null, done: false };
58
+ try {
59
+ matter = await db.getMatter(closed.subject_id);
60
+ const action = matter && matter.action;
61
+ if (area === 'people' && action && (action.type === 'assign_rank' || action.type === 'unassign_rank')) {
62
+ // The same walls as the direct route: a custom rank that still exists,
63
+ // never a seeded one (a seeded rank follows the builder's rank — R95b).
64
+ const rank = await db.getRankByKey(action.rank_key);
65
+ if (!rank || rank.is_system) {
66
+ result = { ...result, action: action.type, done: false, reason: rank ? 'seeded_rank' : 'rank_gone' };
67
+ } else if (action.type === 'assign_rank') {
68
+ await db.assignRank({ builderId: action.builder_id, rankKey: action.rank_key, assignedBy: matter.proposed_by });
69
+ result = { ...result, action: action.type, done: true, builder_id: String(action.builder_id), rank_key: action.rank_key };
70
+ } else {
71
+ await db.unassignRank({ builderId: action.builder_id, rankKey: action.rank_key });
72
+ result = { ...result, action: action.type, done: true, builder_id: String(action.builder_id), rank_key: action.rank_key };
73
+ }
74
+ }
75
+ } catch (err) {
76
+ log.error(`[government] matter ${closed.subject_id} passed at ${area} but its act did NOT run (re-drivable from the passed item): ${err && err.message}`);
77
+ result = { ...result, done: false, reason: 'act_failed' };
78
+ }
79
+ return {
80
+ result,
81
+ payload: {
82
+ item_id: String(closed.id),
83
+ area,
84
+ matter_id: String(closed.subject_id),
85
+ title: matter ? matter.title : null,
86
+ action: matter && matter.action ? matter.action : null,
87
+ acted: result.done,
88
+ passed_at: closed.closed_at || null,
89
+ },
90
+ };
91
+ }
92
+
93
+ // ── who decides what: proposing a change (task 1004529) ─────────────────────
94
+ //
95
+ // A change to who decides what at a building is an AMENDMENT — of kind
96
+ // 'decision_rules' — filed and decided exactly like a constitution amendment:
97
+ // validated strictly, sat under the constitution IN FORCE, ratified by the board
98
+ // (the charter). Its `proposed` holds { decision_rules: [rows] }; the close
99
+ // writes them (applyPassedDecisionRules). There is no other write path.
100
+ //
101
+ // Returns { ok, amendment, item, rules } or a named refusal:
102
+ // validateDecisionRulesProposal's reasons | 'unknown_rank' | 'no_change'
103
+ // | 'missing_proposer'.
104
+ async function proposeDecisionRules({ rules, rationaleMd = null, proposedBy } = {}) {
105
+ const v = decisionRules.validateDecisionRulesProposal(rules);
106
+ if (!v.ok) return v;
107
+ if (proposedBy == null || proposedBy === '') return { ok: false, reason: 'missing_proposer' };
108
+ // A custom rank that does not exist would seat nobody: fail-closed, but a
109
+ // building nobody can ever decide. Refused before filing, like a council.
110
+ for (const key of decisionRules.customRankKeysIn(v.rules)) {
111
+ if (!(await db.getRankByKey(key))) return { ok: false, reason: 'unknown_rank', rank_key: key };
112
+ }
113
+ const live = await db.listDecisionRules();
114
+ if (!decisionRules.changesAnything(v.rules, live)) return { ok: false, reason: 'no_change' };
115
+
116
+ const { board } = loadGovernmentConfig();
117
+ const words = v.rules.map((r) => {
118
+ const resolved = decisionRules.resolveRow(r, { board });
119
+ return `${resolved.name}: ${resolved.who}, ${resolved.rule_words}`;
120
+ }).join('; ');
121
+ const amendment = await db.createAmendment({
122
+ proposed: { decision_rules: v.rules },
123
+ rationaleMd: rationaleMd || `Who decides: ${words}.`,
124
+ proposedBy,
125
+ kind: 'decision_rules',
126
+ });
127
+ const item = await db.openBoardItem({
128
+ subjectType: 'constitutional_amendment',
129
+ subjectId: amendment.id,
130
+ openedBy: proposedBy,
131
+ constitution: board,
132
+ windowMinutes: board.window_minutes,
133
+ });
134
+ return { ok: true, amendment, item, rules: v.rules };
135
+ }
136
+
137
+ // The live table, resolved: every building in street order with who decides it
138
+ // and how. Read per request, uncached.
139
+ async function decisionRulesView() {
140
+ const { board } = loadGovernmentConfig();
141
+ const rows = await db.listDecisionRules();
142
+ return decisionRules.resolveAll(rows, { board });
143
+ }
144
+
145
+ // ── a building's matter: the board's new subjects (task 1004529) ────────────
146
+ //
147
+ // Put a matter to the building's sitting. Allowed ONLY where the building's row
148
+ // names a sitting — i.e. a passed amendment moved it off its fixed gate. Under
149
+ // the default rows every building refuses ('decided_by_gate'), so nothing about
150
+ // today's behaviour changes until a project amends itself.
151
+ //
152
+ // The sitting is snapshotted from the ROW (who and how), not the board: that is
153
+ // the whole point — People decided by a council's consent sits as exactly that.
154
+ const MATTER_TITLE_MAX = 200;
155
+ const MATTER_DETAIL_MAX = 5000;
156
+ const MATTER_ACTION_TYPES = Object.freeze(['assign_rank', 'unassign_rank']);
157
+
158
+ function validateMatterAction(area, action) {
159
+ if (action == null) return { ok: true, action: null };
160
+ if (area !== 'people') return { ok: false, reason: 'action_not_supported_here', expected: 'only People can act by itself today (assign_rank / unassign_rank)' };
161
+ if (typeof action !== 'object' || Array.isArray(action)) return { ok: false, reason: 'bad_action' };
162
+ for (const k of Object.keys(action)) {
163
+ if (!['type', 'builder_id', 'rank_key'].includes(k)) return { ok: false, reason: 'unknown_field', field: `action.${k}` };
164
+ }
165
+ if (!MATTER_ACTION_TYPES.includes(action.type)) return { ok: false, reason: 'bad_action', expected: MATTER_ACTION_TYPES };
166
+ const builderId = Number(action.builder_id);
167
+ if (!Number.isInteger(builderId) || builderId <= 0) return { ok: false, reason: 'bad_action', field: 'action.builder_id' };
168
+ if (typeof action.rank_key !== 'string' || !/^[a-z][a-z0-9-]*$/.test(action.rank_key) || action.rank_key.length > 64) {
169
+ return { ok: false, reason: 'bad_action', field: 'action.rank_key' };
170
+ }
171
+ return { ok: true, action: { type: action.type, builder_id: builderId, rank_key: action.rank_key } };
172
+ }
173
+
174
+ async function openMatter({ area, title, detailMd = null, action = null, proposedBy } = {}) {
175
+ if (!decisionRules.AREA_KEYS.includes(area)) return { ok: false, reason: 'unknown_area', expected: decisionRules.AREA_KEYS };
176
+ if (area === 'charter') return { ok: false, reason: 'charter_matters_are_amendments', expected: 'POST /government/board/items (or /charter, /decision-rules)' };
177
+ if (typeof title !== 'string' || !title.trim() || title.length > MATTER_TITLE_MAX) return { ok: false, reason: 'bad_title' };
178
+ if (detailMd != null && (typeof detailMd !== 'string' || detailMd.length > MATTER_DETAIL_MAX)) return { ok: false, reason: 'bad_detail' };
179
+ if (proposedBy == null || proposedBy === '') return { ok: false, reason: 'missing_proposer' };
180
+ const a = validateMatterAction(area, action);
181
+ if (!a.ok) return a;
182
+
183
+ const { board } = loadGovernmentConfig();
184
+ const rows = await db.listDecisionRules();
185
+ const resolved = decisionRules.resolveAll(rows, { board }).find((r) => r.area === area);
186
+ if (resolved.as_today) {
187
+ return {
188
+ ok: false, reason: 'decided_by_gate', area, gate: resolved.gate,
189
+ what_this_means: `${resolved.name} is decided as it always has been: ${resolved.today} decides alone, on its own page. A sitting here needs an amendment first.`,
190
+ };
191
+ }
192
+ if (!resolved.sitting) return { ok: false, reason: 'no_one_holds_this', area, what_this_means: `No one holds ${resolved.name} yet, so nothing there can be decided.` };
193
+ if (a.action) {
194
+ const rank = await db.getRankByKey(a.action.rank_key);
195
+ if (!rank) return { ok: false, reason: 'unknown_rank', rank_key: a.action.rank_key };
196
+ if (rank.is_system) return { ok: false, reason: 'seeded_rank', rank_key: a.action.rank_key, expected: 'a custom rank — a seeded rank follows the builder\'s rank' };
197
+ }
198
+ const constitution = decisionRules.sittingConstitution(resolved, { board, clockSafe: isClockSafe });
199
+ if (!constitution) return { ok: false, reason: 'no_one_holds_this', area };
200
+
201
+ const matter = await db.createMatter({ area, title: title.trim(), detailMd, action: a.action, proposedBy });
202
+ const item = await db.openBoardItem({
203
+ subjectType: decisionRules.MATTER_SUBJECT_TYPES[area],
204
+ subjectId: matter.id,
205
+ openedBy: proposedBy,
206
+ constitution,
207
+ windowMinutes: constitution.window_minutes,
208
+ });
209
+ return { ok: true, matter, item, decided: resolved };
210
+ }
211
+
212
+ // May this builder act ALONE at this building — the check a building's own
213
+ // direct route runs before acting (People's rank assignments today). Resolved
214
+ // per request against the live row and the builder's live ranks, uncached.
215
+ // Returns { ok: true } or { ok: false, reason: 'decided_by_sitting', … }.
216
+ async function checkActAlone(area, builderId) {
217
+ const { board } = loadGovernmentConfig();
218
+ const rows = await db.listDecisionRules();
219
+ const resolved = decisionRules.resolveAll(rows, { board }).find((r) => r.area === area);
220
+ if (!resolved) return { ok: false, reason: 'unknown_area' };
221
+ if (resolved.as_today) return { ok: true, decided: resolved };
222
+ const seated = resolved.sitting
223
+ ? await isBoardMember(builderId, { membership: resolved.decided_by }).catch(() => false)
224
+ : false;
225
+ if (decisionRules.mayActAlone(resolved, { seated })) return { ok: true, decided: resolved };
226
+ return {
227
+ ok: false,
228
+ reason: 'decided_by_sitting',
229
+ area,
230
+ rule: resolved.rule,
231
+ who: resolved.who,
232
+ what_this_means: resolved.sitting
233
+ ? `${resolved.name} is decided by ${resolved.who} (${resolved.rule_words}). Put it to them as a matter instead.`
234
+ : `No one holds ${resolved.name} yet, so nothing there can be decided.`,
235
+ open_with: resolved.sitting ? 'POST /government/board/matters' : null,
236
+ };
237
+ }
238
+
239
+ // board.matter.passed: after commit, for the module that owns the building. A
240
+ // nudge — nothing that decides or pays may subscribe; the passed row is the fact.
241
+ async function announcePassedMatter(acted) {
242
+ try {
243
+ if (api.emitAsync) await api.emitAsync('board.matter.passed', acted.payload);
244
+ } catch (_) { /* emitAsync isolates listener errors */ }
245
+ }
246
+
247
+ // ── the reads board.js shows ────────────────────────────────────────────────
248
+
249
+ // The Board Room's matter subjects, keyed by id. No query when no matter is on
250
+ // the board, so a project that never amended itself reads exactly what it did.
251
+ async function matterSubjectsById(items) {
252
+ const ids = [...new Set((items || []).filter((i) => decisionRules.isMatterSubject(i.subject_type)).map((i) => i.subject_id))];
253
+ const rows = ids.length ? await db.getMatterSubjects(ids) : [];
254
+ return new Map(rows.map((r) => [String(r.id), r]));
255
+ }
256
+
257
+ // One matter as the room shows it; null when its row is gone (said honestly).
258
+ function projectMatterSubject(r) {
259
+ if (!r) return null;
260
+ return {
261
+ area: r.area, title: r.title, detail_md: r.detail_md || null, action: r.action || null,
262
+ author_id: r.proposed_by == null ? null : String(r.proposed_by),
263
+ };
264
+ }
265
+
266
+ // The table resolved against the live board block, or null when unreadable —
267
+ // the rest of the constitution view still answers.
268
+ async function decisionRulesFor(board) {
269
+ try {
270
+ return decisionRules.resolveAll(await db.listDecisionRules(), { board });
271
+ } catch (_) {
272
+ return null;
273
+ }
274
+ }
275
+
276
+ // GET /government/constitution's `decision_rules` block.
277
+ function constitutionBlock(resolved) {
278
+ if (resolved === null || resolved === undefined) return null;
279
+ return {
280
+ areas: resolved,
281
+ rules: decisionRules.DECISION_RULES.map((r) => ({ key: r.key, words: r.words })),
282
+ changed_by: 'a passed amendment only — POST /government/board/decision-rules files one',
283
+ };
284
+ }
285
+
286
+ module.exports = {
287
+ announcePassedMatter,
288
+ matterSubjectsById,
289
+ projectMatterSubject,
290
+ decisionRulesFor,
291
+ constitutionBlock,
292
+ decisionRulesWriteAllowed,
293
+ applyPassedDecisionRules,
294
+ actOnPassedMatter,
295
+ proposeDecisionRules,
296
+ decisionRulesView,
297
+ validateMatterAction,
298
+ openMatter,
299
+ checkActAlone,
300
+ };