@bongos/core 1.19.224 → 1.19.225

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.
@@ -281,6 +281,123 @@ async function builderHoldsRankActive(builderId, rankKey) {
281
281
  return rows.length > 0;
282
282
  }
283
283
 
284
+ // --- the board WRITES (BV1.R07 of goal 1000069) ------------------------------
285
+
286
+ // Open a window for a subject. The idempotency is STRUCTURAL: the INSERT
287
+ // targets government_003's partial unique index (one OPEN item per subject),
288
+ // so a concurrent double-open inserts exactly one row — the loser gets null and
289
+ // the caller reads the winner back with getOpenBoardItem. closes_at is computed
290
+ // by the DATABASE clock (now() + the window), absolute rather than a duration,
291
+ // so changing the project's window length never moves an already-open deadline;
292
+ // NULL windowMinutes means NO CLOCK (the monarchy configuration — the item
293
+ // closes on the first vote, not on time).
294
+ async function openBoardItem({ subjectType, subjectId, openedBy = null, constitution, windowMinutes = null }) {
295
+ const { rows } = await pool.query(
296
+ `INSERT INTO government_board_items (subject_type, subject_id, opened_by, closes_at, constitution)
297
+ VALUES ($1, $2, $3,
298
+ CASE WHEN $4::int IS NULL THEN NULL ELSE now() + make_interval(mins => $4::int) END,
299
+ $5::jsonb)
300
+ ON CONFLICT (subject_type, subject_id) WHERE closed_at IS NULL DO NOTHING
301
+ RETURNING *`,
302
+ [subjectType, subjectId, openedBy, windowMinutes, JSON.stringify(constitution || {})],
303
+ );
304
+ return rows[0] || null;
305
+ }
306
+
307
+ async function getOpenBoardItem(subjectType, subjectId) {
308
+ const { rows } = await pool.query(
309
+ `SELECT * FROM government_board_items
310
+ WHERE subject_type = $1 AND subject_id = $2 AND closed_at IS NULL
311
+ LIMIT 1`,
312
+ [subjectType, subjectId],
313
+ );
314
+ return rows[0] || null;
315
+ }
316
+
317
+ // --- the vote READS + WRITES (BV1.R08 of goal 1000069) -----------------------
318
+
319
+ // One item, with the expiry computed on the DATABASE clock so the route never
320
+ // compares a Postgres timestamptz against a process clock. NULL closes_at is
321
+ // the no-clock configuration and never expires.
322
+ async function getBoardItemForVote(itemId) {
323
+ const { rows } = await pool.query(
324
+ `SELECT *, (closes_at IS NOT NULL AND closes_at < now()) AS expired
325
+ FROM government_board_items
326
+ WHERE id = $1`,
327
+ [itemId],
328
+ );
329
+ return rows[0] || null;
330
+ }
331
+
332
+ // The anti-self rule's subject: who put this item up. A full_idea's author is
333
+ // the idea's captor (idea_inbox is a shared core table — reading it directly is
334
+ // the sanctioned pattern; the boundary is code imports, not table access). An
335
+ // amendment's author is its proposer, already on the row as opened_by. NULL —
336
+ // an auto-opened item whose idea row is somehow gone — means no self to refuse.
337
+ async function getBoardItemAuthor(item) {
338
+ if (!item) return null;
339
+ if (item.subject_type === 'full_idea') {
340
+ const { rows } = await pool.query(
341
+ `SELECT captured_by FROM idea_inbox WHERE id = $1`,
342
+ [item.subject_id],
343
+ );
344
+ return rows[0] ? rows[0].captured_by : null;
345
+ }
346
+ return item.opened_by ?? null;
347
+ }
348
+
349
+ // The open ballot's one write. Idempotency is STRUCTURAL — UNIQUE(item_id,
350
+ // voter_id); the loser of a double-cast gets null and the caller reads the
351
+ // standing vote back.
352
+ async function insertBoardVote({ itemId, voterId, direction, reasonMd = null, sectionKey = null }) {
353
+ const { rows } = await pool.query(
354
+ `INSERT INTO government_board_votes (item_id, voter_id, direction, reason_md, section_key)
355
+ VALUES ($1, $2, $3, $4, $5)
356
+ ON CONFLICT (item_id, voter_id) DO NOTHING
357
+ RETURNING *`,
358
+ [itemId, voterId, direction, reasonMd, sectionKey],
359
+ );
360
+ return rows[0] || null;
361
+ }
362
+
363
+ async function getBoardVote(itemId, voterId) {
364
+ const { rows } = await pool.query(
365
+ `SELECT * FROM government_board_votes WHERE item_id = $1 AND voter_id = $2`,
366
+ [itemId, voterId],
367
+ );
368
+ return rows[0] || null;
369
+ }
370
+
371
+ // Voter karma (+1 in-window, owner decision 8) — rides the FRESH insert only,
372
+ // so the idempotent re-cast can never double-pay. granted_by NULL: earned from
373
+ // the system by the act, not granted by a person (the governance_002 backfill
374
+ // convention). karma_log is a shared core table; same access rule as above.
375
+ // Author-ratification karma (+3, board.js AUTHOR_RATIFY_KARMA_DELTA) — fired by
376
+ // the R10 close on a passed item. IDEMPOTENT: the reason key is unique per item
377
+ // and the INSERT is one atomic WHERE-NOT-EXISTS statement (the economy
378
+ // credit-lane idiom — karma_log carries no unique constraint, so the predicate
379
+ // IS the guard). Returns whether a row was actually written.
380
+ async function insertAuthorRatifyKarma({ authorId, itemId, delta }) {
381
+ const reason = `board.item.ratified:${itemId}`;
382
+ const { rowCount } = await pool.query(
383
+ `INSERT INTO karma_log (builder_id, delta, reason, granted_by)
384
+ SELECT $1, $2, $3, NULL
385
+ WHERE NOT EXISTS (
386
+ SELECT 1 FROM karma_log WHERE builder_id = $1 AND reason = $3
387
+ )`,
388
+ [authorId, delta, reason],
389
+ );
390
+ return rowCount > 0;
391
+ }
392
+
393
+ async function insertVoteKarma({ voterId, itemId, delta }) {
394
+ await pool.query(
395
+ `INSERT INTO karma_log (builder_id, delta, reason, granted_by)
396
+ VALUES ($1, $2, $3, NULL)`,
397
+ [voterId, delta, `board.vote.cast:${itemId}`],
398
+ );
399
+ }
400
+
284
401
  module.exports = {
285
402
  getPermissionKeysForBuilder,
286
403
  getRankKeysForBuilder,
@@ -298,4 +415,12 @@ module.exports = {
298
415
  unassignRank,
299
416
  getActiveBuildersWithRank,
300
417
  builderHoldsRankActive,
418
+ openBoardItem,
419
+ getOpenBoardItem,
420
+ getBoardItemForVote,
421
+ getBoardItemAuthor,
422
+ insertBoardVote,
423
+ getBoardVote,
424
+ insertVoteKarma,
425
+ insertAuthorRatifyKarma,
301
426
  };
@@ -24,6 +24,7 @@ const catalog = require('../catalog');
24
24
  const db = require('../db');
25
25
  const sessionScopes = require('../session-scopes');
26
26
  const boardMembership = require('../board-membership');
27
+ const board = require('../board');
27
28
 
28
29
  // The port surface. Deliberately narrow: resolution + the catalog reads a gate or
29
30
  // the Archon tab needs. It grows with R98-R101 (principals, session-scopes,
@@ -86,6 +87,10 @@ const governmentPort = {
86
87
  parseMembershipPredicate: boardMembership.parseMembershipPredicate,
87
88
  resolveBoardMembers: boardMembership.resolveBoardMembers,
88
89
  isBoardMember: boardMembership.isBoardMember,
90
+ // R07: the ideas capture path fires this when a Full Idea's grade lands — the
91
+ // bar gate lives INSIDE board.js (a sub-bar idea never reaches the board, and
92
+ // no caller is trusted to pre-filter). Returns, never throws into capture.
93
+ openBoardWindowForIdea: board.openWindowForFullIdea,
89
94
  };
90
95
 
91
96
  function registerSeams() {
@@ -405,6 +410,55 @@ module.exports = function buildGovernmentRouter() {
405
410
  }
406
411
  });
407
412
 
413
+ // rank: perm board.vote.cast — cast a vote on an open board item (R08 of goal
414
+ // 1000069, ADR 0175). The atom FLOORS AT XENOS deliberately (the R04 carry-
415
+ // note): the permission is only the coarse gate, and the real wall is the
416
+ // in-handler membership re-check against the constitution SNAPSHOTTED on the
417
+ // item — do not "tighten" the floor to archon; that would fork authority
418
+ // truth and make widening the franchise a migration instead of a config
419
+ // change. Open ballot, anti-self, idempotent (UNIQUE(item_id, voter_id)),
420
+ // voter karma +1 on a fresh in-window cast. The policy lives in board.js
421
+ // castVote; this handler is the HTTP mapping.
422
+ router.post('/government/board/items/:itemId/votes', api.requireBuilder, api.requirePermission('board.vote.cast'), async (req, res) => {
423
+ const itemId = parseId(req, res, { param: 'itemId', code: 'bad_item_id', positiveInt: true });
424
+ if (itemId === null) return;
425
+ if (validateOrRespond(req, res, {
426
+ direction: { required: true, type: 'string', maxLength: 3 },
427
+ reason_md: { type: 'string', maxLength: 5000 },
428
+ section_key: { type: 'string', maxLength: 32 },
429
+ })) return;
430
+ try {
431
+ const out = await board.castVote({
432
+ itemId,
433
+ voterId: req.builder.id,
434
+ direction: req.body.direction,
435
+ reasonMd: req.body.reason_md ?? null,
436
+ sectionKey: req.body.section_key ?? null,
437
+ });
438
+ if (!out.ok) {
439
+ const status =
440
+ out.reason === 'item_not_found' ? 404 :
441
+ out.reason === 'not_board_member' ? 403 :
442
+ out.reason === 'anti_self_vote' ? 403 :
443
+ out.reason === 'item_closed' ? 409 :
444
+ out.reason === 'vote_window_expired' ? 409 :
445
+ 400; // bad_direction / yes_carries_nothing / objection_needs_reason / objection_needs_section
446
+ return res.fail(out.reason, status, { item_id: itemId });
447
+ }
448
+ // Idempotent re-cast reads back the standing vote — 200, not 201, and no
449
+ // karma line: nothing new was recorded.
450
+ res.status(out.fresh ? 201 : 200).json({
451
+ ok: true,
452
+ vote: out.vote,
453
+ already_voted: !out.fresh,
454
+ ...(out.fresh ? { karma_delta: out.karma_delta } : {}),
455
+ });
456
+ } catch (err) {
457
+ console.error('[gds] POST /government/board/items/:itemId/votes', err);
458
+ res.fail('vote_failed', { status: 500, message: 'internal error' });
459
+ }
460
+ });
461
+
408
462
  return router;
409
463
  };
410
464
 
@@ -17,6 +17,7 @@
17
17
  "ideas.capture"
18
18
  ],
19
19
  "consumes": [
20
- "reward"
20
+ "reward",
21
+ "government"
21
22
  ]
22
23
  }
@@ -146,6 +146,33 @@ module.exports = function buildInboxRouter() {
146
146
  // no warning surfaced — the idea and its grade are intact; the credit
147
147
  // is the only thing missed, and a reconcile/re-grade path can pay it.
148
148
  }
149
+
150
+ // R07 (goal 1000069, ADR 0175 §5): a Full Idea crossing the
151
+ // completeness bar automatically opens its ratification window in the
152
+ // Board Room. Resolved through the `government` port (declared in
153
+ // consumes), never a direct import — and the BAR GATE lives on the
154
+ // government side (board.js refuses a sub-bar score), so this hook
155
+ // fires unconditionally with the stored score and the board decides.
156
+ // FAIL-OPEN like the credit above: the idea and its grade are already
157
+ // stored, and a board hiccup must never lose a capture. The open is
158
+ // idempotent (one open window per subject, structural in the schema),
159
+ // so a retry or re-capture can never open two.
160
+ try {
161
+ const government = api.resolveOptional && api.resolveOptional('government');
162
+ if (government && government.openBoardWindowForIdea) {
163
+ const w = await government.openBoardWindowForIdea({
164
+ ideaId: idea.id,
165
+ completenessScore: row.completeness_score,
166
+ });
167
+ if (w && w.opened && w.item) {
168
+ idea.board_window = { item_id: w.item.id, closes_at: w.item.closes_at };
169
+ }
170
+ }
171
+ } catch (boardErr) {
172
+ console.error('[gds] POST /inbox board-window open failed (idea captured + graded, window skipped):', boardErr);
173
+ // R11's expiry poller sweeps open items, not missed opens; a missed
174
+ // open is recoverable by resubmission (R13) — capture stays intact.
175
+ }
149
176
  } catch (gradeErr) {
150
177
  console.error('[gds] POST /inbox full-grade write failed (idea captured, landing as quick):', gradeErr);
151
178
  grade_warning = 'captured_but_ungraded';
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.224",
3
+ "version": "1.19.225",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.224",
9
+ "version": "1.19.225",
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.224",
3
+ "version": "1.19.225",
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",
@@ -247,6 +247,10 @@ const GOVERNMENT_PINS = {
247
247
  'POST /government/ranks/:rankKey/reset': 'perm:government.manage',
248
248
  'POST /government/assignments': 'perm:government.manage',
249
249
  'DELETE /government/assignments/:builderId/:rankKey': 'perm:government.manage',
250
+ // The Board Room's first route (R08 of goal 1000069 — the R04-carried pin).
251
+ // The XENOS-floored atom is the COARSE gate by design; the real wall is the
252
+ // in-handler membership re-check against the item's snapshotted constitution.
253
+ 'POST /government/board/items/:itemId/votes': 'perm:board.vote.cast',
250
254
  };
251
255
 
252
256
  // Module-scoped privileged-rank pins: a privileged route that lives in a FEATURE
package/src/module-api.js CHANGED
@@ -875,8 +875,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
875
875
  // landed since 1.19.222 with no explicit bump. run 32097491676. (task 1002620)
876
876
  // 1.19.224 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
877
877
  // landed since 1.19.223 with no explicit bump. run 32098083321. (task 1002620)
878
+ // 1.19.225 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
879
+ // landed since 1.19.224 with no explicit bump. run 32099793591. (task 1002620)
878
880
  // ---------------------------------------------------------------------------
879
- const CORE_VERSION = '1.19.224'; // CI auto-patch carrier (ADR 0161); see changelog above
881
+ const CORE_VERSION = '1.19.225'; // CI auto-patch carrier (ADR 0161); see changelog above
880
882
 
881
883
  // A namespaced logger so a module's log lines are attributable + consistent.
882
884
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,247 @@
1
+ // tests/government_board_vote.mjs — cast a vote: open ballot, anti-self, live
2
+ // re-check, karma (BV1.R08 of goal 1000069, ADR 0175 §§4/6/9).
3
+ //
4
+ // DB-free (db monkeypatched, the sibling board tests' idiom) — and deliberately
5
+ // NOT membership-stubbed: castVote runs the REAL board-membership parse against
6
+ // the item's snapshotted constitution, with only the db row lookups stubbed, so
7
+ // the "who may vote is the SNAPSHOT's rule" property is exercised rather than
8
+ // assumed. The invariants:
9
+ //
10
+ // 1. THE SECTION KEYS ARE ONE LIST IN THREE PLACES — board.js's validation,
11
+ // government_003's CHECK, and the ideas template declaration. A module may
12
+ // not require a sibling (ADR 0083), so they are held equal HERE.
13
+ // 2. ANTI-SELF IS SERVER-SIDE AND UNCONDITIONAL — the bright line that keeps
14
+ // ratification from being ceremonial.
15
+ // 3. MEMBERSHIP IS RE-CHECKED LIVE, AGAINST THE SNAPSHOT — a demotion between
16
+ // open and vote refuses the vote; an amendment mid-sitting cannot change
17
+ // who votes on an already-open item.
18
+ // 4. IDEMPOTENT, AND KARMA RIDES ONLY THE FRESH INSERT — a double-cast is one
19
+ // vote and one +1, never two.
20
+ import assert from 'node:assert/strict';
21
+ import { test } from 'node:test';
22
+ import { createRequire } from 'node:module';
23
+ import fs from 'node:fs';
24
+ import path from 'node:path';
25
+ import { fileURLToPath } from 'node:url';
26
+
27
+ const require = createRequire(import.meta.url);
28
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
29
+ const GOV = path.join(ROOT, 'modules', 'government');
30
+ const board = require(path.join(GOV, 'board.js'));
31
+ const db = require(path.join(GOV, 'db.js'));
32
+ const { fieldOrder, templateFor } = require(path.join(ROOT, 'modules', 'ideas', 'templates.js'));
33
+
34
+ const { FULL_IDEA_SECTION_KEYS, VOTE_KARMA_DELTA, AUTHOR_RATIFY_KARMA_DELTA, awardAuthorRatifyKarma, validateVote, castVote } = board;
35
+
36
+ // ── invariant 1: one list, three places ─────────────────────────────────────
37
+
38
+ test('the objection section keys ARE the Full Idea template fields (no second list that drifts)', () => {
39
+ assert.deepEqual([...FULL_IDEA_SECTION_KEYS], fieldOrder(templateFor('base-v1')),
40
+ 'board.js must flag against the same five sections the template declares');
41
+ const migration = fs.readFileSync(
42
+ path.join(GOV, 'migrations', 'government_003_board_tables.sql'), 'utf8');
43
+ for (const key of FULL_IDEA_SECTION_KEYS) {
44
+ assert.ok(migration.includes(`'${key}'`), `the government_003 CHECK must carry '${key}'`);
45
+ }
46
+ });
47
+
48
+ // ── the pure payload validation ──────────────────────────────────────────────
49
+
50
+ test('a yes carries nothing; a no carries a reason AND exactly one section', () => {
51
+ assert.deepEqual(validateVote({ direction: 'yes' }),
52
+ { ok: true, direction: 'yes', reasonMd: null, sectionKey: null });
53
+ assert.equal(validateVote({ direction: 'yes', reasonMd: 'but…' }).reason, 'yes_carries_nothing');
54
+ assert.equal(validateVote({ direction: 'yes', sectionKey: 'fit' }).reason, 'yes_carries_nothing');
55
+
56
+ const no = validateVote({ direction: 'no', reasonMd: ' needs a real fit story ', sectionKey: 'fit' });
57
+ assert.deepEqual(no, { ok: true, direction: 'no', reasonMd: 'needs a real fit story', sectionKey: 'fit' });
58
+ assert.equal(validateVote({ direction: 'no', sectionKey: 'fit' }).reason, 'objection_needs_reason');
59
+ assert.equal(validateVote({ direction: 'no', reasonMd: ' ', sectionKey: 'fit' }).reason, 'objection_needs_reason');
60
+ assert.equal(validateVote({ direction: 'no', reasonMd: 'x' }).reason, 'objection_needs_section');
61
+ assert.equal(validateVote({ direction: 'no', reasonMd: 'x', sectionKey: 'vibes' }).reason, 'objection_needs_section');
62
+ for (const bad of [undefined, null, 'YES', 'abstain', 1]) {
63
+ assert.equal(validateVote({ direction: bad }).reason, 'bad_direction', `${String(bad)} is not a ballot`);
64
+ }
65
+ });
66
+
67
+ // ── castVote against a stubbed db (membership logic REAL) ───────────────────
68
+
69
+ const MONARCHY = { membership: 'rank:archon', pass_rule: 'first_ratifier', window_minutes: null, close_early_on_full_turnout: true };
70
+ const openItem = (over = {}) => ({
71
+ id: '11', subject_type: 'full_idea', subject_id: '7', opened_by: null,
72
+ closed_at: null, expired: false, constitution: MONARCHY, ...over,
73
+ });
74
+
75
+ async function withDb(stubs, fn) {
76
+ const originals = {};
77
+ for (const [k, v] of Object.entries(stubs)) { originals[k] = db[k]; db[k] = v; }
78
+ try { return await fn(); }
79
+ finally { for (const [k, v] of Object.entries(originals)) db[k] = v; }
80
+ }
81
+
82
+ test('the happy path: a member yes is recorded fresh, with its +1 karma', async () => {
83
+ const karma = [];
84
+ let insertArgs = null;
85
+ await withDb({
86
+ getBoardItemForVote: async () => openItem(),
87
+ builderHoldsRankActive: async (builderId, rankKey) =>
88
+ builderId === '42' && rankKey === 'archon', // the REAL membership parse chose 'archon' from the snapshot
89
+ getBoardItemAuthor: async () => '7', // someone else authored the idea
90
+ insertBoardVote: async (args) => { insertArgs = args; return { id: '1', ...args }; },
91
+ insertVoteKarma: async (args) => { karma.push(args); },
92
+ }, async () => {
93
+ const out = await castVote({ itemId: 11, voterId: '42', direction: 'yes' });
94
+ assert.equal(out.ok, true);
95
+ assert.equal(out.fresh, true);
96
+ assert.equal(out.karma_delta, VOTE_KARMA_DELTA);
97
+ });
98
+ assert.deepEqual(insertArgs, { itemId: 11, voterId: '42', direction: 'yes', reasonMd: null, sectionKey: null });
99
+ assert.deepEqual(karma, [{ voterId: '42', itemId: 11, delta: VOTE_KARMA_DELTA }]);
100
+ });
101
+
102
+ test('membership is the SNAPSHOT\'s rule: a rank the snapshot does not name is refused', async () => {
103
+ // The live config could say anything; the item was opened under monarchy, so
104
+ // only an archon-holder votes on it. A metic-holder is refused even though
105
+ // they hold A rank — just not the snapshotted one.
106
+ await withDb({
107
+ getBoardItemForVote: async () => openItem(),
108
+ builderHoldsRankActive: async (builderId, rankKey) => rankKey === 'metic', // holds metic, not archon
109
+ getBoardItemAuthor: async () => { throw new Error('must not be reached'); },
110
+ insertBoardVote: async () => { throw new Error('must not be reached'); },
111
+ }, async () => {
112
+ const out = await castVote({ itemId: 11, voterId: '42', direction: 'yes' });
113
+ assert.deepEqual(out, { ok: false, reason: 'not_board_member' });
114
+ });
115
+ });
116
+
117
+ test('the demotion between open and vote refuses the vote — live re-check, uncached', async () => {
118
+ let demoted = false;
119
+ await withDb({
120
+ getBoardItemForVote: async () => openItem(),
121
+ builderHoldsRankActive: async () => !demoted,
122
+ getBoardItemAuthor: async () => '7',
123
+ insertBoardVote: async (args) => ({ id: '1', ...args }),
124
+ insertVoteKarma: async () => {},
125
+ }, async () => {
126
+ assert.equal((await castVote({ itemId: 11, voterId: '42', direction: 'yes' })).ok, true);
127
+ demoted = true;
128
+ assert.deepEqual(await castVote({ itemId: 11, voterId: '42', direction: 'yes' }),
129
+ { ok: false, reason: 'not_board_member' }, 'off the board on the very next request');
130
+ });
131
+ });
132
+
133
+ test('anti-self: the author cannot vote on their own item — number/string ids compare equal', async () => {
134
+ await withDb({
135
+ getBoardItemForVote: async () => openItem(),
136
+ builderHoldsRankActive: async () => true,
137
+ getBoardItemAuthor: async () => 42, // Postgres hands back a number; the voter carries a string
138
+ insertBoardVote: async () => { throw new Error('must not be reached'); },
139
+ }, async () => {
140
+ const out = await castVote({ itemId: 11, voterId: '42', direction: 'yes' });
141
+ assert.deepEqual(out, { ok: false, reason: 'anti_self_vote' });
142
+ });
143
+ });
144
+
145
+ test('closed and expired items refuse a vote, each with its own name', async () => {
146
+ await withDb({
147
+ getBoardItemForVote: async () => openItem({ closed_at: 't', outcome: 'passed' }),
148
+ }, async () => {
149
+ assert.deepEqual(await castVote({ itemId: 11, voterId: '42', direction: 'yes' }),
150
+ { ok: false, reason: 'item_closed' });
151
+ });
152
+ await withDb({
153
+ getBoardItemForVote: async () => openItem({ expired: true }),
154
+ }, async () => {
155
+ // The gap between expiry and the R11 sweep: a vote after time never lands.
156
+ assert.deepEqual(await castVote({ itemId: 11, voterId: '42', direction: 'yes' }),
157
+ { ok: false, reason: 'vote_window_expired' });
158
+ });
159
+ await withDb({ getBoardItemForVote: async () => null }, async () => {
160
+ assert.deepEqual(await castVote({ itemId: 999, voterId: '42', direction: 'yes' }),
161
+ { ok: false, reason: 'item_not_found' });
162
+ });
163
+ });
164
+
165
+ test('idempotent: a double-cast is ONE vote and ONE karma line', async () => {
166
+ const karma = [];
167
+ const standing = { id: '1', item_id: '11', voter_id: '42', direction: 'yes' };
168
+ let hasVote = false;
169
+ await withDb({
170
+ getBoardItemForVote: async () => openItem(),
171
+ builderHoldsRankActive: async () => true,
172
+ getBoardItemAuthor: async () => '7',
173
+ insertBoardVote: async (args) => (hasVote ? null : (hasVote = true, { ...standing, ...args })),
174
+ getBoardVote: async () => standing,
175
+ insertVoteKarma: async (args) => { karma.push(args); },
176
+ }, async () => {
177
+ const first = await castVote({ itemId: 11, voterId: '42', direction: 'yes' });
178
+ const second = await castVote({ itemId: 11, voterId: '42', direction: 'yes' });
179
+ assert.equal(first.fresh, true);
180
+ assert.equal(second.fresh, false);
181
+ assert.deepEqual(second.vote, standing, 'the re-cast returns the standing vote');
182
+ assert.equal(karma.length, 1, 'karma rode the fresh insert only');
183
+ });
184
+ });
185
+
186
+ test('an invalid payload is refused before ANY db read', async () => {
187
+ await withDb({
188
+ getBoardItemForVote: async () => { throw new Error('must not be reached'); },
189
+ }, async () => {
190
+ assert.equal((await castVote({ itemId: 11, voterId: '42', direction: 'no', reasonMd: 'x' })).reason,
191
+ 'objection_needs_section');
192
+ assert.equal((await castVote({ itemId: 'nope', voterId: '42', direction: 'yes' })).reason,
193
+ 'item_not_found');
194
+ });
195
+ });
196
+
197
+ // ── the structural pins ──────────────────────────────────────────────────────
198
+
199
+ test('the vote INSERT targets UNIQUE(item_id, voter_id) — idempotency is the schema', () => {
200
+ const src = fs.readFileSync(path.join(GOV, 'db.js'), 'utf8');
201
+ const block = src.slice(src.indexOf('async function insertBoardVote'));
202
+ assert.match(block, /ON CONFLICT \(item_id, voter_id\) DO NOTHING/);
203
+ });
204
+
205
+ test('the route pin exists and names the XENOS-floored atom (the R04-carried pin)', () => {
206
+ const rrc = require(path.join(ROOT, 'src', 'bongos', 'route-rank-check.js'));
207
+ assert.equal(rrc.MODULE_EXPECTED_RANKS.government['POST /government/board/items/:itemId/votes'],
208
+ `${rrc.PERM_PIN_PREFIX}board.vote.cast`,
209
+ 'the coarse gate is board.vote.cast; membership is the in-handler wall');
210
+ });
211
+
212
+ // ── author karma on ratification (declared here, fired by R10's close) ───────
213
+
214
+ test('the author-ratification karma mirrors KARMA_RATIFY_DELTA (+3, not the stale +5)', () => {
215
+ const { KARMA_RATIFY_DELTA } = require(path.join(ROOT, 'modules', 'ideas', 'inbox.js'));
216
+ assert.equal(AUTHOR_RATIFY_KARMA_DELTA, KARMA_RATIFY_DELTA,
217
+ 'board ratification pays what criterion-proposal ratification pays — one number, held equal here');
218
+ assert.equal(AUTHOR_RATIFY_KARMA_DELTA, 3, 'V3.R28 tuned this down from +5; do not copy the stale docs');
219
+ });
220
+
221
+ test('awardAuthorRatifyKarma pays once per item, and nobody when there is no author', async () => {
222
+ const paid = [];
223
+ await withDb({
224
+ getBoardItemAuthor: async () => '7',
225
+ insertAuthorRatifyKarma: async (args) => { paid.push(args); return paid.length === 1; }, // second call: reason exists
226
+ }, async () => {
227
+ const first = await awardAuthorRatifyKarma({ id: '11', subject_type: 'full_idea', subject_id: '7' });
228
+ const second = await awardAuthorRatifyKarma({ id: '11', subject_type: 'full_idea', subject_id: '7' });
229
+ assert.deepEqual(first, { paid: true, karma_delta: AUTHOR_RATIFY_KARMA_DELTA });
230
+ assert.deepEqual(second, { paid: false, reason: 'already_paid' }, 'a re-driven close never double-pays');
231
+ });
232
+ assert.deepEqual(paid[0], { authorId: '7', itemId: '11', delta: AUTHOR_RATIFY_KARMA_DELTA });
233
+
234
+ await withDb({
235
+ getBoardItemAuthor: async () => null,
236
+ insertAuthorRatifyKarma: async () => { throw new Error('must not be reached'); },
237
+ }, async () => {
238
+ assert.deepEqual(await awardAuthorRatifyKarma({ id: '12' }), { paid: false, reason: 'no_author' });
239
+ });
240
+ });
241
+
242
+ test('the ratify-karma INSERT is one atomic WHERE-NOT-EXISTS statement (idempotency in the SQL)', () => {
243
+ const src = fs.readFileSync(path.join(GOV, 'db.js'), 'utf8');
244
+ const block = src.slice(src.indexOf('async function insertAuthorRatifyKarma'), src.indexOf('async function insertVoteKarma'));
245
+ assert.match(block, /WHERE NOT EXISTS/, 'the predicate is the guard — karma_log has no unique constraint');
246
+ assert.match(block, /board\.item\.ratified:/, 'the per-item reason key is the idempotency key');
247
+ });