@bongos/core 1.20.24 → 1.20.26
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 +70 -40
- 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 +2 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0354-a-builder-hosts-one-project-free-and-more-takes-the-fleet-permission.md +46 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +36 -2
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +18 -18
- package/docs/copy-registry.json +20 -20
- package/docs/design/project-startup-direction.md +131 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +15 -15
- package/modules/discord/board-broadcast.js +1 -0
- package/modules/government/board.js +204 -2
- 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 +20 -0
- package/modules/hall-ui/public/board-room.js +5 -0
- package/modules/provisioning/free-place-lock.js +52 -0
- package/modules/provisioning/paid-shape-gate.js +90 -30
- package/modules/provisioning/routes/provisioning.js +6 -6
- package/modules/public-landing/public/projects.html +7 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/src/bongos/route-rank-check.js +3 -0
- package/src/module-api.js +1 -1
- package/tests/government_board_genesis_stage.mjs +305 -0
- package/tests/government_seed.mjs +1 -0
- package/tests/project_catalog_routes.mjs +2 -0
- package/tests/projects_hub_pre_uat.mjs +1 -1
- package/tests/provisioning_capacity_gate.mjs +2 -0
- package/tests/provisioning_domain_attach.mjs +2 -0
- package/tests/provisioning_paid_shape_gate.mjs +43 -11
- package/tests/provisioning_teardown_intent.mjs +2 -0
- package/tests/provisioning_wizard_create_xenos.mjs +381 -0
|
@@ -627,7 +627,12 @@ async function closeItem(item, { cause = 'vote' } = {}) {
|
|
|
627
627
|
});
|
|
628
628
|
if (!closed) return { closed: false, reason: 'already_closed' };
|
|
629
629
|
|
|
630
|
-
|
|
630
|
+
// A genesis stage ratifies no authored work — closing it is the project's
|
|
631
|
+
// decision that a stage is done, not a reward for the founder who put it up
|
|
632
|
+
// (task 1004429). Ideas and amendments pay exactly as before.
|
|
633
|
+
const authorKarma = outcome === 'passed' && closed.subject_type !== 'genesis_stage'
|
|
634
|
+
? await awardAuthorRatifyKarma(closed)
|
|
635
|
+
: null;
|
|
631
636
|
|
|
632
637
|
// R14: a PASSED amendment becomes the live constitution — applied AFTER the
|
|
633
638
|
// close commits (the board's decision is the durable record), loudly on
|
|
@@ -652,6 +657,7 @@ async function closeItem(item, { cause = 'vote' } = {}) {
|
|
|
652
657
|
try {
|
|
653
658
|
if (api.emitAsync) await api.emitAsync('board.item.passed', payload);
|
|
654
659
|
} catch (_) { /* emitAsync isolates listener errors; nothing that moves credits subscribes */ }
|
|
660
|
+
if (closed.subject_type === 'genesis_stage') await emitGenesisStagePassed(closed);
|
|
655
661
|
}
|
|
656
662
|
|
|
657
663
|
// R22's mirror event: BOTH outcomes, after commit, emitAsync. Separate from
|
|
@@ -806,6 +812,188 @@ async function proposeAmendment({
|
|
|
806
812
|
return { ok: true, amendment, item };
|
|
807
813
|
}
|
|
808
814
|
|
|
815
|
+
// ── the board's third subject: closing a genesis stage (task 1004429) ───────
|
|
816
|
+
//
|
|
817
|
+
// Spec decision D7 (docs/specs/bongos-v2-project-startup.md): a new project is
|
|
818
|
+
// founded through five stages, and when a stage's tasks are all finished its
|
|
819
|
+
// close is put to the constitution IN FORCE. It is an ordinary board item, and
|
|
820
|
+
// that is the whole design — the stage borrows every rule the board already
|
|
821
|
+
// has rather than bringing one of its own:
|
|
822
|
+
//
|
|
823
|
+
// • THE CONSTITUTION IS SNAPSHOTTED, like every item, so a charter adopted
|
|
824
|
+
// mid-sitting does not change how an already-open stage closes.
|
|
825
|
+
// • NO NEW PASS RULE, NO GENESIS BYPASS. Under the monarchy default
|
|
826
|
+
// (first_ratifier) the founder's single confirm closes the stage because
|
|
827
|
+
// the founder is the first ratifier — not because anything special-cases
|
|
828
|
+
// genesis. Under consent, majority or unanimous it is a real sitting, with
|
|
829
|
+
// the clock and turnout rules unchanged.
|
|
830
|
+
// • THE AUTHOR RULE IS UNCHANGED — which means it does not reach a stage. It
|
|
831
|
+
// is full_idea-scoped (authorRuleApplies, ADR 0191): it guards an author
|
|
832
|
+
// who GAINS from ratification, and closing a stage pays nobody. A stage is
|
|
833
|
+
// in the amendment's position, where §7 already says the one member of a
|
|
834
|
+
// monarchy must be able to decide their own item.
|
|
835
|
+
// • IT PAYS NOTHING. No credit (the reward port is only reached for a
|
|
836
|
+
// full_idea) and no ratify karma (closeItem skips it for a stage).
|
|
837
|
+
//
|
|
838
|
+
// The subject_id is the stage NUMBER. An instance is one project, so the
|
|
839
|
+
// number names a stage uniquely, and government_003's one-open-per-subject
|
|
840
|
+
// index then keeps at most one sitting per stage STRUCTURALLY — the same
|
|
841
|
+
// idempotency a Full Idea gets. A passed stage may be sat again later (the
|
|
842
|
+
// design's "second pass once your builders join"); the index is partial on
|
|
843
|
+
// open items only. government_020 widens the subject CHECK and bounds the range.
|
|
844
|
+
const GENESIS_STAGES = Object.freeze([
|
|
845
|
+
Object.freeze({ key: 'scope', number: 1, label: 'Scope' }),
|
|
846
|
+
Object.freeze({ key: 'strategize', number: 2, label: 'Strategize' }),
|
|
847
|
+
Object.freeze({ key: 'charter', number: 3, label: 'Charter' }),
|
|
848
|
+
Object.freeze({ key: 'legalize', number: 4, label: 'Legalize' }),
|
|
849
|
+
Object.freeze({ key: 'legislation', number: 5, label: 'Legislation' }),
|
|
850
|
+
]);
|
|
851
|
+
|
|
852
|
+
// "Finished" is the task's terminal states: shipped, or dropped — which the
|
|
853
|
+
// tasks table spells `abandoned` (migration 012; a merged-away task lands
|
|
854
|
+
// there too). `confirmed` is NOT finished: the work is graded but not landed.
|
|
855
|
+
const STAGE_TASK_FINISHED_STATUSES = Object.freeze(['shipped', 'abandoned']);
|
|
856
|
+
|
|
857
|
+
const stageByKey = (key) => GENESIS_STAGES.find((s) => s.key === key) || null;
|
|
858
|
+
const stageByNumber = (n) => GENESIS_STAGES.find((s) => s.number === Number(n)) || null;
|
|
859
|
+
|
|
860
|
+
// The pass rules, said in words for the founding band. Keyed by exactly
|
|
861
|
+
// config.js PASS_RULES (held equal by tests/government_board_genesis_stage.mjs,
|
|
862
|
+
// so a fifth rule cannot land without its words); an unknown rule in an old
|
|
863
|
+
// snapshot says so rather than guessing.
|
|
864
|
+
const PASS_RULE_WORDS = Object.freeze({
|
|
865
|
+
first_ratifier: 'One yes from any member decides it.',
|
|
866
|
+
consent: 'It passes unless a member objects, once everyone has voted or the clock runs out.',
|
|
867
|
+
majority: 'More than half of all members must say yes.',
|
|
868
|
+
unanimous: 'Everyone who votes must agree, and at least one member must say yes.',
|
|
869
|
+
});
|
|
870
|
+
|
|
871
|
+
// Put a finished stage to the board. Returns, never throws for a refusal:
|
|
872
|
+
// { opened: true, item, stage }
|
|
873
|
+
// { opened: false, reason: 'unknown_stage' | 'no_stage_tasks' }
|
|
874
|
+
// { opened: false, reason: 'stage_tasks_unfinished', unfinished: [{ task_id, status }] }
|
|
875
|
+
// { opened: false, reason: 'already_open', item, stage }
|
|
876
|
+
//
|
|
877
|
+
// THE FINISHED-TASKS GATE LIVES HERE, not in the caller — the same posture as
|
|
878
|
+
// the Full Idea bar: the genesis home says which tasks make up the stage, and
|
|
879
|
+
// this module checks them against the tasks table before anything is written.
|
|
880
|
+
// An empty list is refused rather than read as "nothing left to do", because a
|
|
881
|
+
// stage close with no tasks behind it would be exactly the bypass D7 rules out.
|
|
882
|
+
// `openedBy` is the founder (or whoever put it up); it may be null for an
|
|
883
|
+
// automatic open, the same as a grade-opened idea.
|
|
884
|
+
//
|
|
885
|
+
// WHO MAY CALL THIS is the caller's gate, by design, exactly as for
|
|
886
|
+
// openWindowForFullIdea: this is a port method with no HTTP route, and opening
|
|
887
|
+
// a sitting decides nothing — it only asks the board. Whoever exposes it to a
|
|
888
|
+
// person (the genesis home) must gate that route itself. The deciding wall is
|
|
889
|
+
// unchanged either way: only a member under the snapshot can vote it closed.
|
|
890
|
+
async function openGenesisStageClose({ stageKey, taskIds, openedBy = null } = {}) {
|
|
891
|
+
const stage = stageByKey(stageKey);
|
|
892
|
+
if (!stage) return { opened: false, reason: 'unknown_stage' };
|
|
893
|
+
const ids = Array.isArray(taskIds) ? taskIds.map(Number) : [];
|
|
894
|
+
if (!ids.length || !ids.every((n) => Number.isInteger(n) && n > 0)) {
|
|
895
|
+
return { opened: false, reason: 'no_stage_tasks' };
|
|
896
|
+
}
|
|
897
|
+
const unique = [...new Set(ids)];
|
|
898
|
+
const rows = await db.getTaskStatuses(unique);
|
|
899
|
+
const statusById = new Map(rows.map((r) => [String(r.id), r.status]));
|
|
900
|
+
const unfinished = unique
|
|
901
|
+
.filter((id) => !STAGE_TASK_FINISHED_STATUSES.includes(statusById.get(String(id))))
|
|
902
|
+
.map((id) => ({ task_id: String(id), status: statusById.get(String(id)) ?? null }));
|
|
903
|
+
if (unfinished.length) return { opened: false, reason: 'stage_tasks_unfinished', unfinished };
|
|
904
|
+
|
|
905
|
+
const { board } = loadGovernmentConfig();
|
|
906
|
+
const item = await db.openBoardItem({
|
|
907
|
+
subjectType: 'genesis_stage',
|
|
908
|
+
subjectId: stage.number,
|
|
909
|
+
openedBy: openedBy == null || openedBy === '' ? null : openedBy,
|
|
910
|
+
constitution: board,
|
|
911
|
+
windowMinutes: board.window_minutes,
|
|
912
|
+
});
|
|
913
|
+
if (!item) {
|
|
914
|
+
return { opened: false, reason: 'already_open', item: await db.getOpenBoardItem('genesis_stage', stage.number), stage };
|
|
915
|
+
}
|
|
916
|
+
await emitWindowOpened(item);
|
|
917
|
+
return { opened: true, item, stage };
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// The genesis home's cue (spec D7 / task 1004423): a stage's sitting PASSED,
|
|
921
|
+
// so mark it done and wake its room. Emitted after the close commits, beside
|
|
922
|
+
// the general `board.item.passed`, carrying what that contract deliberately
|
|
923
|
+
// does not — which stage. Best-effort by construction, like every board event:
|
|
924
|
+
// the durable fact is the `outcome = 'passed'` row, which genesisStageStatus
|
|
925
|
+
// reads, so a listener that missed the nudge can always recover the state.
|
|
926
|
+
async function emitGenesisStagePassed(closed) {
|
|
927
|
+
const stage = stageByNumber(closed.subject_id);
|
|
928
|
+
if (!stage) return;
|
|
929
|
+
try {
|
|
930
|
+
if (api.emitAsync) {
|
|
931
|
+
await api.emitAsync('board.genesis_stage.passed', {
|
|
932
|
+
item_id: String(closed.id),
|
|
933
|
+
stage_key: stage.key,
|
|
934
|
+
stage_number: stage.number,
|
|
935
|
+
passed_at: closed.closed_at || null,
|
|
936
|
+
});
|
|
937
|
+
}
|
|
938
|
+
} catch (_) { /* a nudge that fails is recoverable from the passed row */ }
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
// "Who is this stage waiting on" — for the founding band. One entry per stage,
|
|
942
|
+
// from the LATEST sitting on it:
|
|
943
|
+
// state: 'not_put' (never put to the board) | 'sitting' | 'passed' | 'returned'
|
|
944
|
+
// and for a sitting, the members under the item's OWN snapshot (not the live
|
|
945
|
+
// config), split into who has voted and who is still to — per request,
|
|
946
|
+
// uncached, the ADR 0016 posture of every membership read here. Under monarchy
|
|
947
|
+
// `one_vote_decides` is true and `waiting_on` names everyone who could give
|
|
948
|
+
// that vote. The countdown is the database's (`remaining_seconds`); the client
|
|
949
|
+
// never does clock math. Membership is resolved once per distinct predicate.
|
|
950
|
+
async function genesisStageStatus() {
|
|
951
|
+
const items = await db.listLatestGenesisStageItems();
|
|
952
|
+
const open = items.filter((i) => !i.closed_at);
|
|
953
|
+
const votes = open.length ? await db.getVotesForItems(open.map((i) => i.id)) : [];
|
|
954
|
+
const membersByPredicate = new Map();
|
|
955
|
+
const stages = [];
|
|
956
|
+
for (const stage of GENESIS_STAGES) {
|
|
957
|
+
const item = items.find((i) => Number(i.subject_id) === stage.number);
|
|
958
|
+
const base = { key: stage.key, number: stage.number, label: stage.label };
|
|
959
|
+
if (!item) {
|
|
960
|
+
stages.push({ ...base, state: 'not_put', item_id: null, voted: [], waiting_on: [] });
|
|
961
|
+
continue;
|
|
962
|
+
}
|
|
963
|
+
const constitution = item.constitution || {};
|
|
964
|
+
const common = {
|
|
965
|
+
...base,
|
|
966
|
+
item_id: String(item.id),
|
|
967
|
+
opened_at: item.opened_at || null,
|
|
968
|
+
pass_rule: constitution.pass_rule || null,
|
|
969
|
+
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.',
|
|
970
|
+
one_vote_decides: constitution.pass_rule === 'first_ratifier',
|
|
971
|
+
};
|
|
972
|
+
if (item.closed_at) {
|
|
973
|
+
stages.push({ ...common, state: item.outcome, closed_at: item.closed_at, voted: [], waiting_on: [] });
|
|
974
|
+
continue;
|
|
975
|
+
}
|
|
976
|
+
const key = String(constitution.membership || '');
|
|
977
|
+
if (!membersByPredicate.has(key)) membersByPredicate.set(key, await resolveBoardMembers(constitution));
|
|
978
|
+
const members = membersByPredicate.get(key);
|
|
979
|
+
const cast = votes.filter((v) => String(v.item_id) === String(item.id));
|
|
980
|
+
const castBy = new Map(cast.map((v) => [String(v.voter_id), v]));
|
|
981
|
+
stages.push({
|
|
982
|
+
...common,
|
|
983
|
+
state: 'sitting',
|
|
984
|
+
closes_at: item.closes_at || null,
|
|
985
|
+
remaining_seconds: item.remaining_seconds ?? null,
|
|
986
|
+
voted: cast.map((v) => ({
|
|
987
|
+
builder_id: String(v.voter_id), github_login: v.github_login, display_name: v.display_name, direction: v.direction,
|
|
988
|
+
})),
|
|
989
|
+
waiting_on: members.filter((m) => !castBy.has(String(m.builder_id))).map((m) => ({
|
|
990
|
+
builder_id: m.builder_id, github_login: m.github_login, display_name: m.display_name,
|
|
991
|
+
})),
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
return { stages };
|
|
995
|
+
}
|
|
996
|
+
|
|
809
997
|
// ── the Board Room view (BV1.R16 of goal 1000069, ADR 0175 §9) ──────────────
|
|
810
998
|
//
|
|
811
999
|
// One assembled read for the room: open items with their clock, the OPEN
|
|
@@ -851,7 +1039,17 @@ async function boardRoomView({ closedLimit = 20 } = {}) {
|
|
|
851
1039
|
});
|
|
852
1040
|
}
|
|
853
1041
|
const project = (item) => {
|
|
854
|
-
|
|
1042
|
+
// A genesis stage's subject is the stage itself — named from the one list,
|
|
1043
|
+
// so the room never mistakes it for an amendment (task 1004429).
|
|
1044
|
+
const subject = item.subject_type === 'genesis_stage'
|
|
1045
|
+
? (() => {
|
|
1046
|
+
const s = stageByNumber(item.subject_id);
|
|
1047
|
+
return s ? {
|
|
1048
|
+
stage_key: s.key, stage_number: s.number, label: s.label,
|
|
1049
|
+
author_id: item.opened_by == null ? null : String(item.opened_by),
|
|
1050
|
+
} : null;
|
|
1051
|
+
})()
|
|
1052
|
+
: item.subject_type === 'full_idea'
|
|
855
1053
|
? (() => {
|
|
856
1054
|
const r = ideaById.get(String(item.subject_id));
|
|
857
1055
|
return r ? {
|
|
@@ -1138,6 +1336,10 @@ async function pendingVotesFor(builderId) {
|
|
|
1138
1336
|
}
|
|
1139
1337
|
|
|
1140
1338
|
module.exports = {
|
|
1339
|
+
GENESIS_STAGES,
|
|
1340
|
+
PASS_RULE_WORDS,
|
|
1341
|
+
openGenesisStageClose,
|
|
1342
|
+
genesisStageStatus,
|
|
1141
1343
|
pendingVotesFor,
|
|
1142
1344
|
hasMajority,
|
|
1143
1345
|
majorityDecided,
|
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;
|
|
@@ -108,6 +108,13 @@ const governmentPort = {
|
|
|
108
108
|
// is no counterpart that records an outcome, and there must never be one: an
|
|
109
109
|
// outcome is written by a close, never by the module reading it.
|
|
110
110
|
boardPassForIdea: board.boardPassForIdea,
|
|
111
|
+
// task 1004429 (BV2.PS04): a finished genesis stage is put to the board — the
|
|
112
|
+
// genesis home (task 1004423) calls this when a stage's tasks are done. The
|
|
113
|
+
// finished-tasks gate lives inside board.js; the item is an ordinary one under
|
|
114
|
+
// the constitution in force. And the founding band's read: per stage, what it
|
|
115
|
+
// is waiting on. A pass is announced as `board.genesis_stage.passed`.
|
|
116
|
+
openGenesisStageClose: board.openGenesisStageClose,
|
|
117
|
+
genesisStageStatus: board.genesisStageStatus,
|
|
111
118
|
};
|
|
112
119
|
|
|
113
120
|
function registerSeams() {
|
|
@@ -460,6 +467,19 @@ module.exports = function buildGovernmentRouter() {
|
|
|
460
467
|
}
|
|
461
468
|
});
|
|
462
469
|
|
|
470
|
+
// rank: perm board.vote.cast — the founding band's read (task 1004429): each
|
|
471
|
+
// genesis stage's latest sitting, and who it is still waiting on under the
|
|
472
|
+
// item's own snapshot. Same atom as the room — seeing who is still to vote is
|
|
473
|
+
// part of the open ballot (§9). A read; nothing here opens or decides.
|
|
474
|
+
router.get('/government/board/genesis-stages', api.requireBuilder, api.requirePermission('board.vote.cast'), async (req, res) => {
|
|
475
|
+
try {
|
|
476
|
+
res.json(await board.genesisStageStatus());
|
|
477
|
+
} catch (err) {
|
|
478
|
+
log.error('[gds] GET /government/board/genesis-stages', err);
|
|
479
|
+
res.fail('genesis_stage_read_failed', { status: 500, message: 'internal error' });
|
|
480
|
+
}
|
|
481
|
+
});
|
|
482
|
+
|
|
463
483
|
// rank: perm board.item.open — put an AMENDMENT to the board (R14 of goal
|
|
464
484
|
// 1000069, ADR 0175 §7). The board's other subject never comes through here:
|
|
465
485
|
// a Full Idea's window opens AUTOMATICALLY on grade pass (R07), so requesting
|
|
@@ -132,6 +132,11 @@
|
|
|
132
132
|
const title = item.subject ? item.subject.title : `idea ${item.subject_id}`;
|
|
133
133
|
return `<a href="/#/idea/${escapeHtml(String(item.subject_id))}">${escapeHtml(title)}</a> ${pill('quiet', 'Full Idea')}`;
|
|
134
134
|
}
|
|
135
|
+
// task 1004429: the third subject — closing a founding stage.
|
|
136
|
+
if (item.subject_type === 'genesis_stage') {
|
|
137
|
+
const label = item.subject ? item.subject.label : `stage ${item.subject_id}`;
|
|
138
|
+
return `Close the ${escapeHtml(label)} stage ${pill('quiet', 'genesis stage')}`;
|
|
139
|
+
}
|
|
135
140
|
return `Constitutional amendment ${pill('quiet', 'amendment')}`;
|
|
136
141
|
}
|
|
137
142
|
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// modules/provisioning/free-place-lock.js — one first-free hosted-project request per
|
|
2
|
+
// builder at a time, across every web process (task 1004412, ADR 0354 D2).
|
|
3
|
+
//
|
|
4
|
+
// The shape gate counts a builder's live hosted projects and the route inserts the new
|
|
5
|
+
// one a few awaits later. Two requests sent together would both count zero and both take
|
|
6
|
+
// the free place. A process-local mark only closes that inside one process, so the guard
|
|
7
|
+
// is a Postgres advisory lock keyed on the builder: whichever request holds it answers,
|
|
8
|
+
// and any other request from that builder, from any process, is refused until it is let
|
|
9
|
+
// go. It is taken with TRY, so nothing ever queues behind it.
|
|
10
|
+
//
|
|
11
|
+
// Session-level, on a client held from the gate's count to the route's insert, because the
|
|
12
|
+
// count and the insert are not one transaction. Releasing unlocks and returns the client; if the
|
|
13
|
+
// unlock itself fails the client is destroyed, which ends the session and frees the lock.
|
|
14
|
+
//
|
|
15
|
+
// Its own file so a test can re-point `holdFreePlace` through the module object, and
|
|
16
|
+
// because provisioning.js and routes/provisioning.js sit at their size ratchets.
|
|
17
|
+
'use strict';
|
|
18
|
+
|
|
19
|
+
// The lock's first key: one namespace for this guard, so it can never collide with an
|
|
20
|
+
// advisory lock another part of the platform takes on a builder id.
|
|
21
|
+
const NAMESPACE_SQL = "hashtext('provisioning.one-free-hosted-project')";
|
|
22
|
+
|
|
23
|
+
// holdFreePlace — try to hold the builder's free-place lock. Resolves to a release
|
|
24
|
+
// function when it is held, or null when another request already holds it. Throws when
|
|
25
|
+
// the database cannot be asked; the caller refuses then (it never admits on a guess).
|
|
26
|
+
async function holdFreePlace(db, builderId) {
|
|
27
|
+
const key = Number(builderId);
|
|
28
|
+
if (!Number.isInteger(key)) throw new TypeError(`free-place lock: builder id ${builderId} is not an integer`);
|
|
29
|
+
const client = await db.connect();
|
|
30
|
+
let held;
|
|
31
|
+
try {
|
|
32
|
+
const { rows } = await client.query(`SELECT pg_try_advisory_lock(${NAMESPACE_SQL}, $1::int) AS held`, [key]);
|
|
33
|
+
held = !!(rows[0] && rows[0].held);
|
|
34
|
+
} catch (err) {
|
|
35
|
+
client.release(true);
|
|
36
|
+
throw err;
|
|
37
|
+
}
|
|
38
|
+
if (!held) { client.release(); return null; }
|
|
39
|
+
let released = false;
|
|
40
|
+
return async function release() {
|
|
41
|
+
if (released) return;
|
|
42
|
+
released = true;
|
|
43
|
+
try {
|
|
44
|
+
await client.query(`SELECT pg_advisory_unlock(${NAMESPACE_SQL}, $1::int)`, [key]);
|
|
45
|
+
client.release();
|
|
46
|
+
} catch {
|
|
47
|
+
client.release(true);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
module.exports = { holdFreePlace };
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
// modules/provisioning/paid-shape-gate.js — who may ask for which hosting SHAPE
|
|
2
|
-
//
|
|
1
|
+
// modules/provisioning/paid-shape-gate.js — who may ask for which hosting SHAPE, and how
|
|
2
|
+
// many hosted projects a builder may hold without the fleet permission
|
|
3
|
+
// (task 1003682, widened by task 1003370 / audit B4, one free hosted project by task 1004412).
|
|
3
4
|
//
|
|
4
5
|
// THE FILENAME IS NARROWER THAN THE FILE, DELIBERATELY. This started as the gate over
|
|
5
6
|
// METERED shapes alone. Task 1003370 widened it to every shape that is not the free
|
|
@@ -9,9 +10,9 @@
|
|
|
9
10
|
// clarity for free.
|
|
10
11
|
//
|
|
11
12
|
// The create route is open to any authenticated builder, and it must stay that way:
|
|
12
|
-
// standing up a project is the platform's front door, not a privilege. But only
|
|
13
|
-
// the shapes it accepts
|
|
14
|
-
// something the platform pays for or trusts:
|
|
13
|
+
// standing up a project is the platform's front door, not a privilege. But only two of
|
|
14
|
+
// the shapes it accepts are that front door, and only one of them without limit. The
|
|
15
|
+
// others each hand the caller something the platform pays for or trusts:
|
|
15
16
|
//
|
|
16
17
|
// byo-host — the front door since ADR 0323. The OWNER supplies the host, so the
|
|
17
18
|
// platform pays nothing and runs none of the caller's code. No
|
|
@@ -22,23 +23,23 @@
|
|
|
22
23
|
// co-tenant — RETIRED (ADR 0323). Was the free front door; ran on the platform's
|
|
23
24
|
// own box, which is why the name could not be carried over to
|
|
24
25
|
// bring-your-own-host. Now UNREQUESTABLE — see below.
|
|
25
|
-
// cloud-host —
|
|
26
|
-
//
|
|
27
|
-
//
|
|
26
|
+
// cloud-host — the project's hall, hosted by us (ADR 0345), and the only shape the
|
|
27
|
+
// create wizard sends. It runs the caller's OWN repo on the platform's
|
|
28
|
+
// own box: the unit's WorkingDirectory is that checkout
|
|
28
29
|
// (`scripts/gds/provision-units.js`) and the module loader discovers
|
|
29
30
|
// modules from `resolveInstanceRoot()`, which is process.cwd()
|
|
30
31
|
// (`src/module-loader/loader.js`). So the caller names a repo and the
|
|
31
|
-
// platform require()s its `modules/` —
|
|
32
|
-
//
|
|
33
|
-
//
|
|
34
|
-
// "any Xenos may run code here" an acceptable default.
|
|
32
|
+
// platform require()s its `modules/` — code execution on the control
|
|
33
|
+
// plane's own machine. Task 1003369 gave each instance its own uid,
|
|
34
|
+
// which bounds the blast radius to that account.
|
|
35
35
|
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
36
|
+
// Task 1003370 gated it outright, and that also refused every new user
|
|
37
|
+
// at the wizard's last button (task 1004412): ADR 0345 decision 1 says
|
|
38
|
+
// creating a project needs no account, key or bill. The owner's ruling
|
|
39
|
+
// (2026-09-30, ADR 0354): a builder's FIRST live hosted project needs no
|
|
40
|
+
// authority, and a further one needs the fleet permission. The limit is
|
|
41
|
+
// what stops one person taking every place on the box (ADR 0285). See
|
|
42
|
+
// ONE_FREE_SHAPES below.
|
|
42
43
|
//
|
|
43
44
|
// Stored as 'standalone' until task 1004182 renamed it to match the
|
|
44
45
|
// owner-facing name (migration provisioning_027). The old 'dedicated'
|
|
@@ -54,7 +55,8 @@
|
|
|
54
55
|
// reason this is a middleware over one field rather than a floor on the endpoint.
|
|
55
56
|
//
|
|
56
57
|
// AN ALLOW-LIST, NOT A DENY-LIST — the control-plane-guard.js rule, for the same reason.
|
|
57
|
-
// The
|
|
58
|
+
// The sets named below are the ones that need no authority (OPEN_SHAPES), or none for a
|
|
59
|
+
// builder's first project (ONE_FREE_SHAPES). Every other shape, including
|
|
58
60
|
// one added after this file was last read, requires the permission by omission. The
|
|
59
61
|
// previous shape of this gate listed the one shape that was gated and admitted the rest,
|
|
60
62
|
// and that is exactly how `cloud-host` and `control-plane` stayed open: `cloud-host`
|
|
@@ -79,14 +81,30 @@
|
|
|
79
81
|
'use strict';
|
|
80
82
|
|
|
81
83
|
const provisioning = require('./provisioning.js');
|
|
84
|
+
const { failFrom } = require('./public-refusal');
|
|
82
85
|
|
|
83
|
-
// The shapes any authenticated builder may request with no permission at all
|
|
84
|
-
//
|
|
85
|
-
//
|
|
86
|
-
// provisioning_paid_shape_gate.mjs` pins HOSTING_SHAPES against
|
|
86
|
+
// The shapes any authenticated builder may request with no permission at all, as many
|
|
87
|
+
// times as they like. Deliberately one entry long: byo-host runs on the owner's own
|
|
88
|
+
// host, so it costs the platform nothing and runs none of the caller's code. `tests/
|
|
89
|
+
// provisioning_paid_shape_gate.mjs` pins HOSTING_SHAPES against these sets so a new shape
|
|
87
90
|
// cannot be added without someone deciding, in writing, which side of the line it is on.
|
|
88
91
|
const OPEN_SHAPES = Object.freeze(new Set(['byo-host']));
|
|
89
92
|
|
|
93
|
+
// The shapes a builder may hold ONE live project of with no permission (task 1004412,
|
|
94
|
+
// ADR 0354). A second one needs provisioning.fleet.manage, like any shape outside every
|
|
95
|
+
// set here. cloud-host is the only member: it is the wizard's front door under ADR 0345,
|
|
96
|
+
// and it spends a place on the shared box, of which there are about fifteen (ADR 0285).
|
|
97
|
+
// "Live" is any status but torn_down: a failed or queued project still holds its place,
|
|
98
|
+
// and its owner can retry it (the same slug) or take it offline to free the place.
|
|
99
|
+
const ONE_FREE_SHAPES = Object.freeze(new Set(['cloud-host']));
|
|
100
|
+
|
|
101
|
+
// One first-free request per builder at a time. The count and the insert are two steps
|
|
102
|
+
// with awaits between them, so two requests sent at once would both count zero and both
|
|
103
|
+
// take the free place. The builder's advisory lock is held from before the count until
|
|
104
|
+
// the response closes, so a second request sent meanwhile — from any web process — is
|
|
105
|
+
// refused (the wizard never sends two). ./free-place-lock.js says how it is held.
|
|
106
|
+
const freePlace = require('./free-place-lock');
|
|
107
|
+
|
|
90
108
|
// Shapes that are not requestable through this route AT ALL — no permission admits them,
|
|
91
109
|
// because there is no rank at which "ask the API for a second control plane" is a
|
|
92
110
|
// coherent request. The control-plane row is the platform describing ITSELF; a builder
|
|
@@ -105,6 +123,8 @@ const UNREQUESTABLE_SHAPES = Object.freeze(new Set(['control-plane', 'co-tenant'
|
|
|
105
123
|
|
|
106
124
|
// shapeAuthority — what does this wire value demand? PURE. Returns one of:
|
|
107
125
|
// 'open' → admit with no permission
|
|
126
|
+
// 'one-free' → admit the caller's first live project of the shape; after that,
|
|
127
|
+
// require provisioning.fleet.manage
|
|
108
128
|
// 'unrequestable' → refuse outright, whatever the caller holds
|
|
109
129
|
// 'permission' → require provisioning.fleet.manage
|
|
110
130
|
// 'defer' → not a shape this gate recognises; let the route's own validator
|
|
@@ -128,18 +148,58 @@ function shapeAuthority(rawShape) {
|
|
|
128
148
|
if (!provisioning.HOSTING_SHAPES.includes(shape)) return 'defer';
|
|
129
149
|
if (UNREQUESTABLE_SHAPES.has(shape)) return 'unrequestable';
|
|
130
150
|
if (OPEN_SHAPES.has(shape)) return 'open';
|
|
151
|
+
if (ONE_FREE_SHAPES.has(shape)) return 'one-free';
|
|
131
152
|
return 'permission';
|
|
132
153
|
}
|
|
133
154
|
|
|
155
|
+
// otherLiveProjects — the caller's own projects of this shape that still hold their place,
|
|
156
|
+
// leaving out the slug being asked for: a retry or revive of your own project is that
|
|
157
|
+
// project again, not a second one. The slug is normalised exactly as the route does.
|
|
158
|
+
// Reads provisioning through its module object at request time so a test can re-point it.
|
|
159
|
+
async function otherLiveProjects(db, builderId, shape, rawSlug) {
|
|
160
|
+
const slug = String(rawSlug || '').trim().toLowerCase();
|
|
161
|
+
const rows = await provisioning.listInstancesForOwner(db, builderId);
|
|
162
|
+
return rows.filter((r) => r.hosting_shape === shape && r.status !== 'torn_down' && r.slug !== slug);
|
|
163
|
+
}
|
|
164
|
+
|
|
134
165
|
// requireAuthorityForShape — the create route's shape gate. Mount it AFTER requireBuilder
|
|
135
166
|
// and BEFORE the handler, so a refusal leaves no row behind for the runner to drain later.
|
|
136
167
|
// `requirePermission` is injected (rather than required here) so the gate is exercisable
|
|
137
|
-
// without standing up the auth stack.
|
|
138
|
-
function requireAuthorityForShape(requirePermission) {
|
|
168
|
+
// without standing up the auth stack; `db` and `log` serve the one-free count.
|
|
169
|
+
function requireAuthorityForShape(requirePermission, db, log) {
|
|
139
170
|
const gate = requirePermission('provisioning.fleet.manage');
|
|
140
|
-
return function shapeGate(req, res, next) {
|
|
171
|
+
return async function shapeGate(req, res, next) {
|
|
141
172
|
const verdict = shapeAuthority(req.body && req.body.hosting_shape);
|
|
142
173
|
if (verdict === 'open' || verdict === 'defer') return next();
|
|
174
|
+
if (verdict === 'one-free') {
|
|
175
|
+
let others;
|
|
176
|
+
try {
|
|
177
|
+
// Read through the module object at request time so a test can re-point it.
|
|
178
|
+
const release = await freePlace.holdFreePlace(db, req.builder.id);
|
|
179
|
+
if (!release) {
|
|
180
|
+
return res.fail('create_in_progress', {
|
|
181
|
+
status: 409,
|
|
182
|
+
message: 'Another project request from this account is still being answered. Try again once it finishes.',
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
// Held until the row exists: the handler calls req.releaseFreePlace right after its
|
|
186
|
+
// insert, so the lock's connection is not kept through the rest of the request.
|
|
187
|
+
// Every earlier exit (a refusal, a validation 400, a thrown error, a client that
|
|
188
|
+
// hangs up) releases on 'close' instead — 'close', not 'finish', because 'finish'
|
|
189
|
+
// never fires for a response the client abandoned. Release is idempotent.
|
|
190
|
+
req.releaseFreePlace = release;
|
|
191
|
+
res.once('close', () => { release().catch(() => {}); });
|
|
192
|
+
others = await otherLiveProjects(db, req.builder.id, req.body.hosting_shape, req.body.slug);
|
|
193
|
+
} catch (err) {
|
|
194
|
+
// Fail CLOSED (ADR 0016): a lock or count that could not be read is not a free place.
|
|
195
|
+
log.error('[provisioning] shape gate could not lock or count the caller\'s projects; refusing', err);
|
|
196
|
+
return failFrom(res, err, 'request_failed');
|
|
197
|
+
}
|
|
198
|
+
if (others.length === 0) return next();
|
|
199
|
+
// A further live project: the same permission as any gated shape, and the kernel
|
|
200
|
+
// gate's own refusal (permission_forbidden, naming the atom) is the answer.
|
|
201
|
+
return gate(req, res, next);
|
|
202
|
+
}
|
|
143
203
|
if (verdict === 'unrequestable') {
|
|
144
204
|
// Two shapes land here for different reasons, so the message names which.
|
|
145
205
|
//
|
|
@@ -167,8 +227,8 @@ function requireAuthorityForShape(requirePermission) {
|
|
|
167
227
|
};
|
|
168
228
|
}
|
|
169
229
|
|
|
170
|
-
// Only the gate is exported. OPEN_SHAPES, UNREQUESTABLE_SHAPES and
|
|
171
|
-
// deliberately private: nothing outside this file needs to ask "what
|
|
172
|
-
// demand" separately from being gated on it, and an export nobody
|
|
173
|
-
// thing to keep true.
|
|
230
|
+
// Only the gate is exported. OPEN_SHAPES, ONE_FREE_SHAPES, UNREQUESTABLE_SHAPES and
|
|
231
|
+
// shapeAuthority are deliberately private: nothing outside this file needs to ask "what
|
|
232
|
+
// does this shape demand" separately from being gated on it, and an export nobody
|
|
233
|
+
// consumes is one more thing to keep true.
|
|
174
234
|
module.exports = { requireAuthorityForShape };
|