@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.
- package/.bongos-core.json +92 -37
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +7 -1
- package/clients/bongos-client/index.mjs +4 -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 +137 -3
- package/docs/api-reference.md +4 -2
- package/docs/copy-inventory.md +34 -34
- package/docs/copy-registry.json +38 -38
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +75 -73
- package/modules/discord/board-broadcast.js +1 -0
- package/modules/government/board.js +261 -2
- package/modules/government/charter.js +232 -0
- package/modules/government/db.js +42 -1
- package/modules/government/migrations/government_020_genesis_stage_subject.sql +55 -0
- package/modules/government/routes/government.js +82 -0
- package/modules/hall-ui/public/board-room.js +5 -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 +20 -0
- package/scripts/gds/run-unit-tests.js +4 -0
- package/src/bongos/route-rank-check.js +6 -0
- package/src/module-api.js +1 -1
- package/tests/government_board_genesis_stage.mjs +305 -0
- package/tests/government_charter_forms.mjs +283 -0
- package/tests/government_seed.mjs +1 -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.
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
};
|
package/modules/government/db.js
CHANGED
|
@@ -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
|
-
|
|
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;
|