@bongos/core 1.21.169 → 1.21.171

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/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.169'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.171'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -60,14 +60,14 @@ function well(over = {}) {
60
60
  { id: '71', criterion_id: 'c2', criterion_md: 'The second ring is open', satisfied: false, pending_review: false, task_ids: ['501', '502'] },
61
61
  ],
62
62
  tasks: [
63
- { id: '500', title: 'The task', status: 'shipped', credits_reward: 60, created_on: '2026-07-01', idea_id: '10' },
63
+ { id: '500', title: 'The task', status: 'shipped', credits_reward: 60, credits_awarded: 60, created_on: '2026-07-01', idea_id: '10' },
64
64
  { id: '501', title: 'A held task', status: 'active', credits_reward: 40, created_on: '2026-08-01', idea_id: null },
65
65
  { id: '502', title: 'A claimable task', status: 'ready', credits_reward: 30, created_on: '2026-08-02', idea_id: null },
66
66
  ],
67
67
  ...over,
68
68
  };
69
69
  }
70
- const tasksOf = (prefix, n, status, created = '2026-05-01') => Array.from({ length: n }, (_, i) => ({ id: `${prefix}${i}`, title: `task ${prefix}${i}`, status, credits_reward: 20, created_on: created, idea_id: null }));
70
+ const tasksOf = (prefix, n, status, created = '2026-05-01') => Array.from({ length: n }, (_, i) => ({ id: `${prefix}${i}`, title: `task ${prefix}${i}`, status, credits_reward: 20, credits_awarded: status === 'shipped' ? 20 : 0, created_on: created, idea_id: null }));
71
71
  // A quiet well: nothing held, nothing landed this week.
72
72
  const quiet = (over = {}) => well({ goal_id: '9', title: 'A quiet well — nothing moves', task_total: 4, task_done: 4, criteria: [], tasks: tasksOf('9-', 4, 'shipped'), ...over });
73
73
  const settled = (over = {}) => well({ goal_id: '8', title: 'A settled system — it finished', status: 'achieved', task_total: 3, task_done: 3, tasks: tasksOf('8-', 3, 'shipped'), created_at: '2026-03-01T00:00:00.000Z', ...over });
@@ -142,7 +142,7 @@ await test('a goal is a well sized by its credits; settled and collapsed goals k
142
142
  assert.equal(o.mass, 60);
143
143
  assert.ok(Math.abs(a.r - o.r * 0.8) < 1e-9, 'a settled system is 0.8× the well it would have been');
144
144
  assert.ok(Math.abs(c.r - o.r * 0.5) < 1e-9, 'a collapsed goal is half');
145
- const big = await boot({ sky: sky([well(same({ goal_id: '1' })), well({ ...same({ goal_id: '4' }), tasks: tasksOf('4-', 3, 'shipped').map((t) => ({ ...t, credits_reward: 400 })) })]) });
145
+ const big = await boot({ sky: sky([well(same({ goal_id: '1' })), well({ ...same({ goal_id: '4' }), tasks: tasksOf('4-', 3, 'shipped').map((t) => ({ ...t, credits_reward: 400, credits_awarded: 400 })) })]) });
146
146
  assert.ok(goal(big, '4').r > goal(big, '1').r, 'more credits, a bigger well');
147
147
  });
148
148
 
@@ -227,7 +227,7 @@ await test('EXPLORE IS ON BY DEFAULT: the sky asks where you begin, and the ledg
227
227
  assert.equal(p.el('start').hidden, false, '"where do you begin" is up');
228
228
  const opts = p.el('startOpts').querySelectorAll('button');
229
229
  assert.equal(opts[0].textContent, 'where my thoughts are');
230
- assert.deepEqual(opts.slice(1, -1).map((b) => b.dataset.start), ['7', '9'], 'one door per open well, biggest first');
230
+ assert.deepEqual(opts.slice(1, -1).map((b) => b.dataset.start), ['9', '7'], 'one door per open well, biggest first (task 1004781: 7 weighs only the 60 it paid now)');
231
231
  assert.equal(opts.at(-1).textContent, 'show me everything');
232
232
  assert.equal(SKY.seen(goal(p, '7')), false, 'nothing is reached yet');
233
233
  p.key('l');
@@ -283,7 +283,7 @@ await test('THE LEDGER IS THE ACCESSIBLE PATH: every goal is a row, both directi
283
283
  assert.ok(rows.includes(SKY.esc(g.title)), `${g.title} is named whole`);
284
284
  }
285
285
  const rowOf = (id) => (new RegExp(`data-id="${id}"[\\s\\S]*?</button>`).exec(rows) || [''])[0];
286
- assert.match(rowOf('7'), /alive · 1\/3 tasks · 130 cr · 1\/2 rings · waits on 1/, 'the waiting goal (alive: a task is held) counts what it waits on');
286
+ assert.match(rowOf('7'), /alive · 1\/3 tasks · 60 cr · 1\/2 rings · waits on 1/, 'the waiting goal (alive: a task is held) counts what it waits on');
287
287
  assert.match(rowOf('9'), /waited on by 1/, 'the goal waited on counts it the other way');
288
288
  assert.match(rowOf('8'), /settled/); assert.match(rowOf('6'), /collapsed/);
289
289
  const filters = p.el('filters').querySelectorAll('button').map((b) => [b.dataset.f, b.textContent.replace(/\s*\d+$/, '').trim()]);
@@ -311,6 +311,41 @@ await test('empty, closed with Escape, and focus goes back where it was', async
311
311
  assert.deepEqual(b.counts, [0]);
312
312
  });
313
313
 
314
+ // task 1004765: a round SUBMITTED but never applied sat unseen for weeks. The
315
+ // server lists it apart (not_applied), the panel shows it below the reviewable
316
+ // page, marked, with how long it has waited, and with no verb; the door counts
317
+ // only what can be reviewed.
318
+ await test('submitted but not applied: listed apart, marked, how long, no verbs, not in the door count', async () => {
319
+ const NOW = Date.parse('2026-10-20T12:00:00Z');
320
+ const submitted = {
321
+ id: 60, status: 'ready', holder_id: null, artist_id: ME, artist_login: 'me', updated_at: '2026-09-29T08:00:00Z',
322
+ source_ref: pages.roundSourceRef('landing:projects', 2),
323
+ description: ['Page tweak round 2.', pages.composeBatchBlock({ page_id: 'landing:projects', reading_hash: 'h', submitted_at: '2026-09-29T08:00:00Z', lines: [{ key: 'L0002', section: 'list', before: 'Every project', after: 'All of them', placement: 'placed', file: 'x.html', line: 1, string_id: 'a' }] })].join('\n\n'),
324
+ };
325
+ const tasks = [...TASKS.filter((t) => t.id !== 42), submitted];
326
+ const data = pageStatus.composeApprovals({ inventory: INVENTORY, tasks, builderId: ME, crafts: ['artist'], now: NOW });
327
+ assert.equal(data.count, 1, 'the door counts reviewable rounds only');
328
+ assert.deepEqual(data.queue.map((q) => q.task_id), ['41']);
329
+ assert.deepEqual(data.not_applied.map((r) => [r.page_id, r.task_id, r.state_word, r.waited_days, r.line_count]),
330
+ [['landing:projects', '60', 'Submitted, waiting to be applied', 21, 1]]);
331
+
332
+ const b = bootPanel({ answers: { [APPROVALS]: { ok: true, ...data }, [vis(41)]: slotsFor(41) } });
333
+ await b.panel.open(); await settle(); await settle();
334
+ assert.equal(text(b.q('.aq-pending__h')), 'Submitted, not applied yet (1)');
335
+ const row = b.q('.aq-pending__row');
336
+ assert.match(text(row), /Projects/);
337
+ assert.match(text(row), /not applied yet/);
338
+ assert.match(text(row), /Submitted, waiting to be applied · waiting 21 days\. The words are waiting to be applied/);
339
+ assert.equal(row.querySelectorAll('button').length, 0, 'no approve or send-back on a page nobody applied');
340
+ assert.deepEqual(b.counts, [1]);
341
+
342
+ const empty = bootPanel({ answers: { [APPROVALS]: { ok: true, ...pageStatus.composeApprovals({ inventory: INVENTORY, tasks: [submitted], builderId: ME, crafts: ['artist'], now: NOW }) } } });
343
+ await empty.panel.open(); await settle();
344
+ assert.equal(text(empty.q('.aq-title')), 'Nothing waiting', 'nothing to review');
345
+ assert.ok(empty.q('.aq-pending__row'), 'but the submitted page is still shown');
346
+ assert.deepEqual(empty.counts, [0]);
347
+ });
348
+
314
349
  // ── the writes: only the host's, and the host's are the studio's allowlist ──
315
350
 
316
351
  await test('the panel writes only through the writes it is handed; the studio hands it its own WRITE_ROUTES', () => {
@@ -92,7 +92,7 @@ function well(over = {}) {
92
92
  { id: '71', criterion_id: 'c2', criterion_md: 'The second ring is open', satisfied: false, pending_review: false, task_ids: ['501', '502'] },
93
93
  ],
94
94
  tasks: [
95
- { id: '500', title: 'The task', status: 'shipped', credits_reward: 60, created_on: '2026-07-01', idea_id: '10' },
95
+ { id: '500', title: 'The task', status: 'shipped', credits_reward: 60, credits_awarded: 6, created_on: '2026-07-01', idea_id: '10' },
96
96
  { id: '501', title: 'A held task', status: 'active', credits_reward: 40, created_on: '2026-08-01', idea_id: null },
97
97
  { id: '502', title: 'A claimable task', status: 'ready', credits_reward: 30, created_on: '2026-08-02', idea_id: null },
98
98
  ],
@@ -211,7 +211,18 @@ await test('the rings are the criteria, closing as each is satisfied and thick w
211
211
  const g = p.S().goalById.get('7');
212
212
  assert.equal(g.crit.length, 2);
213
213
  same(g.crit.map((c) => c.satisfied), [true, false]);
214
- same(g.crit.map((c) => c.credits), [60, 70], 'a ring\'s credits are the credits of the tasks on it');
214
+ // task 1004781 — a ring weighs what its tasks were PAID: the shipped task paid 6 (its 60 was a guess), and
215
+ // the held and claimable tasks have paid nothing yet, so their estimates stay off the ring.
216
+ same(g.crit.map((c) => c.credits), [6, 0], 'a ring\'s credits are what the tasks on it were paid');
217
+ assert.equal(g.tasks.find((t) => t.id === '500').estimate, null, 'a settled task carries no estimate');
218
+ assert.equal(g.tasks.find((t) => t.id === '501').estimate, 40, 'live work keeps its estimate, apart from the total');
219
+ const SKY = p.SKY();
220
+ SKY.pick({ kind: 'task', ref: g.tasks.find((t) => t.id === '501') }, false);
221
+ assert.match(p.panel(), /40 <span class="lbl">Est\.<\/span>/, 'the panel labels a live task\'s figure as an estimate');
222
+ assert.doesNotMatch(p.panel(), /credits paid/);
223
+ SKY.pick({ kind: 'task', ref: g.tasks.find((t) => t.id === '500') }, false);
224
+ assert.match(p.panel(), /credits paid[\s\S]*?6/, 'and a settled task\'s as what it was paid');
225
+ assert.doesNotMatch(p.panel(), /Est\./);
215
226
  assert.equal(g.tasks.find((t) => t.id === '500').ci, 0, 'a task knows which ring it gates');
216
227
  assert.equal(g.tasks.find((t) => t.id === '500').sourceRef, '10', 'and which thought it was born of');
217
228
  });
@@ -230,8 +241,9 @@ await test('abandoned work is listed at zero and adds nothing to a well or its r
230
241
  const g = p.S().goalById.get('7');
231
242
  assert.equal(g.tasks.find((t) => t.id === '503').credits, 0, 'still listed, at zero');
232
243
  assert.equal(g.tasks.find((t) => t.id === '504').credits, 0, 'a merged-away task is listed at zero too');
233
- assert.equal(g.credits, 130, 'the well\'s mass is the live work only (60 + 40 + 30)');
234
- same(g.crit.map((c) => c.credits), [60, 70], 'and the ring it sat on is not thickened by it');
244
+ assert.equal(g.credits, 6, 'the well\'s mass is what was paid only');
245
+ same(g.crit.map((c) => c.credits), [6, 0], 'and the ring it sat on is not thickened by it');
246
+ assert.equal(g.tasks.find((t) => t.id === '503').estimate, null, 'abandoned work carries no estimate either');
235
247
  });
236
248
 
237
249
  await test('a private goal keeps its structure and none of its prose', async () => {
@@ -20,7 +20,7 @@ import { fileURLToPath } from 'node:url';
20
20
 
21
21
  const require = createRequire(import.meta.url);
22
22
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
23
- const { MIN_HOLDERS, assessInstall, assessReliability, recordInstallSignals } = require('../scripts/gds/module-assess-installs.js');
23
+ const { MIN_HOLDERS, PRIOR_TESTERS, assessInstall, assessReliability, assessTesterFeedback, recordInstallSignals } = require('../scripts/gds/module-assess-installs.js');
24
24
  const reports = require('../src/bongos/module-usage-reports.js');
25
25
  const { ERROR_CODE_RE } = require('../src/bongos/module-usage-reports.js');
26
26
  const { cmdInstall } = require('../scripts/gds/module.js');
@@ -146,11 +146,13 @@ test('the switch: off writes an opt-out row, on deletes it', async () => {
146
146
 
147
147
  // ---- the re-score ----
148
148
 
149
- function scoringDb({ latest, days, current = [] }) {
149
+ function scoringDb({ latest, days, current = [], testerInstalls = [], testerDays = [] }) {
150
150
  return fakeDb([
151
- [/FROM module_install_reports r/, () => ({ rows: latest })],
152
- [/FROM module_runtime_error_counts r/, () => ({ rows: days })],
153
- [/part IN \('install', 'reliability'\)/, () => ({ rows: current })],
151
+ [/FROM module_install_reports r\s/, () => ({ rows: latest })],
152
+ [/FROM module_runtime_error_counts r\s/, () => ({ rows: days })],
153
+ [/FROM module_install_reports ir/, () => ({ rows: testerInstalls })],
154
+ [/FROM module_runtime_error_counts ec/, () => ({ rows: testerDays })],
155
+ [/part IN \('install', 'reliability', 'tester_feedback'\)/, () => ({ rows: current })],
154
156
  [/INSERT INTO module_assessment_signals/, () => ({ rows: [] })],
155
157
  [/SELECT DISTINCT ON \(part\) id, part, outcome, score, sample_size/, () => ({ rows: [] })],
156
158
  [/FROM module_assessment_scores/, () => ({ rows: [] })],
@@ -173,7 +175,10 @@ test('re-score: writes a signal row only for a part whose result moved, then re-
173
175
  const latest = [{ ok: true }, { ok: true }, { ok: false, error_code: 'place_failed' }];
174
176
  const db = scoringDb({
175
177
  latest, days: [],
176
- current: [{ part: 'reliability', outcome: 'no_data', score: null, sample_size: 0 }],
178
+ current: [
179
+ { part: 'reliability', outcome: 'no_data', score: null, sample_size: 0 },
180
+ { part: 'tester_feedback', outcome: 'no_data', score: null, sample_size: 0 },
181
+ ],
177
182
  });
178
183
  const res = await recordInstallSignals(5, { db });
179
184
  assert.deepEqual(res.written, ['install'], 'reliability is unchanged, so no new row');
@@ -184,12 +189,73 @@ test('re-score: writes a signal row only for a part whose result moved, then re-
184
189
  const again = scoringDb({ latest, days: [], current: [
185
190
  { part: 'install', outcome: 'scored', score: 67, sample_size: 3 },
186
191
  { part: 'reliability', outcome: 'no_data', score: null, sample_size: 0 },
192
+ { part: 'tester_feedback', outcome: 'no_data', score: null, sample_size: 0 },
187
193
  ] });
188
194
  const res2 = await recordInstallSignals(5, { db: again });
189
195
  assert.deepEqual(res2.written, []);
190
196
  assert.ok(!again.calls.some((c) => /INSERT/.test(c.sql)), 'nothing moved, nothing written');
191
197
  });
192
198
 
199
+ // ---- tester feedback (task 1003806, ADR 0344 D5) ----
200
+
201
+ const testers = (n, { failed = 0, erroring = 0 } = {}) => {
202
+ const installs = Array.from({ length: n }, (_, i) => ({ holder_kind: 'instance', holder_ref: `p${i}`, ok: i >= failed, error_code: i < failed ? 'place_failed' : null }));
203
+ const days = Array.from({ length: erroring }, (_, i) => ({ holder_kind: 'instance', holder_ref: `p${n - 1 - i}`, error_count: 2 }));
204
+ return { installs, days };
205
+ };
206
+
207
+ test('Tester feedback: no tester reports is no_data, and fewer than MIN_HOLDERS is pending', () => {
208
+ assert.equal(assessTesterFeedback({}).outcome, 'no_data');
209
+ const s = assessTesterFeedback(testers(MIN_HOLDERS - 1));
210
+ assert.equal(s.outcome, 'pending');
211
+ assert.equal(s.score, null);
212
+ });
213
+
214
+ test('Tester feedback: a tester it went well for installed cleanly AND hit no runtime errors', () => {
215
+ const s = assessTesterFeedback(testers(20, { failed: 2, erroring: 3 }));
216
+ assert.equal(s.detail.went_well, 15);
217
+ assert.equal(s.detail.install_failed, 2);
218
+ assert.equal(s.detail.with_errors, 3);
219
+ assert.equal(s.detail.errors, 6);
220
+ assert.equal(s.score, Math.round((100 * (15 + PRIOR_TESTERS / 2)) / (20 + PRIOR_TESTERS)));
221
+ });
222
+
223
+ test('Tester feedback: a tester who only sent error counts still counts once', () => {
224
+ const s = assessTesterFeedback({ installs: [], days: [
225
+ { holder_kind: 'instance', holder_ref: 'a', error_count: 0 },
226
+ { holder_kind: 'instance', holder_ref: 'a', error_count: 0 },
227
+ { holder_kind: 'instance', holder_ref: 'b', error_count: 0 },
228
+ { holder_kind: 'builder', holder_ref: 'a', error_count: 1 },
229
+ ] });
230
+ assert.equal(s.sample_size, 3);
231
+ assert.equal(s.detail.went_well, 2);
232
+ });
233
+
234
+ test('Tester feedback: sample size weighs — a few friendly testers cannot outrank many real ones', () => {
235
+ const few = assessTesterFeedback(testers(3)).score;
236
+ const many = assessTesterFeedback(testers(200, { failed: 10 })).score;
237
+ assert.ok(many > few, `200 testers at 95% (${many}) must beat 3 at 100% (${few})`);
238
+ assert.ok(assessTesterFeedback(testers(200)).score >= 95, 'a large clean sample still scores high');
239
+ });
240
+
241
+ test('re-score: tester reports are read only inside the tester window, author excluded', async () => {
242
+ const t4 = testers(4);
243
+ const db = scoringDb({ latest: [], days: [], testerInstalls: t4.installs, testerDays: t4.days });
244
+ const res = await recordInstallSignals(5, { db });
245
+ assert.ok(res.written.includes('tester_feedback'));
246
+ assert.equal(res.signals.tester_feedback.score, Math.round((100 * (4 + PRIOR_TESTERS / 2)) / (4 + PRIOR_TESTERS)));
247
+ for (const [table, a, when] of [['module_install_reports', 'ir', /ir\.reported_at >= t\.start AND \(t\.stop IS NULL OR ir\.reported_at < t\.stop\)/],
248
+ ['module_runtime_error_counts', 'ec', /ec\.day >= t\.start::date AND \(t\.stop IS NULL OR ec\.day <= t\.stop::date\)/]]) {
249
+ const sql = db.calls.find((c) => c.sql.includes(`FROM ${table} ${a}`)).sql;
250
+ assert.match(sql, /c1\.channel = 'tester'/, `${table}: the window opens on the tester channel`);
251
+ assert.match(sql, /c2\.channel <> 'tester' AND c2\.set_at > s\.start/, `${table}: and closes when it leaves it`);
252
+ assert.match(sql, when, `${table}: only reports inside the window`);
253
+ assert.ok(sql.includes(`NOT (${a}.holder_kind = 'builder' AND ${a}.holder_ref = m.author_id::text)`), `${table}: excludes the author`);
254
+ }
255
+ const insert = db.calls.find((c) => /INSERT INTO module_assessment_signals/.test(c.sql) && c.params[1] === 'tester_feedback');
256
+ assert.deepEqual(insert.params.slice(0, 5), [5, 'tester_feedback', 'scored', res.signals.tester_feedback.score, 4]);
257
+ });
258
+
193
259
  // ---- the migration: there is nowhere to put anything but counts ----
194
260
 
195
261
  test('core_273: three additive tables whose columns hold counts and codes only', () => {
@@ -136,14 +136,27 @@ function loadBrandApply(rewardBlock) {
136
136
  const SHIPPED_TASK = { status: 'shipped', credits_awarded: 0, credits_reward: 60 };
137
137
  const READY_TASK = { status: 'ready', credits_reward: 60 };
138
138
 
139
- test('hall: cost-plus-only keeps "Est." on a SHIPPED task (the 60 is not a payout)', () => {
139
+ // task 1004781 — a settled task shows what it was PAID, never its estimate:
140
+ // the 60 is not a payout, so a shipped task with nothing in the ledger is 0,
141
+ // and that 0 is an actual figure (plain label). Only live work is "Est.".
142
+ test('hall: cost-plus-only shows a SHIPPED task as what it paid (0), not the 60 estimate', () => {
140
143
  const B = loadBrandApply(CPO);
141
- assert.equal(B.taskCreditLabel(SHIPPED_TASK), 'credits Est.',
142
- 'dropping "Est." presents the unpaid estimate as the real payout');
144
+ assert.equal(B.taskCreditValue(SHIPPED_TASK), 0, 'the estimate must never stand in for a payout');
145
+ assert.equal(B.taskCreditLabel(SHIPPED_TASK), 'credits');
146
+ assert.equal(B.taskCreditValue({ ...SHIPPED_TASK, credits_awarded: 7 }), 7, 'a real payout shows as itself');
147
+ assert.equal(B.taskCreditValue(READY_TASK), 60, 'live work keeps its estimate');
143
148
  assert.equal(B.taskCreditLabel(READY_TASK), 'credits Est.');
144
149
  assert.equal(B.perTaskCredit(), false);
145
150
  });
146
151
 
152
+ test('hall: a settled task never falls back to its estimate, on any reward mode', () => {
153
+ for (const mode of [CPO, PAYS, undefined]) {
154
+ const B = loadBrandApply(mode);
155
+ assert.equal(B.taskCreditValue(SHIPPED_TASK), 0);
156
+ assert.equal(B.taskCreditValue({ status: 'confirmed', credits_reward: 30 }), 0, 'no ledger figure ⇒ 0');
157
+ }
158
+ });
159
+
147
160
  test('hall: a PAYING instance keeps the original settled/unsettled split', () => {
148
161
  const B = loadBrandApply(PAYS);
149
162
  assert.equal(B.taskCreditLabel(SHIPPED_TASK), 'credits', 'settled ⇒ plain label, unchanged');
@@ -56,14 +56,14 @@ await test('the shape: wells carry their criteria and their compact tasks; edges
56
56
  assert.equal(w1.created_at, '2026-09-01T00:00:00.000Z');
57
57
  // task 1004402: each ring also carries its UAT state + backend-only flag (ADR 0351).
58
58
  assert.deepEqual(w1.criteria, [{ id: '10', criterion_id: 'slug-10', criterion_md: 'Criterion 10', satisfied: false, backend_only: false, task_ids: ['100', '101'], pending_review: false, uat_state: 'open' }]);
59
- assert.deepEqual(w1.tasks[0], { id: '100', title: 'Task 100', status: 'shipped', credits_reward: 30, created_on: '2026-09-02', idea_id: '555' });
59
+ assert.deepEqual(w1.tasks[0], { id: '100', title: 'Task 100', status: 'shipped', credits_reward: 30, credits_awarded: 0, created_on: '2026-09-02', idea_id: '555' });
60
60
  assert.equal(w1.tasks[1].idea_id, null, 'a task not born of an idea carries null, not an empty string');
61
61
  assert.deepEqual(out.goals[1].criteria[0].task_ids, [], 'a criterion with no linked task is an empty ring, not a missing one');
62
62
  });
63
63
 
64
64
  await test('nothing but the compact fields rides on a task — no description, no value summary, no notes', () => {
65
65
  const out = shapeSky({ goals: [g(1)], extras: [x(1)], tasks: [t(1, 1, { description: 'long prose', value_summary: 'more prose', notes: 'and more' })] });
66
- assert.deepEqual(Object.keys(out.goals[0].tasks[0]).sort(), ['created_on', 'credits_reward', 'id', 'idea_id', 'status', 'title']);
66
+ assert.deepEqual(Object.keys(out.goals[0].tasks[0]).sort(), ['created_on', 'credits_awarded', 'credits_reward', 'id', 'idea_id', 'status', 'title']);
67
67
  });
68
68
 
69
69
  await test('a ring or a task under a goal that is not a well is dropped, not drawn to nowhere', () => {
@@ -177,6 +177,17 @@ await test('the criterion→task join is read ONCE for every goal-bearing criter
177
177
  assert.match(crit[0].sql, /LEFT JOIN tasks t\s+ON t\.id = tc\.task_id/, 'a criterion with no task still arrives (the rollup\'s LEFT JOIN)');
178
178
  });
179
179
 
180
+ // task 1004781 — the sky sizes wells by what the ledger PAID, so the read
181
+ // carries the payout (the shared subquery, settled-only) beside the estimate.
182
+ await test('a task carries what the ledger paid for it, from the shared payout subquery', async () => {
183
+ const out = shapeSky({ goals: [g(1)], extras: [x(1)], tasks: [t(100, 1, { status: 'shipped', credits_awarded: '7' })] });
184
+ assert.equal(out.goals[0].tasks[0].credits_awarded, 7);
185
+ const pool = fakePool();
186
+ await skyRows({}, { pool });
187
+ const tq = pool.calls.find((c) => /FROM tasks t\s+JOIN goals g/.test(c.sql));
188
+ assert.match(tq.sql, /FROM credit_log cl[\s\S]*AS credits_awarded/);
189
+ });
190
+
180
191
  await test('the task query is compact by construction: no description, no value_summary, and the date is truncated in SQL', async () => {
181
192
  const pool = fakePool();
182
193
  await skyRows({}, { pool });