@bongos/core 1.19.915 → 1.19.917

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,103 @@
1
+ // modules/ideas/projection.js — the SHAPE every idea read returns (task 1004115).
2
+ //
3
+ // Carved out of inbox.js, which sat at exactly the 1,500-line ratchet ceiling
4
+ // (scripts/gds/fitness-ratchets.js OVERSIZE_LINES) and could therefore not grow
5
+ // by one line. That ceiling had started deciding the design rather than the
6
+ // domain: modules/ideas/CLAUDE.md records two files (trail-descent.js,
7
+ // trail-size.js) that exist partly because of it, and task 1004084 shipped an
8
+ // inline require() inside a call expression to save a line.
9
+ //
10
+ // THIS FILE IS THE BOTTOM OF THE MODULE. It holds the column lists, the grade
11
+ // vocabulary and the join fragment that the reads in reads.js, trail.js and
12
+ // grades.js all splice into SQL — the "one column list every idea read returns"
13
+ // argument below is exactly why they must share a file rather than a copy. It
14
+ // requires NOTHING: no doorway, no pool, no sibling. That is deliberate and
15
+ // load-bearing — every other file in the carve depends on this one, so anything
16
+ // added here that reached back up would make the module cyclic
17
+ // (require_cycle_count is a baselined ratchet).
18
+
19
+ // The one column list every idea read returns. Four copies had to stay in step
20
+ // (capture / list / get / update-RETURNING); core_195 added two columns to each,
21
+ // so it is now a constant rather than four places to forget. Kept as names rather
22
+ // than a string so the qualified form (task 1002916 joined the grade table in, and
23
+ // an unqualified `id` is ambiguous across a join) is derived, not a fifth copy.
24
+ const IDEA_COL_NAMES = [
25
+ 'id', 'title', 'body_md', 'kind', 'suggested_version', 'suggested_priority',
26
+ 'captured_at', 'captured_by', 'status', 'promoted_to_task_id', 'reviewed_at',
27
+ 'merged_into_idea_id', 'triage_note',
28
+ // core_222 (task 1003060, BV1.R02): the routing HINT — "this probably belongs
29
+ // to goal N" — on a row that did NOT convert. Added to the shared list because
30
+ // R02 shipped the column and nothing read it: /idea-triage (R13) cannot walk
31
+ // homeless-only work if the read that feeds it never returns the hint. Strictly
32
+ // advisory: no rank check, no scope wall, and no payout may read it (see the
33
+ // migration's own header). The BINDING home for "which goal" is tasks.goal_id.
34
+ 'suggested_goal_id',
35
+ ];
36
+ const IDEA_COLS = IDEA_COL_NAMES.join(', ');
37
+ const IDEA_COLS_Q = IDEA_COL_NAMES.map((c) => `i.${c}`).join(', ');
38
+
39
+ // -------------------------------------------------------------------------
40
+ // Idea grades (task 1002916 / BV1.R140, goal 1000062)
41
+ //
42
+ // Three grades, from the owner's brief:
43
+ // quick — title + description, open to every role, aimed at turning a thought
44
+ // into a task fast. NOT a draft Full Idea.
45
+ // spark — a Quick Idea whose AUTHOR marked it as needing development. The mark
46
+ // is the consent that makes cross-builder development fair (R144).
47
+ // full — carries the base template's five fields, scored on completeness.
48
+ //
49
+ // AN IDEA WITH NO ideas_grades ROW READS AS QUICK. That is the design, not a
50
+ // fallback: every idea captured before this shipped keeps grade='quick' without
51
+ // a row being written or a column being backfilled. The COALESCE below is where
52
+ // that rule lives, and it is the reason the reads LEFT JOIN rather than JOIN.
53
+ // An explicit quick row is equally legal (un-sparking writes one) and reads the
54
+ // same, so callers never have to care which of the two they are looking at.
55
+ const DEFAULT_GRADE = 'quick';
56
+ const VALID_GRADES = ['quick', 'spark', 'full'];
57
+
58
+ // Which template `field_values` was written against. Stored per row so a project
59
+ // that later defines its own shape (R156) does not retro-reinterpret ideas
60
+ // written against the base one. R141 declares what 'base-v1' actually contains —
61
+ // this module only records which template a row used.
62
+ const DEFAULT_TEMPLATE_KEY = 'base-v1';
63
+ const TEMPLATE_KEY_RE = /^[a-z][a-z0-9-]*$/;
64
+ const TEMPLATE_KEY_MAX = 64;
65
+
66
+ // Mirrors ideas_grades_field_values_size_chk. Declared here too so an oversize
67
+ // payload gets a structured 400 instead of a constraint-violation 500.
68
+ const FIELD_VALUES_MAX = 32768;
69
+
70
+ // The grade half of an idea read: the COALESCE above, plus the template-shaped
71
+ // store and the score. `g.*` columns are NULL for an ungraded idea, which is
72
+ // exactly right — it has no template and no score, it just has a default grade.
73
+ // developed_by/developed_at (ideas_005, task 1002922 / BV1.R146) ride along on
74
+ // EVERY idea read rather than needing a second query: "who developed this" is
75
+ // part of an idea's identity once it has been developed, and the R145 queue, the
76
+ // hall detail view (R153) and the R147 credit split all want it. NULL for the
77
+ // overwhelming majority of rows — a quick idea and a self-authored Full Idea were
78
+ // never developed by anyone — which is the honest answer, not a missing one.
79
+ const GRADE_COLS_Q = `COALESCE(g.grade, '${DEFAULT_GRADE}') AS grade,
80
+ g.template_key, g.field_values, g.completeness_score,
81
+ g.developed_by, g.developed_at`;
82
+ const IDEA_JOIN_GRADE = `LEFT JOIN ideas_grades g ON g.idea_id = i.id`;
83
+
84
+ // The same projection for a row that provably has no grade row yet (a just-
85
+ // INSERTed idea), so INSERT…RETURNING does not need a second round trip to
86
+ // return the same shape every other read returns.
87
+ function ungradedProjection() {
88
+ return { grade: DEFAULT_GRADE, template_key: null, field_values: null, completeness_score: null, developed_by: null, developed_at: null };
89
+ }
90
+
91
+ module.exports = {
92
+ IDEA_COLS,
93
+ IDEA_COLS_Q,
94
+ DEFAULT_GRADE,
95
+ VALID_GRADES,
96
+ DEFAULT_TEMPLATE_KEY,
97
+ TEMPLATE_KEY_RE,
98
+ TEMPLATE_KEY_MAX,
99
+ FIELD_VALUES_MAX,
100
+ GRADE_COLS_Q,
101
+ IDEA_JOIN_GRADE,
102
+ ungradedProjection,
103
+ };
@@ -0,0 +1,211 @@
1
+ // modules/ideas/ratify.js — criterion-proposal ratification (task 1004115;
2
+ // the transaction itself is V3.R31).
3
+ //
4
+ // Carved out of inbox.js verbatim, and the most clearly separable thing in it:
5
+ // this is the ONE place the ideas module writes done_when_criteria, karma_log
6
+ // and criterion_proposal_audit — three tables no other function here touches —
7
+ // in a single transaction. It reads one idea_inbox row and never uses the
8
+ // shared idea projection, so unlike every other carved file it depends on
9
+ // nothing but the doorway.
10
+ //
11
+ // It is a PLANNING write wearing an idea's clothes: a Metic proposes through
12
+ // the inbox because that is the capture surface everyone already has, and an
13
+ // Archon ratifies it into a version's scope. Keeping it beside the triage
14
+ // writes made the two look like the same kind of act. They are not.
15
+
16
+ const { pool } = require('../../src/module-api');
17
+
18
+ // -------------------------------------------------------------------------
19
+ // Criterion-proposal ratification (V3.R31 / #252)
20
+ //
21
+ // Atomically promotes an idea_inbox row with kind='criterion-proposal' into a
22
+ // real done_when_criteria row, marks the idea as promoted, awards karma to
23
+ // the proposer (delta=+3, reason='proposal_ratified'), and writes an audit
24
+ // row capturing who proposed / who ratified / when / what.
25
+ //
26
+ // Called by POST /api/bongos/inbox/:id/ratify (Archon-only, gated by requireRank
27
+ // in the router). The body_md of the source idea is parsed as JSON with the
28
+ // shape { target_version, criterion_md, sort_order_hint }; malformed bodies
29
+ // throw BAD_BODY so the route can return a structured 400.
30
+ //
31
+ // Karma amount is tunable; V3.R28 (#248) sets it to +3 to recognize good civic
32
+ // input without overweighting it relative to peer-upvote signals that arrive
33
+ // in volume. criterion_id on the new done_when_criteria row defaults to
34
+ // "proposal-<idea_id>" so the (version_id, criterion_id) UNIQUE constraint
35
+ // fires if someone ratifies the same idea twice — the second attempt fails the
36
+ // transaction rather than creating a duplicate criterion silently.
37
+ const KARMA_RATIFY_DELTA = 3;
38
+ const KARMA_RATIFY_REASON = 'proposal_ratified';
39
+
40
+ function parseProposalBody(bodyMd) {
41
+ if (!bodyMd || typeof bodyMd !== 'string') {
42
+ throw { code: 'BAD_BODY', reason: 'body_md_empty' };
43
+ }
44
+ let parsed;
45
+ try {
46
+ parsed = JSON.parse(bodyMd);
47
+ } catch (err) {
48
+ throw { code: 'BAD_BODY', reason: 'body_md_not_json', detail: err.message };
49
+ }
50
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
51
+ throw { code: 'BAD_BODY', reason: 'body_md_not_object' };
52
+ }
53
+ const { target_version, criterion_md, sort_order_hint } = parsed;
54
+ if (!target_version || typeof target_version !== 'string') {
55
+ throw { code: 'BAD_BODY', reason: 'target_version_required' };
56
+ }
57
+ if (!criterion_md || typeof criterion_md !== 'string') {
58
+ throw { code: 'BAD_BODY', reason: 'criterion_md_required' };
59
+ }
60
+ let hint = null;
61
+ if (sort_order_hint != null) {
62
+ const n = Number(sort_order_hint);
63
+ if (!Number.isFinite(n) || !Number.isInteger(n)) {
64
+ throw { code: 'BAD_BODY', reason: 'sort_order_hint_not_integer' };
65
+ }
66
+ hint = n;
67
+ }
68
+ return { target_version, criterion_md, sort_order_hint: hint };
69
+ }
70
+
71
+ async function ratifyCriterionProposal(ideaId, ratifierId, { notesMd = '' } = {}) {
72
+ const id = Number(ideaId);
73
+ if (!Number.isFinite(id)) {
74
+ throw { code: 'BAD_ID' };
75
+ }
76
+ const rid = Number(ratifierId);
77
+ if (!Number.isFinite(rid)) {
78
+ throw { code: 'BAD_RATIFIER' };
79
+ }
80
+ const client = await pool.connect();
81
+ try {
82
+ await client.query('BEGIN');
83
+
84
+ // a. Read + lock the idea row.
85
+ const { rows: ideaRows } = await client.query(
86
+ `SELECT id, title, body_md, kind, status, captured_by
87
+ FROM idea_inbox
88
+ WHERE id = $1
89
+ FOR UPDATE`,
90
+ [id]
91
+ );
92
+ if (ideaRows.length === 0) throw { code: 'IDEA_NOT_FOUND' };
93
+ const idea = ideaRows[0];
94
+ if (idea.kind !== 'criterion-proposal') {
95
+ throw { code: 'WRONG_KIND', kind: idea.kind, expected: 'criterion-proposal' };
96
+ }
97
+ if (idea.status !== 'open') {
98
+ throw { code: 'IDEA_NOT_OPEN', status: idea.status };
99
+ }
100
+
101
+ // b. Parse body_md.
102
+ const proposal = parseProposalBody(idea.body_md);
103
+
104
+ // c. Verify target version exists. (FK on done_when_criteria.version_id
105
+ // would catch this too, but giving a 400 instead of a 23503 is friendlier.)
106
+ const { rows: verRows } = await client.query(
107
+ `SELECT id FROM versions WHERE id = $1`,
108
+ [proposal.target_version]
109
+ );
110
+ if (verRows.length === 0) {
111
+ throw { code: 'VERSION_NOT_FOUND', version_id: proposal.target_version };
112
+ }
113
+
114
+ // d. Resolve sort_order: prefer the hint; otherwise max(existing)+1, or 100
115
+ // if no rows exist yet (matches the migration 006 default).
116
+ let sortOrder = proposal.sort_order_hint;
117
+ if (sortOrder == null) {
118
+ const { rows: maxRows } = await client.query(
119
+ `SELECT COALESCE(MAX(sort_order), 0) + 1 AS next
120
+ FROM done_when_criteria
121
+ WHERE version_id = $1`,
122
+ [proposal.target_version]
123
+ );
124
+ sortOrder = maxRows[0].next;
125
+ }
126
+
127
+ // e. INSERT done_when_criteria with criterion_id = 'proposal-<idea_id>'.
128
+ // The UNIQUE (version_id, criterion_id) constraint provides natural
129
+ // idempotency — re-ratifying the same idea would 23505 here.
130
+ const criterionRefId = `proposal-${idea.id}`;
131
+ const { rows: critRows } = await client.query(
132
+ `INSERT INTO done_when_criteria
133
+ (version_id, criterion_id, criterion_md, sort_order)
134
+ VALUES ($1, $2, $3, $4)
135
+ RETURNING id, version_id, criterion_id, criterion_md, sort_order`,
136
+ [proposal.target_version, criterionRefId, proposal.criterion_md, sortOrder]
137
+ );
138
+ const criterion = critRows[0];
139
+
140
+ // f. Mark the idea promoted. promoted_to_task_id stays NULL — a criterion
141
+ // is not a task. reviewed_at is stamped to now() so triage views skip it.
142
+ await client.query(
143
+ `UPDATE idea_inbox
144
+ SET status = 'promoted',
145
+ promoted_to_task_id = NULL,
146
+ reviewed_at = now()
147
+ WHERE id = $1`,
148
+ [idea.id]
149
+ );
150
+
151
+ // g. Award karma to the proposer. captured_by may be NULL on legacy rows;
152
+ // skip the karma insert in that case (no proposer to credit) but still
153
+ // record the audit row.
154
+ let karmaAwarded = 0;
155
+ if (idea.captured_by != null) {
156
+ await client.query(
157
+ `INSERT INTO karma_log (builder_id, delta, reason, granted_by)
158
+ VALUES ($1, $2, $3, $4)`,
159
+ [idea.captured_by, KARMA_RATIFY_DELTA, KARMA_RATIFY_REASON, rid]
160
+ );
161
+ karmaAwarded = KARMA_RATIFY_DELTA;
162
+
163
+ // Note: prior to 2026-05-30 this also ticked a 'first_vote_received'
164
+ // onboarding stage. That stage was removed (migration 059) — graduation
165
+ // no longer waits on a peer vote — so there is nothing to mark here now.
166
+ }
167
+
168
+ // h. Audit row — durable record of the ratification event.
169
+ const { rows: auditRows } = await client.query(
170
+ `INSERT INTO criterion_proposal_audit
171
+ (idea_id, criterion_id, proposed_by, ratified_by,
172
+ target_version, criterion_md, sort_order_hint, notes_md)
173
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
174
+ RETURNING id, ratified_at`,
175
+ [
176
+ idea.id,
177
+ criterion.id,
178
+ idea.captured_by,
179
+ rid,
180
+ proposal.target_version,
181
+ proposal.criterion_md,
182
+ proposal.sort_order_hint,
183
+ notesMd || '',
184
+ ]
185
+ );
186
+
187
+ await client.query('COMMIT');
188
+ return {
189
+ criterion_id: criterion.id,
190
+ criterion_ref: criterion.criterion_id,
191
+ version_id: criterion.version_id,
192
+ sort_order: criterion.sort_order,
193
+ audit_id: auditRows[0].id,
194
+ ratified_at: auditRows[0].ratified_at,
195
+ karma_awarded: karmaAwarded,
196
+ proposer_id: idea.captured_by,
197
+ };
198
+ } catch (err) {
199
+ await client.query('ROLLBACK').catch(() => {});
200
+ throw err;
201
+ } finally {
202
+ client.release();
203
+ }
204
+ }
205
+
206
+ module.exports = {
207
+ KARMA_RATIFY_DELTA,
208
+ KARMA_RATIFY_REASON,
209
+ parseProposalBody,
210
+ ratifyCriterionProposal,
211
+ };
@@ -0,0 +1,96 @@
1
+ // modules/ideas/reads.js — the three plain idea reads (task 1004115).
2
+ //
3
+ // listOpenIdeas / getIdea / countOpenIdeas, carved out of inbox.js verbatim.
4
+ // They are grouped because they are the reads with no second relation in them:
5
+ // the trail (trail.js) joins lineage and outcomes, the spark queue (grades.js)
6
+ // starts from ideas_grades, the descent (trail-descent.js) walks task-to-task.
7
+ // These three just read idea_inbox with its grade attached.
8
+ //
9
+ // IT SITS BELOW grades.js ON PURPOSE. developSpark needs getIdea to build the
10
+ // idea.graded.full payload, and updateIdea in inbox.js needs it too. Leaving it
11
+ // in inbox.js would have made inbox -> grades -> inbox a require cycle, which
12
+ // is a baselined ratchet (require_cycle_count). Reads depend on projection.js
13
+ // and skill-friction.js and on nothing above them.
14
+
15
+ const { pool } = require('../../src/module-api');
16
+ const { IDEA_COLS_Q, GRADE_COLS_Q, IDEA_JOIN_GRADE } = require('./projection');
17
+ const { skillFrictionHiddenClause } = require('./skill-friction');
18
+
19
+ // -------------------------------------------------------------------------
20
+ // Read
21
+ // -------------------------------------------------------------------------
22
+
23
+ // viewerRank gates skill-friction visibility (V3.R70 / #289): Xenos must not
24
+ // see skill-friction rows; Metic+ (or unknown rank, pre-R04) see everything.
25
+ // We filter in SQL when the viewer is positively Xenos so the LIMIT still
26
+ // returns up to `limit` *visible* rows rather than `limit` rows that then get
27
+ // thinned out in JS. The marker is a kind OR a legacy title prefix — see
28
+ // skillFrictionHiddenClause, which is the only place that knows that.
29
+ async function listOpenIdeas({ kind = null, limit = 50, viewerRank = null } = {}) {
30
+ const params = [];
31
+ const where = [`i.status = 'open'`];
32
+ if (kind) {
33
+ params.push(kind);
34
+ where.push(`i.kind = $${params.length}`);
35
+ }
36
+ const hideFriction = skillFrictionHiddenClause(viewerRank, params, 'i');
37
+ if (hideFriction) where.push(hideFriction);
38
+ params.push(limit);
39
+ const { rows } = await pool.query(
40
+ `SELECT ${IDEA_COLS_Q}, ${GRADE_COLS_Q}
41
+ FROM idea_inbox i
42
+ ${IDEA_JOIN_GRADE}
43
+ WHERE ${where.join(' AND ')}
44
+ ORDER BY i.captured_at DESC
45
+ LIMIT $${params.length}`,
46
+ params
47
+ );
48
+ return rows;
49
+ }
50
+
51
+ // The by-id read. It carries the two people's LOGINS as well as their ids
52
+ // (task 1002929 / R153): an idea's attribution is a claim about humans, and
53
+ // "captured_by: 41" is not attribution to anyone reading it. Named to match the
54
+ // R145 spark queue's `filed_by_login` so the two idea surfaces agree.
55
+ //
56
+ // Both joins are LEFT: a builder row can be missing (an FK that went
57
+ // ON DELETE SET NULL), and an idea nobody developed has no developer at all —
58
+ // which is the common case, not an error. A plain JOIN here would drop most of
59
+ // the inbox, the same trap the grade join documents above.
60
+ async function getIdea(id) {
61
+ const { rows } = await pool.query(
62
+ `SELECT ${IDEA_COLS_Q}, ${GRADE_COLS_Q},
63
+ ba.github_login AS filed_by_login,
64
+ bd.github_login AS developed_by_login
65
+ FROM idea_inbox i
66
+ ${IDEA_JOIN_GRADE}
67
+ LEFT JOIN builders ba ON ba.id = i.captured_by
68
+ LEFT JOIN builders bd ON bd.id = g.developed_by
69
+ WHERE i.id = $1`,
70
+ [id]
71
+ );
72
+ return rows[0] ?? null;
73
+ }
74
+
75
+ // viewerRank applies the SAME skill-friction visibility gate as listOpenIdeas
76
+ // (V3.R70 / #289) so the count a viewer is shown matches the rows they can see:
77
+ // a Xenos must not be told there are N open ideas when [skill-friction] rows
78
+ // are filtered out of their list. Default null preserves the old "count
79
+ // everything" behaviour for callers that don't have (or don't gate on) a rank.
80
+ async function countOpenIdeas({ viewerRank = null } = {}) {
81
+ const params = [];
82
+ const where = [`status = 'open'`];
83
+ const hideFriction = skillFrictionHiddenClause(viewerRank, params);
84
+ if (hideFriction) where.push(hideFriction);
85
+ const { rows } = await pool.query(
86
+ `SELECT count(*)::int AS n FROM idea_inbox WHERE ${where.join(' AND ')}`,
87
+ params
88
+ );
89
+ return rows[0].n;
90
+ }
91
+
92
+ module.exports = {
93
+ listOpenIdeas,
94
+ getIdea,
95
+ countOpenIdeas,
96
+ };
@@ -0,0 +1,111 @@
1
+ // modules/ideas/trail.js — THE IDEA TRAIL: every idea one builder filed, and
2
+ // what became of each (task 1004115; the read itself is task 1003281).
3
+ //
4
+ // Carved out of inbox.js verbatim. It joins its own siblings — trail-descent.js
5
+ // hangs the task-to-task walk under these rows, trail-size.js hangs what the
6
+ // descent has paid — and both of those were put in their own file for the
7
+ // reason this carve now applies to the read itself: inbox.js was AT the
8
+ // 1,500-line ratchet, so "a different relation deserves a different file" and
9
+ // "nothing more fits" had become the same sentence. They are separable again.
10
+ // The trail is a read keyed by PERSON; inbox.js is the capture chokepoint and
11
+ // the triage writes.
12
+
13
+ const { pool } = require('../../src/module-api');
14
+ const { IDEA_COLS_Q, GRADE_COLS_Q, IDEA_JOIN_GRADE } = require('./projection');
15
+ const { skillFrictionHiddenClause } = require('./skill-friction');
16
+
17
+ // -------------------------------------------------------------------------
18
+ // The idea TRAIL — every idea one builder filed, and what became of it
19
+ // (task 1003281, goal 1000072, criterion legibility-activity-by-role).
20
+ //
21
+ // THE READ THAT EXISTS NOWHERE ELSE, and the reason the criterion was written:
22
+ // the owner could file an idea and then never find out what happened to it.
23
+ // Every other idea read is scoped to what is STILL OPEN — listOpenIdeas, the
24
+ // spark queue, the nod queue — so a promoted, discarded or merged idea drops
25
+ // out of every surface the moment it is triaged, which is exactly when its
26
+ // outcome becomes interesting. This read is scoped to a PERSON instead of to a
27
+ // status, so nothing they filed ever leaves it.
28
+ //
29
+ // NO NEW STORAGE. The four outcomes are already on the row: `status` says which
30
+ // one, `promoted_to_task_id` + ideas_task_lineage say which task a promotion
31
+ // produced, `merged_into_idea_id` says which idea absorbed a merge, and
32
+ // `triage_note` carries the triager's sentence (core_195, task 1002447). The
33
+ // joins below turn those ids into names so a reader gets "became task #1003281
34
+ // — See what you did, by role" rather than a number they must go look up.
35
+ //
36
+ // AN OPEN IDEA IS AN OUTCOME. It reads as "still open", never as an absence —
37
+ // one of the four the criterion names. That is why status is returned verbatim
38
+ // and the surface renders it, rather than the read filtering open rows out.
39
+ //
40
+ // LINEAGE, NOT JUST THE LEGACY COLUMN. One idea can become MANY tasks
41
+ // (ideas_task_lineage, task 1002997), while `promoted_to_task_id` holds only the
42
+ // first. Both are returned: the lineage array is the complete answer, the legacy
43
+ // column is the only answer for promotions that predate the lineage table. The
44
+ // LATERAL is bounded by the page window, so this stays one query, not an N+1.
45
+ const IDEA_TRAIL_DEFAULT_LIMIT = 20;
46
+ const IDEA_TRAIL_MAX_LIMIT = 100;
47
+
48
+ // The WHERE this list and its count share, so the pager can never claim rows the
49
+ // list will not show. viewerRank applies the SAME skill-friction gate as every
50
+ // other idea read (V3.R70 / #289) — including on a builder reading their OWN
51
+ // trail: the gate is about what a rank may see, not about authorship.
52
+ function ideaTrailWhere(builderId, viewerRank, params) {
53
+ params.push(builderId);
54
+ const where = [`i.captured_by = $${params.length}`];
55
+ const hideFriction = skillFrictionHiddenClause(viewerRank, params, 'i');
56
+ if (hideFriction) where.push(hideFriction);
57
+ return where.join(' AND ');
58
+ }
59
+
60
+ async function listIdeaTrailForBuilder(builderId, { limit = IDEA_TRAIL_DEFAULT_LIMIT, offset = 0, viewerRank = null } = {}, deps = {}) {
61
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
62
+ const lim = Math.min(Math.max(Number.isInteger(Number(limit)) ? Number(limit) : IDEA_TRAIL_DEFAULT_LIMIT, 1), IDEA_TRAIL_MAX_LIMIT);
63
+ const off = Number.isInteger(Number(offset)) && Number(offset) > 0 ? Number(offset) : 0;
64
+ const params = [];
65
+ const where = ideaTrailWhere(builderId, viewerRank, params);
66
+ params.push(lim, off);
67
+ const { rows } = await activePool.query(
68
+ `SELECT ${IDEA_COLS_Q}, ${GRADE_COLS_Q},
69
+ pt.title AS promoted_task_title,
70
+ pt.status AS promoted_task_status,
71
+ mi.title AS merged_into_idea_title,
72
+ mi.status AS merged_into_idea_status,
73
+ COALESCE(lin.tasks, '[]'::json) AS lineage_tasks
74
+ FROM idea_inbox i
75
+ ${IDEA_JOIN_GRADE}
76
+ LEFT JOIN tasks pt ON pt.id = i.promoted_to_task_id
77
+ LEFT JOIN idea_inbox mi ON mi.id = i.merged_into_idea_id
78
+ LEFT JOIN LATERAL (
79
+ SELECT json_agg(json_build_object(
80
+ 'task_id', l.task_id, 'origin', l.origin,
81
+ 'title', lt.title, 'status', lt.status
82
+ ) ORDER BY l.task_id) AS tasks
83
+ FROM ideas_task_lineage l
84
+ JOIN tasks lt ON lt.id = l.task_id
85
+ WHERE l.idea_id = i.id
86
+ ) lin ON true
87
+ WHERE ${where}
88
+ ORDER BY i.captured_at DESC, i.id DESC
89
+ LIMIT $${params.length - 1} OFFSET $${params.length}`,
90
+ params
91
+ );
92
+ return rows;
93
+ }
94
+
95
+ async function countIdeaTrailForBuilder(builderId, { viewerRank = null } = {}, deps = {}) {
96
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
97
+ const params = [];
98
+ const where = ideaTrailWhere(builderId, viewerRank, params);
99
+ const { rows } = await activePool.query(
100
+ `SELECT count(*)::int AS n FROM idea_inbox i WHERE ${where}`,
101
+ params
102
+ );
103
+ return rows[0].n;
104
+ }
105
+
106
+ module.exports = {
107
+ IDEA_TRAIL_DEFAULT_LIMIT,
108
+ IDEA_TRAIL_MAX_LIMIT,
109
+ listIdeaTrailForBuilder,
110
+ countIdeaTrailForBuilder,
111
+ };
@@ -17,7 +17,7 @@
17
17
  // shipped task looks like THIS, file THAT task, for THAT discipline. There is no
18
18
  // condition language, no stored rules, no per-project configuration and no UI —
19
19
  // every one of those is a thing to add when a second and third cascade exist and
20
- // have shown what they have in common. Today `CASCADES` ships EMPTY: the
20
+ // have shown what they have in common. `CASCADES` no longer ships empty: the
21
21
  // mechanism, its idempotency and its non-blocking property are proved by the
22
22
  // tests' own declarations, and R04 (task 1003115) writes the first production
23
23
  // entry. A mechanism whose first real rule is written by the task that needs it
@@ -169,6 +169,50 @@ function artistReviewBody(task) {
169
169
  ].join('\n');
170
170
  }
171
171
 
172
+ // The removal proposal's body (task 1004090 / BV2.R15, ADR 0313).
173
+ //
174
+ // IT ASKS, IT DOES NOT TELL. Every line here is written so a reader confirms or
175
+ // dismisses on the evidence rather than rubber-stamping a verdict — because the
176
+ // owner chose propose-then-confirm precisely for the cases this derivation cannot
177
+ // tell apart from a real removal. The three it CAN tell apart (a rename, a move,
178
+ // a rewrite in place) are already excluded upstream in removal-derivation.js.
179
+ //
180
+ // The affected tasks and paths are named as PROSE, deliberately. A cascade files
181
+ // one task per rule per ship keyed on (rule, ship), with no field for a target,
182
+ // and R05's runner may not be widened to fan out (ADR 0242). Owner decision
183
+ // 2026-09-20: the body carries them and R20 designs its own storage when it needs
184
+ // one. So this text IS the record until then — keep it readable and keep it exact.
185
+ function removalProposalBody(task, ctx) {
186
+ const found = ((ctx || {}).removals) || [];
187
+ const lines = [];
188
+ lines.push(`Shipping task ${task.id} deleted files that earlier SHIPPED work put there.`);
189
+ lines.push('');
190
+ lines.push('**This is a question, not a verdict.** Nothing has been marked removed, no');
191
+ lines.push('status changed and no credit moved — and none of that happens unless a person');
192
+ lines.push('confirms below. `shipped` is terminal and stays terminal.');
193
+ lines.push('');
194
+ lines.push('**Removed work attributed to:**');
195
+ for (const r of found) {
196
+ lines.push(` - task ${r.taskId}${r.title ? ` — ${r.title}` : ''}`);
197
+ for (const path of r.paths) lines.push(` ${path}`);
198
+ }
199
+ lines.push('');
200
+ if ((ctx || {}).removalTruncated) {
201
+ lines.push(`Note: this ship deleted ${(ctx || {}).removalDeletedCount} paths, more than the`);
202
+ lines.push('attribution cap, so the list above is PARTIAL. A ship that large is usually a');
203
+ lines.push('move or a sweep — weigh that before confirming.');
204
+ lines.push('');
205
+ }
206
+ lines.push('**Confirm** if that work is genuinely gone: the ideas whose descent it belongs');
207
+ lines.push('to will dim accordingly. **Dismiss** if this was a refactor, a move the rename');
208
+ lines.push('detection missed, or a deletion of something already superseded — dismissing is');
209
+ lines.push('what stops the sky being dimmed for a reason that is not real.');
210
+ lines.push('');
211
+ lines.push('Attribution runs through `claims.published_head_sha`, so a deletion whose origin');
212
+ lines.push('predates that record is silently absent rather than guessed at.');
213
+ return lines.join('\n');
214
+ }
215
+
172
216
  // Production rules. The artist review is the first (R04); add another by
173
217
  // appending an entry, never by touching the runner (ADR 0242).
174
218
  const CASCADES = Object.freeze([
@@ -189,6 +233,25 @@ const CASCADES = Object.freeze([
189
233
  title: (t) => `Artist review: the copy and visuals that shipped in task ${t.id}`,
190
234
  body: artistReviewBody,
191
235
  }),
236
+ Object.freeze({
237
+ key: 'removal-proposed-on-ship',
238
+ // ENGINEER, not artist: confirming asks whether deleted code is genuinely
239
+ // gone or was moved, which is a question about the diff.
240
+ discipline: 'engineer',
241
+ kind: 'unclassified',
242
+ // The anti-recursion guard FIRST, the same order and for the same reason as
243
+ // the rule above — a cascade-generated task must never cascade.
244
+ //
245
+ // The candidates are resolved ONCE into ctx by runCascadesForShip, never
246
+ // here: a `when` is pure and synchronous by contract, and this derivation is
247
+ // a network read. An absent ctx therefore means "nothing was derived" and
248
+ // files nothing — the opposite default to the artist gate above, and
249
+ // deliberately so: silence about a removal must never be read as a removal.
250
+ when: (t, ctx) => t.source !== CASCADE_SOURCE
251
+ && Array.isArray((ctx || {}).removals) && (ctx || {}).removals.length > 0,
252
+ title: (t) => `Was this removed? Task ${t.id} deleted earlier shipped work`,
253
+ body: removalProposalBody,
254
+ }),
192
255
  ]);
193
256
 
194
257
  // THE TRIGGERS. A cascade reacts to a state transition; there are two, and this
@@ -319,7 +382,7 @@ function cascadesFor(task, rules = CASCADES, ctx = {}, trigger = TRIGGERS.ship)
319
382
  }
320
383
  if (!hit) continue;
321
384
  try {
322
- matched.push({ rule, spec: cascadeTaskSpec(rule, task, trigger) });
385
+ matched.push({ rule, spec: cascadeTaskSpec(rule, task, trigger, ctx) });
323
386
  } catch (e) {
324
387
  errors.push(`cascade "${rule.key}": title/body threw (${e && e.message})`);
325
388
  }
@@ -329,8 +392,11 @@ function cascadesFor(task, rules = CASCADES, ctx = {}, trigger = TRIGGERS.ship)
329
392
 
330
393
  // The createTask payload for one matched rule. The attribution preamble is
331
394
  // prepended here rather than left to each rule, so no rule can forget it.
332
- function cascadeTaskSpec(rule, task, trigger = TRIGGERS.ship) {
333
- const body = String(rule.body(task) || '').trim();
395
+ // `ctx` reaches body() as a second argument, mirroring when(). The artist rule
396
+ // ignores it; the removal rule needs it, because what a ship deleted is derived
397
+ // per run and is not a property of the task row.
398
+ function cascadeTaskSpec(rule, task, trigger = TRIGGERS.ship, ctx = {}) {
399
+ const body = String(rule.body(task, ctx) || '').trim();
334
400
  const preamble =
335
401
  `Filed automatically when ${trigger.happened(task)} — the "${rule.key}" cascade `
336
402
  + `(modules/lifecycle/cascade.js). Nobody typed this; it is a REQUEST, filed at backlog, `
@@ -466,6 +532,27 @@ async function runCascadesForShip(shipped, deps = {}) {
466
532
  try { ctx.artistGate = deps.artistGateMode(); }
467
533
  catch (e) { report.errors.push(`cascade: artistGateMode() threw (${e && e.message}) — policy unread`); }
468
534
  }
535
+ // WHAT THIS SHIP DELETED (task 1004090 / BV2.R15, ADR 0313). Resolved here
536
+ // rather than in the rule for the reason the rule's own comment gives: a
537
+ // `when` is pure and synchronous, and this is a network read.
538
+ //
539
+ // Same posture as the policy read above — a resolver that throws is REPORTED
540
+ // and leaves the keys unset, so a derivation outage files no proposal and
541
+ // costs the ship nothing. `removalCandidates` already swallows its own
542
+ // failures (removal-derivation.js returns a report rather than rejecting);
543
+ // this try/catch is the belt to that braces, because a non-blocking promise
544
+ // is only non-blocking until someone edits it.
545
+ if (typeof deps.removalCandidates === 'function') {
546
+ try {
547
+ const derived = await deps.removalCandidates(task);
548
+ ctx.removals = (derived && derived.removals) || [];
549
+ ctx.removalTruncated = Boolean(derived && derived.truncated);
550
+ ctx.removalDeletedCount = (derived && derived.deletedCount) || 0;
551
+ for (const e of (derived && derived.errors) || []) report.errors.push(e);
552
+ } catch (e) {
553
+ report.errors.push(`cascade: removalCandidates() threw (${e && e.message}) — no removal derived`);
554
+ }
555
+ }
469
556
 
470
557
  const { matched, errors } = cascadesFor(task, deps.rules || CASCADES, ctx);
471
558
  report.errors.push(...errors);
@@ -480,6 +567,7 @@ async function runCascadesForShip(shipped, deps = {}) {
480
567
 
481
568
  module.exports = {
482
569
  CASCADES,
570
+ removalProposalBody,
483
571
  IDEA_CASCADES,
484
572
  TRIGGERS,
485
573
  fullIdeaScopingBody,