@bongos/core 1.20.25 → 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 (41) hide show
  1. package/.bongos-core.json +92 -37
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +7 -1
  6. package/clients/bongos-client/index.mjs +4 -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 +137 -3
  11. package/docs/api-reference.md +4 -2
  12. package/docs/copy-inventory.md +34 -34
  13. package/docs/copy-registry.json +38 -38
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/page-readings.json +75 -73
  16. package/modules/discord/board-broadcast.js +1 -0
  17. package/modules/government/board.js +261 -2
  18. package/modules/government/charter.js +232 -0
  19. package/modules/government/db.js +42 -1
  20. package/modules/government/migrations/government_020_genesis_stage_subject.sql +55 -0
  21. package/modules/government/routes/government.js +82 -0
  22. package/modules/hall-ui/public/board-room.js +5 -0
  23. package/modules/hall-ui/public/government.js +11 -0
  24. package/modules/platform-identity/migrations/platform_identity_028_purge_pre_notice_invites.sql +65 -0
  25. package/modules/provisioning/planet-physics.js +154 -0
  26. package/modules/provisioning/provisioning.js +29 -9
  27. package/modules/provisioning/routes/body-validators.js +25 -7
  28. package/modules/provisioning/routes/provisioning.js +8 -3
  29. package/modules/provisioning/screening.js +62 -0
  30. package/modules/provisioning/soft-limits.js +146 -0
  31. package/package-lock.json +2 -2
  32. package/package.json +1 -1
  33. package/release-notes.json +20 -0
  34. package/scripts/gds/run-unit-tests.js +4 -0
  35. package/src/bongos/route-rank-check.js +6 -0
  36. package/src/module-api.js +1 -1
  37. package/tests/government_board_genesis_stage.mjs +305 -0
  38. package/tests/government_charter_forms.mjs +283 -0
  39. package/tests/government_seed.mjs +1 -0
  40. package/tests/pending_invite_purge_db.mjs +129 -0
  41. 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.
@@ -627,7 +630,12 @@ async function closeItem(item, { cause = 'vote' } = {}) {
627
630
  });
628
631
  if (!closed) return { closed: false, reason: 'already_closed' };
629
632
 
630
- const authorKarma = outcome === 'passed' ? await awardAuthorRatifyKarma(closed) : null;
633
+ // A genesis stage ratifies no authored work — closing it is the project's
634
+ // decision that a stage is done, not a reward for the founder who put it up
635
+ // (task 1004429). Ideas and amendments pay exactly as before.
636
+ const authorKarma = outcome === 'passed' && closed.subject_type !== 'genesis_stage'
637
+ ? await awardAuthorRatifyKarma(closed)
638
+ : null;
631
639
 
632
640
  // R14: a PASSED amendment becomes the live constitution — applied AFTER the
633
641
  // close commits (the board's decision is the durable record), loudly on
@@ -652,6 +660,7 @@ async function closeItem(item, { cause = 'vote' } = {}) {
652
660
  try {
653
661
  if (api.emitAsync) await api.emitAsync('board.item.passed', payload);
654
662
  } catch (_) { /* emitAsync isolates listener errors; nothing that moves credits subscribes */ }
663
+ if (closed.subject_type === 'genesis_stage') await emitGenesisStagePassed(closed);
655
664
  }
656
665
 
657
666
  // R22's mirror event: BOTH outcomes, after commit, emitAsync. Separate from
@@ -806,6 +815,237 @@ async function proposeAmendment({
806
815
  return { ok: true, amendment, item };
807
816
  }
808
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
+
868
+ // ── the board's third subject: closing a genesis stage (task 1004429) ───────
869
+ //
870
+ // Spec decision D7 (docs/specs/bongos-v2-project-startup.md): a new project is
871
+ // founded through five stages, and when a stage's tasks are all finished its
872
+ // close is put to the constitution IN FORCE. It is an ordinary board item, and
873
+ // that is the whole design — the stage borrows every rule the board already
874
+ // has rather than bringing one of its own:
875
+ //
876
+ // • THE CONSTITUTION IS SNAPSHOTTED, like every item, so a charter adopted
877
+ // mid-sitting does not change how an already-open stage closes.
878
+ // • NO NEW PASS RULE, NO GENESIS BYPASS. Under the monarchy default
879
+ // (first_ratifier) the founder's single confirm closes the stage because
880
+ // the founder is the first ratifier — not because anything special-cases
881
+ // genesis. Under consent, majority or unanimous it is a real sitting, with
882
+ // the clock and turnout rules unchanged.
883
+ // • THE AUTHOR RULE IS UNCHANGED — which means it does not reach a stage. It
884
+ // is full_idea-scoped (authorRuleApplies, ADR 0191): it guards an author
885
+ // who GAINS from ratification, and closing a stage pays nobody. A stage is
886
+ // in the amendment's position, where §7 already says the one member of a
887
+ // monarchy must be able to decide their own item.
888
+ // • IT PAYS NOTHING. No credit (the reward port is only reached for a
889
+ // full_idea) and no ratify karma (closeItem skips it for a stage).
890
+ //
891
+ // The subject_id is the stage NUMBER. An instance is one project, so the
892
+ // number names a stage uniquely, and government_003's one-open-per-subject
893
+ // index then keeps at most one sitting per stage STRUCTURALLY — the same
894
+ // idempotency a Full Idea gets. A passed stage may be sat again later (the
895
+ // design's "second pass once your builders join"); the index is partial on
896
+ // open items only. government_020 widens the subject CHECK and bounds the range.
897
+ const GENESIS_STAGES = Object.freeze([
898
+ Object.freeze({ key: 'scope', number: 1, label: 'Scope' }),
899
+ Object.freeze({ key: 'strategize', number: 2, label: 'Strategize' }),
900
+ Object.freeze({ key: 'charter', number: 3, label: 'Charter' }),
901
+ Object.freeze({ key: 'legalize', number: 4, label: 'Legalize' }),
902
+ Object.freeze({ key: 'legislation', number: 5, label: 'Legislation' }),
903
+ ]);
904
+
905
+ // "Finished" is the task's terminal states: shipped, or dropped — which the
906
+ // tasks table spells `abandoned` (migration 012; a merged-away task lands
907
+ // there too). `confirmed` is NOT finished: the work is graded but not landed.
908
+ const STAGE_TASK_FINISHED_STATUSES = Object.freeze(['shipped', 'abandoned']);
909
+
910
+ const stageByKey = (key) => GENESIS_STAGES.find((s) => s.key === key) || null;
911
+ const stageByNumber = (n) => GENESIS_STAGES.find((s) => s.number === Number(n)) || null;
912
+
913
+ // The pass rules, said in words for the founding band. Keyed by exactly
914
+ // config.js PASS_RULES (held equal by tests/government_board_genesis_stage.mjs,
915
+ // so a fifth rule cannot land without its words); an unknown rule in an old
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
+ ));
922
+
923
+ // Put a finished stage to the board. Returns, never throws for a refusal:
924
+ // { opened: true, item, stage }
925
+ // { opened: false, reason: 'unknown_stage' | 'no_stage_tasks' }
926
+ // { opened: false, reason: 'stage_tasks_unfinished', unfinished: [{ task_id, status }] }
927
+ // { opened: false, reason: 'already_open', item, stage }
928
+ //
929
+ // THE FINISHED-TASKS GATE LIVES HERE, not in the caller — the same posture as
930
+ // the Full Idea bar: the genesis home says which tasks make up the stage, and
931
+ // this module checks them against the tasks table before anything is written.
932
+ // An empty list is refused rather than read as "nothing left to do", because a
933
+ // stage close with no tasks behind it would be exactly the bypass D7 rules out.
934
+ // `openedBy` is the founder (or whoever put it up); it may be null for an
935
+ // automatic open, the same as a grade-opened idea.
936
+ //
937
+ // WHO MAY CALL THIS is the caller's gate, by design, exactly as for
938
+ // openWindowForFullIdea: this is a port method with no HTTP route, and opening
939
+ // a sitting decides nothing — it only asks the board. Whoever exposes it to a
940
+ // person (the genesis home) must gate that route itself. The deciding wall is
941
+ // unchanged either way: only a member under the snapshot can vote it closed.
942
+ async function openGenesisStageClose({ stageKey, taskIds, openedBy = null } = {}) {
943
+ const stage = stageByKey(stageKey);
944
+ if (!stage) return { opened: false, reason: 'unknown_stage' };
945
+ const ids = Array.isArray(taskIds) ? taskIds.map(Number) : [];
946
+ if (!ids.length || !ids.every((n) => Number.isInteger(n) && n > 0)) {
947
+ return { opened: false, reason: 'no_stage_tasks' };
948
+ }
949
+ const unique = [...new Set(ids)];
950
+ const rows = await db.getTaskStatuses(unique);
951
+ const statusById = new Map(rows.map((r) => [String(r.id), r.status]));
952
+ const unfinished = unique
953
+ .filter((id) => !STAGE_TASK_FINISHED_STATUSES.includes(statusById.get(String(id))))
954
+ .map((id) => ({ task_id: String(id), status: statusById.get(String(id)) ?? null }));
955
+ if (unfinished.length) return { opened: false, reason: 'stage_tasks_unfinished', unfinished };
956
+
957
+ const { board } = loadGovernmentConfig();
958
+ const item = await db.openBoardItem({
959
+ subjectType: 'genesis_stage',
960
+ subjectId: stage.number,
961
+ openedBy: openedBy == null || openedBy === '' ? null : openedBy,
962
+ constitution: board,
963
+ windowMinutes: board.window_minutes,
964
+ });
965
+ if (!item) {
966
+ return { opened: false, reason: 'already_open', item: await db.getOpenBoardItem('genesis_stage', stage.number), stage };
967
+ }
968
+ await emitWindowOpened(item);
969
+ return { opened: true, item, stage };
970
+ }
971
+
972
+ // The genesis home's cue (spec D7 / task 1004423): a stage's sitting PASSED,
973
+ // so mark it done and wake its room. Emitted after the close commits, beside
974
+ // the general `board.item.passed`, carrying what that contract deliberately
975
+ // does not — which stage. Best-effort by construction, like every board event:
976
+ // the durable fact is the `outcome = 'passed'` row, which genesisStageStatus
977
+ // reads, so a listener that missed the nudge can always recover the state.
978
+ async function emitGenesisStagePassed(closed) {
979
+ const stage = stageByNumber(closed.subject_id);
980
+ if (!stage) return;
981
+ try {
982
+ if (api.emitAsync) {
983
+ await api.emitAsync('board.genesis_stage.passed', {
984
+ item_id: String(closed.id),
985
+ stage_key: stage.key,
986
+ stage_number: stage.number,
987
+ passed_at: closed.closed_at || null,
988
+ });
989
+ }
990
+ } catch (_) { /* a nudge that fails is recoverable from the passed row */ }
991
+ }
992
+
993
+ // "Who is this stage waiting on" — for the founding band. One entry per stage,
994
+ // from the LATEST sitting on it:
995
+ // state: 'not_put' (never put to the board) | 'sitting' | 'passed' | 'returned'
996
+ // and for a sitting, the members under the item's OWN snapshot (not the live
997
+ // config), split into who has voted and who is still to — per request,
998
+ // uncached, the ADR 0016 posture of every membership read here. Under monarchy
999
+ // `one_vote_decides` is true and `waiting_on` names everyone who could give
1000
+ // that vote. The countdown is the database's (`remaining_seconds`); the client
1001
+ // never does clock math. Membership is resolved once per distinct predicate.
1002
+ async function genesisStageStatus() {
1003
+ const items = await db.listLatestGenesisStageItems();
1004
+ const open = items.filter((i) => !i.closed_at);
1005
+ const votes = open.length ? await db.getVotesForItems(open.map((i) => i.id)) : [];
1006
+ const membersByPredicate = new Map();
1007
+ const stages = [];
1008
+ for (const stage of GENESIS_STAGES) {
1009
+ const item = items.find((i) => Number(i.subject_id) === stage.number);
1010
+ const base = { key: stage.key, number: stage.number, label: stage.label };
1011
+ if (!item) {
1012
+ stages.push({ ...base, state: 'not_put', item_id: null, voted: [], waiting_on: [] });
1013
+ continue;
1014
+ }
1015
+ const constitution = item.constitution || {};
1016
+ const common = {
1017
+ ...base,
1018
+ item_id: String(item.id),
1019
+ opened_at: item.opened_at || null,
1020
+ pass_rule: constitution.pass_rule || null,
1021
+ needs: PASS_RULE_WORDS[constitution.pass_rule] || 'The rule this sitting runs under is not one this version knows; only its clock can close it.',
1022
+ one_vote_decides: constitution.pass_rule === 'first_ratifier',
1023
+ };
1024
+ if (item.closed_at) {
1025
+ stages.push({ ...common, state: item.outcome, closed_at: item.closed_at, voted: [], waiting_on: [] });
1026
+ continue;
1027
+ }
1028
+ const key = String(constitution.membership || '');
1029
+ if (!membersByPredicate.has(key)) membersByPredicate.set(key, await resolveBoardMembers(constitution));
1030
+ const members = membersByPredicate.get(key);
1031
+ const cast = votes.filter((v) => String(v.item_id) === String(item.id));
1032
+ const castBy = new Map(cast.map((v) => [String(v.voter_id), v]));
1033
+ stages.push({
1034
+ ...common,
1035
+ state: 'sitting',
1036
+ closes_at: item.closes_at || null,
1037
+ remaining_seconds: item.remaining_seconds ?? null,
1038
+ voted: cast.map((v) => ({
1039
+ builder_id: String(v.voter_id), github_login: v.github_login, display_name: v.display_name, direction: v.direction,
1040
+ })),
1041
+ waiting_on: members.filter((m) => !castBy.has(String(m.builder_id))).map((m) => ({
1042
+ builder_id: m.builder_id, github_login: m.github_login, display_name: m.display_name,
1043
+ })),
1044
+ });
1045
+ }
1046
+ return { stages };
1047
+ }
1048
+
809
1049
  // ── the Board Room view (BV1.R16 of goal 1000069, ADR 0175 §9) ──────────────
810
1050
  //
811
1051
  // One assembled read for the room: open items with their clock, the OPEN
@@ -851,7 +1091,17 @@ async function boardRoomView({ closedLimit = 20 } = {}) {
851
1091
  });
852
1092
  }
853
1093
  const project = (item) => {
854
- const subject = item.subject_type === 'full_idea'
1094
+ // A genesis stage's subject is the stage itself — named from the one list,
1095
+ // so the room never mistakes it for an amendment (task 1004429).
1096
+ const subject = item.subject_type === 'genesis_stage'
1097
+ ? (() => {
1098
+ const s = stageByNumber(item.subject_id);
1099
+ return s ? {
1100
+ stage_key: s.key, stage_number: s.number, label: s.label,
1101
+ author_id: item.opened_by == null ? null : String(item.opened_by),
1102
+ } : null;
1103
+ })()
1104
+ : item.subject_type === 'full_idea'
855
1105
  ? (() => {
856
1106
  const r = ideaById.get(String(item.subject_id));
857
1107
  return r ? {
@@ -1001,6 +1251,10 @@ async function constitutionView({ historyLimit = 50 } = {}) {
1001
1251
  // last passed one is not a shape this system has, and the honest failure
1002
1252
  // there is a missed warning rather than a wrong one.
1003
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),
1004
1258
  // THE CLOCK ADVICE THE AMENDMENT FORM NEEDS (task 1003733, ADR 0267 §3).
1005
1259
  // A proposer choosing a pass rule has to choose a window in the same breath
1006
1260
  // — a constitution is ratified whole, never by diff — and the two choices
@@ -1138,6 +1392,11 @@ async function pendingVotesFor(builderId) {
1138
1392
  }
1139
1393
 
1140
1394
  module.exports = {
1395
+ GENESIS_STAGES,
1396
+ PASS_RULE_WORDS,
1397
+ proposeCharter,
1398
+ openGenesisStageClose,
1399
+ genesisStageStatus,
1141
1400
  pendingVotesFor,
1142
1401
  hasMajority,
1143
1402
  majorityDecided,
@@ -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
+ };
@@ -688,7 +688,12 @@ async function getExpiredOpenBoardItems(limit = 100) {
688
688
  async function openBoardItemsAwaitingVoter(voterId) {
689
689
  const { rows } = await pool.query(
690
690
  `SELECT i.id, i.subject_type, i.subject_id, i.constitution, i.opened_at, i.closes_at,
691
- CASE WHEN i.subject_type = 'full_idea' THEN idea.captured_by ELSE i.opened_by END AS author_id,
691
+ -- A genesis stage has no author to hold back (task 1004429): the
692
+ -- founder who put it up IS the confirm monarchy waits on, so they
693
+ -- must be summoned, not skipped.
694
+ CASE WHEN i.subject_type = 'full_idea' THEN idea.captured_by
695
+ WHEN i.subject_type = 'genesis_stage' THEN NULL
696
+ ELSE i.opened_by END AS author_id,
692
697
  (v.id IS NOT NULL) AS already_voted
693
698
  FROM government_board_items i
694
699
  LEFT JOIN idea_inbox idea
@@ -703,6 +708,40 @@ async function openBoardItemsAwaitingVoter(voterId) {
703
708
  return rows;
704
709
  }
705
710
 
711
+ // --- genesis stages (task 1004429 / BV2.PS04) --------------------------------
712
+
713
+ // The status of each task a stage names, for the "are this stage's tasks all
714
+ // finished" gate. `tasks` is a core table; READING it is the sanctioned pattern
715
+ // (the boundary is code imports, not table access). An id with no row comes
716
+ // back absent, and the caller counts that as unfinished — a stage cannot close
717
+ // on a task nobody can find.
718
+ async function getTaskStatuses(taskIds) {
719
+ if (!taskIds || !taskIds.length) return [];
720
+ const { rows } = await pool.query(
721
+ `SELECT id, status FROM tasks WHERE id = ANY($1::bigint[])`,
722
+ [taskIds],
723
+ );
724
+ return rows;
725
+ }
726
+
727
+ // The latest sitting for each genesis stage (one row per stage number, newest
728
+ // first within it), with the same database-clock fields the room reads. Served
729
+ // by government_020's partial index, keyed to exactly this ORDER BY — the table
730
+ // holds every idea and amendment sitting forever, and without it this read
731
+ // would scan all of that history to find a handful of stage rows.
732
+ async function listLatestGenesisStageItems() {
733
+ const { rows } = await pool.query(
734
+ `SELECT DISTINCT ON (subject_id) *,
735
+ (closes_at IS NOT NULL AND closes_at < now()) AS expired,
736
+ CASE WHEN closes_at IS NULL THEN NULL
737
+ ELSE GREATEST(0, EXTRACT(EPOCH FROM (closes_at - now()))::int) END AS remaining_seconds
738
+ FROM government_board_items
739
+ WHERE subject_type = 'genesis_stage'
740
+ ORDER BY subject_id, opened_at DESC, id DESC`,
741
+ );
742
+ return rows;
743
+ }
744
+
706
745
  // Author-ratification karma (+3, board.js AUTHOR_RATIFY_KARMA_DELTA) — fired by
707
746
  // the R10 close on a passed item. IDEMPOTENT: the reason key is unique per item
708
747
  // and the INSERT is one atomic WHERE-NOT-EXISTS statement (the economy
@@ -731,6 +770,8 @@ async function insertVoteKarma({ voterId, itemId, delta }) {
731
770
 
732
771
  module.exports = {
733
772
  openBoardItemsAwaitingVoter,
773
+ getTaskStatuses,
774
+ listLatestGenesisStageItems,
734
775
  getPermissionKeysForBuilder,
735
776
  getRankKeysForBuilder,
736
777
  getSeededRanks,
@@ -0,0 +1,55 @@
1
+ -- government_020_genesis_stage_subject.sql — the board's third subject: closing
2
+ -- a genesis stage (task 1004429 / BV2.PS04, spec docs/specs/bongos-v2-project-
3
+ -- startup.md decision D7).
4
+ --
5
+ -- government_003 built `subject_type` so that a third subject would be "a row,
6
+ -- not a migration". That held for the ROWS — no new table, no new column — but
7
+ -- the column carries a CHECK naming the two subjects it knew about, so the
8
+ -- vocabulary itself still has to widen here. That is the whole of this file.
9
+ --
10
+ -- A genesis stage's subject_id is its STAGE NUMBER (1 scope · 2 strategize ·
11
+ -- 3 charter · 4 legalize · 5 legislation — board.js GENESIS_STAGES is the one
12
+ -- list). An instance is one project, so the number names the stage uniquely,
13
+ -- and that is what lets government_003's partial unique index keep AT MOST ONE
14
+ -- OPEN SITTING PER STAGE structurally, the same way it keeps one per idea: a
15
+ -- double-fired "the stage's tasks are done" opens exactly one item. A stage
16
+ -- that passed can be sat again later (the "second pass once your builders
17
+ -- join" in the design of record) — the index is partial on open items only.
18
+ --
19
+ -- Nothing about deciding changes. The item snapshots the constitution in force
20
+ -- like every other item, the pass rules are the same four, and the author and
21
+ -- clock rules are the ones every item runs under.
22
+ --
23
+ -- Additive, idempotent: dropping and re-adding a CHECK that only WIDENS admits
24
+ -- every row already in the table.
25
+
26
+ BEGIN;
27
+
28
+ ALTER TABLE government_board_items
29
+ DROP CONSTRAINT IF EXISTS government_board_items_subject_type_check;
30
+
31
+ ALTER TABLE government_board_items
32
+ ADD CONSTRAINT government_board_items_subject_type_check
33
+ CHECK (subject_type IN ('full_idea', 'constitutional_amendment', 'genesis_stage'));
34
+
35
+ -- The stage number is a small closed range; a stray id would be a sitting on a
36
+ -- stage that does not exist. Scoped to genesis_stage so the other subjects'
37
+ -- ids (idea and amendment row ids) are untouched.
38
+ ALTER TABLE government_board_items
39
+ DROP CONSTRAINT IF EXISTS government_board_items_genesis_stage_number;
40
+
41
+ ALTER TABLE government_board_items
42
+ ADD CONSTRAINT government_board_items_genesis_stage_number
43
+ CHECK (subject_type <> 'genesis_stage' OR subject_id BETWEEN 1 AND 5);
44
+
45
+ -- Serves the founding band's read (db.js listLatestGenesisStageItems: the latest
46
+ -- sitting per stage, DISTINCT ON subject_id ORDER BY subject_id, opened_at DESC,
47
+ -- id DESC). The table only grows — every Full Idea and amendment sitting stays
48
+ -- forever — so a read that filters by subject_type alone would scan that whole
49
+ -- history to find at most a handful of stage rows. Partial on the one subject it
50
+ -- asks for and keyed to its ORDER BY exactly, the government_006/018 shape.
51
+ CREATE INDEX IF NOT EXISTS government_board_items_genesis_stage_latest
52
+ ON government_board_items (subject_id, opened_at DESC, id DESC)
53
+ WHERE subject_type = 'genesis_stage';
54
+
55
+ COMMIT;