@bongos/core 1.19.297 → 1.19.299

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.
@@ -6,8 +6,8 @@ const api = require('../../../src/module-api');
6
6
  const inbox = require('../inbox');
7
7
  // Full Idea capture (task 1002919 / BV1.R143): the template validator (R141)
8
8
  // and the deterministic scorer (R142) — both pure, both walk the declaration.
9
- const { validateFieldValues } = require('../templates');
10
- const { completenessScore, weakestSectionPayload } = require('../completeness');
9
+ const { validateFieldValues, templateFor, fieldOrder } = require('../templates');
10
+ const { completenessScore, weakestSectionPayload, scoreFieldValues } = require('../completeness');
11
11
  const { validateOrRespond, LIMITS, parseId } = api;
12
12
 
13
13
  const INBOX_STATUS_VALUES = ['open', 'promoted', 'discarded', 'merged'];
@@ -313,6 +313,94 @@ module.exports = function buildInboxRouter() {
313
313
  });
314
314
 
315
315
 
316
+
317
+ // -----------------------------------------------------------------------
318
+ // The two reads the hall's Full Idea composer is built from
319
+ // (task 1002928 / BV1.R152, goal 1000062).
320
+ //
321
+ // The done-when says the live meter must reflect THE REAL SCORER, "not a
322
+ // second copy of the rules". A browser cannot require() this module —
323
+ // completeness.js and templates.js are CJS and templates.js reaches inbox.js
324
+ // for DEFAULT_TEMPLATE_KEY, which reaches the pool — so the choice is between
325
+ // re-implementing the arithmetic in page JS and asking the server. These two
326
+ // routes are that ask, and they are why the composer contains no rules at all:
327
+ //
328
+ // GET /inbox/template → what the five fields ARE (so the FORM is rendered
329
+ // from the R141 declaration, not from five <textarea>
330
+ // elements hand-written into the HTML — a hard-coded
331
+ // form is the same "second copy" defect as a
332
+ // hard-coded scorer, one step earlier, and it would
333
+ // silently ignore a per-project template (R156)).
334
+ // POST /inbox/score → what it is WORTH right now (the R142 scorer itself,
335
+ // per-field, in template order).
336
+ //
337
+ // BOTH ARE PURE. No DB read, no write, no idea id: they describe and score a
338
+ // payload the caller already holds. That is what makes a debounced call on
339
+ // every keystroke reasonable, and why scoring carries no rank beyond sign-in —
340
+ // it tells you nothing you did not just type.
341
+ //
342
+ // DECLARED BEFORE `GET /inbox/:id`, and for GET /inbox/template that ordering
343
+ // is LOAD-BEARING: Express matches in declaration order, so with `:id` first
344
+ // the literal "/inbox/template" binds id='template' and parseId 400s it. Same
345
+ // trap as /inbox/sparks and /inbox/awaiting-nod above.
346
+ //
347
+ // rank: any-builder — filing a Full Idea is open to every role by the goal's
348
+ // own settled decision ("no permission gate on Full Ideas; completeness scoring
349
+ // and triage do the filtering"), so describing and scoring one must be too.
350
+ // Deliberately NOT pinned in route-rank-check: neither has a gate to protect.
351
+ router.get('/inbox/template', api.requireBuilder, api.asyncHandler('GET /inbox/template', async (req, res) => {
352
+ // templateFor deliberately does NOT default — it returns null for anything
353
+ // that is not a declared own key, which is what makes it safe against an
354
+ // attacker-supplied 'constructor'/'__proto__' (the R143 grader's finding). So
355
+ // the DEFAULT is supplied here, explicitly, from the one place that declares
356
+ // it. Leaving it undefined 404s the normal page load, which is exactly what
357
+ // the first run of tests/idea_hall_composer.mjs caught.
358
+ const key = typeof req.query.template_key === 'string' && req.query.template_key
359
+ ? req.query.template_key
360
+ : inbox.DEFAULT_TEMPLATE_KEY;
361
+ const template = templateFor(key);
362
+ if (!template) {
363
+ return res.fail('unknown_template', 404, { template_key: key });
364
+ }
365
+ // Projected rather than passed through: the declaration is frozen and the
366
+ // composer needs exactly these four things per field.
367
+ const byKey = new Map(template.fields.map((f) => [f.key, f]));
368
+ return res.json({
369
+ template: {
370
+ key: template.key,
371
+ label: template.label,
372
+ // ORDER from fieldOrder (which returns KEYS — the one legal source of
373
+ // display order, since jsonb does not preserve key order), DATA from the
374
+ // declaration itself. Spelled as a lookup rather than mapping over
375
+ // template.fields directly so the provenance of the order is visible at
376
+ // the call site and cannot drift into an Object.keys() somewhere.
377
+ fields: fieldOrder(template).map((key) => {
378
+ const f = byKey.get(key);
379
+ return {
380
+ key: f.key,
381
+ label: f.label,
382
+ required: !!f.required,
383
+ substance_min: f.substanceMin || 0,
384
+ };
385
+ }),
386
+ },
387
+ });
388
+ }, { errorCode: 'template_read_failed' }));
389
+
390
+ router.post('/inbox/score', api.requireBuilder, api.asyncHandler('POST /inbox/score', async (req, res) => {
391
+ if (validateOrRespond(req, res, {
392
+ field_values: { required: true, type: 'object' },
393
+ template_key: { type: 'string', maxLength: inbox.TEMPLATE_KEY_MAX },
394
+ })) return;
395
+ const templateKey = req.body.template_key || undefined;
396
+ // scoreFieldValues NEVER throws (R142's contract) and answers
397
+ // { score: null, fields: [] } for anything it cannot score — an unknown
398
+ // template, a non-object, a non-string field. A draft in progress is exactly
399
+ // that kind of half-formed input, so the composer wants the null rather than
400
+ // a 400: "not scorable yet" is a legal state on the way to a Full Idea.
401
+ const { score, fields } = scoreFieldValues(req.body.field_values, templateKey ? { templateKey } : undefined);
402
+ return res.json({ score, fields, template_key: templateKey || null });
403
+ }, { errorCode: 'score_failed' }));
316
404
  // GET /inbox/sparks — THE SPARK QUEUE (task 1002921 / BV1.R145, goal 1000062).
317
405
  //
318
406
  // Big Idea Sparks awaiting development: the ideator craft's pickup surface,
@@ -614,16 +702,15 @@ module.exports = function buildInboxRouter() {
614
702
  // to tell a 404 apart from a 409 and to give a useful message; it is not the
615
703
  // guard, and it deliberately does not try to be.
616
704
  //
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.
705
+ // THE STREAM-2 CREDIT IS PAID HERE, SPLIT (task 1002923 / R147, ADR 0185).
706
+ // R146 shipped this route paying nothing on purpose: with two people involved,
707
+ // the recipient was ambiguous, and awardIdeaFullPassCredit's idempotency
708
+ // predicate is (builder_id, reason) — so guessing the developer would NOT have
709
+ // blocked a later payment to the author, and one idea would have paid twice.
710
+ // R147 resolved that by moving the payee decision INTO the economy module: it
711
+ // reads captured_by + developed_by itself and books 70% to the developer, 30%
712
+ // to the spark author (one payee at 100% when they are the same person). This
713
+ // route can therefore call it safely — it no longer chooses who earns.
627
714
  //
628
715
  // The board window IS opened, because it is recipient-agnostic (it takes an
629
716
  // idea and a score, not a builder) and without it a developed Full Idea could
@@ -716,6 +803,28 @@ module.exports = function buildInboxRouter() {
716
803
  developed_at: row.developed_at,
717
804
  };
718
805
 
806
+ // Fail-open, exactly like the capture and resubmit paths: the development has
807
+ // already committed, and a credit-lane hiccup must never roll back somebody's
808
+ // work. The split (and who is in it) is decided inside the economy module.
809
+ try {
810
+ const reward = api.resolveOptional && api.resolveOptional('reward');
811
+ if (reward && reward.awardIdeaFullPassCredit) {
812
+ const r = await reward.awardIdeaFullPassCredit(api.pool, {
813
+ ideaId: id,
814
+ authorId: idea.captured_by,
815
+ completenessScore: row.completeness_score,
816
+ });
817
+ if (r && r.credited > 0) {
818
+ out.ideator_credit = r.credited;
819
+ // The per-payee breakdown, so a developer can see their share and the
820
+ // author theirs without reading the ledger.
821
+ if (r.payees) out.ideator_credit_split = r.payees;
822
+ }
823
+ }
824
+ } catch (creditErr) {
825
+ api.logger('ideas').error(`develop: pass-credit failed (development intact, credit skipped): ${creditErr && creditErr.message}`);
826
+ }
827
+
719
828
  let board_window;
720
829
  try {
721
830
  const government = api.resolveOptional && api.resolveOptional('government');
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.297",
3
+ "version": "1.19.299",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.297",
9
+ "version": "1.19.299",
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.297",
3
+ "version": "1.19.299",
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",
@@ -927,6 +927,11 @@ function mountInternalSurfaces(app) {
927
927
  // flag is open to every rank by design (see the nav comment in shell.js), so
928
928
  // the page's only real gate is sign-in — the writes behind it self-gate, and
929
929
  // closing someone else's flag is checked server-side at metic-or-author.
930
+ // task 1002928: `ideas` (the R152 Full Idea composer) joins the same tier for
931
+ // the same reason — goal 1000062's settled decision is that there is NO
932
+ // permission gate on Full Ideas; scoring and triage do the filtering. Its two
933
+ // reads (GET /inbox/template, POST /inbox/score) are pure and any-builder, and
934
+ // the file itself goes through POST /inbox, which carries its own rules.
930
935
  //
931
936
  // NB the assignment below must stay `const NAME_RE = /…/;` with NOTHING between
932
937
  // the `=` and the literal: tests/hall_page_gate_map.mjs reconstructs the whole
@@ -934,7 +939,7 @@ function mountInternalSurfaces(app) {
934
939
  // the regex out of its map — which reads as "every page on this tier is
935
940
  // ungated". It fails loudly by design; keep prose above the const.
936
941
  const SIGNEDIN_BUILDERS_PAGE_RE =
937
- /^\/builders\/(?:work|pair|settings|atlas|modules|goals|roadmap|copy-desk|primer(?:\.md)?|diagrams)(?:\.html)?\/?$/;
942
+ /^\/builders\/(?:work|pair|settings|atlas|modules|goals|roadmap|copy-desk|ideas|primer(?:\.md)?|diagrams)(?:\.html)?\/?$/;
938
943
  // The diagram image assets under /builders/diagrams/ ride the same tier as the
939
944
  // page that embeds them.
940
945
  const SIGNEDIN_BUILDERS_PREFIX_RE = /^\/builders\/diagrams\//;
package/src/module-api.js CHANGED
@@ -1021,8 +1021,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1021
1021
  // landed since 1.19.295 with no explicit bump. run 32591155887. (task 1002620)
1022
1022
  // 1.19.297 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1023
1023
  // landed since 1.19.296 with no explicit bump. run 32592502621. (task 1002620)
1024
+ // 1.19.298 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1025
+ // landed since 1.19.297 with no explicit bump. run 32597859474. (task 1002620)
1026
+ // 1.19.299 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1027
+ // landed since 1.19.298 with no explicit bump. run 32600322268. (task 1002620)
1024
1028
  // ---------------------------------------------------------------------------
1025
- const CORE_VERSION = '1.19.297'; // CI auto-patch carrier (ADR 0161); see changelog above
1029
+ const CORE_VERSION = '1.19.299'; // CI auto-patch carrier (ADR 0161); see changelog above
1026
1030
 
1027
1031
  // A namespaced logger so a module's log lines are attributable + consistent.
1028
1032
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -137,7 +137,12 @@ test('sidebar groups read by audience, in order', () => {
137
137
  // whose subject is a person rather than the product. Deliberate addition to
138
138
  // this oracle: the group is pinned exactly so a new tab in the hall's most
139
139
  // valuable nav real estate is a reviewed decision, not a drift.
140
- assert.deepEqual(nav[0].items.map((i) => i.id), ['home', 'roadmap', 'goals', 'work', 'copy-desk'],
140
+ // task 1002928 (R152 of goal 1000062): 'ideas' — the Full Idea composer — joins
141
+ // the same group, placed BEFORE copy-desk so the run reads as the four project
142
+ // pages followed by the two CRAFT surfaces side by side: the ideator's queue and
143
+ // the artist's. Same reasoning as copy-desk for why it is here rather than Docs
144
+ // (a place you act, not reference material) or You (its subject is the project).
145
+ assert.deepEqual(nav[0].items.map((i) => i.id), ['home', 'roadmap', 'goals', 'work', 'ideas', 'copy-desk'],
141
146
  'the unlabeled group is the project itself (task 1002425 order)');
142
147
  assert.deepEqual(nav[1].items.map((i) => i.id), ['standing', 'profile'], 'You = your own numbers');
143
148
  // People (task 1002577): the /scouting directory graduated into the hall.
@@ -0,0 +1,224 @@
1
+ // tests/idea_credit_split.mjs
2
+ //
3
+ // task 1002923 (BV1.R147, goal 1000062; ADR 0185) — the spark-handoff credit
4
+ // split: 70% developer / 30% spark author, on both ideator streams.
5
+ //
6
+ // The three properties that are REAL BUGS if missed, asserted first:
7
+ // 1. the total is CONSERVED under rounding (round(3.5)+round(1.5) = 6 from a
8
+ // 5-credit payout is the naive mistake);
9
+ // 2. a SELF-DEVELOPED idea collapses to ONE payee — two rows for one builder
10
+ // would be silently UNDER-paid, because the idempotency predicate is
11
+ // (builder_id, reason) and the second insert is refused;
12
+ // 3. zero-value rows are DROPPED, not booked as 0-credit ledger noise.
13
+ //
14
+ // Then the two streams end-to-end against a stub client, and the develop route
15
+ // actually paying (the gap R146 deliberately left open).
16
+ //
17
+ // Run: node tests/idea_credit_split.mjs
18
+
19
+ import { strict as assert } from 'node:assert';
20
+ import { createRequire } from 'node:module';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const credits = require('../modules/economy/credits.js');
24
+ const { splitIdeaCredit, IDEA_DEVELOPER_SHARE, IDEA_FULL_PASS_CREDIT, IDEA_TASK_SHIP_BASE } = credits;
25
+
26
+ let passed = 0;
27
+ let failed = 0;
28
+ async function test(name, fn) {
29
+ try { await fn(); passed++; console.log(` ok ${name}`); }
30
+ catch (err) { failed++; console.error(` FAIL ${name}\n ${err.message}`); }
31
+ }
32
+
33
+ const sum = (ps) => ps.reduce((n, p) => n + p.delta, 0);
34
+
35
+ // --- property 1: the total is conserved ---------------------------------
36
+
37
+ await test('the split CONSERVES the total at every payout size', () => {
38
+ for (let total = 1; total <= 120; total += 1) {
39
+ const ps = splitIdeaCredit(total, { authorId: 9, developedBy: 42 });
40
+ assert.equal(sum(ps), total, `total ${total} must not gain or lose a credit`);
41
+ }
42
+ });
43
+
44
+ await test('a 5-credit payout splits 4/1, not 4/2 — the rounding trap', () => {
45
+ const ps = splitIdeaCredit(5, { authorId: 9, developedBy: 42 });
46
+ assert.equal(sum(ps), 5);
47
+ const dev = ps.find((p) => p.role === 'developer');
48
+ const author = ps.find((p) => p.role === 'spark_author');
49
+ assert.equal(dev.delta, 4, 'round(5 * 0.70) = 4');
50
+ assert.equal(author.delta, 1, 'the author gets the REMAINDER, not round(1.5)=2');
51
+ });
52
+
53
+ await test('the developer gets the larger share, and it tracks the constant', () => {
54
+ assert.equal(IDEA_DEVELOPER_SHARE, 0.70, 'the owner decision, 2026-08-22');
55
+ const ps = splitIdeaCredit(100, { authorId: 9, developedBy: 42 });
56
+ assert.equal(ps.find((p) => p.role === 'developer').delta, 70);
57
+ assert.equal(ps.find((p) => p.role === 'spark_author').delta, 30);
58
+ });
59
+
60
+ // --- property 2: self-development collapses -----------------------------
61
+
62
+ await test('a SELF-developed idea is ONE payee at the full amount', () => {
63
+ const ps = splitIdeaCredit(5, { authorId: 9, developedBy: 9 });
64
+ assert.equal(ps.length, 1, 'two rows for one builder would be silently under-paid');
65
+ assert.equal(ps[0].delta, 5);
66
+ assert.equal(ps[0].builderId, 9);
67
+ assert.equal(ps[0].role, 'author_developer');
68
+ });
69
+
70
+ await test('string/number id mismatch still counts as the same person', () => {
71
+ // captured_by and developed_by come back from pg as strings on a bigint column.
72
+ const ps = splitIdeaCredit(5, { authorId: '9', developedBy: 9 });
73
+ assert.equal(ps.length, 1, 'a type difference must not become a phantom second payee');
74
+ });
75
+
76
+ await test('an UNDEVELOPED idea pays its author everything', () => {
77
+ const ps = splitIdeaCredit(5, { authorId: 9, developedBy: null });
78
+ assert.deepEqual(ps, [{ builderId: 9, delta: 5, role: 'author' }]);
79
+ });
80
+
81
+ // --- property 3: no zero rows -------------------------------------------
82
+
83
+ await test('a 1-credit payout pays the developer 1 and books NOTHING for the author', () => {
84
+ const ps = splitIdeaCredit(1, { authorId: 9, developedBy: 42 });
85
+ assert.equal(sum(ps), 1);
86
+ assert.equal(ps.length, 1, 'a 0-credit ledger row reads like a payment that was not made');
87
+ assert.equal(ps[0].role, 'developer');
88
+ });
89
+
90
+ await test('a zero or negative payout pays nobody', () => {
91
+ assert.deepEqual(splitIdeaCredit(0, { authorId: 9, developedBy: 42 }), []);
92
+ assert.deepEqual(splitIdeaCredit(-5, { authorId: 9, developedBy: 42 }), []);
93
+ });
94
+
95
+ await test('no author means no payment at all', () => {
96
+ assert.deepEqual(splitIdeaCredit(5, { authorId: null, developedBy: 42 }), []);
97
+ });
98
+
99
+ // --- stream 2, end to end ----------------------------------------------
100
+
101
+ // A stub client that records every INSERT and answers the parties lookup.
102
+ function stubClient({ authorId, developedBy, alreadyPaid = [] }) {
103
+ const inserts = [];
104
+ return {
105
+ inserts,
106
+ query: async (sql, params = []) => {
107
+ if (/FROM idea_inbox i/i.test(sql) && /developed_by/i.test(sql)) {
108
+ return { rows: [{ captured_by: authorId, developed_by: developedBy }] };
109
+ }
110
+ if (/FROM ideas_task_lineage/i.test(sql)) {
111
+ return { rows: [{ idea_id: '77', captured_by: authorId, grade: 'full', completeness: 100, developed_by: developedBy }] };
112
+ }
113
+ if (/INSERT INTO credit_log/i.test(sql)) {
114
+ const [builderId, , delta, reason] = params;
115
+ if (alreadyPaid.some((a) => String(a.builderId) === String(builderId) && a.reason === reason)) {
116
+ return { rowCount: 0 };
117
+ }
118
+ inserts.push({ builderId, delta, reason, description: params[4] });
119
+ return { rowCount: 1 };
120
+ }
121
+ return { rows: [], rowCount: 0 };
122
+ },
123
+ };
124
+ }
125
+
126
+ const ON = { ideatorCredit: true };
127
+
128
+ await test('stream 2: a developed Full Idea pays BOTH parties under one reason key', async () => {
129
+ const c = stubClient({ authorId: '9', developedBy: '42' });
130
+ const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 77, authorId: '9', completenessScore: 100, config: ON });
131
+ assert.equal(r.credited, IDEA_FULL_PASS_CREDIT, 'the whole payout is booked, split across two rows');
132
+ assert.equal(c.inserts.length, 2);
133
+ // One reason key for both — which is exactly why the idempotency predicate
134
+ // must include builder_id, or the second payee would be refused.
135
+ assert.equal(new Set(c.inserts.map((i) => i.reason)).size, 1);
136
+ assert.equal(c.inserts.find((i) => i.builderId === '42').delta, 4);
137
+ assert.equal(c.inserts.find((i) => i.builderId === '9').delta, 1);
138
+ assert.match(c.inserts.find((i) => i.builderId === '42').description, /developer/);
139
+ assert.match(c.inserts.find((i) => i.builderId === '9').description, /spark author/);
140
+ });
141
+
142
+ await test('stream 2: the PARTIES come from the DB, not from the caller', async () => {
143
+ // A caller naming the wrong author cannot redirect the money — the whole
144
+ // reason R146 could not safely pay.
145
+ const c = stubClient({ authorId: '9', developedBy: '42' });
146
+ await credits.awardIdeaFullPassCredit(c, { ideaId: 77, authorId: '999', completenessScore: 100, config: ON });
147
+ assert.ok(!c.inserts.some((i) => i.builderId === '999'), 'the caller-supplied author is ignored when the row is readable');
148
+ assert.deepEqual(c.inserts.map((i) => i.builderId).sort(), ['42', '9']);
149
+ });
150
+
151
+ await test('stream 2: an undeveloped Full Idea still pays its author the full 5', async () => {
152
+ const c = stubClient({ authorId: '9', developedBy: null });
153
+ const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 77, authorId: '9', completenessScore: 100, config: ON });
154
+ assert.equal(r.credited, IDEA_FULL_PASS_CREDIT);
155
+ assert.equal(c.inserts.length, 1);
156
+ assert.equal(c.inserts[0].builderId, '9');
157
+ assert.equal(c.inserts[0].delta, IDEA_FULL_PASS_CREDIT);
158
+ });
159
+
160
+ await test('stream 2: a PARTIALLY booked split completes on a re-run, never double-pays', async () => {
161
+ const reason = 'idea.credit.full_pass:77';
162
+ // The developer's row landed; the author's did not (a crash between inserts).
163
+ const c = stubClient({ authorId: '9', developedBy: '42', alreadyPaid: [{ builderId: '42', reason }] });
164
+ const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 77, authorId: '9', completenessScore: 100, config: ON });
165
+ assert.equal(c.inserts.length, 1, 'only the missing row is written');
166
+ assert.equal(c.inserts[0].builderId, '9');
167
+ assert.equal(r.credited, 1, 'credited reports only what was NEWLY booked');
168
+ });
169
+
170
+ await test('stream 2: a fully booked split reports already_paid and writes nothing', async () => {
171
+ const reason = 'idea.credit.full_pass:77';
172
+ const c = stubClient({ authorId: '9', developedBy: '42', alreadyPaid: [{ builderId: '42', reason }, { builderId: '9', reason }] });
173
+ const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 77, authorId: '9', completenessScore: 100, config: ON });
174
+ assert.equal(c.inserts.length, 0);
175
+ assert.equal(r.credited, 0);
176
+ assert.equal(r.reason, 'already_paid');
177
+ });
178
+
179
+ await test('stream 2: below the bar nobody is paid, developed or not', async () => {
180
+ const c = stubClient({ authorId: '9', developedBy: '42' });
181
+ const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 77, authorId: '9', completenessScore: 10, config: ON });
182
+ assert.equal(r.credited, 0);
183
+ assert.equal(r.reason, 'below_bar');
184
+ assert.equal(c.inserts.length, 0);
185
+ });
186
+
187
+ await test('stream 2: the lane can still be switched off entirely', async () => {
188
+ const c = stubClient({ authorId: '9', developedBy: '42' });
189
+ const r = await credits.awardIdeaFullPassCredit(c, { ideaId: 77, authorId: '9', completenessScore: 100, config: { ideatorCredit: false } });
190
+ assert.equal(r.reason, 'ideator_lane_off');
191
+ assert.equal(c.inserts.length, 0);
192
+ });
193
+
194
+ // --- stream 1, end to end ----------------------------------------------
195
+
196
+ await test('stream 1: a task shipped under a DEVELOPED idea splits the same way', async () => {
197
+ const c = stubClient({ authorId: '9', developedBy: '42' });
198
+ const r = await credits.awardIdeaTaskShipCredit(c, { taskId: 100, config: ON });
199
+ assert.equal(r.credited, IDEA_TASK_SHIP_BASE, 'a 100% full idea pays the full base, split');
200
+ assert.equal(c.inserts.length, 2);
201
+ assert.equal(sum(c.inserts.map((i) => ({ delta: i.delta }))), IDEA_TASK_SHIP_BASE);
202
+ assert.equal(c.inserts.find((i) => i.builderId === '42').delta, 4);
203
+ assert.equal(c.inserts.find((i) => i.builderId === '9').delta, 1);
204
+ // Same rule, so the same wording — one rule to explain, not two.
205
+ assert.match(c.inserts.find((i) => i.builderId === '42').description, /stream 1/);
206
+ assert.match(c.inserts.find((i) => i.builderId === '42').description, /developer/);
207
+ });
208
+
209
+ await test('stream 1: an undeveloped idea is unchanged — the author gets it all', async () => {
210
+ const c = stubClient({ authorId: '9', developedBy: null });
211
+ const r = await credits.awardIdeaTaskShipCredit(c, { taskId: 100, config: ON });
212
+ assert.equal(r.credited, IDEA_TASK_SHIP_BASE);
213
+ assert.equal(c.inserts.length, 1);
214
+ assert.equal(c.inserts[0].builderId, '9');
215
+ });
216
+
217
+ await test('stream 1: the per-(idea,task) reason key still scopes idempotency', async () => {
218
+ const c = stubClient({ authorId: '9', developedBy: '42' });
219
+ await credits.awardIdeaTaskShipCredit(c, { taskId: 100, config: ON });
220
+ for (const i of c.inserts) assert.equal(i.reason, 'idea.credit.task_ship:77:100');
221
+ });
222
+
223
+ console.log(`\nidea_credit_split: ${passed} passed, ${failed} failed`);
224
+ if (failed > 0) process.exit(1);
@@ -93,9 +93,11 @@ const basePoolQuery = async (sql, params = []) => {
93
93
  async function test(name, fn) {
94
94
  queries = [];
95
95
  portsResolved = [];
96
- // Restored every test: one case below swaps in a two-phase stub to simulate a
97
- // triager closing the idea mid-request, and it must not leak into the next.
96
+ // Restored every test: cases below swap in a two-phase pool stub (a triager
97
+ // closing the idea mid-request) and reward/government port stubs, and none of
98
+ // them may leak into the next test.
98
99
  api.pool.query = basePoolQuery;
100
+ api.resolveOptional = (name) => { portsResolved.push(name); return undefined; };
99
101
  // The author is builder 9; the developer in call() defaults to 42.
100
102
  idea = {
101
103
  id: '77', title: 'a spark', body_md: 'the thought', status: 'open',
@@ -293,16 +295,51 @@ await test('a missing idea is a 404, not a 500', async () => {
293
295
 
294
296
  // --- the money boundary: R147 owns the split ---------------------------
295
297
 
296
- await test('the stream-2 pass credit is NOT paid here — R147 owns the split', async () => {
298
+ // R146 shipped this route paying NOTHING on purpose: with two people involved the
299
+ // recipient was ambiguous, and awardIdeaFullPassCredit's idempotency predicate is
300
+ // (builder_id, reason) — so guessing the developer would not have blocked a later
301
+ // payment to the author, and one idea would have paid twice. R147 (ADR 0185)
302
+ // resolved that by moving the payee decision INTO the economy module, so this
303
+ // route can now call it safely: it no longer chooses who earns.
304
+ await test('the stream-2 pass credit IS paid here now, and the route does not pick the payee', async () => {
305
+ let seen = null;
306
+ api.resolveOptional = (name) => {
307
+ portsResolved.push(name);
308
+ if (name === 'reward') {
309
+ return {
310
+ awardIdeaFullPassCredit: async (_client, args) => {
311
+ seen = args;
312
+ return { credited: 5, reason: 'paid', payees: [
313
+ { builderId: '42', delta: 4, role: 'developer', booked: true },
314
+ { builderId: '9', delta: 1, role: 'spark_author', booked: true },
315
+ ] };
316
+ },
317
+ };
318
+ }
319
+ return undefined;
320
+ };
297
321
  const r = await call();
298
322
  assert.equal(r.status, 200);
299
- // Stronger than "no INSERT INTO credit_log": the handler never even asks for
300
- // the reward port. awardIdeaFullPassCredit is idempotent per (builder, idea),
301
- // so paying the DEVELOPER here would NOT block a later payment to the AUTHOR —
302
- // the same idea would pay twice.
303
- assert.ok(!portsResolved.includes('reward'), 'the reward port is never resolved on the develop path');
304
- assert.ok(!queries.some((q) => /credit_log/i.test(q.sql)), 'no credit is booked');
305
- assert.equal(r.payload.ideator_credit, undefined);
323
+ assert.ok(portsResolved.includes('reward'), 'the reward port is consulted');
324
+ assert.equal(r.payload.idea.ideator_credit, 5, 'the total is surfaced');
325
+ assert.equal(r.payload.idea.ideator_credit_split.length, 2, 'and the per-payee breakdown');
326
+ // The route hands over the idea and the score — never a chosen recipient beyond
327
+ // the author fallback the economy module overrides from the DB.
328
+ assert.equal(seen.ideaId, 77);
329
+ assert.equal(seen.completenessScore, 100);
330
+ assert.ok(!('developedBy' in seen), 'the route does not tell economy who developed it');
331
+ });
332
+
333
+ await test('a credit failure does not roll back the development (fail-open)', async () => {
334
+ api.resolveOptional = (name) => {
335
+ portsResolved.push(name);
336
+ if (name === 'reward') return { awardIdeaFullPassCredit: async () => { throw new Error('synthetic credit outage'); } };
337
+ return undefined;
338
+ };
339
+ const r = await call();
340
+ assert.equal(r.status, 200, 'the development landed');
341
+ assert.equal(r.payload.developed, true);
342
+ assert.equal(r.payload.idea.ideator_credit, undefined);
306
343
  });
307
344
 
308
345
  await test('but the board window IS opened — otherwise the handoff dead-ends', async () => {