@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.
- package/.bongos-core.json +45 -30
- package/clients/bongos-client/index.d.ts +1 -1
- package/docs/adr/0179-board-membership-predicate-grammar.md +113 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +3 -0
- package/docs/api-reference.md +1 -1
- package/docs/copy-inventory.md +46 -44
- package/docs/copy-registry.json +64 -46
- package/modules/government/board-membership.js +20 -20
- package/modules/government/board.js +63 -4
- package/modules/government/config.js +14 -8
- package/modules/government/db.js +18 -7
- package/modules/government/membership-predicate.js +115 -0
- package/modules/government/routes/government.js +19 -0
- package/modules/hall-ui/public/government.js +88 -9
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/run-unit-tests.js +5 -0
- package/src/module-api.js +5 -1
- package/tests/government_board_amendment.mjs +87 -1
- package/tests/government_board_membership.mjs +101 -10
- package/tests/government_board_membership_db.mjs +150 -0
- package/tests/government_board_vote.mjs +5 -3
- package/tests/government_charter_library.mjs +107 -1
- package/tests/government_constitution_view.mjs +53 -0
- package/tests/rank_tier_single_source.mjs +9 -0
package/modules/government/db.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
[
|
|
275
|
+
[rankKeys],
|
|
265
276
|
);
|
|
266
277
|
return rows;
|
|
267
278
|
}
|
|
268
279
|
|
|
269
|
-
async function builderHoldsRankActive(builderId,
|
|
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,
|
|
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
|
|
443
|
-
const
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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,
|
|
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('
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
assert.deepEqual(parseMembershipPredicate('rank:
|
|
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', //
|
|
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 (
|
|
93
|
-
|
|
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,
|
|
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);
|