@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
package/src/bongos/routes/me.js
CHANGED
|
@@ -105,7 +105,7 @@ module.exports = function buildMeRouter() {
|
|
|
105
105
|
builder, activeClaims, estVsActual, unlocked, lockedWithProgress, streakInfo,
|
|
106
106
|
recentGrades, builderAvg, onboardingRow, shipCount, recentKarma, karma30d, discordLink, boxConnected, cliAuthed,
|
|
107
107
|
behaviorPrefs, geminiKeyRow, needsRebaseTasks, foundingBuilder, helpRequestCount,
|
|
108
|
-
taskRecommendationCount,
|
|
108
|
+
taskRecommendationCount, boardVotesPending,
|
|
109
109
|
] = await Promise.all([
|
|
110
110
|
db.getBuilderById(req.builder.id),
|
|
111
111
|
lc().getActiveClaimsForBuilder(req.builder.id),
|
|
@@ -192,6 +192,19 @@ module.exports = function buildMeRouter() {
|
|
|
192
192
|
return await lifecycle.openTaskRecommendationsFor(req.builder.id);
|
|
193
193
|
} catch (_) { return 0; }
|
|
194
194
|
})(),
|
|
195
|
+
// task 1003271: open board sittings waiting on THIS builder's vote, via
|
|
196
|
+
// the `government` port — core imports no module (ADR 0093). Nine items
|
|
197
|
+
// once sat for two days with nobody told, because the Discord announcer
|
|
198
|
+
// (best-effort by construction) was the only summons; this is the durable
|
|
199
|
+
// one. Same fan-out slot and fail-open posture as the two collab reads
|
|
200
|
+
// above: a board read must never be able to break a profile.
|
|
201
|
+
(async () => {
|
|
202
|
+
try {
|
|
203
|
+
const government = seams.resolveOptional('government');
|
|
204
|
+
if (!government || !government.pendingBoardVotesFor) return null;
|
|
205
|
+
return await government.pendingBoardVotesFor(req.builder.id);
|
|
206
|
+
} catch (_) { return null; }
|
|
207
|
+
})(),
|
|
195
208
|
]);
|
|
196
209
|
// V3.R84 (#302), criterion C3: the builder's month-to-date spend vs their
|
|
197
210
|
// monthly budget. Surfaced here so the CLI (session-start ping) and the hall
|
|
@@ -333,6 +346,10 @@ module.exports = function buildMeRouter() {
|
|
|
333
346
|
// module (ADR 0093); undefined when lifecycle is off, which the need reads
|
|
334
347
|
// as "nothing asked".
|
|
335
348
|
openHelpRequests: helpRequestCount,
|
|
349
|
+
// task 1003271: { count, first_item_id } of open board sittings awaiting
|
|
350
|
+
// this builder's vote; null when government is off, which the need reads
|
|
351
|
+
// as "nothing waiting".
|
|
352
|
+
pendingBoardVotes: boardVotesPending,
|
|
336
353
|
// task 1003173 (C03, goal 1000075): open task recommendations waiting on
|
|
337
354
|
// this builder, read through the same `lifecycle` port so core never
|
|
338
355
|
// imports the module (ADR 0093).
|
package/src/module-api.js
CHANGED
|
@@ -1156,8 +1156,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1156
1156
|
// landed since 1.19.361 with no explicit bump. run 32886106098. (task 1002620)
|
|
1157
1157
|
// 1.19.363 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1158
1158
|
// landed since 1.19.362 with no explicit bump. run 32889324497. (task 1002620)
|
|
1159
|
+
// 1.19.364 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1160
|
+
// landed since 1.19.363 with no explicit bump. run 32890225073. (task 1002620)
|
|
1161
|
+
// 1.19.365 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1162
|
+
// landed since 1.19.364 with no explicit bump. run 32893443215. (task 1002620)
|
|
1159
1163
|
// ---------------------------------------------------------------------------
|
|
1160
|
-
const CORE_VERSION = '1.19.
|
|
1164
|
+
const CORE_VERSION = '1.19.365'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1161
1165
|
|
|
1162
1166
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1163
1167
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -79,6 +79,45 @@ test('missing port, empty board, and a Discord failure are named skips — NEVER
|
|
|
79
79
|
assert.equal((await postBoardAnnouncement(null, { log: () => {} })).skipped, 'no_item');
|
|
80
80
|
});
|
|
81
81
|
|
|
82
|
+
test('an unconfigured board channel is its OWN named skip, not a generic failure (task 1003271)', async () => {
|
|
83
|
+
// WHY THIS DISTINCTION EARNS A TEST. Nine sittings went unannounced and the
|
|
84
|
+
// logs could not say whether the webhook was never configured (an operator
|
|
85
|
+
// action) or Discord had failed (weather). Both used to read 'post_failed'.
|
|
86
|
+
const deps = {
|
|
87
|
+
resolveMembers: async () => [member(1, 'a')],
|
|
88
|
+
getLinks: async () => new Map(),
|
|
89
|
+
hasWebhook: () => false,
|
|
90
|
+
log: () => {},
|
|
91
|
+
};
|
|
92
|
+
const out = await postBoardAnnouncement(payload(), deps);
|
|
93
|
+
assert.deepEqual({ posted: out.posted, skipped: out.skipped },
|
|
94
|
+
{ posted: false, skipped: 'channel_not_configured' });
|
|
95
|
+
|
|
96
|
+
// Configured but failing is still the OTHER skip — the two never collapse.
|
|
97
|
+
const failed = await postBoardAnnouncement(payload(), {
|
|
98
|
+
...deps, hasWebhook: () => true, post: async () => { throw new Error('discord down'); },
|
|
99
|
+
});
|
|
100
|
+
assert.equal(failed.skipped, 'post_failed');
|
|
101
|
+
|
|
102
|
+
// An INJECTED poster resolves no webhook URL, so a missing one must not stop
|
|
103
|
+
// it — otherwise every deps-injected caller would report a false skip.
|
|
104
|
+
let sent = null;
|
|
105
|
+
const injected = await postBoardAnnouncement(payload(), {
|
|
106
|
+
...deps, hasWebhook: () => false, post: async (_c, text) => { sent = text; return { message_id: '5' }; },
|
|
107
|
+
recordMessage: async () => {},
|
|
108
|
+
});
|
|
109
|
+
assert.equal(injected.posted, true, 'an injected poster is never gated on webhook config');
|
|
110
|
+
assert.ok(sent && sent.includes('before the board'));
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('hasWebhook answers false for an unknown channel and for a configured-but-absent one', () => {
|
|
114
|
+
assert.equal(webhook.hasWebhook('definitely_not_a_channel'), false);
|
|
115
|
+
assert.ok(webhook.CHANNELS.has('board_room'), 'the board channel is a known key');
|
|
116
|
+
// Its truth here depends on the environment, so pin the TYPE, not the value —
|
|
117
|
+
// a boolean is the contract the caller branches on.
|
|
118
|
+
assert.equal(typeof webhook.hasWebhook('board_room'), 'boolean');
|
|
119
|
+
});
|
|
120
|
+
|
|
82
121
|
test('the emitter fires on FRESH opens only, and the listener is registered (source pins)', () => {
|
|
83
122
|
const board = fs.readFileSync(path.join(ROOT, 'modules', 'government', 'board.js'), 'utf8');
|
|
84
123
|
const open = board.slice(board.indexOf('async function openWindowForFullIdea'), board.indexOf('async function emitWindowOpened'));
|
|
@@ -56,7 +56,7 @@ test('every field is required and every refusal is named — no silent correctio
|
|
|
56
56
|
[{ ...CONSENT_90, extra: true }, 'unknown_field'],
|
|
57
57
|
[{ ...CONSENT_90, membership: 'everyone' }, 'bad_membership'],
|
|
58
58
|
[{ ...CONSENT_90, membership: undefined }, 'bad_membership'],
|
|
59
|
-
[{ ...CONSENT_90, pass_rule: '
|
|
59
|
+
[{ ...CONSENT_90, pass_rule: 'unanimous' }, 'bad_pass_rule'],
|
|
60
60
|
[{ ...CONSENT_90, window_minutes: 0 }, 'bad_window_minutes'],
|
|
61
61
|
[{ ...CONSENT_90, window_minutes: 1.5 }, 'bad_window_minutes'],
|
|
62
62
|
[{ ...CONSENT_90, close_early_on_full_turnout: 'yes' }, 'bad_close_early'],
|
|
@@ -68,8 +68,14 @@ test('every field is required and every refusal is named — no silent correctio
|
|
|
68
68
|
}
|
|
69
69
|
// THE CONTRAST WITH THE SANITIZER, stated as an assertion: the sanitizer
|
|
70
70
|
// silently corrects a bad pass_rule; the proposal validator refuses it.
|
|
71
|
-
assert.equal(config.sanitizeBoard({ ...CONSENT_90, pass_rule: '
|
|
72
|
-
assert.equal(validateProposedConstitution({ ...CONSENT_90, pass_rule: '
|
|
71
|
+
assert.equal(config.sanitizeBoard({ ...CONSENT_90, pass_rule: 'unanimous' }).pass_rule, 'first_ratifier');
|
|
72
|
+
assert.equal(validateProposedConstitution({ ...CONSENT_90, pass_rule: 'unanimous' }).ok, false);
|
|
73
|
+
// THE PERMISSIVE HALF, asserted on purpose (task 1003273): `majority` is a
|
|
74
|
+
// REAL rule now, so a proposal naming it must be ACCEPTED. This used to be
|
|
75
|
+
// this file's canonical invalid value — a later change that quietly dropped
|
|
76
|
+
// majority back out of PASS_RULES has to fail here, not pass silently.
|
|
77
|
+
assert.equal(validateProposedConstitution({ ...CONSENT_90, pass_rule: 'majority' }).ok, true);
|
|
78
|
+
assert.equal(config.sanitizeBoard({ ...CONSENT_90, pass_rule: 'majority' }).pass_rule, 'majority');
|
|
73
79
|
});
|
|
74
80
|
|
|
75
81
|
// ── propose: files the row and opens the window in the same breath ──────────
|
|
@@ -101,7 +107,7 @@ test('an invalid proposal files NOTHING — refused before any write', async ()
|
|
|
101
107
|
openBoardItem: async () => { throw new Error('must not be reached'); },
|
|
102
108
|
builderHoldsRankActive: async () => true,
|
|
103
109
|
}, async () => {
|
|
104
|
-
assert.equal((await proposeAmendment({ proposal: { ...CONSENT_90, pass_rule: '
|
|
110
|
+
assert.equal((await proposeAmendment({ proposal: { ...CONSENT_90, pass_rule: 'unanimous' }, proposedBy: '42' })).ok, false);
|
|
105
111
|
assert.equal((await proposeAmendment({ proposal: CONSENT_90, proposedBy: null })).reason, 'missing_proposer');
|
|
106
112
|
});
|
|
107
113
|
});
|
|
@@ -245,15 +251,38 @@ test('the monarchy act end to end: the sole Archon ratifies their OWN amendment
|
|
|
245
251
|
}
|
|
246
252
|
});
|
|
247
253
|
|
|
248
|
-
test('
|
|
254
|
+
test('the author rule is still SUBJECT-TYPED — but it now limits deciding, not casting (task 1003273)', async () => {
|
|
255
|
+
// The old assertion here was that an author's cast on a full_idea is REFUSED
|
|
256
|
+
// (`anti_self_vote`). The owner replaced that rule on 2026-08-25: an author may
|
|
257
|
+
// vote, and may never decide alone. What must still differ by subject type is
|
|
258
|
+
// WHO IS HELD TO IT — an amendment proposer is not (§7's monarchy act, proven
|
|
259
|
+
// in the test above), an idea's author is.
|
|
260
|
+
let karmaPaid = 0;
|
|
261
|
+
let inserted = null;
|
|
249
262
|
await withDb({
|
|
250
263
|
getBoardItemForVote: async () => amendmentItem({ subject_type: 'full_idea', subject_id: '7' }),
|
|
251
264
|
builderHoldsRankActive: async () => true,
|
|
252
265
|
getBoardItemAuthor: async () => '42',
|
|
253
|
-
insertBoardVote: async () => {
|
|
266
|
+
insertBoardVote: async (args) => { inserted = { id: '1', voter_id: args.voterId, direction: args.direction }; return inserted; },
|
|
267
|
+
insertVoteKarma: async () => { karmaPaid += 1; },
|
|
268
|
+
getVotesForItem: async () => (inserted ? [inserted] : []),
|
|
269
|
+
getActiveBuildersWithRank: async () => [{ builder_id: '42', github_login: 'a', display_name: 'A' }],
|
|
270
|
+
closeBoardItem: async () => { throw new Error('must not close on the author\'s own vote'); },
|
|
254
271
|
}, async () => {
|
|
255
|
-
|
|
256
|
-
|
|
272
|
+
// 1. The cast is ACCEPTED — this is the permissive half, and it is the half a
|
|
273
|
+
// later "tightening" would silently undo.
|
|
274
|
+
const cast = await castVote({ itemId: 21, voterId: '42', direction: 'yes' });
|
|
275
|
+
assert.equal(cast.ok, true, `an author's cast on their own idea must land: ${JSON.stringify(cast)}`);
|
|
276
|
+
assert.equal(cast.self_cast, true);
|
|
277
|
+
// 2. It earns NO karma — a self-vote confers no advantage.
|
|
278
|
+
assert.equal(cast.karma_delta, 0);
|
|
279
|
+
assert.equal(karmaPaid, 0, 'no karma row is written for a self-cast');
|
|
280
|
+
// 3. And it does NOT end the sitting: under first_ratifier the trigger needs
|
|
281
|
+
// a non-author vote, so the item stays open for a real second voice. The
|
|
282
|
+
// closeBoardItem stub above throws if this is ever violated.
|
|
283
|
+
const still = await maybeCloseAfterVote(21);
|
|
284
|
+
assert.deepEqual({ closed: still.closed, reason: still.reason },
|
|
285
|
+
{ closed: false, reason: 'window_still_open' });
|
|
257
286
|
});
|
|
258
287
|
});
|
|
259
288
|
|
|
@@ -113,7 +113,12 @@ test('a pass pays the author (+3, idempotent downstream); a return pays nobody',
|
|
|
113
113
|
await withDb({
|
|
114
114
|
getVotesForItem: async () => [no('42')],
|
|
115
115
|
closeBoardItem: async ({ itemId, outcome }) => ({ ...openItem(), id: String(itemId), closed_at: 't', outcome }),
|
|
116
|
-
|
|
116
|
+
// task 1003273: the author is now READ before the tally on a full_idea —
|
|
117
|
+
// their own 'no' must not be able to return their own idea — so this can no
|
|
118
|
+
// longer assert "never asked". What it still asserts is the thing the test is
|
|
119
|
+
// about: a return PAYS nobody. Someone who did not vote, so the tally is
|
|
120
|
+
// unaffected by the author rule.
|
|
121
|
+
getBoardItemAuthor: async () => '999',
|
|
117
122
|
insertAuthorRatifyKarma: async () => { throw new Error('must not be reached'); },
|
|
118
123
|
}, async () => {
|
|
119
124
|
const out = await closeItem(openItem());
|
|
@@ -162,6 +167,10 @@ test('consent with turnout outstanding leaves the window open', async () => {
|
|
|
162
167
|
getBoardItemForVote: async () => openItem({ constitution: CONSENT }),
|
|
163
168
|
getVotesForItem: async () => [yes('1')],
|
|
164
169
|
getActiveBuildersWithRank: async () => [member(1), member(2)], // 1 of 2 voted
|
|
170
|
+
// task 1003273: the trigger reads the author too (an author's cast never
|
|
171
|
+
// closes the sitting). '999' voted on nothing, so consent's turnout maths is
|
|
172
|
+
// exactly as it was.
|
|
173
|
+
getBoardItemAuthor: async () => '999',
|
|
165
174
|
closeBoardItem: async () => { throw new Error('must not be reached'); },
|
|
166
175
|
}, async () => {
|
|
167
176
|
assert.deepEqual(await maybeCloseAfterVote(11), { closed: false, reason: 'window_still_open' });
|
|
@@ -223,7 +232,9 @@ test('a RETURNED item books nothing and emits nothing — only a pass pays or an
|
|
|
223
232
|
assert.equal(afterClose, null, 'no in-tx hook on a return — nothing to book');
|
|
224
233
|
return { ...openItem(), closed_at: 't', outcome };
|
|
225
234
|
},
|
|
226
|
-
|
|
235
|
+
// Read (the author rule's input) but never PAID and never completeness-read —
|
|
236
|
+
// the two things this test is actually about. See the note on the return case.
|
|
237
|
+
getBoardItemAuthor: async () => '999',
|
|
227
238
|
getIdeaCompleteness: async () => { throw new Error('must not be reached'); },
|
|
228
239
|
}, async () => {
|
|
229
240
|
const out = await closeItem(openItem());
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// tests/government_board_majority.mjs — the `majority` pass rule, and the author
|
|
2
|
+
// rule that replaced the flat anti-self refusal (task 1003273).
|
|
3
|
+
//
|
|
4
|
+
// TWO OWNER DECISIONS OF 2026-08-25, pinned here:
|
|
5
|
+
// A. This project decides by MAJORITY. Strictly more than half of the RESOLVED
|
|
6
|
+
// MEMBERSHIP must vote yes. Silence is NOT assent — an item that never
|
|
7
|
+
// reaches a majority RETURNS at expiry. That is the whole difference from
|
|
8
|
+
// `consent`, which config.js documents as letting an empty window pass
|
|
9
|
+
// unopposed.
|
|
10
|
+
// B. An author MAY vote on their own idea, and may NEVER DECIDE ALONE. The old
|
|
11
|
+
// rule refused the cast outright and produced a dead end: every open item was
|
|
12
|
+
// authored by the one member reading them, so the room showed nine sittings
|
|
13
|
+
// and no ballot.
|
|
14
|
+
//
|
|
15
|
+
// THE PERMISSIVE ANSWERS ARE ASSERTED AS DELIBERATELY AS THE RESTRICTIVE ONES.
|
|
16
|
+
// modules/government/CLAUDE.md warns that a later well-meaning "tightening" must
|
|
17
|
+
// fail a test by design — an author's cast landing, and majority being a legal
|
|
18
|
+
// pass_rule, are exactly the two things such a change would quietly undo.
|
|
19
|
+
//
|
|
20
|
+
// DB-free (db monkeypatched — the sibling board tests' idiom).
|
|
21
|
+
import assert from 'node:assert/strict';
|
|
22
|
+
import { test } from 'node:test';
|
|
23
|
+
import { createRequire } from 'node:module';
|
|
24
|
+
import path from 'node:path';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
|
|
27
|
+
const require = createRequire(import.meta.url);
|
|
28
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
29
|
+
const GOV = path.join(ROOT, 'modules', 'government');
|
|
30
|
+
const board = require(path.join(GOV, 'board.js'));
|
|
31
|
+
const db = require(path.join(GOV, 'db.js'));
|
|
32
|
+
const config = require(path.join(GOV, 'config.js'));
|
|
33
|
+
|
|
34
|
+
const {
|
|
35
|
+
hasMajority, majorityDecided, tallyOutcome, shouldCloseAfterVote,
|
|
36
|
+
castVote, maybeCloseAfterVote, closeItem,
|
|
37
|
+
} = board;
|
|
38
|
+
|
|
39
|
+
async function withDb(stubs, fn) {
|
|
40
|
+
const originals = {};
|
|
41
|
+
for (const [k, v] of Object.entries(stubs)) { originals[k] = db[k]; db[k] = v; }
|
|
42
|
+
try { return await fn(); }
|
|
43
|
+
finally { for (const [k, v] of Object.entries(originals)) db[k] = v; }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const MAJORITY = {
|
|
47
|
+
membership: 'rank:archon', pass_rule: 'majority',
|
|
48
|
+
window_minutes: 4320, close_early_on_full_turnout: true,
|
|
49
|
+
};
|
|
50
|
+
const FIRST = { membership: 'rank:archon', pass_rule: 'first_ratifier', window_minutes: null, close_early_on_full_turnout: true };
|
|
51
|
+
|
|
52
|
+
const yes = (v) => ({ voter_id: String(v), direction: 'yes' });
|
|
53
|
+
const no = (v, section = 'fit') => ({ voter_id: String(v), direction: 'no', reason_md: 'r', section_key: section });
|
|
54
|
+
const member = (id) => ({ builder_id: String(id), github_login: `m${id}`, display_name: `M${id}` });
|
|
55
|
+
// The live board this project actually has: 4 active archons.
|
|
56
|
+
const FOUR = [member(1000025), member(1000094), member(1000095), member(3)];
|
|
57
|
+
|
|
58
|
+
const ideaItem = (over = {}) => ({
|
|
59
|
+
id: '11', subject_type: 'full_idea', subject_id: '7', opened_by: null,
|
|
60
|
+
constitution: MAJORITY, closed_at: null, outcome: null, expired: false, ...over,
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ── A. more than half, never half ───────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
test('majority is STRICTLY more than half — a tie is not a majority', () => {
|
|
66
|
+
// The 4-archon board this project runs: 3 carries, 2 does not.
|
|
67
|
+
assert.equal(hasMajority(3, 4), true);
|
|
68
|
+
assert.equal(hasMajority(2, 4), false, 'two of four is a tie, not a majority');
|
|
69
|
+
// Odd boards, and the degenerate ones.
|
|
70
|
+
assert.equal(hasMajority(2, 3), true);
|
|
71
|
+
assert.equal(hasMajority(1, 3), false);
|
|
72
|
+
assert.equal(hasMajority(1, 1), true);
|
|
73
|
+
assert.equal(hasMajority(0, 0), false, 'an empty board carries nothing');
|
|
74
|
+
assert.equal(hasMajority(1, 0), false);
|
|
75
|
+
assert.equal(hasMajority(0, 4), false);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('the denominator is the MEMBERSHIP, not the votes cast', () => {
|
|
79
|
+
// Two yes votes out of nine members is not a majority, however unanimous the
|
|
80
|
+
// turnout was. This is the rule that stops low turnout quietly passing things.
|
|
81
|
+
assert.equal(
|
|
82
|
+
tallyOutcome([yes(1), yes(2)], { constitution: MAJORITY, members: Array.from({ length: 9 }, (_, i) => member(i + 1)), authorId: '99' }),
|
|
83
|
+
'returned',
|
|
84
|
+
);
|
|
85
|
+
assert.equal(
|
|
86
|
+
tallyOutcome([yes(1), yes(2)], { constitution: MAJORITY, members: [member(1), member(2), member(3)], authorId: '99' }),
|
|
87
|
+
'passed',
|
|
88
|
+
);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ── A. silence is not assent ────────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
test('a majority sitting with ZERO votes RETURNS — the opposite of the other two rules', () => {
|
|
94
|
+
// The single most important difference. Under first_ratifier/consent an unvoted
|
|
95
|
+
// item PASSES, which is why a clock is unsafe there: the expiry sweep would
|
|
96
|
+
// ratify things nobody read. Under majority the same expiry returns them.
|
|
97
|
+
assert.equal(tallyOutcome([], { constitution: MAJORITY, members: FOUR, authorId: '1000025' }), 'returned');
|
|
98
|
+
// The contrast, asserted in the same breath so the distinction cannot rot:
|
|
99
|
+
assert.equal(tallyOutcome([], { constitution: FIRST, members: FOUR, authorId: '1000025' }), 'passed');
|
|
100
|
+
assert.equal(tallyOutcome([], { constitution: { pass_rule: 'consent' }, members: FOUR, authorId: null }), 'passed');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('a short-of-majority sitting returns even with no objection at all', () => {
|
|
104
|
+
// Nobody voted no; it simply never carried. Under the legacy rules this would
|
|
105
|
+
// have passed, since only a reasoned objection returns an item there.
|
|
106
|
+
assert.equal(tallyOutcome([yes(1000094)], { constitution: MAJORITY, members: FOUR, authorId: '1000025' }), 'returned');
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
// ── B. the author may vote, and may never decide alone ──────────────────────
|
|
110
|
+
|
|
111
|
+
test('an author MAY cast on their own idea, and it is recorded', async () => {
|
|
112
|
+
let written = null;
|
|
113
|
+
await withDb({
|
|
114
|
+
getBoardItemForVote: async () => ideaItem(),
|
|
115
|
+
builderHoldsRankActive: async () => true,
|
|
116
|
+
getBoardItemAuthor: async () => '1000025',
|
|
117
|
+
insertBoardVote: async (args) => { written = args; return { id: '1', voter_id: args.voterId, direction: args.direction }; },
|
|
118
|
+
insertVoteKarma: async () => { throw new Error('a self-cast must not be paid karma'); },
|
|
119
|
+
}, async () => {
|
|
120
|
+
const out = await castVote({ itemId: 11, voterId: '1000025', direction: 'yes' });
|
|
121
|
+
assert.equal(out.ok, true, 'the cast the OLD rule refused must now land');
|
|
122
|
+
assert.equal(out.self_cast, true);
|
|
123
|
+
assert.equal(out.karma_delta, 0);
|
|
124
|
+
assert.ok(written, 'the vote is really written, not merely tolerated');
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('a majority made ONLY of the author cannot carry the item', () => {
|
|
129
|
+
// A one-member board where that member is the author: 1 of 1 IS a majority, and
|
|
130
|
+
// it still must not pass — this is the guard that survives any constitution.
|
|
131
|
+
assert.equal(
|
|
132
|
+
tallyOutcome([yes(1000025)], { constitution: MAJORITY, members: [member(1000025)], authorId: '1000025' }),
|
|
133
|
+
'returned',
|
|
134
|
+
);
|
|
135
|
+
// The same votes with the author being someone else DO carry it, so the
|
|
136
|
+
// assertion above is about authorship and not about arithmetic.
|
|
137
|
+
assert.equal(
|
|
138
|
+
tallyOutcome([yes(1000025)], { constitution: MAJORITY, members: [member(1000025)], authorId: '999' }),
|
|
139
|
+
'passed',
|
|
140
|
+
);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("the author's yes COUNTS toward the majority — it just cannot be all of it", () => {
|
|
144
|
+
// 4 members, majority = 3. Author + 2 peers = 3 → carries.
|
|
145
|
+
assert.equal(
|
|
146
|
+
tallyOutcome([yes(1000025), yes(1000094), yes(1000095)], { constitution: MAJORITY, members: FOUR, authorId: '1000025' }),
|
|
147
|
+
'passed',
|
|
148
|
+
);
|
|
149
|
+
// The 3 peers without the author also carry it, obviously.
|
|
150
|
+
assert.equal(
|
|
151
|
+
tallyOutcome([yes(1000094), yes(1000095), yes(3)], { constitution: MAJORITY, members: FOUR, authorId: '1000025' }),
|
|
152
|
+
'passed',
|
|
153
|
+
);
|
|
154
|
+
// But author + 1 peer is only 2 of 4 — a tie, not a majority.
|
|
155
|
+
assert.equal(
|
|
156
|
+
tallyOutcome([yes(1000025), yes(1000094)], { constitution: MAJORITY, members: FOUR, authorId: '1000025' }),
|
|
157
|
+
'returned',
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test('an author cannot make their own idea WORSE by voting on it', () => {
|
|
162
|
+
// Their 'no' is excluded from the objection test, under every rule. Otherwise
|
|
163
|
+
// the safest act available to an author would be silence, which is a strange
|
|
164
|
+
// incentive to build into a board.
|
|
165
|
+
assert.equal(tallyOutcome([no(1000025)], { constitution: FIRST, members: FOUR, authorId: '1000025' }), 'passed');
|
|
166
|
+
// A PEER's no returns it, which is the rule that must survive.
|
|
167
|
+
assert.equal(tallyOutcome([no(1000094)], { constitution: FIRST, members: FOUR, authorId: '1000025' }), 'returned');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// ── B. the close trigger needs a voice that is not the author's ─────────────
|
|
171
|
+
|
|
172
|
+
test("an author's cast never ends the sitting, under any rule", () => {
|
|
173
|
+
for (const constitution of [FIRST, MAJORITY, { ...MAJORITY, pass_rule: 'consent' }]) {
|
|
174
|
+
assert.equal(
|
|
175
|
+
shouldCloseAfterVote({ constitution, votes: [yes(1000025)], members: FOUR, authorId: '1000025' }),
|
|
176
|
+
false,
|
|
177
|
+
`${constitution.pass_rule}: the author alone must not close it`,
|
|
178
|
+
);
|
|
179
|
+
}
|
|
180
|
+
// A single NON-author vote closes it under first_ratifier, as it always did.
|
|
181
|
+
assert.equal(
|
|
182
|
+
shouldCloseAfterVote({ constitution: FIRST, votes: [yes(1000094)], members: FOUR, authorId: '1000025' }),
|
|
183
|
+
true,
|
|
184
|
+
);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('the whole live deadlock, end to end: the author votes, and the item stays open for a real voice', async () => {
|
|
188
|
+
// This is the nine-items situation with the new rule. The author can now put a
|
|
189
|
+
// position on the record — which the old rule denied them — and the sitting
|
|
190
|
+
// still waits for someone else, which is the point.
|
|
191
|
+
let votes = [];
|
|
192
|
+
await withDb({
|
|
193
|
+
getBoardItemForVote: async () => ideaItem(),
|
|
194
|
+
builderHoldsRankActive: async () => true,
|
|
195
|
+
getBoardItemAuthor: async () => '1000025',
|
|
196
|
+
insertBoardVote: async (a) => { const v = { id: String(votes.length + 1), voter_id: a.voterId, direction: a.direction }; votes.push(v); return v; },
|
|
197
|
+
insertVoteKarma: async () => {},
|
|
198
|
+
getVotesForItem: async () => votes,
|
|
199
|
+
getActiveBuildersWithRank: async () => FOUR,
|
|
200
|
+
closeBoardItem: async () => { throw new Error('must not close yet'); },
|
|
201
|
+
}, async () => {
|
|
202
|
+
await castVote({ itemId: 11, voterId: '1000025', direction: 'yes' });
|
|
203
|
+
assert.deepEqual(await maybeCloseAfterVote(11), { closed: false, reason: 'window_still_open' });
|
|
204
|
+
// A second archon joins: 2 of 4 is a tie, still not a majority.
|
|
205
|
+
await castVote({ itemId: 11, voterId: '1000094', direction: 'yes' });
|
|
206
|
+
assert.deepEqual(await maybeCloseAfterVote(11), { closed: false, reason: 'window_still_open' });
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test('the third yes carries it, and the close pays the author', async () => {
|
|
211
|
+
const votes = [yes(1000025), yes(1000094), yes(1000095)];
|
|
212
|
+
let paid = null;
|
|
213
|
+
await withDb({
|
|
214
|
+
getVotesForItem: async () => votes,
|
|
215
|
+
getBoardItemAuthor: async () => '1000025',
|
|
216
|
+
getActiveBuildersWithRank: async () => FOUR,
|
|
217
|
+
getIdeaCompleteness: async () => 100,
|
|
218
|
+
closeBoardItem: async ({ outcome }) => ({ ...ideaItem(), closed_at: 'T', outcome }),
|
|
219
|
+
insertAuthorRatifyKarma: async (a) => { paid = a; return true; },
|
|
220
|
+
}, async () => {
|
|
221
|
+
const out = await closeItem(ideaItem(), { cause: 'vote' });
|
|
222
|
+
assert.equal(out.outcome, 'passed', '3 of 4, with two non-author voices');
|
|
223
|
+
assert.ok(paid, 'ratification pays the author, as it always did');
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// ── the early close: a sitting whose answer is fixed ────────────────────────
|
|
228
|
+
|
|
229
|
+
test('a decided majority closes early rather than idling', () => {
|
|
230
|
+
// Carried already (3 of 4, with peers among them) — no later vote can change it.
|
|
231
|
+
assert.equal(majorityDecided({ votes: [yes(1000094), yes(1000095), yes(3)], members: FOUR, authorId: '1000025' }), true);
|
|
232
|
+
// Unreachable already: 3 of 4 voted no, so even a yes from the last member
|
|
233
|
+
// cannot reach 3. There is no vote-changing in v1, so this is final.
|
|
234
|
+
assert.equal(majorityDecided({ votes: [no(1000094), no(1000095), no(3)], members: FOUR, authorId: '1000025' }), true);
|
|
235
|
+
// Still live: 1 yes, 3 members yet to vote.
|
|
236
|
+
assert.equal(majorityDecided({ votes: [yes(1000094)], members: FOUR, authorId: '1000025' }), false);
|
|
237
|
+
// An empty board decides nothing.
|
|
238
|
+
assert.equal(majorityDecided({ votes: [], members: [], authorId: null }), false);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test('a majority that is only the author is NOT treated as decided', () => {
|
|
242
|
+
// It would be arithmetically carried on a 1-member board, but it cannot pass —
|
|
243
|
+
// so closing early would deny the item the one real voice that could save it.
|
|
244
|
+
assert.equal(majorityDecided({ votes: [yes(1000025)], members: [member(1000025)], authorId: '1000025' }), false);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('full turnout closes a majority sitting, and only when the constitution says so', () => {
|
|
248
|
+
const all = [yes(1000025), no(1000094), no(1000095), no(3)];
|
|
249
|
+
// Every member has voted; the outcome is already unreachable anyway.
|
|
250
|
+
assert.equal(shouldCloseAfterVote({ constitution: MAJORITY, votes: all, members: FOUR, authorId: '1000025' }), true);
|
|
251
|
+
// With close_early off, an undecided-but-full sitting waits for the clock.
|
|
252
|
+
const split = [yes(1000025), yes(1000094)]; // 2 of 2 members here
|
|
253
|
+
const two = [member(1000025), member(1000094)];
|
|
254
|
+
assert.equal(
|
|
255
|
+
shouldCloseAfterVote({ constitution: { ...MAJORITY, close_early_on_full_turnout: false }, votes: split, members: two, authorId: '1000025' }),
|
|
256
|
+
true, 'carried on the merits (2 of 2, one non-author), so it closes regardless',
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// ── the rule is a real, proposable value ───────────────────────────────────
|
|
261
|
+
|
|
262
|
+
test('majority is a LEGAL pass_rule everywhere a rule is read', () => {
|
|
263
|
+
assert.ok(config.PASS_RULES.includes('majority'));
|
|
264
|
+
// The sanitizer keeps it (it is not a typo to be corrected away)…
|
|
265
|
+
assert.equal(config.sanitizeBoard({ ...MAJORITY }).pass_rule, 'majority');
|
|
266
|
+
// …and the fail-closed fallback is unchanged for a value that IS a typo.
|
|
267
|
+
assert.equal(config.sanitizeBoard({ ...MAJORITY, pass_rule: 'majorty' }).pass_rule, 'first_ratifier');
|
|
268
|
+
assert.equal(board.validateProposedConstitution({ ...MAJORITY }).ok, true);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
test('an unknown rule still closes NOTHING after a vote (fail-closed, unchanged)', () => {
|
|
272
|
+
assert.equal(
|
|
273
|
+
shouldCloseAfterVote({ constitution: { pass_rule: 'whatever' }, votes: [yes(1000094)], members: FOUR, authorId: '1000025' }),
|
|
274
|
+
false,
|
|
275
|
+
);
|
|
276
|
+
});
|