@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.
- package/.bongos-core.json +47 -27
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/adr/0185-spark-handoff-credit-split.md +129 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +74 -2
- package/docs/api-reference.md +3 -2
- package/docs/file-map.md +1 -1
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
- package/modules/economy/credits.js +156 -31
- package/modules/ideas/inbox.js +86 -2
- package/modules/ideas/migrations/ideas_005_grade_developed_by.sql +85 -0
- package/modules/ideas/routes/inbox.js +175 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/spark.js +119 -5
- package/src/module-api.js +5 -1
- package/tests/idea_credit_split.mjs +224 -0
- package/tests/idea_develop_spark.mjs +436 -0
- package/tests/idea_full_pass_credit.mjs +50 -18
- package/tests/idea_spark_queue.mjs +11 -3
|
@@ -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 (
|
|
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
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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
|
|
447
|
-
const {
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
);
|
|
453
|
-
return
|
|
454
|
-
? { credited
|
|
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,
|
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
|
// -------------------------------------------------------------------------
|
|
@@ -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.
|
|
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.
|
|
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.
|
|
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",
|