@bongos/core 1.19.226 → 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 +18 -13
- package/modules/government/board.js +85 -1
- package/modules/government/db.js +27 -0
- package/modules/government/routes/government.js +14 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +3 -1
- package/tests/government_board_close.mjs +177 -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",
|
|
@@ -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",
|
|
@@ -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);
|
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
|
@@ -879,8 +879,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
879
879
|
// landed since 1.19.224 with no explicit bump. run 32099793591. (task 1002620)
|
|
880
880
|
// 1.19.226 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
881
881
|
// landed since 1.19.225 with no explicit bump. run 32100543772. (task 1002620)
|
|
882
|
+
// 1.19.227 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
883
|
+
// landed since 1.19.226 with no explicit bump. run 32101041951. (task 1002620)
|
|
882
884
|
// ---------------------------------------------------------------------------
|
|
883
|
-
const CORE_VERSION = '1.19.
|
|
885
|
+
const CORE_VERSION = '1.19.227'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
884
886
|
|
|
885
887
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
886
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
|
+
});
|