@bongos/core 1.20.26 → 1.20.27

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 +78 -33
  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 +5 -1
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0353-a-hub-invite-is-a-notice-of-the-projects-own-invite.md +1 -1
  8. package/docs/adr/0355-a-soft-limit-is-a-rule-with-a-reason-and-nobody-enforces-it.md +30 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +103 -3
  11. package/docs/api-reference.md +3 -2
  12. package/docs/copy-inventory.md +17 -17
  13. package/docs/copy-registry.json +18 -18
  14. package/docs/module-api-changelog.md +2 -0
  15. package/docs/page-readings.json +61 -59
  16. package/modules/government/board.js +64 -7
  17. package/modules/government/charter.js +232 -0
  18. package/modules/government/routes/government.js +62 -0
  19. package/modules/hall-ui/public/government.js +11 -0
  20. package/modules/platform-identity/migrations/platform_identity_028_purge_pre_notice_invites.sql +65 -0
  21. package/modules/provisioning/planet-physics.js +154 -0
  22. package/modules/provisioning/provisioning.js +29 -9
  23. package/modules/provisioning/routes/body-validators.js +25 -7
  24. package/modules/provisioning/routes/provisioning.js +8 -3
  25. package/modules/provisioning/screening.js +62 -0
  26. package/modules/provisioning/soft-limits.js +146 -0
  27. package/package-lock.json +2 -2
  28. package/package.json +1 -1
  29. package/release-notes.json +14 -0
  30. package/scripts/gds/run-unit-tests.js +4 -0
  31. package/src/bongos/route-rank-check.js +3 -0
  32. package/src/module-api.js +1 -1
  33. package/tests/government_charter_forms.mjs +283 -0
  34. package/tests/pending_invite_purge_db.mjs +129 -0
  35. package/tests/provisioning_planet_physics.mjs +377 -0
@@ -57,6 +57,9 @@ const {
57
57
  // describeMembership renders a predicate in the words the proposer was thinking
58
58
  // in ("metic and above"), for the disenfranchisement refusal (task 1003094).
59
59
  const { describeMembership } = require('./membership-predicate');
60
+ // The genesis charter + legislation vocabulary (task 1004427): the real form
61
+ // and variable names, and the name a constitution reads as.
62
+ const charter = require('./charter');
60
63
 
61
64
  // ADR 0175 §5: the completeness bar a Full Idea must clear to reach the board.
62
65
  // Held equal to economy's IDEA_FULL_PASS_BAR by test — see the header.
@@ -812,6 +815,56 @@ async function proposeAmendment({
812
815
  return { ok: true, amendment, item };
813
816
  }
814
817
 
818
+ // ── the genesis charter + legislation choice (task 1004427, spec D10) ───────
819
+ //
820
+ // A founder picks a form of government (the charter stage) and fine-tunes its
821
+ // settings (the legislation stage). Both land HERE and nowhere else: the choice
822
+ // is built into a whole constitution on top of the live one (charter.js) and
823
+ // filed as an ordinary AMENDMENT — validated strictly, decided under the
824
+ // constitution in force, and applied by the close like any amendment. Under
825
+ // the monarchy default the founder's one yes applies it; under any other form
826
+ // it is a real sitting. There is no second write path to the constitution, and
827
+ // the disenfranchisement guard applies exactly as it does to a hand-written
828
+ // proposal (a founder choosing a council they do not sit on is asked to
829
+ // acknowledge it).
830
+ //
831
+ // Returns proposeAmendment's shape plus `reads_as` (the form the PROPOSED
832
+ // constitution will read as, or Custom), or a named refusal:
833
+ // charter.buildCharterProposal's reasons | 'unknown_council_rank' | 'no_change'
834
+ // | any proposeAmendment refusal.
835
+ async function proposeCharter({
836
+ form, franchise, councilRank, passRule, windowMinutes,
837
+ rationaleMd = null, proposedBy, acknowledgeSelfRemoval = false,
838
+ } = {}) {
839
+ const { board: live } = loadGovernmentConfig();
840
+ const built = charter.buildCharterProposal(live, { form, franchise, councilRank, passRule, windowMinutes });
841
+ if (!built.ok) return built;
842
+ const v = validateProposedConstitution(built.proposal);
843
+ if (!v.ok) return { ok: false, ...v };
844
+ if (proposedBy == null || proposedBy === '') return { ok: false, reason: 'missing_proposer' };
845
+ // A council is a custom rank, which is a DB row this module's pure half
846
+ // cannot see. A council that does not exist would seat nobody — fail-closed,
847
+ // but a sitting nobody can ever vote on — so it is refused before filing.
848
+ if (charter.readCharter(v.proposal).franchise === 'council') {
849
+ const key = v.proposal.membership.slice('rank:'.length);
850
+ if (!(await db.getRankByKey(key))) return { ok: false, reason: 'unknown_council_rank', rank_key: key };
851
+ }
852
+ const unchanged = ['membership', 'pass_rule', 'window_minutes', 'close_early_on_full_turnout']
853
+ .every((k) => v.proposal[k] === live[k]);
854
+ if (unchanged) return { ok: false, reason: 'no_change' };
855
+
856
+ const readsAs = charter.readCharter(v.proposal);
857
+ const out = await proposeAmendment({
858
+ proposal: v.proposal,
859
+ // The record says in words what was chosen, so the amendment history reads
860
+ // "Charter: Direct Democracy" rather than a bare JSON block.
861
+ rationaleMd: rationaleMd || `Charter: ${readsAs.name}. ${readsAs.explanation}`,
862
+ proposedBy,
863
+ acknowledgeSelfRemoval,
864
+ });
865
+ return out.ok ? { ...out, reads_as: readsAs } : out;
866
+ }
867
+
815
868
  // ── the board's third subject: closing a genesis stage (task 1004429) ───────
816
869
  //
817
870
  // Spec decision D7 (docs/specs/bongos-v2-project-startup.md): a new project is
@@ -860,13 +913,12 @@ const stageByNumber = (n) => GENESIS_STAGES.find((s) => s.number === Number(n))
860
913
  // The pass rules, said in words for the founding band. Keyed by exactly
861
914
  // config.js PASS_RULES (held equal by tests/government_board_genesis_stage.mjs,
862
915
  // so a fifth rule cannot land without its words); an unknown rule in an old
863
- // snapshot says so rather than guessing.
864
- const PASS_RULE_WORDS = Object.freeze({
865
- first_ratifier: 'One yes from any member decides it.',
866
- consent: 'It passes unless a member objects, once everyone has voted or the clock runs out.',
867
- majority: 'More than half of all members must say yes.',
868
- unanimous: 'Everyone who votes must agree, and at least one member must say yes.',
869
- });
916
+ // snapshot says so rather than guessing. The sentences are written ONCE, as the
917
+ // pass-rule explanations in charter.js (task 1004427), so the legislation stage
918
+ // and the founding band can never describe one rule two ways.
919
+ const PASS_RULE_WORDS = Object.freeze(Object.fromEntries(
920
+ charter.PASS_RULE_OPTIONS.map((o) => [o.key, o.explanation]),
921
+ ));
870
922
 
871
923
  // Put a finished stage to the board. Returns, never throws for a refusal:
872
924
  // { opened: true, item, stage }
@@ -1199,6 +1251,10 @@ async function constitutionView({ historyLimit = 50 } = {}) {
1199
1251
  // last passed one is not a shape this system has, and the honest failure
1200
1252
  // there is a missed warning rather than a wrong one.
1201
1253
  divergence: constitutionDivergence(live, history),
1254
+ // The genesis charter + legislation stages' payload (task 1004427): every
1255
+ // form and variable by its real name with its plain explanation, and the
1256
+ // form the constitution in force reads as (or Custom). Pure — no query.
1257
+ charter: charter.charterView(live),
1202
1258
  // THE CLOCK ADVICE THE AMENDMENT FORM NEEDS (task 1003733, ADR 0267 §3).
1203
1259
  // A proposer choosing a pass rule has to choose a window in the same breath
1204
1260
  // — a constitution is ratified whole, never by diff — and the two choices
@@ -1338,6 +1394,7 @@ async function pendingVotesFor(builderId) {
1338
1394
  module.exports = {
1339
1395
  GENESIS_STAGES,
1340
1396
  PASS_RULE_WORDS,
1397
+ proposeCharter,
1341
1398
  openGenesisStageClose,
1342
1399
  genesisStageStatus,
1343
1400
  pendingVotesFor,
@@ -0,0 +1,232 @@
1
+ 'use strict';
2
+
3
+ // modules/government/charter.js — the genesis charter and legislation stages'
4
+ // vocabulary, on the REAL constitution (task 1004427 / BV2.PS13, spec decision
5
+ // D10 in docs/specs/bongos-v2-project-startup.md).
6
+ //
7
+ // REAL NAMES, EACH EXPLAINED PLAINLY. Real-world governments are the model for
8
+ // a project's structure, so every form and every setting is called what it
9
+ // actually is — Monarchy / BDFL, Timocratic Republic, franchise, judicial
10
+ // review — and carries a one- or two-sentence explanation of how it works in a
11
+ // project. The explanation is simpler than the academic definition and never a
12
+ // replacement for the name.
13
+ //
14
+ // ONLY WHAT IS BUILT CLAIMS TO BE. Two variables are the live board block:
15
+ // franchise (`membership`) and pass rule (`pass_rule`). Loyalty of enforcement
16
+ // is always on — authority is resolved from the rank you hold, per request and
17
+ // uncached (ADR 0016), so nobody keeps a rank's powers after leaving it. The
18
+ // other four are shown as coming later, and so are the three forms that need
19
+ // machinery this system does not have (elections, member shares, federated
20
+ // chambers).
21
+ //
22
+ // THE NAME FOLLOWS THE SETTINGS. `readCharter` reads what a constitution SEATS
23
+ // (through the real membership parser) and its pass rule, and names the form
24
+ // those make — or "Custom" when they make none. It reads the seated set, not
25
+ // the spelling, so `rank:metic+` and `rank:metic,archon` read the same.
26
+ //
27
+ // APPLYING IS AN AMENDMENT. `buildCharterProposal` only BUILDS a whole
28
+ // constitution from a choice; board.js `proposeCharter` files it through the
29
+ // ordinary amendment path, so the choice is validated strictly and decided
30
+ // under the constitution in force (under monarchy, the founder's one yes
31
+ // applies it). There is no second write path to the constitution.
32
+ //
33
+ // Pure and DB-free: it requires only the catalog's ladder and the membership
34
+ // grammar, so a test (or a boot-time caller) can use it without a database.
35
+ //
36
+ // The form names are held equal to the hall's charter library
37
+ // (modules/hall-ui/public/government.js CHARTERS) by
38
+ // tests/government_charter_forms.mjs — browser code cannot require this file,
39
+ // and one form must never go by two names.
40
+
41
+ const { RANK_ORDER } = require('./catalog');
42
+ const { parseMembershipPredicate } = require('./membership-predicate');
43
+
44
+ const freezeAll = (list) => Object.freeze(list.map((x) => Object.freeze({ ...x })));
45
+
46
+ // ── franchise: who gets a vote ───────────────────────────────────────────────
47
+ // `predicate` is the exact membership string written to the constitution, and
48
+ // `seats` is what it must resolve to. `council` names a custom rank the owner
49
+ // chooses, so its predicate is built from that choice.
50
+ const FRANCHISE_OPTIONS = freezeAll([
51
+ { key: 'founder', name: 'The founder', predicate: 'rank:archon', seats: ['archon'],
52
+ explanation: 'Only the founder votes (the Archon rank). Right for a solo start, when there is nobody else to ask.' },
53
+ { key: 'trusted', name: 'Trusted builders and up', predicate: 'rank:metic+', seats: ['metic', 'archon'],
54
+ explanation: 'Builders who have earned the trusted rank by shipping get a vote, along with the founder.' },
55
+ { key: 'everyone', name: 'Every builder', predicate: 'rank:xenos+', seats: [...RANK_ORDER],
56
+ explanation: 'Everyone on the project gets a vote, from the newest builder to the founder.' },
57
+ { key: 'council', name: 'A council you name', predicate: null, seats: null,
58
+ explanation: 'Only the members of a council rank you create get a vote. You choose who sits on it.' },
59
+ ]);
60
+
61
+ // ── pass rule (winning coalition): how many must agree ───────────────────────
62
+ // Exactly the four rules config.js PASS_RULES knows (held equal by test).
63
+ const PASS_RULE_OPTIONS = freezeAll([
64
+ { key: 'first_ratifier', name: 'One yes is enough',
65
+ explanation: 'One yes from any member decides it.' },
66
+ { key: 'consent', name: 'Passes unless someone objects',
67
+ explanation: 'It passes unless a member objects, once everyone has voted or the clock runs out.' },
68
+ { key: 'majority', name: 'More than half',
69
+ explanation: 'More than half of all members must say yes.' },
70
+ { key: 'unanimous', name: 'Everyone who votes agrees',
71
+ explanation: 'Everyone who votes must agree, and at least one member must say yes.' },
72
+ ]);
73
+
74
+ // ── trajectory: how the government changes as the project grows ─────────────
75
+ const TRAJECTORIES = freezeAll([
76
+ { key: 'locked', name: 'Locked',
77
+ explanation: 'The government stays as founded until the project votes to change it.' },
78
+ { key: 'laddered', name: 'Laddered',
79
+ explanation: 'The government steps up automatically as the team crosses each size threshold.' },
80
+ { key: 'scheduled', name: 'Scheduled',
81
+ explanation: 'The government changes on dates the project commits to in advance.' },
82
+ ]);
83
+
84
+ // ── the government doc's seven variables ─────────────────────────────────────
85
+ // status: 'live' (a real setting of the constitution today) | 'always_on' |
86
+ // 'later' (shown, explained, and not yet settable).
87
+ const VARIABLES = freezeAll([
88
+ { key: 'franchise', name: 'Franchise', question: 'Who gets a vote?', status: 'live',
89
+ explanation: 'Who sits on the board and votes on the project\'s decisions.' },
90
+ { key: 'pass_rule', name: 'Pass rule (winning coalition)', question: 'How many must agree?', status: 'live',
91
+ explanation: 'How much agreement a decision needs before it passes.' },
92
+ { key: 'veto_points', name: 'Veto points', question: 'How many checkpoints must a big change pass?', status: 'later',
93
+ explanation: 'How many separate approvals a big change must collect before it happens.' },
94
+ { key: 'executive_legislative', name: 'Executive–legislative relationship',
95
+ question: 'Does the leader serve at the team\'s confidence?', status: 'later',
96
+ explanation: 'Whether the team can replace its leader by vote, or the leader holds office for a set term.' },
97
+ { key: 'judicial_review', name: 'Judicial review', question: 'Who can void a decision that breaks the charter?', status: 'later',
98
+ explanation: 'Who may cancel a decision that goes against the project\'s charter.' },
99
+ { key: 'loyalty_of_enforcement', name: 'Loyalty of enforcement',
100
+ question: 'Does power belong to the office or the person?', status: 'always_on',
101
+ explanation: 'Whoever holds a rank holds its powers, and nobody keeps them after leaving it.' },
102
+ { key: 'trajectory', name: 'Trajectory', question: 'How does the government change as you grow?', status: 'later',
103
+ explanation: 'Whether the government stays put, grows with the team, or changes on a schedule.' },
104
+ ]);
105
+
106
+ // ── the forms ────────────────────────────────────────────────────────────────
107
+ // An available form is a franchise + a pass rule; choosing it sets exactly
108
+ // those two. `key` matches the hall charter library's key.
109
+ const FORMS = freezeAll([
110
+ { key: 'monarchy', name: 'Monarchy / BDFL', available: true, franchise: 'founder', pass_rule: 'first_ratifier',
111
+ explanation: 'One leader decides. In a project the founder makes the calls, which is fast, clear and honest for a solo start.' },
112
+ { key: 'timocratic-republic', name: 'Timocratic Republic', available: true, franchise: 'trusted', pass_rule: 'consent',
113
+ explanation: 'A say is earned, not given. Builders who have reached the trusted rank decide together.' },
114
+ { key: 'direct-democracy', name: 'Direct Democracy', available: true, franchise: 'everyone', pass_rule: 'consent',
115
+ explanation: 'Every builder votes directly. A decision passes unless someone objects with a reason.' },
116
+ { key: 'foundation', name: 'Foundation', available: true, franchise: 'council', pass_rule: 'consent',
117
+ explanation: 'A written charter looked after by a small council of trustees you appoint.' },
118
+ { key: 'presidential', name: 'Presidential', available: false,
119
+ explanation: 'The team elects a leader for a fixed term. Coming later, because it needs elections.' },
120
+ { key: 'cooperative', name: 'Cooperative', available: false,
121
+ explanation: 'Every member holds a share and gets one vote. Coming later, because it needs member shares.' },
122
+ { key: 'federation', name: 'Federation', available: false,
123
+ explanation: 'Self-governing teams coordinate under one shared pact. Coming later, for projects with many teams.' },
124
+ ]);
125
+
126
+ const CUSTOM_EXPLANATION = 'These settings match no named form of government. They are still the real rules the board runs under.';
127
+
128
+ const byKey = (list, key) => list.find((x) => x.key === key) || null;
129
+ const sameSet = (a, b) => a.length === b.length && a.every((k) => b.includes(k));
130
+
131
+ // Is this a single CUSTOM rank (a council)? A custom rank is a DB row this file
132
+ // cannot see, so "not on the standard ladder" is the whole test here; whether
133
+ // it exists is board.js's check against the live rows.
134
+ const isCustomRankKey = (key) => typeof key === 'string' && !RANK_ORDER.includes(key);
135
+
136
+ // Which franchise option does a membership predicate SEAT? Read through the
137
+ // real parser, so an equivalent spelling reads the same and an unparseable one
138
+ // reads as custom rather than as a guess.
139
+ function franchiseOf(membership) {
140
+ const parsed = parseMembershipPredicate(membership);
141
+ if (!parsed) return 'custom';
142
+ const seats = [...parsed.rankKeys];
143
+ for (const o of FRANCHISE_OPTIONS) if (o.seats && sameSet(o.seats, seats)) return o.key;
144
+ if (seats.length === 1 && isCustomRankKey(seats[0])) return 'council';
145
+ return 'custom';
146
+ }
147
+
148
+ // The name follows the settings: which form does this board block make?
149
+ function readCharter(board) {
150
+ const b = board || {};
151
+ const franchise = franchiseOf(b.membership);
152
+ const passRule = byKey(PASS_RULE_OPTIONS, b.pass_rule) ? b.pass_rule : 'custom';
153
+ const form = FORMS.find((f) => f.available && f.franchise === franchise && f.pass_rule === passRule);
154
+ 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 });
157
+ }
158
+
159
+ // Build the WHOLE constitution a charter choice makes, on top of the live one.
160
+ // A choice is a form, a franchise, a pass rule, a window, or a form fine-tuned by
161
+ // the others (the legislation stage tunes what the charter stage picked). The
162
+ // fields a charter does not speak to — the clock and the full-turnout early
163
+ // close — are KEPT from the live constitution unless a window is named, because
164
+ // a constitution is ratified whole and the choice must not quietly change them.
165
+ // Returns { ok: true, proposal } or { ok: false, reason }; the caller validates
166
+ // the result strictly and files it as an amendment.
167
+ function buildCharterProposal(liveBoard, { form, franchise, councilRank, passRule, windowMinutes } = {}) {
168
+ const live = liveBoard || {};
169
+ let franchiseKey = franchise;
170
+ let rule = passRule;
171
+ if (form !== undefined && form !== null) {
172
+ const f = byKey(FORMS, form);
173
+ if (!f) return { ok: false, reason: 'unknown_form' };
174
+ if (!f.available) return { ok: false, reason: 'form_not_available' };
175
+ if (franchiseKey == null) franchiseKey = f.franchise;
176
+ if (rule == null) rule = f.pass_rule;
177
+ }
178
+ if (franchiseKey == null && rule == null && windowMinutes === undefined) return { ok: false, reason: 'nothing_chosen' };
179
+
180
+ let membership = live.membership;
181
+ if (franchiseKey != null) {
182
+ const option = byKey(FRANCHISE_OPTIONS, franchiseKey);
183
+ if (!option) return { ok: false, reason: 'unknown_franchise' };
184
+ if (option.key === 'council') {
185
+ if (councilRank == null || councilRank === '') return { ok: false, reason: 'council_needs_a_rank' };
186
+ const predicate = `rank:${councilRank}`;
187
+ const parsed = parseMembershipPredicate(predicate);
188
+ if (!parsed || parsed.rankKeys.length !== 1 || !isCustomRankKey(parsed.rankKeys[0])) {
189
+ return { ok: false, reason: 'council_needs_a_custom_rank' };
190
+ }
191
+ membership = predicate;
192
+ } else {
193
+ membership = option.predicate;
194
+ }
195
+ }
196
+ if (rule != null && !byKey(PASS_RULE_OPTIONS, rule)) return { ok: false, reason: 'unknown_pass_rule' };
197
+
198
+ return {
199
+ ok: true,
200
+ proposal: {
201
+ membership,
202
+ pass_rule: rule != null ? rule : live.pass_rule,
203
+ window_minutes: windowMinutes !== undefined ? windowMinutes : (live.window_minutes ?? null),
204
+ close_early_on_full_turnout: live.close_early_on_full_turnout,
205
+ },
206
+ };
207
+ }
208
+
209
+ // The whole vocabulary plus what the constitution in force reads as — the
210
+ // payload the genesis charter and legislation stages render from.
211
+ function charterView(liveBoard) {
212
+ return {
213
+ forms: FORMS,
214
+ variables: VARIABLES.map((v) => (
215
+ v.key === 'franchise' ? { ...v, options: FRANCHISE_OPTIONS }
216
+ : v.key === 'pass_rule' ? { ...v, options: PASS_RULE_OPTIONS }
217
+ : v.key === 'trajectory' ? { ...v, options: TRAJECTORIES }
218
+ : v)),
219
+ reads_as: readCharter(liveBoard),
220
+ };
221
+ }
222
+
223
+ module.exports = {
224
+ FORMS,
225
+ VARIABLES,
226
+ FRANCHISE_OPTIONS,
227
+ PASS_RULE_OPTIONS,
228
+ TRAJECTORIES,
229
+ readCharter,
230
+ buildCharterProposal,
231
+ charterView,
232
+ };
@@ -114,6 +114,11 @@ const governmentPort = {
114
114
  // the constitution in force. And the founding band's read: per stage, what it
115
115
  // is waiting on. A pass is announced as `board.genesis_stage.passed`.
116
116
  openGenesisStageClose: board.openGenesisStageClose,
117
+ // task 1004427 (BV2.PS13): the genesis charter + legislation choice, filed as
118
+ // an ordinary amendment under the constitution in force — for the genesis
119
+ // home, which renders the stages from GET /government/constitution's
120
+ // `charter` block. Opening a sitting decides nothing; the vote does.
121
+ proposeCharter: board.proposeCharter,
117
122
  genesisStageStatus: board.genesisStageStatus,
118
123
  };
119
124
 
@@ -536,6 +541,63 @@ module.exports = function buildGovernmentRouter() {
536
541
  }
537
542
  });
538
543
 
544
+ // rank: perm board.item.open — the genesis charter + legislation choice (task
545
+ // 1004427, spec D10): a form of government and/or its two live settings
546
+ // (franchise, pass rule), built into a whole constitution on top of the live
547
+ // one and filed through the SAME amendment path as the route above — the same
548
+ // atom, the same strict validation, the same self-removal guard. Nothing here
549
+ // changes the constitution; the sitting it opens is what decides.
550
+ router.post('/government/board/charter', api.requireBuilder, api.requirePermission('board.item.open'), async (req, res) => {
551
+ if (validateOrRespond(req, res, {
552
+ form: { type: 'string', maxLength: 64 },
553
+ franchise: { type: 'string', maxLength: 32 },
554
+ council_rank: { type: 'string', maxLength: RANK_KEY_MAX },
555
+ pass_rule: { type: 'string', maxLength: 32 },
556
+ rationale_md: { type: 'string', maxLength: 5000 },
557
+ acknowledge_self_removal: { type: 'boolean' },
558
+ })) return;
559
+ const body = req.body || {};
560
+ if (body.window_minutes !== undefined && body.window_minutes !== null && !Number.isInteger(body.window_minutes)) {
561
+ return res.fail('invalid_proposal', 400, { reason: 'bad_window_minutes', expected: 'null (no clock) or a whole number of minutes' });
562
+ }
563
+ try {
564
+ const out = await board.proposeCharter({
565
+ form: body.form ?? undefined,
566
+ franchise: body.franchise ?? undefined,
567
+ councilRank: body.council_rank ?? undefined,
568
+ passRule: body.pass_rule ?? undefined,
569
+ windowMinutes: body.window_minutes,
570
+ rationaleMd: body.rationale_md ?? null,
571
+ proposedBy: req.builder.id,
572
+ acknowledgeSelfRemoval: body.acknowledge_self_removal === true,
573
+ });
574
+ if (!out.ok) {
575
+ if (out.reason === 'proposer_would_lose_their_seat') {
576
+ return res.fail('proposer_would_lose_their_seat', 409, {
577
+ membership: out.membership,
578
+ describes: out.describes,
579
+ what_this_means: out.what_this_means,
580
+ hint: out.hint,
581
+ retry_with: { acknowledge_self_removal: true },
582
+ });
583
+ }
584
+ if (out.reason === 'no_change') {
585
+ return res.fail('no_change', 409, { reason: 'the constitution in force already has these settings' });
586
+ }
587
+ return res.fail('invalid_charter', 400, {
588
+ reason: out.reason,
589
+ ...(out.field ? { field: out.field } : {}),
590
+ ...(out.expected ? { expected: out.expected } : {}),
591
+ ...(out.rank_key ? { rank_key: out.rank_key } : {}),
592
+ });
593
+ }
594
+ res.status(201).json({ ok: true, amendment: out.amendment, item: out.item, reads_as: out.reads_as });
595
+ } catch (err) {
596
+ log.error('[gds] POST /government/board/charter', err);
597
+ res.fail('charter_failed', { status: 500, message: 'internal error' });
598
+ }
599
+ });
600
+
539
601
  // rank: perm board.vote.cast — cast a vote on an open board item (R08 of goal
540
602
  // 1000069, ADR 0175). The atom FLOORS AT XENOS deliberately (the R04 carry-
541
603
  // note): the permission is only the coarse gate, and the real wall is the
@@ -678,6 +678,16 @@
678
678
  // hand" note; PASS_RULE_NAME sits beside its sentence-length twin
679
679
  // PASS_RULE_PLAIN there, so the two renderings of one pass rule cannot drift.
680
680
 
681
+ // The form of government the constitution in force READS AS (task 1004427):
682
+ // the real name and its plain explanation, or Custom. The server does the
683
+ // reading (charter.js readCharter) — the name follows the settings, so this
684
+ // page never guesses it. An older server without the block is said plainly.
685
+ function formOfGovernmentSentence(charter) {
686
+ const r = charter && charter.reads_as;
687
+ if (!r || !r.name) return 'Not available from this server yet.';
688
+ return `<strong>${escapeHtml(r.name)}</strong>. ${escapeHtml(r.explanation || '')}`;
689
+ }
690
+
681
691
  function dialRow(name, sentenceHtml) {
682
692
  return `<li class="ov-row"><div class="ov-row__main"><span class="ov-row__title">${escapeHtml(name)}</span><p class="ov-row__sub">${sentenceHtml}</p></div></li>`;
683
693
  }
@@ -829,6 +839,7 @@
829
839
  live.innerHTML = `
830
840
  ${divergenceHtml(data.divergence)}
831
841
  <ul class="ov-rows">
842
+ ${dialRow('Form of government', formOfGovernmentSentence(data.charter))}
832
843
  ${dialRow('Who sits on the board', membershipSentence(b))}
833
844
  ${dialRow('What counts as passing', passRuleSentence(b))}
834
845
  ${dialRow('How long a sitting runs', windowSentence(b, data.clock))}
@@ -0,0 +1,65 @@
1
+ -- platform_identity_028_purge_pre_notice_invites.sql — delete the `pending` rows the
2
+ -- OLD hub invite wrote (task 1004408, the owner's call on ADR 0353's open consequence).
3
+ --
4
+ -- WHY. Before ADR 0353 the hub's POST /projects/invite wrote a `pending` membership
5
+ -- row and nothing on the project. My Projects renders that row as "Open project →",
6
+ -- and on any door but `open` (the default is `apply`) it leads to the project's own
7
+ -- gate, which never heard of the invite: a locked door. Since ADR 0353 a `pending`
8
+ -- row is a NOTICE that only the project writes (POST /sso/invites/notify), after it
9
+ -- wrote its own `invited` row. So a pending row is a dead end exactly when it
10
+ -- predates the hub's first run of ADR 0353 code.
11
+ --
12
+ -- THE CUTOFF IS THAT FIRST RUN, read from the core_upgrades ledger: the earliest
13
+ -- upgrade to core 1.20.22 or later (the first release carrying ADR 0353, PR 1367).
14
+ -- No such row means the hub has never served a notice route, so every pending row
15
+ -- is an old one and all of them go. That is the expected case: the hub served
16
+ -- 1.19.1081 when this was written, and upgrade.js writes the ledger row AFTER
17
+ -- migrate, so the upgrade applying this file is not yet in the ledger. The ledger
18
+ -- row lands just after the restart, so a notice written in those few seconds would
19
+ -- also go; an old-style row cannot survive either way.
20
+ --
21
+ -- WHAT IS LOST. The owner accepted this (option (a)): an old row on an `open` door
22
+ -- did lead somewhere, and it is deleted too. That invitee can still sign in to the
23
+ -- project, and the inviter can re-invite through the project's own hall.
24
+ --
25
+ -- Touches `pending` rows only, so no real membership (owner, member, visitor) can
26
+ -- be removed. One-shot: a replay after this file is recorded does nothing, so a
27
+ -- hand-run later can never sweep up real notices. Deletes data but adds nothing
28
+ -- the previous release cannot read, so it is forward-safe (ADR 0083 §5).
29
+
30
+ BEGIN;
31
+
32
+ DO $$
33
+ DECLARE
34
+ notices_since timestamptz;
35
+ removed integer;
36
+ BEGIN
37
+ IF EXISTS (SELECT 1 FROM schema_migrations
38
+ WHERE version = 'platform_identity_028_purge_pre_notice_invites') THEN
39
+ RETURN;
40
+ END IF;
41
+
42
+ -- Only a well-formed x.y.z counts (any suffix ignored); the digit caps keep the
43
+ -- int cast from overflowing, so a malformed ledger row can never fail the deploy.
44
+ IF to_regclass('core_upgrades') IS NOT NULL THEN
45
+ SELECT min(applied_at) INTO notices_since
46
+ FROM (SELECT applied_at,
47
+ substring(to_version FROM '^v?([0-9]{1,6}\.[0-9]{1,6}\.[0-9]{1,6})(?:[^0-9]|$)') AS xyz
48
+ FROM core_upgrades) u
49
+ WHERE xyz IS NOT NULL
50
+ AND string_to_array(xyz, '.')::int[] >= ARRAY[1, 20, 22];
51
+ END IF;
52
+
53
+ DELETE FROM platform_identity_project_memberships
54
+ WHERE membership_kind = 'pending'
55
+ AND (notices_since IS NULL OR joined_at < notices_since);
56
+ GET DIAGNOSTICS removed = ROW_COUNT;
57
+
58
+ RAISE NOTICE 'platform_identity_028: removed % pre-ADR-0353 pending invite row(s) (cutoff: %)',
59
+ removed, COALESCE(notices_since::text, 'none, the hub never ran ADR 0353');
60
+ END $$;
61
+
62
+ INSERT INTO schema_migrations (version) VALUES ('platform_identity_028_purge_pre_notice_invites')
63
+ ON CONFLICT DO NOTHING;
64
+
65
+ COMMIT;