@bongos/core 1.21.7 → 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.
- package/.bongos-core.json +98 -38
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +8 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/adr/0363-who-decides-what-is-a-table-changed-only-by-amendment.md +52 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +182 -3
- package/docs/api-reference.md +4 -2
- package/docs/copy-inventory.md +20 -5
- package/docs/copy-registry.json +163 -16
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-inventory.json +33 -4
- package/docs/page-readings.json +44 -2
- package/modules/discord/board-broadcast.js +2 -0
- package/modules/government/board.js +82 -23
- package/modules/government/db.js +81 -6
- package/modules/government/decision-board.js +300 -0
- package/modules/government/decision-rules.js +312 -0
- package/modules/government/docket.js +49 -9
- package/modules/government/migrations/government_023_decision_rules.sql +190 -0
- package/modules/government/routes/government.js +115 -0
- package/modules/hall-ui/public/city-draw.js +11 -2
- package/modules/hall-ui/public/street-draw.js +585 -0
- package/modules/hall-ui/public/street.css +188 -0
- package/modules/hall-ui/public/street.html +118 -0
- package/modules/hall-ui/public/street.js +364 -0
- package/modules/hall-ui/public/street.states.json +43 -0
- package/modules/hall-ui/records/governor-street.md +14 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/src/bongos/route-rank-check.js +6 -0
- package/src/bongos/serve-internal.js +12 -0
- package/src/module-api.js +1 -1
- package/tests/government_abuse_matrix.mjs +3 -0
- package/tests/government_decision_rules.mjs +701 -0
- package/tests/government_routes.mjs +4 -0
- package/tests/government_seed.mjs +1 -0
- package/tests/hall_audit.mjs +9 -1
- package/tests/hall_page_gate_map.mjs +3 -0
- package/tests/hall_street.mjs +521 -0
|
@@ -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
|
-
|
|
393
|
-
|
|
394
|
-
//
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
(
|
|
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
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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,
|
package/modules/government/db.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
};
|