@bongos/core 1.19.231 → 1.19.233
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 +47 -27
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +6 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +123 -3
- package/docs/api-reference.md +4 -2
- package/modules/government/board.js +178 -6
- package/modules/government/config.js +31 -0
- package/modules/government/db.js +99 -0
- package/modules/government/migrations/government_007_amendments.sql +25 -0
- package/modules/government/migrations/government_008_closed_items_index.sql +17 -0
- package/modules/government/routes/government.js +55 -0
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/government.js +122 -19
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/route-rank-check.js +6 -0
- package/src/bongos/serve-internal.js +8 -1
- package/src/module-api.js +5 -1
- package/tests/government_board_amendment.mjs +226 -0
- package/tests/government_board_view.mjs +129 -0
- package/tests/government_seed.mjs +2 -0
- package/tests/hall_page_gate_map.mjs +6 -2
|
@@ -42,8 +42,13 @@
|
|
|
42
42
|
|
|
43
43
|
const api = require('../../src/module-api');
|
|
44
44
|
const db = require('./db');
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
// config is required as a NAMESPACE so applyBoardAmendment resolves by property
|
|
46
|
+
// lookup at CALL time — a test can redirect the instance-pack write to a temp
|
|
47
|
+
// file (the reward-port delegation lesson: a destructured binding is frozen at
|
|
48
|
+
// load and un-patchable).
|
|
49
|
+
const governmentConfig = require('./config');
|
|
50
|
+
const { loadGovernmentConfig, PASS_RULES, MAX_WINDOW_MINUTES } = governmentConfig;
|
|
51
|
+
const { isBoardMember, resolveBoardMembers, parseMembershipPredicate } = require('./board-membership');
|
|
47
52
|
|
|
48
53
|
// ADR 0175 §5: the completeness bar a Full Idea must clear to reach the board.
|
|
49
54
|
// Held equal to economy's IDEA_FULL_PASS_BAR by test — see the header.
|
|
@@ -180,9 +185,19 @@ async function castVote({ itemId, voterId, direction, reasonMd = null, sectionKe
|
|
|
180
185
|
const constitution = item.constitution || {};
|
|
181
186
|
if (!(await isBoardMember(voterId, constitution))) return { ok: false, reason: 'not_board_member' };
|
|
182
187
|
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
188
|
+
// ANTI-SELF IS A FULL-IDEA RULE, NOT AN AMENDMENT RULE (R14, ADR 0175 §§7/11).
|
|
189
|
+
// The bright line exists because an idea's author GAINS from ratification
|
|
190
|
+
// (credits, legitimacy) — ceremonial self-ratification is what it prevents.
|
|
191
|
+
// An amendment pays nothing, and §7 explicitly blesses the monarchy act:
|
|
192
|
+
// under a one-Archon board with no clock, the proposer must be able to
|
|
193
|
+
// ratify their own amendment or the subject is dead on arrival — and adding
|
|
194
|
+
// a special escape for that case is the bootstrap exception §7 forbids.
|
|
195
|
+
// Skipping anti-self FOR AMENDMENTS is the rule that needs no exceptions.
|
|
196
|
+
if (item.subject_type === 'full_idea') {
|
|
197
|
+
const authorId = await db.getBoardItemAuthor(item);
|
|
198
|
+
if (authorId != null && String(authorId) === String(voterId)) {
|
|
199
|
+
return { ok: false, reason: 'anti_self_vote' };
|
|
200
|
+
}
|
|
186
201
|
}
|
|
187
202
|
|
|
188
203
|
const inserted = await db.insertBoardVote({
|
|
@@ -326,6 +341,24 @@ async function closeItem(item, { cause = 'vote' } = {}) {
|
|
|
326
341
|
|
|
327
342
|
const authorKarma = outcome === 'passed' ? await awardAuthorRatifyKarma(closed) : null;
|
|
328
343
|
|
|
344
|
+
// R14: a PASSED amendment becomes the live constitution — applied AFTER the
|
|
345
|
+
// close commits (the board's decision is the durable record), loudly on
|
|
346
|
+
// failure, never silently absorbed. The apply is re-drivable from the passed
|
|
347
|
+
// item + amendment rows. A returned amendment applies nothing.
|
|
348
|
+
let appliedConstitution = null;
|
|
349
|
+
if (outcome === 'passed' && closed.subject_type === 'constitutional_amendment') {
|
|
350
|
+
try {
|
|
351
|
+
const amendment = await db.getAmendment(closed.subject_id);
|
|
352
|
+
if (amendment) {
|
|
353
|
+
appliedConstitution = governmentConfig.applyBoardAmendment(amendment.proposed);
|
|
354
|
+
} else {
|
|
355
|
+
console.error(`[government] passed amendment item ${closed.id} names amendment ${closed.subject_id}, which does not exist — NOT applied`);
|
|
356
|
+
}
|
|
357
|
+
} catch (applyErr) {
|
|
358
|
+
console.error(`[government] amendment ${closed.subject_id} ratified but NOT applied (re-drivable from the passed item): ${applyErr && applyErr.message}`);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
329
362
|
if (outcome === 'passed') {
|
|
330
363
|
const payload = passPayload(closed, { authorId, completenessScore, passedAt: closed.closed_at });
|
|
331
364
|
try {
|
|
@@ -333,7 +366,11 @@ async function closeItem(item, { cause = 'vote' } = {}) {
|
|
|
333
366
|
} catch (_) { /* emitAsync isolates listener errors; nothing that moves credits subscribes */ }
|
|
334
367
|
}
|
|
335
368
|
|
|
336
|
-
return {
|
|
369
|
+
return {
|
|
370
|
+
closed: true, item: closed, outcome, cause, author_karma: authorKarma,
|
|
371
|
+
...(credit ? { credit } : {}),
|
|
372
|
+
...(appliedConstitution ? { applied_constitution: appliedConstitution } : {}),
|
|
373
|
+
};
|
|
337
374
|
}
|
|
338
375
|
|
|
339
376
|
// The post-vote hook R08's route fires after a FRESH cast. Reads everything it
|
|
@@ -351,6 +388,138 @@ async function maybeCloseAfterVote(itemId) {
|
|
|
351
388
|
return closeItem(item, { cause: 'vote' });
|
|
352
389
|
}
|
|
353
390
|
|
|
391
|
+
// ── proposing an amendment (BV1.R14 of goal 1000069, ADR 0175 §7) ────────────
|
|
392
|
+
//
|
|
393
|
+
// A proposal is validated STRICTLY, not sanitized. R05's sanitizer falls back
|
|
394
|
+
// field-by-field because a config FILE must boot sane no matter what is in it;
|
|
395
|
+
// a PROPOSAL is different — silently "correcting" it would put text before the
|
|
396
|
+
// board that the proposer never wrote, and the board would ratify a lie. Every
|
|
397
|
+
// field is required (a constitution is ratified whole, never by diff) and every
|
|
398
|
+
// invalid field is a named refusal.
|
|
399
|
+
function validateProposedConstitution(raw) {
|
|
400
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
401
|
+
return { ok: false, reason: 'proposal_not_an_object' };
|
|
402
|
+
}
|
|
403
|
+
const known = ['membership', 'pass_rule', 'window_minutes', 'close_early_on_full_turnout'];
|
|
404
|
+
for (const k of Object.keys(raw)) {
|
|
405
|
+
if (!known.includes(k)) return { ok: false, reason: 'unknown_field', field: k };
|
|
406
|
+
}
|
|
407
|
+
if (!parseMembershipPredicate(raw.membership)) {
|
|
408
|
+
return { ok: false, reason: 'bad_membership', expected: "the predicate grammar rank:<key>" };
|
|
409
|
+
}
|
|
410
|
+
if (!PASS_RULES.includes(raw.pass_rule)) {
|
|
411
|
+
return { ok: false, reason: 'bad_pass_rule', expected: PASS_RULES };
|
|
412
|
+
}
|
|
413
|
+
const w = raw.window_minutes;
|
|
414
|
+
if (!(w === null || (Number.isInteger(w) && w > 0 && w <= MAX_WINDOW_MINUTES))) {
|
|
415
|
+
return { ok: false, reason: 'bad_window_minutes', expected: `null (no clock) or an integer 1..${MAX_WINDOW_MINUTES}` };
|
|
416
|
+
}
|
|
417
|
+
if (typeof raw.close_early_on_full_turnout !== 'boolean') {
|
|
418
|
+
return { ok: false, reason: 'bad_close_early', expected: 'boolean' };
|
|
419
|
+
}
|
|
420
|
+
return {
|
|
421
|
+
ok: true,
|
|
422
|
+
proposal: {
|
|
423
|
+
membership: raw.membership,
|
|
424
|
+
pass_rule: raw.pass_rule,
|
|
425
|
+
window_minutes: w,
|
|
426
|
+
close_early_on_full_turnout: raw.close_early_on_full_turnout,
|
|
427
|
+
},
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// File the amendment and open its window IN THE SAME BREATH — a proposal that
|
|
432
|
+
// exists but was never put to the board is a state nothing can act on. The
|
|
433
|
+
// window runs under the constitution IN FORCE (snapshotted, like every item):
|
|
434
|
+
// under monarchy that is first_ratifier with no clock, which §7 resolves to
|
|
435
|
+
// "the Archon ratifies" — including the Archon's own proposal (see castVote's
|
|
436
|
+
// anti-self note). Concurrent amendment windows are legal like any concurrent
|
|
437
|
+
// windows; applies land in close order and the last close wins, deterministic
|
|
438
|
+
// because applyBoardAmendment rewrites the whole block.
|
|
439
|
+
async function proposeAmendment({ proposal, rationaleMd = null, proposedBy } = {}) {
|
|
440
|
+
const v = validateProposedConstitution(proposal);
|
|
441
|
+
if (!v.ok) return { ok: false, ...v };
|
|
442
|
+
if (proposedBy == null || proposedBy === '') return { ok: false, reason: 'missing_proposer' };
|
|
443
|
+
const amendment = await db.createAmendment({ proposed: v.proposal, rationaleMd, proposedBy });
|
|
444
|
+
const { board } = loadGovernmentConfig();
|
|
445
|
+
const item = await db.openBoardItem({
|
|
446
|
+
subjectType: 'constitutional_amendment',
|
|
447
|
+
subjectId: amendment.id,
|
|
448
|
+
openedBy: proposedBy, // an amendment HAS a human opener, unlike a grade-opened idea
|
|
449
|
+
constitution: board,
|
|
450
|
+
windowMinutes: board.window_minutes,
|
|
451
|
+
});
|
|
452
|
+
// A fresh amendment id can never collide with the one-open-per-subject index.
|
|
453
|
+
return { ok: true, amendment, item };
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ── the Board Room view (BV1.R16 of goal 1000069, ADR 0175 §9) ──────────────
|
|
457
|
+
//
|
|
458
|
+
// One assembled read for the room: open items with their clock, the OPEN
|
|
459
|
+
// BALLOT (people, directions, timestamps, objections), and the bounded closed
|
|
460
|
+
// history — enriched with what each subject IS (an idea's title, an
|
|
461
|
+
// amendment's proposed constitution). Deliberately a VIEW, not a policy
|
|
462
|
+
// surface: nothing here decides anything, and the payload carries the same
|
|
463
|
+
// constitution SNAPSHOT the deciding paths use, so the room describes the rule
|
|
464
|
+
// each item actually runs under. `remaining_seconds` comes from the database
|
|
465
|
+
// clock per row — the client renders a countdown, it never does clock math.
|
|
466
|
+
//
|
|
467
|
+
// The subject AUTHOR rides along (an idea's captor / an amendment's proposer)
|
|
468
|
+
// so R17's vote UI can grey out the anti-self case for ideas without a second
|
|
469
|
+
// round trip — the SERVER still refuses regardless (castVote), this is only
|
|
470
|
+
// the courtesy rendering.
|
|
471
|
+
async function boardRoomView({ closedLimit = 20 } = {}) {
|
|
472
|
+
const [open, closed] = await Promise.all([
|
|
473
|
+
db.listOpenBoardItems(),
|
|
474
|
+
db.listClosedBoardItems(closedLimit),
|
|
475
|
+
]);
|
|
476
|
+
const all = [...open, ...closed];
|
|
477
|
+
const votes = await db.getVotesForItems(all.map((i) => i.id));
|
|
478
|
+
const ideaIds = [...new Set(all.filter((i) => i.subject_type === 'full_idea').map((i) => i.subject_id))];
|
|
479
|
+
const amendmentIds = [...new Set(all.filter((i) => i.subject_type === 'constitutional_amendment').map((i) => i.subject_id))];
|
|
480
|
+
const [ideas, amendments] = await Promise.all([
|
|
481
|
+
db.getIdeaSubjects(ideaIds),
|
|
482
|
+
db.getAmendmentSubjects(amendmentIds),
|
|
483
|
+
]);
|
|
484
|
+
const ideaById = new Map(ideas.map((r) => [String(r.id), r]));
|
|
485
|
+
const amendmentById = new Map(amendments.map((r) => [String(r.id), r]));
|
|
486
|
+
const votesByItem = new Map();
|
|
487
|
+
for (const v of votes) {
|
|
488
|
+
const key = String(v.item_id);
|
|
489
|
+
if (!votesByItem.has(key)) votesByItem.set(key, []);
|
|
490
|
+
votesByItem.get(key).push({
|
|
491
|
+
voter_id: String(v.voter_id),
|
|
492
|
+
github_login: v.github_login,
|
|
493
|
+
display_name: v.display_name,
|
|
494
|
+
direction: v.direction,
|
|
495
|
+
reason_md: v.reason_md,
|
|
496
|
+
section_key: v.section_key,
|
|
497
|
+
cast_at: v.cast_at,
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
const project = (item) => {
|
|
501
|
+
const subject = item.subject_type === 'full_idea'
|
|
502
|
+
? (() => { const r = ideaById.get(String(item.subject_id)); return r ? { title: r.title, author_id: String(r.captured_by) } : null; })()
|
|
503
|
+
: (() => { const r = amendmentById.get(String(item.subject_id)); return r ? { proposed: r.proposed, rationale_md: r.rationale_md, author_id: r.proposed_by == null ? null : String(r.proposed_by) } : null; })();
|
|
504
|
+
return {
|
|
505
|
+
id: String(item.id),
|
|
506
|
+
subject_type: item.subject_type,
|
|
507
|
+
subject_id: String(item.subject_id),
|
|
508
|
+
subject, // null when the subject row is gone — the room says so honestly
|
|
509
|
+
opened_by: item.opened_by == null ? null : String(item.opened_by),
|
|
510
|
+
opened_at: item.opened_at,
|
|
511
|
+
closes_at: item.closes_at,
|
|
512
|
+
remaining_seconds: item.remaining_seconds ?? null,
|
|
513
|
+
expired: item.expired === true,
|
|
514
|
+
closed_at: item.closed_at || null,
|
|
515
|
+
outcome: item.outcome || null,
|
|
516
|
+
constitution: item.constitution || null,
|
|
517
|
+
votes: votesByItem.get(String(item.id)) || [],
|
|
518
|
+
};
|
|
519
|
+
};
|
|
520
|
+
return { open: open.map(project), closed: closed.map(project) };
|
|
521
|
+
}
|
|
522
|
+
|
|
354
523
|
// ── R13's read: the feedback a returned idea carries back to its author ─────
|
|
355
524
|
//
|
|
356
525
|
// One reasoned objection is what returns an idea (§3), so the feedback IS that
|
|
@@ -389,4 +558,7 @@ module.exports = {
|
|
|
389
558
|
closeItem,
|
|
390
559
|
maybeCloseAfterVote,
|
|
391
560
|
latestRejectionForIdea,
|
|
561
|
+
validateProposedConstitution,
|
|
562
|
+
proposeAmendment,
|
|
563
|
+
boardRoomView,
|
|
392
564
|
};
|
|
@@ -256,11 +256,42 @@ function loadGovernmentConfig({ neutralPath = NEUTRAL_PATH, instancePath = INSTA
|
|
|
256
256
|
});
|
|
257
257
|
}
|
|
258
258
|
|
|
259
|
+
// ── applying a RATIFIED amendment (BV1.R14 of goal 1000069, ADR 0175 §7) ────
|
|
260
|
+
//
|
|
261
|
+
// A passed amendment becomes the live constitution by writing the INSTANCE
|
|
262
|
+
// pack's board block — the same file a human editing the constitution would
|
|
263
|
+
// touch, so there is ONE resolution path (env → instance → neutral) and R05's
|
|
264
|
+
// precedence story stays true. The write is atomic (tmp + rename), preserves
|
|
265
|
+
// everything else in the instance pack, and re-runs sanitizeBoard as a belt —
|
|
266
|
+
// the proposal was strictly validated at propose time, but a file on disk must
|
|
267
|
+
// never hold a shape the resolver would have to guess at.
|
|
268
|
+
//
|
|
269
|
+
// WHY A FILE WRITE AND NOT A DB ROW: R05 made the constitution config-resolved
|
|
270
|
+
// on purpose (the board block is a RULE SHAPE, not authority — who holds the
|
|
271
|
+
// governing rank stays a DB row). Moving the live value into the DB on
|
|
272
|
+
// ratification would fork the resolution into two paths, the exact thing the
|
|
273
|
+
// board-snapshot design avoids. The instance pack is host-owned and untracked,
|
|
274
|
+
// so a deploy pull never clobbers it.
|
|
275
|
+
//
|
|
276
|
+
// The caller (board.js closeItem) runs this AFTER the close commits — the
|
|
277
|
+
// board's decision is the durable record; a failed apply is logged loudly and
|
|
278
|
+
// re-appliable from the passed item + amendment rows, never silently absorbed.
|
|
279
|
+
function applyBoardAmendment(proposed, { instancePath = INSTANCE_PATH } = {}) {
|
|
280
|
+
const instance = readJson(instancePath) || {};
|
|
281
|
+
const next = { ...instance, board: { ...sanitizeBoard(proposed) } };
|
|
282
|
+
const json = JSON.stringify(next, null, 2) + '\n';
|
|
283
|
+
const tmp = `${instancePath}.tmp-${process.pid}`;
|
|
284
|
+
fs.writeFileSync(tmp, json);
|
|
285
|
+
fs.renameSync(tmp, instancePath);
|
|
286
|
+
return next.board;
|
|
287
|
+
}
|
|
288
|
+
|
|
259
289
|
module.exports = {
|
|
260
290
|
resolveGovernmentConfig,
|
|
261
291
|
loadGovernmentConfig,
|
|
262
292
|
grantableOnly,
|
|
263
293
|
sanitizeBoard,
|
|
294
|
+
applyBoardAmendment,
|
|
264
295
|
BOARD_DEFAULTS,
|
|
265
296
|
PASS_RULES,
|
|
266
297
|
MAX_WINDOW_MINUTES,
|
package/modules/government/db.js
CHANGED
|
@@ -437,6 +437,98 @@ async function getIdeaCompleteness(ideaId) {
|
|
|
437
437
|
return rows[0] ? rows[0].completeness_score : null;
|
|
438
438
|
}
|
|
439
439
|
|
|
440
|
+
// --- the Board Room reads (BV1.R16 of goal 1000069) --------------------------
|
|
441
|
+
//
|
|
442
|
+
// The room's view: open items with their clock, the OPEN BALLOT (voter
|
|
443
|
+
// identity, direction, timestamp, objection), and the closed history. Remaining
|
|
444
|
+
// time is computed by the DATABASE clock per row (the same posture as expiry),
|
|
445
|
+
// so the client never does clock math against its own skew. Concurrent windows
|
|
446
|
+
// are normal — the reads are lists, ordered.
|
|
447
|
+
|
|
448
|
+
async function listOpenBoardItems() {
|
|
449
|
+
const { rows } = await pool.query(
|
|
450
|
+
`SELECT *,
|
|
451
|
+
(closes_at IS NOT NULL AND closes_at < now()) AS expired,
|
|
452
|
+
CASE WHEN closes_at IS NULL THEN NULL
|
|
453
|
+
ELSE GREATEST(0, EXTRACT(EPOCH FROM (closes_at - now()))::int) END AS remaining_seconds
|
|
454
|
+
FROM government_board_items
|
|
455
|
+
WHERE closed_at IS NULL
|
|
456
|
+
ORDER BY opened_at DESC`,
|
|
457
|
+
);
|
|
458
|
+
return rows;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// Bounded closed history, newest close first. Served by government_008's
|
|
462
|
+
// closed-items index — the closed set grows forever (resubmission is
|
|
463
|
+
// unbounded), and this runs on every Board Room page load.
|
|
464
|
+
async function listClosedBoardItems(limit = 20) {
|
|
465
|
+
const { rows } = await pool.query(
|
|
466
|
+
`SELECT * FROM government_board_items
|
|
467
|
+
WHERE closed_at IS NOT NULL
|
|
468
|
+
ORDER BY closed_at DESC
|
|
469
|
+
LIMIT $1`,
|
|
470
|
+
[limit],
|
|
471
|
+
);
|
|
472
|
+
return rows;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// The open ballot for a SET of items in one query — voter identity joined so
|
|
476
|
+
// the room shows people, not ids (§9: a board whose votes are invisible
|
|
477
|
+
// constrains nobody).
|
|
478
|
+
async function getVotesForItems(itemIds) {
|
|
479
|
+
if (!itemIds || !itemIds.length) return [];
|
|
480
|
+
const { rows } = await pool.query(
|
|
481
|
+
`SELECT v.*, b.github_login, b.display_name
|
|
482
|
+
FROM government_board_votes v
|
|
483
|
+
JOIN builders b ON b.id = v.voter_id
|
|
484
|
+
WHERE v.item_id = ANY($1::bigint[])
|
|
485
|
+
ORDER BY v.cast_at, v.id`,
|
|
486
|
+
[itemIds],
|
|
487
|
+
);
|
|
488
|
+
return rows;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Subject enrichment, one query per subject family. Shared-table reads (the
|
|
492
|
+
// boundary is code imports, not table access) — a full_idea shows its title,
|
|
493
|
+
// an amendment its proposed block + rationale.
|
|
494
|
+
async function getIdeaSubjects(ideaIds) {
|
|
495
|
+
if (!ideaIds || !ideaIds.length) return [];
|
|
496
|
+
const { rows } = await pool.query(
|
|
497
|
+
`SELECT id, title, captured_by FROM idea_inbox WHERE id = ANY($1::bigint[])`,
|
|
498
|
+
[ideaIds],
|
|
499
|
+
);
|
|
500
|
+
return rows;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
async function getAmendmentSubjects(amendmentIds) {
|
|
504
|
+
if (!amendmentIds || !amendmentIds.length) return [];
|
|
505
|
+
const { rows } = await pool.query(
|
|
506
|
+
`SELECT id, proposed, rationale_md, proposed_by FROM government_amendments WHERE id = ANY($1::bigint[])`,
|
|
507
|
+
[amendmentIds],
|
|
508
|
+
);
|
|
509
|
+
return rows;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// --- amendments (BV1.R14 of goal 1000069) ------------------------------------
|
|
513
|
+
|
|
514
|
+
async function createAmendment({ proposed, rationaleMd = null, proposedBy }) {
|
|
515
|
+
const { rows } = await pool.query(
|
|
516
|
+
`INSERT INTO government_amendments (proposed, rationale_md, proposed_by)
|
|
517
|
+
VALUES ($1::jsonb, $2, $3)
|
|
518
|
+
RETURNING *`,
|
|
519
|
+
[JSON.stringify(proposed), rationaleMd, proposedBy],
|
|
520
|
+
);
|
|
521
|
+
return rows[0];
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
async function getAmendment(id) {
|
|
525
|
+
const { rows } = await pool.query(
|
|
526
|
+
`SELECT * FROM government_amendments WHERE id = $1`,
|
|
527
|
+
[id],
|
|
528
|
+
);
|
|
529
|
+
return rows[0] || null;
|
|
530
|
+
}
|
|
531
|
+
|
|
440
532
|
// R13's read: the most recent RETURNED item for a subject, carrying its
|
|
441
533
|
// objection (the single 'no' vote — one reasoned objection is what returns an
|
|
442
534
|
// idea, ADR 0175 §3). LEFT JOIN because a returned-by-expiry item could in
|
|
@@ -531,4 +623,11 @@ module.exports = {
|
|
|
531
623
|
getIdeaCompleteness,
|
|
532
624
|
getExpiredOpenBoardItems,
|
|
533
625
|
getLatestReturnedItemWithObjection,
|
|
626
|
+
createAmendment,
|
|
627
|
+
getAmendment,
|
|
628
|
+
listOpenBoardItems,
|
|
629
|
+
listClosedBoardItems,
|
|
630
|
+
getVotesForItems,
|
|
631
|
+
getIdeaSubjects,
|
|
632
|
+
getAmendmentSubjects,
|
|
534
633
|
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
-- government_007_amendments.sql — the board's second subject gets a home
|
|
2
|
+
-- (task 1003027 / R14 of goal 1000069, ADR 0175 §7).
|
|
3
|
+
--
|
|
4
|
+
-- A constitutional amendment is a PROPOSED board block put to the board. The
|
|
5
|
+
-- items table deliberately carries only (subject_type, subject_id) — a subject
|
|
6
|
+
-- is a row somewhere else — so the proposal's text needs its own table. One row
|
|
7
|
+
-- per proposal, immutable once filed: a revised proposal is a NEW row and a new
|
|
8
|
+
-- window, the same never-edit-history posture as everything else on the board.
|
|
9
|
+
--
|
|
10
|
+
-- `proposed` is stored EXACTLY as validated at propose time (strict validation
|
|
11
|
+
-- in board.js — a proposal is never silently corrected the way a config FILE
|
|
12
|
+
-- is, because ratifying text the proposer did not write would be the board
|
|
13
|
+
-- lying). The apply step re-runs the R05 sanitizer as a belt anyway.
|
|
14
|
+
|
|
15
|
+
BEGIN;
|
|
16
|
+
|
|
17
|
+
CREATE TABLE IF NOT EXISTS government_amendments (
|
|
18
|
+
id bigserial PRIMARY KEY,
|
|
19
|
+
proposed jsonb NOT NULL,
|
|
20
|
+
rationale_md text,
|
|
21
|
+
proposed_by bigint REFERENCES builders(id) ON DELETE SET NULL,
|
|
22
|
+
created_at timestamptz NOT NULL DEFAULT now()
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
COMMIT;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
-- government_008_closed_items_index.sql — index the closed-history read
|
|
2
|
+
-- (task 1003029 / R16 of goal 1000069).
|
|
3
|
+
--
|
|
4
|
+
-- The Board Room's closed history reads "newest closes first, LIMIT n" on every
|
|
5
|
+
-- page load, and the closed set grows forever (one row per window; resubmission
|
|
6
|
+
-- is unbounded by design). The table's other indexes are partial on OPEN items
|
|
7
|
+
-- (the sweep, the one-open-per-subject wall) or on outcome alone — none order
|
|
8
|
+
-- by close time. Same reasoning as government_006's returned-items index.
|
|
9
|
+
-- Additive, idempotent, no rows touched.
|
|
10
|
+
|
|
11
|
+
BEGIN;
|
|
12
|
+
|
|
13
|
+
CREATE INDEX IF NOT EXISTS government_board_items_closed_by_time
|
|
14
|
+
ON government_board_items (closed_at DESC)
|
|
15
|
+
WHERE closed_at IS NOT NULL;
|
|
16
|
+
|
|
17
|
+
COMMIT;
|
|
@@ -414,6 +414,61 @@ module.exports = function buildGovernmentRouter() {
|
|
|
414
414
|
}
|
|
415
415
|
});
|
|
416
416
|
|
|
417
|
+
// rank: perm board.vote.cast — the Board Room view: open items with their
|
|
418
|
+
// clock, the OPEN BALLOT, the closed history (R16 of goal 1000069). Gated on
|
|
419
|
+
// the XENOS-floored vote atom, deliberately: seeing the ballot is part of
|
|
420
|
+
// casting (§9 — a board whose votes are invisible constrains nobody), and the
|
|
421
|
+
// room teaches as well as governs (criterion C5). NOT government.manage —
|
|
422
|
+
// that is the admin tab's atom; the room must be visible to whoever the
|
|
423
|
+
// constitution may enfranchise. Membership still gates nothing here: this is
|
|
424
|
+
// a read, and the write (the vote) re-checks membership in-handler.
|
|
425
|
+
router.get('/government/board/items', api.requireBuilder, api.requirePermission('board.vote.cast'), async (req, res) => {
|
|
426
|
+
try {
|
|
427
|
+
const view = await board.boardRoomView({ closedLimit: 20 });
|
|
428
|
+
res.json({ ...view, viewer_id: String(req.builder.id) });
|
|
429
|
+
} catch (err) {
|
|
430
|
+
console.error('[gds] GET /government/board/items', err);
|
|
431
|
+
res.fail('board_read_failed', { status: 500, message: 'internal error' });
|
|
432
|
+
}
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
// rank: perm board.item.open — put an AMENDMENT to the board (R14 of goal
|
|
436
|
+
// 1000069, ADR 0175 §7). The board's other subject never comes through here:
|
|
437
|
+
// a Full Idea's window opens AUTOMATICALLY on grade pass (R07), so requesting
|
|
438
|
+
// one is refused with the reason rather than silently doubling the paths —
|
|
439
|
+
// exactly what the catalog's own guards note predicted this route would be.
|
|
440
|
+
// The proposal is validated STRICTLY in board.js (never sanitized — the board
|
|
441
|
+
// must not ratify text the proposer did not write), and the window runs under
|
|
442
|
+
// the constitution IN FORCE, which under monarchy resolves to "the Archon
|
|
443
|
+
// ratifies" — their own proposal included (§7's no-bootstrap-exception).
|
|
444
|
+
router.post('/government/board/items', api.requireBuilder, api.requirePermission('board.item.open'), async (req, res) => {
|
|
445
|
+
if (validateOrRespond(req, res, {
|
|
446
|
+
subject_type: { required: true, type: 'string', maxLength: 32 },
|
|
447
|
+
proposal: { required: true, type: 'object' },
|
|
448
|
+
rationale_md: { type: 'string', maxLength: 5000 },
|
|
449
|
+
})) return;
|
|
450
|
+
if (req.body.subject_type !== 'constitutional_amendment') {
|
|
451
|
+
return res.fail('subject_opens_automatically', 409, {
|
|
452
|
+
subject_type: req.body.subject_type,
|
|
453
|
+
reason: 'a Full Idea window opens by itself when the idea passes the completeness grade (ADR 0175 §5); only a constitutional_amendment is proposed through this route',
|
|
454
|
+
});
|
|
455
|
+
}
|
|
456
|
+
try {
|
|
457
|
+
const out = await board.proposeAmendment({
|
|
458
|
+
proposal: req.body.proposal,
|
|
459
|
+
rationaleMd: req.body.rationale_md ?? null,
|
|
460
|
+
proposedBy: req.builder.id,
|
|
461
|
+
});
|
|
462
|
+
if (!out.ok) {
|
|
463
|
+
return res.fail('invalid_proposal', 400, { reason: out.reason, ...(out.field ? { field: out.field } : {}), ...(out.expected ? { expected: out.expected } : {}) });
|
|
464
|
+
}
|
|
465
|
+
res.status(201).json({ ok: true, amendment: out.amendment, item: out.item });
|
|
466
|
+
} catch (err) {
|
|
467
|
+
console.error('[gds] POST /government/board/items', err);
|
|
468
|
+
res.fail('propose_failed', { status: 500, message: 'internal error' });
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
|
|
417
472
|
// rank: perm board.vote.cast — cast a vote on an open board item (R08 of goal
|
|
418
473
|
// 1000069, ADR 0175). The atom FLOORS AT XENOS deliberately (the R04 carry-
|
|
419
474
|
// note): the permission is only the coarse gate, and the real wall is the
|
|
@@ -99,6 +99,6 @@
|
|
|
99
99
|
<script src="/builders/palette.js" defer></script>
|
|
100
100
|
<script src="/builders/dom-utils.js?v=2026-06-16-gate"></script>
|
|
101
101
|
<script src="/builders/hall-kit.js"></script>
|
|
102
|
-
<script src="/builders/government.js?v=2026-08-18-
|
|
102
|
+
<script src="/builders/government.js?v=2026-08-18-board-room"></script>
|
|
103
103
|
</body>
|
|
104
104
|
</html>
|