@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.
- package/.bongos-core.json +78 -33
- 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 +5 -1
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0353-a-hub-invite-is-a-notice-of-the-projects-own-invite.md +1 -1
- package/docs/adr/0355-a-soft-limit-is-a-rule-with-a-reason-and-nobody-enforces-it.md +30 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +103 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +17 -17
- package/docs/copy-registry.json +18 -18
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-readings.json +61 -59
- package/modules/government/board.js +64 -7
- package/modules/government/charter.js +232 -0
- package/modules/government/routes/government.js +62 -0
- package/modules/hall-ui/public/government.js +11 -0
- package/modules/platform-identity/migrations/platform_identity_028_purge_pre_notice_invites.sql +65 -0
- package/modules/provisioning/planet-physics.js +154 -0
- package/modules/provisioning/provisioning.js +29 -9
- package/modules/provisioning/routes/body-validators.js +25 -7
- package/modules/provisioning/routes/provisioning.js +8 -3
- package/modules/provisioning/screening.js +62 -0
- package/modules/provisioning/soft-limits.js +146 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +14 -0
- package/scripts/gds/run-unit-tests.js +4 -0
- package/src/bongos/route-rank-check.js +3 -0
- package/src/module-api.js +1 -1
- package/tests/government_charter_forms.mjs +283 -0
- package/tests/pending_invite_purge_db.mjs +129 -0
- 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
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
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))}
|
package/modules/platform-identity/migrations/platform_identity_028_purge_pre_notice_invites.sql
ADDED
|
@@ -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;
|