@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.
@@ -42,8 +42,13 @@
42
42
 
43
43
  const api = require('../../src/module-api');
44
44
  const db = require('./db');
45
- const { loadGovernmentConfig } = require('./config');
46
- const { isBoardMember, resolveBoardMembers } = require('./board-membership');
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
- const authorId = await db.getBoardItemAuthor(item);
184
- if (authorId != null && String(authorId) === String(voterId)) {
185
- return { ok: false, reason: 'anti_self_vote' };
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 { closed: true, item: closed, outcome, cause, author_karma: authorKarma, ...(credit ? { credit } : {}) };
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,
@@ -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-government-ia"></script>
102
+ <script src="/builders/government.js?v=2026-08-18-board-room"></script>
103
103
  </body>
104
104
  </html>