@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.
@@ -0,0 +1,259 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/removal-proposals.js — THE ANSWER to "was this removed?"
4
+ // (task 1004095 / BV2.R16, goal 1000095; ADR 0313 + ADR 0319).
5
+ //
6
+ // WHAT CAME BEFORE. A ship that deletes files an earlier SHIPPED task put there
7
+ // files a question: the `removal-proposed-on-ship` cascade (task 1004090) creates
8
+ // one task at `backlog` naming the affected work. That is deliberately a QUESTION
9
+ // and not a verdict — refactors, renames and file moves all look like deletions to
10
+ // a diff, so a derived-only signal would dim the sky continuously (ADR 0313, spec
11
+ // decision 13). Nothing in the system could answer it. This file is the answer.
12
+ //
13
+ // THE TWO ANSWERS MUST BE DISTINGUISHABLE, AND THAT IS THE WHOLE POINT. Answering
14
+ // either way leaves nothing to build, so either way the proposal task ends
15
+ // `abandoned`. If the status were the only record, "yes, that work is gone" and
16
+ // "no, that was a refactor" would be the same row — and brightness (BV2.R20), the
17
+ // live share of a descent, would have no way to tell the star it must dim from the
18
+ // one it must not. So the verdict is written to `lifecycle_removal_verdicts`
19
+ // (migration lifecycle_009) and the status change is what takes the answered
20
+ // question out of the queues. Both in ONE transaction: a verdict with no closed
21
+ // task would be re-answerable, and a closed task with no verdict is the exact
22
+ // erasure this file exists to prevent.
23
+ //
24
+ // WHAT IS NOT HERE: the list of tasks whose work was removed. The cascade files
25
+ // one task per rule per ship with no field for a target (ADR 0242 forbids widening
26
+ // the runner to fan out), so that list travels as PROSE in the proposal's body —
27
+ // the owner's decision of 2026-09-20, recorded in cascade.js:removalProposalBody.
28
+ // This file does not parse that prose and must not start: a body is written for a
29
+ // person to read, and a reader that silently depends on its line shape breaks the
30
+ // day somebody improves the wording. What brightness gets from here is the
31
+ // verdict and `ship_task_id`, both structured.
32
+ //
33
+ // ROT IS INHERITED, NOT REBUILT. An unanswered proposal is a `backlog` task, which
34
+ // is a rottable status (../rot.js TASK_ROTTABLE_STATUSES), so it enters the
35
+ // existing rot feed on the project's normal clock with no code here at all — spec
36
+ // decision 14, and the reason that decision had no competing option. Answering it
37
+ // moves it to `abandoned`, which is terminal and not rottable, so an answered
38
+ // proposal leaves the feed by the same mechanism.
39
+ //
40
+ // MODULE BOUNDARY: reaches core only through the published doorway
41
+ // `../../src/module-api`; `./cascade` is an intra-module sibling.
42
+
43
+ const api = require('../../src/module-api');
44
+ const { CASCADE_SOURCE, REMOVAL_RULE_KEY, cascadeSourceRef } = require('./cascade');
45
+
46
+ const { pool } = api;
47
+
48
+ // The two answers, as an ALLOW-LIST. Mirrors the CHECK in lifecycle_009 — and a
49
+ // deny-list here ("anything that is not 'dismissed' confirms") would admit every
50
+ // value added to the vocabulary later, with the admitted one being the answer that
51
+ // moves money. Exported so the route and the tests read the same set.
52
+ const VERDICTS = Object.freeze(['confirmed', 'dismissed']);
53
+
54
+ // The `source_ref` prefix every removal proposal carries, BUILT from the same
55
+ // function that writes it rather than typed out. `cascadeSourceRef(key, id)`
56
+ // returns `cascade-<key>-<id>`, so calling it with an empty id yields exactly the
57
+ // prefix, and a change to either half of that format reaches this read instead of
58
+ // quietly making it match nothing. The trailing separator is part of the prefix,
59
+ // which is what stops `…-on-ship-12` from matching proposal `…-on-ship-1234`.
60
+ //
61
+ // The SQL below matches it with `starts_with`, never `LIKE $2 || '%'`. A rule key
62
+ // is free to contain an underscore, and an underscore is a LIKE wildcard — so the
63
+ // LIKE form would silently widen the match the day somebody names a rule
64
+ // `removal_proposed`, and widening a read that decides what may be answered is
65
+ // not a bug that announces itself.
66
+ const PROPOSAL_SOURCE_REF_PREFIX = cascadeSourceRef(REMOVAL_RULE_KEY, '');
67
+
68
+ // shipTaskIdFromSourceRef — the ship a proposal is about, recovered from its ref.
69
+ // Pure. Returns a positive integer, or null when the ref is not a removal
70
+ // proposal's or its tail is not a plain id. Null is a legal outcome and is stored
71
+ // as such: a malformed ref must not make a proposal unanswerable, because the
72
+ // verdict is the record that matters and the ship id is a convenience for the
73
+ // reader that comes next.
74
+ function shipTaskIdFromSourceRef(sourceRef) {
75
+ if (typeof sourceRef !== 'string' || !sourceRef.startsWith(PROPOSAL_SOURCE_REF_PREFIX)) return null;
76
+ const tail = sourceRef.slice(PROPOSAL_SOURCE_REF_PREFIX.length);
77
+ if (!/^\d+$/.test(tail)) return null;
78
+ const n = Number(tail);
79
+ return Number.isSafeInteger(n) && n > 0 ? n : null;
80
+ }
81
+
82
+ const MAX_PROPOSAL_ROWS = 50;
83
+
84
+ // listPending — the removal proposals nobody has answered yet, oldest first.
85
+ //
86
+ // OLDEST FIRST, unlike the nod queue's newest-first: a proposal that has waited is
87
+ // the one about to rot, and a queue that buries it under today's is a queue whose
88
+ // tail nobody ever sees.
89
+ //
90
+ // "Unanswered" is TWO facts and both are required. A proposal with no verdict row
91
+ // is unanswered; a proposal still at `backlog` has not been closed some other way
92
+ // (killed off the rot feed, say). Reading only the verdict table would keep
93
+ // offering an answer for a task somebody already abandoned by hand, and reading
94
+ // only the status would re-offer one this route has already answered if the two
95
+ // ever drift.
96
+ async function listPending({ limit = 20 } = {}, deps = {}) {
97
+ const db = deps.pool || pool;
98
+ const cap = Math.max(1, Math.min(MAX_PROPOSAL_ROWS, Number(limit) || 20));
99
+ const { rows } = await db.query(
100
+ `SELECT t.id::text AS proposal_task_id,
101
+ t.title AS title,
102
+ t.description AS description,
103
+ t.source_ref AS source_ref,
104
+ t.goal_id::text AS goal_id,
105
+ g.title AS goal_title,
106
+ t.created_at AS filed_at
107
+ FROM tasks t
108
+ LEFT JOIN goals g ON g.id = t.goal_id
109
+ WHERE t.source = $1
110
+ AND starts_with(t.source_ref, $2)
111
+ AND t.status = 'backlog'
112
+ AND NOT EXISTS (SELECT 1 FROM lifecycle_removal_verdicts v WHERE v.proposal_task_id = t.id)
113
+ ORDER BY t.created_at ASC, t.id ASC
114
+ LIMIT $3`,
115
+ [CASCADE_SOURCE, PROPOSAL_SOURCE_REF_PREFIX, cap]
116
+ );
117
+ return rows.map((r) => ({ ...r, ship_task_id: shipTaskIdFromSourceRef(r.source_ref) }));
118
+ }
119
+
120
+ // countPending — the same population as a number, for the slice's count pill. Its
121
+ // own query rather than `rows.length`, because the list is capped and a pill that
122
+ // silently reported the cap would under-state a backlog exactly when it mattered.
123
+ async function countPending(deps = {}) {
124
+ const db = deps.pool || pool;
125
+ const { rows } = await db.query(
126
+ `SELECT count(*)::int AS n
127
+ FROM tasks t
128
+ WHERE t.source = $1
129
+ AND starts_with(t.source_ref, $2)
130
+ AND t.status = 'backlog'
131
+ AND NOT EXISTS (SELECT 1 FROM lifecycle_removal_verdicts v WHERE v.proposal_task_id = t.id)`,
132
+ [CASCADE_SOURCE, PROPOSAL_SOURCE_REF_PREFIX]
133
+ );
134
+ return (rows[0] && rows[0].n) || 0;
135
+ }
136
+
137
+ // The one-line trace left on the closed task, so the ledger reads honestly to
138
+ // somebody who never opens the verdict table. Exported and pinned by test: it is
139
+ // the only place the two answers are put into prose, and it must never become the
140
+ // thing a reader PARSES — the verdict row is the record; this is the courtesy.
141
+ function closureSummary(verdict, note) {
142
+ const head = verdict === 'confirmed'
143
+ ? 'Removal confirmed — the work named above is gone.'
144
+ : 'Removal dismissed — the deletion was not a removal of that work.';
145
+ const tail = typeof note === 'string' && note.trim() ? ` ${note.trim()}` : '';
146
+ return `${head}${tail}`;
147
+ }
148
+
149
+ // recordVerdict — answer ONE proposal: write the verdict and close the question,
150
+ // in a single transaction.
151
+ //
152
+ // EVERY REFUSAL IS A NAMED CODE, never a silent no-op, because each of them is a
153
+ // different thing for the caller to say:
154
+ // NOT_FOUND — no such task.
155
+ // NOT_A_PROPOSAL — a real task that is not a removal proposal. Answering it
156
+ // would write a verdict about work nobody proposed removing.
157
+ // ALREADY_DECIDED — a verdict exists. NOT an upsert and not an idempotent
158
+ // 200: overwriting would destroy the record that a person
159
+ // answered differently, and the PK in lifecycle_009 refuses
160
+ // it at the schema too. The existing verdict rides out on
161
+ // the error so the caller can say what it was.
162
+ // PROPOSAL_CLOSED — the task is no longer at `backlog` (killed off the rot
163
+ // feed, promoted, claimed). The question is gone; a verdict
164
+ // on it would answer something that is not being asked.
165
+ // BAD_VERDICT — not one of VERDICTS.
166
+ //
167
+ // The row is locked FOR UPDATE before any of the row-scoped checks, so two people
168
+ // pressing Confirm and Dismiss at the same moment cannot both read "undecided" and
169
+ // both write. The second one loses on ALREADY_DECIDED rather than overwriting.
170
+ //
171
+ // #538 test seam: a caller-supplied pool is honoured ONLY under NODE_ENV==='test',
172
+ // the abandonTask / confirmTask convention, so a test can drive the transaction
173
+ // with a SQL-aware fake and production callers cannot reach it.
174
+ async function recordVerdict({ proposalTaskId, verdict, note = null, builderId = null }, deps = {}) {
175
+ if (!VERDICTS.includes(verdict)) {
176
+ throw Object.assign(new Error(`verdict must be one of ${VERDICTS.join(', ')}`), { code: 'BAD_VERDICT' });
177
+ }
178
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
179
+ const client = await activePool.connect();
180
+ try {
181
+ await client.query('BEGIN');
182
+
183
+ const { rows: taskRows } = await client.query(
184
+ `SELECT id::text AS id, title, status, source, source_ref FROM tasks WHERE id = $1 FOR UPDATE`,
185
+ [proposalTaskId]
186
+ );
187
+ if (taskRows.length === 0) {
188
+ throw Object.assign(new Error('task not found'), { code: 'NOT_FOUND' });
189
+ }
190
+ const task = taskRows[0];
191
+ const shipTaskId = shipTaskIdFromSourceRef(task.source_ref);
192
+ if (task.source !== CASCADE_SOURCE
193
+ || typeof task.source_ref !== 'string'
194
+ || !task.source_ref.startsWith(PROPOSAL_SOURCE_REF_PREFIX)) {
195
+ throw Object.assign(new Error(`task ${proposalTaskId} is not a removal proposal`), { code: 'NOT_A_PROPOSAL' });
196
+ }
197
+
198
+ const { rows: existing } = await client.query(
199
+ `SELECT verdict, decided_by::text AS decided_by, decided_at
200
+ FROM lifecycle_removal_verdicts WHERE proposal_task_id = $1`,
201
+ [proposalTaskId]
202
+ );
203
+ if (existing.length > 0) {
204
+ throw Object.assign(new Error(`proposal ${proposalTaskId} was already ${existing[0].verdict}`), {
205
+ code: 'ALREADY_DECIDED',
206
+ existing: existing[0],
207
+ });
208
+ }
209
+ if (task.status !== 'backlog') {
210
+ throw Object.assign(new Error(`proposal ${proposalTaskId} is '${task.status}', not an open question`), {
211
+ code: 'PROPOSAL_CLOSED',
212
+ status: task.status,
213
+ });
214
+ }
215
+
216
+ const { rows: written } = await client.query(
217
+ `INSERT INTO lifecycle_removal_verdicts
218
+ (proposal_task_id, ship_task_id, verdict, decided_by, note)
219
+ VALUES ($1, $2, $3, $4, NULLIF($5, ''))
220
+ RETURNING proposal_task_id::text AS proposal_task_id,
221
+ ship_task_id::text AS ship_task_id,
222
+ verdict, decided_by::text AS decided_by, decided_at, note`,
223
+ [proposalTaskId, shipTaskId, verdict, builderId, typeof note === 'string' ? note.slice(0, 2000).trim() : '']
224
+ );
225
+
226
+ // The question is answered, so it is no longer work: 'abandoned' is the
227
+ // ledger's one spelling of "finished with the project, not delivered"
228
+ // (../task-status.js TERMINAL_STATUSES). ADR 0313 rejected a `removed` status
229
+ // for the SHIPPED work; this is the proposal's own row, not that work's, and a
230
+ // confirmed removal still leaves `shipped` terminal and every credit paid.
231
+ await client.query(
232
+ `UPDATE tasks
233
+ SET status = 'abandoned',
234
+ value_summary = COALESCE(value_summary, $2),
235
+ updated_at = now()
236
+ WHERE id = $1`,
237
+ [proposalTaskId, closureSummary(verdict, note)]
238
+ );
239
+
240
+ await client.query('COMMIT');
241
+ return { ...written[0], proposal_title: task.title, ship_task_id: shipTaskId == null ? null : String(shipTaskId) };
242
+ } catch (err) {
243
+ await client.query('ROLLBACK').catch(() => {});
244
+ throw err;
245
+ } finally {
246
+ client.release();
247
+ }
248
+ }
249
+
250
+ module.exports = {
251
+ VERDICTS,
252
+ MAX_PROPOSAL_ROWS,
253
+ PROPOSAL_SOURCE_REF_PREFIX,
254
+ shipTaskIdFromSourceRef,
255
+ closureSummary,
256
+ listPending,
257
+ countPending,
258
+ recordVerdict,
259
+ };
@@ -0,0 +1,140 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/routes/removal-proposals.js — the doorway for answering a
4
+ // removal proposal (task 1004095 / BV2.R16, goal 1000095; ADR 0313 + ADR 0319).
5
+ //
6
+ // TWO ROUTES AND NO NEW PERMISSION. Confirming a removal sits at the same floor as
7
+ // the nod — Metic+ via the EXISTING `task.promote` atom (spec decision 15) — which
8
+ // is the precedent the nod slice set in this same card (task 1003066 reused
9
+ // POST /tasks/:id/promote rather than minting an endpoint). The read carries the
10
+ // same atom as the write, so a caller who can SEE the queue can act on it: the two
11
+ // gates agree by construction instead of by coincidence.
12
+ //
13
+ // WHY A NEW ROUTE AT ALL, when the nod reused one. There is no existing route that
14
+ // MEANS "that work is genuinely gone". /tasks/:id/abandon would close the question
15
+ // without recording which answer was given, which is the one thing this feature
16
+ // exists to produce. So: the permission is reused, the verb is not.
17
+ //
18
+ // WHY /removal-proposals AND NOT /tasks/removal-proposals. Express matches in
19
+ // registration order and the `tasks` route file — which declares `/tasks/:id` — is
20
+ // mounted first by the manifest. A literal single segment under /tasks declared in
21
+ // a later file is unreachable: it would arrive as /tasks/:id with id='removal-
22
+ // proposals' and 400 on parseId. The same trap modules/ideas/routes/inbox.js
23
+ // documents for /inbox/awaiting-nod, avoided by not sharing the prefix at all;
24
+ // /override-requests in tasks.js is the existing precedent for a top-level noun.
25
+ //
26
+ // The route is only the doorway; ../removal-proposals.js owns the SQL and the
27
+ // transaction. Keep it that way — this file must never grow a query.
28
+ //
29
+ // MODULE BOUNDARY: reaches core only through the published doorway
30
+ // `../../../src/module-api`; `../removal-proposals` is an intra-module sibling.
31
+
32
+ const express = require('express');
33
+ const api = require('../../../src/module-api');
34
+ const proposals = require('../removal-proposals');
35
+
36
+ const log = api.logger('lifecycle');
37
+
38
+ // The floor: `task.promote` is Metic+ and is the atom the nod already carries,
39
+ // which is what makes 'the same floor as the nod' a fact about the gate rather
40
+ // than a claim in a comment.
41
+ //
42
+ // WRITTEN AS A LITERAL AT EACH GATE, not lifted into a const, and that is not an
43
+ // oversight: the route-rank auditor (src/bongos/route-rank-check.js) reads the
44
+ // atom out of the middleware list with a regex and classifies an unresolvable
45
+ // gate as 'unknown' — which FAILS CLOSED and hard-fails CI for a write route.
46
+ // A gate a machine cannot read is a gate nobody can audit.
47
+
48
+ module.exports = function buildRemovalProposalsRouter() {
49
+ const router = express.Router();
50
+
51
+ // GET /removal-proposals — the removal questions nobody has answered yet.
52
+ //
53
+ // rank: metic+archon (task.promote). No client-side rank gate is needed or wanted in the
54
+ // hall: a non-triager's fetch 403s and the slice renders nothing. One gate, on
55
+ // the server, beats a client check that can disagree with it.
56
+ router.get('/removal-proposals', api.requireBuilder, api.requirePermission('task.promote'), async (req, res) => {
57
+ try {
58
+ const { limit } = api.parsePagination(req, { defaultLimit: 20, maxLimit: proposals.MAX_PROPOSAL_ROWS });
59
+ const [pending, count] = await Promise.all([
60
+ proposals.listPending({ limit }),
61
+ proposals.countPending(),
62
+ ]);
63
+ // A queue read is a "what is still open" question; a cached one is wrong the
64
+ // moment somebody answers a row. Same posture as GET /inbox/rotting.
65
+ res.set('Cache-Control', 'no-store');
66
+ res.json({ pending, count });
67
+ } catch (err) {
68
+ log.error(`GET /removal-proposals: ${err && err.message}`);
69
+ res.fail('removal_proposals_read_failed', { status: 500, message: 'internal error' });
70
+ }
71
+ });
72
+
73
+ // POST /removal-proposals/:id/decide — confirm or dismiss ONE proposal.
74
+ //
75
+ // ONE ROUTE WITH AN ENUM rather than /confirm and /dismiss, because the two
76
+ // answers are the same act with opposite content and splitting them would invite
77
+ // the two halves to drift in what they close, what they record and what they
78
+ // refuse. The enum is validated against an allow-list in the domain layer and by
79
+ // the CHECK in lifecycle_009 — neither admits a value the other does not.
80
+ //
81
+ // rank: metic+archon (task.promote), the same atom as the read above.
82
+ router.post('/removal-proposals/:id/decide', api.requireBuilder, api.requirePermission('task.promote'), async (req, res) => {
83
+ // The enum is read from the domain's own allow-list, never re-typed here, so
84
+ // the request gate and the CHECK in lifecycle_009 cannot disagree about what a
85
+ // verdict is. The domain re-checks it too — it is callable without this route
86
+ // — and that second check is pinned by its own test rather than through here,
87
+ // because a branch only reachable past this gate would be covered in name only.
88
+ if (api.validateOrRespond(req, res, {
89
+ verdict: { type: 'string', required: true, enum: proposals.VERDICTS },
90
+ note: { type: 'string', maxLength: 2000 },
91
+ })) return;
92
+ const id = api.parseId(req, res, { code: 'bad_task_id', positiveInt: true });
93
+ if (id === null) return;
94
+ try {
95
+ const decided = await proposals.recordVerdict({
96
+ proposalTaskId: id,
97
+ verdict: req.body.verdict,
98
+ note: typeof req.body.note === 'string' ? req.body.note : null,
99
+ builderId: Number(req.builder.id),
100
+ });
101
+ res.json({ ok: true, ...decided });
102
+ } catch (err) {
103
+ // Each refusal keeps its own code and status: a caller that cannot tell
104
+ // "already answered" from "not a proposal" cannot say anything useful to the
105
+ // person who pressed the button.
106
+ if (err.code === 'BAD_VERDICT') {
107
+ return res.fail('bad_verdict', {
108
+ status: 400,
109
+ message: `verdict must be one of ${proposals.VERDICTS.join(', ')}`,
110
+ details: { allowed: proposals.VERDICTS },
111
+ });
112
+ }
113
+ if (err.code === 'NOT_FOUND') return res.fail('task_not_found', 404);
114
+ if (err.code === 'NOT_A_PROPOSAL') {
115
+ return res.fail('not_a_removal_proposal', {
116
+ status: 409,
117
+ message: `task ${id} was not filed by the removal cascade — there is no removal question on it to answer`,
118
+ });
119
+ }
120
+ if (err.code === 'ALREADY_DECIDED') {
121
+ return res.fail('removal_already_decided', {
122
+ status: 409,
123
+ message: err.message,
124
+ details: { existing: err.existing || null },
125
+ });
126
+ }
127
+ if (err.code === 'PROPOSAL_CLOSED') {
128
+ return res.fail('removal_proposal_closed', {
129
+ status: 409,
130
+ message: err.message,
131
+ details: { status: err.status || null },
132
+ });
133
+ }
134
+ log.error(`POST /removal-proposals/:id/decide: ${err && err.message}`);
135
+ res.fail('removal_verdict_failed', { status: 500, message: 'internal error' });
136
+ }
137
+ });
138
+
139
+ return router;
140
+ };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.918",
3
+ "version": "1.19.919",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.918",
9
+ "version": "1.19.919",
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.918",
3
+ "version": "1.19.919",
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",
@@ -31,6 +31,12 @@ const SNAPSHOTS = [
31
31
  ['leaderboard', 'leaderboard'],
32
32
  ['inbox', 'inbox', trimList('ideas', 25)],
33
33
  ['inbox/awaiting-nod?limit=20', 'inbox__awaiting-nod', trimList('awaiting', 25)],
34
+ // The attention card's removal-proposal slice (task 1004095). Snapshotted like
35
+ // the nod queue beside it — and, like it, the LIVE answer is usually empty, so
36
+ // the checked-in fixture is hand-written rows rather than whatever this run
37
+ // happened to catch. A slice that only ever renders its empty state cannot be
38
+ // looked at, which is this harness's whole job.
39
+ ['removal-proposals', 'removal-proposals', trimList('pending', 25)],
34
40
  ['goals', 'goals'],
35
41
  ['goals/inbox', 'goals__inbox'],
36
42
  // goal details: a handful of REAL ids (exact-match fixtures) + goal 25
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.918'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.919'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -57,8 +57,22 @@ test('the blocker row carries .ginbox__item, so the card\'s count-pill and visib
57
57
  assert.match(GOAL_INBOX_SRC, /function blockerRowHtml[\s\S]*?class="ginbox__item"/);
58
58
  });
59
59
 
60
- test('the mount() boot sequence actually calls loadBlockers()', () => {
61
- assert.match(GOAL_INBOX_SRC, /loadNodQueue\(\);\s*\n\s*loadBlockers\(\);\s*\n\s*loadRot\(\);/);
60
+ test('the mount() boot sequence actually calls loadBlockers(), in its place in the order', () => {
61
+ // WHAT THIS PINS IS THE CALL AND ITS PLACE, not the three lines that used to
62
+ // sit adjacent. The original regex required loadBlockers() to be sandwiched
63
+ // between loadNodQueue() and loadRot() with nothing between them, so adding a
64
+ // FOURTH slice to the card (task 1004095's removal proposals) redded a test
65
+ // whose real invariant — the blockers feed is booted, after the nod and before
66
+ // rot — still held exactly. A slice is expected to be added here; the ordering
67
+ // rule the card's own comments state is what must not change.
68
+ const boot = /host\.addEventListener\('click', onClick\);([\s\S]*?)window\.OTBProfileNudge/.exec(GOAL_INBOX_SRC);
69
+ assert.ok(boot, 'the mount() boot block must be findable');
70
+ const at = (name) => boot[1].indexOf(`${name}();`);
71
+ assert.ok(at('loadBlockers') !== -1, 'mount() must actually call loadBlockers()');
72
+ assert.ok(at('loadNodQueue') !== -1 && at('loadNodQueue') < at('loadBlockers'),
73
+ 'the nod queue boots before blockers');
74
+ assert.ok(at('loadRot') !== -1 && at('loadBlockers') < at('loadRot'),
75
+ 'blockers boot before rot — rot is the standing question that sits last');
62
76
  });
63
77
 
64
78
  // ---- the full queue page (blockers.html + blockers.js) ---------------------