@bongos/core 1.19.917 → 1.19.919

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.
@@ -2,6 +2,7 @@
2
2
  import assert from 'node:assert/strict';
3
3
  import { test } from 'node:test';
4
4
  import { createRequire } from 'node:module';
5
+ import fs from 'node:fs';
5
6
  import path from 'node:path';
6
7
  import { fileURLToPath } from 'node:url';
7
8
 
@@ -78,12 +79,24 @@ test('`protected` is DERIVED from the imported PROTECTED_GLOBS, never hand-set',
78
79
  // three is still refused by the path wall (asserted below). What changed is who
79
80
  // may CREATE a goal whose territory happens to include them.
80
81
  //
81
- // `builder-settings` STAYS, deliberately: its overlap is src/bongos/routes/me.js
82
- // caught by the blanket src/bongos/routes/ prefix, not a command, so narrowing it
83
- // needs the me.js strangler move or a blanket-glob change — out of scope here.
82
+ // NARROWED NINE -> EIGHT BY ADR 0318 (task 1004110). `builder-settings` left the
83
+ // set. It derived protected on exactly one glob, src/bongos/routes/me.js, caught
84
+ // by the blanket src/bongos/routes/ prefix. That glob was never an ownership
85
+ // claim: MODULE_GLOBS listed it for task AFFINITY and said in the same comment
86
+ // that the GET /me aggregator stays a thin CORE facade. It resolves ten module
87
+ // ports and builder-settings is one of them, so the file is kernel territory and
88
+ // is now owned there. Same shape as ADR 0314 one step on: owning the command is
89
+ // not owning the authority, and neither is being listed next to it.
90
+ //
91
+ // Neither of the two routes the task proposed was taken, and the measurements are
92
+ // why. Narrowing the blanket prefix was rejected because all 18 files under
93
+ // src/bongos/routes/ are protected by that prefix AND NOTHING ELSE, so narrowing
94
+ // removes the only wall from auth.js, builders.js, backup.js and core-update.js.
95
+ // Moving me.js into modules/builder-settings/ was rejected because a ten-port
96
+ // aggregator inside one module would have to resolve nine foreign ports from
97
+ // inside a module, which is the core->module edge ADR 0083 rule 1 forbids.
84
98
  const PROTECTED_MODULES = [
85
99
  'agents',
86
- 'builder-settings',
87
100
  'dev-box',
88
101
  'devsecops',
89
102
  'government',
@@ -632,9 +645,16 @@ test('adopting the unowned paths did NOT change which modules are protected', ()
632
645
  for (const k of ['kernel', 'lifecycle', 'dev-box', 'devsecops', 'methodology']) {
633
646
  assert.equal(scope.scopeForModule(k).protected, true, `${k} was already protected and must stay so`);
634
647
  }
635
- // and the ADR 0314 nine are still exactly the nine — PROTECTED_MODULES above
636
- // is the by-name pin; this states the intent of THIS task against it.
637
- assert.equal(scope.protectedModules().length, 9, 'adoption must not change the size of the protected set');
648
+ // and the adoption changed no COUNT either. Pinned against PROTECTED_MODULES
649
+ // rather than a literal, so the by-name pin above stays the single place the
650
+ // set is spelled out: a deliberate departure (ADR 0318 removing
651
+ // builder-settings) edits one list, while an ACCIDENTAL widening still reds
652
+ // both this and the by-name test. A literal here duplicated the roster and
653
+ // made an intended change look like two unrelated failures.
654
+ assert.equal(
655
+ scope.protectedModules().length, PROTECTED_MODULES.length,
656
+ 'adoption must not change the size of the protected set',
657
+ );
638
658
  });
639
659
 
640
660
  test('the probe trap: matchOne takes an ARRAY, and a string reports everything unowned', () => {
@@ -651,3 +671,76 @@ test('the probe trap: matchOne takes an ARRAY, and a string reports everything u
651
671
  'a STRING second argument must not be mistaken for a working query',
652
672
  );
653
673
  });
674
+
675
+ // --- ADR 0318: the GET /me aggregator is kernel territory (task 1004110) ---
676
+
677
+ test('the GET /me aggregator is owned by kernel, not by builder-settings', () => {
678
+ // The affinity listing that made a whole module Archon-only. MODULE_GLOBS put
679
+ // src/bongos/routes/me.js under builder-settings and said in the same comment
680
+ // that the aggregator stays a thin CORE facade - so the glob was task affinity,
681
+ // never an ownership claim, and isProtected() could not tell the two apart.
682
+ const ME = 'src/bongos/routes/me.js';
683
+ assert.ok(
684
+ matchOne(ME, scope.scopeForModule('kernel').globs),
685
+ 'kernel must own the /me aggregator - it is the core read facade over ten module ports',
686
+ );
687
+ assert.equal(
688
+ matchOne(ME, scope.scopeForModule('builder-settings').globs), null,
689
+ 'builder-settings must NOT own /me: it is one of the ten ports the facade resolves, not its home',
690
+ );
691
+ });
692
+
693
+ test('builder-settings is not protected, and its own territory is what says so', () => {
694
+ assert.equal(
695
+ scope.scopeForModule('builder-settings').protected, false,
696
+ 'builder-settings derived protected on the me.js affinity listing alone - removing it is the whole point of ADR 0318',
697
+ );
698
+ // Prove the module did not simply lose its territory on the way: it still owns
699
+ // its own directory, and that directory is genuinely not protected. Without
700
+ // this, blanking the globs entirely would pass the assertion above.
701
+ assert.ok(
702
+ matchOne('modules/builder-settings/interaction-prefs.js', scope.scopeForModule('builder-settings').globs),
703
+ 'builder-settings must still own its own module directory',
704
+ );
705
+ });
706
+
707
+ test('unprotecting builder-settings did NOT open the path wall on /me', () => {
708
+ // The half that would make this a false success, mirroring the ADR 0314 test
709
+ // above. The module flag decides who may CREATE a goal over the territory; the
710
+ // per-file wall decides who may WRITE it. Only the first was meant to move.
711
+ const ME = 'src/bongos/routes/me.js';
712
+ assert.ok(
713
+ touchesOverlap([ME], PROTECTED_GLOBS),
714
+ 'fixture assumption: /me is still a protected surface via the src/bongos/routes/ prefix',
715
+ );
716
+ assert.equal(gsc.scopeSubset(['kernel'], [ME]).ok, true, 'fixture assumption: kernel now owns /me');
717
+ const r = gsc.check(['kernel'], [ME], 'thetes');
718
+ assert.equal(r.ok, false, 'a sub-Metic builder must still be refused on /me');
719
+ assert.equal(r.code, 'protected_path', '/me must be refused BY THE PATH WALL, not by scope');
720
+ assert.equal(gsc.check(['kernel'], [ME], 'metic').ok, true, 'a Metic must still be able to author on /me');
721
+ });
722
+
723
+ test('every file under src/bongos/routes/ is protected by the blanket prefix AND NOTHING ELSE', () => {
724
+ // The measurement that killed the other option on task 1004110, pinned so the
725
+ // next reader does not have to re-derive it. Narrowing the blanket
726
+ // src/bongos/routes/ prefix looks like a small change and is not: it is the
727
+ // ONLY protection every route file has, auth.js and core-update.js included.
728
+ //
729
+ // If this reds because a route gained its own PROTECTED_GLOBS entry, that is
730
+ // fine and the exemption should be stated deliberately. If it reds because the
731
+ // blanket entry went away, read task 1004116 before touching anything.
732
+ const BLANKET = 'src/bongos/routes/';
733
+ assert.ok(PROTECTED_GLOBS.includes(BLANKET), 'the blanket route prefix must still be a protected glob');
734
+ const others = PROTECTED_GLOBS.filter((g) => g !== BLANKET);
735
+ const dir = path.join(ROOT, 'src', 'bongos', 'routes');
736
+ const files = fs.readdirSync(dir).filter((f) => f.endsWith('.js') || f.endsWith('.md'));
737
+ assert.ok(files.length >= 18, 'expected the route directory to still be populated, saw ' + files.length);
738
+ for (const f of files) {
739
+ const rel = BLANKET + f;
740
+ assert.ok(touchesOverlap([rel], PROTECTED_GLOBS), rel + ' must be protected');
741
+ assert.deepEqual(
742
+ others.filter((g) => matchOne(rel, [g])), [],
743
+ rel + ' is protected by the blanket prefix alone - narrowing the prefix would leave it unwalled',
744
+ );
745
+ }
746
+ });
@@ -0,0 +1,311 @@
1
+ // tests/removal_verdict.mjs — BV2.R16: confirming or dismissing a removal
2
+ // proposal (task 1004095, ADR 0313 + ADR 0319). The sibling of
3
+ // tests/removal_proposal.mjs, which covers the QUESTION this file answers.
4
+ //
5
+ // WHAT THESE ASSERTIONS ARE FOR. The feature's whole output is a fact that
6
+ // survives the click: which answer a person gave. So the tests that matter are
7
+ // the ones that fail if the two answers ever become indistinguishable — if a
8
+ // verdict stops being written, if the closing status is the only record, if the
9
+ // prefix match widens to a task nobody proposed removing, or if a second click
10
+ // silently overwrites the first.
11
+ //
12
+ // Nothing here talks to a database. The pure half is pure; the transaction runs
13
+ // against the #538 `deps.pool` seam with a SQL-aware fake, the abandon_task.mjs
14
+ // posture. The one thing a fake cannot pin — that the SQL is valid Postgres — is
15
+ // left to the live route, deliberately rather than by omission.
16
+ //
17
+ // Run: node tests/removal_verdict.mjs
18
+
19
+ import { strict as assert } from 'node:assert';
20
+ import { createRequire } from 'node:module';
21
+ import { makeRunner, makeSqlAwareClient } from './helpers.mjs';
22
+
23
+ process.env.NODE_ENV = 'test';
24
+
25
+ const require = createRequire(import.meta.url);
26
+ const cascade = require('../modules/lifecycle/cascade.js');
27
+ const rot = require('../modules/lifecycle/rot.js');
28
+ const proposals = require('../modules/lifecycle/removal-proposals.js');
29
+
30
+ const { test, summary } = makeRunner();
31
+
32
+ const SHIP = 1004090;
33
+ const PROPOSAL = 555001;
34
+ const DECIDER = 42;
35
+
36
+ // The ref a REAL proposal carries, built by the same code that files one rather
37
+ // than typed out here. A literal would let this file agree with itself while
38
+ // disagreeing with production — the exact failure mode task 1004090 shipped.
39
+ const rule = cascade.CASCADES.find((r) => r.key === cascade.REMOVAL_RULE_KEY);
40
+ const realSpec = cascade.cascadeTaskSpec(
41
+ rule,
42
+ { id: SHIP, goal_id: 1000095, version_id: 'BONGOS-V2' },
43
+ cascade.TRIGGERS.ship,
44
+ { removals: [{ taskId: '1003000', title: 'earlier work', paths: ['a/gone.js'] }] }
45
+ );
46
+ const REAL_REF = realSpec.sourceRef;
47
+
48
+ // --- the prefix, end to end -------------------------------------------------
49
+
50
+ await test('the prefix matches what the cascade actually writes', () => {
51
+ // THE WHOLE PATH, not its middle: the prefix is asserted against a spec the
52
+ // real rule produced, so a change to the key or to cascadeSourceRef's format
53
+ // reds here instead of quietly making the queue read return nothing.
54
+ assert.ok(REAL_REF.startsWith(proposals.PROPOSAL_SOURCE_REF_PREFIX),
55
+ `a real proposal ref (${REAL_REF}) must carry the prefix the reader matches`);
56
+ assert.equal(proposals.shipTaskIdFromSourceRef(REAL_REF), SHIP,
57
+ 'and the ship id must come back out of it');
58
+ });
59
+
60
+ await test('the prefix does NOT match the other cascade rule', () => {
61
+ // Both rules write `source: 'cascade'`, so the ref prefix is the ONLY thing
62
+ // separating a removal question from an artist review. If it ever widened, a
63
+ // Confirm button would appear on a copy review and answering it would record a
64
+ // removal verdict about work nobody proposed removing.
65
+ const artist = cascade.CASCADES.find((r) => r.key === 'artist-review-on-ship');
66
+ const artistRef = cascade.cascadeSourceRef(artist.key, SHIP);
67
+ assert.ok(!artistRef.startsWith(proposals.PROPOSAL_SOURCE_REF_PREFIX));
68
+ assert.equal(proposals.shipTaskIdFromSourceRef(artistRef), null);
69
+ });
70
+
71
+ await test('a malformed or foreign ref yields null, never a guess', () => {
72
+ const P = proposals.PROPOSAL_SOURCE_REF_PREFIX;
73
+ assert.equal(proposals.shipTaskIdFromSourceRef(`${P}not-a-number`), null);
74
+ assert.equal(proposals.shipTaskIdFromSourceRef(P), null, 'a bare prefix names no ship');
75
+ assert.equal(proposals.shipTaskIdFromSourceRef(`${P}0`), null, 'zero is not a task id');
76
+ assert.equal(proposals.shipTaskIdFromSourceRef(`${P}12.5`), null);
77
+ assert.equal(proposals.shipTaskIdFromSourceRef('planning-session-bv2'), null);
78
+ assert.equal(proposals.shipTaskIdFromSourceRef(null), null);
79
+ assert.equal(proposals.shipTaskIdFromSourceRef(undefined), null);
80
+ });
81
+
82
+ // --- the vocabulary ---------------------------------------------------------
83
+
84
+ await test('the verdict vocabulary is an allow-list of exactly two', () => {
85
+ assert.deepEqual([...proposals.VERDICTS].sort(), ['confirmed', 'dismissed']);
86
+ });
87
+
88
+ await test('an unknown verdict is refused BEFORE the transaction opens', async () => {
89
+ // A deny-list ("anything that is not dismissed confirms") would admit every
90
+ // value added later, and the admitted one is the answer that dims a star. The
91
+ // refusal must also cost no connection — a rejected verdict that still opened a
92
+ // transaction would be a lock taken for a write that can never happen.
93
+ for (const bad of ['removed', 'CONFIRMED', 'confirm', '', null, undefined, 1]) {
94
+ let connected = false;
95
+ const pool = { connect: async () => { connected = true; throw new Error('must not connect'); } };
96
+ await assert.rejects(
97
+ () => proposals.recordVerdict({ proposalTaskId: PROPOSAL, verdict: bad }, { pool }),
98
+ (err) => err.code === 'BAD_VERDICT',
99
+ `verdict ${JSON.stringify(bad)} must be refused`
100
+ );
101
+ assert.equal(connected, false, 'no transaction for a refused verdict');
102
+ }
103
+ });
104
+
105
+ // --- the transaction --------------------------------------------------------
106
+
107
+ // The fake: one proposal row, an optional existing verdict, and a recording log.
108
+ function fakePool({
109
+ status = 'backlog',
110
+ notFound = false,
111
+ source = 'cascade',
112
+ sourceRef = REAL_REF,
113
+ existingVerdict = null,
114
+ } = {}) {
115
+ return makeSqlAwareClient((s, params) => {
116
+ if (/^SELECT id::text AS id, title, status, source, source_ref FROM tasks WHERE id = \$1 FOR UPDATE/.test(s)) {
117
+ return { rows: notFound ? [] : [{ id: String(params[0]), title: 'Was this removed?', status, source, source_ref: sourceRef }] };
118
+ }
119
+ if (/^SELECT verdict, decided_by/.test(s)) {
120
+ return { rows: existingVerdict ? [existingVerdict] : [] };
121
+ }
122
+ if (/^INSERT INTO lifecycle_removal_verdicts/.test(s)) {
123
+ return {
124
+ rows: [{
125
+ proposal_task_id: String(params[0]),
126
+ ship_task_id: params[1] == null ? null : String(params[1]),
127
+ verdict: params[2],
128
+ decided_by: params[3] == null ? null : String(params[3]),
129
+ decided_at: '2026-09-20T00:00:00.000Z',
130
+ note: params[4] || null,
131
+ }],
132
+ };
133
+ }
134
+ return { rows: [] }; // BEGIN / COMMIT / ROLLBACK / UPDATE tasks
135
+ });
136
+ }
137
+
138
+ const find = (queries, re) => queries.find((q) => re.test(String(q.sql).replace(/\s+/g, ' ').trim()));
139
+
140
+ await test('confirming writes the verdict AND closes the question, in one txn', async () => {
141
+ const pool = fakePool();
142
+ const out = await proposals.recordVerdict(
143
+ { proposalTaskId: PROPOSAL, verdict: 'confirmed', builderId: DECIDER },
144
+ { pool }
145
+ );
146
+
147
+ assert.equal(out.verdict, 'confirmed');
148
+ assert.equal(out.ship_task_id, String(SHIP), 'the ship is denormalised out of the ref');
149
+ assert.equal(out.decided_by, String(DECIDER), 'a verdict without an author is a rumour');
150
+
151
+ const ins = find(pool._client.queries, /^INSERT INTO lifecycle_removal_verdicts/);
152
+ assert.ok(ins, 'the verdict row is the record — it must be written');
153
+ assert.deepEqual(ins.params.slice(0, 4), [PROPOSAL, SHIP, 'confirmed', DECIDER]);
154
+
155
+ const upd = find(pool._client.queries, /^UPDATE tasks/);
156
+ assert.ok(upd, 'the answered question must leave the queue');
157
+ assert.ok(/status = 'abandoned'/.test(String(upd.sql)), 'closed as abandoned, the ledger\'s one spelling');
158
+ assert.ok(find(pool._client.queries, /^COMMIT/), 'both writes commit together');
159
+ assert.ok(!find(pool._client.queries, /^ROLLBACK/));
160
+ });
161
+
162
+ await test('dismissing writes the OPPOSITE verdict and closes it the same way', async () => {
163
+ // The point of the whole table: the two answers differ in the verdict column
164
+ // and NOWHERE else that matters. If a future edit made dismiss skip the insert,
165
+ // a dismissed proposal would be indistinguishable from an unanswered one that
166
+ // somebody killed off the rot feed.
167
+ const pool = fakePool();
168
+ const out = await proposals.recordVerdict(
169
+ { proposalTaskId: PROPOSAL, verdict: 'dismissed', builderId: DECIDER },
170
+ { pool }
171
+ );
172
+ assert.equal(out.verdict, 'dismissed');
173
+ const ins = find(pool._client.queries, /^INSERT INTO lifecycle_removal_verdicts/);
174
+ assert.equal(ins.params[2], 'dismissed');
175
+ assert.ok(find(pool._client.queries, /^UPDATE tasks/), 'dismissing closes the question too');
176
+ assert.ok(find(pool._client.queries, /^COMMIT/));
177
+ });
178
+
179
+ await test('the proposal row is locked FOR UPDATE before anything is decided', async () => {
180
+ // Two people pressing Confirm and Dismiss at the same moment must not both read
181
+ // "undecided" and both write. The lock is what makes the second one lose.
182
+ const pool = fakePool();
183
+ await proposals.recordVerdict({ proposalTaskId: PROPOSAL, verdict: 'confirmed' }, { pool });
184
+ const sel = find(pool._client.queries, /FROM tasks WHERE id = \$1 FOR UPDATE/);
185
+ assert.ok(sel, 'the row must be locked');
186
+ const order = pool._client.queries.map((q) => String(q.sql).replace(/\s+/g, ' ').trim());
187
+ assert.ok(order.indexOf('BEGIN') < order.findIndex((s) => /FOR UPDATE/.test(s)),
188
+ 'the lock is taken inside the transaction');
189
+ });
190
+
191
+ await test('a task that is not a removal proposal cannot be answered', async () => {
192
+ const pool = fakePool({ source: 'planning-session', sourceRef: 'bv2-ideator-surface-R15a' });
193
+ await assert.rejects(
194
+ () => proposals.recordVerdict({ proposalTaskId: PROPOSAL, verdict: 'confirmed' }, { pool }),
195
+ (err) => err.code === 'NOT_A_PROPOSAL'
196
+ );
197
+ assert.ok(!find(pool._client.queries, /^INSERT INTO lifecycle_removal_verdicts/), 'nothing written');
198
+ assert.ok(find(pool._client.queries, /^ROLLBACK/));
199
+ });
200
+
201
+ await test('an ARTIST REVIEW is not a removal proposal either', async () => {
202
+ // Same `source`, different rule. This is the row most likely to be mistaken for
203
+ // one, because everything except the ref matches.
204
+ const artistRef = cascade.cascadeSourceRef('artist-review-on-ship', SHIP);
205
+ const pool = fakePool({ source: 'cascade', sourceRef: artistRef });
206
+ await assert.rejects(
207
+ () => proposals.recordVerdict({ proposalTaskId: PROPOSAL, verdict: 'dismissed' }, { pool }),
208
+ (err) => err.code === 'NOT_A_PROPOSAL'
209
+ );
210
+ assert.ok(!find(pool._client.queries, /^INSERT INTO lifecycle_removal_verdicts/));
211
+ });
212
+
213
+ await test('a missing task is NOT_FOUND and rolls back', async () => {
214
+ const pool = fakePool({ notFound: true });
215
+ await assert.rejects(
216
+ () => proposals.recordVerdict({ proposalTaskId: PROPOSAL, verdict: 'confirmed' }, { pool }),
217
+ (err) => err.code === 'NOT_FOUND'
218
+ );
219
+ assert.ok(find(pool._client.queries, /^ROLLBACK/));
220
+ });
221
+
222
+ await test('a second answer is refused, never an overwrite', async () => {
223
+ // Not an upsert and not an idempotent 200. Overwriting would destroy the record
224
+ // that a person answered differently, which is the one thing this table holds.
225
+ const pool = fakePool({
226
+ status: 'abandoned',
227
+ existingVerdict: { verdict: 'dismissed', decided_by: '7', decided_at: '2026-09-19T00:00:00.000Z' },
228
+ });
229
+ await assert.rejects(
230
+ () => proposals.recordVerdict({ proposalTaskId: PROPOSAL, verdict: 'confirmed' }, { pool }),
231
+ (err) => err.code === 'ALREADY_DECIDED' && err.existing && err.existing.verdict === 'dismissed'
232
+ );
233
+ assert.ok(!find(pool._client.queries, /^INSERT INTO lifecycle_removal_verdicts/));
234
+ });
235
+
236
+ await test('already-decided outranks already-closed, so the message is the useful one', async () => {
237
+ // An answered proposal is ALSO abandoned, so checking the status first would
238
+ // report "this is not an open question" to someone whose real answer is "it was
239
+ // already dismissed, by builder 7, yesterday".
240
+ const pool = fakePool({
241
+ status: 'abandoned',
242
+ existingVerdict: { verdict: 'confirmed', decided_by: '7', decided_at: '2026-09-19T00:00:00.000Z' },
243
+ });
244
+ await assert.rejects(
245
+ () => proposals.recordVerdict({ proposalTaskId: PROPOSAL, verdict: 'confirmed' }, { pool }),
246
+ (err) => err.code === 'ALREADY_DECIDED'
247
+ );
248
+ });
249
+
250
+ await test('a proposal that left backlog with no verdict is closed, not answerable', async () => {
251
+ // Killed or prioritised off the rot feed. The question is gone; a verdict on it
252
+ // would answer something nobody is asking.
253
+ const pool = fakePool({ status: 'ready' });
254
+ await assert.rejects(
255
+ () => proposals.recordVerdict({ proposalTaskId: PROPOSAL, verdict: 'confirmed' }, { pool }),
256
+ (err) => err.code === 'PROPOSAL_CLOSED' && err.status === 'ready'
257
+ );
258
+ assert.ok(!find(pool._client.queries, /^INSERT INTO lifecycle_removal_verdicts/));
259
+ });
260
+
261
+ await test('an unparseable ref still answers — with a null ship, not a refusal', async () => {
262
+ // The verdict is the point. Refusing to record one because the convenience
263
+ // column could not be filled would be the tail wagging the dog.
264
+ const pool = fakePool({ sourceRef: `${proposals.PROPOSAL_SOURCE_REF_PREFIX}oops` });
265
+ const out = await proposals.recordVerdict({ proposalTaskId: PROPOSAL, verdict: 'confirmed' }, { pool });
266
+ assert.equal(out.ship_task_id, null);
267
+ const ins = find(pool._client.queries, /^INSERT INTO lifecycle_removal_verdicts/);
268
+ assert.equal(ins.params[1], null, 'ship_task_id binds NULL rather than a guess');
269
+ });
270
+
271
+ await test('a note is trimmed and capped; an empty one stores nothing', async () => {
272
+ const pool = fakePool();
273
+ await proposals.recordVerdict(
274
+ { proposalTaskId: PROPOSAL, verdict: 'dismissed', note: ' it was a move ' },
275
+ { pool }
276
+ );
277
+ assert.equal(find(pool._client.queries, /^INSERT INTO lifecycle_removal_verdicts/).params[4], 'it was a move');
278
+
279
+ const pool2 = fakePool();
280
+ await proposals.recordVerdict({ proposalTaskId: PROPOSAL, verdict: 'dismissed', note: ' ' }, { pool: pool2 });
281
+ assert.equal(find(pool2._client.queries, /^INSERT INTO lifecycle_removal_verdicts/).params[4], '',
282
+ 'blank binds the empty string, which NULLIF turns into NULL');
283
+
284
+ const pool3 = fakePool();
285
+ await proposals.recordVerdict({ proposalTaskId: PROPOSAL, verdict: 'dismissed', note: 'x'.repeat(5000) }, { pool: pool3 });
286
+ assert.ok(find(pool3._client.queries, /^INSERT INTO lifecycle_removal_verdicts/).params[4].length <= 2000);
287
+ });
288
+
289
+ await test('the closure line says which answer was given, and is not the record', () => {
290
+ const yes = proposals.closureSummary('confirmed', null);
291
+ const no = proposals.closureSummary('dismissed', null);
292
+ assert.notEqual(yes, no, 'the two answers must not read the same');
293
+ assert.match(yes, /confirmed/i);
294
+ assert.match(no, /dismissed/i);
295
+ assert.match(proposals.closureSummary('dismissed', 'a rename'), /a rename$/);
296
+ });
297
+
298
+ // --- the clock it rides rather than rebuilds --------------------------------
299
+
300
+ await test('an unanswered proposal rots, an answered one leaves the feed', () => {
301
+ // Spec decision 14, proved against the rot module's own vocabulary rather than
302
+ // asserted in a comment. A proposal is filed at 'backlog' (pinned upstream in
303
+ // tests/removal_proposal.mjs) and answering it writes 'abandoned'.
304
+ assert.ok(rot.TASK_ROTTABLE_STATUSES.includes('backlog'),
305
+ 'an unanswered proposal must sit in a rottable status');
306
+ assert.ok(!rot.TASK_ROTTABLE_STATUSES.includes('abandoned'),
307
+ 'an answered one must fall out of the feed by the same mechanism');
308
+ assert.equal(realSpec.status, 'backlog', 'and that is the status the cascade actually files');
309
+ });
310
+
311
+ summary();