@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.
- package/.bongos-core.json +39 -24
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +82 -2
- package/docs/api-reference.md +3 -2
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/government/board.js +211 -0
- package/modules/government/db.js +125 -0
- package/modules/government/routes/government.js +54 -0
- package/modules/ideas/module.json +2 -1
- package/modules/ideas/routes/inbox.js +27 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/route-rank-check.js +4 -0
- package/src/module-api.js +3 -1
- package/tests/government_board_vote.mjs +247 -0
- package/tests/government_board_window.mjs +144 -0
- package/tests/government_routes.mjs +26 -5
package/modules/government/db.js
CHANGED
|
@@ -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
|
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
});
|