@bongos/core 1.19.901 → 1.19.903

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.
Files changed (39) hide show
  1. package/.bongos-core.json +43 -68
  2. package/bin/bongos.js +7 -13
  3. package/docs/module-api-changelog.md +4 -0
  4. package/docs/recipes/bongos-cli-release.md +3 -2
  5. package/docs/recipes/instance-first-run.md +1 -2
  6. package/docs/recipes/local-dev.md +0 -2
  7. package/modules/economy/credits.js +216 -9
  8. package/modules/economy/reward-policy.js +34 -1
  9. package/modules/economy/reward.js +4 -0
  10. package/modules/hall-ui/public/hall-kit.js +11 -5
  11. package/modules/hall-ui/public/profile-charts.js +1 -1
  12. package/modules/ideas/module.json +2 -1
  13. package/modules/ideas/routes/inbox.js +26 -0
  14. package/modules/lifecycle/db-ship.js +15 -0
  15. package/package-lock.json +2 -2
  16. package/package.json +1 -1
  17. package/scripts/gds/api.js +1 -25
  18. package/scripts/gds/build-cli-package.js +17 -35
  19. package/scripts/gds/claude-materialize.js +1 -1
  20. package/scripts/gds/cli-lib.js +5 -127
  21. package/scripts/gds/go-live.js +1 -1
  22. package/scripts/gds/login.js +1 -1
  23. package/scripts/gds/plain-cards.js +0 -8
  24. package/scripts/gds/ship-land.js +2 -18
  25. package/src/module-api.js +1 -1
  26. package/tests/bongos_dispatch.mjs +4 -4
  27. package/tests/claude_materialize.mjs +1 -1
  28. package/tests/cli_package.mjs +1 -4
  29. package/tests/cli_surface.mjs +1 -88
  30. package/tests/hall_kit_charts.mjs +38 -0
  31. package/tests/idea_descent_ship_credit.mjs +481 -0
  32. package/tests/plain_cards.mjs +2 -2
  33. package/tests/rank_tier_single_source.mjs +3 -5
  34. package/scripts/gds/box-connect-lib.js +0 -550
  35. package/scripts/gds/code.js +0 -105
  36. package/scripts/gds/shell.js +0 -102
  37. package/tests/bongos_box_connect.mjs +0 -166
  38. package/tests/box_scope_predicate.mjs +0 -117
  39. package/tests/cli_box_scope.mjs +0 -92
@@ -0,0 +1,481 @@
1
+ // tests/idea_descent_ship_credit.mjs
2
+ //
3
+ // task 1004088 (BV2.R09, goal 1000095) — the ideator payout follows the
4
+ // task→task descent and decays by depth. ADR 0312; spec
5
+ // docs/specs/bongos-v2-ideator-surface.md decisions 6, 7 and 16.
6
+ //
7
+ // WHY THIS FILE IS SHARP. This is money. The failure modes are all silent —
8
+ // nothing renders wrong, nobody sees an error, an origin is simply paid the
9
+ // wrong amount or paid twice:
10
+ //
11
+ // * PAID TWICE. A merge makes the descent a DAG, so one origin can sit above
12
+ // a shipping task by two routes of different length, and two ancestor tasks
13
+ // can share one source idea. Each of those is a duplicate payment that
14
+ // looks exactly like a correct one in the ledger.
15
+ // * FARMED. Spec decision 7 draws the line at descendant WORK: a separate
16
+ // thought that merely cites an origin must not pay it. The descent surface
17
+ // deliberately displays MORE than the payout counts, so the payout path has
18
+ // to be provably free of idea→idea relations.
19
+ // * DRIFTED. The direct payout and this one must agree about what an idea's
20
+ // work is worth before depth, or the same idea pays differently depending
21
+ // on which hop the ship happened at.
22
+ // * DIVERGED FROM TODAY. "Existing first-hop behaviour preserved" is a
23
+ // done-when, so the no-port and no-ancestor paths must be provably inert.
24
+ //
25
+ // DB-free: the client is a stub whose credit_log is a set of reasons and whose
26
+ // lineage SELECT answers from a fixture, and the descent port is registered
27
+ // with a stub walk — so the decay arithmetic, the de-duplication and the
28
+ // idempotency are proven without a Postgres. A real-Postgres suite would
29
+ // SELF-SKIP on a machine with no database, and a skipped case prints ok and
30
+ // counts as passed.
31
+ //
32
+ // Run: node tests/idea_descent_ship_credit.mjs
33
+
34
+ import { strict as assert } from 'node:assert';
35
+ import { createRequire } from 'node:module';
36
+ import fs from 'node:fs';
37
+ import path from 'node:path';
38
+ import { fileURLToPath } from 'node:url';
39
+
40
+ const require = createRequire(import.meta.url);
41
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
42
+ const credits = require('../modules/economy/credits.js');
43
+ const api = require('../src/module-api.js');
44
+ const { rewardPolicy, IDEA_DESCENT_DECAY } = require('../modules/economy/reward-policy.js');
45
+
46
+ let passed = 0;
47
+ let failed = 0;
48
+ async function test(name, fn) {
49
+ try { await fn(); passed += 1; console.log(`ok ${name}`); }
50
+ catch (err) { failed += 1; console.log(`FAIL ${name}\n ${err && (err.message || JSON.stringify(err))}`); }
51
+ }
52
+
53
+ // ---------------------------------------------------------------------------
54
+ // the harness
55
+ // ---------------------------------------------------------------------------
56
+
57
+ // The `ideas.descent` port is SINGLE-PROVIDER and has no unregister (a port is
58
+ // a required capability, not a last-write-wins slot — src/module-seams.js). So
59
+ // the stub walk is registered exactly once, and each test varies what it
60
+ // returns rather than re-registering. The one case that needs the port ABSENT
61
+ // runs first, before the registration below.
62
+ let ancestorsFixture = [];
63
+ function registerStubDescentPort() {
64
+ api.registerProvider('ideas.descent', {
65
+ ancestorsOfTask: async () => ancestorsFixture,
66
+ });
67
+ }
68
+ const withDescentPort = async (ancestors, fn) => {
69
+ ancestorsFixture = ancestors;
70
+ try { return await fn(); } finally { ancestorsFixture = []; }
71
+ };
72
+
73
+ // `lineage` maps an ancestor task id to the row the DISTINCT ON read returns.
74
+ function stubClient(lineage = {}) {
75
+ const credited = [];
76
+ const log = [];
77
+ return {
78
+ credited,
79
+ log,
80
+ async query(sql, params) {
81
+ log.push({ sql, params });
82
+ if (/FROM ideas_task_lineage/i.test(sql)) {
83
+ const ids = (params[0] || []).map(String);
84
+ return { rows: ids.map((id) => lineage[id]).filter(Boolean) };
85
+ }
86
+ if (/INSERT INTO credit_log/i.test(sql)) {
87
+ // THE STUB MUST DEDUPE THE WAY THE REAL SQL DOES — on (builder_id,
88
+ // reason), not on reason alone. bookIdeaSplit books a SPLIT as two rows
89
+ // sharing one reason, so a reason-only stub refuses the second payee and
90
+ // reports a correct 70/30 split as a duplicate. That is a stub that
91
+ // manufactures a failure the database would not have, and it is exactly
92
+ // how a split-payee test gets written to the wrong expectation.
93
+ assert.match(sql, /builder_id = \$1 AND reason = \$4/,
94
+ 'the real predicate is builder-scoped; this stub mirrors it, so pin it');
95
+ const reason = params[3];
96
+ const builderId = params[0];
97
+ if (credited.some((r) => r.reason === reason && String(r.builder_id) === String(builderId))) {
98
+ return { rowCount: 0, rows: [] };
99
+ }
100
+ credited.push({ builder_id: builderId, task_id: params[1], delta: params[2], reason });
101
+ return { rowCount: 1, rows: [] };
102
+ }
103
+ return { rows: [] };
104
+ },
105
+ };
106
+ }
107
+
108
+ const src = (taskId, ideaId, authorId, extra = {}) => ({
109
+ task_id: String(taskId), idea_id: ideaId, captured_by: authorId,
110
+ grade: 'quick', completeness: null, developed_by: null, ...extra,
111
+ });
112
+
113
+ const ON = rewardPolicy({});
114
+ const BASE = credits.IDEA_TASK_SHIP_BASE;
115
+ const QUICK = credits.QUICK_IDEA_FACTOR;
116
+
117
+ // ---------------------------------------------------------------------------
118
+ // 0. BEFORE the port exists — existing behaviour, preserved
119
+ // ---------------------------------------------------------------------------
120
+
121
+ // FIRST, and it has to be: a port is single-provider and cannot be unregistered,
122
+ // so the only honest way to test the absent-provider path is to test it before
123
+ // anything registers one.
124
+ await test('with NO descent port, nothing is paid and nothing is asked of the database', async () => {
125
+ // An instance running without the ideas module still ships tasks, and its
126
+ // direct payout is untouched. This is what "existing first-hop behaviour
127
+ // preserved" means when the module is absent.
128
+ assert.equal(api.hasProvider('ideas.descent'), false, 'nothing has registered the port yet');
129
+ const c = stubClient({});
130
+ const r = await credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON });
131
+ assert.equal(r.credited, 0);
132
+ assert.equal(r.reason, 'no_descent_port');
133
+ assert.equal(c.log.length, 0, 'no query is issued at all');
134
+ });
135
+
136
+ registerStubDescentPort();
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // 1. the decay
140
+ // ---------------------------------------------------------------------------
141
+
142
+ await test('a task ONE hop below an idea pays it the halved share', async () => {
143
+ const c = stubClient({ 500: src(500, 7, 3) });
144
+ const r = await withDescentPort([{ id: 500, depth: 1, relation: 'split' }],
145
+ () => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON }));
146
+ assert.equal(r.credited, Math.round(BASE * QUICK * 0.5), '5 × 0.6 × 0.5 = 2 (rounded once)');
147
+ assert.equal(r.payments[0].depth, 1);
148
+ assert.equal(c.credited[0].builder_id, 3);
149
+ });
150
+
151
+ await test('each further hop halves again, and the rate is the DIAL not a constant', async () => {
152
+ const anc = [{ id: 500, depth: 1 }, { id: 501, depth: 2 }, { id: 502, depth: 3 }];
153
+ // FULL-grade origins at 100%, so all three hops still round to a credit and
154
+ // the halving is visible as a sequence rather than truncated by the floor.
155
+ const full = { grade: 'full', completeness: 100 };
156
+ const lineage = {
157
+ 500: src(500, 7, 3, full), 501: src(501, 8, 4, full), 502: src(502, 9, 5, full),
158
+ };
159
+
160
+ const half = stubClient(lineage);
161
+ const rHalf = await withDescentPort(anc,
162
+ () => credits.awardIdeaDescentShipCredit(half, { taskId: 900, config: ON }));
163
+ assert.deepEqual(rHalf.payments.map((p) => p.delta), [
164
+ Math.round(BASE * 0.5), Math.round(BASE * 0.25), Math.round(BASE * 0.125),
165
+ ], 'each hop is the one above it, halved');
166
+
167
+ // A project that wants a shallower descent to matter less says so, and the
168
+ // arithmetic follows the setting rather than a hard-coded half.
169
+ const steep = stubClient(lineage);
170
+ const rSteep = await withDescentPort(anc,
171
+ () => credits.awardIdeaDescentShipCredit(steep, { taskId: 900, config: rewardPolicy({ ideaDescentDecay: 0.1 }) }));
172
+ assert.ok(rSteep.credited < rHalf.credited, 'a steeper decay pays a descent less');
173
+ });
174
+
175
+ await test('a descendant stops paying when its share no longer ROUNDS to a credit', async () => {
176
+ // The floor the spec left open (Q1) is arithmetic, not a hop limit: at the
177
+ // default rate a quick idea's share is under half a credit by depth 4.
178
+ const deep = [];
179
+ const lineage = {};
180
+ for (let d = 1; d <= 8; d += 1) {
181
+ deep.push({ id: 600 + d, depth: d });
182
+ lineage[600 + d] = src(600 + d, 100 + d, 3);
183
+ }
184
+ const c = stubClient(lineage);
185
+ const r = await withDescentPort(deep,
186
+ () => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON }));
187
+ assert.ok(r.payments.length > 0 && r.payments.length < 8,
188
+ `the deep tail pays nothing and is not booked — paid ${r.payments.length} of 8`);
189
+ assert.ok(r.payments.every((p) => p.delta > 0), 'no zero-credit row is ever written');
190
+ const maxDepth = Math.max(...r.payments.map((p) => p.depth));
191
+ assert.ok(deep.some((a) => a.depth > maxDepth), 'and there were deeper ancestors it declined to pay');
192
+ });
193
+
194
+ await test('a descent of ANY depth pays, in total, no more than the direct task did', async () => {
195
+ // The property that makes a hop limit unnecessary (spec decision 6). Sum of
196
+ // (1/2)^d over every hop is 1, so even an unbounded chain of DISTINCT origins
197
+ // cannot, between them, take more out of one ship than the ship's own direct
198
+ // payout. Sixty-four hops, every one a different idea — the worst case.
199
+ const anc = [];
200
+ const lineage = {};
201
+ for (let d = 1; d <= 64; d += 1) {
202
+ anc.push({ id: 700 + d, depth: d });
203
+ lineage[700 + d] = src(700 + d, 200 + d, 3);
204
+ }
205
+ const c = stubClient(lineage);
206
+ const r = await withDescentPort(anc,
207
+ () => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON }));
208
+ const direct = Math.round(BASE * QUICK);
209
+ assert.ok(r.credited <= direct,
210
+ `the whole 64-hop descent paid ${r.credited}, which must not exceed the direct share ${direct}`);
211
+ assert.ok(r.payments.length < 5, 'and the deep tail is not even booked');
212
+ });
213
+
214
+ await test('the grade factor is the SAME one the direct payout applies', async () => {
215
+ // A full idea at 80% one hop down: 5 × 0.80 × 0.5. If the two paths kept
216
+ // separate copies of the clamp they could disagree, and the same idea would
217
+ // be worth different amounts depending on which hop shipped.
218
+ const c = stubClient({ 500: src(500, 7, 3, { grade: 'full', completeness: 80 }) });
219
+ const r = await withDescentPort([{ id: 500, depth: 1 }],
220
+ () => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON }));
221
+ assert.equal(r.credited, Math.round(BASE * 0.8 * 0.5), '5 × 0.80 × 0.5 = 2');
222
+ });
223
+
224
+ await test('a SPARK someone else developed splits the decayed share 70/30, like the direct one', async () => {
225
+ // R147 / ADR 0185: when someone other than the author developed the idea, both
226
+ // ideator streams split with them. The descent payout wires splitIdeaCredit up
227
+ // the same way, so the split has to be DRIVEN here — a fixture that always
228
+ // carries developed_by: null never enters that branch, and the branch that is
229
+ // never entered is the one that silently pays the wrong person.
230
+ const c = stubClient({ 500: src(500, 7, 3, { grade: 'full', completeness: 100, developed_by: 9 }) });
231
+ const r = await withDescentPort([{ id: 500, depth: 1 }],
232
+ () => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON }));
233
+ const delta = Math.round(BASE * 1 * 0.5);
234
+ assert.equal(r.credited, delta, 'the TOTAL is the decayed share — the split divides it, never inflates it');
235
+ assert.equal(c.credited.length, 2, 'two ledger rows, one per payee');
236
+ const byBuilder = Object.fromEntries(c.credited.map((x) => [String(x.builder_id), x.delta]));
237
+ assert.ok(byBuilder['9'] >= byBuilder['3'], 'the developer takes the larger 70% share');
238
+ assert.equal(byBuilder['9'] + byBuilder['3'], delta, 'and the two shares conserve the total');
239
+ assert.equal(new Set(c.credited.map((x) => x.reason)).size, 1,
240
+ 'both rows share ONE reason — which is why the dedup must be builder-scoped');
241
+ });
242
+
243
+ await test('a SELF-developed idea collapses to one payee at the decayed rate', async () => {
244
+ // splitIdeaCredit collapses this deliberately: two rows for one builder would
245
+ // UNDER-pay under the per-builder idempotency predicate, not double-pay.
246
+ const c = stubClient({ 500: src(500, 7, 3, { grade: 'full', completeness: 100, developed_by: 3 }) });
247
+ const r = await withDescentPort([{ id: 500, depth: 1 }],
248
+ () => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON }));
249
+ assert.equal(c.credited.length, 1, 'one payee, one row');
250
+ assert.equal(r.credited, Math.round(BASE * 1 * 0.5), 'paid the whole decayed share');
251
+ });
252
+
253
+ // ---------------------------------------------------------------------------
254
+ // 2. paid ONCE — the DAG cases
255
+ // ---------------------------------------------------------------------------
256
+
257
+ await test('one origin above a ship by TWO routes is paid ONCE, at the shorter', async () => {
258
+ // Ancestors 500 (depth 1) and 501 (depth 3) both trace to idea 7 — the shape a
259
+ // merge produces. Paying both would pay one origin twice for one ship.
260
+ const c = stubClient({ 500: src(500, 7, 3), 501: src(501, 7, 3) });
261
+ const r = await withDescentPort([{ id: 500, depth: 1 }, { id: 501, depth: 3 }],
262
+ () => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON }));
263
+ assert.equal(r.payments.length, 1, 'one payment, not two');
264
+ assert.equal(r.payments[0].depth, 1, 'at the SHORTEST depth of the two');
265
+ assert.equal(c.credited.length, 1, 'and one ledger row');
266
+ });
267
+
268
+ await test('the DIRECT payout wins over a decayed one for the same idea', async () => {
269
+ // Idea 7 is both this task's own source AND an ancestor's source. It must be
270
+ // paid the full direct share, not the full share plus a decayed one.
271
+ const c = stubClient({ 500: src(500, 7, 3) });
272
+ const r = await withDescentPort([{ id: 500, depth: 1 }],
273
+ () => credits.awardIdeaDescentShipCredit(c, { taskId: 900, skipIdeaIds: [7], config: ON }));
274
+ assert.equal(r.credited, 0);
275
+ assert.equal(r.payments.length, 0, 'the already-settled origin is skipped, not re-booked');
276
+ assert.equal(c.credited.length, 0);
277
+ });
278
+
279
+ await test('re-running the same ship never pays an origin twice', async () => {
280
+ const lineage = { 500: src(500, 7, 3), 501: src(501, 8, 4) };
281
+ const c = stubClient(lineage);
282
+ const anc = [{ id: 500, depth: 1 }, { id: 501, depth: 1 }];
283
+ const first = await withDescentPort(anc, () => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON }));
284
+ assert.ok(first.credited > 0);
285
+ const rows = c.credited.length;
286
+ const again = await withDescentPort(anc, () => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON }));
287
+ assert.equal(again.credited, 0, 'the second run books nothing');
288
+ assert.equal(again.reason, 'already_paid');
289
+ assert.equal(c.credited.length, rows, 'and writes no further ledger rows');
290
+ });
291
+
292
+ await test('the reason key is per (idea, task) — the SAME key the direct payout uses', async () => {
293
+ // Not per (idea, task, depth): if the depth rode in the key, re-walking a
294
+ // descent that had changed shape would pay the same origin again for one ship.
295
+ const c = stubClient({ 500: src(500, 7, 3) });
296
+ await withDescentPort([{ id: 500, depth: 2 }],
297
+ () => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON }));
298
+ assert.equal(c.credited[0].reason, 'idea.credit.task_ship:7:900');
299
+ assert.doesNotMatch(c.credited[0].reason, /:2$|depth/, 'no depth in the key');
300
+ });
301
+
302
+ // ---------------------------------------------------------------------------
303
+ // 3. the farming boundary — spec decision 7
304
+ // ---------------------------------------------------------------------------
305
+
306
+ await test('the payout reads no idea-to-idea relation — a citation cannot farm an origin', async () => {
307
+ // The descent SURFACE follows a merge (modules/ideas/trail-descent.js) so a
308
+ // merged thought still sees the work it caused. The PAYOUT must not, or filing
309
+ // a thought that cites an old one would mint credit from someone else's work.
310
+ const c = stubClient({ 500: src(500, 7, 3) });
311
+ await withDescentPort([{ id: 500, depth: 1 }],
312
+ () => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON }));
313
+ for (const q of c.log) {
314
+ assert.doesNotMatch(q.sql, /merged_into_idea_id/,
315
+ 'a merge is an idea-to-idea relation and must not reach the money');
316
+ }
317
+ const SRC = fs.readFileSync(path.join(ROOT, 'modules/economy/credits.js'), 'utf8');
318
+ const fn = SRC.slice(SRC.indexOf('async function awardIdeaDescentShipCredit'));
319
+ const body = fn.slice(0, fn.indexOf('\n}\n'));
320
+ assert.doesNotMatch(body, /merged_into_idea_id|attachDescentToTrail|trail-descent/,
321
+ 'nor may the function reach for the surface read that does follow merges');
322
+ });
323
+
324
+ await test('it walks the TASK edge table, not the idea lineage, to find its ancestors', async () => {
325
+ // The ancestors come from the port (ideas_task_descent, task→task); the
326
+ // lineage read only NAMES the idea behind each ancestor task. Reversing those
327
+ // two is how a citation graph would sneak in.
328
+ const SRC = fs.readFileSync(path.join(ROOT, 'modules/economy/credits.js'), 'utf8');
329
+ const fn = SRC.slice(SRC.indexOf('async function awardIdeaDescentShipCredit'));
330
+ const body = fn.slice(0, fn.indexOf('\n}\n'));
331
+ assert.match(body, /ancestorsOfTask/, 'the descent comes from the walk');
332
+ assert.match(body, /FROM ideas_task_lineage/, 'and the lineage read only resolves the origin');
333
+ });
334
+
335
+ // ---------------------------------------------------------------------------
336
+ // 4. existing behaviour preserved
337
+ // ---------------------------------------------------------------------------
338
+
339
+ await test('a task with NO ancestors pays nothing and issues no lineage read', async () => {
340
+ const c = stubClient({});
341
+ const r = await withDescentPort([], () => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON }));
342
+ assert.equal(r.credited, 0);
343
+ assert.equal(r.reason, 'no_ancestors');
344
+ assert.equal(c.log.length, 0);
345
+ });
346
+
347
+ await test('the ideator lane being OFF suppresses the descent payout too', async () => {
348
+ const c = stubClient({ 500: src(500, 7, 3) });
349
+ const r = await withDescentPort([{ id: 500, depth: 1 }],
350
+ () => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: { ideatorCredit: false } }));
351
+ assert.equal(r.credited, 0);
352
+ assert.equal(r.reason, 'ideator_lane_off');
353
+ assert.equal(c.log.length, 0, 'the lane gate is checked before the walk, not after it');
354
+ });
355
+
356
+ await test('a decay of 0 restores exactly the old first-hop-only behaviour', async () => {
357
+ const c = stubClient({ 500: src(500, 7, 3) });
358
+ const r = await withDescentPort([{ id: 500, depth: 1 }],
359
+ () => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: rewardPolicy({ ideaDescentDecay: 0 }) }));
360
+ assert.equal(r.credited, 0);
361
+ assert.equal(r.reason, 'decay_off');
362
+ assert.equal(c.log.length, 0, 'and it costs nothing — no walk, no read');
363
+ });
364
+
365
+ // ---------------------------------------------------------------------------
366
+ // 5. the dial, and the wiring
367
+ // ---------------------------------------------------------------------------
368
+
369
+ await test('the PORT METHOD re-applies the convergence bound, not just the resolver', async () => {
370
+ // awardIdeaDescentShipCredit is reachable through the reward port with a
371
+ // caller-supplied config that never passed through rewardPolicy(). A rate of
372
+ // 1.5 would make a deeper ancestor pay MORE than a nearer one — the exact
373
+ // property the comments promise cannot happen.
374
+ const c = stubClient({ 500: src(500, 7, 3), 501: src(501, 8, 4) });
375
+ const r = await withDescentPort([{ id: 500, depth: 1 }, { id: 501, depth: 2 }],
376
+ () => credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: { ideatorCredit: true, ideaDescentDecay: 1.5 } }));
377
+ const deltas = r.payments.map((p) => p.delta);
378
+ for (let i = 1; i < deltas.length; i += 1) {
379
+ assert.ok(deltas[i] <= deltas[i - 1], `a deeper hop may never pay more — got ${JSON.stringify(deltas)}`);
380
+ }
381
+ assert.ok(r.credited <= Math.round(BASE * QUICK), 'and the total stays bounded by the direct share');
382
+ });
383
+
384
+ await test('the walk is capped at the depth the money can actually reach', async () => {
385
+ // Past the depth where even a 100%-complete idea rounds to nothing, every
386
+ // further hop is a round trip that provably pays nobody. The cap is derived
387
+ // from the rate, so a steeper decay walks LESS, never more.
388
+ let askedFor = null;
389
+ const c = stubClient({ 500: src(500, 7, 3) });
390
+ ancestorsFixture = [{ id: 500, depth: 1 }];
391
+ const port = api.resolve('ideas.descent');
392
+ const realWalk = port.ancestorsOfTask;
393
+ port.ancestorsOfTask = async (id, opts) => { askedFor = opts && opts.maxDepth; return realWalk(id, opts); };
394
+ try {
395
+ await credits.awardIdeaDescentShipCredit(c, { taskId: 900, config: ON });
396
+ const atHalf = askedFor;
397
+ assert.ok(Number.isInteger(atHalf) && atHalf > 0 && atHalf < 64,
398
+ `the walk gets a derived cap, not the 64 backstop — got ${atHalf}`);
399
+ await credits.awardIdeaDescentShipCredit(c, { taskId: 901, config: rewardPolicy({ ideaDescentDecay: 0.1 }) });
400
+ assert.ok(askedFor < atHalf, `a steeper decay walks shallower — ${askedFor} vs ${atHalf}`);
401
+ } finally {
402
+ port.ancestorsOfTask = realWalk;
403
+ ancestorsFixture = [];
404
+ }
405
+ });
406
+
407
+ await test('the decay dial defaults to halving and refuses a non-converging rate', async () => {
408
+ assert.equal(rewardPolicy({}).ideaDescentDecay, IDEA_DESCENT_DECAY);
409
+ assert.equal(IDEA_DESCENT_DECAY, 0.5, 'spec decision 16: halving each hop');
410
+ assert.equal(rewardPolicy({ ideaDescentDecay: 0.25 }).ideaDescentDecay, 0.25, 'a project may set its own');
411
+ assert.equal(rewardPolicy({ ideaDescentDecay: 0 }).ideaDescentDecay, 0, '0 is legal — only the direct task pays');
412
+ for (const bad of [1, 1.5, -0.5, 'half', null, undefined, NaN]) {
413
+ assert.equal(rewardPolicy({ ideaDescentDecay: bad }).ideaDescentDecay, IDEA_DESCENT_DECAY,
414
+ `a rate of ${String(bad)} must fall back to the default, not poison an award`);
415
+ }
416
+ });
417
+
418
+ await test('the ship hook CALLS it, and hands it what the direct payout already settled', async () => {
419
+ // A payout nothing calls is dead code. The skipIdeaIds hand-off is what stops
420
+ // a direct source that is also an ancestor's source being paid twice, so the
421
+ // call site is pinned, not just the function.
422
+ const SHIP = fs.readFileSync(path.join(ROOT, 'modules/lifecycle/db-ship.js'), 'utf8');
423
+ // ANCHORED ON THE CALL, not on the name. /reward\.awardIdeaDescentShipCredit/
424
+ // on its own still matches `reward.awardIdeaDescentShipCreditXX` — a typo'd
425
+ // call that resolves to undefined, takes the guard's else branch and skips
426
+ // the payout forever. Reverting the wiring has to red this, and with the bare
427
+ // name it did not. The open paren is what makes it assert a CALL.
428
+ assert.match(SHIP, /await reward\.awardIdeaDescentShipCredit\(/,
429
+ 'the ship hook CALLS it through the reward port');
430
+ assert.match(SHIP, /skipIdeaIds:[\s\S]{0,120}ideaBonus[\s\S]{0,40}ideaId/,
431
+ 'and passes the idea the direct payout just settled');
432
+ const PORT = fs.readFileSync(path.join(ROOT, 'modules/economy/reward.js'), 'utf8');
433
+ assert.match(PORT, /awardIdeaDescentShipCredit: \(\.\.\.a\) => credits\.awardIdeaDescentShipCredit\(/,
434
+ 'the reward port publishes it, or the ship hook guard silently skips forever');
435
+ });
436
+
437
+ await test('ideas PROVIDES the port, and economy deliberately does NOT declare it as consumed', async () => {
438
+ // THE DECLARATION THAT WOULD BREAK BOOT, and the reason this is a test rather
439
+ // than a comment. `consumes` means REQUIRED: src/module-loader/loader.js
440
+ // verifyEnabledPorts THROWS and refuses to boot when an enabled module
441
+ // consumes a port no enabled module provides. The ideas module can be
442
+ // disabled; economy is default-on. So declaring ideas.descent in economy's
443
+ // consumes would turn "this instance does not use ideas" into "this instance
444
+ // does not start" — for a capability that is optional by construction and
445
+ // degrades to exactly today's first-hop payout.
446
+ //
447
+ // The next reader will see an undeclared port and want to tidy it up. This is
448
+ // the test that tells them why not.
449
+ const ideas = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/ideas/module.json'), 'utf8'));
450
+ const economy = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/economy/module.json'), 'utf8'));
451
+ assert.ok(ideas.provides.includes('ideas.descent'), 'ideas provides it');
452
+ assert.ok(!(economy.consumes || []).includes('ideas.descent'),
453
+ 'economy must NOT require it — an instance with the ideas module off still has to boot');
454
+
455
+ const SRC = fs.readFileSync(path.join(ROOT, 'modules/economy/credits.js'), 'utf8');
456
+ assert.match(SRC, /resolveOptional\('ideas\.descent'\)/,
457
+ 'which is only safe because it is resolved OPTIONALLY, never through resolve()');
458
+ assert.doesNotMatch(SRC, /[^t]resolve\('ideas\.descent'\)/,
459
+ 'a hard resolve would throw on the request path instead of degrading');
460
+
461
+ const ROUTES = fs.readFileSync(path.join(ROOT, 'modules/ideas/routes/inbox.js'), 'utf8');
462
+ assert.match(ROUTES, /registerProvider\('ideas\.descent'/, 'and something actually registers it');
463
+ assert.match(ROUTES, /hasProvider\('ideas\.descent'\)/,
464
+ 'guarded, or rebuilding the router trips the single-provider guard');
465
+ });
466
+
467
+ await test('the port publishes only names the walk actually exports', async () => {
468
+ // A guessed method name resolves to undefined and the payout silently pays
469
+ // nobody — the exact failure a port cannot surface at boot.
470
+ const descent = require('../modules/ideas/descent.js');
471
+ const ROUTES = fs.readFileSync(path.join(ROOT, 'modules/ideas/routes/inbox.js'), 'utf8');
472
+ const block = ROUTES.slice(ROUTES.indexOf("registerProvider('ideas.descent'"));
473
+ for (const name of [...block.slice(0, block.indexOf('}')).matchAll(/(\w+):\s*descent\.(\w+)/g)]) {
474
+ assert.equal(typeof descent[name[2]], 'function',
475
+ `descent.js must export ${name[2]} — the port publishes it`);
476
+ }
477
+ assert.equal(typeof descent.ancestorsOfTask, 'function', 'and the payout calls ancestorsOfTask by that name');
478
+ });
479
+
480
+ console.log(`\n${passed} passed, ${failed} failed`);
481
+ process.exit(failed === 0 ? 0 : 1);
@@ -107,7 +107,7 @@ test('every card is complete, and short enough to be read', () => {
107
107
 
108
108
  test('the restrictions that must never be worked around are marked by_design', () => {
109
109
  for (const code of ['REBASE_REQUIRED', 'INSUFFICIENT_RANK', 'rank_too_low_for_task', 'session_already_has_active_claim',
110
- 'main_checkout_claim', 'ship:protected_branch', 'ship:auth', 'box_scope', 'no_push_credential', 'grade_refused_authorization']) {
110
+ 'main_checkout_claim', 'ship:protected_branch', 'ship:auth', 'no_push_credential', 'grade_refused_authorization']) {
111
111
  assert.equal(cardFor(code)?.by_design, true, code);
112
112
  }
113
113
  assert.match(cardFor('no_push_credential').next, /never ask/i);
@@ -164,7 +164,7 @@ test('a person at a terminal gets no card — it is written in the agent\'s voic
164
164
 
165
165
  test('the sign-in printers, the release refusals and the claim guard each print their card', () => {
166
166
  const wired = {
167
- 'scripts/gds/cli-lib.js': ['session_invalid', 'box_scope', 'no_session'],
167
+ 'scripts/gds/cli-lib.js': ['session_invalid', 'no_session'],
168
168
  'scripts/gds/release.js': ['no_active_claim', 'CLAIM_SESSION_MISMATCH'],
169
169
  'scripts/gds/claim.js': ['main_checkout_claim'],
170
170
  'scripts/gds/ship-land.js': ['land_pending', 'reconcile_blocked'],
@@ -12,7 +12,7 @@
12
12
  // modules/discord/discord-channels.js): dependency-free substrate
13
13
  // (permission-path-check runs in a bare git hook), source-parsed literals
14
14
  // (src/bongos/db.js — gate-review.js + diagram-facts.js regex its text),
15
- // standalone CLIs (sequence, box-connect-lib), the require-free dev-box
15
+ // standalone CLIs (sequence), the require-free dev-box
16
16
  // module template (boxes.js), zero-dep catalog + its text-parsing auditor
17
17
  // (route-rank-check), and the hall browser statics.
18
18
  // 3. NO NEW parallel map can land: a repo scan flags any rank→number map or
@@ -38,7 +38,6 @@ const lifecycleDb = require(path.join(ROOT, 'modules', 'lifecycle', 'db.js'));
38
38
  const ppc = require(path.join(ROOT, 'src', 'bongos', 'permission-path-check.js'));
39
39
  const rrc = require(path.join(ROOT, 'src', 'bongos', 'route-rank-check.js'));
40
40
  const sequence = require(path.join(ROOT, 'scripts', 'gds', 'sequence.js'));
41
- const boxConnect = require(path.join(ROOT, 'scripts', 'gds', 'box-connect-lib.js'));
42
41
  const boxes = require(path.join(ROOT, 'modules', 'dev-box', 'boxes.js'));
43
42
  const catalog = require(path.join(ROOT, 'modules', 'government', 'catalog.js'));
44
43
 
@@ -82,8 +81,8 @@ test('1-based local copies match: permission-path-check, sequence CLI', () => {
82
81
  assert.deepEqual(sequence.RANK_TIER, TIER, 'sequence.js CLI copy drifted');
83
82
  });
84
83
 
85
- test('0-based local copies match: box-connect-lib CLI, dev-box boxes.js', () => {
86
- for (const [name, map] of [['box-connect-lib', boxConnect.RANK_ORDER], ['boxes', boxes.RANK_ORDER]]) {
84
+ test('0-based local copies match: dev-box boxes.js', () => {
85
+ for (const [name, map] of [['boxes', boxes.RANK_ORDER]]) {
87
86
  assert.deepEqual(Object.keys(map).sort(), [...RANK_KEYS].sort(), `${name} keys drifted`);
88
87
  for (const r of RANK_KEYS) {
89
88
  assert.equal(map[r], TIER[r] - 1, `${name}.RANK_ORDER.${r} must be tier-1 (0-based)`);
@@ -193,7 +192,6 @@ const MAP_ALLOWLIST = new Set([
193
192
  'src/bongos/db.js',
194
193
  'src/bongos/permission-path-check.js',
195
194
  'scripts/gds/sequence.js',
196
- 'scripts/gds/box-connect-lib.js',
197
195
  'modules/dev-box/boxes.js',
198
196
  'modules/hall-ui/public/builders.js',
199
197
  // task 1003206 carved the hall page scripts up. builders.js KEPT its map (the