@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.
- package/.bongos-core.json +55 -30
- 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/module-api-changelog.md +2 -0
- 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/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +6 -0
- package/src/bongos/route-rank-check.js +6 -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
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/government/decision-rules.js — WHO DECIDES WHAT: the per-project table
|
|
4
|
+
// that says, for each building of the government, who decides its matters and by
|
|
5
|
+
// which rule (task 1004529, goal 1000125 the Governor City). Decision of record:
|
|
6
|
+
// docs/adr/0363-who-decides-what-is-a-table-changed-only-by-amendment.md.
|
|
7
|
+
//
|
|
8
|
+
// WHY. Before this, every building's matters were decided one way: whoever held
|
|
9
|
+
// that building's fixed permission acted alone (the rank gate), and only the
|
|
10
|
+
// charter had a board. The reference design (docs/design/mocks/governor-city/
|
|
11
|
+
// Governments.dc.html, "Four governments") shows one project decided four ways —
|
|
12
|
+
// one founder, a council, everyone votes, a board of trustees — and a project
|
|
13
|
+
// that starts as one and amends itself into another. That needs the answer to
|
|
14
|
+
// "who decides, and how" to be DATA a project can change, not code.
|
|
15
|
+
//
|
|
16
|
+
// THE THREE THINGS A ROW CAN SAY (the table is government_023):
|
|
17
|
+
// decided_by 'gate' — AS TODAY. Whoever holds the building's fixed
|
|
18
|
+
// permission acts alone, through its own page. No
|
|
19
|
+
// rule of its own (the effective rule is `decide`).
|
|
20
|
+
// Every building except the charter starts here.
|
|
21
|
+
// decided_by 'board' — the CHARTER only: the constitution's own board,
|
|
22
|
+
// under its own pass rule. The charter is changed by
|
|
23
|
+
// amending the board block, never through this table.
|
|
24
|
+
// decided_by 'nobody' — VACANT (rule 'vacant'): no one holds this building
|
|
25
|
+
// yet. Its matters wait; none can be put to a sitting.
|
|
26
|
+
// decided_by <predicate> — a SITTING: matters here are put to the members the
|
|
27
|
+
// predicate seats (the board's own grammar, rank:<key>,
|
|
28
|
+
// rank:<key>+, rank:a,b — membership-predicate.js) and
|
|
29
|
+
// decided by the row's rule.
|
|
30
|
+
//
|
|
31
|
+
// CHANGED ONLY BY A PASSED AMENDMENT. There is no settings route that writes the
|
|
32
|
+
// table. A change is filed as an amendment of kind 'decision_rules'
|
|
33
|
+
// (board.proposeDecisionRules), decided by the board in force like every other
|
|
34
|
+
// amendment, and written INSIDE the transaction that closes the sitting as
|
|
35
|
+
// passed (board.closeItem). The database refuses any other write (government_023's
|
|
36
|
+
// trigger), so a hand-edited row is refused too.
|
|
37
|
+
//
|
|
38
|
+
// PURE: no db, no requires beyond the grammar and the catalog. The board and the
|
|
39
|
+
// docket read the table through db.js and hand the rows here.
|
|
40
|
+
|
|
41
|
+
const { parseMembershipPredicate, describeMembership } = require('./membership-predicate');
|
|
42
|
+
const { RANK_ORDER } = require('./catalog');
|
|
43
|
+
|
|
44
|
+
// The nine buildings, in street order (held equal to docket-vocab BUILDINGS by
|
|
45
|
+
// test). `gate` is the fixed permission that decides the building's matters
|
|
46
|
+
// today — the atom its own pages require, named so a reader can see what a
|
|
47
|
+
// sitting replaces. `acts` lists the direct acts in THIS module that already
|
|
48
|
+
// defer to the row (task 1004529 wired People's rank assignments); every other
|
|
49
|
+
// building's own pages keep their gate until their owning module adopts
|
|
50
|
+
// `mayActAlone` (the ADR's follow-up list).
|
|
51
|
+
const AREAS = Object.freeze([
|
|
52
|
+
Object.freeze({ key: 'charter', name: 'Charter', gate: null,
|
|
53
|
+
today: 'the board, under the constitution in force', acts: Object.freeze([]) }),
|
|
54
|
+
Object.freeze({ key: 'rules', name: 'Rules', gate: 'project.settings.manage',
|
|
55
|
+
today: 'whoever may change the project settings', acts: Object.freeze([]) }),
|
|
56
|
+
Object.freeze({ key: 'disputes', name: 'Disputes', gate: 'security.report.adjudicate',
|
|
57
|
+
today: 'whoever may judge reports', acts: Object.freeze([]) }),
|
|
58
|
+
Object.freeze({ key: 'people', name: 'People', gate: 'government.manage',
|
|
59
|
+
today: 'whoever may manage ranks',
|
|
60
|
+
acts: Object.freeze(['POST /government/assignments', 'DELETE /government/assignments/:builderId/:rankKey']) }),
|
|
61
|
+
Object.freeze({ key: 'planning', name: 'Planning', gate: 'goal.create',
|
|
62
|
+
today: 'whoever may create goals', acts: Object.freeze([]) }),
|
|
63
|
+
Object.freeze({ key: 'automation', name: 'Automation', gate: 'autonomy.fence.manage',
|
|
64
|
+
today: 'whoever may switch unattended building on and off', acts: Object.freeze([]) }),
|
|
65
|
+
Object.freeze({ key: 'review', name: 'Review', gate: 'criterion.satisfy',
|
|
66
|
+
today: 'whoever may mark work as meeting its checks', acts: Object.freeze([]) }),
|
|
67
|
+
Object.freeze({ key: 'release', name: 'Release', gate: 'core.pin.move',
|
|
68
|
+
today: 'whoever may put a release live', acts: Object.freeze([]) }),
|
|
69
|
+
Object.freeze({ key: 'treasury', name: 'Treasury', gate: 'builder.budget.set',
|
|
70
|
+
today: 'whoever may set spending limits', acts: Object.freeze([]) }),
|
|
71
|
+
]);
|
|
72
|
+
const AREA_KEYS = Object.freeze(AREAS.map((a) => a.key));
|
|
73
|
+
const areaByKey = (key) => AREAS.find((a) => a.key === key) || null;
|
|
74
|
+
|
|
75
|
+
// The seven ways a building can decide (the design's decision glyphs, plus
|
|
76
|
+
// vacant from the "Four governments" board). `sitting` rules put a matter to the
|
|
77
|
+
// members a predicate seats; `decide` and `first` also let a seated member act
|
|
78
|
+
// alone; `vacant` decides nothing.
|
|
79
|
+
const DECISION_RULES = Object.freeze([
|
|
80
|
+
Object.freeze({ key: 'decide', words: 'decides alone', passRule: 'first_ratifier', actsAlone: true }),
|
|
81
|
+
Object.freeze({ key: 'first', words: 'first yes wins', passRule: 'first_ratifier', actsAlone: true }),
|
|
82
|
+
Object.freeze({ key: 'consent', words: 'passes unless someone objects', passRule: 'consent', actsAlone: false }),
|
|
83
|
+
Object.freeze({ key: 'majority', words: 'majority vote', passRule: 'majority', actsAlone: false }),
|
|
84
|
+
Object.freeze({ key: 'unanimous', words: 'everyone agrees', passRule: 'unanimous', actsAlone: false }),
|
|
85
|
+
Object.freeze({ key: 'signoff', words: 'signed off by someone who did not build it', passRule: 'first_ratifier', actsAlone: false }),
|
|
86
|
+
Object.freeze({ key: 'vacant', words: 'no one holds this yet', passRule: null, actsAlone: false }),
|
|
87
|
+
]);
|
|
88
|
+
const DECISION_RULE_KEYS = Object.freeze(DECISION_RULES.map((r) => r.key));
|
|
89
|
+
const SITTING_RULE_KEYS = Object.freeze(DECISION_RULE_KEYS.filter((k) => k !== 'vacant'));
|
|
90
|
+
const ruleByKey = (key) => DECISION_RULES.find((r) => r.key === key) || null;
|
|
91
|
+
|
|
92
|
+
// The board's pass rules in the table's words (the charter row's effective rule).
|
|
93
|
+
const PASS_RULE_TO_DECISION_RULE = Object.freeze({
|
|
94
|
+
first_ratifier: 'first', consent: 'consent', majority: 'majority', unanimous: 'unanimous',
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
// The board subject a building's matters sit as. The charter has none of its
|
|
98
|
+
// own: its matters are constitutional amendments.
|
|
99
|
+
const MATTER_SUBJECT_TYPES = Object.freeze(Object.fromEntries(
|
|
100
|
+
AREAS.filter((a) => a.key !== 'charter').map((a) => [a.key, `${a.key}_matter`]),
|
|
101
|
+
));
|
|
102
|
+
const areaForSubjectType = (subjectType) => {
|
|
103
|
+
const hit = Object.entries(MATTER_SUBJECT_TYPES).find(([, t]) => t === subjectType);
|
|
104
|
+
return hit ? hit[0] : null;
|
|
105
|
+
};
|
|
106
|
+
const isMatterSubject = (subjectType) => areaForSubjectType(subjectType) !== null;
|
|
107
|
+
|
|
108
|
+
const WHO_MAX = 120;
|
|
109
|
+
const DECIDED_BY_MAX = 200;
|
|
110
|
+
|
|
111
|
+
// The as-today row for a building — what government_023 seeds, and what a
|
|
112
|
+
// building reads as when its row is missing or unreadable as stored.
|
|
113
|
+
function defaultRow(area) {
|
|
114
|
+
return Object.freeze({
|
|
115
|
+
area, decided_by: area === 'charter' ? 'board' : 'gate', rule: null, who: null, set_by_item_id: null,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── strict validation of a proposed change ──────────────────────────────────
|
|
120
|
+
//
|
|
121
|
+
// Like a constitution proposal (board.validateProposedConstitution), a proposal
|
|
122
|
+
// is VALIDATED, never sanitized: the board must not ratify a row its proposer
|
|
123
|
+
// did not write. A proposal lists the buildings it changes; each listed row is
|
|
124
|
+
// replaced whole. Returns { ok, rules } in street order, or a named refusal.
|
|
125
|
+
const PROPOSAL_FIELDS = Object.freeze(['area', 'decided_by', 'rule', 'who']);
|
|
126
|
+
const PREDICATE_EXPECTED = "'gate' (as today), 'nobody' (vacant), or rank:<key> / rank:<key>+ / a comma list such as rank:council,archon";
|
|
127
|
+
|
|
128
|
+
function validateDecisionRulesProposal(raw) {
|
|
129
|
+
if (!Array.isArray(raw) || raw.length === 0) return { ok: false, reason: 'proposal_not_a_list', expected: 'a non-empty list of { area, decided_by, rule, who }' };
|
|
130
|
+
if (raw.length > AREAS.length) return { ok: false, reason: 'too_many_rows' };
|
|
131
|
+
const seen = new Set();
|
|
132
|
+
const out = [];
|
|
133
|
+
for (const row of raw) {
|
|
134
|
+
if (!row || typeof row !== 'object' || Array.isArray(row)) return { ok: false, reason: 'row_not_an_object' };
|
|
135
|
+
for (const k of Object.keys(row)) {
|
|
136
|
+
if (!PROPOSAL_FIELDS.includes(k)) return { ok: false, reason: 'unknown_field', field: k };
|
|
137
|
+
}
|
|
138
|
+
const area = row.area;
|
|
139
|
+
if (!AREA_KEYS.includes(area)) return { ok: false, reason: 'unknown_area', expected: AREA_KEYS };
|
|
140
|
+
if (seen.has(area)) return { ok: false, reason: 'duplicate_area', area };
|
|
141
|
+
seen.add(area);
|
|
142
|
+
if (area === 'charter') {
|
|
143
|
+
return {
|
|
144
|
+
ok: false, reason: 'charter_is_the_board', area,
|
|
145
|
+
expected: 'the charter is decided by the constitution\'s own board — change its members or rule with a constitution amendment (POST /government/board/items or /government/board/charter)',
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
const decidedBy = row.decided_by;
|
|
149
|
+
if (typeof decidedBy !== 'string' || decidedBy.length === 0 || decidedBy.length > DECIDED_BY_MAX) {
|
|
150
|
+
return { ok: false, reason: 'bad_decided_by', area, expected: PREDICATE_EXPECTED };
|
|
151
|
+
}
|
|
152
|
+
const rule = row.rule === undefined ? null : row.rule;
|
|
153
|
+
if (decidedBy === 'gate') {
|
|
154
|
+
if (rule !== null) return { ok: false, reason: 'gate_takes_no_rule', area, expected: 'leave rule out: as today, whoever holds the building\'s permission decides alone' };
|
|
155
|
+
} else if (decidedBy === 'nobody') {
|
|
156
|
+
if (rule !== 'vacant') return { ok: false, reason: 'nobody_is_vacant', area, expected: "rule 'vacant'" };
|
|
157
|
+
} else if (decidedBy === 'board') {
|
|
158
|
+
return { ok: false, reason: 'only_the_charter_is_the_board', area };
|
|
159
|
+
} else {
|
|
160
|
+
if (!parseMembershipPredicate(decidedBy)) return { ok: false, reason: 'bad_decided_by', area, expected: PREDICATE_EXPECTED };
|
|
161
|
+
if (!SITTING_RULE_KEYS.includes(rule)) return { ok: false, reason: 'bad_rule', area, expected: SITTING_RULE_KEYS };
|
|
162
|
+
}
|
|
163
|
+
let who = row.who === undefined ? null : row.who;
|
|
164
|
+
if (who !== null) {
|
|
165
|
+
if (typeof who !== 'string' || who.trim().length === 0 || who.length > WHO_MAX) {
|
|
166
|
+
return { ok: false, reason: 'bad_who', area, expected: `null, or the seat in words (1..${WHO_MAX} characters)` };
|
|
167
|
+
}
|
|
168
|
+
who = who.trim();
|
|
169
|
+
}
|
|
170
|
+
out.push(Object.freeze({ area, decided_by: decidedBy, rule, who }));
|
|
171
|
+
}
|
|
172
|
+
out.sort((a, b) => AREA_KEYS.indexOf(a.area) - AREA_KEYS.indexOf(b.area));
|
|
173
|
+
return { ok: true, rules: Object.freeze(out) };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// The CUSTOM rank keys a proposal names (a standard rank always exists), so the
|
|
177
|
+
// caller can refuse a council that is not a rank yet — it would seat nobody.
|
|
178
|
+
function customRankKeysIn(rules) {
|
|
179
|
+
const keys = new Set();
|
|
180
|
+
for (const r of rules || []) {
|
|
181
|
+
const parsed = parseMembershipPredicate(r.decided_by);
|
|
182
|
+
if (!parsed) continue;
|
|
183
|
+
for (const k of parsed.rankKeys) if (!RANK_ORDER.includes(k)) keys.add(k);
|
|
184
|
+
}
|
|
185
|
+
return [...keys];
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Would applying these rows change anything? A proposal that changes nothing is
|
|
189
|
+
// refused before it costs a sitting.
|
|
190
|
+
function changesAnything(rules, liveRows) {
|
|
191
|
+
const live = new Map((liveRows || []).map((r) => [r.area, r]));
|
|
192
|
+
return (rules || []).some((r) => {
|
|
193
|
+
const cur = live.get(r.area) || defaultRow(r.area);
|
|
194
|
+
return cur.decided_by !== r.decided_by || (cur.rule || null) !== (r.rule || null) || (cur.who || null) !== (r.who || null);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ── reading a row ───────────────────────────────────────────────────────────
|
|
199
|
+
|
|
200
|
+
// A stored row as the public, resolved shape: effective rule, the seat in words,
|
|
201
|
+
// whether matters here go to a sitting, whether a seated member may act alone.
|
|
202
|
+
// `board` is the live constitution block (the charter's row reads its rule).
|
|
203
|
+
// A row that is malformed as stored reads as the as-today default — the same
|
|
204
|
+
// fail-closed direction as the constitution's sanitizer: never wider than today.
|
|
205
|
+
function resolveRow(stored, { board = {} } = {}) {
|
|
206
|
+
const area = stored && AREA_KEYS.includes(stored.area) ? stored.area : null;
|
|
207
|
+
if (!area) return null;
|
|
208
|
+
const def = areaByKey(area);
|
|
209
|
+
let row = stored;
|
|
210
|
+
const shapeOk = (area === 'charter' && row.decided_by === 'board' && row.rule == null)
|
|
211
|
+
|| (area !== 'charter' && row.decided_by === 'gate' && row.rule == null)
|
|
212
|
+
|| (area !== 'charter' && row.decided_by === 'nobody' && row.rule === 'vacant')
|
|
213
|
+
|| (area !== 'charter' && parseMembershipPredicate(row.decided_by) && SITTING_RULE_KEYS.includes(row.rule));
|
|
214
|
+
if (!shapeOk) row = defaultRow(area);
|
|
215
|
+
|
|
216
|
+
const asToday = row.decided_by === 'gate' || row.decided_by === 'board';
|
|
217
|
+
let rule;
|
|
218
|
+
let who;
|
|
219
|
+
if (row.decided_by === 'board') {
|
|
220
|
+
rule = PASS_RULE_TO_DECISION_RULE[board.pass_rule] || null;
|
|
221
|
+
const members = describeMembership(board.membership);
|
|
222
|
+
who = row.who || (members ? `the board (${members})` : 'the board');
|
|
223
|
+
} else if (row.decided_by === 'gate') {
|
|
224
|
+
rule = 'decide';
|
|
225
|
+
who = row.who || def.today;
|
|
226
|
+
} else if (row.decided_by === 'nobody') {
|
|
227
|
+
rule = 'vacant';
|
|
228
|
+
who = row.who || 'no one yet';
|
|
229
|
+
} else {
|
|
230
|
+
rule = row.rule;
|
|
231
|
+
who = row.who || describeMembership(row.decided_by);
|
|
232
|
+
}
|
|
233
|
+
const r = ruleByKey(rule);
|
|
234
|
+
return Object.freeze({
|
|
235
|
+
area,
|
|
236
|
+
name: def.name,
|
|
237
|
+
decided_by: row.decided_by,
|
|
238
|
+
rule,
|
|
239
|
+
rule_words: r ? r.words : null,
|
|
240
|
+
who,
|
|
241
|
+
as_today: asToday,
|
|
242
|
+
// Matters here are put to a sitting of the seated members (never for the
|
|
243
|
+
// charter — its matters are amendments, sat under the board in force).
|
|
244
|
+
sitting: area !== 'charter' && !asToday && row.decided_by !== 'nobody',
|
|
245
|
+
gate: def.gate,
|
|
246
|
+
today: def.today,
|
|
247
|
+
acts: def.acts,
|
|
248
|
+
set_by_item_id: row.set_by_item_id == null ? null : String(row.set_by_item_id),
|
|
249
|
+
updated_at: row.updated_at || null,
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Every building, in street order, from whatever rows were read (a missing row
|
|
254
|
+
// reads as its default).
|
|
255
|
+
function resolveAll(rows, { board = {} } = {}) {
|
|
256
|
+
const byArea = new Map((rows || []).map((r) => [r.area, r]));
|
|
257
|
+
return Object.freeze(AREAS.map((a) => resolveRow(byArea.get(a.key) || defaultRow(a.key), { board })));
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// The constitution a matter's sitting is snapshotted under. Built from the row
|
|
261
|
+
// (who and how) and the live board (early close). A clock is set only under a
|
|
262
|
+
// rule whose expiry means RETURN — under first_ratifier or consent a deadline
|
|
263
|
+
// would pass a matter nobody read (ADR 0191 §4), so those sit with no clock.
|
|
264
|
+
function sittingConstitution(resolved, { board = {}, clockSafe = (passRule) => passRule === 'majority' || passRule === 'unanimous' } = {}) {
|
|
265
|
+
const r = ruleByKey(resolved && resolved.rule);
|
|
266
|
+
if (!resolved || !resolved.sitting || !r || !r.passRule) return null;
|
|
267
|
+
const window = clockSafe(r.passRule) && Number.isInteger(board.window_minutes) ? board.window_minutes : null;
|
|
268
|
+
return Object.freeze({
|
|
269
|
+
membership: resolved.decided_by,
|
|
270
|
+
pass_rule: r.passRule,
|
|
271
|
+
window_minutes: window,
|
|
272
|
+
close_early_on_full_turnout: board.close_early_on_full_turnout !== false,
|
|
273
|
+
// The table's own words ride the snapshot, so the docket and the board read
|
|
274
|
+
// the rule the matter was decided under, and `signoff` holds the proposer
|
|
275
|
+
// to the author rule.
|
|
276
|
+
rule: resolved.rule,
|
|
277
|
+
area: resolved.area,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// May this builder act ALONE at this building right now? `seated` is whether
|
|
282
|
+
// the row's predicate seats them (resolved by the caller, server-side,
|
|
283
|
+
// uncached). As today → yes (the route's own permission gate still applies).
|
|
284
|
+
// decide / first → only a seated member. Any other rule, or vacant → nobody:
|
|
285
|
+
// the matter goes to a sitting.
|
|
286
|
+
function mayActAlone(resolved, { seated = false } = {}) {
|
|
287
|
+
if (!resolved) return false;
|
|
288
|
+
if (resolved.as_today) return true;
|
|
289
|
+
const r = ruleByKey(resolved.rule);
|
|
290
|
+
return !!(r && r.actsAlone && resolved.sitting && seated);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
module.exports = {
|
|
294
|
+
AREAS,
|
|
295
|
+
AREA_KEYS,
|
|
296
|
+
areaByKey,
|
|
297
|
+
DECISION_RULES,
|
|
298
|
+
DECISION_RULE_KEYS,
|
|
299
|
+
SITTING_RULE_KEYS,
|
|
300
|
+
PASS_RULE_TO_DECISION_RULE,
|
|
301
|
+
MATTER_SUBJECT_TYPES,
|
|
302
|
+
areaForSubjectType,
|
|
303
|
+
isMatterSubject,
|
|
304
|
+
defaultRow,
|
|
305
|
+
validateDecisionRulesProposal,
|
|
306
|
+
customRankKeysIn,
|
|
307
|
+
changesAnything,
|
|
308
|
+
resolveRow,
|
|
309
|
+
resolveAll,
|
|
310
|
+
sittingConstitution,
|
|
311
|
+
mayActAlone,
|
|
312
|
+
};
|