@bongos/core 1.24.72 → 1.24.73

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.
Files changed (33) hide show
  1. package/.bongos-core.json +55 -35
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +2 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/api/openapi.json +36 -2
  8. package/docs/api-reference.md +3 -2
  9. package/docs/copy-inventory.md +4 -4
  10. package/docs/copy-registry.json +4 -4
  11. package/docs/module-api-changelog.md +2 -0
  12. package/docs/page-readings.json +6 -6
  13. package/modules/government/board.js +19 -7
  14. package/modules/government/config.js +21 -9
  15. package/modules/government/constitution.js +224 -0
  16. package/modules/government/db.js +28 -0
  17. package/modules/government/migrations/government_037_constitution.sql +33 -0
  18. package/modules/government/module.json +2 -1
  19. package/modules/government/pollers/constitution-restore.js +45 -0
  20. package/modules/government/routes/government.js +7 -7
  21. package/modules/hall-ui/public/board-room.js +7 -3
  22. package/modules/hall-ui/public/street-card.js +2 -0
  23. package/package-lock.json +2 -2
  24. package/package.json +1 -1
  25. package/release-notes.json +6 -0
  26. package/scripts/bongos/government-walk.js +7 -0
  27. package/src/bongos/route-rank-check.js +4 -0
  28. package/src/module-api.js +1 -1
  29. package/tests/genesis_government.mjs +11 -0
  30. package/tests/government_board_amendment.mjs +10 -0
  31. package/tests/government_charter_forms.mjs +10 -0
  32. package/tests/government_constitution_durable.mjs +304 -0
  33. package/tests/government_seed.mjs +1 -0
@@ -200,7 +200,11 @@ test('the founder picks Direct Democracy and ratifies it: the amendment path wri
200
200
  const originalApply = config.applyBoardAmendment;
201
201
  const applied = [];
202
202
  config.applyBoardAmendment = (proposed) => { applied.push(proposed); fs.writeFileSync(target, JSON.stringify({ board: proposed })); return proposed; };
203
+ // The read-back after the apply (task 1005140) reads the same temp file.
204
+ const originalLoad = config.loadGovernmentConfig;
205
+ config.loadGovernmentConfig = (opts = {}) => originalLoad({ ...opts, instancePath: target });
203
206
  let filed = null;
207
+ let savedRow = null;
204
208
  const votes = [];
205
209
  try {
206
210
  await withDb({
@@ -216,6 +220,9 @@ test('the founder picks Direct Democracy and ratifies it: the amendment path wri
216
220
  closeBoardItem: async ({ outcome }) => ({ id: '21', subject_type: 'constitutional_amendment', subject_id: '5', opened_by: '42', closed_at: 'T', outcome }),
217
221
  insertAuthorRatifyKarma: async () => true,
218
222
  getAmendment: async () => ({ id: '5', proposed: filed.proposed }),
223
+ // The durable row (task 1005140) — written first, read back after.
224
+ saveConstitutionRow: async (args) => { savedRow = { board: args.board }; return savedRow; },
225
+ getConstitutionRow: async () => savedRow,
219
226
  }, async () => {
220
227
  const out = await board.proposeCharter({ form: 'direct-democracy', proposedBy: '42' });
221
228
  assert.equal(out.ok, true);
@@ -227,11 +234,14 @@ test('the founder picks Direct Democracy and ratifies it: the amendment path wri
227
234
  await board.castVote({ itemId: '21', voterId: '42', direction: 'yes' });
228
235
  const closed = await board.maybeCloseAfterVote('21');
229
236
  assert.equal(closed.outcome, 'passed');
237
+ assert.equal(closed.constitution_not_applied, undefined, 'read back: it is in force');
230
238
  });
231
239
  assert.deepEqual(applied, [filed.proposed], 'the ratified text is exactly what was filed');
232
240
  assert.equal(JSON.parse(fs.readFileSync(target, 'utf8')).board.membership, 'rank:xenos+');
241
+ assert.equal(savedRow && savedRow.board.membership, 'rank:xenos+', 'the durable row carries it too');
233
242
  } finally {
234
243
  config.applyBoardAmendment = originalApply;
244
+ config.loadGovernmentConfig = originalLoad;
235
245
  fs.rmSync(dir, { recursive: true, force: true });
236
246
  }
237
247
  });
@@ -0,0 +1,304 @@
1
+ // tests/government_constitution_durable.mjs — a ratified constitution survives a
2
+ // restart (task 1005140).
3
+ //
4
+ // Found live on toast, 2026-11-07: the board ratified a Timocratic Republic, the
5
+ // amendment was written to <instance root>/config/government.json, and the next
6
+ // container start (deploy, restart, or an idle wake on a free plan) erased the
7
+ // file. The project went back to the day-one monarchy without an error.
8
+ //
9
+ // DB-free: the constitution row lives in an in-memory stand-in for db.js's two
10
+ // functions; the instance pack is a REAL temp file, and "a restart" is deleting
11
+ // it. The invariants:
12
+ //
13
+ // 1. A restart does not lose a ratified constitution — the row restores it.
14
+ // 2. A file with no row is ADOPTED into the row (nobody loses one on upgrade).
15
+ // 3. The row wins over a stale file.
16
+ // 4. The row is written BEFORE the file, and a write that did not take is
17
+ // REPORTED at the close (constitution_not_applied), never absorbed.
18
+ // 5. The operator's re-apply only ever re-applies a PASSED constitutional
19
+ // amendment — it cannot invent a block.
20
+ // 6. The restore runs at boot, immediately.
21
+ import assert from 'node:assert/strict';
22
+ import { test } from 'node:test';
23
+ import { createRequire } from 'node:module';
24
+ import fs from 'node:fs';
25
+ import os from 'node:os';
26
+ import path from 'node:path';
27
+ import { fileURLToPath } from 'node:url';
28
+
29
+ const require = createRequire(import.meta.url);
30
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
31
+ const GOV = path.join(ROOT, 'modules', 'government');
32
+ const db = require(path.join(GOV, 'db.js'));
33
+ const config = require(path.join(GOV, 'config.js'));
34
+ const constitution = require(path.join(GOV, 'constitution.js'));
35
+ const board = require(path.join(GOV, 'board.js'));
36
+
37
+ const MONARCHY = Object.freeze({ membership: 'rank:archon', pass_rule: 'first_ratifier', window_minutes: null, close_early_on_full_turnout: true });
38
+ const TIMOCRACY = Object.freeze({ membership: 'rank:metic+', pass_rule: 'consent', window_minutes: null, close_early_on_full_turnout: true });
39
+ const quiet = { log: () => {}, error: () => {} };
40
+
41
+ let seq = 0;
42
+ const tmpPack = () => path.join(os.tmpdir(), `gov-durable-${process.pid}-${Date.now()}-${seq++}`, 'config', 'government.json');
43
+
44
+ // One harness: an in-memory row, and the config's instance path redirected to
45
+ // a temp file for both the write and the read — what a real process does with
46
+ // its own INSTANCE_PATH.
47
+ async function withInstance(fn, { row = null, saveFails = false, getFails = null, history } = {}) {
48
+ const pack = tmpPack();
49
+ const store = { row, saves: [] };
50
+ const orig = {
51
+ get: db.getConstitutionRow, save: db.saveConstitutionRow,
52
+ apply: config.applyBoardAmendment, load: config.loadGovernmentConfig,
53
+ list: db.listAmendmentHistory,
54
+ };
55
+ if (history !== undefined) db.listAmendmentHistory = async () => history;
56
+ db.getConstitutionRow = async () => { if (getFails) throw getFails; return store.row; };
57
+ db.saveConstitutionRow = async (args) => {
58
+ if (saveFails) throw new Error('connection refused');
59
+ store.saves.push(args);
60
+ store.row = { board: args.board, source: args.source, amendment_item_id: args.itemId };
61
+ return store.row;
62
+ };
63
+ config.applyBoardAmendment = (proposed, opts = {}) => orig.apply(proposed, { instancePath: opts.instancePath || pack });
64
+ config.loadGovernmentConfig = (opts = {}) => orig.load({ ...opts, instancePath: opts.instancePath || pack });
65
+ try { return await fn({ pack, store, live: () => orig.load({ instancePath: pack }).board }); }
66
+ finally {
67
+ db.getConstitutionRow = orig.get; db.saveConstitutionRow = orig.save; db.listAmendmentHistory = orig.list;
68
+ config.applyBoardAmendment = orig.apply; config.loadGovernmentConfig = orig.load;
69
+ fs.rmSync(path.dirname(path.dirname(pack)), { recursive: true, force: true });
70
+ }
71
+ }
72
+
73
+ const restart = (pack) => fs.rmSync(pack, { force: true });
74
+
75
+ // ── 1. the bug itself ────────────────────────────────────────────────────────
76
+
77
+ test('a ratified constitution survives a container restart (the toast bug)', async () => {
78
+ await withInstance(async ({ pack, store, live }) => {
79
+ const out = await constitution.recordRatified(TIMOCRACY, { itemId: '7' });
80
+ assert.equal(out.failure, null, `the ratified amendment took: ${JSON.stringify(out.failure)}`);
81
+ assert.deepEqual({ ...live() }, TIMOCRACY, 'in force right after the close');
82
+ assert.deepEqual(store.row.board, TIMOCRACY, 'the durable row holds it');
83
+ assert.equal(store.row.source, 'amendment');
84
+
85
+ restart(pack); // the container's filesystem is wiped
86
+ assert.deepEqual({ ...live() }, MONARCHY, 'fixture: with the file gone the process alone falls back to monarchy');
87
+
88
+ const r = await constitution.reconcile({ instancePath: pack, ...quiet });
89
+ assert.equal(r.action, 'restored');
90
+ assert.deepEqual({ ...live() }, TIMOCRACY, 'after the boot restore the board\'s decision is in force again');
91
+ });
92
+ });
93
+
94
+ // ── 2 + 3. adopt on first read; the row wins ─────────────────────────────────
95
+
96
+ test('a file with no row is adopted into the database and the log says so', async () => {
97
+ await withInstance(async ({ pack, store }) => {
98
+ fs.mkdirSync(path.dirname(pack), { recursive: true });
99
+ fs.writeFileSync(pack, JSON.stringify({ ranks: { metic: { label: 'Trusted' } }, board: TIMOCRACY }));
100
+ const lines = [];
101
+ const r = await constitution.reconcile({ instancePath: pack, log: (m) => lines.push(m), error: () => {} });
102
+ assert.equal(r.action, 'adopted');
103
+ assert.deepEqual(store.row.board, TIMOCRACY);
104
+ assert.equal(store.row.source, 'adopted_file');
105
+ assert.ok(lines.some((l) => /adopted/.test(l)), 'the adoption is logged');
106
+ const again = await constitution.reconcile({ instancePath: pack, ...quiet });
107
+ assert.equal(again.action, 'in_step', 'a second pass changes nothing');
108
+ });
109
+ });
110
+
111
+ test('no row and no file is the vanilla instance: nothing is written', async () => {
112
+ await withInstance(async ({ pack, store }) => {
113
+ const r = await constitution.reconcile({ instancePath: pack, ...quiet });
114
+ assert.equal(r.action, 'none');
115
+ assert.equal(store.saves.length, 0);
116
+ assert.equal(fs.existsSync(pack), false, 'no file is invented for a vanilla instance');
117
+ }, { history: [] });
118
+ });
119
+
120
+ test('the toast case: no row, no file, a passed amendment — the first boot puts it back in force (part C)', async () => {
121
+ const history = [
122
+ { item_id: 30, amendment_id: 9, outcome: 'returned', proposed: MONARCHY, kind: 'constitution' },
123
+ { item_id: 21, amendment_id: 1, outcome: 'passed', proposed: TIMOCRACY, kind: 'constitution' },
124
+ ];
125
+ await withInstance(async ({ pack, store, live }) => {
126
+ assert.deepEqual({ ...live() }, MONARCHY, 'fixture: the erased instance answers the day-one monarchy');
127
+ const lines = [];
128
+ const r = await constitution.reconcile({ instancePath: pack, log: (m) => lines.push(m), error: () => {} });
129
+ assert.equal(r.action, 'recovered', JSON.stringify(r));
130
+ assert.equal(r.item_id, '21', 'the PASSED amendment, never the newer returned one');
131
+ assert.deepEqual({ ...live() }, TIMOCRACY, 'the board decision is in force again');
132
+ assert.deepEqual(store.row.board, TIMOCRACY, 'and durable, so the next restart keeps it');
133
+ assert.ok(lines.some((l) => /put back passed amendment 1/.test(l)), 'the log says so');
134
+ assert.equal((await constitution.reconcile({ instancePath: pack, ...quiet })).action, 'in_step');
135
+ }, { history });
136
+ });
137
+
138
+ test('the row wins over a stale file, and the file keeps its other keys', async () => {
139
+ await withInstance(async ({ pack, live }) => {
140
+ fs.mkdirSync(path.dirname(pack), { recursive: true });
141
+ fs.writeFileSync(pack, JSON.stringify({ ranks: { metic: { label: 'Trusted' } }, board: MONARCHY }));
142
+ const r = await constitution.reconcile({ instancePath: pack, ...quiet });
143
+ assert.equal(r.action, 'restored');
144
+ assert.deepEqual({ ...live() }, TIMOCRACY);
145
+ assert.equal(JSON.parse(fs.readFileSync(pack, 'utf8')).ranks.metic.label, 'Trusted', 'rank templates survive the restore');
146
+ }, { row: { board: TIMOCRACY, source: 'amendment' } });
147
+ });
148
+
149
+ test('an unmigrated database (no table) and an unreadable one never throw and never touch the file', async () => {
150
+ const missing = Object.assign(new Error('relation "government_constitution" does not exist'), { code: '42P01' });
151
+ await withInstance(async ({ pack }) => {
152
+ assert.equal((await constitution.reconcile({ instancePath: pack, ...quiet })).action, 'no_table');
153
+ assert.equal(fs.existsSync(pack), false);
154
+ }, { getFails: missing });
155
+ await withInstance(async ({ pack }) => {
156
+ assert.equal((await constitution.reconcile({ instancePath: pack, ...quiet })).action, 'read_failed');
157
+ }, { getFails: new Error('timeout') });
158
+ });
159
+
160
+ // ── 4. row first, and a write that did not take is reported ──────────────────
161
+
162
+ test('the row is written before the file: a failed save leaves the old rule and SAYS so', async () => {
163
+ await withInstance(async ({ pack, live }) => {
164
+ const out = await constitution.recordRatified(TIMOCRACY, { itemId: '7' });
165
+ assert.equal(out.applied, null);
166
+ assert.ok(out.failure, 'reported, not absorbed');
167
+ assert.equal(out.failure.problems[0].where, 'database');
168
+ assert.equal(fs.existsSync(pack), false, 'nothing was put in force that would vanish at the next restart');
169
+ assert.equal(out.failure.problems[0].reason, 'save_failed');
170
+ assert.doesNotMatch(JSON.stringify(out.failure), /connection refused/, 'a voter sees a reason word, never the driver message');
171
+ assert.deepEqual({ ...live() }, MONARCHY);
172
+ }, { saveFails: true });
173
+ });
174
+
175
+ test('a file write that silently did not take is caught by the read-back', async () => {
176
+ await withInstance(async ({ store }) => {
177
+ config.applyBoardAmendment = (proposed) => ({ ...proposed }); // claims success, writes nothing
178
+ const out = await constitution.recordRatified(TIMOCRACY, { itemId: '7' });
179
+ assert.ok(out.failure, 'the verify caught it');
180
+ assert.deepEqual(out.failure.problems.map((p) => p.where), ['in_force']);
181
+ assert.deepEqual(out.failure.problems[0].fields, ['membership', 'pass_rule']);
182
+ assert.deepEqual(store.row.board, TIMOCRACY, 'the durable row still holds the decision, so a restore can finish the job');
183
+ });
184
+ });
185
+
186
+ test('closeItem hands a constitution that did not take back to the caller (and the vote route forwards it)', async () => {
187
+ const item = { id: '21', subject_type: 'constitutional_amendment', subject_id: '5', opened_by: '42', closed_at: null, expired: false, constitution: MONARCHY };
188
+ const stubs = {
189
+ getVotesForItem: async () => [{ id: '1', voter_id: '42', direction: 'yes' }],
190
+ closeBoardItem: async ({ outcome }) => ({ ...item, closed_at: 'T', outcome }),
191
+ getAmendment: async () => ({ id: '5', proposed: TIMOCRACY, kind: 'constitution' }),
192
+ insertAuthorRatifyKarma: async () => true,
193
+ getBoardItemAuthor: async () => '42',
194
+ };
195
+ const orig = {};
196
+ for (const [k, v] of Object.entries(stubs)) { orig[k] = db[k]; db[k] = v; }
197
+ try {
198
+ await withInstance(async () => {
199
+ const closed = await board.closeItem(item, { cause: 'vote' });
200
+ assert.equal(closed.outcome, 'passed');
201
+ assert.ok(closed.constitution_not_applied, 'the close itself reports it');
202
+ assert.equal(closed.constitution_not_applied.problems[0].where, 'database');
203
+ }, { saveFails: true });
204
+ await withInstance(async ({ live }) => {
205
+ const closed = await board.closeItem(item, { cause: 'vote' });
206
+ assert.equal(closed.constitution_not_applied, undefined, 'a clean apply reports nothing');
207
+ assert.deepEqual({ ...closed.applied_constitution }, TIMOCRACY);
208
+ assert.deepEqual({ ...live() }, TIMOCRACY);
209
+ });
210
+ } finally { for (const [k, v] of Object.entries(orig)) db[k] = v; }
211
+
212
+ const routeSrc = fs.readFileSync(path.join(GOV, 'routes', 'government.js'), 'utf8');
213
+ assert.match(routeSrc, /closure && closure\.constitution_not_applied \? \{ constitution_not_applied:/,
214
+ 'the vote route forwards it to the page the voter is standing on');
215
+ for (const page of ['board-room.js', 'street-card.js']) {
216
+ const src = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', page), 'utf8');
217
+ assert.match(src, /constitution_not_applied/, `${page} says so instead of "in force"`);
218
+ }
219
+ });
220
+
221
+ // ── 5. the operator's re-apply ───────────────────────────────────────────────
222
+
223
+ test('re-apply puts the newest PASSED constitutional amendment back, skipping returned ones and laws', async () => {
224
+ const history = [
225
+ { item_id: 30, amendment_id: 12, outcome: 'returned', proposed: MONARCHY, kind: 'constitution' },
226
+ { item_id: 29, amendment_id: 11, outcome: 'passed', proposed: { enact: [] }, kind: 'laws' },
227
+ { item_id: 21, amendment_id: 1, outcome: 'passed', proposed: TIMOCRACY, kind: 'constitution' },
228
+ ];
229
+ const origList = db.listAmendmentHistory;
230
+ db.listAmendmentHistory = async () => history;
231
+ try {
232
+ await withInstance(async ({ store, live }) => {
233
+ const out = await constitution.reapplyNewestPassed({ updatedBy: '3' });
234
+ assert.equal(out.ok, true, JSON.stringify(out));
235
+ assert.equal(out.item_id, '21');
236
+ assert.deepEqual({ ...live() }, TIMOCRACY);
237
+ assert.equal(store.row.source, 'reapply');
238
+ assert.equal(store.saves[0].updatedBy, '3');
239
+ });
240
+ db.listAmendmentHistory = async () => history.slice(0, 2);
241
+ await withInstance(async ({ store }) => {
242
+ const out = await constitution.reapplyNewestPassed({});
243
+ assert.deepEqual(out, { ok: false, reason: 'no_passed_amendment' });
244
+ assert.equal(store.saves.length, 0, 'it never invents a constitution');
245
+ });
246
+ } finally { db.listAmendmentHistory = origList; }
247
+ });
248
+
249
+ test('the re-apply route is pinned to the owner atom and takes no block from the request', () => {
250
+ const { GOVERNMENT_PINS } = require(path.join(ROOT, 'src', 'bongos', 'route-rank-check.js'));
251
+ const pins = GOVERNMENT_PINS || {};
252
+ const src = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'route-rank-check.js'), 'utf8');
253
+ assert.match(src, /'POST \/government\/constitution\/reapply': 'perm:government\.manage'/);
254
+ if (Object.keys(pins).length) assert.equal(pins['POST /government/constitution/reapply'], 'perm:government.manage');
255
+ const routeSrc = fs.readFileSync(path.join(GOV, 'routes', 'government.js'), 'utf8');
256
+ assert.match(routeSrc, /router\.post\('\/government\/constitution\/reapply', api\.requireBuilder, api\.requirePermission\('government\.manage'\), constitution\.reapplyRoute\(log\)\)/,
257
+ 'the route exists, gated on the owner atom');
258
+ assert.doesNotMatch(constitution.reapplyRoute.toString(), /req\.body/, 'the block comes from a passed amendment, never the request');
259
+ });
260
+
261
+ test('the re-apply handler answers 404 with nothing passed and 200 with the board it put back', async () => {
262
+ const call = async () => {
263
+ const res = { code: 200, body: null,
264
+ status(c) { this.code = c; return this; }, json(b) { this.body = b; return this; },
265
+ fail(code, status, extra) { this.code = typeof status === 'number' ? status : status.status; this.body = { code, ...extra }; return this; } };
266
+ await constitution.reapplyRoute({ error: () => {} })({ builder: { id: '3' }, body: { board: MONARCHY } }, res);
267
+ return res;
268
+ };
269
+ const origList = db.listAmendmentHistory;
270
+ try {
271
+ db.listAmendmentHistory = async () => [];
272
+ await withInstance(async () => {
273
+ const res = await call();
274
+ assert.equal(res.code, 404);
275
+ assert.equal(res.body.code, 'no_passed_amendment');
276
+ });
277
+ db.listAmendmentHistory = async () => [{ item_id: 21, amendment_id: 1, outcome: 'passed', proposed: TIMOCRACY, kind: 'constitution' }];
278
+ await withInstance(async ({ live }) => {
279
+ const res = await call();
280
+ assert.equal(res.code, 200);
281
+ assert.deepEqual({ ...res.body.board }, TIMOCRACY, 'the passed amendment, not the request body');
282
+ assert.deepEqual({ ...live() }, TIMOCRACY);
283
+ });
284
+ } finally { db.listAmendmentHistory = origList; }
285
+ });
286
+
287
+ // ── 6. at boot ───────────────────────────────────────────────────────────────
288
+
289
+ test('the restore runs the moment the poller starts, not after a delay', () => {
290
+ const poller = require(path.join(GOV, 'pollers', 'constitution-restore.js'));
291
+ let calls = 0;
292
+ poller.start({ reconcileFn: async () => { calls += 1; return { action: 'in_step' }; } });
293
+ try {
294
+ return new Promise((resolve) => setImmediate(() => { assert.equal(calls, 1); resolve(); }));
295
+ } finally { poller.stop(); }
296
+ });
297
+
298
+ test('the module declares the poller and the migration creates the one-row table', () => {
299
+ const manifest = JSON.parse(fs.readFileSync(path.join(GOV, 'module.json'), 'utf8'));
300
+ assert.ok(manifest.contributes.pollers.includes('constitution-restore'));
301
+ const sql = fs.readFileSync(path.join(GOV, 'migrations', 'government_037_constitution.sql'), 'utf8');
302
+ assert.match(sql, /CREATE TABLE IF NOT EXISTS government_constitution/);
303
+ assert.match(sql, /CHECK \(id = 1\)/, 'one row, by construction');
304
+ });
@@ -53,6 +53,7 @@ const NON_GRANT_MIGRATIONS = new Set([
53
53
  'government_031_treasury.sql', // the treasury: a pot per goal and the loans a dry pot asks the reserve for (task 1004712)
54
54
  'government_032_ask_flags.sql', // the reword list: matters a reader flagged as unclear (owner 2026-10-23)
55
55
  'government_036_access_holders.sql', // who holds the keys outside Bongos + the access:<key> ask seat (task 1005000)
56
+ 'government_037_constitution.sql', // the constitution in force as a durable row (task 1005140)
56
57
  ]);
57
58
 
58
59
  // Permission keys RENAMED after the grant migrations that first granted them.