@bongos/core 1.19.918 → 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.
- package/.bongos-core.json +53 -28
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +8 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0319-a-removal-verdict-is-a-row-beside-the-question.md +69 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +134 -3
- package/docs/api-reference.md +8 -1
- package/docs/copy-inventory.md +15 -11
- package/docs/copy-registry.json +48 -12
- package/docs/module-api-changelog.md +2 -0
- package/modules/hall-ui/public/goal-inbox.js +102 -0
- package/modules/hall-ui/public/style.css +12 -0
- package/modules/lifecycle/cascade.js +9 -1
- package/modules/lifecycle/migrations/lifecycle_009_removal_verdicts.sql +111 -0
- package/modules/lifecycle/module.json +2 -1
- package/modules/lifecycle/removal-proposals.js +259 -0
- package/modules/lifecycle/routes/removal-proposals.js +140 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/hall-preview/refresh-fixtures.js +6 -0
- package/src/module-api.js +1 -1
- package/tests/blockers_hall.mjs +16 -2
- package/tests/removal_verdict.mjs +311 -0
|
@@ -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();
|