@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
|
@@ -133,7 +133,21 @@ async function editWebhook(channel, messageId, text) {
|
|
|
133
133
|
return { ok: true, status: res.status };
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
// Is a channel actually wired up? (task 1003271.) Callers whose post is
|
|
137
|
+
// best-effort swallow every failure, which makes "this instance never configured
|
|
138
|
+
// the webhook" indistinguishable from "Discord returned 500" in the logs — and
|
|
139
|
+
// the first is an operator action while the second is weather. This answers the
|
|
140
|
+
// cheap half without attempting a post. False for an unknown channel too: a name
|
|
141
|
+
// nothing can resolve is not configured.
|
|
142
|
+
function hasWebhook(channel) {
|
|
143
|
+
try {
|
|
144
|
+
return !!(CHANNELS.has(channel) && loadWebhooks()[channel]);
|
|
145
|
+
} catch (_) {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
136
150
|
module.exports = {
|
|
137
|
-
CHANNELS, WEBHOOKS_PATH, loadWebhooks, resolveWebhookUrl,
|
|
151
|
+
CHANNELS, WEBHOOKS_PATH, loadWebhooks, resolveWebhookUrl, hasWebhook,
|
|
138
152
|
postWebhook, postWebhookMentions, editWebhook,
|
|
139
153
|
};
|
|
@@ -214,10 +214,13 @@ function validateVote({ direction, reasonMd, sectionKey } = {}) {
|
|
|
214
214
|
|
|
215
215
|
// Cast a vote on an open item. Returns a typed result, never throws for a
|
|
216
216
|
// policy refusal — the route maps each to its status:
|
|
217
|
-
// { ok: true, vote, fresh: true, karma_delta }
|
|
218
|
-
// { ok: true, vote, fresh: false } — idempotent re-cast
|
|
217
|
+
// { ok: true, vote, fresh: true, karma_delta, self_cast } — recorded now
|
|
218
|
+
// { ok: true, vote, fresh: false, self_cast } — idempotent re-cast
|
|
219
219
|
// { ok: false, reason: 'item_not_found' | 'item_closed' | 'vote_window_expired'
|
|
220
|
-
// | 'not_board_member' |
|
|
220
|
+
// | 'not_board_member' | <validateVote reason> }
|
|
221
|
+
// 'anti_self_vote' is GONE as of task 1003273 — an author may cast; what they
|
|
222
|
+
// cannot do is decide alone, which shouldCloseAfterVote and tallyOutcome enforce.
|
|
223
|
+
// A self-cast is recorded and visible but earns no karma (karma_delta: 0).
|
|
221
224
|
async function castVote({ itemId, voterId, direction, reasonMd = null, sectionKey = null } = {}) {
|
|
222
225
|
const id = Number(itemId);
|
|
223
226
|
if (!Number.isFinite(id) || id <= 0) return { ok: false, reason: 'item_not_found' };
|
|
@@ -237,20 +240,34 @@ async function castVote({ itemId, voterId, direction, reasonMd = null, sectionKe
|
|
|
237
240
|
const constitution = item.constitution || {};
|
|
238
241
|
if (!(await isBoardMember(voterId, constitution))) return { ok: false, reason: 'not_board_member' };
|
|
239
242
|
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
//
|
|
247
|
-
//
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
243
|
+
// THE AUTHOR MAY NOW VOTE (task 1003273, owner decision 2026-08-25). The old
|
|
244
|
+
// rule refused an author's cast outright, which is defensible on a board of
|
|
245
|
+
// strangers and dead-ended here: every open item was authored by the one member
|
|
246
|
+
// reading them, so the room offered nine sittings and no ballot.
|
|
247
|
+
//
|
|
248
|
+
// WHAT REPLACES IT IS NOT A WEAKER RULE, IT IS A RULE IN A DIFFERENT PLACE.
|
|
249
|
+
// "An author never decides alone" is enforced where deciding actually happens —
|
|
250
|
+
// shouldCloseAfterVote (an author's cast never ends the sitting) and
|
|
251
|
+
// tallyOutcome (a majority with no non-author yes cannot carry, and an author's
|
|
252
|
+
// 'no' cannot return their own idea). Refusing the CAST was only ever a proxy
|
|
253
|
+
// for those, and it was the proxy that produced the deadlock.
|
|
254
|
+
//
|
|
255
|
+
// So there is no anti-self branch here any more, for either subject type. The
|
|
256
|
+
// amendment carve-out ADR 0175 §§7/11 blessed is subsumed rather than removed:
|
|
257
|
+
// a sole Archon proposing an amendment could always ratify it, and still can —
|
|
258
|
+
// that path never had a non-author to wait for, and an amendment pays nothing,
|
|
259
|
+
// which is why it was carved out in the first place.
|
|
260
|
+
//
|
|
261
|
+
// NO KARMA FOR VOTING ON YOUR OWN ITEM. The +1 exists to reward the work of
|
|
262
|
+
// reading someone else's idea and taking a position on it; paying it for a
|
|
263
|
+
// self-cast would make an author's own ballot a tiny income stream, and the
|
|
264
|
+
// owner's rule is that a self-vote confers no advantage.
|
|
265
|
+
// Not even asked for an amendment — the carve-out means there is nothing to
|
|
266
|
+
// learn, and the read is a query on the vote path.
|
|
267
|
+
const subjectAuthorId = authorRuleApplies(item.subject_type)
|
|
268
|
+
? await db.getBoardItemAuthor(item)
|
|
269
|
+
: null;
|
|
270
|
+
const isSelfCast = subjectAuthorId != null && String(subjectAuthorId) === String(voterId);
|
|
254
271
|
|
|
255
272
|
const inserted = await db.insertBoardVote({
|
|
256
273
|
itemId: id, voterId, direction: v.direction, reasonMd: v.reasonMd, sectionKey: v.sectionKey,
|
|
@@ -258,12 +275,15 @@ async function castVote({ itemId, voterId, direction, reasonMd = null, sectionKe
|
|
|
258
275
|
if (!inserted) {
|
|
259
276
|
// UNIQUE(item_id, voter_id) — the idempotent path. Return what stands.
|
|
260
277
|
const existing = await db.getBoardVote(id, voterId);
|
|
261
|
-
return { ok: true, vote: existing, fresh: false };
|
|
278
|
+
return { ok: true, vote: existing, fresh: false, self_cast: isSelfCast };
|
|
262
279
|
}
|
|
263
280
|
// Karma rides the FRESH insert only, and the whole route refused a closed or
|
|
264
281
|
// expired item above, so every paid vote is by construction in-window.
|
|
282
|
+
if (isSelfCast) {
|
|
283
|
+
return { ok: true, vote: inserted, fresh: true, karma_delta: 0, self_cast: true };
|
|
284
|
+
}
|
|
265
285
|
await db.insertVoteKarma({ voterId, itemId: id, delta: VOTE_KARMA_DELTA });
|
|
266
|
-
return { ok: true, vote: inserted, fresh: true, karma_delta: VOTE_KARMA_DELTA };
|
|
286
|
+
return { ok: true, vote: inserted, fresh: true, karma_delta: VOTE_KARMA_DELTA, self_cast: false };
|
|
267
287
|
}
|
|
268
288
|
|
|
269
289
|
// ── close + tally (BV1.R10 of goal 1000069, ADR 0175 §3) ────────────────────
|
|
@@ -299,19 +319,128 @@ async function castVote({ itemId, voterId, direction, reasonMd = null, sectionKe
|
|
|
299
319
|
// `WHERE outcome = 'passed'` shape survives precisely because closing and
|
|
300
320
|
// emitting are separate acts.
|
|
301
321
|
|
|
302
|
-
//
|
|
303
|
-
//
|
|
304
|
-
|
|
305
|
-
|
|
322
|
+
// ── the author's own vote (task 1003273) ────────────────────────────────────
|
|
323
|
+
//
|
|
324
|
+
// THE RULE THE OWNER CHOSE, and the deadlock it replaces. The old line was flat:
|
|
325
|
+
// an author could not vote on their own idea at all. That is defensible on a
|
|
326
|
+
// board of strangers and produced a dead end here — every open item was authored
|
|
327
|
+
// by the one person looking at them, so the Board Room showed nine sittings and
|
|
328
|
+
// no ballot. The rule now: AN AUTHOR MAY VOTE, AND MAY NEVER DECIDE ALONE.
|
|
329
|
+
//
|
|
330
|
+
// "Never decide alone" is enforced in two places, because there are two ways to
|
|
331
|
+
// decide something:
|
|
332
|
+
// 1. by CLOSING it — shouldCloseAfterVote requires a NON-AUTHOR vote, so
|
|
333
|
+
// an author's cast never trips the trigger that ends the sitting;
|
|
334
|
+
// 2. by CARRYING it — the majority tally requires at least one non-author
|
|
335
|
+
// YES, so a majority made only of the author cannot ratify.
|
|
336
|
+
// An author's vote still counts toward the majority NUMERATOR (it is a real
|
|
337
|
+
// vote — they sit on the board), it just can never be the whole of it.
|
|
338
|
+
//
|
|
339
|
+
// AND AN AUTHOR CAN NEVER MAKE THEIR OWN IDEA WORSE. Their 'no' is excluded from
|
|
340
|
+
// the objection test, so voting on your own idea cannot return it — otherwise the
|
|
341
|
+
// safest thing an author could do would be to stay silent, which is a strange
|
|
342
|
+
// incentive to build into a board.
|
|
343
|
+
// THE AUTHOR RULE IS A FULL_IDEA RULE, and that subject-typing is load-bearing
|
|
344
|
+
// rather than tidy (ADR 0175 §§7/11). An idea's author GAINS from ratification
|
|
345
|
+
// (credits, karma, legitimacy), which is what "never decide alone" guards. An
|
|
346
|
+
// amendment pays nothing, and §7 explicitly blesses the monarchy act: on a
|
|
347
|
+
// one-Archon board the proposer must be able to ratify their own amendment or
|
|
348
|
+
// the whole subject type is dead on arrival — and a special escape for that case
|
|
349
|
+
// is precisely the bootstrap exception §7 forbids. So the carve-out is not an
|
|
350
|
+
// exception to the author rule, it is the author rule's scope.
|
|
351
|
+
//
|
|
352
|
+
// Everything downstream reads the author through this: for an amendment the
|
|
353
|
+
// deciding author is NULL, so the trigger and the tally behave exactly as they
|
|
354
|
+
// did before task 1003273.
|
|
355
|
+
const authorRuleApplies = (subjectType) => subjectType === 'full_idea';
|
|
356
|
+
|
|
357
|
+
// The author id that the DECIDING rules should see: the real one for a full
|
|
358
|
+
// idea, nobody for an amendment.
|
|
359
|
+
const decidingAuthorId = (subjectType, authorId) =>
|
|
360
|
+
(authorRuleApplies(subjectType) ? authorId : null);
|
|
361
|
+
|
|
362
|
+
const isAuthorVote = (v, authorId) =>
|
|
363
|
+
authorId != null && String(v.voter_id) === String(authorId);
|
|
364
|
+
|
|
365
|
+
// Votes that can DECIDE: everyone but the subject's author.
|
|
366
|
+
const decidingVotes = (votes, authorId) =>
|
|
367
|
+
(votes || []).filter((v) => !isAuthorVote(v, authorId));
|
|
368
|
+
|
|
369
|
+
// Strictly more than half — never >=. On an even board of 4 that is 3, not 2:
|
|
370
|
+
// "more than half" is the honest reading of majority, and a tie is not a
|
|
371
|
+
// majority. Of the resolved MEMBERSHIP, not of the votes cast, so low turnout
|
|
372
|
+
// cannot quietly pass something on two voices out of nine.
|
|
373
|
+
function hasMajority(yesCount, memberCount) {
|
|
374
|
+
if (!Number.isFinite(memberCount) || memberCount <= 0) return false;
|
|
375
|
+
return yesCount * 2 > memberCount;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// The whole counting rule. Under the two absence-passes rules, absence is nulled
|
|
379
|
+
// by what this function DOESN'T read: only cast votes exist here. Under
|
|
380
|
+
// `majority` absence is the opposite — it is counted, via the membership
|
|
381
|
+
// denominator — which is exactly what makes silence not assent.
|
|
382
|
+
function tallyOutcome(votes, { constitution = {}, members = null, authorId = null } = {}) {
|
|
383
|
+
const list = votes || [];
|
|
384
|
+
if (constitution.pass_rule === 'majority') {
|
|
385
|
+
const yes = list.filter((v) => v.direction === 'yes');
|
|
386
|
+
const memberCount = Array.isArray(members) ? members.length : 0;
|
|
387
|
+
// Both conditions, or it returns: a majority of the membership said yes, AND
|
|
388
|
+
// at least one voice that was not the author's is among them.
|
|
389
|
+
const carried = hasMajority(yes.length, memberCount)
|
|
390
|
+
&& yes.some((v) => !isAuthorVote(v, authorId));
|
|
391
|
+
return carried ? 'passed' : 'returned';
|
|
392
|
+
}
|
|
393
|
+
// first_ratifier / consent / anything unknown: one reasoned objection returns
|
|
394
|
+
// the idea, anything else — including ZERO votes — passes. The author's own
|
|
395
|
+
// vote is excluded so they cannot return their own idea.
|
|
396
|
+
return decidingVotes(list, authorId).some((v) => v.direction === 'no') ? 'returned' : 'passed';
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Is this majority sitting already DECIDED, whatever the absent members do? Once
|
|
400
|
+
// the yes votes clear the bar the outcome cannot change (there is no
|
|
401
|
+
// vote-changing in v1), and once enough members have voted the OTHER way that the
|
|
402
|
+
// remaining ones cannot reach the bar, it cannot change either. Closing then is
|
|
403
|
+
// not impatience — it is refusing to hold a sitting whose answer is already
|
|
404
|
+
// fixed, which is the same reason consent closes on full turnout.
|
|
405
|
+
function majorityDecided({ votes, members, authorId }) {
|
|
406
|
+
const list = votes || [];
|
|
407
|
+
const memberCount = Array.isArray(members) ? members.length : 0;
|
|
408
|
+
if (!memberCount) return false; // an empty board decides nothing
|
|
409
|
+
const yes = list.filter((v) => v.direction === 'yes');
|
|
410
|
+
// Already carried — but only if a non-author yes is among them, since a
|
|
411
|
+
// majority that is only the author can still be joined by someone who makes
|
|
412
|
+
// it legitimate. Closing early there would deny the item its one real voice.
|
|
413
|
+
if (hasMajority(yes.length, memberCount) && yes.some((v) => !isAuthorVote(v, authorId))) return true;
|
|
414
|
+
// Already unreachable: even if every member who has not voted votes yes, the
|
|
415
|
+
// bar cannot be met. A member who voted 'no' cannot vote again (v1).
|
|
416
|
+
const voted = new Set(list.map((v) => String(v.voter_id)));
|
|
417
|
+
const notYetVoted = members.filter((m) => !voted.has(String(m.builder_id))).length;
|
|
418
|
+
return !hasMajority(yes.length + notYetVoted, memberCount);
|
|
306
419
|
}
|
|
307
420
|
|
|
308
421
|
// Should a just-cast vote close the item now, under the SNAPSHOTTED rule?
|
|
309
|
-
|
|
422
|
+
//
|
|
423
|
+
// EVERY RULE NEEDS A NON-AUTHOR VOTE FIRST (task 1003273): an author's cast is
|
|
424
|
+
// recorded but never ends the sitting. Without this an author could close their
|
|
425
|
+
// own item under first_ratifier — the very self-decision the rule forbids.
|
|
426
|
+
function shouldCloseAfterVote({ constitution, votes, members, authorId = null } = {}) {
|
|
310
427
|
const rule = (constitution || {}).pass_rule;
|
|
311
|
-
|
|
428
|
+
const deciding = decidingVotes(votes, authorId);
|
|
429
|
+
if (rule === 'first_ratifier') return deciding.length >= 1;
|
|
430
|
+
if (rule === 'majority') {
|
|
431
|
+
if (!deciding.length) return false; // nobody but the author has spoken
|
|
432
|
+
if (majorityDecided({ votes, members, authorId })) return true;
|
|
433
|
+
// Full turnout closes it too, when the constitution says so — the same
|
|
434
|
+
// courtesy consent gets, and by then every position is on the record.
|
|
435
|
+
if (!(constitution || {}).close_early_on_full_turnout) return false;
|
|
436
|
+
if (!members || !members.length) return false;
|
|
437
|
+
const voters = new Set((votes || []).map((v) => String(v.voter_id)));
|
|
438
|
+
return members.every((m) => voters.has(String(m.builder_id)));
|
|
439
|
+
}
|
|
312
440
|
if (rule === 'consent') {
|
|
313
441
|
if (!(constitution || {}).close_early_on_full_turnout) return false;
|
|
314
442
|
if (!members || !members.length) return false; // an empty board has no turnout to complete
|
|
443
|
+
if (!deciding.length) return false;
|
|
315
444
|
const voters = new Set((votes || []).map((v) => String(v.voter_id)));
|
|
316
445
|
return members.every((m) => voters.has(String(m.builder_id)));
|
|
317
446
|
}
|
|
@@ -357,15 +486,46 @@ function passPayload(closed, { authorId, completenessScore, passedAt }) {
|
|
|
357
486
|
async function closeItem(item, { cause = 'vote' } = {}) {
|
|
358
487
|
if (!item || item.closed_at) return { closed: false, reason: 'already_closed' };
|
|
359
488
|
const votes = await db.getVotesForItem(item.id);
|
|
360
|
-
const
|
|
489
|
+
const constitution = item.constitution || {};
|
|
490
|
+
|
|
491
|
+
// THE TALLY'S INPUTS ARE READ HERE, NOT PASSED IN, so the two callers — a
|
|
492
|
+
// racing vote and the expiry sweep — cannot disagree about what a close means
|
|
493
|
+
// (the invariant R10 already held for the outcome now has to hold for the
|
|
494
|
+
// majority denominator too). Both are resolved against the item's SNAPSHOT and
|
|
495
|
+
// the live rows, uncached, the same ADR 0016 posture as every other read here.
|
|
496
|
+
//
|
|
497
|
+
// authorId moved AHEAD of the tally (task 1003273): it used to be fetched only
|
|
498
|
+
// on a pass, for the payload, but the author rule makes it an INPUT to the
|
|
499
|
+
// outcome — an author's vote must not return their own idea, and a majority
|
|
500
|
+
// made only of the author must not carry it.
|
|
501
|
+
// Read ONLY when a decision actually depends on it. For a full idea the author
|
|
502
|
+
// is an INPUT to the outcome (their 'no' cannot return their own idea, and a
|
|
503
|
+
// majority with no other voice cannot carry it), so it must be known before the
|
|
504
|
+
// tally. For an amendment the carve-out makes it irrelevant to the outcome, so
|
|
505
|
+
// the read stays where it always was — after the tally, only on a pass, for the
|
|
506
|
+
// payload and the ratification karma. Same query count as before this task on
|
|
507
|
+
// the amendment path, one extra on an idea close.
|
|
508
|
+
let authorId = authorRuleApplies(item.subject_type)
|
|
509
|
+
? await db.getBoardItemAuthor(item)
|
|
510
|
+
: null;
|
|
511
|
+
// Only `majority` needs the membership denominator; the absence-passes rules
|
|
512
|
+
// read nothing but the cast votes, so we do not spend the query on them.
|
|
513
|
+
const members = constitution.pass_rule === 'majority'
|
|
514
|
+
? await resolveBoardMembers(constitution)
|
|
515
|
+
: null;
|
|
516
|
+
const outcome = tallyOutcome(votes, {
|
|
517
|
+
constitution, members,
|
|
518
|
+
// Subject-typed: an amendment's proposer is not held to the author rule (§7).
|
|
519
|
+
authorId: decidingAuthorId(item.subject_type, authorId),
|
|
520
|
+
});
|
|
361
521
|
|
|
362
|
-
// The payload's reads
|
|
363
|
-
//
|
|
364
|
-
let authorId = null;
|
|
522
|
+
// The payload's remaining reads (reads never ride a tx they don't need). Only a
|
|
523
|
+
// pass needs them, and only a full_idea can pay.
|
|
365
524
|
let completenessScore = null;
|
|
366
525
|
let credit = null;
|
|
367
526
|
if (outcome === 'passed') {
|
|
368
|
-
|
|
527
|
+
// The amendment path's author, deferred to here — see the note above.
|
|
528
|
+
if (authorId == null) authorId = await db.getBoardItemAuthor(item);
|
|
369
529
|
if (item.subject_type === 'full_idea') {
|
|
370
530
|
completenessScore = await db.getIdeaCompleteness(item.subject_id);
|
|
371
531
|
}
|
|
@@ -444,7 +604,12 @@ async function maybeCloseAfterVote(itemId) {
|
|
|
444
604
|
const constitution = item.constitution || {};
|
|
445
605
|
const votes = await db.getVotesForItem(item.id);
|
|
446
606
|
const members = await resolveBoardMembers(constitution);
|
|
447
|
-
|
|
607
|
+
// task 1003273: the author is an input to the trigger, not just the tally — an
|
|
608
|
+
// author's cast must never be the vote that ends the sitting.
|
|
609
|
+
const authorId = authorRuleApplies(item.subject_type)
|
|
610
|
+
? await db.getBoardItemAuthor(item)
|
|
611
|
+
: null;
|
|
612
|
+
if (!shouldCloseAfterVote({ constitution, votes, members, authorId })) {
|
|
448
613
|
return { closed: false, reason: 'window_still_open' };
|
|
449
614
|
}
|
|
450
615
|
return closeItem(item, { cause: 'vote' });
|
|
@@ -719,7 +884,77 @@ async function latestRejectionForIdea(ideaId) {
|
|
|
719
884
|
});
|
|
720
885
|
}
|
|
721
886
|
|
|
887
|
+
// ── the summons: what is waiting on THIS member's vote (task 1003271) ───────
|
|
888
|
+
//
|
|
889
|
+
// THE PROBLEM THIS EXISTS TO FIX. Nine items sat open for two days with zero
|
|
890
|
+
// votes. The board was not deadlocked — three of the four archons could each
|
|
891
|
+
// have carried any of them with one click — they simply never learned a vote was
|
|
892
|
+
// wanted. The Discord announcer (R21) is the only summons there has ever been,
|
|
893
|
+
// and it is BEST-EFFORT BY CONSTRUCTION: an unconfigured webhook, an unlinked
|
|
894
|
+
// member, or a failed POST all end in a swallowed log line. A nudge that can
|
|
895
|
+
// silently not happen cannot be the only nudge.
|
|
896
|
+
//
|
|
897
|
+
// So the durable summons is the one surface every session and every hall visit
|
|
898
|
+
// already reads: the builder-needs roster on /me (ADR 0073). This function is
|
|
899
|
+
// its counter. Discord stays exactly what §8 says it is — a notification, not a
|
|
900
|
+
// ballot — and voting stays hall-only (ADR 0175 §8, owner decision 2026-08-25).
|
|
901
|
+
//
|
|
902
|
+
// WHY MEMBERSHIP IS RESOLVED HERE AND NOT IN SQL. Each item snapshots the
|
|
903
|
+
// constitution it opened under, so "may this builder vote on THIS item" is a
|
|
904
|
+
// per-item predicate over the LIVE rank rows — the ADR 0016 posture the rest of
|
|
905
|
+
// this module keeps (per request, uncached). Resolved once per DISTINCT
|
|
906
|
+
// predicate rather than once per item, so nine items under one constitution cost
|
|
907
|
+
// one membership resolve, not nine.
|
|
908
|
+
//
|
|
909
|
+
// It counts what the vote route would ACCEPT, so the roster can never promise a
|
|
910
|
+
// ballot the route then refuses:
|
|
911
|
+
// - open, not past its deadline (the SQL's WHERE)
|
|
912
|
+
// - this builder has not already cast (UNIQUE(item_id, voter_id) — one vote each)
|
|
913
|
+
// - this builder is a board member under the item's OWN snapshot
|
|
914
|
+
// - this builder is not the subject's author (the anti-self-vote line, §3)
|
|
915
|
+
// Fail-closed on an unreadable constitution: parseMembershipPredicate yields
|
|
916
|
+
// nobody, so a malformed snapshot summons no one rather than everyone.
|
|
917
|
+
async function pendingVotesFor(builderId) {
|
|
918
|
+
const empty = Object.freeze({ count: 0, first_item_id: null });
|
|
919
|
+
if (builderId === null || builderId === undefined || builderId === '') return empty;
|
|
920
|
+
let rows;
|
|
921
|
+
try {
|
|
922
|
+
rows = await db.openBoardItemsAwaitingVoter(builderId);
|
|
923
|
+
} catch (_) {
|
|
924
|
+
// A summons that cannot be counted is a quiet session, never a failed /me.
|
|
925
|
+
return empty;
|
|
926
|
+
}
|
|
927
|
+
if (!rows || !rows.length) return empty;
|
|
928
|
+
|
|
929
|
+
// One membership answer per distinct predicate. The predicate string IS the
|
|
930
|
+
// cache key: two items under the same membership have the same answer, and the
|
|
931
|
+
// resolve is a DB read we should not repeat nine times on the session hot path.
|
|
932
|
+
const membershipCache = new Map();
|
|
933
|
+
const votable = [];
|
|
934
|
+
for (const row of rows) {
|
|
935
|
+
if (row.already_voted) continue;
|
|
936
|
+
// The author does not vote on their own item (§3). Null author = nobody to
|
|
937
|
+
// disqualify, so the item stays votable.
|
|
938
|
+
if (row.author_id != null && String(row.author_id) === String(builderId)) continue;
|
|
939
|
+
const constitution = row.constitution || {};
|
|
940
|
+
const key = String(constitution.membership || '');
|
|
941
|
+
if (!membershipCache.has(key)) {
|
|
942
|
+
membershipCache.set(key, await isBoardMember(builderId, constitution).catch(() => false));
|
|
943
|
+
}
|
|
944
|
+
if (!membershipCache.get(key)) continue;
|
|
945
|
+
votable.push(row);
|
|
946
|
+
}
|
|
947
|
+
if (!votable.length) return empty;
|
|
948
|
+
// Oldest first (the SQL's ORDER BY), so the deep link lands on the sitting
|
|
949
|
+
// that has been waiting longest rather than an arbitrary one.
|
|
950
|
+
return Object.freeze({ count: votable.length, first_item_id: String(votable[0].id) });
|
|
951
|
+
}
|
|
952
|
+
|
|
722
953
|
module.exports = {
|
|
954
|
+
pendingVotesFor,
|
|
955
|
+
hasMajority,
|
|
956
|
+
majorityDecided,
|
|
957
|
+
decidingVotes,
|
|
723
958
|
FULL_IDEA_PASS_BAR,
|
|
724
959
|
ideaPassesBar,
|
|
725
960
|
openWindowForFullIdea,
|
|
@@ -141,6 +141,13 @@ function sanitizeRole(key, raw) {
|
|
|
141
141
|
// predicate says which RANK governs; who actually holds that rank stays a DB row,
|
|
142
142
|
// resolved per request by R06's membership resolver. Nothing here names a builder,
|
|
143
143
|
// and a config edit cannot make anyone a board member.
|
|
144
|
+
// WHY window_minutes STAYS null HERE (task 1003273). A clock is only safe under
|
|
145
|
+
// a rule where expiry means RETURN. Under first_ratifier and consent the tally
|
|
146
|
+
// passes on absence, so giving a sitting a deadline would make the expiry sweep
|
|
147
|
+
// ratify items NOBODY READ — paying author karma and the idea credit on them.
|
|
148
|
+
// A project that wants a clock wants `majority` first, and sets both together in
|
|
149
|
+
// its own instance pack; the neutral default must not hand every instance an
|
|
150
|
+
// auto-ratifier.
|
|
144
151
|
const BOARD_DEFAULTS = Object.freeze({
|
|
145
152
|
membership: 'rank:archon',
|
|
146
153
|
pass_rule: 'first_ratifier',
|
|
@@ -148,11 +155,25 @@ const BOARD_DEFAULTS = Object.freeze({
|
|
|
148
155
|
close_early_on_full_turnout: true,
|
|
149
156
|
});
|
|
150
157
|
|
|
151
|
-
// The pass rules that EXIST, not the ones a project should pick. `
|
|
152
|
-
//
|
|
153
|
-
//
|
|
154
|
-
//
|
|
155
|
-
|
|
158
|
+
// The pass rules that EXIST, not the ones a project should pick. `first_ratifier`
|
|
159
|
+
// is the day-one default and what an unreadable value falls back to.
|
|
160
|
+
//
|
|
161
|
+
// THE THREE ARE NOT A LADDER OF STRICTNESS — they differ in what SILENCE means,
|
|
162
|
+
// which is the axis that actually matters (task 1003273):
|
|
163
|
+
// • first_ratifier — one affirmative act decides. Silence passes (the tally
|
|
164
|
+
// only returns on a reasoned objection), so an unvoted item ratifies.
|
|
165
|
+
// • consent — the same tally over a sitting; an empty window passes
|
|
166
|
+
// UNOPPOSED. Silence is assent, deliberately (owner decision 6).
|
|
167
|
+
// • majority — strictly more than half of the RESOLVED MEMBERSHIP must
|
|
168
|
+
// vote yes. Silence is NOT assent: an item that never reaches a majority
|
|
169
|
+
// RETURNS at expiry. This is the only rule under which not voting is a
|
|
170
|
+
// meaningful act, and the only one where a clock is safe — see the note on
|
|
171
|
+
// window_minutes below.
|
|
172
|
+
// Adding `majority` cannot widen the fail-closed fallback: sanitizeBoard still
|
|
173
|
+
// falls back to first_ratifier, and majority is the STRICTER end of this axis
|
|
174
|
+
// anyway (it demands affirmative votes from a majority, where the other two
|
|
175
|
+
// pass on absence).
|
|
176
|
+
const PASS_RULES = Object.freeze(['first_ratifier', 'consent', 'majority']);
|
|
156
177
|
|
|
157
178
|
// A membership predicate names WHO SITS. The grammar and its parser live in
|
|
158
179
|
// ./membership-predicate — ONE implementation, shared with R06's resolver
|
package/modules/government/db.js
CHANGED
|
@@ -618,6 +618,52 @@ async function getExpiredOpenBoardItems(limit = 100) {
|
|
|
618
618
|
return rows;
|
|
619
619
|
}
|
|
620
620
|
|
|
621
|
+
// The summons read (task 1003271): every OPEN item this builder could still
|
|
622
|
+
// vote on, with the two facts that decide it — who authored the subject, and
|
|
623
|
+
// whether this builder has already cast. Membership is NOT decided here: it
|
|
624
|
+
// depends on the item's SNAPSHOTTED constitution predicate, which board.js
|
|
625
|
+
// resolves against the live rank rows (the ADR 0016 posture — per request,
|
|
626
|
+
// uncached). This query's job is to make that resolution cheap by handing back
|
|
627
|
+
// everything else in ONE round trip instead of three per item.
|
|
628
|
+
//
|
|
629
|
+
// Expired items are excluded: a sitting past its deadline is the expiry sweep's
|
|
630
|
+
// to close, and nagging someone to vote on it would be asking for a vote the
|
|
631
|
+
// close will not count.
|
|
632
|
+
//
|
|
633
|
+
// The author join mirrors getBoardItemAuthor exactly — full_idea authorship is
|
|
634
|
+
// idea_inbox.captured_by, and anything else is opened_by. Kept as a LEFT JOIN so
|
|
635
|
+
// an item whose idea row is gone still appears with a null author (it has no
|
|
636
|
+
// author to disqualify, so it stays votable) rather than vanishing from the count.
|
|
637
|
+
//
|
|
638
|
+
// INDEXES THIS RELIES ON — all three already exist (government_003), which is why
|
|
639
|
+
// this adds no migration:
|
|
640
|
+
// - government_board_items_open_by_deadline ON (closes_at) WHERE closed_at IS NULL
|
|
641
|
+
// — a PARTIAL index whose predicate is exactly this query's `closed_at IS NULL`,
|
|
642
|
+
// and whose key is the `closes_at` it then filters on.
|
|
643
|
+
// - government_board_votes_one_per_voter UNIQUE (item_id, voter_id) — the votes
|
|
644
|
+
// LEFT JOIN's two columns, in that order, so "did this voter cast on this item"
|
|
645
|
+
// is one index probe rather than a scan of the item's ballot.
|
|
646
|
+
// - idea_inbox's primary key, for the author join.
|
|
647
|
+
// The open set is also intrinsically small (an open item is one awaiting a decision,
|
|
648
|
+
// not history — the closed rows are the unbounded side, per ADR 0175 §4).
|
|
649
|
+
async function openBoardItemsAwaitingVoter(voterId) {
|
|
650
|
+
const { rows } = await pool.query(
|
|
651
|
+
`SELECT i.id, i.subject_type, i.subject_id, i.constitution, i.opened_at, i.closes_at,
|
|
652
|
+
CASE WHEN i.subject_type = 'full_idea' THEN idea.captured_by ELSE i.opened_by END AS author_id,
|
|
653
|
+
(v.id IS NOT NULL) AS already_voted
|
|
654
|
+
FROM government_board_items i
|
|
655
|
+
LEFT JOIN idea_inbox idea
|
|
656
|
+
ON i.subject_type = 'full_idea' AND idea.id = i.subject_id
|
|
657
|
+
LEFT JOIN government_board_votes v
|
|
658
|
+
ON v.item_id = i.id AND v.voter_id = $1
|
|
659
|
+
WHERE i.closed_at IS NULL
|
|
660
|
+
AND (i.closes_at IS NULL OR i.closes_at >= now())
|
|
661
|
+
ORDER BY i.opened_at`,
|
|
662
|
+
[voterId],
|
|
663
|
+
);
|
|
664
|
+
return rows;
|
|
665
|
+
}
|
|
666
|
+
|
|
621
667
|
// Author-ratification karma (+3, board.js AUTHOR_RATIFY_KARMA_DELTA) — fired by
|
|
622
668
|
// the R10 close on a passed item. IDEMPOTENT: the reason key is unique per item
|
|
623
669
|
// and the INSERT is one atomic WHERE-NOT-EXISTS statement (the economy
|
|
@@ -645,6 +691,7 @@ async function insertVoteKarma({ voterId, itemId, delta }) {
|
|
|
645
691
|
}
|
|
646
692
|
|
|
647
693
|
module.exports = {
|
|
694
|
+
openBoardItemsAwaitingVoter,
|
|
648
695
|
getPermissionKeysForBuilder,
|
|
649
696
|
getRankKeysForBuilder,
|
|
650
697
|
getSeededRanks,
|
|
@@ -93,6 +93,12 @@ const governmentPort = {
|
|
|
93
93
|
// bar gate lives INSIDE board.js (a sub-bar idea never reaches the board, and
|
|
94
94
|
// no caller is trusted to pre-filter). Returns, never throws into capture.
|
|
95
95
|
openBoardWindowForIdea: board.openWindowForFullIdea,
|
|
96
|
+
// task 1003271: how many open sittings are waiting on THIS builder's vote
|
|
97
|
+
// (member under the item's own snapshot, not the author, has not yet cast).
|
|
98
|
+
// On the port because the summons is rendered by the kernel's builder-needs
|
|
99
|
+
// roster on /me — the one surface every session already reads — and core
|
|
100
|
+
// imports no module (ADR 0093).
|
|
101
|
+
pendingBoardVotesFor: board.pendingVotesFor,
|
|
96
102
|
// R13: the feedback a board-returned idea carries back to its author — the
|
|
97
103
|
// resubmit path in modules/ideas resolves this to show what was objected to
|
|
98
104
|
// and to check whether the flagged section actually changed.
|
|
@@ -534,7 +540,12 @@ module.exports = function buildGovernmentRouter() {
|
|
|
534
540
|
const status =
|
|
535
541
|
out.reason === 'item_not_found' ? 404 :
|
|
536
542
|
out.reason === 'not_board_member' ? 403 :
|
|
537
|
-
|
|
543
|
+
// 'anti_self_vote' was retired by task 1003273 — an author may cast;
|
|
544
|
+
// what they cannot do is decide alone, which the close trigger and the
|
|
545
|
+
// tally enforce rather than the route. The mapping is left OUT rather
|
|
546
|
+
// than kept "just in case": a reason castVote can no longer return
|
|
547
|
+
// would be dead code pretending to be a wall.
|
|
548
|
+
|
|
538
549
|
out.reason === 'item_closed' ? 409 :
|
|
539
550
|
out.reason === 'vote_window_expired' ? 409 :
|
|
540
551
|
400; // bad_direction / yes_carries_nothing / objection_needs_reason / objection_needs_section
|
|
@@ -89,7 +89,6 @@
|
|
|
89
89
|
validation_failed: 'Missing or invalid fields.',
|
|
90
90
|
// the vote refusals (R17 over R08's route)
|
|
91
91
|
not_board_member: 'Only members of this item\u2019s board may vote on it \u2014 membership follows the rules that were in force when the window opened.',
|
|
92
|
-
anti_self_vote: 'You can\u2019t vote on your own idea \u2014 that\u2019s the line that keeps ratification honest.',
|
|
93
92
|
item_closed: 'This sitting has closed \u2014 refreshing.',
|
|
94
93
|
vote_window_expired: 'This sitting\u2019s clock has run out \u2014 the sweep is closing it.',
|
|
95
94
|
objection_needs_reason: 'An objection must say why \u2014 write the reason.',
|
|
@@ -519,6 +518,14 @@
|
|
|
519
518
|
if (board.pass_rule === 'consent') {
|
|
520
519
|
return 'Items pass by <strong>consent</strong>: silence is consent, a single reasoned objection returns the item to its author, and a sitting that ends with no votes at all passes.';
|
|
521
520
|
}
|
|
521
|
+
// task 1003273. The sentence leads with what silence means, because that is
|
|
522
|
+
// the axis these three rules actually differ on and the one that surprises
|
|
523
|
+
// people: under the other two an unvoted item passes, under this one it does not.
|
|
524
|
+
if (board.pass_rule === 'majority') {
|
|
525
|
+
return 'Items pass by <strong>majority</strong>: more than half of the board must vote yes \u2014 not just more yes than no. '
|
|
526
|
+
+ 'Silence is <em>not</em> agreement here, so an item that never reaches a majority is returned to its author. '
|
|
527
|
+
+ 'An author may vote on their own idea, but it can never be carried by their vote alone.';
|
|
528
|
+
}
|
|
522
529
|
return `Pass rule: <code>${escapeHtml(String(board.pass_rule || 'unset'))}</code>.`;
|
|
523
530
|
}
|
|
524
531
|
|
|
@@ -694,9 +701,12 @@
|
|
|
694
701
|
return rem || 'timed sitting';
|
|
695
702
|
}
|
|
696
703
|
const rule = item.constitution && item.constitution.pass_rule;
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
704
|
+
if (rule === 'first_ratifier') return 'no clock — closes on the first vote';
|
|
705
|
+
// A clockless majority sitting is worth naming honestly: without a deadline
|
|
706
|
+
// it waits indefinitely for a majority, which is a real state a project can
|
|
707
|
+
// configure itself into (task 1003273).
|
|
708
|
+
if (rule === 'majority') return 'no clock — waits for a majority';
|
|
709
|
+
return 'no clock';
|
|
700
710
|
}
|
|
701
711
|
|
|
702
712
|
function ballotHtml(item, viewerId) {
|
|
@@ -750,12 +760,22 @@
|
|
|
750
760
|
if (item.closed_at) return '';
|
|
751
761
|
const mine = item.votes.find((v) => String(v.voter_id) === String(viewerId));
|
|
752
762
|
if (mine) return '';
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
763
|
+
// TASK 1003273: the author gets a REAL ballot. The old branch returned a note
|
|
764
|
+
// and no form, which is what made the Board Room show nine sittings and no
|
|
765
|
+
// way to vote on any of them — every one of them was the reader's own. They
|
|
766
|
+
// may now cast; what they cannot do is decide alone (the close trigger needs
|
|
767
|
+
// a voice that is not theirs, and a majority made only of them cannot carry).
|
|
768
|
+
// The note says so instead of refusing, so the limit is visible rather than
|
|
769
|
+
// discovered by clicking.
|
|
770
|
+
const isOwnIdea = item.subject_type === 'full_idea'
|
|
771
|
+
&& item.subject && String(item.subject.author_id) === String(viewerId);
|
|
756
772
|
const sections = Object.keys(SECTION_LABELS).map((key) => `
|
|
757
773
|
<label class="gov-perm" style="cursor:pointer"><input type="radio" name="vote-section-${escapeHtml(item.id)}" value="${escapeHtml(key)}"> <span>${escapeHtml(SECTION_LABELS[key])}</span></label>`).join('');
|
|
774
|
+
const ownNote = isOwnIdea
|
|
775
|
+
? `<p class="gov-grid-note">This is your idea. You can vote on it \u2014 your position belongs on the record \u2014 but it can\u2019t be decided by your vote alone, and a vote on your own idea earns no karma.</p>`
|
|
776
|
+
: '';
|
|
758
777
|
return `<div class="gov-vote" data-item="${escapeHtml(item.id)}">
|
|
778
|
+
${ownNote}
|
|
759
779
|
<button type="button" class="gov-btn" data-vote="yes">Vote yes</button>
|
|
760
780
|
<details class="gov-vote__no">
|
|
761
781
|
<summary class="gov-btn gov-btn--ghost">Object\u2026</summary>
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.365",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.365",
|
|
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.19.
|
|
3
|
+
"version": "1.19.365",
|
|
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",
|