@bongos/core 1.20.25 → 1.20.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/.bongos-core.json +92 -37
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +7 -1
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/adr/0353-a-hub-invite-is-a-notice-of-the-projects-own-invite.md +1 -1
  8. package/docs/adr/0355-a-soft-limit-is-a-rule-with-a-reason-and-nobody-enforces-it.md +30 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +137 -3
  11. package/docs/api-reference.md +4 -2
  12. package/docs/copy-inventory.md +34 -34
  13. package/docs/copy-registry.json +38 -38
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/page-readings.json +75 -73
  16. package/modules/discord/board-broadcast.js +1 -0
  17. package/modules/government/board.js +261 -2
  18. package/modules/government/charter.js +232 -0
  19. package/modules/government/db.js +42 -1
  20. package/modules/government/migrations/government_020_genesis_stage_subject.sql +55 -0
  21. package/modules/government/routes/government.js +82 -0
  22. package/modules/hall-ui/public/board-room.js +5 -0
  23. package/modules/hall-ui/public/government.js +11 -0
  24. package/modules/platform-identity/migrations/platform_identity_028_purge_pre_notice_invites.sql +65 -0
  25. package/modules/provisioning/planet-physics.js +154 -0
  26. package/modules/provisioning/provisioning.js +29 -9
  27. package/modules/provisioning/routes/body-validators.js +25 -7
  28. package/modules/provisioning/routes/provisioning.js +8 -3
  29. package/modules/provisioning/screening.js +62 -0
  30. package/modules/provisioning/soft-limits.js +146 -0
  31. package/package-lock.json +2 -2
  32. package/package.json +1 -1
  33. package/release-notes.json +20 -0
  34. package/scripts/gds/run-unit-tests.js +4 -0
  35. package/src/bongos/route-rank-check.js +6 -0
  36. package/src/module-api.js +1 -1
  37. package/tests/government_board_genesis_stage.mjs +305 -0
  38. package/tests/government_charter_forms.mjs +283 -0
  39. package/tests/government_seed.mjs +1 -0
  40. package/tests/pending_invite_purge_db.mjs +129 -0
  41. package/tests/provisioning_planet_physics.mjs +377 -0
@@ -0,0 +1,146 @@
1
+ // modules/provisioning/soft-limits.js — which startup options do not apply to a
2
+ // project, and why (BV2.PS02, task 1004416; spec decision D6, "soft limits only").
3
+ //
4
+ // WHAT. One frozen table. Each rule says: WHEN these planet-physics answers hold, this
5
+ // OPTION is hidden, greyed, or carries a note — and here is the REASON, in a line the
6
+ // owner reads beside it. The recommendations.js pattern (ADR 0239): condition ->
7
+ // effect -> reason, and a resolver over the table. Nothing else.
8
+ //
9
+ // SOFT MEANS NOBODY ENFORCES IT. No route refuses an answer for breaking one of these
10
+ // rules, and none may: the server-side age and geography enforcement is a later goal.
11
+ // So every resolution says `enforced: false`, and no reason in the table may word a
12
+ // limit as though it were enforced (the integrity test reads every reason for that).
13
+ // A screen that greys an option is giving advice with a reason, the same standing as
14
+ // `governance.applied: false` in recommendations.js.
15
+ //
16
+ // DECLARATIVE ON PURPOSE. A condition is data — `{ key: { in: [...] } }` for a single
17
+ // answer, `{ key: { has: value } }` or `{ key: { lacks: value } }` for a list answer —
18
+ // never a function, so a rule can be checked against the vocabulary by a test and could
19
+ // be sent to a browser as-is.
20
+ // Every key in `when` must hold; an UNANSWERED key never matches, so no rule fires on
21
+ // an answer nobody gave (the normalizeProjectDetail rule). An empty `when` always holds:
22
+ // that is a fact about the platform, not about the project.
23
+ //
24
+ // EFFECTS, strongest first: `hide` (the option does not apply at all), `grey` (shown,
25
+ // not offered, with the reason), `note` (offered, with the reason beside it — for an
26
+ // option that applies only in part). An option names a whole question (`services`) or
27
+ // one member of it (`reward_intents:equity`).
28
+ //
29
+ // PURE — requires only planet-physics.js.
30
+
31
+ 'use strict';
32
+
33
+ const { EU_COUNTRIES, ENTITY_FORMS } = require('./planet-physics');
34
+
35
+ const EFFECTS = Object.freeze(['hide', 'grey', 'note']);
36
+
37
+ const SOFT_LIMIT_RULES = Object.freeze([
38
+ Object.freeze({
39
+ id: 'existing-work-needs-existing-project',
40
+ when: { project_state: { in: ['new'] } },
41
+ options: ['existing_work', 'main_work'],
42
+ effect: 'hide',
43
+ reason: 'Your planet is new, so there is nothing built yet to describe.',
44
+ }),
45
+ Object.freeze({
46
+ id: 'equity-needs-a-company',
47
+ when: { entity: { in: ['intent-company', 'intent-nonprofit', 'intent-solo', 'intent-unsure', 'university-project', 'social-group', 'private-individual'] } },
48
+ options: ['reward_intents:equity'],
49
+ effect: 'grey',
50
+ reason: 'Equity is off until you form a company — there are no shares to give yet.',
51
+ }),
52
+ Object.freeze({
53
+ id: 'equity-not-in-a-nonprofit',
54
+ when: { entity: { in: ['nonprofit'] } },
55
+ options: ['reward_intents:equity'],
56
+ effect: 'grey',
57
+ reason: 'A nonprofit has no owners, so it has no shares to give.',
58
+ }),
59
+ Object.freeze({
60
+ id: 'no-cash-payouts-to-eu-builders-yet',
61
+ when: { builder_geography: { in: ['us-eu', 'global'] } },
62
+ options: ['reward_intents:cash'],
63
+ effect: 'note',
64
+ reason: "Cash payouts to builders in the EU aren't available yet. Builders elsewhere can still be paid in cash.",
65
+ }),
66
+ Object.freeze({
67
+ id: 'no-cash-payouts-from-an-eu-home-yet',
68
+ when: { home_country: { in: [...EU_COUNTRIES] } },
69
+ options: ['reward_intents:cash'],
70
+ effect: 'grey',
71
+ reason: "Cash payouts from a project based in the EU aren't available yet.",
72
+ }),
73
+ Object.freeze({
74
+ id: 'young-builders-non-monetary',
75
+ when: { minors_on_board: { in: [true] } },
76
+ options: ['reward_intents:cash', 'reward_intents:equity', 'reward_intents:future-payouts'],
77
+ effect: 'note',
78
+ reason: 'Builders aged 13 to 15 join non-monetary tracks only. 16 and 17 year olds need a guardian to co-sign paid work.',
79
+ }),
80
+ Object.freeze({
81
+ id: 'managed-includes-every-service',
82
+ when: { framing: { in: ['managed'] } },
83
+ options: ['services'],
84
+ effect: 'hide',
85
+ reason: 'All managed includes the whole suite, so there is nothing to pick.',
86
+ }),
87
+ Object.freeze({
88
+ id: 'open-source-is-the-free-core',
89
+ when: { framing: { in: ['open-source'] } },
90
+ options: ['services'],
91
+ effect: 'hide',
92
+ reason: 'Open source, local is the free core on its own. You can add services later.',
93
+ }),
94
+ Object.freeze({
95
+ id: 'company-creation-needs-no-entity-yet',
96
+ when: { entity: { in: [...ENTITY_FORMS] } },
97
+ options: ['services:company-creation'],
98
+ effect: 'hide',
99
+ reason: 'You already have a legal entity.',
100
+ }),
101
+ Object.freeze({
102
+ id: 'labor-payment-follows-cash',
103
+ when: { reward_intents: { lacks: 'cash' } },
104
+ options: ['services:labor-payment'],
105
+ effect: 'grey',
106
+ reason: "You aren't considering cash for work, so there is no pay to run.",
107
+ }),
108
+ Object.freeze({
109
+ id: 'public-marketing-on-request',
110
+ when: {},
111
+ options: ['services:public-marketing'],
112
+ effect: 'grey',
113
+ reason: "A publicly marketed project is on request for now — contact us and we'll talk it through.",
114
+ }),
115
+ ]);
116
+
117
+ // Does one condition hold against the answers? An absent answer never matches — not
118
+ // even `lacks`, because "never said" is not "said no". PURE.
119
+ function conditionHolds(answers, key, test) {
120
+ if (!Object.prototype.hasOwnProperty.call(answers, key) || answers[key] === null || answers[key] === undefined) return false;
121
+ const value = answers[key];
122
+ if (test.in) return test.in.includes(value);
123
+ if ('has' in test) return Array.isArray(value) && value.includes(test.has);
124
+ if ('lacks' in test) return Array.isArray(value) && !value.includes(test.lacks);
125
+ return false;
126
+ }
127
+
128
+ // The limits that apply to one project's answers:
129
+ //
130
+ // { enforced: false, limits: [{ option, effect, reason, rule }] }
131
+ //
132
+ // one entry per (rule, option), in table order — several rules may name one option,
133
+ // and a screen shows the strongest effect with every reason. `answers` is the stored
134
+ // detail (projectDetail(row)), or the in-progress answers of a flow that has not
135
+ // created anything yet. Never throws. PURE — the result is fresh, never the table.
136
+ function softLimitsFor(answers) {
137
+ const given = answers && typeof answers === 'object' && !Array.isArray(answers) ? answers : {};
138
+ const limits = [];
139
+ for (const rule of SOFT_LIMIT_RULES) {
140
+ if (!Object.entries(rule.when).every(([key, test]) => conditionHolds(given, key, test))) continue;
141
+ for (const option of rule.options) limits.push({ option, effect: rule.effect, reason: rule.reason, rule: rule.id });
142
+ }
143
+ return { enforced: false, limits };
144
+ }
145
+
146
+ module.exports = { SOFT_LIMIT_RULES, EFFECTS, softLimitsFor };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.25",
3
+ "version": "1.20.27",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.25",
9
+ "version": "1.20.27",
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.25",
3
+ "version": "1.20.27",
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",
@@ -8210,5 +8210,25 @@
8210
8210
  "id": "1004412",
8211
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
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
+ }
8219
+ ],
8220
+ "1.20.27": [
8221
+ {
8222
+ "id": "1004416",
8223
+ "text": "A new project can now store the seven startup answers: new or existing, where everyone is, whether under-18s are building, what the entity is, how many builders, how they'll be rewarded, and how much Bongos should run. A wrong"
8224
+ },
8225
+ {
8226
+ "id": "1004427",
8227
+ "text": "A new project now chooses its government by the real names: Monarchy / BDFL, Timocratic Republic, Direct Democracy or Foundation, with Presidential, Cooperative and Federation shown as coming later. Every form and setting has"
8228
+ },
8229
+ {
8230
+ "id": "1004408",
8231
+ "text": "Old project invitations that led to a locked door are cleared from people's My Projects list."
8232
+ }
8213
8233
  ]
8214
8234
  }
@@ -208,6 +208,10 @@ const INTEGRATION = new Set([
208
208
  // prove the CHECK actually refuses a stored fourth state, that the backfill lands on a
209
209
  // pre-migration row, and that publishableSql and projectPublishability agree.
210
210
  'project_visibility_db',
211
+ // task 1004408: migration platform_identity_028 deletes the old hub invite's
212
+ // pending rows before a cutoff it reads from the core_upgrades ledger. Only a real
213
+ // engine runs the DO block, the numeric version compare and the one-shot guard.
214
+ 'pending_invite_purge_db',
211
215
  // task 1003956 (ADR 0294): that CLI sessions really never expire. The DB-free
212
216
  // sibling (auth_session_ttl) pins the constant, createSession's value against a
213
217
  // stub pool, and the TEXT of the refresh statement — but a regex over source
@@ -268,6 +268,12 @@ 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',
274
+ // task 1004427: the genesis charter + legislation choice — an amendment by
275
+ // another door, so the same atom as proposing one.
276
+ 'POST /government/board/charter': 'perm:board.item.open',
271
277
  };
272
278
 
273
279
  // 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.25'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.27'; // 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
+ });