@bongos/core 1.19.921 → 1.19.922

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.
@@ -56,7 +56,11 @@ const {
56
56
  // decay — the resolved per-instance dial (reward-policy ideaDescentDecay)
57
57
  // paidByTask — Map<taskIdString, creditsPaid> from credit_log, per task
58
58
  //
59
- // Returns { paid, estimated, payments, estimatedPayments }.
59
+ // Returns { paid, estimated, payments, estimatedPayments, byTask }, where
60
+ // `byTask` is Map<taskIdString, credits> — the per-hop breakdown brightness
61
+ // weights a descent by (task 1004099 / BV2.R16). The early return on an
62
+ // unusable decay yields an EMPTY map rather than no map, so a caller never has
63
+ // to test for its absence.
60
64
  //
61
65
  // ONLY A SHIPPED TASK COUNTS. The payout fires at ship, so a ready or abandoned
62
66
  // descendant has earned its origin nothing and estimating for it would credit
@@ -75,7 +79,19 @@ const {
75
79
  // what the same descent would have been paid hop by hop, which is the whole
76
80
  // thing this function exists to reproduce.
77
81
  function priceDescentForIdea({ tasks, grade, completeness, decay, paidByTask } = {}) {
78
- const out = { paid: 0, estimated: 0, payments: 0, estimatedPayments: 0 };
82
+ // `byTask` — the same arithmetic, kept per task instead of only summed
83
+ // (task 1004099 / BV2.R16). Brightness is the LIVE SHARE of a size, so it has
84
+ // to weight each descent task by what that task contributed, and the totals
85
+ // alone cannot be taken apart again afterwards. Added here rather than
86
+ // recomputed in brightness because a second copy of this loop is a second
87
+ // answer to "what is this hop worth", and the two would drift the first time
88
+ // a decay dial or a grade factor moved.
89
+ //
90
+ // Map<taskIdString, credits>, carrying BOTH kinds — a paid hop at its ledger
91
+ // figure and an estimated hop at its priced one. Brightness must use the same
92
+ // basis as size or the share would be a fraction of a different number than
93
+ // the one drawn beside it.
94
+ const out = { paid: 0, estimated: 0, payments: 0, estimatedPayments: 0, byTask: new Map() };
79
95
  const list = Array.isArray(tasks) ? tasks : [];
80
96
  const paidMap = paidByTask instanceof Map ? paidByTask : new Map();
81
97
 
@@ -98,8 +114,10 @@ function priceDescentForIdea({ tasks, grade, completeness, decay, paidByTask } =
98
114
  seen.add(key);
99
115
 
100
116
  if (paidMap.has(key)) {
101
- out.paid += Number(paidMap.get(key)) || 0;
117
+ const banked = Number(paidMap.get(key)) || 0;
118
+ out.paid += banked;
102
119
  out.payments += 1;
120
+ out.byTask.set(key, banked);
103
121
  continue;
104
122
  }
105
123
 
@@ -113,6 +131,7 @@ function priceDescentForIdea({ tasks, grade, completeness, decay, paidByTask } =
113
131
  if (delta <= 0) continue;
114
132
  out.estimated += delta;
115
133
  out.estimatedPayments += 1;
134
+ out.byTask.set(key, delta);
116
135
  }
117
136
  return out;
118
137
  }
@@ -152,7 +152,9 @@ function idOf(v) {
152
152
  // row is stream 2 — the thought, not the work it caused.
153
153
  //
154
154
  // Returns Map<ideaIdString, { credits, paid, estimated, payments, scale,
155
- // historical }>. Every id asked for is present, at zero when it has been paid
155
+ // historical, byTask }>, where `byTask` is Map<taskIdString, credits> summing to
156
+ // `credits` — the per-hop breakdown brightness (./idea-brightness.js, BV2.R16)
157
+ // takes its live share of. Every id asked for is present, at zero when it has been paid
156
158
  // nothing: absent and zero are different answers to a renderer, and an idea that
157
159
  // caused nothing yet is a real state the surface has to draw, not a gap.
158
160
  //
@@ -184,7 +186,10 @@ async function ideaSizes(ideaIds, { client = null, descentByIdea = null } = {})
184
186
  const ids = [...new Set((Array.isArray(ideaIds) ? ideaIds : []).map(idOf).filter(Boolean))];
185
187
  const out = new Map();
186
188
  for (const id of ids) {
187
- out.set(id, { credits: 0, paid: 0, estimated: 0, payments: 0, scale: 0, historical: false });
189
+ // `byTask` empty rather than absent: an idea with no payouts has a real,
190
+ // established breakdown (nothing), and a caller should not have to tell
191
+ // that apart from a field that was never filled in.
192
+ out.set(id, { credits: 0, paid: 0, estimated: 0, payments: 0, scale: 0, historical: false, byTask: new Map() });
188
193
  }
189
194
  if (ids.length === 0) return out;
190
195
 
@@ -235,6 +240,11 @@ async function ideaSizes(ideaIds, { client = null, descentByIdea = null } = {})
235
240
  out.set(id, {
236
241
  credits: acc.total, paid: acc.total, estimated: null,
237
242
  payments: acc.payments, scale: compressIdeaSize(acc.total), historical: null,
243
+ // The ledger's own per-task figures are still true even with no descent
244
+ // in hand, and they sum to `credits` here because `credits` IS the
245
+ // ledger total on this branch. Brightness can therefore still take a
246
+ // share; it simply cannot see hops that were never paid for.
247
+ byTask: acc.byTask,
238
248
  });
239
249
  continue;
240
250
  }
@@ -251,6 +261,22 @@ async function ideaSizes(ideaIds, { client = null, descentByIdea = null } = {})
251
261
  // edge) is still money this idea was paid, and dropping it would make size
252
262
  // fall.
253
263
  const credits = acc.total + priced.estimated;
264
+ // THE PER-TASK BREAKDOWN, for brightness (task 1004099 / BV2.R16), and it
265
+ // has to SUM TO `credits` or the share taken of it is a fraction of the
266
+ // wrong number. Two sources, in this order:
267
+ //
268
+ // • `priced.byTask` — every hop the descent reaches, at its ledger figure
269
+ // where one was paid and at its priced figure where it was estimated.
270
+ // • then any ledger payment for a task the descent NO LONGER REACHES — a
271
+ // re-parented task, a deleted edge. Those are in `acc.total` (kept there
272
+ // on purpose, a few lines up, so size cannot fall), so leaving them out
273
+ // here would make the parts sum to less than the whole and quietly
274
+ // overstate brightness. `priced.byTask` wins on a collision because a
275
+ // paid hop carries the same banked figure in both.
276
+ const byTask = new Map(priced.byTask);
277
+ for (const [taskId, paid] of acc.byTask) {
278
+ if (!byTask.has(taskId)) byTask.set(taskId, paid);
279
+ }
254
280
  out.set(id, {
255
281
  credits,
256
282
  paid: acc.total,
@@ -258,6 +284,7 @@ async function ideaSizes(ideaIds, { client = null, descentByIdea = null } = {})
258
284
  payments: acc.payments + priced.estimatedPayments,
259
285
  scale: compressIdeaSize(credits),
260
286
  historical: priced.estimated > 0,
287
+ byTask,
261
288
  });
262
289
  }
263
290
  return out;
@@ -367,7 +394,7 @@ async function walkDescentForIdeas(ids, { client }) {
367
394
  // shape a single-idea panel wants.
368
395
  async function ideaSize(ideaId, opts = {}) {
369
396
  const key = idOf(ideaId);
370
- const zero = { credits: 0, payments: 0, scale: 0 };
397
+ const zero = { credits: 0, payments: 0, scale: 0, byTask: new Map() };
371
398
  if (!key) return zero;
372
399
  const sizes = await ideaSizes([key], opts);
373
400
  return sizes.get(key) || zero;
@@ -565,6 +565,42 @@ async function runCascadesForShip(shipped, deps = {}) {
565
565
  report.errors.push(...errors);
566
566
 
567
567
  await fileMatches({ matched, deps: { ...deps, log }, report, trigger: TRIGGERS.ship, subject: task });
568
+
569
+ // WHOSE WORK THE PROPOSAL IS ABOUT (task 1004099 / BV2.R16 brightness).
570
+ //
571
+ // The proposal's body renders `ctx.removals` as prose for a person; this
572
+ // stores the same objects as rows, because brightness needs the attribution
573
+ // structured and removal-proposals.js is right to refuse to parse a body
574
+ // back. One derivation, two renderings — they cannot disagree.
575
+ //
576
+ // WRITTEN HERE AND NOT AT VERDICT TIME because `ctx.removals` only exists
577
+ // while the cascade runs: it came from capped, rate-limited GitHub
578
+ // commit-history reads, and re-deriving it when a person clicks confirm
579
+ // would spend that budget again against a history that has moved.
580
+ //
581
+ // ON THE SKIPPED ROW TOO, not only the created one. A re-ship finds the
582
+ // proposal already filed and reports it as `skipped`, and that is exactly
583
+ // the run that must be able to fill in an attribution the first run missed
584
+ // (a derivation outage files the question with no targets). The writer is
585
+ // idempotent, so doing it on every run costs a no-op and buys the repair.
586
+ //
587
+ // NOT WIDENING THE RUNNER (ADR 0242): the removal rule still files exactly
588
+ // one task. This records a side fact about that one task, the same shape as
589
+ // the verdict row its answer writes.
590
+ if (typeof deps.recordRemovalTargets === 'function' && Array.isArray(ctx.removals) && ctx.removals.length) {
591
+ const filed = [...report.created, ...report.skipped].find((r) => r.key === REMOVAL_RULE_KEY);
592
+ if (filed && filed.taskId != null) {
593
+ try {
594
+ await deps.recordRemovalTargets({ proposalTaskId: filed.taskId, removals: ctx.removals });
595
+ } catch (e) {
596
+ // Reported, never thrown. The ship is landed, graded and paid before
597
+ // any of this runs; an attribution that cannot be stored leaves the
598
+ // proposal answerable and brightness unable to attribute it, which
599
+ // countUnattributedConfirmed() is there to make visible.
600
+ report.errors.push(`cascade: recordRemovalTargets() threw (${e && e.message}) — proposal ${filed.taskId} has no stored targets`);
601
+ }
602
+ }
603
+ }
568
604
  } catch (e) {
569
605
  report.errors.push(`cascade: ${e && e.message}`);
570
606
  }
@@ -0,0 +1,116 @@
1
+ -- lifecycle_010_removal_targets.sql — WHOSE WORK a removal proposal is about
2
+ -- (task 1004099 / BV2.R16 brightness, goal 1000095; ADR 0321, under ADR 0313 +
3
+ -- ADR 0319).
4
+ --
5
+ -- THE GAP THIS FILLS, IN ONE SENTENCE. lifecycle_009 records the VERDICT on a
6
+ -- removal proposal and the SHIP that did the deleting, and says in as many words
7
+ -- that the list of tasks whose work was removed is "deliberately not here" —
8
+ -- "brightness designs its own storage for targets when it needs one". This is
9
+ -- brightness needing one.
10
+ --
11
+ -- WHY THE VERDICT ROW IS NOT ENOUGH. `lifecycle_removal_verdicts.ship_task_id` is
12
+ -- the task whose merge deleted the files. Brightness is the live share of an
13
+ -- IDEA'S DESCENT, so what it has to know is the opposite direction: which
14
+ -- previously-shipped tasks stopped counting. The ship that deleted them is almost
15
+ -- never in the descent it dims — a later, unrelated piece of work tore out an
16
+ -- earlier one. Reading `ship_task_id` as the removed task would dim the wrong
17
+ -- star, and would dim it for the ship's own idea rather than for the idea whose
18
+ -- work actually died.
19
+ --
20
+ -- WHY NOT PARSE THE PROPOSAL BODY. The attribution already exists in prose:
21
+ -- `cascade.js:removalProposalBody` writes ` - task <id> — <title>` under a
22
+ -- "Removed work attributed to:" heading, from the derivation's structured output.
23
+ -- removal-proposals.js refuses to read it and is right to — "a body is written for
24
+ -- a person to read, and a reader that silently depends on its line shape breaks
25
+ -- the day somebody improves the wording". That refusal only holds if something
26
+ -- else carries the fact, which is this table. The derivation produces
27
+ -- `{ taskId, title, paths }` objects (removal-derivation.js); the body renders
28
+ -- them and this stores them, so the prose and the rows have ONE source and cannot
29
+ -- disagree.
30
+ --
31
+ -- WRITTEN AT PROPOSAL TIME, NOT AT VERDICT TIME. The attribution is a fact about
32
+ -- the diff, and the diff is only in hand while the cascade is running — it comes
33
+ -- from GitHub commit-history reads that are capped at eight paths per ship and run
34
+ -- on the server's shared push credential (removal-derivation.js
35
+ -- MAX_ATTRIBUTED_PATHS). Re-deriving it when a person clicks confirm, days later,
36
+ -- would spend that budget again against a history that has moved on. So the rows
37
+ -- land with the question and the verdict arrives beside them later.
38
+ --
39
+ -- THIS IS NOT THE CASCADE RUNNER FANNING OUT. ADR 0242 forbids widening the runner
40
+ -- to file more than one task per rule per ship, and that is untouched: the removal
41
+ -- rule still files exactly one proposal. This records a side fact ABOUT that one
42
+ -- task, which is the same shape as the verdict row the answer writes.
43
+ --
44
+ -- A PROPOSAL WITH NO ROWS IS A REAL STATE, not a bug, and brightness must read it
45
+ -- as "cannot attribute" rather than "nothing was removed". Three ways it happens:
46
+ -- a proposal filed before this migration; an attribution that found nothing
47
+ -- because every deletion predates `claims.published_head_sha`; a derivation
48
+ -- outage. In all three the honest answer is that the targets are unknown, and
49
+ -- idea-brightness.js treats an unattributed confirmed removal as exactly that —
50
+ -- it dims nothing and says the count out loud, rather than guessing in either
51
+ -- direction.
52
+ --
53
+ -- MODULE-OWNED and `lifecycle_`-prefixed so fitness check 11 permits it (ADR 0083
54
+ -- §Decision #5) — the lifecycle_004..009 convention. It only REFERENCES the core
55
+ -- `tasks` table; a module migration may not ALTER one.
56
+ --
57
+ -- Module migrations are ADDITIVE and NAMESPACED: no down-migration, idempotent
58
+ -- (safe to re-run).
59
+
60
+ BEGIN;
61
+
62
+ CREATE TABLE IF NOT EXISTS lifecycle_removal_targets (
63
+ -- The PROPOSAL task — the question these targets belong to. The join key to
64
+ -- lifecycle_removal_verdicts, whose primary key is the same column, which is
65
+ -- what makes "confirmed removals and what they removed" one join and not a
66
+ -- correlated lookup.
67
+ proposal_task_id bigint NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
68
+
69
+ -- The task whose shipped work the deletion was attributed to. THIS is what
70
+ -- brightness intersects with a descent.
71
+ removed_task_id bigint NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
72
+
73
+ -- The deleted paths attributed to that task, as the derivation found them.
74
+ -- Stored because the proposal's prose is not a record: once a person confirms,
75
+ -- "what exactly went" is the only evidence of what the verdict covered, and a
76
+ -- later reader asking why a star dimmed has nothing else to look at. An empty
77
+ -- array is legal — the attribution is the load-bearing half, the paths are the
78
+ -- receipt.
79
+ paths text[] NOT NULL DEFAULT '{}',
80
+
81
+ -- One row per (proposal, removed task). The derivation already groups its paths
82
+ -- by task, so a second row for the same pair would be a double count of the
83
+ -- same removal — and since the cascade is idempotent (a re-ship re-files
84
+ -- nothing and re-runs this writer), the constraint is what makes re-running
85
+ -- safe rather than additive.
86
+ PRIMARY KEY (proposal_task_id, removed_task_id)
87
+ );
88
+
89
+ -- Brightness reads in the removed_task_id direction: "of these descent tasks,
90
+ -- which are named by a confirmed removal". The primary key leads with
91
+ -- proposal_task_id and cannot serve that, so the reverse index is the one the hot
92
+ -- query needs.
93
+ CREATE INDEX IF NOT EXISTS idx_lifecycle_removal_targets_removed
94
+ ON lifecycle_removal_targets (removed_task_id);
95
+
96
+ -- SAFETY NET — a migration that cannot prove its own effect must not report
97
+ -- success (the lifecycle_007 / ideas_007 / lifecycle_009 precedent). Read-only,
98
+ -- no DDL. If the composite primary key came back wrong, one proposal could carry
99
+ -- the same removed task twice and brightness would count that work as dying
100
+ -- twice — dimming a star further than the removal warrants.
101
+ DO $$
102
+ DECLARE pk_cols text;
103
+ BEGIN
104
+ SELECT string_agg(a.attname, ',' ORDER BY a.attname) INTO pk_cols
105
+ FROM pg_constraint c
106
+ JOIN unnest(c.conkey) AS k(attnum) ON true
107
+ JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum
108
+ WHERE c.conrelid = 'lifecycle_removal_targets'::regclass
109
+ AND c.contype = 'p';
110
+ IF pk_cols IS DISTINCT FROM 'proposal_task_id,removed_task_id' THEN
111
+ RAISE EXCEPTION
112
+ 'lifecycle_010: lifecycle_removal_targets primary key is (%) — expected (proposal_task_id, removed_task_id), so one proposal names one removed task once.', COALESCE(pk_cols, 'none');
113
+ END IF;
114
+ END $$;
115
+
116
+ COMMIT;
@@ -35,7 +35,8 @@
35
35
  },
36
36
  "provides": [
37
37
  "lifecycle",
38
- "lifecycle.kickoffSeed"
38
+ "lifecycle.kickoffSeed",
39
+ "lifecycle.removals"
39
40
  ],
40
41
  "consumes": []
41
42
  }
@@ -0,0 +1,217 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/removal-targets.js — WHOSE WORK a removal proposal is about
4
+ // (task 1004099 / BV2.R16 brightness, goal 1000095; ADR 0321, under ADR 0313 +
5
+ // ADR 0319).
6
+ //
7
+ // THE ONE FACT THIS FILE CARRIES. lifecycle_009 stores the VERDICT on a removal
8
+ // proposal and the SHIP that deleted the files, and says outright that the list
9
+ // of tasks whose work was removed is "deliberately not here — brightness designs
10
+ // its own storage for targets when it needs one". Brightness needs one, because
11
+ // the direction it reads is the opposite of the one the verdict row holds: it
12
+ // asks "which previously-shipped tasks stopped counting", and `ship_task_id`
13
+ // answers "which ship did the deleting". Those are different tasks, usually under
14
+ // different ideas, and confusing them dims the wrong star.
15
+ //
16
+ // THE PROSE IS NOT THE RECORD. `cascade.js:removalProposalBody` already renders
17
+ // the attribution for a person to read. removal-proposals.js refuses to parse it
18
+ // back — "a body is written for a person to read, and a reader that silently
19
+ // depends on its line shape breaks the day somebody improves the wording" — and
20
+ // that refusal is only safe because of this table. Both the prose and these rows
21
+ // are rendered from the SAME derivation output (`{ taskId, title, paths }` off
22
+ // removal-derivation.js), so there is one source and they cannot disagree.
23
+ //
24
+ // WRITTEN WITH THE QUESTION, READ AFTER THE ANSWER. The attribution is only
25
+ // available while the cascade runs: it comes from GitHub commit-history reads,
26
+ // capped at eight paths per ship, on the server's shared push credential. Storing
27
+ // it at proposal time and joining the verdict later is what keeps `confirm` a
28
+ // pure database write instead of a second trip to GitHub against a history that
29
+ // has since moved.
30
+ //
31
+ // MODULE BOUNDARY: reaches core only through the published doorway
32
+ // `../../src/module-api`. Nothing here imports another module; the economy module
33
+ // reads it through the `lifecycle.removals` port, never by require (ADR 0083
34
+ // rule 1).
35
+
36
+ const api = require('../../src/module-api');
37
+
38
+ const { pool } = api;
39
+
40
+ // A positive integer id as it arrives off a row, a caller or a derivation —
41
+ // where it may be a bigint serialised to a string. Anything else is dropped
42
+ // rather than sent to the database, and never coerced with Number(), which
43
+ // loses bigint precision. The same guard idea-size.js uses, for the same reason.
44
+ function idOf(v) {
45
+ return /^[1-9]\d*$/.test(String(v)) ? String(v) : null;
46
+ }
47
+
48
+ // Paths as the derivation hands them: an array of non-empty strings. Anything
49
+ // else becomes an empty array rather than a null, because the column is NOT NULL
50
+ // and because the paths are the receipt, not the load-bearing half — a malformed
51
+ // path list must not cost us the attribution it came with.
52
+ function pathsOf(v) {
53
+ if (!Array.isArray(v)) return [];
54
+ return v.filter((p) => typeof p === 'string' && p.length > 0).map(String);
55
+ }
56
+
57
+ // recordRemovalTargets — store the attribution for ONE proposal.
58
+ //
59
+ // proposalTaskId the task the removal cascade filed
60
+ // removals the derivation's output: [{ taskId, title, paths }]
61
+ //
62
+ // Returns the number of rows written (0 when there was nothing to attribute).
63
+ //
64
+ // IDEMPOTENT, because its caller is. The cascade re-runs on a re-ship and on a
65
+ // reconciler sweep, and `createTask`'s (source, source_ref) idempotency hands
66
+ // back the ORIGINAL proposal row rather than minting a second one — so this
67
+ // writer is called again with the same proposal id. ON CONFLICT DO NOTHING makes
68
+ // that a no-op instead of a duplicate-key throw that would surface as a cascade
69
+ // error on every re-ship.
70
+ //
71
+ // DO NOTHING, NOT DO UPDATE, and that is a decision rather than a shortcut. A
72
+ // second derivation of the same ship can legitimately find FEWER paths — the
73
+ // commit-history walk is capped and rate-limited, and an exhausted page yields
74
+ // no attribution at all. Overwriting would let a degraded second read erase a
75
+ // good first one. The first attribution is the one taken while the diff was
76
+ // freshest; it stands.
77
+ //
78
+ // A `title` in the derivation output is deliberately NOT stored: it is the
79
+ // task's own title, which lives on `tasks` and can be joined for. Copying it
80
+ // here would be a second copy that goes stale the first time a title is edited.
81
+ //
82
+ // NEVER THROWS INTO THE SHIP. The cascade runner catches everything and turns it
83
+ // into a report, and this is called from inside that guard — but the ship has
84
+ // already landed, been graded and been paid before any of this runs, so an
85
+ // attribution that cannot be stored must cost the ship nothing. The caller
86
+ // reports; it does not fail.
87
+ async function recordRemovalTargets({ proposalTaskId, removals } = {}, { client = null } = {}) {
88
+ const proposal = idOf(proposalTaskId);
89
+ if (!proposal) return 0;
90
+
91
+ // De-duplicated on the way in. The derivation groups paths by task, so a
92
+ // repeat is not expected — but the primary key would reject the whole
93
+ // statement rather than the offending row, and a doubled pair inside ONE
94
+ // insert is not something ON CONFLICT rescues.
95
+ const byTask = new Map();
96
+ for (const r of Array.isArray(removals) ? removals : []) {
97
+ const taskId = idOf(r && r.taskId);
98
+ if (!taskId) continue;
99
+ if (byTask.has(taskId)) {
100
+ // Merge rather than drop: two entries for one task means the derivation
101
+ // split its paths, and losing half of them loses half the receipt.
102
+ const merged = new Set([...byTask.get(taskId), ...pathsOf(r.paths)]);
103
+ byTask.set(taskId, [...merged]);
104
+ continue;
105
+ }
106
+ byTask.set(taskId, pathsOf(r && r.paths));
107
+ }
108
+ if (byTask.size === 0) return 0;
109
+
110
+ const q = client || pool;
111
+ const payload = [...byTask.entries()].map(([taskId, paths]) => ({ taskId, paths }));
112
+
113
+ // ONE jsonb parameter, not two parallel arrays — and the reason is a trap
114
+ // worth naming. The obvious shape is `unnest($2::bigint[])` joined by
115
+ // ORDINALITY to `unnest($3::text[][])`, and it is wrong: a Postgres
116
+ // multidimensional array is rectangular and `unnest` FLATTENS it completely,
117
+ // so a task with two paths and a task with one do not even produce the same
118
+ // number of rows as their ids, and the ordinality join silently pairs the
119
+ // wrong path with the wrong task. jsonb keeps each task's paths nested where
120
+ // they belong, and it costs the same single round trip.
121
+ const { rowCount } = await q.query(
122
+ `INSERT INTO lifecycle_removal_targets (proposal_task_id, removed_task_id, paths)
123
+ SELECT $1::bigint,
124
+ (e->>'taskId')::bigint,
125
+ COALESCE(ARRAY(SELECT jsonb_array_elements_text(e->'paths')), '{}')
126
+ FROM jsonb_array_elements($2::jsonb) AS e
127
+ ON CONFLICT (proposal_task_id, removed_task_id) DO NOTHING`,
128
+ [proposal, JSON.stringify(payload)]
129
+ );
130
+ return rowCount || 0;
131
+ }
132
+
133
+ // confirmedRemovalsForTasks — of these tasks, which are named by a CONFIRMED
134
+ // removal, and by which proposal.
135
+ //
136
+ // This is the read brightness is built on. Returns
137
+ // Map<removedTaskIdString, { proposalTaskId, shipTaskId, paths, decidedAt }>;
138
+ // a task absent from the map is alive. Only 'confirmed' rows join — a dismissed
139
+ // proposal is a refactor or a move and must dim nothing, which is the entire
140
+ // reason the verdict is a person's answer rather than a derived signal
141
+ // (ADR 0313).
142
+ //
143
+ // ONE QUERY FOR THE WHOLE SET, not one per task: this runs behind a page of
144
+ // ideas, each with a descent, and a per-task lookup would be a round trip per
145
+ // node of every descent on screen.
146
+ //
147
+ // A task named by SEVERAL confirmed proposals collapses to the EARLIEST verdict.
148
+ // Work can be deleted, re-added and deleted again, and the question brightness
149
+ // asks is binary — is this alive — so the count of times it died is not the
150
+ // fact. The earliest is kept because it is the one whose paths describe the
151
+ // original removal rather than a later re-removal of something restored in
152
+ // between, and because a stable pick makes the read deterministic.
153
+ async function confirmedRemovalsForTasks(taskIds, { client = null } = {}) {
154
+ const ids = [...new Set((Array.isArray(taskIds) ? taskIds : []).map(idOf).filter(Boolean))];
155
+ const out = new Map();
156
+ if (ids.length === 0) return out;
157
+
158
+ const q = client || pool;
159
+ const { rows } = await q.query(
160
+ `SELECT tg.removed_task_id,
161
+ tg.proposal_task_id,
162
+ tg.paths,
163
+ v.ship_task_id,
164
+ v.decided_at
165
+ FROM lifecycle_removal_targets tg
166
+ JOIN lifecycle_removal_verdicts v ON v.proposal_task_id = tg.proposal_task_id
167
+ WHERE v.verdict = 'confirmed'
168
+ AND tg.removed_task_id = ANY($1::bigint[])
169
+ ORDER BY tg.removed_task_id, v.decided_at ASC, tg.proposal_task_id ASC`,
170
+ [ids]
171
+ );
172
+ for (const row of rows) {
173
+ const key = idOf(row.removed_task_id);
174
+ if (!key || out.has(key)) continue; // ORDER BY put the earliest first
175
+ out.set(key, {
176
+ proposalTaskId: String(row.proposal_task_id),
177
+ shipTaskId: row.ship_task_id == null ? null : String(row.ship_task_id),
178
+ paths: Array.isArray(row.paths) ? row.paths : [],
179
+ decidedAt: row.decided_at || null,
180
+ });
181
+ }
182
+ return out;
183
+ }
184
+
185
+ // countUnattributedConfirmed — how many CONFIRMED removals name no target at all.
186
+ //
187
+ // The number nothing else can tell you, and the reason it is exported rather
188
+ // than left implicit. A confirmed removal with no target rows is invisible to
189
+ // brightness by construction: the work is genuinely gone, and no star dims for
190
+ // it. That happens for proposals filed before lifecycle_010, for attributions
191
+ // where every deletion predates `claims.published_head_sha`, and for a
192
+ // derivation outage.
193
+ //
194
+ // Brightness cannot fix that — it does not know which idea to dim, and guessing
195
+ // is worse than not dimming. What it can do is refuse to let the gap be silent.
196
+ // A surface over the sky (BV2.R17/R18) can say "the sky may read brighter than
197
+ // the truth by up to N confirmed removals", which is an honest caveat, where a
198
+ // silently-optimistic picture is the quiet lie the spec names.
199
+ async function countUnattributedConfirmed({ client = null } = {}) {
200
+ const q = client || pool;
201
+ const { rows } = await q.query(
202
+ `SELECT COUNT(*)::int AS n
203
+ FROM lifecycle_removal_verdicts v
204
+ WHERE v.verdict = 'confirmed'
205
+ AND NOT EXISTS (
206
+ SELECT 1 FROM lifecycle_removal_targets tg
207
+ WHERE tg.proposal_task_id = v.proposal_task_id
208
+ )`
209
+ );
210
+ return (rows[0] && rows[0].n) || 0;
211
+ }
212
+
213
+ module.exports = {
214
+ recordRemovalTargets,
215
+ confirmedRemovalsForTasks,
216
+ countUnattributedConfirmed,
217
+ };
@@ -29,6 +29,7 @@ const lifecycle = require('../lifecycle');
29
29
  const db = require('../db');
30
30
  const cascade = require('../cascade');
31
31
  const removalDerivation = require('../removal-derivation');
32
+ const removalTargets = require('../removal-targets');
32
33
 
33
34
  // task 1003549 — the cross-role cascade listener. `task.shipped` already exists
34
35
  // as a kernel event (emitted post-commit by the publish reconciler and the ship
@@ -102,6 +103,14 @@ function registerSeams() {
102
103
  removalCandidates: (task) => removalDerivation.removalCandidatesForTask(task, {
103
104
  shippedTasksByPublishedSha: (shas) => db.shippedTasksByPublishedSha(shas),
104
105
  }),
106
+ // WHOSE WORK IT WAS, stored (task 1004099 / BV2.R16 brightness). The
107
+ // derivation above already computed the attribution and the proposal body
108
+ // renders it as prose; this persists the same objects so brightness can
109
+ // read them structured. Passed as the NAMED function rather than wrapped in
110
+ // a closure, for the reason the note above gives: a closure here is not
111
+ // reachable from a test, and that is how the `number`/`pr_number` bug lived
112
+ // in production with a green suite.
113
+ recordRemovalTargets: removalTargets.recordRemovalTargets,
105
114
  log: api.logger ? api.logger('lifecycle') : null,
106
115
  }));
107
116
  }
@@ -32,9 +32,34 @@
32
32
  const express = require('express');
33
33
  const api = require('../../../src/module-api');
34
34
  const proposals = require('../removal-proposals');
35
+ const removalTargets = require('../removal-targets');
35
36
 
36
37
  const log = api.logger('lifecycle');
37
38
 
39
+ // THE `lifecycle.removals` PORT (task 1004099 / BV2.R16 brightness).
40
+ //
41
+ // Brightness is the live share of an idea's descent, and it lives in the ECONOMY
42
+ // module beside size — because it is a share of the same payouts, and putting it
43
+ // in lifecycle would mean economy asking lifecycle for money arithmetic. That
44
+ // makes "which of these tasks were removed" a cross-module read, and the one-way
45
+ // rule (ADR 0083 rule 1) says it crosses through a kernel-mediated port, never a
46
+ // require. This is the same shape as `ideas.descent`, which economy already
47
+ // resolves for the descent walk itself.
48
+ //
49
+ // REGISTERED HERE, in a route file, because that is where a module's ports are
50
+ // registered (the `ideas.capture` / `lifecycle.kickoffSeed` precedent) — the
51
+ // registration runs when the module mounts, so nothing resolves a port from a
52
+ // module that is switched off.
53
+ //
54
+ // RESOLVED OPTIONALLY on the far side. Economy degrades to "cannot attribute"
55
+ // when lifecycle is absent rather than failing the read, so this is not declared
56
+ // in economy's `consumes` — the same treatment `ideas.descent` gets, and the
57
+ // reason `consumes` there is still `[]`.
58
+ const removalsPort = Object.freeze({
59
+ confirmedRemovalsForTasks: removalTargets.confirmedRemovalsForTasks,
60
+ countUnattributedConfirmed: removalTargets.countUnattributedConfirmed,
61
+ });
62
+
38
63
  // The floor: `task.promote` is Metic+ and is the atom the nod already carries,
39
64
  // which is what makes 'the same floor as the nod' a fact about the gate rather
40
65
  // than a claim in a comment.
@@ -48,6 +73,14 @@ const log = api.logger('lifecycle');
48
73
  module.exports = function buildRemovalProposalsRouter() {
49
74
  const router = express.Router();
50
75
 
76
+ // Guarded, because the router factory runs again every time a test rebuilds
77
+ // it and registerProvider THROWS on a duplicate — a port is single-provider by
78
+ // design. The `lifecycle` port's own registration in routes/lifecycle.js uses
79
+ // the identical guard for the identical reason.
80
+ if (!api.hasProvider('lifecycle.removals')) {
81
+ api.registerProvider('lifecycle.removals', removalsPort);
82
+ }
83
+
51
84
  // GET /removal-proposals — the removal questions nobody has answered yet.
52
85
  //
53
86
  // rank: metic+archon (task.promote). No client-side rank gate is needed or wanted in the
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.921",
3
+ "version": "1.19.922",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.921",
9
+ "version": "1.19.922",
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.921",
3
+ "version": "1.19.922",
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",
@@ -30,7 +30,7 @@
30
30
 
31
31
  const path = require('path');
32
32
  const { CONFIG, MANIFEST_VERIFY_INTERVAL_MS, MANIFEST_VERIFY_TRIES, REPO_ROOT } = require('./provision-config.js');
33
- const { standaloneRoot } = require('./provision-repo.js');
33
+ const { APP_USER, dbName, instanceUser, standaloneRoot } = require('./provision-repo.js');
34
34
  const { isValidSlug } = require('../../modules/provisioning/provisioning.js');
35
35
 
36
36
  // The shapes whose box this runner can actually reach. Mirrors UPGRADE_SHAPES in the
@@ -186,7 +186,31 @@ async function coreUpgradeInstance(inst, deps, intent) {
186
186
  // the silent thirteen-release revert upgrade.js's own header records.
187
187
  const healthUrl = `http://127.0.0.1:${inst.port}/healthz`;
188
188
  const versionUrl = `http://127.0.0.1:${inst.port}/version`;
189
- const sudoP = privileged ? 'sudo ' : '';
189
+ // WHO THE UPGRADE RUNS AS, AND WHICH DATABASE IT NAMES (task 1004119).
190
+ //
191
+ // A bare `sudo` runs as ROOT, and both halves of the upgrade resolve against the
192
+ // CURRENT OS USER rather than a flag:
193
+ //
194
+ // • migrate.sh peer-auths over the local socket as whoever invoked it, and `root`
195
+ // is almost never a Postgres role — pre-flight refuses with "Postgres has no
196
+ // role root" before the pin moves (correctly; the pin must not move for a
197
+ // migrate that cannot run).
198
+ // • with no PGDATABASE, resolveInstanceDb() has to FIND the database by loading
199
+ // node_modules/@bongos/core/src/bongos/pool from the instance root. An adopted
200
+ // repo that installs its workspaces rather than its root has no core there, so
201
+ // the name cannot be resolved at all.
202
+ //
203
+ // Both were live on 2026-09-20: four presses of the owner's Upgrade button, four
204
+ // pre-flight refusals, no instance ever moved. The helpers to fix it already sat in
205
+ // provision-repo.js — this leg simply never used them.
206
+ //
207
+ // The account is the instance's own (`bongos-<slug>`), which is ALSO the name of its
208
+ // Postgres login role (instanceDbRole), so peer auth lands on a role that owns
209
+ // exactly that instance's database. The control plane is the exception: it is not a
210
+ // provisioned tenant and has no `bongos-` account — it runs as APP_USER, the operator
211
+ // the recipe's gate-3 invocation names.
212
+ const runAs = inst.hosting_shape === 'control-plane' ? APP_USER : instanceUser(inst);
213
+ const sudoP = privileged ? `sudo -u ${runAs} env PGDATABASE=${dbName(inst)} ` : '';
190
214
  // WHERE THE CLI LIVES AND WHERE THE TARGET LIVES ARE DIFFERENT PLACES (task 1004053).
191
215
  //
192
216
  // `scripts/gds/upgrade.js` ships in the CORE checkout. A provisioned instance root holds