@bongos/core 1.19.614 → 1.19.616

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/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.614",
3
+ "version": "1.19.616",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.614",
9
+ "version": "1.19.616",
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.614",
3
+ "version": "1.19.616",
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.614'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
58
+ const CORE_VERSION = '1.19.616'; // 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: 'unanimous' }, 'bad_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: 'unanimous' }).pass_rule, 'first_ratifier');
72
- assert.equal(validateProposedConstitution({ ...CONSENT_90, pass_rule: 'unanimous' }).ok, false);
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: 'unanimous' }, proposedBy: '42' })).ok, false);
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: 'unanimous' }, votes: [yes('1')], members: [member(1)] }), false,
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: 'unanimous' },
268
- { pass_rule: 'unanimous' },
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). The
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
+ });