@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.
@@ -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 } — recorded now
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' | 'anti_self_vote' | <validateVote reason> }
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
- // ANTI-SELF IS A FULL-IDEA RULE, NOT AN AMENDMENT RULE (R14, ADR 0175 §§7/11).
241
- // The bright line exists because an idea's author GAINS from ratification
242
- // (credits, legitimacy) — ceremonial self-ratification is what it prevents.
243
- // An amendment pays nothing, and §7 explicitly blesses the monarchy act:
244
- // under a one-Archon board with no clock, the proposer must be able to
245
- // ratify their own amendment or the subject is dead on arrival — and adding
246
- // a special escape for that case is the bootstrap exception §7 forbids.
247
- // Skipping anti-self FOR AMENDMENTS is the rule that needs no exceptions.
248
- if (item.subject_type === 'full_idea') {
249
- const authorId = await db.getBoardItemAuthor(item);
250
- if (authorId != null && String(authorId) === String(voterId)) {
251
- return { ok: false, reason: 'anti_self_vote' };
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
- // The whole counting rule. Absence is nulled by what this function DOESN'T
303
- // read: only cast votes exist here.
304
- function tallyOutcome(votes) {
305
- return (votes || []).some((v) => v.direction === 'no') ? 'returned' : 'passed';
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
- function shouldCloseAfterVote({ constitution, votes, members } = {}) {
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
- if (rule === 'first_ratifier') return (votes || []).length >= 1;
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 outcome = tallyOutcome(votes);
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, ahead of the transaction (reads never ride a tx they
363
- // don't need). Only a pass needs them, and only a full_idea can pay.
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
- authorId = await db.getBoardItemAuthor(item);
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
- if (!shouldCloseAfterVote({ constitution, votes, members })) {
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. `consent` is the
152
- // documented recommendation once a project has a board worth sitting (all Archons,
153
- // one vote each, consent, a 90-minute window); `first_ratifier` is the day-one
154
- // default and what an unreadable value falls back to.
155
- const PASS_RULES = Object.freeze(['first_ratifier', 'consent']);
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
@@ -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
- out.reason === 'anti_self_vote' ? 403 :
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
- return rule === 'first_ratifier'
698
- ? 'no clock — closes on the first vote'
699
- : 'no clock';
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
- if (item.subject_type === 'full_idea' && item.subject && String(item.subject.author_id) === String(viewerId)) {
754
- return `<p class="gov-grid-note">This is your idea \u2014 its author does not vote on it. The board decides; a return comes back to you with its reason.</p>`;
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.363",
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.363",
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.363",
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",