@bongos/core 1.19.250 → 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.
Files changed (36) hide show
  1. package/.bongos-core.json +100 -30
  2. package/clients/bongos-client/index.d.ts +1 -1
  3. package/docs/adr/0179-board-membership-predicate-grammar.md +113 -0
  4. package/docs/adr/README.md +1 -0
  5. package/docs/api/openapi.json +3 -0
  6. package/docs/api-reference.md +1 -1
  7. package/docs/copy-inventory.md +1432 -0
  8. package/docs/copy-registry.json +13311 -0
  9. package/modules/copy-desk/CLAUDE.md +15 -0
  10. package/modules/copy-desk/flags.js +245 -0
  11. package/modules/copy-desk/migrations/copy_desk_001_flags.sql +128 -0
  12. package/modules/copy-desk/module.json +11 -0
  13. package/modules/copy-desk/registry.js +317 -0
  14. package/modules/copy-desk/routes/copy-desk.js +272 -0
  15. package/modules/copy-desk/tests/copy_flags.mjs +246 -0
  16. package/modules/copy-desk/tests/copy_no_cms.mjs +104 -0
  17. package/modules/copy-desk/tests/copy_queue.mjs +257 -0
  18. package/modules/government/board-membership.js +20 -20
  19. package/modules/government/board.js +57 -3
  20. package/modules/government/config.js +14 -8
  21. package/modules/government/db.js +18 -7
  22. package/modules/government/membership-predicate.js +115 -0
  23. package/modules/government/routes/government.js +19 -0
  24. package/modules/hall-ui/public/government.js +54 -5
  25. package/package-lock.json +2 -2
  26. package/package.json +1 -1
  27. package/scripts/gds/publish-manifest.js +9 -0
  28. package/scripts/gds/run-unit-tests.js +5 -0
  29. package/src/module-api.js +5 -1
  30. package/tests/government_board_amendment.mjs +87 -1
  31. package/tests/government_board_membership.mjs +101 -10
  32. package/tests/government_board_membership_db.mjs +150 -0
  33. package/tests/government_board_vote.mjs +5 -3
  34. package/tests/government_constitution_view.mjs +53 -0
  35. package/tests/publish_manifest.mjs +82 -0
  36. package/tests/rank_tier_single_source.mjs +9 -0
@@ -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
  }
@@ -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.250",
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.250",
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.250",
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",
@@ -62,6 +62,7 @@ const PUBLISH_ALLOWLIST = [
62
62
  'modules/autonomy/',
63
63
  'modules/security/',
64
64
  'modules/government/', // the same machinery under its post-ADR-0174 name (goal 1000068, task 1003003). Added in lockstep with the protected-surfaces.json entry: publish_manifest asserts every PROTECTED_GLOB is either topology-excluded or publishable, so registering the glob as protected WITHOUT this line default-denies the module from the published core — the open-source methodology would silently lose its permission machinery at the next release.
65
+ 'modules/copy-desk/', // the artist's loop over user-facing TEXT (goal 1000074, ADR 0178). default:true, so it MUST be here — and note the ASYMMETRY that makes omitting it worse than useless: its hall surface ships regardless, because modules/hall-ui/ is allowlisted as one subtree. A released core carrying the page and the nav entry but not the routes behind them puts a tab in every instance's hall whose every call 404s. The module and its page are only ever added or removed together; tests/publish_manifest.mjs holds that pair.
65
66
  'modules/hall-ui/',
66
67
  'modules/status-ui/',
67
68
  'modules/dev-box/',
@@ -104,6 +105,14 @@ const PUBLISH_ALLOWLIST = [
104
105
  'docs/branding-contract.md',
105
106
  'docs/branding-fill-prompt.md',
106
107
  'docs/design-contract.md',
108
+ // RUNTIME DATA that happens to live under docs/, not documentation (ADR 0178).
109
+ // modules/copy-desk reads the registry on EVERY queue load, and nothing on a
110
+ // deployed box regenerates it — copy-inventory.js is a commit-it generator run
111
+ // against a source tree, which an instance does not have. Omit these and a
112
+ // released instance answers 503 registry_missing forever. They sit under docs/
113
+ // only because that is where their generator has always written them.
114
+ 'docs/copy-registry.json',
115
+ 'docs/copy-inventory.md', // the human twin, generated in the same pass
107
116
  'docs/file-map.md',
108
117
  'docs/repo-map.md',
109
118
  'docs/handoff-template.md', // the portable session-handoff format (methodology)
@@ -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
@@ -927,8 +927,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
927
927
  // landed since 1.19.248 with no explicit bump. run 32199293372. (task 1002620)
928
928
  // 1.19.250 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
929
929
  // landed since 1.19.249 with no explicit bump. run 32203357427. (task 1002620)
930
+ // 1.19.251 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
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)
930
934
  // ---------------------------------------------------------------------------
931
- const CORE_VERSION = '1.19.250'; // 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
932
936
 
933
937
  // A namespaced logger so a module's log lines are attributable + consistent.
934
938
  // Usage: const log = api.logger('dev-box'); log.info('mounted');