@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.
Files changed (39) hide show
  1. package/.bongos-core.json +70 -40
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +2 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0354-a-builder-hosts-one-project-free-and-more-takes-the-fleet-permission.md +46 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +36 -2
  10. package/docs/api-reference.md +3 -2
  11. package/docs/copy-inventory.md +18 -18
  12. package/docs/copy-registry.json +20 -20
  13. package/docs/design/project-startup-direction.md +131 -0
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/page-readings.json +15 -15
  16. package/modules/discord/board-broadcast.js +1 -0
  17. package/modules/government/board.js +204 -2
  18. package/modules/government/db.js +42 -1
  19. package/modules/government/migrations/government_020_genesis_stage_subject.sql +55 -0
  20. package/modules/government/routes/government.js +20 -0
  21. package/modules/hall-ui/public/board-room.js +5 -0
  22. package/modules/provisioning/free-place-lock.js +52 -0
  23. package/modules/provisioning/paid-shape-gate.js +90 -30
  24. package/modules/provisioning/routes/provisioning.js +6 -6
  25. package/modules/public-landing/public/projects.html +7 -0
  26. package/package-lock.json +2 -2
  27. package/package.json +1 -1
  28. package/release-notes.json +16 -0
  29. package/src/bongos/route-rank-check.js +3 -0
  30. package/src/module-api.js +1 -1
  31. package/tests/government_board_genesis_stage.mjs +305 -0
  32. package/tests/government_seed.mjs +1 -0
  33. package/tests/project_catalog_routes.mjs +2 -0
  34. package/tests/projects_hub_pre_uat.mjs +1 -1
  35. package/tests/provisioning_capacity_gate.mjs +2 -0
  36. package/tests/provisioning_domain_attach.mjs +2 -0
  37. package/tests/provisioning_paid_shape_gate.mjs +43 -11
  38. package/tests/provisioning_teardown_intent.mjs +2 -0
  39. 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
- const authorKarma = outcome === 'passed' ? await awardAuthorRatifyKarma(closed) : null;
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
- const subject = item.subject_type === 'full_idea'
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,
@@ -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;
@@ -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
- // (task 1003682, widened by task 1003370 / audit B4).
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 ONE of
13
- // the shapes it accepts is actually that front door. The others each hand the caller
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 — runs the caller's OWN repo on a host the platform stands up. TODAY
26
- // that host is still the platform's own box, and that is what this
27
- // entry gates: the unit's WorkingDirectory is that checkout
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/` — arbitrary code execution on the
32
- // control plane's own machine. Task 1003369 gave each instance its own
33
- // uid, which bounds the blast radius to that account; it does not make
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
- // Once ADR 0327 lands, a cloud-host stands up on the OWNER's platform
37
- // account instead and that specific argument dissolves — but the gate
38
- // does NOT automatically follow, because the platform is still doing
39
- // the standing-up and still spending its own runner on it. Re-decide
40
- // this entry when the move happens (task 1004184); do not assume the
41
- // shape becomes open just because the box stops being ours.
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 set named below is the set that needs NO authority. Every other shape, including
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. This is
84
- // the whole allow-list, and it is deliberately one entry long: byo-host is the front
85
- // door, and the front door is the only thing that should be free. `tests/
86
- // provisioning_paid_shape_gate.mjs` pins HOSTING_SHAPES against this set so a new shape
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 shapeAuthority are
171
- // deliberately private: nothing outside this file needs to ask "what does this shape
172
- // demand" separately from being gated on it, and an export nobody consumes is one more
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 };