@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
|
@@ -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;
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
|
144
|
-
// facade
|
|
145
|
-
//
|
|
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.
|
|
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');
|
package/tests/blockers_hall.mjs
CHANGED
|
@@ -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
|
-
|
|
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) ---------------------
|