@bongos/core 1.19.154 → 1.19.155

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.154",
6
- "core_contract": "1.19.154",
7
- "source_commit": "fbe11785041864be4c61ec8bb98507574cdd5f10",
5
+ "core_version": "1.19.155",
6
+ "core_contract": "1.19.155",
7
+ "source_commit": "64b481f3953b847b3950e606203102e1d130fbc9",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-16T15:55:19.456Z",
9
+ "built_at": "2026-08-16T16:05:24.376Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 304,
13
13
  "agent_docs_stubbed": 21,
14
- "functional_verbatim": 1254,
14
+ "functional_verbatim": 1255,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1579,
20
- "tree_sha256": "b118dbf9bacb9695248f0821386defeac7d1498742efe7e907383359eb1f070b",
19
+ "file_count": 1580,
20
+ "tree_sha256": "6da1e49fcc1c83ee83beaf54277f2ab817dba9dea35185de47e0528a4e9e53aa",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -4107,7 +4107,7 @@
4107
4107
  {
4108
4108
  "path": "modules/sessions/db.js",
4109
4109
  "mode": "0000644",
4110
- "sha256": "fde41184b9f82f51dc5fff5df081d3435d296c3132fb619183996c250d621692"
4110
+ "sha256": "65cb78d4a3e0507df5d2b37ac99cdb3d762b98de5c34996f64c6de7ad38dc71a"
4111
4111
  },
4112
4112
  {
4113
4113
  "path": "modules/sessions/module.json",
@@ -4117,7 +4117,7 @@
4117
4117
  {
4118
4118
  "path": "modules/sessions/pollers/session-reward-reconcile.js",
4119
4119
  "mode": "0000644",
4120
- "sha256": "8d08704aa90f14c8f1a653bc7dc252ddca983dc309f7fa0e190070878a3856e3"
4120
+ "sha256": "32942d1169776ff1b2a32b85269c285f52167a6f3a7add7e38f74f108a9f3f42"
4121
4121
  },
4122
4122
  {
4123
4123
  "path": "modules/sessions/pulse.js",
@@ -4187,12 +4187,12 @@
4187
4187
  {
4188
4188
  "path": "package-lock.json",
4189
4189
  "mode": "0000644",
4190
- "sha256": "9f78c537cd7c7914c01ae8fd732fa039c2c749e9ee7f4cf3395d6ce71d0070c8"
4190
+ "sha256": "356b00e692fa069d4f14d94317d0c126ef5c35879491be3fb41ea7c8536d3059"
4191
4191
  },
4192
4192
  {
4193
4193
  "path": "package.json",
4194
4194
  "mode": "0000644",
4195
- "sha256": "d5a00856ea454cdbf92b96b9a2d536272b3dd0481cdf8ecdb92c6e5bff9ec6eb"
4195
+ "sha256": "62448d273d09db942d8c9fa123c8a6238070ca9bf754a6ecf0ea664d2c170df5"
4196
4196
  },
4197
4197
  {
4198
4198
  "path": "public-docs/index.html",
@@ -5782,7 +5782,7 @@
5782
5782
  {
5783
5783
  "path": "src/module-api.js",
5784
5784
  "mode": "0000644",
5785
- "sha256": "0ab78138a36bd9d516a9dd8e3fed336297b4c87387fa6a2254811d5286d938ab"
5785
+ "sha256": "bc766f15de530f14ccf76f0b280e58f4ec594efebea6570744012fc5483b98d4"
5786
5786
  },
5787
5787
  {
5788
5788
  "path": "src/module-loader/catalog.js",
@@ -7579,6 +7579,11 @@
7579
7579
  "mode": "0000644",
7580
7580
  "sha256": "921c24a356d0fc1f398563bab3e8bb6f0831919a6954d86b6d74fdc60ff3b5f9"
7581
7581
  },
7582
+ {
7583
+ "path": "tests/session_attach_from_claim.mjs",
7584
+ "mode": "0000644",
7585
+ "sha256": "e76a3baf5fb2cdee08ffe8d80c55630b5ab4f708046f3c63c07c480926ccda31"
7586
+ },
7582
7587
  {
7583
7588
  "path": "tests/session_caps.mjs",
7584
7589
  "mode": "0000644",
@@ -7612,7 +7617,7 @@
7612
7617
  {
7613
7618
  "path": "tests/session_reward_on_land.mjs",
7614
7619
  "mode": "0000644",
7615
- "sha256": "62d0b356029aca4ea307957951aba7ec677598c152ce68caee5001c6c36f9323"
7620
+ "sha256": "52be7bd7afd17f7f7d1c93efb9be32902d08062cd65f35e3e3709a0e3669050f"
7616
7621
  },
7617
7622
  {
7618
7623
  "path": "tests/session_reward_reconcile.mjs",
@@ -191,6 +191,67 @@ async function upsertSessionRecordWithReward(opts) {
191
191
  });
192
192
  }
193
193
 
194
+ // attachClaimSessionsToTaskTx — SERVER-SIDE task↔session attribution (task
195
+ // 1002964). Every payout path keys on session_records.task_ids, and until this
196
+ // existed exactly ONE thing ever populated that column: ship.js's client-side
197
+ // upload (`task_ids: [taskId]`). When that upload skipped — no
198
+ // CLAUDE_CODE_SESSION_ID, an unsafe id, or a transcript the client couldn't find
199
+ // (classifyUploadSkip, scripts/gds/ship.js) — the session was left with an EMPTY
200
+ // task_ids, which matches NOTHING in awardDeferredSessionRewardForTask,
201
+ // reconcileUnbookedSessionRewards, or the upload-side gate. The reward then
202
+ // stranded PERMANENTLY, not late: even the state-based safety net could not see
203
+ // the row. Builder IAMMASTERQUA hit exactly this — 19 sessions, 19 empty, ~$730
204
+ // of recorded session cost, zero paid, for ten weeks, with the only diagnostic
205
+ // (recordUploadSkip's trace) written to their own laptop.
206
+ //
207
+ // The server does not need the client's help here: a claim already records WHO
208
+ // shipped WHAT and WHEN. So we recover the link from durable state — the
209
+ // builder's session_records whose activity window overlaps the claim window.
210
+ //
211
+ // Design notes:
212
+ // • OVERLAP, not containment: a session that was already running when the
213
+ // claim opened (or outlived it) still did that work. started_at/ended_at
214
+ // fall back to uploaded_at when the transcript carried no span.
215
+ // • OVER-ATTACHMENT IS FINANCIALLY SAFE. The reward is idempotent per
216
+ // session_id (the watermark SELECT + uniq_credit_session_token_reward_basis),
217
+ // so linking one session to several tasks pays it ONCE, never per task. The
218
+ // bounded downside is a longer task_ids list, not double-pay.
219
+ // • Bounded by `limit` so a pathological long-running session can't fan out
220
+ // an unbounded UPDATE.
221
+ // • Does NOT touch the landing gate. It only answers "which session did this
222
+ // task come from"; whether the task LANDED is still checked by every caller
223
+ // (ADR 0120 — never pay for code that didn't land).
224
+ // Returns { attached } (rows freshly linked).
225
+ async function attachClaimSessionsToTaskTx(client, taskId, { limit = 50 } = {}) {
226
+ const id = Number(taskId);
227
+ if (!Number.isFinite(id)) return { attached: 0 };
228
+ const n = Number(limit);
229
+ const cap = Number.isFinite(n) ? Math.max(1, Math.min(500, Math.trunc(n))) : 50;
230
+ const { rows } = await client.query(
231
+ `WITH cand AS (
232
+ SELECT DISTINCT sr.id
233
+ FROM claims c
234
+ JOIN session_records sr ON sr.builder_id = c.builder_id
235
+ WHERE c.task_id = $1
236
+ AND NOT (sr.task_ids @> ARRAY[$1]::bigint[])
237
+ AND COALESCE(sr.started_at, sr.uploaded_at) <= COALESCE(c.released_at, now())
238
+ AND COALESCE(sr.ended_at, sr.uploaded_at) >= c.claimed_at
239
+ ORDER BY sr.id
240
+ LIMIT $2
241
+ )
242
+ UPDATE session_records sr
243
+ SET task_ids = (
244
+ SELECT COALESCE(array_agg(DISTINCT e ORDER BY e), '{}')
245
+ FROM unnest(sr.task_ids || ARRAY[$1]::bigint[]) AS e
246
+ )
247
+ FROM cand
248
+ WHERE cand.id = sr.id
249
+ RETURNING sr.session_id`,
250
+ [id, cap]
251
+ );
252
+ return { attached: rows.length };
253
+ }
254
+
194
255
  // awardDeferredSessionRewardForTask — the LAND-side half of ADR 0120 "pay on
195
256
  // land". When a task reaches status='shipped', routes/sessions.js's task.shipped
196
257
  // listener calls this with the shipped task id. It finds every session_record
@@ -198,16 +259,24 @@ async function upsertSessionRecordWithReward(opts) {
198
259
  // ci/async path, where the digest was uploaded (and DEFERRED) before the code
199
260
  // landed. Opens its own transaction.
200
261
  //
262
+ // It first runs attachClaimSessionsToTaskTx (task 1002964) so a session whose
263
+ // client-side upload skipped is still found: before that, an unsent task_ids
264
+ // made the reward unreachable by every path, permanently.
265
+ //
201
266
  // We do NOT pre-filter on token_reward_drachmae: awardSessionTokenReward is
202
267
  // idempotent per session_id, so a session that already collected at upload time
203
268
  // (sync mode) — or a re-fired event — is a clean no-op. The credit_log is the
204
269
  // single source of truth for "already paid"; the mirror column is only
205
- // observability, so it is never the idempotency key. Returns { sessions, awarded }
206
- // (rows scanned, rewards freshly booked).
270
+ // observability, so it is never the idempotency key.
271
+ // Returns { sessions, awarded, attached } (rows scanned, rewards freshly booked,
272
+ // links freshly recovered).
207
273
  async function awardDeferredSessionRewardForTask(taskId) {
208
274
  const id = Number(taskId);
209
- if (!Number.isFinite(id)) return { sessions: 0, awarded: 0 };
275
+ if (!Number.isFinite(id)) return { sessions: 0, awarded: 0, attached: 0 };
210
276
  return api.withTx(async (client) => {
277
+ // Recover any link the client-side upload failed to send BEFORE looking for
278
+ // payable sessions — otherwise a skipped upload is unpayable forever.
279
+ const { attached } = await attachClaimSessionsToTaskTx(client, id);
211
280
  const { rows } = await client.query(
212
281
  `SELECT session_id, builder_id, model_usage, total_tokens
213
282
  FROM session_records
@@ -224,7 +293,7 @@ async function awardDeferredSessionRewardForTask(taskId) {
224
293
  });
225
294
  if (tokenReward && tokenReward.awarded && tokenReward.drachmae > 0) awarded += 1;
226
295
  }
227
- return { sessions: rows.length, awarded };
296
+ return { sessions: rows.length, awarded, attached };
228
297
  });
229
298
  }
230
299
 
@@ -287,6 +356,64 @@ async function reconcileUnbookedSessionRewards({ limit = 200 } = {}) {
287
356
  });
288
357
  }
289
358
 
359
+ // reconcileUnattachedClaimSessions — the ATTRIBUTION half of the safety net
360
+ // (task 1002964), and the reason reconcileUnbookedSessionRewards alone was not
361
+ // enough. That sweep starts from session_records.task_ids, so a session the
362
+ // client never linked is invisible to it — the very rows most in need of rescue
363
+ // are the ones it cannot see. This sweep runs FIRST and comes at it from the
364
+ // other side: start from LANDED tasks, walk their claims, and link the claiming
365
+ // builder's overlapping sessions. Once linked, the existing sweep pays them.
366
+ //
367
+ // Ordering matters: the poller calls this, then reconcileUnbookedSessionRewards.
368
+ // Same idempotency story as attachClaimSessionsToTaskTx — a session already
369
+ // carrying the task is skipped by the NOT (task_ids @> …) guard, and the payout
370
+ // stays exactly-once per session regardless of how many tasks it links to.
371
+ // Strictly pay-on-land: only tasks at status='shipped' are considered.
372
+ //
373
+ // ⚠️ LOOKBACK WINDOW — this is a SAFETY NET, NOT A BACKFILL. The reward sweep
374
+ // runs immediately after this one, so every task linked here becomes payable on
375
+ // the same tick. Unbounded, the first run would retro-link the ENTIRE history
376
+ // (measured on cloudbongos.com 2026-08-16: 1383 tasks / 1157 sessions for one
377
+ // builder alone) and mint a mass retroactive payout nobody authorised. Deliberate
378
+ // historical back-pay is an Archon decision made with eyes open via
379
+ // scripts/gds/backfill-task-rewards.js (dry-run by default, reviewed amounts) —
380
+ // never a side effect of a poller starting up. So we only consider tasks that
381
+ // landed inside the window: recent enough that a live client-side upload skip is
382
+ // what we are recovering from. Widen it deliberately, never casually.
383
+ // Returns { scanned, attached }.
384
+ const ATTACH_LOOKBACK_DAYS = Number(process.env.SESSION_ATTACH_LOOKBACK_DAYS) || 7;
385
+
386
+ async function reconcileUnattachedClaimSessions({ limit = 200, lookbackDays } = {}) {
387
+ const n = Number(limit);
388
+ const cap = Number.isFinite(n) ? Math.max(1, Math.min(1000, Math.trunc(n))) : 200;
389
+ const d = Number(lookbackDays ?? ATTACH_LOOKBACK_DAYS);
390
+ const days = Number.isFinite(d) ? Math.max(1, Math.min(365, Math.trunc(d))) : 7;
391
+ return api.withTx(async (client) => {
392
+ // Candidate LANDED tasks whose claiming builder has at least one overlapping
393
+ // session that is not yet linked. Bounded, newest first — a backlog drains
394
+ // over successive sweeps rather than in one unbounded UPDATE.
395
+ const { rows } = await client.query(
396
+ `SELECT DISTINCT c.task_id
397
+ FROM claims c
398
+ JOIN tasks t ON t.id = c.task_id AND t.status = 'shipped'
399
+ JOIN session_records sr ON sr.builder_id = c.builder_id
400
+ WHERE NOT (sr.task_ids @> ARRAY[c.task_id]::bigint[])
401
+ AND COALESCE(t.shipped_at, t.updated_at) >= now() - ($2 || ' days')::interval
402
+ AND COALESCE(sr.started_at, sr.uploaded_at) <= COALESCE(c.released_at, now())
403
+ AND COALESCE(sr.ended_at, sr.uploaded_at) >= c.claimed_at
404
+ ORDER BY c.task_id DESC
405
+ LIMIT $1`,
406
+ [cap, String(days)]
407
+ );
408
+ let attached = 0;
409
+ for (const r of rows) {
410
+ const res = await attachClaimSessionsToTaskTx(client, r.task_id);
411
+ attached += res.attached;
412
+ }
413
+ return { scanned: rows.length, attached };
414
+ });
415
+ }
416
+
290
417
  // ---- 6D.2: session search + fetch (read side, ADR 0027) -------------------
291
418
 
292
419
  // Safe ORDER BY whitelist — the ONLY value interpolated into the search SQL, so
@@ -459,6 +586,8 @@ module.exports = {
459
586
  upsertSessionRecordWithReward, // route entry point — upsert + token reward (task 1005)
460
587
  awardDeferredSessionRewardForTask, // ADR 0120 land-side trigger — task.shipped listener calls it
461
588
  reconcileUnbookedSessionRewards, // ADR 0120 state-based safety net — books rewards for out-of-band ships
589
+ attachClaimSessionsToTaskTx, // task 1002964 — server-side task↔session link, recovered from the claim
590
+ reconcileUnattachedClaimSessions, // task 1002964 — attribution sweep; must run BEFORE the reward sweep
462
591
  // search + fetch (6D.2)
463
592
  searchSessions,
464
593
  summarizeSessions,
@@ -29,13 +29,25 @@ let intervalTimer = null;
29
29
  let firstTimer = null;
30
30
 
31
31
  async function runSweep() {
32
+ // ATTRIBUTION FIRST (task 1002964). reconcileUnbookedSessionRewards starts from
33
+ // session_records.task_ids, so a session whose client-side upload skipped — and
34
+ // which therefore carries an EMPTY task_ids — is invisible to it. Those are
35
+ // precisely the stranded rows worth rescuing, so recover the task↔session link
36
+ // from the claim before looking for unbooked rewards. Both halves are
37
+ // idempotent, so a re-sweep neither re-links nor double-pays.
38
+ const attach = await db.reconcileUnattachedClaimSessions({ limit: 200 });
39
+ if (attach && attach.attached > 0) {
40
+ console.log(
41
+ `[session-reward-reconcile] linked ${attach.attached} session(s) to landed tasks from claim state (scanned ${attach.scanned})`
42
+ );
43
+ }
32
44
  const res = await db.reconcileUnbookedSessionRewards({ limit: 200 });
33
45
  if (res && res.awarded > 0) {
34
46
  console.log(
35
47
  `[session-reward-reconcile] booked ${res.awarded} stranded session reward(s) (scanned ${res.scanned})`
36
48
  );
37
49
  }
38
- return res;
50
+ return { ...res, attached: attach ? attach.attached : 0 };
39
51
  }
40
52
 
41
53
  // Start the periodic sweep. Idempotent (a second call is a no-op). Kill-switched by
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.154",
3
+ "version": "1.19.155",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.154",
9
+ "version": "1.19.155",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.154",
3
+ "version": "1.19.155",
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",
package/src/module-api.js CHANGED
@@ -735,8 +735,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
735
735
  // landed since 1.19.152 with no explicit bump. run 31956560292. (task 1002620)
736
736
  // 1.19.154 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
737
737
  // landed since 1.19.153 with no explicit bump. run 31957052737. (task 1002620)
738
+ // 1.19.155 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
739
+ // landed since 1.19.154 with no explicit bump. run 31957556550. (task 1002620)
738
740
  // ---------------------------------------------------------------------------
739
- const CORE_VERSION = '1.19.154'; // CI auto-patch carrier (ADR 0161); see changelog above
741
+ const CORE_VERSION = '1.19.155'; // CI auto-patch carrier (ADR 0161); see changelog above
740
742
 
741
743
  // A namespaced logger so a module's log lines are attributable + consistent.
742
744
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,188 @@
1
+ // tests/session_attach_from_claim.mjs
2
+ //
3
+ // Regression guard for task 1002964 — SERVER-SIDE task↔session attribution.
4
+ //
5
+ // THE BUG. Every payout path keys on session_records.task_ids, and until this
6
+ // fix exactly ONE thing populated that column: ship.js's client-side upload.
7
+ // When that upload skipped (classifyUploadSkip: no_session_id /
8
+ // unsafe_session_id / transcript_not_found) the session was left with an EMPTY
9
+ // task_ids — which matches NOTHING in the upload-side gate,
10
+ // awardDeferredSessionRewardForTask, or reconcileUnbookedSessionRewards. The
11
+ // reward stranded PERMANENTLY, invisible even to the state-based safety net.
12
+ // Builder IAMMASTERQUA hit exactly this: 19 sessions, 19 empty, ~$730 of
13
+ // recorded session cost, zero paid, for ten weeks.
14
+ //
15
+ // THE FIX. The server recovers the link from durable claim state — the claiming
16
+ // builder's session_records whose activity window overlaps the claim window —
17
+ // so a client-side skip can no longer zero a builder's pay. What this must NOT
18
+ // do is loosen the landing gate: ADR 0120 still pays only for code that landed.
19
+ //
20
+ // Run: node tests/session_attach_from_claim.mjs
21
+
22
+ import { strict as assert } from 'node:assert';
23
+ import { createRequire } from 'node:module';
24
+ import { makeRunner, makeSqlAwareClient } from './helpers.mjs';
25
+
26
+ process.env.NODE_ENV = 'test';
27
+
28
+ const require = createRequire(import.meta.url);
29
+ const seams = require('../src/module-seams.js');
30
+ if (!seams.hasProvider('reward')) seams.registerProvider('reward', require('../modules/economy/reward.js'));
31
+ const api = require('../src/module-api.js');
32
+ const db = require('../modules/sessions/db.js');
33
+
34
+ const { test, summary } = makeRunner();
35
+
36
+ // Same priceable fixture the sibling reward tests use: Opus 110k input →
37
+ // $0.55 true cost → ×1.20 → 1 drachma. A non-zero payout is what lets the
38
+ // idempotency watermark SELECT run at all.
39
+ const MODEL_USAGE = { 'claude-opus-4-8': { input: 110_000 } };
40
+ const WATERMARK_RE = /COALESCE\(SUM\(delta\)/;
41
+
42
+ const norm = (q) => String(q.sql).replace(/\s+/g, ' ').trim();
43
+ const find = (queries, re) => queries.find((q) => re.test(norm(q)));
44
+ const all = (queries, re) => queries.filter((q) => re.test(norm(q)));
45
+
46
+ const ATTACH_RE = /WITH cand AS .* UPDATE session_records/;
47
+ const PAYABLE_RE = /SELECT session_id, builder_id, model_usage, total_tokens FROM session_records WHERE task_ids @>/;
48
+
49
+ async function withFakeTx(handler, run) {
50
+ const fake = makeSqlAwareClient(handler);
51
+ const prev = api.withTx;
52
+ api.withTx = (fn) => fn(fake._client);
53
+ try {
54
+ const result = await run();
55
+ return { result, queries: fake.queries };
56
+ } finally {
57
+ api.withTx = prev;
58
+ }
59
+ }
60
+
61
+ // =========================================================================
62
+ // (1) The attach runs, and runs BEFORE the payable-session lookup
63
+ // =========================================================================
64
+
65
+ await test('awardDeferredSessionRewardForTask: attaches from the claim BEFORE selecting payable sessions', async () => {
66
+ const seen = [];
67
+ const { result, queries } = await withFakeTx(
68
+ (s) => {
69
+ seen.push(s);
70
+ if (ATTACH_RE.test(s)) return { rows: [{ session_id: 'sess-hook-only' }] };
71
+ if (PAYABLE_RE.test(s)) {
72
+ return { rows: [{ session_id: 'sess-hook-only', builder_id: 7, model_usage: MODEL_USAGE, total_tokens: 5000 }] };
73
+ }
74
+ if (WATERMARK_RE.test(s)) return { rows: [{ paid: 0, legacy: 0 }] };
75
+ if (/^INSERT INTO credit_log/.test(s)) return { rows: [{ id: 1 }] };
76
+ return null;
77
+ },
78
+ () => db.awardDeferredSessionRewardForTask(660)
79
+ );
80
+
81
+ const attachIdx = seen.findIndex((s) => ATTACH_RE.test(s));
82
+ const payableIdx = seen.findIndex((s) => PAYABLE_RE.test(s));
83
+ assert.ok(attachIdx >= 0, 'the claim-based attach UPDATE must run');
84
+ assert.ok(payableIdx >= 0, 'the payable-session SELECT must run');
85
+ assert.ok(attachIdx < payableIdx,
86
+ 'the attach MUST precede the payable lookup — otherwise a skipped upload is unpayable forever');
87
+ assert.equal(result.attached, 1, 'the freshly-linked session is reported');
88
+ assert.equal(result.awarded, 1, 'the hook-only session is paid once linked');
89
+ });
90
+
91
+ // =========================================================================
92
+ // (2) The end-to-end shape the bug produced: hook-only record gets paid
93
+ // =========================================================================
94
+
95
+ await test('a hook-only record (empty task_ids, empty model_usage) is attributed and paid from total_tokens', async () => {
96
+ // The exact Masterqua row shape: no task, no per-model usage — only a token
97
+ // total. Pricing must fall back to totalTokens (task 1676) or this still pays 0.
98
+ const { result, queries } = await withFakeTx(
99
+ (s) => {
100
+ if (ATTACH_RE.test(s)) return { rows: [{ session_id: 'sess-masterqua' }] };
101
+ if (PAYABLE_RE.test(s)) {
102
+ return { rows: [{ session_id: 'sess-masterqua', builder_id: 1000025, model_usage: {}, total_tokens: 77_403_545 }] };
103
+ }
104
+ if (WATERMARK_RE.test(s)) return { rows: [{ paid: 0, legacy: 0 }] };
105
+ if (/^INSERT INTO credit_log/.test(s)) return { rows: [{ id: 1 }] };
106
+ return null;
107
+ },
108
+ () => db.awardDeferredSessionRewardForTask(660)
109
+ );
110
+
111
+ const insert = find(queries, /^INSERT INTO credit_log/);
112
+ assert.ok(insert, 'an empty model_usage must still book a reward via the totalTokens fallback');
113
+ assert.equal(result.awarded, 1, 'the session that was stranded for ten weeks now pays');
114
+ });
115
+
116
+ // =========================================================================
117
+ // (3) Idempotency + the landing gate (the two things this must not break)
118
+ // =========================================================================
119
+
120
+ await test('attach SQL skips sessions already carrying the task (no re-link, no double-pay)', async () => {
121
+ const { queries } = await withFakeTx(
122
+ (s) => {
123
+ if (ATTACH_RE.test(s)) return { rows: [] }; // nothing new to link
124
+ if (PAYABLE_RE.test(s)) return { rows: [] };
125
+ return null;
126
+ },
127
+ () => db.awardDeferredSessionRewardForTask(660)
128
+ );
129
+ const attach = find(queries, ATTACH_RE);
130
+ assert.ok(/NOT \(sr\.task_ids @> ARRAY/.test(norm(attach)),
131
+ 'the attach must exclude sessions already linked to this task');
132
+ assert.equal(all(queries, /^INSERT INTO credit_log/).length, 0, 'nothing linked → nothing paid');
133
+ });
134
+
135
+ await test('attach is bounded by a LIMIT so one long session cannot fan out an unbounded UPDATE', async () => {
136
+ const { queries } = await withFakeTx(
137
+ (s) => (ATTACH_RE.test(s) || PAYABLE_RE.test(s) ? { rows: [] } : null),
138
+ () => db.awardDeferredSessionRewardForTask(660)
139
+ );
140
+ const attach = find(queries, ATTACH_RE);
141
+ assert.ok(/LIMIT \$2/.test(norm(attach)), 'the candidate set must be bounded');
142
+ assert.equal(attach.params[1], 50, 'default cap');
143
+ });
144
+
145
+ await test('reconcileUnattachedClaimSessions considers ONLY landed tasks (ADR 0120 unchanged)', async () => {
146
+ const { queries } = await withFakeTx(
147
+ (s) => (/FROM claims c JOIN tasks t/.test(s) ? { rows: [] } : null),
148
+ () => db.reconcileUnattachedClaimSessions({ limit: 5 })
149
+ );
150
+ const scan = find(queries, /FROM claims c JOIN tasks t/);
151
+ assert.ok(scan, 'the attribution sweep must scan claims joined to tasks');
152
+ assert.ok(/t\.status = 'shipped'/.test(norm(scan)),
153
+ "the sweep must require status='shipped' — never attribute-and-pay unlanded work");
154
+ assert.equal(scan.params[0], 5, 'the caller-supplied limit is honoured');
155
+ });
156
+
157
+ // The guard that keeps a safety net from becoming an unauthorised mass payout.
158
+ // Without the window, the first sweep on cloudbongos.com would have retro-linked
159
+ // 1383 tasks / 1157 sessions for a single builder and paid them on the same tick.
160
+ await test('the sweep is WINDOWED — it can never become a retroactive mass backfill', async () => {
161
+ const { queries } = await withFakeTx(
162
+ (s) => (/FROM claims c JOIN tasks t/.test(s) ? { rows: [] } : null),
163
+ () => db.reconcileUnattachedClaimSessions({ limit: 5 })
164
+ );
165
+ const scan = find(queries, /FROM claims c JOIN tasks t/);
166
+ assert.ok(/shipped_at, t\.updated_at\) >= now\(\) - /.test(norm(scan)),
167
+ 'the sweep MUST bound itself to recently-landed tasks — historical back-pay is a deliberate Archon decision, not a poller side effect');
168
+ assert.equal(scan.params[1], '7', 'conservative default lookback');
169
+ });
170
+
171
+ await test('lookbackDays is clamped to a sane range (no 0-day no-op, no 10-year backfill)', async () => {
172
+ for (const [given, expected] of [[0, '1'], [-5, '1'], [99999, '365'], ['junk', '7']]) {
173
+ const { queries } = await withFakeTx(
174
+ (s) => (/FROM claims c JOIN tasks t/.test(s) ? { rows: [] } : null),
175
+ () => db.reconcileUnattachedClaimSessions({ limit: 5, lookbackDays: given })
176
+ );
177
+ assert.equal(find(queries, /FROM claims c JOIN tasks t/).params[1], expected,
178
+ `lookbackDays ${given} must clamp to ${expected}`);
179
+ }
180
+ });
181
+
182
+ await test('a non-finite task id is a clean no-op (no SQL, no reward)', async () => {
183
+ const { result, queries } = await withFakeTx(() => null, () => db.awardDeferredSessionRewardForTask('nope'));
184
+ assert.equal(queries.length, 0, 'a garbage id must not reach the DB');
185
+ assert.deepEqual(result, { sessions: 0, awarded: 0, attached: 0 });
186
+ });
187
+
188
+ summary();
@@ -224,8 +224,10 @@ await test('awardDeferredSessionRewardForTask: no session shipped this task →
224
224
 
225
225
  await test('awardDeferredSessionRewardForTask: non-numeric id → immediate no-op (no DB touch)', async () => {
226
226
  // No withTx patch — a bad id must return before opening a transaction.
227
+ // `attached` joined the shape in task 1002964 (the claim-based attribution
228
+ // recovery that now runs ahead of the payable-session lookup).
227
229
  const out = await db.awardDeferredSessionRewardForTask('not-a-number');
228
- assert.deepEqual(out, { sessions: 0, awarded: 0 });
230
+ assert.deepEqual(out, { sessions: 0, awarded: 0, attached: 0 });
229
231
  });
230
232
 
231
233
  // =========================================================================