@bongos/core 1.19.899 → 1.19.901

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.
@@ -0,0 +1,285 @@
1
+ // modules/ideas/descent.js — task→task provenance edges: the relation that
2
+ // makes an origin tag survive a rewrite, a split and a merge (task 1004086,
3
+ // BV2.R07, goal 1000095; ADR 0312).
4
+ //
5
+ // WHY THIS IS ITS OWN FILE, beside inbox.js rather than inside it. ADR 0312's
6
+ // central claim is that provenance is a DIFFERENT RELATION from both work
7
+ // decomposition (tasks.parent_task_id) and idea→task lineage
8
+ // (ideas_task_lineage): the first answers "what is this work part of", the
9
+ // second "which thought opened this work", and this one "where did this work
10
+ // come from". Keeping it in its own module is that argument in the file layout
11
+ // — and it keeps inbox.js under the size ratchet, which adding it there broke.
12
+ //
13
+ // Reads the shared pool through the published doorway (src/module-api.js — the
14
+ // only core file a module may require, ADR 0083), the same way inbox.js does.
15
+
16
+ const api = require('../../src/module-api');
17
+ const { pool } = api;
18
+
19
+ // The walk is bounded, but the bound is a backstop, not the safety property —
20
+ // see walk() below, which cannot revisit a node and therefore cannot loop.
21
+ const DESCENT_MAX_DEPTH = 64;
22
+
23
+ const DESCENT_RELATIONS = Object.freeze(['rewrite', 'split', 'merge', 'fold', 'cascade']);
24
+
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.
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.
113
+ // WHY NOT A RECURSIVE CTE, which is the obvious shape and was the first cut.
114
+ // A recursive CTE's working table re-expands every path independently, so a
115
+ // densely-connected DAG — paths that converge and diverge again, which is
116
+ // exactly what a merge followed by a split produces — costs work exponential
117
+ // in depth, and a final GROUP BY cannot undo that because the blow-up has
118
+ // already happened inside the recursion. A depth bound caps the depth and does
119
+ // nothing about the breadth.
120
+ //
121
+ // Level-by-level BFS with a visited set fixes both at once. A node is expanded
122
+ // AT MOST ONCE ever, so the total work is bounded by the size of the reachable
123
+ // subgraph rather than by the number of paths through it, and the FIRST time a
124
+ // node is reached is necessarily its SHORTEST depth — which is the depth the
125
+ // payout decays by, and the one that stops a single descendant paying the same
126
+ // origin twice. It also makes cycle-safety structural rather than a bound to
127
+ // remember: a cycle simply has nothing new to visit and the walk ends.
128
+ //
129
+ // The cost is one round trip per level instead of one per walk. Depth decay
130
+ // halves each hop (spec decision 16), so a descent deep enough for that to
131
+ // matter is already worth almost nothing.
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)) || [];
139
+ }
140
+
141
+ // ancestorsOfTask — every task this one descends from, nearest first, each at
142
+ // its shortest depth. This is what the payout (R09) walks.
143
+ function ancestorsOfTask(taskId, opts = {}) {
144
+ return walk(taskId, 'up', opts);
145
+ }
146
+
147
+ // descendantsOfTask — the other direction, for the descent view (R10).
148
+ function descendantsOfTask(taskId, opts = {}) {
149
+ return walk(taskId, 'down', opts);
150
+ }
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
+
160
+ // linkTaskDescent — record that `taskId` came out of `parentTaskId`.
161
+ //
162
+ // Idempotent on the pair, because the derivation below runs on every ship and
163
+ // may legitimately see the same signal twice.
164
+ async function linkTaskDescent(taskId, parentTaskId, relation, { client = null } = {}) {
165
+ const child = Number(taskId);
166
+ const parent = Number(parentTaskId);
167
+ if (!Number.isInteger(child) || !Number.isInteger(parent) || child <= 0 || parent <= 0) {
168
+ throw { code: 'DESCENT_BAD_ID', task_id: taskId, parent_task_id: parentTaskId };
169
+ }
170
+ if (child === parent) {
171
+ throw { code: 'DESCENT_SELF_EDGE', task_id: child };
172
+ }
173
+ // Validated here as well as by the DB CHECK. The constraint is the guarantee;
174
+ // this is the error message — a rejected enum should name the caller's bad
175
+ // value, not surface as a constraint violation from three frames down.
176
+ if (!DESCENT_RELATIONS.includes(relation)) {
177
+ throw { code: 'DESCENT_BAD_RELATION', relation, allowed: DESCENT_RELATIONS };
178
+ }
179
+ // Would this close a loop? It does if the proposed PARENT already DESCENDS
180
+ // from the child. Unbounded on purpose: walk() cannot revisit a node, so it
181
+ // always terminates, and a bound here would silently miss a cycle whose
182
+ // closing edge sits deeper than the bound — which is the one case that
183
+ // matters, because that cycle is the one that gets written.
184
+ const reachable = await descendantsOfTask(child, { client, maxDepth: Infinity });
185
+ if (reachable.some((r) => Number(r.id) === parent)) {
186
+ throw { code: 'DESCENT_CYCLE', task_id: child, parent_task_id: parent };
187
+ }
188
+ const q = client || pool;
189
+ await q.query(
190
+ `INSERT INTO ideas_task_descent (task_id, parent_task_id, relation)
191
+ VALUES ($1, $2, $3)
192
+ ON CONFLICT (task_id, parent_task_id) DO UPDATE
193
+ SET relation = EXCLUDED.relation`,
194
+ [child, parent, relation]
195
+ );
196
+ }
197
+
198
+ // ── the derivation: three signals the system already has ────────────────────
199
+ //
200
+ // ADR 0312's finding was that three seams already know a task was derived and
201
+ // none of them is telling anyone. This turns each into an edge.
202
+ //
203
+ // WHY IT IS PULLED FROM `tasks` HERE rather than written by the code that
204
+ // causes it. A module may not import another module (ADR 0083, rule 1), so
205
+ // modules/lifecycle/cascade.js cannot call into this file, and `tasks` is the
206
+ // protected lifecycle module's table so this may not add a column to it. What
207
+ // IS available is a read of `tasks` (the ideas_002 precedent — a FK, and a
208
+ // SELECT, are reads) and the `task.shipped` event on the published doorway.
209
+ // So the ideas module derives its own edges from facts already on the row,
210
+ // reacting to the event. Nothing in lifecycle changes.
211
+ const CASCADE_SOURCE_REF = /-(\d+)$/;
212
+
213
+ async function deriveEdgesForTask(taskId, { client = null } = {}) {
214
+ const q = client || pool;
215
+ const id = Number(taskId);
216
+ if (!Number.isInteger(id) || id <= 0) return [];
217
+
218
+ const { rows } = await q.query(
219
+ `SELECT t.id, t.source, t.source_ref, t.parent_task_id,
220
+ (SELECT array_agg(m.id)
221
+ FROM tasks m
222
+ WHERE m.merged_into_task_id = t.id) AS folded_in
223
+ FROM tasks t
224
+ WHERE t.id = $1`,
225
+ [id]
226
+ );
227
+ const t = rows[0];
228
+ if (!t) return [];
229
+
230
+ const edges = [];
231
+
232
+ // 1. CASCADE — cascade.js writes `cascade-<key>-<originating id>` into
233
+ // source_ref. The key itself contains dashes ('artist-review-on-ship'),
234
+ // so the id is the trailing numeric run, not the third segment.
235
+ if (t.source === 'cascade' && typeof t.source_ref === 'string') {
236
+ const m = CASCADE_SOURCE_REF.exec(t.source_ref);
237
+ if (m) edges.push({ parent: Number(m[1]), relation: 'cascade' });
238
+ }
239
+
240
+ // 2. SPLIT — the one-level work decomposition. The hierarchy keeps owning
241
+ // what the work is PART OF; this records that it CAME FROM it.
242
+ if (t.parent_task_id != null) {
243
+ edges.push({ parent: Number(t.parent_task_id), relation: 'split' });
244
+ }
245
+
246
+ // 3. FOLD — tasks abandoned INTO this one (core_232 constrains
247
+ // merged_into_task_id to abandoned rows). The mockup's "satellite": work
248
+ // that stopped being its own thing and now orbits this one.
249
+ for (const foldedId of t.folded_in || []) {
250
+ edges.push({ parent: Number(foldedId), relation: 'fold' });
251
+ }
252
+
253
+ return edges.filter((e) => Number.isInteger(e.parent) && e.parent > 0 && e.parent !== id);
254
+ }
255
+
256
+ // syncDescentForTask — derive and record, tolerantly.
257
+ //
258
+ // Returns what it wrote and what it skipped rather than throwing: this runs off
259
+ // a ship event, and a provenance edge must never be able to break a ship. A
260
+ // refused cycle is a normal outcome here, not an error.
261
+ async function syncDescentForTask(taskId, { client = null } = {}) {
262
+ const edges = await deriveEdgesForTask(taskId, { client });
263
+ const written = [];
264
+ const skipped = [];
265
+ for (const e of edges) {
266
+ try {
267
+ await linkTaskDescent(taskId, e.parent, e.relation, { client });
268
+ written.push(e);
269
+ } catch (err) {
270
+ skipped.push({ ...e, code: (err && err.code) || 'UNKNOWN' });
271
+ }
272
+ }
273
+ return { written, skipped };
274
+ }
275
+
276
+ module.exports = {
277
+ DESCENT_MAX_DEPTH,
278
+ DESCENT_RELATIONS,
279
+ linkTaskDescent,
280
+ ancestorsOfTask,
281
+ descendantsOfTask,
282
+ descendantsOfTasks,
283
+ deriveEdgesForTask,
284
+ syncDescentForTask,
285
+ };
@@ -0,0 +1,111 @@
1
+ -- ideas_007_task_descent_edges.sql — task→task provenance edges (task 1004086,
2
+ -- BV2.R07, goal 1000095 "Working area 6"; ADR 0312).
3
+ --
4
+ -- WHY A SECOND TABLE AND NOT A COLUMN ON ideas_task_lineage. The spike (task
5
+ -- 1004080) was told to confirm the lineage table rather than assume it, and
6
+ -- confirmed one half while refuting the other. As the idea→task ANCHOR the
7
+ -- table is right: it is bipartite, carries no depth cap and no version
8
+ -- predicate, and so has none of the invariants that close the task hierarchy to
9
+ -- this. But a derivation is a property of the TASK PAIR, not of the
10
+ -- (task, idea) pair that ideas_006 made the key. Adding derived_from_task_id
11
+ -- there would write the same derivation once per co-source idea — and
12
+ -- co-source ideas are legal and EXPECTED since ideas_006 (merging duplicate
13
+ -- reports into one task is the triage move the inbox exists to encourage) — so
14
+ -- two rows for one task could disagree about where that task came from, with
15
+ -- nothing to arbitrate. ideas_006 exists precisely because an under-keyed row
16
+ -- silently lost attribution on prod and it took months to notice.
17
+ --
18
+ -- WHY NOT tasks.parent_task_id. modules/lifecycle/hierarchy.js caps parentage
19
+ -- at ONE level and refuses cross-version trees (SELF_PARENT, VERSION_MISMATCH,
20
+ -- PARENT_HAS_PARENT, TARGET_HAS_CHILDREN). A descent — idea → task → rewrite →
21
+ -- split → removal — is four levels and crosses a version boundary. Both
22
+ -- invariants are load-bearing for ship rollup and claim safety, and widening
23
+ -- them is out of scope (spec decision 8). Provenance is a DIFFERENT RELATION
24
+ -- from work decomposition: parent_task_id answers "what is this work part of",
25
+ -- this table answers "where did this work come from". They are not redundant.
26
+ --
27
+ -- WHY IT LIVES HERE, NAMESPACED. `tasks` is the PROTECTED lifecycle module and
28
+ -- a module migration may not ALTER a table outside its own namespace (ADR 0083
29
+ -- §Decision #5, fitness Check 11). So this is an IDEAS-OWNED side table keyed by
30
+ -- task ids — the same shape ideas_002 used for the same reason. FKs to existing
31
+ -- tables are reads, not schema changes to them, so it stays inside the wall.
32
+ --
33
+ -- Module migrations are ADDITIVE and NAMESPACED: every object is ideas_-prefixed,
34
+ -- no down-migration, idempotent (safe to re-run).
35
+
36
+ BEGIN;
37
+
38
+ CREATE TABLE IF NOT EXISTS ideas_task_descent (
39
+ -- The DERIVED task — the one that came out of something else.
40
+ task_id bigint NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
41
+
42
+ -- What it came FROM. A task may have SEVERAL parents: a merge folds two
43
+ -- pieces of work into one, so this is a DAG, not a tree. That is why the key
44
+ -- is the pair rather than task_id alone — the ideas_006 lesson, applied up
45
+ -- front instead of after an incident.
46
+ parent_task_id bigint NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
47
+
48
+ -- HOW it was derived. Three of these are written from signals the system
49
+ -- ALREADY has and currently throws away (ADR 0312): 'cascade' from the
50
+ -- originating id that cascade.js encodes inside source_ref as a string
51
+ -- nothing can join on, 'fold' from tasks.merged_into_task_id, 'split' from
52
+ -- tasks.parent_task_id. Only 'rewrite' and cross-version 'merge' are
53
+ -- editorial acts with no automatic signal, so only those depend on someone
54
+ -- saying so — and their absence degrades the picture rather than corrupting
55
+ -- it. Widen this CHECK the ideas_003 way if a sixth relation ever appears.
56
+ relation text NOT NULL
57
+ CHECK (relation IN ('rewrite', 'split', 'merge', 'fold', 'cascade')),
58
+
59
+ created_at timestamptz NOT NULL DEFAULT now(),
60
+
61
+ -- A task may hold at most one edge to a given parent; several parents are fine.
62
+ PRIMARY KEY (task_id, parent_task_id),
63
+
64
+ -- A SELF-EDGE IS AN INFINITE WALK, NOT A COSMETIC ERROR. The payout walks
65
+ -- this graph (R09) and the descent reads it (R10), so a cycle does not render
66
+ -- oddly — it fails to terminate. This CHECK refuses the one-node case in the
67
+ -- schema; longer cycles cannot be expressed as a row constraint and are
68
+ -- refused by the writer, which walks to the root before inserting.
69
+ CONSTRAINT ideas_task_descent_no_self_edge CHECK (task_id <> parent_task_id)
70
+ );
71
+
72
+ -- Two directions, two indexes, because both are hot and neither is served by
73
+ -- the other. The DESCENT walks DOWN from an origin (children of a parent); the
74
+ -- PAYOUT walks UP from a task that just shipped (its ancestors). The primary
75
+ -- key's implicit index already covers the (task_id, …) direction, so only the
76
+ -- parent side needs its own.
77
+ CREATE INDEX IF NOT EXISTS idx_ideas_task_descent_parent
78
+ ON ideas_task_descent (parent_task_id);
79
+
80
+ -- SAFETY NET — a migration that cannot prove its own effect must not report
81
+ -- success (the ideas_003 / ideas_005 / ideas_006 precedent). Read-only, no DDL.
82
+ -- If the table came back without the composite key, a task could hold two
83
+ -- contradictory edges to the same parent; if it came back without the self-edge
84
+ -- CHECK, the first bad write hangs the payout walk instead of being refused.
85
+ DO $$
86
+ DECLARE pk_cols text; has_self_check boolean;
87
+ BEGIN
88
+ SELECT string_agg(a.attname, ',' ORDER BY a.attname) INTO pk_cols
89
+ FROM pg_constraint c
90
+ JOIN unnest(c.conkey) AS k(attnum) ON true
91
+ JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = k.attnum
92
+ WHERE c.conrelid = 'ideas_task_descent'::regclass
93
+ AND c.contype = 'p';
94
+ IF pk_cols IS DISTINCT FROM 'parent_task_id,task_id' THEN
95
+ RAISE EXCEPTION
96
+ 'ideas_007: ideas_task_descent primary key is (%) — expected the composite (task_id, parent_task_id).', COALESCE(pk_cols, 'none');
97
+ END IF;
98
+
99
+ SELECT EXISTS (
100
+ SELECT 1 FROM pg_constraint
101
+ WHERE conrelid = 'ideas_task_descent'::regclass
102
+ AND contype = 'c'
103
+ AND conname = 'ideas_task_descent_no_self_edge'
104
+ ) INTO has_self_check;
105
+ IF NOT has_self_check THEN
106
+ RAISE EXCEPTION
107
+ 'ideas_007: the no-self-edge CHECK is missing — a self-edge would make the payout walk non-terminating.';
108
+ END IF;
109
+ END $$;
110
+
111
+ COMMIT;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "key": "ideas",
3
3
  "title": "Ideas",
4
- "description": "The idea inbox (capture, triage, criterion-proposal ratification) and the blockers queue \u2014 the methodology surfaces that feed the backlog. A core-domain module carved out of the GDS core in BV1.R78 (ADR 0093 \u00a71); default-on because every instance captures ideas and tracks what's stuck on its owner. Registers the `ideas.capture` kernel PORT the discord #ideas inbound resolves (ADR 0093 \u00a75 \u2014 retiring the temporary `captureIdea` doorway leak).",
4
+ "description": "The idea inbox (capture, triage, criterion-proposal ratification) and the blockers queue — the methodology surfaces that feed the backlog. A core-domain module carved out of the GDS core in BV1.R78 (ADR 0093 §1); default-on because every instance captures ideas and tracks what's stuck on its owner. Registers the `ideas.capture` kernel PORT the discord #ideas inbound resolves (ADR 0093 §5 — retiring the temporary `captureIdea` doorway leak).",
5
5
  "coreVersion": "^1.8.0",
6
6
  "default": true,
7
7
  "maintenance": {
@@ -11,6 +11,9 @@
11
11
  "routes": [
12
12
  "inbox",
13
13
  "blockers"
14
+ ],
15
+ "pollers": [
16
+ "descent-writer"
14
17
  ]
15
18
  },
16
19
  "provides": [
@@ -0,0 +1,69 @@
1
+ // modules/ideas/pollers/descent-writer.js — the production writer for
2
+ // task→task provenance edges (task 1004086, BV2.R07, goal 1000095; ADR 0312).
3
+ //
4
+ // WHY A POLLER AND NOT A CALL FROM THE CODE THAT CAUSES THE DERIVATION. The
5
+ // three signals ADR 0312 identified all live on `tasks`, which belongs to the
6
+ // protected `lifecycle` module, and a module may not import another module
7
+ // (ADR 0083, rule 1: one doorway, one direction). So cascade.js cannot call
8
+ // into `ideas`, and `ideas` cannot reach into lifecycle's code. The published
9
+ // doorway's `task.shipped` event is the seam that legitimately joins them, and
10
+ // the agents module already uses it for exactly this shape of reaction
11
+ // (modules/agents/pollers/event-router.js).
12
+ //
13
+ // "Poller" here means "something the module runs in the web process", not
14
+ // "something on a clock" — it owns no timer, and the same off→on replacement
15
+ // idiom keeps a reloaded loader from accumulating duplicate listeners, which
16
+ // would make every ship derive its edges twice.
17
+ //
18
+ // A PROVENANCE EDGE MUST NEVER BREAK A SHIP. The handler is deliberately
19
+ // total: syncDescentForTask already returns its refusals rather than throwing
20
+ // (a refused cycle is a normal outcome, not an error), and this wraps the whole
21
+ // call anyway. The worst failure mode available to it is a missing edge, which
22
+ // degrades the picture. The one it must not have is a thrown error on the ship
23
+ // path — a reactor must never break the actor.
24
+
25
+ const api = require('../../../src/module-api');
26
+ const { syncDescentForTask } = require('../descent');
27
+
28
+ const log = api.logger('ideas/descent-writer');
29
+ const EVENT = 'task.shipped';
30
+
31
+ let _off = null;
32
+
33
+ // The event carries the TASK ROW, the same subject modules/agents receives.
34
+ async function onShipped(task) {
35
+ const taskId = task && task.id;
36
+ if (taskId == null) return undefined;
37
+ try {
38
+ const { written, skipped } = await syncDescentForTask(taskId);
39
+ if (written.length > 0 || skipped.length > 0) {
40
+ log.info(
41
+ `ideas/descent-writer: task ${taskId} — ${written.length} edge(s) recorded`
42
+ + (skipped.length > 0 ? `, ${skipped.length} refused` : ''),
43
+ );
44
+ }
45
+ } catch (err) {
46
+ // A reactor must never break the actor.
47
+ log.error(`ideas/descent-writer: derivation threw for task ${taskId}: ${err && err.message}`);
48
+ }
49
+ return undefined;
50
+ }
51
+
52
+ function start() {
53
+ if (_off) return;
54
+ _off = api.on(EVENT, onShipped);
55
+ log.info(`[ideas/descent-writer] listening on ${EVENT} — deriving provenance edges from source_ref, parent_task_id and merged_into_task_id`);
56
+ }
57
+
58
+ function stop() {
59
+ if (_off) { try { _off(); } catch (_) { /* already gone */ } }
60
+ _off = null;
61
+ }
62
+
63
+ module.exports = {
64
+ start,
65
+ stop,
66
+ // Exported for tests: the handler is the whole reaction, and it needs no
67
+ // server to prove.
68
+ onShipped,
69
+ };
@@ -35,6 +35,10 @@
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');
38
42
  // Full Idea capture (task 1002919 / BV1.R143): the template validator (R141)
39
43
  // and the deterministic scorer (R142) — both pure, both walk the declaration.
40
44
  const { validateFieldValues, templateFor, fieldOrder } = require('../templates');
@@ -552,6 +556,12 @@ module.exports = function buildInboxRouter() {
552
556
  inbox.listIdeaTrailForBuilder(builderId, { limit, offset, viewerRank }),
553
557
  inbox.countIdeaTrailForBuilder(builderId, { viewerRank }),
554
558
  ]);
559
+ // Everything those tasks went on to cause, to any depth and across version
560
+ // boundaries (BV2.R09, ADR 0312). Composed here rather than inside the trail
561
+ // read: the descent is a task->task walk, the trail is an idea->task read,
562
+ // and the walk is batched across the WHOLE page — so it stays one query per
563
+ // level however many ideas the window carries, never one walk per idea.
564
+ await attachDescentToTrail(ideas);
555
565
  return res.json({ builder_id: builderId, ideas, total, limit, offset });
556
566
  }, { errorCode: 'idea_trail_failed' }));
557
567