@bongos/core 1.21.80 → 1.21.82

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.
@@ -602,10 +602,36 @@ async function awardIdeaTaskShipCredit(client, { taskId, config = rewardConfig()
602
602
  LIMIT 1`,
603
603
  [taskId]
604
604
  );
605
- if (rows.length === 0 || rows[0].captured_by == null) {
605
+ // THE GOAL AN IDEA BECAME (task 1004442). A Full Idea ratified into a goal
606
+ // writes ideas_goal_ratifications and NO lineage row, so every task later
607
+ // filed under that goal shipped paying the idea nothing. With no lineage row
608
+ // the goal's idea is the source, at the same full share; a lineage row wins.
609
+ let source = rows[0];
610
+ let viaGoalId = null;
611
+ if (!source) {
612
+ const born = await client.query(
613
+ `SELECT r.idea_id,
614
+ r.goal_id,
615
+ i.captured_by,
616
+ COALESCE(g.grade, 'quick') AS grade,
617
+ g.completeness_score AS completeness,
618
+ g.developed_by AS developed_by
619
+ FROM tasks t
620
+ JOIN ideas_goal_ratifications r ON r.goal_id = t.goal_id
621
+ JOIN idea_inbox i ON i.id = r.idea_id
622
+ LEFT JOIN ideas_grades g ON g.idea_id = r.idea_id
623
+ WHERE t.id = $1
624
+ ORDER BY r.ratified_at ASC, r.idea_id ASC
625
+ LIMIT 1`,
626
+ [taskId]
627
+ );
628
+ source = born.rows[0];
629
+ viaGoalId = source ? source.goal_id : null;
630
+ }
631
+ if (!source || source.captured_by == null) {
606
632
  return { credited: 0, reason: 'no_source_idea', ideaId: null, authorId: null };
607
633
  }
608
- const { idea_id: ideaId, captured_by: authorId, grade, completeness, developed_by: developedBy } = rows[0];
634
+ const { idea_id: ideaId, captured_by: authorId, grade, completeness, developed_by: developedBy } = source;
609
635
  const factor = ideaGradeFactor(grade, completeness);
610
636
  const delta = Math.round(IDEA_TASK_SHIP_BASE * factor);
611
637
  if (delta <= 0) {
@@ -625,7 +651,7 @@ async function awardIdeaTaskShipCredit(client, { taskId, config = rewardConfig()
625
651
  payees,
626
652
  reason,
627
653
  taskId,
628
- 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' : ''})`,
654
+ describe: (p) => `Task #${taskId} shipped under ${viaGoalId != null ? `goal #${viaGoalId}, ratified from ` : ''}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' : ''})`,
629
655
  });
630
656
  return credited > 0
631
657
  ? { credited, reason: 'paid', ideaId: Number(ideaId), authorId: Number(authorId), payees: booked }
@@ -556,10 +556,10 @@ function passPayload(closed, { authorId, completenessScore, passedAt }) {
556
556
  // credit is booked through the `reward` PORT inside the same transaction that
557
557
  // writes the outcome — if the credit write fails, the close rolls back and the
558
558
  // item stays open, so the vote is retried rather than silently unpaid.
559
- // `resolveOptional` returning null (no economy module), or a reward port that
560
- // does not yet carry awardIdeaGovernancePassCredit (IC5 unbuilt), is a CLEAN
559
+ // The payout is economy's `awardIdeaGovernancePassCredit` (stream 3, IC5). A null
560
+ // `resolveOptional` (no economy), or a port without it (an older one), is a CLEAN
561
561
  // NO-OP: the item still closes, nothing is booked, and the durable
562
- // `outcome = 'passed'` row lets IC5's re-drive sweep pay later. The port is
562
+ // `outcome = 'passed'` row is what a re-drive sweep would pay from. The port is
563
563
  // resolved and the payload's reads happen BEFORE the transaction opens.
564
564
  //
565
565
  // `board.item.passed` is then emitted AFTER commit, emitAsync, for non-money
@@ -207,6 +207,50 @@ async function resetRankGrants({ rankKey, permissionKeys }) {
207
207
  return permissionKeys.length;
208
208
  }
209
209
 
210
+ // Delete a CUSTOM rank (task 1004481) — or refuse, naming who still holds it.
211
+ // The rank row is locked FOR UPDATE before the holders are read: an assignment's
212
+ // foreign-key check takes a KEY SHARE lock on that same row, so a concurrent
213
+ // assign either finishes first (and is seen here as a holder) or waits until the
214
+ // rank is gone (and then fails its FK). Grants go with the rank by CASCADE.
215
+ // A seeded rank never matches (`is_system = false`), whatever the caller sent.
216
+ // Returns { deleted: true } | { deleted: false, reason: 'not_found' | 'held', holders }.
217
+ async function deleteCustomRank(rankKey) {
218
+ const client = await pool.connect();
219
+ try {
220
+ await client.query('BEGIN');
221
+ const { rows: locked } = await client.query(
222
+ `SELECT rank_key FROM government_ranks
223
+ WHERE rank_key = $1 AND is_system = false
224
+ FOR UPDATE`,
225
+ [rankKey],
226
+ );
227
+ if (locked.length === 0) {
228
+ await client.query('ROLLBACK');
229
+ return { deleted: false, reason: 'not_found', holders: [] };
230
+ }
231
+ const { rows: holders } = await client.query(
232
+ `SELECT br.builder_id, b.github_login, b.display_name
233
+ FROM government_builder_ranks br
234
+ JOIN builders b ON b.id = br.builder_id
235
+ WHERE br.rank_key = $1
236
+ ORDER BY br.builder_id`,
237
+ [rankKey],
238
+ );
239
+ if (holders.length > 0) {
240
+ await client.query('ROLLBACK');
241
+ return { deleted: false, reason: 'held', holders };
242
+ }
243
+ await client.query(`DELETE FROM government_ranks WHERE rank_key = $1 AND is_system = false`, [rankKey]);
244
+ await client.query('COMMIT');
245
+ return { deleted: true, holders: [] };
246
+ } catch (err) {
247
+ await client.query('ROLLBACK').catch(() => {});
248
+ throw err;
249
+ } finally {
250
+ client.release();
251
+ }
252
+ }
253
+
210
254
  async function listAssignments() {
211
255
  const { rows } = await pool.query(
212
256
  `SELECT br.builder_id, b.github_login, b.display_name, b.rank,
@@ -885,6 +929,7 @@ module.exports = {
885
929
  grantRankPermission,
886
930
  revokeRankPermission,
887
931
  resetRankGrants,
932
+ deleteCustomRank,
888
933
  listAssignments,
889
934
  assignRank,
890
935
  unassignRank,
@@ -28,6 +28,8 @@ const board = require('../board');
28
28
  const boardWithdraw = require('../board-withdraw');
29
29
  const recommendations = require('../recommendations');
30
30
  const docket = require('../docket');
31
+ const governmentConfig = require('../config');
32
+ const membershipPredicate = require('../membership-predicate');
31
33
  // task 1003208: structured logging (pino via the doorway) — was console.*.
32
34
  const log = api.logger('government');
33
35
 
@@ -476,6 +478,75 @@ module.exports = function buildGovernmentRouter() {
476
478
  }
477
479
  });
478
480
 
481
+ // rank: perm government.manage — delete a CUSTOM rank (task 1004481). Ranks
482
+ // added from a recommended set used to be permanent; the owner hesitated to
483
+ // try one for exactly that reason. Three refusals, each naming what to do:
484
+ // - a seeded rank, never (`seeded_rank_immutable`) — the ladder is the system;
485
+ // - a rank someone still holds (`rank_still_held`, with the holders) — owner
486
+ // decision 2026-10-08: no auto-unassign, so nobody loses a rank as a side
487
+ // effect of a cleanup;
488
+ // - a rank the constitution or the who-decides table seats
489
+ // (`rank_in_use_by_government`) — deleting it would leave that seat empty,
490
+ // and only an amendment may change who decides.
491
+ router.delete('/government/ranks/:rankKey', api.requireBuilder, api.requirePermission('government.manage'), async (req, res) => {
492
+ const rankKey = rankKeyParam(req, res);
493
+ if (rankKey === null) return;
494
+ try {
495
+ const rank = await db.getRankByKey(rankKey);
496
+ if (!rank) return res.fail('rank_not_found', 404, { rank_key: rankKey });
497
+ if (rank.is_system) {
498
+ return res.fail('seeded_rank_immutable', 403, { rank_key: rankKey, reason: 'a ladder rank is part of the system and can never be deleted' });
499
+ }
500
+ const seatedIn = await seatsNamingRank(rankKey);
501
+ if (seatedIn.length > 0) {
502
+ return res.fail('rank_in_use_by_government', 409, {
503
+ rank_key: rankKey, seated_in: seatedIn,
504
+ reason: 'this rank decides something; propose an amendment that seats someone else first',
505
+ });
506
+ }
507
+ const out = await db.deleteCustomRank(rankKey);
508
+ if (out.reason === 'not_found') return res.fail('rank_not_found', 404, { rank_key: rankKey });
509
+ if (out.reason === 'held') {
510
+ return res.fail('rank_still_held', 409, {
511
+ rank_key: rankKey,
512
+ holders: out.holders.map((h) => ({ builder_id: h.builder_id, github_login: h.github_login, display_name: h.display_name })),
513
+ reason: 'remove this rank from everyone who holds it, then delete it',
514
+ });
515
+ }
516
+ res.json({ ok: true, rank_key: rankKey, deleted: true });
517
+ } catch (err) {
518
+ log.error('[gds] DELETE /government/ranks/:rankKey', err);
519
+ res.fail('delete_failed', { status: 500, message: 'internal error' });
520
+ }
521
+ });
522
+
523
+ // Where the live government seats a rank by name: the constitution's board
524
+ // membership and every who-decides row that names a sitting.
525
+ //
526
+ // loadGovernmentConfig() reads two small files synchronously, which config.js
527
+ // says belongs at boot. This route is the documented exception, as board.js's
528
+ // window-opening is: an Archon deleting a rank is rare and never a hot path, and
529
+ // the answer must be the constitution IN FORCE NOW (an amendment applied a moment
530
+ // ago rewrites the file), which a boot-time copy would not be.
531
+ //
532
+ // FAIL-CLOSED on an unparseable predicate: it seats nobody today, but if it
533
+ // spells this rank's key it is refused, so a typo someone later fixes cannot
534
+ // point at a rank that was deleted in the meantime.
535
+ async function seatsNamingRank(rankKey) {
536
+ const names = (predicate) => {
537
+ const parsed = membershipPredicate.parseMembershipPredicate(predicate);
538
+ if (parsed) return parsed.rankKeys.includes(rankKey);
539
+ return typeof predicate === 'string' && predicate.includes(rankKey);
540
+ };
541
+ const out = [];
542
+ const { board: live } = governmentConfig.loadGovernmentConfig();
543
+ if (live && names(live.membership)) out.push('the board');
544
+ for (const row of await db.listDecisionRules()) {
545
+ if (names(row.decided_by)) out.push(`who decides at ${row.area}`);
546
+ }
547
+ return out;
548
+ }
549
+
479
550
  // rank: perm government.manage — assign a CUSTOM rank to a builder.
480
551
  // assigned_by is ALWAYS the session builder, never input (the audit trail's
481
552
  // "who did this" must be the authenticated actor).
@@ -268,6 +268,15 @@
268
268
  btn.addEventListener('click', () => resetRank(rank, btn));
269
269
  resetBox.appendChild(btn);
270
270
  }
271
+ // A custom rank can be deleted (task 1004481); the ladder never.
272
+ if (rank && !rank.is_system) {
273
+ const btn = document.createElement('button');
274
+ btn.type = 'button';
275
+ btn.className = 'btn-ghost';
276
+ btn.textContent = 'Delete rank';
277
+ btn.addEventListener('click', () => deleteRank(rank, btn));
278
+ resetBox.appendChild(btn);
279
+ }
271
280
 
272
281
  grid.innerHTML = '';
273
282
  // Principals are rendered in their own section further down the page; a machine
@@ -303,6 +312,35 @@
303
312
  }
304
313
  }
305
314
 
315
+ // Delete a custom rank. The server refuses while anyone holds it and names
316
+ // them, so the toast can say exactly whose rank to remove first.
317
+ async function deleteRank(rank, btn) {
318
+ const label = rank.label || rank.rank_key;
319
+ if (!window.confirm(`Delete the ${label} rank?\n\nIts permissions go with it. This cannot be undone, though you can create a rank with the same name again.`)) return;
320
+ btn.disabled = true;
321
+ const res = await api.request('DELETE', `${API}/government/ranks/${encodeURIComponent(rank.rank_key)}`);
322
+ if (res.status === 401) { window.location.assign(`${API}/auth/web/start`); return; }
323
+ if (res.ok) {
324
+ toast(`${label} deleted.`);
325
+ await reloadRanks();
326
+ return;
327
+ }
328
+ btn.disabled = false;
329
+ toast(deleteRefusal((res.data || {}).error || {}, label));
330
+ }
331
+ function deleteRefusal(err, label) {
332
+ const d = err.details || {};
333
+ if (err.code === 'rank_still_held' && Array.isArray(d.holders) && d.holders.length) {
334
+ const names = d.holders.map((h) => h.display_name || h.github_login || `builder ${h.builder_id}`);
335
+ const shown = names.length > 3 ? `${names.slice(0, 3).join(', ')} and ${names.length - 3} more` : names.join(', ');
336
+ return `${label} is still held by ${shown}. Remove it from them below, then delete it.`;
337
+ }
338
+ if (err.code === 'rank_in_use_by_government' && Array.isArray(d.seated_in) && d.seated_in.length) {
339
+ return `${label} still decides something (${d.seated_in.join(', ')}). Propose an amendment that seats someone else first.`;
340
+ }
341
+ return `Could not delete: ${friendlyError(err.code) || err.message || 'the server refused'}`;
342
+ }
343
+
306
344
  async function toggleGrant(rank, permissionKey, grant, btn) {
307
345
  btn.disabled = true;
308
346
  try {
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.80",
3
+ "version": "1.21.82",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.80",
9
+ "version": "1.21.82",
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.21.80",
3
+ "version": "1.21.82",
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",
@@ -9306,5 +9306,17 @@
9306
9306
  "id": "1004393",
9307
9307
  "text": "The tweak editor is ready to show landing and status pages side by side with the words, instead of in a separate window. It stays off until a small web-server change is made on the host; those steps are waiting in the owner's"
9308
9308
  }
9309
+ ],
9310
+ "1.21.81": [
9311
+ {
9312
+ "id": "1004442",
9313
+ "text": "When the board turns someone's idea into a goal, every task later finished under that goal now pays the idea's author, as promised. Before this, only work promoted straight from the idea paid them, so a goal full of finished w"
9314
+ }
9315
+ ],
9316
+ "1.21.82": [
9317
+ {
9318
+ "id": "1004481",
9319
+ "text": "Custom ranks can now be deleted from the Government page, so trying a recommended rank set is no longer a permanent choice. A rank someone still holds can't be deleted, and the page says who holds it. The four built-in ranks c"
9320
+ }
9309
9321
  ]
9310
9322
  }
@@ -261,6 +261,7 @@ const GOVERNMENT_PINS = {
261
261
  'PUT /government/ranks/:rankKey/permissions/:permissionKey': 'perm:government.manage',
262
262
  'DELETE /government/ranks/:rankKey/permissions/:permissionKey': 'perm:government.manage',
263
263
  'POST /government/ranks/:rankKey/reset': 'perm:government.manage',
264
+ 'DELETE /government/ranks/:rankKey': 'perm:government.manage',
264
265
  'POST /government/rank-sets/apply': 'perm:government.manage',
265
266
  'POST /government/assignments': 'perm:government.manage',
266
267
  'DELETE /government/assignments/:builderId/:rankKey': 'perm:government.manage',
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.80'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.82'; // 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');
@@ -35,6 +35,7 @@ const api = require(path.join(ROOT, 'src', 'module-api.js'));
35
35
  const seams = require(path.join(ROOT, 'src', 'module-seams.js'));
36
36
  const catalog = require(path.join(ROOT, 'modules', 'government', 'catalog.js'));
37
37
  const govDb = require(path.join(ROOT, 'modules', 'government', 'db.js'));
38
+ const governmentConfig = require(path.join(ROOT, 'modules', 'government', 'config.js'));
38
39
  const rrc = require(path.join(ROOT, 'src', 'bongos', 'route-rank-check.js'));
39
40
  const { attachFail } = require(path.join(ROOT, 'src', 'bongos', 'middleware', 'error-envelope.js'));
40
41
  const express = require('express');
@@ -58,6 +59,7 @@ test('the routes file declares the full R110 surface', () => {
58
59
  'PUT /government/ranks/:rankKey/permissions/:permissionKey',
59
60
  'DELETE /government/ranks/:rankKey/permissions/:permissionKey',
60
61
  'POST /government/ranks/:rankKey/reset',
62
+ 'DELETE /government/ranks/:rankKey',
61
63
  'POST /government/assignments',
62
64
  'DELETE /government/assignments/:builderId/:rankKey',
63
65
  'POST /government/rank-sets/apply',
@@ -125,6 +127,7 @@ test('checkRouteRanks passes over the real tree (no governance findings)', () =>
125
127
 
126
128
  // The db stub: every function the routes call, recording args. Reset per test.
127
129
  const calls = [];
130
+ let deleteAnswer = { deleted: true, holders: [] };
128
131
  const stub = {
129
132
  listRanksWithGrants: async () => [{ rank_key: 'metic', label: 'Metic', description: null, is_system: true, seeded_rank: 'metic', created_by: null, created_at: 't', permissions: ['task.create'] }],
130
133
  listAssignments: async () => [{ builder_id: 7, github_login: 'x', display_name: 'X', rank: 'metic', rank_key: 'metic', role_label: 'Metic', is_system: true, seeded_rank: 'metic', assigned_by: null, assigned_at: 't' }],
@@ -141,6 +144,8 @@ const stub = {
141
144
  grantRankPermission: async () => true,
142
145
  revokeRankPermission: async () => true,
143
146
  resetRankGrants: async ({ permissionKeys }) => permissionKeys.length,
147
+ // task 1004481: `deleteAnswer` scripts what the locked delete found.
148
+ deleteCustomRank: async () => deleteAnswer,
144
149
  assignRank: async () => true,
145
150
  unassignRank: async () => true,
146
151
  // task 1004529: who decides at People. [] = the as-today default row.
@@ -412,6 +417,73 @@ test('POST reset: refused on a custom role (no catalog default to return to)', a
412
417
  assert.equal(calls.filter(([k]) => k === 'resetRankGrants').length, 0);
413
418
  });
414
419
 
420
+ // --- delete a custom rank (task 1004481) --------------------------------------
421
+
422
+ test('DELETE a rank: a custom rank nobody holds is deleted', async () => {
423
+ deleteAnswer = { deleted: true, holders: [] };
424
+ const r = await call('DELETE', '/government/ranks/triage-helper');
425
+ assert.equal(r.status, 200);
426
+ assert.equal(r.json.deleted, true);
427
+ const c = calls.find(([k]) => k === 'deleteCustomRank');
428
+ assert.equal(c[1], 'triage-helper');
429
+ });
430
+
431
+ test('DELETE a rank: refused while held, and the refusal names who holds it', async () => {
432
+ deleteAnswer = { deleted: false, reason: 'held', holders: [{ builder_id: 9, github_login: 'ana', display_name: 'Ana', extra: 'x' }] };
433
+ const r = await call('DELETE', '/government/ranks/triage-helper');
434
+ assert.equal(r.status, 409);
435
+ assert.equal(r.json.error.code, 'rank_still_held');
436
+ assert.deepEqual(r.json.error.details.holders, [{ builder_id: 9, github_login: 'ana', display_name: 'Ana' }], 'names them, and only those fields');
437
+ assert.equal(calls.filter(([k]) => k === 'unassignRank').length, 0, 'never unassigns on the way');
438
+ deleteAnswer = { deleted: true, holders: [] };
439
+ });
440
+
441
+ test('DELETE a rank: every seeded rank is refused before the db is asked', async () => {
442
+ for (const k of catalog.RANK_ORDER) {
443
+ const r = await call('DELETE', `/government/ranks/${k}`);
444
+ assert.equal(r.status, 403, k);
445
+ assert.equal(r.json.error.code, 'seeded_rank_immutable');
446
+ assert.equal(calls.filter(([n]) => n === 'deleteCustomRank').length, 0);
447
+ }
448
+ });
449
+
450
+ test('DELETE a rank: a missing rank 404s, from the read or from the locked delete', async () => {
451
+ assert.equal((await call('DELETE', '/government/ranks/missing')).status, 404);
452
+ deleteAnswer = { deleted: false, reason: 'not_found', holders: [] };
453
+ assert.equal((await call('DELETE', '/government/ranks/triage-helper')).status, 404);
454
+ deleteAnswer = { deleted: true, holders: [] };
455
+ });
456
+
457
+ test('DELETE a rank: refused while the board or a who-decides row seats it', async () => {
458
+ const realLoad = governmentConfig.loadGovernmentConfig;
459
+ try {
460
+ governmentConfig.loadGovernmentConfig = () => ({ board: { membership: 'rank:archon,triage-helper' } });
461
+ let r = await call('DELETE', '/government/ranks/triage-helper');
462
+ assert.equal(r.status, 409);
463
+ assert.equal(r.json.error.code, 'rank_in_use_by_government');
464
+ assert.deepEqual(r.json.error.details.seated_in, ['the board']);
465
+ assert.equal(calls.filter(([k]) => k === 'deleteCustomRank').length, 0);
466
+
467
+ governmentConfig.loadGovernmentConfig = () => ({ board: { membership: 'rank:archon' } });
468
+ stub.listDecisionRules = async () => [{ area: 'people', decided_by: 'rank:triage-helper' }, { area: 'treasury', decided_by: 'gate' }];
469
+ r = await call('DELETE', '/government/ranks/triage-helper');
470
+ assert.equal(r.status, 409);
471
+ assert.deepEqual(r.json.error.details.seated_in, ['who decides at people']);
472
+ r = await call('DELETE', '/government/ranks/other-rank');
473
+ assert.equal(r.status, 200, 'a rank nothing seats is still deletable');
474
+
475
+ // fail-closed: an unparseable predicate that spells the key still refuses
476
+ stub.listDecisionRules = async () => [];
477
+ governmentConfig.loadGovernmentConfig = () => ({ board: { membership: 'rank:triage-helper+' } });
478
+ r = await call('DELETE', '/government/ranks/triage-helper');
479
+ assert.equal(r.status, 409, 'rank:<custom>+ is unparseable, and still names the rank');
480
+ assert.deepEqual(r.json.error.details.seated_in, ['the board']);
481
+ } finally {
482
+ governmentConfig.loadGovernmentConfig = realLoad;
483
+ stub.listDecisionRules = async () => [];
484
+ }
485
+ });
486
+
415
487
  // --- assignments -------------------------------------------------------------
416
488
 
417
489
  test('POST /government/assignments assigns a custom role, assigned_by = the session builder', async () => {
@@ -113,5 +113,50 @@ await test('the lane opted out, or a task with no source idea, pays nothing', as
113
113
  assert.equal(r.reason, 'no_source_idea');
114
114
  });
115
115
 
116
+ // task 1004442: a Full Idea ratified into a GOAL writes no lineage row, so a
117
+ // task filed later under that goal is found through the goal instead.
118
+ function goalStubClient(lineage, born) {
119
+ const c = stubClient(lineage);
120
+ const inner = c.query;
121
+ c.goalAsked = 0;
122
+ c.query = async (sql, params) => {
123
+ if (/FROM tasks t\s+JOIN ideas_goal_ratifications/i.test(sql)) {
124
+ c.goalAsked++;
125
+ assert.equal(params[0], 100, 'asks about the shipping task');
126
+ assert.match(sql, /r\.goal_id = t\.goal_id/, 'joined on the task\'s own goal');
127
+ return { rows: born ? [born] : [] };
128
+ }
129
+ return inner(sql, params);
130
+ };
131
+ return c;
132
+ }
133
+
134
+ await test('a task filed under a goal RATIFIED FROM an idea pays that idea, at the full share', async () => {
135
+ const c = goalStubClient(null, { idea_id: 9, goal_id: 55, captured_by: 4, grade: 'full', completeness: 80 });
136
+ const r = await credits.awardIdeaTaskShipCredit(c, { taskId: 100, config: ON });
137
+ assert.equal(r.credited, 4, '5 × 0.80, the same as a lineage task');
138
+ assert.equal(r.ideaId, 9);
139
+ assert.equal(c.credited[0].builder_id, 4);
140
+ assert.equal(c.credited[0].reason, 'idea.credit.task_ship:9:100', 'the stream-1 key, so size and the trail count it');
141
+ const again = await credits.awardIdeaTaskShipCredit(c, { taskId: 100, config: ON });
142
+ assert.equal(again.credited, 0, 'once per (idea, task)');
143
+ });
144
+
145
+ await test('a lineage row WINS: the goal is not asked and its idea is not paid as well', async () => {
146
+ const c = goalStubClient({ idea_id: 7, captured_by: 3, grade: 'full', completeness: 80 },
147
+ { idea_id: 9, goal_id: 55, captured_by: 4, grade: 'full', completeness: 80 });
148
+ const r = await credits.awardIdeaTaskShipCredit(c, { taskId: 100, config: ON });
149
+ assert.equal(r.ideaId, 7);
150
+ assert.equal(c.goalAsked, 0);
151
+ assert.equal(c.credited.length, 1);
152
+ });
153
+
154
+ await test('a goal no idea was ratified into pays nothing', async () => {
155
+ const c = goalStubClient(null, null);
156
+ const r = await credits.awardIdeaTaskShipCredit(c, { taskId: 100, config: ON });
157
+ assert.equal(r.reason, 'no_source_idea');
158
+ assert.equal(c.goalAsked, 1);
159
+ });
160
+
116
161
  console.log(`\nidea_task_ship_credit: ${passed} passed, ${failed} failed`);
117
162
  if (failed > 0) process.exit(1);
@@ -47,7 +47,7 @@ await test('a descent hop is told from a direct payout by the WRITER\'s own word
47
47
  const src = fs.readFileSync(path.join(ROOT, 'modules/economy/credits.js'), 'utf8');
48
48
  // the descent writer and the direct writer, by their own describe() lines
49
49
  const descent = src.match(/describe: \(p\) => `Task #\$\{taskId\} shipped \$\{depth\} hop[^\n]*/);
50
- const direct = src.match(/describe: \(p\) => `Task #\$\{taskId\} shipped under idea[^\n]*/);
50
+ const direct = src.match(/describe: \(p\) => `Task #\$\{taskId\} shipped under [^\n]*/); // "under idea" or, since task 1004442, "under goal …, ratified from idea"
51
51
  assert.ok(descent && direct, 'both writers are where this test reads them');
52
52
  assert.ok(descent[0].includes(earnings.DESCENT_MARK.trim()), 'the descent writer says DESCENT_MARK');
53
53
  assert.ok(!direct[0].includes(earnings.DESCENT_MARK.trim()), 'and the direct one does not, or every idea payout would read as descent');