@bongos/core 1.19.228 → 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.228",
6
- "core_contract": "1.19.228",
7
- "source_commit": "f14babca30bfba954ddd62b510ebc1ed4749d9fa",
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-18T05:08:08.027Z",
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": "2a30117336498006b3241dc75e5157ce5fdc462327dbdf3618e469ad9defb158",
19
+ "file_count": 1643,
20
+ "tree_sha256": "e979d45fff4ac3f56c93dde15a58e8af514e7ed2254ff4df6c028de0aaaa9184",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -2917,7 +2917,7 @@
2917
2917
  {
2918
2918
  "path": "modules/government/db.js",
2919
2919
  "mode": "0000644",
2920
- "sha256": "e20e5a4aa8a79441e5860ceda2f19e41cbdb8fd85c0eda5d00f572eee5701f20"
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": "41bf0bb43e6a556ddf9598e5208bc478dd9bfc5d92f46ff64b6af6ef3ecac0eb"
4310
+ "sha256": "06a9692e854d494a7ddecb1c634fa93f7bc7e41fb0cfe6a9c51ff5cad64fca26"
4306
4311
  },
4307
4312
  {
4308
4313
  "path": "package.json",
4309
4314
  "mode": "0000644",
4310
- "sha256": "1b62282a15730fccfbe7ae8b3a2cb2321c4b1f9947bba37928150613db0e6d7d"
4315
+ "sha256": "e1bc3c9e5f02b5f174bf8a97531dbd6447f367cb0ccef682f9ee994619130308"
4311
4316
  },
4312
4317
  {
4313
4318
  "path": "public-docs/index.html",
@@ -5922,7 +5927,7 @@
5922
5927
  {
5923
5928
  "path": "src/module-api.js",
5924
5929
  "mode": "0000644",
5925
- "sha256": "985014c8a83781918e242757e48bf93cbc1df612b1eb4560177aeba85b30b0f7"
5930
+ "sha256": "2d2767e5620a5743401f0b04ccae76a989682e13356a3a27c80203a8bd267d8d"
5926
5931
  },
5927
5932
  {
5928
5933
  "path": "src/module-loader/catalog.js",
@@ -6849,6 +6854,11 @@
6849
6854
  "mode": "0000644",
6850
6855
  "sha256": "4408ec434f36b70a589b0592814733d3f88d48aa9aff38c08c9a2eb6c6c9f7b3"
6851
6856
  },
6857
+ {
6858
+ "path": "tests/government_board_expiry.mjs",
6859
+ "mode": "0000644",
6860
+ "sha256": "1bf22bc4ae339459e28a9f4e7975aac9e9d84eacd3f901d30371b44574f714c1"
6861
+ },
6852
6862
  {
6853
6863
  "path": "tests/government_board_membership.mjs",
6854
6864
  "mode": "0000644",
@@ -437,6 +437,21 @@ async function getIdeaCompleteness(ideaId) {
437
437
  return rows[0] ? rows[0].completeness_score : null;
438
438
  }
439
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;
453
+ }
454
+
440
455
  // Author-ratification karma (+3, board.js AUTHOR_RATIFY_KARMA_DELTA) — fired by
441
456
  // the R10 close on a passed item. IDEMPOTENT: the reason key is unique per item
442
457
  // and the INSERT is one atomic WHERE-NOT-EXISTS statement (the economy
@@ -491,4 +506,5 @@ module.exports = {
491
506
  getVotesForItem,
492
507
  closeBoardItem,
493
508
  getIdeaCompleteness,
509
+ getExpiredOpenBoardItems,
494
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.228",
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.228",
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.228",
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/src/module-api.js CHANGED
@@ -883,8 +883,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
883
883
  // landed since 1.19.226 with no explicit bump. run 32101041951. (task 1002620)
884
884
  // 1.19.228 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
885
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)
886
888
  // ---------------------------------------------------------------------------
887
- const CORE_VERSION = '1.19.228'; // 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
888
890
 
889
891
  // A namespaced logger so a module's log lines are attributable + consistent.
890
892
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -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
+ });