@bongos/core 1.19.232 → 1.19.233

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.232",
6
- "core_contract": "1.19.232",
7
- "source_commit": "0410dea5ee4d77aab90467679874cc5abee21963",
5
+ "core_version": "1.19.233",
6
+ "core_contract": "1.19.233",
7
+ "source_commit": "17f28d2147bc72ed6a97cbc256e388a889e7ddec",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-18T11:50:40.699Z",
9
+ "built_at": "2026-08-18T16:32:54.929Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 311,
13
13
  "agent_docs_stubbed": 21,
14
- "functional_verbatim": 1315,
14
+ "functional_verbatim": 1317,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1647,
20
- "tree_sha256": "94232cdfd24e7ff58e77b2642499e86fad84a4d09483c8b5150028b363080d4d",
19
+ "file_count": 1649,
20
+ "tree_sha256": "211de1015cfb5ba8ea8bb0e6aa7467566e296ffea14ebc4cabd2875e21fb6b2c",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -347,12 +347,12 @@
347
347
  {
348
348
  "path": "clients/bongos-client/README.md",
349
349
  "mode": "0000644",
350
- "sha256": "6be7d91bb8c0b620f3a67e071e96435946413330e05c97bf901fd7241bbd10f9"
350
+ "sha256": "841ea9c45ad482286f056e885cd25c6769931134a2ff066b638d7585a097888e"
351
351
  },
352
352
  {
353
353
  "path": "clients/bongos-client/bongos-client.global.js",
354
354
  "mode": "0000644",
355
- "sha256": "6af213e35ced87effe655c14a220b472768551c37f0cd7abddebcbdbcb7d81a9"
355
+ "sha256": "a9c482ca4c6d2cc313c76f3e098c3bdaa802f5c1253c749e3b5df6dfbae06463"
356
356
  },
357
357
  {
358
358
  "path": "clients/bongos-client/examples/hello-world.mjs",
@@ -362,17 +362,17 @@
362
362
  {
363
363
  "path": "clients/bongos-client/index.cjs",
364
364
  "mode": "0000644",
365
- "sha256": "18f00d369a1689973e2eeb33034302142884cb2836d3f1ea76b882f3379d0cf8"
365
+ "sha256": "fafd3c715b3eb716d7f8355001cf4ea4a8254b78fd757c4a4343ad673a4f1bd8"
366
366
  },
367
367
  {
368
368
  "path": "clients/bongos-client/index.d.ts",
369
369
  "mode": "0000644",
370
- "sha256": "ed6940e45f5634264705869043f0a795ddb4bea264f036789b3c80cc08424b97"
370
+ "sha256": "dc1ef3a4e2843b836e828e5c8d290861dda56844e4f5727ad5d9b3001d7e4e6b"
371
371
  },
372
372
  {
373
373
  "path": "clients/bongos-client/index.mjs",
374
374
  "mode": "0000644",
375
- "sha256": "e3d388b90604d290b3e6346129701b7f2a0a15b4c3b6abe915a43c8702daa1e8"
375
+ "sha256": "48ea243ec0016c7dc2842322100406c339b2b7d05bccf8f0119060fc0cf7e5b2"
376
376
  },
377
377
  {
378
378
  "path": "clients/bongos-client/package.json",
@@ -1372,12 +1372,12 @@
1372
1372
  {
1373
1373
  "path": "docs/api-reference.md",
1374
1374
  "mode": "0000644",
1375
- "sha256": "3212eaeaaf4f1f64889fa6b144fcc48845ed12c4fe08b2a74d3c2bcffc503064"
1375
+ "sha256": "18317d38ec567284fbb3fd3e351d8fecd10c0aa9371b353128e1e9ff0e6e7717"
1376
1376
  },
1377
1377
  {
1378
1378
  "path": "docs/api/openapi.json",
1379
1379
  "mode": "0000644",
1380
- "sha256": "0709c0e537931a75055afea96cf48dd9f3820faa09cd781f07028b7d0b1f26c8"
1380
+ "sha256": "f2f81530200394e99b8591d43839ba024ab0e055331f86c119fa0e5ab39abbce"
1381
1381
  },
1382
1382
  {
1383
1383
  "path": "docs/architecture.md",
@@ -2902,7 +2902,7 @@
2902
2902
  {
2903
2903
  "path": "modules/government/board.js",
2904
2904
  "mode": "0000644",
2905
- "sha256": "359de8efb5ec1365e6114a8c5b3d62aadd484ca7d94d1a36684b6c0c640aaa99"
2905
+ "sha256": "98c0543844eb7a2143fc9541098e067702d3b76b4a546034b050a45105403d1a"
2906
2906
  },
2907
2907
  {
2908
2908
  "path": "modules/government/catalog.js",
@@ -2917,7 +2917,7 @@
2917
2917
  {
2918
2918
  "path": "modules/government/db.js",
2919
2919
  "mode": "0000644",
2920
- "sha256": "cb5c481fcd1a9f6becb63b88a1f0ca4004e592a2f70e02c968a3cc10378dabd5"
2920
+ "sha256": "dd937ea64d6e4cd6ff677c136c5b8a493fecbf75b63082d9449b4a4390d1a0af"
2921
2921
  },
2922
2922
  {
2923
2923
  "path": "modules/government/migrations/governance_001_tables.sql",
@@ -2989,6 +2989,11 @@
2989
2989
  "mode": "0000644",
2990
2990
  "sha256": "5629112a9bb932873a00b8ddefa847d9a21ed0f8f59cc8a77ffb9c9c5e0eea49"
2991
2991
  },
2992
+ {
2993
+ "path": "modules/government/migrations/government_008_closed_items_index.sql",
2994
+ "mode": "0000644",
2995
+ "sha256": "2392050b4295f9fdb4105b8434a4c44833b5f0e850f11b41afe82ea1860fef34"
2996
+ },
2992
2997
  {
2993
2998
  "path": "modules/government/module.json",
2994
2999
  "mode": "0000644",
@@ -3022,7 +3027,7 @@
3022
3027
  {
3023
3028
  "path": "modules/government/routes/government.js",
3024
3029
  "mode": "0000644",
3025
- "sha256": "2bde6d18afc3f24ae2eaeac08b16d83d3ab6ddb22b61da4b9b2f261ee4e844a6"
3030
+ "sha256": "2c7ae89e1e8e91107a8e0a55cc82d6ee4f8804fedb76d07dbe5e502c4801e187"
3026
3031
  },
3027
3032
  {
3028
3033
  "path": "modules/government/routes/rank-sync.js",
@@ -3267,12 +3272,12 @@
3267
3272
  {
3268
3273
  "path": "modules/hall-ui/public/government.html",
3269
3274
  "mode": "0000644",
3270
- "sha256": "23ceb462eddfb0f66ba3030231ae1394d72eb0b6689a7f127c1ba52554c874bf"
3275
+ "sha256": "ffd03d1fefcd4c4639f66812fe35dd3f74a2c65ed02a576a5d9703883df10736"
3271
3276
  },
3272
3277
  {
3273
3278
  "path": "modules/hall-ui/public/government.js",
3274
3279
  "mode": "0000644",
3275
- "sha256": "4778f9a83d63900aca6969a85e7cb67850ffb591a6cbae1fb4be270393741cd1"
3280
+ "sha256": "39215495c3ec93b4426c95b0a16db76297a070aa678318507f8c064a57692e5d"
3276
3281
  },
3277
3282
  {
3278
3283
  "path": "modules/hall-ui/public/hall-kit.js",
@@ -4317,12 +4322,12 @@
4317
4322
  {
4318
4323
  "path": "package-lock.json",
4319
4324
  "mode": "0000644",
4320
- "sha256": "de0ff156427786537d3a4231d4fae7721d52027b9a25d6185bc53efac35b24d2"
4325
+ "sha256": "c1798947aa88297385a13e4c3b16070a6a6e79208e077b5c40459580dfe3f55c"
4321
4326
  },
4322
4327
  {
4323
4328
  "path": "package.json",
4324
4329
  "mode": "0000644",
4325
- "sha256": "c5fa9bf830e64903a205cf28feabd9255772343cc1f002f344fa92ede02f7f7d"
4330
+ "sha256": "25e6cf1d1ba69421a3a6ee12d77ab8609f6748aacb0d5390ccc0c287dc2c2ff0"
4326
4331
  },
4327
4332
  {
4328
4333
  "path": "public-docs/index.html",
@@ -5802,7 +5807,7 @@
5802
5807
  {
5803
5808
  "path": "src/bongos/route-rank-check.js",
5804
5809
  "mode": "0000644",
5805
- "sha256": "34850de6361ab78ce8788e20c483ded22d3de9ff9fb42bf14497509b0870f66c"
5810
+ "sha256": "a074972d1da13fb71acdd9d60126488f345ff59c33d83aca90cea50b8ed064ab"
5806
5811
  },
5807
5812
  {
5808
5813
  "path": "src/bongos/routes.js",
@@ -5902,7 +5907,7 @@
5902
5907
  {
5903
5908
  "path": "src/bongos/serve-internal.js",
5904
5909
  "mode": "0000644",
5905
- "sha256": "94af8e061c690b6fb979cd95ec3a65e21b0f830898a05ab868c5a9f34d9dee72"
5910
+ "sha256": "885076ed4dce0676735055df3f841a72ac41846568d39434a80d48eacced3e16"
5906
5911
  },
5907
5912
  {
5908
5913
  "path": "src/bongos/stealth-gate.js",
@@ -5937,7 +5942,7 @@
5937
5942
  {
5938
5943
  "path": "src/module-api.js",
5939
5944
  "mode": "0000644",
5940
- "sha256": "1302cbcd4f4b6b35032165625c203741ccc846304680876d3789ce212d0bc746"
5945
+ "sha256": "aff6b3aeff836216d9b45d025375021888c25cc3f6942705341698652836bdd2"
5941
5946
  },
5942
5947
  {
5943
5948
  "path": "src/module-loader/catalog.js",
@@ -6879,6 +6884,11 @@
6879
6884
  "mode": "0000644",
6880
6885
  "sha256": "1a6f90e319c88ecd3e5588d5a6df44a9848c0adf08efd13bfcc52ac99e84d5d0"
6881
6886
  },
6887
+ {
6888
+ "path": "tests/government_board_view.mjs",
6889
+ "mode": "0000644",
6890
+ "sha256": "104dbfd5510f4abf4580cf89f57e4be15667b4b6e6f171a2635786bfd4d65f9e"
6891
+ },
6882
6892
  {
6883
6893
  "path": "tests/government_board_vote.mjs",
6884
6894
  "mode": "0000644",
@@ -6967,7 +6977,7 @@
6967
6977
  {
6968
6978
  "path": "tests/government_seed.mjs",
6969
6979
  "mode": "0000644",
6970
- "sha256": "9c0c1049180013f30a3403a4e308112ed871119d9e60c459e9573108b35a5a27"
6980
+ "sha256": "cd67c76a30e1c69838f99afbb8edd17a0ec2109a8105b61805b9564e27464137"
6971
6981
  },
6972
6982
  {
6973
6983
  "path": "tests/government_session_scope.mjs",
@@ -7057,7 +7067,7 @@
7057
7067
  {
7058
7068
  "path": "tests/hall_page_gate_map.mjs",
7059
7069
  "mode": "0000644",
7060
- "sha256": "cb23b7a54220c3c53906fed559fb0c8a8e97375aa48dfc3cf64a1af88339e110"
7070
+ "sha256": "98a5317bf8599941802794a5a0679e1ad2ff84dbaa7c36c88140a5f5fcd30780"
7061
7071
  },
7062
7072
  {
7063
7073
  "path": "tests/hall_palette.mjs",
@@ -5,7 +5,7 @@ A **generated**, zero-dependency typed client for the Bongos API — produced fr
5
5
  by hand; it regenerates when the spec changes, so it can never drift from the routes.
6
6
 
7
7
  - API version: **v1** (served at `/api/bongos/v1`)
8
- - 283 operations across 46 resource groups
8
+ - 284 operations across 46 resource groups
9
9
 
10
10
  ## Install
11
11
 
@@ -349,6 +349,8 @@ function createClient(opts = {}) {
349
349
  postGovernmentAssignments: (args) => request("POST", "/government/assignments", { hasBody: true }, args),
350
350
  // DELETE /government/assignments/{builderId}/{rankKey} — rank: archon — DELETE /government/assignments/:builderId/:rankKey
351
351
  deleteGovernmentAssignmentsBuilderIdRankKey: (args) => request("DELETE", "/government/assignments/{builderId}/{rankKey}", { hasBody: false }, args),
352
+ // GET /government/board/items — rank: any-builder — GET /government/board/items
353
+ getGovernmentBoardItems: (args) => request("GET", "/government/board/items", { hasBody: false }, args),
352
354
  // POST /government/board/items — rank: metic+archon — POST /government/board/items
353
355
  postGovernmentBoardItems: (args) => request("POST", "/government/board/items", { hasBody: true }, args),
354
356
  // POST /government/board/items/{itemId}/votes — rank: any-builder — POST /government/board/items/:itemId/votes
@@ -348,6 +348,8 @@ function createClient(opts = {}) {
348
348
  postGovernmentAssignments: (args) => request("POST", "/government/assignments", { hasBody: true }, args),
349
349
  // DELETE /government/assignments/{builderId}/{rankKey} — rank: archon — DELETE /government/assignments/:builderId/:rankKey
350
350
  deleteGovernmentAssignmentsBuilderIdRankKey: (args) => request("DELETE", "/government/assignments/{builderId}/{rankKey}", { hasBody: false }, args),
351
+ // GET /government/board/items — rank: any-builder — GET /government/board/items
352
+ getGovernmentBoardItems: (args) => request("GET", "/government/board/items", { hasBody: false }, args),
351
353
  // POST /government/board/items — rank: metic+archon — POST /government/board/items
352
354
  postGovernmentBoardItems: (args) => request("POST", "/government/board/items", { hasBody: true }, args),
353
355
  // POST /government/board/items/{itemId}/votes — rank: any-builder — POST /government/board/items/:itemId/votes
@@ -603,6 +603,8 @@ export interface BongosClient {
603
603
  postGovernmentAssignments(args: RequestArgs & { body: PostGovernmentAssignmentsRequest }): Promise<ApiResponse>;
604
604
  /** DELETE /government/assignments/{builderId}/{rankKey} — rank: archon */
605
605
  deleteGovernmentAssignmentsBuilderIdRankKey(args?: RequestArgs): Promise<DeleteGovernmentAssignmentsBuilderIdRankKeyResponse>;
606
+ /** GET /government/board/items — rank: any-builder */
607
+ getGovernmentBoardItems(args?: RequestArgs): Promise<ApiResponse>;
606
608
  /** POST /government/board/items — rank: metic+archon */
607
609
  postGovernmentBoardItems(args: RequestArgs & { body: PostGovernmentBoardItemsRequest }): Promise<PostGovernmentBoardItemsResponse>;
608
610
  /** POST /government/board/items/{itemId}/votes — rank: any-builder */
@@ -345,6 +345,8 @@ export function createClient(opts = {}) {
345
345
  postGovernmentAssignments: (args) => request("POST", "/government/assignments", { hasBody: true }, args),
346
346
  // DELETE /government/assignments/{builderId}/{rankKey} — rank: archon — DELETE /government/assignments/:builderId/:rankKey
347
347
  deleteGovernmentAssignmentsBuilderIdRankKey: (args) => request("DELETE", "/government/assignments/{builderId}/{rankKey}", { hasBody: false }, args),
348
+ // GET /government/board/items — rank: any-builder — GET /government/board/items
349
+ getGovernmentBoardItems: (args) => request("GET", "/government/board/items", { hasBody: false }, args),
348
350
  // POST /government/board/items — rank: metic+archon — POST /government/board/items
349
351
  postGovernmentBoardItems: (args) => request("POST", "/government/board/items", { hasBody: true }, args),
350
352
  // POST /government/board/items/{itemId}/votes — rank: any-builder — POST /government/board/items/:itemId/votes
@@ -5337,6 +5337,38 @@
5337
5337
  }
5338
5338
  },
5339
5339
  "/government/board/items": {
5340
+ "get": {
5341
+ "operationId": "get_government_board_items",
5342
+ "tags": [
5343
+ "government"
5344
+ ],
5345
+ "summary": "GET /government/board/items",
5346
+ "description": "board.vote.cast — the Board Room view: open items with their clock, the OPEN BALLOT, the closed history (R16 of goal 1000069). Gated on the XENOS-floored vote atom, deliberately: seeing the ballot is part of casting (§9 — a board whose votes are invisible constrains nobody), and the room teaches as well as governs (criterion C5). NOT government.manage — that is the admin tab's atom; the room must be visible to whoever the constitution may enfranchise. Membership still gates nothing here: this is a read, and the write (the vote) re-checks membership in-handler.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).\n\n**Permissions:** `board.vote.cast` (all required).",
5347
+ "x-rank": "any-builder",
5348
+ "x-source": "modules/government/routes/government.js",
5349
+ "x-permissions": [
5350
+ "board.vote.cast"
5351
+ ],
5352
+ "responses": {
5353
+ "200": {
5354
+ "description": "Success."
5355
+ },
5356
+ "400": {
5357
+ "$ref": "#/components/responses/BadRequest"
5358
+ },
5359
+ "401": {
5360
+ "$ref": "#/components/responses/Unauthorized"
5361
+ },
5362
+ "403": {
5363
+ "$ref": "#/components/responses/Forbidden"
5364
+ }
5365
+ },
5366
+ "security": [
5367
+ {
5368
+ "builderSession": []
5369
+ }
5370
+ ]
5371
+ },
5340
5372
  "post": {
5341
5373
  "operationId": "post_government_board_items",
5342
5374
  "tags": [
@@ -17969,7 +18001,7 @@
17969
18001
  "description": "A required dependency/feature is not configured or is temporarily down."
17970
18002
  }
17971
18003
  },
17972
- "x-endpoint-count": 283,
18004
+ "x-endpoint-count": 284,
17973
18005
  "x-schema-count": 321,
17974
18006
  "x-undocumented-bodies": 8,
17975
18007
  "x-response-schemas": 215,
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Bongos API reference
4
4
 
5
- > **Generated from the live route files** — the route file is authoritative. 283 endpoints across 46 route files.
5
+ > **Generated from the live route files** — the route file is authoritative. 284 endpoints across 46 route files.
6
6
  > Machine-readable spec: [`docs/api/openapi.json`](api/openapi.json) (OpenAPI 3.1). Rendered docs site: **`/docs`** (e.g. `cloudbongos.com/docs`).
7
7
 
8
8
  Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>.md)): `public` < `any-builder` < `metic+archon` < `archon`.
@@ -216,13 +216,14 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
216
216
  | GET | `/api/bongos/goals/inbox` | `any-builder` | — | GET /goals/inbox — the caller's private-goals inbox (task 1952 / ADR 0112): { invitations: invites addressed to me (accept/decline); join… |
217
217
  | POST | `/api/bongos/goals/reorder` | `metic+archon` | `version_id`, `ordered_ids` | POST /goals/reorder — Archon drag-to-reorder: set the priority order of every goal WITHIN one version (task 1760). |
218
218
 
219
- ## `government` (11)
219
+ ## `government` (12)
220
220
 
221
221
  | Method | Path | Rank | Body | Description |
222
222
  |---|---|---|---|---|
223
223
  | GET | `/api/bongos/government/assignments` | `archon` | — | government.manage — every builder↔rank assignment row. |
224
224
  | POST | `/api/bongos/government/assignments` | `archon` | `builder_id`, `rank_key` | government.manage — assign a CUSTOM rank to a builder. |
225
225
  | DELETE | `/api/bongos/government/assignments/:builderId/:rankKey` | `archon` | — | government.manage — remove a custom-rank assignment. |
226
+ | GET | `/api/bongos/government/board/items` | `any-builder` | — | board.vote.cast — the Board Room view: open items with their clock, the OPEN BALLOT, the closed history (R16 of goal 1000069). |
226
227
  | POST | `/api/bongos/government/board/items` | `metic+archon` | `subject_type`, `proposal`, `rationale_md` | board.item.open — put an AMENDMENT to the board (R14 of goal 1000069, ADR 0175 §7). |
227
228
  | POST | `/api/bongos/government/board/items/:itemId/votes` | `any-builder` | `direction`, `reason_md`, `section_key` | board.vote.cast — cast a vote on an open board item (R08 of goal 1000069, ADR 0175). |
228
229
  | GET | `/api/bongos/government/permissions` | `archon` | — | government.manage — the permission catalog + principals, straight from code (the catalog is CODE, not a table); no DB read. |
@@ -453,6 +453,73 @@ async function proposeAmendment({ proposal, rationaleMd = null, proposedBy } = {
453
453
  return { ok: true, amendment, item };
454
454
  }
455
455
 
456
+ // ── the Board Room view (BV1.R16 of goal 1000069, ADR 0175 §9) ──────────────
457
+ //
458
+ // One assembled read for the room: open items with their clock, the OPEN
459
+ // BALLOT (people, directions, timestamps, objections), and the bounded closed
460
+ // history — enriched with what each subject IS (an idea's title, an
461
+ // amendment's proposed constitution). Deliberately a VIEW, not a policy
462
+ // surface: nothing here decides anything, and the payload carries the same
463
+ // constitution SNAPSHOT the deciding paths use, so the room describes the rule
464
+ // each item actually runs under. `remaining_seconds` comes from the database
465
+ // clock per row — the client renders a countdown, it never does clock math.
466
+ //
467
+ // The subject AUTHOR rides along (an idea's captor / an amendment's proposer)
468
+ // so R17's vote UI can grey out the anti-self case for ideas without a second
469
+ // round trip — the SERVER still refuses regardless (castVote), this is only
470
+ // the courtesy rendering.
471
+ async function boardRoomView({ closedLimit = 20 } = {}) {
472
+ const [open, closed] = await Promise.all([
473
+ db.listOpenBoardItems(),
474
+ db.listClosedBoardItems(closedLimit),
475
+ ]);
476
+ const all = [...open, ...closed];
477
+ const votes = await db.getVotesForItems(all.map((i) => i.id));
478
+ const ideaIds = [...new Set(all.filter((i) => i.subject_type === 'full_idea').map((i) => i.subject_id))];
479
+ const amendmentIds = [...new Set(all.filter((i) => i.subject_type === 'constitutional_amendment').map((i) => i.subject_id))];
480
+ const [ideas, amendments] = await Promise.all([
481
+ db.getIdeaSubjects(ideaIds),
482
+ db.getAmendmentSubjects(amendmentIds),
483
+ ]);
484
+ const ideaById = new Map(ideas.map((r) => [String(r.id), r]));
485
+ const amendmentById = new Map(amendments.map((r) => [String(r.id), r]));
486
+ const votesByItem = new Map();
487
+ for (const v of votes) {
488
+ const key = String(v.item_id);
489
+ if (!votesByItem.has(key)) votesByItem.set(key, []);
490
+ votesByItem.get(key).push({
491
+ voter_id: String(v.voter_id),
492
+ github_login: v.github_login,
493
+ display_name: v.display_name,
494
+ direction: v.direction,
495
+ reason_md: v.reason_md,
496
+ section_key: v.section_key,
497
+ cast_at: v.cast_at,
498
+ });
499
+ }
500
+ const project = (item) => {
501
+ const subject = item.subject_type === 'full_idea'
502
+ ? (() => { const r = ideaById.get(String(item.subject_id)); return r ? { title: r.title, author_id: String(r.captured_by) } : null; })()
503
+ : (() => { const r = amendmentById.get(String(item.subject_id)); return r ? { proposed: r.proposed, rationale_md: r.rationale_md, author_id: r.proposed_by == null ? null : String(r.proposed_by) } : null; })();
504
+ return {
505
+ id: String(item.id),
506
+ subject_type: item.subject_type,
507
+ subject_id: String(item.subject_id),
508
+ subject, // null when the subject row is gone — the room says so honestly
509
+ opened_by: item.opened_by == null ? null : String(item.opened_by),
510
+ opened_at: item.opened_at,
511
+ closes_at: item.closes_at,
512
+ remaining_seconds: item.remaining_seconds ?? null,
513
+ expired: item.expired === true,
514
+ closed_at: item.closed_at || null,
515
+ outcome: item.outcome || null,
516
+ constitution: item.constitution || null,
517
+ votes: votesByItem.get(String(item.id)) || [],
518
+ };
519
+ };
520
+ return { open: open.map(project), closed: closed.map(project) };
521
+ }
522
+
456
523
  // ── R13's read: the feedback a returned idea carries back to its author ─────
457
524
  //
458
525
  // One reasoned objection is what returns an idea (§3), so the feedback IS that
@@ -493,4 +560,5 @@ module.exports = {
493
560
  latestRejectionForIdea,
494
561
  validateProposedConstitution,
495
562
  proposeAmendment,
563
+ boardRoomView,
496
564
  };
@@ -437,6 +437,78 @@ async function getIdeaCompleteness(ideaId) {
437
437
  return rows[0] ? rows[0].completeness_score : null;
438
438
  }
439
439
 
440
+ // --- the Board Room reads (BV1.R16 of goal 1000069) --------------------------
441
+ //
442
+ // The room's view: open items with their clock, the OPEN BALLOT (voter
443
+ // identity, direction, timestamp, objection), and the closed history. Remaining
444
+ // time is computed by the DATABASE clock per row (the same posture as expiry),
445
+ // so the client never does clock math against its own skew. Concurrent windows
446
+ // are normal — the reads are lists, ordered.
447
+
448
+ async function listOpenBoardItems() {
449
+ const { rows } = await pool.query(
450
+ `SELECT *,
451
+ (closes_at IS NOT NULL AND closes_at < now()) AS expired,
452
+ CASE WHEN closes_at IS NULL THEN NULL
453
+ ELSE GREATEST(0, EXTRACT(EPOCH FROM (closes_at - now()))::int) END AS remaining_seconds
454
+ FROM government_board_items
455
+ WHERE closed_at IS NULL
456
+ ORDER BY opened_at DESC`,
457
+ );
458
+ return rows;
459
+ }
460
+
461
+ // Bounded closed history, newest close first. Served by government_008's
462
+ // closed-items index — the closed set grows forever (resubmission is
463
+ // unbounded), and this runs on every Board Room page load.
464
+ async function listClosedBoardItems(limit = 20) {
465
+ const { rows } = await pool.query(
466
+ `SELECT * FROM government_board_items
467
+ WHERE closed_at IS NOT NULL
468
+ ORDER BY closed_at DESC
469
+ LIMIT $1`,
470
+ [limit],
471
+ );
472
+ return rows;
473
+ }
474
+
475
+ // The open ballot for a SET of items in one query — voter identity joined so
476
+ // the room shows people, not ids (§9: a board whose votes are invisible
477
+ // constrains nobody).
478
+ async function getVotesForItems(itemIds) {
479
+ if (!itemIds || !itemIds.length) return [];
480
+ const { rows } = await pool.query(
481
+ `SELECT v.*, b.github_login, b.display_name
482
+ FROM government_board_votes v
483
+ JOIN builders b ON b.id = v.voter_id
484
+ WHERE v.item_id = ANY($1::bigint[])
485
+ ORDER BY v.cast_at, v.id`,
486
+ [itemIds],
487
+ );
488
+ return rows;
489
+ }
490
+
491
+ // Subject enrichment, one query per subject family. Shared-table reads (the
492
+ // boundary is code imports, not table access) — a full_idea shows its title,
493
+ // an amendment its proposed block + rationale.
494
+ async function getIdeaSubjects(ideaIds) {
495
+ if (!ideaIds || !ideaIds.length) return [];
496
+ const { rows } = await pool.query(
497
+ `SELECT id, title, captured_by FROM idea_inbox WHERE id = ANY($1::bigint[])`,
498
+ [ideaIds],
499
+ );
500
+ return rows;
501
+ }
502
+
503
+ async function getAmendmentSubjects(amendmentIds) {
504
+ if (!amendmentIds || !amendmentIds.length) return [];
505
+ const { rows } = await pool.query(
506
+ `SELECT id, proposed, rationale_md, proposed_by FROM government_amendments WHERE id = ANY($1::bigint[])`,
507
+ [amendmentIds],
508
+ );
509
+ return rows;
510
+ }
511
+
440
512
  // --- amendments (BV1.R14 of goal 1000069) ------------------------------------
441
513
 
442
514
  async function createAmendment({ proposed, rationaleMd = null, proposedBy }) {
@@ -553,4 +625,9 @@ module.exports = {
553
625
  getLatestReturnedItemWithObjection,
554
626
  createAmendment,
555
627
  getAmendment,
628
+ listOpenBoardItems,
629
+ listClosedBoardItems,
630
+ getVotesForItems,
631
+ getIdeaSubjects,
632
+ getAmendmentSubjects,
556
633
  };
@@ -0,0 +1,17 @@
1
+ -- government_008_closed_items_index.sql — index the closed-history read
2
+ -- (task 1003029 / R16 of goal 1000069).
3
+ --
4
+ -- The Board Room's closed history reads "newest closes first, LIMIT n" on every
5
+ -- page load, and the closed set grows forever (one row per window; resubmission
6
+ -- is unbounded by design). The table's other indexes are partial on OPEN items
7
+ -- (the sweep, the one-open-per-subject wall) or on outcome alone — none order
8
+ -- by close time. Same reasoning as government_006's returned-items index.
9
+ -- Additive, idempotent, no rows touched.
10
+
11
+ BEGIN;
12
+
13
+ CREATE INDEX IF NOT EXISTS government_board_items_closed_by_time
14
+ ON government_board_items (closed_at DESC)
15
+ WHERE closed_at IS NOT NULL;
16
+
17
+ COMMIT;
@@ -414,6 +414,24 @@ module.exports = function buildGovernmentRouter() {
414
414
  }
415
415
  });
416
416
 
417
+ // rank: perm board.vote.cast — the Board Room view: open items with their
418
+ // clock, the OPEN BALLOT, the closed history (R16 of goal 1000069). Gated on
419
+ // the XENOS-floored vote atom, deliberately: seeing the ballot is part of
420
+ // casting (§9 — a board whose votes are invisible constrains nobody), and the
421
+ // room teaches as well as governs (criterion C5). NOT government.manage —
422
+ // that is the admin tab's atom; the room must be visible to whoever the
423
+ // constitution may enfranchise. Membership still gates nothing here: this is
424
+ // a read, and the write (the vote) re-checks membership in-handler.
425
+ router.get('/government/board/items', api.requireBuilder, api.requirePermission('board.vote.cast'), async (req, res) => {
426
+ try {
427
+ const view = await board.boardRoomView({ closedLimit: 20 });
428
+ res.json({ ...view, viewer_id: String(req.builder.id) });
429
+ } catch (err) {
430
+ console.error('[gds] GET /government/board/items', err);
431
+ res.fail('board_read_failed', { status: 500, message: 'internal error' });
432
+ }
433
+ });
434
+
417
435
  // rank: perm board.item.open — put an AMENDMENT to the board (R14 of goal
418
436
  // 1000069, ADR 0175 §7). The board's other subject never comes through here:
419
437
  // a Full Idea's window opens AUTOMATICALLY on grade pass (R07), so requesting
@@ -99,6 +99,6 @@
99
99
  <script src="/builders/palette.js" defer></script>
100
100
  <script src="/builders/dom-utils.js?v=2026-06-16-gate"></script>
101
101
  <script src="/builders/hall-kit.js"></script>
102
- <script src="/builders/government.js?v=2026-08-18-government-ia"></script>
102
+ <script src="/builders/government.js?v=2026-08-18-board-room"></script>
103
103
  </body>
104
104
  </html>
@@ -401,30 +401,121 @@
401
401
 
402
402
  // ---- the three-section IA (R15) -------------------------------------------
403
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() {
404
+ // Tab order is the spec's — Constitution · Board Room · Ranks & Permissions.
405
+ // Since R16 the Board Room is furnished, so a non-archon (who cannot use the
406
+ // admin tab) lands there; the Archon still lands on Ranks, their working
407
+ // surface. Flip the non-archon initial to 'constitution' when R18 lands.
408
+ function wireTabs({ isArchon }) {
409
409
  window.OTBKit.makeTabs({
410
410
  mount: document.getElementById('gov-tabs'),
411
411
  hash: true,
412
- initial: 'ranks',
412
+ initial: isArchon ? 'ranks' : 'board-room',
413
413
  tabs: [
414
414
  { id: 'constitution', label: 'Constitution', panel: 'gov-panel-constitution' },
415
415
  { id: 'board-room', label: 'Board Room', panel: 'gov-panel-board' },
416
416
  { id: 'ranks', label: 'Ranks & Permissions', panel: 'gov-panel-ranks' },
417
417
  ],
418
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.
419
+ // The Constitution frame stays an empty state until R18's read exists —
420
+ // rendering live values without it would mean hardcoding what drifts.
422
421
  document.getElementById('gov-constitution').innerHTML = window.OTBKit.emptyStateHtml(
423
422
  'The live constitution — what this project runs today, and how it has changed — appears here when the Board Room opens.',
424
423
  );
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
- );
424
+ }
425
+
426
+ // ---- the Board Room panel (R16) --------------------------------------------
427
+
428
+ // The room renders what the server assembled: open items with the database's
429
+ // own countdown, the OPEN BALLOT (people, directions, timestamps, objections),
430
+ // and the closed history. Nothing here decides anything — casting arrives with
431
+ // R17; this panel is the accountability surface (§9: a board whose votes are
432
+ // invisible constrains nobody).
433
+
434
+ function fmtRemaining(seconds) {
435
+ if (seconds === null || seconds === undefined) return null;
436
+ const s = Number(seconds);
437
+ if (!Number.isFinite(s) || s <= 0) return 'closing…';
438
+ if (s < 90) return `closes in ${Math.max(1, Math.round(s))}s`;
439
+ if (s < 5400) return `closes in ${Math.round(s / 60)}m`;
440
+ return `closes in ${(s / 3600).toFixed(1)}h`;
441
+ }
442
+
443
+ function subjectLine(item) {
444
+ if (item.subject_type === 'full_idea') {
445
+ const title = item.subject ? item.subject.title : `idea ${item.subject_id}`;
446
+ return `<a href="/#/idea/${escapeHtml(String(item.subject_id))}">${escapeHtml(title)}</a> <span class="gov-pill gov-pill--floor">Full Idea</span>`;
447
+ }
448
+ return `Constitutional amendment <span class="gov-pill gov-pill--floor">amendment</span>`;
449
+ }
450
+
451
+ function clockLine(item) {
452
+ if (item.closes_at) {
453
+ const rem = fmtRemaining(item.remaining_seconds);
454
+ return rem || 'timed sitting';
455
+ }
456
+ const rule = item.constitution && item.constitution.pass_rule;
457
+ return rule === 'first_ratifier'
458
+ ? 'no clock — closes on the first vote'
459
+ : 'no clock';
460
+ }
461
+
462
+ function ballotHtml(item, viewerId) {
463
+ if (!item.votes.length) {
464
+ return `<p class="gov-empty">No votes yet${item.closed_at ? ' — closed unopposed' : ''}.</p>`;
465
+ }
466
+ const rows = item.votes.map((v) => {
467
+ const who = escapeHtml(v.display_name || v.github_login || `builder ${v.voter_id}`);
468
+ const mine = String(v.voter_id) === String(viewerId) ? ' <span class="gov-held-note">your vote</span>' : '';
469
+ const when = escapeHtml(String(v.cast_at).slice(0, 16).replace('T', ' '));
470
+ const objection = v.direction === 'no'
471
+ ? `<div class="gov-perm__guards">objected to <code>${escapeHtml(v.section_key || '')}</code> — ${escapeHtml(v.reason_md || '')}</div>`
472
+ : '';
473
+ return `<div class="gov-assign-row"><span class="gov-assign-row__who">${who}${mine}</span>
474
+ <span class="gov-pill ${v.direction === 'no' ? 'gov-pill--locked' : 'gov-pill--seeded'}">${v.direction}</span>
475
+ <span class="muted">${when}</span>${objection}</div>`;
476
+ }).join('');
477
+ return `<div class="gov-section">${rows}</div>`;
478
+ }
479
+
480
+ function itemCard(item, viewerId) {
481
+ const yes = item.votes.filter((v) => v.direction === 'yes').length;
482
+ const no = item.votes.filter((v) => v.direction === 'no').length;
483
+ const state = item.closed_at
484
+ ? `<span class="gov-pill ${item.outcome === 'passed' ? 'gov-pill--seeded' : 'gov-pill--locked'}">${escapeHtml(item.outcome || '')}</span>`
485
+ : `<span class="gov-pill gov-pill--custom">open</span> <span class="muted">${escapeHtml(clockLine(item))}</span>`;
486
+ const amendmentDetail = item.subject_type === 'constitutional_amendment' && item.subject
487
+ ? `<div class="gov-perm__guards">proposes: <code>${escapeHtml(JSON.stringify(item.subject.proposed))}</code>${item.subject.rationale_md ? ` — ${escapeHtml(item.subject.rationale_md)}` : ''}</div>`
488
+ : '';
489
+ const mine = item.votes.find((v) => String(v.voter_id) === String(viewerId));
490
+ const myLine = item.closed_at ? '' : (mine
491
+ ? `<p class="gov-grid-note">You voted <strong>${escapeHtml(mine.direction)}</strong>. One vote per member — no changes.</p>`
492
+ : `<p class="gov-grid-note">You have not voted on this item. Casting arrives with the vote form.</p>`);
493
+ return `<section class="gov-section">
494
+ <div class="gov-section__head">${subjectLine(item)} ${state}</div>
495
+ ${amendmentDetail}
496
+ <p class="gov-grid-note">tally: ${yes} yes · ${no} no — sitting under <code>${escapeHtml((item.constitution && item.constitution.pass_rule) || '?')}</code>, membership <code>${escapeHtml((item.constitution && item.constitution.membership) || '?')}</code></p>
497
+ ${myLine}
498
+ ${ballotHtml(item, viewerId)}
499
+ </section>`;
500
+ }
501
+
502
+ async function loadBoard() {
503
+ const box = document.getElementById('gov-board');
504
+ let data;
505
+ try {
506
+ data = await fetchJson('/government/board/items');
507
+ } catch (err) {
508
+ if (err && err.kind === '401') return;
509
+ box.innerHTML = `<p class="gov-empty">Could not load the Board Room just now.</p>`;
510
+ return;
511
+ }
512
+ const viewerId = data.viewer_id;
513
+ const open = (data.open || []).map((i) => itemCard(i, viewerId)).join('');
514
+ const closed = (data.closed || []).map((i) => itemCard(i, viewerId)).join('');
515
+ box.innerHTML =
516
+ (open || window.OTBKit.emptyStateHtml('Nothing is before the board. When a Full Idea clears the completeness grade, its window opens here.'))
517
+ + `<h2 class="gov-h">Closed sittings</h2>`
518
+ + (closed || `<p class="gov-empty">No sitting has closed yet.</p>`);
428
519
  }
429
520
 
430
521
  // ---- init ----------------------------------------------------------------
@@ -435,11 +526,20 @@
435
526
  document.getElementById('gov-sub').textContent = 'Restricted.';
436
527
  }
437
528
 
529
+ // Since R16 the page shell admits page.view.government (metic+), and only the
530
+ // RANKS tab is the archon admin surface — so the seal narrowed from the whole
531
+ // page to that one panel. Cosmetic defence-in-depth as ever: every
532
+ // /government/* CRUD endpoint still gates on government.manage server-side,
533
+ // and the board read carries its own atom.
534
+ function sealRanksPanel() {
535
+ const panel = document.getElementById('gov-panel-ranks');
536
+ const body = document.getElementById('gov-body');
537
+ // Replace the admin surface with the sealed note, inside its own tab only.
538
+ panel.innerHTML = '<p class="gov-sealed">Ranks & Permissions is the Archon administration surface. The Constitution and the Board Room are yours to read.</p>';
539
+ body.hidden = false;
540
+ }
541
+
438
542
  async function init() {
439
- // Confirm rank for a friendly sealed notice; the server gates the page and
440
- // every /government endpoint on `government.manage` regardless (cosmetic
441
- // defence-in-depth). government.manage is system:true, so only the archon
442
- // rank can hold it — rank is an accurate client-side proxy.
443
543
  let me;
444
544
  try {
445
545
  me = await fetchJson('/me');
@@ -449,17 +549,20 @@
449
549
  me = null;
450
550
  }
451
551
  const rank = (me && (me.builder || me).rank || '').toLowerCase();
452
- if (rank !== 'archon') { showSealed(); return; }
552
+ const isArchon = rank === 'archon';
453
553
 
454
554
  document.getElementById('gov-sub').textContent = 'How this project is governed — the constitution it runs, the Board Room, and the ranks & permissions that administer it.';
455
555
  document.getElementById('gov-body').hidden = false;
456
- wireTabs();
556
+ wireTabs({ isArchon });
557
+ loadBoard(); // the room loads for everyone the page admits
558
+
559
+ if (!isArchon) { sealRanksPanel(); return; }
457
560
  wireForms();
458
561
  try {
459
562
  await load();
460
563
  } catch (err) {
461
564
  if (err && err.kind === '401') return;
462
- if (err && err.kind === '403') { showSealed(); return; }
565
+ if (err && err.kind === '403') { sealRanksPanel(); return; }
463
566
  document.getElementById('gov-grid').innerHTML = `<p class="gov-empty">Could not load government data just now.</p>`;
464
567
  }
465
568
  }
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.232",
3
+ "version": "1.19.233",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.232",
9
+ "version": "1.19.233",
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.232",
3
+ "version": "1.19.233",
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",
@@ -254,6 +254,9 @@ const GOVERNMENT_PINS = {
254
254
  // R14: propose a constitutional amendment (the board's only proposed subject —
255
255
  // a Full Idea's window opens automatically on grade pass).
256
256
  'POST /government/board/items': 'perm:board.item.open',
257
+ // R16: the Board Room view — the open ballot is visible to whoever may cast
258
+ // (xenos-floored atom; the room teaches as well as governs, criterion C5).
259
+ 'GET /government/board/items': 'perm:board.vote.cast',
257
260
  };
258
261
 
259
262
  // Module-scoped privileged-rank pins: a privileged route that lives in a FEATURE
@@ -789,8 +789,15 @@ function mountInternalSurfaces(app) {
789
789
  || SESSIONS_PAGE_RE.test(norm)) {
790
790
  return gdsAuth.requireGovernmentPage(req, res, next);
791
791
  }
792
+ // R16 (goal 1000069) widened the government shell from the manage atom
793
+ // (archon) to page.view.government (metic+): since R15 the page is THREE
794
+ // rooms — Constitution, Board Room, Ranks & Permissions — and only the
795
+ // third is the archon admin surface. Its DATA routes all still gate on
796
+ // government.manage individually (defence in depth unchanged); the board +
797
+ // constitution reads carry their own atoms. The shell follows the widest
798
+ // legitimate audience — the audience changed when the page did.
792
799
  if (GOVERNMENT_PAGE_RE.test(norm)) {
793
- return gdsAuth.requireGovernmentManagePage(req, res, next);
800
+ return gdsAuth.requireGovernmentPage(req, res, next);
794
801
  }
795
802
  if (PEOPLE_PAGE_RE.test(norm) || SCOUTING_PAGE_RE.test(norm)) {
796
803
  return gdsAuth.requireProjectCuratePage(req, res, next);
package/src/module-api.js CHANGED
@@ -891,8 +891,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
891
891
  // landed since 1.19.230 with no explicit bump. run 32133132988. (task 1002620)
892
892
  // 1.19.232 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
893
893
  // landed since 1.19.231 with no explicit bump. run 32133691409. (task 1002620)
894
+ // 1.19.233 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
895
+ // landed since 1.19.232 with no explicit bump. run 32160823445. (task 1002620)
894
896
  // ---------------------------------------------------------------------------
895
- const CORE_VERSION = '1.19.232'; // CI auto-patch carrier (ADR 0161); see changelog above
897
+ const CORE_VERSION = '1.19.233'; // CI auto-patch carrier (ADR 0161); see changelog above
896
898
 
897
899
  // A namespaced logger so a module's log lines are attributable + consistent.
898
900
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,129 @@
1
+ // tests/government_board_view.mjs — the Board Room view (BV1.R16 of goal
2
+ // 1000069, ADR 0175 §9): open items with their clock, the open ballot, and the
3
+ // closed history — assembled server-side, decided nowhere.
4
+ //
5
+ // DB-free (db monkeypatched, the sibling board tests' idiom). The invariants:
6
+ // 1. THE VIEW DECIDES NOTHING — it projects rows; the payload carries each
7
+ // item's SNAPSHOT so the room describes the rule the item actually runs
8
+ // under, and remaining time is the database's number, passed through.
9
+ // 2. SUBJECTS ARE ENRICHED, HONESTLY — an idea shows its title + author, an
10
+ // amendment its proposed block; a subject whose row is GONE projects
11
+ // subject:null rather than being dropped or invented.
12
+ // 3. THE BALLOT IS OPEN — voter identity rides every vote row (§9), and an
13
+ // objection carries its reason + section for the room to show.
14
+ // 4. THE READ IS GATED ON THE VOTE ATOM, NOT THE MANAGE ATOM — seeing the
15
+ // ballot is part of casting; the pin (and the generalized gate test in
16
+ // government_routes.mjs) hold the route to board.vote.cast.
17
+ import assert from 'node:assert/strict';
18
+ import { test } from 'node:test';
19
+ import { createRequire } from 'node:module';
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
26
+ const GOV = path.join(ROOT, 'modules', 'government');
27
+ const board = require(path.join(GOV, 'board.js'));
28
+ const db = require(path.join(GOV, 'db.js'));
29
+
30
+ const { boardRoomView } = board;
31
+
32
+ const MONARCHY = { membership: 'rank:archon', pass_rule: 'first_ratifier', window_minutes: null, close_early_on_full_turnout: true };
33
+
34
+ async function withDb(stubs, fn) {
35
+ const originals = {};
36
+ for (const [k, v] of Object.entries(stubs)) { originals[k] = db[k]; db[k] = v; }
37
+ try { return await fn(); }
38
+ finally { for (const [k, v] of Object.entries(originals)) db[k] = v; }
39
+ }
40
+
41
+ test('the assembled view: enrichment, the open ballot, the snapshot, the database clock — all passthrough', async () => {
42
+ const view = await withDb({
43
+ listOpenBoardItems: async () => [
44
+ { id: 11n, subject_type: 'full_idea', subject_id: 7n, opened_by: null, opened_at: 'T1', closes_at: 'T2', remaining_seconds: 540, expired: false, closed_at: null, outcome: null, constitution: MONARCHY },
45
+ { id: 12n, subject_type: 'constitutional_amendment', subject_id: 5n, opened_by: 42n, opened_at: 'T1', closes_at: null, remaining_seconds: null, expired: false, closed_at: null, outcome: null, constitution: MONARCHY },
46
+ ],
47
+ listClosedBoardItems: async (limit) => { assert.equal(limit, 20); return [
48
+ { id: 9, subject_type: 'full_idea', subject_id: 6, opened_by: null, opened_at: 'T0', closes_at: null, closed_at: 'T3', outcome: 'returned', constitution: MONARCHY },
49
+ ]; },
50
+ getVotesForItems: async (ids) => {
51
+ assert.deepEqual(ids.map(String).sort(), ['11', '12', '9'].sort(), 'one ballot query for ALL items, open and closed');
52
+ return [
53
+ { item_id: 11n, voter_id: 42n, github_login: 'first-member', display_name: 'First Member', direction: 'yes', reason_md: null, section_key: null, cast_at: 'T2' },
54
+ { item_id: 9, voter_id: 43, github_login: 'second-member', display_name: null, direction: 'no', reason_md: 'fit is thin', section_key: 'fit', cast_at: 'T2' },
55
+ ];
56
+ },
57
+ getIdeaSubjects: async (ids) => {
58
+ assert.deepEqual(ids.map(String).sort(), ['6', '7']);
59
+ return [{ id: 7n, title: 'A big idea', captured_by: 9n }]; // idea 6 is GONE — deleted subject
60
+ },
61
+ getAmendmentSubjects: async (ids) => {
62
+ assert.deepEqual(ids.map(String), ['5']);
63
+ return [{ id: 5n, proposed: { pass_rule: 'consent' }, rationale_md: 'open up', proposed_by: 42n }];
64
+ },
65
+ }, () => boardRoomView());
66
+
67
+ assert.equal(view.open.length, 2);
68
+ assert.equal(view.closed.length, 1);
69
+
70
+ const idea = view.open[0];
71
+ assert.deepEqual(idea.subject, { title: 'A big idea', author_id: '9' }, 'an idea shows its title + author (R17 greys anti-self from this)');
72
+ assert.equal(idea.remaining_seconds, 540, 'the DATABASE clock, passed through — the client never does clock math');
73
+ assert.deepEqual(idea.constitution, MONARCHY, 'the SNAPSHOT rides along — the room describes the rule the item runs under');
74
+ assert.equal(idea.votes.length, 1);
75
+ assert.equal(idea.votes[0].github_login, 'first-member', 'the ballot is OPEN — identity on every vote row');
76
+
77
+ const amendment = view.open[1];
78
+ assert.deepEqual(amendment.subject, { proposed: { pass_rule: 'consent' }, rationale_md: 'open up', author_id: '42' });
79
+ assert.equal(amendment.remaining_seconds, null, 'no clock is null, not zero');
80
+
81
+ const gone = view.closed[0];
82
+ assert.equal(gone.subject, null, 'a deleted subject projects null — shown honestly, never dropped or invented');
83
+ assert.equal(gone.outcome, 'returned');
84
+ assert.equal(gone.votes[0].section_key, 'fit', 'the objection travels with the closed history');
85
+ // every id is a string — Postgres bigints and JS numbers must compare equal downstream
86
+ for (const item of [...view.open, ...view.closed]) {
87
+ assert.equal(typeof item.id, 'string');
88
+ assert.equal(typeof item.subject_id, 'string');
89
+ for (const v of item.votes) assert.equal(typeof v.voter_id, 'string');
90
+ }
91
+ });
92
+
93
+ test('an empty board is two empty lists — no error, no placeholder rows', async () => {
94
+ const view = await withDb({
95
+ listOpenBoardItems: async () => [],
96
+ listClosedBoardItems: async () => [],
97
+ getVotesForItems: async (ids) => { assert.deepEqual(ids, []); return []; },
98
+ getIdeaSubjects: async (ids) => { assert.deepEqual(ids, []); return []; },
99
+ getAmendmentSubjects: async (ids) => { assert.deepEqual(ids, []); return []; },
100
+ }, () => boardRoomView());
101
+ assert.deepEqual(view, { open: [], closed: [] });
102
+ });
103
+
104
+ test('the reads are indexed and clock math stays in SQL (source pins)', () => {
105
+ const src = fs.readFileSync(path.join(GOV, 'db.js'), 'utf8');
106
+ const open = src.slice(src.indexOf('async function listOpenBoardItems'), src.indexOf('async function listClosedBoardItems'));
107
+ assert.match(open, /EXTRACT\(EPOCH FROM \(closes_at - now\(\)\)\)/, 'remaining time is the DATABASE clock');
108
+ assert.match(open, /GREATEST\(0,/, 'a just-expired item reads 0, never negative');
109
+ const closed = src.slice(src.indexOf('async function listClosedBoardItems'), src.indexOf('async function getVotesForItems'));
110
+ assert.match(closed, /ORDER BY closed_at DESC/, 'newest closes first');
111
+ const migration = fs.readFileSync(path.join(GOV, 'migrations', 'government_008_closed_items_index.sql'), 'utf8');
112
+ assert.ok(migration.includes('government_board_items_closed_by_time'), 'the closed-history read is indexed (the R13 grader lesson, applied up front)');
113
+ });
114
+
115
+ test('the room is gated on the vote atom, and the page shell widened WITH the tab seal (the R16 pair)', () => {
116
+ const rrc = require(path.join(ROOT, 'src', 'bongos', 'route-rank-check.js'));
117
+ assert.equal(rrc.MODULE_EXPECTED_RANKS.government['GET /government/board/items'],
118
+ `${rrc.PERM_PIN_PREFIX}board.vote.cast`, 'seeing the ballot is part of casting — never the manage atom');
119
+ // The page-gate widening and the client-side ranks seal land TOGETHER: the
120
+ // shell admitting metic+ without the admin tab sealing itself would render a
121
+ // broken tab; the seal without the widening would seal a page nobody new can
122
+ // reach. Both halves pinned here so neither can be reverted alone.
123
+ const serve = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'serve-internal.js'), 'utf8');
124
+ const idx = serve.indexOf('GOVERNMENT_PAGE_RE.test(norm)');
125
+ assert.ok(serve.slice(idx, idx + 200).includes('requireGovernmentPage'),
126
+ 'the shell admits page.view.government (metic+), not the manage atom');
127
+ const client = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'government.js'), 'utf8');
128
+ assert.match(client, /sealRanksPanel/, 'the admin tab seals itself for non-archons');
129
+ });
@@ -40,6 +40,7 @@ const NON_GRANT_MIGRATIONS = new Set([
40
40
  'government_005_seeded_rank_descriptions.sql', // ADR 0174 seeded-description text fix (task 1003083)
41
41
  'government_006_returned_items_index.sql', // R13 returned-item lookup index (task 1003026)
42
42
  'government_007_amendments.sql', // R14 amendment proposals table (task 1003027)
43
+ 'government_008_closed_items_index.sql', // R16 closed-history index (task 1003029)
43
44
  ]);
44
45
 
45
46
  // Permission keys RENAMED after the grant migrations that first granted them.
@@ -67,8 +67,12 @@ const ORACLE = {
67
67
  '/builders/people': 'requireProjectCuratePage',
68
68
  '/builders/scouting': 'requireProjectCuratePage',
69
69
 
70
- // Archon.
71
- '/builders/government': 'requireGovernmentManagePage',
70
+ // Metic+ since R16 (goal 1000069): the Government page is three rooms —
71
+ // Constitution · Board Room · Ranks & Permissions — and only the third is the
72
+ // archon admin surface, whose DATA routes all still gate on government.manage
73
+ // individually. The shell follows the widest legitimate audience; the board
74
+ // reads carry the vote atom, the manage tab seals itself for non-archons.
75
+ '/builders/government': 'requireGovernmentPage',
72
76
 
73
77
  // Deliberately UNGATED — the landing card, the rank ladder, the reward
74
78
  // schedule and a builder's public portfolio all render for a stranger.