@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.
@@ -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;
@@ -122,6 +122,10 @@ const governmentPort = {
122
122
  // `charter` block. Opening a sitting decides nothing; the vote does.
123
123
  proposeCharter: board.proposeCharter,
124
124
  genesisStageStatus: board.genesisStageStatus,
125
+ // task 1004529 — who decides what. The table, resolved, and the check a
126
+ // building's own direct route runs before letting one builder act alone.
127
+ decisionRulesView: board.decisionRulesView,
128
+ checkActAlone: board.checkActAlone,
125
129
  };
126
130
 
127
131
  function registerSeams() {
@@ -225,6 +229,31 @@ module.exports = function buildGovernmentRouter() {
225
229
  }
226
230
  return rank;
227
231
  }
232
+ // WHO DECIDES AT PEOPLE (task 1004529). Giving or taking a custom rank is
233
+ // People's own act, and the project's decision-rules table says who may do it
234
+ // alone. As today (the default row) the route's own permission is the whole
235
+ // gate, unchanged. Once a passed amendment hands People to a sitting, one
236
+ // builder may no longer do it alone — the route refuses and names the matter
237
+ // route instead — unless the row's rule lets a seated member decide alone and
238
+ // this builder is seated. Server-side, per request, uncached (ADR 0016).
239
+ // Fail-CLOSED: an unreadable row refuses rather than waving the act through.
240
+ async function peopleMayActAlone(req, res) {
241
+ let out;
242
+ try {
243
+ out = await board.checkActAlone('people', req.builder.id);
244
+ } catch (err) {
245
+ log.error('[gds] people decision rule unreadable', err);
246
+ res.fail('decision_rule_unreadable', { status: 503, message: 'who decides at People could not be read just now' });
247
+ return false;
248
+ }
249
+ if (out.ok) return true;
250
+ res.fail('decided_by_sitting', 409, {
251
+ area: out.area, rule: out.rule, who: out.who,
252
+ what_this_means: out.what_this_means, open_with: out.open_with,
253
+ });
254
+ return false;
255
+ }
256
+
228
257
  const ARCHON = 'archon';
229
258
  const ARCHON_REASON = 'the Archon rank holds every permission and is immutable — it is the rank that can never be locked out of this tab (ADR 0172)';
230
259
  const ASSIGN_REASON = 'a seeded rank is assigned by rank (R95b sync); change the builder\'s rank instead';
@@ -447,6 +476,7 @@ module.exports = function buildGovernmentRouter() {
447
476
  try {
448
477
  const rank = await loadAssignableRank(res, rankKey);
449
478
  if (rank === null) return;
479
+ if (!(await peopleMayActAlone(req, res))) return;
450
480
  const assigned = await db.assignRank({
451
481
  builderId: req.body.builder_id,
452
482
  rankKey,
@@ -472,6 +502,7 @@ module.exports = function buildGovernmentRouter() {
472
502
  try {
473
503
  const rank = await loadAssignableRank(res, rankKey);
474
504
  if (rank === null) return;
505
+ if (!(await peopleMayActAlone(req, res))) return;
475
506
  const removed = await db.unassignRank({ builderId, rankKey });
476
507
  res.json({ ok: true, builder_id: builderId, rank_key: rankKey, removed });
477
508
  } catch (err) {
@@ -678,6 +709,90 @@ module.exports = function buildGovernmentRouter() {
678
709
  }
679
710
  });
680
711
 
712
+ // rank: perm board.item.open — propose a change to WHO DECIDES WHAT (task
713
+ // 1004529): one or more buildings' rows of the decision-rules table, filed as
714
+ // an amendment of kind 'decision_rules' and sat under the constitution IN
715
+ // FORCE, like every amendment. Nothing here changes the table — only the
716
+ // sitting passing does, inside its closing transaction. The same atom as
717
+ // proposing any amendment.
718
+ //
719
+ // Body: { rules: [{ area, decided_by, rule?, who? }], rationale_md? }
720
+ // area ∈ rules disputes people planning automation review release treasury
721
+ // (the charter is the board — change it with a constitution amendment)
722
+ // decided_by 'gate' (as today) | 'nobody' (vacant) | rank:<key> | rank:<key>+ | rank:a,b
723
+ // rule decide | first | consent | majority | unanimous | signoff (vacant with 'nobody';
724
+ // none with 'gate')
725
+ // who the seat in words, display only ("Mira under $50, the council above")
726
+ router.post('/government/board/decision-rules', api.requireBuilder, api.requirePermission('board.item.open'), async (req, res) => {
727
+ if (validateOrRespond(req, res, {
728
+ rules: { required: true, type: 'array' },
729
+ rationale_md: { type: 'string', maxLength: 5000 },
730
+ })) return;
731
+ try {
732
+ const out = await board.proposeDecisionRules({
733
+ rules: req.body.rules,
734
+ rationaleMd: req.body.rationale_md ?? null,
735
+ proposedBy: req.builder.id,
736
+ });
737
+ if (!out.ok) {
738
+ if (out.reason === 'no_change') return res.fail('no_change', 409, { reason: 'every building named is already decided this way' });
739
+ return res.fail('invalid_decision_rules', 400, {
740
+ reason: out.reason,
741
+ ...(out.area ? { area: out.area } : {}),
742
+ ...(out.field ? { field: out.field } : {}),
743
+ ...(out.expected ? { expected: out.expected } : {}),
744
+ ...(out.rank_key ? { rank_key: out.rank_key } : {}),
745
+ });
746
+ }
747
+ res.status(201).json({ ok: true, amendment: out.amendment, item: out.item, rules: out.rules });
748
+ } catch (err) {
749
+ log.error('[gds] POST /government/board/decision-rules', err);
750
+ res.fail('propose_failed', { status: 500, message: 'internal error' });
751
+ }
752
+ });
753
+
754
+ // rank: perm board.item.open — put a MATTER to a building's sitting (task
755
+ // 1004529): the board's new subjects, one per building other than the
756
+ // charter. Refused unless the building's row names a sitting — under the
757
+ // default rows every building is decided as today and this route opens
758
+ // nothing. The sitting is snapshotted from the row (who and how).
759
+ //
760
+ // Body: { area, title, detail_md?, action? }
761
+ // action (People only): { type: 'assign_rank' | 'unassign_rank', builder_id, rank_key }
762
+ // — carried out when the sitting passes, under the same walls as the direct route.
763
+ router.post('/government/board/matters', api.requireBuilder, api.requirePermission('board.item.open'), async (req, res) => {
764
+ if (validateOrRespond(req, res, {
765
+ area: { required: true, type: 'string', maxLength: 32 },
766
+ title: { required: true, type: 'string', maxLength: 200 },
767
+ detail_md: { type: 'string', maxLength: 5000 },
768
+ action: { type: 'object' },
769
+ })) return;
770
+ try {
771
+ const out = await board.openMatter({
772
+ area: req.body.area,
773
+ title: req.body.title,
774
+ detailMd: req.body.detail_md ?? null,
775
+ action: req.body.action ?? null,
776
+ proposedBy: req.builder.id,
777
+ });
778
+ if (!out.ok) {
779
+ if (out.reason === 'decided_by_gate' || out.reason === 'no_one_holds_this') {
780
+ return res.fail(out.reason, 409, { area: out.area, ...(out.gate ? { gate: out.gate } : {}), what_this_means: out.what_this_means });
781
+ }
782
+ return res.fail('invalid_matter', 400, {
783
+ reason: out.reason,
784
+ ...(out.field ? { field: out.field } : {}),
785
+ ...(out.expected ? { expected: out.expected } : {}),
786
+ ...(out.rank_key ? { rank_key: out.rank_key } : {}),
787
+ });
788
+ }
789
+ res.status(201).json({ ok: true, matter: out.matter, item: out.item, decided: out.decided });
790
+ } catch (err) {
791
+ log.error('[gds] POST /government/board/matters', err);
792
+ res.fail('matter_failed', { status: 500, message: 'internal error' });
793
+ }
794
+ });
795
+
681
796
  // rank: perm board.vote.cast — cast a vote on an open board item (R08 of goal
682
797
  // 1000069, ADR 0175). The atom FLOORS AT XENOS deliberately (the R04 carry-
683
798
  // note): the permission is only the coarse gate, and the real wall is the
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.8",
3
+ "version": "1.21.9",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.8",
9
+ "version": "1.21.9",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.8",
3
+ "version": "1.21.9",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -8804,5 +8804,11 @@
8804
8804
  "id": "1004441",
8805
8805
  "text": "Checked and signed off on the live site: artists and ideators now read as their own craft on profiles, the builders list and the status page. Their earnings charts show, and builders can choose their main role in Settings."
8806
8806
  }
8807
+ ],
8808
+ "1.21.9": [
8809
+ {
8810
+ "id": "1004529",
8811
+ "text": "Each project can now set who decides what in each part of its government: the charter, rules, disputes, people, planning, automation, review, release and treasury. For each part it names who decides and how: one person decides"
8812
+ }
8807
8813
  ]
8808
8814
  }
@@ -281,6 +281,12 @@ const GOVERNMENT_PINS = {
281
281
  // task 1004523: the governor's docket — every matter awaiting a governor. The
282
282
  // coarse gate is the room's; each source inside is scoped to its own atom.
283
283
  'GET /government/docket': 'perm:board.vote.cast',
284
+ // task 1004529: who decides what — a change to the decision-rules table is an
285
+ // amendment by another door (same atom as proposing one), and a building's
286
+ // matter is put to its sitting by the same atom. Only a passed amendment
287
+ // writes the table; neither route does.
288
+ 'POST /government/board/decision-rules': 'perm:board.item.open',
289
+ 'POST /government/board/matters': 'perm:board.item.open',
284
290
  };
285
291
 
286
292
  // Module-scoped privileged-rank pins: a privileged route that lives in a FEATURE
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.8'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.9'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -120,6 +120,9 @@ const stub = {
120
120
  unassignRank: async () => true,
121
121
  listRanksWithGrants: async () => [],
122
122
  listAssignments: async () => [],
123
+ // task 1004529: People's row is read before a rank is given or taken. No rows
124
+ // = every building as today, so this matrix's walls are the route's own.
125
+ listDecisionRules: async () => [],
123
126
  };
124
127
  const REAL = {};
125
128
  for (const k of Object.keys(stub)) {