@bongos/core 1.19.614 → 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 +31 -21
- 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 +2 -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/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/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",
|
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
|
+
});
|