@bongos/core 1.19.613 → 1.19.615
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 +36 -26
- package/docs/adr/0111-instance-hosting-provisioning-module.md +1 -0
- package/docs/adr/0267-unanimity-and-the-revise-and-re-sit-loop.md +124 -0
- package/docs/adr/README.md +1 -0
- package/docs/copy-inventory.md +36 -34
- package/docs/copy-registry.json +53 -35
- package/docs/module-api-changelog.md +4 -0
- package/modules/government/board.js +83 -12
- package/modules/government/config.js +49 -8
- package/modules/hall-ui/public/government.js +30 -5
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/provision-net.js +30 -0
- package/scripts/gds/provision-units.js +25 -12
- package/scripts/gds/provision.js +27 -1
- package/src/module-api.js +1 -1
- package/tests/government_board_amendment.mjs +4 -4
- package/tests/government_board_close.mjs +1 -1
- package/tests/government_config.mjs +5 -4
- package/tests/government_unanimous.mjs +319 -0
- package/tests/provision.mjs +55 -5
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.615",
|
|
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.615",
|
|
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.615",
|
|
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",
|
|
@@ -244,6 +244,34 @@ function healthzCmd(inst) {
|
|
|
244
244
|
return `curl -sS --max-time 5 http://127.0.0.1:${inst.port}/healthz`;
|
|
245
245
|
}
|
|
246
246
|
|
|
247
|
+
// The instance manifest, read over loopback — the identity companion to healthzCmd (task 1003740).
|
|
248
|
+
function identityCmd(inst) {
|
|
249
|
+
return `curl -sS --max-time 5 http://127.0.0.1:${inst.port}/api/gds/instance`;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Does a freshly provisioned instance report ITSELF, or the platform it is hosted on?
|
|
253
|
+
//
|
|
254
|
+
// WHY THIS EXISTS. Provisioning used to pin every instance's branding pack to the shared neutral
|
|
255
|
+
// starter (see provision-units.js), a leftover from the co-tenant model that shared one checkout. Under
|
|
256
|
+
// ADR 0108 each instance has its own, and src/branding.js's ENV_OVERRIDES carry domains but never
|
|
257
|
+
// identity.productName — so every hosted instance served "Cloud Bongos" as its own name for
|
|
258
|
+
// months, greeting newcomers with the wrong project at sign-in. Nothing looked.
|
|
259
|
+
//
|
|
260
|
+
// PURE so it is testable without a box: takes the parsed manifest and returns a verdict.
|
|
261
|
+
// Deliberately a WARNING, not a failure — an owner may legitimately name a project after the
|
|
262
|
+
// platform, and provisioning must not refuse a working instance over a name.
|
|
263
|
+
function identityVerdict({ manifest, slug, neutralName }) {
|
|
264
|
+
const name = manifest && (manifest.name || (manifest.brand && manifest.brand.identity && manifest.brand.identity.productName));
|
|
265
|
+
if (!name) return { ok: null, reason: 'no name in manifest' };
|
|
266
|
+
// The bug's exact shape: the instance answers with the neutral pack's name while its slug says
|
|
267
|
+
// it is somebody else. A project genuinely called that keeps its name and only trips when the
|
|
268
|
+
// slug matches too — in which case it is not the bug.
|
|
269
|
+
if (neutralName && name === neutralName && slug && slug !== neutralName) {
|
|
270
|
+
return { ok: false, name, reason: `serves the platform default name "${name}" instead of its own` };
|
|
271
|
+
}
|
|
272
|
+
return { ok: true, name };
|
|
273
|
+
}
|
|
274
|
+
|
|
247
275
|
// ---------------------------------------------------------------------------
|
|
248
276
|
// The injectable executor. dry-run → log-only; --apply → child_process.
|
|
249
277
|
// ---------------------------------------------------------------------------
|
|
@@ -260,6 +288,8 @@ module.exports = {
|
|
|
260
288
|
federateInstance,
|
|
261
289
|
federationHubOrigin,
|
|
262
290
|
healthzCmd,
|
|
291
|
+
identityCmd,
|
|
292
|
+
identityVerdict,
|
|
263
293
|
shouldFederate,
|
|
264
294
|
zoneForDomain,
|
|
265
295
|
};
|
|
@@ -121,16 +121,30 @@ WantedBy=timers.target
|
|
|
121
121
|
`;
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
-
// The
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
//
|
|
129
|
-
//
|
|
130
|
-
|
|
124
|
+
// The pack the provisioner reads to learn an instance's env-var PREFIX floor.
|
|
125
|
+
//
|
|
126
|
+
// It is NOT the pack the instance loads, and that distinction is the whole of task 1003740.
|
|
127
|
+
// The original co-tenant model shared ONE checkout across every instance, differentiated only by
|
|
128
|
+
// env, so provisioning pinned the pack to `config/branding.neutral.json` and let identity
|
|
129
|
+
// ride ENV OVERRIDES. That model is gone: under ADR 0108 each co-tenant has its own checkout
|
|
130
|
+
// (`WorkingDirectory=/srv/<base>/<slug>`, running `node_modules/@bongos/core`) and its own
|
|
131
|
+
// `config/branding.json` written at init.
|
|
132
|
+
//
|
|
133
|
+
// The pin outlived it, and src/branding.js's ENV_OVERRIDES cover domains, auth.idp and landing —
|
|
134
|
+
// never `identity.productName`. So every hosted instance kept serving the NEUTRAL pack's name:
|
|
135
|
+
// GET /api/gds/instance on hermeslines-marketing, mercury and demo all answered "Cloud Bongos"
|
|
136
|
+
// while each instance's own pack on disk said otherwise, and `bongos login <instance>` greeted a
|
|
137
|
+
// newcomer with the platform's name at the moment they were deciding whether to trust it.
|
|
138
|
+
//
|
|
139
|
+
// The instance now loads its own pack by src/branding.js's DEFAULT (INSTANCE_PATH =
|
|
140
|
+
// <instanceRoot>/config/branding.json), which falls back to neutral when absent — so no
|
|
141
|
+
// branding-file env line is written at all. Reading the neutral pack HERE is still correct: it is
|
|
142
|
+
// the prefix floor an instance inherits unless its own pack overrides envPrefix, and every live
|
|
143
|
+
// co-tenant pack pins `envPrefix: "CLOUDBONGOS"` explicitly.
|
|
144
|
+
const ENV_PREFIX_PACK = 'config/branding.neutral.json';
|
|
131
145
|
|
|
132
146
|
// The env-var prefix a provisioned instance actually RESOLVES — i.e. the envPrefix
|
|
133
|
-
// of the pack it loads (
|
|
147
|
+
// of the pack it loads (ENV_PREFIX_PACK). src/instance-config.resolveEnv
|
|
134
148
|
// reads <envPrefix>_<SUFFIX>, and src/branding.resolveBranding applies
|
|
135
149
|
// <envPrefix>_{PUBLIC,BUILDERS,…}_ORIGIN overrides — so a web.env var only takes
|
|
136
150
|
// effect under THIS prefix. Read from the pack (fail-soft to the vanilla default)
|
|
@@ -139,7 +153,7 @@ const PROVISIONED_BRANDING_FILE = 'config/branding.neutral.json';
|
|
|
139
153
|
// the OAuth creds were silently unreadable — task 1972.)
|
|
140
154
|
function provisionedEnvPrefix() {
|
|
141
155
|
try {
|
|
142
|
-
const pack = JSON.parse(fs.readFileSync(path.join(REPO_ROOT,
|
|
156
|
+
const pack = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, ENV_PREFIX_PACK), 'utf8'));
|
|
143
157
|
return pack.envPrefix || 'CLOUDBONGOS';
|
|
144
158
|
} catch { return 'CLOUDBONGOS'; }
|
|
145
159
|
}
|
|
@@ -228,8 +242,7 @@ GITHUB_APP_PRIVATE_KEY=${String(appPem).replace(/\r?\n/g, '\\n')}
|
|
|
228
242
|
${credComment}
|
|
229
243
|
${prefix}_GITHUB_CLIENT_ID=${clientId || ''}
|
|
230
244
|
${prefix}_GITHUB_CLIENT_SECRET=${clientSecret || ''}
|
|
231
|
-
${firstAdminLine}
|
|
232
|
-
${originLines}${appLines}PORT=${inst.port}
|
|
245
|
+
${firstAdminLine}${originLines}${appLines}PORT=${inst.port}
|
|
233
246
|
PGDATABASE=${dbName(inst)}
|
|
234
247
|
${fedLines}${settingsEnvLines(inst, prefix)}`;
|
|
235
248
|
}
|
|
@@ -318,7 +331,7 @@ function upsertEnvVars(body, vars) {
|
|
|
318
331
|
// env the hub already runs under). Null → not hub-configured → shouldFederate is false.
|
|
319
332
|
|
|
320
333
|
module.exports = {
|
|
321
|
-
|
|
334
|
+
ENV_PREFIX_PACK,
|
|
322
335
|
backupScriptPath,
|
|
323
336
|
backupService,
|
|
324
337
|
backupServicePath,
|
package/scripts/gds/provision.js
CHANGED
|
@@ -52,7 +52,7 @@ const crypto = require('node:crypto');
|
|
|
52
52
|
const { CONFIG, MANIFEST_UA, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES, MAX_INTENT_ATTEMPTS, REPO_ROOT, coreVersionSafe, hasFlag, loadDeps, oauthSecret, provisionerBotEmail } = require('./provision-config.js');
|
|
53
53
|
const { APP_USER, alreadyScaffolded, buildCorePinRefreshCommit, dbCreateCmd, dbName, deployKeyPath, deployKeyTitle, ensurePrivateRepoAccess, installedCorePackDir, installedCoreTarball, instanceInitSpec, instanceRepoRemote, migrateCmd, onboardMode, ownerLoginOf, parseTargetRef, planCorePinRefresh, refreshStandaloneCorePin, resolveOwnerGithubToken, resolveVendorableCoreTarball, safeVersionLabel, scaffoldStandaloneRepo, seedFirstVersionCmd, standaloneInstallCmd, standaloneMigrateCmd, standalonePullCmd, standaloneRegenDocsCmd, standaloneRoot } = require('./provision-repo.js');
|
|
54
54
|
const { backupScriptPath, backupService, backupServicePath, backupTimer, backupTimerPath, backupUnitName, instanceManifestCmd, originEnvVarsFor, serviceUnit, serviceUnitPath, settingsConsumed, settingsEnvVarsFor, upsertEnvVars, webEnvBody, webEnvPath } = require('./provision-units.js');
|
|
55
|
-
const { caddyBlock, caddySnippetPath, checkDnsTokenScope, classifyZoneScope, dnsReleaseEnv, dnsUpsertEnv, federateInstance, federationHubOrigin, healthzCmd, shouldFederate, zoneForDomain } = require('./provision-net.js');
|
|
55
|
+
const { caddyBlock, caddySnippetPath, checkDnsTokenScope, classifyZoneScope, dnsReleaseEnv, dnsUpsertEnv, federateInstance, federationHubOrigin, healthzCmd, identityCmd, identityVerdict, shouldFederate, zoneForDomain } = require('./provision-net.js');
|
|
56
56
|
|
|
57
57
|
function makeExec({ apply, log = console.log, remoteHost = null, remoteCwd = null, sshOpts = '' }) {
|
|
58
58
|
// `shown` (task 1003140): what to LOG in place of the command text — for a command
|
|
@@ -503,6 +503,32 @@ async function provisionInstance(inst, deps) {
|
|
|
503
503
|
}
|
|
504
504
|
} else log(' (verified post-boot)');
|
|
505
505
|
|
|
506
|
+
// ── step 7b: identity ─────────────────────────────────────────────────────
|
|
507
|
+
// Does it answer as ITSELF? A live instance that serves the platform's name instead of its own
|
|
508
|
+
// is invisible to healthz — it is perfectly healthy and telling every newcomer the wrong project
|
|
509
|
+
// (task 1003740, which went unnoticed across every hosted instance for months). A WARNING, never
|
|
510
|
+
// a failure: an owner may legitimately name a project after the platform, and a name must not
|
|
511
|
+
// fail a working standup.
|
|
512
|
+
if (local && apply && healthy !== false) {
|
|
513
|
+
const res = boxExec(identityCmd(inst), { allowFail: true });
|
|
514
|
+
const raw = res && !res.softFailed ? (res.stdout || '') : '';
|
|
515
|
+
let manifest = null;
|
|
516
|
+
try { manifest = raw ? JSON.parse(raw) : null; } catch (_) { manifest = null; }
|
|
517
|
+
if (manifest) {
|
|
518
|
+
let neutralName = null;
|
|
519
|
+
try {
|
|
520
|
+
neutralName = (JSON.parse(fs.readFileSync(path.join(REPO_ROOT, 'config/branding.neutral.json'), 'utf8'))
|
|
521
|
+
.identity || {}).productName || null;
|
|
522
|
+
} catch (_) { /* no neutral pack readable → the check simply cannot fire */ }
|
|
523
|
+
const verdict = identityVerdict({ manifest, slug: inst.slug, neutralName });
|
|
524
|
+
if (verdict.ok === false) {
|
|
525
|
+
log(` ⚠ ${inst.slug} ${verdict.reason} — check its config/branding.json and that no env var pins its branding pack`);
|
|
526
|
+
} else if (verdict.ok) {
|
|
527
|
+
log(` [identity] answers as "${verdict.name}"`);
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
|
|
506
532
|
// Mark active. error_note: null — a stale note from an earlier failed run
|
|
507
533
|
// must not ride into the active state (F15's second half; the co-tenant +
|
|
508
534
|
// standalone paths never cleared it). The runner hands its own observation
|
package/src/module-api.js
CHANGED
|
@@ -55,7 +55,7 @@ const { buildInfo } = require('./build-info');
|
|
|
55
55
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
56
56
|
// the entry to that file. Look for a version's history there, not here.
|
|
57
57
|
// ---------------------------------------------------------------------------
|
|
58
|
-
const CORE_VERSION = '1.19.
|
|
58
|
+
const CORE_VERSION = '1.19.615'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
59
59
|
|
|
60
60
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
61
61
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -56,7 +56,7 @@ test('every field is required and every refusal is named — no silent correctio
|
|
|
56
56
|
[{ ...CONSENT_90, extra: true }, 'unknown_field'],
|
|
57
57
|
[{ ...CONSENT_90, membership: 'everyone' }, 'bad_membership'],
|
|
58
58
|
[{ ...CONSENT_90, membership: undefined }, 'bad_membership'],
|
|
59
|
-
[{ ...CONSENT_90, pass_rule: '
|
|
59
|
+
[{ ...CONSENT_90, pass_rule: 'by_acclamation' }, 'bad_pass_rule'],
|
|
60
60
|
[{ ...CONSENT_90, window_minutes: 0 }, 'bad_window_minutes'],
|
|
61
61
|
[{ ...CONSENT_90, window_minutes: 1.5 }, 'bad_window_minutes'],
|
|
62
62
|
[{ ...CONSENT_90, close_early_on_full_turnout: 'yes' }, 'bad_close_early'],
|
|
@@ -68,8 +68,8 @@ test('every field is required and every refusal is named — no silent correctio
|
|
|
68
68
|
}
|
|
69
69
|
// THE CONTRAST WITH THE SANITIZER, stated as an assertion: the sanitizer
|
|
70
70
|
// silently corrects a bad pass_rule; the proposal validator refuses it.
|
|
71
|
-
assert.equal(config.sanitizeBoard({ ...CONSENT_90, pass_rule: '
|
|
72
|
-
assert.equal(validateProposedConstitution({ ...CONSENT_90, pass_rule: '
|
|
71
|
+
assert.equal(config.sanitizeBoard({ ...CONSENT_90, pass_rule: 'by_acclamation' }).pass_rule, 'first_ratifier');
|
|
72
|
+
assert.equal(validateProposedConstitution({ ...CONSENT_90, pass_rule: 'by_acclamation' }).ok, false);
|
|
73
73
|
// THE PERMISSIVE HALF, asserted on purpose (task 1003273): `majority` is a
|
|
74
74
|
// REAL rule now, so a proposal naming it must be ACCEPTED. This used to be
|
|
75
75
|
// this file's canonical invalid value — a later change that quietly dropped
|
|
@@ -107,7 +107,7 @@ test('an invalid proposal files NOTHING — refused before any write', async ()
|
|
|
107
107
|
openBoardItem: async () => { throw new Error('must not be reached'); },
|
|
108
108
|
builderHoldsRankActive: async () => true,
|
|
109
109
|
}, async () => {
|
|
110
|
-
assert.equal((await proposeAmendment({ proposal: { ...CONSENT_90, pass_rule: '
|
|
110
|
+
assert.equal((await proposeAmendment({ proposal: { ...CONSENT_90, pass_rule: 'by_acclamation' }, proposedBy: '42' })).ok, false);
|
|
111
111
|
assert.equal((await proposeAmendment({ proposal: CONSENT_90, proposedBy: null })).reason, 'missing_proposer');
|
|
112
112
|
});
|
|
113
113
|
});
|
|
@@ -82,7 +82,7 @@ test('consent closes early only on FULL TURNOUT of the live members', () => {
|
|
|
82
82
|
test('close-early OFF, an empty board, and an unknown rule all refuse to close after a vote', () => {
|
|
83
83
|
assert.equal(shouldCloseAfterVote({ constitution: { ...CONSENT, close_early_on_full_turnout: false }, votes: [yes('1')], members: [member(1)] }), false);
|
|
84
84
|
assert.equal(shouldCloseAfterVote({ constitution: CONSENT, votes: [yes('1')], members: [] }), false, 'an empty board has no turnout to complete');
|
|
85
|
-
assert.equal(shouldCloseAfterVote({ constitution: { ...CONSENT, pass_rule: '
|
|
85
|
+
assert.equal(shouldCloseAfterVote({ constitution: { ...CONSENT, pass_rule: 'by_acclamation' }, votes: [yes('1')], members: [member(1)] }), false,
|
|
86
86
|
'unknown rule: fail-closed — only the clock closes it');
|
|
87
87
|
});
|
|
88
88
|
|
|
@@ -264,8 +264,8 @@ test('a malformed constitution can never widen the franchise or lower the bar',
|
|
|
264
264
|
{ membership: 'RANK:ARCHON' }, // grammar is lowercase
|
|
265
265
|
{ membership: '' },
|
|
266
266
|
{ membership: { rank: 'archon' } },
|
|
267
|
-
{ pass_rule: '
|
|
268
|
-
{ pass_rule: '
|
|
267
|
+
{ pass_rule: 'by_acclamation' },
|
|
268
|
+
{ pass_rule: 'UNANIMOUS' }, // a real rule, wrong case — still unreadable
|
|
269
269
|
{ pass_rule: null },
|
|
270
270
|
{ membership: 'rank:xenos; DROP TABLE government_ranks' },
|
|
271
271
|
];
|
|
@@ -325,11 +325,12 @@ test('the resolved constitution is frozen, like the rest of the config contract'
|
|
|
325
325
|
});
|
|
326
326
|
|
|
327
327
|
test('PASS_RULES is the closed set the rest of the goal codes against', () => {
|
|
328
|
-
// `majority` joined the set in task 1003273 (owner decision 2026-08-25)
|
|
328
|
+
// `majority` joined the set in task 1003273 (owner decision 2026-08-25) and
|
|
329
|
+
// `unanimous` in task 1003733 (owner decision 2026-09-08, ADR 0267). The
|
|
329
330
|
// set stays CLOSED and asserted whole on purpose: board.js branches on these
|
|
330
331
|
// strings, so a rule added to config without a tally to match would be a
|
|
331
332
|
// constitution a project could adopt and then never resolve.
|
|
332
|
-
assert.deepEqual([...PASS_RULES].sort(), ['consent', 'first_ratifier', 'majority']);
|
|
333
|
+
assert.deepEqual([...PASS_RULES].sort(), ['consent', 'first_ratifier', 'majority', 'unanimous']);
|
|
333
334
|
assert.ok(Object.isFrozen(PASS_RULES));
|
|
334
335
|
assert.ok(PASS_RULES.includes(BOARD_DEFAULTS.pass_rule), 'the default must be a member of the set');
|
|
335
336
|
// The fail-closed default is UNCHANGED by the addition — adding a stricter rule
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
// tests/government_unanimous.mjs — the `unanimous` pass rule and the
|
|
2
|
+
// revise-and-re-sit loop it exists to drive (task 1003733, ADR 0267).
|
|
3
|
+
//
|
|
4
|
+
// THE OWNER DECISION OF 2026-09-08, pinned here: a Full Idea is ratified by
|
|
5
|
+
// UNANIMOUS agreement from round one. A sitting that does not carry RETURNS to
|
|
6
|
+
// its author, who revises and puts it back to the board — and that loop repeats
|
|
7
|
+
// until the board is unanimous. Two sub-decisions the owner made explicitly:
|
|
8
|
+
//
|
|
9
|
+
// A. SILENCE DOES NOT BLOCK. A member who never votes is not a veto; they drop
|
|
10
|
+
// out of the reckoning entirely. There is deliberately NO membership
|
|
11
|
+
// denominator here — that is what separates `unanimous` from `majority`,
|
|
12
|
+
// and it is why the owner asked for the clock in the same breath: with
|
|
13
|
+
// absence nulled, the deadline is what ends a sitting nobody finished.
|
|
14
|
+
// B. AND SILENCE CANNOT CARRY EITHER. An empty room agrees to nothing, so a
|
|
15
|
+
// sitting that ends with no affirmative voice RETURNS rather than passing.
|
|
16
|
+
// That second half is what makes a clock SAFE under this rule (ADR 0191 §4:
|
|
17
|
+
// a deadline may only be given to a rule whose expiry means return) — and
|
|
18
|
+
// it is the whole difference from `consent`, where an empty window passes.
|
|
19
|
+
//
|
|
20
|
+
// THE PERMISSIVE ANSWERS ARE ASSERTED AS DELIBERATELY AS THE RESTRICTIVE ONES
|
|
21
|
+
// (modules/government/CLAUDE.md): "an absent member does not block" is exactly
|
|
22
|
+
// the property a later well-meaning tightening would quietly undo, so it fails a
|
|
23
|
+
// test by design.
|
|
24
|
+
//
|
|
25
|
+
// DB-free (db monkeypatched — the sibling board tests' idiom).
|
|
26
|
+
import assert from 'node:assert/strict';
|
|
27
|
+
import { test } from 'node:test';
|
|
28
|
+
import { createRequire } from 'node:module';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import { fileURLToPath } from 'node:url';
|
|
31
|
+
|
|
32
|
+
const require = createRequire(import.meta.url);
|
|
33
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
34
|
+
const GOV = path.join(ROOT, 'modules', 'government');
|
|
35
|
+
const board = require(path.join(GOV, 'board.js'));
|
|
36
|
+
const db = require(path.join(GOV, 'db.js'));
|
|
37
|
+
const config = require(path.join(GOV, 'config.js'));
|
|
38
|
+
|
|
39
|
+
const { tallyOutcome, shouldCloseAfterVote, closeItem } = board;
|
|
40
|
+
|
|
41
|
+
async function withDb(stubs, fn) {
|
|
42
|
+
const originals = {};
|
|
43
|
+
for (const [k, v] of Object.entries(stubs)) { originals[k] = db[k]; db[k] = v; }
|
|
44
|
+
try { return await fn(); }
|
|
45
|
+
finally { for (const [k, v] of Object.entries(originals)) db[k] = v; }
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const UNANIMOUS = {
|
|
49
|
+
membership: 'rank:archon', pass_rule: 'unanimous',
|
|
50
|
+
window_minutes: 1440, close_early_on_full_turnout: true,
|
|
51
|
+
};
|
|
52
|
+
const CONSENT = { ...UNANIMOUS, pass_rule: 'consent' };
|
|
53
|
+
|
|
54
|
+
const yes = (v) => ({ voter_id: String(v), direction: 'yes' });
|
|
55
|
+
const no = (v, section = 'fit') => ({ voter_id: String(v), direction: 'no', reason_md: 'too thin', section_key: section });
|
|
56
|
+
const member = (id) => ({ builder_id: String(id), github_login: `m${id}`, display_name: `M${id}` });
|
|
57
|
+
const FOUR = [member(1), member(2), member(3), member(4)];
|
|
58
|
+
const AUTHOR = '1';
|
|
59
|
+
|
|
60
|
+
const ideaItem = (over = {}) => ({
|
|
61
|
+
id: '11', subject_type: 'full_idea', subject_id: '7', opened_by: null,
|
|
62
|
+
constitution: UNANIMOUS, closed_at: null, outcome: null, expired: false, ...over,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// ── A. the tally: everyone who spoke said yes, and somebody spoke ────────────
|
|
66
|
+
|
|
67
|
+
test('unanimous carries when every cast vote is yes and one of them is not the author', () => {
|
|
68
|
+
assert.equal(tallyOutcome([yes(2)], { constitution: UNANIMOUS, members: FOUR, authorId: AUTHOR }), 'passed');
|
|
69
|
+
assert.equal(tallyOutcome([yes(1), yes(2), yes(3), yes(4)], { constitution: UNANIMOUS, members: FOUR, authorId: AUTHOR }), 'passed');
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('ONE objection returns it, however many yes votes stand beside it', () => {
|
|
73
|
+
assert.equal(tallyOutcome([yes(2), yes(3), no(4)], { constitution: UNANIMOUS, members: FOUR, authorId: AUTHOR }), 'returned');
|
|
74
|
+
assert.equal(tallyOutcome([no(2)], { constitution: UNANIMOUS, members: FOUR, authorId: AUTHOR }), 'returned');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('a sitting nobody voted in RETURNS — an empty room agrees to nothing', () => {
|
|
78
|
+
// The half that makes a clock safe here (ADR 0191 §4). Under `consent` the
|
|
79
|
+
// very same sitting PASSES, which is the difference stated as an assertion
|
|
80
|
+
// rather than as prose.
|
|
81
|
+
assert.equal(tallyOutcome([], { constitution: UNANIMOUS, members: FOUR, authorId: AUTHOR }), 'returned');
|
|
82
|
+
assert.equal(tallyOutcome([], { constitution: CONSENT, members: FOUR, authorId: AUTHOR }), 'passed');
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test('SILENCE DOES NOT BLOCK: one yes out of four carries, three absences and all', () => {
|
|
86
|
+
// Owner decision A, and the property a later "tighten it up" change would
|
|
87
|
+
// quietly undo. There is no membership denominator: `members` is passed and
|
|
88
|
+
// deliberately does not matter.
|
|
89
|
+
assert.equal(tallyOutcome([yes(2)], { constitution: UNANIMOUS, members: FOUR, authorId: AUTHOR }), 'passed');
|
|
90
|
+
assert.equal(tallyOutcome([yes(2)], { constitution: UNANIMOUS, members: null, authorId: AUTHOR }), 'passed');
|
|
91
|
+
// Contrast with `majority`, where the same ballot fails on the denominator.
|
|
92
|
+
assert.equal(tallyOutcome([yes(2)], { constitution: { ...UNANIMOUS, pass_rule: 'majority' }, members: FOUR, authorId: AUTHOR }), 'returned');
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// ── B. the ADR 0191 author rule, unchanged under the new tally ───────────────
|
|
96
|
+
|
|
97
|
+
test('an author cannot carry their own idea alone, and cannot return it either', () => {
|
|
98
|
+
// Alone: their yes is the only affirmative voice, so nothing carries.
|
|
99
|
+
assert.equal(tallyOutcome([yes(AUTHOR)], { constitution: UNANIMOUS, members: FOUR, authorId: AUTHOR }), 'returned');
|
|
100
|
+
// Joined: their yes is a real vote, it simply may not be the whole of it.
|
|
101
|
+
assert.equal(tallyOutcome([yes(AUTHOR), yes(2)], { constitution: UNANIMOUS, members: FOUR, authorId: AUTHOR }), 'passed');
|
|
102
|
+
// And an author's 'no' cannot return their own idea — otherwise silence would
|
|
103
|
+
// be the safest act available to an author.
|
|
104
|
+
assert.equal(tallyOutcome([no(AUTHOR), yes(2)], { constitution: UNANIMOUS, members: FOUR, authorId: AUTHOR }), 'passed');
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test('the amendment carve-out survives: no author means no author rule', () => {
|
|
108
|
+
// ADR 0175 §7 — an amendment pays nothing and has no author to wait for, so a
|
|
109
|
+
// sole Archon proposing one must be able to ratify it. authorId is null there.
|
|
110
|
+
assert.equal(tallyOutcome([yes(1)], { constitution: UNANIMOUS, members: FOUR, authorId: null }), 'passed');
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// ── C. the trigger: a yes waits, an objection does not ───────────────────────
|
|
114
|
+
|
|
115
|
+
test('a single yes does NOT end the sitting — a later member must still be able to object', () => {
|
|
116
|
+
// The whole difference from first_ratifier, and the reason the clock matters.
|
|
117
|
+
assert.equal(shouldCloseAfterVote({ constitution: UNANIMOUS, votes: [yes(2)], members: FOUR, authorId: AUTHOR }), false);
|
|
118
|
+
assert.equal(shouldCloseAfterVote({ constitution: { ...UNANIMOUS, pass_rule: 'first_ratifier' }, votes: [yes(2)], members: FOUR, authorId: AUTHOR }), true);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test('a reasoned objection ends it at once — the outcome is already fixed', () => {
|
|
122
|
+
// There is no vote-changing in v1, so once a non-author 'no' is on the record
|
|
123
|
+
// nothing the absent members do can carry it. Closing now is what gets the
|
|
124
|
+
// author their feedback in time to revise, which is the point of the rule.
|
|
125
|
+
assert.equal(shouldCloseAfterVote({ constitution: UNANIMOUS, votes: [yes(2), no(3)], members: FOUR, authorId: AUTHOR }), true);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('full turnout closes it early, and an author-only ballot never does', () => {
|
|
129
|
+
const all = [yes(1), yes(2), yes(3), yes(4)];
|
|
130
|
+
assert.equal(shouldCloseAfterVote({ constitution: UNANIMOUS, votes: all, members: FOUR, authorId: AUTHOR }), true);
|
|
131
|
+
assert.equal(shouldCloseAfterVote({ constitution: { ...UNANIMOUS, close_early_on_full_turnout: false }, votes: all, members: FOUR, authorId: AUTHOR }), false,
|
|
132
|
+
'close-early OFF means only the clock ends it');
|
|
133
|
+
assert.equal(shouldCloseAfterVote({ constitution: UNANIMOUS, votes: [yes(AUTHOR)], members: FOUR, authorId: AUTHOR }), false,
|
|
134
|
+
"an author's own cast never ends their sitting (ADR 0191 §3)");
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// ── D. the loop: a returned sitting hands the idea back, and pays nothing ────
|
|
138
|
+
|
|
139
|
+
test('the clock returns an unvoted sitting, and the author is free to re-sit it', async () => {
|
|
140
|
+
// What the expiry sweep does with a sitting nobody attended. closeItem is the
|
|
141
|
+
// same path the R11 poller calls, so this IS the expiry behaviour.
|
|
142
|
+
let written = null;
|
|
143
|
+
await withDb({
|
|
144
|
+
getVotesForItem: async () => [],
|
|
145
|
+
getBoardItemAuthor: async () => AUTHOR,
|
|
146
|
+
getIdeaCompleteness: async () => 88,
|
|
147
|
+
closeBoardItem: async ({ itemId, outcome }) => {
|
|
148
|
+
written = outcome;
|
|
149
|
+
return { id: itemId, subject_type: 'full_idea', subject_id: '7', outcome, closed_at: new Date().toISOString() };
|
|
150
|
+
},
|
|
151
|
+
}, async () => {
|
|
152
|
+
const out = await closeItem(ideaItem(), { cause: 'expiry' });
|
|
153
|
+
assert.equal(out.closed, true);
|
|
154
|
+
assert.equal(out.outcome, 'returned', 'expiry with nothing cast RETURNS — never a silent ratification');
|
|
155
|
+
});
|
|
156
|
+
assert.equal(written, 'returned');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('a returned sitting is not a dead end: the item closes, and nothing is paid', async () => {
|
|
160
|
+
// The revise-and-re-sit loop lives in the ideas module (POST /inbox/:id/resubmit
|
|
161
|
+
// re-grades and re-fires the window hook). What the BOARD owes that loop is
|
|
162
|
+
// exactly this: a return that CLOSES the item — so government_003's partial
|
|
163
|
+
// unique index admits the next window — and that pays nothing, so a round trip
|
|
164
|
+
// through revision can never earn the author credits or ratification karma.
|
|
165
|
+
const paid = [];
|
|
166
|
+
await withDb({
|
|
167
|
+
getVotesForItem: async () => [no(2, 'fit')],
|
|
168
|
+
getBoardItemAuthor: async () => AUTHOR,
|
|
169
|
+
getIdeaCompleteness: async () => { paid.push('completeness'); return 88; },
|
|
170
|
+
closeBoardItem: async ({ itemId, outcome, afterClose }) => {
|
|
171
|
+
if (afterClose) paid.push('credit');
|
|
172
|
+
return { id: itemId, subject_type: 'full_idea', subject_id: '7', outcome, closed_at: new Date().toISOString() };
|
|
173
|
+
},
|
|
174
|
+
}, async () => {
|
|
175
|
+
const out = await closeItem(ideaItem(), { cause: 'vote' });
|
|
176
|
+
assert.equal(out.outcome, 'returned');
|
|
177
|
+
assert.ok(out.item.closed_at, 'the item is CLOSED, so the next window may open');
|
|
178
|
+
assert.equal(out.author_karma, null, 'a return pays no ratification karma');
|
|
179
|
+
assert.equal(out.credit, undefined, 'a return books no idea credit');
|
|
180
|
+
});
|
|
181
|
+
assert.deepEqual(paid, [], 'nothing on the paying path was even reached');
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// ── E. the config contract ──────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
test('unanimous is a legal, adoptable pass rule — and the fail-closed default is untouched', () => {
|
|
187
|
+
assert.ok(config.PASS_RULES.includes('unanimous'));
|
|
188
|
+
assert.equal(config.sanitizeBoard({ membership: 'rank:metic+', pass_rule: 'unanimous', window_minutes: 1440, close_early_on_full_turnout: true }).pass_rule, 'unanimous');
|
|
189
|
+
// Adding a STRICTER rule must not move what a malformed constitution falls
|
|
190
|
+
// back to — a fresh instance is still the day-one monarchy, which is what lets
|
|
191
|
+
// a solo founder ratify anything at all (ADR 0191 §5).
|
|
192
|
+
assert.equal(config.BOARD_DEFAULTS.pass_rule, 'first_ratifier');
|
|
193
|
+
assert.equal(config.sanitizeBoard({ pass_rule: 'unanimious' }).pass_rule, 'first_ratifier');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('a unanimous constitution may carry a clock, which the older two may not safely', () => {
|
|
197
|
+
// Not a code rule — a REVIEW rule, stated as an assertion so the pairing is
|
|
198
|
+
// visible: window_minutes is only safe where expiry means return.
|
|
199
|
+
const withClock = config.sanitizeBoard({ membership: 'rank:archon', pass_rule: 'unanimous', window_minutes: 1440, close_early_on_full_turnout: true });
|
|
200
|
+
assert.equal(withClock.window_minutes, 1440);
|
|
201
|
+
assert.equal(tallyOutcome([], { constitution: withClock, members: FOUR, authorId: AUTHOR }), 'returned',
|
|
202
|
+
'the clock is safe here precisely because an empty sitting returns');
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
// ── F. the loop's other half: what the board hands back, and the next window ─
|
|
206
|
+
|
|
207
|
+
test('a returned idea carries its objection back — reason and flagged section', async () => {
|
|
208
|
+
// The feedback that makes revision possible. The ideas resubmit path reads
|
|
209
|
+
// this through the `government` port and renders it as the rejection notice
|
|
210
|
+
// (tests/idea_resubmit.mjs pins that end: a board objection OUTRANKS the
|
|
211
|
+
// mechanical weakest-section flag).
|
|
212
|
+
await withDb({
|
|
213
|
+
getLatestReturnedItemWithObjection: async (type, id) => {
|
|
214
|
+
assert.equal(type, 'full_idea');
|
|
215
|
+
assert.equal(id, 7);
|
|
216
|
+
return { item_id: '11', section_key: 'fit', reason_md: 'the fit section names no user', closed_at: '2026-09-08T00:00:00Z' };
|
|
217
|
+
},
|
|
218
|
+
}, async () => {
|
|
219
|
+
const r = await board.latestRejectionForIdea(7);
|
|
220
|
+
assert.equal(r.source, 'board');
|
|
221
|
+
assert.equal(r.section_key, 'fit');
|
|
222
|
+
assert.equal(r.reason_md, 'the fit section names no user');
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test('a ZERO-VOTE return carries no objection — and says so rather than inventing one', async () => {
|
|
227
|
+
// The honest gap this rule creates (ADR 0267 §5). Nobody voted, so there is no
|
|
228
|
+
// reason and no flagged section; the read returns null and the resubmit path
|
|
229
|
+
// falls back to the deterministic weakest-section flag. Returning a fabricated
|
|
230
|
+
// "the board did not read it" would put words in a member's mouth.
|
|
231
|
+
await withDb({
|
|
232
|
+
getLatestReturnedItemWithObjection: async () => null,
|
|
233
|
+
}, async () => {
|
|
234
|
+
assert.equal(await board.latestRejectionForIdea(7), null);
|
|
235
|
+
});
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
test('the next round can open: a CLOSED prior sitting no longer holds the subject', async () => {
|
|
239
|
+
// The precondition the whole loop rests on. government_003's partial unique
|
|
240
|
+
// index admits one OPEN window per subject, so a returned (closed) sitting
|
|
241
|
+
// must not stand in the way of the revised idea's next one — that is why the
|
|
242
|
+
// return path closes the item rather than parking it.
|
|
243
|
+
let opened = 0;
|
|
244
|
+
await withDb({
|
|
245
|
+
openBoardItem: async ({ subjectType, subjectId }) => {
|
|
246
|
+
opened += 1;
|
|
247
|
+
return { id: String(30 + opened), subject_type: subjectType, subject_id: String(subjectId), closes_at: null, constitution: UNANIMOUS };
|
|
248
|
+
},
|
|
249
|
+
getOpenBoardItem: async () => null,
|
|
250
|
+
}, async () => {
|
|
251
|
+
const first = await board.openWindowForFullIdea({ ideaId: 7, completenessScore: 88 });
|
|
252
|
+
assert.equal(first.opened, true);
|
|
253
|
+
// Round two, after a revision that re-cleared the bar. Nothing in the module
|
|
254
|
+
// remembers that this subject has sat before — by design, the loop is unbounded.
|
|
255
|
+
const second = await board.openWindowForFullIdea({ ideaId: 7, completenessScore: 91 });
|
|
256
|
+
assert.equal(second.opened, true);
|
|
257
|
+
assert.notEqual(second.item.id, first.item.id, 'a NEW sitting, not the old one reopened');
|
|
258
|
+
});
|
|
259
|
+
assert.equal(opened, 2);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test('a revision that falls back BELOW the completeness bar never reaches the board', async () => {
|
|
263
|
+
// The grade is the floor and the vote is the merit (ADR 0175 §5): a revision
|
|
264
|
+
// can make an idea worse, and board time is never spent on what a free
|
|
265
|
+
// deterministic check can bounce. The author gets the weakest-section flag
|
|
266
|
+
// instead — which is exactly the zero-vote fallback above.
|
|
267
|
+
await withDb({
|
|
268
|
+
openBoardItem: async () => { throw new Error('must not be reached'); },
|
|
269
|
+
}, async () => {
|
|
270
|
+
const out = await board.openWindowForFullIdea({ ideaId: 7, completenessScore: 41 });
|
|
271
|
+
assert.equal(out.opened, false);
|
|
272
|
+
assert.equal(out.reason, 'below_bar');
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('a subject that ALREADY sits does not open a second window', async () => {
|
|
277
|
+
// The other side of the partial unique index: a double-fired hook, a re-grade
|
|
278
|
+
// or a resubmission race must not put one idea in front of the board twice.
|
|
279
|
+
await withDb({
|
|
280
|
+
openBoardItem: async () => null, // ON CONFLICT DO NOTHING
|
|
281
|
+
getOpenBoardItem: async () => ({ id: '31', subject_type: 'full_idea', subject_id: '7' }),
|
|
282
|
+
}, async () => {
|
|
283
|
+
const out = await board.openWindowForFullIdea({ ideaId: 7, completenessScore: 88 });
|
|
284
|
+
assert.equal(out.opened, false);
|
|
285
|
+
assert.equal(out.reason, 'already_open');
|
|
286
|
+
assert.equal(out.item.id, '31');
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
// ── G. the clock advice the amendment form reads ────────────────────────────
|
|
291
|
+
|
|
292
|
+
test('the constitution view states which rules a clock is honest under, and what to put in the box', async () => {
|
|
293
|
+
// A proposer chooses a pass rule and a window in the same breath (a
|
|
294
|
+
// constitution is ratified whole, never by diff) and the two are NOT
|
|
295
|
+
// independent — ADR 0191 §4. So the server states the pairing rather than the
|
|
296
|
+
// hall hardcoding a number it could drift from.
|
|
297
|
+
const originals = { listAmendmentHistory: db.listAmendmentHistory, countActiveBuilders: db.countActiveBuilders };
|
|
298
|
+
Object.assign(db, { listAmendmentHistory: async () => [], countActiveBuilders: async () => 4 });
|
|
299
|
+
try {
|
|
300
|
+
const view = await board.constitutionView();
|
|
301
|
+
assert.equal(view.clock.recommended_window_minutes, config.RECOMMENDED_WINDOW_MINUTES);
|
|
302
|
+
assert.deepEqual([...view.clock.clock_safe_pass_rules].sort(), ['majority', 'unanimous']);
|
|
303
|
+
// This checkout resolves to the neutral monarchy, under which expiry PASSES
|
|
304
|
+
// — so the view must say a clock is NOT safe here rather than recommending
|
|
305
|
+
// one. That is the auto-ratifier guard, visible in the API.
|
|
306
|
+
assert.equal(view.board.pass_rule, 'first_ratifier');
|
|
307
|
+
assert.equal(view.clock.live_rule_is_clock_safe, false);
|
|
308
|
+
} finally { Object.assign(db, originals); }
|
|
309
|
+
});
|
|
310
|
+
|
|
311
|
+
test('clock safety is a property of the RULE, and the two absence-passes rules never get one', () => {
|
|
312
|
+
assert.equal(config.isClockSafe('unanimous'), true);
|
|
313
|
+
assert.equal(config.isClockSafe('majority'), true);
|
|
314
|
+
assert.equal(config.isClockSafe('first_ratifier'), false, 'expiry PASSES here — a deadline would ratify the unread');
|
|
315
|
+
assert.equal(config.isClockSafe('consent'), false, 'silence is assent here — same trap');
|
|
316
|
+
assert.equal(config.isClockSafe('nonsense'), false, 'an unknown rule is never clock-safe');
|
|
317
|
+
// The two lists cannot drift apart: every clock-safe rule is a real rule.
|
|
318
|
+
for (const r of config.CLOCK_SAFE_PASS_RULES) assert.ok(config.PASS_RULES.includes(r), `${r} must be a real pass rule`);
|
|
319
|
+
});
|