@bongos/core 1.19.916 → 1.19.918

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.
@@ -17,7 +17,7 @@
17
17
  // shipped task looks like THIS, file THAT task, for THAT discipline. There is no
18
18
  // condition language, no stored rules, no per-project configuration and no UI —
19
19
  // every one of those is a thing to add when a second and third cascade exist and
20
- // have shown what they have in common. Today `CASCADES` ships EMPTY: the
20
+ // have shown what they have in common. `CASCADES` no longer ships empty: the
21
21
  // mechanism, its idempotency and its non-blocking property are proved by the
22
22
  // tests' own declarations, and R04 (task 1003115) writes the first production
23
23
  // entry. A mechanism whose first real rule is written by the task that needs it
@@ -169,6 +169,50 @@ function artistReviewBody(task) {
169
169
  ].join('\n');
170
170
  }
171
171
 
172
+ // The removal proposal's body (task 1004090 / BV2.R15, ADR 0313).
173
+ //
174
+ // IT ASKS, IT DOES NOT TELL. Every line here is written so a reader confirms or
175
+ // dismisses on the evidence rather than rubber-stamping a verdict — because the
176
+ // owner chose propose-then-confirm precisely for the cases this derivation cannot
177
+ // tell apart from a real removal. The three it CAN tell apart (a rename, a move,
178
+ // a rewrite in place) are already excluded upstream in removal-derivation.js.
179
+ //
180
+ // The affected tasks and paths are named as PROSE, deliberately. A cascade files
181
+ // one task per rule per ship keyed on (rule, ship), with no field for a target,
182
+ // and R05's runner may not be widened to fan out (ADR 0242). Owner decision
183
+ // 2026-09-20: the body carries them and R20 designs its own storage when it needs
184
+ // one. So this text IS the record until then — keep it readable and keep it exact.
185
+ function removalProposalBody(task, ctx) {
186
+ const found = ((ctx || {}).removals) || [];
187
+ const lines = [];
188
+ lines.push(`Shipping task ${task.id} deleted files that earlier SHIPPED work put there.`);
189
+ lines.push('');
190
+ lines.push('**This is a question, not a verdict.** Nothing has been marked removed, no');
191
+ lines.push('status changed and no credit moved — and none of that happens unless a person');
192
+ lines.push('confirms below. `shipped` is terminal and stays terminal.');
193
+ lines.push('');
194
+ lines.push('**Removed work attributed to:**');
195
+ for (const r of found) {
196
+ lines.push(` - task ${r.taskId}${r.title ? ` — ${r.title}` : ''}`);
197
+ for (const path of r.paths) lines.push(` ${path}`);
198
+ }
199
+ lines.push('');
200
+ if ((ctx || {}).removalTruncated) {
201
+ lines.push(`Note: this ship deleted ${(ctx || {}).removalDeletedCount} paths, more than the`);
202
+ lines.push('attribution cap, so the list above is PARTIAL. A ship that large is usually a');
203
+ lines.push('move or a sweep — weigh that before confirming.');
204
+ lines.push('');
205
+ }
206
+ lines.push('**Confirm** if that work is genuinely gone: the ideas whose descent it belongs');
207
+ lines.push('to will dim accordingly. **Dismiss** if this was a refactor, a move the rename');
208
+ lines.push('detection missed, or a deletion of something already superseded — dismissing is');
209
+ lines.push('what stops the sky being dimmed for a reason that is not real.');
210
+ lines.push('');
211
+ lines.push('Attribution runs through `claims.published_head_sha`, so a deletion whose origin');
212
+ lines.push('predates that record is silently absent rather than guessed at.');
213
+ return lines.join('\n');
214
+ }
215
+
172
216
  // Production rules. The artist review is the first (R04); add another by
173
217
  // appending an entry, never by touching the runner (ADR 0242).
174
218
  const CASCADES = Object.freeze([
@@ -189,6 +233,25 @@ const CASCADES = Object.freeze([
189
233
  title: (t) => `Artist review: the copy and visuals that shipped in task ${t.id}`,
190
234
  body: artistReviewBody,
191
235
  }),
236
+ Object.freeze({
237
+ key: 'removal-proposed-on-ship',
238
+ // ENGINEER, not artist: confirming asks whether deleted code is genuinely
239
+ // gone or was moved, which is a question about the diff.
240
+ discipline: 'engineer',
241
+ kind: 'unclassified',
242
+ // The anti-recursion guard FIRST, the same order and for the same reason as
243
+ // the rule above — a cascade-generated task must never cascade.
244
+ //
245
+ // The candidates are resolved ONCE into ctx by runCascadesForShip, never
246
+ // here: a `when` is pure and synchronous by contract, and this derivation is
247
+ // a network read. An absent ctx therefore means "nothing was derived" and
248
+ // files nothing — the opposite default to the artist gate above, and
249
+ // deliberately so: silence about a removal must never be read as a removal.
250
+ when: (t, ctx) => t.source !== CASCADE_SOURCE
251
+ && Array.isArray((ctx || {}).removals) && (ctx || {}).removals.length > 0,
252
+ title: (t) => `Was this removed? Task ${t.id} deleted earlier shipped work`,
253
+ body: removalProposalBody,
254
+ }),
192
255
  ]);
193
256
 
194
257
  // THE TRIGGERS. A cascade reacts to a state transition; there are two, and this
@@ -319,7 +382,7 @@ function cascadesFor(task, rules = CASCADES, ctx = {}, trigger = TRIGGERS.ship)
319
382
  }
320
383
  if (!hit) continue;
321
384
  try {
322
- matched.push({ rule, spec: cascadeTaskSpec(rule, task, trigger) });
385
+ matched.push({ rule, spec: cascadeTaskSpec(rule, task, trigger, ctx) });
323
386
  } catch (e) {
324
387
  errors.push(`cascade "${rule.key}": title/body threw (${e && e.message})`);
325
388
  }
@@ -329,8 +392,11 @@ function cascadesFor(task, rules = CASCADES, ctx = {}, trigger = TRIGGERS.ship)
329
392
 
330
393
  // The createTask payload for one matched rule. The attribution preamble is
331
394
  // prepended here rather than left to each rule, so no rule can forget it.
332
- function cascadeTaskSpec(rule, task, trigger = TRIGGERS.ship) {
333
- const body = String(rule.body(task) || '').trim();
395
+ // `ctx` reaches body() as a second argument, mirroring when(). The artist rule
396
+ // ignores it; the removal rule needs it, because what a ship deleted is derived
397
+ // per run and is not a property of the task row.
398
+ function cascadeTaskSpec(rule, task, trigger = TRIGGERS.ship, ctx = {}) {
399
+ const body = String(rule.body(task, ctx) || '').trim();
334
400
  const preamble =
335
401
  `Filed automatically when ${trigger.happened(task)} — the "${rule.key}" cascade `
336
402
  + `(modules/lifecycle/cascade.js). Nobody typed this; it is a REQUEST, filed at backlog, `
@@ -466,6 +532,27 @@ async function runCascadesForShip(shipped, deps = {}) {
466
532
  try { ctx.artistGate = deps.artistGateMode(); }
467
533
  catch (e) { report.errors.push(`cascade: artistGateMode() threw (${e && e.message}) — policy unread`); }
468
534
  }
535
+ // WHAT THIS SHIP DELETED (task 1004090 / BV2.R15, ADR 0313). Resolved here
536
+ // rather than in the rule for the reason the rule's own comment gives: a
537
+ // `when` is pure and synchronous, and this is a network read.
538
+ //
539
+ // Same posture as the policy read above — a resolver that throws is REPORTED
540
+ // and leaves the keys unset, so a derivation outage files no proposal and
541
+ // costs the ship nothing. `removalCandidates` already swallows its own
542
+ // failures (removal-derivation.js returns a report rather than rejecting);
543
+ // this try/catch is the belt to that braces, because a non-blocking promise
544
+ // is only non-blocking until someone edits it.
545
+ if (typeof deps.removalCandidates === 'function') {
546
+ try {
547
+ const derived = await deps.removalCandidates(task);
548
+ ctx.removals = (derived && derived.removals) || [];
549
+ ctx.removalTruncated = Boolean(derived && derived.truncated);
550
+ ctx.removalDeletedCount = (derived && derived.deletedCount) || 0;
551
+ for (const e of (derived && derived.errors) || []) report.errors.push(e);
552
+ } catch (e) {
553
+ report.errors.push(`cascade: removalCandidates() threw (${e && e.message}) — no removal derived`);
554
+ }
555
+ }
469
556
 
470
557
  const { matched, errors } = cascadesFor(task, deps.rules || CASCADES, ctx);
471
558
  report.errors.push(...errors);
@@ -480,6 +567,7 @@ async function runCascadesForShip(shipped, deps = {}) {
480
567
 
481
568
  module.exports = {
482
569
  CASCADES,
570
+ removalProposalBody,
483
571
  IDEA_CASCADES,
484
572
  TRIGGERS,
485
573
  fullIdeaScopingBody,
@@ -761,6 +761,48 @@ async function getClaimPublishedSha(taskId) {
761
761
  return rows[0]?.published_head_sha ?? null;
762
762
  }
763
763
 
764
+ // shippedTasksByPublishedSha — the reverse of getClaimPublishedSha: given commit
765
+ // shas, which SHIPPED tasks published them (BV2.R15, ADR 0313).
766
+ //
767
+ // This is how removal attribution answers "whose work was deleted". ADR 0313
768
+ // rejected the obvious alternative — parsing `(task NNNN)` out of commit messages —
769
+ // because only 7 of the last 15 commits on main carry a task id: merge commits and
770
+ // release carriers do not, and a convention that holds most of the time is not a
771
+ // basis for something that moves credits. `published_head_sha` is written by the
772
+ // SERVER at publish time and cannot be forgotten by an author.
773
+ //
774
+ // Only `shipped` rows come back, deliberately. Work that never landed cannot have
775
+ // been removed, and a proposal naming an abandoned task would be noise a person
776
+ // has to read and dismiss.
777
+ //
778
+ // INDEXED by core_252 (claims_published_head_sha_idx, partial on NOT NULL). This
779
+ // is the first reader to go BACKWARD along that column — every prior one reads
780
+ // forward by task_id — so before that index it planned a full scan of the
781
+ // claims table on the ship path, on a table that grows one row per claim
782
+ // forever.
783
+ //
784
+ // Returns a Map of sha -> { taskId, title }. Shas with no claim are simply absent:
785
+ // `published_head_sha` is nullable (rows predating migrations/171, or a publish
786
+ // that rode another branch), and ADR 0313 is explicit that an unattributable
787
+ // deletion files NOTHING. Silence is the correct output — a wrong removal proposal
788
+ // costs a person attention and dims a real star.
789
+ async function shippedTasksByPublishedSha(shas) {
790
+ const list = [...new Set((Array.isArray(shas) ? shas : [])
791
+ .filter((s) => typeof s === 'string' && /^[0-9a-f]{7,40}$/i.test(s)))];
792
+ if (!list.length) return new Map();
793
+ const { rows } = await pool.query(
794
+ `SELECT c.published_head_sha AS sha, t.id AS task_id, t.title
795
+ FROM claims c
796
+ JOIN tasks t ON t.id = c.task_id
797
+ WHERE c.published_head_sha = ANY($1::text[])
798
+ AND t.status = 'shipped'`,
799
+ [list]
800
+ );
801
+ const out = new Map();
802
+ for (const r of rows) out.set(r.sha, { taskId: String(r.task_id), title: r.title });
803
+ return out;
804
+ }
805
+
764
806
  // V3.R14 (#238) — onboarding observability roster.
765
807
  //
766
808
  // One row per builder, joined with the live "what are they doing right
@@ -1408,6 +1450,7 @@ module.exports = {
1408
1450
  getActiveClaimsForBuilderWithStale,
1409
1451
  getBuildersRoster,
1410
1452
  getClaimPublishedSha,
1453
+ shippedTasksByPublishedSha,
1411
1454
  listConfirmedTasksForReconcile,
1412
1455
  needsRebaseTasksForBuilder,
1413
1456
  gatingRebaseTasksForBuilder,
@@ -185,6 +185,7 @@ const {
185
185
  getActiveClaimsForBuilderWithStale,
186
186
  getBuildersRoster,
187
187
  getClaimPublishedSha,
188
+ shippedTasksByPublishedSha,
188
189
  listConfirmedTasksForReconcile,
189
190
  mergeTask,
190
191
  needsRebaseTasksForBuilder,
@@ -330,6 +331,7 @@ module.exports = {
330
331
  listConfirmedTasksForReconcile, // task 1134 — server-owned publish reconciler feed
331
332
  setClaimPublishedSha, // task 1547 — record the SERVER-pushed tip for per-task land proof
332
333
  getClaimPublishedSha, // task 1547 — read the published tip for the reconcile gate
334
+ shippedTasksByPublishedSha, // task 1004090 / BV2.R15 — the reverse read, for removal attribution
333
335
  getActiveClaimsForBuilder,
334
336
  moduleScopeForActiveClaims, // goal 1000051 / R92 — dev-box task-scoped source access (ADR 0148 §2)
335
337
  activeClaimCountForBuilder, // task 1002729 — distinguishes "no claim" from "claim with an empty goal wall"
@@ -0,0 +1,332 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/removal-derivation.js — WHAT A SHIP DELETED, AND WHOSE WORK
4
+ // IT WAS (task 1004090 / BV2.R15, goal 1000095; ADR 0313).
5
+ //
6
+ // Brightness (R20) is the live share of a descent, so something has to be able to
7
+ // say that shipped work was later removed. Nothing recorded that: `shipped` is
8
+ // terminal, there is no reverted status, and `tasks.merged_into_task_id` is
9
+ // CHECK-constrained to abandoned rows. This file derives the signal.
10
+ //
11
+ // It produces a QUESTION, never a verdict. The rule in ./cascade.js files a task
12
+ // at backlog; a person confirms it (R16). Nothing here changes a status, and
13
+ // nothing here touches credits — ADR 0313 is explicit that the ledger is never
14
+ // rewritten, because refactors, renames and file moves all look like deletions to
15
+ // a diff and a derived-only signal would dim the sky continuously.
16
+ //
17
+ // ── WHY NOT THE SIGNAL THAT ALREADY EXISTS ──────────────────────────────────
18
+ //
19
+ // `netNegativeBonusAmount` is the nearest existing thing and is unusable, for
20
+ // three reasons ADR 0313 sets out:
21
+ // • It has no TARGET. `removed > added` over a whole ship says that deletion
22
+ // happened, never what was deleted or whose work it was. Brightness needs the
23
+ // target; the boolean carries none.
24
+ // • It is CLIENT-SUPPLIED — it reads `signals.diff_stats` from the grade request
25
+ // body, and `grader-score.js` already documents defending against a forged
26
+ // `diff_stats`.
27
+ // • Brightness sits beside money. Deriving it from a forgeable, target-less
28
+ // counter would be wrong even where it was convenient.
29
+ //
30
+ // So the signal comes from the SERVER's own view of the merge instead. Landing is
31
+ // server-mediated (ADR 0055): the server holds the authority and can ask GitHub
32
+ // what the pull request actually changed, rather than be told by the box that
33
+ // built it.
34
+
35
+ const api = require('../../src/module-api');
36
+ const githubPush = require('./github-push');
37
+
38
+ // How many deleted paths one ship will be attributed — and therefore the HARD
39
+ // CEILING on GitHub calls this derivation can make per ship. Each path costs one
40
+ // commit-history request, and they run on the server's shared push credential,
41
+ // which the publish path itself depends on: spending that budget to answer a
42
+ // question nobody asked for would be a poor trade.
43
+ //
44
+ // A ship that removes more than this is a sweep — a directory move, a vendored
45
+ // drop, a module deletion — where the proposal is least useful and the cost is
46
+ // highest. Eight is deliberately small: a genuine "this ship removed someone's
47
+ // feature" is a handful of files, and beyond that the truncation note tells a
48
+ // reader to weigh the ship as a move rather than a removal.
49
+ //
50
+ // The cap is on the ATTRIBUTION, not the detection: the deletion count is still
51
+ // reported honestly so the proposal can say its list is partial.
52
+ const MAX_ATTRIBUTED_PATHS = 8;
53
+
54
+ // listCommitsForPath — the shas of the commits that touched ONE path, newest
55
+ // first. The read behind attribution: a deleted file is blamed back to the commit
56
+ // that introduced it, and that sha is resolved through `claims.published_head_sha`.
57
+ //
58
+ // IT LIVES HERE RATHER THAN IN github-push.js, which is where its siblings live,
59
+ // because that file sits ON the 1500-line ratchet ceiling — the same reason
60
+ // `pr-assign.js` was carved out of it, and this module's CLAUDE.md says so in as
61
+ // many words. Its only consumer is the derivation below.
62
+ //
63
+ // WHY THE API AND NOT `git log`. The server keeps no persistent clone — pushBundle
64
+ // works in a temp dir off a bundle and throws it away — so there is no working tree
65
+ // to blame in. GitHub holds the history. Same reason getPullRequestFiles reads the
66
+ // REST files API rather than diffing locally.
67
+ //
68
+ // `until` bounds the walk to commits at or before the merge being examined, so a
69
+ // LATER commit that re-added the path cannot be mistaken for the one that
70
+ // introduced it.
71
+ //
72
+ // Caps at ONE page of 100. Attribution only needs to find a published tip, and a
73
+ // path with more than 100 commits above the one that introduced it is old enough
74
+ // that the introducing claim almost certainly predates `published_head_sha`
75
+ // (migrations/171) — in which case the correct output is no attribution at all,
76
+ // which is exactly what an exhausted page yields. Paging further would spend rate
77
+ // limit to reach the same silence.
78
+ async function listCommitsForPath({ path: filePath, until = null }, deps = {}) {
79
+ // resolveToken is injectable for the same reason fetchImpl and repoInfo are:
80
+ // this function is the one place a rate-limit response is classified, and that
81
+ // classification has to be testable without a live credential.
82
+ const token = deps.resolveToken ? await deps.resolveToken(deps) : await githubPush.resolveToken(deps);
83
+ if (!token) throw new Error('PUSH_UNCONFIGURED');
84
+ if (typeof filePath !== 'string' || !filePath) throw new Error('BAD_PATH');
85
+ const info = deps.repoInfo || api.repoInfo;
86
+ if (!info || info.error || !info.owner || !info.repo) throw new Error('NO_REPO_INFO');
87
+ const doFetch = deps.fetchImpl || fetch;
88
+ const qs = new URLSearchParams({ path: filePath, per_page: '100' });
89
+ if (until) qs.set('until', String(until));
90
+ const res = await doFetch(
91
+ `https://api.github.com/repos/${info.owner}/${info.repo}/commits?${qs.toString()}`,
92
+ { headers: githubPush.ghHeaders(token) }
93
+ );
94
+ // A path that has never existed is a 404 and an EMPTY history, not an error: the
95
+ // caller asks "who introduced this", and "nobody we can see" is an answer.
96
+ if (res.status === 404) return [];
97
+ // RATE LIMITING IS NOT A PER-PATH FAILURE — it is a statement about the whole
98
+ // credential, so it must not be retried and must not be walked past. GitHub
99
+ // signals it as 403 or 429 with the remaining count at zero (a 403 for any
100
+ // other reason keeps a non-zero remaining, or sends no header at all, and is
101
+ // treated as an ordinary failure). Thrown with a recognisable name so the
102
+ // caller can ABANDON the rest of the walk rather than spend the next seven
103
+ // requests learning the same thing.
104
+ if (res.status === 429 || res.status === 403) {
105
+ const remaining = res.headers && typeof res.headers.get === 'function'
106
+ ? res.headers.get('x-ratelimit-remaining')
107
+ : null;
108
+ if (res.status === 429 || remaining === '0') {
109
+ const e = new Error('RATE_LIMITED');
110
+ e.rateLimited = true;
111
+ throw e;
112
+ }
113
+ }
114
+ if (!res.ok) throw new Error(`STATUS_FETCH_FAILED ${res.status}`);
115
+ const commits = await res.json();
116
+ if (!Array.isArray(commits)) return [];
117
+ return commits.map((c) => c && c.sha).filter((sha) => typeof sha === 'string' && sha);
118
+ }
119
+
120
+ // ---------------------------------------------------------------------------
121
+ // The pure half
122
+ // ---------------------------------------------------------------------------
123
+
124
+ // WHAT COUNTS AS REMOVED. ADR 0313 names three exclusions, and each is a status
125
+ // the REST files API already distinguishes — which is exactly why the derivation
126
+ // reads that API rather than counting lines:
127
+ //
128
+ // • 'renamed' — a rename or move is NOT a removal. Git's own rename detection
129
+ // resolves it and the work still stands. This is the exclusion
130
+ // that matters most: a directory reorganisation would otherwise
131
+ // propose a removal for every file in it.
132
+ // • 'modified' — a rewrite in place is NOT a removal. Lines changing is how
133
+ // code lives, and this is where a line-count heuristic
134
+ // (`removed > added`) goes wrong.
135
+ // • 'added' / 'copied' / 'changed' — nothing was lost.
136
+ //
137
+ // Only 'removed' survives. The third ADR exclusion — a cold body is not a remnant
138
+ // — is not expressible here: it is enforced downstream by attributing solely to
139
+ // tasks that actually SHIPPED (see shippedTasksByPublishedSha), because an idea
140
+ // declined at triage never ignited and nothing was built to lose.
141
+ //
142
+ // Pure: hand it the files array and it answers. No network, no clock, no DB.
143
+ function deletedPathsFrom(files) {
144
+ if (!Array.isArray(files)) return [];
145
+ const out = [];
146
+ for (const f of files) {
147
+ if (!f || typeof f !== 'object') continue;
148
+ // `deleted` is getPullRequestFiles' own mapping of status === 'removed'.
149
+ // Both are read, and they must AGREE: trusting `deleted` alone would follow a
150
+ // caller that set it by hand, and trusting `status` alone would miss a caller
151
+ // that shaped the row the other way. Where only one is present, it decides.
152
+ const status = typeof f.status === 'string' ? f.status.toLowerCase() : null;
153
+ const flagged = f.deleted === true;
154
+ const isRemoval = status ? status === 'removed' : flagged;
155
+ if (!isRemoval) continue;
156
+ if (typeof f.path !== 'string' || !f.path) continue;
157
+ out.push(f.path);
158
+ }
159
+ return [...new Set(out)];
160
+ }
161
+
162
+ // Group attributed paths by the task that published them, so the proposal reads
163
+ // as "this task lost these files" rather than a flat list a person has to sort.
164
+ // Pure. Insertion order is preserved, which keeps the rendered body stable for a
165
+ // given diff rather than reshuffling between runs.
166
+ function groupByTask(attributions) {
167
+ const byTask = new Map();
168
+ for (const a of Array.isArray(attributions) ? attributions : []) {
169
+ if (!a || a.taskId == null) continue;
170
+ const key = String(a.taskId);
171
+ if (!byTask.has(key)) byTask.set(key, { taskId: key, title: a.title || null, paths: [] });
172
+ const row = byTask.get(key);
173
+ if (a.path && !row.paths.includes(a.path)) row.paths.push(a.path);
174
+ }
175
+ return [...byTask.values()];
176
+ }
177
+
178
+ // ---------------------------------------------------------------------------
179
+ // The impure half — one network read per deleted path, then one DB read
180
+ // ---------------------------------------------------------------------------
181
+
182
+ // Resolve deleted paths to the shipped tasks that published them.
183
+ //
184
+ // For each path: ask GitHub which commits touched it up to the merge, then look
185
+ // those shas up against `claims.published_head_sha`. The OLDEST matching commit is
186
+ // taken as the one that introduced the file — the newest would attribute a file to
187
+ // whoever last edited it, which is a different and wrong question.
188
+ //
189
+ // `shipTaskId` is excluded: a ship that deletes its OWN earlier file is a builder
190
+ // tidying up inside one piece of work, not a removal of someone else's.
191
+ //
192
+ // Everything is best-effort per path. One path whose history cannot be read costs
193
+ // that path its attribution and nothing else — the other paths still resolve, and
194
+ // a total failure yields an empty list, which files no proposal at all.
195
+ async function attributeDeletions({ paths, shipTaskId, until = null }, deps = {}) {
196
+ const listCommits = deps.listCommitsForPath || listCommitsForPath;
197
+ const lookup = deps.shippedTasksByPublishedSha;
198
+ const errors = [];
199
+ if (typeof lookup !== 'function') return { attributions: [], errors: ['no sha lookup provided'] };
200
+
201
+ const wanted = (Array.isArray(paths) ? paths : []).slice(0, MAX_ATTRIBUTED_PATHS);
202
+ const perPath = [];
203
+ for (const path of wanted) {
204
+ try {
205
+ // Newest first from the API; reversed so the OLDEST visible commit wins.
206
+ const shas = await listCommits({ path, until });
207
+ perPath.push({ path, shas: [...(Array.isArray(shas) ? shas : [])].reverse() });
208
+ } catch (e) {
209
+ // A rate-limit refusal ends the WALK, not just this path. Continuing would
210
+ // spend the remaining budget on requests already known to fail, on the same
211
+ // credential the publish path needs — the opposite of backing off. There is
212
+ // deliberately no retry and no sleep: this runs on the ship path, and
213
+ // waiting there to dim a star later is the wrong trade (ADR 0313).
214
+ if (e && e.rateLimited) {
215
+ errors.push('removal: GitHub rate limit reached — attribution abandoned for this ship');
216
+ break;
217
+ }
218
+ errors.push(`removal: history for ${path} unreadable (${e && e.message})`);
219
+ }
220
+ }
221
+
222
+ // ONE database read for every path's shas together, rather than one per path.
223
+ let shaToTask;
224
+ try {
225
+ shaToTask = await lookup(perPath.flatMap((p) => p.shas));
226
+ } catch (e) {
227
+ return { attributions: [], errors: [...errors, `removal: sha lookup failed (${e && e.message})`] };
228
+ }
229
+ if (!(shaToTask instanceof Map)) return { attributions: [], errors: [...errors, 'removal: sha lookup returned no map'] };
230
+
231
+ const attributions = [];
232
+ for (const { path, shas } of perPath) {
233
+ for (const sha of shas) {
234
+ const hit = shaToTask.get(sha);
235
+ if (!hit) continue;
236
+ if (shipTaskId != null && String(hit.taskId) === String(shipTaskId)) break;
237
+ attributions.push({ path, taskId: hit.taskId, title: hit.title, sha });
238
+ break;
239
+ }
240
+ }
241
+ return { attributions, errors };
242
+ }
243
+
244
+ // THE WHOLE DERIVATION, as the one call the cascade context resolver makes.
245
+ //
246
+ // NON-BLOCKING IS THE CONTRACT, not a nicety. This runs on the path a ship
247
+ // already takes, and ADR 0313 says so in as many words: "a derivation that errors
248
+ // files nothing and never fails the merge. Landing work is more important than
249
+ // dimming a star on time." So every failure mode below returns a report rather
250
+ // than throwing, and the caller treats an empty `removals` as "file nothing".
251
+ async function deriveRemovalsForShip({ taskId, prNumber, until = null }, deps = {}) {
252
+ const empty = (errors) => ({ removals: [], deletedCount: 0, truncated: false, errors });
253
+ try {
254
+ if (prNumber == null) return empty(['removal: no pull request for this ship — nothing to read']);
255
+
256
+ const getFiles = deps.getPullRequestFiles
257
+ || ((args) => githubPush.getPullRequestFiles(args));
258
+
259
+ let files;
260
+ try {
261
+ files = await getFiles({ number: Number(prNumber) });
262
+ } catch (e) {
263
+ return empty([`removal: PR files unreadable (${e && e.message})`]);
264
+ }
265
+
266
+ const deleted = deletedPathsFrom(files);
267
+ if (!deleted.length) return empty([]);
268
+
269
+ const { attributions, errors } = await attributeDeletions(
270
+ { paths: deleted, shipTaskId: taskId, until },
271
+ deps
272
+ );
273
+
274
+ return {
275
+ removals: groupByTask(attributions),
276
+ deletedCount: deleted.length,
277
+ truncated: deleted.length > MAX_ATTRIBUTED_PATHS,
278
+ errors,
279
+ };
280
+ } catch (e) {
281
+ return empty([`removal: ${e && e.message}`]);
282
+ }
283
+ }
284
+
285
+ // removalCandidatesForTask — the whole resolver the ship listener wires, as ONE
286
+ // testable function rather than a closure inside the listener.
287
+ //
288
+ // IT LIVES HERE BECAUSE THE CLOSURE VERSION SHIPPED A DEAD FEATURE. Written
289
+ // inline in routes/lifecycle.js it read `pr.number` from findTaskPullRequest,
290
+ // which returns `pr_number` — so `prNumber` was null on every real ship,
291
+ // deriveRemovalsForShip short-circuited every time, and the rule could never
292
+ // fire in production. Every test injected `removalCandidates` or
293
+ // `getPullRequestFiles` directly, so the whole suite passed over a feature that
294
+ // did nothing: the parts agreed with each other and none of them was wired to
295
+ // the part that was wrong.
296
+ //
297
+ // A closure inside a listener registration is not reachable from a test without
298
+ // rebuilding the router and intercepting the registration. Naming it makes the
299
+ // field read testable, and leaves the listener a single line that cannot hide a
300
+ // second one.
301
+ //
302
+ // Resolving the PR here rather than inside deriveRemovalsForShip keeps THAT
303
+ // function free of the task->PR question, which github-push already answers.
304
+ // A task with no findable PR derives nothing, which files nothing.
305
+ async function removalCandidatesForTask(task, deps = {}) {
306
+ const empty = { removals: [], deletedCount: 0, truncated: false, errors: [] };
307
+ const findPr = deps.findTaskPullRequest
308
+ || ((args) => githubPush.findTaskPullRequest(args));
309
+ let prNumber = null;
310
+ try {
311
+ const pr = await findPr({ taskId: task && task.id });
312
+ // `pr_number`, NOT `pr.number` — findTaskPullRequest returns
313
+ // { branch, pr_number, pr_state, merged }. Pinned by test, because reading
314
+ // the wrong field here is silent: it yields null, which is a legal "no PR"
315
+ // answer, so the feature simply never runs and nothing ever complains.
316
+ prNumber = pr && pr.pr_number != null ? pr.pr_number : null;
317
+ } catch (_) {
318
+ // Never let the PR lookup cost the ship. Same contract as everything below.
319
+ return empty;
320
+ }
321
+ return deriveRemovalsForShip({ taskId: task && task.id, prNumber }, deps);
322
+ }
323
+
324
+ module.exports = {
325
+ MAX_ATTRIBUTED_PATHS,
326
+ removalCandidatesForTask,
327
+ listCommitsForPath,
328
+ deletedPathsFrom,
329
+ groupByTask,
330
+ attributeDeletions,
331
+ deriveRemovalsForShip,
332
+ };
@@ -28,6 +28,7 @@ const api = require('../../../src/module-api');
28
28
  const lifecycle = require('../lifecycle');
29
29
  const db = require('../db');
30
30
  const cascade = require('../cascade');
31
+ const removalDerivation = require('../removal-derivation');
31
32
 
32
33
  // task 1003549 — the cross-role cascade listener. `task.shipped` already exists
33
34
  // as a kernel event (emitted post-commit by the publish reconciler and the ship
@@ -78,6 +79,29 @@ function registerSeams() {
78
79
  // HOLDS a deploy can never be read differently. At 'off' the artist-review
79
80
  // rule files nothing.
80
81
  artistGateMode: () => api.artistGate.artistGateMode(),
82
+ // WHAT THIS SHIP DELETED (task 1004090 / BV2.R15, ADR 0313). The derivation
83
+ // asks GitHub what the pull request actually changed and blames the deleted
84
+ // paths back through `claims.published_head_sha` — the SERVER's own view of
85
+ // the merge, never the client-supplied `diff_stats` the net-negative bonus
86
+ // reads, which is target-less and already known-forgeable.
87
+ //
88
+ // Resolving the PR number here rather than inside the derivation keeps that
89
+ // file free of the task->PR question, which github-push already answers.
90
+ // A task with no findable PR derives nothing, which files nothing.
91
+ //
92
+ // Every failure is swallowed into an empty result: this runs on the ship
93
+ // path, and ADR 0313 is explicit that landing work matters more than dimming
94
+ // a star on time.
95
+ //
96
+ // The resolver itself is `removalCandidatesForTask`, NOT a closure here. It
97
+ // was a closure, and it read the wrong field off findTaskPullRequest
98
+ // (`number` for `pr_number`), which made the whole rule dead in production
99
+ // while every test passed — because every test injected this dep. A closure
100
+ // inside a listener registration is not reachable from a test; a named
101
+ // function is, and this line is now too small to hide a bug of its own.
102
+ removalCandidates: (task) => removalDerivation.removalCandidatesForTask(task, {
103
+ shippedTasksByPublishedSha: (shas) => db.shippedTasksByPublishedSha(shas),
104
+ }),
81
105
  log: api.logger ? api.logger('lifecycle') : null,
82
106
  }));
83
107
  }
@@ -194,15 +194,32 @@ const shippedCopy = (over = {}) => ({
194
194
  ...over,
195
195
  });
196
196
 
197
- await test('the artist review is the one production rule, keyed and disciplined', () => {
198
- assert.equal(cascade.CASCADES.length, 1);
199
- const r = cascade.CASCADES[0];
200
- assert.equal(r.key, 'artist-review-on-ship');
197
+ await test('the artist review is keyed and disciplined, and the table stays well-formed', () => {
198
+ // This pinned CASCADES.length === 1 until task 1004090 appended the removal
199
+ // proposal (BV2.R15). A count is the wrong pin for a table whose whole contract
200
+ // is "extend by appending an entry" (ADR 0242) — it fails on the one change it
201
+ // is meant to permit, and says nothing about WHICH rules are there.
202
+ //
203
+ // So it pins the rule by KEY instead, and pins the SET of keys separately below:
204
+ // a rule vanishing and a rule being added are different events and should fail
205
+ // differently.
206
+ const r = cascade.CASCADES.find((x) => x.key === 'artist-review-on-ship');
207
+ assert.ok(r, 'the artist review rule is gone from CASCADES');
201
208
  assert.equal(r.discipline, 'artist', 'the review is FOR the artist, whatever role shipped the work');
202
209
  assert.equal(r.kind, 'unclassified', 'a valid kind — not the invalid "chore" the spec used to default to');
203
210
  assert.deepEqual(cascade.validateCascades(), [], 'and the shipped table is well-formed');
204
211
  });
205
212
 
213
+ await test('the production rule set is exactly what is declared', () => {
214
+ // The other half of the pin above: adding a rule is a deliberate act, so it
215
+ // should have to be recorded here. Duplicate keys would collide on source_ref
216
+ // and silently suppress one another, which validateCascades already refuses.
217
+ assert.deepEqual(
218
+ cascade.CASCADES.map((r) => r.key).sort(),
219
+ ['artist-review-on-ship', 'removal-proposed-on-ship'],
220
+ );
221
+ });
222
+
206
223
  await test('a copy/visual ship generates the two-step artist review', () => {
207
224
  const { matched } = cascade.cascadesFor(shippedCopy());
208
225
  assert.equal(matched.length, 1);
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.916",
3
+ "version": "1.19.918",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.916",
9
+ "version": "1.19.918",
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.916",
3
+ "version": "1.19.918",
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",