@bongos/core 1.19.443 → 1.19.445

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 (33) hide show
  1. package/.bongos-core.json +120 -20
  2. package/docs/adr/0054-session-token-reward-cost-plus.md +31 -0
  3. package/docs/design/hall-direction-v2.md +295 -0
  4. package/docs/design/mocks/hall/_tokens.css +370 -0
  5. package/docs/design/mocks/hall/board/v1.html +846 -0
  6. package/docs/design/mocks/hall/board/v2.html +858 -0
  7. package/docs/design/mocks/hall/board/v3.html +884 -0
  8. package/docs/design/mocks/hall/goals/v1.html +1212 -0
  9. package/docs/design/mocks/hall/goals/v2.html +1192 -0
  10. package/docs/design/mocks/hall/goals/v3.html +1205 -0
  11. package/docs/design/mocks/hall/hall-sheet.html +244 -0
  12. package/docs/design/mocks/hall/reading/v1.html +1125 -0
  13. package/docs/design/mocks/hall/reading/v2.html +1177 -0
  14. package/docs/design/mocks/hall/reading/v3.html +1150 -0
  15. package/docs/design/mocks/hall/settings/v1.html +1238 -0
  16. package/docs/design/mocks/hall/settings/v2.html +1235 -0
  17. package/docs/design/mocks/hall/settings/v3.html +1299 -0
  18. package/docs/design/mocks/hall/task/v1.html +637 -0
  19. package/docs/design/mocks/hall/task/v2.html +646 -0
  20. package/docs/design/mocks/hall/task/v3.html +651 -0
  21. package/docs/design/vanilla-hall-ui-redesign-scope.md +2 -0
  22. package/docs/module-api-changelog.md +4 -0
  23. package/modules/economy/credits.js +46 -13
  24. package/package-lock.json +2 -2
  25. package/package.json +1 -1
  26. package/scripts/gds/backfill-subagent-rewards.js +485 -0
  27. package/scripts/gds/session-digest-build.js +49 -17
  28. package/scripts/gds/session-digest.js +11 -1
  29. package/scripts/gds/ship-session-upload.js +102 -12
  30. package/scripts/gds/ship.js +7 -1
  31. package/src/module-api.js +1 -1
  32. package/tests/backfill_subagent_rewards.mjs +308 -0
  33. package/tests/credit_grant.mjs +236 -0
@@ -87,6 +87,16 @@ const UNATTRIBUTED_MODEL = 'claude-opus-4-8';
87
87
  function buildSessionDigest(entries, opts = {}) {
88
88
  const extra = opts.extraSecrets || [];
89
89
  const MAX_TURNS = opts.maxTurns || 4000;
90
+ // usageOnly — build the token metrics but no `detail`. For the callers that
91
+ // want ONLY a transcript's model_usage (the subagents-subtree fold, task
92
+ // 1003435), where the per-turn rows would be discarded anyway and the snippet
93
+ // scrubbing is the expensive part. It exists so that fold can reuse THIS
94
+ // function — the one place the token semantics live — instead of a second copy
95
+ // that could drift, without paying for output nobody reads. The waste is worth
96
+ // avoiding on both upload paths, and on the SessionEnd one it is load-bearing:
97
+ // that hook runs under a 6-second watchdog, so a slow pass there doesn't just
98
+ // cost time, it drops the upload.
99
+ const usageOnly = opts.usageOnly === true;
90
100
  const seenMessageIds = new Set();
91
101
  const m = {
92
102
  assistant_turns: 0,
@@ -165,15 +175,17 @@ function buildSessionDigest(entries, opts = {}) {
165
175
  }
166
176
  }
167
177
  }
168
- const row = { i, role: 'assistant', ts, in: tin, out: tout, cache_read: tcr, cache_creation: tcc };
169
- // Marks a line that shares its API call with an earlier one, so a reader
170
- // can tell a genuinely-zero-usage turn from a de-duplicated repeat.
171
- if (dupUsage) row.dup = 1;
172
- if (tools.length) row.tools = tools;
173
- if (textLen) row.text_len = textLen;
174
- const snip = snippetOf(firstText, extra);
175
- if (snip) row.snippet = snip;
176
- detail.push(row);
178
+ if (!usageOnly) {
179
+ const row = { i, role: 'assistant', ts, in: tin, out: tout, cache_read: tcr, cache_creation: tcc };
180
+ // Marks a line that shares its API call with an earlier one, so a reader
181
+ // can tell a genuinely-zero-usage turn from a de-duplicated repeat.
182
+ if (dupUsage) row.dup = 1;
183
+ if (tools.length) row.tools = tools;
184
+ if (textLen) row.text_len = textLen;
185
+ const snip = snippetOf(firstText, extra);
186
+ if (snip) row.snippet = snip;
187
+ detail.push(row);
188
+ }
177
189
  i++;
178
190
  } else if (e.type === 'user' && msg) {
179
191
  m.user_turns++;
@@ -206,13 +218,15 @@ function buildSessionDigest(entries, opts = {}) {
206
218
  }
207
219
  }
208
220
  }
209
- const row = { i, role: 'user', ts };
210
- if (results) row.tool_results = results;
211
- if (errors) row.tool_errors = errors;
212
- if (harnessRetries) row.harness_retries = harnessRetries;
213
- const snip = snippetOf(firstResult, extra);
214
- if (snip) row.snippet = snip;
215
- detail.push(row);
221
+ if (!usageOnly) {
222
+ const row = { i, role: 'user', ts };
223
+ if (results) row.tool_results = results;
224
+ if (errors) row.tool_errors = errors;
225
+ if (harnessRetries) row.harness_retries = harnessRetries;
226
+ const snip = snippetOf(firstResult, extra);
227
+ if (snip) row.snippet = snip;
228
+ detail.push(row);
229
+ }
216
230
  i++;
217
231
  }
218
232
  }
@@ -258,4 +272,22 @@ function buildSessionDigest(entries, opts = {}) {
258
272
  detail: boundedDetail,
259
273
  };
260
274
  }
261
- module.exports = { snippetOf, buildSessionDigest, HARNESS_RETRY_RE, UNATTRIBUTED_MODEL };
275
+ // mergeModelUsage — fold one model_usage map into another, in place, so a
276
+ // session's tokens can be summed across the several transcripts one session
277
+ // actually writes (the interactive thread + its subagent subtree — task
278
+ // 1003435). PURE apart from mutating `target`, which the caller owns. The four
279
+ // buckets are the session_records model_usage shape, NOT the transcript
280
+ // *_input_tokens shape; keep them in step with buildSessionDigest above.
281
+ function mergeModelUsage(target, addition) {
282
+ for (const [model, u] of Object.entries(addition || {})) {
283
+ const bucket = target[model] || (target[model] =
284
+ { input: 0, output: 0, cache_read: 0, cache_creation: 0 });
285
+ bucket.input += Number(u && u.input) || 0;
286
+ bucket.output += Number(u && u.output) || 0;
287
+ bucket.cache_read += Number(u && u.cache_read) || 0;
288
+ bucket.cache_creation += Number(u && u.cache_creation) || 0;
289
+ }
290
+ return target;
291
+ }
292
+
293
+ module.exports = { snippetOf, buildSessionDigest, mergeModelUsage, HARNESS_RETRY_RE, UNATTRIBUTED_MODEL };
@@ -34,7 +34,7 @@ const fs = require('fs');
34
34
  const os = require('os');
35
35
  const path = require('path');
36
36
  const { cliClient, loadSession, arg } = require('./cli-lib');
37
- const { buildSessionDigest, collectEnvSecrets } = require('./ship');
37
+ const { buildSessionDigest, collectEnvSecrets, foldSubagentUsage } = require('./ship');
38
38
 
39
39
 
40
40
  // Claude Code encodes a project transcript dir by replacing every non-alnum
@@ -144,6 +144,10 @@ async function main() {
144
144
  if (session.token) extraSecrets.push(session.token);
145
145
 
146
146
  const digest = buildSessionDigest(parseTranscript(transcriptPath), { extraSecrets });
147
+ // task 1003435: fold this session's subagents subtree into model_usage, the
148
+ // same way the on-ship upload does — the whole session's tokens, not just the
149
+ // interactive thread's.
150
+ foldSubagentUsage(digest, transcriptPath, sessionId);
147
151
 
148
152
  // task_ids:[] — the server upsert UNIONs task_ids, so the on-ship task
149
153
  // attribution is preserved; this end-of-session upload's job is to refresh
@@ -157,6 +161,12 @@ async function main() {
157
161
  ended_at: digest.endedAt,
158
162
  detail: digest.detail,
159
163
  metrics: digest.metrics,
164
+ // model_usage is its OWN column, and the upsert overwrites it from the
165
+ // body — so omitting it here (as this hook did until task 1003435) wiped
166
+ // the map the on-ship upload had just stored, leaving the reward with
167
+ // nothing to price. It is the LAST upload of the session, so it must carry
168
+ // the fullest picture, not the emptiest.
169
+ model_usage: digest.metrics.model_usage || {},
160
170
  };
161
171
  const api = await cliClient({ allowUnauthenticated: true });
162
172
  const r = await api.sessions.postSessions({ body: payload });
@@ -43,9 +43,16 @@ const {
43
43
  // shared with session-digest.js. Re-exported below so this file's public surface
44
44
  // and tests/session_records.mjs are unchanged.
45
45
  const {
46
- snippetOf, buildSessionDigest, HARNESS_RETRY_RE, UNATTRIBUTED_MODEL,
46
+ snippetOf, buildSessionDigest, mergeModelUsage, HARNESS_RETRY_RE, UNATTRIBUTED_MODEL,
47
47
  } = require('./session-digest-build');
48
48
 
49
+ // The subagent subtree walker + the grader-transcript signature live in
50
+ // session-cost.js, which already prices that subtree for the SPEND ledger. The
51
+ // reward reads the same tokens, so it must use the SAME two definitions or the
52
+ // two ledgers describe different sessions (task 1003435). session-cost.js guards
53
+ // its own entry point behind require.main, so importing it is side-effect free.
54
+ const { listJsonlRecursive, looksLikeGraderTranscript } = require('./session-cost.js');
55
+
49
56
 
50
57
  // classifyUploadSkip — PURE decision for whether the session upload must skip,
51
58
  // and the clear, actionable reason why. Separated from I/O so it is unit-tested
@@ -109,6 +116,94 @@ function recordUploadSkip(taskId, { code, reason }, opts = {}) {
109
116
  }
110
117
  }
111
118
 
119
+ // parseJsonl — one transcript file's text into parsed entries, skipping any
120
+ // malformed line (best-effort, exactly like the rest of this path).
121
+ function parseJsonl(raw) {
122
+ const entries = [];
123
+ for (const line of String(raw || '').split('\n')) {
124
+ const t = line.trim();
125
+ if (!t) continue;
126
+ try {
127
+ entries.push(JSON.parse(t));
128
+ } catch (_) {
129
+ /* skip a malformed line — best-effort */
130
+ }
131
+ }
132
+ return entries;
133
+ }
134
+
135
+ // subagentModelUsage — the per-model TOKEN counts this session's ad-hoc
136
+ // subagents burned, so the cost-plus reward (ADR 0054) can see them.
137
+ //
138
+ // WHY (task 1003435): Agent/Task-tool spawns persist in a sibling subtree,
139
+ // <project-dir>/<session-id>/subagents/*.jsonl — never in the main transcript.
140
+ // session-cost.js already prices that subtree into a `session:subagent` cost_log
141
+ // row, so the SPEND ledger sees it; the digest read one file, so the REWARD did
142
+ // not. That paid a builder who follows CLAUDE.md's "delegate freely" less than
143
+ // one who did identical work in the main thread, and made the two ledgers
144
+ // disagree about the same session.
145
+ //
146
+ // Grader transcripts are excluded BY SIGNATURE, not by location: a grader run
147
+ // can nest inside a subagents/ dir, and its cost is already captured via the
148
+ // worker envelope, so pricing it here would double-count (the ADR 0028
149
+ // invariant). Each file goes through buildSessionDigest so the token semantics —
150
+ // dedup by message.id, skip `<synthetic>`, fold unlabeled turns into a priceable
151
+ // bucket — are the ONE implementation, never a second one that can drift.
152
+ function subagentModelUsage(sessionDir) {
153
+ const modelUsage = {};
154
+ let files = 0;
155
+ let tokens = 0;
156
+ let graderSkipped = 0;
157
+ for (const f of listJsonlRecursive(sessionDir)) {
158
+ if (looksLikeGraderTranscript(f)) { graderSkipped++; continue; }
159
+ let digest;
160
+ try {
161
+ digest = buildSessionDigest(parseJsonl(fs.readFileSync(f, 'utf8')), { usageOnly: true });
162
+ } catch (_) {
163
+ continue; // an unreadable subagent file must never fail a ship
164
+ }
165
+ if (!(digest.totalTokens > 0)) continue;
166
+ files++;
167
+ tokens += digest.totalTokens;
168
+ mergeModelUsage(modelUsage, digest.metrics.model_usage);
169
+ }
170
+ return { modelUsage, files, tokens, graderSkipped };
171
+ }
172
+
173
+ // foldSubagentUsage — add the session's subagent tokens to `digest`'s
174
+ // model_usage, IN PLACE, and record what was folded.
175
+ //
176
+ // model_usage ONLY, deliberately (task 1003435). model_usage is the reward's
177
+ // input, so it must describe the whole session. `detail`, `metrics.tokens`,
178
+ // `assistant_turns` and `total_tokens` stay the INTERACTIVE thread's record:
179
+ // they feed the BFG corpus + the inefficiency evaluator, which reason about one
180
+ // agent's turn-by-turn behaviour, and splicing N subagent transcripts into that
181
+ // per-turn story would distort the very signal it exists to measure (and bloat
182
+ // the payload). The three subagent_* counters are how a reader tells the two
183
+ // apart instead of inferring a discrepancy from a mismatch.
184
+ function foldSubagentUsage(digest, transcriptPath, sessionId) {
185
+ if (!digest || !digest.metrics || !transcriptPath || !sessionId) return digest;
186
+ // Path-traversal guard. The session id is interpolated into a filesystem path
187
+ // below, and what comes out of that path is priced into a payout — so a value
188
+ // carrying `../` could aim the walk at an arbitrary directory and fold
189
+ // fabricated token counts into the reward's input. Guarded HERE rather than at
190
+ // each call site: uploadSessionOnShip already screens the id before resolving
191
+ // the transcript, but the SessionEnd hook takes it straight off stdin, and any
192
+ // future caller would inherit the same hole. Same shape as the check in
193
+ // uploadSessionOnShip — a plain UUID-ish token, nothing else.
194
+ if (!/^[A-Za-z0-9_-]+$/.test(String(sessionId))) return digest;
195
+ const sessionDir = path.join(path.dirname(transcriptPath), sessionId);
196
+ const sub = subagentModelUsage(sessionDir);
197
+ if (sub.files === 0 && sub.graderSkipped === 0) return digest;
198
+ digest.metrics.model_usage = mergeModelUsage(
199
+ digest.metrics.model_usage || {}, sub.modelUsage
200
+ );
201
+ digest.metrics.subagent_files = sub.files;
202
+ digest.metrics.subagent_tokens = sub.tokens;
203
+ digest.metrics.subagent_grader_skipped = sub.graderSkipped;
204
+ return digest;
205
+ }
206
+
112
207
  async function uploadSessionOnShip(taskId) {
113
208
  try {
114
209
  const sessionId = process.env.CLAUDE_CODE_SESSION_ID;
@@ -135,17 +230,7 @@ async function uploadSessionOnShip(taskId) {
135
230
  recordUploadSkip(taskId, decision);
136
231
  return null;
137
232
  }
138
- const raw = fs.readFileSync(transcriptPath, 'utf8');
139
- const entries = [];
140
- for (const line of raw.split('\n')) {
141
- const t = line.trim();
142
- if (!t) continue;
143
- try {
144
- entries.push(JSON.parse(t));
145
- } catch (_) {
146
- /* skip a malformed line — best-effort */
147
- }
148
- }
233
+ const entries = parseJsonl(fs.readFileSync(transcriptPath, 'utf8'));
149
234
  // Dynamic secrets to scrub: every secret-shaped env VALUE (Apple/MAC_* CI
150
235
  // creds, a baked GDS/DO token, GITHUB_TOKEN, ANTHROPIC_API_KEY, …) plus this
151
236
  // run's live GDS session token. Defense in depth on top of the pattern +
@@ -157,6 +242,10 @@ async function uploadSessionOnShip(taskId) {
157
242
  const s = await loadSession();
158
243
  if (s && s.token) extraSecrets.push(s.token);
159
244
  const digest = buildSessionDigest(entries, { extraSecrets });
245
+ // task 1003435: the interactive transcript is only part of what this session
246
+ // spent — fold the subagents subtree into model_usage so the cost-plus reward
247
+ // prices the same tokens the cost ledger already bills.
248
+ foldSubagentUsage(digest, transcriptPath, sessionId);
160
249
  // task 1270: surface the session usage so the completion card can show
161
250
  // Claude tokens + an estimated cost. Returned even when the POST below fails
162
251
  // (we already have the digest) — the card is best-effort either way.
@@ -215,4 +304,5 @@ module.exports = {
215
304
  classifyUploadSkip,
216
305
  recordUploadSkip,
217
306
  uploadSessionOnShip,
307
+ foldSubagentUsage,
218
308
  };
@@ -99,7 +99,9 @@ const {
99
99
  resolveInstanceBase,
100
100
  shouldBailOnFetchFailure,
101
101
  } = require('./ship-deploy-target.js');
102
- const { classifyUploadSkip, recordUploadSkip } = require('./ship-session-upload.js');
102
+ const {
103
+ classifyUploadSkip, recordUploadSkip, foldSubagentUsage,
104
+ } = require('./ship-session-upload.js');
103
105
  const {
104
106
  classifyLandOutcome,
105
107
  formatPublishFailure,
@@ -176,6 +178,10 @@ module.exports = {
176
178
  // secret corpus test (tests/scrubber_corpus.mjs) can exercise each layer + tune.
177
179
  shannonEntropy, looksHighEntropySecret, redactHighEntropy, collectEnvSecrets,
178
180
  classifyUploadSkip, recordUploadSkip,
181
+ // task 1003435: the subagents-subtree fold, shared with the SessionEnd hook —
182
+ // both uploads must send the SAME whole-session model_usage or whichever runs
183
+ // last decides what the builder is paid for.
184
+ foldSubagentUsage,
179
185
  // #678: re-grade path + the grade-step helpers it shares with shipMain.
180
186
  // Exporting these formalizes learning #43 (which had to "replicate ~10
181
187
  // lines" because runSubagentGrader wasn't reachable).
package/src/module-api.js CHANGED
@@ -33,7 +33,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
33
33
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
34
34
  // the entry to that file. Look for a version's history there, not here.
35
35
  // ---------------------------------------------------------------------------
36
- const CORE_VERSION = '1.19.443'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
36
+ const CORE_VERSION = '1.19.445'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
37
37
 
38
38
  // A namespaced logger so a module's log lines are attributable + consistent.
39
39
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,308 @@
1
+ // tests/backfill_subagent_rewards.mjs
2
+ //
3
+ // Unit tests for the subagent cost-plus backfill planner (task 1003436, ADR 0054).
4
+ //
5
+ // This script decides who gets paid real credits, so the whole decision table is
6
+ // pure and pinned here with no DB: which sessions are payable, which are refused
7
+ // and WHY, and — the part that actually protects the ledger — that the classes
8
+ // which cannot be trusted (pre-de-dup-fix inflated rows, unrecoverable sessions,
9
+ // the ADR 0173 legacy class) never reach the mint.
10
+ //
11
+ // Run: node tests/backfill_subagent_rewards.mjs
12
+
13
+ import { strict as assert } from 'node:assert';
14
+ import { createRequire } from 'node:module';
15
+ import { makeRunner } from './helpers.mjs';
16
+
17
+ const require = createRequire(import.meta.url);
18
+ const {
19
+ CLASSES, DEDUP_FIX_AT, classifySession, buildBackfillPlan, renderReport,
20
+ } = require('../scripts/gds/backfill-subagent-rewards.js');
21
+ const {
22
+ computeSessionTokenReward, sessionTrueCostUsd, awardSessionTokenReward,
23
+ } = require('../modules/economy/credits.js');
24
+
25
+ const { test, summary } = makeRunner();
26
+
27
+ const AFTER = '2026-08-25T00:00:00Z'; // safely after the de-dup cutoff
28
+ const BEFORE = '2026-08-20T00:00:00Z'; // safely before it
29
+ const OPTS = { cutoffMs: Date.parse(DEDUP_FIX_AT), marginPct: 20, usdPeg: 1 };
30
+
31
+ // 1M opus input tokens = $5.00 interactive (PRICES.opus.input = $5/M).
32
+ const OPUS_1M = { 'claude-opus-4-8': { input: 1_000_000 } };
33
+
34
+ function row(over = {}) {
35
+ return {
36
+ session_id: 'sess-aaaaaaaa',
37
+ builder_id: 7,
38
+ model_usage: OPUS_1M,
39
+ total_tokens: 1_000_000,
40
+ subagent_usd: 3.0,
41
+ subagent_recorded_at: AFTER,
42
+ has_pre_split_cost_row: false,
43
+ has_folded_usage: false, // pre-1003435 digest: model_usage is interactive-only
44
+ paid: 6, // round(5.00 x 1.20) — what the subagent-blind reward paid
45
+ legacy_rows: 0,
46
+ ...over,
47
+ };
48
+ }
49
+
50
+ // =========================================================================
51
+ // classifySession — the decision table
52
+ // =========================================================================
53
+
54
+ await test('payable: owed is priced on interactive + subagent, minus what was paid', () => {
55
+ const r = classifySession(row(), OPTS);
56
+ assert.equal(r.klass, CLASSES.PAYABLE);
57
+ assert.equal(r.interactiveUsd, 5.0);
58
+ assert.equal(r.subagentUsd, 3.0);
59
+ assert.equal(r.trueCostUsd, 8.0);
60
+ assert.equal(r.owed, 10); // round(8.00 x 1.20) = 9.6 -> 10
61
+ assert.equal(r.paid, 6);
62
+ assert.equal(r.delta, 4);
63
+ });
64
+
65
+ await test('payable: the interactive side comes from the SAME function the reward books from', () => {
66
+ // If these ever diverge the backfill would silently re-price what was already
67
+ // paid instead of only adding the missing leg.
68
+ const r = classifySession(row(), OPTS);
69
+ const { trueCostUsd } = sessionTrueCostUsd(OPUS_1M, 1_000_000);
70
+ assert.equal(r.interactiveUsd, trueCostUsd);
71
+ assert.equal(r.owed, computeSessionTokenReward(trueCostUsd + 3.0, 20, 1).drachmae);
72
+ });
73
+
74
+ await test('up_to_date: a session already paid its full worth mints nothing', () => {
75
+ const r = classifySession(row({ paid: 10 }), OPTS);
76
+ assert.equal(r.klass, CLASSES.UP_TO_DATE);
77
+ assert.equal(r.delta, 0);
78
+ });
79
+
80
+ await test('overpaid: paid ABOVE its worth is reported, never clawed back', () => {
81
+ const r = classifySession(row({ paid: 25 }), OPTS);
82
+ assert.equal(r.klass, CLASSES.OVERPAID);
83
+ assert.equal(r.excess, 15);
84
+ assert.equal(r.delta, 0, 'a negative delta must never become a mint');
85
+ });
86
+
87
+ await test('pre_dedup_fix: an inflated cost row is NEVER paid from', () => {
88
+ // Rows written before task 1003217 price one API call once per transcript line
89
+ // (~2.22x). Paying from them would overpay, so they are refused by DATE.
90
+ const r = classifySession(row({ subagent_recorded_at: BEFORE }), OPTS);
91
+ assert.equal(r.klass, CLASSES.PRE_DEDUP_FIX);
92
+ assert.equal(r.delta, 0);
93
+ });
94
+
95
+ await test('--since moves the trust cutoff (a machine on an older core keeps writing inflated rows)', () => {
96
+ const late = { ...OPTS, cutoffMs: Date.parse('2026-08-28T00:00:00Z') };
97
+ assert.equal(classifySession(row(), late).klass, CLASSES.PRE_DEDUP_FIX);
98
+ assert.equal(classifySession(row(), OPTS).klass, CLASSES.PAYABLE);
99
+ });
100
+
101
+ await test('no_subagent_record: no cost row means unrecoverable — never guessed at', () => {
102
+ const r = classifySession(row({ subagent_usd: null, subagent_recorded_at: null }), OPTS);
103
+ assert.equal(r.klass, CLASSES.NO_SUBAGENT_RECORD);
104
+ assert.equal(r.delta, 0);
105
+ assert.equal(r.subagentUsd, undefined, 'no invented figure');
106
+ });
107
+
108
+ await test('pre_split: a legacy single `session` row lumps both legs and cannot be separated', () => {
109
+ const r = classifySession(
110
+ row({ subagent_usd: null, subagent_recorded_at: null, has_pre_split_cost_row: true }), OPTS
111
+ );
112
+ assert.equal(r.klass, CLASSES.PRE_SPLIT);
113
+ assert.equal(r.delta, 0);
114
+ });
115
+
116
+ // -------------------------------------------------------------------------
117
+ // The double-count guard. This is the one that would actually mint bad money:
118
+ // after task 1003435 the digest folds the subagents subtree INTO model_usage, so
119
+ // adding the cost_log subagent row on top pays the same leg twice.
120
+ // -------------------------------------------------------------------------
121
+
122
+ await test('already_folded: a post-1003435 digest already prices the subagent leg — never topped up', () => {
123
+ const r = classifySession(row({ has_folded_usage: true }), OPTS);
124
+ assert.equal(r.klass, CLASSES.ALREADY_FOLDED);
125
+ assert.equal(r.delta, 0, 'adding the cost row here would DOUBLE-COUNT and overpay');
126
+ });
127
+
128
+ await test('already_folded is decided BEFORE any money is computed', () => {
129
+ // A large subagent row must not be able to leak a figure into the plan via a
130
+ // field that later code reads.
131
+ const r = classifySession(row({ has_folded_usage: true, subagent_usd: 500 }), OPTS);
132
+ assert.equal(r.klass, CLASSES.ALREADY_FOLDED);
133
+ assert.equal(r.owed, undefined);
134
+ assert.equal(r.subagentUsd, undefined);
135
+ });
136
+
137
+ await test('the guard keys on the DATA, not a date — a re-uploaded old session is caught too', () => {
138
+ // A session that shipped before the fix but whose SessionEnd digest was
139
+ // re-uploaded after it has folded model_usage AND an old subagent cost row.
140
+ // The date would say "payable"; the counters say "already whole".
141
+ const r = classifySession(
142
+ row({ has_folded_usage: true, subagent_recorded_at: BEFORE }), OPTS
143
+ );
144
+ assert.equal(r.klass, CLASSES.ALREADY_FOLDED);
145
+ });
146
+
147
+ await test('a folded session never reaches the mint, and is reported', () => {
148
+ const plan = buildBackfillPlan([
149
+ row({ session_id: 's1' }), // +4
150
+ row({ session_id: 's2', has_folded_usage: true }), // excluded
151
+ ], { since: DEDUP_FIX_AT, marginPct: 20, usdPeg: 1 });
152
+ assert.equal(plan.grandTotal, 4, 'only the un-folded session is paid');
153
+ assert.equal(plan.counts[CLASSES.ALREADY_FOLDED], 1);
154
+ assert.match(renderReport(plan).text, /already_folded/);
155
+ });
156
+
157
+ await test('legacy: the reward_basis=0 class ADR 0173 declined is never folded in here', () => {
158
+ const r = classifySession(row({ legacy_rows: 2 }), OPTS);
159
+ assert.equal(r.klass, CLASSES.LEGACY);
160
+ assert.equal(r.delta, 0);
161
+ });
162
+
163
+ await test('legacy wins over payable — the class is decided before any money is computed', () => {
164
+ const r = classifySession(row({ legacy_rows: 1, subagent_usd: 100 }), OPTS);
165
+ assert.equal(r.klass, CLASSES.LEGACY);
166
+ assert.equal(r.owed, undefined);
167
+ });
168
+
169
+ // =========================================================================
170
+ // buildBackfillPlan — grouping + the grand total the owner signs off on
171
+ // =========================================================================
172
+
173
+ await test('the plan groups payables by builder and totals them', () => {
174
+ const plan = buildBackfillPlan([
175
+ row({ session_id: 's1', builder_id: 7 }), // +4
176
+ row({ session_id: 's2', builder_id: 7, subagent_usd: 10 }), // owed round(15x1.2)=18, -6 => +12
177
+ row({ session_id: 's3', builder_id: 9 }), // +4
178
+ row({ session_id: 's4', builder_id: 9, legacy_rows: 1 }), // not paid
179
+ row({ session_id: 's5', builder_id: 9, subagent_recorded_at: BEFORE }), // not paid
180
+ ], { since: DEDUP_FIX_AT, marginPct: 20, usdPeg: 1 });
181
+
182
+ assert.equal(plan.totalCandidates, 5);
183
+ assert.equal(plan.plans.length, 2);
184
+ assert.deepEqual(plan.plans.map((p) => [p.builderId, p.toMint]), [[7, 16], [9, 4]]);
185
+ assert.equal(plan.grandTotal, 20);
186
+ assert.equal(plan.counts[CLASSES.PAYABLE], 3);
187
+ assert.equal(plan.counts[CLASSES.LEGACY], 1);
188
+ assert.equal(plan.counts[CLASSES.PRE_DEDUP_FIX], 1);
189
+ });
190
+
191
+ await test('the plan reports what it does NOT settle (ADR 0173 objected to a silent partial)', () => {
192
+ const plan = buildBackfillPlan([
193
+ row({ session_id: 's1', subagent_usd: 7.5, subagent_recorded_at: BEFORE }),
194
+ row({ session_id: 's2', paid: 25 }),
195
+ ], { since: DEDUP_FIX_AT, marginPct: 20, usdPeg: 1 });
196
+ assert.equal(plan.unpaidSubagentUsd, 7.5, 'inflated-but-real spend is surfaced, not hidden');
197
+ assert.equal(plan.overpaidExcess, 15, 'correcting in one direction only is stated');
198
+ assert.equal(plan.grandTotal, 0);
199
+ });
200
+
201
+ await test('an empty ledger plans nothing and totals zero', () => {
202
+ const plan = buildBackfillPlan([], { since: DEDUP_FIX_AT, marginPct: 20, usdPeg: 1 });
203
+ assert.equal(plan.grandTotal, 0);
204
+ assert.equal(plan.plans.length, 0);
205
+ assert.match(renderReport(plan).text, /NOTHING PAYABLE/);
206
+ });
207
+
208
+ await test('the report leads with the grand total and names every refusal reason', () => {
209
+ const plan = buildBackfillPlan([row(), row({ session_id: 's2', legacy_rows: 1 })],
210
+ { since: DEDUP_FIX_AT, marginPct: 20, usdPeg: 1 });
211
+ const { text, total } = renderReport(plan, { loginFor: (id) => (id === 7 ? 'jaxri' : null) });
212
+ assert.equal(total, 4);
213
+ assert.match(text, /GRAND TOTAL to mint: 4 credits/);
214
+ assert.match(text, /jaxri: \+4/);
215
+ for (const k of [CLASSES.PRE_DEDUP_FIX, CLASSES.NO_SUBAGENT_RECORD, CLASSES.PRE_SPLIT, CLASSES.LEGACY]) {
216
+ assert.ok(text.includes(k), `the report must name the ${k} class`);
217
+ }
218
+ });
219
+
220
+ // =========================================================================
221
+ // The mint path — idempotency + the forward-path invariant.
222
+ //
223
+ // These are the properties that keep a backfill from corrupting the ledger, so
224
+ // they are asserted against the REAL writer with a fake client, not described.
225
+ // =========================================================================
226
+
227
+ function fakeClient(ledgerRows) {
228
+ const queries = [];
229
+ const responses = [...ledgerRows];
230
+ return {
231
+ queries,
232
+ async query(sql, params) {
233
+ queries.push({ sql, params });
234
+ return { rows: responses.shift() || [] };
235
+ },
236
+ };
237
+ }
238
+ const ledger = (paid = 0, legacy = 0) => [{ paid, legacy }];
239
+
240
+ await test('the mint goes through the real reward writer, so extraCostUsd raises the basis', async () => {
241
+ const client = fakeClient([ledger(6, 0), [{ id: 1 }]]);
242
+ const res = await awardSessionTokenReward(client, 7, 'sess-aaaaaaaa', OPUS_1M, 1_000_000, 3.0);
243
+ assert.equal(res.awarded, true);
244
+ assert.equal(res.trueCostUsd, 8.0, 'interactive $5 + recovered subagent $3');
245
+ assert.equal(res.drachmae, 4, 'books only the INCREMENT over what was paid');
246
+ const insert = client.queries[1];
247
+ assert.match(insert.sql, /session\.token_reward/);
248
+ assert.match(insert.sql, /ON CONFLICT \(session_id, reward_basis\)/);
249
+ // reward_basis is the CUMULATIVE owed total — the last positional.
250
+ assert.equal(insert.params[insert.params.length - 1], 10);
251
+ assert.equal(insert.params[1], 4, 'delta booked = owed - paid');
252
+ });
253
+
254
+ await test('re-running the backfill is a no-op — the watermark, not a private reason key', async () => {
255
+ // Second run: the ledger now shows the topped-up total, so owed - paid = 0.
256
+ const client = fakeClient([ledger(10, 0)]);
257
+ const res = await awardSessionTokenReward(client, 7, 'sess-aaaaaaaa', OPUS_1M, 1_000_000, 3.0);
258
+ assert.equal(res.awarded, false);
259
+ assert.equal(res.drachmae, 0);
260
+ assert.equal(res.settled, 'up_to_date');
261
+ assert.equal(client.queries.length, 1, 'SELECT only — no second INSERT');
262
+ });
263
+
264
+ await test('FORWARD PATH: a later ship in a backfilled session books only its own increment', async () => {
265
+ // The reason the backfill is written under the NORMAL reason rather than its
266
+ // own key: the watermark reads reason='session.token_reward', so a private key
267
+ // would be invisible to it and the next ship would pay the subagent leg AGAIN.
268
+ // Here the next ship uploads a digest whose model_usage now INCLUDES the
269
+ // subagent tokens (task 1003435) — $8 of usage, already backfilled to 10.
270
+ const eight = { 'claude-opus-4-8': { input: 1_600_000 } }; // 1.6M x $5/M = $8.00
271
+ const client = fakeClient([ledger(10, 0)]);
272
+ const res = await awardSessionTokenReward(client, 7, 'sess-aaaaaaaa', eight, 1_600_000);
273
+ assert.equal(res.trueCostUsd, 8.0);
274
+ assert.equal(res.awarded, false, 'must NOT re-pay the leg the backfill settled');
275
+ assert.equal(client.queries.length, 1);
276
+ });
277
+
278
+ await test('a backfill can never clawback: a smaller basis books nothing, never a negative row', async () => {
279
+ const client = fakeClient([ledger(99, 0)]);
280
+ const res = await awardSessionTokenReward(client, 7, 'sess-aaaaaaaa', OPUS_1M, 1_000_000, 3.0);
281
+ assert.equal(res.awarded, false);
282
+ assert.equal(res.drachmae, 0);
283
+ assert.equal(client.queries.length, 1, 'no INSERT — the ledger is append-only');
284
+ });
285
+
286
+ await test('the legacy refusal still bites at the writer, not only in the planner', async () => {
287
+ const client = fakeClient([ledger(3, 1)]); // one reward_basis=0 row
288
+ const res = await awardSessionTokenReward(client, 7, 'sess-legacy', OPUS_1M, 1_000_000, 3.0);
289
+ assert.equal(res.awarded, false);
290
+ assert.equal(res.settled, 'legacy', 'ADR 0173 class is refused twice over');
291
+ });
292
+
293
+ await test('extraCostUsd defaults to 0, so every pre-existing call site is unchanged', async () => {
294
+ const client = fakeClient([ledger(0, 0), [{ id: 9 }]]);
295
+ const res = await awardSessionTokenReward(client, 7, 'sess-plain', OPUS_1M, 1_000_000);
296
+ assert.equal(res.trueCostUsd, 5.0);
297
+ assert.equal(res.drachmae, 6);
298
+ });
299
+
300
+ await test('a negative, infinite or garbage extraCostUsd cannot inflate a payout', async () => {
301
+ for (const bad of [-100, NaN, Infinity, -Infinity, 'lots', null, undefined]) {
302
+ const client = fakeClient([ledger(0, 0), [{ id: 1 }]]);
303
+ const res = await awardSessionTokenReward(client, 7, 'sess-x', OPUS_1M, 1_000_000, bad);
304
+ assert.equal(res.trueCostUsd, 5.0, `extraCostUsd ${String(bad)} must be treated as 0`);
305
+ }
306
+ });
307
+
308
+ summary();