@bongos/core 1.19.364 → 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.
@@ -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 || {};
361
490
 
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;
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
+ });
521
+
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' });
@@ -787,6 +952,9 @@ async function pendingVotesFor(builderId) {
787
952
 
788
953
  module.exports = {
789
954
  pendingVotesFor,
955
+ hasMajority,
956
+ majorityDecided,
957
+ decidingVotes,
790
958
  FULL_IDEA_PASS_BAR,
791
959
  ideaPassesBar,
792
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
@@ -540,7 +540,12 @@ module.exports = function buildGovernmentRouter() {
540
540
  const status =
541
541
  out.reason === 'item_not_found' ? 404 :
542
542
  out.reason === 'not_board_member' ? 403 :
543
- 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
+
544
549
  out.reason === 'item_closed' ? 409 :
545
550
  out.reason === 'vote_window_expired' ? 409 :
546
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.364",
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.364",
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.364",
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",
package/src/module-api.js CHANGED
@@ -1158,8 +1158,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1158
1158
  // landed since 1.19.362 with no explicit bump. run 32889324497. (task 1002620)
1159
1159
  // 1.19.364 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1160
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)
1161
1163
  // ---------------------------------------------------------------------------
1162
- const CORE_VERSION = '1.19.364'; // CI auto-patch carrier (ADR 0161); see changelog above
1164
+ const CORE_VERSION = '1.19.365'; // CI auto-patch carrier (ADR 0161); see changelog above
1163
1165
 
1164
1166
  // A namespaced logger so a module's log lines are attributable + consistent.
1165
1167
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -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: 'majority' }, 'bad_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: 'majority' }).pass_rule, 'first_ratifier');
72
- assert.equal(validateProposedConstitution({ ...CONSENT_90, pass_rule: 'majority' }).ok, false);
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: 'majority' }, proposedBy: '42' })).ok, false);
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('anti-self STILL binds for a full_idea — the carve-out is subject-typed', async () => {
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 () => { throw new Error('must not be reached'); },
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
- assert.deepEqual(await castVote({ itemId: 21, voterId: '42', direction: 'yes' }),
256
- { ok: false, reason: 'anti_self_vote' });
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
- getBoardItemAuthor: async () => { throw new Error('must not be reached — a return pays nobody'); },
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
- getBoardItemAuthor: async () => { throw new Error('must not be reached'); },
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());