@bongos/core 1.24.71 → 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 (35) hide show
  1. package/.bongos-core.json +57 -37
  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 +4 -0
  12. package/docs/page-readings.json +84 -84
  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/modules/hall-ui/public/style.css +1 -1
  24. package/package-lock.json +2 -2
  25. package/package.json +1 -1
  26. package/release-notes.json +12 -0
  27. package/scripts/bongos/government-walk.js +7 -0
  28. package/src/bongos/route-rank-check.js +4 -0
  29. package/src/module-api.js +1 -1
  30. package/tests/genesis_government.mjs +11 -0
  31. package/tests/government_board_amendment.mjs +10 -0
  32. package/tests/government_charter_forms.mjs +10 -0
  33. package/tests/government_constitution_durable.mjs +304 -0
  34. package/tests/government_seed.mjs +1 -0
  35. package/tests/hall_tokens.mjs +19 -0
@@ -47,6 +47,9 @@ const db = require('./db');
47
47
  // file (the reward-port delegation lesson: a destructured binding is frozen at
48
48
  // load and un-patchable).
49
49
  const governmentConfig = require('./config');
50
+ // The durable half of applying a passed amendment (task 1005140), required as a
51
+ // namespace for the same reason.
52
+ const constitutionStore = require('./constitution');
50
53
  const {
51
54
  loadGovernmentConfig, PASS_RULES, MAX_WINDOW_MINUTES,
52
55
  RECOMMENDED_WINDOW_MINUTES, CLOCK_SAFE_PASS_RULES, isClockSafe,
@@ -668,17 +671,25 @@ async function closeItem(item, { cause = 'vote' } = {}) {
668
671
  // close commits (the board's decision is the durable record), loudly on
669
672
  // failure, never silently absorbed. The apply is re-drivable from the passed
670
673
  // item + amendment rows. A returned amendment applies nothing.
674
+ //
675
+ // Task 1005140: the ROW is written first (a container's file does not survive
676
+ // a restart), then this process's file, and then both are READ BACK. An
677
+ // amendment that did not take comes back as `constitution_not_applied`, which
678
+ // the vote route hands to the page the voter is standing on.
671
679
  let appliedConstitution = null;
680
+ let constitutionNotApplied = null;
672
681
  // A rules or laws amendment changes its own table, never the board block.
673
682
  if (outcome === 'passed' && closed.subject_type === 'constitutional_amendment' && !rulesAmendment && !lawsAmendment) {
674
- try {
675
- if (amendment) {
676
- appliedConstitution = governmentConfig.applyBoardAmendment(amendment.proposed);
677
- } else {
678
- console.error(`[government] passed amendment item ${closed.id} names amendment ${closed.subject_id}, which does not exist — NOT applied`);
683
+ if (amendment) {
684
+ const out = await constitutionStore.recordRatified(amendment.proposed, { itemId: closed.id });
685
+ appliedConstitution = out.applied;
686
+ constitutionNotApplied = out.failure;
687
+ if (out.failure) {
688
+ console.error(`[government] amendment ${closed.subject_id} ratified but NOT in force (re-apply with POST /government/constitution/reapply): ${JSON.stringify(out.failure.problems)}`);
679
689
  }
680
- } catch (applyErr) {
681
- console.error(`[government] amendment ${closed.subject_id} ratified but NOT applied (re-drivable from the passed item): ${applyErr && applyErr.message}`);
690
+ } else {
691
+ console.error(`[government] passed amendment item ${closed.id} names amendment ${closed.subject_id}, which does not exist — NOT applied`);
692
+ constitutionNotApplied = Object.freeze({ should: null, problems: [{ where: 'database', reason: 'the amendment row does not exist' }] });
682
693
  }
683
694
  }
684
695
 
@@ -713,6 +724,7 @@ async function closeItem(item, { cause = 'vote' } = {}) {
713
724
  closed: true, item: closed, outcome, cause, author_karma: authorKarma,
714
725
  ...(credit ? { credit } : {}),
715
726
  ...(appliedConstitution ? { applied_constitution: appliedConstitution } : {}),
727
+ ...(constitutionNotApplied ? { constitution_not_applied: constitutionNotApplied } : {}),
716
728
  ...(appliedDecisionRules ? { applied_decision_rules: appliedDecisionRules } : {}),
717
729
  ...(appliedLaws ? { applied_laws: appliedLaws } : {}),
718
730
  ...(acted ? { acted: acted.result } : {}),
@@ -366,16 +366,20 @@ function loadGovernmentConfig({ neutralPath = NEUTRAL_PATH, instancePath = INSTA
366
366
  // the proposal was strictly validated at propose time, but a file on disk must
367
367
  // never hold a shape the resolver would have to guess at.
368
368
  //
369
- // WHY A FILE WRITE AND NOT A DB ROW: R05 made the constitution config-resolved
370
- // on purpose (the board block is a RULE SHAPE, not authority — who holds the
371
- // governing rank stays a DB row). Moving the live value into the DB on
372
- // ratification would fork the resolution into two paths, the exact thing the
373
- // board-snapshot design avoids. The instance pack is host-owned and untracked,
374
- // so a deploy pull never clobbers it.
369
+ // THE FILE IS NOT THE DURABLE RECORD (task 1005140). It used to be, on the
370
+ // reasoning that the instance pack is host-owned and untracked so a deploy pull
371
+ // never clobbers it. True on a long-lived checkout; false on every instance this
372
+ // platform provisions, whose container filesystem is wiped on each deploy,
373
+ // restart and idle wake — toast's ratified Timocratic Republic vanished that way.
374
+ // The durable record is now the government_constitution ROW, written first by
375
+ // constitution.js recordRatified; this write is the running process's copy of
376
+ // it, and constitution.js reconcile restores it at boot. Resolution is still
377
+ // ONE path (env → instance → neutral): only where the instance layer comes from
378
+ // changed.
375
379
  //
376
- // The caller (board.js closeItem) runs this AFTER the close commits — the
377
- // board's decision is the durable record; a failed apply is logged loudly and
378
- // re-appliable from the passed item + amendment rows, never silently absorbed.
380
+ // The caller (constitution.recordRatified, from board.js closeItem) runs this
381
+ // AFTER the close commits and re-reads what is in force afterwards, so an
382
+ // amendment that did not take is reported at the close itself.
379
383
  function applyBoardAmendment(proposed, { instancePath = INSTANCE_PATH } = {}) {
380
384
  const instance = readJson(instancePath) || {};
381
385
  const next = { ...instance, board: { ...sanitizeBoard(proposed) } };
@@ -392,8 +396,16 @@ function applyBoardAmendment(proposed, { instancePath = INSTANCE_PATH } = {}) {
392
396
  return next.board;
393
397
  }
394
398
 
399
+ // The instance pack as it is on disk (null when absent; a file that does not
400
+ // parse throws, as the loader does). constitution.js reads the board block
401
+ // through this so absent and malformed keep one meaning.
402
+ function readInstancePack(instancePath = INSTANCE_PATH) {
403
+ return readJson(instancePath);
404
+ }
405
+
395
406
  module.exports = {
396
407
  resolveGovernmentConfig,
408
+ readInstancePack,
397
409
  loadGovernmentConfig,
398
410
  grantableOnly,
399
411
  sanitizeBoard,
@@ -0,0 +1,224 @@
1
+ 'use strict';
2
+
3
+ // modules/government/constitution.js — the constitution in force survives a
4
+ // restart (task 1005140).
5
+ //
6
+ // THE BUG THIS EXISTS FOR. A ratified amendment was written only to
7
+ // <instance root>/config/government.json. Every instance this platform
8
+ // provisions runs on a container whose filesystem is wiped on each deploy,
9
+ // restart and idle wake, so the write took effect and then vanished at the next
10
+ // container start: toast's board ratified a Timocratic Republic on 2026-11-06
11
+ // and the constitution went on answering the day-one monarchy. ADR 0268 fixed
12
+ // the PATH of that write; this fixes its MEDIUM.
13
+ //
14
+ // THE SHAPE. The database row (government_037) is the durable record of the
15
+ // instance's board block. The file stays as the RUNNING PROCESS's copy of that
16
+ // row, so `config.loadGovernmentConfig()` keeps its one synchronous path
17
+ // (env → instance → neutral) and none of its callers change:
18
+ //
19
+ // • a passed amendment writes the ROW, then the file (`recordRatified`), then
20
+ // re-reads both and compares (`verifyInForce`) — an amendment that did not
21
+ // take is reported at the close, not weeks later by the divergence banner;
22
+ // • at boot, and on a slow interval after, `reconcile` brings the file in step
23
+ // with the row (a fresh container has no file; the row restores it). If the
24
+ // row does not exist yet but a file does, the file's board block is ADOPTED
25
+ // into the row and the log says so — nobody loses a constitution that
26
+ // happens to still be on disk. If there is NEITHER a row NOR a file but a
27
+ // constitutional amendment has passed, that amendment is put back in force:
28
+ // an instance whose ratified constitution a restart already erased (toast,
29
+ // 2026-11-06) repairs itself on its first boot with this code. That is not
30
+ // the auto-heal ADR 0268 refused — with no file there is no hand edit to
31
+ // revert; the only thing the process could answer is a default nobody chose;
32
+ // • `reapplyNewestPassed` is the operator's supported path: it writes the
33
+ // newest PASSED constitutional amendment back into force. It never takes a
34
+ // free-form block — changing the constitution is a board act (owner
35
+ // decision 11); this only makes a decision the board already took stick.
36
+ //
37
+ // The ROW WINS over the file. A hand edit of the file on a long-lived checkout
38
+ // is therefore overwritten at the next reconcile once a row exists — on a
39
+ // container that edit was never durable anyway, and a constitution that quietly
40
+ // differed between the database and one process is the failure being fixed.
41
+
42
+ const api = require('../../src/module-api');
43
+ const governmentConfig = require('./config');
44
+ const db = require('./db');
45
+
46
+ const logger = api.logger('government');
47
+
48
+ const BOARD_KEYS = Object.freeze(['membership', 'pass_rule', 'window_minutes', 'close_early_on_full_turnout']);
49
+
50
+ // Field-by-field over the four keys — the same comparison board.js
51
+ // constitutionDivergence makes, so "in step" here and "no divergence" there
52
+ // mean the same thing. The two answer different questions (is this process in
53
+ // step with the row / is the newest passed amendment in force); if the board
54
+ // block ever gains a key, change both.
55
+ function differingFields(should, actual) {
56
+ const a = actual || {};
57
+ return BOARD_KEYS.filter((k) => should[k] !== a[k]);
58
+ }
59
+
60
+ // The file's own board block, or null when there is none. Read through the
61
+ // config loader's reader so absent and malformed keep their meanings (absent is
62
+ // null; a file that does not parse throws — a human must fix it).
63
+ function fileBoard(instancePath) {
64
+ const pack = governmentConfig.readInstancePack(instancePath);
65
+ return pack && pack.board && typeof pack.board === 'object' ? pack.board : null;
66
+ }
67
+
68
+ // A missing table means this instance has not run government_037 yet: the
69
+ // caller reports it rather than pretending the row is empty.
70
+ const isMissingTable = (err) => !!(err && err.code === '42P01');
71
+
72
+ // The newest PASSED constitution amendment (not a rules or laws one, which
73
+ // change their own tables), or null. listAmendmentHistory is newest first.
74
+ async function newestPassed(historyLimit = 50) {
75
+ const rows = await db.listAmendmentHistory(historyLimit);
76
+ return (rows || []).find((r) => r.outcome === 'passed' && r.proposed && r.kind === 'constitution') || null;
77
+ }
78
+
79
+ // A failure the caller hands to a voter carries a fixed reason word, never a
80
+ // driver message (that is logged here, for the operator).
81
+ function problem(where, reason, err) {
82
+ if (err) logger.error(`[government] constitution: ${where} ${reason}: ${err.message || err}`);
83
+ return { where, reason };
84
+ }
85
+
86
+ // Bring this process's file in step with the row, or adopt the file into an
87
+ // empty row. Never throws; returns what it did.
88
+ async function reconcile({ instancePath = governmentConfig.INSTANCE_PATH, log = console.log, error = console.error } = {}) {
89
+ let row;
90
+ try {
91
+ row = await db.getConstitutionRow();
92
+ } catch (err) {
93
+ if (isMissingTable(err)) return { action: 'no_table' };
94
+ error(`[government] constitution: could not read the constitution row (the file stays as it is): ${err && err.message}`);
95
+ return { action: 'read_failed' };
96
+ }
97
+ try {
98
+ if (row && row.board) {
99
+ const should = governmentConfig.sanitizeBoard(row.board);
100
+ const current = fileBoard(instancePath);
101
+ if (current && differingFields(should, governmentConfig.sanitizeBoard(current)).length === 0) {
102
+ return { action: 'in_step', board: should };
103
+ }
104
+ governmentConfig.applyBoardAmendment(should, { instancePath });
105
+ log(`[government] constitution: restored the constitution in force from the database (${should.pass_rule}, ${should.membership})`);
106
+ return { action: 'restored', board: should };
107
+ }
108
+ const current = fileBoard(instancePath);
109
+ if (!current) {
110
+ // Neither a row nor a file: if the board passed a constitution, the only
111
+ // reason it is not in force is that a restart erased it. Put it back.
112
+ const last = await newestPassed();
113
+ if (!last) return { action: 'none' };
114
+ const out = await recordRatified(last.proposed, { itemId: last.item_id, source: 'reapply', instancePath });
115
+ if (out.failure) {
116
+ error(`[government] constitution: could not put back passed amendment ${last.amendment_id}: ${JSON.stringify(out.failure.problems)}`);
117
+ return { action: 'failed', failure: out.failure };
118
+ }
119
+ log(`[government] constitution: no constitution survived the restart — put back passed amendment ${last.amendment_id} (item ${last.item_id}: ${out.applied.pass_rule}, ${out.applied.membership})`);
120
+ return { action: 'recovered', board: out.applied, item_id: String(last.item_id) };
121
+ }
122
+ const adopted = governmentConfig.sanitizeBoard(current);
123
+ await db.saveConstitutionRow({ board: adopted, source: 'adopted_file' });
124
+ log(`[government] constitution: adopted the board block from ${instancePath} into the database (${adopted.pass_rule}, ${adopted.membership}) — it now survives a restart`);
125
+ return { action: 'adopted', board: adopted };
126
+ } catch (err) {
127
+ error(`[government] constitution: reconcile failed: ${err && err.message}`);
128
+ return { action: 'failed', error: err && err.message };
129
+ }
130
+ }
131
+
132
+ // Re-read the row and the constitution this process resolves, and compare both
133
+ // to what should be in force. Null when both match; otherwise what is wrong.
134
+ //
135
+ // The row is read back on purpose even though the save RETURNed it: the point
136
+ // is to prove what a fresh read sees, not what the driver said it wrote. Two
137
+ // small reads, only when a constitution sitting closes.
138
+ async function verifyInForce(should, { instancePath } = {}) {
139
+ const problems = [];
140
+ let row = null;
141
+ try { row = await db.getConstitutionRow(); }
142
+ catch (err) { problems.push(problem('database', 'read_back_failed', err)); }
143
+ if (!problems.length) {
144
+ const rowFields = row && row.board ? differingFields(should, governmentConfig.sanitizeBoard(row.board)) : BOARD_KEYS.slice();
145
+ if (rowFields.length) problems.push({ where: 'database', fields: rowFields });
146
+ }
147
+ let live = null;
148
+ try { live = governmentConfig.loadGovernmentConfig(instancePath ? { instancePath } : {}).board; }
149
+ catch (err) { problems.push(problem('in_force', 'resolve_failed', err)); }
150
+ if (live) {
151
+ const liveFields = differingFields(should, live);
152
+ if (liveFields.length) problems.push({ where: 'in_force', fields: liveFields });
153
+ }
154
+ return problems.length ? Object.freeze({ should, problems }) : null;
155
+ }
156
+
157
+ // Put a ratified board block into force: the ROW first (the durable record),
158
+ // then the file (this process), then verify. Never throws — the board's
159
+ // decision is already committed; the caller reports what this returns.
160
+ async function recordRatified(proposed, { itemId = null, source = 'amendment', updatedBy = null, instancePath } = {}) {
161
+ const should = governmentConfig.sanitizeBoard(proposed);
162
+ try {
163
+ await db.saveConstitutionRow({ board: should, source, itemId, updatedBy });
164
+ } catch (err) {
165
+ return { applied: null, failure: Object.freeze({ should, problems: [problem('database', 'save_failed', err)] }) };
166
+ }
167
+ let applied = null;
168
+ try {
169
+ // By property lookup at call time, so a test can redirect the file write.
170
+ applied = instancePath
171
+ ? governmentConfig.applyBoardAmendment(should, { instancePath })
172
+ : governmentConfig.applyBoardAmendment(should);
173
+ } catch (err) {
174
+ return { applied: null, failure: Object.freeze({ should, problems: [problem('in_force', 'write_failed', err)] }) };
175
+ }
176
+ return { applied, failure: await verifyInForce(should, { instancePath }) };
177
+ }
178
+
179
+ // The operator's path: re-apply the newest PASSED constitutional amendment.
180
+ // Refuses when there is none — it never invents a constitution.
181
+ async function reapplyNewestPassed({ updatedBy = null, historyLimit = 50 } = {}) {
182
+ const last = await newestPassed(historyLimit);
183
+ if (!last) return { ok: false, reason: 'no_passed_amendment' };
184
+ const out = await recordRatified(last.proposed, { itemId: last.item_id, source: 'reapply', updatedBy });
185
+ return {
186
+ ok: !out.failure,
187
+ item_id: String(last.item_id),
188
+ amendment_id: String(last.amendment_id),
189
+ applied: out.applied,
190
+ ...(out.failure ? { reason: 'not_applied', failure: out.failure } : {}),
191
+ };
192
+ }
193
+
194
+ // The handler behind POST /government/constitution/reapply. The route — its
195
+ // path, its government.manage gate and its pin — is declared in
196
+ // routes/government.js, where route-rank-check reads it; only the body lives
197
+ // here (the access-routes.js pattern, for the router's size ratchet). It takes
198
+ // NO block from the request: it can only re-apply what a sitting passed, never
199
+ // invent one (changing the constitution is a board act, owner decision 11).
200
+ function reapplyRoute(log) {
201
+ return async (req, res) => {
202
+ try {
203
+ const out = await module.exports.reapplyNewestPassed({ updatedBy: req.builder.id });
204
+ if (!out.ok && out.reason === 'no_passed_amendment') {
205
+ return res.fail('no_passed_amendment', 404, { message: 'No change to the constitution has passed yet, so there is nothing to re-apply. Propose one in the Board Room; once it passes it takes effect.' });
206
+ }
207
+ if (!out.ok) return res.fail('constitution_not_applied', 500, { item_id: out.item_id, failure: out.failure });
208
+ res.json({ ok: true, item_id: out.item_id, amendment_id: out.amendment_id, board: out.applied });
209
+ } catch (err) {
210
+ log.error('[gds] POST /government/constitution/reapply', err);
211
+ res.fail('constitution_reapply_failed', { status: 500, message: 'internal error' });
212
+ }
213
+ };
214
+ }
215
+
216
+ module.exports = {
217
+ reapplyRoute,
218
+ reconcile,
219
+ recordRatified,
220
+ verifyInForce,
221
+ reapplyNewestPassed,
222
+ differingFields,
223
+ BOARD_KEYS,
224
+ };
@@ -1320,7 +1320,35 @@ async function insertVoteKarma({ voterId, itemId, delta }) {
1320
1320
  );
1321
1321
  }
1322
1322
 
1323
+ // ── the constitution in force (task 1005140) ─────────────────────────────────
1324
+ // ONE row (government_037). The board block is stored exactly as sanitizeBoard
1325
+ // returns it; constitution.js owns the rules for when it is written and how the
1326
+ // running process is brought in step with it. `q` is a client or the pool.
1327
+ async function getConstitutionRow(q = pool) {
1328
+ const { rows } = await q.query(
1329
+ `SELECT board, source, amendment_item_id, updated_by, updated_at
1330
+ FROM government_constitution WHERE id = 1`,
1331
+ );
1332
+ return rows[0] || null;
1333
+ }
1334
+
1335
+ async function saveConstitutionRow({ board, source, itemId = null, updatedBy = null } = {}, q = pool) {
1336
+ const { rows } = await q.query(
1337
+ `INSERT INTO government_constitution (id, board, source, amendment_item_id, updated_by, updated_at)
1338
+ VALUES (1, $1::jsonb, $2, $3, $4, now())
1339
+ ON CONFLICT (id) DO UPDATE
1340
+ SET board = EXCLUDED.board, source = EXCLUDED.source,
1341
+ amendment_item_id = EXCLUDED.amendment_item_id,
1342
+ updated_by = EXCLUDED.updated_by, updated_at = now()
1343
+ RETURNING board, source, amendment_item_id, updated_by, updated_at`,
1344
+ [JSON.stringify(board), source, itemId, updatedBy],
1345
+ );
1346
+ return rows[0] || null;
1347
+ }
1348
+
1323
1349
  module.exports = {
1350
+ getConstitutionRow,
1351
+ saveConstitutionRow,
1324
1352
  openBoardItemsAwaitingVoter,
1325
1353
  getTaskStatuses,
1326
1354
  listLatestGenesisStageItems,
@@ -0,0 +1,33 @@
1
+ -- government_037_constitution.sql — the constitution in force is a ROW (task 1005140).
2
+ --
3
+ -- WHY
4
+ --
5
+ -- A ratified amendment used to live only in <instance root>/config/government.json.
6
+ -- Every instance this platform provisions runs on a container whose filesystem is
7
+ -- wiped on each deploy, restart and idle wake, so the board's decision was written,
8
+ -- took effect, and vanished at the next container start. The project went back to
9
+ -- the day-one monarchy without an error (found live on toast, 2026-11-07).
10
+ --
11
+ -- WHAT THIS ADDS
12
+ --
13
+ -- government_constitution — at most ONE row (id = 1): the instance's board block,
14
+ -- exactly as sanitizeBoard (modules/government/config.js) would write it. The four
15
+ -- keys are the whole block; nothing else rides here (rank templates stay config).
16
+ -- `source` says how the row got here: a passed amendment, a file adopted on first
17
+ -- read, or an operator re-applying the newest passed amendment.
18
+ --
19
+ -- It is NOT authority: the block names which RANK governs; who holds that rank is
20
+ -- still a government_builder_ranks row, resolved per request.
21
+
22
+ BEGIN;
23
+
24
+ CREATE TABLE IF NOT EXISTS government_constitution (
25
+ id smallint PRIMARY KEY DEFAULT 1 CHECK (id = 1),
26
+ board jsonb NOT NULL CHECK (jsonb_typeof(board) = 'object'),
27
+ source text NOT NULL CHECK (source IN ('amendment', 'adopted_file', 'reapply')),
28
+ amendment_item_id bigint REFERENCES government_board_items(id) ON DELETE SET NULL,
29
+ updated_by bigint REFERENCES builders(id) ON DELETE SET NULL,
30
+ updated_at timestamptz NOT NULL DEFAULT now()
31
+ );
32
+
33
+ COMMIT;
@@ -17,7 +17,8 @@
17
17
  ],
18
18
  "pollers": [
19
19
  "board-expiry",
20
- "treasury-borrow"
20
+ "treasury-borrow",
21
+ "constitution-restore"
21
22
  ]
22
23
  },
23
24
  "provides": [
@@ -0,0 +1,45 @@
1
+ 'use strict';
2
+
3
+ // modules/government/pollers/constitution-restore.js — the constitution in force
4
+ // comes back after a restart (task 1005140).
5
+ //
6
+ // A provisioned instance boots on a fresh container: the file the running
7
+ // process reads its constitution from is gone, and without this the project
8
+ // silently answers the day-one monarchy until somebody notices. So the FIRST
9
+ // reconcile runs as soon as the poller starts, not after a delay — every moment
10
+ // before it is a moment the wrong rule is in force. After that it re-checks on a
11
+ // slow interval, which also keeps a second process in step with an amendment
12
+ // another one applied.
13
+ //
14
+ // Quiet when in step; one line when it restores or adopts (constitution.js).
15
+
16
+ const api = require('../../../src/module-api');
17
+ const constitution = require('../constitution');
18
+
19
+ const logger = api.logger('government');
20
+
21
+ const INTERVAL_MS = Number(process.env.CONSTITUTION_RESTORE_SWEEP_MS) || 5 * 60_000;
22
+
23
+ let intervalTimer = null;
24
+
25
+ function start({ reconcileFn = constitution.reconcile } = {}) {
26
+ if (process.env.CONSTITUTION_RESTORE_DISABLED === '1') {
27
+ logger.info('[constitution-restore] disabled (CONSTITUTION_RESTORE_DISABLED=1)');
28
+ return;
29
+ }
30
+ if (intervalTimer) return;
31
+ const tick = () => {
32
+ Promise.resolve()
33
+ .then(() => reconcileFn({ log: (m) => logger.info(m), error: (m) => logger.error(m) }))
34
+ .catch((e) => logger.error(`[constitution-restore] reconcile crashed: ${e && e.message}`));
35
+ };
36
+ tick();
37
+ intervalTimer = setInterval(tick, INTERVAL_MS);
38
+ if (intervalTimer.unref) intervalTimer.unref();
39
+ }
40
+
41
+ function stop() {
42
+ if (intervalTimer) { clearInterval(intervalTimer); intervalTimer = null; }
43
+ }
44
+
45
+ module.exports = { start, stop, INTERVAL_MS };
@@ -36,6 +36,7 @@ const asksDb = require('../asks-db');
36
36
  const routing = require('../routing');
37
37
  const { accessHandlers } = require('../access-routes');
38
38
  const governmentConfig = require('../config');
39
+ const constitution = require('../constitution');
39
40
  const membershipPredicate = require('../membership-predicate');
40
41
  const positions = require('../positions');
41
42
  const decisionBoard = require('../decision-board');
@@ -724,8 +725,7 @@ module.exports = function buildGovernmentRouter() {
724
725
  // the live board block plus the full amendment history, passed and returned
725
726
  // alike. Same atom as the room — reading the rule you vote under is part of
726
727
  // casting, and the section teaches (criterion C5). The live value resolves
727
- // fresh per request, so an amendment applied a moment ago reads back
728
- // immediately (the same instant-effect posture as everything else here).
728
+ // fresh per request, so an amendment applied a moment ago reads back at once.
729
729
  router.get('/government/constitution', api.requireBuilder, api.requirePermission('board.vote.cast'), async (req, res) => {
730
730
  try {
731
731
  // ?type= and ?headcount= ask about another project type or size (task
@@ -741,6 +741,9 @@ module.exports = function buildGovernmentRouter() {
741
741
  }
742
742
  });
743
743
 
744
+ // rank: perm government.manage — re-apply the newest PASSED amendment (task 1005140; constitution.js).
745
+ router.post('/government/constitution/reapply', api.requireBuilder, api.requirePermission('government.manage'), constitution.reapplyRoute(log));
746
+
744
747
  // rank: perm board.vote.cast — the founding band's read (task 1004429): each
745
748
  // genesis stage's latest sitting, and who it is still waiting on under the
746
749
  // item's own snapshot. Same atom as the room — seeing who is still to vote is
@@ -1454,11 +1457,7 @@ module.exports = function buildGovernmentRouter() {
1454
1457
  const status =
1455
1458
  out.reason === 'item_not_found' ? 404 :
1456
1459
  out.reason === 'not_board_member' ? 403 :
1457
- // 'anti_self_vote' was retired by task 1003273 — an author may cast;
1458
- // what they cannot do is decide alone, which the close trigger and the
1459
- // tally enforce rather than the route. The mapping is left OUT rather
1460
- // than kept "just in case": a reason castVote can no longer return
1461
- // would be dead code pretending to be a wall.
1460
+ // 'anti_self_vote' was retired by task 1003273 (an author may cast, never decide alone) — left OUT on purpose.
1462
1461
 
1463
1462
  out.reason === 'item_closed' ? 409 :
1464
1463
  out.reason === 'vote_window_expired' ? 409 :
@@ -1486,6 +1485,7 @@ module.exports = function buildGovernmentRouter() {
1486
1485
  already_voted: !out.fresh,
1487
1486
  ...(out.fresh ? { karma_delta: out.karma_delta } : {}),
1488
1487
  ...(closure && closure.closed ? { item_closed: true, outcome: closure.outcome } : {}),
1488
+ ...(closure && closure.constitution_not_applied ? { constitution_not_applied: closure.constitution_not_applied } : {}),
1489
1489
  });
1490
1490
  } catch (err) {
1491
1491
  log.error('[gds] POST /government/board/items/:itemId/votes', err);
@@ -415,9 +415,13 @@
415
415
  }
416
416
  try {
417
417
  const out = await sendJson('POST', `/government/board/items/${encodeURIComponent(itemId)}/votes`, body);
418
- toast(out.item_closed
419
- ? `Vote cast — the sitting closed: ${out.outcome}.`
420
- : 'Vote cast.');
418
+ // Task 1005140: a ratified change that did not take is said now, loudly,
419
+ // not weeks later in the Constitution tab's warning.
420
+ toast(out.constitution_not_applied
421
+ ? 'Passed, but the new constitution did not take effect. The old rules are still in force; the owner can re-apply it.'
422
+ : out.item_closed
423
+ ? `Vote cast — the sitting closed: ${out.outcome}.`
424
+ : 'Vote cast.');
421
425
  } catch (err) {
422
426
  if (err && err.kind === '401') return;
423
427
  toast(`Could not vote: ${err.message || err}`);
@@ -801,6 +801,8 @@
801
801
  return { toast: 'Sent to the council. It is carried out when they pass it.', building: b, kind: 'light', close: false };
802
802
  }
803
803
  if (key === 'for') {
804
+ // Task 1005140: never say "in force" about a change that did not take.
805
+ if (r.item_closed && r.outcome === 'passed' && r.constitution_not_applied) return { toast: 'Passed, but it did not take effect. The old rules are still in force.', building: b, kind: 'settle', close: false };
804
806
  if (r.item_closed && r.outcome === 'passed') return { toast: 'Passed. It is in force from today.', building: b, kind: 'rise', close: false };
805
807
  if (r.item_closed) return { toast: 'The sitting closed: it went back to its author.', building: b, kind: 'settle', close: false };
806
808
  return { toast: r.already_voted ? 'Your vote was already on the record.' : 'Your yes is on the record.', building: b, kind: 'light', close: false };
@@ -757,7 +757,7 @@ html[data-theme="dark"] .brand-mark__mark { --mark-ink: #e2e1e4; }
757
757
  .genesis__step-n { display: inline-grid; place-items: center; width: 16px; height: 16px; border-radius: 50%; font-size: 10px; background: var(--rule-soft); }
758
758
  .genesis__step.is-done { color: var(--ink-soft); }
759
759
  .genesis__step.is-now { color: var(--ink); border-color: color-mix(in srgb, var(--accent) 45%, transparent); background: color-mix(in srgb, var(--accent) 10%, transparent); }
760
- .genesis__step.is-now .genesis__step-n { background: var(--accent); color: var(--bg); }
760
+ .genesis__step.is-now .genesis__step-n { background: var(--accent); color: var(--accent-on); }
761
761
  .genesis__plan, .genesis__stages, .genesis__tasks, .genesis__life { list-style: none; margin: 0; padding: 0; }
762
762
  .genesis__plan-row, .genesis__stage, .genesis__task { display: flex; align-items: center; gap: 12px; padding: 12px 0; border-top: 1px solid var(--rule-soft); }
763
763
  .genesis__plan-row:first-child, .genesis__stage:first-child, .genesis__task:first-child { border-top: 0; }
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.24.71",
3
+ "version": "1.24.73",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.24.71",
9
+ "version": "1.24.73",
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.24.71",
3
+ "version": "1.24.73",
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",
@@ -11451,5 +11451,17 @@
11451
11451
  "id": "1002450",
11452
11452
  "text": "New projects can no longer be born on an outdated or mislabelled copy of the core. When the hub's saved core file is stale or doesn't match the published one, provisioning now says so loudly and uses the current, published cor"
11453
11453
  }
11454
+ ],
11455
+ "1.24.72": [
11456
+ {
11457
+ "id": "1005103",
11458
+ "text": "The current step number in the project-setup steps is now dark ink on the orange dot, so it's readable in light mode too."
11459
+ }
11460
+ ],
11461
+ "1.24.73": [
11462
+ {
11463
+ "id": "1005140",
11464
+ "text": "When a project's board votes in a new form of government, that choice now stays in force after the server restarts. Before, it was quietly lost on every restart and the project fell back to the original one-person rule. A proj"
11465
+ }
11454
11466
  ]
11455
11467
  }
@@ -169,6 +169,7 @@ function makeStore(catalog) {
169
169
  items: [],
170
170
  votes: [],
171
171
  amendments: new Map(),
172
+ constitutionRow: null,
172
173
  matters: new Map(),
173
174
  leads: [], // { id, building, builder_id, charter, appointed_by_item_id, removed_by_item_id, removed_at }
174
175
  laws: [], // { id, text, effect_on_ai, locks, proposed_by, passed_by_item_id, passed_at, repealed_by_item_id, repealed_at }
@@ -298,6 +299,12 @@ function makeStore(catalog) {
298
299
  return copy(row);
299
300
  },
300
301
  async getAmendment(id) { return copy(s.amendments.get(String(id))) || null; },
302
+ // The constitution in force as a durable row (task 1005140).
303
+ async getConstitutionRow() { return copy(s.constitutionRow) || null; },
304
+ async saveConstitutionRow({ board, source, itemId = null, updatedBy = null } = {}) {
305
+ s.constitutionRow = { board: copy(board), source, amendment_item_id: itemId, updated_by: updatedBy };
306
+ return copy(s.constitutionRow);
307
+ },
301
308
  async listAmendmentHistory(limit = 50) {
302
309
  return s.items
303
310
  .filter((i) => i.subject_type === 'constitutional_amendment' && i.closed_at)
@@ -297,6 +297,10 @@ const GOVERNMENT_PINS = {
297
297
  // R18: the Constitution view — the live rule + the amendment history; reading
298
298
  // the rule you vote under is part of casting.
299
299
  'GET /government/constitution': 'perm:board.vote.cast',
300
+ // task 1005140: put the newest PASSED constitutional amendment back into force
301
+ // (a container restart had erased it). Decides nothing — the board already did —
302
+ // so it is the owner's operational atom, not a board one.
303
+ 'POST /government/constitution/reapply': 'perm:government.manage',
300
304
  // task 1004429: the founding band — who each genesis stage is waiting on. A
301
305
  // read of the open ballot, so the same atom as the room.
302
306
  'GET /government/board/genesis-stages': 'perm:board.vote.cast',
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.24.71'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.24.73'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');