@bongos/core 1.19.222 → 1.19.224

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.222",
6
- "core_contract": "1.19.222",
7
- "source_commit": "4425d4ace9a3b996b50be1b59002e4cf85f027db",
5
+ "core_version": "1.19.224",
6
+ "core_contract": "1.19.224",
7
+ "source_commit": "cddd2aa1a006cc4524db36eb319d3813a35c5e57",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-18T03:36:13.104Z",
9
+ "built_at": "2026-08-18T04:10:09.443Z",
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": "6bf87d2a8bb013b102f3ae8287a56fd6fe7ed99ed49b48f4607abcb047f59f74",
19
+ "file_count": 1637,
20
+ "tree_sha256": "88a20aaa26450db245b3da0aa9685b344fb4c9c2a53618fb5242e79dfe8ec9fb",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -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",
@@ -2907,7 +2912,7 @@
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",
@@ -3242,12 +3247,12 @@
3242
3247
  {
3243
3248
  "path": "modules/hall-ui/public/government.html",
3244
3249
  "mode": "0000644",
3245
- "sha256": "91c14637adc59dde39f47e51cfd243ddc1796b0a4108491f46aa64e68eea4a0d"
3250
+ "sha256": "23ceb462eddfb0f66ba3030231ae1394d72eb0b6689a7f127c1ba52554c874bf"
3246
3251
  },
3247
3252
  {
3248
3253
  "path": "modules/hall-ui/public/government.js",
3249
3254
  "mode": "0000644",
3250
- "sha256": "b7504f63fc1086c4367dbd76da7ac96ac4297a23f74456f90c5aebe5e974e034"
3255
+ "sha256": "4778f9a83d63900aca6969a85e7cb67850ffb591a6cbae1fb4be270393741cd1"
3251
3256
  },
3252
3257
  {
3253
3258
  "path": "modules/hall-ui/public/hall-kit.js",
@@ -3337,7 +3342,7 @@
3337
3342
  {
3338
3343
  "path": "modules/hall-ui/public/palette.js",
3339
3344
  "mode": "0000644",
3340
- "sha256": "f01a585277f24dafb12caa39d4b475cd65e61cda50ba918c66941a20916fd8b1"
3345
+ "sha256": "f6b2dc3a7ee452ff7121fbc0245bd554c692f6d8be1dc6285d0fa0eab2d6aed6"
3341
3346
  },
3342
3347
  {
3343
3348
  "path": "modules/hall-ui/public/people.html",
@@ -4292,12 +4297,12 @@
4292
4297
  {
4293
4298
  "path": "package-lock.json",
4294
4299
  "mode": "0000644",
4295
- "sha256": "44f9bf150c24b1bed1d68bcd0a9661b4c659a32e5087a268a21b7b4370d7099a"
4300
+ "sha256": "c0b226a7cd5289673d7a88f416d410c5855a7094e8cd0bcff12e063b705baf82"
4296
4301
  },
4297
4302
  {
4298
4303
  "path": "package.json",
4299
4304
  "mode": "0000644",
4300
- "sha256": "231179c4f9a0fffdd0ecdb48ceb161e99e7aab4e3058296762561d510f15281a"
4305
+ "sha256": "49b050b03e108d8f8be6aef3ed472dfd699496a1101e7bd286793e17351db3b5"
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": "e999662a04f1981cbf01b2a9b5d18d33e71034311702fac6e5a6e0a0a209dcc0"
5920
+ "sha256": "ff93015b043ae508d50b88b8de3fe512221a9906479d36e28bc418d837455fd4"
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",
@@ -7007,7 +7017,7 @@
7007
7017
  {
7008
7018
  "path": "tests/hall_palette.mjs",
7009
7019
  "mode": "0000644",
7010
- "sha256": "6a37c64bce418e7669c43b9225dffd50687e450937285b2a556287a302970a7c"
7020
+ "sha256": "7cb75511b1b09bc2ff542aa7d85849ad800d18624646e40f23483ae86127fa28"
7011
7021
  },
7012
7022
  {
7013
7023
  "path": "tests/hall_signout.mjs",
@@ -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
+ };
@@ -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() {
@@ -4,7 +4,7 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>Government — {{worldName}}</title>
7
- <meta name="description" content="Ranks and permissions — create a custom rank, grant everyday permissions, assign it to a builder. Trust-boundary powers stay locked.">
7
+ <meta name="description" content="How this project is governed — the constitution it runs, the Board Room where Full Ideas are ratified, and the ranks &amp; permissions that administer it.">
8
8
  <link rel="icon" href="data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'><text x='16' y='24' font-size='24' text-anchor='middle' font-family='sans-serif'>⌂</text></svg>">
9
9
  <link rel="preconnect" href="https://fonts.googleapis.com">
10
10
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -26,6 +26,30 @@
26
26
  <p class="gov-sealed" id="gov-sealed" hidden>Archons only.</p>
27
27
 
28
28
  <section id="gov-body" hidden>
29
+ <!-- The three-section IA (R15 of goal 1000069, ADR 0175): the page that
30
+ answers "how is this project governed". Tabs are OTBKit.makeTabs
31
+ (the work.js idiom) with hash deep links — #constitution,
32
+ #board-room, #ranks. -->
33
+ <div id="gov-tabs"></div>
34
+
35
+ <!-- §1 Constitution — what this project runs. The live view (today's
36
+ values + the change history) and the charter library land with
37
+ R18/R19; until then the section frames the model honestly. -->
38
+ <section id="gov-panel-constitution" hidden>
39
+ <h2 class="gov-h">Constitution</h2>
40
+ <p class="gov-grid-note">Three dials decide how this project is governed: <strong>who sits on the board</strong> (a rank — everyone holding it is a member), <strong>what counts as passing</strong> (a first ratifier, or the consent of the sitting), and <strong>how long a vote stays open</strong> (a timed sitting, or no clock at all). They are configuration on the same machinery at every setting — a project starts with the founder ratifying alone and opens up by changing numbers, not code.</p>
41
+ <div id="gov-constitution"></div>
42
+ </section>
43
+
44
+ <!-- §2 Board Room — open items, the clock, your vote (R16/R17). -->
45
+ <section id="gov-panel-board" hidden>
46
+ <h2 class="gov-h">Board Room</h2>
47
+ <p class="gov-grid-note">Where a Full Idea that cleared the completeness grade comes to be ratified — and where the constitution itself is amended. Open items appear here with their clock and a live tally; an objection must carry a reason and name the section it faults, which is why votes are cast here and only mirrored to chat.</p>
48
+ <div id="gov-board"></div>
49
+ </section>
50
+
51
+ <!-- §3 Ranks & Permissions — the administration surface, unchanged. -->
52
+ <section id="gov-panel-ranks" hidden>
29
53
  <div class="gov-layout">
30
54
  <div class="gov-ranks-col">
31
55
  <h2 class="gov-h">Ranks</h2>
@@ -59,6 +83,7 @@
59
83
  <h2 class="gov-h">Machine principals</h2>
60
84
  <p class="gov-grid-note">Capabilities held by a machine identity, never by a person or a rank. Shown for completeness — nothing here can be granted.</p>
61
85
  <div id="gov-principals"></div>
86
+ </section>
62
87
  </section>
63
88
  </main>
64
89
 
@@ -73,6 +98,7 @@
73
98
  <!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
74
99
  <script src="/builders/palette.js" defer></script>
75
100
  <script src="/builders/dom-utils.js?v=2026-06-16-gate"></script>
76
- <script src="/builders/government.js?v=2026-08-16-government"></script>
101
+ <script src="/builders/hall-kit.js"></script>
102
+ <script src="/builders/government.js?v=2026-08-18-government-ia"></script>
77
103
  </body>
78
104
  </html>
@@ -25,9 +25,16 @@
25
25
  // gated server-side on the `government.manage` atom (archon floor). On 401 →
26
26
  // web sign-in.
27
27
  //
28
- // DELIBERATELY NOT IN THE SIDEBAR YET: the nav entry + the server page gate are
29
- // R113 (the next slice) — tests/hall_nav.mjs pins the Government nav group and
30
- // grows with that slice, not this one. Until then the page is reached by URL.
28
+ // SINCE R15 (goal 1000069, ADR 0175) this page carries the three-section IA —
29
+ // Constitution · Board Room · Ranks & Permissions — because Government is the
30
+ // page that answers "how is this project governed", and administration is only
31
+ // the third part of that answer. Tabs are OTBKit.makeTabs with hash deep links
32
+ // (#constitution / #board-room / #ranks). The first two sections are FRAMES
33
+ // until their build-out lands: R18 fills Constitution with the live values +
34
+ // change history, R19 adds the charter library, R16/R17 fill the Board Room
35
+ // with open items and the vote. Until then each frame states what belongs there
36
+ // and shows an honest kit empty state — never a fake or a hardcoded value that
37
+ // could drift from the real config.
31
38
 
32
39
  (() => {
33
40
  'use strict';
@@ -392,6 +399,34 @@
392
399
  });
393
400
  }
394
401
 
402
+ // ---- the three-section IA (R15) -------------------------------------------
403
+
404
+ // Tab order is the spec's — Constitution · Board Room · Ranks & Permissions —
405
+ // but the INITIAL tab is Ranks until R16/R18 fill the other two: landing the
406
+ // Archon on a frame when the working surface is one tab over would be IA
407
+ // winning over usefulness. Flip `initial` to 'constitution' when R18 lands.
408
+ function wireTabs() {
409
+ window.OTBKit.makeTabs({
410
+ mount: document.getElementById('gov-tabs'),
411
+ hash: true,
412
+ initial: 'ranks',
413
+ tabs: [
414
+ { id: 'constitution', label: 'Constitution', panel: 'gov-panel-constitution' },
415
+ { id: 'board-room', label: 'Board Room', panel: 'gov-panel-board' },
416
+ { id: 'ranks', label: 'Ranks & Permissions', panel: 'gov-panel-ranks' },
417
+ ],
418
+ });
419
+ // The frames' empty states — kit-standard, one line, no fake data. What this
420
+ // project actually RUNS is config the server owns; rendering it here before
421
+ // the R18 read exists would mean hardcoding a value that drifts.
422
+ document.getElementById('gov-constitution').innerHTML = window.OTBKit.emptyStateHtml(
423
+ 'The live constitution — what this project runs today, and how it has changed — appears here when the Board Room opens.',
424
+ );
425
+ document.getElementById('gov-board').innerHTML = window.OTBKit.emptyStateHtml(
426
+ 'Nothing is before the board. When a Full Idea clears the completeness grade, its window opens here.',
427
+ );
428
+ }
429
+
395
430
  // ---- init ----------------------------------------------------------------
396
431
 
397
432
  function showSealed() {
@@ -416,8 +451,9 @@
416
451
  const rank = (me && (me.builder || me).rank || '').toLowerCase();
417
452
  if (rank !== 'archon') { showSealed(); return; }
418
453
 
419
- document.getElementById('gov-sub').textContent = 'Ranks, permissions, and who holds them. Ranks below Archon are yours to shape; what the substrate enforces stays locked.';
454
+ document.getElementById('gov-sub').textContent = 'How this project is governed — the constitution it runs, the Board Room, and the ranks & permissions that administer it.';
420
455
  document.getElementById('gov-body').hidden = false;
456
+ wireTabs();
421
457
  wireForms();
422
458
  try {
423
459
  await load();
@@ -13,7 +13,7 @@
13
13
  // 1. Pages — read live from the sidebar shell.js rendered, so rank + module
14
14
  // gating come for free and can never drift from the nav. A hidden nav item
15
15
  // is a hidden destination. (Cosmetic only; the server still gates each page.)
16
- // 2. Sections — the sub-destinations of the three pages that have them. These
16
+ // 2. Sections — the sub-destinations of the four pages that have them. These
17
17
  // cannot be read from another page's DOM, so they are declared below and
18
18
  // held to the real ids by tests/hall_palette.mjs.
19
19
  // 3. Records — id syntax: `1234`, `g 12`, `b 7`, `i 42`.
@@ -28,9 +28,10 @@
28
28
  // element whose visibility decides whether the group is offered — the nav item
29
29
  // itself for pages, the profile chip for Settings (which is not a nav item).
30
30
  // Anchors must stay in step with their source of truth; the test asserts it:
31
- // standing → builders.js STANDING_GROUPS (+ shell.js VIEW_ALIASES)
32
- // work → work.js makeTabs ids
33
- // settings → settings.js RAIL_GROUPS
31
+ // standing → builders.js STANDING_GROUPS (+ shell.js VIEW_ALIASES)
32
+ // work → work.js makeTabs ids
33
+ // government → government.js makeTabs ids (the R15 three-section IA)
34
+ // settings → settings.js RAIL_GROUPS
34
35
  var SECTIONS = [
35
36
  { parent: 'Standing', gate: 'nav-standing', items: [
36
37
  { label: 'Work', href: '/#standing-work' },
@@ -44,6 +45,11 @@
44
45
  { label: 'Backlog', href: '/work#backlog' },
45
46
  { label: 'Completed', href: '/work#completed' },
46
47
  ] },
48
+ { parent: 'Government', gate: 'nav-government', items: [
49
+ { label: 'Constitution', href: '/government#constitution' },
50
+ { label: 'Board Room', href: '/government#board-room' },
51
+ { label: 'Ranks & Permissions', href: '/government#ranks' },
52
+ ] },
47
53
  { parent: 'Settings', gate: 'shell-user', items: [
48
54
  { label: 'Account', href: '/settings#account' },
49
55
  { label: 'Access', href: '/settings#access' },
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.222",
3
+ "version": "1.19.224",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.222",
9
+ "version": "1.19.224",
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.222",
3
+ "version": "1.19.224",
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
@@ -871,8 +871,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
871
871
  // landed since 1.19.220 with no explicit bump. run 32095276039. (task 1002620)
872
872
  // 1.19.222 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
873
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)
876
+ // 1.19.224 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
877
+ // landed since 1.19.223 with no explicit bump. run 32098083321. (task 1002620)
874
878
  // ---------------------------------------------------------------------------
875
- const CORE_VERSION = '1.19.222'; // CI auto-patch carrier (ADR 0161); see changelog above
879
+ const CORE_VERSION = '1.19.224'; // CI auto-patch carrier (ADR 0161); see changelog above
876
880
 
877
881
  // A namespaced logger so a module's log lines are attributable + consistent.
878
882
  // 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
+ });
@@ -111,6 +111,16 @@ test('Tasks tab anchors exist in work.js makeTabs', () => {
111
111
  }
112
112
  });
113
113
 
114
+ test('Government section anchors exist in government.js makeTabs (the R15 three-section IA)', () => {
115
+ const ids = [...read('government.js').matchAll(/\{ id: '([a-z-]+)', label: '[^']*', panel:/g)].map((m) => m[1]);
116
+ assert.ok(ids.length === 3, `sanity: found the three government tabs (${ids})`);
117
+ const declared = declaredHrefs((x) => x.startsWith('/government#'));
118
+ assert.ok(declared.length === 3, 'palette declares the three Government sections');
119
+ for (const h of declared) {
120
+ assert.ok(ids.includes(h.split('#')[1]), `${h} must be a real government.js tab id`);
121
+ }
122
+ });
123
+
114
124
  test('Settings panel anchors exist in settings.js RAIL_GROUPS', () => {
115
125
  // RAIL_GROUPS is derived: RAIL_ITEMS.map(i => i.id).concat('extensions').
116
126
  const src = read('settings.js');