@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.
@@ -0,0 +1,115 @@
1
+ 'use strict';
2
+
3
+ // modules/government/membership-predicate.js — the board membership predicate
4
+ // GRAMMAR, in one place (task 1003094).
5
+ //
6
+ // WHY THIS FILE EXISTS AT ALL. The grammar was previously written TWICE: a
7
+ // shape regex in config.js (R05's sanitizer) and a parse regex in
8
+ // board-membership.js (R06's resolver), pinned together only by a property test
9
+ // asserting "config keeps it ⇔ the resolver parses it". That worked while the
10
+ // grammar was one literal form. It stops working the moment the grammar has a
11
+ // rule the regex alone cannot express — and this change adds exactly that: `+`
12
+ // is legal only on a rank that HAS a ladder position, which is a lookup, not a
13
+ // character class. Two hand-written copies of a rule with a lookup in it is the
14
+ // same drift that produced the bug this task fixes. So: one parser, two callers.
15
+ //
16
+ // PURE, and requires only ./catalog (which requires nothing), so config.js can
17
+ // use it during boot without dragging in the DB layer.
18
+ //
19
+ // ── THE GRAMMAR ──────────────────────────────────────────────────────────────
20
+ //
21
+ // rank:metic exactly metic (v1 form, unchanged)
22
+ // rank:metic+ metic and every rank above it (ladder)
23
+ // rank:metic,archon exactly these ranks (union)
24
+ // rank:xenos+,ops the ladder from xenos, plus a custom rank
25
+ //
26
+ // THE BUG IT FIXES. v1 defined only `rank:<key>` and resolved it with an EXACT
27
+ // SQL match, so `rank:metic` seated metics ONLY and excluded Archons,
28
+ // `rank:xenos` seated NEWCOMERS ONLY rather than "everyone", and "Metic and
29
+ // above" could not be said at all. On a real-Postgres rehearsal an Archon
30
+ // ratified `rank:metic` and was immediately 403'd off their own board.
31
+ //
32
+ // A BARE KEY STILL MEANS EXACTLY THAT KEY — the owner's decision (2026-08-18),
33
+ // and the reason this change is meaning-preserving: no constitution already on
34
+ // disk, including the shipped monarchy default `rank:archon`, resolves to a
35
+ // different board after an upgrade. Redefining the bare form as "and above"
36
+ // would have silently rewritten every ratified constitution in the fleet.
37
+ //
38
+ // `+` IS DEFINED ONLY ON THE STANDARD LADDER. RANK_ORDER is the four-rung
39
+ // standard ladder; a project's CUSTOM ranks are DB rows with no position on it.
40
+ // "Metic and above" is a real question; "and above" about a rank that sits on no
41
+ // ladder is not, so `rank:some-custom+` is a REFUSAL rather than a guess. A bare
42
+ // custom key is unaffected and still resolves against live rows.
43
+ //
44
+ // FAIL-CLOSED: anything unparseable yields null, and every caller must read null
45
+ // as NOBODY. An unrecognised predicate must never widen the franchise.
46
+
47
+ const { RANK_ORDER } = require('./catalog');
48
+
49
+ const PREFIX = 'rank:';
50
+ // One element: a rank key, optionally suffixed `+`.
51
+ const ELEMENT_RE = /^([a-z][a-z0-9_-]*)(\+?)$/;
52
+
53
+ // Expand one element to the keys it names, or null if it names nothing sayable.
54
+ function expandElement(key, plus) {
55
+ if (!plus) return [key];
56
+ const i = RANK_ORDER.indexOf(key);
57
+ if (i === -1) return null; // no ladder position → no "above"
58
+ return RANK_ORDER.slice(i);
59
+ }
60
+
61
+ // predicate string -> { rankKeys } (frozen, deduped, deterministic) or null.
62
+ //
63
+ // The answer is always a SET, including for the bare single-rank form — a
64
+ // one-element set resolves byte-identically to the old exact match, which is
65
+ // what keeps existing constitutions meaning what they meant.
66
+ function parseMembershipPredicate(predicate) {
67
+ if (typeof predicate !== 'string') return null;
68
+ if (!predicate.startsWith(PREFIX)) return null;
69
+ const body = predicate.slice(PREFIX.length);
70
+ if (body === '') return null;
71
+
72
+ const keys = [];
73
+ for (const element of body.split(',')) {
74
+ const m = ELEMENT_RE.exec(element);
75
+ if (!m) return null;
76
+ const expanded = expandElement(m[1], m[2] === '+');
77
+ if (expanded === null) return null;
78
+ for (const k of expanded) if (!keys.includes(k)) keys.push(k);
79
+ }
80
+ if (keys.length === 0) return null;
81
+ return Object.freeze({ rankKeys: Object.freeze(keys) });
82
+ }
83
+
84
+ // The shape gate R05's sanitizer needs. Same function, boolean-shaped — so the
85
+ // sanitizer and the resolver can never disagree about what is legal, because
86
+ // there is nothing left for them to disagree with.
87
+ function isMembershipPredicate(predicate) {
88
+ return parseMembershipPredicate(predicate) !== null;
89
+ }
90
+
91
+ // Human phrasing for a predicate, for the surfaces that must tell an owner what
92
+ // they are about to enact (R14's guard message, and the charter library in task
93
+ // 1003095). Returns null for an unparseable predicate rather than inventing
94
+ // reassuring prose about something that seats nobody.
95
+ function describeMembership(predicate) {
96
+ const parsed = parseMembershipPredicate(predicate);
97
+ if (!parsed) return null;
98
+ const keys = parsed.rankKeys;
99
+ if (keys.length === 1) return `exactly ${keys[0]}`;
100
+ // A contiguous run up the standard ladder reads as "X and above" — the way the
101
+ // person who typed `metic+` was thinking about it.
102
+ const i = RANK_ORDER.indexOf(keys[0]);
103
+ const isLadderRun = i !== -1
104
+ && keys.length === RANK_ORDER.length - i
105
+ && keys.every((k, n) => k === RANK_ORDER[i + n]);
106
+ if (isLadderRun) return `${keys[0]} and above (${keys.join(', ')})`;
107
+ return keys.join(', ');
108
+ }
109
+
110
+ module.exports = {
111
+ RANK_ORDER,
112
+ parseMembershipPredicate,
113
+ isMembershipPredicate,
114
+ describeMembership,
115
+ };
@@ -461,6 +461,10 @@ module.exports = function buildGovernmentRouter() {
461
461
  subject_type: { required: true, type: 'string', maxLength: 32 },
462
462
  proposal: { required: true, type: 'object' },
463
463
  rationale_md: { type: 'string', maxLength: 5000 },
464
+ // Task 1003094's disenfranchisement guard. A SEPARATE FIELD, never part of
465
+ // `proposal` — validateProposedConstitution refuses unknown fields, so the
466
+ // acknowledgement can never leak into the text the board ratifies.
467
+ acknowledge_self_removal: { type: 'boolean' },
464
468
  })) return;
465
469
  if (req.body.subject_type !== 'constitutional_amendment') {
466
470
  return res.fail('subject_opens_automatically', 409, {
@@ -473,8 +477,23 @@ module.exports = function buildGovernmentRouter() {
473
477
  proposal: req.body.proposal,
474
478
  rationaleMd: req.body.rationale_md ?? null,
475
479
  proposedBy: req.builder.id,
480
+ acknowledgeSelfRemoval: req.body.acknowledge_self_removal === true,
476
481
  });
477
482
  if (!out.ok) {
483
+ // The self-removal refusal is a CONFLICT with live state, not a malformed
484
+ // body — the proposal is perfectly valid, it just would unseat its author.
485
+ // It carries prose the hall shows verbatim, so it gets its own code and
486
+ // keeps its explanatory fields rather than being flattened into the
487
+ // generic invalid_proposal envelope.
488
+ if (out.reason === 'proposer_would_lose_their_seat') {
489
+ return res.fail('proposer_would_lose_their_seat', 409, {
490
+ membership: out.membership,
491
+ describes: out.describes,
492
+ what_this_means: out.what_this_means,
493
+ hint: out.hint,
494
+ retry_with: { acknowledge_self_removal: true },
495
+ });
496
+ }
478
497
  return res.fail('invalid_proposal', 400, { reason: out.reason, ...(out.field ? { field: out.field } : {}), ...(out.expected ? { expected: out.expected } : {}) });
479
498
  }
480
499
  res.status(201).json({ ok: true, amendment: out.amendment, item: out.item });
@@ -438,12 +438,61 @@
438
438
  // switch can never be flipped quietly, and every change is auditable against
439
439
  // the constitution of its day.
440
440
 
441
+ // The predicate grammar, in plain language (task 1003094). This MIRRORS
442
+ // modules/government/membership-predicate.js — browser code cannot import the
443
+ // server module, the established pattern here (the five section labels do the
444
+ // same) — and tests/government_board_vote_ui.mjs holds the two equal.
445
+ //
446
+ // Rendering the raw predicate was fine while the only form was `rank:<key>`.
447
+ // It is not fine now: `rank:metic+` would read as "builders holding the metic+
448
+ // rank", which names a rank that does not exist and hides the very thing the
449
+ // suffix says. A constitution panel that misdescribes who governs is the
450
+ // failure this whole task exists to fix, one layer up.
451
+ const STANDARD_LADDER = ['xenos', 'thetes', 'metic', 'archon'];
452
+
453
+ function membershipRankKeys(predicate) {
454
+ const m = String(predicate || '');
455
+ if (!m.startsWith('rank:')) return null;
456
+ const body = m.slice(5);
457
+ if (!body) return null;
458
+ const keys = [];
459
+ for (const element of body.split(',')) {
460
+ const match = /^([a-z][a-z0-9_-]*)(\+?)$/.exec(element);
461
+ if (!match) return null;
462
+ let expanded;
463
+ if (match[2] === '+') {
464
+ const i = STANDARD_LADDER.indexOf(match[1]);
465
+ if (i === -1) return null; // "and above" is undefined off the ladder
466
+ expanded = STANDARD_LADDER.slice(i);
467
+ } else {
468
+ expanded = [match[1]];
469
+ }
470
+ for (const k of expanded) if (keys.indexOf(k) === -1) keys.push(k);
471
+ }
472
+ return keys.length ? keys : null;
473
+ }
474
+
441
475
  function membershipSentence(board) {
442
- const m = String(board.membership || '');
443
- const rank = m.startsWith('rank:') ? m.slice(5) : null;
444
- return rank
445
- ? `Every active builder holding the <strong>${escapeHtml(rank)}</strong> rank sits on the board — membership follows the rank, live.`
446
- : `Membership predicate: <code>${escapeHtml(m || 'unset')}</code>.`;
476
+ const raw = String(board.membership || '');
477
+ const keys = membershipRankKeys(raw);
478
+ if (!keys) {
479
+ // Fail-closed on the surface too: an unparseable predicate seats NOBODY
480
+ // server-side, and saying so is the whole point — an empty board is loud
481
+ // and fixable, a board described as working is neither.
482
+ return `Membership predicate <code>${escapeHtml(raw || 'unset')}</code> cannot be read, so <strong>nobody</strong> sits on the board until it is corrected.`;
483
+ }
484
+ const strong = keys.map((k) => `<strong>${escapeHtml(k)}</strong>`);
485
+ if (keys.length === 1) {
486
+ return `Every active builder holding the ${strong[0]} rank — and only that rank — sits on the board. Membership follows the rank, live.`;
487
+ }
488
+ const i = STANDARD_LADDER.indexOf(keys[0]);
489
+ const isLadderRun = i !== -1
490
+ && keys.length === STANDARD_LADDER.length - i
491
+ && keys.every((k, n) => k === STANDARD_LADDER[i + n]);
492
+ const list = `${strong.slice(0, -1).join(', ')} and ${strong[strong.length - 1]}`;
493
+ return isLadderRun
494
+ ? `Every active builder at ${strong[0]} <em>or above</em> sits on the board — that is ${list}. Membership follows the rank, live.`
495
+ : `Every active builder holding ${list} sits on the board. Membership follows the rank, live.`;
447
496
  }
448
497
 
449
498
  function passRuleSentence(board) {
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.251",
3
+ "version": "1.19.252",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.251",
9
+ "version": "1.19.252",
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.19.251",
3
+ "version": "1.19.252",
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",
@@ -90,6 +90,11 @@ const INTEGRATION = new Set([
90
90
  // DB-free lane, where they only "passed" because they self-skip. The pairing guard
91
91
  // is in tests/government_live_proof_shape.mjs — every curated entry must name a
92
92
  // real test file.
93
+ // task 1003094: the board FRANCHISE against real SQL. The DB-free sibling covers
94
+ // the grammar; this covers what the task actually changed — the ANY() rewrite,
95
+ // the DISTINCT, and that a deactivated builder or a machine principal still
96
+ // never governs under the widened predicate.
97
+ 'government_board_membership_db',
93
98
  'government_fold_bench', 'government_live_proof', 'linkify_refs', 'memory_isolation',
94
99
  'memory_pull', 'needs_rebase', 'parallel-load', 'profile_rollup_consent', 'reward_mode_cost_plus_only',
95
100
  'scouting_rollup_fanout', 'session_records', 'setup_smoke',
package/src/module-api.js CHANGED
@@ -929,8 +929,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
929
929
  // landed since 1.19.249 with no explicit bump. run 32203357427. (task 1002620)
930
930
  // 1.19.251 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
931
931
  // landed since 1.19.250 with no explicit bump. run 32204008770. (task 1002620)
932
+ // 1.19.252 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
933
+ // landed since 1.19.251 with no explicit bump. run 32205191350. (task 1002620)
932
934
  // ---------------------------------------------------------------------------
933
- const CORE_VERSION = '1.19.251'; // CI auto-patch carrier (ADR 0161); see changelog above
935
+ const CORE_VERSION = '1.19.252'; // CI auto-patch carrier (ADR 0161); see changelog above
934
936
 
935
937
  // A namespaced logger so a module's log lines are attributable + consistent.
936
938
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -79,6 +79,10 @@ test('a proposal files the amendment and opens its window under the constitution
79
79
  await withDb({
80
80
  createAmendment: async (args) => ({ id: '5', proposed: args.proposed, rationale_md: args.rationaleMd, proposed_by: args.proposedBy }),
81
81
  openBoardItem: async (args) => { opened = args; return { id: '21', ...args }; },
82
+ // Task 1003094's disenfranchisement guard asks, before any write, whether the
83
+ // proposer would still sit under the PROPOSED block. Here they would (the
84
+ // proposal keeps rank:archon), so the propose proceeds.
85
+ builderHoldsRankActive: async () => true,
82
86
  }, async () => {
83
87
  const out = await proposeAmendment({ proposal: CONSENT_90, rationaleMd: 'time to open up', proposedBy: '42' });
84
88
  assert.equal(out.ok, true);
@@ -95,12 +99,94 @@ test('an invalid proposal files NOTHING — refused before any write', async ()
95
99
  await withDb({
96
100
  createAmendment: async () => { throw new Error('must not be reached'); },
97
101
  openBoardItem: async () => { throw new Error('must not be reached'); },
102
+ builderHoldsRankActive: async () => true,
98
103
  }, async () => {
99
104
  assert.equal((await proposeAmendment({ proposal: { ...CONSENT_90, pass_rule: 'majority' }, proposedBy: '42' })).ok, false);
100
105
  assert.equal((await proposeAmendment({ proposal: CONSENT_90, proposedBy: null })).reason, 'missing_proposer');
101
106
  });
102
107
  });
103
108
 
109
+ // ── the disenfranchisement guard (task 1003094) ──────────────────────────────
110
+ //
111
+ // The shape that cannot self-correct: an amendment that removes the person
112
+ // making it. Once it lands they can no longer vote — including to undo it. This
113
+ // is not hypothetical. On the R25 rehearsal an Archon ratified `rank:metic`
114
+ // under monarchy, the apply worked perfectly, and the next vote came back 403
115
+ // not_board_member. The board would have deadlocked at one member.
116
+
117
+ test('a proposal that would unseat its own proposer is REFUSED before any write', async () => {
118
+ await withDb({
119
+ createAmendment: async () => { throw new Error('must not be reached — nothing may be written'); },
120
+ openBoardItem: async () => { throw new Error('must not be reached'); },
121
+ // The proposer is an Archon; the proposed block seats metics ONLY.
122
+ builderHoldsRankActive: async (_id, rankKeys) => rankKeys.includes('archon'),
123
+ }, async () => {
124
+ const out = await proposeAmendment({
125
+ proposal: { ...CONSENT_90, membership: 'rank:metic' },
126
+ proposedBy: '42',
127
+ });
128
+ assert.equal(out.ok, false);
129
+ assert.equal(out.reason, 'proposer_would_lose_their_seat');
130
+ assert.match(out.what_this_means, /would remove YOU from the board/);
131
+ assert.match(out.hint, /rank:<key>\+/, 'the refusal names the one-character fix');
132
+ });
133
+ });
134
+
135
+ test('the guard is satisfied by the LADDER form — the fix the hint points at', async () => {
136
+ let wrote = false;
137
+ await withDb({
138
+ createAmendment: async (args) => { wrote = true; return { id: '9', proposed: args.proposed }; },
139
+ openBoardItem: async (args) => ({ id: '31', ...args }),
140
+ builderHoldsRankActive: async (_id, rankKeys) => rankKeys.includes('archon'),
141
+ }, async () => {
142
+ const out = await proposeAmendment({
143
+ proposal: { ...CONSENT_90, membership: 'rank:metic+' },
144
+ proposedBy: '42',
145
+ });
146
+ assert.equal(out.ok, true, 'metic-and-above keeps the Archon seated, so the proposal proceeds');
147
+ assert.equal(wrote, true);
148
+ });
149
+ });
150
+
151
+ test('stepping back is still expressible — with an explicit acknowledgement', async () => {
152
+ // Deliberately not a hard ban: handing the franchise to a wider body and
153
+ // stepping back is a legitimate constitutional act, and a system that cannot
154
+ // express it is telling owners what they may decide.
155
+ let wrote = false;
156
+ await withDb({
157
+ createAmendment: async (args) => { wrote = true; return { id: '10', proposed: args.proposed }; },
158
+ openBoardItem: async (args) => ({ id: '32', ...args }),
159
+ builderHoldsRankActive: async () => { throw new Error('the guard must not even ask when acknowledged'); },
160
+ }, async () => {
161
+ const out = await proposeAmendment({
162
+ proposal: { ...CONSENT_90, membership: 'rank:metic' },
163
+ proposedBy: '42',
164
+ acknowledgeSelfRemoval: true,
165
+ });
166
+ assert.equal(out.ok, true);
167
+ assert.equal(wrote, true);
168
+ });
169
+ });
170
+
171
+ test('the acknowledgement never becomes part of the ratified text', async () => {
172
+ // It is a separate parameter, not a proposal field — and the strict validator
173
+ // refuses unknown fields, which is what keeps that honest.
174
+ const v = validateProposedConstitution({ ...CONSENT_90, acknowledge_self_removal: true });
175
+ assert.equal(v.ok, false);
176
+ assert.equal(v.reason, 'unknown_field');
177
+ assert.equal(v.field, 'acknowledge_self_removal');
178
+ });
179
+
180
+ test('the bad-membership refusal TEACHES the grammar', () => {
181
+ // Whoever lands here is trying to write a constitution, and since task 1003094
182
+ // the commonest reason is reaching for "and above".
183
+ const v = validateProposedConstitution({ ...CONSENT_90, membership: 'rank:metic++' });
184
+ assert.equal(v.ok, false);
185
+ assert.equal(v.reason, 'bad_membership');
186
+ assert.match(v.expected, /rank:<key>\+/);
187
+ assert.match(v.expected, /comma list/);
188
+ });
189
+
104
190
  // ── invariants 2+3: the monarchy act, and the subject-typed anti-self ────────
105
191
 
106
192
  const MONARCHY = Object.freeze({ membership: 'rank:archon', pass_rule: 'first_ratifier', window_minutes: null, close_early_on_full_turnout: true });
@@ -121,7 +207,7 @@ test('the monarchy act end to end: the sole Archon ratifies their OWN amendment
121
207
  let authorAsked = 0;
122
208
  await withDb({
123
209
  getBoardItemForVote: async () => amendmentItem(),
124
- builderHoldsRankActive: async (builderId, rankKey) => builderId === '42' && rankKey === 'archon',
210
+ builderHoldsRankActive: async (builderId, rankKeys) => builderId === '42' && rankKeys.includes('archon'),
125
211
  insertBoardVote: async (args) => { const v = { id: '1', voter_id: args.voterId, direction: args.direction }; votes.push(v); return v; },
126
212
  insertVoteKarma: async () => {},
127
213
  getVotesForItem: async () => votes,
@@ -31,12 +31,61 @@ const { parseMembershipPredicate, resolveBoardMembers, isBoardMember } = members
31
31
 
32
32
  // ── the pure parser ──────────────────────────────────────────────────────────
33
33
 
34
- test('the one v1 grammar parses: rank:<key>', () => {
35
- assert.deepEqual(parseMembershipPredicate('rank:archon'), { rankKey: 'archon' });
36
- assert.deepEqual(parseMembershipPredicate('rank:metic'), { rankKey: 'metic' });
37
- assert.deepEqual(parseMembershipPredicate('rank:triage-helper'), { rankKey: 'triage-helper' },
38
- 'a CUSTOM rank is legal by shape — whether it seats anyone is the DB\'s answer');
39
- assert.deepEqual(parseMembershipPredicate('rank:board_member'), { rankKey: 'board_member' });
34
+ test('a bare rank:<key> still means EXACTLY that key — the meaning-preserving half', () => {
35
+ // The owner's decision (2026-08-18) was EXPLICIT grammar, not a redefinition:
36
+ // every constitution already ratified — including the shipped monarchy default
37
+ // `rank:archon` — must resolve to the same board after an upgrade. Redefining
38
+ // the bare form as "and above" would have silently rewritten the whole fleet.
39
+ assert.deepEqual(parseMembershipPredicate('rank:archon'), { rankKeys: ['archon'] });
40
+ assert.deepEqual(parseMembershipPredicate('rank:metic'), { rankKeys: ['metic'] });
41
+ assert.deepEqual(parseMembershipPredicate('rank:triage-helper'), { rankKeys: ['triage-helper'] },
42
+ 'a CUSTOM rank is legal by shape — whether it seats anyone is the DB answer');
43
+ assert.deepEqual(parseMembershipPredicate('rank:board_member'), { rankKeys: ['board_member'] });
44
+ });
45
+
46
+ test('rank:<key>+ means that rank AND ABOVE — the form whose absence unseated an Archon', () => {
47
+ // THE BUG, in one assertion. Under v1 `rank:metic` was an exact SQL match, so
48
+ // an Archon who ratified it was 403'd off their own board on the next vote,
49
+ // and "Metic and above" could not be said at all.
50
+ assert.deepEqual(parseMembershipPredicate('rank:metic+'), { rankKeys: ['metic', 'archon'] });
51
+ assert.deepEqual(parseMembershipPredicate('rank:xenos+'), { rankKeys: ['xenos', 'thetes', 'metic', 'archon'] },
52
+ 'rank:xenos+ is how you actually say everyone — bare rank:xenos seats NEWCOMERS ONLY');
53
+ assert.deepEqual(parseMembershipPredicate('rank:archon+'), { rankKeys: ['archon'] },
54
+ 'the top of the ladder and above is just the top — no special case needed');
55
+ });
56
+
57
+ test('a comma list is an explicit union', () => {
58
+ assert.deepEqual(parseMembershipPredicate('rank:metic,archon'), { rankKeys: ['metic', 'archon'] });
59
+ assert.deepEqual(parseMembershipPredicate('rank:xenos+,ops'),
60
+ { rankKeys: ['xenos', 'thetes', 'metic', 'archon', 'ops'] },
61
+ 'the standard ladder and a custom rank compose');
62
+ });
63
+
64
+ test('a union dedupes and keeps a deterministic order', () => {
65
+ // The tally denominator is built from this list; a duplicate would raise the
66
+ // bar a consent vote must clear without anyone amending the constitution.
67
+ assert.deepEqual(parseMembershipPredicate('rank:metic+,archon'), { rankKeys: ['metic', 'archon'] });
68
+ assert.deepEqual(parseMembershipPredicate('rank:archon,archon'), { rankKeys: ['archon'] });
69
+ assert.deepEqual(parseMembershipPredicate('rank:metic+,xenos+'),
70
+ { rankKeys: ['metic', 'archon', 'xenos', 'thetes'] },
71
+ 'first occurrence wins its position — deterministic, which is what a denominator needs');
72
+ });
73
+
74
+ test('+ on a rank with NO ladder position is REFUSED, not guessed', () => {
75
+ // "Metic and above" is a real question. "and above" about a rank sitting on no
76
+ // ladder is not, so it fails closed rather than resolving to something
77
+ // plausible. A BARE custom key is unaffected.
78
+ assert.equal(parseMembershipPredicate('rank:triage-helper+'), null);
79
+ assert.equal(parseMembershipPredicate('rank:ops+'), null);
80
+ assert.equal(parseMembershipPredicate('rank:archon,ops+'), null,
81
+ 'one bad element poisons the whole predicate — a partial franchise is not a safe fallback');
82
+ assert.deepEqual(parseMembershipPredicate('rank:triage-helper'), { rankKeys: ['triage-helper'] });
83
+ });
84
+
85
+ test('the parse result is frozen all the way down', () => {
86
+ const parsed = parseMembershipPredicate('rank:metic+');
87
+ assert.ok(Object.isFrozen(parsed));
88
+ assert.ok(Object.isFrozen(parsed.rankKeys), 'a caller must not be able to widen the franchise in place');
40
89
  });
41
90
 
42
91
  test('everything else parses to null — the fail-closed value', () => {
@@ -47,9 +96,15 @@ test('everything else parses to null — the fail-closed value', () => {
47
96
  'RANK:ARCHON', // grammar is lowercase
48
97
  'rank:Archon',
49
98
  'role:builder', // the rejected role-chamber model — must never parse
50
- 'rank:archon;metic', // no lists in v1
99
+ 'rank:archon;metic', // ';' is not the list separator
51
100
  'rank: archon', // no whitespace
52
101
  'builder:3', // naming a PERSON is config-as-authority — refused by shape
102
+ 'rank:archon,', // trailing separator
103
+ 'rank:,archon', // leading separator
104
+ 'rank:archon,,metic', // empty element
105
+ 'rank:metic++', // one suffix, not a pile
106
+ 'rank:+metic', // the suffix is a suffix
107
+ 'rank:metic+archon', // '+' modifies, it does not join
53
108
  ];
54
109
  for (const p of junk) {
55
110
  assert.equal(parseMembershipPredicate(p), null, `${JSON.stringify(p)} must not parse`);
@@ -89,8 +144,12 @@ const MONARCHY = Object.freeze({ ...BOARD_DEFAULTS });
89
144
 
90
145
  test('resolveBoardMembers: the happy path — rows come back frozen, ids as strings', async () => {
91
146
  await withDb({
92
- getActiveBuildersWithRank: async (rankKey) => {
93
- assert.equal(rankKey, 'archon', 'the predicate\'s rank key reaches the query verbatim');
147
+ getActiveBuildersWithRank: async (rankKeys) => {
148
+ // The db read takes a SET since task 1003094 — every predicate form,
149
+ // including the bare one, resolves to one. A one-element set queries
150
+ // byte-identically to the old exact match, which is what keeps every
151
+ // already-ratified constitution meaning what it meant.
152
+ assert.deepEqual(rankKeys, ['archon'], 'the predicate rank set reaches the query verbatim');
94
153
  return [
95
154
  { builder_id: 42n, github_login: 'first-member', display_name: 'First Member' },
96
155
  { builder_id: '1000099', github_login: 'second-member', display_name: 'Second Member' },
@@ -117,6 +176,38 @@ test('an unparseable predicate resolves to NOBODY without touching the db', asyn
117
176
  });
118
177
  });
119
178
 
179
+ test('a ladder predicate reaches the query as the whole expanded set', async () => {
180
+ // The end-to-end shape of the fix: `rank:metic+` must arrive at the SQL as
181
+ // BOTH metic and archon, or the Archon is off the board exactly as before.
182
+ let seen = null;
183
+ await withDb({
184
+ getActiveBuildersWithRank: async (rankKeys) => {
185
+ seen = rankKeys;
186
+ return [{ builder_id: '7', github_login: 'a-metic', display_name: 'A Metic' },
187
+ { builder_id: '8', github_login: 'an-archon', display_name: 'An Archon' }];
188
+ },
189
+ }, async () => {
190
+ const members = await resolveBoardMembers({ ...MONARCHY, membership: 'rank:metic+' });
191
+ assert.deepEqual(seen, ['metic', 'archon']);
192
+ assert.equal(members.length, 2);
193
+ });
194
+ });
195
+
196
+ test('THE REGRESSION: an Archon stays on the board under rank:metic+', async () => {
197
+ // The literal scenario from the R25 rehearsal. Under v1 this returned false
198
+ // and the owner lost their seat on their own constitution.
199
+ await withDb({
200
+ // A faithful stand-in for the real SQL: the builder holds exactly 'archon',
201
+ // and the query now asks whether that is IN the predicate's set.
202
+ builderHoldsRankActive: async (builderId, rankKeys) => rankKeys.includes('archon'),
203
+ }, async () => {
204
+ assert.equal(await isBoardMember('42', { ...MONARCHY, membership: 'rank:metic+' }), true,
205
+ 'metic-and-above must seat the Archon');
206
+ assert.equal(await isBoardMember('42', { ...MONARCHY, membership: 'rank:metic' }), false,
207
+ 'and the BARE form must still mean exactly metic — that is the compatibility half');
208
+ });
209
+ });
210
+
120
211
  test('a predicate naming a rank with no assignments seats an EMPTY board — loud, not widened', async () => {
121
212
  await withDb({ getActiveBuildersWithRank: async () => [] }, async () => {
122
213
  const members = await resolveBoardMembers({ ...MONARCHY, membership: 'rank:board-of-directors' });
@@ -126,7 +217,7 @@ test('a predicate naming a rank with no assignments seats an EMPTY board — lou
126
217
 
127
218
  test('isBoardMember: fail-closed on a missing builder id, true only on a live row', async () => {
128
219
  await withDb({
129
- builderHoldsRankActive: async (builderId, rankKey) => builderId === '42' && rankKey === 'archon',
220
+ builderHoldsRankActive: async (builderId, rankKeys) => builderId === '42' && rankKeys.includes('archon'),
130
221
  }, async () => {
131
222
  assert.equal(await isBoardMember('42', MONARCHY), true);
132
223
  assert.equal(await isBoardMember('999', MONARCHY), false);