@bongos/core 1.19.251 → 1.19.252

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.
@@ -3,9 +3,9 @@
3
3
  "generator": "scripts/gds/copy-inventory.js",
4
4
  "goal": 1000074,
5
5
  "counts": {
6
- "strings": 1332,
7
- "unique_ids": 1178,
8
- "certain": 1332,
6
+ "strings": 1334,
7
+ "unique_ids": 1180,
8
+ "certain": 1334,
9
9
  "heuristic": 0,
10
10
  "duplicate_ids": 123
11
11
  },
@@ -13,7 +13,7 @@
13
13
  "builders-hall": {
14
14
  "at": "modules/hall-ui/public",
15
15
  "files": 59,
16
- "strings": 1051
16
+ "strings": 1053
17
17
  },
18
18
  "status-dashboard": {
19
19
  "at": "modules/status-ui/public",
@@ -996,7 +996,7 @@
996
996
  "occurrences": 2,
997
997
  "at": [
998
998
  "modules/hall-ui/public/gate.js:205",
999
- "modules/hall-ui/public/government.js:763"
999
+ "modules/hall-ui/public/government.js:812"
1000
1000
  ]
1001
1001
  },
1002
1002
  {
@@ -1549,7 +1549,7 @@
1549
1549
  "surface": "builders-hall",
1550
1550
  "text": ", membership",
1551
1551
  "file": "modules/hall-ui/public/government.js",
1552
- "line": 707,
1552
+ "line": 756,
1553
1553
  "origin": "js-markup",
1554
1554
  "confidence": "certain"
1555
1555
  },
@@ -1855,7 +1855,7 @@
1855
1855
  "surface": "builders-hall",
1856
1856
  "text": "An objection returns the idea to its author with your reason and the section it faults \\u2014 the feedback they revise against.",
1857
1857
  "file": "modules/hall-ui/public/government.js",
1858
- "line": 662,
1858
+ "line": 711,
1859
1859
  "origin": "js-markup",
1860
1860
  "confidence": "certain"
1861
1861
  },
@@ -2026,7 +2026,7 @@
2026
2026
  "surface": "builders-hall",
2027
2027
  "text": "As configuration:",
2028
2028
  "file": "modules/hall-ui/public/government.js",
2029
- "line": 554,
2029
+ "line": 603,
2030
2030
  "origin": "js-markup",
2031
2031
  "confidence": "certain"
2032
2032
  },
@@ -2269,7 +2269,7 @@
2269
2269
  "surface": "builders-hall",
2270
2270
  "text": "Cast objection",
2271
2271
  "file": "modules/hall-ui/public/government.js",
2272
- "line": 662,
2272
+ "line": 711,
2273
2273
  "origin": "js-markup",
2274
2274
  "confidence": "certain"
2275
2275
  },
@@ -2332,7 +2332,7 @@
2332
2332
  "surface": "builders-hall",
2333
2333
  "text": "Charter library",
2334
2334
  "file": "modules/hall-ui/public/government.js",
2335
- "line": 531,
2335
+ "line": 580,
2336
2336
  "origin": "js-markup",
2337
2337
  "confidence": "certain"
2338
2338
  },
@@ -2566,7 +2566,7 @@
2566
2566
  "surface": "builders-hall",
2567
2567
  "text": "Closed sittings",
2568
2568
  "file": "modules/hall-ui/public/government.js",
2569
- "line": 733,
2569
+ "line": 782,
2570
2570
  "origin": "js-markup",
2571
2571
  "confidence": "certain"
2572
2572
  },
@@ -2998,7 +2998,7 @@
2998
2998
  "surface": "builders-hall",
2999
2999
  "text": "Could not load government data just now.",
3000
3000
  "file": "modules/hall-ui/public/government.js",
3001
- "line": 804,
3001
+ "line": 853,
3002
3002
  "origin": "js-markup",
3003
3003
  "confidence": "certain"
3004
3004
  },
@@ -3052,7 +3052,7 @@
3052
3052
  "surface": "builders-hall",
3053
3053
  "text": "Could not load the Board Room just now.",
3054
3054
  "file": "modules/hall-ui/public/government.js",
3055
- "line": 725,
3055
+ "line": 774,
3056
3056
  "origin": "js-markup",
3057
3057
  "confidence": "certain"
3058
3058
  },
@@ -3061,7 +3061,7 @@
3061
3061
  "surface": "builders-hall",
3062
3062
  "text": "Could not load the constitution just now.",
3063
3063
  "file": "modules/hall-ui/public/government.js",
3064
- "line": 549,
3064
+ "line": 598,
3065
3065
  "origin": "js-markup",
3066
3066
  "confidence": "certain"
3067
3067
  },
@@ -3457,7 +3457,7 @@
3457
3457
  "surface": "builders-hall",
3458
3458
  "text": "Could not vote: {…}",
3459
3459
  "file": "modules/hall-ui/public/government.js",
3460
- "line": 689,
3460
+ "line": 738,
3461
3461
  "origin": "js-notice",
3462
3462
  "confidence": "certain"
3463
3463
  },
@@ -4087,7 +4087,7 @@
4087
4087
  "surface": "builders-hall",
4088
4088
  "text": "Full Idea",
4089
4089
  "file": "modules/hall-ui/public/government.js",
4090
- "line": 590,
4090
+ "line": 639,
4091
4091
  "origin": "js-markup",
4092
4092
  "confidence": "certain"
4093
4093
  },
@@ -4348,7 +4348,7 @@
4348
4348
  "surface": "builders-hall",
4349
4349
  "text": "How a project moves between structures over time — summarized from the synthesis:",
4350
4350
  "file": "modules/hall-ui/public/government.js",
4351
- "line": 531,
4351
+ "line": 580,
4352
4352
  "origin": "js-markup",
4353
4353
  "confidence": "certain"
4354
4354
  },
@@ -4366,7 +4366,7 @@
4366
4366
  "surface": "builders-hall",
4367
4367
  "text": "How it has changed",
4368
4368
  "file": "modules/hall-ui/public/government.js",
4369
- "line": 554,
4369
+ "line": 603,
4370
4370
  "origin": "js-markup",
4371
4371
  "confidence": "certain"
4372
4372
  },
@@ -4429,7 +4429,7 @@
4429
4429
  "surface": "builders-hall",
4430
4430
  "text": "How this project is governed — the constitution it runs, the Board Room, and the ranks & permissions that administer it.",
4431
4431
  "file": "modules/hall-ui/public/government.js",
4432
- "line": 791,
4432
+ "line": 840,
4433
4433
  "origin": "js-text-assign",
4434
4434
  "confidence": "certain"
4435
4435
  },
@@ -5509,7 +5509,7 @@
5509
5509
  "surface": "builders-hall",
5510
5510
  "text": "No sitting has closed yet.",
5511
5511
  "file": "modules/hall-ui/public/government.js",
5512
- "line": 734,
5512
+ "line": 783,
5513
5513
  "origin": "js-markup",
5514
5514
  "confidence": "certain"
5515
5515
  },
@@ -5563,7 +5563,7 @@
5563
5563
  "surface": "builders-hall",
5564
5564
  "text": "No votes yet{…}.",
5565
5565
  "file": "modules/hall-ui/public/government.js",
5566
- "line": 608,
5566
+ "line": 657,
5567
5567
  "origin": "js-markup",
5568
5568
  "confidence": "certain"
5569
5569
  },
@@ -6292,7 +6292,7 @@
6292
6292
  "surface": "builders-hall",
6293
6293
  "text": "Ranks & Permissions is the Archon administration surface. The Constitution and the Board Room are yours to read.",
6294
6294
  "file": "modules/hall-ui/public/government.js",
6295
- "line": 775,
6295
+ "line": 824,
6296
6296
  "origin": "js-markup",
6297
6297
  "confidence": "certain"
6298
6298
  },
@@ -6733,7 +6733,7 @@
6733
6733
  "surface": "builders-hall",
6734
6734
  "text": "Restricted.",
6735
6735
  "file": "modules/hall-ui/public/government.js",
6736
- "line": 763,
6736
+ "line": 812,
6737
6737
  "origin": "js-text-assign",
6738
6738
  "confidence": "certain"
6739
6739
  },
@@ -7030,7 +7030,7 @@
7030
7030
  "surface": "builders-hall",
7031
7031
  "text": "Selectable today",
7032
7032
  "file": "modules/hall-ui/public/government.js",
7033
- "line": 531,
7033
+ "line": 580,
7034
7034
  "origin": "js-markup",
7035
7035
  "confidence": "certain"
7036
7036
  },
@@ -7624,7 +7624,7 @@
7624
7624
  "surface": "builders-hall",
7625
7625
  "text": "The developmental ladder: {…}. Formality is adopted only where informal coordination breaks.",
7626
7626
  "file": "modules/hall-ui/public/government.js",
7627
- "line": 537,
7627
+ "line": 586,
7628
7628
  "origin": "js-markup",
7629
7629
  "confidence": "certain"
7630
7630
  },
@@ -7795,7 +7795,7 @@
7795
7795
  "surface": "builders-hall",
7796
7796
  "text": "The seven government structures, for getting a team up to speed before deciding when to flip a switch.",
7797
7797
  "file": "modules/hall-ui/public/government.js",
7798
- "line": 531,
7798
+ "line": 580,
7799
7799
  "origin": "js-markup",
7800
7800
  "confidence": "certain"
7801
7801
  },
@@ -8020,7 +8020,7 @@
8020
8020
  "surface": "builders-hall",
8021
8021
  "text": "This is your idea \\u2014 its author does not vote on it. The board decides; a return comes back to you with its reason.",
8022
8022
  "file": "modules/hall-ui/public/government.js",
8023
- "line": 658,
8023
+ "line": 707,
8024
8024
  "origin": "js-markup",
8025
8025
  "confidence": "certain"
8026
8026
  },
@@ -8236,7 +8236,7 @@
8236
8236
  "surface": "builders-hall",
8237
8237
  "text": "Vote yes",
8238
8238
  "file": "modules/hall-ui/public/government.js",
8239
- "line": 662,
8239
+ "line": 711,
8240
8240
  "origin": "js-markup",
8241
8241
  "confidence": "certain"
8242
8242
  },
@@ -8371,7 +8371,7 @@
8371
8371
  "surface": "builders-hall",
8372
8372
  "text": "What this project runs today",
8373
8373
  "file": "modules/hall-ui/public/government.js",
8374
- "line": 554,
8374
+ "line": 603,
8375
8375
  "origin": "js-markup",
8376
8376
  "confidence": "certain"
8377
8377
  },
@@ -8479,7 +8479,7 @@
8479
8479
  "surface": "builders-hall",
8480
8480
  "text": "Why \\u2014 the reason the author will act on",
8481
8481
  "file": "modules/hall-ui/public/government.js",
8482
- "line": 662,
8482
+ "line": 711,
8483
8483
  "origin": "js-markup",
8484
8484
  "confidence": "certain"
8485
8485
  },
@@ -8623,7 +8623,7 @@
8623
8623
  "surface": "builders-hall",
8624
8624
  "text": "You have not voted on this item. Casting arrives with the vote form.",
8625
8625
  "file": "modules/hall-ui/public/government.js",
8626
- "line": 706,
8626
+ "line": 755,
8627
8627
  "origin": "js-markup",
8628
8628
  "confidence": "certain"
8629
8629
  },
@@ -8650,7 +8650,7 @@
8650
8650
  "surface": "builders-hall",
8651
8651
  "text": "You voted",
8652
8652
  "file": "modules/hall-ui/public/government.js",
8653
- "line": 705,
8653
+ "line": 754,
8654
8654
  "origin": "js-markup",
8655
8655
  "confidence": "certain"
8656
8656
  },
@@ -8956,7 +8956,7 @@
8956
8956
  "surface": "builders-hall",
8957
8957
  "text": "active builder{…}: {…}",
8958
8958
  "file": "modules/hall-ui/public/government.js",
8959
- "line": 531,
8959
+ "line": 580,
8960
8960
  "origin": "js-markup",
8961
8961
  "confidence": "certain"
8962
8962
  },
@@ -9037,7 +9037,7 @@
9037
9037
  "surface": "builders-hall",
9038
9038
  "text": "as configuration: {…}",
9039
9039
  "file": "modules/hall-ui/public/government.js",
9040
- "line": 525,
9040
+ "line": 574,
9041
9041
  "origin": "js-markup",
9042
9042
  "confidence": "certain"
9043
9043
  },
@@ -9122,6 +9122,15 @@
9122
9122
  "origin": "js-markup",
9123
9123
  "confidence": "certain"
9124
9124
  },
9125
+ {
9126
+ "id": "b73a83223bc0",
9127
+ "surface": "builders-hall",
9128
+ "text": "cannot be read, so",
9129
+ "file": "modules/hall-ui/public/government.js",
9130
+ "line": 482,
9131
+ "origin": "js-markup",
9132
+ "confidence": "certain"
9133
+ },
9125
9134
  {
9126
9135
  "id": "3a49b4974af1",
9127
9136
  "surface": "builders-hall",
@@ -9172,7 +9181,7 @@
9172
9181
  "surface": "builders-hall",
9173
9182
  "text": "documented for later",
9174
9183
  "file": "modules/hall-ui/public/government.js",
9175
- "line": 523,
9184
+ "line": 572,
9176
9185
  "origin": "js-markup",
9177
9186
  "confidence": "certain"
9178
9187
  },
@@ -9235,7 +9244,7 @@
9235
9244
  "surface": "builders-hall",
9236
9245
  "text": "first vote",
9237
9246
  "file": "modules/hall-ui/public/government.js",
9238
- "line": 451,
9247
+ "line": 500,
9239
9248
  "origin": "js-markup",
9240
9249
  "confidence": "certain"
9241
9250
  },
@@ -9487,7 +9496,7 @@
9487
9496
  "surface": "builders-hall",
9488
9497
  "text": "means expressible through the real configuration — a rank-based membership plus a pass rule — with no new code. This project has",
9489
9498
  "file": "modules/hall-ui/public/government.js",
9490
- "line": 531,
9499
+ "line": 580,
9491
9500
  "origin": "js-markup",
9492
9501
  "confidence": "certain"
9493
9502
  },
@@ -9550,7 +9559,7 @@
9550
9559
  "surface": "builders-hall",
9551
9560
  "text": "no clock",
9552
9561
  "file": "modules/hall-ui/public/government.js",
9553
- "line": 461,
9562
+ "line": 510,
9554
9563
  "origin": "js-markup",
9555
9564
  "confidence": "certain"
9556
9565
  },
@@ -9613,7 +9622,7 @@
9613
9622
  "surface": "builders-hall",
9614
9623
  "text": "objected to",
9615
9624
  "file": "modules/hall-ui/public/government.js",
9616
- "line": 615,
9625
+ "line": 664,
9617
9626
  "origin": "js-markup",
9618
9627
  "confidence": "certain"
9619
9628
  },
@@ -9698,6 +9707,15 @@
9698
9707
  "origin": "js-markup",
9699
9708
  "confidence": "certain"
9700
9709
  },
9710
+ {
9711
+ "id": "c38db02adb61",
9712
+ "surface": "builders-hall",
9713
+ "text": "or above",
9714
+ "file": "modules/hall-ui/public/government.js",
9715
+ "line": 494,
9716
+ "origin": "js-markup",
9717
+ "confidence": "certain"
9718
+ },
9701
9719
  {
9702
9720
  "id": "66f7e9c2dde0",
9703
9721
  "surface": "builders-hall",
@@ -9748,7 +9766,7 @@
9748
9766
  "surface": "builders-hall",
9749
9767
  "text": "recommended at your headcount",
9750
9768
  "file": "modules/hall-ui/public/government.js",
9751
- "line": 524,
9769
+ "line": 573,
9752
9770
  "origin": "js-markup",
9753
9771
  "confidence": "certain"
9754
9772
  },
@@ -9802,7 +9820,7 @@
9802
9820
  "surface": "builders-hall",
9803
9821
  "text": "selectable today",
9804
9822
  "file": "modules/hall-ui/public/government.js",
9805
- "line": 522,
9823
+ "line": 571,
9806
9824
  "origin": "js-markup",
9807
9825
  "confidence": "certain"
9808
9826
  },
@@ -9838,7 +9856,7 @@
9838
9856
  "surface": "builders-hall",
9839
9857
  "text": "tally: {…} yes · {…} no — sitting under",
9840
9858
  "file": "modules/hall-ui/public/government.js",
9841
- "line": 707,
9859
+ "line": 756,
9842
9860
  "origin": "js-markup",
9843
9861
  "confidence": "certain"
9844
9862
  },
@@ -10054,7 +10072,7 @@
10054
10072
  "surface": "builders-hall",
10055
10073
  "text": "your vote",
10056
10074
  "file": "modules/hall-ui/public/government.js",
10057
- "line": 612,
10075
+ "line": 661,
10058
10076
  "origin": "js-markup",
10059
10077
  "confidence": "certain"
10060
10078
  },
@@ -10153,7 +10171,7 @@
10153
10171
  "surface": "builders-hall",
10154
10172
  "text": "{…} minutes",
10155
10173
  "file": "modules/hall-ui/public/government.js",
10156
- "line": 466,
10174
+ "line": 515,
10157
10175
  "origin": "js-markup",
10158
10176
  "confidence": "certain"
10159
10177
  },
@@ -10234,7 +10252,7 @@
10234
10252
  "surface": "builders-hall",
10235
10253
  "text": "{…} proposed",
10236
10254
  "file": "modules/hall-ui/public/government.js",
10237
- "line": 478,
10255
+ "line": 527,
10238
10256
  "origin": "js-markup",
10239
10257
  "confidence": "certain"
10240
10258
  },
@@ -10549,7 +10567,7 @@
10549
10567
  "surface": "builders-hall",
10550
10568
  "text": "— {…}{…}",
10551
10569
  "file": "modules/hall-ui/public/government.js",
10552
- "line": 526,
10570
+ "line": 575,
10553
10571
  "origin": "js-markup",
10554
10572
  "confidence": "certain"
10555
10573
  },
@@ -3,8 +3,8 @@
3
3
  // modules/government/board-membership.js — WHO SITS ON THE BOARD, right now
4
4
  // (BV1.R06 of goal 1000069, ADR 0175).
5
5
  //
6
- // Resolves the constitution's membership predicate (R05: `board.membership`,
7
- // grammar `rank:<key>`) to the live member list. Consumed by R07 (who to
6
+ // Resolves the constitution's membership predicate (R05: `board.membership`;
7
+ // the grammar lives in ./membership-predicate) to the live member list. Consumed by R07 (who to
8
8
  // announce a window to), R08 (the live membership re-check before a vote
9
9
  // counts), and R10 (the tally's denominator).
10
10
  //
@@ -34,22 +34,13 @@
34
34
 
35
35
  const db = require('./db');
36
36
 
37
- // The one predicate grammar v1 defines, mirrored from config.js's MEMBERSHIP_RE
38
- // (the two must agree; tests/government_board_membership.mjs pins that). The
39
- // rank key is validated by SHAPE only — whether it names a live rank is the
40
- // DB's question, answered by the query returning nobody for a key with no
41
- // assignment rows. Config deliberately cannot see custom ranks (R05), so this
42
- // is where `rank:some-custom-rank` becomes real or resolves to an empty board.
43
- const PREDICATE_RE = /^rank:([a-z][a-z0-9_-]*)$/;
44
-
45
- // Pure: predicate string -> { rankKey } or null. Null means "unparseable", and
46
- // every caller must treat null as NOBODY. Exported so R07/R08 can validate a
47
- // snapshotted constitution's predicate without touching the DB.
48
- function parseMembershipPredicate(predicate) {
49
- if (typeof predicate !== 'string') return null;
50
- const m = PREDICATE_RE.exec(predicate);
51
- return m ? Object.freeze({ rankKey: m[1] }) : null;
52
- }
37
+ // The predicate GRAMMAR lives in ./membership-predicate — one parser, shared with
38
+ // R05's config sanitizer (task 1003094). It used to be written twice, as a shape
39
+ // regex there and a parse regex here, pinned only by a property test; the moment
40
+ // the grammar gained a rule a regex cannot express (`+` is legal only on a rank
41
+ // that HAS a ladder position) two hand-written copies became the same drift that
42
+ // produced the bug this fixes. Read that file for the grammar and the reasoning.
43
+ const { parseMembershipPredicate } = require('./membership-predicate');
53
44
 
54
45
  // The resolve entry point: constitution board block -> the live member list, as
55
46
  // a frozen array of { builder_id, github_login, display_name } rows sorted by
@@ -60,7 +51,7 @@ function parseMembershipPredicate(predicate) {
60
51
  async function resolveBoardMembers(board) {
61
52
  const parsed = parseMembershipPredicate(board && board.membership);
62
53
  if (!parsed) return Object.freeze([]); // unparseable predicate → nobody governs
63
- const rows = await db.getActiveBuildersWithRank(parsed.rankKey);
54
+ const rows = await db.getActiveBuildersWithRank(parsed.rankKeys);
64
55
  return Object.freeze(rows.map((r) => Object.freeze({
65
56
  builder_id: String(r.builder_id),
66
57
  github_login: r.github_login,
@@ -75,11 +66,20 @@ async function isBoardMember(builderId, board) {
75
66
  if (builderId === null || builderId === undefined || builderId === '') return false;
76
67
  const parsed = parseMembershipPredicate(board && board.membership);
77
68
  if (!parsed) return false;
78
- return db.builderHoldsRankActive(builderId, parsed.rankKey);
69
+ return db.builderHoldsRankActive(builderId, parsed.rankKeys);
70
+ }
71
+
72
+ // Would `builderId` sit on the board under this PROPOSED board block? Same
73
+ // question as isBoardMember, named for its one caller — R14's disenfranchisement
74
+ // guard (board.js) — so the intent is legible where it is used and nobody has to
75
+ // infer "we are asking about a constitution that is not in force yet".
76
+ async function wouldBeBoardMember(builderId, proposedBoard) {
77
+ return isBoardMember(builderId, proposedBoard);
79
78
  }
80
79
 
81
80
  module.exports = {
82
81
  parseMembershipPredicate,
83
82
  resolveBoardMembers,
84
83
  isBoardMember,
84
+ wouldBeBoardMember,
85
85
  };
@@ -48,7 +48,12 @@ const db = require('./db');
48
48
  // load and un-patchable).
49
49
  const governmentConfig = require('./config');
50
50
  const { loadGovernmentConfig, PASS_RULES, MAX_WINDOW_MINUTES } = governmentConfig;
51
- const { isBoardMember, resolveBoardMembers, parseMembershipPredicate } = require('./board-membership');
51
+ const {
52
+ isBoardMember, resolveBoardMembers, parseMembershipPredicate, wouldBeBoardMember,
53
+ } = require('./board-membership');
54
+ // describeMembership renders a predicate in the words the proposer was thinking
55
+ // in ("metic and above"), for the disenfranchisement refusal (task 1003094).
56
+ const { describeMembership } = require('./membership-predicate');
52
57
 
53
58
  // ADR 0175 §5: the completeness bar a Full Idea must clear to reach the board.
54
59
  // Held equal to economy's IDEA_FULL_PASS_BAR by test — see the header.
@@ -462,7 +467,15 @@ function validateProposedConstitution(raw) {
462
467
  if (!known.includes(k)) return { ok: false, reason: 'unknown_field', field: k };
463
468
  }
464
469
  if (!parseMembershipPredicate(raw.membership)) {
465
- return { ok: false, reason: 'bad_membership', expected: "the predicate grammar rank:<key>" };
470
+ return {
471
+ ok: false,
472
+ reason: 'bad_membership',
473
+ // The refusal TEACHES the grammar, because the person hitting it is trying
474
+ // to write a constitution and the commonest reason to land here since task
475
+ // 1003094 is reaching for "and above" — the exact thing that used to be
476
+ // inexpressible and cost an Archon their own board seat.
477
+ expected: 'rank:<key> (exactly that rank), rank:<key>+ (that rank and above, standard ranks only), or a comma list such as rank:metic,archon',
478
+ };
466
479
  }
467
480
  if (!PASS_RULES.includes(raw.pass_rule)) {
468
481
  return { ok: false, reason: 'bad_pass_rule', expected: PASS_RULES };
@@ -493,10 +506,51 @@ function validateProposedConstitution(raw) {
493
506
  // anti-self note). Concurrent amendment windows are legal like any concurrent
494
507
  // windows; applies land in close order and the last close wins, deterministic
495
508
  // because applyBoardAmendment rewrites the whole block.
496
- async function proposeAmendment({ proposal, rationaleMd = null, proposedBy } = {}) {
509
+ async function proposeAmendment({
510
+ proposal, rationaleMd = null, proposedBy, acknowledgeSelfRemoval = false,
511
+ } = {}) {
497
512
  const v = validateProposedConstitution(proposal);
498
513
  if (!v.ok) return { ok: false, ...v };
499
514
  if (proposedBy == null || proposedBy === '') return { ok: false, reason: 'missing_proposer' };
515
+
516
+ // ── THE DISENFRANCHISEMENT GUARD (task 1003094) ──────────────────────────
517
+ //
518
+ // A change to the government that removes the person making it is the ONE
519
+ // shape that cannot self-correct: once it lands they can no longer vote to
520
+ // change it back. This is not hypothetical — it is what happened on the R25
521
+ // rehearsal.
522
+ // An Archon ratified `rank:metic` under monarchy, the apply worked perfectly,
523
+ // and the next vote came back 403 not_board_member. The board would have
524
+ // deadlocked at one member, because an Archon can still PROPOSE
525
+ // (board.item.open floors at metic) but no longer VOTE.
526
+ //
527
+ // REFUSE BY DEFAULT, with an explicit acknowledgement to proceed — the owner's
528
+ // "refuse or loudly confirm". Not a hard ban: handing the franchise to a wider
529
+ // body and stepping back is a legitimate constitutional act, and a system that
530
+ // cannot express it is telling owners what they may decide. The flag is a
531
+ // SEPARATE PARAMETER, never a field on the proposal, so the acknowledgement
532
+ // never becomes part of the ratified text (validateProposedConstitution
533
+ // refuses unknown fields, which keeps that honest).
534
+ //
535
+ // Checked against the PROPOSED block and the proposer's LIVE rank, per request
536
+ // and uncached like every other membership read here.
537
+ if (!acknowledgeSelfRemoval) {
538
+ const stillSeated = await wouldBeBoardMember(proposedBy, v.proposal);
539
+ if (!stillSeated) {
540
+ return {
541
+ ok: false,
542
+ reason: 'proposer_would_lose_their_seat',
543
+ membership: v.proposal.membership,
544
+ describes: describeMembership(v.proposal.membership),
545
+ what_this_means:
546
+ 'This amendment would remove YOU from the board. If it passes you could no longer vote — including to undo it. '
547
+ + 'If that is deliberate, re-submit with acknowledge_self_removal.',
548
+ // The commonest cause, named, because the fix is usually one character.
549
+ hint: 'A bare rank:<key> means EXACTLY that rank. To keep yourself and everyone above, use rank:<key>+.',
550
+ };
551
+ }
552
+ }
553
+
500
554
  const amendment = await db.createAmendment({ proposed: v.proposal, rationaleMd, proposedBy });
501
555
  const { board } = loadGovernmentConfig();
502
556
  const item = await db.openBoardItem({
@@ -154,13 +154,19 @@ const BOARD_DEFAULTS = Object.freeze({
154
154
  // default and what an unreadable value falls back to.
155
155
  const PASS_RULES = Object.freeze(['first_ratifier', 'consent']);
156
156
 
157
- // A membership predicate is `rank:<key>` — the only grammar v1 defines. It is NOT
158
- // validated against catalog.RANK_ORDER on purpose: a project may govern by a CUSTOM
159
- // rank, and custom ranks are DB rows this file cannot see (checking here would make
160
- // config authoritative over a live rank list, which is exactly the inversion the
161
- // header forbids). R06 resolves the predicate against the live ranks and yields an
162
- // empty board if it names nothing — fail-closed there, shape-checked here.
163
- const MEMBERSHIP_RE = /^rank:[a-z][a-z0-9_-]*$/;
157
+ // A membership predicate names WHO SITS. The grammar and its parser live in
158
+ // ./membership-predicate — ONE implementation, shared with R06's resolver
159
+ // (board-membership.js). They used to be two hand-written regexes pinned only by
160
+ // a property test; task 1003094 added a rule a regex cannot express (`+` is legal
161
+ // only on a rank that HAS a ladder position), and two copies of a rule with a
162
+ // lookup in it is precisely the drift that produced the bug that task fixes.
163
+ //
164
+ // Still NOT validated against a live rank list, and that is unchanged: a project
165
+ // may govern by a CUSTOM rank, which is a DB row this file cannot see. Checking
166
+ // that here would make config authoritative over the live ranks — the inversion
167
+ // the header forbids. R06 resolves against the real rows and yields an empty
168
+ // board if the predicate names nobody: fail-closed there, shape-checked here.
169
+ const { isMembershipPredicate } = require('./membership-predicate');
164
170
 
165
171
  // A sitting longer than a week is indistinguishable from no clock at all, and a
166
172
  // zero/negative window would close every item the instant it opened. Both are far
@@ -178,7 +184,7 @@ const MAX_WINDOW_MINUTES = 7 * 24 * 60;
178
184
  // config cannot quietly enfranchise anyone or lower the bar.
179
185
  function sanitizeBoard(raw) {
180
186
  const r = isObj(raw) ? raw : {};
181
- const membership = typeof r.membership === 'string' && MEMBERSHIP_RE.test(r.membership)
187
+ const membership = typeof r.membership === 'string' && isMembershipPredicate(r.membership)
182
188
  ? r.membership
183
189
  : BOARD_DEFAULTS.membership;
184
190
  const passRule = PASS_RULES.includes(r.pass_rule) ? r.pass_rule : BOARD_DEFAULTS.pass_rule;
@@ -252,31 +252,42 @@ async function unassignRank({ builderId, rankKey }) {
252
252
  // that is circumstance, not a rule; this makes it structural, mirroring the
253
253
  // resolver's principal-key drop (R98).
254
254
 
255
- async function getActiveBuildersWithRank(rankKey) {
255
+ // Both reads take a SET of rank keys, not one key (task 1003094). The board
256
+ // predicate grammar gained `rank:metic+` (ladder) and `rank:a,b` (union), and
257
+ // board-membership.js resolves every form — including the bare one — to a set.
258
+ // `= ANY($1)` is the whole change; the exact-match behaviour of a one-element
259
+ // set is byte-identical to the old `= $1`, which is why no ratified constitution
260
+ // changes meaning.
261
+ //
262
+ // DISTINCT is deliberate and not defensive noise: a set can now match a builder
263
+ // through more than one key, and government_builder_ranks does not structurally
264
+ // forbid a second row. Without it a member could be counted twice in the tally's
265
+ // denominator, which silently raises the bar a consent vote has to clear.
266
+ async function getActiveBuildersWithRank(rankKeys) {
256
267
  const { rows } = await pool.query(
257
- `SELECT br.builder_id, b.github_login, b.display_name
268
+ `SELECT DISTINCT br.builder_id, b.github_login, b.display_name
258
269
  FROM government_builder_ranks br
259
270
  JOIN builders b ON b.id = br.builder_id
260
- WHERE br.rank_key = $1
271
+ WHERE br.rank_key = ANY($1)
261
272
  AND b.status = 'active'
262
273
  AND b.system_role IS NULL
263
274
  ORDER BY br.builder_id`,
264
- [rankKey],
275
+ [rankKeys],
265
276
  );
266
277
  return rows;
267
278
  }
268
279
 
269
- async function builderHoldsRankActive(builderId, rankKey) {
280
+ async function builderHoldsRankActive(builderId, rankKeys) {
270
281
  const { rows } = await pool.query(
271
282
  `SELECT 1
272
283
  FROM government_builder_ranks br
273
284
  JOIN builders b ON b.id = br.builder_id
274
285
  WHERE br.builder_id = $1
275
- AND br.rank_key = $2
286
+ AND br.rank_key = ANY($2)
276
287
  AND b.status = 'active'
277
288
  AND b.system_role IS NULL
278
289
  LIMIT 1`,
279
- [builderId, rankKey],
290
+ [builderId, rankKeys],
280
291
  );
281
292
  return rows.length > 0;
282
293
  }