@bongos/core 1.19.251 → 1.19.253

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.
@@ -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
  }
@@ -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) {
@@ -490,15 +539,40 @@
490
539
  // config — a rank-predicate membership plus first_ratifier|consent — so nobody
491
540
  // is misled about what a settings change can and cannot reach. Source: the
492
541
  // Six-Variables synthesis (Verro Labs, Aug 2026), Parts III–IV, via the spec.
542
+ // EVERY SELECTABLE CHARTER CARRIES A MACHINE-CHECKABLE PREDICATE (task 1003095).
543
+ //
544
+ // This library is the ONLY place in the product that tells an owner what to
545
+ // type. That makes a wrong `how` line worse than no library at all — and two
546
+ // of them were wrong. Under the pre-1003094 exact-match grammar,
547
+ // `membership rank:xenos (everyone)` seated NEWCOMERS ONLY: an owner who
548
+ // followed this page handed the board to the least-trusted tier and locked
549
+ // themselves out. Timocratic Republic was true as far as it went and silent
550
+ // about the same surprise.
551
+ //
552
+ // So the claim is no longer prose. `predicate` is the exact string an owner
553
+ // types, and `seatsRanks` is what it must resolve to — checked in
554
+ // tests/government_charter_library.mjs against the REAL parser
555
+ // (modules/government/membership-predicate.js), not against a copy of the
556
+ // intent. If the grammar ever changes underneath this file again, that test
557
+ // reds instead of the page quietly lying.
558
+ //
559
+ // `predicate: null` means the predicate is the owner's to choose (Foundation
560
+ // names their own custom rank), which is honest rather than unchecked — the
561
+ // test asserts such a charter says so in its `how`.
493
562
  const CHARTERS = [
494
563
  { key: 'monarchy', name: 'Monarchy / BDFL', selectable: true,
495
- how: "membership rank:archon · first ratifier · no clock — the shipped default",
564
+ predicate: 'rank:archon', seatsRanks: ['archon'],
565
+ how: 'membership rank:archon · first ratifier · no clock — the shipped default',
496
566
  blurb: 'One trusted executive decides. Fast, legible, and honest at founding scale; the shape every project actually starts in.' },
497
567
  { key: 'timocratic-republic', name: 'Timocratic Republic', selectable: true,
498
- how: 'membership by a trusted rank (e.g. rank:metic) · consent · a timed sitting',
568
+ predicate: 'rank:metic+', seatsRanks: ['metic', 'archon'],
569
+ how: 'membership rank:metic+ (metic and above — Archons included) · consent · a timed sitting',
499
570
  blurb: 'Standing accrues to those actively building: the franchise follows earned rank, not headcount.' },
500
571
  { key: 'direct-democracy', name: 'Direct Democracy', selectable: true,
501
- how: 'membership rank:xenos (everyone) · consent · a timed sitting',
572
+ predicate: 'rank:xenos+', seatsRanks: ['xenos', 'thetes', 'metic', 'archon'],
573
+ // The `+` is load-bearing and is why it is spelled out: a bare rank:xenos
574
+ // seats newcomers ONLY, which is what this line used to say.
575
+ how: 'membership rank:xenos+ (every builder — the + is what makes it everyone) · consent · a timed sitting',
502
576
  blurb: 'Every builder sits on the board. Consent keeps it unblockable: silence passes, one reasoned objection returns.' },
503
577
  { key: 'presidential', name: 'Presidential', selectable: false,
504
578
  blurb: 'An ELECTED executive with a fixed term. Needs election machinery this system does not have yet — documented for later.' },
@@ -507,7 +581,12 @@
507
581
  { key: 'federation', name: 'Federation', selectable: false,
508
582
  blurb: 'Multiple chambers or sub-projects, each self-governing, coordinating through a compact. The >150 shape — documented for later.' },
509
583
  { key: 'foundation', name: 'Foundation', selectable: true,
510
- how: 'a CUSTOM rank as the trustee body (the Ranks tab creates it) · consent · a timed sitting',
584
+ // The one selectable charter whose predicate the OWNER names — a custom
585
+ // rank is a DB row this page cannot know. Deliberately null rather than a
586
+ // made-up example, and the `how` says so: `+` does not apply to a custom
587
+ // rank (it has no ladder position), so a bare key is the correct form here.
588
+ predicate: null, seatsRanks: null,
589
+ how: 'membership rank:<your-trustee-rank> — a CUSTOM rank you create on the Ranks tab, named exactly (no +, a custom rank has no ladder position) · consent · a timed sitting',
511
590
  blurb: 'A written charter carried by a small appointed council. Expressible today by governing through a custom rank.' },
512
591
  ];
513
592
  const TRAJECTORIES = [
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.253",
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.253",
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.253",
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,12 @@ 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)
934
+ // 1.19.253 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
935
+ // landed since 1.19.252 with no explicit bump. run 32205833181. (task 1002620)
932
936
  // ---------------------------------------------------------------------------
933
- const CORE_VERSION = '1.19.251'; // CI auto-patch carrier (ADR 0161); see changelog above
937
+ const CORE_VERSION = '1.19.253'; // CI auto-patch carrier (ADR 0161); see changelog above
934
938
 
935
939
  // A namespaced logger so a module's log lines are attributable + consistent.
936
940
  // 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);