@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.
- package/.bongos-core.json +51 -36
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +77 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +6 -7
- package/docs/copy-registry.json +8 -17
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +310 -318
- package/modules/government/board-withdraw.js +38 -0
- package/modules/government/board.js +1 -0
- package/modules/government/db.js +20 -0
- package/modules/government/migrations/government_026_board_outcome_withdrawn.sql +29 -0
- package/modules/government/routes/government.js +26 -0
- package/modules/hall-ui/public/street-card.js +16 -0
- package/modules/hall-ui/public/street-desk.js +2 -0
- package/modules/hall-ui/public/street-inside.js +3 -13
- package/modules/hall-ui/public/street.css +2 -6
- package/modules/hall-ui/public/street.html +0 -4
- package/modules/hall-ui/public/street.js +3 -18
- package/modules/hall-ui/records/governor-street.md +2 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +12 -0
- package/src/bongos/route-rank-check.js +4 -0
- package/src/module-api.js +1 -1
- package/tests/government_board_withdraw.mjs +144 -0
- package/tests/government_seed.mjs +1 -0
- package/tests/hall_street_desk.mjs +37 -0
- 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 };
|
package/modules/government/db.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
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
|
|
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-
|
|
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-
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
- **
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -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.
|
|
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.
|