@bongos/core 1.21.8 → 1.21.9

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.
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Bongos API reference
4
4
 
5
- > **Generated from the live route files** — the route file is authoritative. 497 endpoints across 92 route files.
5
+ > **Generated from the live route files** — the route file is authoritative. 499 endpoints across 92 route files.
6
6
  > Machine-readable spec: [`docs/api/openapi.json`](api/openapi.json) (OpenAPI 3.1). Rendered docs site: **`/docs`** (e.g. `cloudbongos.com/docs`).
7
7
 
8
8
  Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/0016-trust-boundary-server-enforced-permissions.md)): `public` < `any-builder` < `metic+archon` < `archon`.
@@ -340,7 +340,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/0016-trust
340
340
  | GET | `/api/bongos/goals/rollups` | `any-builder` | — | GET /goals/rollups?version= — one small row per goal: readiness counters, the last-activity timestamp, the craft set, and the next claima… |
341
341
  | GET | `/api/bongos/goals/suggestions` | `any-builder` | — | GET /goals/suggestions — "which goal should this draft live in?", answered before anything is created (task 1003062 / R07). |
342
342
 
343
- ## `government` (17)
343
+ ## `government` (19)
344
344
 
345
345
  | Method | Path | Rank | Body | Description |
346
346
  |---|---|---|---|---|
@@ -348,10 +348,12 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/0016-trust
348
348
  | POST | `/api/bongos/government/assignments` | `archon` | `builder_id`, `rank_key` | government.manage — assign a CUSTOM rank to a builder. |
349
349
  | DELETE | `/api/bongos/government/assignments/:builderId/:rankKey` | `archon` | — | government.manage — remove a custom-rank assignment. |
350
350
  | POST | `/api/bongos/government/board/charter` | `metic+archon` | `form`, `franchise`, `council_rank`, `pass_rule`, `rationale_md`, `acknowledge_self_removal` | board.item.open — the genesis charter + legislation choice (task 1004427, spec D10): a form of government and/or its two live settings (f… |
351
+ | POST | `/api/bongos/government/board/decision-rules` | `metic+archon` | `rules`, `rationale_md` | board.item.open — propose a change to WHO DECIDES WHAT (task 1004529): one or more buildings' rows of the decision-rules table, filed as … |
351
352
  | GET | `/api/bongos/government/board/genesis-stages` | `any-builder` | — | board.vote.cast — the founding band's read (task 1004429): each genesis stage's latest sitting, and who it is still waiting on under the … |
352
353
  | GET | `/api/bongos/government/board/items` | `any-builder` | — | board.vote.cast — the Board Room view: open items with their clock, the OPEN BALLOT, the closed history (R16 of goal 1000069). |
353
354
  | POST | `/api/bongos/government/board/items` | `metic+archon` | `subject_type`, `proposal`, `rationale_md`, `acknowledge_self_removal` | board.item.open — put an AMENDMENT to the board (R14 of goal 1000069, ADR 0175 §7). |
354
355
  | POST | `/api/bongos/government/board/items/:itemId/votes` | `any-builder` | `direction`, `reason_md`, `section_key` | board.vote.cast — cast a vote on an open board item (R08 of goal 1000069, ADR 0175). |
356
+ | POST | `/api/bongos/government/board/matters` | `metic+archon` | `area`, `title`, `detail_md`, `action` | board.item.open — put a MATTER to a building's sitting (task 1004529): the board's new subjects, one per building other than the charter. |
355
357
  | GET | `/api/bongos/government/constitution` | `any-builder` | — | board.vote.cast — the Constitution view (R18 of goal 1000069): the live board block plus the full amendment history, passed and returned … |
356
358
  | GET | `/api/bongos/government/docket` | `any-builder` | — | board.vote.cast — the governor's DOCKET (task 1004523, goal 1000125): ONE read of every matter waiting on, or moving for, a governor, whi… |
357
359
  | GET | `/api/bongos/government/permissions` | `archon` | — | government.manage — the permission catalog + principals, straight from code (the catalog is CODE, not a table); no DB read. |
@@ -2843,5 +2843,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2843
2843
  landed since 1.21.6 with no explicit bump. run 37038340446. (task 1002620)
2844
2844
  1.21.8 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2845
2845
  landed since 1.21.7 with no explicit bump. run 37041143274. (task 1002620)
2846
+ 1.21.9 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2847
+ landed since 1.21.8 with no explicit bump. run 37042188671. (task 1002620)
2846
2848
  ---------------------------------------------------------------------------
2847
2849
  ```
@@ -149,6 +149,8 @@ const CHANNEL = 'board_room';
149
149
 
150
150
  function subjectLabel(subjectType) {
151
151
  if (subjectType === 'genesis_stage') return 'A genesis stage'; // task 1004429
152
+ // task 1004529: a building's matter (rules_matter, people_matter, ...)
153
+ if (typeof subjectType === 'string' && subjectType.endsWith('_matter')) return 'A matter';
152
154
  return subjectType === 'constitutional_amendment' ? 'A constitutional amendment' : 'A Full Idea';
153
155
  }
154
156
 
@@ -61,6 +61,8 @@ const { describeMembership } = require('./membership-predicate');
61
61
  // and variable names, and the name a constitution reads as.
62
62
  const charter = require('./charter');
63
63
  const recommendations = require('./recommendations');
64
+ const decisionRules = require('./decision-rules');
65
+ const decisionBoard = require('./decision-board');
64
66
 
65
67
  // ADR 0175 §5: the completeness bar a Full Idea must clear to reach the board.
66
68
  // Held equal to economy's IDEA_FULL_PASS_BAR by test — see the header.
@@ -296,7 +298,7 @@ async function castVote({ itemId, voterId, direction, reasonMd = null, sectionKe
296
298
  // owner's rule is that a self-vote confers no advantage.
297
299
  // Not even asked for an amendment — the carve-out means there is nothing to
298
300
  // learn, and the read is a query on the vote path.
299
- const subjectAuthorId = authorRuleApplies(item.subject_type)
301
+ const subjectAuthorId = authorRuleApplies(item.subject_type, constitution)
300
302
  ? await db.getBoardItemAuthor(item)
301
303
  : null;
302
304
  const isSelfCast = subjectAuthorId != null && String(subjectAuthorId) === String(voterId);
@@ -389,12 +391,17 @@ async function castVote({ itemId, voterId, direction, reasonMd = null, sectionKe
389
391
  // Everything downstream reads the author through this: for an amendment the
390
392
  // deciding author is NULL, so the trigger and the tally behave exactly as they
391
393
  // did before task 1003273.
392
- const authorRuleApplies = (subjectType) => subjectType === 'full_idea';
393
-
394
- // The author id that the DECIDING rules should see: the real one for a full
395
- // idea, nobody for an amendment.
396
- const decidingAuthorId = (subjectType, authorId) =>
397
- (authorRuleApplies(subjectType) ? authorId : null);
394
+ // One more scope (task 1004529): a building's matter sat under SIGN-OFF, which
395
+ // IS the author rule (someone who did not build it signs). Other matters are in
396
+ // the amendment's position.
397
+ const authorRuleApplies = (subjectType, constitution = null) =>
398
+ subjectType === 'full_idea'
399
+ || (decisionRules.isMatterSubject(subjectType) && !!constitution && constitution.rule === 'signoff');
400
+
401
+ // The author id the DECIDING rules see: real for a full idea or a sign-off
402
+ // matter, nobody for an amendment.
403
+ const decidingAuthorId = (subjectType, authorId, constitution = null) =>
404
+ (authorRuleApplies(subjectType, constitution) ? authorId : null);
398
405
 
399
406
  const isAuthorVote = (v, authorId) =>
400
407
  authorId != null && String(v.voter_id) === String(authorId);
@@ -581,7 +588,7 @@ async function closeItem(item, { cause = 'vote' } = {}) {
581
588
  // the read stays where it always was — after the tally, only on a pass, for the
582
589
  // payload and the ratification karma. Same query count as before this task on
583
590
  // the amendment path, one extra on an idea close.
584
- let authorId = authorRuleApplies(item.subject_type)
591
+ let authorId = authorRuleApplies(item.subject_type, constitution)
585
592
  ? await db.getBoardItemAuthor(item)
586
593
  : null;
587
594
  // Only `majority` needs the membership denominator; the absence-passes rules
@@ -596,7 +603,7 @@ async function closeItem(item, { cause = 'vote' } = {}) {
596
603
  const outcome = tallyOutcome(votes, {
597
604
  constitution, members,
598
605
  // Subject-typed: an amendment's proposer is not held to the author rule (§7).
599
- authorId: decidingAuthorId(item.subject_type, authorId),
606
+ authorId: decidingAuthorId(item.subject_type, authorId, constitution),
600
607
  });
601
608
 
602
609
  // The payload's remaining reads (reads never ride a tx they don't need). Only a
@@ -616,16 +623,28 @@ async function closeItem(item, { cause = 'vote' } = {}) {
616
623
  : null;
617
624
  const payMoney = reward && typeof reward.awardIdeaGovernancePassCredit === 'function';
618
625
 
626
+ // Read BEFORE the close (task 1004529): a decision-rules amendment is written
627
+ // INSIDE the closing transaction, so a failed write rolls the close back and
628
+ // the sitting is retried. A board-block amendment still applies after commit.
629
+ const amendment = outcome === 'passed' && item.subject_type === 'constitutional_amendment'
630
+ ? await db.getAmendment(item.subject_id)
631
+ : null;
632
+ const rulesAmendment = !!amendment && amendment.kind === 'decision_rules';
633
+ let appliedDecisionRules = null;
634
+
619
635
  const closed = await db.closeBoardItem({
620
636
  itemId: item.id,
621
637
  outcome,
622
- afterClose: payMoney
623
- ? async (client) => {
624
- credit = await reward.awardIdeaGovernancePassCredit(client, {
625
- ideaId: item.subject_id,
626
- authorId,
627
- completenessScore,
628
- });
638
+ afterClose: (payMoney || rulesAmendment)
639
+ ? async (client, closedRow) => {
640
+ if (payMoney) {
641
+ credit = await reward.awardIdeaGovernancePassCredit(client, {
642
+ ideaId: item.subject_id,
643
+ authorId,
644
+ completenessScore,
645
+ });
646
+ }
647
+ if (rulesAmendment) appliedDecisionRules = await decisionBoard.applyPassedDecisionRules(client, closedRow, amendment);
629
648
  }
630
649
  : null,
631
650
  });
@@ -633,8 +652,10 @@ async function closeItem(item, { cause = 'vote' } = {}) {
633
652
 
634
653
  // A genesis stage ratifies no authored work — closing it is the project's
635
654
  // decision that a stage is done, not a reward for the founder who put it up
636
- // (task 1004429). Ideas and amendments pay exactly as before.
655
+ // (task 1004429). Ideas and amendments pay exactly as before; a building's
656
+ // matter pays nothing either (task 1004529).
637
657
  const authorKarma = outcome === 'passed' && closed.subject_type !== 'genesis_stage'
658
+ && !decisionRules.isMatterSubject(closed.subject_type)
638
659
  ? await awardAuthorRatifyKarma(closed)
639
660
  : null;
640
661
 
@@ -643,9 +664,8 @@ async function closeItem(item, { cause = 'vote' } = {}) {
643
664
  // failure, never silently absorbed. The apply is re-drivable from the passed
644
665
  // item + amendment rows. A returned amendment applies nothing.
645
666
  let appliedConstitution = null;
646
- if (outcome === 'passed' && closed.subject_type === 'constitutional_amendment') {
667
+ if (outcome === 'passed' && closed.subject_type === 'constitutional_amendment' && !rulesAmendment) {
647
668
  try {
648
- const amendment = await db.getAmendment(closed.subject_id);
649
669
  if (amendment) {
650
670
  appliedConstitution = governmentConfig.applyBoardAmendment(amendment.proposed);
651
671
  } else {
@@ -656,12 +676,21 @@ async function closeItem(item, { cause = 'vote' } = {}) {
656
676
  }
657
677
  }
658
678
 
679
+ // A PASSED building matter: the building acts where it can by itself (People
680
+ // giving or taking a custom rank, task 1004529), after the commit, loudly on
681
+ // failure — the passed row is the durable decision.
682
+ let acted = null;
683
+ if (outcome === 'passed' && decisionRules.isMatterSubject(closed.subject_type)) {
684
+ acted = await decisionBoard.actOnPassedMatter(closed);
685
+ }
686
+
659
687
  if (outcome === 'passed') {
660
688
  const payload = passPayload(closed, { authorId, completenessScore, passedAt: closed.closed_at });
661
689
  try {
662
690
  if (api.emitAsync) await api.emitAsync('board.item.passed', payload);
663
691
  } catch (_) { /* emitAsync isolates listener errors; nothing that moves credits subscribes */ }
664
692
  if (closed.subject_type === 'genesis_stage') await emitGenesisStagePassed(closed);
693
+ if (acted) await decisionBoard.announcePassedMatter(acted);
665
694
  }
666
695
 
667
696
  // R22's mirror event: BOTH outcomes, after commit, emitAsync. Separate from
@@ -678,6 +707,8 @@ async function closeItem(item, { cause = 'vote' } = {}) {
678
707
  closed: true, item: closed, outcome, cause, author_karma: authorKarma,
679
708
  ...(credit ? { credit } : {}),
680
709
  ...(appliedConstitution ? { applied_constitution: appliedConstitution } : {}),
710
+ ...(appliedDecisionRules ? { applied_decision_rules: appliedDecisionRules } : {}),
711
+ ...(acted ? { acted: acted.result } : {}),
681
712
  };
682
713
  }
683
714
 
@@ -692,7 +723,7 @@ async function maybeCloseAfterVote(itemId) {
692
723
  const members = await resolveBoardMembers(constitution);
693
724
  // task 1003273: the author is an input to the trigger, not just the tally — an
694
725
  // author's cast must never be the vote that ends the sitting.
695
- const authorId = authorRuleApplies(item.subject_type)
726
+ const authorId = authorRuleApplies(item.subject_type, constitution)
696
727
  ? await db.getBoardItemAuthor(item)
697
728
  : null;
698
729
  if (!shouldCloseAfterVote({ constitution, votes, members, authorId })) {
@@ -866,6 +897,17 @@ async function proposeCharter({
866
897
  return out.ok ? { ...out, reads_as: readsAs } : out;
867
898
  }
868
899
 
900
+ // ── who decides what (task 1004529, ADR 0363) ───────────────────────────────
901
+ // The board side lives in decision-board.js; these wrappers add the window-open
902
+ // nudge every open emits.
903
+ const withOpenNudge = (fn) => async (args) => {
904
+ const out = await fn(args);
905
+ if (out.ok && out.item) await emitWindowOpened(out.item);
906
+ return out;
907
+ };
908
+ const proposeDecisionRules = withOpenNudge((a) => decisionBoard.proposeDecisionRules(a));
909
+ const openMatter = withOpenNudge((a) => decisionBoard.openMatter(a));
910
+
869
911
  // ── the board's third subject: closing a genesis stage (task 1004429) ───────
870
912
  //
871
913
  // Spec decision D7 (docs/specs/bongos-v2-project-startup.md): a new project is
@@ -1071,9 +1113,10 @@ async function boardRoomView({ closedLimit = 20 } = {}) {
1071
1113
  const votes = await db.getVotesForItems(all.map((i) => i.id));
1072
1114
  const ideaIds = [...new Set(all.filter((i) => i.subject_type === 'full_idea').map((i) => i.subject_id))];
1073
1115
  const amendmentIds = [...new Set(all.filter((i) => i.subject_type === 'constitutional_amendment').map((i) => i.subject_id))];
1074
- const [ideas, amendments] = await Promise.all([
1116
+ const [ideas, amendments, matterById] = await Promise.all([
1075
1117
  db.getIdeaSubjects(ideaIds),
1076
1118
  db.getAmendmentSubjects(amendmentIds),
1119
+ decisionBoard.matterSubjectsById(all), // task 1004529: no query when no matter is on the board
1077
1120
  ]);
1078
1121
  const ideaById = new Map(ideas.map((r) => [String(r.id), r]));
1079
1122
  const amendmentById = new Map(amendments.map((r) => [String(r.id), r]));
@@ -1118,7 +1161,9 @@ async function boardRoomView({ closedLimit = 20 } = {}) {
1118
1161
  recommendation: r.recommendation ?? null,
1119
1162
  } : null;
1120
1163
  })()
1121
- : (() => { const r = amendmentById.get(String(item.subject_id)); return r ? { proposed: r.proposed, rationale_md: r.rationale_md, author_id: r.proposed_by == null ? null : String(r.proposed_by) } : null; })();
1164
+ : decisionRules.isMatterSubject(item.subject_type)
1165
+ ? decisionBoard.projectMatterSubject(matterById.get(String(item.subject_id)))
1166
+ : (() => { const r = amendmentById.get(String(item.subject_id)); return r ? { proposed: r.proposed, rationale_md: r.rationale_md, ...(r.kind ? { kind: r.kind } : {}), author_id: r.proposed_by == null ? null : String(r.proposed_by) } : null; })();
1122
1167
  return {
1123
1168
  id: String(item.id),
1124
1169
  subject_type: item.subject_type,
@@ -1226,7 +1271,9 @@ async function constitutionView({ historyLimit = 50, type = null, headcount: ask
1226
1271
  closed_at: r.closed_at,
1227
1272
  outcome: r.outcome,
1228
1273
  decided_under: r.decided_under || null,
1274
+ kind: r.kind || 'constitution',
1229
1275
  }));
1276
+ const rulesTable = await decisionBoard.decisionRulesFor(live); // task 1004529; null if unreadable
1230
1277
  return {
1231
1278
  board: live,
1232
1279
  history,
@@ -1244,7 +1291,11 @@ async function constitutionView({ historyLimit = 50, type = null, headcount: ask
1244
1291
  // `historyLimit` in principle; a board with 50 amendments newer than its
1245
1292
  // last passed one is not a shape this system has, and the honest failure
1246
1293
  // there is a missed warning rather than a wrong one.
1247
- divergence: constitutionDivergence(live, history),
1294
+ // Only board-block amendments say what the board block should be (a rules
1295
+ // amendment changes the table, task 1004529).
1296
+ divergence: constitutionDivergence(live, history.filter((h) => h.kind !== 'decision_rules')),
1297
+ // Who decides what at each building, as today or set by a passed amendment.
1298
+ decision_rules: decisionBoard.constitutionBlock(rulesTable),
1248
1299
  // The genesis charter + legislation stages' payload (task 1004427): every
1249
1300
  // form and variable by its real name with its plain explanation, and the
1250
1301
  // form the constitution in force reads as (or Custom). Pure — no query.
@@ -1400,6 +1451,14 @@ async function pendingVotesFor(builderId) {
1400
1451
  }
1401
1452
 
1402
1453
  module.exports = {
1454
+ proposeDecisionRules,
1455
+ openMatter,
1456
+ decisionRulesView: (...a) => decisionBoard.decisionRulesView(...a),
1457
+ decisionRulesWriteAllowed: decisionBoard.decisionRulesWriteAllowed,
1458
+ applyPassedDecisionRules: decisionBoard.applyPassedDecisionRules,
1459
+ validateMatterAction: decisionBoard.validateMatterAction,
1460
+ checkActAlone: (...a) => decisionBoard.checkActAlone(...a),
1461
+ authorRuleApplies,
1403
1462
  GENESIS_STAGES,
1404
1463
  PASS_RULE_WORDS,
1405
1464
  proposeCharter,
@@ -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,