@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 +25 -15
- package/modules/government/board.js +78 -4
- package/modules/government/db.js +64 -7
- package/modules/government/module.json +3 -0
- package/modules/government/pollers/board-expiry.js +101 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness.js +10 -0
- package/src/module-api.js +5 -1
- package/tests/government_board_close.mjs +85 -6
- package/tests/government_board_expiry.mjs +109 -0
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.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
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-
|
|
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":
|
|
14
|
+
"functional_verbatim": 1311,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
4310
|
+
"sha256": "06a9692e854d494a7ddecb1c634fa93f7bc7e41fb0cfe6a9c51ff5cad64fca26"
|
|
4306
4311
|
},
|
|
4307
4312
|
{
|
|
4308
4313
|
"path": "package.json",
|
|
4309
4314
|
"mode": "0000644",
|
|
4310
|
-
"sha256": "
|
|
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": "
|
|
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": "
|
|
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": "
|
|
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
|
-
//
|
|
254
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|
package/modules/government/db.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
`
|
|
392
|
-
|
|
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]
|
|
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
|
};
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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",
|
package/scripts/gds/fitness.js
CHANGED
|
@@ -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.
|
|
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
|
|
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('
|
|
172
|
-
assert.match(
|
|
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
|
-
|
|
175
|
-
|
|
176
|
-
assert.ok(
|
|
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
|
+
});
|