@bongos/core 1.19.295 → 1.19.297
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 +37 -22
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +4 -0
- package/clients/bongos-client/index.cjs +4 -0
- package/clients/bongos-client/index.d.ts +6 -0
- package/clients/bongos-client/index.mjs +4 -0
- package/docs/api/openapi.json +124 -3
- package/docs/api-reference.md +4 -2
- package/docs/file-map.md +1 -1
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
- package/modules/ideas/inbox.js +174 -2
- package/modules/ideas/migrations/ideas_005_grade_developed_by.sql +85 -0
- package/modules/ideas/routes/inbox.js +191 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/spark.js +171 -5
- package/src/module-api.js +5 -1
- package/tests/idea_develop_spark.mjs +399 -0
- package/tests/idea_spark_queue.mjs +233 -0
package/modules/ideas/inbox.js
CHANGED
|
@@ -100,15 +100,22 @@ const FIELD_VALUES_MAX = 32768;
|
|
|
100
100
|
// The grade half of an idea read: the COALESCE above, plus the template-shaped
|
|
101
101
|
// store and the score. `g.*` columns are NULL for an ungraded idea, which is
|
|
102
102
|
// exactly right — it has no template and no score, it just has a default grade.
|
|
103
|
+
// developed_by/developed_at (ideas_005, task 1002922 / BV1.R146) ride along on
|
|
104
|
+
// EVERY idea read rather than needing a second query: "who developed this" is
|
|
105
|
+
// part of an idea's identity once it has been developed, and the R145 queue, the
|
|
106
|
+
// hall detail view (R153) and the R147 credit split all want it. NULL for the
|
|
107
|
+
// overwhelming majority of rows — a quick idea and a self-authored Full Idea were
|
|
108
|
+
// never developed by anyone — which is the honest answer, not a missing one.
|
|
103
109
|
const GRADE_COLS_Q = `COALESCE(g.grade, '${DEFAULT_GRADE}') AS grade,
|
|
104
|
-
g.template_key, g.field_values, g.completeness_score
|
|
110
|
+
g.template_key, g.field_values, g.completeness_score,
|
|
111
|
+
g.developed_by, g.developed_at`;
|
|
105
112
|
const IDEA_JOIN_GRADE = `LEFT JOIN ideas_grades g ON g.idea_id = i.id`;
|
|
106
113
|
|
|
107
114
|
// The same projection for a row that provably has no grade row yet (a just-
|
|
108
115
|
// INSERTed idea), so INSERT…RETURNING does not need a second round trip to
|
|
109
116
|
// return the same shape every other read returns.
|
|
110
117
|
function ungradedProjection() {
|
|
111
|
-
return { grade: DEFAULT_GRADE, template_key: null, field_values: null, completeness_score: null };
|
|
118
|
+
return { grade: DEFAULT_GRADE, template_key: null, field_values: null, completeness_score: null, developed_by: null, developed_at: null };
|
|
112
119
|
}
|
|
113
120
|
|
|
114
121
|
// -------------------------------------------------------------------------
|
|
@@ -782,6 +789,91 @@ async function countRoutedAwaitingNod(deps = {}) {
|
|
|
782
789
|
return rows[0] ? rows[0].n : 0;
|
|
783
790
|
}
|
|
784
791
|
|
|
792
|
+
// -------------------------------------------------------------------------
|
|
793
|
+
// The spark queue (task 1002921 / BV1.R145, goal 1000062)
|
|
794
|
+
//
|
|
795
|
+
// "What Big Idea Sparks are awaiting development?" — the ideator craft's pickup
|
|
796
|
+
// surface, and the first place in this codebase where one craft sources work
|
|
797
|
+
// from another craft's output.
|
|
798
|
+
//
|
|
799
|
+
// THE PREDICATE IS grade='spark' AND status='open', AND THAT IS THE WHOLE
|
|
800
|
+
// QUEUE. There is deliberately no `developed` flag and no separate queue table:
|
|
801
|
+
// R146 develops a spark into a Full Idea IN PLACE on the same idea_inbox row
|
|
802
|
+
// (same id, same captured_by), which flips its grade spark→full — so a developed
|
|
803
|
+
// spark leaves this queue as a CONSEQUENCE of being developed, not because
|
|
804
|
+
// anything remembered to remove it. A second source of truth for "still
|
|
805
|
+
// waiting" is a thing that can disagree with the grade; this cannot.
|
|
806
|
+
//
|
|
807
|
+
// AN INNER JOIN, WHICH INVERTS THIS MODULE'S USUAL RULE ON PURPOSE. Every other
|
|
808
|
+
// idea read LEFT JOINs ideas_grades and COALESCEs to 'quick', because an
|
|
809
|
+
// un-graded idea must still appear. Here the opposite is correct and safe:
|
|
810
|
+
// grade='spark' can never be implicit — absence of a row means quick — so a
|
|
811
|
+
// spark ALWAYS has a real row, and starting from ideas_grades lets
|
|
812
|
+
// ideas_grades_grade_score_idx (ideas_001, written for this very read) drive the
|
|
813
|
+
// scan instead of filtering a full idea_inbox pass afterwards.
|
|
814
|
+
//
|
|
815
|
+
// ORDERING — "newest-or-best first", and only the newest half is real. A spark
|
|
816
|
+
// carries no completeness score by construction (it has no template fields to
|
|
817
|
+
// score), so there is no "best" signal to sort by: upvotes live on builders as
|
|
818
|
+
// karma, not per-idea, and inventing a quality metric for an undeveloped thought
|
|
819
|
+
// is exactly the guess this queue should not be making. captured_at DESC, id
|
|
820
|
+
// DESC as a stable tiebreak for same-timestamp rows. When a real per-idea
|
|
821
|
+
// signal exists, this ORDER BY is the one line that changes.
|
|
822
|
+
//
|
|
823
|
+
// The skill-friction visibility gate (V3.R70) is applied here too, on the same
|
|
824
|
+
// title-prefix rule listOpenIdeas uses. A routine-filed friction row is unlikely
|
|
825
|
+
// to ever be sparked (marking requires an author), but the two idea listings
|
|
826
|
+
// agreeing by construction beats them agreeing by accident.
|
|
827
|
+
function sparkQueueWhere(viewerRank, params) {
|
|
828
|
+
const where = [`g.grade = 'spark'`, `i.status = 'open'`];
|
|
829
|
+
if (!rankCanSeeSkillFriction(viewerRank)) {
|
|
830
|
+
params.push(`${SKILL_FRICTION_TAG}%`);
|
|
831
|
+
where.push(`i.title NOT LIKE $${params.length}`);
|
|
832
|
+
}
|
|
833
|
+
return where;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
async function listSparkQueue({ limit = 50, viewerRank = null } = {}, deps = {}) {
|
|
837
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
838
|
+
const params = [];
|
|
839
|
+
const where = sparkQueueWhere(viewerRank, params);
|
|
840
|
+
params.push(limit);
|
|
841
|
+
const { rows } = await activePool.query(
|
|
842
|
+
// `b.github_login` because attribution is the POINT of this surface: an
|
|
843
|
+
// ideator picking work up should see whose thought they are developing, and
|
|
844
|
+
// R146 has to preserve that name. LEFT JOIN — a builder row can be missing.
|
|
845
|
+
`SELECT ${IDEA_COLS_Q}, ${GRADE_COLS_Q},
|
|
846
|
+
b.github_login AS filed_by_login
|
|
847
|
+
FROM ideas_grades g
|
|
848
|
+
JOIN idea_inbox i ON i.id = g.idea_id
|
|
849
|
+
LEFT JOIN builders b ON b.id = i.captured_by
|
|
850
|
+
WHERE ${where.join(' AND ')}
|
|
851
|
+
ORDER BY i.captured_at DESC, i.id DESC
|
|
852
|
+
LIMIT $${params.length}`,
|
|
853
|
+
params
|
|
854
|
+
);
|
|
855
|
+
return rows;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// The count behind the queue's pill — the SAME predicate as the list (the
|
|
859
|
+
// countRoutedAwaitingNod / countOpenIdeas precedent), so the number a viewer is
|
|
860
|
+
// shown can never disagree with the rows they can actually see. It deliberately
|
|
861
|
+
// takes viewerRank too: a count that ignored the visibility gate would tell a
|
|
862
|
+
// Xenos there are sparks they cannot find.
|
|
863
|
+
async function countSparkQueue({ viewerRank = null } = {}, deps = {}) {
|
|
864
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
865
|
+
const params = [];
|
|
866
|
+
const where = sparkQueueWhere(viewerRank, params);
|
|
867
|
+
const { rows } = await activePool.query(
|
|
868
|
+
`SELECT COUNT(*)::int AS n
|
|
869
|
+
FROM ideas_grades g
|
|
870
|
+
JOIN idea_inbox i ON i.id = g.idea_id
|
|
871
|
+
WHERE ${where.join(' AND ')}`,
|
|
872
|
+
params
|
|
873
|
+
);
|
|
874
|
+
return rows[0] ? rows[0].n : 0;
|
|
875
|
+
}
|
|
876
|
+
|
|
785
877
|
// -------------------------------------------------------------------------
|
|
786
878
|
// Grade writes (task 1002916 / BV1.R140)
|
|
787
879
|
//
|
|
@@ -866,6 +958,81 @@ async function setIdeaCompleteness(ideaId, completenessScore) {
|
|
|
866
958
|
return rows[0] ?? null;
|
|
867
959
|
}
|
|
868
960
|
|
|
961
|
+
// -------------------------------------------------------------------------
|
|
962
|
+
// developSpark — turn a Big Idea Spark into a Full Idea IN PLACE
|
|
963
|
+
// (task 1002922 / BV1.R146, goal 1000062)
|
|
964
|
+
//
|
|
965
|
+
// The pickup half of the spark handoff: R144 recorded the author's consent, R145
|
|
966
|
+
// made the spark findable, and this is another builder filling in the five
|
|
967
|
+
// template fields.
|
|
968
|
+
//
|
|
969
|
+
// IN PLACE, ON THE SAME ROW, AND THAT IS A DONE-WHEN. idea_inbox.id,
|
|
970
|
+
// captured_by and captured_at are NOT touched — only the grade row moves. A new
|
|
971
|
+
// row for the developed version would break the hall's /#/idea/N deep link,
|
|
972
|
+
// ideas_task_lineage.idea_id, and the per-idea credit_log reason key. The
|
|
973
|
+
// author's claim on their own thought is precisely what must survive being
|
|
974
|
+
// developed by someone else; it is what the R147 split pays against.
|
|
975
|
+
//
|
|
976
|
+
// ONE GUARDED UPDATE, WHICH IS WHERE "ONLY ONCE" ACTUALLY LIVES. The predicate
|
|
977
|
+
// carries `AND grade = 'spark'`, so the database decides whether this row was
|
|
978
|
+
// still developable. A read-then-write ("is it a spark? ok, develop it") looks
|
|
979
|
+
// identical in a test and loses a race in production: two builders picking the
|
|
980
|
+
// same spark off the R145 queue at the same moment would both pass the check and
|
|
981
|
+
// the second would overwrite the first's work AND their attribution. Here the
|
|
982
|
+
// second UPDATE matches zero rows and the route turns that into a 409. Zero rows
|
|
983
|
+
// is therefore NOT an error condition to log — it is the answer.
|
|
984
|
+
//
|
|
985
|
+
// It is also why this is not expressed as setIdeaGrade + a second write:
|
|
986
|
+
// two statements cannot carry one guard, and a Full Idea with no recorded
|
|
987
|
+
// developer is the exact state the done-when forbids.
|
|
988
|
+
//
|
|
989
|
+
// THE STATUS HALF OF THE GUARD, AND WHY IT IS NOT REDUNDANT WITH THE ROUTE'S
|
|
990
|
+
// PRE-CHECK. Triage (`updateIdea`) writes idea_inbox.status and NEVER touches
|
|
991
|
+
// ideas_grades — so a spark that a Metic+ triager discards, merges or promotes
|
|
992
|
+
// keeps grade='spark' forever. Grade alone therefore does NOT mean "still
|
|
993
|
+
// developable", and a develop that trusted it would resurrect content the triage
|
|
994
|
+
// flow already decided was dead, opening a fresh board ratification window for
|
|
995
|
+
// it. The R145 queue already filters on status and the R144 mark already requires
|
|
996
|
+
// it; this closes the same hole on the write. In the PREDICATE rather than only
|
|
997
|
+
// in the handler because a triager discarding an idea mid-request is the same
|
|
998
|
+
// class of race as two developers colliding — and the fix for a race is never a
|
|
999
|
+
// check that ran earlier.
|
|
1000
|
+
//
|
|
1001
|
+
// NOTE ON setIdeaGrade's whole-row contract: developed_by/developed_at are
|
|
1002
|
+
// deliberately OUTSIDE it. That contract exists so a grade change cannot inherit
|
|
1003
|
+
// stale field_values; but a developer is a historical FACT about who did the
|
|
1004
|
+
// work, not current content, and setIdeaGrade's ON CONFLICT SET list omits both
|
|
1005
|
+
// columns — so a later re-score or an author's R13 resubmission preserves the
|
|
1006
|
+
// developer's attribution instead of silently erasing it.
|
|
1007
|
+
async function developSpark(ideaId, { developedBy, templateKey = DEFAULT_TEMPLATE_KEY, fieldValues = {}, completenessScore = null } = {}) {
|
|
1008
|
+
// Reuse the R140 payload validator so a bad template key or an oversize
|
|
1009
|
+
// field_values map is a structured throw here rather than a constraint
|
|
1010
|
+
// violation surfacing as a 500 (the assertGradePayload contract).
|
|
1011
|
+
assertGradePayload({ grade: 'full', templateKey, fieldValues, completenessScore });
|
|
1012
|
+
if (developedBy == null) throw { code: 'MISSING_DEVELOPER' };
|
|
1013
|
+
const { rows } = await pool.query(
|
|
1014
|
+
`UPDATE ideas_grades
|
|
1015
|
+
SET grade = 'full',
|
|
1016
|
+
template_key = $2,
|
|
1017
|
+
field_values = $3::jsonb,
|
|
1018
|
+
completeness_score = $4,
|
|
1019
|
+
developed_by = $5,
|
|
1020
|
+
developed_at = now()
|
|
1021
|
+
WHERE idea_id = $1
|
|
1022
|
+
AND grade = 'spark'
|
|
1023
|
+
AND EXISTS (
|
|
1024
|
+
SELECT 1 FROM idea_inbox i WHERE i.id = $1 AND i.status = 'open'
|
|
1025
|
+
)
|
|
1026
|
+
RETURNING idea_id, grade, template_key, field_values, completeness_score,
|
|
1027
|
+
developed_by, developed_at, created_at, updated_at`,
|
|
1028
|
+
[ideaId, templateKey, JSON.stringify(fieldValues), completenessScore, developedBy]
|
|
1029
|
+
);
|
|
1030
|
+
// null = "this row was not a developable spark when we looked". THREE ways to
|
|
1031
|
+
// get here, and the caller re-reads to say which: it was never a spark,
|
|
1032
|
+
// somebody else developed it first, or a triager closed the idea underneath us.
|
|
1033
|
+
return rows[0] ?? null;
|
|
1034
|
+
}
|
|
1035
|
+
|
|
869
1036
|
// The grade row on its own. null = no row, which reads as grade='quick'
|
|
870
1037
|
// everywhere the idea itself is read (see DEFAULT_GRADE).
|
|
871
1038
|
async function getIdeaGrade(ideaId) {
|
|
@@ -1101,6 +1268,8 @@ module.exports = {
|
|
|
1101
1268
|
updateIdeaBody,
|
|
1102
1269
|
setIdeaGrade,
|
|
1103
1270
|
setIdeaCompleteness,
|
|
1271
|
+
// develop a spark into a Full Idea, in place (task 1002922, BV1.R146)
|
|
1272
|
+
developSpark,
|
|
1104
1273
|
// idea → task lineage (task 1002997 / BV1.R173) — the one-idea-many-tasks
|
|
1105
1274
|
// link stream 1 (IC4) pays against
|
|
1106
1275
|
linkIdeaTask,
|
|
@@ -1109,6 +1278,9 @@ module.exports = {
|
|
|
1109
1278
|
// the nod queue (task 1003066, BV1.R05)
|
|
1110
1279
|
listRoutedAwaitingNod,
|
|
1111
1280
|
countRoutedAwaitingNod,
|
|
1281
|
+
// the spark queue (task 1002921, BV1.R145) — sparks awaiting development
|
|
1282
|
+
listSparkQueue,
|
|
1283
|
+
countSparkQueue,
|
|
1112
1284
|
// criterion-proposal ratification (V3.R31 / #252)
|
|
1113
1285
|
ratifyCriterionProposal,
|
|
1114
1286
|
parseProposalBody,
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
-- ideas_005_grade_developed_by.sql — who developed this spark into a Full Idea
|
|
2
|
+
-- (task 1002922, BV1.R146, goal 1000062 "three grades of idea").
|
|
3
|
+
--
|
|
4
|
+
-- WHY. R144 let an author mark a quick idea as a Big Idea Spark ("I have the
|
|
5
|
+
-- spark but not the full picture, someone develop this") and R145 made those
|
|
6
|
+
-- sparks browsable. R146 is the pickup itself: another builder fills in the five
|
|
7
|
+
-- template fields and the spark becomes a Full Idea. The done-when requires the
|
|
8
|
+
-- row to keep its id and its ORIGINAL author while a DISTINCT developer is
|
|
9
|
+
-- recorded — and ideas_grades had nowhere to record the second person. This adds
|
|
10
|
+
-- that place.
|
|
11
|
+
--
|
|
12
|
+
-- WHY IN-PLACE, ON THE SAME ROW. The alternative — a new idea_inbox row for the
|
|
13
|
+
-- developed version — breaks every reference to the original: the hall's
|
|
14
|
+
-- /#/idea/N deep link, ideas_task_lineage.idea_id, the credit_log reason key
|
|
15
|
+
-- `idea.credit.full_pass:<id>`, and any prose that cited it. "References to the
|
|
16
|
+
-- idea never break" is a done-when, so development mutates the grade row and
|
|
17
|
+
-- leaves idea_inbox.id / captured_by / captured_at untouched. That is also why
|
|
18
|
+
-- the developer needs its own column instead of overwriting captured_by: the
|
|
19
|
+
-- author's claim on their own thought is exactly what must survive being
|
|
20
|
+
-- developed by someone else, and it is what the R147 credit split pays against.
|
|
21
|
+
--
|
|
22
|
+
-- WHY developed_at, AND NOT JUST updated_at. ideas_001 put a touch trigger on
|
|
23
|
+
-- this table, so updated_at moves on EVERY later write — a re-score, a
|
|
24
|
+
-- resubmission, a triage-adjacent grade rewrite. An attribution record whose
|
|
25
|
+
-- only timestamp drifts cannot answer "when was this developed", which is the
|
|
26
|
+
-- question the R147 split and any dispute about it will ask. One extra column is
|
|
27
|
+
-- cheaper than an unanswerable question.
|
|
28
|
+
--
|
|
29
|
+
-- SELF-DEVELOPMENT IS LEGAL, SO THERE IS NO developed_by <> captured_by CHECK.
|
|
30
|
+
-- An author may mark their own idea as needing development and then develop it
|
|
31
|
+
-- themselves (they may have had the full picture a week later). Forbidding that
|
|
32
|
+
-- would add a rule the owner never asked for; R147 simply sees a split where
|
|
33
|
+
-- both halves are the same person. The pair-coherence CHECK below is the only
|
|
34
|
+
-- constraint, and it guards a shape this module's own code controls.
|
|
35
|
+
--
|
|
36
|
+
-- Module migrations are ADDITIVE and NAMESPACED: every object here targets the
|
|
37
|
+
-- ideas_-owned ideas_grades table, no down-migration, idempotent (safe to re-run).
|
|
38
|
+
|
|
39
|
+
BEGIN;
|
|
40
|
+
|
|
41
|
+
-- ON DELETE SET NULL, matching the builders-FK posture of every other module
|
|
42
|
+
-- table that names a person (agents_001, government_001): losing a builder row
|
|
43
|
+
-- must never cascade away an idea's grade. The attribution degrades to "someone",
|
|
44
|
+
-- which the read path already renders (R145's queue prints "an unnamed builder").
|
|
45
|
+
ALTER TABLE ideas_grades
|
|
46
|
+
ADD COLUMN IF NOT EXISTS developed_by bigint REFERENCES builders(id) ON DELETE SET NULL;
|
|
47
|
+
|
|
48
|
+
ALTER TABLE ideas_grades
|
|
49
|
+
ADD COLUMN IF NOT EXISTS developed_at timestamptz;
|
|
50
|
+
|
|
51
|
+
-- The two columns are written together by developSpark() and mean nothing apart:
|
|
52
|
+
-- a developer with no timestamp cannot be dated, and a timestamp with no
|
|
53
|
+
-- developer names nobody. Defense in depth against a future partial write.
|
|
54
|
+
ALTER TABLE ideas_grades DROP CONSTRAINT IF EXISTS ideas_grades_developed_pair_chk;
|
|
55
|
+
ALTER TABLE ideas_grades ADD CONSTRAINT ideas_grades_developed_pair_chk
|
|
56
|
+
CHECK ((developed_by IS NULL) = (developed_at IS NULL));
|
|
57
|
+
|
|
58
|
+
-- "What has this builder developed?" — the read the R147 credit split walks, and
|
|
59
|
+
-- the one a builder's own profile will want. PARTIAL, because the overwhelming
|
|
60
|
+
-- majority of grade rows have no developer at all (every quick idea, every
|
|
61
|
+
-- self-authored full idea), so indexing them would be paying for empty space.
|
|
62
|
+
CREATE INDEX IF NOT EXISTS ideas_grades_developed_by_idx
|
|
63
|
+
ON ideas_grades (developed_by)
|
|
64
|
+
WHERE developed_by IS NOT NULL;
|
|
65
|
+
|
|
66
|
+
-- SAFETY NET — a migration that cannot prove its own effect must not report
|
|
67
|
+
-- success (the ideas_003 precedent). Read-only, no DDL: if either column is
|
|
68
|
+
-- missing, the ADD silently did nothing and R146's route would fail at runtime
|
|
69
|
+
-- on the one write whose whole job is preserving attribution.
|
|
70
|
+
DO $$
|
|
71
|
+
DECLARE missing text;
|
|
72
|
+
BEGIN
|
|
73
|
+
SELECT string_agg(c, ', ') INTO missing
|
|
74
|
+
FROM unnest(ARRAY['developed_by', 'developed_at']) AS c
|
|
75
|
+
WHERE NOT EXISTS (
|
|
76
|
+
SELECT 1 FROM information_schema.columns
|
|
77
|
+
WHERE table_name = 'ideas_grades' AND column_name = c
|
|
78
|
+
);
|
|
79
|
+
IF missing IS NOT NULL THEN
|
|
80
|
+
RAISE EXCEPTION
|
|
81
|
+
'ideas_005: column(s) % missing from ideas_grades after ADD COLUMN — the migration did not take.', missing;
|
|
82
|
+
END IF;
|
|
83
|
+
END $$;
|
|
84
|
+
|
|
85
|
+
COMMIT;
|
|
@@ -312,6 +312,43 @@ module.exports = function buildInboxRouter() {
|
|
|
312
312
|
}
|
|
313
313
|
});
|
|
314
314
|
|
|
315
|
+
|
|
316
|
+
// GET /inbox/sparks — THE SPARK QUEUE (task 1002921 / BV1.R145, goal 1000062).
|
|
317
|
+
//
|
|
318
|
+
// Big Idea Sparks awaiting development: the ideator craft's pickup surface,
|
|
319
|
+
// and the first place one craft sources work from another craft's output.
|
|
320
|
+
//
|
|
321
|
+
// READABLE BY ANY BUILDER, by design and by the done-when. A pickup surface
|
|
322
|
+
// that only trusted builders could read would defeat its own purpose — the
|
|
323
|
+
// whole point is that someone OTHER than the author comes along and develops
|
|
324
|
+
// the thought. The author's consent is already recorded (the R144 mark is what
|
|
325
|
+
// put the row here), so no further gate belongs on the read. Contrast
|
|
326
|
+
// /inbox/awaiting-nod directly above, which IS gated: that queue exists to be
|
|
327
|
+
// ACTED on by a triager, this one to be BROWSED by anyone.
|
|
328
|
+
//
|
|
329
|
+
// DECLARED BEFORE `GET /inbox/:id` DELIBERATELY — and this ordering is
|
|
330
|
+
// load-bearing, not stylistic. Express matches in declaration order, so with
|
|
331
|
+
// the routes the other way round `/inbox/sparks` binds `:id = 'sparks'` and
|
|
332
|
+
// parseId turns the whole queue into a 400 bad_id. Same reason
|
|
333
|
+
// /inbox/awaiting-nod sits above it. tests/idea_spark_queue.mjs pins the
|
|
334
|
+
// relative order so a future insertion cannot quietly re-shadow it.
|
|
335
|
+
//
|
|
336
|
+
// rank: any-builder — a pickup surface is public to builders (the R144 mark is
|
|
337
|
+
// the consent). Deliberately NOT pinned in route-rank-check: a read with no
|
|
338
|
+
// gate to protect has no downgrade to catch, and pinning it would only assert
|
|
339
|
+
// that a read stays open.
|
|
340
|
+
router.get('/inbox/sparks', api.requireBuilder, api.asyncHandler('GET /inbox/sparks', async (req, res) => {
|
|
341
|
+
const limitRaw = Number(req.query.limit);
|
|
342
|
+
const limit = Number.isInteger(limitRaw) && limitRaw > 0 && limitRaw <= 100 ? limitRaw : 50;
|
|
343
|
+
// Both halves take the viewer's rank so the pill can never claim sparks the
|
|
344
|
+
// list will not show (the countRoutedAwaitingNod precedent).
|
|
345
|
+
const viewerRank = req.builder && req.builder.rank;
|
|
346
|
+
const [sparks, count] = await Promise.all([
|
|
347
|
+
inbox.listSparkQueue({ limit, viewerRank }),
|
|
348
|
+
inbox.countSparkQueue({ viewerRank }),
|
|
349
|
+
]);
|
|
350
|
+
return res.json({ sparks, count });
|
|
351
|
+
}, { errorCode: 'spark_queue_failed' }));
|
|
315
352
|
// rank: any-builder — single idea read for the "#NNN" deep-link resolver
|
|
316
353
|
// (#1047). Mirrors GET /blockers/:id. The hall resolves /#/idea/N by id, but
|
|
317
354
|
// the /inbox LIST returns only the open-first-50, so a closed/older idea ref
|
|
@@ -556,6 +593,160 @@ module.exports = function buildInboxRouter() {
|
|
|
556
593
|
});
|
|
557
594
|
}, { errorCode: 'idea_unspark_failed' }));
|
|
558
595
|
|
|
596
|
+
// POST /inbox/:id/develop — DEVELOP A SPARK INTO A FULL IDEA
|
|
597
|
+
// (task 1002922 / BV1.R146, goal 1000062).
|
|
598
|
+
//
|
|
599
|
+
// The pickup half of the handoff R144 and R145 set up: the author consented by
|
|
600
|
+
// marking the spark, the queue made it findable, and now another builder fills
|
|
601
|
+
// in the five template fields and the thought becomes a Full Idea.
|
|
602
|
+
//
|
|
603
|
+
// OPEN TO ANY BUILDER, INCLUDING THE AUTHOR. Cross-builder development is the
|
|
604
|
+
// entire point, so there is no author check here — the mirror image of R144,
|
|
605
|
+
// where the gate was the whole feature. The consent was already given and is
|
|
606
|
+
// already recorded (this row is a spark BECAUSE its author said so), and
|
|
607
|
+
// re-asking for it would make the queue unpickable. Self-development is
|
|
608
|
+
// likewise legal: an author may mark a thought and come back to it themselves.
|
|
609
|
+
//
|
|
610
|
+
// ONLY A SPARK CAN BE DEVELOPED, and "only once" is enforced by the DATABASE,
|
|
611
|
+
// not by this handler. developSpark's UPDATE carries `AND grade = 'spark'`, so
|
|
612
|
+
// two builders picking the same spark off the queue at the same instant cannot
|
|
613
|
+
// both succeed — the second matches zero rows. The pre-read below exists ONLY
|
|
614
|
+
// to tell a 404 apart from a 409 and to give a useful message; it is not the
|
|
615
|
+
// guard, and it deliberately does not try to be.
|
|
616
|
+
//
|
|
617
|
+
// WHAT THIS ROUTE DELIBERATELY DOES NOT DO: pay the stream-2 "Full Idea passed"
|
|
618
|
+
// credit. Capture and resubmit both fire it, but on those paths the recipient
|
|
619
|
+
// is unambiguously the author. Here there are two people, and choosing between
|
|
620
|
+
// them is R147's job (task 1002923 — "karma for a ratified Full Idea, scaled by
|
|
621
|
+
// completeness and SPLIT WITH THE SPARK AUTHOR"). Firing it here would not just
|
|
622
|
+
// pre-empt that decision, it would corrupt it: awardIdeaFullPassCredit's
|
|
623
|
+
// idempotency predicate is (builder_id, `idea.credit.full_pass:<id>`), so a
|
|
624
|
+
// payment to the DEVELOPER does not block a later payment to the AUTHOR — one
|
|
625
|
+
// idea would pay twice. The credit is idempotent per idea+builder, so R147 can
|
|
626
|
+
// still pay it at the right time, to the right people, in the right shares.
|
|
627
|
+
//
|
|
628
|
+
// The board window IS opened, because it is recipient-agnostic (it takes an
|
|
629
|
+
// idea and a score, not a builder) and without it a developed Full Idea could
|
|
630
|
+
// never reach ratification — the handoff would dead-end one step from done.
|
|
631
|
+
// Fail-open like every other caller of that port.
|
|
632
|
+
//
|
|
633
|
+
// rank: any-builder — development is the pickup, and the author's mark is the
|
|
634
|
+
// consent. Deliberately NOT pinned in route-rank-check: there is no gate here
|
|
635
|
+
// to protect, and pinning it would only assert that an open route stays open.
|
|
636
|
+
router.post('/inbox/:id/develop', api.requireBuilder, api.asyncHandler('POST /inbox/:id/develop', async (req, res) => {
|
|
637
|
+
const id = parseId(req, res, { code: 'bad_id', positiveInt: true });
|
|
638
|
+
if (id === null) return;
|
|
639
|
+
if (validateOrRespond(req, res, {
|
|
640
|
+
field_values: { required: true, type: 'object' },
|
|
641
|
+
template_key: { type: 'string', maxLength: 64 },
|
|
642
|
+
})) return;
|
|
643
|
+
|
|
644
|
+
const idea = await inbox.getIdea(id);
|
|
645
|
+
if (!idea) return res.fail('idea_not_found', 404);
|
|
646
|
+
if (idea.grade !== 'spark') {
|
|
647
|
+
return res.fail('not_a_spark', 409, {
|
|
648
|
+
grade: idea.grade,
|
|
649
|
+
...(idea.developed_by ? { developed_by: idea.developed_by } : {}),
|
|
650
|
+
reason: idea.grade === 'full'
|
|
651
|
+
? 'this idea has already been developed into a Full Idea'
|
|
652
|
+
: 'only a Big Idea Spark can be developed — its author has to mark it first, which is what makes developing it fair',
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
// A SPARK IS NOT NECESSARILY STILL DEVELOPABLE. Triage writes
|
|
656
|
+
// idea_inbox.status and never touches ideas_grades, so an idea a triager
|
|
657
|
+
// discarded, merged or promoted keeps grade='spark' forever. Developing one
|
|
658
|
+
// would resurrect content the triage flow already decided was dead — and
|
|
659
|
+
// worse, open a fresh board ratification window for it. The R144 mark and the
|
|
660
|
+
// R145 queue both require status='open'; this is the same rule on the write.
|
|
661
|
+
// (developSpark's predicate carries it too, for the mid-request race.)
|
|
662
|
+
if (idea.status !== 'open') {
|
|
663
|
+
return res.fail('idea_not_open', 409, {
|
|
664
|
+
status: idea.status,
|
|
665
|
+
reason: 'this idea is no longer open, so it is not awaiting development',
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Same validation the capture and resubmit paths use, against the same
|
|
670
|
+
// template declaration — a developed Full Idea is a Full Idea, and must not
|
|
671
|
+
// be held to a looser standard just because someone else wrote it.
|
|
672
|
+
const templateKey = req.body.template_key ?? idea.template_key ?? undefined;
|
|
673
|
+
let v;
|
|
674
|
+
try {
|
|
675
|
+
v = validateFieldValues(req.body.field_values, templateKey ? { templateKey } : undefined);
|
|
676
|
+
} catch (validationErr) {
|
|
677
|
+
api.logger('ideas').error(`POST /inbox/:id/develop validation threw: ${validationErr && validationErr.message}`);
|
|
678
|
+
return res.fail('invalid_field_values', 400, { errors: [{ field: null, reason: 'unvalidatable' }] });
|
|
679
|
+
}
|
|
680
|
+
if (!v.ok) return res.fail('invalid_field_values', 400, { errors: v.errors, template_key: templateKey ?? null });
|
|
681
|
+
|
|
682
|
+
const row = await inbox.developSpark(id, {
|
|
683
|
+
developedBy: req.builder.id,
|
|
684
|
+
...(templateKey ? { templateKey } : {}),
|
|
685
|
+
fieldValues: req.body.field_values,
|
|
686
|
+
completenessScore: completenessScore(req.body.field_values, templateKey ? { templateKey } : undefined),
|
|
687
|
+
});
|
|
688
|
+
// Zero rows means the guard fired: somebody else developed this spark between
|
|
689
|
+
// the read above and this write. Their work stands; this caller is told so
|
|
690
|
+
// rather than silently overwriting it.
|
|
691
|
+
if (!row) {
|
|
692
|
+
// The guard fired between the read above and the write. THREE causes now,
|
|
693
|
+
// and guessing would misinform: another developer got there first, or a
|
|
694
|
+
// triager closed the idea. One extra read on a rare path buys an honest
|
|
695
|
+
// answer instead of a plausible one.
|
|
696
|
+
const now = await inbox.getIdea(id);
|
|
697
|
+
if (now && now.status !== 'open') {
|
|
698
|
+
return res.fail('idea_not_open', 409, {
|
|
699
|
+
status: now.status,
|
|
700
|
+
reason: 'a triager closed this idea while you were developing it',
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
return res.fail('spark_already_developed', 409, {
|
|
704
|
+
...(now && now.developed_by ? { developed_by: now.developed_by } : {}),
|
|
705
|
+
reason: 'another builder developed this spark first — their version stands',
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const out = {
|
|
710
|
+
...idea,
|
|
711
|
+
grade: row.grade,
|
|
712
|
+
template_key: row.template_key,
|
|
713
|
+
field_values: row.field_values,
|
|
714
|
+
completeness_score: row.completeness_score,
|
|
715
|
+
developed_by: row.developed_by,
|
|
716
|
+
developed_at: row.developed_at,
|
|
717
|
+
};
|
|
718
|
+
|
|
719
|
+
let board_window;
|
|
720
|
+
try {
|
|
721
|
+
const government = api.resolveOptional && api.resolveOptional('government');
|
|
722
|
+
if (government && government.openBoardWindowForIdea) {
|
|
723
|
+
const w = await government.openBoardWindowForIdea({ ideaId: id, completenessScore: row.completeness_score });
|
|
724
|
+
if (w && w.opened && w.item) board_window = { item_id: w.item.id, closes_at: w.item.closes_at };
|
|
725
|
+
else if (w && w.reason === 'below_bar') {
|
|
726
|
+
// The same honest feedback the resubmit path gives: which section is
|
|
727
|
+
// thinnest, computed from what was just written. Derived, never stored.
|
|
728
|
+
const flag = weakestSectionPayload(req.body.field_values, templateKey ? { templateKey } : undefined);
|
|
729
|
+
if (flag) out.rejection = flag;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
} catch (boardErr) {
|
|
733
|
+
// Fail-open, like every other caller of this port: the development landed
|
|
734
|
+
// and must not be rolled back because the board could not be told.
|
|
735
|
+
api.logger('ideas').error(`develop: board-window open failed (development intact, window skipped): ${boardErr && boardErr.message}`);
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
return res.json({
|
|
739
|
+
idea: out,
|
|
740
|
+
developed: true,
|
|
741
|
+
// Named explicitly so a caller never has to compare two ids to find out,
|
|
742
|
+
// and so the R147 split reads its two parties off one response.
|
|
743
|
+
author_id: idea.captured_by ?? null,
|
|
744
|
+
developer_id: row.developed_by,
|
|
745
|
+
self_developed: idea.captured_by != null && String(idea.captured_by) === String(row.developed_by),
|
|
746
|
+
...(board_window ? { board_window } : {}),
|
|
747
|
+
});
|
|
748
|
+
}, { errorCode: 'idea_develop_failed' }));
|
|
749
|
+
|
|
559
750
|
// Metic+ (#360 / ADR 0018): idea triage (promote/discard/merge) shapes the
|
|
560
751
|
// backlog. Criterion C2 assigns triage to Metic, so this widened from
|
|
561
752
|
// archon-only to metic+archon when the three-rank model went live. Any
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.297",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.297",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.297",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|