@bongos/core 1.19.221 → 1.19.223

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 CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.221",
6
- "core_contract": "1.19.221",
7
- "source_commit": "30b2efb16495a941d6a4b70fd03b772cd69bce23",
5
+ "core_version": "1.19.223",
6
+ "core_contract": "1.19.223",
7
+ "source_commit": "f9d11234723ed42c73cf2eff487deabe34464ab5",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-18T03:23:47.936Z",
9
+ "built_at": "2026-08-18T04:00:41.918Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 311,
13
13
  "agent_docs_stubbed": 21,
14
- "functional_verbatim": 1303,
14
+ "functional_verbatim": 1305,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1635,
20
- "tree_sha256": "aa35386f9d066c748b4374cf27b4dc5fbf48ccfef32dfb8ef8c60cf44fdb8c96",
19
+ "file_count": 1637,
20
+ "tree_sha256": "149332598cd294f2de39adf373feaa298158120232a1dfebbd58e5ee56207689",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -397,7 +397,7 @@
397
397
  {
398
398
  "path": "config/government.neutral.json",
399
399
  "mode": "0000644",
400
- "sha256": "70fe731d06dd9744178a27ec4379ee8c769f9a45d9a585f095b1765433767afc"
400
+ "sha256": "7ab2f7aff4bc52d09ee1a4aa3b76a7646ff2ee462bbc459941a468a9cc1246eb"
401
401
  },
402
402
  {
403
403
  "path": "config/modules.neutral.json",
@@ -2894,6 +2894,11 @@
2894
2894
  "mode": "0000644",
2895
2895
  "sha256": "7cb9dac9cb87b679e1ae50e4112b3a7928a38ebb9902c8947a74d17bec4d9009"
2896
2896
  },
2897
+ {
2898
+ "path": "modules/government/board-membership.js",
2899
+ "mode": "0000644",
2900
+ "sha256": "5bf474bee95bc8a8982e4a52c9e5b19955a20195dc1affd51c07e99540c8c059"
2901
+ },
2897
2902
  {
2898
2903
  "path": "modules/government/catalog.js",
2899
2904
  "mode": "0000644",
@@ -2902,12 +2907,12 @@
2902
2907
  {
2903
2908
  "path": "modules/government/config.js",
2904
2909
  "mode": "0000644",
2905
- "sha256": "183bca92e8ba897871287aeb58e6f9cfebcf9510b15d6cd9701066ce7c108edd"
2910
+ "sha256": "dc4784b8d1f39c10c62ee935c6ff7d2d85db42f80479750e2ca2b8ca2877ebc6"
2906
2911
  },
2907
2912
  {
2908
2913
  "path": "modules/government/db.js",
2909
2914
  "mode": "0000644",
2910
- "sha256": "0e84802af9d1dd4c139548d4e621fc11896513415abf4e9eadfacbae33c40952"
2915
+ "sha256": "581d1b67c4765ec3f1d98a7af06afaefb2b6904f84d4fbb8be79f3d0008d4712"
2911
2916
  },
2912
2917
  {
2913
2918
  "path": "modules/government/migrations/governance_001_tables.sql",
@@ -2997,7 +3002,7 @@
2997
3002
  {
2998
3003
  "path": "modules/government/routes/government.js",
2999
3004
  "mode": "0000644",
3000
- "sha256": "872f144bcd788e36f8b8bc87e5bd31f2389169ffa5d930e03d74ba24a0264f3b"
3005
+ "sha256": "120877f860336de8bb010fe09872348257967b79a51951e5914fc32e907b5886"
3001
3006
  },
3002
3007
  {
3003
3008
  "path": "modules/government/routes/rank-sync.js",
@@ -4292,12 +4297,12 @@
4292
4297
  {
4293
4298
  "path": "package-lock.json",
4294
4299
  "mode": "0000644",
4295
- "sha256": "5b55ea236b725452ef02e308616d3827927f68e82a483022a856e4066864dc99"
4300
+ "sha256": "886be125dcb957387fea954c943d27f852347b7f38662905d59e2146cbb17e0b"
4296
4301
  },
4297
4302
  {
4298
4303
  "path": "package.json",
4299
4304
  "mode": "0000644",
4300
- "sha256": "ec221145b014962cce2d2c9fc9e231a0b296bb65153ba8079e30f2f4e80b9df7"
4305
+ "sha256": "7e5374d7ead5886cd5b376a95724500196a0e9dc748617a99af436b2863a235c"
4301
4306
  },
4302
4307
  {
4303
4308
  "path": "public-docs/index.html",
@@ -5912,7 +5917,7 @@
5912
5917
  {
5913
5918
  "path": "src/module-api.js",
5914
5919
  "mode": "0000644",
5915
- "sha256": "4aa9331871262decd3e4b2a16e1cd9ca58b4e4000228cde02086ad209a1bb4b7"
5920
+ "sha256": "ea7ae52689115222eeafffffbcf8fb1f9fcf13b6e01f90f1153e133928d52dea"
5916
5921
  },
5917
5922
  {
5918
5923
  "path": "src/module-loader/catalog.js",
@@ -6834,6 +6839,11 @@
6834
6839
  "mode": "0000644",
6835
6840
  "sha256": "8f5354cbacf15c9b49500f96b265fe52f5c0d4500a4ec7b41b4819a149782893"
6836
6841
  },
6842
+ {
6843
+ "path": "tests/government_board_membership.mjs",
6844
+ "mode": "0000644",
6845
+ "sha256": "1a6f90e319c88ecd3e5588d5a6df44a9848c0adf08efd13bfcc52ac99e84d5d0"
6846
+ },
6837
6847
  {
6838
6848
  "path": "tests/government_catalog.mjs",
6839
6849
  "mode": "0000644",
@@ -6847,7 +6857,7 @@
6847
6857
  {
6848
6858
  "path": "tests/government_config.mjs",
6849
6859
  "mode": "0000644",
6850
- "sha256": "979deef4473243dc86ab13b9284104bce71f6a1ee839acc6c5a6db04770cd62c"
6860
+ "sha256": "330372412c80d8d33698eba68a5c19e79daff17b0d820a9a797c883071a5641f"
6851
6861
  },
6852
6862
  {
6853
6863
  "path": "tests/government_fold.mjs",
@@ -17,5 +17,12 @@
17
17
  "label": "Archon",
18
18
  "description": "Seeded rank \u2014 top authority. Adds the admin + system (non-grantable) powers."
19
19
  }
20
+ },
21
+ "_board_note": "The CONSTITUTION \u2014 the Board Room's rule set (BV1.R05 of goal 1000069, ADR 0175). Day one runs the real machinery configured to MONARCHY: membership 'rank:archon', pass_rule 'first_ratifier', no clock. That is a set of VALUES, never a separate code path \u2014 the same resolver, tally and window run whatever the numbers say, so flipping to a full board later changes configuration and not a branch that has never executed. RECOMMENDED once a project has a board worth sitting: all Archons, one vote each, pass_rule 'consent', window_minutes 90 \u2014 documented here, deliberately not the default. membership is the predicate 'rank:<key>'; it may name a CUSTOM rank, and WHO HOLDS that rank stays a DB row resolved per request (this file grants nobody anything). pass_rule is one of first_ratifier | consent. window_minutes is null for no clock, else whole minutes up to 7 days. Every unreadable field falls back to ITS OWN default, and the fallbacks are the STRICT end of both axes, so a malformed constitution can never resolve to a board that is wider or easier to pass than this one. To customise, copy to config/government.json and edit there \u2014 not by env var: a label is cosmetic, a constitution is not.",
22
+ "board": {
23
+ "membership": "rank:archon",
24
+ "pass_rule": "first_ratifier",
25
+ "window_minutes": null,
26
+ "close_early_on_full_turnout": true
20
27
  }
21
28
  }
@@ -0,0 +1,85 @@
1
+ 'use strict';
2
+
3
+ // modules/government/board-membership.js — WHO SITS ON THE BOARD, right now
4
+ // (BV1.R06 of goal 1000069, ADR 0175).
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
8
+ // announce a window to), R08 (the live membership re-check before a vote
9
+ // counts), and R10 (the tally's denominator).
10
+ //
11
+ // It preserves ADR 0016 exactly, the same three ways resolver.js does:
12
+ // • server-side — membership is computed from government_builder_ranks rows,
13
+ // never from a token claim or a client-supplied list;
14
+ // • per request — the caller asks on the request that needs the answer, so a
15
+ // demoted Archon is off the board on their very next request, and a vote
16
+ // they attempt after the demotion does not count (R08's re-check);
17
+ // • UNCACHED — no memo, no TTL, no process-local map. A cached member list
18
+ // would keep a demoted builder governing, which is the precise failure the
19
+ // trust boundary exists to prevent. If the join ever profiles hot, the
20
+ // answer is a DB index, not a cache.
21
+ //
22
+ // WHY RANK, NEVER CRAFT (the spec's rejected craft-chamber model): PATCH
23
+ // /me/disciplines is deliberately ungated — a builder owns their own profile
24
+ // (src/bongos/routes/me.js) — so a craft-derived franchise (Artist / Ideator /
25
+ // Builder as voting chambers) would be one-request self-enfranchisement. Rank
26
+ // is written only through the Archon-gated rank-set path with the R95b rank
27
+ // rotation, so deriving membership from rank inherits that write gate for free.
28
+ //
29
+ // Fail-closed throughout, in the same direction as R05's config sanitizer: a
30
+ // predicate this file cannot parse resolves to NOBODY, never to everybody. An
31
+ // unknown grammar must not quietly widen the franchise — an empty board under
32
+ // `first_ratifier` simply means no one can ratify until the constitution is
33
+ // fixed, which is loud, visible, and reversible by fixing the config.
34
+
35
+ const db = require('./db');
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
+ }
53
+
54
+ // The resolve entry point: constitution board block -> the live member list, as
55
+ // a frozen array of { builder_id, github_login, display_name } rows sorted by
56
+ // id. Takes the BOARD BLOCK (not the whole config) because R07-R10 operate on
57
+ // the constitution SNAPSHOT stored on the item (government_board_items
58
+ // .constitution), not on the live config — an amendment must not rewrite who
59
+ // may vote on an already-open item mid-sitting.
60
+ async function resolveBoardMembers(board) {
61
+ const parsed = parseMembershipPredicate(board && board.membership);
62
+ if (!parsed) return Object.freeze([]); // unparseable predicate → nobody governs
63
+ const rows = await db.getActiveBuildersWithRank(parsed.rankKey);
64
+ return Object.freeze(rows.map((r) => Object.freeze({
65
+ builder_id: String(r.builder_id),
66
+ github_login: r.github_login,
67
+ display_name: r.display_name,
68
+ })));
69
+ }
70
+
71
+ // The R08 re-check: is THIS builder on the board defined by THIS constitution,
72
+ // right now? One indexed lookup rather than materializing the whole list —
73
+ // same rows, same filters, same fail-closed null handling.
74
+ async function isBoardMember(builderId, board) {
75
+ if (builderId === null || builderId === undefined || builderId === '') return false;
76
+ const parsed = parseMembershipPredicate(board && board.membership);
77
+ if (!parsed) return false;
78
+ return db.builderHoldsRankActive(builderId, parsed.rankKey);
79
+ }
80
+
81
+ module.exports = {
82
+ parseMembershipPredicate,
83
+ resolveBoardMembers,
84
+ isBoardMember,
85
+ };
@@ -126,6 +126,78 @@ function sanitizeRole(key, raw) {
126
126
  });
127
127
  }
128
128
 
129
+ // ── the constitution: who governs, how an item passes, how long a sitting runs ──
130
+ // BV1.R05 of goal 1000069 (ADR 0175). The Board Room's rule set is CONFIGURATION on
131
+ // the real machinery, never a separate code path: day one runs the same resolver,
132
+ // the same tally and the same window as a full consent board, with the numbers set
133
+ // to monarchy (owner decision 1). Flipping the switch later changes values, not a
134
+ // branch that has never executed.
135
+ //
136
+ // It lives here, beside the rank templates, for the same reason ADR 0175 put the
137
+ // board tables inside modules/government/: this is an AUTHORITY question, and
138
+ // authority is this module's territory.
139
+ //
140
+ // STILL NOT A SOURCE OF AUTHORITY TRUTH. Same wall as the rest of this file — the
141
+ // predicate says which RANK governs; who actually holds that rank stays a DB row,
142
+ // resolved per request by R06's membership resolver. Nothing here names a builder,
143
+ // and a config edit cannot make anyone a board member.
144
+ const BOARD_DEFAULTS = Object.freeze({
145
+ membership: 'rank:archon',
146
+ pass_rule: 'first_ratifier',
147
+ window_minutes: null, // null = no clock; monarchy does not sit
148
+ close_early_on_full_turnout: true,
149
+ });
150
+
151
+ // The pass rules that EXIST, not the ones a project should pick. `consent` is the
152
+ // documented recommendation once a project has a board worth sitting (all Archons,
153
+ // one vote each, consent, a 90-minute window); `first_ratifier` is the day-one
154
+ // default and what an unreadable value falls back to.
155
+ const PASS_RULES = Object.freeze(['first_ratifier', 'consent']);
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_-]*$/;
164
+
165
+ // A sitting longer than a week is indistinguishable from no clock at all, and a
166
+ // zero/negative window would close every item the instant it opened. Both are far
167
+ // likelier to be a typo than an intent, so both fall back to the default.
168
+ const MAX_WINDOW_MINUTES = 7 * 24 * 60;
169
+
170
+ // Sanitize the board block. Every unreadable field falls back to ITS OWN default
171
+ // independently, so one typo costs one setting rather than the whole constitution.
172
+ //
173
+ // THE INVARIANT THAT MATTERS: a malformed constitution must never resolve to a board
174
+ // that is WIDER or EASIER to pass than the platform default. `rank:archon` is the
175
+ // narrowest membership this project can express, and `first_ratifier` demands an
176
+ // affirmative act by a member where `consent` lets an empty window pass unopposed
177
+ // (owner decision 6). So the fallbacks are the strict end of both axes — a broken
178
+ // config cannot quietly enfranchise anyone or lower the bar.
179
+ function sanitizeBoard(raw) {
180
+ const r = isObj(raw) ? raw : {};
181
+ const membership = typeof r.membership === 'string' && MEMBERSHIP_RE.test(r.membership)
182
+ ? r.membership
183
+ : BOARD_DEFAULTS.membership;
184
+ const passRule = PASS_RULES.includes(r.pass_rule) ? r.pass_rule : BOARD_DEFAULTS.pass_rule;
185
+ // null is a MEANINGFUL value (no clock), so it is accepted rather than defaulted.
186
+ // Anything else must be a whole number of minutes inside the sane band.
187
+ const w = r.window_minutes;
188
+ const windowMinutes = w === null ? null
189
+ : (Number.isInteger(w) && w > 0 && w <= MAX_WINDOW_MINUTES ? w : BOARD_DEFAULTS.window_minutes);
190
+ const closeEarly = typeof r.close_early_on_full_turnout === 'boolean'
191
+ ? r.close_early_on_full_turnout
192
+ : BOARD_DEFAULTS.close_early_on_full_turnout;
193
+ return Object.freeze({
194
+ membership,
195
+ pass_rule: passRule,
196
+ window_minutes: windowMinutes,
197
+ close_early_on_full_turnout: closeEarly,
198
+ });
199
+ }
200
+
129
201
  // Pure resolver — all inputs injected, so tests drive it with zero I/O (the
130
202
  // resolveBranding pattern).
131
203
  function resolveGovernmentConfig({ neutral, instance, env = process.env } = {}) {
@@ -146,7 +218,16 @@ function resolveGovernmentConfig({ neutral, instance, env = process.env } = {})
146
218
  for (const key of catalog.RANK_ORDER) {
147
219
  if (!ranks[key]) ranks[key] = sanitizeRole(key, {});
148
220
  }
149
- return Object.freeze({ ranks: Object.freeze(ranks) });
221
+ // The board block merges FIELD BY FIELD, neutral under instance, so a project that
222
+ // wants only a different window keeps the platform's answer for everything else
223
+ // rather than having to restate the whole constitution to change one number.
224
+ //
225
+ // Deliberately NOT env-overridable. applyLabelOverrides exists because a label is
226
+ // cosmetic; the constitution is not, and an env var that could change who governs
227
+ // would be a way to alter authority from outside the checkout AND outside the DB.
228
+ // Changing the constitution is a board act (owner decision 11), not a deploy flag.
229
+ const board = sanitizeBoard({ ...((neutral || {}).board || {}), ...((instance || {}).board || {}) });
230
+ return Object.freeze({ ranks: Object.freeze(ranks), board });
150
231
  }
151
232
 
152
233
  // READ, never require(): a bare require('../../config/X.json') of a boundary-excluded
@@ -179,6 +260,10 @@ module.exports = {
179
260
  resolveGovernmentConfig,
180
261
  loadGovernmentConfig,
181
262
  grantableOnly,
263
+ sanitizeBoard,
264
+ BOARD_DEFAULTS,
265
+ PASS_RULES,
266
+ MAX_WINDOW_MINUTES,
182
267
  NEUTRAL_PATH,
183
268
  INSTANCE_PATH,
184
269
  };
@@ -239,6 +239,48 @@ async function unassignRank({ builderId, rankKey }) {
239
239
  return rowCount > 0;
240
240
  }
241
241
 
242
+ // --- the board membership READS (BV1.R06 of goal 1000069) --------------------
243
+ //
244
+ // Both filters are load-bearing, and both must appear in BOTH queries below —
245
+ // board-membership.js's list and its single-member re-check must be the same
246
+ // question at two granularities, never two questions:
247
+ // • b.status = 'active' — a deactivated builder is off the board the moment
248
+ // builder-exit flips them (their rank also drops to xenos, but membership
249
+ // must not depend on that ordering);
250
+ // • b.system_role IS NULL — a machine principal never governs. Today's two
251
+ // principals are xenos+inactive so the status filter would catch them, but
252
+ // that is circumstance, not a rule; this makes it structural, mirroring the
253
+ // resolver's principal-key drop (R98).
254
+
255
+ async function getActiveBuildersWithRank(rankKey) {
256
+ const { rows } = await pool.query(
257
+ `SELECT br.builder_id, b.github_login, b.display_name
258
+ FROM government_builder_ranks br
259
+ JOIN builders b ON b.id = br.builder_id
260
+ WHERE br.rank_key = $1
261
+ AND b.status = 'active'
262
+ AND b.system_role IS NULL
263
+ ORDER BY br.builder_id`,
264
+ [rankKey],
265
+ );
266
+ return rows;
267
+ }
268
+
269
+ async function builderHoldsRankActive(builderId, rankKey) {
270
+ const { rows } = await pool.query(
271
+ `SELECT 1
272
+ FROM government_builder_ranks br
273
+ JOIN builders b ON b.id = br.builder_id
274
+ WHERE br.builder_id = $1
275
+ AND br.rank_key = $2
276
+ AND b.status = 'active'
277
+ AND b.system_role IS NULL
278
+ LIMIT 1`,
279
+ [builderId, rankKey],
280
+ );
281
+ return rows.length > 0;
282
+ }
283
+
242
284
  module.exports = {
243
285
  getPermissionKeysForBuilder,
244
286
  getRankKeysForBuilder,
@@ -254,4 +296,6 @@ module.exports = {
254
296
  listAssignments,
255
297
  assignRank,
256
298
  unassignRank,
299
+ getActiveBuildersWithRank,
300
+ builderHoldsRankActive,
257
301
  };
@@ -23,6 +23,7 @@ const resolver = require('../resolver');
23
23
  const catalog = require('../catalog');
24
24
  const db = require('../db');
25
25
  const sessionScopes = require('../session-scopes');
26
+ const boardMembership = require('../board-membership');
26
27
 
27
28
  // The port surface. Deliberately narrow: resolution + the catalog reads a gate or
28
29
  // the Archon tab needs. It grows with R98-R101 (principals, session-scopes,
@@ -78,6 +79,13 @@ const governmentPort = {
78
79
  narrowToSessionScope: resolver.narrowToSessionScope,
79
80
  sessionScopeFor: sessionScopes.scopeFor,
80
81
  listSessionScopes: () => sessionScopes.SESSION_SCOPES,
82
+ // The Board Room's membership axis (R06 of goal 1000069): who governs, right
83
+ // now, under a given constitution's board block — per request, uncached, the
84
+ // same ADR 0016 posture as the resolves above. On the port because R21 (the
85
+ // Discord announcer) needs the member list from outside this module.
86
+ parseMembershipPredicate: boardMembership.parseMembershipPredicate,
87
+ resolveBoardMembers: boardMembership.resolveBoardMembers,
88
+ isBoardMember: boardMembership.isBoardMember,
81
89
  };
82
90
 
83
91
  function registerSeams() {
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.221",
3
+ "version": "1.19.223",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.221",
9
+ "version": "1.19.223",
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.221",
3
+ "version": "1.19.223",
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",
package/src/module-api.js CHANGED
@@ -869,8 +869,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
869
869
  // landed since 1.19.219 with no explicit bump. run 32094701831. (task 1002620)
870
870
  // 1.19.221 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
871
871
  // landed since 1.19.220 with no explicit bump. run 32095276039. (task 1002620)
872
+ // 1.19.222 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
873
+ // landed since 1.19.221 with no explicit bump. run 32096034408. (task 1002620)
874
+ // 1.19.223 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
875
+ // landed since 1.19.222 with no explicit bump. run 32097491676. (task 1002620)
872
876
  // ---------------------------------------------------------------------------
873
- const CORE_VERSION = '1.19.221'; // CI auto-patch carrier (ADR 0161); see changelog above
877
+ const CORE_VERSION = '1.19.223'; // CI auto-patch carrier (ADR 0161); see changelog above
874
878
 
875
879
  // A namespaced logger so a module's log lines are attributable + consistent.
876
880
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,183 @@
1
+ // tests/government_board_membership.mjs — who sits on the board (BV1.R06 of
2
+ // goal 1000069, ADR 0175).
3
+ //
4
+ // DB-free: the pure predicate parser is exercised directly, and the two resolve
5
+ // entry points run against a monkeypatched db module (the government_routes.mjs
6
+ // idiom), so the invariants here are testable without Postgres. What must hold:
7
+ //
8
+ // 1. FAIL-CLOSED — an unparseable predicate resolves to NOBODY, never to
9
+ // everybody. Membership widening by typo is the board-shaped version of the
10
+ // config file granting a permission, and it fails the same direction.
11
+ // 2. THE TWO GRAMMARS AGREE — config.js (R05) decides which predicates SURVIVE
12
+ // sanitization; this file decides which predicates RESOLVE. A string the
13
+ // config keeps but the resolver cannot parse would be a constitution that
14
+ // validates and then seats nobody, silently.
15
+ // 3. NO CACHE — the member list is computed per call from the db module's
16
+ // CURRENT answer. A demotion between two calls changes the second answer.
17
+ import assert from 'node:assert/strict';
18
+ import { test } from 'node:test';
19
+ import { createRequire } from 'node:module';
20
+ import path from 'node:path';
21
+ import { fileURLToPath } from 'node:url';
22
+
23
+ const require = createRequire(import.meta.url);
24
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
25
+ const GOV = path.join(ROOT, 'modules', 'government');
26
+ const membership = require(path.join(GOV, 'board-membership.js'));
27
+ const db = require(path.join(GOV, 'db.js'));
28
+ const { sanitizeBoard, BOARD_DEFAULTS } = require(path.join(GOV, 'config.js'));
29
+
30
+ const { parseMembershipPredicate, resolveBoardMembers, isBoardMember } = membership;
31
+
32
+ // ── the pure parser ──────────────────────────────────────────────────────────
33
+
34
+ test('the one v1 grammar parses: rank:<key>', () => {
35
+ assert.deepEqual(parseMembershipPredicate('rank:archon'), { rankKey: 'archon' });
36
+ assert.deepEqual(parseMembershipPredicate('rank:metic'), { rankKey: 'metic' });
37
+ assert.deepEqual(parseMembershipPredicate('rank:triage-helper'), { rankKey: 'triage-helper' },
38
+ 'a CUSTOM rank is legal by shape — whether it seats anyone is the DB\'s answer');
39
+ assert.deepEqual(parseMembershipPredicate('rank:board_member'), { rankKey: 'board_member' });
40
+ });
41
+
42
+ test('everything else parses to null — the fail-closed value', () => {
43
+ const junk = [
44
+ undefined, null, 42, {}, [], '',
45
+ 'archon', // bare rank, no axis prefix
46
+ 'rank:', // empty key
47
+ 'RANK:ARCHON', // grammar is lowercase
48
+ 'rank:Archon',
49
+ 'role:builder', // the rejected role-chamber model — must never parse
50
+ 'rank:archon;metic', // no lists in v1
51
+ 'rank: archon', // no whitespace
52
+ 'builder:3', // naming a PERSON is config-as-authority — refused by shape
53
+ ];
54
+ for (const p of junk) {
55
+ assert.equal(parseMembershipPredicate(p), null, `${JSON.stringify(p)} must not parse`);
56
+ }
57
+ });
58
+
59
+ test('the parser and the config sanitizer agree on every sample — no validate-then-seat-nobody gap', () => {
60
+ // Property: config KEEPS a membership string ⇔ this file PARSES it. A predicate
61
+ // that survives R05's sanitizer but fails R06's parse would be a constitution
62
+ // that looks valid and silently seats nobody; the reverse would mean the
63
+ // resolver honours strings config would have replaced with the default.
64
+ const samples = [
65
+ 'rank:archon', 'rank:metic', 'rank:xenos', 'rank:triage-helper', 'rank:a1_b-c',
66
+ 'archon', 'rank:', 'RANK:ARCHON', 'role:builder', 'builder:3', '', 'rank: archon',
67
+ 'rank:Archon', 'rank:archon;metic',
68
+ ];
69
+ for (const s of samples) {
70
+ const kept = sanitizeBoard({ membership: s }).membership === s;
71
+ const parses = parseMembershipPredicate(s) !== null;
72
+ assert.equal(kept, parses, `config and resolver disagree on ${JSON.stringify(s)}`);
73
+ }
74
+ });
75
+
76
+ // ── the resolve entry points, against a stubbed db ───────────────────────────
77
+
78
+ // board-membership.js reads db.* by property lookup at CALL time, so replacing
79
+ // the shared module object's methods (and restoring in finally) drives both
80
+ // entry points without Postgres.
81
+ async function withDb(stubs, fn) {
82
+ const originals = {};
83
+ for (const [k, v] of Object.entries(stubs)) { originals[k] = db[k]; db[k] = v; }
84
+ try { return await fn(); }
85
+ finally { for (const [k, v] of Object.entries(originals)) db[k] = v; }
86
+ }
87
+
88
+ const MONARCHY = Object.freeze({ ...BOARD_DEFAULTS });
89
+
90
+ test('resolveBoardMembers: the happy path — rows come back frozen, ids as strings', async () => {
91
+ await withDb({
92
+ getActiveBuildersWithRank: async (rankKey) => {
93
+ assert.equal(rankKey, 'archon', 'the predicate\'s rank key reaches the query verbatim');
94
+ return [
95
+ { builder_id: 42n, github_login: 'first-member', display_name: 'First Member' },
96
+ { builder_id: '1000099', github_login: 'second-member', display_name: 'Second Member' },
97
+ ];
98
+ },
99
+ }, async () => {
100
+ const members = await resolveBoardMembers(MONARCHY);
101
+ assert.equal(members.length, 2);
102
+ assert.deepEqual(members.map((m) => m.builder_id), ['42', '1000099'],
103
+ 'ids are normalized to strings — Postgres bigints and JS numbers must compare equal downstream');
104
+ assert.ok(Object.isFrozen(members) && Object.isFrozen(members[0]));
105
+ });
106
+ });
107
+
108
+ test('an unparseable predicate resolves to NOBODY without touching the db', async () => {
109
+ await withDb({
110
+ getActiveBuildersWithRank: async () => { throw new Error('must not be called'); },
111
+ builderHoldsRankActive: async () => { throw new Error('must not be called'); },
112
+ }, async () => {
113
+ for (const board of [undefined, null, {}, { membership: 'everyone' }, { membership: 'role:builder' }]) {
114
+ assert.deepEqual(await resolveBoardMembers(board), [], `${JSON.stringify(board)} seats nobody`);
115
+ assert.equal(await isBoardMember('42', board), false);
116
+ }
117
+ });
118
+ });
119
+
120
+ test('a predicate naming a rank with no assignments seats an EMPTY board — loud, not widened', async () => {
121
+ await withDb({ getActiveBuildersWithRank: async () => [] }, async () => {
122
+ const members = await resolveBoardMembers({ ...MONARCHY, membership: 'rank:board-of-directors' });
123
+ assert.deepEqual(members, []);
124
+ });
125
+ });
126
+
127
+ test('isBoardMember: fail-closed on a missing builder id, true only on a live row', async () => {
128
+ await withDb({
129
+ builderHoldsRankActive: async (builderId, rankKey) => builderId === '42' && rankKey === 'archon',
130
+ }, async () => {
131
+ assert.equal(await isBoardMember('42', MONARCHY), true);
132
+ assert.equal(await isBoardMember('999', MONARCHY), false);
133
+ for (const bad of [null, undefined, '']) {
134
+ assert.equal(await isBoardMember(bad, MONARCHY), false, `${String(bad)} is nobody`);
135
+ }
136
+ });
137
+ });
138
+
139
+ test('NO CACHE: a demotion between two calls changes the second answer', async () => {
140
+ // The ADR 0016 instant-demotion property, observed rather than asserted from
141
+ // source text: the same call against a db whose answer changed returns the
142
+ // changed answer. A memo anywhere in the path would fail this.
143
+ let demoted = false;
144
+ await withDb({
145
+ getActiveBuildersWithRank: async () => (demoted
146
+ ? []
147
+ : [{ builder_id: '42', github_login: 'demoted-archon', display_name: 'Demoted Archon' }]),
148
+ builderHoldsRankActive: async () => !demoted,
149
+ }, async () => {
150
+ assert.equal((await resolveBoardMembers(MONARCHY)).length, 1);
151
+ assert.equal(await isBoardMember('42', MONARCHY), true);
152
+ demoted = true;
153
+ assert.deepEqual(await resolveBoardMembers(MONARCHY), [], 'off the board on the very next request');
154
+ assert.equal(await isBoardMember('42', MONARCHY), false);
155
+ });
156
+ });
157
+
158
+ test('the membership queries exclude machines and the deactivated — structurally, in the SQL', () => {
159
+ // The db half is one query away from Postgres, so its two load-bearing filters
160
+ // are pinned as source: b.status = 'active' (builder-exit takes effect without
161
+ // depending on the rank drop's ordering) and b.system_role IS NULL (a machine
162
+ // principal never governs — today's principals happen to be inactive xenos,
163
+ // but that is circumstance; this is the rule). Both must appear in BOTH reads.
164
+ const fs = require('node:fs');
165
+ const src = fs.readFileSync(path.join(GOV, 'db.js'), 'utf8');
166
+ const start = src.indexOf('getActiveBuildersWithRank');
167
+ assert.ok(start > -1);
168
+ const block = src.slice(start);
169
+ assert.equal((block.match(/b\.status = 'active'/g) || []).length, 2, 'both reads filter status');
170
+ assert.equal((block.match(/b\.system_role IS NULL/g) || []).length, 2, 'both reads exclude principals');
171
+ });
172
+
173
+ test('the government port carries the membership axis for cross-module consumers (R21)', () => {
174
+ const routes = require(path.join(GOV, 'routes', 'government.js'));
175
+ assert.equal(typeof routes, 'function', 'the router factory registers the port');
176
+ const api = require(path.join(ROOT, 'src', 'module-api.js'));
177
+ routes(); // registering twice is a no-op (hasProvider guard)
178
+ const port = api.resolveOptional('government');
179
+ assert.ok(port, 'the government port is registered');
180
+ for (const method of ['parseMembershipPredicate', 'resolveBoardMembers', 'isBoardMember']) {
181
+ assert.equal(typeof port[method], 'function', `port.${method} must be callable`);
182
+ }
183
+ });
@@ -18,7 +18,8 @@ const GOV = path.join(ROOT, 'modules', 'government');
18
18
  const config = require(path.join(GOV, 'config.js'));
19
19
  const catalog = require(path.join(GOV, 'catalog.js'));
20
20
  const { envPrefix } = require(path.join(ROOT, 'src', 'instance-config'));
21
- const { resolveGovernmentConfig, loadGovernmentConfig, grantableOnly, NEUTRAL_PATH, INSTANCE_PATH } = config;
21
+ const { resolveGovernmentConfig, loadGovernmentConfig, grantableOnly, sanitizeBoard,
22
+ BOARD_DEFAULTS, PASS_RULES, MAX_WINDOW_MINUTES, NEUTRAL_PATH, INSTANCE_PATH } = config;
22
23
 
23
24
  const NEUTRAL = JSON.parse(fs.readFileSync(NEUTRAL_PATH, 'utf8'));
24
25
  // No env by default — every precedence test injects exactly the env it is asserting on.
@@ -158,11 +159,20 @@ test('the neutral starter carries no host-specific identity (a vanilla instance
158
159
  const { scanPromptIdentity } = require(path.join(ROOT, 'scripts', 'gds', 'fitness.js'));
159
160
  const raw = fs.readFileSync(NEUTRAL_PATH, 'utf8');
160
161
  assert.deepEqual(scanPromptIdentity('config/government.neutral.json', raw, '.json'), []);
161
- // And it carries only templates — never a live per-builder assignment.
162
- assert.deepEqual(Object.keys(NEUTRAL).filter((k) => k !== '_note'), ['ranks']);
162
+ // And it carries only templates — never a live per-builder assignment. The key list
163
+ // is an ALLOWLIST on purpose: a new top-level key has to be justified here before it
164
+ // can ship, which is what stops a live grant or assignment arriving unnoticed.
165
+ // `board` joined it in R05 (goal 1000069) — it is the CONSTITUTION, a rule shape, and
166
+ // the assertion below pins that it names no builder. `_`-prefixed keys are prose notes.
167
+ assert.deepEqual(Object.keys(NEUTRAL).filter((k) => !k.startsWith('_')).sort(), ['board', 'ranks']);
163
168
  for (const rank of Object.values(NEUTRAL.ranks)) {
164
169
  assert.ok(!('permissions' in rank), 'the neutral seeded ranks carry labels only, never grants');
165
170
  }
171
+ // The constitution says which RANK governs, never WHO. A builder id, login or handle
172
+ // here would make config authoritative over membership, which is R06's DB question.
173
+ assert.match(NEUTRAL.board.membership, /^rank:/, 'membership is a rank predicate, not a person');
174
+ assert.equal(JSON.stringify(NEUTRAL.board).includes('builder'), false,
175
+ 'the neutral constitution must not reference any builder');
166
176
  });
167
177
 
168
178
  test('the two config paths are the branding-shaped neutral/instance pair under config/', () => {
@@ -185,3 +195,137 @@ test('the neutral starter publishes with the core (a packaged vanilla instance n
185
195
  assert.equal(isPublishable('config/government.neutral.json'), true);
186
196
  assert.equal(isPublishable('config/government.json'), false, "a host's filled-in pack is instance content");
187
197
  });
198
+
199
+ // ── the constitution (BV1.R05 of goal 1000069, ADR 0175) ────────────────────────
200
+ //
201
+ // The board block is CONFIGURATION on the real machinery, not a second code path.
202
+ // These tests pin the two things that make that safe: the platform default really is
203
+ // monarchy, and a malformed constitution can never resolve to a board that is WIDER
204
+ // or EASIER to pass than that default. A config file is editable by anyone who can
205
+ // write the checkout — the same trust argument the permission tests above make.
206
+
207
+ test('day one is monarchy: the neutral starter ships the Archon-ratifies constitution', () => {
208
+ const out = resolveGovernmentConfig({ neutral: NEUTRAL, env: NO_ENV });
209
+ assert.deepEqual(out.board, {
210
+ membership: 'rank:archon',
211
+ pass_rule: 'first_ratifier',
212
+ window_minutes: null, // no clock — monarchy does not sit
213
+ close_early_on_full_turnout: true,
214
+ }, 'owner decision 1: the real machinery, configured to monarchy');
215
+ assert.deepEqual(NEUTRAL.board, out.board, 'the shipped file and the resolved value must agree');
216
+ });
217
+
218
+ test('a project with no board block at all still gets the full default constitution', () => {
219
+ // The vanilla-boot case, and the upgrade case: an instance whose config predates
220
+ // R05 has no board key. It must resolve to monarchy, not to undefined fields that
221
+ // R06 would then have to guess at.
222
+ const out = resolveGovernmentConfig({ neutral: {}, instance: {}, env: NO_ENV });
223
+ assert.deepEqual(out.board, BOARD_DEFAULTS);
224
+ });
225
+
226
+ test('the instance pack overrides the constitution FIELD BY FIELD', () => {
227
+ // Changing one number must not require restating the whole constitution.
228
+ const out = resolveGovernmentConfig({
229
+ neutral: NEUTRAL,
230
+ instance: { board: { pass_rule: 'consent', window_minutes: 90 } },
231
+ env: NO_ENV,
232
+ });
233
+ assert.equal(out.board.pass_rule, 'consent');
234
+ assert.equal(out.board.window_minutes, 90);
235
+ assert.equal(out.board.membership, 'rank:archon', 'untouched fields keep the neutral answer');
236
+ assert.equal(out.board.close_early_on_full_turnout, true);
237
+ });
238
+
239
+ test('the documented recommendation is expressible as pure configuration', () => {
240
+ // "all Archons, one vote each, consent, a 90-minute window" — the spec's recommended
241
+ // starting point. If this could not be reached by config alone, the "values not code
242
+ // paths" claim would be false.
243
+ const out = resolveGovernmentConfig({
244
+ neutral: NEUTRAL,
245
+ instance: { board: { membership: 'rank:archon', pass_rule: 'consent', window_minutes: 90 } },
246
+ env: NO_ENV,
247
+ });
248
+ assert.deepEqual(out.board, {
249
+ membership: 'rank:archon',
250
+ pass_rule: 'consent',
251
+ window_minutes: 90,
252
+ close_early_on_full_turnout: true,
253
+ });
254
+ });
255
+
256
+ test('a malformed constitution can never widen the franchise or lower the bar', () => {
257
+ // The invariant, enumerated over every junk shape a hand-edited file can take.
258
+ // rank:archon is the narrowest membership expressible; first_ratifier demands an
259
+ // affirmative act by a member where consent lets an empty window pass (decision 6).
260
+ const junk = [
261
+ undefined, null, 42, 'consent', [],
262
+ { membership: 'everyone' },
263
+ { membership: 'rank:' },
264
+ { membership: 'RANK:ARCHON' }, // grammar is lowercase
265
+ { membership: '' },
266
+ { membership: { rank: 'archon' } },
267
+ { pass_rule: 'unanimous' },
268
+ { pass_rule: 'majority' },
269
+ { pass_rule: null },
270
+ { membership: 'rank:xenos; DROP TABLE government_ranks' },
271
+ ];
272
+ for (const raw of junk) {
273
+ const b = sanitizeBoard(raw);
274
+ assert.equal(b.membership, BOARD_DEFAULTS.membership, `${JSON.stringify(raw)} must not widen membership`);
275
+ assert.equal(b.pass_rule, BOARD_DEFAULTS.pass_rule, `${JSON.stringify(raw)} must not lower the pass bar`);
276
+ }
277
+ });
278
+
279
+ test('one unreadable field costs one setting, not the whole constitution', () => {
280
+ const b = sanitizeBoard({ membership: 'rank:metic', pass_rule: 'nonsense', window_minutes: 30 });
281
+ assert.equal(b.membership, 'rank:metic', 'a VALID field survives its neighbour being junk');
282
+ assert.equal(b.window_minutes, 30);
283
+ assert.equal(b.pass_rule, BOARD_DEFAULTS.pass_rule, 'only the broken field falls back');
284
+ });
285
+
286
+ test('membership accepts a CUSTOM rank — config cannot be authoritative over the live rank list', () => {
287
+ // Deliberately NOT validated against catalog.RANK_ORDER: custom ranks are DB rows
288
+ // this file cannot see, and checking here would invert the config/DB boundary.
289
+ // R06 resolves the predicate against live ranks and yields an empty board if it
290
+ // names nothing, so the fail-closed step happens where the truth lives.
291
+ assert.equal(sanitizeBoard({ membership: 'rank:triage-helper' }).membership, 'rank:triage-helper');
292
+ assert.equal(sanitizeBoard({ membership: 'rank:board_member' }).membership, 'rank:board_member');
293
+ });
294
+
295
+ test('window_minutes: null means no clock, and only a sane whole-minute band is accepted', () => {
296
+ assert.equal(sanitizeBoard({ window_minutes: null }).window_minutes, null, 'null is MEANINGFUL, not missing');
297
+ assert.equal(sanitizeBoard({ window_minutes: 90 }).window_minutes, 90);
298
+ assert.equal(sanitizeBoard({ window_minutes: MAX_WINDOW_MINUTES }).window_minutes, MAX_WINDOW_MINUTES);
299
+ for (const bad of [0, -5, 1.5, '90', NaN, Infinity, MAX_WINDOW_MINUTES + 1]) {
300
+ assert.equal(sanitizeBoard({ window_minutes: bad }).window_minutes, BOARD_DEFAULTS.window_minutes,
301
+ `window_minutes ${String(bad)} must fall back — a zero window closes on open, a huge one is not a sitting`);
302
+ }
303
+ });
304
+
305
+ test('no env var can change the constitution', () => {
306
+ // applyLabelOverrides exists because a label is cosmetic. Authority is not: an env
307
+ // var that could move the board would be a way to change who governs from outside
308
+ // both the checkout and the DB. Changing it is a board act (owner decision 11).
309
+ const p = envPrefix();
310
+ const hostile = {
311
+ [`${p}_GOVERNMENT_BOARD_MEMBERSHIP`]: 'rank:xenos',
312
+ [`${p}_GOVERNMENT_BOARD_PASS_RULE`]: 'consent',
313
+ [`${p}_GOVERNMENT_BOARD`]: JSON.stringify({ membership: 'rank:xenos' }),
314
+ [`${p}_GOVERNMENT_RANK_LABELS`]: 'archon=Owner',
315
+ };
316
+ const out = resolveGovernmentConfig({ neutral: NEUTRAL, env: hostile });
317
+ assert.deepEqual(out.board, NEUTRAL.board, 'the constitution is untouched by env');
318
+ assert.equal(out.ranks.archon.label, 'Owner', 'while the LABEL override still works — proving env was read');
319
+ });
320
+
321
+ test('the resolved constitution is frozen, like the rest of the config contract', () => {
322
+ const out = resolveGovernmentConfig({ neutral: NEUTRAL, env: NO_ENV });
323
+ assert.ok(Object.isFrozen(out.board));
324
+ assert.throws(() => { 'use strict'; out.board.membership = 'rank:xenos'; });
325
+ });
326
+
327
+ test('PASS_RULES is the closed set the rest of the goal codes against', () => {
328
+ assert.deepEqual([...PASS_RULES].sort(), ['consent', 'first_ratifier']);
329
+ assert.ok(Object.isFrozen(PASS_RULES));
330
+ assert.ok(PASS_RULES.includes(BOARD_DEFAULTS.pass_rule), 'the default must be a member of the set');
331
+ });