@bongos/core 1.25.20 → 1.25.22
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 +213 -78
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +18 -0
- package/clients/bongos-client/index.cjs +18 -0
- package/clients/bongos-client/index.d.ts +34 -8
- package/clients/bongos-client/index.mjs +18 -0
- package/docs/adr/0406-every-project-gets-a-sandbox-that-follows-main-and-live-moves-only-when-a-person-promotes.md +18 -0
- package/docs/adr/0407-a-project-grant-is-made-by-the-owner-not-witnessed-at-sign-in.md +3 -1
- package/docs/api/openapi.json +693 -125
- package/docs/api-reference.md +13 -4
- package/docs/copy-inventory.md +432 -427
- package/docs/copy-registry.json +571 -526
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +350 -344
- package/modules/government/board.js +8 -7
- package/modules/government/charter-advice.js +10 -3
- package/modules/government/charter.js +88 -4
- package/modules/government/migrations/government_039_vote_cast_via_filed.sql +22 -0
- package/modules/government/routes/government.js +12 -12
- package/modules/government/sole-member.js +75 -0
- package/modules/hall-ui/public/genesis-home.js +12 -5
- package/modules/lifecycle/kickoff-checklist.js +1 -1
- package/modules/lifecycle/routes/pay-type-gates.js +7 -2
- package/modules/onboarding/genesis-home.js +14 -2
- package/modules/onboarding/project-facts.js +318 -0
- package/modules/onboarding/routes/genesis-work.js +16 -0
- package/modules/platform-identity/module.json +1 -1
- package/modules/platform-identity/project-standing.js +49 -0
- package/modules/platform-identity/routes/my-projects.js +8 -0
- package/modules/provisioning/grants.js +126 -0
- package/modules/provisioning/migrations/provisioning_038_render_promote.sql +43 -0
- package/modules/provisioning/migrations/provisioning_039_project_grants.sql +40 -0
- package/modules/provisioning/module.json +2 -1
- package/modules/provisioning/project-facts.js +55 -0
- package/modules/provisioning/project-refusal.js +107 -0
- package/modules/provisioning/provisioning.js +6 -3
- package/modules/provisioning/render-lookup.js +36 -1
- package/modules/provisioning/render-promote.js +80 -0
- package/modules/provisioning/render-sandbox.js +6 -0
- package/modules/provisioning/route-helpers.js +70 -16
- package/modules/provisioning/routes/env-manifest.js +21 -5
- package/modules/provisioning/routes/grants.js +141 -0
- package/modules/provisioning/routes/provisioning.js +8 -7
- package/modules/provisioning/routes/render-standup.js +82 -3
- package/modules/public-landing/public/projects.html +290 -32
- package/package-lock.json +2 -2
- package/package.json +2 -1
- package/release-notes.json +32 -0
- package/scripts/bongos/genesis.js +49 -3
- package/scripts/bongos/government-walk-sole.js +41 -0
- package/scripts/bongos/government-walk.js +15 -16
- package/scripts/bongos/init.js +13 -0
- package/scripts/bongos/package-core.js +2 -0
- package/scripts/bongos/provision-render-common.js +48 -0
- package/scripts/bongos/provision-render-promote.js +93 -0
- package/scripts/bongos/provision-render-sandbox.js +19 -3
- package/scripts/bongos/provision-render.js +4 -36
- package/scripts/bongos/provision.js +1 -0
- package/scripts/bongos/render-api.js +17 -2
- package/scripts/bongos/render-payload.js +5 -2
- package/scripts/gds/government-walk-sole.js +6 -0
- package/scripts/gds/provision-render-common.js +6 -0
- package/scripts/gds/provision-render-promote.js +6 -0
- package/src/bongos/folder-upload.js +243 -0
- package/src/bongos/routes/github.js +109 -16
- package/src/bongos/routes.js +3 -0
- package/src/branding.js +4 -0
- package/src/module-api.js +1 -1
- package/src/sandbox/gate.js +164 -0
- package/src/sandbox/index.js +144 -0
- package/tests/fleet_cost_view.mjs +2 -0
- package/tests/folder_upload_project.mjs +396 -0
- package/tests/genesis_government.mjs +5 -1
- package/tests/genesis_home.mjs +17 -0
- package/tests/genesis_work_routes.mjs +25 -1
- package/tests/government_charter_advice.mjs +49 -0
- package/tests/government_charter_forms.mjs +14 -3
- package/tests/government_seed.mjs +1 -0
- package/tests/government_sole_member.mjs +153 -0
- package/tests/project_catalog_routes.mjs +2 -0
- package/tests/project_facts.mjs +158 -0
- package/tests/projects_hub.mjs +1 -1
- package/tests/provision_settings_apply.mjs +2 -1
- package/tests/provisioning_env_manifest.mjs +4 -0
- package/tests/provisioning_env_manifest_edit_delete.mjs +2 -0
- package/tests/provisioning_project_grants.mjs +458 -0
- package/tests/provisioning_project_owner_gate.mjs +3 -1
- package/tests/provisioning_project_refusal.mjs +316 -0
- package/tests/provisioning_render_route.mjs +1 -1
- package/tests/provisioning_restart.mjs +2 -0
- package/tests/provisioning_settings.mjs +2 -0
- package/tests/provisioning_settings_apply.mjs +1 -1
- package/tests/provisioning_settings_env.mjs +2 -2
- package/tests/provisioning_teardown_intent.mjs +2 -2
- package/tests/public_landing_projects.mjs +27 -5
- package/tests/render_promote.mjs +309 -0
- package/tests/render_sandbox.mjs +16 -8
- package/tests/sandbox_gate.mjs +182 -0
|
@@ -256,20 +256,20 @@ function validateVote({ direction, reasonMd, sectionKey } = {}) {
|
|
|
256
256
|
// { ok: true, vote, fresh: false, self_cast } — idempotent re-cast
|
|
257
257
|
// { ok: false, reason: 'item_not_found' | 'item_closed' | 'vote_window_expired'
|
|
258
258
|
// | 'not_board_member' | 'bad_cast_via' | <validateVote reason> }
|
|
259
|
-
// `castVia` (task 1005121, government_038) is internal — never read from a request: 'ballot',
|
|
259
|
+
// `castVia` (task 1005121, government_038) is internal — never read from a request: 'ballot', 'stage_close' (genesis-close.js: a founder's confirm given by closing a genesis stage — genesis_stage + yes only, no vote karma), or 'filed' (sole-member.js, task 1005130 / government_039: a board of one's confirm given by filing — constitutional_amendment + yes only, no vote karma).
|
|
260
260
|
// 'anti_self_vote' is GONE as of task 1003273 — an author may cast; what they
|
|
261
261
|
// cannot do is decide alone, which shouldCloseAfterVote and tallyOutcome enforce.
|
|
262
262
|
// A self-cast is recorded and visible but earns no karma (karma_delta: 0).
|
|
263
263
|
async function castVote({ itemId, voterId, direction, reasonMd = null, sectionKey = null, castVia = 'ballot' } = {}) {
|
|
264
264
|
const id = Number(itemId);
|
|
265
265
|
if (!Number.isFinite(id) || id <= 0) return { ok: false, reason: 'item_not_found' };
|
|
266
|
-
if (
|
|
266
|
+
if (!['ballot', 'stage_close', 'filed'].includes(castVia)) return { ok: false, reason: 'bad_cast_via' };
|
|
267
267
|
const v = validateVote({ direction, reasonMd, sectionKey });
|
|
268
268
|
if (!v.ok) return v;
|
|
269
269
|
|
|
270
270
|
const item = await db.getBoardItemForVote(id);
|
|
271
271
|
if (!item) return { ok: false, reason: 'item_not_found' };
|
|
272
|
-
if (castVia
|
|
272
|
+
if (castVia !== 'ballot' && (item.subject_type !== (castVia === 'filed' ? 'constitutional_amendment' : 'genesis_stage') || v.direction !== 'yes')) return { ok: false, reason: 'bad_cast_via' };
|
|
273
273
|
if (item.closed_at) return { ok: false, reason: 'item_closed' };
|
|
274
274
|
// The clock is checked here as well as by the R11 poller: a vote landing in
|
|
275
275
|
// the gap between expiry and the sweep would be a vote after time. The
|
|
@@ -321,7 +321,7 @@ async function castVote({ itemId, voterId, direction, reasonMd = null, sectionKe
|
|
|
321
321
|
}
|
|
322
322
|
// Karma rides the FRESH insert only, and the whole route refused a closed or
|
|
323
323
|
// expired item above, so every paid vote is by construction in-window.
|
|
324
|
-
if (isSelfCast || castVia
|
|
324
|
+
if (isSelfCast || castVia !== 'ballot') {
|
|
325
325
|
return { ok: true, vote: inserted, fresh: true, karma_delta: 0, self_cast: isSelfCast };
|
|
326
326
|
}
|
|
327
327
|
await db.insertVoteKarma({ voterId, itemId: id, delta: VOTE_KARMA_DELTA });
|
|
@@ -906,9 +906,10 @@ async function proposeCharter({
|
|
|
906
906
|
}
|
|
907
907
|
const unchanged = ['membership', 'pass_rule', 'window_minutes', 'close_early_on_full_turnout']
|
|
908
908
|
.every((k) => v.proposal[k] === live[k]);
|
|
909
|
-
|
|
910
|
-
|
|
909
|
+
// task 1005130: a choice that changes nothing SAYS so, and counts as done.
|
|
910
|
+
if (unchanged) return { ok: false, reason: 'no_change', in_force: charter.readCharter(live).name, complete: true, message: charter.alreadyLine(live) };
|
|
911
911
|
const readsAs = charter.readCharter(v.proposal);
|
|
912
|
+
const next = `${readsAs.form === 'custom' ? 'These settings' : `Choosing ${readsAs.name}`} ${charter.whatHappensNext(live)}`;
|
|
912
913
|
const out = await proposeAmendment({
|
|
913
914
|
proposal: v.proposal,
|
|
914
915
|
// The record says in words what was chosen, so the amendment history reads
|
|
@@ -917,7 +918,7 @@ async function proposeCharter({
|
|
|
917
918
|
proposedBy,
|
|
918
919
|
acknowledgeSelfRemoval,
|
|
919
920
|
});
|
|
920
|
-
return out.ok ? { ...out, reads_as: readsAs } : out;
|
|
921
|
+
return out.ok ? { ...out, reads_as: readsAs, what_happens_next: next } : out;
|
|
921
922
|
}
|
|
922
923
|
|
|
923
924
|
// ── who decides what (task 1004529, ADR 0363) ───────────────────────────────
|
|
@@ -67,12 +67,18 @@ function draftBylaws({ charter: rec, preset, prose = {} }) {
|
|
|
67
67
|
// buildAdvice({ answers, prose, headcount, type, laws }) — PURE. `answers` holds only
|
|
68
68
|
// the closed answers the founder actually gave (not "not sure yet", not skipped);
|
|
69
69
|
// `laws` is db.listLaws() rows, so the preset view says which is in force now.
|
|
70
|
-
function buildAdvice({ answers = {}, prose = {}, headcount = 0, type = null, laws = [] } = {}) {
|
|
70
|
+
function buildAdvice({ answers = {}, prose = {}, headcount = 0, type = null, laws = [], board = null } = {}) {
|
|
71
71
|
const rec = recommendedCharter(headcount, type, answers);
|
|
72
72
|
const preset = presets.recommendedPreset(answers);
|
|
73
73
|
const view = presets.presetsView(laws);
|
|
74
|
+
// task 1005130: what is true NOW, before the founder chooses — the form in force,
|
|
75
|
+
// every form saying who decides and what closing takes, and what choosing the
|
|
76
|
+
// recommended one would do (nothing, when it is already in force).
|
|
77
|
+
const forms = board ? charter.charterView(board) : null;
|
|
74
78
|
return {
|
|
75
|
-
|
|
79
|
+
in_force: forms ? forms.in_force : null,
|
|
80
|
+
forms: forms ? forms.forms : null,
|
|
81
|
+
charter: board ? { ...rec, on_choose: charter.choiceOutcome(board, rec.key) } : rec,
|
|
76
82
|
// null when the founder's principles do not point at one — no preset is fine
|
|
77
83
|
preset: preset ? { ...preset, locks: (view.presets.find((p) => p.key === preset.key) || {}).locks || [] } : null,
|
|
78
84
|
preset_in_force: view.current,
|
|
@@ -91,13 +97,14 @@ function recommendedCharter(headcount, type, answers) {
|
|
|
91
97
|
// otherwise the live count of active builders.
|
|
92
98
|
async function charterAdvice({ answers, prose, headcount = null, type = null } = {}, deps = {}) {
|
|
93
99
|
const db = deps.db || require('./db');
|
|
100
|
+
const board = deps.board !== undefined ? deps.board : require('./config').loadGovernmentConfig().board;
|
|
94
101
|
const [count, laws] = await Promise.all([
|
|
95
102
|
headcount != null && Number(headcount) > 0 ? Number(headcount) : db.countActiveBuilders(),
|
|
96
103
|
db.listLaws(),
|
|
97
104
|
]);
|
|
98
105
|
const p = {};
|
|
99
106
|
for (const k of PROSE) if (prose && text(prose[k])) p[k] = text(prose[k]);
|
|
100
|
-
return buildAdvice({ answers, prose: p, headcount: count, type, laws });
|
|
107
|
+
return buildAdvice({ answers, prose: p, headcount: count, type, laws, board });
|
|
101
108
|
}
|
|
102
109
|
|
|
103
110
|
module.exports = { buildAdvice, charterAdvice };
|
|
@@ -123,6 +123,41 @@ const FORMS = freezeAll([
|
|
|
123
123
|
explanation: 'Self-governing teams coordinate under one shared pact. Coming later, for projects with many teams.' },
|
|
124
124
|
]);
|
|
125
125
|
|
|
126
|
+
// ── what a form asks of the founder day to day (task 1005130) ───────────────
|
|
127
|
+
// Owner, 2026-11-07: "I am the monarch" read as "nothing is asked of me", and the
|
|
128
|
+
// project still asked for a vote to close a stage. Monarchy means ONE yes decides,
|
|
129
|
+
// not NO yes is needed. So every form says, beside its name, who decides and what
|
|
130
|
+
// closing something takes — DERIVED from its franchise and pass rule, never written
|
|
131
|
+
// by hand, so it can never disagree with what the board actually runs.
|
|
132
|
+
const WHO_DECIDES = Object.freeze({
|
|
133
|
+
founder: 'You decide.',
|
|
134
|
+
trusted: 'You and the builders who have earned the trusted rank decide together.',
|
|
135
|
+
everyone: 'Every builder on the project decides.',
|
|
136
|
+
council: 'The council you appoint decides.',
|
|
137
|
+
custom: 'The members these settings seat decide.',
|
|
138
|
+
});
|
|
139
|
+
const TO_CLOSE = Object.freeze({
|
|
140
|
+
first_ratifier: 'Each decision takes one yes from one of them.',
|
|
141
|
+
consent: 'Each decision passes unless one of them objects, once they have all voted (or its clock runs out, if one is set).',
|
|
142
|
+
majority: 'Each decision needs a yes from more than half of them.',
|
|
143
|
+
unanimous: 'Each decision needs everyone who votes to agree, and at least one yes.',
|
|
144
|
+
});
|
|
145
|
+
// A board of one: every rule comes down to that one person's vote.
|
|
146
|
+
const TO_CLOSE_FOUNDER = Object.freeze({
|
|
147
|
+
first_ratifier: 'Each decision takes one yes from you.',
|
|
148
|
+
consent: 'Each decision takes your vote: it passes unless you object.',
|
|
149
|
+
majority: 'Each decision takes one yes from you.',
|
|
150
|
+
unanimous: 'Each decision takes one yes from you.',
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// dutyOf(franchise, passRule) → { who_decides, to_close } in plain words.
|
|
154
|
+
function dutyOf(franchise, passRule) {
|
|
155
|
+
const who = WHO_DECIDES[franchise] || WHO_DECIDES.custom;
|
|
156
|
+
const close = (franchise === 'founder' ? TO_CLOSE_FOUNDER : TO_CLOSE)[passRule]
|
|
157
|
+
|| 'This pass rule is not one this version knows; only its clock can close a decision.';
|
|
158
|
+
return { who_decides: who, to_close: close };
|
|
159
|
+
}
|
|
160
|
+
|
|
126
161
|
const CUSTOM_EXPLANATION = 'These settings match no named form of government. They are still the real rules the board runs under.';
|
|
127
162
|
|
|
128
163
|
const byKey = (list, key) => list.find((x) => x.key === key) || null;
|
|
@@ -152,8 +187,8 @@ function readCharter(board) {
|
|
|
152
187
|
const passRule = byKey(PASS_RULE_OPTIONS, b.pass_rule) ? b.pass_rule : 'custom';
|
|
153
188
|
const form = FORMS.find((f) => f.available && f.franchise === franchise && f.pass_rule === passRule);
|
|
154
189
|
return Object.freeze(form
|
|
155
|
-
? { form: form.key, name: form.name, explanation: form.explanation, franchise, pass_rule: passRule }
|
|
156
|
-
: { form: 'custom', name: 'Custom', explanation: CUSTOM_EXPLANATION, franchise, pass_rule: passRule });
|
|
190
|
+
? { form: form.key, name: form.name, explanation: form.explanation, franchise, pass_rule: passRule, ...dutyOf(franchise, passRule) }
|
|
191
|
+
: { form: 'custom', name: 'Custom', explanation: CUSTOM_EXPLANATION, franchise, pass_rule: passRule, ...dutyOf(franchise, passRule) });
|
|
157
192
|
}
|
|
158
193
|
|
|
159
194
|
// Build the WHOLE constitution a charter choice makes, on top of the live one.
|
|
@@ -209,17 +244,62 @@ function buildCharterProposal(liveBoard, { form, franchise, councilRank, passRul
|
|
|
209
244
|
// The whole vocabulary plus what the constitution in force reads as — the
|
|
210
245
|
// payload the genesis charter and legislation stages render from.
|
|
211
246
|
function charterView(liveBoard) {
|
|
247
|
+
const readsAs = readCharter(liveBoard);
|
|
212
248
|
return {
|
|
213
|
-
|
|
249
|
+
// Every form, each saying what it asks day to day, and the one in force marked.
|
|
250
|
+
forms: FORMS.map((f) => (f.available
|
|
251
|
+
? { ...f, ...dutyOf(f.franchise, f.pass_rule), current: f.key === readsAs.form }
|
|
252
|
+
: { ...f, current: false })),
|
|
253
|
+
in_force: inForce(readsAs),
|
|
214
254
|
variables: VARIABLES.map((v) => (
|
|
215
255
|
v.key === 'franchise' ? { ...v, options: FRANCHISE_OPTIONS }
|
|
216
256
|
: v.key === 'pass_rule' ? { ...v, options: PASS_RULE_OPTIONS }
|
|
217
257
|
: v.key === 'trajectory' ? { ...v, options: TRAJECTORIES }
|
|
218
258
|
: v)),
|
|
219
|
-
reads_as:
|
|
259
|
+
reads_as: readsAs,
|
|
220
260
|
};
|
|
221
261
|
}
|
|
222
262
|
|
|
263
|
+
// inForce(readsAs) → what is true NOW, said before the founder chooses (task 1005130):
|
|
264
|
+
// every project opens as Monarchy / BDFL, and a founder should learn that from the
|
|
265
|
+
// platform rather than infer it from a choice that does nothing.
|
|
266
|
+
function inForce(readsAs) {
|
|
267
|
+
const opening = readsAs.form === 'monarchy'
|
|
268
|
+
? ' Every project opens as one, so choosing Monarchy / BDFL changes nothing.'
|
|
269
|
+
: '';
|
|
270
|
+
return Object.freeze({
|
|
271
|
+
form: readsAs.form,
|
|
272
|
+
name: readsAs.name,
|
|
273
|
+
who_decides: readsAs.who_decides,
|
|
274
|
+
to_close: readsAs.to_close,
|
|
275
|
+
line: `This project runs as ${readsAs.name} now.${opening} ${readsAs.who_decides} ${readsAs.to_close}`,
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// choiceOutcome(liveBoard, formKey) → what choosing a form would DO, in words: nothing
|
|
280
|
+
// (it is already in force, so this part of the stage is done), or an amendment
|
|
281
|
+
// decided under the government in force — and, when one yes carries it, whose.
|
|
282
|
+
function choiceOutcome(liveBoard, formKey) {
|
|
283
|
+
const now = readCharter(liveBoard);
|
|
284
|
+
const f = byKey(FORMS, formKey);
|
|
285
|
+
if (!f) return null;
|
|
286
|
+
if (now.form === f.key) return Object.freeze({ changes: false, complete: true, line: alreadyLine(liveBoard) });
|
|
287
|
+
return Object.freeze({ changes: true, complete: false, line: `Choosing ${f.name} ${whatHappensNext(liveBoard)}` });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// whatHappensNext(liveBoard) → the rest of the sentence "Choosing X …": a change is
|
|
291
|
+
// filed as an amendment and decided under the government in force, and who carries it.
|
|
292
|
+
function whatHappensNext(liveBoard) {
|
|
293
|
+
const now = readCharter(liveBoard);
|
|
294
|
+
const oneYes = now.pass_rule === 'first_ratifier';
|
|
295
|
+
const whose = now.franchise === 'founder' ? 'your own one yes applies it' : 'one yes from a member applies it';
|
|
296
|
+
return `is filed as a change to the rules and decided under the government in force (${now.name}). ${oneYes ? `Under it, ${whose}.` : now.to_close}`;
|
|
297
|
+
}
|
|
298
|
+
// alreadyLine(liveBoard) → "You are already a Monarchy / BDFL. Nothing to change…"
|
|
299
|
+
function alreadyLine(liveBoard) {
|
|
300
|
+
return `You are already a ${readCharter(liveBoard).name}. Nothing to change, and this choice is done.`;
|
|
301
|
+
}
|
|
302
|
+
|
|
223
303
|
module.exports = {
|
|
224
304
|
FORMS,
|
|
225
305
|
VARIABLES,
|
|
@@ -227,6 +307,10 @@ module.exports = {
|
|
|
227
307
|
PASS_RULE_OPTIONS,
|
|
228
308
|
TRAJECTORIES,
|
|
229
309
|
readCharter,
|
|
310
|
+
dutyOf,
|
|
311
|
+
choiceOutcome,
|
|
312
|
+
alreadyLine,
|
|
313
|
+
whatHappensNext,
|
|
230
314
|
buildCharterProposal,
|
|
231
315
|
charterView,
|
|
232
316
|
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
-- government_039_vote_cast_via_filed.sql — a third way a vote is cast (task 1005130).
|
|
2
|
+
--
|
|
3
|
+
-- A board of ONE under first_ratifier: the board exists only to collect that one
|
|
4
|
+
-- person's yes, so asking them to vote on their own instruction is ceremony, not
|
|
5
|
+
-- governance (owner feedback 2026-11-07; the same ruling task 1005121 shipped for
|
|
6
|
+
-- closing a genesis stage). When that sole member FILES an amendment, their yes is
|
|
7
|
+
-- cast in the same call (sole-member.js) — and, as with a stage close, the record
|
|
8
|
+
-- says HOW it was cast rather than looking like a clicked ballot.
|
|
9
|
+
--
|
|
10
|
+
-- cast_via 'ballot' a vote cast in the Board Room (the vote route)
|
|
11
|
+
-- 'stage_close' the confirm given by closing a genesis stage
|
|
12
|
+
-- 'filed' the sole member's confirm given by filing an amendment;
|
|
13
|
+
-- only board.js writes it, only on a
|
|
14
|
+
-- constitutional_amendment item, only yes
|
|
15
|
+
|
|
16
|
+
BEGIN;
|
|
17
|
+
|
|
18
|
+
ALTER TABLE government_board_votes DROP CONSTRAINT IF EXISTS government_board_votes_cast_via_check;
|
|
19
|
+
ALTER TABLE government_board_votes ADD CONSTRAINT government_board_votes_cast_via_check
|
|
20
|
+
CHECK (cast_via IN ('ballot', 'stage_close', 'filed') AND (cast_via = 'ballot' OR direction = 'yes'));
|
|
21
|
+
|
|
22
|
+
COMMIT;
|
|
@@ -137,7 +137,7 @@ const governmentPort = {
|
|
|
137
137
|
// an ordinary amendment under the constitution in force — for the genesis
|
|
138
138
|
// home, which renders the stages from GET /government/constitution's
|
|
139
139
|
// `charter` block. Opening a sitting decides nothing; the vote does.
|
|
140
|
-
proposeCharter:
|
|
140
|
+
proposeCharter: (a) => require('../sole-member').proposeCharter(a), // task 1005130: a board of one decides by filing (sole-member.js), here and on every amendment route
|
|
141
141
|
charterAdvice: (input) => require('../charter-advice').charterAdvice(input), // task 1005119: advice only, never a write
|
|
142
142
|
genesisStageStatus: board.genesisStageStatus,
|
|
143
143
|
// task 1004529 — who decides what. The table, resolved, and the check a
|
|
@@ -972,7 +972,7 @@ module.exports = function buildGovernmentRouter() {
|
|
|
972
972
|
});
|
|
973
973
|
}
|
|
974
974
|
try {
|
|
975
|
-
const out = await
|
|
975
|
+
const out = await require('../sole-member').proposeAmendment({
|
|
976
976
|
proposal: req.body.proposal,
|
|
977
977
|
rationaleMd: req.body.rationale_md ?? null,
|
|
978
978
|
proposedBy: req.builder.id,
|
|
@@ -1002,7 +1002,7 @@ module.exports = function buildGovernmentRouter() {
|
|
|
1002
1002
|
if (wording && typeof wording.flagInvestmentWording === 'function' && typeof req.body.rationale_md === 'string') {
|
|
1003
1003
|
wording.flagInvestmentWording(req.body.rationale_md, { where: 'board item', ref: out.item && out.item.id });
|
|
1004
1004
|
}
|
|
1005
|
-
res.status(201).json({ ok: true, amendment: out.amendment, item: out.item });
|
|
1005
|
+
res.status(201).json({ ok: true, amendment: out.amendment, item: out.item , decided: out.decided });
|
|
1006
1006
|
} catch (err) {
|
|
1007
1007
|
log.error('[gds] POST /government/board/items', err);
|
|
1008
1008
|
res.fail('propose_failed', { status: 500, message: 'internal error' });
|
|
@@ -1033,7 +1033,7 @@ module.exports = function buildGovernmentRouter() {
|
|
|
1033
1033
|
return res.fail('invalid_proposal', 400, { reason: 'bad_window_minutes', expected: 'null (no clock) or a whole number of minutes' });
|
|
1034
1034
|
}
|
|
1035
1035
|
try {
|
|
1036
|
-
const out = await
|
|
1036
|
+
const out = await require('../sole-member').proposeCharter({
|
|
1037
1037
|
form: body.form ?? undefined,
|
|
1038
1038
|
franchise: body.franchise ?? undefined,
|
|
1039
1039
|
councilRank: body.council_rank ?? undefined,
|
|
@@ -1054,7 +1054,7 @@ module.exports = function buildGovernmentRouter() {
|
|
|
1054
1054
|
});
|
|
1055
1055
|
}
|
|
1056
1056
|
if (out.reason === 'no_change') {
|
|
1057
|
-
return res.fail('no_change', 409, { reason: 'the constitution in force already has these settings' });
|
|
1057
|
+
return res.fail('no_change', 409, { reason: 'the constitution in force already has these settings', in_force: out.in_force, complete: true, message: out.message });
|
|
1058
1058
|
}
|
|
1059
1059
|
return res.fail('invalid_charter', 400, {
|
|
1060
1060
|
reason: out.reason,
|
|
@@ -1063,7 +1063,7 @@ module.exports = function buildGovernmentRouter() {
|
|
|
1063
1063
|
...(out.rank_key ? { rank_key: out.rank_key } : {}),
|
|
1064
1064
|
});
|
|
1065
1065
|
}
|
|
1066
|
-
res.status(201).json({ ok: true, amendment: out.amendment, item: out.item, reads_as: out.reads_as });
|
|
1066
|
+
res.status(201).json({ ok: true, amendment: out.amendment, item: out.item, reads_as: out.reads_as, what_happens_next: out.what_happens_next , decided: out.decided });
|
|
1067
1067
|
} catch (err) {
|
|
1068
1068
|
log.error('[gds] POST /government/board/charter', err);
|
|
1069
1069
|
res.fail('charter_failed', { status: 500, message: 'internal error' });
|
|
@@ -1090,7 +1090,7 @@ module.exports = function buildGovernmentRouter() {
|
|
|
1090
1090
|
rationale_md: { type: 'string', maxLength: 5000 },
|
|
1091
1091
|
})) return;
|
|
1092
1092
|
try {
|
|
1093
|
-
const out = await
|
|
1093
|
+
const out = await require('../sole-member').proposeDecisionRules({
|
|
1094
1094
|
rules: req.body.rules,
|
|
1095
1095
|
rationaleMd: req.body.rationale_md ?? null,
|
|
1096
1096
|
proposedBy: req.builder.id,
|
|
@@ -1105,7 +1105,7 @@ module.exports = function buildGovernmentRouter() {
|
|
|
1105
1105
|
...(out.rank_key ? { rank_key: out.rank_key } : {}),
|
|
1106
1106
|
});
|
|
1107
1107
|
}
|
|
1108
|
-
res.status(201).json({ ok: true, amendment: out.amendment, item: out.item, rules: out.rules });
|
|
1108
|
+
res.status(201).json({ ok: true, amendment: out.amendment, item: out.item, rules: out.rules , decided: out.decided });
|
|
1109
1109
|
} catch (err) {
|
|
1110
1110
|
log.error('[gds] POST /government/board/decision-rules', err);
|
|
1111
1111
|
res.fail('propose_failed', { status: 500, message: 'internal error' });
|
|
@@ -1129,7 +1129,7 @@ module.exports = function buildGovernmentRouter() {
|
|
|
1129
1129
|
rationale_md: { type: 'string', maxLength: 5000 },
|
|
1130
1130
|
})) return;
|
|
1131
1131
|
try {
|
|
1132
|
-
const out = await
|
|
1132
|
+
const out = await require('../sole-member').proposeLaws({
|
|
1133
1133
|
enact: req.body.enact,
|
|
1134
1134
|
repeal: req.body.repeal,
|
|
1135
1135
|
rationaleMd: req.body.rationale_md ?? null,
|
|
@@ -1150,7 +1150,7 @@ module.exports = function buildGovernmentRouter() {
|
|
|
1150
1150
|
...(out.setting ? { setting: out.setting } : {}),
|
|
1151
1151
|
});
|
|
1152
1152
|
}
|
|
1153
|
-
res.status(201).json({ ok: true, amendment: out.amendment, item: out.item, proposal: out.proposal });
|
|
1153
|
+
res.status(201).json({ ok: true, amendment: out.amendment, item: out.item, proposal: out.proposal , decided: out.decided });
|
|
1154
1154
|
} catch (err) {
|
|
1155
1155
|
log.error('[gds] POST /government/board/laws', err);
|
|
1156
1156
|
res.fail('propose_failed', { status: 500, message: 'internal error' });
|
|
@@ -1200,14 +1200,14 @@ module.exports = function buildGovernmentRouter() {
|
|
|
1200
1200
|
if (sw.reason === 'preset_in_force') return res.fail('preset_in_force', 409, { preset: req.body.preset });
|
|
1201
1201
|
return res.fail('invalid_preset', 400, { reason: sw.reason, ...(sw.expected ? { expected: sw.expected } : {}) });
|
|
1202
1202
|
}
|
|
1203
|
-
const out = await
|
|
1203
|
+
const out = await require('../sole-member').proposeLaws({ ...sw.proposal, rationaleMd: sw.rationale, proposedBy: req.builder.id });
|
|
1204
1204
|
if (!out.ok) {
|
|
1205
1205
|
if (out.reason === 'already_in_force' || out.reason === 'setting_already_locked' || out.reason === 'not_in_force') {
|
|
1206
1206
|
return res.fail(out.reason, 409, { ...(out.law_id ? { law_id: out.law_id } : {}), ...(out.setting ? { setting: out.setting } : {}) });
|
|
1207
1207
|
}
|
|
1208
1208
|
return res.fail('invalid_preset', 400, { reason: out.reason });
|
|
1209
1209
|
}
|
|
1210
|
-
res.status(201).json({ ok: true, preset: req.body.preset, amendment: out.amendment, item: out.item, proposal: out.proposal });
|
|
1210
|
+
res.status(201).json({ ok: true, preset: req.body.preset, amendment: out.amendment, item: out.item, proposal: out.proposal , decided: out.decided });
|
|
1211
1211
|
} catch (err) {
|
|
1212
1212
|
log.error('[gds] POST /government/board/presets', err);
|
|
1213
1213
|
res.fail('propose_failed', { status: 500, message: 'internal error' });
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
// modules/government/sole-member.js — a board of ONE decides by filing (task 1005130,
|
|
3
|
+
// part C; owner feedback 2026-11-07, the ruling task 1005121 shipped for closing a
|
|
4
|
+
// genesis stage, applied wherever it holds).
|
|
5
|
+
//
|
|
6
|
+
// WHY. Under monarchy the board is one person, and a sitting it opens exists only to
|
|
7
|
+
// collect that person's yes. Asking them to vote on their own instruction is a second
|
|
8
|
+
// step that decides nothing: "the government is in place after it is defined, but it
|
|
9
|
+
// still asked me to cast a vote". So when the SOLE member files an amendment, their
|
|
10
|
+
// yes is cast in the same call and the sitting closes.
|
|
11
|
+
//
|
|
12
|
+
// EXACTLY THREE CONDITIONS, read on the server from the item's own snapshot — the
|
|
13
|
+
// constitution in force at the moment of filing — never from the request:
|
|
14
|
+
// 1. the pass rule is first_ratifier (one yes decides);
|
|
15
|
+
// 2. the board that snapshot seats has exactly one member (live rows, uncached);
|
|
16
|
+
// 3. the person filing IS that member.
|
|
17
|
+
// Anything else — two or more members, any other rule, a caller who is not the sole
|
|
18
|
+
// member — returns the filing untouched, byte for byte what it was before. Under
|
|
19
|
+
// first_ratifier the first yes closes regardless of a window or early-close setting,
|
|
20
|
+
// so neither changes the outcome here.
|
|
21
|
+
//
|
|
22
|
+
// NOTHING IS SKIPPED. The filing ran its own permission gate, strict validation and
|
|
23
|
+
// self-removal guard before this is reached; the yes goes through board.castVote,
|
|
24
|
+
// which re-checks membership under the snapshot, the window and the clock; the close
|
|
25
|
+
// goes through board.maybeCloseAfterVote → closeItem, so a passed amendment is applied
|
|
26
|
+
// and recorded exactly as a clicked ballot's would be. The vote is that member's,
|
|
27
|
+
// attributed and timestamped, marked cast_via 'filed' (government_039) so the record
|
|
28
|
+
// tells it from a ballot — the stage-close precedent — and it earns no vote karma.
|
|
29
|
+
//
|
|
30
|
+
// FAIL-OPEN. The filing already stands; if the confirm or the close fails, the
|
|
31
|
+
// sitting is simply left open for an ordinary vote, as before this existed.
|
|
32
|
+
//
|
|
33
|
+
// Composed only from board.js's exports, looked up at call time (genesis-close.js's
|
|
34
|
+
// pattern), so a test's stubs on board's db bite exactly as they do for board itself.
|
|
35
|
+
const board = require('./board.js');
|
|
36
|
+
const { resolveBoardMembers } = require('./board-membership.js');
|
|
37
|
+
const log = require('../../src/module-api').logger('government');
|
|
38
|
+
|
|
39
|
+
// confirmIfSoleMember(out, filedBy) → the filing's answer, plus `decided` when the
|
|
40
|
+
// sole member's filing decided it: { closed, outcome, cast_via, constitution_not_applied? }.
|
|
41
|
+
async function confirmIfSoleMember(out, filedBy) {
|
|
42
|
+
if (!out || !out.ok || !out.item || filedBy == null || filedBy === '') return out;
|
|
43
|
+
const item = out.item;
|
|
44
|
+
const constitution = item.constitution || {};
|
|
45
|
+
if (constitution.pass_rule !== 'first_ratifier') return out;
|
|
46
|
+
try {
|
|
47
|
+
const members = await resolveBoardMembers(constitution);
|
|
48
|
+
if (members.length !== 1 || String(members[0].builder_id) !== String(filedBy)) return out;
|
|
49
|
+
const v = await board.castVote({ itemId: item.id, voterId: filedBy, direction: 'yes', castVia: 'filed' });
|
|
50
|
+
if (!v.ok || !v.fresh) return out;
|
|
51
|
+
const c = await board.maybeCloseAfterVote(item.id);
|
|
52
|
+
return {
|
|
53
|
+
...out,
|
|
54
|
+
decided: {
|
|
55
|
+
closed: !!(c && c.closed),
|
|
56
|
+
outcome: (c && c.outcome) || null,
|
|
57
|
+
cast_via: 'filed',
|
|
58
|
+
...(c && c.constitution_not_applied ? { constitution_not_applied: c.constitution_not_applied } : {}),
|
|
59
|
+
},
|
|
60
|
+
};
|
|
61
|
+
} catch (err) {
|
|
62
|
+
log.error('[government] sole-member confirm failed (the filing stands; the sitting is left open):', err);
|
|
63
|
+
return out;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// The amendment filers, each followed by the confirm. Same arguments, same answer,
|
|
68
|
+
// plus `decided` when a board of one decided it.
|
|
69
|
+
const wrap = (name) => async (args = {}) => confirmIfSoleMember(await board[name](args), args.proposedBy);
|
|
70
|
+
const proposeAmendment = wrap('proposeAmendment');
|
|
71
|
+
const proposeCharter = wrap('proposeCharter');
|
|
72
|
+
const proposeLaws = wrap('proposeLaws');
|
|
73
|
+
const proposeDecisionRules = wrap('proposeDecisionRules');
|
|
74
|
+
|
|
75
|
+
module.exports = { proposeAmendment, proposeCharter, proposeLaws, proposeDecisionRules };
|
|
@@ -35,9 +35,11 @@
|
|
|
35
35
|
|
|
36
36
|
// What saving the plan did to the government, when it was not settled in the same
|
|
37
37
|
// click. The server's charter outcome (modules/onboarding/genesis-home.js
|
|
38
|
-
// applyGovernmentChoice)
|
|
38
|
+
// applyGovernmentChoice). The server's own words come first (task 1005130): an
|
|
39
|
+
// already-in-force choice is said plainly, never silently; none needs no sentence.
|
|
39
40
|
function charterNote(c) {
|
|
40
41
|
if (!c) return '';
|
|
42
|
+
if ((c.state === 'in_force' || c.state === 'sitting' || c.state === 'adopted') && c.message) return c.message;
|
|
41
43
|
if (c.state === 'sitting') return 'Your starting government is in the Board Room for a vote.';
|
|
42
44
|
if (c.state === 'needs_council') return 'A Foundation needs a council first. Add a council rank, then choose Foundation on the Charter.';
|
|
43
45
|
if (c.state === 'not_filed') return 'The plan is saved, but the government could not be changed just now. You can change it on the Charter.';
|
|
@@ -133,7 +135,9 @@
|
|
|
133
135
|
|
|
134
136
|
// The starting government: the real academic names, each with its plain sentence
|
|
135
137
|
// (spec D10), the recommended one picked. Read from the government module's own
|
|
136
|
-
// constitution view so the names and explanations are never typed twice.
|
|
138
|
+
// constitution view so the names and explanations are never typed twice. Task
|
|
139
|
+
// 1005130: what is in force is said first and its form is marked, and each form says
|
|
140
|
+
// who decides and what closing something takes, in the server's own words.
|
|
137
141
|
function governmentHtml(gov) {
|
|
138
142
|
if (!gov || !gov.forms || !gov.forms.length) return '';
|
|
139
143
|
var rec = gov.recommended && gov.recommended.key;
|
|
@@ -142,9 +146,12 @@
|
|
|
142
146
|
return '<fieldset class="genesis__pick genesis__pick--gov"><legend>Starting government</legend>'
|
|
143
147
|
+ '<label class="genesis__expected">Builders you expect in the first year '
|
|
144
148
|
+ '<input type="number" min="1" max="100000" value="' + esc(String(gov.headcount || 1)) + '" data-expected></label>'
|
|
149
|
+
+ (gov.in_force && gov.in_force.line ? '<p class="genesis__later">' + esc(gov.in_force.line) + '</p>' : '')
|
|
145
150
|
+ avail.map(function (f) {
|
|
146
|
-
var
|
|
147
|
-
|
|
151
|
+
var duty = f.who_decides ? ' ' + f.who_decides + ' ' + (f.to_close || '') : '';
|
|
152
|
+
var hint = f.explanation + duty + (f.key === rec && gov.recommended.why ? ' Recommended: ' + gov.recommended.why : '');
|
|
153
|
+
var tags = (f.current ? ' · in force now' : '') + (f.key === rec ? ' · recommended' : '');
|
|
154
|
+
return radio('gov', f.key, f.name + tags, hint, f.key === rec);
|
|
148
155
|
}).join('')
|
|
149
156
|
+ (later.length ? '<p class="genesis__later">Coming later: ' + later.map(function (f) { return esc(f.name); }).join(', ') + '.</p>' : '')
|
|
150
157
|
+ '</fieldset>';
|
|
@@ -385,7 +392,7 @@
|
|
|
385
392
|
var q = expected ? '?headcount=' + encodeURIComponent(expected) : '';
|
|
386
393
|
return send('GET', '/government/constitution' + q).then(function (c) {
|
|
387
394
|
var ch = c && c.charter;
|
|
388
|
-
return ch ? { forms: ch.forms || [], recommended: c.recommended || null, headcount: expected || c.headcount || 1 } : null;
|
|
395
|
+
return ch ? { forms: ch.forms || [], in_force: ch.in_force || null, recommended: c.recommended || null, headcount: expected || c.headcount || 1 } : null;
|
|
389
396
|
}).catch(function () { return null; });
|
|
390
397
|
}
|
|
391
398
|
|
|
@@ -159,7 +159,7 @@ const GENESIS_STEPS = Object.freeze({
|
|
|
159
159
|
],
|
|
160
160
|
legalize: [
|
|
161
161
|
{ title: 'Decide how contributors are rewarded and who can be paid',
|
|
162
|
-
description: 'Genesis stage 4 (legalize). Whether builders earn credits, money or nothing, and the limits that apply (age, country, the entity behind the project). When it is done, the Economy wakes.' },
|
|
162
|
+
description: 'Genesis stage 4 (legalize). Whether builders earn credits, money or nothing, and the limits that apply (age, country, the entity behind the project). Run `npx bongos genesis options` first: it lists every reward option with what the server enforces and the facts about this project beside it (`npx bongos genesis facts` shows and changes those facts). Explain those rules to the founder; never write a rule of your own into a document. If a rule seems missing, say so and leave the line to the founder. When it is done, the Economy wakes.' },
|
|
163
163
|
],
|
|
164
164
|
legislation: [
|
|
165
165
|
{ title: 'Set the voting rules: who votes and how many must agree',
|
|
@@ -24,9 +24,14 @@ module.exports = function buildPayTypeGatesRouter() {
|
|
|
24
24
|
// The `lifecycle.payTypeGates` port (task 1004910): core's module-sale payouts ask it
|
|
25
25
|
// whether module_share is cleared (licence, takedown contact, payee onboarding) before
|
|
26
26
|
// a rule, a sale or a share moves. hasProvider-guarded so a rebuilt router never
|
|
27
|
-
// registers it twice.
|
|
27
|
+
// registers it twice. Genesis's legalize stage (task 1005129) reads every gate through
|
|
28
|
+
// it too, met/missing only, so the founder sees what a pay type needs as they choose.
|
|
28
29
|
if (!api.hasProvider('lifecycle.payTypeGates')) {
|
|
29
|
-
api.registerProvider('lifecycle.payTypeGates', {
|
|
30
|
+
api.registerProvider('lifecycle.payTypeGates', {
|
|
31
|
+
refuseUnclearedGates: gates.refuseUnclearedGates,
|
|
32
|
+
readGates: async () => (await gates.readGates()).map(gates.withoutEvidence),
|
|
33
|
+
labels: require('../pay-terms').PAY_TYPE_LABELS,
|
|
34
|
+
});
|
|
30
35
|
}
|
|
31
36
|
|
|
32
37
|
// rank: any-builder for met/missing (the same pay information `GET /pay-terms` shows, no person's
|
|
@@ -395,7 +395,10 @@ async function savePlan(body, builderId, deps = {}) {
|
|
|
395
395
|
// Never refuses the plan — the plan is saved first, and a choice that cannot be filed
|
|
396
396
|
// says why in `state`:
|
|
397
397
|
// none no government was picked
|
|
398
|
-
// in_force the constitution already reads as that form (Monarchy at birth)
|
|
398
|
+
// in_force the constitution already reads as that form (Monarchy at birth):
|
|
399
|
+
// nothing is filed, `message` says so and `complete` is true
|
|
400
|
+
// adopted filed AND decided in the same call: the founder is the board's only
|
|
401
|
+
// member under first_ratifier (task 1005130), so it is in force now
|
|
399
402
|
// sitting filed; `item_id`, and `one_vote_decides` when one yes carries it
|
|
400
403
|
// needs_council Foundation, which needs a council rank before it can seat anyone
|
|
401
404
|
// not_filed anything else (`reason`), or no government module to file it with
|
|
@@ -411,16 +414,25 @@ async function applyGovernmentChoice(form, builderId, s) {
|
|
|
411
414
|
}
|
|
412
415
|
if (out && out.ok) {
|
|
413
416
|
const snap = (out.item && out.item.constitution) || {};
|
|
417
|
+
// task 1005130: the founder is the board's only member, so choosing it WAS their
|
|
418
|
+
// one yes — it is in force now, and saying "in the Board Room" would be untrue.
|
|
419
|
+
if (out.decided && out.decided.closed && out.decided.outcome === 'passed') {
|
|
420
|
+
const name = out.reads_as ? out.reads_as.name : form;
|
|
421
|
+
return { state: 'adopted', form, item_id: out.item ? String(out.item.id) : null, complete: true,
|
|
422
|
+
message: `${name} is in force now. As the board's only member, choosing it was your one yes.` };
|
|
423
|
+
}
|
|
414
424
|
return {
|
|
415
425
|
state: 'sitting',
|
|
416
426
|
form,
|
|
417
427
|
item_id: out.item ? String(out.item.id) : null,
|
|
418
428
|
one_vote_decides: snap.pass_rule === 'first_ratifier',
|
|
419
429
|
reads_as: out.reads_as ? out.reads_as.name : null,
|
|
430
|
+
message: out.what_happens_next || null, // task 1005130: what happens next, in words
|
|
420
431
|
};
|
|
421
432
|
}
|
|
422
433
|
const reason = (out && out.reason) || 'charter_failed';
|
|
423
|
-
|
|
434
|
+
// task 1005130: already in force is said plainly and counts as done, never silent.
|
|
435
|
+
if (reason === 'no_change') return { state: 'in_force', form, complete: true, message: out.message || null };
|
|
424
436
|
if (reason === 'council_needs_a_rank') return { state: 'needs_council', form };
|
|
425
437
|
return { state: 'not_filed', form, reason };
|
|
426
438
|
}
|