@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,701 @@
|
|
|
1
|
+
// tests/government_decision_rules.mjs — WHO DECIDES WHAT, set per project by
|
|
2
|
+
// amendment (task 1004529, goal 1000125 the Governor City; ADR 0363).
|
|
3
|
+
//
|
|
4
|
+
// What this pins:
|
|
5
|
+
// 1. THE DEFAULTS ARE TODAY. government_023 seeds one row per building, each
|
|
6
|
+
// meaning "as today" (the fixed gate; the charter's board), and every
|
|
7
|
+
// building reads that way with no rows at all — no permission changes for
|
|
8
|
+
// an existing project until it amends itself.
|
|
9
|
+
// 2. THE FOUR GOVERNMENTS ARE EXPRESSIBLE — read off the design file itself
|
|
10
|
+
// (docs/design/mocks/governor-city/Governments.dc.html): one founder, a
|
|
11
|
+
// council, everyone votes, a board of trustees, each as a valid
|
|
12
|
+
// constitution plus valid table rows whose resolved rule per building is
|
|
13
|
+
// the design's glyph.
|
|
14
|
+
// 3. CHANGED ONLY BY A PASSED AMENDMENT. The table is written in exactly one
|
|
15
|
+
// place — inside the transaction that closes a decision-rules amendment as
|
|
16
|
+
// passed — under a guard that refuses every other shape; the SQL carries
|
|
17
|
+
// the same condition and the migration's trigger refuses any other write.
|
|
18
|
+
// No route writes it.
|
|
19
|
+
// 4. MORE SUBJECTS. Eight building matters join the board's three subjects; a
|
|
20
|
+
// matter opens only where an amendment handed the building to a sitting,
|
|
21
|
+
// and sits under the row's own rule (sign-off holds the proposer to the
|
|
22
|
+
// author rule).
|
|
23
|
+
// 5. THE GATE IS REPLACED WHERE THE BUILDING ALLOWS IT — People's rank
|
|
24
|
+
// assignments defer to People's row; a passed People matter acts by itself.
|
|
25
|
+
// 6. THE FEED READS THE TABLE — the docket's buildings carry who decides, and a
|
|
26
|
+
// building's sitting carries the table's rule.
|
|
27
|
+
//
|
|
28
|
+
// DB-free: db reads/writes are monkeypatched by property.
|
|
29
|
+
//
|
|
30
|
+
// Run: node tests/government_decision_rules.mjs
|
|
31
|
+
|
|
32
|
+
import { strict as assert } from 'node:assert';
|
|
33
|
+
import { test, after } from 'node:test';
|
|
34
|
+
import http from 'node:http';
|
|
35
|
+
import fs from 'node:fs';
|
|
36
|
+
import path from 'node:path';
|
|
37
|
+
import { createRequire } from 'node:module';
|
|
38
|
+
import { fileURLToPath } from 'node:url';
|
|
39
|
+
|
|
40
|
+
const require = createRequire(import.meta.url);
|
|
41
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
42
|
+
const GOV = path.join(ROOT, 'modules', 'government');
|
|
43
|
+
const dr = require(path.join(GOV, 'decision-rules.js'));
|
|
44
|
+
const vocab = require(path.join(GOV, 'docket-vocab.js'));
|
|
45
|
+
const catalog = require(path.join(GOV, 'catalog.js'));
|
|
46
|
+
const board = require(path.join(GOV, 'board.js'));
|
|
47
|
+
const db = require(path.join(GOV, 'db.js'));
|
|
48
|
+
const config = require(path.join(GOV, 'config.js'));
|
|
49
|
+
const docket = require(path.join(GOV, 'docket.js'));
|
|
50
|
+
|
|
51
|
+
const MIGRATION = fs.readFileSync(path.join(GOV, 'migrations', 'government_023_decision_rules.sql'), 'utf8');
|
|
52
|
+
const MIGRATION_CODE = MIGRATION.replace(/--[^\n]*/g, '');
|
|
53
|
+
|
|
54
|
+
async function withDb(stubs, fn) {
|
|
55
|
+
const originals = {};
|
|
56
|
+
for (const [k, v] of Object.entries(stubs)) { originals[k] = db[k]; db[k] = v; }
|
|
57
|
+
try { return await fn(); } finally { for (const [k, v] of Object.entries(originals)) db[k] = v; }
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const MONARCHY = Object.freeze({ membership: 'rank:archon', pass_rule: 'first_ratifier', window_minutes: null, close_early_on_full_turnout: true });
|
|
61
|
+
|
|
62
|
+
// ── 1. the defaults are today ───────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
test('the nine buildings are the docket\'s nine, in street order, each gate a real permission', () => {
|
|
65
|
+
assert.deepEqual(dr.AREA_KEYS, vocab.BUILDING_KEYS);
|
|
66
|
+
for (const a of dr.AREAS) {
|
|
67
|
+
if (a.key === 'charter') { assert.equal(a.gate, null); continue; }
|
|
68
|
+
assert.ok(catalog.byKey(a.gate), `${a.key}: gate ${a.gate} is a catalog permission`);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('with no rows at all every building reads AS TODAY: the gate decides alone, the charter is the board', () => {
|
|
73
|
+
const all = dr.resolveAll([], { board: MONARCHY });
|
|
74
|
+
assert.deepEqual(all.map((r) => r.area), vocab.BUILDING_KEYS);
|
|
75
|
+
for (const r of all) {
|
|
76
|
+
assert.equal(r.as_today, true, `${r.area} is as today`);
|
|
77
|
+
assert.equal(r.sitting, false, `${r.area}: nothing goes to a sitting`);
|
|
78
|
+
assert.equal(r.set_by_item_id, null);
|
|
79
|
+
assert.equal(dr.mayActAlone(r), true, `${r.area}: whoever holds the gate may still act alone`);
|
|
80
|
+
if (r.area === 'charter') {
|
|
81
|
+
assert.equal(r.decided_by, 'board');
|
|
82
|
+
assert.equal(r.rule, 'first', 'the charter reads the board\'s own pass rule (monarchy: first yes)');
|
|
83
|
+
} else {
|
|
84
|
+
assert.equal(r.decided_by, 'gate');
|
|
85
|
+
assert.equal(r.rule, 'decide');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('the migration seeds exactly the as-today row for every building, and nothing else', () => {
|
|
91
|
+
const seedBlock = /FROM \(VALUES([\s\S]*?)\) AS v\(area, decided_by\)/.exec(MIGRATION_CODE)[1];
|
|
92
|
+
const values = [...seedBlock.matchAll(/\('(\w+)',\s*'(gate|board)'\)/g)].map((m) => [m[1], m[2]]);
|
|
93
|
+
assert.deepEqual(values.map(([a]) => a), dr.AREA_KEYS, 'one seed row per building, in order');
|
|
94
|
+
for (const [area, decidedBy] of values) assert.equal(decidedBy, dr.defaultRow(area).decided_by);
|
|
95
|
+
assert.match(MIGRATION_CODE, /INSERT INTO government_decision_rules \(area, decided_by, rule\)\s+SELECT v\.area, v\.decided_by, NULL/,
|
|
96
|
+
'the seed carries no rule of its own — a default row means "as today"');
|
|
97
|
+
// a stored row that is malformed reads as the default: never wider than today
|
|
98
|
+
const odd = dr.resolveRow({ area: 'people', decided_by: 'rank:xenos+', rule: 'by_acclamation' });
|
|
99
|
+
assert.equal(odd.as_today, true);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('a malformed board block cannot make the charter row read wider', () => {
|
|
103
|
+
const r = dr.resolveRow({ area: 'charter', decided_by: 'gate', rule: null }, { board: MONARCHY });
|
|
104
|
+
assert.equal(r.decided_by, 'board', 'the charter is the board, always');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// ── 2. the four governments, read off the design of record ───────────────────
|
|
108
|
+
|
|
109
|
+
const GOVERNMENTS_SRC = fs.readFileSync(path.join(ROOT, 'docs', 'design', 'mocks', 'governor-city', 'Governments.dc.html'), 'utf8');
|
|
110
|
+
function designCells() {
|
|
111
|
+
const lists = [...GOVERNMENTS_SRC.matchAll(/cells: (\[\[[^\n]*\]\])/g)].map((m) => Function(`return ${m[1]}`)());
|
|
112
|
+
const auto = Function(`return ${/const automation = (\[\[[^\n]*\]\]);/.exec(GOVERNMENTS_SRC)[1]}`)();
|
|
113
|
+
const rowNames = Function(`return ${/const rowNames = (\[[^\n]*\]);/.exec(GOVERNMENTS_SRC)[1]}`)();
|
|
114
|
+
const names = [...GOVERNMENTS_SRC.matchAll(/name: '([^']+)', form: '([^']+)'/g)].map((m) => m[1]);
|
|
115
|
+
return lists.map((cells, i) => {
|
|
116
|
+
const full = [...cells, auto[i]];
|
|
117
|
+
return { name: names[i], byArea: Object.fromEntries(rowNames.map((n, r) => [n.toLowerCase(), { who: full[r][0], how: full[r][1] }])) };
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Each government written as THIS system expresses it: a constitution (the
|
|
122
|
+
// charter's board) + the table's rows. Custom ranks (council, tester, lead,
|
|
123
|
+
// trustee, maintainer, treasurer) are a project's own rank rows.
|
|
124
|
+
const EXPRESSED = {
|
|
125
|
+
'One founder': {
|
|
126
|
+
board: MONARCHY,
|
|
127
|
+
rules: [
|
|
128
|
+
{ area: 'rules', decided_by: 'rank:archon', rule: 'decide', who: 'you' },
|
|
129
|
+
{ area: 'treasury', decided_by: 'rank:archon', rule: 'decide', who: 'you' },
|
|
130
|
+
{ area: 'planning', decided_by: 'rank:archon', rule: 'decide', who: 'you' },
|
|
131
|
+
{ area: 'review', decided_by: 'rank:xenos+', rule: 'signoff', who: 'any builder who did not build it' },
|
|
132
|
+
{ area: 'release', decided_by: 'rank:archon', rule: 'decide', who: 'you' },
|
|
133
|
+
{ area: 'people', decided_by: 'rank:archon', rule: 'first', who: 'you' },
|
|
134
|
+
{ area: 'disputes', decided_by: 'rank:archon', rule: 'decide', who: 'you' },
|
|
135
|
+
{ area: 'automation', decided_by: 'rank:archon', rule: 'decide', who: 'you' },
|
|
136
|
+
],
|
|
137
|
+
},
|
|
138
|
+
'A council': {
|
|
139
|
+
board: { membership: 'rank:council', pass_rule: 'unanimous', window_minutes: 1440, close_early_on_full_turnout: true },
|
|
140
|
+
rules: [
|
|
141
|
+
{ area: 'rules', decided_by: 'rank:council', rule: 'majority', who: 'the council' },
|
|
142
|
+
{ area: 'treasury', decided_by: 'rank:treasurer', rule: 'decide', who: 'Mira under $50, the council above' },
|
|
143
|
+
{ area: 'planning', decided_by: 'rank:council', rule: 'majority', who: 'the council' },
|
|
144
|
+
{ area: 'review', decided_by: 'nobody', rule: 'vacant', who: 'no one yet' },
|
|
145
|
+
{ area: 'release', decided_by: 'rank:archon', rule: 'decide', who: 'you' },
|
|
146
|
+
{ area: 'people', decided_by: 'rank:council', rule: 'consent', who: 'the council' },
|
|
147
|
+
{ area: 'disputes', decided_by: 'rank:council', rule: 'decide', who: 'one member, on rota' },
|
|
148
|
+
{ area: 'automation', decided_by: 'rank:archon', rule: 'decide', who: 'you, the owner' },
|
|
149
|
+
],
|
|
150
|
+
},
|
|
151
|
+
'Everyone votes': {
|
|
152
|
+
board: { membership: 'rank:xenos+', pass_rule: 'majority', window_minutes: 1440, close_early_on_full_turnout: true },
|
|
153
|
+
rules: [
|
|
154
|
+
{ area: 'rules', decided_by: 'rank:xenos+', rule: 'majority', who: 'every member' },
|
|
155
|
+
{ area: 'treasury', decided_by: 'rank:xenos+', rule: 'consent', who: 'every member' },
|
|
156
|
+
{ area: 'planning', decided_by: 'rank:xenos+', rule: 'majority', who: 'every member' },
|
|
157
|
+
{ area: 'review', decided_by: 'rank:tester', rule: 'signoff', who: 'testers' },
|
|
158
|
+
{ area: 'release', decided_by: 'rank:lead', rule: 'first', who: 'any lead' },
|
|
159
|
+
{ area: 'people', decided_by: 'rank:xenos+', rule: 'consent', who: 'every member' },
|
|
160
|
+
{ area: 'disputes', decided_by: 'rank:xenos+', rule: 'majority', who: 'every member' },
|
|
161
|
+
{ area: 'automation', decided_by: 'rank:xenos+', rule: 'majority', who: 'every member' },
|
|
162
|
+
],
|
|
163
|
+
},
|
|
164
|
+
'A board of trustees': {
|
|
165
|
+
board: { membership: 'rank:trustee', pass_rule: 'unanimous', window_minutes: 1440, close_early_on_full_turnout: true },
|
|
166
|
+
rules: [
|
|
167
|
+
{ area: 'rules', decided_by: 'rank:trustee', rule: 'majority', who: 'the trustees' },
|
|
168
|
+
{ area: 'treasury', decided_by: 'rank:trustee', rule: 'majority', who: 'the trustees' },
|
|
169
|
+
{ area: 'planning', decided_by: 'rank:maintainer', rule: 'first', who: 'the maintainers' },
|
|
170
|
+
{ area: 'review', decided_by: 'rank:reviewer', rule: 'signoff', who: 'reviewers' },
|
|
171
|
+
{ area: 'release', decided_by: 'rank:maintainer', rule: 'first', who: 'any maintainer' },
|
|
172
|
+
{ area: 'people', decided_by: 'rank:maintainer', rule: 'consent', who: 'the maintainers' },
|
|
173
|
+
{ area: 'disputes', decided_by: 'rank:trustee', rule: 'majority', who: 'the trustees' },
|
|
174
|
+
{ area: 'automation', decided_by: 'rank:trustee', rule: 'majority', who: 'the trustees' },
|
|
175
|
+
],
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
test('the design file names four governments over all nine buildings', () => {
|
|
180
|
+
const govs = designCells();
|
|
181
|
+
assert.deepEqual(govs.map((g) => g.name), ['One founder', 'A council', 'Everyone votes', 'A board of trustees']);
|
|
182
|
+
for (const g of govs) assert.deepEqual(Object.keys(g.byArea).sort(), [...dr.AREA_KEYS].sort());
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
for (const g of designCells()) {
|
|
186
|
+
test(`expressible: ${g.name} — a valid constitution + valid rows, every building's rule the design's glyph`, () => {
|
|
187
|
+
const e = EXPRESSED[g.name];
|
|
188
|
+
assert.ok(e, `an expression of ${g.name}`);
|
|
189
|
+
assert.equal(board.validateProposedConstitution(e.board).ok, true, 'its charter is a valid constitution');
|
|
190
|
+
const v = dr.validateDecisionRulesProposal(e.rules);
|
|
191
|
+
assert.equal(v.ok, true, JSON.stringify(v));
|
|
192
|
+
const resolved = dr.resolveAll(v.rules, { board: e.board });
|
|
193
|
+
for (const r of resolved) {
|
|
194
|
+
const cell = g.byArea[r.area];
|
|
195
|
+
// A one-seat board's first yes IS that person deciding alone: the founder's
|
|
196
|
+
// charter (monarchy, first_ratifier over rank:archon) is the design's 'decide'.
|
|
197
|
+
const expected = r.area === 'charter' && cell.how === 'decide' && e.board.pass_rule === 'first_ratifier' ? 'first' : cell.how;
|
|
198
|
+
assert.equal(r.rule, expected, `${g.name} · ${r.area}: design says "${cell.who}, ${cell.how}"`);
|
|
199
|
+
if (r.area !== 'charter') assert.equal(r.who, cell.who, `${g.name} · ${r.area}: the seat in the design's words`);
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── strict validation ───────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
test('a proposal is validated strictly: every refusal named, nothing corrected', () => {
|
|
207
|
+
const ok = [{ area: 'people', decided_by: 'rank:council', rule: 'consent' }];
|
|
208
|
+
assert.equal(dr.validateDecisionRulesProposal(ok).ok, true);
|
|
209
|
+
const cases = [
|
|
210
|
+
[null, 'proposal_not_a_list'],
|
|
211
|
+
[[], 'proposal_not_a_list'],
|
|
212
|
+
[[{ area: 'people', decided_by: 'rank:council', rule: 'consent', extra: 1 }], 'unknown_field'],
|
|
213
|
+
[[{ area: 'kitchen', decided_by: 'gate' }], 'unknown_area'],
|
|
214
|
+
[[{ area: 'people', decided_by: 'gate' }, { area: 'people', decided_by: 'gate' }], 'duplicate_area'],
|
|
215
|
+
[[{ area: 'charter', decided_by: 'rank:council', rule: 'unanimous' }], 'charter_is_the_board'],
|
|
216
|
+
[[{ area: 'people', decided_by: 'board' }], 'only_the_charter_is_the_board'],
|
|
217
|
+
[[{ area: 'people', decided_by: 'gate', rule: 'consent' }], 'gate_takes_no_rule'],
|
|
218
|
+
[[{ area: 'review', decided_by: 'nobody', rule: 'consent' }], 'nobody_is_vacant'],
|
|
219
|
+
[[{ area: 'people', decided_by: 'everyone', rule: 'consent' }], 'bad_decided_by'],
|
|
220
|
+
[[{ area: 'people', decided_by: 'rank:council', rule: 'vacant' }], 'bad_rule'],
|
|
221
|
+
[[{ area: 'people', decided_by: 'rank:council', rule: 'by_acclamation' }], 'bad_rule'],
|
|
222
|
+
[[{ area: 'people', decided_by: 'rank:council', rule: 'consent', who: '' }], 'bad_who'],
|
|
223
|
+
];
|
|
224
|
+
for (const [raw, reason] of cases) {
|
|
225
|
+
const v = dr.validateDecisionRulesProposal(raw);
|
|
226
|
+
assert.equal(v.ok, false, `${JSON.stringify(raw)} refused`);
|
|
227
|
+
assert.equal(v.reason, reason, JSON.stringify(raw));
|
|
228
|
+
}
|
|
229
|
+
// returning a building to its gate is a legal change
|
|
230
|
+
assert.equal(dr.validateDecisionRulesProposal([{ area: 'people', decided_by: 'gate' }]).ok, true);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
test('a sitting is snapshotted from the row: who, how, and a clock only where expiry means return', () => {
|
|
234
|
+
const board4 = { ...MONARCHY, window_minutes: 90 };
|
|
235
|
+
const maj = dr.sittingConstitution(dr.resolveRow({ area: 'rules', decided_by: 'rank:council', rule: 'majority' }), { board: board4 });
|
|
236
|
+
assert.deepEqual({ ...maj }, { membership: 'rank:council', pass_rule: 'majority', window_minutes: 90, close_early_on_full_turnout: true, rule: 'majority', area: 'rules' });
|
|
237
|
+
const con = dr.sittingConstitution(dr.resolveRow({ area: 'people', decided_by: 'rank:council', rule: 'consent' }), { board: board4 });
|
|
238
|
+
assert.equal(con.window_minutes, null, 'consent passes on silence — no clock');
|
|
239
|
+
const sign = dr.sittingConstitution(dr.resolveRow({ area: 'review', decided_by: 'rank:tester', rule: 'signoff' }), { board: board4 });
|
|
240
|
+
assert.equal(sign.pass_rule, 'first_ratifier');
|
|
241
|
+
assert.equal(sign.window_minutes, null);
|
|
242
|
+
assert.equal(dr.sittingConstitution(dr.resolveRow({ area: 'review', decided_by: 'nobody', rule: 'vacant' }), { board: board4 }), null);
|
|
243
|
+
assert.equal(dr.sittingConstitution(dr.resolveRow({ area: 'review', decided_by: 'gate', rule: null }), { board: board4 }), null);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test('mayActAlone: as today yes; decide/first only a seated member; any other rule nobody', () => {
|
|
247
|
+
const R = (decided_by, rule) => dr.resolveRow({ area: 'people', decided_by, rule });
|
|
248
|
+
assert.equal(dr.mayActAlone(R('gate', null)), true);
|
|
249
|
+
assert.equal(dr.mayActAlone(R('rank:council', 'decide'), { seated: true }), true);
|
|
250
|
+
assert.equal(dr.mayActAlone(R('rank:council', 'decide'), { seated: false }), false);
|
|
251
|
+
assert.equal(dr.mayActAlone(R('rank:council', 'first'), { seated: true }), true);
|
|
252
|
+
for (const rule of ['consent', 'majority', 'unanimous', 'signoff']) {
|
|
253
|
+
assert.equal(dr.mayActAlone(R('rank:council', rule), { seated: true }), false, rule);
|
|
254
|
+
}
|
|
255
|
+
assert.equal(dr.mayActAlone(R('nobody', 'vacant'), { seated: true }), false);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// ── 3. changed only by a passed amendment ───────────────────────────────────
|
|
259
|
+
|
|
260
|
+
test('proposing files a decision_rules amendment, sat under the constitution IN FORCE', async () => {
|
|
261
|
+
let created = null; let opened = null;
|
|
262
|
+
await withDb({
|
|
263
|
+
getRankByKey: async (k) => ({ rank_key: k, is_system: false }),
|
|
264
|
+
listDecisionRules: async () => [],
|
|
265
|
+
createAmendment: async (args) => { created = args; return { id: '5', ...args }; },
|
|
266
|
+
openBoardItem: async (args) => { opened = args; return { id: '21', subject_type: args.subjectType, subject_id: args.subjectId, constitution: args.constitution }; },
|
|
267
|
+
}, async () => {
|
|
268
|
+
const out = await board.proposeDecisionRules({ rules: [{ area: 'people', decided_by: 'rank:council', rule: 'consent', who: 'the council' }], proposedBy: '42' });
|
|
269
|
+
assert.equal(out.ok, true, JSON.stringify(out));
|
|
270
|
+
});
|
|
271
|
+
assert.equal(created.kind, 'decision_rules');
|
|
272
|
+
assert.deepEqual(created.proposed, { decision_rules: [{ area: 'people', decided_by: 'rank:council', rule: 'consent', who: 'the council' }] });
|
|
273
|
+
assert.match(created.rationaleMd, /People: the council, passes unless someone objects/);
|
|
274
|
+
assert.equal(opened.subjectType, 'constitutional_amendment', 'one amendment subject, one pass path');
|
|
275
|
+
assert.equal(opened.constitution.pass_rule, 'first_ratifier', 'decided under the board in force (this checkout: monarchy)');
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('proposing refuses an unknown rank, a no-op, and anything malformed — before any write', async () => {
|
|
279
|
+
const never = async () => { throw new Error('must not be reached'); };
|
|
280
|
+
await withDb({
|
|
281
|
+
getRankByKey: async () => null,
|
|
282
|
+
listDecisionRules: async () => [],
|
|
283
|
+
createAmendment: never,
|
|
284
|
+
openBoardItem: never,
|
|
285
|
+
}, async () => {
|
|
286
|
+
const unknown = await board.proposeDecisionRules({ rules: [{ area: 'people', decided_by: 'rank:council', rule: 'consent' }], proposedBy: '42' });
|
|
287
|
+
assert.equal(unknown.reason, 'unknown_rank');
|
|
288
|
+
assert.equal(unknown.rank_key, 'council');
|
|
289
|
+
const noop = await board.proposeDecisionRules({ rules: [{ area: 'people', decided_by: 'gate' }], proposedBy: '42' });
|
|
290
|
+
assert.equal(noop.reason, 'no_change', 'People is already as today');
|
|
291
|
+
const bad = await board.proposeDecisionRules({ rules: [{ area: 'charter', decided_by: 'rank:archon', rule: 'decide' }], proposedBy: '42' });
|
|
292
|
+
assert.equal(bad.reason, 'charter_is_the_board');
|
|
293
|
+
assert.equal((await board.proposeDecisionRules({ rules: [{ area: 'people', decided_by: 'rank:metic+', rule: 'consent' }], proposedBy: null })).reason, 'missing_proposer');
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
|
|
297
|
+
const rulesAmendment = { id: '5', kind: 'decision_rules', proposed: { decision_rules: [{ area: 'people', decided_by: 'rank:council', rule: 'consent', who: null }] } };
|
|
298
|
+
const amendmentItem = (over = {}) => ({ id: '21', subject_type: 'constitutional_amendment', subject_id: '5', opened_by: '42', closed_at: null, expired: false, constitution: MONARCHY, ...over });
|
|
299
|
+
|
|
300
|
+
// A closeBoardItem stub that behaves like the real one: afterClose runs INSIDE
|
|
301
|
+
// the "transaction"; a throw from it rolls the close back (nothing is closed).
|
|
302
|
+
function txClose(log) {
|
|
303
|
+
return async ({ itemId, outcome, afterClose }) => {
|
|
304
|
+
const closedRow = { ...amendmentItem({ id: itemId }), closed_at: 'T', outcome };
|
|
305
|
+
const client = { tx: true };
|
|
306
|
+
if (typeof afterClose === 'function') {
|
|
307
|
+
try { await afterClose(client, closedRow); } catch (err) { log.push('rolled_back'); throw err; }
|
|
308
|
+
}
|
|
309
|
+
log.push('committed');
|
|
310
|
+
return closedRow;
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
test('a PASSED decision-rules amendment writes the rows inside the closing transaction — and never touches the board block', async () => {
|
|
315
|
+
const log = [];
|
|
316
|
+
let written = null;
|
|
317
|
+
const origApply = config.applyBoardAmendment;
|
|
318
|
+
config.applyBoardAmendment = () => { throw new Error('a rules amendment must not rewrite the board block'); };
|
|
319
|
+
try {
|
|
320
|
+
await withDb({
|
|
321
|
+
getVotesForItem: async () => [{ voter_id: '42', direction: 'yes' }],
|
|
322
|
+
getAmendment: async (id) => { assert.equal(String(id), '5'); return rulesAmendment; },
|
|
323
|
+
applyDecisionRules: async (client, args) => { assert.equal(client.tx, true, 'the closing transaction\'s client'); written = args; return 1; },
|
|
324
|
+
closeBoardItem: txClose(log),
|
|
325
|
+
getBoardItemAuthor: async () => '42',
|
|
326
|
+
insertAuthorRatifyKarma: async () => true,
|
|
327
|
+
}, async () => {
|
|
328
|
+
const out = await board.closeItem(amendmentItem());
|
|
329
|
+
assert.equal(out.outcome, 'passed');
|
|
330
|
+
assert.deepEqual(out.applied_decision_rules.map((r) => r.area), ['people']);
|
|
331
|
+
assert.equal(out.applied_constitution, undefined);
|
|
332
|
+
});
|
|
333
|
+
} finally { config.applyBoardAmendment = origApply; }
|
|
334
|
+
assert.deepEqual(log, ['committed']);
|
|
335
|
+
assert.equal(String(written.itemId), '21');
|
|
336
|
+
assert.deepEqual(written.rules.map((r) => [r.area, r.decided_by, r.rule]), [['people', 'rank:council', 'consent']]);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test('a RETURNED decision-rules amendment writes nothing', async () => {
|
|
340
|
+
await withDb({
|
|
341
|
+
getVotesForItem: async () => [{ voter_id: '9', direction: 'no', reason_md: 'r', section_key: 'fit' }],
|
|
342
|
+
getAmendment: async () => { throw new Error('must not be reached — a return applies nothing'); },
|
|
343
|
+
applyDecisionRules: async () => { throw new Error('must not be reached'); },
|
|
344
|
+
closeBoardItem: async ({ outcome, afterClose }) => { assert.equal(afterClose, null); return { ...amendmentItem(), closed_at: 'T', outcome }; },
|
|
345
|
+
}, async () => {
|
|
346
|
+
const out = await board.closeItem(amendmentItem());
|
|
347
|
+
assert.equal(out.outcome, 'returned');
|
|
348
|
+
assert.equal(out.applied_decision_rules, undefined);
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
test('THE GUARD: only a passed decision-rules amendment, naming its own amendment, may write the table', async () => {
|
|
353
|
+
const passed = { id: '21', subject_type: 'constitutional_amendment', subject_id: '5', outcome: 'passed' };
|
|
354
|
+
assert.equal(board.decisionRulesWriteAllowed(passed, rulesAmendment), true);
|
|
355
|
+
const refused = [
|
|
356
|
+
[{ ...passed, outcome: 'returned' }, rulesAmendment, 'a returned sitting'],
|
|
357
|
+
[{ ...passed, outcome: null }, rulesAmendment, 'an open sitting'],
|
|
358
|
+
[{ ...passed, subject_type: 'people_matter' }, rulesAmendment, 'a matter, not an amendment'],
|
|
359
|
+
[{ ...passed, subject_type: 'full_idea' }, rulesAmendment, 'an idea'],
|
|
360
|
+
[passed, { ...rulesAmendment, kind: 'constitution' }, 'a board-block amendment'],
|
|
361
|
+
[passed, { ...rulesAmendment, kind: undefined }, 'an amendment of no kind'],
|
|
362
|
+
[{ ...passed, subject_id: '6' }, rulesAmendment, 'another amendment\'s item'],
|
|
363
|
+
[null, rulesAmendment, 'no item'],
|
|
364
|
+
[passed, null, 'no amendment'],
|
|
365
|
+
];
|
|
366
|
+
await withDb({ applyDecisionRules: async () => { throw new Error('the write must never be reached'); } }, async () => {
|
|
367
|
+
for (const [row, am, why] of refused) {
|
|
368
|
+
assert.equal(board.decisionRulesWriteAllowed(row, am), false, why);
|
|
369
|
+
await assert.rejects(() => board.applyPassedDecisionRules({ tx: true }, row, am), /only be written by a passed decision-rules amendment/, why);
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test('a failed write rolls the close back — the sitting is retried, never passed-but-unapplied', async () => {
|
|
375
|
+
const log = [];
|
|
376
|
+
await withDb({
|
|
377
|
+
getVotesForItem: async () => [{ voter_id: '42', direction: 'yes' }],
|
|
378
|
+
getAmendment: async () => rulesAmendment,
|
|
379
|
+
applyDecisionRules: async () => { throw new Error('trigger refused'); },
|
|
380
|
+
closeBoardItem: txClose(log),
|
|
381
|
+
}, async () => {
|
|
382
|
+
await assert.rejects(() => board.closeItem(amendmentItem()), /trigger refused/);
|
|
383
|
+
});
|
|
384
|
+
assert.deepEqual(log, ['rolled_back']);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
test('nothing else writes the table: one writer in db.js, one caller in decision-board.js, no route, and the database refuses the rest', () => {
|
|
388
|
+
const files = [];
|
|
389
|
+
const walk = (dir) => {
|
|
390
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
391
|
+
if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
|
|
392
|
+
const p = path.join(dir, e.name);
|
|
393
|
+
if (e.isDirectory()) walk(p);
|
|
394
|
+
else if (/\.(js|mjs|cjs)$/.test(e.name)) files.push(p);
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
walk(path.join(ROOT, 'modules'));
|
|
398
|
+
walk(path.join(ROOT, 'src'));
|
|
399
|
+
walk(path.join(ROOT, 'scripts'));
|
|
400
|
+
const writers = [];
|
|
401
|
+
for (const f of files) {
|
|
402
|
+
const src = fs.readFileSync(f, 'utf8');
|
|
403
|
+
if (/(INSERT INTO|UPDATE|DELETE FROM)\s+government_decision_rules\b/.test(src)) writers.push(path.relative(ROOT, f).replace(/\\/g, '/'));
|
|
404
|
+
}
|
|
405
|
+
assert.deepEqual(writers, ['modules/government/db.js'], 'exactly one SQL writer');
|
|
406
|
+
const callers = files.filter((f) => /\.applyDecisionRules\(/.test(fs.readFileSync(f, 'utf8'))).map((f) => path.relative(ROOT, f).replace(/\\/g, '/'));
|
|
407
|
+
assert.deepEqual(callers, ['modules/government/decision-board.js']);
|
|
408
|
+
const boardSrc = fs.readFileSync(path.join(GOV, 'decision-board.js'), 'utf8');
|
|
409
|
+
assert.equal((boardSrc.match(/db\.applyDecisionRules\(/g) || []).length, 1, 'called once, inside applyPassedDecisionRules');
|
|
410
|
+
const routes = fs.readFileSync(path.join(GOV, 'routes', 'government.js'), 'utf8');
|
|
411
|
+
assert.ok(!/applyDecisionRules|applyPassedDecisionRules|government_decision_rules/.test(routes), 'no route writes the table');
|
|
412
|
+
// the SQL carries the same condition, and the trigger refuses every other write
|
|
413
|
+
const dbSrc = fs.readFileSync(path.join(GOV, 'db.js'), 'utf8');
|
|
414
|
+
assert.match(dbSrc, /i\.outcome = 'passed' AND a\.kind = 'decision_rules'/);
|
|
415
|
+
assert.match(MIGRATION_CODE, /CREATE OR REPLACE TRIGGER government_decision_rules_amendment_only\s+BEFORE INSERT OR UPDATE OR DELETE ON government_decision_rules/);
|
|
416
|
+
assert.match(MIGRATION_CODE, /i\.subject_type = 'constitutional_amendment'\s+AND i\.outcome = 'passed'\s+AND a\.kind = 'decision_rules'/);
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
// ── 4. more subjects ────────────────────────────────────────────────────────
|
|
420
|
+
|
|
421
|
+
test('the board votes on eleven subjects now — three before, one per building besides the charter', () => {
|
|
422
|
+
const types = Object.values(dr.MATTER_SUBJECT_TYPES);
|
|
423
|
+
assert.equal(types.length, 8);
|
|
424
|
+
const check = /CHECK \(subject_type IN \(([\s\S]*?)\)\)/.exec(MIGRATION_CODE)[1];
|
|
425
|
+
const listed = [...check.matchAll(/'(\w+)'/g)].map((m) => m[1]);
|
|
426
|
+
assert.deepEqual(listed, ['full_idea', 'constitutional_amendment', 'genesis_stage', ...types]);
|
|
427
|
+
for (const t of types) assert.equal(dr.isMatterSubject(t), true);
|
|
428
|
+
assert.equal(dr.isMatterSubject('full_idea'), false);
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
const sittingRow = (area, decided_by, rule) => ({ area, decided_by, rule, who: null, set_by_item_id: '30' });
|
|
432
|
+
|
|
433
|
+
test('under the default rows no matter can be opened anywhere — today unchanged', async () => {
|
|
434
|
+
const never = async () => { throw new Error('must not be reached'); };
|
|
435
|
+
await withDb({ listDecisionRules: async () => [], createMatter: never, openBoardItem: never }, async () => {
|
|
436
|
+
for (const area of dr.AREA_KEYS.filter((a) => a !== 'charter')) {
|
|
437
|
+
const out = await board.openMatter({ area, title: 'Admit Kai as a builder', proposedBy: '42' });
|
|
438
|
+
assert.equal(out.reason, 'decided_by_gate', area);
|
|
439
|
+
}
|
|
440
|
+
assert.equal((await board.openMatter({ area: 'charter', title: 'x', proposedBy: '42' })).reason, 'charter_matters_are_amendments');
|
|
441
|
+
});
|
|
442
|
+
await withDb({ listDecisionRules: async () => [sittingRow('review', 'nobody', 'vacant')], createMatter: never, openBoardItem: never }, async () => {
|
|
443
|
+
assert.equal((await board.openMatter({ area: 'review', title: 'Sign off the new sign-up', proposedBy: '42' })).reason, 'no_one_holds_this');
|
|
444
|
+
});
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
test('a matter at a building handed to a sitting sits under the ROW: its members, its rule', async () => {
|
|
448
|
+
let opened = null; let made = null;
|
|
449
|
+
await withDb({
|
|
450
|
+
listDecisionRules: async () => [sittingRow('people', 'rank:council', 'consent')],
|
|
451
|
+
getRankByKey: async (k) => ({ rank_key: k, is_system: false }),
|
|
452
|
+
createMatter: async (args) => { made = args; return { id: '8', ...args }; },
|
|
453
|
+
openBoardItem: async (args) => { opened = args; return { id: '40', subject_type: args.subjectType, subject_id: args.subjectId, constitution: args.constitution }; },
|
|
454
|
+
}, async () => {
|
|
455
|
+
const out = await board.openMatter({ area: 'people', title: 'Admit Kai as a builder', action: { type: 'assign_rank', builder_id: 77, rank_key: 'tester' }, proposedBy: '42' });
|
|
456
|
+
assert.equal(out.ok, true, JSON.stringify(out));
|
|
457
|
+
});
|
|
458
|
+
assert.deepEqual(made.action, { type: 'assign_rank', builder_id: 77, rank_key: 'tester' });
|
|
459
|
+
assert.equal(opened.subjectType, 'people_matter');
|
|
460
|
+
assert.equal(opened.constitution.membership, 'rank:council');
|
|
461
|
+
assert.equal(opened.constitution.pass_rule, 'consent');
|
|
462
|
+
assert.equal(opened.constitution.rule, 'consent');
|
|
463
|
+
assert.equal(opened.windowMinutes, null);
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
test('a matter\'s act is checked when it is opened: People only, a custom rank only', async () => {
|
|
467
|
+
await withDb({
|
|
468
|
+
listDecisionRules: async () => [sittingRow('people', 'rank:council', 'consent'), sittingRow('rules', 'rank:council', 'majority')],
|
|
469
|
+
getRankByKey: async (k) => (k === 'metic' ? { rank_key: k, is_system: true } : k === 'ghost' ? null : { rank_key: k, is_system: false }),
|
|
470
|
+
createMatter: async () => { throw new Error('must not be reached'); },
|
|
471
|
+
openBoardItem: async () => { throw new Error('must not be reached'); },
|
|
472
|
+
}, async () => {
|
|
473
|
+
const act = (a) => ({ type: 'assign_rank', builder_id: 7, rank_key: 'tester', ...a });
|
|
474
|
+
assert.equal((await board.openMatter({ area: 'rules', title: 't', action: act(), proposedBy: '42' })).reason, 'action_not_supported_here');
|
|
475
|
+
assert.equal((await board.openMatter({ area: 'people', title: 't', action: act({ rank_key: 'metic' }), proposedBy: '42' })).reason, 'seeded_rank');
|
|
476
|
+
assert.equal((await board.openMatter({ area: 'people', title: 't', action: act({ rank_key: 'ghost' }), proposedBy: '42' })).reason, 'unknown_rank');
|
|
477
|
+
assert.equal((await board.openMatter({ area: 'people', title: 't', action: act({ type: 'promote' }), proposedBy: '42' })).reason, 'bad_action');
|
|
478
|
+
assert.equal((await board.openMatter({ area: 'people', title: 't', action: act({ builder_id: -1 }), proposedBy: '42' })).reason, 'bad_action');
|
|
479
|
+
assert.equal((await board.openMatter({ area: 'people', title: '', proposedBy: '42' })).reason, 'bad_title');
|
|
480
|
+
});
|
|
481
|
+
});
|
|
482
|
+
|
|
483
|
+
test('sign-off holds the proposer to the author rule: their own yes never decides; another member\'s does', () => {
|
|
484
|
+
const signoff = { membership: 'rank:tester', pass_rule: 'first_ratifier', rule: 'signoff', close_early_on_full_turnout: true };
|
|
485
|
+
assert.equal(board.authorRuleApplies('review_matter', signoff), true);
|
|
486
|
+
assert.equal(board.authorRuleApplies('review_matter', { ...signoff, rule: 'first' }), false, 'first yes: the proposer may decide, like an amendment');
|
|
487
|
+
assert.equal(board.authorRuleApplies('constitutional_amendment', signoff), false, 'the amendment carve-out is untouched');
|
|
488
|
+
assert.equal(board.authorRuleApplies('full_idea', null), true);
|
|
489
|
+
const own = [{ voter_id: '42', direction: 'yes' }];
|
|
490
|
+
assert.equal(board.shouldCloseAfterVote({ constitution: signoff, votes: own, members: [], authorId: '42' }), false);
|
|
491
|
+
assert.equal(board.shouldCloseAfterVote({ constitution: signoff, votes: [...own, { voter_id: '9', direction: 'yes' }], members: [], authorId: '42' }), true);
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
test('a passed People matter acts by itself: the rank is given, nobody is paid, the decision is announced', async () => {
|
|
495
|
+
const events = [];
|
|
496
|
+
const api = require(path.join(ROOT, 'src', 'module-api.js'));
|
|
497
|
+
const origEmit = api.emitAsync;
|
|
498
|
+
api.emitAsync = async (name, payload) => { events.push([name, payload]); };
|
|
499
|
+
let assigned = null;
|
|
500
|
+
try {
|
|
501
|
+
await withDb({
|
|
502
|
+
getVotesForItem: async () => [{ voter_id: '9', direction: 'yes' }],
|
|
503
|
+
closeBoardItem: async ({ outcome, afterClose }) => { assert.equal(afterClose, null, 'a matter books nothing in the close'); return { id: '40', subject_type: 'people_matter', subject_id: '8', opened_by: '42', closed_at: 'T', outcome }; },
|
|
504
|
+
getMatter: async () => ({ id: '8', area: 'people', title: 'Admit Kai', action: { type: 'assign_rank', builder_id: 77, rank_key: 'tester' }, proposed_by: '42' }),
|
|
505
|
+
getRankByKey: async (k) => ({ rank_key: k, is_system: false }),
|
|
506
|
+
assignRank: async (args) => { assigned = args; return true; },
|
|
507
|
+
insertAuthorRatifyKarma: async () => { throw new Error('a matter pays nobody'); },
|
|
508
|
+
getBoardItemAuthor: async () => '42',
|
|
509
|
+
}, async () => {
|
|
510
|
+
const item = { id: '40', subject_type: 'people_matter', subject_id: '8', opened_by: '42', closed_at: null, constitution: { membership: 'rank:council', pass_rule: 'consent', rule: 'consent' } };
|
|
511
|
+
const out = await board.closeItem(item);
|
|
512
|
+
assert.equal(out.outcome, 'passed');
|
|
513
|
+
assert.equal(out.author_karma, null);
|
|
514
|
+
assert.equal(out.acted.done, true);
|
|
515
|
+
});
|
|
516
|
+
} finally { api.emitAsync = origEmit; }
|
|
517
|
+
assert.deepEqual(assigned, { builderId: 77, rankKey: 'tester', assignedBy: '42' });
|
|
518
|
+
const passed = events.find(([n]) => n === 'board.matter.passed');
|
|
519
|
+
assert.ok(passed, 'board.matter.passed emitted');
|
|
520
|
+
assert.equal(passed[1].area, 'people');
|
|
521
|
+
assert.equal(passed[1].acted, true);
|
|
522
|
+
});
|
|
523
|
+
|
|
524
|
+
// ── 5. the gate replaced where the building allows it ───────────────────────
|
|
525
|
+
|
|
526
|
+
test('checkActAlone: People as today lets the gate act; handed to a sitting it refuses one builder acting alone', async () => {
|
|
527
|
+
await withDb({ listDecisionRules: async () => [] }, async () => {
|
|
528
|
+
assert.equal((await board.checkActAlone('people', '42')).ok, true);
|
|
529
|
+
});
|
|
530
|
+
await withDb({ listDecisionRules: async () => [sittingRow('people', 'rank:council', 'consent')], builderHoldsRankActive: async () => true }, async () => {
|
|
531
|
+
const out = await board.checkActAlone('people', '42');
|
|
532
|
+
assert.equal(out.ok, false);
|
|
533
|
+
assert.equal(out.reason, 'decided_by_sitting');
|
|
534
|
+
assert.equal(out.open_with, 'POST /government/board/matters');
|
|
535
|
+
});
|
|
536
|
+
await withDb({ listDecisionRules: async () => [sittingRow('people', 'rank:council', 'decide')], builderHoldsRankActive: async (_id, keys) => keys.includes('council') }, async () => {
|
|
537
|
+
assert.equal((await board.checkActAlone('people', '42')).ok, true, 'a seated member may decide alone');
|
|
538
|
+
});
|
|
539
|
+
await withDb({ listDecisionRules: async () => [sittingRow('people', 'rank:council', 'decide')], builderHoldsRankActive: async () => false }, async () => {
|
|
540
|
+
assert.equal((await board.checkActAlone('people', '42')).ok, false, 'not seated: not theirs to decide');
|
|
541
|
+
});
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
// ── 6. the feed reads the table ─────────────────────────────────────────────
|
|
545
|
+
|
|
546
|
+
test('the docket: every building carries who decides it, and a building\'s sitting carries the table\'s rule', async () => {
|
|
547
|
+
const decisions = dr.resolveAll([sittingRow('review', 'rank:tester', 'signoff')], { board: MONARCHY });
|
|
548
|
+
const origView = board.boardRoomView;
|
|
549
|
+
const origMembers = db.getActiveBuildersWithRank;
|
|
550
|
+
const origCount = db.countActiveBuilders;
|
|
551
|
+
board.boardRoomView = async () => ({
|
|
552
|
+
open: [{
|
|
553
|
+
id: '40', subject_type: 'review_matter', subject_id: '8', opened_at: '2026-10-01T00:00:00Z', closes_at: null, expired: false,
|
|
554
|
+
closed_at: null, outcome: null, votes: [],
|
|
555
|
+
subject: { area: 'review', title: 'Sign off the new sign-up', author_id: '42' },
|
|
556
|
+
constitution: { membership: 'rank:tester', pass_rule: 'first_ratifier', rule: 'signoff', area: 'review' },
|
|
557
|
+
}],
|
|
558
|
+
closed: [],
|
|
559
|
+
});
|
|
560
|
+
db.getActiveBuildersWithRank = async () => [{ builder_id: 7, github_login: 'lu', display_name: 'Lu' }];
|
|
561
|
+
db.countActiveBuilders = async () => 3;
|
|
562
|
+
try {
|
|
563
|
+
const d = await docket.buildDocket({ viewer: { id: 7 }, permissions: ['board.vote.cast'], contributions: { sources: new Map(), readings: new Map() }, decisions });
|
|
564
|
+
const m = d.matters.find((x) => x.id === 'board:40');
|
|
565
|
+
assert.equal(m.building, 'review');
|
|
566
|
+
assert.equal(m.rule, 'signoff', 'the table\'s rule, not the pass rule underneath it');
|
|
567
|
+
assert.equal(m.state, 'needs', 'a tester who did not build it is needed');
|
|
568
|
+
assert.equal(m.where, 'review · matter');
|
|
569
|
+
const review = d.buildings.find((b) => b.key === 'review');
|
|
570
|
+
assert.equal(review.decision.rule, 'signoff');
|
|
571
|
+
assert.equal(review.decision.as_today, false);
|
|
572
|
+
const people = d.buildings.find((b) => b.key === 'people');
|
|
573
|
+
assert.deepEqual({ rule: people.decision.rule, as_today: people.decision.as_today }, { rule: 'decide', as_today: true });
|
|
574
|
+
assert.deepEqual(d.vocabulary.decision_rules.map((r) => r.key), dr.DECISION_RULE_KEYS);
|
|
575
|
+
const unread = await docket.buildDocket({ viewer: { id: 7 }, permissions: ['board.vote.cast'], contributions: { sources: new Map(), readings: new Map() }, decisions: null });
|
|
576
|
+
assert.ok(unread.buildings.every((b) => b.decision === null), 'an unread table is null, never a guess');
|
|
577
|
+
} finally {
|
|
578
|
+
board.boardRoomView = origView;
|
|
579
|
+
db.getActiveBuildersWithRank = origMembers;
|
|
580
|
+
db.countActiveBuilders = origCount;
|
|
581
|
+
}
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
test('the constitution view carries the table, and a rules amendment is never read as a board-block divergence', async () => {
|
|
585
|
+
await withDb({
|
|
586
|
+
listAmendmentHistory: async () => [{
|
|
587
|
+
item_id: '21', amendment_id: '5', kind: 'decision_rules', outcome: 'passed', closed_at: 'T',
|
|
588
|
+
proposed: { decision_rules: [{ area: 'people', decided_by: 'rank:council', rule: 'consent', who: null }] },
|
|
589
|
+
}],
|
|
590
|
+
countActiveBuilders: async () => 1,
|
|
591
|
+
listDecisionRules: async () => [sittingRow('people', 'rank:council', 'consent')],
|
|
592
|
+
}, async () => {
|
|
593
|
+
const v = await board.constitutionView({});
|
|
594
|
+
assert.equal(v.divergence, null, 'a passed rules amendment says nothing about the board block');
|
|
595
|
+
const people = v.decision_rules.areas.find((a) => a.area === 'people');
|
|
596
|
+
assert.equal(people.rule, 'consent');
|
|
597
|
+
assert.equal(people.set_by_item_id, '30');
|
|
598
|
+
assert.equal(v.history[0].kind, 'decision_rules');
|
|
599
|
+
});
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
// ── the routes, through the real router ─────────────────────────────────────
|
|
603
|
+
|
|
604
|
+
const api = require(path.join(ROOT, 'src', 'module-api.js'));
|
|
605
|
+
const seams = require(path.join(ROOT, 'src', 'module-seams.js'));
|
|
606
|
+
const rrc = require(path.join(ROOT, 'src', 'bongos', 'route-rank-check.js'));
|
|
607
|
+
const { attachFail } = require(path.join(ROOT, 'src', 'bongos', 'middleware', 'error-envelope.js'));
|
|
608
|
+
const express = require('express');
|
|
609
|
+
|
|
610
|
+
test('the two new routes are pinned to the amendment atom', () => {
|
|
611
|
+
const pins = rrc.MODULE_EXPECTED_RANKS.government;
|
|
612
|
+
assert.equal(pins['POST /government/board/decision-rules'], 'perm:board.item.open');
|
|
613
|
+
assert.equal(pins['POST /government/board/matters'], 'perm:board.item.open');
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
const realRequireBuilder = api.requireBuilder;
|
|
617
|
+
api.requireBuilder = (req, res, next) => { req.builder = { id: 42, rank: 'archon' }; next(); };
|
|
618
|
+
seams._reset();
|
|
619
|
+
let held = ['government.manage', 'board.item.open', 'board.vote.cast'];
|
|
620
|
+
seams.registerProvider('government', { resolveBuilderPermissions: async () => held });
|
|
621
|
+
const buildGovernmentRouter = require(path.join(GOV, 'routes', 'government.js'));
|
|
622
|
+
const app = express();
|
|
623
|
+
app.use(express.json());
|
|
624
|
+
app.use(attachFail);
|
|
625
|
+
app.use(buildGovernmentRouter());
|
|
626
|
+
const server = http.createServer(app);
|
|
627
|
+
await new Promise((r) => server.listen(0, '127.0.0.1', r));
|
|
628
|
+
const BASE = `http://127.0.0.1:${server.address().port}`;
|
|
629
|
+
after(() => { server.close(); api.requireBuilder = realRequireBuilder; seams._reset(); });
|
|
630
|
+
|
|
631
|
+
async function call(method, p, body) {
|
|
632
|
+
const res = await fetch(`${BASE}${p}`, { method, headers: body === undefined ? {} : { 'content-type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body) });
|
|
633
|
+
let json = null; try { json = await res.json(); } catch { /* none */ }
|
|
634
|
+
return { status: res.status, json };
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
test('People\'s rank assignments: as today they go through; once People is a sitting one builder is refused', async () => {
|
|
638
|
+
let assigned = 0;
|
|
639
|
+
const base = { getRankByKey: async (k) => ({ rank_key: k, is_system: false, seeded_rank: null }), assignRank: async () => { assigned += 1; return true; }, unassignRank: async () => { assigned += 1; return true; } };
|
|
640
|
+
await withDb({ ...base, listDecisionRules: async () => [] }, async () => {
|
|
641
|
+
const r = await call('POST', '/government/assignments', { builder_id: 7, rank_key: 'tester' });
|
|
642
|
+
assert.equal(r.status, 201);
|
|
643
|
+
});
|
|
644
|
+
await withDb({ ...base, listDecisionRules: async () => [sittingRow('people', 'rank:council', 'consent')], builderHoldsRankActive: async () => true }, async () => {
|
|
645
|
+
const r = await call('POST', '/government/assignments', { builder_id: 7, rank_key: 'tester' });
|
|
646
|
+
assert.equal(r.status, 409);
|
|
647
|
+
assert.equal(r.json.error.code, 'decided_by_sitting');
|
|
648
|
+
const d = await call('DELETE', '/government/assignments/7/tester');
|
|
649
|
+
assert.equal(d.status, 409);
|
|
650
|
+
});
|
|
651
|
+
assert.equal(assigned, 1, 'only the as-today assignment was written');
|
|
652
|
+
await withDb({ ...base, listDecisionRules: async () => { throw new Error('db down'); } }, async () => {
|
|
653
|
+
const r = await call('POST', '/government/assignments', { builder_id: 7, rank_key: 'tester' });
|
|
654
|
+
assert.equal(r.status, 503, 'an unreadable row refuses — fail-closed');
|
|
655
|
+
});
|
|
656
|
+
assert.equal(assigned, 1);
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
test('POST /government/board/decision-rules files the amendment; a bad row is a named 400; a no-op is 409', async () => {
|
|
660
|
+
await withDb({
|
|
661
|
+
getRankByKey: async (k) => ({ rank_key: k, is_system: false }),
|
|
662
|
+
listDecisionRules: async () => [],
|
|
663
|
+
createAmendment: async (a) => ({ id: '5', ...a }),
|
|
664
|
+
openBoardItem: async (a) => ({ id: '21', subject_type: a.subjectType, subject_id: a.subjectId, constitution: a.constitution }),
|
|
665
|
+
}, async () => {
|
|
666
|
+
const ok = await call('POST', '/government/board/decision-rules', { rules: [{ area: 'review', decided_by: 'rank:tester', rule: 'signoff', who: 'testers' }] });
|
|
667
|
+
assert.equal(ok.status, 201, JSON.stringify(ok.json));
|
|
668
|
+
assert.equal(ok.json.rules[0].rule, 'signoff');
|
|
669
|
+
const bad = await call('POST', '/government/board/decision-rules', { rules: [{ area: 'charter', decided_by: 'rank:council', rule: 'unanimous' }] });
|
|
670
|
+
assert.equal(bad.status, 400);
|
|
671
|
+
assert.equal(bad.json.error.code, 'invalid_decision_rules');
|
|
672
|
+
const noop = await call('POST', '/government/board/decision-rules', { rules: [{ area: 'review', decided_by: 'gate' }] });
|
|
673
|
+
assert.equal(noop.status, 409);
|
|
674
|
+
});
|
|
675
|
+
});
|
|
676
|
+
|
|
677
|
+
test('POST /government/board/matters: 409 where the building is as today, 201 where a sitting decides it', async () => {
|
|
678
|
+
await withDb({ listDecisionRules: async () => [] }, async () => {
|
|
679
|
+
const r = await call('POST', '/government/board/matters', { area: 'rules', title: 'Small fixes ship without a vote' });
|
|
680
|
+
assert.equal(r.status, 409);
|
|
681
|
+
assert.equal(r.json.error.code, 'decided_by_gate');
|
|
682
|
+
});
|
|
683
|
+
await withDb({
|
|
684
|
+
listDecisionRules: async () => [sittingRow('rules', 'rank:council', 'majority')],
|
|
685
|
+
createMatter: async (a) => ({ id: '8', ...a }),
|
|
686
|
+
openBoardItem: async (a) => ({ id: '41', subject_type: a.subjectType, subject_id: a.subjectId, constitution: a.constitution }),
|
|
687
|
+
}, async () => {
|
|
688
|
+
const r = await call('POST', '/government/board/matters', { area: 'rules', title: 'Small fixes ship without a vote' });
|
|
689
|
+
assert.equal(r.status, 201, JSON.stringify(r.json));
|
|
690
|
+
assert.equal(r.json.item.subject_type, 'rules_matter');
|
|
691
|
+
assert.equal(r.json.decided.rule, 'majority');
|
|
692
|
+
});
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
test('without board.item.open both new routes 403', async () => {
|
|
696
|
+
held = ['board.vote.cast'];
|
|
697
|
+
try {
|
|
698
|
+
assert.equal((await call('POST', '/government/board/decision-rules', { rules: [] })).status, 403);
|
|
699
|
+
assert.equal((await call('POST', '/government/board/matters', { area: 'rules', title: 'x' })).status, 403);
|
|
700
|
+
} finally { held = ['government.manage', 'board.item.open', 'board.vote.cast']; }
|
|
701
|
+
});
|