@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
@@ -285,14 +285,13 @@ module.exports = function provisioningRoutes() {
285
285
  });
286
286
 
287
287
  // POST /provisioning/instances — request a new instance. rank: any authenticated
288
- // builder (own resource) for the free `co-tenant` shape. Every other shape also needs
289
- // provisioning.fleet.manage — `dedicated` because it bills, `standalone` because it runs
290
- // the caller's own repo on the platform's box — and `control-plane` is refused outright,
291
- // since the platform enrolls its own row (../paid-shape-gate.js, task 1003370 / audit B4).
288
+ // builder (own resource) for `byo-host`, and for their FIRST live `cloud-host` (task
289
+ // 1004412, ADR 0354). A further cloud-host needs provisioning.fleet.manage, and
290
+ // `control-plane` / `co-tenant` are refused outright (../paid-shape-gate.js).
292
291
  // A full shared box answers 409 box_full up front (../capacity-gate.js).
293
292
  // ENQUEUES a 'provision' intent the runner (task P3) stands up. Idempotent on slug for the SAME
294
293
  // owner (re-request returns the existing row); a slug owned by ANOTHER builder → 409.
295
- // Body: { slug (required), target_ref?, hosting_shape? (co-tenant free; others gated),
294
+ // Body: { slug (required), target_ref?, hosting_shape? (byo-host free; one cloud-host free),
296
295
  // domain?, tier?, onboard_mode? (greenfield|adopt) }.
297
296
  //
298
297
  // onboard_mode is LOAD-BEARING, not a label (task 1002562): the control-plane runner
@@ -300,7 +299,7 @@ module.exports = function provisioningRoutes() {
300
299
  // ALREADY has, so the runner must clone + layer onto its history instead of pushing a
301
300
  // fresh tree at `main` (which an existing repo rejects as unrelated history). Absent →
302
301
  // 'greenfield', the safe default.
303
- router.post('/provisioning/instances', auth.requireBuilder, requireAuthorityForShape(auth.requirePermission), requireRoomOnBox(pool, log), async (req, res) => {
302
+ router.post('/provisioning/instances', auth.requireBuilder, requireAuthorityForShape(auth.requirePermission, pool, log), requireRoomOnBox(pool, log), async (req, res) => {
304
303
  if (validateOrRespond(req, res, {
305
304
  slug: { required: true, type: 'string', maxLength: 40 },
306
305
  target_ref: { type: 'string', maxLength: 200 },
@@ -427,6 +426,7 @@ module.exports = function provisioningRoutes() {
427
426
  // with the same standing: skipped, and resolved from the bundle on read.
428
427
  modules: body.modules,
429
428
  });
429
+ if (req.releaseFreePlace) await req.releaseFreePlace(); // the row exists: the one-free lock's work is done (../free-place-lock.js)
430
430
  // Slug already taken by ANOTHER builder — refuse (never reassign ownership).
431
431
  if (!created && String(instance.owner_builder_id) !== String(req.builder.id)) {
432
432
  return res.fail('slug_taken', { status: 409, message: 'That instance slug is already in use.' });
@@ -4868,6 +4868,13 @@ summary{min-height:24px;padding:3px 0;}
4868
4868
  if (code === 'bad_domain') return 'The domain didn’t parse as a real hostname — use something like myproject.example.com.';
4869
4869
  if (code === 'repo_scope_required') return 'GitHub hasn’t granted repository access for this session. Sign in again — it asks for repository access — and try once more.';
4870
4870
  if (code === 'request_failed') return 'The platform couldn’t file the request. Nothing was created — try again in a moment.';
4871
+ /* the shape gate (task 1004412): an account hosts ONE project without the fleet
4872
+ permission, and this wizard only asks for a hosted project, so a refusal naming
4873
+ that permission means "you already have one". held is null when authority could
4874
+ not be checked at all: a blip, so it falls to the generic sentence instead. */
4875
+ var details = code ? err.data.error.details : null;
4876
+ if (code === 'permission_forbidden' && details && Array.isArray(details.held)) return 'Each account can host one project for free, and yours already has one. To start a new one, take the old one offline from its page under Your projects. Trusted builders can host more than one — ask the platform team if you need that.';
4877
+ if (code === 'box_full') return 'Our shared server is full right now, so there’s no room for a new project. Nothing was created — try again later.';
4871
4878
  return '';
4872
4879
  }
4873
4880
  function createFailureMarkup(err) {
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.24",
3
+ "version": "1.20.26",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.24",
9
+ "version": "1.20.26",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.24",
3
+ "version": "1.20.26",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -8200,5 +8200,21 @@
8200
8200
  "id": "1004428",
8201
8201
  "text": "The plan for the new project-startup experience is agreed and written down. New projects begin with a demo-or-create choice and seven planet questions, and each project picks its own colours and logo. While a project is being"
8202
8202
  }
8203
+ ],
8204
+ "1.20.25": [
8205
+ {
8206
+ "id": "1004415",
8207
+ "text": "The project-startup design is now saved in the project as the official reference. It sets out screen by screen what the current builders hall keeps and what founding mode adds, so the new design never takes over the existing h"
8208
+ },
8209
+ {
8210
+ "id": "1004412",
8211
+ "text": "New users can create a project again. Since 19 September, anyone new who pressed \"Create my project\" was turned away with a vague error, because hosted projects had been limited to trusted builders. Now everyone who is signed"
8212
+ }
8213
+ ],
8214
+ "1.20.26": [
8215
+ {
8216
+ "id": "1004429",
8217
+ "text": "A new project's founding stages are now decided by its own government. When a stage's work is finished, closing the stage goes to the Board Room like any other decision. A solo founder under the default one-leader government c"
8218
+ }
8203
8219
  ]
8204
8220
  }
@@ -268,6 +268,9 @@ const GOVERNMENT_PINS = {
268
268
  // R18: the Constitution view — the live rule + the amendment history; reading
269
269
  // the rule you vote under is part of casting.
270
270
  'GET /government/constitution': 'perm:board.vote.cast',
271
+ // task 1004429: the founding band — who each genesis stage is waiting on. A
272
+ // read of the open ballot, so the same atom as the room.
273
+ 'GET /government/board/genesis-stages': 'perm:board.vote.cast',
271
274
  };
272
275
 
273
276
  // Module-scoped privileged-rank pins: a privileged route that lives in a FEATURE
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.20.24'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.26'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -0,0 +1,305 @@
1
+ // tests/government_board_genesis_stage.mjs — a genesis stage is the Board
2
+ // Room's third subject (task 1004429 / BV2.PS04, spec decision D7).
3
+ //
4
+ // When a founding stage's tasks are all finished (shipped or dropped), closing
5
+ // the stage is put to the constitution IN FORCE as a board item. DB-free (db
6
+ // monkeypatched, membership logic real). The invariants:
7
+ //
8
+ // 1. THE GATE IS "THE STAGE'S TASKS ARE FINISHED", and it lives here — an
9
+ // unknown stage, an empty task list, or any unfinished (or missing) task
10
+ // opens nothing, before any write.
11
+ // 2. THE ITEM IS AN ORDINARY ITEM — it snapshots the live constitution, runs
12
+ // the live window, and is idempotent through government_003's one-open-
13
+ // per-subject index (subject_id = the stage number).
14
+ // 3. MONARCHY: THE FOUNDER'S ONE CONFIRM CLOSES IT — under first_ratifier the
15
+ // founder who put the stage up casts yes and the sitting passes. No bypass
16
+ // makes that true: the author rule is full_idea-scoped (ADR 0191), exactly
17
+ // as for an amendment, so the founder is simply the first ratifier.
18
+ // 4. EVERY OTHER SHAPE IS A REAL SITTING — consent waits for full turnout,
19
+ // majority counts the membership; no new pass rule exists.
20
+ // 5. A PASS FIRES `board.genesis_stage.passed` for the genesis home, pays no
21
+ // credit and no ratify karma.
22
+ // 6. "WHO IS THIS STAGE WAITING ON" is answered from the item's own snapshot
23
+ // and the live members, and the founder is SUMMONED for their own stage.
24
+ import assert from 'node:assert/strict';
25
+ import { test } from 'node:test';
26
+ import { createRequire } from 'node:module';
27
+ import fs from 'node:fs';
28
+ import path from 'node:path';
29
+ import { fileURLToPath } from 'node:url';
30
+
31
+ const require = createRequire(import.meta.url);
32
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
33
+ const GOV = path.join(ROOT, 'modules', 'government');
34
+ const api = require(path.join(ROOT, 'src', 'module-api.js'));
35
+ const board = require(path.join(GOV, 'board.js'));
36
+ const db = require(path.join(GOV, 'db.js'));
37
+ const config = require(path.join(GOV, 'config.js'));
38
+
39
+ const { GENESIS_STAGES, openGenesisStageClose, genesisStageStatus, castVote, maybeCloseAfterVote, closeItem } = board;
40
+
41
+ const FOUNDER = '42';
42
+ const AVERY = '43';
43
+ const JORDAN = '44';
44
+
45
+ const MONARCHY = Object.freeze({
46
+ membership: 'rank:archon', pass_rule: 'first_ratifier', window_minutes: null, close_early_on_full_turnout: true,
47
+ });
48
+ const CONSENT = Object.freeze({ ...MONARCHY, membership: 'rank:metic+', pass_rule: 'consent', window_minutes: 1440 });
49
+ const MAJORITY = Object.freeze({ ...CONSENT, pass_rule: 'majority' });
50
+
51
+ async function withDb(stubs, fn) {
52
+ const originals = {};
53
+ for (const [k, v] of Object.entries(stubs)) { originals[k] = db[k]; db[k] = v; }
54
+ try { return await fn(); } finally { for (const [k, v] of Object.entries(originals)) db[k] = v; }
55
+ }
56
+
57
+ // Capture one seam event for the duration of fn.
58
+ async function capturing(event, fn) {
59
+ const seen = [];
60
+ const off = api.on(event, (p) => { seen.push(p); });
61
+ try { await fn(); } finally { if (typeof off === 'function') off(); }
62
+ return seen;
63
+ }
64
+
65
+ const stageItem = (over = {}) => ({
66
+ id: '90', subject_type: 'genesis_stage', subject_id: '3', opened_by: FOUNDER,
67
+ closes_at: null, closed_at: null, outcome: null, expired: false, constitution: MONARCHY, ...over,
68
+ });
69
+ const vote = (voter, direction = 'yes', extra = {}) => ({ voter_id: voter, direction, ...extra });
70
+ const member = (id, login) => ({ builder_id: id, github_login: login, display_name: login });
71
+
72
+ // ── the stage list ──────────────────────────────────────────────────────────
73
+
74
+ test('the five founding stages, in order, numbered 1..5 — the range the migration CHECK allows', () => {
75
+ assert.deepEqual(GENESIS_STAGES.map((s) => s.key), ['scope', 'strategize', 'charter', 'legalize', 'legislation']);
76
+ assert.deepEqual(GENESIS_STAGES.map((s) => s.number), [1, 2, 3, 4, 5]);
77
+ assert.ok(Object.isFrozen(GENESIS_STAGES) && GENESIS_STAGES.every(Object.isFrozen));
78
+ const sql = fs.readFileSync(path.join(GOV, 'migrations', 'government_020_genesis_stage_subject.sql'), 'utf8');
79
+ assert.match(sql, /subject_type IN \('full_idea', 'constitutional_amendment', 'genesis_stage'\)/,
80
+ 'the CHECK widens to the third subject and keeps the first two');
81
+ assert.match(sql, new RegExp(`subject_id BETWEEN 1 AND ${GENESIS_STAGES.length}`),
82
+ 'the stage-number range in the database is the length of the one list here');
83
+ });
84
+
85
+ // ── invariant 1: the finished-tasks gate ─────────────────────────────────────
86
+
87
+ test('nothing opens for an unknown stage, an empty task list, or any unfinished or missing task', async () => {
88
+ await withDb({
89
+ openBoardItem: async () => { throw new Error('must not be reached'); },
90
+ getTaskStatuses: async (ids) => ids.filter((id) => id !== 103).map((id) => ({
91
+ id: String(id), status: id === 102 ? 'active' : 'shipped',
92
+ })),
93
+ }, async () => {
94
+ assert.equal((await openGenesisStageClose({ stageKey: 'coronation', taskIds: [101], openedBy: FOUNDER })).reason, 'unknown_stage');
95
+ assert.equal((await openGenesisStageClose({ stageKey: 'charter', taskIds: [], openedBy: FOUNDER })).reason, 'no_stage_tasks');
96
+ assert.equal((await openGenesisStageClose({ stageKey: 'charter', taskIds: ['x'], openedBy: FOUNDER })).reason, 'no_stage_tasks');
97
+ const out = await openGenesisStageClose({ stageKey: 'charter', taskIds: [101, 102, 103], openedBy: FOUNDER });
98
+ assert.equal(out.opened, false);
99
+ assert.equal(out.reason, 'stage_tasks_unfinished');
100
+ assert.deepEqual(out.unfinished, [
101
+ { task_id: '102', status: 'active' },
102
+ { task_id: '103', status: null },
103
+ ], 'each blocker is named, and a task nobody can find counts as unfinished');
104
+ });
105
+ });
106
+
107
+ // ── invariant 2: an ordinary item under the constitution in force ────────────
108
+
109
+ test('finished tasks (shipped AND dropped) open ONE item that snapshots the live constitution', async () => {
110
+ let opened = null;
111
+ const live = config.loadGovernmentConfig().board;
112
+ const events = await capturing('board.item.opened', () => withDb({
113
+ getTaskStatuses: async () => [{ id: '101', status: 'shipped' }, { id: '102', status: 'abandoned' }],
114
+ openBoardItem: async (args) => { opened = args; return { ...stageItem(), constitution: args.constitution }; },
115
+ }, async () => {
116
+ const out = await openGenesisStageClose({ stageKey: 'charter', taskIds: [101, '102'], openedBy: FOUNDER });
117
+ assert.equal(out.opened, true);
118
+ assert.equal(out.stage.key, 'charter');
119
+ }));
120
+ assert.equal(opened.subjectType, 'genesis_stage');
121
+ assert.equal(opened.subjectId, 3, 'the subject is the stage NUMBER — one open sitting per stage, structurally');
122
+ assert.equal(opened.openedBy, FOUNDER);
123
+ assert.deepEqual(opened.constitution, live, 'the item snapshots the constitution in force, like every item');
124
+ assert.equal(opened.windowMinutes, live.window_minutes, 'the live clock rule, unchanged');
125
+ assert.equal(events.length, 1, 'the window-open nudge fires, as for any item');
126
+ });
127
+
128
+ test('a second open while the stage sits returns the standing item and opens nothing', async () => {
129
+ const events = await capturing('board.item.opened', () => withDb({
130
+ getTaskStatuses: async () => [{ id: '101', status: 'shipped' }],
131
+ openBoardItem: async () => null, // the partial unique index said no
132
+ getOpenBoardItem: async (type, id) => (type === 'genesis_stage' && id === 3 ? stageItem() : null),
133
+ }, async () => {
134
+ const out = await openGenesisStageClose({ stageKey: 'charter', taskIds: [101], openedBy: FOUNDER });
135
+ assert.equal(out.opened, false);
136
+ assert.equal(out.reason, 'already_open');
137
+ assert.equal(out.item.id, '90');
138
+ }));
139
+ assert.equal(events.length, 0, 'nobody is summoned twice');
140
+ });
141
+
142
+ // ── invariant 3: the monarchy default — the founder's one confirm ────────────
143
+
144
+ test('under Monarchy / BDFL the founder\'s single yes closes the stage — no bypass, just the first ratifier', async () => {
145
+ const votes = [];
146
+ let karmaPaid = false;
147
+ const passed = await capturing('board.genesis_stage.passed', async () => {
148
+ await withDb({
149
+ getBoardItemForVote: async () => stageItem(),
150
+ builderHoldsRankActive: async (id) => id === FOUNDER,
151
+ getActiveBuildersWithRank: async () => [member(FOUNDER, 'founder')],
152
+ // Returns opened_by for any non-idea subject — the founder. The point of
153
+ // the test is that the deciding rules never hold it against them.
154
+ getBoardItemAuthor: async (item) => item.opened_by,
155
+ insertBoardVote: async (row) => { const v = { ...row, voter_id: row.voterId, direction: row.direction }; votes.push(v); return v; },
156
+ insertVoteKarma: async () => {},
157
+ getVotesForItem: async () => votes,
158
+ closeBoardItem: async ({ outcome, afterClose }) => {
159
+ assert.equal(afterClose, null, 'a stage close books no credit — nothing rides the transaction');
160
+ return { ...stageItem(), closed_at: 'T', outcome };
161
+ },
162
+ insertAuthorRatifyKarma: async () => { karmaPaid = true; return true; },
163
+ }, async () => {
164
+ const cast = await castVote({ itemId: '90', voterId: FOUNDER, direction: 'yes' });
165
+ assert.equal(cast.ok, true);
166
+ assert.equal(cast.self_cast, false, 'the author rule is full_idea-scoped — the founder is not a self-caster here');
167
+ const closed = await maybeCloseAfterVote('90');
168
+ assert.equal(closed.closed, true);
169
+ assert.equal(closed.outcome, 'passed');
170
+ });
171
+ });
172
+ assert.equal(karmaPaid, false, 'closing a stage ratifies no authored work — no ratify karma');
173
+ assert.deepEqual(passed, [{ item_id: '90', stage_key: 'charter', stage_number: 3, passed_at: 'T' }],
174
+ 'the genesis home hears exactly which stage passed');
175
+ });
176
+
177
+ // ── invariant 4: every other shape is a real sitting ─────────────────────────
178
+
179
+ test('under consent the founder\'s yes does not end it — the sitting waits for full turnout', async () => {
180
+ const members = [member(FOUNDER, 'founder'), member(AVERY, 'avery')];
181
+ const votes = [vote(FOUNDER)];
182
+ await withDb({
183
+ getBoardItemForVote: async () => stageItem({ constitution: CONSENT }),
184
+ getActiveBuildersWithRank: async () => members,
185
+ getVotesForItem: async () => votes,
186
+ getBoardItemAuthor: async (item) => item.opened_by,
187
+ closeBoardItem: async ({ outcome }) => ({ ...stageItem({ constitution: CONSENT }), closed_at: 'T', outcome }),
188
+ insertAuthorRatifyKarma: async () => true,
189
+ }, async () => {
190
+ assert.equal((await maybeCloseAfterVote('90')).reason, 'window_still_open');
191
+ votes.push(vote(AVERY));
192
+ const out = await maybeCloseAfterVote('90');
193
+ assert.equal(out.outcome, 'passed', 'every member has spoken, nobody objected');
194
+ });
195
+ });
196
+
197
+ test('under majority, silence is not assent: one yes of three returns the stage at expiry', async () => {
198
+ const members = [member(FOUNDER, 'f'), member(AVERY, 'a'), member(JORDAN, 'j')];
199
+ const passed = await capturing('board.genesis_stage.passed', () => withDb({
200
+ getVotesForItem: async () => [vote(FOUNDER)],
201
+ getActiveBuildersWithRank: async () => members,
202
+ getBoardItemAuthor: async (item) => item.opened_by,
203
+ closeBoardItem: async ({ outcome }) => ({ ...stageItem({ constitution: MAJORITY }), closed_at: 'T', outcome }),
204
+ }, async () => {
205
+ const out = await closeItem(stageItem({ constitution: MAJORITY }), { cause: 'expiry' });
206
+ assert.equal(out.outcome, 'returned');
207
+ }));
208
+ assert.equal(passed.length, 0, 'a returned stage announces no pass');
209
+ });
210
+
211
+ // ── invariant 6: who is this stage waiting on ────────────────────────────────
212
+
213
+ test('the stage status names who each open sitting is waiting on, from the item\'s own snapshot', async () => {
214
+ const items = [
215
+ { ...stageItem({ id: '80', subject_id: '1' }), closed_at: 'T1', outcome: 'passed' },
216
+ stageItem({ id: '90', subject_id: '3', constitution: CONSENT, closes_at: 'T9', remaining_seconds: 600 }),
217
+ ];
218
+ let resolvedFor = null;
219
+ await withDb({
220
+ listLatestGenesisStageItems: async () => items,
221
+ getVotesForItems: async () => [{ item_id: '90', voter_id: AVERY, direction: 'yes', github_login: 'avery', display_name: 'Avery' }],
222
+ getActiveBuildersWithRank: async (keys) => { resolvedFor = keys; return [member(FOUNDER, 'founder'), member(AVERY, 'avery')]; },
223
+ }, async () => {
224
+ const status = await genesisStageStatus();
225
+ assert.deepEqual(status.stages.map((s) => [s.key, s.state]), [
226
+ ['scope', 'passed'], ['strategize', 'not_put'], ['charter', 'sitting'], ['legalize', 'not_put'], ['legislation', 'not_put'],
227
+ ]);
228
+ const charter = status.stages[2];
229
+ assert.equal(charter.item_id, '90');
230
+ assert.equal(charter.pass_rule, 'consent');
231
+ assert.equal(charter.remaining_seconds, 600, 'the database\'s own countdown, never client clock math');
232
+ assert.deepEqual(charter.waiting_on.map((m) => m.builder_id), [FOUNDER]);
233
+ assert.deepEqual(charter.voted.map((m) => [m.builder_id, m.direction]), [[AVERY, 'yes']]);
234
+ assert.equal(typeof charter.needs, 'string');
235
+ assert.ok(charter.needs.length > 0, 'the rule is said in words');
236
+ assert.equal(status.stages[0].waiting_on.length, 0, 'a closed stage waits on nobody');
237
+ });
238
+ assert.ok(resolvedFor.includes('archon') && resolvedFor.includes('metic'),
239
+ 'membership is resolved under the SNAPSHOT (rank:metic+), not the live config');
240
+ });
241
+
242
+ test('under monarchy the band says one vote decides, and names every member who could give it', async () => {
243
+ await withDb({
244
+ listLatestGenesisStageItems: async () => [stageItem()],
245
+ getVotesForItems: async () => [],
246
+ getActiveBuildersWithRank: async () => [member(FOUNDER, 'founder')],
247
+ }, async () => {
248
+ const charter = (await genesisStageStatus()).stages[2];
249
+ assert.equal(charter.one_vote_decides, true);
250
+ assert.deepEqual(charter.waiting_on.map((m) => m.builder_id), [FOUNDER]);
251
+ });
252
+ });
253
+
254
+ test('every pass rule the constitution knows has its words for the band — no more, no fewer', () => {
255
+ assert.deepEqual(Object.keys(board.PASS_RULE_WORDS).sort(), [...config.PASS_RULES].sort());
256
+ });
257
+
258
+ test('the latest-sitting read has its own partial index, keyed to its ORDER BY', () => {
259
+ const sql = fs.readFileSync(path.join(GOV, 'migrations', 'government_020_genesis_stage_subject.sql'), 'utf8');
260
+ assert.match(sql, /ON government_board_items \(subject_id, opened_at DESC, id DESC\)\s+WHERE subject_type = 'genesis_stage'/);
261
+ const src = fs.readFileSync(path.join(GOV, 'db.js'), 'utf8');
262
+ const block = src.slice(src.indexOf('async function listLatestGenesisStageItems'), src.indexOf('// Author-ratification karma'));
263
+ assert.match(block, /WHERE subject_type = 'genesis_stage'\s+ORDER BY subject_id, opened_at DESC, id DESC/,
264
+ 'the query filters and orders exactly as the index is built');
265
+ });
266
+
267
+ test('the founder is summoned for their own stage — the author hold-back is not applied to it', () => {
268
+ const src = fs.readFileSync(path.join(GOV, 'db.js'), 'utf8');
269
+ const block = src.slice(src.indexOf('async function openBoardItemsAwaitingVoter'), src.indexOf('async function getTaskStatuses'));
270
+ assert.match(block, /WHEN i\.subject_type = 'genesis_stage' THEN NULL/,
271
+ 'the summons SQL yields no author for a stage, so pendingVotesFor never skips the founder');
272
+ });
273
+
274
+ // ── the surfaces that carry it ───────────────────────────────────────────────
275
+
276
+ test('the Board Room view names the stage instead of mistaking it for an amendment', async () => {
277
+ await withDb({
278
+ listOpenBoardItems: async () => [stageItem()],
279
+ listClosedBoardItems: async () => [],
280
+ getVotesForItems: async () => [],
281
+ getIdeaSubjects: async () => [],
282
+ getAmendmentSubjects: async () => [],
283
+ }, async () => {
284
+ const view = await board.boardRoomView();
285
+ assert.deepEqual(view.open[0].subject, { stage_key: 'charter', stage_number: 3, label: 'Charter', author_id: FOUNDER });
286
+ });
287
+ const room = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'board-room.js'), 'utf8');
288
+ assert.match(room, /subject_type === 'genesis_stage'/, 'the hall card has its own line for a stage');
289
+ const discord = require(path.join(ROOT, 'modules', 'discord', 'board-broadcast.js'));
290
+ if (typeof discord.subjectLabel === 'function') {
291
+ assert.match(discord.subjectLabel('genesis_stage'), /genesis stage/i);
292
+ } else {
293
+ const src = fs.readFileSync(path.join(ROOT, 'modules', 'discord', 'board-broadcast.js'), 'utf8');
294
+ assert.match(src, /genesis_stage/, 'the announcer does not call a stage "A Full Idea"');
295
+ }
296
+ });
297
+
298
+ test('the port carries the open and the status; the status route is pinned on the vote atom', () => {
299
+ const routes = fs.readFileSync(path.join(GOV, 'routes', 'government.js'), 'utf8');
300
+ assert.match(routes, /openGenesisStageClose: board\.openGenesisStageClose/);
301
+ assert.match(routes, /genesisStageStatus: board\.genesisStageStatus/);
302
+ assert.match(routes, /router\.get\('\/government\/board\/genesis-stages', api\.requireBuilder, api\.requirePermission\('board\.vote\.cast'\)/);
303
+ const pins = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'route-rank-check.js'), 'utf8');
304
+ assert.match(pins, /'GET \/government\/board\/genesis-stages': 'perm:board\.vote\.cast'/);
305
+ });
@@ -43,6 +43,7 @@ const NON_GRANT_MIGRATIONS = new Set([
43
43
  'government_008_closed_items_index.sql', // R16 closed-history index (task 1003029)
44
44
  'government_017_board_section_keys_v2.sql', // BV2.R06 board section_key CHECK widening (task 1004085)
45
45
  'government_018_passed_items_index.sql', // BV2.R17 passed-item lookup index (task 1004096)
46
+ 'government_020_genesis_stage_subject.sql', // BV2.PS04 genesis_stage board subject CHECK (task 1004429)
46
47
  ]);
47
48
 
48
49
  // Permission keys RENAMED after the grant migrations that first granted them.
@@ -52,6 +52,8 @@ let healed = [];
52
52
  provisioning.getInstanceById = async (_db, id) => (instance ? { ...instance, id } : null);
53
53
  provisioning.getInstanceBySlug = async (_db, slug) => (instance && instance.slug === slug ? { ...instance } : null);
54
54
  provisioning.getInstanceByDomain = async () => null;
55
+ provisioning.listInstancesForOwner = async () => []; // the shape gate's count (task 1004412): no other hosted project
56
+ require('../modules/provisioning/free-place-lock.js').holdFreePlace = async () => async () => {}; // and its lock: always free here
55
57
  provisioning.createInstance = async (_db, req) => {
56
58
  instance = { ...BASE_ROW, slug: req.slug, domain: req.domain || null, description: req.description || null };
57
59
  return { instance: { ...instance }, created: true };
@@ -508,7 +508,7 @@ test('an unmapped create failure gets a plain sentence with the raw text folded
508
508
  const friendly = fn('friendlyCreateError');
509
509
  assert.match(friendly, /return '';\n\s*\}/, 'unmapped codes return nothing — the caller folds them');
510
510
  assert.doesNotMatch(friendly, /Couldn’t file the request: ' \+/, 'the raw message is no longer the headline');
511
- for (const code of ['slug_taken', 'bad_slug', 'domain_reserved', 'domain_taken', 'bad_domain', 'repo_scope_required', 'request_failed']) {
511
+ for (const code of ['slug_taken', 'bad_slug', 'domain_reserved', 'domain_taken', 'bad_domain', 'repo_scope_required', 'request_failed', 'permission_forbidden', 'box_full']) {
512
512
  assert.match(friendly, new RegExp(`code === '${code}'`), `${code} stays headline-only`);
513
513
  }
514
514
  assert.match(friendly, /belongs to the platform’s own domain/, 'domain_reserved covers both server branches');
@@ -48,6 +48,8 @@ capacity.fleetCapacity = async () => {
48
48
  const provisioning = require('../modules/provisioning/provisioning.js');
49
49
  let existing = null, lastCreateArgs = null;
50
50
  provisioning.getInstanceBySlug = async () => existing;
51
+ provisioning.listInstancesForOwner = async () => []; // the shape gate's count (task 1004412): no other hosted project
52
+ require('../modules/provisioning/free-place-lock.js').holdFreePlace = async () => async () => {}; // and its lock: always free here
51
53
  provisioning.createInstance = async (_db, args) => {
52
54
  lastCreateArgs = args;
53
55
  if (existing) return { instance: existing, created: false };
@@ -118,6 +118,8 @@ provisioning.enqueueIntent = async (_db, id, action, requestedBy) => {
118
118
  provisioning.getOpenIntent = async () => null;
119
119
  provisioning.createInstance = async () => createResult;
120
120
  provisioning.getInstanceBySlug = async () => null; // the task-1002704 birth-time pre-check
121
+ provisioning.listInstancesForOwner = async () => []; // the shape gate's count (task 1004412): no other hosted project
122
+ require('../modules/provisioning/free-place-lock.js').holdFreePlace = async () => async () => {}; // and its lock: always free here
121
123
  provisioning.recordEvent = async () => {};
122
124
  provisioning.createOAuthManifest = async (_db, row) => { manifestRows.push(row); return { id: 1, ...row }; };
123
125
  provisioning.getLatestOAuthManifestForInstance = async () => null;
@@ -37,6 +37,13 @@
37
37
  // So the set below is now the set that needs NO authority, and a shape added later is gated
38
38
  // by omission instead of admitted by it.
39
39
  //
40
+ // NARROWED FOR ONE PROJECT BY TASK 1004412 (ADR 0354). Gating cloud-host outright also
41
+ // refused every new user at the create wizard's last button, because the wizard only ever
42
+ // asks for cloud-host, and ADR 0345 made that shape the free front door. The owner's ruling:
43
+ // a builder's FIRST live hosted project needs no permission, and a further one needs
44
+ // provisioning.fleet.manage. The route-level proof with a real Xenos is
45
+ // tests/provisioning_wizard_create_xenos.mjs; the cases below hold the gate's side.
46
+ //
40
47
  // DB-free: the pool is never reached (createInstance is stubbed), auth is faked. No network
41
48
  // beyond a loopback listener.
42
49
  //
@@ -65,6 +72,13 @@ provisioning.createInstance = async (_db, args) => {
65
72
  provisioning.enqueueIntent = async () => ({ intent: { id: 1, state: 'pending' }, created: true });
66
73
  provisioning.recordEvent = async () => {};
67
74
  provisioning.getInstanceBySlug = async () => null;
75
+ // The caller's own projects, which the one-free count reads (task 1004412). Flipped per case.
76
+ let owned = [];
77
+ provisioning.listInstancesForOwner = async () => owned;
78
+ // …and the free-place lock beside it (free-place-lock.js), always free here: the race is
79
+ // proved in tests/provisioning_wizard_create_xenos.mjs.
80
+ require('../modules/provisioning/free-place-lock.js').holdFreePlace = async () => async () => {};
81
+ const LIVE_HOSTED = { id: 9, owner_builder_id: 7, slug: 'older', hosting_shape: 'cloud-host', status: 'active' };
68
82
 
69
83
  const api = require('../src/module-api.js');
70
84
  api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
@@ -189,28 +203,42 @@ await ta('the refusal speaks WIRE vocabulary — no owner-facing label is transl
189
203
  'a refusal that names no alternative is a dead end — the stored values are the alternative');
190
204
  });
191
205
 
192
- // ── cloud-host runs the caller's OWN code on the platform's box (task 1003370) ────
206
+ // ── cloud-host runs the caller's OWN code on the platform's box ───────────────────
207
+ // Task 1003370 gated it outright; task 1004412 lets each builder host ONE project free.
193
208
 
194
- await ta('cloud-host: REFUSED for a builder without provisioning.fleet.manage', async () => {
195
- // Task 1003682 left this shape open, reasoning that a $0 shape hands the caller
196
- // nothing. That is true of the invoice and false of the trust: the systemd unit's
197
- // WorkingDirectory is the caller's own checkout and the module loader discovers
198
- // `modules/` from process.cwd(), so a cloud-host request is "please require() this
199
- // repo on your control plane". The uid split (task 1003369) bounds the blast radius;
200
- // it does not make the default acceptable.
209
+ await ta('cloud-host: a builder\'s FIRST live hosted project needs no permission (task 1004412)', async () => {
210
+ // The wizard's front door (ADR 0345). The trust question task 1003370 raised is still
211
+ // true — the unit's WorkingDirectory is the caller's own checkout, so the platform
212
+ // require()s their repo, bounded by the per-instance uid (task 1003369) — and the owner
213
+ // accepted it for one project per builder (ADR 0354).
201
214
  held = [];
215
+ owned = [];
202
216
  lastCreateArgs = null;
203
217
  const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'cloud-host', no_address: true });
218
+ assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
219
+ assert.equal(lastCreateArgs && lastCreateArgs.hostingShape, 'cloud-host');
220
+ });
221
+
222
+ await ta('cloud-host: a SECOND live hosted project is REFUSED without provisioning.fleet.manage', async () => {
223
+ // The limit is what stops one person taking every place on the shared box (ADR 0285).
224
+ // The refusal is the kernel permission gate's own, naming the atom.
225
+ held = [];
226
+ owned = [LIVE_HOSTED];
227
+ lastCreateArgs = null;
228
+ const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'cloud-host', no_address: true });
229
+ owned = [];
204
230
  assert.equal(res.status, 403, `expected 403, got ${res.status} ${res.raw}`);
205
231
  assert.equal(res.body.error.code, 'permission_forbidden');
206
232
  assert.deepEqual(res.body.error.details.required, ['provisioning.fleet.manage']);
207
233
  assert.equal(lastCreateArgs, null, 'no row is created when the shape is refused');
208
234
  });
209
235
 
210
- await ta('cloud-host: ADMITTED for a builder holding provisioning.fleet.manage', async () => {
236
+ await ta('cloud-host: ADMITTED past the limit for a builder holding provisioning.fleet.manage', async () => {
211
237
  held = ['provisioning.fleet.manage'];
238
+ owned = [LIVE_HOSTED, { ...LIVE_HOSTED, slug: 'oldest' }];
212
239
  lastCreateArgs = null;
213
240
  const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'cloud-host', no_address: true });
241
+ owned = [];
214
242
  assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
215
243
  assert.equal(lastCreateArgs && lastCreateArgs.hostingShape, 'cloud-host');
216
244
  });
@@ -271,8 +299,11 @@ await ta('the gate refuses BEFORE the row exists — a denied create leaves noth
271
299
  // The denial must land ahead of createInstance, not after it: a refused-but-inserted row
272
300
  // would still be drained by the runner, which reads the column, not the HTTP status.
273
301
  held = [];
302
+ owned = [LIVE_HOSTED]; // a second hosted project, so the shape is refused
274
303
  lastCreateArgs = null;
275
- await post('/provisioning/instances', { slug: 'spendy', hosting_shape: 'cloud-host', no_address: true });
304
+ const res = await post('/provisioning/instances', { slug: 'spendy', hosting_shape: 'cloud-host', no_address: true });
305
+ owned = [];
306
+ assert.equal(res.status, 403, `expected 403, got ${res.status} ${res.raw}`);
276
307
  assert.equal(lastCreateArgs, null);
277
308
  });
278
309
 
@@ -301,7 +332,8 @@ await ta('HOSTING_SHAPES is pinned, so a new shape forces a written decision abo
301
332
  // POST for a second control plane until task 1003370.
302
333
  //
303
334
  // So the question this list now forces is the one that generalises. The gate is an
304
- // allow-list: `byo-host` needs nothing, `control-plane` and `co-tenant` are refused
335
+ // allow-list: `byo-host` needs nothing, `cloud-host` needs nothing for a builder's first
336
+ // live project (task 1004412), `control-plane` and `co-tenant` are refused
305
337
  // outright (for different reasons — never-yours vs retired), and
306
338
  // EVERY other shape — including one added after this file was last read — demands
307
339
  // provisioning.fleet.manage by omission. A fifth shape therefore arrives already
@@ -187,6 +187,8 @@ provisioning.enqueueIntent = async (_db, id, action, requestedBy) => {
187
187
  provisioning.getOpenIntent = async () => null;
188
188
  provisioning.createInstance = async () => createResult;
189
189
  provisioning.getInstanceBySlug = async () => null; // the task-1002704 birth-time pre-check
190
+ provisioning.listInstancesForOwner = async () => []; // the shape gate's count (task 1004412): no other hosted project
191
+ require('../modules/provisioning/free-place-lock.js').holdFreePlace = async () => async () => {}; // and its lock: always free here
190
192
  provisioning.recordEvent = async () => {};
191
193
 
192
194
  const api = require('../src/module-api.js');