@bongos/core 1.19.918 → 1.19.920
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 +88 -58
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +6 -0
- package/clients/bongos-client/index.cjs +6 -0
- package/clients/bongos-client/index.d.ts +8 -0
- package/clients/bongos-client/index.mjs +6 -0
- package/docs/adr/0319-a-removal-verdict-is-a-row-beside-the-question.md +69 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +134 -3
- package/docs/api-reference.md +8 -1
- package/docs/copy-inventory.md +15 -11
- package/docs/copy-registry.json +48 -12
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/goal-inbox.js +102 -0
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +1 -1
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/people.html +1 -1
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings.html +1 -1
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/style.css +12 -0
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- 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/routes/me.js +9 -1
- package/src/module-api.js +1 -1
- package/tests/blockers_hall.mjs +16 -2
- package/tests/core_update_banner.mjs +28 -0
- package/tests/nav_permission_atoms.mjs +110 -0
- package/tests/removal_verdict.mjs +311 -0
|
@@ -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.920",
|
|
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.920",
|
|
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.920",
|
|
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/bongos/routes/me.js
CHANGED
|
@@ -119,7 +119,15 @@ const discordIsLinked = (builderId) =>
|
|
|
119
119
|
// COSMETIC ONLY (ADR 0016). This decides who is OFFERED a link. The page gate
|
|
120
120
|
// (auth.js PAGE_GATES) and each data route's requirePermission are the wall, and
|
|
121
121
|
// they run regardless of what this says.
|
|
122
|
-
|
|
122
|
+
// `core.pin.move` (task 1004118). The core-update banner in shell.js gates on it,
|
|
123
|
+
// and task 1004045 shipped that gate WITHOUT adding the atom here — so the value was
|
|
124
|
+
// `undefined` for every builder, the guard always bailed, and the banner could not
|
|
125
|
+
// render for anyone, on any instance. The authority was never the problem: the same
|
|
126
|
+
// account that saw no banner is answered 200 by a `core.pin.move`-gated route. Only
|
|
127
|
+
// this projection omitted it. Adding an atom here is the deliberate act this comment
|
|
128
|
+
// already described; tests/nav_permission_atoms.mjs now fails if shell.js gates on
|
|
129
|
+
// one that is missing, so the act cannot be skipped again.
|
|
130
|
+
const NAV_PERMISSION_ATOMS = Object.freeze(['board.vote.cast', 'core.pin.move']);
|
|
123
131
|
|
|
124
132
|
// navPermissionsFor(builderId) → { '<atom>': boolean } over NAV_PERMISSION_ATOMS,
|
|
125
133
|
// or null when authority could not be established (government off, port missing,
|
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.920'; // 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) ---------------------
|
|
@@ -306,3 +306,31 @@ test('the banner is asked for ONCE, not on every access settle', async () => {
|
|
|
306
306
|
const first = b.requested.filter((r) => r.endsWith('/core-update')).length;
|
|
307
307
|
assert.equal(first, 1);
|
|
308
308
|
});
|
|
309
|
+
|
|
310
|
+
// ── the chain, with the map the SERVER would really send (task 1004118) ───────
|
|
311
|
+
//
|
|
312
|
+
// Every case above hands applyAccess a permissions map written here. That is exactly
|
|
313
|
+
// how task 1004045's banner shipped dead: it gated on `core.pin.move`, /me resolved
|
|
314
|
+
// only NAV_PERMISSION_ATOMS, the atom was not in it, and the guard bailed for every
|
|
315
|
+
// builder on every instance — while this suite stayed green throughout.
|
|
316
|
+
//
|
|
317
|
+
// So this case does NOT write the map. It DERIVES it from me.js's own declaration,
|
|
318
|
+
// the way the server does, and grants everything in it. If the atom the banner needs
|
|
319
|
+
// is not part of that projection, there is nothing here to grant and the banner
|
|
320
|
+
// cannot render — which is the real bug, reproduced as a failure.
|
|
321
|
+
test('the banner renders from the projection /me ACTUALLY sends, not one written by this test', async () => {
|
|
322
|
+
const meSrc = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/me.js'), 'utf8');
|
|
323
|
+
const m = meSrc.match(/NAV_PERMISSION_ATOMS = Object\.freeze\(\[([^\]]*)\]\)/);
|
|
324
|
+
assert.ok(m, 'NAV_PERMISSION_ATOMS is readable');
|
|
325
|
+
const atoms = m[1].split(',').map((x) => x.trim().replace(/^'|'$/g, '')).filter(Boolean);
|
|
326
|
+
// A builder who holds everything the projection can report — the most generous real
|
|
327
|
+
// answer the server is capable of giving.
|
|
328
|
+
const permissions = Object.fromEntries(atoms.map((a) => [a, true]));
|
|
329
|
+
const b = boot({ permissions, update: BEHIND });
|
|
330
|
+
await new Promise((r) => setTimeout(r, 0));
|
|
331
|
+
assert.ok(
|
|
332
|
+
b.banner(),
|
|
333
|
+
'the banner did not render for a builder holding EVERY atom /me can report — so the atom it ' +
|
|
334
|
+
'gates on is outside NAV_PERMISSION_ATOMS and the feature is dead for everyone (task 1004118)',
|
|
335
|
+
);
|
|
336
|
+
});
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
// tests/nav_permission_atoms.mjs — the hall shell may only gate on a permission the
|
|
2
|
+
// server actually reports, and shell.js may not change behind its own cache-bust
|
|
3
|
+
// (task 1004118).
|
|
4
|
+
//
|
|
5
|
+
// WHY THIS FILE EXISTS. Task 1004045 added a banner gated on `core.pin.move`, and
|
|
6
|
+
// `/me` resolves ONLY the keys in NAV_PERMISSION_ATOMS — which did not include it. The
|
|
7
|
+
// value was `undefined` for every builder, the guard always bailed, and the banner
|
|
8
|
+
// could not render for ANYONE, on any instance. Its own test suite passed throughout,
|
|
9
|
+
// because every case called applyAccess({ permissions: { 'core.pin.move': true } }) —
|
|
10
|
+
// injecting the very map whose real shape was the defect. A renderer proven correct
|
|
11
|
+
// against an input the server never produces is proven of nothing.
|
|
12
|
+
//
|
|
13
|
+
// So this asserts the CONTRACT BETWEEN TWO FILES, which is the only place the bug was
|
|
14
|
+
// visible, and it reads both rather than trusting either.
|
|
15
|
+
import assert from 'node:assert/strict';
|
|
16
|
+
import { test } from 'node:test';
|
|
17
|
+
import fs from 'node:fs';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import crypto from 'node:crypto';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
21
|
+
|
|
22
|
+
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
23
|
+
const SHELL_PATH = path.join(ROOT, 'modules/hall-ui/public/shell.js');
|
|
24
|
+
const SHELL = fs.readFileSync(SHELL_PATH, 'utf8');
|
|
25
|
+
const ME = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/me.js'), 'utf8');
|
|
26
|
+
|
|
27
|
+
// The atoms /me will actually resolve, read out of its own declaration.
|
|
28
|
+
function navAtoms() {
|
|
29
|
+
const m = ME.match(/NAV_PERMISSION_ATOMS = Object\.freeze\(\[([^\]]*)\]\)/);
|
|
30
|
+
assert.ok(m, 'NAV_PERMISSION_ATOMS is declared where this test can read it');
|
|
31
|
+
return m[1].split(',').map((s) => s.trim().replace(/^'|'$/g, '')).filter(Boolean);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Every atom the shell gates on, in BOTH the shapes it uses: a nav item's
|
|
35
|
+
// `gate: 'perm:<atom>'`, and a direct read of the resolved map.
|
|
36
|
+
function shellAtoms() {
|
|
37
|
+
const out = new Set();
|
|
38
|
+
for (const m of SHELL.matchAll(/gate: *'perm:([^']+)'/g)) out.add(m[1]);
|
|
39
|
+
for (const m of SHELL.matchAll(/perms\['([^']+)'\]/g)) out.add(m[1]);
|
|
40
|
+
for (const m of SHELL.matchAll(/permissions\['([^']+)'\]/g)) out.add(m[1]);
|
|
41
|
+
return [...out];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
test('every permission the shell gates on is one /me actually reports', () => {
|
|
45
|
+
const served = navAtoms();
|
|
46
|
+
const needed = shellAtoms();
|
|
47
|
+
assert.ok(needed.length, 'the shell gates on at least one atom (else this test is vacuous)');
|
|
48
|
+
for (const atom of needed) {
|
|
49
|
+
assert.ok(
|
|
50
|
+
served.includes(atom),
|
|
51
|
+
`shell.js gates on '${atom}' but /me does not resolve it — NAV_PERMISSION_ATOMS carries [${served.join(', ')}]. ` +
|
|
52
|
+
'A gate on an atom the server never sends is not a gate, it is an OFF switch: the value is undefined for ' +
|
|
53
|
+
"every builder, so the feature is dead for everyone. Add the atom to NAV_PERMISSION_ATOMS in src/bongos/routes/me.js.",
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('the shell gates on nothing the catalog does not define', () => {
|
|
59
|
+
// The other direction: a typo'd atom would sit in NAV_PERMISSION_ATOMS resolving
|
|
60
|
+
// false forever, which looks exactly like "you do not hold it".
|
|
61
|
+
const catalog = fs.readFileSync(path.join(ROOT, 'modules/government/catalog.js'), 'utf8');
|
|
62
|
+
for (const atom of shellAtoms()) {
|
|
63
|
+
assert.ok(catalog.includes(`'${atom}'`), `'${atom}' is a real permission in the government catalog`);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('the hall-preview fixture carries every atom /me resolves', () => {
|
|
68
|
+
// modules/hall-ui/CLAUDE.md makes this a required step — "add the key to
|
|
69
|
+
// scripts/hall-preview/fixtures/me.json when you add an atom, or the harness renders
|
|
70
|
+
// the item's absent state forever" — and nothing enforced it, so this task missed it
|
|
71
|
+
// exactly as the doc predicted. A design harness that silently shows the wrong state
|
|
72
|
+
// is worse than one that is obviously broken, because it is trusted.
|
|
73
|
+
const fixture = JSON.parse(fs.readFileSync(path.join(ROOT, 'scripts/hall-preview/fixtures/me.json'), 'utf8'));
|
|
74
|
+
const perms = fixture.permissions || {};
|
|
75
|
+
for (const atom of navAtoms()) {
|
|
76
|
+
assert.ok(
|
|
77
|
+
atom in perms,
|
|
78
|
+
`'${atom}' is resolved by /me but missing from the hall-preview fixture — the harness will render its ` +
|
|
79
|
+
'absent state forever. Add it to scripts/hall-preview/fixtures/me.json.',
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// ── the cache-bust ────────────────────────────────────────────────────────────
|
|
85
|
+
//
|
|
86
|
+
// shell.js is requested with a `?v=` query by every hall page. Task 1004045 changed
|
|
87
|
+
// the file and left the query alone, so a returning browser kept the cached copy —
|
|
88
|
+
// a fix that reaches nobody looks identical to a fix that does not work.
|
|
89
|
+
|
|
90
|
+
const SHELL_SHA = 'd91b8e5fad83c8e2';
|
|
91
|
+
|
|
92
|
+
test('shell.js has not changed without its cache-bust being bumped', () => {
|
|
93
|
+
const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);
|
|
94
|
+
assert.equal(
|
|
95
|
+
actual, SHELL_SHA,
|
|
96
|
+
`shell.js changed. Bump the ?v= on every hall page that loads it, then set SHELL_SHA in this file to '${actual}'. ` +
|
|
97
|
+
'Without the bump the edit reaches only first-time visitors.',
|
|
98
|
+
);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test('every hall page busts shell.js with the SAME string', () => {
|
|
102
|
+
// Divergent strings are how one of them goes stale: three different queries for one
|
|
103
|
+
// file were live before this task, and only the majority ever got bumped.
|
|
104
|
+
const dir = path.join(ROOT, 'modules/hall-ui/public');
|
|
105
|
+
const versions = new Set();
|
|
106
|
+
for (const f of fs.readdirSync(dir).filter((f) => f.endsWith('.html'))) {
|
|
107
|
+
for (const m of fs.readFileSync(path.join(dir, f), 'utf8').matchAll(/shell\.js\?v=([^"]+)/g)) versions.add(m[1]);
|
|
108
|
+
}
|
|
109
|
+
assert.equal(versions.size, 1, `one cache-bust for one file — found: [${[...versions].join(', ')}]`);
|
|
110
|
+
});
|