@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.
@@ -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 building = item.subject_type === 'full_idea' ? 'planning' : 'charter';
218
- const title = item.subject_type === 'constitutional_amendment'
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 = item.subject_type === 'constitutional_amendment' ? 'charter · amendment'
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: vocab.PASS_RULE_TO_RULE[constitution.pass_rule] || null,
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: item.subject_type === 'constitutional_amendment' ? 'amendment'
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
- async function buildDocket({ viewer, permissions, contributions = registeredContributions(), now = Date.now(), timeoutMs = SOURCE_TIMEOUT_MS } = {}) {
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
- return { key: b.key, name: b.name, group: b.group, counts: countStates(here), matter_ids: here.map((m) => m.id) };
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: { buildings: vocab.BUILDINGS, rules: vocab.RULES, states: vocab.STATES },
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
- return buildDocket({ viewer: builder, permissions });
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;