@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.
@@ -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.295",
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.295",
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.295",
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",