@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.
- package/.bongos-core.json +74 -24
- package/docs/adr/0317-a-plane-that-cannot-give-an-instance-its-own-account-is-refused.md +148 -0
- package/docs/adr/README.md +1 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/self-host.md +1 -1
- package/migrations/core_252_claims_published_head_sha_idx.sql +33 -0
- package/modules/government/board.js +1 -1
- package/modules/ideas/grades.js +317 -0
- package/modules/ideas/inbox.js +65 -721
- package/modules/ideas/projection.js +103 -0
- package/modules/ideas/ratify.js +211 -0
- package/modules/ideas/reads.js +96 -0
- package/modules/ideas/trail.js +111 -0
- package/modules/lifecycle/cascade.js +92 -4
- package/modules/lifecycle/db-ship.js +43 -0
- package/modules/lifecycle/db.js +2 -0
- package/modules/lifecycle/removal-derivation.js +332 -0
- package/modules/lifecycle/routes/lifecycle.js +24 -0
- package/modules/lifecycle/tests/cascade.mjs +21 -4
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/provision-repo.js +52 -0
- package/scripts/gds/provision.js +7 -1
- package/src/module-api.js +1 -1
- package/tests/idea_cascade.mjs +3 -3
- package/tests/provision_account_identity.mjs +135 -0
- package/tests/removal_proposal.mjs +583 -0
|
@@ -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.
|
|
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
|
-
|
|
333
|
-
|
|
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,
|