@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.
- package/.bongos-core.json +100 -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 +1432 -0
- package/docs/copy-registry.json +13311 -0
- package/modules/copy-desk/CLAUDE.md +15 -0
- package/modules/copy-desk/flags.js +245 -0
- package/modules/copy-desk/migrations/copy_desk_001_flags.sql +128 -0
- package/modules/copy-desk/module.json +11 -0
- package/modules/copy-desk/registry.js +317 -0
- package/modules/copy-desk/routes/copy-desk.js +272 -0
- package/modules/copy-desk/tests/copy_flags.mjs +246 -0
- package/modules/copy-desk/tests/copy_no_cms.mjs +104 -0
- package/modules/copy-desk/tests/copy_queue.mjs +257 -0
- package/modules/government/board-membership.js +20 -20
- package/modules/government/board.js +57 -3
- 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 +54 -5
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/publish-manifest.js +9 -0
- 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_constitution_view.mjs +53 -0
- package/tests/publish_manifest.mjs +82 -0
- 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
|
|
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
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
//
|
|
42
|
-
//
|
|
43
|
-
const
|
|
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.
|
|
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.
|
|
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 {
|
|
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 {
|
|
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({
|
|
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
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
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' &&
|
|
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;
|
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) {
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
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.
|
|
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.
|
|
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.
|
|
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');
|