@bongos/core 1.21.8 → 1.21.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +59 -34
- 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 +8 -27
- package/docs/copy-registry.json +6 -199
- package/docs/module-api-changelog.md +4 -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 +12 -0
- package/scripts/gds/copy-inventory.js +24 -7
- package/src/bongos/route-rank-check.js +6 -0
- package/src/module-api.js +1 -1
- package/tests/copy_inventory.mjs +62 -0
- 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,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
|
+
};
|
|
@@ -40,6 +40,15 @@
|
|
|
40
40
|
// rows — the screen can say a building has matters you cannot see without
|
|
41
41
|
// leaking them.
|
|
42
42
|
//
|
|
43
|
+
// WHO DECIDES, FROM THE TABLE (task 1004529). Every building carries
|
|
44
|
+
// `decision` — who decides it and by which rule — read from the project's
|
|
45
|
+
// decision-rules table (decision-rules.js), so the street and the building
|
|
46
|
+
// interiors state the project's own government rather than a fixed one. A
|
|
47
|
+
// sitting at a building (one of the board's matter subjects) carries the rule it
|
|
48
|
+
// was opened under, which is the table's row at that moment, snapshotted on the
|
|
49
|
+
// item. A contributed matter keeps the rule its own route decides it by: that
|
|
50
|
+
// route's gate is what decides it until its module defers to the row.
|
|
51
|
+
//
|
|
43
52
|
// NOTHING IS INVENTED. A source the design names that has no data yet (grade
|
|
44
53
|
// appeals, votes on new standing rules, the treasury's spending matters) is
|
|
45
54
|
// reported `coming`; a source whose module is off is `off`; a read that throws or
|
|
@@ -51,6 +60,7 @@ const board = require('./board');
|
|
|
51
60
|
const db = require('./db');
|
|
52
61
|
const { resolveBoardMembers } = require('./board-membership');
|
|
53
62
|
const resolver = require('./resolver');
|
|
63
|
+
const decisionRules = require('./decision-rules');
|
|
54
64
|
|
|
55
65
|
const log = api.logger('government');
|
|
56
66
|
|
|
@@ -214,13 +224,17 @@ function boardMatter(item, { viewerId, members }) {
|
|
|
214
224
|
else if (voted || memberIds.has(viewer) || authorId === viewer) state = 'moving';
|
|
215
225
|
else state = 'settled';
|
|
216
226
|
|
|
217
|
-
const
|
|
218
|
-
const
|
|
227
|
+
const matterArea = decisionRules.areaForSubjectType(item.subject_type);
|
|
228
|
+
const building = matterArea || (item.subject_type === 'full_idea' ? 'planning' : 'charter');
|
|
229
|
+
const title = matterArea
|
|
230
|
+
? ((item.subject && item.subject.title) || `A matter at ${building}`)
|
|
231
|
+
: item.subject_type === 'constitutional_amendment'
|
|
219
232
|
? (item.subject && item.subject.rationale_md ? firstLine(item.subject.rationale_md) : 'A change to the charter')
|
|
220
233
|
: item.subject_type === 'genesis_stage'
|
|
221
234
|
? `Close the founding stage: ${(item.subject && item.subject.label) || item.subject_id}`
|
|
222
235
|
: ((item.subject && item.subject.title) || `Full idea ${item.subject_id}`);
|
|
223
|
-
const where =
|
|
236
|
+
const where = matterArea ? `${building} · matter`
|
|
237
|
+
: item.subject_type === 'constitutional_amendment' ? 'charter · amendment'
|
|
224
238
|
: item.subject_type === 'genesis_stage' ? 'charter · founding'
|
|
225
239
|
: 'planning · idea to ratify';
|
|
226
240
|
const forN = votes.filter((v) => v.direction === 'yes').length;
|
|
@@ -233,10 +247,15 @@ function boardMatter(item, { viewerId, members }) {
|
|
|
233
247
|
return {
|
|
234
248
|
id: item.id,
|
|
235
249
|
building,
|
|
236
|
-
rule
|
|
250
|
+
// A building's sitting carries the table's own rule on its snapshot (so a
|
|
251
|
+
// sign-off reads as sign-off, a one-person decision as decide); every other
|
|
252
|
+
// sitting reads the board's pass rule.
|
|
253
|
+
rule: (vocab.RULE_KEYS.includes(constitution.rule) ? constitution.rule : null)
|
|
254
|
+
|| vocab.PASS_RULE_TO_RULE[constitution.pass_rule] || null,
|
|
237
255
|
state,
|
|
238
256
|
title,
|
|
239
|
-
short:
|
|
257
|
+
short: matterArea ? 'matter'
|
|
258
|
+
: item.subject_type === 'constitutional_amendment' ? 'amendment'
|
|
240
259
|
: item.subject_type === 'genesis_stage' ? 'founding stage' : 'idea to ratify',
|
|
241
260
|
where,
|
|
242
261
|
opened_at: item.opened_at,
|
|
@@ -340,7 +359,10 @@ function registeredContributions() {
|
|
|
340
359
|
// Build the docket for one viewer. `permissions` is the viewer's resolved set;
|
|
341
360
|
// `contributions` defaults to what is registered on the seam (a test passes its
|
|
342
361
|
// own). Never throws for a source: each is isolated and timed.
|
|
343
|
-
|
|
362
|
+
// `decisions` is the resolved decision-rules table (board.decisionRulesView), or
|
|
363
|
+
// null when it could not be read — each building's `decision` is then null
|
|
364
|
+
// rather than a guess.
|
|
365
|
+
async function buildDocket({ viewer, permissions, contributions = registeredContributions(), now = Date.now(), timeoutMs = SOURCE_TIMEOUT_MS, decisions = null } = {}) {
|
|
344
366
|
const held = new Set(permissions || []);
|
|
345
367
|
const ctx = Object.freeze({
|
|
346
368
|
builderId: String(viewer.id),
|
|
@@ -416,9 +438,17 @@ async function buildDocket({ viewer, permissions, contributions = registeredCont
|
|
|
416
438
|
}));
|
|
417
439
|
|
|
418
440
|
const counts = countStates(matters);
|
|
441
|
+
const decisionBy = new Map((Array.isArray(decisions) ? decisions : []).filter(Boolean).map((d) => [d.area, d]));
|
|
419
442
|
const buildings = vocab.BUILDINGS.map((b) => {
|
|
420
443
|
const here = matters.filter((m) => m.building === b.key);
|
|
421
|
-
|
|
444
|
+
const d = decisionBy.get(b.key) || null;
|
|
445
|
+
return {
|
|
446
|
+
key: b.key, name: b.name, group: b.group, counts: countStates(here), matter_ids: here.map((m) => m.id),
|
|
447
|
+
decision: d ? {
|
|
448
|
+
who: d.who, rule: d.rule, rule_words: d.rule_words, decided_by: d.decided_by,
|
|
449
|
+
as_today: d.as_today, sitting: d.sitting, set_by_item_id: d.set_by_item_id,
|
|
450
|
+
} : null,
|
|
451
|
+
};
|
|
422
452
|
});
|
|
423
453
|
return {
|
|
424
454
|
viewer: { id: ctx.builderId },
|
|
@@ -429,7 +459,11 @@ async function buildDocket({ viewer, permissions, contributions = registeredCont
|
|
|
429
459
|
buildings,
|
|
430
460
|
sources: sourceStatus,
|
|
431
461
|
readings,
|
|
432
|
-
vocabulary: {
|
|
462
|
+
vocabulary: {
|
|
463
|
+
buildings: vocab.BUILDINGS, rules: vocab.RULES, states: vocab.STATES,
|
|
464
|
+
// The table's rule set — the matter glyphs plus vacant (no one holds it).
|
|
465
|
+
decision_rules: decisionRules.DECISION_RULES.map((r) => ({ key: r.key, words: r.words })),
|
|
466
|
+
},
|
|
433
467
|
};
|
|
434
468
|
}
|
|
435
469
|
|
|
@@ -437,7 +471,13 @@ async function buildDocket({ viewer, permissions, contributions = registeredCont
|
|
|
437
471
|
// uncached — ADR 0016), then build.
|
|
438
472
|
async function docketFor(builder) {
|
|
439
473
|
const permissions = await resolver.resolveBuilderPermissions(builder.id);
|
|
440
|
-
|
|
474
|
+
// The table is read per request, uncached (ADR 0016). A failed read leaves
|
|
475
|
+
// every building's decision null; it never fails the city.
|
|
476
|
+
const decisions = await board.decisionRulesView().catch((err) => {
|
|
477
|
+
log.warn({ err: err && err.message }, 'governor docket: decision rules unreadable');
|
|
478
|
+
return null;
|
|
479
|
+
});
|
|
480
|
+
return buildDocket({ viewer: builder, permissions, decisions });
|
|
441
481
|
}
|
|
442
482
|
|
|
443
483
|
module.exports = {
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
-- government_023_decision_rules.sql — who decides what, per building, changed
|
|
2
|
+
-- only by a passed amendment (task 1004529, goal 1000125 the Governor City).
|
|
3
|
+
-- Decision of record: docs/adr/0363-who-decides-what-is-a-table-changed-only-by-amendment.md
|
|
4
|
+
--
|
|
5
|
+
-- WHAT THIS ADDS
|
|
6
|
+
--
|
|
7
|
+
-- 1. government_decision_rules — ONE ROW PER BUILDING of the project's
|
|
8
|
+
-- government (charter, rules, disputes, people, planning, automation,
|
|
9
|
+
-- review, release, treasury). Each row says WHO decides matters at that
|
|
10
|
+
-- building and BY WHICH RULE (decide alone, first yes, consent, majority,
|
|
11
|
+
-- unanimous, sign-off, or vacant — nobody holds it yet). An instance is one
|
|
12
|
+
-- project, so the area is the whole key.
|
|
13
|
+
--
|
|
14
|
+
-- 2. The nine DEFAULT rows, each meaning "exactly as today": `decided_by =
|
|
15
|
+
-- 'gate'` (whoever holds the building's fixed permission acts alone, through
|
|
16
|
+
-- its own page) and, for the charter, `decided_by = 'board'` (the
|
|
17
|
+
-- constitution's own board, under its own pass rule). A default row carries
|
|
18
|
+
-- no rule of its own (rule IS NULL). Seeding them changes NO permission for
|
|
19
|
+
-- any existing project.
|
|
20
|
+
--
|
|
21
|
+
-- 3. A GUARD TRIGGER: after the seed, a row may only be changed by a passed
|
|
22
|
+
-- decision-rules amendment. Every insert or update must name the board item
|
|
23
|
+
-- that carried it (`set_by_item_id`), and that item must be a CLOSED, PASSED
|
|
24
|
+
-- constitutional amendment whose amendment row is of kind 'decision_rules'.
|
|
25
|
+
-- There is no settings route that writes this table, and the trigger makes
|
|
26
|
+
-- that structural — a hand-written UPDATE is refused too. Deleting a row is
|
|
27
|
+
-- refused outright. The one exception is re-seeding a MISSING area with its
|
|
28
|
+
-- as-today default, so a re-run of this file stays safe.
|
|
29
|
+
--
|
|
30
|
+
-- 4. government_amendments.kind — 'constitution' (every existing row, and the
|
|
31
|
+
-- board block amendments filed today) or 'decision_rules' (a change to rows
|
|
32
|
+
-- of the table above). One amendments table, one subject type, one pass
|
|
33
|
+
-- path: a rules amendment is decided by the board in force exactly like any
|
|
34
|
+
-- other amendment.
|
|
35
|
+
--
|
|
36
|
+
-- 5. government_matters + eight new board subjects. Today the board votes on
|
|
37
|
+
-- three things (a Full Idea, a constitutional amendment, a genesis stage).
|
|
38
|
+
-- This adds one subject per building other than the charter — rules_matter,
|
|
39
|
+
-- disputes_matter, people_matter, planning_matter, automation_matter,
|
|
40
|
+
-- review_matter, release_matter, treasury_matter — eleven in all. A matter
|
|
41
|
+
-- is put to a sitting ONLY when its building's row names a sitting (an
|
|
42
|
+
-- amendment moved it off the fixed gate); under the default rows none can
|
|
43
|
+
-- be opened, so nothing about today's behaviour changes.
|
|
44
|
+
--
|
|
45
|
+
-- Additive and idempotent: new tables, a new column with a default every
|
|
46
|
+
-- existing row satisfies, CHECKs that only WIDEN (dropped and re-added under
|
|
47
|
+
-- the same name, the government_020 pattern), and CREATE OR REPLACE for the
|
|
48
|
+
-- trigger function and trigger (Postgres 14+; production runs 16).
|
|
49
|
+
|
|
50
|
+
BEGIN;
|
|
51
|
+
|
|
52
|
+
-- ---------------------------------------------------------------------------
|
|
53
|
+
-- 4. amendments gain a kind (first: the guard below reads it).
|
|
54
|
+
-- ---------------------------------------------------------------------------
|
|
55
|
+
ALTER TABLE government_amendments
|
|
56
|
+
ADD COLUMN IF NOT EXISTS kind text NOT NULL DEFAULT 'constitution';
|
|
57
|
+
|
|
58
|
+
ALTER TABLE government_amendments
|
|
59
|
+
DROP CONSTRAINT IF EXISTS government_amendments_kind_check;
|
|
60
|
+
|
|
61
|
+
ALTER TABLE government_amendments
|
|
62
|
+
ADD CONSTRAINT government_amendments_kind_check
|
|
63
|
+
CHECK (kind IN ('constitution', 'decision_rules'));
|
|
64
|
+
|
|
65
|
+
-- ---------------------------------------------------------------------------
|
|
66
|
+
-- 1. the table
|
|
67
|
+
-- ---------------------------------------------------------------------------
|
|
68
|
+
CREATE TABLE IF NOT EXISTS government_decision_rules (
|
|
69
|
+
area text PRIMARY KEY
|
|
70
|
+
CHECK (area IN ('charter', 'rules', 'disputes', 'people', 'planning',
|
|
71
|
+
'automation', 'review', 'release', 'treasury')),
|
|
72
|
+
-- 'gate' — as today: whoever holds the building's fixed permission acts alone
|
|
73
|
+
-- 'board' — the constitution's own board (the charter's only value)
|
|
74
|
+
-- 'nobody' — vacant: no one holds this building yet
|
|
75
|
+
-- otherwise a board membership predicate (rank:<key>, rank:<key>+, rank:a,b),
|
|
76
|
+
-- resolved per request and uncached like every other franchise read (ADR 0016).
|
|
77
|
+
decided_by text NOT NULL CHECK (char_length(decided_by) BETWEEN 1 AND 200),
|
|
78
|
+
rule text CHECK (rule IN ('decide', 'first', 'consent', 'majority',
|
|
79
|
+
'unanimous', 'signoff', 'vacant')),
|
|
80
|
+
-- The design's words for the seat ("Mira under $50, the council above").
|
|
81
|
+
-- Display only; it never decides anything.
|
|
82
|
+
who text CHECK (who IS NULL OR char_length(who) BETWEEN 1 AND 120),
|
|
83
|
+
-- The passed amendment's board item that set this row. NULL only on a default.
|
|
84
|
+
set_by_item_id bigint REFERENCES government_board_items(id) ON DELETE RESTRICT,
|
|
85
|
+
updated_at timestamptz NOT NULL DEFAULT now(),
|
|
86
|
+
|
|
87
|
+
-- A default row carries no rule; every other row carries exactly one.
|
|
88
|
+
CONSTRAINT government_decision_rules_rule_shape CHECK (
|
|
89
|
+
(decided_by IN ('gate', 'board') AND rule IS NULL)
|
|
90
|
+
OR (decided_by = 'nobody' AND rule = 'vacant')
|
|
91
|
+
OR (decided_by NOT IN ('gate', 'board', 'nobody') AND rule IS NOT NULL AND rule <> 'vacant')
|
|
92
|
+
),
|
|
93
|
+
-- The charter is the board, always: its own rule is changed by amending the
|
|
94
|
+
-- constitution's board block, never through this table. And only the charter
|
|
95
|
+
-- may be 'board'.
|
|
96
|
+
CONSTRAINT government_decision_rules_charter_is_the_board CHECK (
|
|
97
|
+
(area = 'charter') = (decided_by = 'board')
|
|
98
|
+
)
|
|
99
|
+
);
|
|
100
|
+
|
|
101
|
+
-- ---------------------------------------------------------------------------
|
|
102
|
+
-- 2. the defaults — exactly today's behaviour. Inserted BEFORE the guard
|
|
103
|
+
-- exists; on a re-run only a missing area is inserted (and the guard admits
|
|
104
|
+
-- exactly that shape).
|
|
105
|
+
-- ---------------------------------------------------------------------------
|
|
106
|
+
INSERT INTO government_decision_rules (area, decided_by, rule)
|
|
107
|
+
SELECT v.area, v.decided_by, NULL
|
|
108
|
+
FROM (VALUES
|
|
109
|
+
('charter', 'board'),
|
|
110
|
+
('rules', 'gate'),
|
|
111
|
+
('disputes', 'gate'),
|
|
112
|
+
('people', 'gate'),
|
|
113
|
+
('planning', 'gate'),
|
|
114
|
+
('automation', 'gate'),
|
|
115
|
+
('review', 'gate'),
|
|
116
|
+
('release', 'gate'),
|
|
117
|
+
('treasury', 'gate')
|
|
118
|
+
) AS v(area, decided_by)
|
|
119
|
+
WHERE NOT EXISTS (SELECT 1 FROM government_decision_rules r WHERE r.area = v.area);
|
|
120
|
+
|
|
121
|
+
-- ---------------------------------------------------------------------------
|
|
122
|
+
-- 3. the guard: changed only by a passed decision-rules amendment.
|
|
123
|
+
-- ---------------------------------------------------------------------------
|
|
124
|
+
CREATE OR REPLACE FUNCTION government_decision_rules_guard() RETURNS trigger
|
|
125
|
+
LANGUAGE plpgsql AS $guard$
|
|
126
|
+
BEGIN
|
|
127
|
+
IF TG_OP = 'DELETE' THEN
|
|
128
|
+
RAISE EXCEPTION 'government_decision_rules: a building cannot be removed from the government'
|
|
129
|
+
USING ERRCODE = 'check_violation';
|
|
130
|
+
END IF;
|
|
131
|
+
|
|
132
|
+
-- Re-seeding a missing area with its as-today default (a re-run of the
|
|
133
|
+
-- migration). Only on INSERT: an UPDATE back to the default is a change like
|
|
134
|
+
-- any other and needs its amendment.
|
|
135
|
+
IF TG_OP = 'INSERT' AND NEW.set_by_item_id IS NULL
|
|
136
|
+
AND NEW.decided_by IN ('gate', 'board') AND NEW.rule IS NULL THEN
|
|
137
|
+
RETURN NEW;
|
|
138
|
+
END IF;
|
|
139
|
+
|
|
140
|
+
IF NEW.set_by_item_id IS NULL OR NOT EXISTS (
|
|
141
|
+
SELECT 1
|
|
142
|
+
FROM government_board_items i
|
|
143
|
+
JOIN government_amendments a ON a.id = i.subject_id
|
|
144
|
+
WHERE i.id = NEW.set_by_item_id
|
|
145
|
+
AND i.subject_type = 'constitutional_amendment'
|
|
146
|
+
AND i.outcome = 'passed'
|
|
147
|
+
AND a.kind = 'decision_rules'
|
|
148
|
+
) THEN
|
|
149
|
+
RAISE EXCEPTION 'government_decision_rules changes only through a passed decision-rules amendment (board item %)', NEW.set_by_item_id
|
|
150
|
+
USING ERRCODE = 'check_violation';
|
|
151
|
+
END IF;
|
|
152
|
+
|
|
153
|
+
NEW.updated_at := now();
|
|
154
|
+
RETURN NEW;
|
|
155
|
+
END
|
|
156
|
+
$guard$;
|
|
157
|
+
|
|
158
|
+
CREATE OR REPLACE TRIGGER government_decision_rules_amendment_only
|
|
159
|
+
BEFORE INSERT OR UPDATE OR DELETE ON government_decision_rules
|
|
160
|
+
FOR EACH ROW EXECUTE FUNCTION government_decision_rules_guard();
|
|
161
|
+
|
|
162
|
+
-- ---------------------------------------------------------------------------
|
|
163
|
+
-- 5. matters, and the eight new subjects
|
|
164
|
+
-- ---------------------------------------------------------------------------
|
|
165
|
+
CREATE TABLE IF NOT EXISTS government_matters (
|
|
166
|
+
id bigserial PRIMARY KEY,
|
|
167
|
+
area text NOT NULL
|
|
168
|
+
CHECK (area IN ('rules', 'disputes', 'people', 'planning',
|
|
169
|
+
'automation', 'review', 'release', 'treasury')),
|
|
170
|
+
title text NOT NULL CHECK (char_length(btrim(title)) BETWEEN 1 AND 200),
|
|
171
|
+
detail_md text CHECK (detail_md IS NULL OR char_length(detail_md) <= 5000),
|
|
172
|
+
-- What the building does by itself when the sitting passes, where it can
|
|
173
|
+
-- (today: People giving or taking a custom rank). NULL = a recorded decision.
|
|
174
|
+
action jsonb,
|
|
175
|
+
proposed_by bigint REFERENCES builders(id) ON DELETE SET NULL,
|
|
176
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
ALTER TABLE government_board_items
|
|
180
|
+
DROP CONSTRAINT IF EXISTS government_board_items_subject_type_check;
|
|
181
|
+
|
|
182
|
+
ALTER TABLE government_board_items
|
|
183
|
+
ADD CONSTRAINT government_board_items_subject_type_check
|
|
184
|
+
CHECK (subject_type IN (
|
|
185
|
+
'full_idea', 'constitutional_amendment', 'genesis_stage',
|
|
186
|
+
'rules_matter', 'disputes_matter', 'people_matter', 'planning_matter',
|
|
187
|
+
'automation_matter', 'review_matter', 'release_matter', 'treasury_matter'
|
|
188
|
+
));
|
|
189
|
+
|
|
190
|
+
COMMIT;
|