@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.
- package/.bongos-core.json +61 -31
- 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/0314-module-protection-is-per-path-not-per-module.md +10 -5
- package/docs/adr/0318-listing-a-file-for-task-affinity-is-not-owning-it.md +101 -0
- package/docs/adr/0319-a-removal-verdict-is-a-row-beside-the-question.md +69 -0
- package/docs/adr/README.md +2 -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 +4 -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/bongos/module-scope-map.js +14 -4
- package/src/module-api.js +1 -1
- package/tests/blockers_hall.mjs +16 -2
- package/tests/module-scope-map.mjs +100 -7
- package/tests/removal_verdict.mjs +311 -0
|
@@ -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`
|
|
82
|
-
//
|
|
83
|
-
//
|
|
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
|
|
636
|
-
//
|
|
637
|
-
|
|
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();
|