@bongos/core 1.19.296 → 1.19.298

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.
@@ -334,6 +334,102 @@ async function maybeAwardIdeaPromotionBonus(client, taskId, shipperBuilderId, mu
334
334
  };
335
335
  }
336
336
 
337
+ // -------------------------------------------------------------------------
338
+ // The spark-handoff split (task 1002923 / BV1.R147, goal 1000062; ADR 0185)
339
+ //
340
+ // When one builder develops ANOTHER builder's Big Idea Spark into a Full Idea
341
+ // (R146), two people made the idea: the one who had the thought and said "I do
342
+ // not have the full picture", and the one who wrote out the five fields. Both
343
+ // existing ideator streams paid `captured_by` alone, so the developer earned
344
+ // nothing and the R145 queue had no reason to ever be picked up.
345
+ //
346
+ // OWNER DECISION (2026-08-22): 70% to the DEVELOPER, 30% to the spark author,
347
+ // on BOTH existing streams. The developer takes the larger share because
348
+ // developing someone else's spark is the scarce act the queue depends on — at
349
+ // 50/50 it pays strictly worse than authoring your own Full Idea, so nobody
350
+ // picks anything up. The author is not left thin: they keep 30% here AND the
351
+ // full trailing stream-1 share on every task shipped under the idea forever.
352
+ //
353
+ // A POLICY CONSTANT IN THIS FILE, not a table — the ADR 0172 §Consequences rule
354
+ // ("thresholds/factors are policy constants in credits.js", the ADR 0023
355
+ // precedent): it deserves a review and a commit, not a runtime edit.
356
+ const IDEA_DEVELOPER_SHARE = 0.70;
357
+
358
+ // splitIdeaCredit — divide one payout between the two parties.
359
+ //
360
+ // THREE PROPERTIES THIS MUST HAVE, each of which is a real bug if missed:
361
+ //
362
+ // 1. THE TOTAL IS CONSERVED. The developer's share is rounded and the author
363
+ // gets THE REMAINDER — never both rounded independently, which would pay
364
+ // 6 credits out of a 5-credit payout (round(3.5) + round(1.5) = 4 + 2).
365
+ // 2. SELF-DEVELOPMENT COLLAPSES TO ONE PAYEE. R146 deliberately allows an
366
+ // author to develop their own spark. Emitting two rows for the same builder
367
+ // would not double-pay — it would UNDER-pay, because the idempotency
368
+ // predicate is (builder_id, reason) and the second insert is silently
369
+ // refused, so the builder would receive only one of their two shares.
370
+ // 3. ZERO-VALUE ROWS ARE DROPPED. A 1-credit payout splits to 1/0; booking a
371
+ // 0-credit ledger row is noise that reads like a payment.
372
+ //
373
+ // Pure — no DB, no config. Returns [{ builderId, delta, role }], biggest first.
374
+ function splitIdeaCredit(delta, { authorId, developedBy } = {}) {
375
+ const total = Math.round(Number(delta) || 0);
376
+ if (total <= 0 || authorId == null) return [];
377
+ // IDS PASS THROUGH UNCONVERTED. builder_id is a bigint, which pg hands back as
378
+ // a STRING; Number()-ing it would risk precision on a large id and would also
379
+ // change the value type the pre-R147 code fed straight into the INSERT. Only
380
+ // the COMPARISON below normalises, via String() — a number/string mismatch
381
+ // between captured_by and developed_by must not become a phantom second payee.
382
+ if (developedBy == null) {
383
+ return [{ builderId: authorId, delta: total, role: 'author' }];
384
+ }
385
+ if (String(developedBy) === String(authorId)) {
386
+ return [{ builderId: authorId, delta: total, role: 'author_developer' }];
387
+ }
388
+ const developerCut = Math.round(total * IDEA_DEVELOPER_SHARE);
389
+ const authorCut = total - developerCut; // the remainder — conserves the total
390
+ const out = [];
391
+ if (developerCut > 0) out.push({ builderId: developedBy, delta: developerCut, role: 'developer' });
392
+ if (authorCut > 0) out.push({ builderId: authorId, delta: authorCut, role: 'spark_author' });
393
+ return out;
394
+ }
395
+
396
+ // Who are the two parties for this idea? Read from the DB rather than trusted
397
+ // from the caller, deliberately: R146's develop route could not safely pay
398
+ // anything precisely because the recipient was ambiguous at the call site. With
399
+ // the payees derived HERE, a caller cannot direct a payment at the wrong builder,
400
+ // and there is exactly one place that decides who earns from an idea.
401
+ async function ideaCreditParties(client, ideaId) {
402
+ const { rows } = await client.query(
403
+ `SELECT i.captured_by, g.developed_by
404
+ FROM idea_inbox i
405
+ LEFT JOIN ideas_grades g ON g.idea_id = i.id
406
+ WHERE i.id = $1`,
407
+ [ideaId]
408
+ );
409
+ if (rows.length === 0) return null;
410
+ return { authorId: rows[0].captured_by, developedBy: rows[0].developed_by };
411
+ }
412
+
413
+ // Book one split across the ledger, idempotently per (builder, reason). Returns
414
+ // { credited, payees } where credited is the sum of what was NEWLY booked — so a
415
+ // re-run that finds both rows present reports 0, and a partially-booked split
416
+ // (one payee inserted, then a crash) completes on the next call.
417
+ async function bookIdeaSplit(client, { payees, reason, taskId = null, describe }) {
418
+ let credited = 0;
419
+ const booked = [];
420
+ for (const p of payees) {
421
+ const { rowCount } = await client.query(
422
+ `INSERT INTO credit_log (builder_id, task_id, delta, reason, description)
423
+ SELECT $1, $2, $3, $4, $5
424
+ WHERE NOT EXISTS (SELECT 1 FROM credit_log WHERE builder_id = $1 AND reason = $4)`,
425
+ [p.builderId, taskId, p.delta, reason, describe(p)]
426
+ );
427
+ if (rowCount > 0) credited += p.delta;
428
+ booked.push({ ...p, booked: rowCount > 0 });
429
+ }
430
+ return { credited, payees: booked };
431
+ }
432
+
337
433
  // Stream 2 (ADR 0172 / IC2): a Full Idea with a passing completeness score pays
338
434
  // its author IDEA_FULL_PASS_CREDIT, ONCE. Transaction-participant like the rest
339
435
  // of the reward port — the caller passes its `client` (a pool works too, since
@@ -362,29 +458,46 @@ async function awardIdeaFullPassCredit(client, { ideaId, authorId, completenessS
362
458
  if (completenessScore == null || !Number.isFinite(Number(completenessScore)) || Number(completenessScore) < IDEA_FULL_PASS_BAR) {
363
459
  return { credited: 0, reason: 'below_bar' };
364
460
  }
365
- if (authorId == null || ideaId == null) {
461
+ if (ideaId == null) {
366
462
  return { credited: 0, reason: 'missing_author_or_idea' };
367
463
  }
464
+ // R147: the PARTIES come from the DB, not the call site. `authorId` is kept as
465
+ // a fallback only for a caller holding an idea the lookup cannot see (and for
466
+ // the pre-R147 call shape); when the row is there, it wins.
467
+ const parties = await ideaCreditParties(client, ideaId);
468
+ const resolvedAuthor = (parties && parties.authorId != null) ? parties.authorId : authorId;
469
+ if (resolvedAuthor == null) {
470
+ return { credited: 0, reason: 'missing_author_or_idea' };
471
+ }
472
+ const payees = splitIdeaCredit(IDEA_FULL_PASS_CREDIT, {
473
+ authorId: resolvedAuthor,
474
+ developedBy: parties ? parties.developedBy : null,
475
+ });
476
+ if (payees.length === 0) {
477
+ return { credited: 0, reason: 'zero_after_scaling' };
478
+ }
479
+ // Idempotency predicate is `builder_id = payee AND reason = <per-idea key>`.
480
+ // The `builder_id` term was originally an index optimisation (hitting
481
+ // `idx_credit_log_builder` instead of scanning a growing credit_log — the
482
+ // R143/IC2 grader finding), and R147 makes it load-bearing for CORRECTNESS
483
+ // too: a split books one row per payee under the SAME reason, so the predicate
484
+ // has to be per-builder or the second payee would be refused. That is also
485
+ // exactly why splitIdeaCredit collapses a self-developed idea to a single
486
+ // payee — two rows for one builder would be silently under-paid, not
487
+ // double-paid. credit_log is a CORE table, so a module migration cannot add an
488
+ // index on `reason` anyway (the additive-namespace rule).
368
489
  const reason = `idea.credit.full_pass:${ideaId}`;
369
- const description = `Full Idea #${ideaId} passed (completeness ${Math.round(Number(completenessScore))}/100) → ${IDEA_FULL_PASS_CREDIT}c to its author (ideator lane, ADR 0172)`;
370
- // Idempotency predicate is `builder_id = author AND reason = <per-idea key>`.
371
- // The `builder_id` term is NOT for correctness — the reason string is already
372
- // globally unique per idea — it exists to hit the existing
373
- // `idx_credit_log_builder (builder_id, recorded_at)` index: the NOT EXISTS
374
- // narrows to the author's handful of credit rows (index scan) instead of a
375
- // sequential scan of a credit_log that grows with total platform activity.
376
- // credit_log is a CORE table, so a module migration cannot add an index on
377
- // `reason` (the additive-namespace rule) — reusing the builder index is the
378
- // in-scope fix. (R143/IC2 grader finding.)
379
- const { rowCount } = await client.query(
380
- `INSERT INTO credit_log (builder_id, task_id, delta, reason, description)
381
- SELECT $1, NULL, $2, $3, $4
382
- WHERE NOT EXISTS (SELECT 1 FROM credit_log WHERE builder_id = $1 AND reason = $3)`,
383
- [authorId, IDEA_FULL_PASS_CREDIT, reason, description]
384
- );
385
- return rowCount > 0
386
- ? { credited: IDEA_FULL_PASS_CREDIT, reason: 'paid' }
387
- : { credited: 0, reason: 'already_paid' };
490
+ const pct = Math.round(Number(completenessScore));
491
+ const { credited, payees: booked } = await bookIdeaSplit(client, {
492
+ payees,
493
+ reason,
494
+ describe: (p) => `Full Idea #${ideaId} passed (completeness ${pct}/100) → ${p.delta}c to its ${p.role.replace(/_/g, ' ')} (ideator lane, ADR 0172${p.role === 'developer' || p.role === 'spark_author' ? '; split ADR 0185' : ''})`,
495
+ });
496
+ return {
497
+ credited,
498
+ reason: credited > 0 ? 'paid' : 'already_paid',
499
+ payees: booked,
500
+ };
388
501
  }
389
502
 
390
503
  // Stream 1 (ADR 0172 / goal 1000067 IC4): as each task descended from an idea
@@ -420,7 +533,8 @@ async function awardIdeaTaskShipCredit(client, { taskId, config = rewardConfig()
420
533
  `SELECT l.idea_id,
421
534
  i.captured_by,
422
535
  COALESCE(g.grade, 'quick') AS grade,
423
- g.completeness_score AS completeness
536
+ g.completeness_score AS completeness,
537
+ g.developed_by AS developed_by
424
538
  FROM ideas_task_lineage l
425
539
  JOIN idea_inbox i ON i.id = l.idea_id
426
540
  LEFT JOIN ideas_grades g ON g.idea_id = l.idea_id
@@ -430,7 +544,7 @@ async function awardIdeaTaskShipCredit(client, { taskId, config = rewardConfig()
430
544
  if (rows.length === 0 || rows[0].captured_by == null) {
431
545
  return { credited: 0, reason: 'no_source_idea', ideaId: null, authorId: null };
432
546
  }
433
- const { idea_id: ideaId, captured_by: authorId, grade, completeness } = rows[0];
547
+ const { idea_id: ideaId, captured_by: authorId, grade, completeness, developed_by: developedBy } = rows[0];
434
548
  // Grade factor: a Full Idea earns in proportion to its completeness; a Quick
435
549
  // or Spark idea (no completeness score) earns the flat quick factor. A full
436
550
  // idea with no score yet also falls back to the quick factor rather than 0 —
@@ -442,16 +556,24 @@ async function awardIdeaTaskShipCredit(client, { taskId, config = rewardConfig()
442
556
  if (delta <= 0) {
443
557
  return { credited: 0, reason: 'zero_after_scaling', ideaId: Number(ideaId), authorId: Number(authorId) };
444
558
  }
559
+ // R147 (ADR 0185): if someone else developed this spark, the per-task stream is
560
+ // split with them too — the same 70/30 rule as stream 2, so there is one rule
561
+ // to explain rather than two. A developer who shaped the idea shares in what
562
+ // the idea goes on to deliver.
563
+ const payees = splitIdeaCredit(delta, { authorId, developedBy });
564
+ if (payees.length === 0) {
565
+ return { credited: 0, reason: 'zero_after_scaling', ideaId: Number(ideaId), authorId: Number(authorId) };
566
+ }
445
567
  const reason = `idea.credit.task_ship:${ideaId}:${taskId}`;
446
- const description = `Task #${taskId} shipped under idea #${ideaId} (${grade}${completeness != null ? ` ${Math.round(Number(completeness))}%` : ''}) → ${delta}c to its author (ideator lane stream 1, ADR 0172)`;
447
- const { rowCount } = await client.query(
448
- `INSERT INTO credit_log (builder_id, task_id, delta, reason, description)
449
- SELECT $1, $2, $3, $4, $5
450
- WHERE NOT EXISTS (SELECT 1 FROM credit_log WHERE builder_id = $1 AND reason = $4)`,
451
- [authorId, taskId, delta, reason, description]
452
- );
453
- return rowCount > 0
454
- ? { credited: delta, reason: 'paid', ideaId: Number(ideaId), authorId: Number(authorId) }
568
+ const gradeLabel = `${grade}${completeness != null ? ` ${Math.round(Number(completeness))}%` : ''}`;
569
+ const { credited, payees: booked } = await bookIdeaSplit(client, {
570
+ payees,
571
+ reason,
572
+ taskId,
573
+ describe: (p) => `Task #${taskId} shipped under idea #${ideaId} (${gradeLabel}) → ${p.delta}c to its ${p.role.replace(/_/g, ' ')} (ideator lane stream 1, ADR 0172${p.role === 'developer' || p.role === 'spark_author' ? '; split ADR 0185' : ''})`,
574
+ });
575
+ return credited > 0
576
+ ? { credited, reason: 'paid', ideaId: Number(ideaId), authorId: Number(authorId), payees: booked }
455
577
  : { credited: 0, reason: 'already_paid', ideaId: Number(ideaId), authorId: Number(authorId) };
456
578
  }
457
579
 
@@ -667,6 +789,9 @@ module.exports = {
667
789
  maybeAwardIdeaPromotionBonus,
668
790
  awardIdeaFullPassCredit,
669
791
  awardIdeaTaskShipCredit,
792
+ // the spark-handoff split (task 1002923 / BV1.R147, ADR 0185)
793
+ splitIdeaCredit,
794
+ IDEA_DEVELOPER_SHARE,
670
795
  IDEA_FULL_PASS_CREDIT,
671
796
  IDEA_FULL_PASS_BAR,
672
797
  IDEA_TASK_SHIP_BASE,
@@ -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
  // -------------------------------------------------------------------------
@@ -951,6 +958,81 @@ async function setIdeaCompleteness(ideaId, completenessScore) {
951
958
  return rows[0] ?? null;
952
959
  }
953
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
+
954
1036
  // The grade row on its own. null = no row, which reads as grade='quick'
955
1037
  // everywhere the idea itself is read (see DEFAULT_GRADE).
956
1038
  async function getIdeaGrade(ideaId) {
@@ -1186,6 +1268,8 @@ module.exports = {
1186
1268
  updateIdeaBody,
1187
1269
  setIdeaGrade,
1188
1270
  setIdeaCompleteness,
1271
+ // develop a spark into a Full Idea, in place (task 1002922, BV1.R146)
1272
+ developSpark,
1189
1273
  // idea → task lineage (task 1002997 / BV1.R173) — the one-idea-many-tasks
1190
1274
  // link stream 1 (IC4) pays against
1191
1275
  linkIdeaTask,
@@ -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;
@@ -593,6 +593,181 @@ module.exports = function buildInboxRouter() {
593
593
  });
594
594
  }, { errorCode: 'idea_unspark_failed' }));
595
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
+ // THE STREAM-2 CREDIT IS PAID HERE, SPLIT (task 1002923 / R147, ADR 0185).
618
+ // R146 shipped this route paying nothing on purpose: with two people involved,
619
+ // the recipient was ambiguous, and awardIdeaFullPassCredit's idempotency
620
+ // predicate is (builder_id, reason) — so guessing the developer would NOT have
621
+ // blocked a later payment to the author, and one idea would have paid twice.
622
+ // R147 resolved that by moving the payee decision INTO the economy module: it
623
+ // reads captured_by + developed_by itself and books 70% to the developer, 30%
624
+ // to the spark author (one payee at 100% when they are the same person). This
625
+ // route can therefore call it safely — it no longer chooses who earns.
626
+ //
627
+ // The board window IS opened, because it is recipient-agnostic (it takes an
628
+ // idea and a score, not a builder) and without it a developed Full Idea could
629
+ // never reach ratification — the handoff would dead-end one step from done.
630
+ // Fail-open like every other caller of that port.
631
+ //
632
+ // rank: any-builder — development is the pickup, and the author's mark is the
633
+ // consent. Deliberately NOT pinned in route-rank-check: there is no gate here
634
+ // to protect, and pinning it would only assert that an open route stays open.
635
+ router.post('/inbox/:id/develop', api.requireBuilder, api.asyncHandler('POST /inbox/:id/develop', async (req, res) => {
636
+ const id = parseId(req, res, { code: 'bad_id', positiveInt: true });
637
+ if (id === null) return;
638
+ if (validateOrRespond(req, res, {
639
+ field_values: { required: true, type: 'object' },
640
+ template_key: { type: 'string', maxLength: 64 },
641
+ })) return;
642
+
643
+ const idea = await inbox.getIdea(id);
644
+ if (!idea) return res.fail('idea_not_found', 404);
645
+ if (idea.grade !== 'spark') {
646
+ return res.fail('not_a_spark', 409, {
647
+ grade: idea.grade,
648
+ ...(idea.developed_by ? { developed_by: idea.developed_by } : {}),
649
+ reason: idea.grade === 'full'
650
+ ? 'this idea has already been developed into a Full Idea'
651
+ : 'only a Big Idea Spark can be developed — its author has to mark it first, which is what makes developing it fair',
652
+ });
653
+ }
654
+ // A SPARK IS NOT NECESSARILY STILL DEVELOPABLE. Triage writes
655
+ // idea_inbox.status and never touches ideas_grades, so an idea a triager
656
+ // discarded, merged or promoted keeps grade='spark' forever. Developing one
657
+ // would resurrect content the triage flow already decided was dead — and
658
+ // worse, open a fresh board ratification window for it. The R144 mark and the
659
+ // R145 queue both require status='open'; this is the same rule on the write.
660
+ // (developSpark's predicate carries it too, for the mid-request race.)
661
+ if (idea.status !== 'open') {
662
+ return res.fail('idea_not_open', 409, {
663
+ status: idea.status,
664
+ reason: 'this idea is no longer open, so it is not awaiting development',
665
+ });
666
+ }
667
+
668
+ // Same validation the capture and resubmit paths use, against the same
669
+ // template declaration — a developed Full Idea is a Full Idea, and must not
670
+ // be held to a looser standard just because someone else wrote it.
671
+ const templateKey = req.body.template_key ?? idea.template_key ?? undefined;
672
+ let v;
673
+ try {
674
+ v = validateFieldValues(req.body.field_values, templateKey ? { templateKey } : undefined);
675
+ } catch (validationErr) {
676
+ api.logger('ideas').error(`POST /inbox/:id/develop validation threw: ${validationErr && validationErr.message}`);
677
+ return res.fail('invalid_field_values', 400, { errors: [{ field: null, reason: 'unvalidatable' }] });
678
+ }
679
+ if (!v.ok) return res.fail('invalid_field_values', 400, { errors: v.errors, template_key: templateKey ?? null });
680
+
681
+ const row = await inbox.developSpark(id, {
682
+ developedBy: req.builder.id,
683
+ ...(templateKey ? { templateKey } : {}),
684
+ fieldValues: req.body.field_values,
685
+ completenessScore: completenessScore(req.body.field_values, templateKey ? { templateKey } : undefined),
686
+ });
687
+ // Zero rows means the guard fired: somebody else developed this spark between
688
+ // the read above and this write. Their work stands; this caller is told so
689
+ // rather than silently overwriting it.
690
+ if (!row) {
691
+ // The guard fired between the read above and the write. THREE causes now,
692
+ // and guessing would misinform: another developer got there first, or a
693
+ // triager closed the idea. One extra read on a rare path buys an honest
694
+ // answer instead of a plausible one.
695
+ const now = await inbox.getIdea(id);
696
+ if (now && now.status !== 'open') {
697
+ return res.fail('idea_not_open', 409, {
698
+ status: now.status,
699
+ reason: 'a triager closed this idea while you were developing it',
700
+ });
701
+ }
702
+ return res.fail('spark_already_developed', 409, {
703
+ ...(now && now.developed_by ? { developed_by: now.developed_by } : {}),
704
+ reason: 'another builder developed this spark first — their version stands',
705
+ });
706
+ }
707
+
708
+ const out = {
709
+ ...idea,
710
+ grade: row.grade,
711
+ template_key: row.template_key,
712
+ field_values: row.field_values,
713
+ completeness_score: row.completeness_score,
714
+ developed_by: row.developed_by,
715
+ developed_at: row.developed_at,
716
+ };
717
+
718
+ // Fail-open, exactly like the capture and resubmit paths: the development has
719
+ // already committed, and a credit-lane hiccup must never roll back somebody's
720
+ // work. The split (and who is in it) is decided inside the economy module.
721
+ try {
722
+ const reward = api.resolveOptional && api.resolveOptional('reward');
723
+ if (reward && reward.awardIdeaFullPassCredit) {
724
+ const r = await reward.awardIdeaFullPassCredit(api.pool, {
725
+ ideaId: id,
726
+ authorId: idea.captured_by,
727
+ completenessScore: row.completeness_score,
728
+ });
729
+ if (r && r.credited > 0) {
730
+ out.ideator_credit = r.credited;
731
+ // The per-payee breakdown, so a developer can see their share and the
732
+ // author theirs without reading the ledger.
733
+ if (r.payees) out.ideator_credit_split = r.payees;
734
+ }
735
+ }
736
+ } catch (creditErr) {
737
+ api.logger('ideas').error(`develop: pass-credit failed (development intact, credit skipped): ${creditErr && creditErr.message}`);
738
+ }
739
+
740
+ let board_window;
741
+ try {
742
+ const government = api.resolveOptional && api.resolveOptional('government');
743
+ if (government && government.openBoardWindowForIdea) {
744
+ const w = await government.openBoardWindowForIdea({ ideaId: id, completenessScore: row.completeness_score });
745
+ if (w && w.opened && w.item) board_window = { item_id: w.item.id, closes_at: w.item.closes_at };
746
+ else if (w && w.reason === 'below_bar') {
747
+ // The same honest feedback the resubmit path gives: which section is
748
+ // thinnest, computed from what was just written. Derived, never stored.
749
+ const flag = weakestSectionPayload(req.body.field_values, templateKey ? { templateKey } : undefined);
750
+ if (flag) out.rejection = flag;
751
+ }
752
+ }
753
+ } catch (boardErr) {
754
+ // Fail-open, like every other caller of this port: the development landed
755
+ // and must not be rolled back because the board could not be told.
756
+ api.logger('ideas').error(`develop: board-window open failed (development intact, window skipped): ${boardErr && boardErr.message}`);
757
+ }
758
+
759
+ return res.json({
760
+ idea: out,
761
+ developed: true,
762
+ // Named explicitly so a caller never has to compare two ids to find out,
763
+ // and so the R147 split reads its two parties off one response.
764
+ author_id: idea.captured_by ?? null,
765
+ developer_id: row.developed_by,
766
+ self_developed: idea.captured_by != null && String(idea.captured_by) === String(row.developed_by),
767
+ ...(board_window ? { board_window } : {}),
768
+ });
769
+ }, { errorCode: 'idea_develop_failed' }));
770
+
596
771
  // Metic+ (#360 / ADR 0018): idea triage (promote/discard/merge) shapes the
597
772
  // backlog. Criterion C2 assigns triage to Metic, so this widened from
598
773
  // 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.296",
3
+ "version": "1.19.298",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.296",
9
+ "version": "1.19.298",
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.296",
3
+ "version": "1.19.298",
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",