@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,317 @@
|
|
|
1
|
+
// modules/ideas/grades.js — the quick / spark / full grade domain (task 1004115).
|
|
2
|
+
//
|
|
3
|
+
// Carved out of inbox.js verbatim: the spark queue (R145), the grade writes
|
|
4
|
+
// (R140), developSpark (R146) and the grade-row read. One file because they are
|
|
5
|
+
// one relation — ideas_grades — and because the guarantees interlock:
|
|
6
|
+
// setIdeaGrade writes the WHOLE row so a re-grade cannot inherit stale
|
|
7
|
+
// field_values, developSpark deliberately steps OUTSIDE that contract for
|
|
8
|
+
// developed_by/developed_at, and the queue exists only because grade=spark is
|
|
9
|
+
// never implicit. Those three arguments read as one argument here and read as
|
|
10
|
+
// three scattered comments when they are 300 lines apart in a 1,500-line file.
|
|
11
|
+
//
|
|
12
|
+
// The grade VOCABULARY (VALID_GRADES, the template caps, the COALESCE default)
|
|
13
|
+
// stays in projection.js, not here: every idea read returns a grade, so the
|
|
14
|
+
// vocabulary is shared and the writes are not.
|
|
15
|
+
|
|
16
|
+
const api = require('../../src/module-api');
|
|
17
|
+
const { pool } = api;
|
|
18
|
+
const {
|
|
19
|
+
IDEA_COLS_Q, GRADE_COLS_Q,
|
|
20
|
+
VALID_GRADES, DEFAULT_TEMPLATE_KEY, TEMPLATE_KEY_RE, TEMPLATE_KEY_MAX, FIELD_VALUES_MAX,
|
|
21
|
+
} = require('./projection');
|
|
22
|
+
const { skillFrictionHiddenClause } = require('./skill-friction');
|
|
23
|
+
// developSpark reads the whole idea back to build the idea.graded.full payload.
|
|
24
|
+
// From ./reads, never from ./inbox — see the cycle note in reads.js.
|
|
25
|
+
const { getIdea } = require('./reads');
|
|
26
|
+
|
|
27
|
+
// -------------------------------------------------------------------------
|
|
28
|
+
// The spark queue (task 1002921 / BV1.R145, goal 1000062)
|
|
29
|
+
//
|
|
30
|
+
// "What Big Idea Sparks are awaiting development?" — the ideator craft's pickup
|
|
31
|
+
// surface, and the first place in this codebase where one craft sources work
|
|
32
|
+
// from another craft's output.
|
|
33
|
+
//
|
|
34
|
+
// THE PREDICATE IS grade='spark' AND status='open', AND THAT IS THE WHOLE
|
|
35
|
+
// QUEUE. There is deliberately no `developed` flag and no separate queue table:
|
|
36
|
+
// R146 develops a spark into a Full Idea IN PLACE on the same idea_inbox row
|
|
37
|
+
// (same id, same captured_by), which flips its grade spark→full — so a developed
|
|
38
|
+
// spark leaves this queue as a CONSEQUENCE of being developed, not because
|
|
39
|
+
// anything remembered to remove it. A second source of truth for "still
|
|
40
|
+
// waiting" is a thing that can disagree with the grade; this cannot.
|
|
41
|
+
//
|
|
42
|
+
// AN INNER JOIN, WHICH INVERTS THIS MODULE'S USUAL RULE ON PURPOSE. Every other
|
|
43
|
+
// idea read LEFT JOINs ideas_grades and COALESCEs to 'quick', because an
|
|
44
|
+
// un-graded idea must still appear. Here the opposite is correct and safe:
|
|
45
|
+
// grade='spark' can never be implicit — absence of a row means quick — so a
|
|
46
|
+
// spark ALWAYS has a real row, and starting from ideas_grades lets
|
|
47
|
+
// ideas_grades_grade_score_idx (ideas_001, written for this very read) drive the
|
|
48
|
+
// scan instead of filtering a full idea_inbox pass afterwards.
|
|
49
|
+
//
|
|
50
|
+
// ORDERING — "newest-or-best first", and only the newest half is real. A spark
|
|
51
|
+
// carries no completeness score by construction (it has no template fields to
|
|
52
|
+
// score), so there is no "best" signal to sort by: upvotes live on builders as
|
|
53
|
+
// karma, not per-idea, and inventing a quality metric for an undeveloped thought
|
|
54
|
+
// is exactly the guess this queue should not be making. captured_at DESC, id
|
|
55
|
+
// DESC as a stable tiebreak for same-timestamp rows. When a real per-idea
|
|
56
|
+
// signal exists, this ORDER BY is the one line that changes.
|
|
57
|
+
//
|
|
58
|
+
// The skill-friction visibility gate (V3.R70) is applied here too, on the same
|
|
59
|
+
// title-prefix rule listOpenIdeas uses. A routine-filed friction row is unlikely
|
|
60
|
+
// to ever be sparked (marking requires an author), but the two idea listings
|
|
61
|
+
// agreeing by construction beats them agreeing by accident.
|
|
62
|
+
function sparkQueueWhere(viewerRank, params) {
|
|
63
|
+
const where = [`g.grade = 'spark'`, `i.status = 'open'`];
|
|
64
|
+
const hideFriction = skillFrictionHiddenClause(viewerRank, params, 'i');
|
|
65
|
+
if (hideFriction) where.push(hideFriction);
|
|
66
|
+
return where;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function listSparkQueue({ limit = 50, viewerRank = null } = {}, deps = {}) {
|
|
70
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
71
|
+
const params = [];
|
|
72
|
+
const where = sparkQueueWhere(viewerRank, params);
|
|
73
|
+
params.push(limit);
|
|
74
|
+
const { rows } = await activePool.query(
|
|
75
|
+
// `b.github_login` because attribution is the POINT of this surface: an
|
|
76
|
+
// ideator picking work up should see whose thought they are developing, and
|
|
77
|
+
// R146 has to preserve that name. LEFT JOIN — a builder row can be missing.
|
|
78
|
+
`SELECT ${IDEA_COLS_Q}, ${GRADE_COLS_Q},
|
|
79
|
+
b.github_login AS filed_by_login
|
|
80
|
+
FROM ideas_grades g
|
|
81
|
+
JOIN idea_inbox i ON i.id = g.idea_id
|
|
82
|
+
LEFT JOIN builders b ON b.id = i.captured_by
|
|
83
|
+
WHERE ${where.join(' AND ')}
|
|
84
|
+
ORDER BY i.captured_at DESC, i.id DESC
|
|
85
|
+
LIMIT $${params.length}`,
|
|
86
|
+
params
|
|
87
|
+
);
|
|
88
|
+
return rows;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// The count behind the queue's pill — the SAME predicate as the list (the
|
|
92
|
+
// countRoutedAwaitingNod / countOpenIdeas precedent), so the number a viewer is
|
|
93
|
+
// shown can never disagree with the rows they can actually see. It deliberately
|
|
94
|
+
// takes viewerRank too: a count that ignored the visibility gate would tell a
|
|
95
|
+
// Xenos there are sparks they cannot find.
|
|
96
|
+
async function countSparkQueue({ viewerRank = null } = {}, deps = {}) {
|
|
97
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
98
|
+
const params = [];
|
|
99
|
+
const where = sparkQueueWhere(viewerRank, params);
|
|
100
|
+
const { rows } = await activePool.query(
|
|
101
|
+
`SELECT COUNT(*)::int AS n
|
|
102
|
+
FROM ideas_grades g
|
|
103
|
+
JOIN idea_inbox i ON i.id = g.idea_id
|
|
104
|
+
WHERE ${where.join(' AND ')}`,
|
|
105
|
+
params
|
|
106
|
+
);
|
|
107
|
+
return rows[0] ? rows[0].n : 0;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// -------------------------------------------------------------------------
|
|
111
|
+
// Grade writes (task 1002916 / BV1.R140)
|
|
112
|
+
//
|
|
113
|
+
// setIdeaGrade writes the WHOLE grade row — it does not merge with what is
|
|
114
|
+
// already there. That is deliberate: a partial-merge upsert reads innocently at
|
|
115
|
+
// the call site and silently preserves stale field_values from a previous grade,
|
|
116
|
+
// which is the bug that would make "develop this spark into a full idea" (R146)
|
|
117
|
+
// quietly inherit whatever was there before. Callers that only want to move the
|
|
118
|
+
// score have setIdeaCompleteness below; anyone changing the grade states the
|
|
119
|
+
// whole row.
|
|
120
|
+
//
|
|
121
|
+
// Structured throws (not raw constraint violations) so the routes that land on
|
|
122
|
+
// top of this — R143's Full Idea capture, R144's mark-as-spark — return a clean
|
|
123
|
+
// 400/404 rather than a 500 with a Postgres message in it.
|
|
124
|
+
function assertGradePayload({ grade, templateKey, fieldValues, completenessScore }) {
|
|
125
|
+
if (!VALID_GRADES.includes(grade)) {
|
|
126
|
+
throw { code: 'BAD_GRADE', grade, valid: VALID_GRADES };
|
|
127
|
+
}
|
|
128
|
+
if (typeof templateKey !== 'string' || !TEMPLATE_KEY_RE.test(templateKey) || templateKey.length > TEMPLATE_KEY_MAX) {
|
|
129
|
+
throw { code: 'BAD_TEMPLATE_KEY', template_key: templateKey };
|
|
130
|
+
}
|
|
131
|
+
// A map, never a scalar or an array — the scorer and the read path walk it by
|
|
132
|
+
// key. `typeof null === 'object'`, hence the explicit null check.
|
|
133
|
+
if (fieldValues === null || typeof fieldValues !== 'object' || Array.isArray(fieldValues)) {
|
|
134
|
+
throw { code: 'BAD_FIELD_VALUES' };
|
|
135
|
+
}
|
|
136
|
+
if (JSON.stringify(fieldValues).length > FIELD_VALUES_MAX) {
|
|
137
|
+
throw { code: 'FIELD_VALUES_TOO_LARGE', max: FIELD_VALUES_MAX };
|
|
138
|
+
}
|
|
139
|
+
if (completenessScore !== null) {
|
|
140
|
+
if (!Number.isInteger(completenessScore) || completenessScore < 0 || completenessScore > 100) {
|
|
141
|
+
throw { code: 'BAD_COMPLETENESS', completeness_score: completenessScore };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function setIdeaGrade(ideaId, {
|
|
147
|
+
grade,
|
|
148
|
+
templateKey = DEFAULT_TEMPLATE_KEY,
|
|
149
|
+
fieldValues = {},
|
|
150
|
+
completenessScore = null,
|
|
151
|
+
} = {}) {
|
|
152
|
+
assertGradePayload({ grade, templateKey, fieldValues, completenessScore });
|
|
153
|
+
try {
|
|
154
|
+
const { rows } = await pool.query(
|
|
155
|
+
`INSERT INTO ideas_grades (idea_id, grade, template_key, field_values, completeness_score)
|
|
156
|
+
VALUES ($1, $2, $3, $4::jsonb, $5)
|
|
157
|
+
ON CONFLICT (idea_id) DO UPDATE
|
|
158
|
+
SET grade = EXCLUDED.grade,
|
|
159
|
+
template_key = EXCLUDED.template_key,
|
|
160
|
+
field_values = EXCLUDED.field_values,
|
|
161
|
+
completeness_score = EXCLUDED.completeness_score
|
|
162
|
+
RETURNING idea_id, grade, template_key, field_values, completeness_score,
|
|
163
|
+
created_at, updated_at`,
|
|
164
|
+
[ideaId, grade, templateKey, JSON.stringify(fieldValues), completenessScore]
|
|
165
|
+
);
|
|
166
|
+
return rows[0];
|
|
167
|
+
} catch (err) {
|
|
168
|
+
// 23503 = FK violation on idea_id: the idea does not exist. Worth its own
|
|
169
|
+
// code because "you graded a missing idea" is a 404, not a 500.
|
|
170
|
+
if (err && err.code === '23503') throw { code: 'IDEA_NOT_FOUND', idea_id: ideaId };
|
|
171
|
+
throw err;
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// The score-only write, so the R142 scorer never has to restate the grade and
|
|
176
|
+
// the fields just to move a number (and so it cannot wipe them by forgetting).
|
|
177
|
+
// Returns null when the idea has no grade row: an ungraded idea is quick, and a
|
|
178
|
+
// quick idea has nothing to score — that is a no-op, not an error.
|
|
179
|
+
async function setIdeaCompleteness(ideaId, completenessScore) {
|
|
180
|
+
if (completenessScore !== null && (!Number.isInteger(completenessScore) || completenessScore < 0 || completenessScore > 100)) {
|
|
181
|
+
throw { code: 'BAD_COMPLETENESS', completeness_score: completenessScore };
|
|
182
|
+
}
|
|
183
|
+
const { rows } = await pool.query(
|
|
184
|
+
`UPDATE ideas_grades
|
|
185
|
+
SET completeness_score = $2
|
|
186
|
+
WHERE idea_id = $1
|
|
187
|
+
RETURNING idea_id, grade, template_key, field_values, completeness_score,
|
|
188
|
+
created_at, updated_at`,
|
|
189
|
+
[ideaId, completenessScore]
|
|
190
|
+
);
|
|
191
|
+
return rows[0] ?? null;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// -------------------------------------------------------------------------
|
|
195
|
+
// developSpark — turn a Big Idea Spark into a Full Idea IN PLACE
|
|
196
|
+
// (task 1002922 / BV1.R146, goal 1000062)
|
|
197
|
+
//
|
|
198
|
+
// The pickup half of the spark handoff: R144 recorded the author's consent, R145
|
|
199
|
+
// made the spark findable, and this is another builder filling in the five
|
|
200
|
+
// template fields.
|
|
201
|
+
//
|
|
202
|
+
// IN PLACE, ON THE SAME ROW, AND THAT IS A DONE-WHEN. idea_inbox.id,
|
|
203
|
+
// captured_by and captured_at are NOT touched — only the grade row moves. A new
|
|
204
|
+
// row for the developed version would break the hall's /#/idea/N deep link,
|
|
205
|
+
// ideas_task_lineage.idea_id, and the per-idea credit_log reason key. The
|
|
206
|
+
// author's claim on their own thought is precisely what must survive being
|
|
207
|
+
// developed by someone else; it is what the R147 split pays against.
|
|
208
|
+
//
|
|
209
|
+
// ONE GUARDED UPDATE, WHICH IS WHERE "ONLY ONCE" ACTUALLY LIVES. The predicate
|
|
210
|
+
// carries `AND grade = 'spark'`, so the database decides whether this row was
|
|
211
|
+
// still developable. A read-then-write ("is it a spark? ok, develop it") looks
|
|
212
|
+
// identical in a test and loses a race in production: two builders picking the
|
|
213
|
+
// same spark off the R145 queue at the same moment would both pass the check and
|
|
214
|
+
// the second would overwrite the first's work AND their attribution. Here the
|
|
215
|
+
// second UPDATE matches zero rows and the route turns that into a 409. Zero rows
|
|
216
|
+
// is therefore NOT an error condition to log — it is the answer.
|
|
217
|
+
//
|
|
218
|
+
// It is also why this is not expressed as setIdeaGrade + a second write:
|
|
219
|
+
// two statements cannot carry one guard, and a Full Idea with no recorded
|
|
220
|
+
// developer is the exact state the done-when forbids.
|
|
221
|
+
//
|
|
222
|
+
// THE STATUS HALF OF THE GUARD, AND WHY IT IS NOT REDUNDANT WITH THE ROUTE'S
|
|
223
|
+
// PRE-CHECK. Triage (`updateIdea`) writes idea_inbox.status and NEVER touches
|
|
224
|
+
// ideas_grades — so a spark that a Metic+ triager discards, merges or promotes
|
|
225
|
+
// keeps grade='spark' forever. Grade alone therefore does NOT mean "still
|
|
226
|
+
// developable", and a develop that trusted it would resurrect content the triage
|
|
227
|
+
// flow already decided was dead, opening a fresh board ratification window for
|
|
228
|
+
// it. The R145 queue already filters on status and the R144 mark already requires
|
|
229
|
+
// it; this closes the same hole on the write. In the PREDICATE rather than only
|
|
230
|
+
// in the handler because a triager discarding an idea mid-request is the same
|
|
231
|
+
// class of race as two developers colliding — and the fix for a race is never a
|
|
232
|
+
// check that ran earlier.
|
|
233
|
+
//
|
|
234
|
+
// NOTE ON setIdeaGrade's whole-row contract: developed_by/developed_at are
|
|
235
|
+
// deliberately OUTSIDE it. That contract exists so a grade change cannot inherit
|
|
236
|
+
// stale field_values; but a developer is a historical FACT about who did the
|
|
237
|
+
// work, not current content, and setIdeaGrade's ON CONFLICT SET list omits both
|
|
238
|
+
// columns — so a later re-score or an author's R13 resubmission preserves the
|
|
239
|
+
// developer's attribution instead of silently erasing it.
|
|
240
|
+
async function developSpark(ideaId, { developedBy, templateKey = DEFAULT_TEMPLATE_KEY, fieldValues = {}, completenessScore = null } = {}) {
|
|
241
|
+
// Reuse the R140 payload validator so a bad template key or an oversize
|
|
242
|
+
// field_values map is a structured throw here rather than a constraint
|
|
243
|
+
// violation surfacing as a 500 (the assertGradePayload contract).
|
|
244
|
+
assertGradePayload({ grade: 'full', templateKey, fieldValues, completenessScore });
|
|
245
|
+
if (developedBy == null) throw { code: 'MISSING_DEVELOPER' };
|
|
246
|
+
const { rows } = await pool.query(
|
|
247
|
+
`UPDATE ideas_grades
|
|
248
|
+
SET grade = 'full',
|
|
249
|
+
template_key = $2,
|
|
250
|
+
field_values = $3::jsonb,
|
|
251
|
+
completeness_score = $4,
|
|
252
|
+
developed_by = $5,
|
|
253
|
+
developed_at = now()
|
|
254
|
+
WHERE idea_id = $1
|
|
255
|
+
AND grade = 'spark'
|
|
256
|
+
AND EXISTS (
|
|
257
|
+
SELECT 1 FROM idea_inbox i WHERE i.id = $1 AND i.status = 'open'
|
|
258
|
+
)
|
|
259
|
+
RETURNING idea_id, grade, template_key, field_values, completeness_score,
|
|
260
|
+
developed_by, developed_at, created_at, updated_at`,
|
|
261
|
+
[ideaId, templateKey, JSON.stringify(fieldValues), completenessScore, developedBy]
|
|
262
|
+
);
|
|
263
|
+
// null = "this row was not a developable spark when we looked". THREE ways to
|
|
264
|
+
// get here, and the caller re-reads to say which: it was never a spark,
|
|
265
|
+
// somebody else developed it first, or a triager closed the idea underneath us.
|
|
266
|
+
const graded = rows[0] ?? null;
|
|
267
|
+
|
|
268
|
+
// task 1003856: a Full Idea is the ideator's hand-off point, and the
|
|
269
|
+
// cross-role cascade that turns it into an engineer's scoping request needs a
|
|
270
|
+
// transition to hang on. Emitted only on a REAL transition — the UPDATE's
|
|
271
|
+
// `AND grade = 'spark'` means a no-op re-grade returns null and fires nothing,
|
|
272
|
+
// so the event cannot repeat for one idea.
|
|
273
|
+
//
|
|
274
|
+
// Wrapped and awaited-free by the same reasoning as every other kernel emit
|
|
275
|
+
// here: a listener must never be able to fail the write that preceded it. The
|
|
276
|
+
// cascade runner catches everything of its own accord as well.
|
|
277
|
+
if (graded) {
|
|
278
|
+
// The payload carries the WHOLE idea, not just its id. The listener lives in
|
|
279
|
+
// the lifecycle module, which owns no idea table and has no read port for one
|
|
280
|
+
// (ideas provides only `ideas.capture`) — so the read happens here, inside the
|
|
281
|
+
// module that owns the row, rather than by widening another module's surface.
|
|
282
|
+
try {
|
|
283
|
+
const idea = await getIdea(ideaId);
|
|
284
|
+
api.emit('idea.graded.full', {
|
|
285
|
+
...(idea || {}),
|
|
286
|
+
id: ideaId,
|
|
287
|
+
idea_id: ideaId,
|
|
288
|
+
grade: 'full',
|
|
289
|
+
completeness_score: graded.completeness_score,
|
|
290
|
+
developed_by: graded.developed_by,
|
|
291
|
+
});
|
|
292
|
+
} catch { /* a listener, or the read that feeds it, must never cost the ideator their grade */ }
|
|
293
|
+
}
|
|
294
|
+
return graded;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// The grade row on its own. null = no row, which reads as grade='quick'
|
|
298
|
+
// everywhere the idea itself is read (see DEFAULT_GRADE).
|
|
299
|
+
async function getIdeaGrade(ideaId) {
|
|
300
|
+
const { rows } = await pool.query(
|
|
301
|
+
`SELECT idea_id, grade, template_key, field_values, completeness_score,
|
|
302
|
+
created_at, updated_at
|
|
303
|
+
FROM ideas_grades
|
|
304
|
+
WHERE idea_id = $1`,
|
|
305
|
+
[ideaId]
|
|
306
|
+
);
|
|
307
|
+
return rows[0] ?? null;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
module.exports = {
|
|
311
|
+
listSparkQueue,
|
|
312
|
+
countSparkQueue,
|
|
313
|
+
setIdeaGrade,
|
|
314
|
+
setIdeaCompleteness,
|
|
315
|
+
developSpark,
|
|
316
|
+
getIdeaGrade,
|
|
317
|
+
};
|