@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.
@@ -0,0 +1,111 @@
1
+ -- lifecycle_009_removal_verdicts.sql — the ANSWER to a removal proposal
2
+ -- (task 1004095 / BV2.R16, goal 1000095; ADR 0313 + ADR 0319).
3
+ --
4
+ -- WHY A TABLE AT ALL, when the proposal is already a task row. Because the two
5
+ -- answers are otherwise indistinguishable. A removal proposal is a question filed
6
+ -- at `backlog` by the `removal-proposed-on-ship` cascade (task 1004090); answering
7
+ -- it either way leaves nothing to build, so either way the task ends `abandoned`.
8
+ -- "Yes, that work is gone" and "no, that was a refactor" would then be the SAME
9
+ -- row, and brightness — the live share of a descent, the next task in this chain —
10
+ -- would have no way to tell the star it must dim from the one it must not. The
11
+ -- verdict is the fact the whole feature exists to produce; it cannot live in a
12
+ -- status that has one spelling for both answers.
13
+ --
14
+ -- WHY NOT A NEW TASK STATUS. ADR 0313 rejected exactly that: `shipped` is terminal
15
+ -- by design and much of the ledger leans on it, so "a removal is a fact about
16
+ -- work's fate, recorded BESIDE it, not a new terminal state that ship rollup would
17
+ -- have to learn". This table is that beside.
18
+ --
19
+ -- WHAT IS DELIBERATELY NOT HERE: the list of tasks whose work was removed. The
20
+ -- cascade files one task per rule per ship with no field for a target (ADR 0242
21
+ -- forbids widening the runner to fan out), so that list travels as prose in the
22
+ -- proposal's body — the owner's decision of 2026-09-20, recorded in
23
+ -- cascade.js:removalProposalBody. Brightness designs its own storage for targets
24
+ -- when it needs one. What this table owes brightness is the VERDICT and a
25
+ -- structured handle on the ship it was about; both are here, and neither is prose.
26
+ --
27
+ -- MODULE-OWNED and `lifecycle_`-prefixed so fitness check 11 permits it (ADR 0083
28
+ -- §Decision #5) — the lifecycle_004..008 convention. It only REFERENCES the core
29
+ -- `tasks` / `builders` tables; a module migration may not ALTER them.
30
+ --
31
+ -- Module migrations are ADDITIVE and NAMESPACED: no down-migration, idempotent
32
+ -- (safe to re-run).
33
+
34
+ BEGIN;
35
+
36
+ CREATE TABLE IF NOT EXISTS lifecycle_removal_verdicts (
37
+ -- The PROPOSAL task — the question that was answered. PRIMARY KEY, not just an
38
+ -- index: one proposal has exactly one answer, ever. A second decide is refused
39
+ -- at the route rather than upserted, because silently overwriting a person's
40
+ -- verdict would lose the only record that they gave a different one.
41
+ proposal_task_id bigint PRIMARY KEY REFERENCES tasks(id) ON DELETE CASCADE,
42
+
43
+ -- The SHIP the proposal was about — the task whose merge deleted the files.
44
+ -- Denormalised out of the proposal's `source_ref`, which encodes it as
45
+ -- `cascade-removal-proposed-on-ship-<id>`: a string nothing can join on, the
46
+ -- same complaint ideas_007 makes about the same field. Nullable because a
47
+ -- proposal whose ref cannot be parsed must still be answerable — the verdict is
48
+ -- the point, and refusing to record it over a malformed ref would be the tail
49
+ -- wagging the dog.
50
+ ship_task_id bigint REFERENCES tasks(id) ON DELETE SET NULL,
51
+
52
+ -- The answer. An ALLOW-LIST, not a deny-list: a value added to the vocabulary
53
+ -- later has to be added here too, rather than being admitted by default.
54
+ -- 'confirmed' — the work is genuinely gone. This is what brightness reads.
55
+ -- 'dismissed' — a refactor, a move the rename detection missed, or work
56
+ -- already superseded. Nothing dims.
57
+ verdict text NOT NULL CHECK (verdict IN ('confirmed', 'dismissed')),
58
+
59
+ -- WHO answered, and WHEN. A verdict with no author is a rumour: this is the
60
+ -- record that a PERSON decided, which is the entire difference between this
61
+ -- design and the derived-only one ADR 0313 rejected. Nullable only so a
62
+ -- deleted builder does not take the verdict with them.
63
+ decided_by bigint REFERENCES builders(id) ON DELETE SET NULL,
64
+ decided_at timestamptz NOT NULL DEFAULT now(),
65
+
66
+ -- Optional free text. Not NOT NULL, unlike lifecycle_008's `reason`: there the
67
+ -- row exists only when somebody overrode a rule and the "why" is the whole
68
+ -- record, whereas here the common case is an obvious yes or no and demanding
69
+ -- prose for it would only breed empty ceremony.
70
+ note text
71
+ );
72
+
73
+ -- Brightness reads the CONFIRMED rows and only those, so the partial index is the
74
+ -- shape of the one hot query rather than a general-purpose one on a two-value
75
+ -- column, which would never be worth its write cost.
76
+ CREATE INDEX IF NOT EXISTS idx_lifecycle_removal_verdicts_confirmed
77
+ ON lifecycle_removal_verdicts (ship_task_id)
78
+ WHERE verdict = 'confirmed';
79
+
80
+ -- SAFETY NET — a migration that cannot prove its own effect must not report
81
+ -- success (the lifecycle_007 / ideas_007 precedent). Read-only, no DDL. If the
82
+ -- table came back without the single-column primary key, one proposal could carry
83
+ -- two contradictory verdicts with nothing to arbitrate; if the CHECK is missing,
84
+ -- a typo'd verdict would be stored and read as neither answer.
85
+ DO $$
86
+ DECLARE pk_cols text; has_verdict_check boolean;
87
+ BEGIN
88
+ SELECT string_agg(a.attname, ',' ORDER BY a.attname) INTO pk_cols
89
+ FROM pg_constraint c
90
+ JOIN unnest(c.conkey) AS k(attnum) ON true
91
+ JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum
92
+ WHERE c.conrelid = 'lifecycle_removal_verdicts'::regclass
93
+ AND c.contype = 'p';
94
+ IF pk_cols IS DISTINCT FROM 'proposal_task_id' THEN
95
+ RAISE EXCEPTION
96
+ 'lifecycle_009: lifecycle_removal_verdicts primary key is (%) — expected (proposal_task_id), so one proposal holds one verdict.', COALESCE(pk_cols, 'none');
97
+ END IF;
98
+
99
+ SELECT EXISTS (
100
+ SELECT 1 FROM pg_constraint
101
+ WHERE conrelid = 'lifecycle_removal_verdicts'::regclass
102
+ AND contype = 'c'
103
+ AND pg_get_constraintdef(oid) ILIKE '%verdict%'
104
+ ) INTO has_verdict_check;
105
+ IF NOT has_verdict_check THEN
106
+ RAISE EXCEPTION
107
+ 'lifecycle_009: the verdict CHECK is missing — an unrecognised verdict would store as neither answer.';
108
+ END IF;
109
+ END $$;
110
+
111
+ COMMIT;
@@ -26,7 +26,8 @@
26
26
  "kickoff-seed",
27
27
  "rot",
28
28
  "project-settings",
29
- "artist-gate"
29
+ "artist-gate",
30
+ "removal-proposals"
30
31
  ],
31
32
  "pollers": [
32
33
  "publish-reconciler"
@@ -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.917",
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.917",
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.917",
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
@@ -62,6 +62,14 @@ const MODULE_GLOBS = {
62
62
  // exploit-detail engagement-doc store (/security/docs). Stays kernel when the
63
63
  // red-team FEATURE carved to modules/security/ (BV1.R79 / 02-module-map §5).
64
64
  'src/bongos/routes/security.js',
65
+ // The GET /me AGGREGATOR (task 1004110, ADR 0318). A core facade, not a
66
+ // builder-settings file: it resolves TEN module ports (lifecycle, reward,
67
+ // onboarding, government, specialities, grade, box, art, discord) and
68
+ // builder-settings is merely one of them. It was listed under that module
69
+ // for task AFFINITY, and the affinity listing alone made the whole module
70
+ // protected - ADR 0314 in a second costume. Ownership follows what the
71
+ // file IS, and what it is is the core read facade over ten modules.
72
+ 'src/bongos/routes/me.js',
65
73
  'server.js', 'config/',
66
74
 
67
75
  // ADOPTED (task 1004111): a protected path owned by no module is authorable
@@ -140,11 +148,13 @@ const MODULE_GLOBS = {
140
148
  // --- builder prefs (wandering/skill/sound/render) + builder-needs ---
141
149
  // Carved into modules/builder-settings/ (BV1.R80 / ADR 0093 §1): the five pref
142
150
  // libs + builder-needs + their db slice + the `/me/*-prefs` routes + the
143
- // `builder-settings` kernel PORT. The `GET /me` aggregator stays a thin core
144
- // facade (routes/me.js) resolving that port — listed for task affinity. The
145
- // pref-mirroring CLIs travel with the domain but stay under scripts/.
151
+ // `builder-settings` kernel PORT. The `GET /me` aggregator is NOT here: it
152
+ // stays a thin core facade resolving that port, and since task 1004110
153
+ // (ADR 0318) it is owned by KERNEL. Listing it here for task AFFINITY was
154
+ // the single glob that made this whole module protected, on a file the
155
+ // comment itself said was core. The pref-mirroring CLIs travel with the
156
+ // domain but stay under scripts/.
146
157
  'builder-settings': [
147
- 'src/bongos/routes/me.js',
148
158
  'scripts/gds/fetch-sound-prefs.js',
149
159
  'modules/builder-settings/',
150
160
  ],
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.917'; // 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) ---------------------