@bongos/core 1.19.900 → 1.19.902

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.
@@ -28,6 +28,28 @@ const REWARD_MARGIN_PCT = 20;
28
28
  const REWARD_MODE_DEFAULT = 'cost-plus-and-estimate';
29
29
  const REWARD_MODE_COST_PLUS_ONLY = 'cost-plus-only';
30
30
 
31
+ // The DEPTH DECAY on the ideator descent payout (task 1004088, BV2.R09; spec
32
+ // decision 16). A task shipping pays the idea it came from in full; a task
33
+ // shipping one hop BELOW that pays it this fraction, and so on down.
34
+ //
35
+ // WHY 0.5, AND WHY THE SERIES NEEDS NO CAP. At a half per hop an infinite
36
+ // descent is worth at most twice the direct task, so the payout converges on
37
+ // its own and nothing has to pick an arbitrary hop limit. A rate of 1 would not
38
+ // converge — a wide enough descent could pay an origin without bound — so the
39
+ // resolver refuses anything at or above 1 and falls back to the default.
40
+ //
41
+ // WHY IT LIVES HERE. The owner's ruling (interview Q1) is that the rate belongs
42
+ // to the project, not to the platform, so it is a per-instance dial and this is
43
+ // where per-instance reward policy already resolves. It is the SECOND such dial
44
+ // (the ignite floor in modules/ideas/routing.js is the first), and BV2.R05 is
45
+ // the task that gives the two of them one home; putting it in the existing
46
+ // resolver now makes that a move rather than a rewrite.
47
+ //
48
+ // 0 IS A LEGAL SETTING and means "only the direct task pays" — an instance that
49
+ // wants the old first-hop-only behaviour sets it and gets exactly that, with no
50
+ // code path to disable separately.
51
+ const IDEA_DESCENT_DECAY = 0.5;
52
+
31
53
  // PURE. Resolves a raw branding `reward` block over the code defaults. Every field
32
54
  // is validated rather than trusted: a malformed pack degrades to the defaults
33
55
  // instead of poisoning an award.
@@ -39,12 +61,23 @@ function rewardPolicy(rawReward) {
39
61
  const perTaskCredit = mode !== REWARD_MODE_COST_PLUS_ONLY;
40
62
  // Default-on, opt-out-only: only an explicit `false` withholds the ideator lane.
41
63
  const ideatorCredit = r.ideatorCredit !== false;
42
- return { marginPct, usdPeg, mode, perTaskCredit, ideatorCredit };
64
+ // Validated to [0, 1): see IDEA_DESCENT_DECAY above — a rate of 1 or more does
65
+ // not converge, and a negative one would pay a descent backwards.
66
+ // ABSENT IS THE DEFAULT, NOT ZERO — and Number(null) is 0, which is a legal
67
+ // rate meaning "only the direct task pays". Without this guard a pack that
68
+ // simply omits the key, or carries an explicit null, would silently switch
69
+ // the whole descent payout off and look exactly like a deliberate choice.
70
+ const rawDecay = r.ideaDescentDecay == null ? NaN : Number(r.ideaDescentDecay);
71
+ const ideaDescentDecay = Number.isFinite(rawDecay) && rawDecay >= 0 && rawDecay < 1
72
+ ? rawDecay
73
+ : IDEA_DESCENT_DECAY;
74
+ return { marginPct, usdPeg, mode, perTaskCredit, ideatorCredit, ideaDescentDecay };
43
75
  }
44
76
 
45
77
  module.exports = {
46
78
  REWARD_MARGIN_PCT,
47
79
  REWARD_MODE_DEFAULT,
48
80
  REWARD_MODE_COST_PLUS_ONLY,
81
+ IDEA_DESCENT_DECAY,
49
82
  rewardPolicy,
50
83
  };
@@ -28,6 +28,10 @@ module.exports = {
28
28
  awardIdeaFullPassCredit: (...a) => credits.awardIdeaFullPassCredit(...a),
29
29
  // ADR 0172 IC4 — stream 1: pay the idea's author per shipped task, grade-scaled (replaces the 25% slice at ship).
30
30
  awardIdeaTaskShipCredit: (...a) => credits.awardIdeaTaskShipCredit(...a),
31
+ // BV2.R09 / ADR 0312 — the same stream, for the origins the shipping task
32
+ // DESCENDS from rather than the one it was promoted from, decayed by depth.
33
+ // Called right after the direct payout, which always wins for the same idea.
34
+ awardIdeaDescentShipCredit: (...a) => credits.awardIdeaDescentShipCredit(...a),
31
35
  // ADR 0172 IC5 — stream 3: a board-ratified Full Idea pays up to 100, scaled by completeness.
32
36
  // The board's close transaction resolves this port and calls this with its own
33
37
  // `client` (docs/specs/board-room-pass-event-contract.md) — the port is how
@@ -378,9 +378,55 @@
378
378
  const ideaHref = (id) => href(`${hallBase()}/idea/${encodeURIComponent(id)}`);
379
379
  const goalHref = (id) => href(`${hallBase()}/goals#/goal/${encodeURIComponent(id)}`);
380
380
 
381
+ // THE DESCENT — everything an idea's tasks went on to cause, at any depth and
382
+ // across version boundaries (task 1004089, BV2.R09; ADR 0312). Served as
383
+ // `descent_tasks`, already ordered nearest-first by the server, each entry at
384
+ // its shortest depth from one of the idea's own tasks.
385
+ //
386
+ // WHY IT RENDERS HERE, under the outcome and not beside it. The outcome line
387
+ // answers "what became of my idea" with the FIRST thing it became, which is
388
+ // all the trail could ever say while the read was one hop. The descent is the
389
+ // rest of that sentence, so it belongs in the same cell, subordinate to it.
390
+ //
391
+ // CAPPED, and the cap says so. The whole descent is the idea page's job
392
+ // (BV2.R10); a ledger row that paints forty tasks stops being a row. The
393
+ // overflow is a LINK to that page, never a silent truncation.
394
+ const DESCENT_SHOWN = 6;
395
+ function descentHtml(idea) {
396
+ const rows = Array.isArray(idea.descent_tasks) ? idea.descent_tasks : [];
397
+ if (!rows.length) return '';
398
+ const cells = rows.slice(0, DESCENT_SHOWN).map((t) => {
399
+ // The version is printed rather than compared away. A descendant living
400
+ // in a LATER version is the interesting case — it is the proof that an
401
+ // origin tag survives a version boundary — so it is labelled, not
402
+ // filtered and not silently flattened into the idea's own version.
403
+ const meta = [
404
+ `${escapeHtml(String(t.relation || 'descends'))}`,
405
+ `depth ${escapeHtml(String(t.depth))}`,
406
+ t.version_id ? escapeHtml(String(t.version_id)) : null,
407
+ ].filter(Boolean).join(' · ');
408
+ return `<span style="display:block;margin-top:2px">`
409
+ + `<a href="${taskHref(t.task_id)}">#${escapeHtml(String(t.task_id))} ${escapeHtml(t.title || '')}</a>`
410
+ + ` <span style="font-size:11px;color:var(--ink-faint)">${meta}</span>`
411
+ + `</span>`;
412
+ }).join('');
413
+ const rest = rows.length - DESCENT_SHOWN;
414
+ const more = rest > 0
415
+ ? `<span style="display:block;margin-top:2px;font-size:12px"><a href="${ideaHref(idea.id)}">and ${escapeHtml(String(rest))} more below this thought</a></span>`
416
+ : '';
417
+ return `<span style="display:block;margin-top:4px;font-size:12px;color:var(--ink-faint)">`
418
+ + `Then caused ${escapeHtml(String(rows.length))} ${rows.length === 1 ? 'task' : 'tasks'}</span>`
419
+ + cells + more;
420
+ }
421
+
381
422
  // What became of one idea, in a sentence a person can read. The four outcomes
382
423
  // the criterion names, each linked through to the thing it became.
383
424
  function ideaOutcomeHtml(idea) {
425
+ // The descent rides under EVERY outcome, not just a promotion: a merged
426
+ // idea inherits the descent of the idea it merged into (that is what
427
+ // "nothing is lost" means), and an idea whose first task was later split
428
+ // has a descent whatever its own status says.
429
+ const descent = descentHtml(idea);
384
430
  const note = idea.triage_note
385
431
  ? `<span style="display:block;margin-top:2px;font-size:12px;color:var(--ink-faint)">${escapeHtml(idea.triage_note)}</span>`
386
432
  : '';
@@ -396,19 +442,19 @@
396
442
  : []);
397
443
  // 'promoted' with nothing to point at is a real (old) state, not a bug to
398
444
  // paper over — say what is known rather than inventing a link.
399
- if (!links.length) return `<span class="task__tag">Promoted</span>${note}`;
400
- return `<span class="task__tag">Became ${links.length === 1 ? 'a task' : `${links.length} tasks`}</span> ${links.join(' · ')}${note}`;
445
+ if (!links.length) return `<span class="task__tag">Promoted</span>${note}${descent}`;
446
+ return `<span class="task__tag">Became ${links.length === 1 ? 'a task' : `${links.length} tasks`}</span> ${links.join(' · ')}${note}${descent}`;
401
447
  }
402
448
  if (idea.status === 'merged') {
403
449
  const target = idea.merged_into_idea_id
404
450
  ? ` <a href="${ideaHref(idea.merged_into_idea_id)}">#${escapeHtml(String(idea.merged_into_idea_id))} ${escapeHtml(idea.merged_into_idea_title || '')}</a>`
405
451
  : '';
406
- return `<span class="task__tag">Merged into</span>${target}${note}`;
452
+ return `<span class="task__tag">Merged into</span>${target}${note}${descent}`;
407
453
  }
408
454
  if (idea.status === 'discarded') {
409
- return `<span class="task__tag">Discarded</span>${note}`;
455
+ return `<span class="task__tag">Discarded</span>${note}${descent}`;
410
456
  }
411
- return `<span class="task__tag">Still open</span>${note}`;
457
+ return `<span class="task__tag">Still open</span>${note}${descent}`;
412
458
  }
413
459
 
414
460
  // One ledger driven by one server-paged endpoint. `fetchPage(offset, perPage)`
@@ -22,8 +22,94 @@ const DESCENT_MAX_DEPTH = 64;
22
22
 
23
23
  const DESCENT_RELATIONS = Object.freeze(['rewrite', 'split', 'merge', 'fold', 'cascade']);
24
24
 
25
- // walk — breadth-first over the edge table, one query PER LEVEL.
25
+ // ── the walk: one implementation, batched and single-root ─────────────────
26
+ // walkMany — a breadth-first walk from MANY roots at once: ONE query per LEVEL
27
+ // for the whole set, rather than one walk per root.
26
28
  //
29
+ // WHY THE BATCH IS THE POINT OF THIS FUNCTION (task 1004089, BV2.R09). The
30
+ // descent is read for a PAGE of ideas, not for one. Walking each root on its
31
+ // own costs (roots x depth) round trips, which is the 4-6 serial round trips
32
+ // per read that sank the previous attempt at this read. Batched, the cost is
33
+ // depth round trips for the whole page — INDEPENDENT of how many ideas the page
34
+ // carries, which is the property tests/idea_trail_descent.mjs pins.
35
+ //
36
+ // WHY NOT A RECURSIVE CTE, which collapses it to literally one query: the walk
37
+ // comment above says it, and nothing about batching changes that argument. A
38
+ // CTE re-expands every path, so a convergent DAG costs work exponential in
39
+ // depth; here the whole page costs at most one query per level whatever the
40
+ // shape. One query per level is the floor a walk that must not blow up can
41
+ // reach, and this is that floor.
42
+ //
43
+ // PER-ROOT ATTRIBUTION is why the row carries `from_id`. The union of every
44
+ // root's frontier goes to the database in one array, so a row coming back has
45
+ // to say which frontier node produced it or the answer cannot be split back
46
+ // apart. Depth is per-root and must stay that way: the payout decays by the
47
+ // depth from THAT origin, so one shared answer would pay the wrong rate.
48
+ //
49
+ // Returns Map<rootKey, rows> — rootKey is String(rootId), rows exactly as the
50
+ // single-root walk returns them.
51
+ async function walkMany(startIds, direction, { maxDepth = DESCENT_MAX_DEPTH, client = null } = {}) {
52
+ const q = client || pool;
53
+ // Two literal statements rather than one with an interpolated column name:
54
+ // nothing here is caller-controlled, and a query built by concatenation is a
55
+ // shape a reader (and a scanner) has to stop and prove safe.
56
+ const SQL_UP = `SELECT parent_task_id AS id, task_id AS from_id, relation
57
+ FROM ideas_task_descent
58
+ WHERE task_id = ANY($1::bigint[])`;
59
+ const SQL_DOWN = `SELECT task_id AS id, parent_task_id AS from_id, relation
60
+ FROM ideas_task_descent
61
+ WHERE parent_task_id = ANY($1::bigint[])`;
62
+ const sql = direction === 'up' ? SQL_UP : SQL_DOWN;
63
+
64
+ // A root that is not a positive integer is dropped here rather than sent to
65
+ // the database as part of a bigint[]. linkTaskDescent already rejects a bad
66
+ // id by name before it ever walks; this is the second line, for the readers
67
+ // that take their roots from a row rather than from an argument.
68
+ const roots = [...new Set((startIds || []).map((r) => String(r)))].filter((r) => /^[1-9]\d*$/.test(r));
69
+
70
+ // One independent walk state per root, stepped in lockstep so that every
71
+ // root's frontier is expanded by the same query.
72
+ const state = new Map();
73
+ for (const r of roots) {
74
+ state.set(r, { found: new Map(), visited: new Set([r]), frontier: [Number(r)] });
75
+ }
76
+
77
+ for (let depth = 1; depth <= maxDepth; depth += 1) {
78
+ const union = new Set();
79
+ for (const s of state.values()) for (const n of s.frontier) union.add(n);
80
+ if (union.size === 0) break;
81
+ const { rows } = await q.query(sql, [[...union]]);
82
+ // from_id -> the edges it produced, so each root can expand only the nodes
83
+ // that were actually on ITS frontier.
84
+ const adjacency = new Map();
85
+ for (const row of rows) {
86
+ const from = String(row.from_id);
87
+ if (!adjacency.has(from)) adjacency.set(from, []);
88
+ adjacency.get(from).push(row);
89
+ }
90
+ for (const s of state.values()) {
91
+ const next = [];
92
+ for (const node of s.frontier) {
93
+ for (const edge of adjacency.get(String(node)) || []) {
94
+ const key = String(edge.id);
95
+ if (s.visited.has(key)) continue; // already reached at a shallower depth
96
+ s.visited.add(key);
97
+ s.found.set(key, { id: edge.id, depth, relation: edge.relation });
98
+ next.push(Number(edge.id));
99
+ }
100
+ }
101
+ s.frontier = next;
102
+ }
103
+ }
104
+
105
+ const out = new Map();
106
+ for (const [root, s] of state) {
107
+ out.set(root, [...s.found.values()].sort((a, b) => a.depth - b.depth || Number(a.id) - Number(b.id)));
108
+ }
109
+ return out;
110
+ }
111
+
112
+ // Both of the above are breadth-first over the edge table, one query PER LEVEL.
27
113
  // WHY NOT A RECURSIVE CTE, which is the obvious shape and was the first cut.
28
114
  // A recursive CTE's working table re-expands every path independently, so a
29
115
  // densely-connected DAG — paths that converge and diverge again, which is
@@ -43,37 +129,13 @@ const DESCENT_RELATIONS = Object.freeze(['rewrite', 'split', 'merge', 'fold', 'c
43
129
  // The cost is one round trip per level instead of one per walk. Depth decay
44
130
  // halves each hop (spec decision 16), so a descent deep enough for that to
45
131
  // matter is already worth almost nothing.
46
- async function walk(startId, direction, { maxDepth = DESCENT_MAX_DEPTH, client = null } = {}) {
47
- const q = client || pool;
48
- // Two literal statements rather than one with an interpolated column name:
49
- // nothing here is caller-controlled, and a query built by concatenation is a
50
- // shape a reader (and a scanner) has to stop and prove safe.
51
- const SQL_UP = `SELECT parent_task_id AS id, relation
52
- FROM ideas_task_descent
53
- WHERE task_id = ANY($1::bigint[])`;
54
- const SQL_DOWN = `SELECT task_id AS id, relation
55
- FROM ideas_task_descent
56
- WHERE parent_task_id = ANY($1::bigint[])`;
57
- const sql = direction === 'up' ? SQL_UP : SQL_DOWN;
58
-
59
- const found = new Map();
60
- const visited = new Set([String(startId)]);
61
- let frontier = [Number(startId)];
62
-
63
- for (let depth = 1; depth <= maxDepth && frontier.length > 0; depth += 1) {
64
- const { rows } = await q.query(sql, [frontier]);
65
- const next = [];
66
- for (const r of rows) {
67
- const key = String(r.id);
68
- if (visited.has(key)) continue; // already reached at a shallower depth
69
- visited.add(key);
70
- found.set(key, { id: r.id, depth, relation: r.relation });
71
- next.push(Number(r.id));
72
- }
73
- frontier = next;
74
- }
75
-
76
- return [...found.values()].sort((a, b) => a.depth - b.depth || Number(a.id) - Number(b.id));
132
+ // walk — the single-root walk, which is walkMany with one root. ONE
133
+ // implementation on purpose: the payout (R11) walks up and the descent surface
134
+ // (R10) walks down, and a second copy of the de-duplication would be free to
135
+ // drift from this one — a drift that shows up as money, not as a render bug.
136
+ async function walk(startId, direction, opts = {}) {
137
+ const byRoot = await walkMany([startId], direction, opts);
138
+ return byRoot.get(String(startId)) || [];
77
139
  }
78
140
 
79
141
  // ancestorsOfTask — every task this one descends from, nearest first, each at
@@ -87,6 +149,14 @@ function descendantsOfTask(taskId, opts = {}) {
87
149
  return walk(taskId, 'down', opts);
88
150
  }
89
151
 
152
+ // descendantsOfTasks — the descent below SEVERAL roots, in one batch. What the
153
+ // trail read (modules/ideas/trail-descent.js) consumes: a page of ideas has a
154
+ // root task per promotion, and the whole page must cost one query per level,
155
+ // not one walk per idea. Returns Map<String(rootId), rows>.
156
+ function descendantsOfTasks(taskIds, opts = {}) {
157
+ return walkMany(taskIds, 'down', opts);
158
+ }
159
+
90
160
  // linkTaskDescent — record that `taskId` came out of `parentTaskId`.
91
161
  //
92
162
  // Idempotent on the pair, because the derivation below runs on every ship and
@@ -209,6 +279,7 @@ module.exports = {
209
279
  linkTaskDescent,
210
280
  ancestorsOfTask,
211
281
  descendantsOfTask,
282
+ descendantsOfTasks,
212
283
  deriveEdgesForTask,
213
284
  syncDescentForTask,
214
285
  };
@@ -17,7 +17,8 @@
17
17
  ]
18
18
  },
19
19
  "provides": [
20
- "ideas.capture"
20
+ "ideas.capture",
21
+ "ideas.descent"
21
22
  ],
22
23
  "consumes": [
23
24
  "reward",
@@ -35,6 +35,12 @@
35
35
  const express = require('express');
36
36
  const api = require('../../../src/module-api');
37
37
  const inbox = require('../inbox');
38
+ // The trail read to the FULL depth of the descent (task 1004089, BV2.R09). Its
39
+ // own file because inbox.js is AT the size ratchet and because a task->task
40
+ // walk is a different relation from the idea->task trail.
41
+ const { attachDescentToTrail } = require('../trail-descent');
42
+ // The walk itself, published as the `ideas.descent` port below.
43
+ const descent = require('../descent');
38
44
  // Full Idea capture (task 1002919 / BV1.R143): the template validator (R141)
39
45
  // and the deterministic scorer (R142) — both pure, both walk the declaration.
40
46
  const { validateFieldValues, templateFor, fieldOrder } = require('../templates');
@@ -57,10 +63,34 @@ const { TRIAGE_NOTE_MAX } = inbox;
57
63
  // hook that runs both in the live server AND when a test rebuilds the GDS
58
64
  // router) and hasProvider-guarded so re-instantiation never trips the seam's
59
65
  // single-provider guard. Mirrors modules/economy/routes/reward.js.
66
+ // The `ideas.descent` kernel PORT (task 1004088, BV2.R09; ADR 0312 + ADR 0093
67
+ // §5). The ideator payout follows the task→task descent and decays by depth, so
68
+ // modules/economy/credits.js has to know how far a shipping task sits below the
69
+ // task an idea was promoted into.
70
+ //
71
+ // WHY A PORT AND NOT A SECOND SQL READ, which is what credits.js does for the
72
+ // lineage. A cross-table SQL read across the module wall is allowed and credits.js
73
+ // already relies on that; a cross-module require is not. But the descent is not a
74
+ // read, it is a WALK — breadth-first with a visited set, each node at its shortest
75
+ // depth — and a second copy of that in economy would be free to drift from this
76
+ // one. The payout decays by the depth the walk returns, so a drift between the two
77
+ // copies is not a rendering difference, it is a different amount of money. One
78
+ // implementation, reached through the switchboard.
79
+ //
80
+ // Registered here beside `ideas.capture` for the same reason: the route factory is
81
+ // the one boot hook that runs both in the live server and when a test rebuilds the
82
+ // GDS router. hasProvider-guarded so re-instantiation never trips the single-
83
+ // provider guard.
60
84
  function registerSeams() {
61
85
  if (!api.hasProvider('ideas.capture')) {
62
86
  api.registerProvider('ideas.capture', inbox.captureIdea);
63
87
  }
88
+ if (!api.hasProvider('ideas.descent')) {
89
+ api.registerProvider('ideas.descent', {
90
+ ancestorsOfTask: descent.ancestorsOfTask,
91
+ descendantsOfTask: descent.descendantsOfTask,
92
+ });
93
+ }
64
94
  }
65
95
 
66
96
  // The author-facing shape of a ratification window this request just opened —
@@ -552,6 +582,12 @@ module.exports = function buildInboxRouter() {
552
582
  inbox.listIdeaTrailForBuilder(builderId, { limit, offset, viewerRank }),
553
583
  inbox.countIdeaTrailForBuilder(builderId, { viewerRank }),
554
584
  ]);
585
+ // Everything those tasks went on to cause, to any depth and across version
586
+ // boundaries (BV2.R09, ADR 0312). Composed here rather than inside the trail
587
+ // read: the descent is a task->task walk, the trail is an idea->task read,
588
+ // and the walk is batched across the WHOLE page — so it stays one query per
589
+ // level however many ideas the window carries, never one walk per idea.
590
+ await attachDescentToTrail(ideas);
555
591
  return res.json({ builder_id: builderId, ideas, total, limit, offset });
556
592
  }, { errorCode: 'idea_trail_failed' }));
557
593
 
@@ -0,0 +1,224 @@
1
+ // modules/ideas/trail-descent.js — the idea trail, read to the FULL depth of
2
+ // the descent instead of one hop (task 1004089, BV2.R09, goal 1000095;
3
+ // ADR 0312, spec docs/specs/bongos-v2-ideator-surface.md).
4
+ //
5
+ // WHAT WAS MISSING. listIdeaTrailForBuilder (modules/ideas/inbox.js) answers
6
+ // "what became of the idea I filed" with `lineage_tasks[]` — the tasks the idea
7
+ // was promoted INTO, and nothing further. That is one hop. Everything those
8
+ // tasks then caused — the split that broke one of them in two, the cascade a
9
+ // ship fired, the task that folded into another — is recorded in
10
+ // ideas_task_descent and shown nowhere. An ideator reading their own trail sees
11
+ // the first thing their thought caused and none of the rest of it, which is the
12
+ // opposite of the promise the surface makes.
13
+ //
14
+ // WHY IT IS A SEPARATE FILE rather than more of listIdeaTrailForBuilder. Two
15
+ // reasons, and the second is the real one. inbox.js is AT the 1500-line size
16
+ // ratchet (scripts/gds/fitness-ratchets.js), so a line added there fails the
17
+ // build — the same wall that put descent.js beside it. And the descent is a
18
+ // different relation from the trail: the trail is idea → task (one query, one
19
+ // page window), the descent is task → task (a walk). Composing them here keeps
20
+ // the trail read a trail read, and keeps this walk usable by the payout and the
21
+ // detail surface without dragging the page window along with it.
22
+ //
23
+ // THREE FINDINGS INHERITED FROM THE ABANDONED TASK 1004054 are design
24
+ // requirements here, not history:
25
+ //
26
+ // 1. TEST AT THE RENDERED LAYER. The prior attempt tested the server-side
27
+ // trail builder only, which is exactly why finding 2 survived it. What a
28
+ // reader actually gets is HTML, so tests/idea_trail_descent.mjs runs the
29
+ // real modules/hall-ui/public/profile.js and asserts on that HTML.
30
+ // 2. A MERGED IDEA CARRIES ITS OWN TRIAGE NOTE. When idea A is merged into
31
+ // idea B, the descent A should show is the one running through B's tasks —
32
+ // that is what "nothing is lost" means (ADR 0311). The note beside it is
33
+ // still A's own, off the MERGED row, because that note is the sentence the
34
+ // triager wrote about A. Reading the note from the target instead is the
35
+ // bug that broke the headline scenario last time, so the merge follow here
36
+ // deliberately carries task ids ONLY, never prose.
37
+ // 3. NOT PER-HOP ROUND TRIPS. The prior attempt cost 4-6 serial round trips
38
+ // per read. This costs, for a WHOLE PAGE of ideas: one query to follow the
39
+ // merges, one query per level of the deepest descent on the page, and one
40
+ // to name the tasks found. The count tracks depth and is INDEPENDENT of
41
+ // how many ideas the page carries — pinned as an assertion, because that
42
+ // independence is the whole claim and it is invisible in review.
43
+ //
44
+ // ACROSS VERSIONS, deliberately and by omission: no read below filters on
45
+ // version_id, and the hydration returns it so a surface can SAY that a
46
+ // descendant lives in a later version. A thought does not stop being the origin
47
+ // of the work it caused because the work crossed a version boundary; the
48
+ // one-level cap and the cross-version refusal that ADR 0312 left alone belong
49
+ // to ideas_task_lineage, which is a different table.
50
+
51
+ const api = require('../../src/module-api');
52
+ const { pool } = api;
53
+ const { descendantsOfTasks, DESCENT_MAX_DEPTH } = require('./descent');
54
+
55
+ // A positive integer id, as it arrives off a row — where it may be a bigint
56
+ // serialised to a string. Anything else is dropped rather than sent to the
57
+ // database inside a bigint[].
58
+ function idOf(v) {
59
+ return /^[1-9]\d*$/.test(String(v)) ? String(v) : null;
60
+ }
61
+
62
+ // directRootsForIdea — the tasks an idea became, which are the roots its
63
+ // descent hangs below. ideas_task_lineage is the complete answer (one idea can
64
+ // become many tasks, task 1002997); promoted_to_task_id is the only answer for
65
+ // promotions that predate that table, so it is the fallback, exactly as the
66
+ // trail read and the profile renderer already treat it.
67
+ function directRootsForIdea(idea) {
68
+ const out = [];
69
+ const lineage = Array.isArray(idea.lineage_tasks) ? idea.lineage_tasks : [];
70
+ for (const t of lineage) {
71
+ const id = idOf(t && t.task_id);
72
+ if (id) out.push(id);
73
+ }
74
+ if (out.length === 0) {
75
+ const legacy = idOf(idea.promoted_to_task_id);
76
+ if (legacy) out.push(legacy);
77
+ }
78
+ return out;
79
+ }
80
+
81
+ // followMerges — a merged idea inherits its TARGET's root tasks.
82
+ //
83
+ // One query for every merge on the page. The read is task ids and nothing else:
84
+ // see finding 2 above — prose from the target row must not travel with them, or
85
+ // the merged idea starts showing somebody else's triage note as its own.
86
+ //
87
+ // Only one hop is followed. A merge chain cannot exist: updateIdea in
88
+ // modules/ideas/inbox.js throws MERGE_TARGET_MERGED when the target is itself
89
+ // merged (the no-chains guard, core_195), so a second hop would be walking an
90
+ // edge the writer will not write.
91
+ async function followMerges(ideas, q) {
92
+ const targets = new Map(); // targetIdeaId -> [idea, ...] that merged into it
93
+ for (const idea of ideas) {
94
+ if (idea.status !== 'merged') continue;
95
+ const target = idOf(idea.merged_into_idea_id);
96
+ if (!target) continue;
97
+ if (!targets.has(target)) targets.set(target, []);
98
+ targets.get(target).push(idea);
99
+ }
100
+ if (targets.size === 0) return new Map();
101
+
102
+ const { rows } = await q.query(
103
+ `SELECT i.id AS idea_id, i.promoted_to_task_id, l.task_id
104
+ FROM idea_inbox i
105
+ LEFT JOIN ideas_task_lineage l ON l.idea_id = i.id
106
+ WHERE i.id = ANY($1::bigint[])`,
107
+ [[...targets.keys()]]
108
+ );
109
+
110
+ const byTarget = new Map();
111
+ for (const row of rows) {
112
+ const target = String(row.idea_id);
113
+ if (!byTarget.has(target)) byTarget.set(target, { lineage: [], legacy: idOf(row.promoted_to_task_id) });
114
+ const id = idOf(row.task_id);
115
+ if (id) byTarget.get(target).lineage.push(id);
116
+ }
117
+
118
+ const inherited = new Map(); // ideaId -> [rootTaskId, ...]
119
+ for (const [target, seen] of byTarget) {
120
+ const roots = seen.lineage.length > 0 ? seen.lineage : (seen.legacy ? [seen.legacy] : []);
121
+ for (const idea of targets.get(target) || []) {
122
+ inherited.set(String(idea.id), roots);
123
+ }
124
+ }
125
+ return inherited;
126
+ }
127
+
128
+ // hydrate — turn the ids the walk found into things a reader can read. One
129
+ // query for every descendant on the page.
130
+ //
131
+ // version_id rides along BECAUSE the descent crosses versions: a descendant in
132
+ // a later version is the interesting case, not an anomaly to hide, and a
133
+ // surface cannot mark it without being told.
134
+ async function hydrate(taskIds, q) {
135
+ const byId = new Map();
136
+ if (taskIds.length === 0) return byId;
137
+ const { rows } = await q.query(
138
+ `SELECT id, title, status, version_id, kind
139
+ FROM tasks
140
+ WHERE id = ANY($1::bigint[])`,
141
+ [taskIds.map(Number)]
142
+ );
143
+ for (const row of rows) byId.set(String(row.id), row);
144
+ return byId;
145
+ }
146
+
147
+ // attachDescentToTrail — the whole read, composed.
148
+ //
149
+ // Mutates nothing the caller did not ask for: every idea comes back with a
150
+ // `descent_tasks` array, empty for an idea that caused nothing. Empty is an
151
+ // ANSWER here, the same way 'still open' is an outcome on the trail — a filer
152
+ // whose idea produced one task and no descent must see that, not a missing key
153
+ // the renderer has to guess about.
154
+ async function attachDescentToTrail(ideas, { client = null, maxDepth = DESCENT_MAX_DEPTH } = {}) {
155
+ const list = Array.isArray(ideas) ? ideas : [];
156
+ if (list.length === 0) return list;
157
+ const q = client || pool;
158
+
159
+ const inherited = await followMerges(list, q);
160
+
161
+ // rootsByIdea is what each idea's descent hangs below; rootUnion is what goes
162
+ // to the walk, deduplicated, so two ideas sharing a root cost one walk.
163
+ const rootsByIdea = new Map();
164
+ const rootUnion = new Set();
165
+ for (const idea of list) {
166
+ const roots = new Set(directRootsForIdea(idea));
167
+ for (const r of inherited.get(String(idea.id)) || []) roots.add(r);
168
+ rootsByIdea.set(String(idea.id), roots);
169
+ for (const r of roots) rootUnion.add(r);
170
+ }
171
+
172
+ const byRoot = rootUnion.size > 0
173
+ ? await descendantsOfTasks([...rootUnion], { client, maxDepth })
174
+ : new Map();
175
+
176
+ const found = new Set();
177
+ for (const rows of byRoot.values()) for (const r of rows) found.add(String(r.id));
178
+ const titles = await hydrate([...found], q);
179
+
180
+ for (const idea of list) {
181
+ const roots = rootsByIdea.get(String(idea.id)) || new Set();
182
+ // Shortest depth wins when a descendant is reachable from two of this
183
+ // idea's roots — the same rule the walk applies within one root, for the
184
+ // same reason: the payout decays by depth, so one descendant must resolve
185
+ // to one depth or the origin is paid twice for it (ADR 0312).
186
+ const best = new Map();
187
+ for (const root of roots) {
188
+ for (const row of byRoot.get(root) || []) {
189
+ const key = String(row.id);
190
+ // A root of THIS idea is already in lineage_tasks — the trail shows it
191
+ // as what the idea became, so repeating it below as its own descendant
192
+ // would double-count the first hop.
193
+ if (roots.has(key)) continue;
194
+ const prev = best.get(key);
195
+ if (prev && prev.depth <= row.depth) continue;
196
+ best.set(key, { depth: row.depth, relation: row.relation, origin_task_id: root });
197
+ }
198
+ }
199
+
200
+ idea.descent_tasks = [...best.entries()]
201
+ .map(([taskId, hit]) => {
202
+ const t = titles.get(taskId) || {};
203
+ return {
204
+ task_id: taskId,
205
+ depth: hit.depth,
206
+ relation: hit.relation,
207
+ origin_task_id: hit.origin_task_id,
208
+ title: t.title ?? null,
209
+ status: t.status ?? null,
210
+ version_id: t.version_id ?? null,
211
+ kind: t.kind ?? null,
212
+ };
213
+ })
214
+ .sort((a, b) => a.depth - b.depth || Number(a.task_id) - Number(b.task_id));
215
+ }
216
+
217
+ return list;
218
+ }
219
+
220
+ module.exports = {
221
+ attachDescentToTrail,
222
+ directRootsForIdea,
223
+ followMerges,
224
+ };
@@ -371,11 +371,26 @@ async function awardLandingCredit(client, { taskId, creditedBuilderId, creditsRe
371
371
  const ideaBonus = reward.awardIdeaTaskShipCredit
372
372
  ? await reward.awardIdeaTaskShipCredit(client, { taskId })
373
373
  : null;
374
+ // BV2.R09 (ADR 0312): the same stream, for the origins this task DESCENDS
375
+ // from. The direct payout above pays the idea whose lineage row names this
376
+ // task; this pays the ideas above it in the task→task descent, decayed by
377
+ // depth. Its skipIdeaIds carries whatever the direct payout just settled, so
378
+ // an idea that is both the direct source AND an ancestor's source is paid the
379
+ // full share once rather than the full share plus a decayed one.
380
+ // Guarded the same way — an older economy module without this export, or an
381
+ // instance with no ideas module, simply pays nothing extra.
382
+ const ideaDescent = reward.awardIdeaDescentShipCredit
383
+ ? await reward.awardIdeaDescentShipCredit(client, {
384
+ taskId,
385
+ skipIdeaIds: ideaBonus && ideaBonus.ideaId != null ? [ideaBonus.ideaId] : [],
386
+ })
387
+ : null;
374
388
  return {
375
389
  creditsAwarded: credit.creditsAwarded,
376
390
  creditsRaw: credit.raw,
377
391
  creditsMultiplier: credit.multiplier,
378
392
  ideaBonus,
393
+ ideaDescent,
379
394
  };
380
395
  }
381
396
 
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.900",
3
+ "version": "1.19.902",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.900",
9
+ "version": "1.19.902",
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.900",
3
+ "version": "1.19.902",
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",