@bongos/core 1.19.222 → 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 +22 -12
- package/modules/government/board-membership.js +85 -0
- package/modules/government/db.js +44 -0
- package/modules/government/routes/government.js +8 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +3 -1
- package/tests/government_board_membership.mjs +183 -0
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.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
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-
|
|
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":
|
|
14
|
+
"functional_verbatim": 1305,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1637,
|
|
20
|
+
"tree_sha256": "149332598cd294f2de39adf373feaa298158120232a1dfebbd58e5ee56207689",
|
|
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": "
|
|
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": "
|
|
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": "
|
|
4300
|
+
"sha256": "886be125dcb957387fea954c943d27f852347b7f38662905d59e2146cbb17e0b"
|
|
4296
4301
|
},
|
|
4297
4302
|
{
|
|
4298
4303
|
"path": "package.json",
|
|
4299
4304
|
"mode": "0000644",
|
|
4300
|
-
"sha256": "
|
|
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": "
|
|
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",
|
|
@@ -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
|
+
};
|
package/modules/government/db.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
@@ -871,8 +871,10 @@ 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)
|
|
874
876
|
// ---------------------------------------------------------------------------
|
|
875
|
-
const CORE_VERSION = '1.19.
|
|
877
|
+
const CORE_VERSION = '1.19.223'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
876
878
|
|
|
877
879
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
878
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
|
+
});
|