@bongos/core 1.19.363 → 1.19.365
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.bongos-core.json +44 -29
- package/docs/adr/0191-majority-and-the-author-who-may-not-decide-alone.md +142 -0
- package/docs/adr/README.md +2 -0
- package/docs/copy-inventory.md +61 -61
- package/docs/copy-registry.json +64 -64
- package/modules/builder-settings/builder-needs.js +45 -2
- package/modules/discord/board-broadcast.js +28 -1
- package/modules/discord/webhook.js +15 -1
- package/modules/government/board.js +266 -31
- package/modules/government/config.js +26 -5
- package/modules/government/db.js +47 -0
- package/modules/government/routes/government.js +12 -1
- package/modules/hall-ui/public/government.js +27 -7
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/routes/me.js +18 -1
- package/src/module-api.js +5 -1
- package/tests/discord_board_broadcast.mjs +39 -0
- package/tests/government_board_amendment.mjs +37 -8
- package/tests/government_board_close.mjs +13 -2
- package/tests/government_board_majority.mjs +276 -0
- package/tests/government_board_summons.mjs +222 -0
- package/tests/government_board_vote.mjs +35 -4
- package/tests/government_board_vote_ui.mjs +18 -3
- package/tests/government_config.mjs +9 -2
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// tests/government_board_summons.mjs — the summons: what is waiting on THIS
|
|
2
|
+
// member's vote (task 1003271).
|
|
3
|
+
//
|
|
4
|
+
// THE BUG THIS PINS. Nine sittings sat open for two days with zero votes. The
|
|
5
|
+
// board was never deadlocked — three of four archons could each have carried any
|
|
6
|
+
// item with one click — they were simply never told, because the Discord
|
|
7
|
+
// announcer was the only summons and it is best-effort by construction (an
|
|
8
|
+
// unconfigured webhook, an unlinked member and a failed POST all end in a
|
|
9
|
+
// swallowed log line). The durable summons is now the builder-needs roster.
|
|
10
|
+
//
|
|
11
|
+
// The rules pinned here:
|
|
12
|
+
// 1. THE COUNT MATCHES WHAT THE VOTE ROUTE WOULD ACCEPT — a member, not the
|
|
13
|
+
// author, not already cast. Anything the route would refuse must not be
|
|
14
|
+
// counted, or the roster promises a ballot the route then denies.
|
|
15
|
+
// 2. THE AUTHOR IS EXCLUDED, and that is the whole live symptom: the owner
|
|
16
|
+
// authored all nine, so their own count is zero while a peer's is nine.
|
|
17
|
+
// 3. MEMBERSHIP IS PER-ITEM, off the item's OWN snapshotted constitution —
|
|
18
|
+
// never the live config — and resolved ONCE PER DISTINCT PREDICATE.
|
|
19
|
+
// 4. FAIL-CLOSED AND FAIL-OPEN IN THE RIGHT DIRECTIONS: an unreadable
|
|
20
|
+
// predicate summons nobody; a DB error yields a zero count, never a throw
|
|
21
|
+
// (this runs on the /me session hot path and must not break a profile).
|
|
22
|
+
//
|
|
23
|
+
// DB-free (db monkeypatched — the sibling board tests' idiom).
|
|
24
|
+
import assert from 'node:assert/strict';
|
|
25
|
+
import { test } from 'node:test';
|
|
26
|
+
import { createRequire } from 'node:module';
|
|
27
|
+
import path from 'node:path';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
|
|
30
|
+
const require = createRequire(import.meta.url);
|
|
31
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
32
|
+
const GOV = path.join(ROOT, 'modules', 'government');
|
|
33
|
+
const board = require(path.join(GOV, 'board.js'));
|
|
34
|
+
const db = require(path.join(GOV, 'db.js'));
|
|
35
|
+
const needs = require(path.join(ROOT, 'modules', 'builder-settings', 'builder-needs.js'));
|
|
36
|
+
|
|
37
|
+
async function withDb(stubs, fn) {
|
|
38
|
+
const originals = {};
|
|
39
|
+
for (const [k, v] of Object.entries(stubs)) { originals[k] = db[k]; db[k] = v; }
|
|
40
|
+
try { return await fn(); }
|
|
41
|
+
finally { for (const [k, v] of Object.entries(originals)) db[k] = v; }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const ARCHON = { membership: 'rank:archon', pass_rule: 'first_ratifier' };
|
|
45
|
+
|
|
46
|
+
// One open item. author_id/already_voted are what the SQL hands back.
|
|
47
|
+
const item = (id, { author = null, voted = false, constitution = ARCHON } = {}) => ({
|
|
48
|
+
id: String(id),
|
|
49
|
+
subject_type: 'full_idea',
|
|
50
|
+
subject_id: String(1000000 + Number(id)),
|
|
51
|
+
constitution,
|
|
52
|
+
opened_at: `2026-08-2${(Number(id) % 9) + 1}T00:00:00Z`,
|
|
53
|
+
closes_at: null,
|
|
54
|
+
author_id: author == null ? null : String(author),
|
|
55
|
+
already_voted: voted,
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
// ── rule 2: the live symptom — author sees nothing, a peer sees all nine ─────
|
|
59
|
+
|
|
60
|
+
test('the author of every open item is summoned to none of them', async () => {
|
|
61
|
+
// The exact live shape: 9 items, all authored by builder 1000025, membership
|
|
62
|
+
// rank:archon, and 1000025 IS an archon. The vote route would refuse each one
|
|
63
|
+
// (anti_self_vote), so the roster must not claim any are waiting on them.
|
|
64
|
+
const items = Array.from({ length: 9 }, (_, i) => item(i + 1, { author: 1000025 }));
|
|
65
|
+
await withDb({
|
|
66
|
+
openBoardItemsAwaitingVoter: async () => items,
|
|
67
|
+
builderHoldsRankActive: async () => true, // they really are an archon
|
|
68
|
+
}, async () => {
|
|
69
|
+
const out = await board.pendingVotesFor(1000025);
|
|
70
|
+
assert.equal(out.count, 0, 'the author must be summoned to zero of their own sittings');
|
|
71
|
+
assert.equal(out.first_item_id, null);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test('a peer archon who authored none of them is summoned to all nine', async () => {
|
|
76
|
+
const items = Array.from({ length: 9 }, (_, i) => item(i + 1, { author: 1000025 }));
|
|
77
|
+
await withDb({
|
|
78
|
+
openBoardItemsAwaitingVoter: async () => items,
|
|
79
|
+
builderHoldsRankActive: async () => true,
|
|
80
|
+
}, async () => {
|
|
81
|
+
const out = await board.pendingVotesFor(1000094); // scootermack, not the author
|
|
82
|
+
assert.equal(out.count, 9);
|
|
83
|
+
// Oldest first — the sitting that has waited longest is the one to answer.
|
|
84
|
+
assert.equal(out.first_item_id, '1');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ── rule 1: the count matches what the route would accept ────────────────────
|
|
89
|
+
|
|
90
|
+
test('an already-cast vote is not counted again', async () => {
|
|
91
|
+
await withDb({
|
|
92
|
+
openBoardItemsAwaitingVoter: async () => [
|
|
93
|
+
item(1, { author: 7, voted: true }),
|
|
94
|
+
item(2, { author: 7, voted: false }),
|
|
95
|
+
],
|
|
96
|
+
builderHoldsRankActive: async () => true,
|
|
97
|
+
}, async () => {
|
|
98
|
+
const out = await board.pendingVotesFor(1);
|
|
99
|
+
assert.equal(out.count, 1, 'one vote each — a cast item is settled for this voter');
|
|
100
|
+
assert.equal(out.first_item_id, '2');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('a non-member is summoned to nothing, however many items are open', async () => {
|
|
105
|
+
await withDb({
|
|
106
|
+
openBoardItemsAwaitingVoter: async () => [item(1, { author: 7 }), item(2, { author: 7 })],
|
|
107
|
+
builderHoldsRankActive: async () => false, // not an archon
|
|
108
|
+
}, async () => {
|
|
109
|
+
const out = await board.pendingVotesFor(999);
|
|
110
|
+
assert.equal(out.count, 0);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test('an item with no author at all stays votable (nobody to disqualify)', async () => {
|
|
115
|
+
await withDb({
|
|
116
|
+
openBoardItemsAwaitingVoter: async () => [item(1, { author: null })],
|
|
117
|
+
builderHoldsRankActive: async () => true,
|
|
118
|
+
}, async () => {
|
|
119
|
+
assert.equal((await board.pendingVotesFor(1000025)).count, 1);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// ── rule 3: membership is per-item, and resolved once per predicate ──────────
|
|
124
|
+
|
|
125
|
+
test('membership comes from the ITEM snapshot, not one global answer', async () => {
|
|
126
|
+
// Two items under DIFFERENT constitutions: this builder sits on one board only.
|
|
127
|
+
const metic = { membership: 'rank:metic+', pass_rule: 'first_ratifier' };
|
|
128
|
+
await withDb({
|
|
129
|
+
openBoardItemsAwaitingVoter: async () => [
|
|
130
|
+
item(1, { author: 7, constitution: ARCHON }),
|
|
131
|
+
item(2, { author: 7, constitution: metic }),
|
|
132
|
+
],
|
|
133
|
+
builderHoldsRankActive: async (_id, rankKeys) => rankKeys.includes('metic'),
|
|
134
|
+
}, async () => {
|
|
135
|
+
const out = await board.pendingVotesFor(1000027);
|
|
136
|
+
assert.equal(out.count, 1, 'only the sitting whose snapshot enfranchises them');
|
|
137
|
+
assert.equal(out.first_item_id, '2');
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('one membership resolve per DISTINCT predicate, not one per item', async () => {
|
|
142
|
+
let calls = 0;
|
|
143
|
+
const items = Array.from({ length: 9 }, (_, i) => item(i + 1, { author: 7 }));
|
|
144
|
+
await withDb({
|
|
145
|
+
openBoardItemsAwaitingVoter: async () => items,
|
|
146
|
+
builderHoldsRankActive: async () => { calls++; return true; },
|
|
147
|
+
}, async () => {
|
|
148
|
+
const out = await board.pendingVotesFor(5);
|
|
149
|
+
assert.equal(out.count, 9);
|
|
150
|
+
assert.equal(calls, 1, 'nine items under one constitution cost ONE resolve');
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ── rule 4: the two failure directions ──────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
test('an unreadable membership predicate summons nobody (fail-closed)', async () => {
|
|
157
|
+
await withDb({
|
|
158
|
+
openBoardItemsAwaitingVoter: async () => [
|
|
159
|
+
item(1, { author: 7, constitution: { membership: 'not a predicate' } }),
|
|
160
|
+
],
|
|
161
|
+
// Would say yes if it were ever asked — the parse must refuse first.
|
|
162
|
+
builderHoldsRankActive: async () => true,
|
|
163
|
+
}, async () => {
|
|
164
|
+
assert.equal((await board.pendingVotesFor(1)).count, 0);
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('a DB error yields a zero count, never a throw (the /me hot path)', async () => {
|
|
169
|
+
await withDb({
|
|
170
|
+
openBoardItemsAwaitingVoter: async () => { throw new Error('boom'); },
|
|
171
|
+
}, async () => {
|
|
172
|
+
const out = await board.pendingVotesFor(1);
|
|
173
|
+
assert.equal(out.count, 0);
|
|
174
|
+
assert.equal(out.first_item_id, null);
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('a missing builder id is answered without touching the database', async () => {
|
|
179
|
+
await withDb({
|
|
180
|
+
openBoardItemsAwaitingVoter: async () => { throw new Error('must not be called'); },
|
|
181
|
+
}, async () => {
|
|
182
|
+
for (const id of [null, undefined, '']) {
|
|
183
|
+
assert.equal((await board.pendingVotesFor(id)).count, 0);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// ── the need the count feeds ─────────────────────────────────────────────────
|
|
189
|
+
|
|
190
|
+
test('zero pending votes produces NO need row', () => {
|
|
191
|
+
// An empty state is not an input the system needs from you — the sibling
|
|
192
|
+
// collab needs take the same line.
|
|
193
|
+
assert.equal(needs.boardVotesNeed({}), null);
|
|
194
|
+
assert.equal(needs.boardVotesNeed({ pendingBoardVotes: { count: 0 } }), null);
|
|
195
|
+
assert.equal(needs.boardVotesNeed({ pendingBoardVotes: null }), null);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('pending votes are action_needed and deep-link the oldest sitting', () => {
|
|
199
|
+
const n = needs.boardVotesNeed({ pendingBoardVotes: { count: 9, first_item_id: '1' } });
|
|
200
|
+
assert.equal(n.id, 'board_votes');
|
|
201
|
+
assert.equal(n.state, 'action_needed');
|
|
202
|
+
assert.equal(n.action.href, '/government?item=1#board-room');
|
|
203
|
+
// The hash is the TAB (makeTabs owns it); the item is a query param.
|
|
204
|
+
assert.match(n.action.href, /#board-room$/);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('a count with no item id still links the room', () => {
|
|
208
|
+
const n = needs.boardVotesNeed({ pendingBoardVotes: { count: 2 } });
|
|
209
|
+
assert.equal(n.action.href, '/government#board-room');
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('the need reaches computeNeeds as an action_needed item', () => {
|
|
213
|
+
const out = needs.computeNeeds({
|
|
214
|
+
rank: 'archon', ownKeySet: true,
|
|
215
|
+
pendingBoardVotes: { count: 3, first_item_id: '4' },
|
|
216
|
+
});
|
|
217
|
+
const row = out.items.find((i) => i.id === 'board_votes');
|
|
218
|
+
assert.ok(row, 'the registry must carry the new need');
|
|
219
|
+
assert.equal(out.has_action_needed, true);
|
|
220
|
+
// action_needed sorts first, so a renderer taking "the first one" gets it.
|
|
221
|
+
assert.equal(out.items[0].id, 'board_votes');
|
|
222
|
+
});
|
|
@@ -132,15 +132,46 @@ test('the demotion between open and vote refuses the vote — live re-check, unc
|
|
|
132
132
|
});
|
|
133
133
|
});
|
|
134
134
|
|
|
135
|
-
test('
|
|
135
|
+
test('an author MAY cast on their own item, earns no karma for it — number/string ids compare equal', async () => {
|
|
136
|
+
// Task 1003273 replaced the flat refusal: an author may vote, and may never
|
|
137
|
+
// decide alone (enforced by the close trigger and the tally, not by refusing
|
|
138
|
+
// the cast — see government_board_majority.mjs). The id-shape detail this test
|
|
139
|
+
// has always guarded still matters: Postgres hands back a NUMBER author id
|
|
140
|
+
// while the voter carries a STRING, and the self-cast comparison must see
|
|
141
|
+
// through that, or an author would silently be paid karma on their own item.
|
|
142
|
+
let karmaPaid = 0;
|
|
136
143
|
await withDb({
|
|
137
144
|
getBoardItemForVote: async () => openItem(),
|
|
138
145
|
builderHoldsRankActive: async () => true,
|
|
139
|
-
getBoardItemAuthor: async () => 42, //
|
|
140
|
-
insertBoardVote: async () => {
|
|
146
|
+
getBoardItemAuthor: async () => 42, // number, deliberately
|
|
147
|
+
insertBoardVote: async (args) => ({ id: '9', voter_id: args.voterId, direction: args.direction }),
|
|
148
|
+
insertVoteKarma: async () => { karmaPaid += 1; },
|
|
141
149
|
}, async () => {
|
|
142
150
|
const out = await castVote({ itemId: 11, voterId: '42', direction: 'yes' });
|
|
143
|
-
assert.
|
|
151
|
+
assert.equal(out.ok, true);
|
|
152
|
+
assert.equal(out.self_cast, true, '42 and "42" are the same author');
|
|
153
|
+
assert.equal(out.karma_delta, 0);
|
|
154
|
+
assert.equal(karmaPaid, 0, 'a self-cast writes no karma row');
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('a NON-author on the same item is paid the vote karma as before', async () => {
|
|
159
|
+
// The contrast, so the no-karma rule above cannot silently become "no karma
|
|
160
|
+
// for anyone" — which would quietly delete the incentive to read other
|
|
161
|
+
// people's ideas.
|
|
162
|
+
let karmaPaid = 0;
|
|
163
|
+
await withDb({
|
|
164
|
+
getBoardItemForVote: async () => openItem(),
|
|
165
|
+
builderHoldsRankActive: async () => true,
|
|
166
|
+
getBoardItemAuthor: async () => 42,
|
|
167
|
+
insertBoardVote: async (args) => ({ id: '9', voter_id: args.voterId, direction: args.direction }),
|
|
168
|
+
insertVoteKarma: async () => { karmaPaid += 1; },
|
|
169
|
+
}, async () => {
|
|
170
|
+
const out = await castVote({ itemId: 11, voterId: '77', direction: 'yes' });
|
|
171
|
+
assert.equal(out.ok, true);
|
|
172
|
+
assert.equal(out.self_cast, false);
|
|
173
|
+
assert.equal(out.karma_delta, VOTE_KARMA_DELTA);
|
|
174
|
+
assert.equal(karmaPaid, 1);
|
|
144
175
|
});
|
|
145
176
|
});
|
|
146
177
|
|
|
@@ -47,11 +47,26 @@ test('an objection needs the reason AND exactly one section; a yes sends neither
|
|
|
47
47
|
assert.match(cast, /item_closed/, 'a cast that CLOSED the sitting says so (first_ratifier / full turnout)');
|
|
48
48
|
});
|
|
49
49
|
|
|
50
|
-
test('the author of a full_idea
|
|
50
|
+
test('the author of a full_idea gets a REAL ballot plus the limit in words (task 1003273)', () => {
|
|
51
|
+
// This test used to assert the opposite — that an author saw a note INSTEAD of
|
|
52
|
+
// a form. That was the shape of the live bug: every open item was the reader's
|
|
53
|
+
// own, so the room offered nine sittings and no way to vote on any of them. The
|
|
54
|
+
// owner's rule is now "may vote, may not decide alone", so the form must be
|
|
55
|
+
// there and the limit must be stated rather than enforced by a missing button.
|
|
51
56
|
const form = CLIENT.slice(CLIENT.indexOf('function voteFormHtml'), CLIENT.indexOf('async function castVoteFromRoom'));
|
|
52
57
|
assert.match(form, /subject_type === 'full_idea'[^]*author_id.*viewerId/,
|
|
53
|
-
'
|
|
54
|
-
|
|
58
|
+
'still SUBJECT-TYPED — an amendment proposer is not held to the author rule (§7)');
|
|
59
|
+
// The note is now additive: it rides ALONGSIDE the buttons, not in place of them.
|
|
60
|
+
assert.match(form, /ownNote/, 'the own-idea note is composed into the form');
|
|
61
|
+
assert.match(form, /can\u2019t be decided by your vote alone|decided by your vote alone/,
|
|
62
|
+
'the note states the real limit');
|
|
63
|
+
assert.match(form, /earns no karma/, 'and that a self-cast is unpaid');
|
|
64
|
+
// The single most important structural assertion: there is exactly ONE return
|
|
65
|
+
// of the ballot, so no branch can silently withhold it from the author again.
|
|
66
|
+
assert.equal((form.match(/data-vote="yes"/g) || []).length, 1,
|
|
67
|
+
'one ballot, reachable by author and peer alike');
|
|
68
|
+
assert.ok(!/return `<p class="gov-grid-note">This is your idea/.test(form),
|
|
69
|
+
'the old form-suppressing early return is gone, not merely bypassed');
|
|
55
70
|
assert.match(form, /if \(mine\) return ''/, 'one vote per member — a cast voter gets no second form');
|
|
56
71
|
assert.match(form, /if \(item\.closed_at\) return ''/, 'closed sittings take no votes');
|
|
57
72
|
});
|
|
@@ -265,7 +265,7 @@ test('a malformed constitution can never widen the franchise or lower the bar',
|
|
|
265
265
|
{ membership: '' },
|
|
266
266
|
{ membership: { rank: 'archon' } },
|
|
267
267
|
{ pass_rule: 'unanimous' },
|
|
268
|
-
{ pass_rule: '
|
|
268
|
+
{ pass_rule: 'unanimous' },
|
|
269
269
|
{ pass_rule: null },
|
|
270
270
|
{ membership: 'rank:xenos; DROP TABLE government_ranks' },
|
|
271
271
|
];
|
|
@@ -325,7 +325,14 @@ test('the resolved constitution is frozen, like the rest of the config contract'
|
|
|
325
325
|
});
|
|
326
326
|
|
|
327
327
|
test('PASS_RULES is the closed set the rest of the goal codes against', () => {
|
|
328
|
-
|
|
328
|
+
// `majority` joined the set in task 1003273 (owner decision 2026-08-25). The
|
|
329
|
+
// set stays CLOSED and asserted whole on purpose: board.js branches on these
|
|
330
|
+
// strings, so a rule added to config without a tally to match would be a
|
|
331
|
+
// constitution a project could adopt and then never resolve.
|
|
332
|
+
assert.deepEqual([...PASS_RULES].sort(), ['consent', 'first_ratifier', 'majority']);
|
|
329
333
|
assert.ok(Object.isFrozen(PASS_RULES));
|
|
330
334
|
assert.ok(PASS_RULES.includes(BOARD_DEFAULTS.pass_rule), 'the default must be a member of the set');
|
|
335
|
+
// The fail-closed default is UNCHANGED by the addition — adding a stricter rule
|
|
336
|
+
// must not move what a malformed constitution falls back to.
|
|
337
|
+
assert.equal(BOARD_DEFAULTS.pass_rule, 'first_ratifier');
|
|
331
338
|
});
|