@bongos/core 1.19.227 → 1.19.229

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.227",
6
- "core_contract": "1.19.227",
7
- "source_commit": "f6137a0375f70937656a4026edfe79fb32897774",
5
+ "core_version": "1.19.229",
6
+ "core_contract": "1.19.229",
7
+ "source_commit": "5c10983a65226999c00c47505064cbbc2f220b69",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-18T04:58:06.044Z",
9
+ "built_at": "2026-08-18T11:08:02.722Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 311,
13
13
  "agent_docs_stubbed": 21,
14
- "functional_verbatim": 1309,
14
+ "functional_verbatim": 1311,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1641,
20
- "tree_sha256": "eede258e0c6a9ae77dd442500e39fdb81956608f764730d9eccbf7caa11a4fce",
19
+ "file_count": 1643,
20
+ "tree_sha256": "e979d45fff4ac3f56c93dde15a58e8af514e7ed2254ff4df6c028de0aaaa9184",
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": "43a284c7c69e3181268d1cb1e177deecbafe3c2693881211a74e72442aabf854"
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": "8bd28cbedd0dcb829147ad341c5a0bd3cf30225d6b2665c66218172a1930d1b0"
2920
+ "sha256": "41c6cd987c619f7bfe6ae4f03a9acf7502872cb2f929b7256c95dbb84784c769"
2921
2921
  },
2922
2922
  {
2923
2923
  "path": "modules/government/migrations/governance_001_tables.sql",
@@ -2982,7 +2982,12 @@
2982
2982
  {
2983
2983
  "path": "modules/government/module.json",
2984
2984
  "mode": "0000644",
2985
- "sha256": "6ee190f95f9a5c7bbe6d4d4b193dff5116fe3c317ffcffc68512a40da7b1084c"
2985
+ "sha256": "7baa08a671982532d9961164ffdb37b56bdf11bc8ba133739d3af07b54b0eaa8"
2986
+ },
2987
+ {
2988
+ "path": "modules/government/pollers/board-expiry.js",
2989
+ "mode": "0000644",
2990
+ "sha256": "0e524bee731d9a6120cb98a23f419a2803f9bb19118f5e5aa46befd8e253e9ca"
2986
2991
  },
2987
2992
  {
2988
2993
  "path": "modules/government/protected-surfaces.js",
@@ -4302,12 +4307,12 @@
4302
4307
  {
4303
4308
  "path": "package-lock.json",
4304
4309
  "mode": "0000644",
4305
- "sha256": "9f7314822fed28bb1cc244c3a440c0139fa6c383f58019565647078c267738d8"
4310
+ "sha256": "06a9692e854d494a7ddecb1c634fa93f7bc7e41fb0cfe6a9c51ff5cad64fca26"
4306
4311
  },
4307
4312
  {
4308
4313
  "path": "package.json",
4309
4314
  "mode": "0000644",
4310
- "sha256": "2b3299463571782b859abbccdb9a2fb7d0130dc6390da6de5484ab6c9800bde3"
4315
+ "sha256": "e1bc3c9e5f02b5f174bf8a97531dbd6447f367cb0ccef682f9ee994619130308"
4311
4316
  },
4312
4317
  {
4313
4318
  "path": "public-docs/index.html",
@@ -4932,7 +4937,7 @@
4932
4937
  {
4933
4938
  "path": "scripts/gds/fitness.js",
4934
4939
  "mode": "0000644",
4935
- "sha256": "a255afac6eb4a2b8269e896032e8eabf10f104aed5a0f1e8d0a7348695625424"
4940
+ "sha256": "00eacd557419ebdba21347e215ef0f197de8cd839f6af778bf90ff2901823a0e"
4936
4941
  },
4937
4942
  {
4938
4943
  "path": "scripts/gds/gate-review.js",
@@ -5922,7 +5927,7 @@
5922
5927
  {
5923
5928
  "path": "src/module-api.js",
5924
5929
  "mode": "0000644",
5925
- "sha256": "206a9ffcb92d028f8f954dad04df2ec172bfacbd6be04d5204176acd4a94788a"
5930
+ "sha256": "2d2767e5620a5743401f0b04ccae76a989682e13356a3a27c80203a8bd267d8d"
5926
5931
  },
5927
5932
  {
5928
5933
  "path": "src/module-loader/catalog.js",
@@ -6847,7 +6852,12 @@
6847
6852
  {
6848
6853
  "path": "tests/government_board_close.mjs",
6849
6854
  "mode": "0000644",
6850
- "sha256": "59b35a20d28c2d689fac976f02eb0e10e95ef887c63702b9e52e3361e71eb6b4"
6855
+ "sha256": "4408ec434f36b70a589b0592814733d3f88d48aa9aff38c08c9a2eb6c6c9f7b3"
6856
+ },
6857
+ {
6858
+ "path": "tests/government_board_expiry.mjs",
6859
+ "mode": "0000644",
6860
+ "sha256": "1bf22bc4ae339459e28a9f4e7975aac9e9d84eacd3f901d30371b44574f714c1"
6851
6861
  },
6852
6862
  {
6853
6863
  "path": "tests/government_board_membership.mjs",
@@ -40,6 +40,7 @@
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
46
  const { isBoardMember, resolveBoardMembers } = require('./board-membership');
@@ -250,17 +251,89 @@ function shouldCloseAfterVote({ constitution, votes, members } = {}) {
250
251
  return false; // unknown rule: only the clock closes it
251
252
  }
252
253
 
253
- // Close an item: tally what was cast, write the outcome, pay the author on a
254
- // pass. Idempotent end to end — the single-winner UPDATE plus the karma's
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
255
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.
256
290
  async function closeItem(item, { cause = 'vote' } = {}) {
257
291
  if (!item || item.closed_at) return { closed: false, reason: 'already_closed' };
258
292
  const votes = await db.getVotesForItem(item.id);
259
293
  const outcome = tallyOutcome(votes);
260
- const closed = await db.closeBoardItem({ itemId: item.id, outcome });
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
+ });
261
325
  if (!closed) return { closed: false, reason: 'already_closed' };
326
+
262
327
  const authorKarma = outcome === 'passed' ? await awardAuthorRatifyKarma(closed) : null;
263
- return { closed: true, item: closed, outcome, cause, author_karma: authorKarma };
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 } : {}) };
264
337
  }
265
338
 
266
339
  // The post-vote hook R08's route fires after a FRESH cast. Reads everything it
@@ -290,6 +363,7 @@ module.exports = {
290
363
  castVote,
291
364
  tallyOutcome,
292
365
  shouldCloseAfterVote,
366
+ passPayload,
293
367
  closeItem,
294
368
  maybeCloseAfterVote,
295
369
  };
@@ -386,15 +386,70 @@ async function getVotesForItem(itemId) {
386
386
  // two concurrent closers (a racing vote and the expiry sweep) write exactly one
387
387
  // outcome; the loser gets null and reads the winner's row back if it cares.
388
388
  // closed_at comes from the database clock, like every timestamp on this table.
389
- async function closeBoardItem({ itemId, outcome }) {
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) {
390
433
  const { rows } = await pool.query(
391
- `UPDATE government_board_items
392
- SET closed_at = now(), outcome = $2
393
- WHERE id = $1 AND closed_at IS NULL
394
- RETURNING *`,
395
- [itemId, outcome],
434
+ `SELECT completeness_score FROM ideas_grades WHERE idea_id = $1`,
435
+ [ideaId],
396
436
  );
397
- return rows[0] || null;
437
+ return rows[0] ? rows[0].completeness_score : null;
438
+ }
439
+
440
+ // The expiry sweep's read (R11): every OPEN item whose deadline has passed, on
441
+ // the database clock, oldest deadline first. Served by the partial index
442
+ // government_board_items_open_by_deadline (government_003 built it for exactly
443
+ // this). NULL closes_at — the no-clock configuration — never appears here.
444
+ async function getExpiredOpenBoardItems(limit = 100) {
445
+ const { rows } = await pool.query(
446
+ `SELECT * FROM government_board_items
447
+ WHERE closed_at IS NULL AND closes_at IS NOT NULL AND closes_at < now()
448
+ ORDER BY closes_at
449
+ LIMIT $1`,
450
+ [limit],
451
+ );
452
+ return rows;
398
453
  }
399
454
 
400
455
  // Author-ratification karma (+3, board.js AUTHOR_RATIFY_KARMA_DELTA) — fired by
@@ -450,4 +505,6 @@ module.exports = {
450
505
  insertAuthorRatifyKarma,
451
506
  getVotesForItem,
452
507
  closeBoardItem,
508
+ getIdeaCompleteness,
509
+ getExpiredOpenBoardItems,
453
510
  };
@@ -12,6 +12,9 @@
12
12
  "routes": [
13
13
  "government",
14
14
  "rank-sync"
15
+ ],
16
+ "pollers": [
17
+ "board-expiry"
15
18
  ]
16
19
  },
17
20
  "provides": [
@@ -0,0 +1,101 @@
1
+ 'use strict';
2
+
3
+ // modules/government/pollers/board-expiry.js — the window expiry sweep
4
+ // (BV1.R11 of goal 1000069, ADR 0175 §2).
5
+ //
6
+ // A window closes ON TIME with nobody touching it. The clock is the sitting
7
+ // model's deadlock exit — absent members drop out of the denominator and the
8
+ // deadline ends the sitting — so this poller is what makes the timed half of
9
+ // the constitution real. It calls the SAME closeItem the post-vote path calls
10
+ // (R10), so a clock-close and a turnout-close can never mean different things,
11
+ // and the single-winner UPDATE makes racing one of them against a landing vote
12
+ // safe: exactly one outcome is written.
13
+ //
14
+ // THE SWEEP SHAPE follows the publish reconciler (the in-tree precedent the
15
+ // spec names): a bounded per-sweep read, PER-ITEM failure isolation (one item
16
+ // that cannot close must not stall the item behind it), and a per-item
17
+ // DISPOSITION LINE so a stuck window is visible in the journal rather than
18
+ // silent. Unlike the reconciler it stays QUIET on an empty sweep — a
19
+ // minute-cadence poller logging every tick would bury the journal (the
20
+ // reconciler's own warn-once lesson).
21
+ //
22
+ // CADENCE: windows are minutes-granular (the recommended sitting is 90
23
+ // minutes), so a one-minute sweep bounds lateness at ~1 minute — tight enough
24
+ // that "closes on time" is honest, cheap enough that the query (one indexed
25
+ // read; government_board_items_open_by_deadline exists for exactly this, per
26
+ // government_003's own comment) is negligible. Concurrent windows are normal
27
+ // (owner decision 10): the sweep closes every due item, oldest deadline first.
28
+
29
+ const board = require('../board');
30
+ const db = require('../db');
31
+
32
+ const INTERVAL_MS = Number(process.env.BOARD_EXPIRY_SWEEP_MS) || 60_000;
33
+ // A first sweep shortly after boot, so windows that expired while the process
34
+ // was down (a deploy restart mid-sitting) close promptly rather than one full
35
+ // interval late.
36
+ const FIRST_SWEEP_DELAY_MS = Number(process.env.BOARD_EXPIRY_FIRST_MS) || 15_000;
37
+ // Runaway guard, not an expected bound — a project rarely has more than a
38
+ // handful of open windows (the board's subjects are rare by construction).
39
+ const SWEEP_LIMIT = 100;
40
+
41
+ let intervalTimer = null;
42
+ let firstTimer = null;
43
+
44
+ // One sweep. Injectable deps keep it DB-free testable: listFn yields the due
45
+ // items, closeFn closes one, log receives the disposition lines.
46
+ async function runSweep({ listFn = db.getExpiredOpenBoardItems, closeFn = board.closeItem, log = console.log, error = console.error } = {}) {
47
+ let due;
48
+ try {
49
+ due = await listFn(SWEEP_LIMIT);
50
+ } catch (e) {
51
+ error(`[board-expiry] sweep read failed: ${e && e.message}`);
52
+ return { closed: 0, failed: 0, due: 0 };
53
+ }
54
+ if (!due || !due.length) return { closed: 0, failed: 0, due: 0 }; // quiet on empty
55
+ let closed = 0;
56
+ let failed = 0;
57
+ for (const item of due) {
58
+ try {
59
+ const out = await closeFn(item, { cause: 'expiry' });
60
+ if (out && out.closed) {
61
+ closed += 1;
62
+ log(`[board-expiry] item ${item.id} (${item.subject_type} ${item.subject_id}) expired → ${out.outcome}`);
63
+ } else {
64
+ // A racer (a landing vote's close) beat the sweep — the item is closed,
65
+ // just not by us. That is the single-winner design working; say so.
66
+ log(`[board-expiry] item ${item.id} already closed by a racer — nothing to do`);
67
+ }
68
+ } catch (e) {
69
+ failed += 1;
70
+ // Per-item isolation: name the item and keep sweeping. A close that
71
+ // throws here (e.g. the in-tx credit failing) has ROLLED BACK, so the
72
+ // item is still open and the next sweep retries it — the pass-event
73
+ // contract's "retried, never silently unpaid".
74
+ error(`[board-expiry] item ${item.id} failed to close (still open; next sweep retries): ${e && e.message}`);
75
+ }
76
+ }
77
+ return { closed, failed, due: due.length };
78
+ }
79
+
80
+ function start(deps = {}) {
81
+ if (process.env.BOARD_EXPIRY_DISABLED === '1') {
82
+ console.log('[board-expiry] disabled (BOARD_EXPIRY_DISABLED=1)');
83
+ return;
84
+ }
85
+ if (intervalTimer) return; // already started
86
+ const tick = () => {
87
+ runSweep(deps).catch((e) => console.error(`[board-expiry] sweep crashed: ${e && e.message}`));
88
+ };
89
+ firstTimer = setTimeout(tick, FIRST_SWEEP_DELAY_MS);
90
+ if (firstTimer.unref) firstTimer.unref();
91
+ intervalTimer = setInterval(tick, INTERVAL_MS);
92
+ if (intervalTimer.unref) intervalTimer.unref(); // never hold the process open on a restart
93
+ console.log(`[board-expiry] started (every ${Math.round(INTERVAL_MS / 1000)}s; first sweep in ${Math.round(FIRST_SWEEP_DELAY_MS / 1000)}s)`);
94
+ }
95
+
96
+ function stop() {
97
+ if (firstTimer) { clearTimeout(firstTimer); firstTimer = null; }
98
+ if (intervalTimer) { clearInterval(intervalTimer); intervalTimer = null; }
99
+ }
100
+
101
+ module.exports = { start, stop, runSweep, INTERVAL_MS, SWEEP_LIMIT };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.227",
3
+ "version": "1.19.229",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.227",
9
+ "version": "1.19.229",
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.227",
3
+ "version": "1.19.229",
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
@@ -881,8 +881,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
881
881
  // landed since 1.19.225 with no explicit bump. run 32100543772. (task 1002620)
882
882
  // 1.19.227 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
883
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)
886
+ // 1.19.229 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
887
+ // landed since 1.19.228 with no explicit bump. run 32130159805. (task 1002620)
884
888
  // ---------------------------------------------------------------------------
885
- const CORE_VERSION = '1.19.227'; // CI auto-patch carrier (ADR 0161); see changelog above
889
+ const CORE_VERSION = '1.19.229'; // CI auto-patch carrier (ADR 0161); see changelog above
886
890
 
887
891
  // A namespaced logger so a module's log lines are attributable + consistent.
888
892
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -99,6 +99,7 @@ test('a pass pays the author (+3, idempotent downstream); a return pays nobody',
99
99
  getVotesForItem: async () => [yes('42')],
100
100
  closeBoardItem: async ({ itemId, outcome }) => ({ ...openItem(), id: String(itemId), closed_at: 't', outcome }),
101
101
  getBoardItemAuthor: async () => '7',
102
+ getIdeaCompleteness: async () => 82,
102
103
  insertAuthorRatifyKarma: async (args) => { karma.push(args); return true; },
103
104
  }, async () => {
104
105
  const out = await closeItem(openItem(), { cause: 'vote' });
@@ -124,6 +125,8 @@ test('a pass pays the author (+3, idempotent downstream); a return pays nobody',
124
125
  test('the losing racer gets already_closed and pays nothing', async () => {
125
126
  await withDb({
126
127
  getVotesForItem: async () => [yes('42')],
128
+ getBoardItemAuthor: async () => '7',
129
+ getIdeaCompleteness: async () => 82,
127
130
  closeBoardItem: async () => null, // the WHERE closed_at IS NULL conflict path
128
131
  insertAuthorRatifyKarma: async () => { throw new Error('must not be reached'); },
129
132
  }, async () => {
@@ -143,6 +146,7 @@ test('monarchy end to end: the first yes closes the item as passed, author paid'
143
146
  getActiveBuildersWithRank: async () => [{ builder_id: '42', github_login: 'a', display_name: 'A' }],
144
147
  closeBoardItem: async ({ outcome }) => ({ ...openItem(), closed_at: 't', outcome }),
145
148
  getBoardItemAuthor: async () => '7',
149
+ getIdeaCompleteness: async () => 82,
146
150
  insertAuthorRatifyKarma: async (args) => { karma.push(args); return true; },
147
151
  }, async () => {
148
152
  const out = await maybeCloseAfterVote(11);
@@ -166,12 +170,87 @@ test('consent with turnout outstanding leaves the window open', async () => {
166
170
 
167
171
  // ── the structural pins ──────────────────────────────────────────────────────
168
172
 
169
- test('the close UPDATE is single-winner in the SQL, and the pass event is NOT emitted here', () => {
173
+ test('the close UPDATE is single-winner in the SQL — in BOTH the plain and the transactional form', () => {
170
174
  const src = fs.readFileSync(path.join(GOV, 'db.js'), 'utf8');
171
- const block = src.slice(src.indexOf('async function closeBoardItem'), src.indexOf('// Author-ratification karma'));
172
- assert.match(block, /WHERE id = \$1 AND closed_at IS NULL/, 'two racers write exactly one outcome');
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)', () => {
173
248
  const boardSrc = fs.readFileSync(path.join(GOV, 'board.js'), 'utf8');
174
- assert.ok(!/emit|event/i.test(boardSrc.slice(boardSrc.indexOf('async function closeItem'), boardSrc.indexOf('async function maybeCloseAfterVote'))) || true,
175
- 'sanity only');
176
- assert.ok(!boardSrc.includes('pass_event'), 'R12 owns the pass event — closing and emitting are separate acts');
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');
177
256
  });
@@ -0,0 +1,109 @@
1
+ // tests/government_board_expiry.mjs — the window expiry poller (BV1.R11 of
2
+ // goal 1000069, ADR 0175 §2): a window closes on time with nobody touching it.
3
+ //
4
+ // DB-free: runSweep takes injectable deps (the reconciler's testable shape).
5
+ // What must hold:
6
+ // 1. EVERY due item is closed through the SAME closeItem R10 built, with
7
+ // cause 'expiry' — a clock-close and a turnout-close are one path.
8
+ // 2. PER-ITEM ISOLATION — an item that fails to close (rolled back, still
9
+ // open, retried next sweep) must not stall the items behind it.
10
+ // 3. DISPOSITIONS ARE VISIBLE — a closed item and a racer-beaten item each
11
+ // log a line; an EMPTY sweep logs nothing (a minute-cadence poller must
12
+ // not bury the journal).
13
+ // 4. The timers never hold the process open, and stop() is clean.
14
+ import assert from 'node:assert/strict';
15
+ import { test } from 'node:test';
16
+ import { createRequire } from 'node:module';
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+
21
+ const require = createRequire(import.meta.url);
22
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
23
+ const GOV = path.join(ROOT, 'modules', 'government');
24
+ const poller = require(path.join(GOV, 'pollers', 'board-expiry.js'));
25
+
26
+ const item = (id, over = {}) => ({ id: String(id), subject_type: 'full_idea', subject_id: String(id * 10), closed_at: null, ...over });
27
+
28
+ test('every due item closes via closeItem with cause expiry — concurrent windows are normal', async () => {
29
+ const closed = [];
30
+ const lines = [];
31
+ const out = await poller.runSweep({
32
+ listFn: async (limit) => { assert.equal(limit, poller.SWEEP_LIMIT); return [item(1), item(2), item(3)]; },
33
+ closeFn: async (it, opts) => { closed.push([it.id, opts.cause]); return { closed: true, outcome: 'passed' }; },
34
+ log: (l) => lines.push(l),
35
+ error: () => { throw new Error('no errors expected'); },
36
+ });
37
+ assert.deepEqual(closed, [['1', 'expiry'], ['2', 'expiry'], ['3', 'expiry']]);
38
+ assert.deepEqual(out, { closed: 3, failed: 0, due: 3 });
39
+ assert.equal(lines.length, 3, 'one disposition line per closed item');
40
+ assert.ok(lines[0].includes('expired → passed'));
41
+ });
42
+
43
+ test('one failing item does not stall the sweep — named, counted, retried next time', async () => {
44
+ const closed = [];
45
+ const errors = [];
46
+ const out = await poller.runSweep({
47
+ listFn: async () => [item(1), item(2), item(3)],
48
+ closeFn: async (it) => {
49
+ if (it.id === '2') throw new Error('credit write refused — close rolled back');
50
+ closed.push(it.id);
51
+ return { closed: true, outcome: 'returned' };
52
+ },
53
+ log: () => {},
54
+ error: (l) => errors.push(l),
55
+ });
56
+ assert.deepEqual(closed, ['1', '3'], 'the items behind the failure still closed');
57
+ assert.deepEqual(out, { closed: 2, failed: 1, due: 3 });
58
+ assert.equal(errors.length, 1);
59
+ assert.ok(errors[0].includes('item 2') && errors[0].includes('next sweep retries'),
60
+ 'the stuck window is visible, and the retry story is stated');
61
+ });
62
+
63
+ test('a racer-beaten item is a disposition, not an error — the single-winner design working', async () => {
64
+ const lines = [];
65
+ const out = await poller.runSweep({
66
+ listFn: async () => [item(7)],
67
+ closeFn: async () => ({ closed: false, reason: 'already_closed' }),
68
+ log: (l) => lines.push(l),
69
+ error: () => { throw new Error('not an error'); },
70
+ });
71
+ assert.deepEqual(out, { closed: 0, failed: 0, due: 1 });
72
+ assert.ok(lines[0].includes('already closed by a racer'));
73
+ });
74
+
75
+ test('an empty sweep is SILENT, and a failed read is one error line, not a crash', async () => {
76
+ const lines = [];
77
+ const errors = [];
78
+ assert.deepEqual(await poller.runSweep({ listFn: async () => [], log: (l) => lines.push(l), error: (l) => errors.push(l) }),
79
+ { closed: 0, failed: 0, due: 0 });
80
+ assert.equal(lines.length, 0, 'a minute-cadence poller must not bury the journal');
81
+ assert.deepEqual(await poller.runSweep({ listFn: async () => { throw new Error('db down'); }, log: () => {}, error: (l) => errors.push(l) }),
82
+ { closed: 0, failed: 0, due: 0 });
83
+ assert.equal(errors.length, 1);
84
+ });
85
+
86
+ test('the sweep read is indexed, deadline-ordered, and never sees a no-clock item (SQL pins)', () => {
87
+ const src = fs.readFileSync(path.join(GOV, 'db.js'), 'utf8');
88
+ const block = src.slice(src.indexOf('async function getExpiredOpenBoardItems'), src.indexOf('// Author-ratification karma'));
89
+ assert.match(block, /closed_at IS NULL AND closes_at IS NOT NULL AND closes_at < now\(\)/,
90
+ 'open + due, on the DATABASE clock; NULL closes_at (no clock) never expires');
91
+ assert.match(block, /ORDER BY closes_at/, 'oldest deadline first');
92
+ const migration = fs.readFileSync(path.join(GOV, 'migrations', 'government_003_board_tables.sql'), 'utf8');
93
+ assert.ok(migration.includes('government_board_items_open_by_deadline'),
94
+ 'the partial index government_003 built for this sweep exists');
95
+ });
96
+
97
+ test('the manifest declares the poller, and start/stop are idempotent + unref-safe', () => {
98
+ const manifest = JSON.parse(fs.readFileSync(path.join(GOV, 'module.json'), 'utf8'));
99
+ assert.ok(manifest.contributes.pollers.includes('board-expiry'),
100
+ 'the loader starts it only when declared (startModulePollers contract)');
101
+ // Behavioural start/stop with the sweep stubbed inert: no timer may survive stop().
102
+ poller.start({ listFn: async () => [], log: () => {}, error: () => {} });
103
+ poller.start(); // second start is a no-op (already started)
104
+ poller.stop();
105
+ poller.stop(); // second stop is a no-op
106
+ // The timers are unref'd in start(); nothing to await — reaching here without
107
+ // the test runner hanging IS the assertion.
108
+ assert.ok(true);
109
+ });