@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.
- package/.bongos-core.json +57 -37
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +2 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +36 -2
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +4 -4
- package/docs/copy-registry.json +4 -4
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +84 -84
- package/modules/government/board.js +19 -7
- package/modules/government/config.js +21 -9
- package/modules/government/constitution.js +224 -0
- package/modules/government/db.js +28 -0
- package/modules/government/migrations/government_037_constitution.sql +33 -0
- package/modules/government/module.json +2 -1
- package/modules/government/pollers/constitution-restore.js +45 -0
- package/modules/government/routes/government.js +7 -7
- package/modules/hall-ui/public/board-room.js +7 -3
- package/modules/hall-ui/public/street-card.js +2 -0
- package/modules/hall-ui/public/style.css +1 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/scripts/bongos/government-walk.js +7 -0
- package/src/bongos/route-rank-check.js +4 -0
- package/src/module-api.js +1 -1
- package/tests/genesis_government.mjs +11 -0
- package/tests/government_board_amendment.mjs +10 -0
- package/tests/government_charter_forms.mjs +10 -0
- package/tests/government_constitution_durable.mjs +304 -0
- package/tests/government_seed.mjs +1 -0
- 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
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
-
}
|
|
681
|
-
console.error(`[government] amendment ${closed.
|
|
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
|
-
//
|
|
370
|
-
//
|
|
371
|
-
//
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
//
|
|
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
|
|
377
|
-
//
|
|
378
|
-
//
|
|
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
|
+
};
|
package/modules/government/db.js
CHANGED
|
@@ -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;
|
|
@@ -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
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
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(--
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -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.
|
|
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');
|