@bongos/core 1.19.225 → 1.19.227
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 +21 -16
- package/modules/government/board.js +85 -1
- package/modules/government/db.js +27 -0
- package/modules/government/routes/government.js +14 -0
- package/modules/ideas/completeness.js +44 -1
- package/modules/ideas/routes/inbox.js +12 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +5 -1
- package/tests/government_board_close.mjs +177 -0
- package/tests/idea_completeness.mjs +30 -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.227",
|
|
6
|
+
"core_contract": "1.19.227",
|
|
7
|
+
"source_commit": "f6137a0375f70937656a4026edfe79fb32897774",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-18T04:
|
|
9
|
+
"built_at": "2026-08-18T04:58:06.044Z",
|
|
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": 1309,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1641,
|
|
20
|
+
"tree_sha256": "eede258e0c6a9ae77dd442500e39fdb81956608f764730d9eccbf7caa11a4fce",
|
|
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": "43a284c7c69e3181268d1cb1e177deecbafe3c2693881211a74e72442aabf854"
|
|
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": "8bd28cbedd0dcb829147ad341c5a0bd3cf30225d6b2665c66218172a1930d1b0"
|
|
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": "
|
|
3010
|
+
"sha256": "63ab50af4bc8d36db246eacfc9041c18cf0af89279f8ffe8e89f67978773401b"
|
|
3011
3011
|
},
|
|
3012
3012
|
{
|
|
3013
3013
|
"path": "modules/government/routes/rank-sync.js",
|
|
@@ -3517,7 +3517,7 @@
|
|
|
3517
3517
|
{
|
|
3518
3518
|
"path": "modules/ideas/completeness.js",
|
|
3519
3519
|
"mode": "0000644",
|
|
3520
|
-
"sha256": "
|
|
3520
|
+
"sha256": "07e5666362c91f92d34388b4f6f4507cc5387866d317970478e6cae417c19975"
|
|
3521
3521
|
},
|
|
3522
3522
|
{
|
|
3523
3523
|
"path": "modules/ideas/inbox.js",
|
|
@@ -3547,7 +3547,7 @@
|
|
|
3547
3547
|
{
|
|
3548
3548
|
"path": "modules/ideas/routes/inbox.js",
|
|
3549
3549
|
"mode": "0000644",
|
|
3550
|
-
"sha256": "
|
|
3550
|
+
"sha256": "9e9288725b66b696bdc565e693c82f046bd78665ae070e3f571ff987f82a7b7b"
|
|
3551
3551
|
},
|
|
3552
3552
|
{
|
|
3553
3553
|
"path": "modules/ideas/templates.js",
|
|
@@ -4302,12 +4302,12 @@
|
|
|
4302
4302
|
{
|
|
4303
4303
|
"path": "package-lock.json",
|
|
4304
4304
|
"mode": "0000644",
|
|
4305
|
-
"sha256": "
|
|
4305
|
+
"sha256": "9f7314822fed28bb1cc244c3a440c0139fa6c383f58019565647078c267738d8"
|
|
4306
4306
|
},
|
|
4307
4307
|
{
|
|
4308
4308
|
"path": "package.json",
|
|
4309
4309
|
"mode": "0000644",
|
|
4310
|
-
"sha256": "
|
|
4310
|
+
"sha256": "2b3299463571782b859abbccdb9a2fb7d0130dc6390da6de5484ab6c9800bde3"
|
|
4311
4311
|
},
|
|
4312
4312
|
{
|
|
4313
4313
|
"path": "public-docs/index.html",
|
|
@@ -5922,7 +5922,7 @@
|
|
|
5922
5922
|
{
|
|
5923
5923
|
"path": "src/module-api.js",
|
|
5924
5924
|
"mode": "0000644",
|
|
5925
|
-
"sha256": "
|
|
5925
|
+
"sha256": "206a9ffcb92d028f8f954dad04df2ec172bfacbd6be04d5204176acd4a94788a"
|
|
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": "59b35a20d28c2d689fac976f02eb0e10e95ef887c63702b9e52e3361e71eb6b4"
|
|
6851
|
+
},
|
|
6847
6852
|
{
|
|
6848
6853
|
"path": "tests/government_board_membership.mjs",
|
|
6849
6854
|
"mode": "0000644",
|
|
@@ -7077,7 +7082,7 @@
|
|
|
7077
7082
|
{
|
|
7078
7083
|
"path": "tests/idea_completeness.mjs",
|
|
7079
7084
|
"mode": "0000644",
|
|
7080
|
-
"sha256": "
|
|
7085
|
+
"sha256": "7e2024b3f1fa79a6bdec55b134be6337d4867df0a77fedb205f32b783a3d6193"
|
|
7081
7086
|
},
|
|
7082
7087
|
{
|
|
7083
7088
|
"path": "tests/idea_full_capture.mjs",
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
|
|
43
43
|
const db = require('./db');
|
|
44
44
|
const { loadGovernmentConfig } = require('./config');
|
|
45
|
-
const { isBoardMember } = require('./board-membership');
|
|
45
|
+
const { isBoardMember, resolveBoardMembers } = require('./board-membership');
|
|
46
46
|
|
|
47
47
|
// ADR 0175 §5: the completeness bar a Full Idea must clear to reach the board.
|
|
48
48
|
// Held equal to economy's IDEA_FULL_PASS_BAR by test — see the header.
|
|
@@ -198,6 +198,86 @@ async function castVote({ itemId, voterId, direction, reasonMd = null, sectionKe
|
|
|
198
198
|
return { ok: true, vote: inserted, fresh: true, karma_delta: VOTE_KARMA_DELTA };
|
|
199
199
|
}
|
|
200
200
|
|
|
201
|
+
// ── close + tally (BV1.R10 of goal 1000069, ADR 0175 §3) ────────────────────
|
|
202
|
+
//
|
|
203
|
+
// EVERY RULE DECLARED, NONE EMERGENT. The tally is one line: a single reasoned
|
|
204
|
+
// objection returns the idea; anything else — including ZERO votes — passes.
|
|
205
|
+
// Absent members are NULLED by construction rather than by branch: the tally
|
|
206
|
+
// only ever reads the votes that were cast, so a member who never voted simply
|
|
207
|
+
// does not exist in it, and absence can never block (§2 — the sitting model).
|
|
208
|
+
// A zero-vote pass is safe because the idea already cleared the grade gate,
|
|
209
|
+
// the author could not vote for themselves, and the ballot is open.
|
|
210
|
+
//
|
|
211
|
+
// TWO TRIGGERS, ONE CLOSE PATH:
|
|
212
|
+
// • after a vote — first_ratifier closes on the FIRST vote (monarchy: the
|
|
213
|
+
// Archon ratifies or returns, done); consent closes early only on FULL
|
|
214
|
+
// TURNOUT (every current member has voted — recorded positions and the
|
|
215
|
+
// vote karma are worth the wait, and no later vote can change the outcome
|
|
216
|
+
// anyway since there is no vote-changing in v1);
|
|
217
|
+
// • on expiry — R11's poller calls the same closeItem, so the clock and the
|
|
218
|
+
// turnout can never disagree about what a close means.
|
|
219
|
+
// An unknown pass_rule closes NOTHING after a vote (fail-closed — only the
|
|
220
|
+
// clock can end it), the same strict-end posture as R05's sanitizer.
|
|
221
|
+
//
|
|
222
|
+
// THE CLOSE IS STRUCTURALLY SINGLE-WINNER: closeBoardItem's UPDATE carries
|
|
223
|
+
// WHERE closed_at IS NULL, so two concurrent closers (a racing vote and the
|
|
224
|
+
// expiry sweep, say) write exactly one outcome and the loser reads it back.
|
|
225
|
+
//
|
|
226
|
+
// RATIFICATION PAYS THE AUTHOR HERE — this is what fires the +3
|
|
227
|
+
// awardAuthorRatifyKarma R08 declared (idempotent by reason key, so a re-driven
|
|
228
|
+
// close never double-pays). THE PASS EVENT IS DELIBERATELY NOT EMITTED HERE:
|
|
229
|
+
// that is R12's contract (docs/specs/board-room-pass-event-contract.md), and
|
|
230
|
+
// its once-per-idea semantics deserve their own slice — the sweep-friendly
|
|
231
|
+
// `WHERE outcome = 'passed'` shape survives precisely because closing and
|
|
232
|
+
// emitting are separate acts.
|
|
233
|
+
|
|
234
|
+
// The whole counting rule. Absence is nulled by what this function DOESN'T
|
|
235
|
+
// read: only cast votes exist here.
|
|
236
|
+
function tallyOutcome(votes) {
|
|
237
|
+
return (votes || []).some((v) => v.direction === 'no') ? 'returned' : 'passed';
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Should a just-cast vote close the item now, under the SNAPSHOTTED rule?
|
|
241
|
+
function shouldCloseAfterVote({ constitution, votes, members } = {}) {
|
|
242
|
+
const rule = (constitution || {}).pass_rule;
|
|
243
|
+
if (rule === 'first_ratifier') return (votes || []).length >= 1;
|
|
244
|
+
if (rule === 'consent') {
|
|
245
|
+
if (!(constitution || {}).close_early_on_full_turnout) return false;
|
|
246
|
+
if (!members || !members.length) return false; // an empty board has no turnout to complete
|
|
247
|
+
const voters = new Set((votes || []).map((v) => String(v.voter_id)));
|
|
248
|
+
return members.every((m) => voters.has(String(m.builder_id)));
|
|
249
|
+
}
|
|
250
|
+
return false; // unknown rule: only the clock closes it
|
|
251
|
+
}
|
|
252
|
+
|
|
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
|
|
255
|
+
// reason-key guard. `cause` is for the caller's telemetry ('vote' | 'expiry').
|
|
256
|
+
async function closeItem(item, { cause = 'vote' } = {}) {
|
|
257
|
+
if (!item || item.closed_at) return { closed: false, reason: 'already_closed' };
|
|
258
|
+
const votes = await db.getVotesForItem(item.id);
|
|
259
|
+
const outcome = tallyOutcome(votes);
|
|
260
|
+
const closed = await db.closeBoardItem({ itemId: item.id, outcome });
|
|
261
|
+
if (!closed) return { closed: false, reason: 'already_closed' };
|
|
262
|
+
const authorKarma = outcome === 'passed' ? await awardAuthorRatifyKarma(closed) : null;
|
|
263
|
+
return { closed: true, item: closed, outcome, cause, author_karma: authorKarma };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// The post-vote hook R08's route fires after a FRESH cast. Reads everything it
|
|
267
|
+
// judges — the item, the votes, the LIVE members under the snapshot's rule —
|
|
268
|
+
// per request, uncached, then hands closeItem the decision.
|
|
269
|
+
async function maybeCloseAfterVote(itemId) {
|
|
270
|
+
const item = await db.getBoardItemForVote(Number(itemId));
|
|
271
|
+
if (!item || item.closed_at) return { closed: false, reason: 'not_open' };
|
|
272
|
+
const constitution = item.constitution || {};
|
|
273
|
+
const votes = await db.getVotesForItem(item.id);
|
|
274
|
+
const members = await resolveBoardMembers(constitution);
|
|
275
|
+
if (!shouldCloseAfterVote({ constitution, votes, members })) {
|
|
276
|
+
return { closed: false, reason: 'window_still_open' };
|
|
277
|
+
}
|
|
278
|
+
return closeItem(item, { cause: 'vote' });
|
|
279
|
+
}
|
|
280
|
+
|
|
201
281
|
module.exports = {
|
|
202
282
|
FULL_IDEA_PASS_BAR,
|
|
203
283
|
ideaPassesBar,
|
|
@@ -208,4 +288,8 @@ module.exports = {
|
|
|
208
288
|
awardAuthorRatifyKarma,
|
|
209
289
|
validateVote,
|
|
210
290
|
castVote,
|
|
291
|
+
tallyOutcome,
|
|
292
|
+
shouldCloseAfterVote,
|
|
293
|
+
closeItem,
|
|
294
|
+
maybeCloseAfterVote,
|
|
211
295
|
};
|
package/modules/government/db.js
CHANGED
|
@@ -372,6 +372,31 @@ 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
|
+
async function closeBoardItem({ itemId, outcome }) {
|
|
390
|
+
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],
|
|
396
|
+
);
|
|
397
|
+
return rows[0] || null;
|
|
398
|
+
}
|
|
399
|
+
|
|
375
400
|
// Author-ratification karma (+3, board.js AUTHOR_RATIFY_KARMA_DELTA) — fired by
|
|
376
401
|
// the R10 close on a passed item. IDEMPOTENT: the reason key is unique per item
|
|
377
402
|
// and the INSERT is one atomic WHERE-NOT-EXISTS statement (the economy
|
|
@@ -423,4 +448,6 @@ module.exports = {
|
|
|
423
448
|
getBoardVote,
|
|
424
449
|
insertVoteKarma,
|
|
425
450
|
insertAuthorRatifyKarma,
|
|
451
|
+
getVotesForItem,
|
|
452
|
+
closeBoardItem,
|
|
426
453
|
};
|
|
@@ -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);
|
|
@@ -69,4 +69,47 @@ function completenessScore(fieldValues, opts) {
|
|
|
69
69
|
return scoreFieldValues(fieldValues, opts).score;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
// ── the sub-bar rejection payload (BV1.R09 of goal 1000069, ADR 0175 §4) ────
|
|
73
|
+
//
|
|
74
|
+
// Criterion C2's other half: an idea that never reaches the board (sub-bar,
|
|
75
|
+
// ADR 0175 §5) still returns to its author WITH A REASON THEY CAN ACT ON. The
|
|
76
|
+
// reason is computed, not written: the per-field breakdown above already knows
|
|
77
|
+
// which section carried the least substance, so the weakest section is flagged
|
|
78
|
+
// automatically — same shape as a board objection (reason_md + section_key,
|
|
79
|
+
// government_003's vote payload), so R13's return flow renders one thing.
|
|
80
|
+
//
|
|
81
|
+
// DETERMINISTIC AND DERIVED, NEVER STORED: the scorer is deterministic, so the
|
|
82
|
+
// flag is recomputable from field_values at any time — storing it would be a
|
|
83
|
+
// second copy that drifts. Ties go to TEMPLATE ORDER (the fields array is
|
|
84
|
+
// already in it), so the flag is stable for identical inputs.
|
|
85
|
+
//
|
|
86
|
+
// BAR-AGNOSTIC BY DESIGN: whether a score is sub-bar is decided where the bar
|
|
87
|
+
// lives (economy's IDEA_FULL_PASS_BAR / the board's gate) — this constructs the
|
|
88
|
+
// payload once a caller has decided. Passing an unscorable input returns null,
|
|
89
|
+
// the same "cannot score" posture as everything above.
|
|
90
|
+
|
|
91
|
+
function weakestField(fieldValues, opts) {
|
|
92
|
+
const { fields } = scoreFieldValues(fieldValues, opts);
|
|
93
|
+
if (!fields.length) return null;
|
|
94
|
+
return fields.reduce((worst, f) => (f.ratio < worst.ratio ? f : worst));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// The rejection payload for a sub-bar Full Idea: { section_key, reason_md } in
|
|
98
|
+
// the board-objection shape. The reason names the section by its TEMPLATE LABEL
|
|
99
|
+
// (the words the author saw when writing) and states the shortfall in the
|
|
100
|
+
// scorer's own honest terms — substance against the floor, nothing smarter.
|
|
101
|
+
function weakestSectionPayload(fieldValues, { templateKey = DEFAULT_TEMPLATE_KEY, registry = BUILTIN_TEMPLATES } = {}) {
|
|
102
|
+
const worst = weakestField(fieldValues, { templateKey, registry });
|
|
103
|
+
if (!worst) return null;
|
|
104
|
+
const template = templateFor(templateKey, registry);
|
|
105
|
+
const field = template.fields.find((f) => f.key === worst.key);
|
|
106
|
+
const label = field ? field.label : worst.key;
|
|
107
|
+
return {
|
|
108
|
+
section_key: worst.key,
|
|
109
|
+
reason_md: `Completeness is below the bar, and "${label}" is the thinnest section — `
|
|
110
|
+
+ `${worst.substance} character${worst.substance === 1 ? '' : 's'} of substance against a floor of ${worst.floor}. `
|
|
111
|
+
+ 'Flesh that section out and resubmit; the score is deterministic, so the same words always score the same.',
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
module.exports = { completenessScore, scoreFieldValues, substanceOf, weakestField, weakestSectionPayload };
|
|
@@ -7,7 +7,7 @@ const inbox = require('../inbox');
|
|
|
7
7
|
// Full Idea capture (task 1002919 / BV1.R143): the template validator (R141)
|
|
8
8
|
// and the deterministic scorer (R142) — both pure, both walk the declaration.
|
|
9
9
|
const { validateFieldValues } = require('../templates');
|
|
10
|
-
const { completenessScore } = require('../completeness');
|
|
10
|
+
const { completenessScore, weakestSectionPayload } = require('../completeness');
|
|
11
11
|
const { validateOrRespond, LIMITS, parseId } = api;
|
|
12
12
|
|
|
13
13
|
const INBOX_STATUS_VALUES = ['open', 'promoted', 'discarded', 'merged'];
|
|
@@ -166,6 +166,17 @@ module.exports = function buildInboxRouter() {
|
|
|
166
166
|
});
|
|
167
167
|
if (w && w.opened && w.item) {
|
|
168
168
|
idea.board_window = { item_id: w.item.id, closes_at: w.item.closes_at };
|
|
169
|
+
} else if (w && w.reason === 'below_bar') {
|
|
170
|
+
// R09 (goal 1000069, criterion C2): a sub-bar idea returns to
|
|
171
|
+
// its author WITH A REASON THEY CAN ACT ON — the weakest section
|
|
172
|
+
// auto-flagged from the per-field scores, in the same shape as a
|
|
173
|
+
// board objection. Riding the BOARD'S OWN below_bar answer keeps
|
|
174
|
+
// the bar in exactly one place (board.js); this module never
|
|
175
|
+
// decides what "sub-bar" means. Derived, not stored — the scorer
|
|
176
|
+
// is deterministic, so R13's return flow recomputes the same
|
|
177
|
+
// flag from field_values whenever it needs it.
|
|
178
|
+
const flag = weakestSectionPayload(body.field_values, templateKey ? { templateKey } : undefined);
|
|
179
|
+
if (flag) idea.rejection = flag;
|
|
169
180
|
}
|
|
170
181
|
}
|
|
171
182
|
} catch (boardErr) {
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.227",
|
|
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.227",
|
|
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.227",
|
|
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
|
@@ -877,8 +877,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
877
877
|
// landed since 1.19.223 with no explicit bump. run 32098083321. (task 1002620)
|
|
878
878
|
// 1.19.225 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
879
879
|
// landed since 1.19.224 with no explicit bump. run 32099793591. (task 1002620)
|
|
880
|
+
// 1.19.226 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
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)
|
|
880
884
|
// ---------------------------------------------------------------------------
|
|
881
|
-
const CORE_VERSION = '1.19.
|
|
885
|
+
const CORE_VERSION = '1.19.227'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
882
886
|
|
|
883
887
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
884
888
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -0,0 +1,177 @@
|
|
|
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
|
+
insertAuthorRatifyKarma: async (args) => { karma.push(args); return true; },
|
|
103
|
+
}, async () => {
|
|
104
|
+
const out = await closeItem(openItem(), { cause: 'vote' });
|
|
105
|
+
assert.equal(out.closed, true);
|
|
106
|
+
assert.equal(out.outcome, 'passed');
|
|
107
|
+
assert.deepEqual(out.author_karma, { paid: true, karma_delta: AUTHOR_RATIFY_KARMA_DELTA });
|
|
108
|
+
});
|
|
109
|
+
assert.equal(karma.length, 1);
|
|
110
|
+
assert.equal(karma[0].delta, AUTHOR_RATIFY_KARMA_DELTA);
|
|
111
|
+
|
|
112
|
+
await withDb({
|
|
113
|
+
getVotesForItem: async () => [no('42')],
|
|
114
|
+
closeBoardItem: async ({ itemId, outcome }) => ({ ...openItem(), id: String(itemId), closed_at: 't', outcome }),
|
|
115
|
+
getBoardItemAuthor: async () => { throw new Error('must not be reached — a return pays nobody'); },
|
|
116
|
+
insertAuthorRatifyKarma: async () => { throw new Error('must not be reached'); },
|
|
117
|
+
}, async () => {
|
|
118
|
+
const out = await closeItem(openItem());
|
|
119
|
+
assert.equal(out.outcome, 'returned');
|
|
120
|
+
assert.equal(out.author_karma, null);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test('the losing racer gets already_closed and pays nothing', async () => {
|
|
125
|
+
await withDb({
|
|
126
|
+
getVotesForItem: async () => [yes('42')],
|
|
127
|
+
closeBoardItem: async () => null, // the WHERE closed_at IS NULL conflict path
|
|
128
|
+
insertAuthorRatifyKarma: async () => { throw new Error('must not be reached'); },
|
|
129
|
+
}, async () => {
|
|
130
|
+
assert.deepEqual(await closeItem(openItem()), { closed: false, reason: 'already_closed' });
|
|
131
|
+
});
|
|
132
|
+
assert.deepEqual(await closeItem(openItem({ closed_at: 't' })), { closed: false, reason: 'already_closed' },
|
|
133
|
+
'an already-closed item is refused before any read');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// ── maybeCloseAfterVote: the whole post-vote path ────────────────────────────
|
|
137
|
+
|
|
138
|
+
test('monarchy end to end: the first yes closes the item as passed, author paid', async () => {
|
|
139
|
+
const karma = [];
|
|
140
|
+
await withDb({
|
|
141
|
+
getBoardItemForVote: async () => openItem(),
|
|
142
|
+
getVotesForItem: async () => [yes('42')],
|
|
143
|
+
getActiveBuildersWithRank: async () => [{ builder_id: '42', github_login: 'a', display_name: 'A' }],
|
|
144
|
+
closeBoardItem: async ({ outcome }) => ({ ...openItem(), closed_at: 't', outcome }),
|
|
145
|
+
getBoardItemAuthor: async () => '7',
|
|
146
|
+
insertAuthorRatifyKarma: async (args) => { karma.push(args); return true; },
|
|
147
|
+
}, async () => {
|
|
148
|
+
const out = await maybeCloseAfterVote(11);
|
|
149
|
+
assert.equal(out.closed, true);
|
|
150
|
+
assert.equal(out.outcome, 'passed');
|
|
151
|
+
assert.equal(out.cause, 'vote');
|
|
152
|
+
});
|
|
153
|
+
assert.equal(karma.length, 1);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('consent with turnout outstanding leaves the window open', async () => {
|
|
157
|
+
await withDb({
|
|
158
|
+
getBoardItemForVote: async () => openItem({ constitution: CONSENT }),
|
|
159
|
+
getVotesForItem: async () => [yes('1')],
|
|
160
|
+
getActiveBuildersWithRank: async () => [member(1), member(2)], // 1 of 2 voted
|
|
161
|
+
closeBoardItem: async () => { throw new Error('must not be reached'); },
|
|
162
|
+
}, async () => {
|
|
163
|
+
assert.deepEqual(await maybeCloseAfterVote(11), { closed: false, reason: 'window_still_open' });
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
// ── the structural pins ──────────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
test('the close UPDATE is single-winner in the SQL, and the pass event is NOT emitted here', () => {
|
|
170
|
+
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');
|
|
173
|
+
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');
|
|
177
|
+
});
|
|
@@ -94,5 +94,35 @@ test('the scorer walks the declaration — a different template scores by ITS fi
|
|
|
94
94
|
assert.equal(C.completenessScore(COMPLETE, opts), 0);
|
|
95
95
|
});
|
|
96
96
|
|
|
97
|
+
// ── the sub-bar rejection payload (BV1.R09 of goal 1000069, criterion C2) ───
|
|
98
|
+
|
|
99
|
+
test('the weakest section is the LOWEST ratio, with ties going to template order', () => {
|
|
100
|
+
const values = { ...COMPLETE, big_picture: 'thin', why_matters: 'thin' }; // two equally thin
|
|
101
|
+
const worst = C.weakestField(values);
|
|
102
|
+
assert.equal(worst.key, 'big_picture', 'the earlier of two equal-thinnest fields, by template order');
|
|
103
|
+
// strictly-thinnest wins regardless of position
|
|
104
|
+
assert.equal(C.weakestField({ ...COMPLETE, fit: 'x' }).key, 'fit');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('the rejection payload is board-objection-shaped, names the template LABEL, and states the shortfall', () => {
|
|
108
|
+
const flag = C.weakestSectionPayload({ ...COMPLETE, fit: 'too thin' });
|
|
109
|
+
assert.equal(flag.section_key, 'fit', 'exactly one of the five section keys — the board-vote shape');
|
|
110
|
+
assert.ok(flag.reason_md.includes('How does the Idea Fit'), 'the reason speaks the words the author saw');
|
|
111
|
+
assert.ok(/\d+ characters? of substance against a floor of \d+/.test(flag.reason_md),
|
|
112
|
+
'the shortfall is stated in the scorer\'s own honest terms');
|
|
113
|
+
const field = BASE_TEMPLATE.fields.find((f) => f.key === 'fit');
|
|
114
|
+
assert.ok(flag.reason_md.includes(String(field.substanceMin)), 'the floor named is the template\'s real one');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test('the payload is DERIVED, deterministic, and null on unscorable input — never stored, never throws', () => {
|
|
118
|
+
const values = { ...COMPLETE, purpose: 'meh' };
|
|
119
|
+
assert.deepEqual(C.weakestSectionPayload(values), C.weakestSectionPayload(values),
|
|
120
|
+
'same words, same flag — recomputable whenever R13 needs it, so storing it would only drift');
|
|
121
|
+
for (const bad of [null, undefined, 42, [], 'text']) {
|
|
122
|
+
assert.equal(C.weakestSectionPayload(bad), null, `${String(bad)} cannot be flagged`);
|
|
123
|
+
}
|
|
124
|
+
assert.equal(C.weakestSectionPayload({}, { templateKey: 'no-such-template' }), null);
|
|
125
|
+
});
|
|
126
|
+
|
|
97
127
|
console.log(`\nidea_completeness: ${passed} passed, ${failed} failed`);
|
|
98
128
|
if (failed > 0) process.exit(1);
|