@bongos/core 1.21.37 → 1.21.39

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.
Files changed (33) hide show
  1. package/.bongos-core.json +51 -36
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +3 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/api/openapi.json +77 -3
  8. package/docs/api-reference.md +3 -2
  9. package/docs/copy-inventory.md +6 -7
  10. package/docs/copy-registry.json +8 -17
  11. package/docs/module-api-changelog.md +4 -0
  12. package/docs/page-readings.json +310 -318
  13. package/modules/government/board-withdraw.js +38 -0
  14. package/modules/government/board.js +1 -0
  15. package/modules/government/db.js +20 -0
  16. package/modules/government/migrations/government_026_board_outcome_withdrawn.sql +29 -0
  17. package/modules/government/routes/government.js +26 -0
  18. package/modules/hall-ui/public/street-card.js +16 -0
  19. package/modules/hall-ui/public/street-desk.js +2 -0
  20. package/modules/hall-ui/public/street-inside.js +3 -13
  21. package/modules/hall-ui/public/street.css +2 -6
  22. package/modules/hall-ui/public/street.html +0 -4
  23. package/modules/hall-ui/public/street.js +3 -18
  24. package/modules/hall-ui/records/governor-street.md +2 -2
  25. package/package-lock.json +2 -2
  26. package/package.json +1 -1
  27. package/release-notes.json +12 -0
  28. package/src/bongos/route-rank-check.js +4 -0
  29. package/src/module-api.js +1 -1
  30. package/tests/government_board_withdraw.mjs +144 -0
  31. package/tests/government_seed.mjs +1 -0
  32. package/tests/hall_street_desk.mjs +37 -0
  33. package/tests/hall_street_inside.mjs +18 -16
@@ -0,0 +1,38 @@
1
+ 'use strict';
2
+
3
+ // modules/government/board-withdraw.js — a proposer withdraws their own open
4
+ // board sitting before anyone has voted (task 1004564). Its own file because
5
+ // board.js sits at the size ratchet's line cap; it reads and writes through the
6
+ // same db slice and announces the close through board.js's own close event, so
7
+ // the Discord mirror and every other listener see one shape for every close.
8
+
9
+ const db = require('./db');
10
+ const board = require('./board');
11
+
12
+ // The proposer may take back a sitting they opened, but only while nobody has
13
+ // voted on it: once a vote is cast the sitting belongs to the voters too, and
14
+ // pulling it would erase their say. "Proposer" is the person who OPENED it
15
+ // (opened_by) — a Full Idea's sitting opens itself on its grade, so it has no
16
+ // proposer here and cannot be withdrawn this way. An item past its clock is
17
+ // refused as well: the sweep is about to decide it (under consent, silence
18
+ // passes it), and a withdraw then would dodge that outcome.
19
+ //
20
+ // Each refusal is read first so the caller gets a reason; the write itself
21
+ // repeats every guard in its WHERE (db.withdrawBoardItem), so a vote that
22
+ // arrives between the reads and the write leaves the sitting open
23
+ // ('item_changed') rather than withdrawn over a cast vote.
24
+ async function withdrawItem({ itemId, builderId } = {}) {
25
+ const item = await db.getBoardItemForVote(Number(itemId));
26
+ if (!item) return { ok: false, reason: 'item_not_found' };
27
+ if (item.closed_at) return { ok: false, reason: 'item_closed' };
28
+ if (item.opened_by == null || String(item.opened_by) !== String(builderId)) return { ok: false, reason: 'not_proposer' };
29
+ if (item.expired) return { ok: false, reason: 'vote_window_expired' };
30
+ const votes = await db.getVotesForItem(item.id);
31
+ if (votes.length > 0) return { ok: false, reason: 'votes_cast' };
32
+ const closed = await db.withdrawBoardItem({ itemId: item.id, builderId });
33
+ if (!closed) return { ok: false, reason: 'item_changed' };
34
+ await board.emitItemClosed(closed, { cause: 'withdrawn', votes: [] });
35
+ return { ok: true, item: closed };
36
+ }
37
+
38
+ module.exports = { withdrawItem };
@@ -1486,6 +1486,7 @@ module.exports = {
1486
1486
  passPayload,
1487
1487
  closeItem,
1488
1488
  maybeCloseAfterVote,
1489
+ emitItemClosed,
1489
1490
  latestRejectionForIdea,
1490
1491
  boardPassForIdea,
1491
1492
  boardPassedIdeaIds,
@@ -393,6 +393,25 @@ async function getVotesForItem(itemId) {
393
393
  return rows;
394
394
  }
395
395
 
396
+ // The withdraw write (task 1004564) — a proposer takes back their own open
397
+ // sitting before anyone has voted. EVERY guard is in the WHERE, not only in
398
+ // withdrawItem (board-withdraw.js)'s reads: still open, opened by this builder, and no vote
399
+ // row on it. So a vote that lands between the caller's read and this write
400
+ // makes it match nothing (null), and the sitting stays open — the same
401
+ // single-winner shape as closeBoardItem.
402
+ async function withdrawBoardItem({ itemId, builderId }) {
403
+ const { rows } = await pool.query(
404
+ `UPDATE government_board_items
405
+ SET closed_at = now(), outcome = 'withdrawn'
406
+ WHERE id = $1 AND closed_at IS NULL
407
+ AND opened_by = $2
408
+ AND NOT EXISTS (SELECT 1 FROM government_board_votes WHERE item_id = $1)
409
+ RETURNING *`,
410
+ [itemId, builderId],
411
+ );
412
+ return rows[0] || null;
413
+ }
414
+
396
415
  // The close write — STRUCTURALLY SINGLE-WINNER: WHERE closed_at IS NULL means
397
416
  // two concurrent closers (a racing vote and the expiry sweep) write exactly one
398
417
  // outcome; the loser gets null and reads the winner's row back if it cares.
@@ -881,6 +900,7 @@ module.exports = {
881
900
  insertAuthorRatifyKarma,
882
901
  getVotesForItem,
883
902
  closeBoardItem,
903
+ withdrawBoardItem,
884
904
  getIdeaCompleteness,
885
905
  getExpiredOpenBoardItems,
886
906
  getLatestReturnedItemWithObjection,
@@ -0,0 +1,29 @@
1
+ -- government_026_board_outcome_withdrawn.sql — a board sitting may also end
2
+ -- 'withdrawn' or 'expired' (task 1004564, goal 1000125).
3
+ --
4
+ -- WHAT IT IS. government_003 limited government_board_items.outcome to 'passed'
5
+ -- and 'returned'. A proposer may now withdraw their own open sitting before
6
+ -- anyone has voted (POST /government/board/items/:itemId/withdraw), which closes
7
+ -- it with outcome 'withdrawn'. 'expired' is added in the same statement because
8
+ -- the docket already names it (modules/government/docket-floors.js
9
+ -- OUTCOME_WORDS) — nothing writes it yet; allowing it now saves a second
10
+ -- constraint swap later.
11
+ --
12
+ -- Additive only: the CHECK is WIDENED. Every row the old CHECK allowed ('passed',
13
+ -- 'returned', NULL for an open item) is still allowed, so no row can fail it and
14
+ -- no data changes. The closed-consistency CHECK (closed_at and outcome set
15
+ -- together) is untouched.
16
+ -- Idempotent: DROP CONSTRAINT IF EXISTS + ADD, the government_017/020 idiom.
17
+ -- The constraint name is the one Postgres gave government_003's inline CHECK.
18
+ -- Approved by the owner on 2026-10-04 — the only schema change that task carries.
19
+
20
+ BEGIN;
21
+
22
+ ALTER TABLE government_board_items
23
+ DROP CONSTRAINT IF EXISTS government_board_items_outcome_check;
24
+
25
+ ALTER TABLE government_board_items
26
+ ADD CONSTRAINT government_board_items_outcome_check
27
+ CHECK (outcome IN ('passed', 'returned', 'withdrawn', 'expired'));
28
+
29
+ COMMIT;
@@ -25,6 +25,7 @@ const db = require('../db');
25
25
  const sessionScopes = require('../session-scopes');
26
26
  const boardMembership = require('../board-membership');
27
27
  const board = require('../board');
28
+ const boardWithdraw = require('../board-withdraw');
28
29
  const recommendations = require('../recommendations');
29
30
  const docket = require('../docket');
30
31
  // task 1003208: structured logging (pino via the doorway) — was console.*.
@@ -807,6 +808,31 @@ module.exports = function buildGovernmentRouter() {
807
808
  }
808
809
  });
809
810
 
811
+ // rank: perm board.item.open — withdraw your own open sitting (task 1004564).
812
+ // The same atom as putting one up: taking it back is the proposer's half of
813
+ // the same act. The atom is only the coarse gate; the walls are in
814
+ // board-withdraw.js and repeated in its write — only the builder who opened
815
+ // the sitting, only while it is open and inside its clock, and only before
816
+ // anyone has voted. It closes with outcome 'withdrawn' (government_026).
817
+ router.post('/government/board/items/:itemId/withdraw', api.requireBuilder, api.requirePermission('board.item.open'), async (req, res) => {
818
+ const itemId = parseId(req, res, { param: 'itemId', code: 'bad_item_id', positiveInt: true });
819
+ if (itemId === null) return;
820
+ try {
821
+ const out = await boardWithdraw.withdrawItem({ itemId, builderId: req.builder.id });
822
+ if (!out.ok) {
823
+ const status =
824
+ out.reason === 'item_not_found' ? 404 :
825
+ out.reason === 'not_proposer' ? 403 :
826
+ 409; // item_closed / vote_window_expired / votes_cast / item_changed
827
+ return res.fail(out.reason, status, { item_id: itemId });
828
+ }
829
+ res.json({ ok: true, item_closed: true, outcome: out.item.outcome, item: out.item });
830
+ } catch (err) {
831
+ log.error('[gds] POST /government/board/items/:itemId/withdraw', err);
832
+ res.fail('withdraw_failed', { status: 500, message: 'internal error' });
833
+ }
834
+ });
835
+
810
836
  // rank: perm board.vote.cast — cast a vote on an open board item (R08 of goal
811
837
  // 1000069, ADR 0175). The atom FLOORS AT XENOS deliberately (the R04 carry-
812
838
  // note): the permission is only the coarse gate, and the real wall is the
@@ -325,6 +325,7 @@
325
325
  if (item.closed_at) {
326
326
  cv.readOnly = true;
327
327
  if (item.outcome === 'passed') cv.result = 'Passed ' + b.forN + '–' + b.againstN + '. In force from ' + str(item.closed_at).slice(0, 10) + '.';
328
+ else if (item.outcome === 'withdrawn') cv.result = b.isAuthor ? 'You withdrew it before anyone voted.' : 'Withdrawn by ' + (proposer || 'its author') + ' before anyone voted.';
328
329
  else if (b.isAuthor && cv.counsel && cv.counsel.revise) { cv.result = 'It came back to you.'; cv.readOnly = false; cv.reviseForm = { text: words, label: kind === 'matter' ? 'the matter, in your words' : 'why, in your words' }; }
329
330
  else cv.result = 'It went back to ' + (proposer || 'its author') + ' to revise.';
330
331
  return cv;
@@ -346,6 +347,16 @@
346
347
  cv.result = m.state === 'needs' ? 'Read the idea in the Board Room and vote there, with its sections in front of you.' : 'Not yours to vote on.';
347
348
  return cv;
348
349
  }
350
+ // task 1004564: the proposer may withdraw their own sitting until anyone has
351
+ // voted (POST .../withdraw; the server holds the same two walls). If they may
352
+ // also vote on it, withdraw takes the second place: an objection to your own
353
+ // proposal is a withdraw said the long way.
354
+ var withdrawable = b.isAuthor && !((item.votes || []).length);
355
+ if (withdrawable) {
356
+ cv.actions = b.youOpen ? [act('for', 'vote for', 'primary'), act('withdraw', 'withdraw', 'quiet')] : [act('withdraw', 'withdraw', 'quiet')];
357
+ cv.result = 'Yours. No one has voted yet, so you can still withdraw it.';
358
+ return cv;
359
+ }
349
360
  if (b.youOpen) {
350
361
  cv.actions = [act('for', 'vote for', 'primary'), act('object', 'object', 'quiet')];
351
362
  cv.textActions = [act('abstain', 'abstain', 'text')];
@@ -575,6 +586,7 @@
575
586
  if (s.itemId != null) {
576
587
  if (key === 'for') return post('/government/board/items/' + enc(s.itemId) + '/votes', { direction: 'yes' });
577
588
  if (key === 'object') return post('/government/board/items/' + enc(s.itemId) + '/votes', { direction: 'no', reason_md: str(inp.reason).trim(), section_key: str(inp.section) });
589
+ if (key === 'withdraw') return post('/government/board/items/' + enc(s.itemId) + '/withdraw', {});
578
590
  if (key === 'revise') {
579
591
  var text = str(inp.text).trim();
580
592
  if (s.kind === 'amendment') return post('/government/board/items', { subject_type: 'constitutional_amendment', proposal: s.proposed, rationale_md: text });
@@ -629,6 +641,7 @@
629
641
  return { toast: r.already_voted ? 'Your vote was already on the record.' : 'Your yes is on the record.', building: b, kind: 'light', close: false };
630
642
  }
631
643
  if (key === 'object') return { toast: r.item_closed ? 'Your objection is on the record. It goes back to its author to revise.' : 'Your objection is on the record.', building: b, kind: 'settle', close: false };
644
+ if (key === 'withdraw') return { toast: 'Withdrawn. The sitting is closed and nothing was decided.', building: b, kind: 'settle', close: true };
632
645
  if (key === 'abstain') return { toast: 'Noted. Nothing goes on the record, and it still waits for your vote until it closes.', building: null, kind: null, close: true };
633
646
  if (key === 'revise') return { toast: 'Revised. It sits again on the new wording.', building: b, kind: 'light', close: true };
634
647
  if (key === 'hold') return { toast: 'Held. It waits for you.', building: null, kind: null, close: true };
@@ -678,6 +691,9 @@
678
691
  backup_already_running: 'A backup is already running.',
679
692
  intent_conflict: 'Another core run is going. Try again when it settles.',
680
693
  criterion_needs_recording: 'This one needs a recording. Sign it off on its goal page.',
694
+ not_proposer: 'Only the person who put it up may withdraw it.',
695
+ votes_cast: 'Someone has voted on it, so it is the sitting\'s to decide now.',
696
+ item_changed: 'Someone voted just now, so it stays with the sitting.',
681
697
  };
682
698
  function refusal(status, body) {
683
699
  var e = body && body.error ? body.error : {};
@@ -119,6 +119,8 @@
119
119
  decide: 'decide', ratify: 'ratify', ratify_goal: 'make goal', up: 'move up', down: 'move down',
120
120
  // task 1004561: a builder's monthly limit, and a sign-off undone
121
121
  budget: 'set limit', unsign: 'undo',
122
+ // task 1004564: the proposer takes back their own sitting before any vote
123
+ withdraw: 'withdraw',
122
124
  });
123
125
  // What a row's chips ask, per act (a reason or an option is one tap, never text).
124
126
  const ASKS = Object.freeze({ budget: 'monthly limit', unsign: 'why', decide: 'pick one', ratify_goal: 'which version', check: 'check', take_core: 'move to', rot: 'stale after', module_off: 'sure', invite: 'their GitHub name', object: 'why', force: 'why', switch_off: 'why', pause: 'why', deactivate: 'why', signoff: 'confirm', allow: 'which goal', tier: 'which tier', give: 'to whom', take: 'from whom' });
@@ -34,8 +34,6 @@
34
34
  // the seat is drawn empty and says so, and the rule is the building's own
35
35
  // matters' rule, else the design's default, named as the default
36
36
  // (holderOf()).
37
- // * On the table here: the docket's matters at this building. Each row opens
38
- // the short card (GovStreet.cardFor(); task 1004528 makes it act).
39
37
  // * The record: the building's history (docket.history: rulings, amendments,
40
38
  // sign-offs, releases, payouts) and its matters, filed by month, one drawer
41
39
  // a month, newest first.
@@ -176,7 +174,7 @@
176
174
  // key one of the nine civic keys
177
175
  // b the building, in civicInfo()'s shape: { group, name, holds, seats,
178
176
  // who, rule, how, cap, growth, floors: [{ name, line, count, status,
179
- // lines }], record: [{ label, items }], onFile, table: [rows], annex }
177
+ // lines }], record: [{ label, items }], onFile, annex }
180
178
  // st { floor, drawer, picks, vacant, reviewHeld, evening }
181
179
  // Everything the prototype's template binds, as data, plus `selFloor` and
182
180
  // `selDrawer`. The geometry is the prototype's to the pixel.
@@ -261,7 +259,6 @@
261
259
  : { key: s, init: s, bg: T.seatFor.bg, fg: T.seatFor.fg, border: 'none' };
262
260
  const g = City.glyph(b.rule, 9, 9, 16);
263
261
  const pct = Math.round(occ / cap * 100);
264
- const table = Array.isArray(b.table) ? b.table : [];
265
262
  const f = b.floors[selFloor];
266
263
  const quietWord = f && QUIET[f.status] !== undefined ? ' · ' + (f.status === 'proposed' ? 'proposed' : QUIET[f.status]) : '';
267
264
  return {
@@ -269,7 +266,6 @@
269
266
  seats: b.seats.map(seatOf), whoText: b.who, gf: g.fill, gl: g.line, howText: b.how,
270
267
  fill: pct, fillColor: pct >= 80 ? '#fb9569' : T.line,
271
268
  growth: (vacant ? 'Not built yet. ' : occ + ' of ' + cap + ' floors in use. ') + b.growth,
272
- hasTable: table.length > 0, table: table,
273
269
  openKicker: 'floor ' + (selFloor + 1) + (f && f.status === 'pending' ? ' · being decided' : quietWord),
274
270
  sym: f ? Object.assign(symbolPath(floorSymbol(f.name)), { c: '#fb9569' }) : { d: '', f: '', c: 'none' },
275
271
  flipA: ((st.picks || 0) % 2) === 0, flipB: ((st.picks || 0) % 2) === 1,
@@ -528,11 +524,6 @@
528
524
  if (!vacant && spec[2] && spec[2].held) fl.name = spec[2].held;
529
525
  return fl;
530
526
  });
531
- const order = { needs: 0, moving: 1, settled: 2 };
532
- const table = matters.slice().sort((a, b) => order[stateOf(a)] - order[stateOf(b)]).map((m) => {
533
- const s = stateOf(m), when = S.whenWord(m.deadline, c.now);
534
- return { key: String(m.id), matter: String(m.id), label: String(m.title || m.short || ''), status: s === 'needs' ? 'needs you' + (when ? ' · ' + when : '') : s === 'moving' ? 'moving' + (when ? ' · ' + when : '') : (when || 'settled'), state: s, color: s === 'needs' ? 'accent' : 'ink' };
535
- });
536
527
  const history = docket && Array.isArray(docket.history) ? docket.history.filter((x) => x && x.building === key) : [];
537
528
  const record = recordOf(matters, c.now, history);
538
529
  return {
@@ -541,7 +532,7 @@
541
532
  growth: vacant ? plan.vacantGrowth : plan.growth,
542
533
  floors: floors, record: record,
543
534
  onFile: !docket ? 'the docket could not be read just now' : matters.length ? matters.length + (matters.length === 1 ? ' matter' : ' matters') + ' on the docket' + (history.length ? ' · ' + history.length + ' on the record' : '') : history.length ? history.length + ' on the record' : 'nothing on file yet',
544
- table: table, annex: !vacant && floors.length >= plan.cap ? plan.annex : null, vacant: vacant,
535
+ annex: !vacant && floors.length >= plan.cap ? plan.annex : null, vacant: vacant,
545
536
  };
546
537
  }
547
538
 
@@ -558,9 +549,8 @@
558
549
  const docket = o.docket && Array.isArray(o.docket.matters) ? o.docket : null;
559
550
  const evening = !!docket && !(docket.counts && Number(docket.counts.needs) > 0);
560
551
  const bv = buildingView(T, o.key, b, { floor: o.floor, drawer: o.drawer, picks: o.picks, vacant: b.vacant, reviewHeld: !b.vacant, evening: evening, cleared: o.cleared });
561
- bv.table = bv.table.map((r) => Object.assign({}, r, { color: r.color === 'accent' ? T.accentText : T.ink }));
562
552
  bv.key = o.key; bv.mode = mode; bv.vacant = b.vacant;
563
- bv.label = b.name + ' in section: ' + b.floors.length + ' of ' + bv.cap + ' floors in use' + (bv.hasTable ? ', ' + bv.table.length + ' on the table' : '') + '.';
553
+ bv.label = b.name + ' in section: ' + b.floors.length + ' of ' + bv.cap + ' floors in use.';
564
554
  return bv;
565
555
  }
566
556
 
@@ -195,7 +195,7 @@ html[data-fullscreen="on"] .street-top { padding-right: 184px; }
195
195
  on the hall's tokens. Under 900 px the artboard gives way to a column: the
196
196
  drawing in its own sideways strip, the panel below it. */
197
197
  .inside { position: absolute; inset: 0; display: flex; overflow-x: auto; overflow-y: auto; background: var(--bg); }
198
- .inside[hidden], .inside-annex[hidden], .inside-dhead[hidden], .inside-legend[hidden], .inside-table[hidden], .inside-open[hidden], .inside-note[hidden], .inside-panel[hidden], .inside-more[hidden] { display: none; }
198
+ .inside[hidden], .inside-annex[hidden], .inside-dhead[hidden], .inside-legend[hidden], .inside-open[hidden], .inside-note[hidden], .inside-panel[hidden], .inside-more[hidden] { display: none; }
199
199
  .inside-sizer { position: relative; margin: auto; flex: none; overflow: hidden; }
200
200
  .inside-board { position: absolute; left: 0; top: 0; width: 1440px; height: 708px; transform-origin: 0 0; transform: scale(var(--inside-scale, 1)); color: var(--ink); }
201
201
  .inside-head { position: absolute; left: 32px; top: 16px; display: flex; align-items: center; gap: 16px; z-index: 2; }
@@ -265,10 +265,6 @@ html[data-fullscreen="on"] .street-top { padding-right: 184px; }
265
265
  .inside-meter__fill { height: 6px; width: 0; background: color-mix(in srgb, var(--ink) 30%, var(--bg)); }
266
266
  .inside-meter__fill--full { background: var(--accent); }
267
267
  .inside-meter__line { font-size: 13px; color: var(--ink-soft); line-height: 1.45; }
268
- .inside-table { display: flex; flex-direction: column; gap: 8px; }
269
- .inside-row { display: flex; justify-content: space-between; align-items: center; gap: 12px; width: 100%; background: none; border: 0; border-top: 1px solid var(--street-rule); padding: 10px 0; cursor: pointer; font-family: var(--font-body); font-size: 15px; text-align: left; color: var(--ink); }
270
- .inside-row--needs { color: var(--street-accent-text); }
271
- .inside-row__status { font-size: 12.5px; color: var(--ink-faint); white-space: nowrap; }
272
268
 
273
269
  /* the chosen floor, its sign large; the block re-enters on every pick */
274
270
  .inside-open { display: flex; flex-direction: column; gap: 10px; }
@@ -362,7 +358,7 @@ html[data-fullscreen="on"] .street-top { padding-right: 184px; }
362
358
  .inside-ditem { display: flex; justify-content: space-between; gap: 14px; padding: 8px 0; border-top: 1px solid var(--street-rule); font-size: 14px; line-height: 1.4; }
363
359
  .inside-ditem__meta { color: var(--ink-faint); white-space: nowrap; }
364
360
 
365
- .inside-back:focus-visible, .inside-textbtn:focus-visible, .inside-row:focus-visible, .inside-drawer:focus-visible, .inside-item:focus-visible, .inside-more:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
361
+ .inside-back:focus-visible, .inside-textbtn:focus-visible, .inside-drawer:focus-visible, .inside-item:focus-visible, .inside-more:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }
366
362
  .inside-floor:focus-visible { outline: 2px solid var(--accent); outline-offset: -3px; }
367
363
  .inside-name:focus { outline: none; }
368
364
  .inside-name:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
@@ -166,10 +166,6 @@
166
166
  <span class="inside-meter__line" id="insideGrowth"></span>
167
167
  </div>
168
168
  </div>
169
- <div class="inside-table" id="insideTableWrap" hidden>
170
- <span class="street-label">on the table here</span>
171
- <div id="insideTable"></div>
172
- </div>
173
169
  <div class="inside-open" id="insideOpenA"></div>
174
170
  <div class="inside-open" id="insideOpenB" hidden></div>
175
171
  <div class="inside-record">
@@ -493,19 +493,6 @@
493
493
  var fill = $('insideFill');
494
494
  if (fill) { fill.style.width = bv.fill + '%'; fill.className = 'inside-meter__fill' + (bv.fill >= 80 ? ' inside-meter__fill--full' : ''); }
495
495
  setText('insideGrowth', bv.growth);
496
- show('insideTableWrap', bv.hasTable);
497
- var table = $('insideTable'); clear(table);
498
- bv.table.forEach(function (r) {
499
- var b = node('button', 'inside-row' + (r.state === 'needs' ? ' inside-row--needs' : ''));
500
- b.setAttribute('type', 'button');
501
- b.setAttribute('data-matter', r.matter);
502
- b.setAttribute('aria-controls', 'streetCard');
503
- b.setAttribute('aria-expanded', view.open === r.matter ? 'true' : 'false');
504
- b.appendChild(node('span', 'inside-row__label', r.label));
505
- b.appendChild(node('span', 'inside-row__status', r.status));
506
- b.addEventListener('click', function () { openCard(r.matter, b); });
507
- if (table) table.appendChild(b);
508
- });
509
496
  // the chosen floor: two blocks that take turns, so the block re-enters on every pick
510
497
  var on = $(bv.flipA ? 'insideOpenA' : 'insideOpenB'), off = $(bv.flipA ? 'insideOpenB' : 'insideOpenA');
511
498
  if (off) { off.hidden = true; clear(off); }
@@ -751,7 +738,7 @@
751
738
  pause: 'paused', resume: 'resumed', tier: 'new tier', deactivate: 'deactivated', give: 'gave', take: 'took back',
752
739
  check: 'checked', rot: 'set', module_on: 'turned on', module_off: 'turned off', backup: 'backed up', invite: 'invited',
753
740
  decide: 'decided', ratify: 'ratified', ratify_goal: 'made a goal of',
754
- budget: 'set the limit of', unsign: 'undid',
741
+ budget: 'set the limit of', unsign: 'undid', withdraw: 'withdrew',
755
742
  };
756
743
  // One act from a row: checked, sent through the card's own request (the
757
744
  // server's permission checks decide), then the row clears, the floor counts
@@ -1104,12 +1091,10 @@
1104
1091
  // The pins were redrawn, so focus goes back to the new button for the same matter.
1105
1092
  if (restore !== false && was && was.getAttribute) {
1106
1093
  var key = was.getAttribute('data-matter');
1107
- // the pins and the table rows were redrawn: focus the new control for the same matter
1094
+ // the pins were redrawn: focus the new pin for the same matter
1108
1095
  // .children is an HTMLCollection, which concat does not spread: copy it first
1109
1096
  var list = function (id) { var el = $(id); return el && el.children ? Array.prototype.slice.call(el.children) : []; };
1110
- var pins = list('streetPins');
1111
- var rows = list('insideTable');
1112
- var pool = view.inside ? [].concat(rows, pins) : [].concat(pins, rows);
1097
+ var pool = list('streetPins');
1113
1098
  for (var i = 0; i < pool.length; i++) {
1114
1099
  if (pool[i].getAttribute('data-matter') === key) { if (pool[i].focus) pool[i].focus(); break; }
1115
1100
  }
@@ -21,12 +21,12 @@ Built 1:1 on `buildingView()` and `districtView()` of the same prototype. The in
21
21
  Files: `street-inside.js` (`window.GovInside`, node-require-safe, pure): the prototype's `crown()`, `workItem()`, `floorSymbol()`, `symbolPath()` (all forty symbols) and the section drawing itself, verbatim, plus the real-data adapters. `street.js` builds the nodes; `street.css` carries the interior's styles under the `inside-` prefix.
22
22
 
23
23
  - **The building in section.** Every storey a real floor (slab, cut walls with glazing, three lamps, a partition with a doorway, the framed sign with the floor's symbol, its name and state lettered on the wall, its count, its level number), dark empty floors up to the building's capacity, the crown per building, the lift in its shaft, the orange frame and orange sign on the chosen floor, and the annex lot once the building is full. An unheld Review is the dashed ghost (`GovStreet.reviewHeldOf()`, so the street and the interior agree).
24
- - **The right panel.** Name and what it holds, who holds it (seat chips), how it decides (the rule glyph), **change how this works**, the fill meter with its growth line, on the table here, the chosen floor with its sign large, and the record as monthly filing drawers.
24
+ - **The right panel.** Name and what it holds, who holds it (seat chips), how it decides (the rule glyph), **change how this works**, the fill meter with its growth line, the chosen floor with its sign large, and the record as monthly filing drawers.
25
25
  - **Floors are the prototype's structure; a number appears only when the docket holds it.** `GovInside.PLAN` keeps each building's floors, order and capacity. A floor bound to a docket source shows that source's open matters at this building (count, line, needs you, the lines); one bound to a reading shows its figure; Planning's Goals floor counts the open goals. Every other floor says "not recorded yet" on the wall, lettered faint, with no number. A source that is off, coming, not yours or unreadable says which, never a figure. Filling every floor with real data is task 1004527, through `floorFor()` and `PLAN`.
26
26
  - **Who holds it.** The docket names no holders yet (task 1004529), so the seat is an empty dashed chip and says "who holds it is not set on this project yet". The rule is the one the building's open matters are decided by, else the design's default, which the words call "by default". `holderOf()` reads a docket building's `holders` / `rule` / `who` first, so 1004529 lands without touching the drawing.
27
27
  - **The lift and the flip.** The lift is one node kept across draws, so picking a floor slides it by a CSS transform transition; the chosen floor's block is two blocks that take turns (`flipA` / `flipB`), so it re-enters on every pick. Both are dead under reduced motion.
28
28
  - **Change how this works** is present and honest: pressed, it says drafting an amendment from here is not built yet (task 1004530 wires it).
29
- - **On the table here** rows open the same short card a pin opens, through `openCard()`; Escape gives focus back to the row.
29
+ - **No "on the table here" list.** The prototype's list of the building's open matters was removed on the owner's direction (task 1004565): the floor desks carry every matter as an actionable row, so the list was duplication. Inside a building a matter is its desk row; the short card opens from the street's pins.
30
30
  - **Districts.** The goal's tasks (`GET /tasks?goal_id=N`, read once per goal): shape from the discipline (tower engineer, studio artist, glass front UI, pavilion ideator; none is a tower), stage from the status (backlog and blocked a staked plot, ready a slab, active a scaffold, completed and confirmed built with a flag, shipped lit), label the kind; height from the estimate. Up to ten stand on the road, work in flight first; the rest are counted with a link to the goal. Each one opens its task.
31
31
  - **Narrow.** Under 900 px the board becomes a column: the drawing in its own sideways strip (never scaled under 0.6, so a floor stays a 24 px target), then the panel.
32
32
 
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.37",
3
+ "version": "1.21.39",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.37",
9
+ "version": "1.21.39",
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.21.37",
3
+ "version": "1.21.39",
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",
@@ -8986,5 +8986,17 @@
8986
8986
  "id": "1004495",
8987
8987
  "text": "The hall now looks different in each craft mode. In Artistry, Ideating and Governing the mode button, the current sidebar tab and the top edge of the page take the mode's own colour (moonlight or night ink, gold, city blue), t"
8988
8988
  }
8989
+ ],
8990
+ "1.21.38": [
8991
+ {
8992
+ "id": "1004564",
8993
+ "text": "If you put something up for a vote and nobody has voted yet, you can now withdraw it with one tap on your desk. Once anyone votes, it stays with the vote. A small database change makes \"withdrawn\" a valid way for a sitting to"
8994
+ }
8995
+ ],
8996
+ "1.21.39": [
8997
+ {
8998
+ "id": "1004565",
8999
+ "text": "Inside each building on the governor street, the separate \"on the table here\" list is gone. The floor desks already show every matter as a row you can act on, so the list only repeated them. The rest of the building panel look"
9000
+ }
8989
9001
  ]
8990
9002
  }
@@ -292,6 +292,10 @@ const GOVERNMENT_PINS = {
292
292
  // writes the table; neither route does.
293
293
  'POST /government/board/decision-rules': 'perm:board.item.open',
294
294
  'POST /government/board/matters': 'perm:board.item.open',
295
+ // task 1004564: a proposer withdraws their own open sitting before any vote —
296
+ // the proposer's half of putting one up, so the same atom. The proposer-only
297
+ // and no-votes walls are in withdrawItem (board-withdraw.js) and its write.
298
+ 'POST /government/board/items/:itemId/withdraw': 'perm:board.item.open',
295
299
  };
296
300
 
297
301
  // Module-scoped privileged-rank pins: a privileged route that lives in a FEATURE
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.37'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.39'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -0,0 +1,144 @@
1
+ // tests/government_board_withdraw.mjs — a proposer withdraws their own open
2
+ // sitting before anyone has voted (task 1004564, goal 1000125).
3
+ //
4
+ // WHAT IT GUARDS.
5
+ // 1. THE TWO WALLS IN withdrawItem (board-withdraw.js) — only the builder who OPENED the
6
+ // sitting, and only while no vote is on it. Each refusal asserts the write
7
+ // was never reached, so deleting either guard reds a test here (the stub
8
+ // write would otherwise "succeed").
9
+ // 2. THE SAME WALLS IN THE WRITE ITSELF — db.withdrawBoardItem's WHERE repeats
10
+ // open + proposer + no vote, so a vote landing between the reads and the
11
+ // write cannot be withdrawn over. Checked on the SQL the db actually sends.
12
+ // 3. THE MIGRATION — government_026 widens the outcome CHECK to carry
13
+ // 'withdrawn' and 'expired', is idempotent, and never narrows it.
14
+ //
15
+ // DB-free: the db functions (and, for 2, the pool's query) are monkeypatched,
16
+ // the sibling board tests' idiom.
17
+ //
18
+ // Run: node --test tests/government_board_withdraw.mjs
19
+ import assert from 'node:assert/strict';
20
+ import { test } from 'node:test';
21
+ import { createRequire } from 'node:module';
22
+ import fs from 'node:fs';
23
+ import path from 'node:path';
24
+ import { fileURLToPath } from 'node:url';
25
+
26
+ const require = createRequire(import.meta.url);
27
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
28
+ const GOV = path.join(ROOT, 'modules', 'government');
29
+ const db = require(path.join(GOV, 'db.js'));
30
+ const { withdrawItem } = require(path.join(GOV, 'board-withdraw.js'));
31
+
32
+ const CONSENT = { membership: 'rank:metic', pass_rule: 'consent', window_minutes: 1440, close_early_on_full_turnout: true };
33
+ const openItem = (over = {}) => ({
34
+ id: '21', subject_type: 'constitutional_amendment', subject_id: '4', opened_by: '42',
35
+ closed_at: null, outcome: null, expired: false, constitution: CONSENT, ...over,
36
+ });
37
+
38
+ async function withDb(stubs, fn) {
39
+ const originals = {};
40
+ for (const [k, v] of Object.entries(stubs)) { originals[k] = db[k]; db[k] = v; }
41
+ try { return await fn(); }
42
+ finally { for (const [k, v] of Object.entries(originals)) db[k] = v; }
43
+ }
44
+
45
+ // A stub set whose write records whether it was reached.
46
+ function stubs(item, votes = []) {
47
+ const calls = { write: null };
48
+ return {
49
+ calls,
50
+ db: {
51
+ getBoardItemForVote: async () => item,
52
+ getVotesForItem: async () => votes,
53
+ withdrawBoardItem: async (args) => { calls.write = args; return { ...item, closed_at: '2026-10-04T10:00:00Z', outcome: 'withdrawn' }; },
54
+ },
55
+ };
56
+ }
57
+
58
+ // ── 1. the walls in withdrawItem (board-withdraw.js) ───────────────────────────────────────
59
+
60
+ test('the proposer withdraws their own open sitting before any vote: closed, outcome withdrawn', async () => {
61
+ const s = stubs(openItem());
62
+ const out = await withDb(s.db, () => withdrawItem({ itemId: 21, builderId: 42 }));
63
+ assert.equal(out.ok, true);
64
+ assert.equal(out.item.outcome, 'withdrawn');
65
+ assert.deepEqual(s.calls.write, { itemId: '21', builderId: 42 }, 'the write carries the proposer, so its WHERE can check them too');
66
+ });
67
+
68
+ test('PROPOSER ONLY: anyone else is refused not_proposer, and the write is never reached', async () => {
69
+ const s = stubs(openItem({ opened_by: '42' }));
70
+ const out = await withDb(s.db, () => withdrawItem({ itemId: 21, builderId: 7 }));
71
+ assert.deepEqual(out, { ok: false, reason: 'not_proposer' });
72
+ assert.equal(s.calls.write, null, 'a refused withdraw writes nothing');
73
+ });
74
+
75
+ test('PROPOSER ONLY: a sitting nobody opened (a Full Idea\'s, opened by its grade) cannot be withdrawn', async () => {
76
+ const s = stubs(openItem({ subject_type: 'full_idea', opened_by: null }));
77
+ const out = await withDb(s.db, () => withdrawItem({ itemId: 21, builderId: 42 }));
78
+ assert.deepEqual(out, { ok: false, reason: 'not_proposer' });
79
+ assert.equal(s.calls.write, null);
80
+ });
81
+
82
+ test('NO VOTES: once anyone has voted — even the proposer — it is refused votes_cast, and the write is never reached', async () => {
83
+ for (const voter of ['7', '42']) {
84
+ const s = stubs(openItem(), [{ id: '1', item_id: '21', voter_id: voter, direction: 'yes' }]);
85
+ const out = await withDb(s.db, () => withdrawItem({ itemId: 21, builderId: 42 }));
86
+ assert.deepEqual(out, { ok: false, reason: 'votes_cast' }, `a vote by ${voter} holds the sitting`);
87
+ assert.equal(s.calls.write, null);
88
+ }
89
+ });
90
+
91
+ test('closed, past its clock, missing, or a vote racing the write: each refused with its own reason', async () => {
92
+ const cases = [
93
+ [null, 'item_not_found'],
94
+ [openItem({ closed_at: '2026-10-01T00:00:00Z', outcome: 'returned' }), 'item_closed'],
95
+ [openItem({ expired: true }), 'vote_window_expired'],
96
+ ];
97
+ for (const [item, reason] of cases) {
98
+ const s = stubs(item);
99
+ const out = await withDb(s.db, () => withdrawItem({ itemId: 21, builderId: 42 }));
100
+ assert.deepEqual(out, { ok: false, reason });
101
+ assert.equal(s.calls.write, null, `${reason} writes nothing`);
102
+ }
103
+ // the write's own WHERE matched nothing — a vote landed after the reads
104
+ const s = stubs(openItem());
105
+ s.db.withdrawBoardItem = async () => null;
106
+ const out = await withDb(s.db, () => withdrawItem({ itemId: 21, builderId: 42 }));
107
+ assert.deepEqual(out, { ok: false, reason: 'item_changed' });
108
+ });
109
+
110
+ // ── 2. the same walls in the write ───────────────────────────────────────────
111
+
112
+ test('the write repeats every wall in its WHERE: open, opened by this builder, no vote row', async () => {
113
+ const { pool } = require(path.join(ROOT, 'src', 'bongos', 'pool'));
114
+ const original = pool.query;
115
+ let sent = null;
116
+ pool.query = async (sql, params) => { sent = { sql: String(sql).replace(/\s+/g, ' '), params }; return { rows: [] }; };
117
+ try {
118
+ const out = await db.withdrawBoardItem({ itemId: 21, builderId: 42 });
119
+ assert.equal(out, null, 'no row matched → null, the sitting untouched');
120
+ } finally { pool.query = original; }
121
+ assert.match(sent.sql, /UPDATE government_board_items SET closed_at = now\(\), outcome = 'withdrawn'/);
122
+ assert.match(sent.sql, /closed_at IS NULL/);
123
+ assert.match(sent.sql, /opened_by = \$2/);
124
+ assert.match(sent.sql, /NOT EXISTS \(SELECT 1 FROM government_board_votes WHERE item_id = \$1\)/);
125
+ assert.deepEqual(sent.params, [21, 42]);
126
+ });
127
+
128
+ // ── 3. the migration ─────────────────────────────────────────────────────────
129
+
130
+ test('government_026 widens the outcome CHECK to withdrawn and expired, idempotently, never narrowing it', () => {
131
+ const sql = fs.readFileSync(path.join(GOV, 'migrations', 'government_026_board_outcome_withdrawn.sql'), 'utf8');
132
+ const original = fs.readFileSync(path.join(GOV, 'migrations', 'government_003_board_tables.sql'), 'utf8');
133
+ assert.match(original, /outcome\s+text\s+CHECK \(outcome IN \('passed', 'returned'\)\)/, 'sanity: government_003 is the CHECK being widened');
134
+ assert.match(sql, /DROP CONSTRAINT IF EXISTS government_board_items_outcome_check;/, 'idempotent: safe to run twice');
135
+ const check = /ADD CONSTRAINT government_board_items_outcome_check\s+CHECK \(outcome IN \(([^)]*)\)\)/.exec(sql);
136
+ assert.ok(check, 'the widened CHECK is re-added under the same name');
137
+ const words = check[1].split(',').map((w) => w.trim().replace(/'/g, ''));
138
+ assert.deepEqual(words.sort(), ['expired', 'passed', 'returned', 'withdrawn']);
139
+ // the docket already names both new outcomes, so the CHECK and its words agree
140
+ const floors = fs.readFileSync(path.join(GOV, 'docket-floors.js'), 'utf8');
141
+ for (const w of words) assert.match(floors, new RegExp(`\\b${w}:`), `docket-floors OUTCOME_WORDS names '${w}'`);
142
+ // additive only: nothing in it rewrites a row or drops anything else
143
+ assert.doesNotMatch(sql.replace(/--.*$/gm, ''), /\b(UPDATE|DELETE|DROP TABLE|DROP COLUMN)\b/i);
144
+ });
@@ -45,6 +45,7 @@ const NON_GRANT_MIGRATIONS = new Set([
45
45
  'government_018_passed_items_index.sql', // BV2.R17 passed-item lookup index (task 1004096)
46
46
  'government_020_genesis_stage_subject.sql', // BV2.PS04 genesis_stage board subject CHECK (task 1004429)
47
47
  'government_023_decision_rules.sql', // who decides what: the decision-rules table, matters (task 1004529)
48
+ 'government_026_board_outcome_withdrawn.sql', // board outcome CHECK widened: withdrawn, expired (task 1004564)
48
49
  ]);
49
50
 
50
51
  // Permission keys RENAMED after the grant migrations that first granted them.