@bongos/core 1.19.226 → 1.19.228

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 CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.226",
6
- "core_contract": "1.19.226",
7
- "source_commit": "b99c0b52cf19ffd33ad98d0fc3dd763011559401",
5
+ "core_version": "1.19.228",
6
+ "core_contract": "1.19.228",
7
+ "source_commit": "f14babca30bfba954ddd62b510ebc1ed4749d9fa",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-18T04:50:04.434Z",
9
+ "built_at": "2026-08-18T05:08:08.027Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 311,
13
13
  "agent_docs_stubbed": 21,
14
- "functional_verbatim": 1308,
14
+ "functional_verbatim": 1309,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1640,
20
- "tree_sha256": "024eb9d71d1dd27389cb4db58a347eadfad7412b37e48d140a9c113cc8ee9234",
19
+ "file_count": 1641,
20
+ "tree_sha256": "2a30117336498006b3241dc75e5157ce5fdc462327dbdf3618e469ad9defb158",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -2902,7 +2902,7 @@
2902
2902
  {
2903
2903
  "path": "modules/government/board.js",
2904
2904
  "mode": "0000644",
2905
- "sha256": "0711f087cc61ed19f91fb207705a9e4831422c9acf29ae8402f87926a31d2a51"
2905
+ "sha256": "6a2b7ebfcbba1442f45efe6df40131b03455fa0fff9ecce51e87696a72c5b117"
2906
2906
  },
2907
2907
  {
2908
2908
  "path": "modules/government/catalog.js",
@@ -2917,7 +2917,7 @@
2917
2917
  {
2918
2918
  "path": "modules/government/db.js",
2919
2919
  "mode": "0000644",
2920
- "sha256": "1f50cef895c7937aebbb365aa7a9ce1b1681bd097034c0045d94458cd369f50a"
2920
+ "sha256": "e20e5a4aa8a79441e5860ceda2f19e41cbdb8fd85c0eda5d00f572eee5701f20"
2921
2921
  },
2922
2922
  {
2923
2923
  "path": "modules/government/migrations/governance_001_tables.sql",
@@ -3007,7 +3007,7 @@
3007
3007
  {
3008
3008
  "path": "modules/government/routes/government.js",
3009
3009
  "mode": "0000644",
3010
- "sha256": "41ac023e96901b9b3cf8a5059a5c26ff5f86b564d7bb37dc8d5a55a206db85fb"
3010
+ "sha256": "63ab50af4bc8d36db246eacfc9041c18cf0af89279f8ffe8e89f67978773401b"
3011
3011
  },
3012
3012
  {
3013
3013
  "path": "modules/government/routes/rank-sync.js",
@@ -4302,12 +4302,12 @@
4302
4302
  {
4303
4303
  "path": "package-lock.json",
4304
4304
  "mode": "0000644",
4305
- "sha256": "93e2a0f8b239aa724d99178fa5cfcd7794d7a09c9e820a9ba3f8da94a81ec2b0"
4305
+ "sha256": "41bf0bb43e6a556ddf9598e5208bc478dd9bfc5d92f46ff64b6af6ef3ecac0eb"
4306
4306
  },
4307
4307
  {
4308
4308
  "path": "package.json",
4309
4309
  "mode": "0000644",
4310
- "sha256": "378978670cd1efdcc3929f44178118e321f8e381bb923aff175716af2b24134d"
4310
+ "sha256": "1b62282a15730fccfbe7ae8b3a2cb2321c4b1f9947bba37928150613db0e6d7d"
4311
4311
  },
4312
4312
  {
4313
4313
  "path": "public-docs/index.html",
@@ -4932,7 +4932,7 @@
4932
4932
  {
4933
4933
  "path": "scripts/gds/fitness.js",
4934
4934
  "mode": "0000644",
4935
- "sha256": "a255afac6eb4a2b8269e896032e8eabf10f104aed5a0f1e8d0a7348695625424"
4935
+ "sha256": "00eacd557419ebdba21347e215ef0f197de8cd839f6af778bf90ff2901823a0e"
4936
4936
  },
4937
4937
  {
4938
4938
  "path": "scripts/gds/gate-review.js",
@@ -5922,7 +5922,7 @@
5922
5922
  {
5923
5923
  "path": "src/module-api.js",
5924
5924
  "mode": "0000644",
5925
- "sha256": "d7a3216e0393ff4a4cb0fe3c7823b9c8a294ff45549a4a698b95c78ea93e4ad8"
5925
+ "sha256": "985014c8a83781918e242757e48bf93cbc1df612b1eb4560177aeba85b30b0f7"
5926
5926
  },
5927
5927
  {
5928
5928
  "path": "src/module-loader/catalog.js",
@@ -6844,6 +6844,11 @@
6844
6844
  "mode": "0000644",
6845
6845
  "sha256": "8f5354cbacf15c9b49500f96b265fe52f5c0d4500a4ec7b41b4819a149782893"
6846
6846
  },
6847
+ {
6848
+ "path": "tests/government_board_close.mjs",
6849
+ "mode": "0000644",
6850
+ "sha256": "4408ec434f36b70a589b0592814733d3f88d48aa9aff38c08c9a2eb6c6c9f7b3"
6851
+ },
6847
6852
  {
6848
6853
  "path": "tests/government_board_membership.mjs",
6849
6854
  "mode": "0000644",
@@ -40,9 +40,10 @@
40
40
  // take effect on the very next window without a process restart, the same
41
41
  // instant-effect posture the resolver takes (ADR 0016).
42
42
 
43
+ const api = require('../../src/module-api');
43
44
  const db = require('./db');
44
45
  const { loadGovernmentConfig } = require('./config');
45
- const { isBoardMember } = require('./board-membership');
46
+ const { isBoardMember, resolveBoardMembers } = require('./board-membership');
46
47
 
47
48
  // ADR 0175 §5: the completeness bar a Full Idea must clear to reach the board.
48
49
  // Held equal to economy's IDEA_FULL_PASS_BAR by test — see the header.
@@ -198,6 +199,158 @@ async function castVote({ itemId, voterId, direction, reasonMd = null, sectionKe
198
199
  return { ok: true, vote: inserted, fresh: true, karma_delta: VOTE_KARMA_DELTA };
199
200
  }
200
201
 
202
+ // ── close + tally (BV1.R10 of goal 1000069, ADR 0175 §3) ────────────────────
203
+ //
204
+ // EVERY RULE DECLARED, NONE EMERGENT. The tally is one line: a single reasoned
205
+ // objection returns the idea; anything else — including ZERO votes — passes.
206
+ // Absent members are NULLED by construction rather than by branch: the tally
207
+ // only ever reads the votes that were cast, so a member who never voted simply
208
+ // does not exist in it, and absence can never block (§2 — the sitting model).
209
+ // A zero-vote pass is safe because the idea already cleared the grade gate,
210
+ // the author could not vote for themselves, and the ballot is open.
211
+ //
212
+ // TWO TRIGGERS, ONE CLOSE PATH:
213
+ // • after a vote — first_ratifier closes on the FIRST vote (monarchy: the
214
+ // Archon ratifies or returns, done); consent closes early only on FULL
215
+ // TURNOUT (every current member has voted — recorded positions and the
216
+ // vote karma are worth the wait, and no later vote can change the outcome
217
+ // anyway since there is no vote-changing in v1);
218
+ // • on expiry — R11's poller calls the same closeItem, so the clock and the
219
+ // turnout can never disagree about what a close means.
220
+ // An unknown pass_rule closes NOTHING after a vote (fail-closed — only the
221
+ // clock can end it), the same strict-end posture as R05's sanitizer.
222
+ //
223
+ // THE CLOSE IS STRUCTURALLY SINGLE-WINNER: closeBoardItem's UPDATE carries
224
+ // WHERE closed_at IS NULL, so two concurrent closers (a racing vote and the
225
+ // expiry sweep, say) write exactly one outcome and the loser reads it back.
226
+ //
227
+ // RATIFICATION PAYS THE AUTHOR HERE — this is what fires the +3
228
+ // awardAuthorRatifyKarma R08 declared (idempotent by reason key, so a re-driven
229
+ // close never double-pays). THE PASS EVENT IS DELIBERATELY NOT EMITTED HERE:
230
+ // that is R12's contract (docs/specs/board-room-pass-event-contract.md), and
231
+ // its once-per-idea semantics deserve their own slice — the sweep-friendly
232
+ // `WHERE outcome = 'passed'` shape survives precisely because closing and
233
+ // emitting are separate acts.
234
+
235
+ // The whole counting rule. Absence is nulled by what this function DOESN'T
236
+ // read: only cast votes exist here.
237
+ function tallyOutcome(votes) {
238
+ return (votes || []).some((v) => v.direction === 'no') ? 'returned' : 'passed';
239
+ }
240
+
241
+ // Should a just-cast vote close the item now, under the SNAPSHOTTED rule?
242
+ function shouldCloseAfterVote({ constitution, votes, members } = {}) {
243
+ const rule = (constitution || {}).pass_rule;
244
+ if (rule === 'first_ratifier') return (votes || []).length >= 1;
245
+ if (rule === 'consent') {
246
+ if (!(constitution || {}).close_early_on_full_turnout) return false;
247
+ if (!members || !members.length) return false; // an empty board has no turnout to complete
248
+ const voters = new Set((votes || []).map((v) => String(v.voter_id)));
249
+ return members.every((m) => voters.has(String(m.builder_id)));
250
+ }
251
+ return false; // unknown rule: only the clock closes it
252
+ }
253
+
254
+ // The pass-event payload (R12, docs/specs/board-room-pass-event-contract.md
255
+ // Decision 2) — IDENTICAL shape for the in-transaction port call and the
256
+ // post-commit event, so there is one thing to reason about. Deliberately
257
+ // carries no tally, no voter list, no constitution: the economy module has no
258
+ // business reading how the vote went, only that it passed.
259
+ function passPayload(closed, { authorId, completenessScore, passedAt }) {
260
+ const isIdea = closed.subject_type === 'full_idea';
261
+ return {
262
+ item_id: closed.id,
263
+ subject_type: closed.subject_type,
264
+ subject_id: closed.subject_id,
265
+ idea_id: isIdea ? closed.subject_id : null,
266
+ author_id: authorId,
267
+ completeness_score: completenessScore,
268
+ passed_at: passedAt,
269
+ };
270
+ }
271
+
272
+ // Close an item: tally what was cast, write the outcome, pay on a pass.
273
+ // Idempotent end to end — the single-winner UPDATE plus every award's
274
+ // reason-key guard. `cause` is for the caller's telemetry ('vote' | 'expiry').
275
+ //
276
+ // R12 (the pass-event contract): MONEY RIDES THE CLOSE TRANSACTION. Stream 3's
277
+ // credit is booked through the `reward` PORT inside the same transaction that
278
+ // writes the outcome — if the credit write fails, the close rolls back and the
279
+ // item stays open, so the vote is retried rather than silently unpaid.
280
+ // `resolveOptional` returning null (no economy module), or a reward port that
281
+ // does not yet carry awardIdeaGovernancePassCredit (IC5 unbuilt), is a CLEAN
282
+ // NO-OP: the item still closes, nothing is booked, and the durable
283
+ // `outcome = 'passed'` row lets IC5's re-drive sweep pay later. The port is
284
+ // resolved and the payload's reads happen BEFORE the transaction opens.
285
+ //
286
+ // `board.item.passed` is then emitted AFTER commit, emitAsync, for non-money
287
+ // reactions only (R22's Discord mirror, future notifications) — emitAsync
288
+ // swallows listener errors, which is right for a broadcast and disqualifying
289
+ // for a payment, which is exactly why the money went through the port above.
290
+ async function closeItem(item, { cause = 'vote' } = {}) {
291
+ if (!item || item.closed_at) return { closed: false, reason: 'already_closed' };
292
+ const votes = await db.getVotesForItem(item.id);
293
+ const outcome = tallyOutcome(votes);
294
+
295
+ // The payload's reads, ahead of the transaction (reads never ride a tx they
296
+ // don't need). Only a pass needs them, and only a full_idea can pay.
297
+ let authorId = null;
298
+ let completenessScore = null;
299
+ let credit = null;
300
+ if (outcome === 'passed') {
301
+ authorId = await db.getBoardItemAuthor(item);
302
+ if (item.subject_type === 'full_idea') {
303
+ completenessScore = await db.getIdeaCompleteness(item.subject_id);
304
+ }
305
+ }
306
+
307
+ const reward = outcome === 'passed' && item.subject_type === 'full_idea'
308
+ ? (api.resolveOptional && api.resolveOptional('reward'))
309
+ : null;
310
+ const payMoney = reward && typeof reward.awardIdeaGovernancePassCredit === 'function';
311
+
312
+ const closed = await db.closeBoardItem({
313
+ itemId: item.id,
314
+ outcome,
315
+ afterClose: payMoney
316
+ ? async (client) => {
317
+ credit = await reward.awardIdeaGovernancePassCredit(client, {
318
+ ideaId: item.subject_id,
319
+ authorId,
320
+ completenessScore,
321
+ });
322
+ }
323
+ : null,
324
+ });
325
+ if (!closed) return { closed: false, reason: 'already_closed' };
326
+
327
+ const authorKarma = outcome === 'passed' ? await awardAuthorRatifyKarma(closed) : null;
328
+
329
+ if (outcome === 'passed') {
330
+ const payload = passPayload(closed, { authorId, completenessScore, passedAt: closed.closed_at });
331
+ try {
332
+ if (api.emitAsync) await api.emitAsync('board.item.passed', payload);
333
+ } catch (_) { /* emitAsync isolates listener errors; nothing that moves credits subscribes */ }
334
+ }
335
+
336
+ return { closed: true, item: closed, outcome, cause, author_karma: authorKarma, ...(credit ? { credit } : {}) };
337
+ }
338
+
339
+ // The post-vote hook R08's route fires after a FRESH cast. Reads everything it
340
+ // judges — the item, the votes, the LIVE members under the snapshot's rule —
341
+ // per request, uncached, then hands closeItem the decision.
342
+ async function maybeCloseAfterVote(itemId) {
343
+ const item = await db.getBoardItemForVote(Number(itemId));
344
+ if (!item || item.closed_at) return { closed: false, reason: 'not_open' };
345
+ const constitution = item.constitution || {};
346
+ const votes = await db.getVotesForItem(item.id);
347
+ const members = await resolveBoardMembers(constitution);
348
+ if (!shouldCloseAfterVote({ constitution, votes, members })) {
349
+ return { closed: false, reason: 'window_still_open' };
350
+ }
351
+ return closeItem(item, { cause: 'vote' });
352
+ }
353
+
201
354
  module.exports = {
202
355
  FULL_IDEA_PASS_BAR,
203
356
  ideaPassesBar,
@@ -208,4 +361,9 @@ module.exports = {
208
361
  awardAuthorRatifyKarma,
209
362
  validateVote,
210
363
  castVote,
364
+ tallyOutcome,
365
+ shouldCloseAfterVote,
366
+ passPayload,
367
+ closeItem,
368
+ maybeCloseAfterVote,
211
369
  };
@@ -372,6 +372,71 @@ async function getBoardVote(itemId, voterId) {
372
372
  // so the idempotent re-cast can never double-pay. granted_by NULL: earned from
373
373
  // the system by the act, not granted by a person (the governance_002 backfill
374
374
  // convention). karma_log is a shared core table; same access rule as above.
375
+ // Every vote cast on an item, in cast order — the tally's whole input. Absent
376
+ // members are nulled by construction: they have no row here to read.
377
+ async function getVotesForItem(itemId) {
378
+ const { rows } = await pool.query(
379
+ `SELECT * FROM government_board_votes WHERE item_id = $1 ORDER BY cast_at, id`,
380
+ [itemId],
381
+ );
382
+ return rows;
383
+ }
384
+
385
+ // The close write — STRUCTURALLY SINGLE-WINNER: WHERE closed_at IS NULL means
386
+ // two concurrent closers (a racing vote and the expiry sweep) write exactly one
387
+ // outcome; the loser gets null and reads the winner's row back if it cares.
388
+ // closed_at comes from the database clock, like every timestamp on this table.
389
+ //
390
+ // R12 (the pass-event contract, Decision 1) made this a TRANSACTION: money
391
+ // rides the close, so `afterClose(client, closedRow)` runs INSIDE it — if the
392
+ // credit write fails, the close rolls back and the item stays open (the vote is
393
+ // retried, never silently unpaid; "the ledger and the outcome can never
394
+ // disagree"). No afterClose (or a null one) degrades to the plain single-
395
+ // statement close R10 shipped.
396
+ async function closeBoardItem({ itemId, outcome, afterClose = null }) {
397
+ if (typeof afterClose !== 'function') {
398
+ const { rows } = await pool.query(
399
+ `UPDATE government_board_items
400
+ SET closed_at = now(), outcome = $2
401
+ WHERE id = $1 AND closed_at IS NULL
402
+ RETURNING *`,
403
+ [itemId, outcome],
404
+ );
405
+ return rows[0] || null;
406
+ }
407
+ const client = await pool.connect();
408
+ try {
409
+ await client.query('BEGIN');
410
+ const { rows } = await client.query(
411
+ `UPDATE government_board_items
412
+ SET closed_at = now(), outcome = $2
413
+ WHERE id = $1 AND closed_at IS NULL
414
+ RETURNING *`,
415
+ [itemId, outcome],
416
+ );
417
+ const closed = rows[0] || null;
418
+ if (closed) await afterClose(client, closed);
419
+ await client.query('COMMIT');
420
+ return closed;
421
+ } catch (err) {
422
+ try { await client.query('ROLLBACK'); } catch (_) { /* connection-level failure */ }
423
+ throw err;
424
+ } finally {
425
+ client.release();
426
+ }
427
+ }
428
+
429
+ // The subject's stored completeness score, for the pass-event payload (R12).
430
+ // ideas_grades is another module's table; READING it is the sanctioned pattern
431
+ // (the boundary is code imports, not table access) — null when unscored.
432
+ async function getIdeaCompleteness(ideaId) {
433
+ const { rows } = await pool.query(
434
+ `SELECT completeness_score FROM ideas_grades WHERE idea_id = $1`,
435
+ [ideaId],
436
+ );
437
+ return rows[0] ? rows[0].completeness_score : null;
438
+ }
439
+
375
440
  // Author-ratification karma (+3, board.js AUTHOR_RATIFY_KARMA_DELTA) — fired by
376
441
  // the R10 close on a passed item. IDEMPOTENT: the reason key is unique per item
377
442
  // and the INSERT is one atomic WHERE-NOT-EXISTS statement (the economy
@@ -423,4 +488,7 @@ module.exports = {
423
488
  getBoardVote,
424
489
  insertVoteKarma,
425
490
  insertAuthorRatifyKarma,
491
+ getVotesForItem,
492
+ closeBoardItem,
493
+ getIdeaCompleteness,
426
494
  };
@@ -445,6 +445,19 @@ module.exports = function buildGovernmentRouter() {
445
445
  400; // bad_direction / yes_carries_nothing / objection_needs_reason / objection_needs_section
446
446
  return res.fail(out.reason, status, { item_id: itemId });
447
447
  }
448
+ // R10: a fresh cast may CLOSE the sitting — first_ratifier closes on the
449
+ // first vote (monarchy: the Archon ratifies or returns, done); consent
450
+ // closes early on full turnout. The close is single-winner in the schema,
451
+ // so a concurrent racer is safe, and a close hiccup never loses the vote
452
+ // (already recorded) — fail-open, the capture-path posture.
453
+ let closure = null;
454
+ if (out.fresh) {
455
+ try {
456
+ closure = await board.maybeCloseAfterVote(itemId);
457
+ } catch (closeErr) {
458
+ console.error('[gds] post-vote close failed (vote recorded, close left to the expiry sweep):', closeErr);
459
+ }
460
+ }
448
461
  // Idempotent re-cast reads back the standing vote — 200, not 201, and no
449
462
  // karma line: nothing new was recorded.
450
463
  res.status(out.fresh ? 201 : 200).json({
@@ -452,6 +465,7 @@ module.exports = function buildGovernmentRouter() {
452
465
  vote: out.vote,
453
466
  already_voted: !out.fresh,
454
467
  ...(out.fresh ? { karma_delta: out.karma_delta } : {}),
468
+ ...(closure && closure.closed ? { item_closed: true, outcome: closure.outcome } : {}),
455
469
  });
456
470
  } catch (err) {
457
471
  console.error('[gds] POST /government/board/items/:itemId/votes', err);
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.226",
3
+ "version": "1.19.228",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.226",
9
+ "version": "1.19.228",
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.226",
3
+ "version": "1.19.228",
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",
@@ -1688,6 +1688,16 @@ const VOCAB_ALLOW = [
1688
1688
  + 'repo, so the old spelling is still read. Dropping it would silently stop '
1689
1689
  + 'honouring a label override someone already configured.',
1690
1690
  },
1691
+ {
1692
+ file: 'modules/government/board.js',
1693
+ match: 'governancepasscredit',
1694
+ why: 'the R02 pass-event contract (docs/specs/board-room-pass-event-contract.md, agreed '
1695
+ + 'cross-goal 2026-08-17) names the economy-side award awardIdeaGovernancePassCredit, '
1696
+ + 'matching the credit_log reason family idea.credit.governance_pass:<id> that streams '
1697
+ + '1-3 share. The board calls the CONSUMER\'s agreed method name — renaming it here '
1698
+ + 'would break the contract, and the reason strings are durable ledger rows. Scoped to '
1699
+ + 'the method name, so a stray "governance" elsewhere in board.js still reds the build.',
1700
+ },
1691
1701
  {
1692
1702
  file: 'modules/government/module.json',
1693
1703
  match: 'role',
package/src/module-api.js CHANGED
@@ -879,8 +879,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
879
879
  // landed since 1.19.224 with no explicit bump. run 32099793591. (task 1002620)
880
880
  // 1.19.226 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
881
881
  // landed since 1.19.225 with no explicit bump. run 32100543772. (task 1002620)
882
+ // 1.19.227 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
883
+ // landed since 1.19.226 with no explicit bump. run 32101041951. (task 1002620)
884
+ // 1.19.228 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
885
+ // landed since 1.19.227 with no explicit bump. run 32101659669. (task 1002620)
882
886
  // ---------------------------------------------------------------------------
883
- const CORE_VERSION = '1.19.226'; // CI auto-patch carrier (ADR 0161); see changelog above
887
+ const CORE_VERSION = '1.19.228'; // CI auto-patch carrier (ADR 0161); see changelog above
884
888
 
885
889
  // A namespaced logger so a module's log lines are attributable + consistent.
886
890
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,256 @@
1
+ // tests/government_board_close.mjs — close + tally: consent, nulled absences,
2
+ // zero-vote pass (BV1.R10 of goal 1000069, ADR 0175 §3).
3
+ //
4
+ // DB-free (db monkeypatched, the sibling board tests' idiom). The declared
5
+ // rules, each pinned:
6
+ //
7
+ // 1. THE TALLY IS ONE LINE — one reasoned objection returns the idea;
8
+ // anything else, INCLUDING ZERO VOTES, passes. Absence is nulled by
9
+ // construction: the tally reads only cast votes, so an absent member has
10
+ // nothing to read and can never block.
11
+ // 2. TWO TRIGGERS, ONE PATH — first_ratifier closes on the first vote;
12
+ // consent closes early only on FULL TURNOUT of the LIVE members under the
13
+ // snapshot's rule; an unknown rule closes nothing after a vote.
14
+ // 3. THE CLOSE IS SINGLE-WINNER — WHERE closed_at IS NULL in the SQL; a
15
+ // losing racer gets already_closed and no karma.
16
+ // 4. RATIFICATION PAYS THE AUTHOR HERE — +3 on 'passed', nothing on
17
+ // 'returned' — and the PASS EVENT is deliberately NOT emitted (R12's).
18
+ import assert from 'node:assert/strict';
19
+ import { test } from 'node:test';
20
+ import { createRequire } from 'node:module';
21
+ import fs from 'node:fs';
22
+ import path from 'node:path';
23
+ import { fileURLToPath } from 'node:url';
24
+
25
+ const require = createRequire(import.meta.url);
26
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
27
+ const GOV = path.join(ROOT, 'modules', 'government');
28
+ const board = require(path.join(GOV, 'board.js'));
29
+ const db = require(path.join(GOV, 'db.js'));
30
+
31
+ const { tallyOutcome, shouldCloseAfterVote, closeItem, maybeCloseAfterVote, AUTHOR_RATIFY_KARMA_DELTA } = board;
32
+
33
+ const yes = (voter) => ({ voter_id: voter, direction: 'yes' });
34
+ const no = (voter, section = 'fit') => ({ voter_id: voter, direction: 'no', reason_md: 'r', section_key: section });
35
+ const member = (id) => ({ builder_id: String(id), github_login: `m${id}`, display_name: `M${id}` });
36
+
37
+ async function withDb(stubs, fn) {
38
+ const originals = {};
39
+ for (const [k, v] of Object.entries(stubs)) { originals[k] = db[k]; db[k] = v; }
40
+ try { return await fn(); }
41
+ finally { for (const [k, v] of Object.entries(originals)) db[k] = v; }
42
+ }
43
+
44
+ // ── rule 1: the tally ────────────────────────────────────────────────────────
45
+
46
+ test('one reasoned objection returns; everything else — zero votes included — passes', () => {
47
+ assert.equal(tallyOutcome([]), 'passed', 'the zero-vote pass: silence is consent, absence never blocks');
48
+ assert.equal(tallyOutcome([yes('1')]), 'passed');
49
+ assert.equal(tallyOutcome([yes('1'), yes('2'), yes('3')]), 'passed');
50
+ assert.equal(tallyOutcome([yes('1'), no('2'), yes('3')]), 'returned', '4-yes/1-no returns — real minority power, the spec\'s recorded trade-off');
51
+ assert.equal(tallyOutcome([no('1')]), 'returned');
52
+ });
53
+
54
+ test('absence is nulled BY CONSTRUCTION — the tally has no denominator to block', () => {
55
+ // A 5-member board where only 2 voted: the tally sees 2 votes, not 3 absences.
56
+ // There is no argument to pass the member list through — that is the design.
57
+ assert.equal(tallyOutcome.length, 1, 'tallyOutcome takes votes and nothing else');
58
+ assert.equal(tallyOutcome([yes('1'), yes('2')]), 'passed');
59
+ });
60
+
61
+ // ── rule 2: the two triggers ─────────────────────────────────────────────────
62
+
63
+ const MONARCHY = { membership: 'rank:archon', pass_rule: 'first_ratifier', window_minutes: null, close_early_on_full_turnout: true };
64
+ const CONSENT = { membership: 'rank:archon', pass_rule: 'consent', window_minutes: 90, close_early_on_full_turnout: true };
65
+
66
+ test('first_ratifier closes on the FIRST vote — monarchy: the Archon acts, done', () => {
67
+ assert.equal(shouldCloseAfterVote({ constitution: MONARCHY, votes: [yes('1')], members: [member(1)] }), true);
68
+ assert.equal(shouldCloseAfterVote({ constitution: MONARCHY, votes: [no('1')], members: [member(1)] }), true, 'a first NO closes too — as returned');
69
+ assert.equal(shouldCloseAfterVote({ constitution: MONARCHY, votes: [], members: [member(1)] }), false);
70
+ });
71
+
72
+ test('consent closes early only on FULL TURNOUT of the live members', () => {
73
+ const members = [member(1), member(2), member(3)];
74
+ assert.equal(shouldCloseAfterVote({ constitution: CONSENT, votes: [yes('1')], members }), false, 'partial turnout waits for the clock');
75
+ assert.equal(shouldCloseAfterVote({ constitution: CONSENT, votes: [yes('1'), no('2')], members }), false,
76
+ 'even a decided outcome waits — recorded positions and vote karma are worth the sitting');
77
+ assert.equal(shouldCloseAfterVote({ constitution: CONSENT, votes: [yes('1'), no('2'), yes('3')], members }), true);
78
+ // number/string ids compare equal across the turnout check
79
+ assert.equal(shouldCloseAfterVote({ constitution: CONSENT, votes: [{ voter_id: 1, direction: 'yes' }], members: [member(1)] }), true);
80
+ });
81
+
82
+ test('close-early OFF, an empty board, and an unknown rule all refuse to close after a vote', () => {
83
+ assert.equal(shouldCloseAfterVote({ constitution: { ...CONSENT, close_early_on_full_turnout: false }, votes: [yes('1')], members: [member(1)] }), false);
84
+ assert.equal(shouldCloseAfterVote({ constitution: CONSENT, votes: [yes('1')], members: [] }), false, 'an empty board has no turnout to complete');
85
+ assert.equal(shouldCloseAfterVote({ constitution: { ...CONSENT, pass_rule: 'unanimous' }, votes: [yes('1')], members: [member(1)] }), false,
86
+ 'unknown rule: fail-closed — only the clock closes it');
87
+ });
88
+
89
+ // ── rules 3+4: closeItem ─────────────────────────────────────────────────────
90
+
91
+ const openItem = (over = {}) => ({
92
+ id: '11', subject_type: 'full_idea', subject_id: '7', opened_by: null,
93
+ closed_at: null, constitution: MONARCHY, ...over,
94
+ });
95
+
96
+ test('a pass pays the author (+3, idempotent downstream); a return pays nobody', async () => {
97
+ const karma = [];
98
+ await withDb({
99
+ getVotesForItem: async () => [yes('42')],
100
+ closeBoardItem: async ({ itemId, outcome }) => ({ ...openItem(), id: String(itemId), closed_at: 't', outcome }),
101
+ getBoardItemAuthor: async () => '7',
102
+ getIdeaCompleteness: async () => 82,
103
+ insertAuthorRatifyKarma: async (args) => { karma.push(args); return true; },
104
+ }, async () => {
105
+ const out = await closeItem(openItem(), { cause: 'vote' });
106
+ assert.equal(out.closed, true);
107
+ assert.equal(out.outcome, 'passed');
108
+ assert.deepEqual(out.author_karma, { paid: true, karma_delta: AUTHOR_RATIFY_KARMA_DELTA });
109
+ });
110
+ assert.equal(karma.length, 1);
111
+ assert.equal(karma[0].delta, AUTHOR_RATIFY_KARMA_DELTA);
112
+
113
+ await withDb({
114
+ getVotesForItem: async () => [no('42')],
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'); },
117
+ insertAuthorRatifyKarma: async () => { throw new Error('must not be reached'); },
118
+ }, async () => {
119
+ const out = await closeItem(openItem());
120
+ assert.equal(out.outcome, 'returned');
121
+ assert.equal(out.author_karma, null);
122
+ });
123
+ });
124
+
125
+ test('the losing racer gets already_closed and pays nothing', async () => {
126
+ await withDb({
127
+ getVotesForItem: async () => [yes('42')],
128
+ getBoardItemAuthor: async () => '7',
129
+ getIdeaCompleteness: async () => 82,
130
+ closeBoardItem: async () => null, // the WHERE closed_at IS NULL conflict path
131
+ insertAuthorRatifyKarma: async () => { throw new Error('must not be reached'); },
132
+ }, async () => {
133
+ assert.deepEqual(await closeItem(openItem()), { closed: false, reason: 'already_closed' });
134
+ });
135
+ assert.deepEqual(await closeItem(openItem({ closed_at: 't' })), { closed: false, reason: 'already_closed' },
136
+ 'an already-closed item is refused before any read');
137
+ });
138
+
139
+ // ── maybeCloseAfterVote: the whole post-vote path ────────────────────────────
140
+
141
+ test('monarchy end to end: the first yes closes the item as passed, author paid', async () => {
142
+ const karma = [];
143
+ await withDb({
144
+ getBoardItemForVote: async () => openItem(),
145
+ getVotesForItem: async () => [yes('42')],
146
+ getActiveBuildersWithRank: async () => [{ builder_id: '42', github_login: 'a', display_name: 'A' }],
147
+ closeBoardItem: async ({ outcome }) => ({ ...openItem(), closed_at: 't', outcome }),
148
+ getBoardItemAuthor: async () => '7',
149
+ getIdeaCompleteness: async () => 82,
150
+ insertAuthorRatifyKarma: async (args) => { karma.push(args); return true; },
151
+ }, async () => {
152
+ const out = await maybeCloseAfterVote(11);
153
+ assert.equal(out.closed, true);
154
+ assert.equal(out.outcome, 'passed');
155
+ assert.equal(out.cause, 'vote');
156
+ });
157
+ assert.equal(karma.length, 1);
158
+ });
159
+
160
+ test('consent with turnout outstanding leaves the window open', async () => {
161
+ await withDb({
162
+ getBoardItemForVote: async () => openItem({ constitution: CONSENT }),
163
+ getVotesForItem: async () => [yes('1')],
164
+ getActiveBuildersWithRank: async () => [member(1), member(2)], // 1 of 2 voted
165
+ closeBoardItem: async () => { throw new Error('must not be reached'); },
166
+ }, async () => {
167
+ assert.deepEqual(await maybeCloseAfterVote(11), { closed: false, reason: 'window_still_open' });
168
+ });
169
+ });
170
+
171
+ // ── the structural pins ──────────────────────────────────────────────────────
172
+
173
+ test('the close UPDATE is single-winner in the SQL — in BOTH the plain and the transactional form', () => {
174
+ const src = fs.readFileSync(path.join(GOV, 'db.js'), 'utf8');
175
+ const block = src.slice(src.indexOf('async function closeBoardItem'), src.indexOf('async function getIdeaCompleteness'));
176
+ assert.equal((block.match(/WHERE id = \$1 AND closed_at IS NULL/g) || []).length, 2,
177
+ 'two racers write exactly one outcome, with or without an in-tx credit hook');
178
+ assert.match(block, /ROLLBACK/, 'a failed in-tx credit rolls the close back — the vote is retried, never silently unpaid');
179
+ });
180
+
181
+ // ── R12: money rides the port inside the tx; the event is post-commit courtesy ──
182
+
183
+ test('a pass books the stream-3 credit INSIDE the close transaction, through the reward port', async () => {
184
+ const api = require(path.join(ROOT, 'src', 'module-api.js'));
185
+ const hadReward = api.hasProvider('reward');
186
+ if (!hadReward) {
187
+ api.registerProvider('reward', {
188
+ awardIdeaGovernancePassCredit: async (client, args) => ({ credited: 82, reason: 'paid', _client: client, _args: args }),
189
+ });
190
+ }
191
+ const seen = [];
192
+ try {
193
+ await withDb({
194
+ getVotesForItem: async () => [yes('42')],
195
+ getBoardItemAuthor: async () => '7',
196
+ getIdeaCompleteness: async () => 82,
197
+ closeBoardItem: async ({ outcome, afterClose }) => {
198
+ const closed = { ...openItem(), closed_at: 't', outcome };
199
+ // the db layer hands afterClose ITS transaction client — simulate that
200
+ if (afterClose) await afterClose({ __tx: true }, closed);
201
+ seen.push({ outcome, hadHook: typeof afterClose === 'function' });
202
+ return closed;
203
+ },
204
+ insertAuthorRatifyKarma: async () => true,
205
+ }, async () => {
206
+ const out = await closeItem(openItem(), { cause: 'vote' });
207
+ assert.equal(out.closed, true);
208
+ if (out.credit) { // present only when a reward provider carries IC5's method
209
+ assert.equal(out.credit.reason, 'paid');
210
+ assert.deepEqual(out.credit._client, { __tx: true }, 'the award ran on the CLOSE transaction\'s client');
211
+ assert.equal(out.credit._args.ideaId, '7');
212
+ assert.equal(out.credit._args.completenessScore, 82);
213
+ }
214
+ assert.equal(seen[0].outcome, 'passed');
215
+ });
216
+ } finally { /* provider registry is process-global; leave whatever was there */ }
217
+ });
218
+
219
+ test('a RETURNED item books nothing and emits nothing — only a pass pays or announces', async () => {
220
+ await withDb({
221
+ getVotesForItem: async () => [no('42')],
222
+ closeBoardItem: async ({ outcome, afterClose }) => {
223
+ assert.equal(afterClose, null, 'no in-tx hook on a return — nothing to book');
224
+ return { ...openItem(), closed_at: 't', outcome };
225
+ },
226
+ getBoardItemAuthor: async () => { throw new Error('must not be reached'); },
227
+ getIdeaCompleteness: async () => { throw new Error('must not be reached'); },
228
+ }, async () => {
229
+ const out = await closeItem(openItem());
230
+ assert.equal(out.outcome, 'returned');
231
+ assert.equal(out.credit, undefined);
232
+ });
233
+ });
234
+
235
+ test('the payload matches the contract shape, and an amendment carries idea_id null', () => {
236
+ const closed = { id: '11', subject_type: 'full_idea', subject_id: '7', closed_at: 'T' };
237
+ const p = board.passPayload(closed, { authorId: '42', completenessScore: 82, passedAt: 'T' });
238
+ assert.deepEqual(Object.keys(p).sort(),
239
+ ['author_id', 'completeness_score', 'idea_id', 'item_id', 'passed_at', 'subject_id', 'subject_type'],
240
+ 'exactly the contract\'s seven fields — no tally, no voter list, no constitution');
241
+ assert.equal(p.idea_id, '7', 'idea_id === subject_id for a full_idea');
242
+ const amendment = board.passPayload({ ...closed, subject_type: 'constitutional_amendment' },
243
+ { authorId: '3', completenessScore: null, passedAt: 'T' });
244
+ assert.equal(amendment.idea_id, null, 'an amendment traverses the same path and must book nothing — the consumer filters on subject_type');
245
+ });
246
+
247
+ test('the event is emitAsync AFTER the close, and money never rides it (source pins)', () => {
248
+ const boardSrc = fs.readFileSync(path.join(GOV, 'board.js'), 'utf8');
249
+ const close = boardSrc.slice(boardSrc.indexOf('async function closeItem'), boardSrc.indexOf('async function maybeCloseAfterVote'));
250
+ assert.match(close, /emitAsync\('board\.item\.passed'/, 'the courtesy event exists for R22');
251
+ assert.ok(close.indexOf('closeBoardItem') < close.indexOf("emitAsync('board.item.passed'"),
252
+ 'emitted after the close write — a payment must not depend on a listener having been alive');
253
+ assert.match(close, /awardIdeaGovernancePassCredit/, 'the money goes through the PORT');
254
+ const emitBlock = close.slice(close.indexOf("emitAsync('board.item.passed'"));
255
+ assert.ok(!emitBlock.includes('award'), 'nothing that moves credits touches the event path');
256
+ });