@bongos/core 1.19.315 → 1.19.317

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 (38) hide show
  1. package/.bongos-core.json +104 -29
  2. package/.claude/skills/merge-mode/SKILL.md +16 -0
  3. package/.claude/skills/planning-session/SKILL.md +10 -0
  4. package/modules/lifecycle/db-analytics.js +719 -0
  5. package/modules/lifecycle/db-claim-reads.js +165 -0
  6. package/modules/lifecycle/db-claims.js +934 -0
  7. package/modules/lifecycle/db-deps-criteria.js +441 -0
  8. package/modules/lifecycle/db-goals.js +763 -0
  9. package/modules/lifecycle/db-grade.js +233 -0
  10. package/modules/lifecycle/db-overrides.js +181 -0
  11. package/modules/lifecycle/db-rank-authz.js +446 -0
  12. package/modules/lifecycle/db-shared.js +115 -0
  13. package/modules/lifecycle/db-ship.js +994 -0
  14. package/modules/lifecycle/db-tasks.js +810 -0
  15. package/modules/lifecycle/db-versions.js +118 -0
  16. package/modules/lifecycle/db.js +167 -5564
  17. package/package-lock.json +2 -2
  18. package/package.json +1 -1
  19. package/scripts/gds/coreB-carve-proof.sh +18 -9
  20. package/scripts/gds/fitness.js +35 -27
  21. package/scripts/gds/identity-strip.js +75 -0
  22. package/scripts/gds/preflight.js +25 -2
  23. package/scripts/gds/provision.js +7 -3
  24. package/scripts/gds/skill-preflight.js +336 -0
  25. package/src/module-api.js +5 -1
  26. package/tests/auto_satisfy_criteria.mjs +3 -2
  27. package/tests/dependencies_migration.mjs +3 -2
  28. package/tests/fitness.mjs +43 -0
  29. package/tests/goal_abuse_matrix.mjs +3 -2
  30. package/tests/goal_advisory.mjs +3 -2
  31. package/tests/government_claim_eligibility.mjs +3 -1
  32. package/tests/government_rank_sync.mjs +9 -2
  33. package/tests/helpers.mjs +19 -0
  34. package/tests/migration_allocation.mjs +3 -1
  35. package/tests/publish_reconciler.mjs +3 -1
  36. package/tests/rank_gate.mjs +3 -1
  37. package/tests/reward_gate.mjs +3 -1
  38. package/tests/skill_preflight.mjs +251 -0
@@ -0,0 +1,446 @@
1
+ // modules/lifecycle/db-rank-authz.js — rank ladder, claim permissions, rank changes + the xenos→thetes graduation.
2
+ //
3
+ // Carved out of modules/lifecycle/db.js by task 1003204 (goal 1000079, the R03
4
+ // de-monolith plan) — a STRUCTURAL move, not a rewrite: the code below is the
5
+ // original db.js lines 148-560 unchanged. db.js remains the facade that
6
+ // re-exports every name, so every existing db call site is unchanged.
7
+ //
8
+ // THE BOUNDARY (ADR 0093 §1): this file reaches core ONLY through the published
9
+ // doorway ../../src/module-api.js — never a core internal, never a sibling module.
10
+
11
+ 'use strict';
12
+
13
+ const api = require('../../src/module-api');
14
+ const { pool, LIVE_RANK_LADDER, RANK_TIER } = api;
15
+ const seams = api;
16
+ const { matchProtected } = api.permissionPathCheck;
17
+
18
+ // -------------------------------------------------------------------------
19
+ // Builders
20
+ // -------------------------------------------------------------------------
21
+
22
+
23
+ const RANKS = [
24
+ 'olympian', 'hermes', 'oracle', 'demigod',
25
+ 'archon', 'strategos', 'boule', 'aristoi', 'hippeis', 'zeugitai',
26
+ 'metic', 'thetes', 'xenos', 'periokoi', 'doulos', 'helot',
27
+ ];
28
+
29
+ // THETES auto-graduation (#692 / ADR 0034; trigger corrected in #704). A Xenos
30
+ // who has this many SUCCESSFULLY SHIPPED tasks (tasks.status='shipped',
31
+ // attributed via tasks.shipped_by) is promoted xenos→thetes the moment the Nth
32
+ // ship lands. The signal is SHIPPED, not confirmed — a task only counts once it
33
+ // is merged + deployed, per the Archon rule "after 3 tasks are successfully
34
+ // shipped." Pure decision helper is exported so tests/rank_gate.mjs can exercise
35
+ // the threshold without a DB.
36
+
37
+ const THETES_GRADUATION_THRESHOLD = 3;
38
+
39
+ function qualifiesForThetesGraduation(rank, shippedCount) {
40
+ return rank === 'xenos' && Number(shippedCount) >= THETES_GRADUATION_THRESHOLD;
41
+ }
42
+
43
+ // GENERAL_QUEUE_RANKS — the live ranks allowed to claim any ready task (the
44
+ // whole general queue). Everything NOT in this set is sandboxed (see
45
+ // xenosClaimAllowed). LIVE_RANK_LADDER minus the sandboxed entry rank 'xenos':
46
+ // thetes (graduated newcomer, #692 / ADR 0034) and up — derived (BV1.R108).
47
+
48
+ const GENERAL_QUEUE_RANKS = new Set(LIVE_RANK_LADDER.filter((r) => r !== 'xenos'));
49
+
50
+ // xenosClaimAllowed — the row-level half of the three-rank model (#360 / ADR
51
+ // 0018; renamed in V3.R15 #239 to newcomer_friendly). The sandboxed entry rank
52
+ // may claim a task ONLY if it's been explicitly opened (newcomer_friendly ===
53
+ // true); the live general-queue ranks claim any ready task.
54
+ //
55
+ // #510: FAIL CLOSED. The old test was `if (rank !== 'xenos') return true`, which
56
+ // failed OPEN — ANY rank that wasn't the literal string 'xenos' (a reserved rank
57
+ // like 'helot', a legacy value, or a future typo) skipped the sandbox and could
58
+ // claim any ready task. We now allowlist the live general-queue ranks instead:
59
+ // the sandbox applies to 'xenos' AND every reserved/unknown/typo rank. The
60
+ // privileged set is {thetes, metic, archon}, NOT just {metic, archon} — thetes
61
+ // is the live graduated-newcomer rank (#692 / ADR 0034) and by design claims the
62
+ // whole general queue (asserted in tests/rank_gate.mjs). This matches the
63
+ // fail-closed posture rankAllowsTask already takes for unknown ranks.
64
+ //
65
+ // The parameter is named `newcomerFriendly` (the V3.R15 canonical name).
66
+ // Pure + exported so tests/rank_gate.mjs can exercise it without a DB; claimTask
67
+ // + claimTasksBatch both call it (composed with rankAllowsTask).
68
+
69
+ // BV1.R106 (ADR 0151): this rank rule is no longer the primary gate — the claim
70
+ // paths decide the queue axis on the PERMISSIONS `task.claim.any` /
71
+ // `task.claim.newcomer` (claim-eligibility.js) and keep this as the fallback for a
72
+ // builder whose role row the run-once compat backfill never created. It stays
73
+ // exported: tests/rank_gate.mjs pins it, and it is the oracle the parity test in
74
+ // tests/government_claim_eligibility.mjs compares the permission predicate against.
75
+
76
+ function xenosClaimAllowed(rank, newcomerFriendly) {
77
+ if (GENERAL_QUEUE_RANKS.has(rank)) return true;
78
+ return newcomerFriendly === true;
79
+ }
80
+
81
+ // claimPermissionsFor — the builder's effective permission set for the claim
82
+ // decision, read through the `governance` kernel PORT (never by importing the
83
+ // module — the one-way rule, ADR 0083). Returns a Set, or NULL when authority
84
+ // cannot be ESTABLISHED, which claimEligibilityAllows reads as "fall back to the
85
+ // incumbent rank rule":
86
+ // • the governance module is not mounted (no port), or the resolver throws;
87
+ // • the builder resolves to ZERO permissions — i.e. they hold no role row.
88
+ // governance_002's backfill is run-once and nothing yet re-assigns a rank-role
89
+ // when a builder is created or re-ranked, so this is a live gap, not a theory.
90
+ // Every seeded rank-role grants at least twelve permissions, so an empty set never
91
+ // means "a real role that grants nothing" today.
92
+ //
93
+ // Called BEFORE the claim transaction opens: authority is a per-request read (ADR
94
+ // 0016 — resolved server-side, uncached), and taking a second pool connection while
95
+ // a tx holds FOR UPDATE locks is a needless deadlock/exhaustion hazard.
96
+ async function claimPermissionsFor(builderId) {
97
+ const governance = seams.resolveOptional('government');
98
+ if (!governance || typeof governance.resolveBuilderPermissions !== 'function') return null;
99
+ try {
100
+ const keys = await governance.resolveBuilderPermissions(builderId);
101
+ if (!keys || keys.length === 0) return null;
102
+ return new Set(keys);
103
+ } catch (err) {
104
+ console.error('[gds] claim eligibility: permission resolve failed, falling back to the rank rule', err);
105
+ return null;
106
+ }
107
+ }
108
+
109
+ // V3.R82 (#301): the INVERSE of newcomer_friendly. Some tasks raise the floor
110
+ // instead of opening the door — `requires_rank` is a per-task MINIMUM rank
111
+ // that a builder must meet to claim. Default 'xenos' means "no extra gate"
112
+ // (any builder can claim, subject to the existing xenos sandbox rule).
113
+ //
114
+ // The LIVE ranks form an ordered tier (ADR 0018; thetes added #692 / ADR 0034):
115
+ // xenos (1) < thetes (2) < metic (3) < archon (4)
116
+ // A 'thetes' clears any requires_rank='xenos' (the default) task — i.e. the
117
+ // whole general queue — but is blocked from requires_rank='metic'/'archon'.
118
+ // Reserved ranks aren't live — if one is somehow stored on a builder row, we
119
+ // fail closed (treat as below xenos / refuse). The DB CHECK on
120
+ // tasks.requires_rank enforces the same enum so the column never holds a value
121
+ // this helper can't compare.
122
+ //
123
+ // Pure + exported so tests/rank_gate.mjs can exercise it without a DB.
124
+ // claimTask + claimTasksBatch both call it after xenosClaimAllowed; the two
125
+ // gates compose without contradiction (see migration 044 for the rationale).
126
+
127
+ // BV1.R108: no longer a hand-copied literal — the canonical map is DERIVED from
128
+ // LIVE_RANK_LADDER in db-kernel and reaches this module through the doorway.
129
+ // The local name survives because ~10 helpers below and the module export read it.
130
+ const REQUIRES_RANK_TIER = RANK_TIER;
131
+
132
+ function rankAllowsTask(builderRank, requiredRank) {
133
+ // Tasks default to requires_rank='xenos' (the NOT NULL DEFAULT on the
134
+ // column), so a null/undefined here means "no extra gate" — treat as xenos.
135
+ const needed = REQUIRES_RANK_TIER[requiredRank ?? 'xenos'];
136
+ if (!needed) {
137
+ // Bad value in the column (shouldn't happen — DB CHECK blocks it).
138
+ // Fail closed: refuse the claim so a future rank can't be silently bypassed.
139
+ return false;
140
+ }
141
+ const have = REQUIRES_RANK_TIER[builderRank];
142
+ if (!have) {
143
+ // Builder rank not in the live three. Fail closed.
144
+ return false;
145
+ }
146
+ return have >= needed;
147
+ }
148
+
149
+ // deriveRequiredRank — task 1498 / ADR 0085. The per-task requires_rank floor
150
+ // (V3.R82 #301) shipped INERT: no create/edit path ever set it, so every task
151
+ // sat at the 'xenos' default and the claim gate never bit. We now DERIVE a floor
152
+ // from what a task touches + its sensitivity, reusing the SAME protected-path
153
+ // matcher the trust boundary uses (permission-path-check.matchProtected) — one
154
+ // source of truth for "this is sensitive" (ADR 0043). A task that touches the
155
+ // rank/authz/ship-grade/deploy/migration core, or is flagged security_sensitive,
156
+ // floors at 'metic' (the first trusted working rank — exactly the paths a
157
+ // sub-Metic builder already cannot push to). Everything else stays 'xenos' (the
158
+ // open general queue). NEVER auto-assigns 'archon': "only the Archon may claim
159
+ // this" is a deliberate manual override, not something to infer.
160
+ // Pure + exported so tests/rank_gate.mjs can exercise it without a DB.
161
+
162
+ function deriveRequiredRank(touches, securitySensitive) {
163
+ if (securitySensitive === true) return 'metic';
164
+ const files = Array.isArray(touches) ? touches : [];
165
+ if (files.length > 0 && matchProtected(files).length > 0) return 'metic';
166
+ return 'xenos';
167
+ }
168
+
169
+ // highestRank — the higher-authority of two ranks (treating null/undefined/
170
+ // unknown as the 'xenos' floor, tier 1). Composes the derived FLOOR with an
171
+ // explicit/override rank: stored requires_rank is always max(floor, requested),
172
+ // so an override can only RAISE the bar, never drop below the safety floor
173
+ // (fail-closed). Pure + exported.
174
+
175
+ function highestRank(a, b) {
176
+ const ta = REQUIRES_RANK_TIER[a] || 1;
177
+ const tb = REQUIRES_RANK_TIER[b] || 1;
178
+ const winner = ta >= tb ? a : b;
179
+ return REQUIRES_RANK_TIER[winner] ? winner : 'xenos';
180
+ }
181
+
182
+ // LIVE rank ladder, lowest→highest. The promotion ladder for the manual
183
+ // one-rung rule (#707). Indexes are 0-based; live tier = index + 1, matching
184
+ // REQUIRES_RANK_TIER.
185
+ // LIVE_RANK_LADDER moved to ./db-kernel (BV1.R69 / ADR 0091 §2: the rank ladder
186
+ // is a kernel primitive). Imported + re-exported at the top of this file; the
187
+ // 0-based indexing note above still applies to the uses just below.
188
+
189
+ // singleRungPromotionError — #707. The Archon rule: a MANUAL promotion may
190
+ // advance a builder EXACTLY one live rung at a time (xenos→thetes→metic→archon).
191
+ // Returns null when the change is allowed (a one-rung promotion, a demotion, a
192
+ // lateral move, or an off-ladder/reserved rank on either side), or a structured
193
+ // error object when a promotion skips ≥1 rung (e.g. xenos→metic, thetes→archon).
194
+ // Pure + exported so tests/rank_gate.mjs can exercise it without a DB. Only the
195
+ // manual chokepoint (setBuilderRank) enforces it; auto-graduation (always +1)
196
+ // bypasses it.
197
+
198
+ function singleRungPromotionError(oldRank, newRank) {
199
+ const a = REQUIRES_RANK_TIER[oldRank];
200
+ const b = REQUIRES_RANK_TIER[newRank];
201
+ if (!a || !b) return null; // one side is off the live ladder — not our gate
202
+ if (b <= a) return null; // demotion or lateral — unconstrained here
203
+ if (b - a === 1) return null; // exactly one rung up — allowed
204
+ return {
205
+ code: 'MULTI_RUNG_PROMOTION',
206
+ from: oldRank,
207
+ to: newRank,
208
+ next_rung: LIVE_RANK_LADDER[a], // rank at tier a+1 (ladder is 0-based)
209
+ };
210
+ }
211
+
212
+ // foldWorktreeName — task 1002522. The ONE normalization of an incoming
213
+ // worktree_name, applied at every ingest point so the app guard
214
+ // (shouldRefuseSessionClaim) and its DB backstop (core_204's index, keyed on
215
+ // (creator_session_id, COALESCE(worktree_name, ''))) can never disagree about
216
+ // which claims share a working tree.
217
+ //
218
+ // The index folds NULL to '' — so to Postgres an EMPTY-string name IS the
219
+ // worktree-less group. The guard compared raw values, where '' !== null: it
220
+ // waved through a ''-named claim beside a worktree-less one, and the INSERT
221
+ // then failed the backstop with a 23505 the caller never earned. Whitespace-only
222
+ // is the same case wearing a coat (`.claude/worktrees/<blank>` is the repo root,
223
+ // not a tree), so it folds too, and surrounding whitespace is trimmed rather
224
+ // than stored — an untrimmed name would key the index differently from the
225
+ // trimmed one the guard compared.
226
+ //
227
+ // Fold ONCE, at ingest: claimTask/claimTasksBatch normalize before the INSERT,
228
+ // so every stored name is already trimmed-or-NULL and the guard's comparison of
229
+ // stored rows is exact. Pure + exported for DB-free tests.
230
+ function foldWorktreeName(name) {
231
+ if (name == null) return null;
232
+ const trimmed = String(name).trim();
233
+ return trimmed === '' ? null : trimmed;
234
+ }
235
+
236
+ // disambiguateWorktreeName — V3.R51 (#270). Two simultaneously-ACTIVE claims
237
+ // must never share a worktree_name: `.claude/worktrees/<name>` is a filesystem
238
+ // path, and two live builders pointing at the same one would stomp each other.
239
+ // This is NOT a refusal (unlike ALREADY_CLAIMED / TOUCHES_CONFLICT) — we
240
+ // silently append a numeric suffix and let the claim succeed, returning the
241
+ // disambiguated name so the caller knows what they actually got.
242
+ //
243
+ // `taken` is the set of worktree_names held by currently-ACTIVE claims only
244
+ // (released_at IS NULL). A name freed by a resolved/shipped claim is freely
245
+ // reusable WITHOUT a suffix — this is load-bearing for the overnight builder,
246
+ // which reuses ONE worktree_name across sequential tasks (it ships, freeing the
247
+ // claim, before claiming the next). Keying off historical claims would break it.
248
+ //
249
+ // Suffix scheme: "name" → "name-2" → "name-3" … (first collision becomes -2,
250
+ // matching git-worktree / file-manager conventions). A null/empty desired name
251
+ // has nothing to collide on, so it's returned unchanged. Pure + exported so
252
+ // tests can exercise it without a DB; claimTask calls it inside the txn.
253
+
254
+ function disambiguateWorktreeName(desired, taken) {
255
+ if (desired === null || desired === undefined || desired === '') return desired;
256
+ const takenSet = taken instanceof Set ? taken : new Set(taken || []);
257
+ if (!takenSet.has(desired)) return desired;
258
+ for (let suffix = 2; ; suffix++) {
259
+ const candidate = `${desired}-${suffix}`;
260
+ if (!takenSet.has(candidate)) return candidate;
261
+ }
262
+ }
263
+
264
+ // setBuilderRank — the single chokepoint for rank changes (V3.R07 #232).
265
+ // Transactional: re-reads the target's current rank under FOR UPDATE, flips
266
+ // it, and writes an audit row to credit_log. The audit row's delta encodes
267
+ // direction (V3.R28 / #249 ceremony):
268
+ // - Promotion (newIdx < oldIdx) → delta = +5 — small celebratory bonus so
269
+ // drachmae mirrors authority. The trigger on credit_log will roll +5 into
270
+ // builders.total_credits.
271
+ // - Demotion (newIdx > oldIdx) → delta = 0 — audit-only, no shaming
272
+ // deduction. Demotions are dignified.
273
+ // - Cross-ladder (one rank not in RANKS — should be impossible since the
274
+ // API gates on RANKS, but the DB CHECK on builders.rank is the backstop)
275
+ // → delta = 0, treated as audit-only.
276
+ // reason is always 'rank_change' so downstream queries (GET /me/pending-rank-
277
+ // change, audit replay) can join on it cleanly. The full old→new + actor go
278
+ // into the `description` column added by migration 031 — no parsing required.
279
+ // The legacy `reason` text format ("rank_change: <old>→<new> by builder#<actor>")
280
+ // is preserved verbatim too, so historical credit_log rows from before R28
281
+ // remain parseable by the same code path.
282
+ //
283
+ // Returns the updated builder row, or throws { code:'BUILDER_NOT_FOUND' } /
284
+ // { code:'RANK_UNCHANGED' }.
285
+ // syncGovernanceRankRole — keep government_builder_ranks in step with builders.rank
286
+ // (BV1.R95b, ADR 0151). This module's rank writer backs the automatic xenos→thetes
287
+ // graduation, which had the same hole as the Archon-driven path: the rank moved and
288
+ // the role did not, so a freshly-graduated Thetes kept the xenos role.
289
+ //
290
+ // Resolved through the `government.rankSync` kernel port (never a sibling require —
291
+ // ADR 0083) and run on the CALLER's transaction client, so the rotation commits
292
+ // atomically with the ship + graduation. A missing port (governance disabled) skips:
293
+ // no role ⇒ zero permissions, fail-closed.
294
+ //
295
+ // TWINNED with src/bongos/db.js (the boundary-forced _applyRankChangeTx duplication,
296
+ // ADR 0091 §2) — keep the two in sync.
297
+ async function syncGovernanceRankRole(client, builderId, rank) {
298
+ const roleSync = seams.resolveOptional('government.rankSync');
299
+ if (!roleSync || typeof roleSync.syncRank !== 'function') return null;
300
+ return roleSync.syncRank(client, { builderId, rank });
301
+ }
302
+
303
+ // _applyRankChangeTx — the in-transaction CORE of a rank change, shared by the
304
+ // Archon-driven setBuilderRank and the automatic xenos→thetes graduation in the
305
+ // confirm paths (#692 / ADR 0034). Runs inside the CALLER's transaction/client
306
+ // — it does NOT open or commit one. Locks the builder row, flips the rank,
307
+ // writes the credit_log audit row (+5 ceremony on promotion, 0 otherwise), and
308
+ // — on demotion — revokes live sessions so a stale token can't replay old
309
+ // authority. Returns the audit shape (incl. the builder's `status`, so the
310
+ // caller can fire the post-commit box reconcile on its OWN connection).
311
+ async function _applyRankChangeTx(client, { builderId, newRank, actorBuilderId, reasonNote = null, enforceSingleRungPromotion = false }) {
312
+ const { rows } = await client.query(
313
+ `SELECT id, rank, status FROM builders WHERE id = $1 FOR UPDATE`,
314
+ [builderId]
315
+ );
316
+ const target = rows[0];
317
+ if (!target) throw { code: 'BUILDER_NOT_FOUND' };
318
+ const oldRank = target.rank;
319
+ if (oldRank === newRank) throw { code: 'RANK_UNCHANGED', rank: oldRank };
320
+
321
+ // #707 — manual promotions advance ONE live rung at a time. Checked here
322
+ // (atomic, under the FOR UPDATE lock) but only when the caller opts in; the
323
+ // automatic xenos→thetes graduation (always +1) does not.
324
+ if (enforceSingleRungPromotion) {
325
+ const rungErr = singleRungPromotionError(oldRank, newRank);
326
+ if (rungErr) throw rungErr;
327
+ }
328
+
329
+ const { rows: updated } = await client.query(
330
+ `UPDATE builders SET rank = $2 WHERE id = $1
331
+ RETURNING id, github_id, github_login, display_name, avatar_url, total_credits, created_at, rank`,
332
+ [builderId, newRank]
333
+ );
334
+
335
+ // BV1.R95b — rotate the seeded rank-role IN THIS TRANSACTION (removing the old one,
336
+ // not merely adding the new). Not try/catch-wrapped: a failure must roll the rank
337
+ // change back rather than leave rank and role disagreeing. See the twin in
338
+ // src/bongos/db.js.
339
+ await syncGovernanceRankRole(client, builderId, newRank);
340
+
341
+ // V3.R28 / #249 — ceremony delta. Promotion (moving UP the ladder = lower
342
+ // index in RANKS) earns +5; demotion is audit-only (0).
343
+ const oldIdx = RANKS.indexOf(oldRank);
344
+ const newIdx = RANKS.indexOf(newRank);
345
+ const isPromotion = oldIdx !== -1 && newIdx !== -1 && newIdx < oldIdx;
346
+ const isDemotion = oldIdx !== -1 && newIdx !== -1 && newIdx > oldIdx;
347
+ const delta = isPromotion ? 5 : 0;
348
+ // reason is the join key for downstream pickup; the human-readable text stays
349
+ // in `description`. The "by builder#N" clause is load-bearing — the rank-change
350
+ // greeting parser (getLatestRankChangeForBuilder) matches on it — so an
351
+ // automatic promotion (#692) keeps it (actor = the builder themselves) and
352
+ // APPENDS the "auto-graduation: …" note rather than replacing it.
353
+ const description = reasonNote
354
+ ? `rank_change: ${oldRank}→${newRank} by builder#${actorBuilderId} (${reasonNote})`
355
+ : `rank_change: ${oldRank}→${newRank} by builder#${actorBuilderId}`;
356
+ await client.query(
357
+ `INSERT INTO credit_log (builder_id, task_id, delta, reason, description)
358
+ VALUES ($1, NULL, $2, 'rank_change', $3)`,
359
+ [builderId, delta, description]
360
+ );
361
+
362
+ // V3.R38 / #259 — DEMOTION hook for criterion C7 (rank-change-near-instant).
363
+ // Revoke every active session in the same transaction so the demoted builder's
364
+ // CLI tokens stop working on their next API call. Combined with requireBuilder
365
+ // re-reading rank from the DB per request (ADR 0016), this is belt-and-braces.
366
+ // Promotion never revokes — we don't log someone out for getting a raise.
367
+ let demotionRevoked = 0;
368
+ if (isDemotion) {
369
+ const { rowCount } = await client.query(
370
+ `UPDATE builder_sessions
371
+ SET revoked_at = now()
372
+ WHERE builder_id = $1 AND revoked_at IS NULL`,
373
+ [builderId]
374
+ );
375
+ demotionRevoked = rowCount;
376
+ }
377
+
378
+ return {
379
+ builder: updated[0],
380
+ previous_rank: oldRank,
381
+ status: target.status,
382
+ sessions_revoked: demotionRevoked,
383
+ direction: isPromotion ? 'promotion' : isDemotion ? 'demotion' : 'lateral',
384
+ credit_delta: delta,
385
+ };
386
+ }
387
+
388
+
389
+ async function maybeAutoGraduateToThetes(client, builderId) {
390
+ if (builderId == null) return null;
391
+ const { rows } = await client.query(`SELECT rank FROM builders WHERE id = $1`, [builderId]);
392
+ const rank = rows[0]?.rank;
393
+ if (rank !== 'xenos') return null;
394
+ const { rows: c } = await client.query(
395
+ `SELECT count(*)::int AS n FROM tasks
396
+ WHERE shipped_by = $1 AND status = 'shipped'`,
397
+ [builderId]
398
+ );
399
+ if (!qualifiesForThetesGraduation(rank, c[0].n)) return null;
400
+ return _applyRankChangeTx(client, {
401
+ builderId,
402
+ newRank: 'thetes',
403
+ actorBuilderId: builderId,
404
+ reasonNote: `auto-graduation: ${c[0].n} shipped tasks (#692)`,
405
+ });
406
+ }
407
+
408
+ // countShippedTasksForBuilder — #766. The number of successfully shipped tasks
409
+ // attributed to a builder (tasks.shipped_by, status='shipped'). Drives the
410
+ // onboarding checklist's "N of 3" ship counter (the 'ship_three' terminal
411
+ // stage) and is the same count maybeAutoGraduateToThetes gates on. Returns 0
412
+ // for a null/unknown builder.
413
+
414
+ async function countShippedTasksForBuilder(builderId) {
415
+ if (builderId == null) return 0;
416
+ const { rows } = await pool.query(
417
+ `SELECT count(*)::int AS n FROM tasks WHERE shipped_by = $1 AND status = 'shipped'`,
418
+ [builderId]
419
+ );
420
+ return rows[0]?.n ?? 0;
421
+ }
422
+
423
+ // getLatestRankChangeForBuilder — V3.R28 / #249.
424
+ // Returns the most recent credit_log row with reason='rank_change' for the
425
+ // given builder, parsed into a shape the SessionStart hook + /me/pending-
426
+ // rank-change route can consume directly. Returns null when the builder has
427
+ // never had a rank change. The parser tolerates both the post-R28 format
428
+ // (delta carries +5 / 0, description carries the legacy "a→b by #N" text)
429
+ // and the pre-R28 format (reason carries the full text, description NULL).
430
+
431
+ module.exports = {
432
+ RANKS,
433
+ REQUIRES_RANK_TIER,
434
+ THETES_GRADUATION_THRESHOLD,
435
+ claimPermissionsFor,
436
+ countShippedTasksForBuilder,
437
+ deriveRequiredRank,
438
+ disambiguateWorktreeName,
439
+ foldWorktreeName,
440
+ highestRank,
441
+ maybeAutoGraduateToThetes,
442
+ qualifiesForThetesGraduation,
443
+ rankAllowsTask,
444
+ singleRungPromotionError,
445
+ xenosClaimAllowed,
446
+ };
@@ -0,0 +1,115 @@
1
+ // modules/lifecycle/db-shared.js — shared SQL fragments + small pure helpers used across the lifecycle data layer.
2
+ //
3
+ // Carved out of modules/lifecycle/db.js by task 1003204 (goal 1000079, the R03
4
+ // de-monolith plan) — a STRUCTURAL move, not a rewrite: the code below is the
5
+ // original db.js lines 55-147 unchanged. db.js remains the facade that
6
+ // re-exports every name, so every existing db call site is unchanged.
7
+ //
8
+ // THE BOUNDARY (ADR 0093 §1): this file reaches core ONLY through the published
9
+ // doorway ../../src/module-api.js — never a core internal, never a sibling module.
10
+
11
+ 'use strict';
12
+
13
+
14
+ // ---------------------------------------------------------------------------
15
+ // Shared SQL fragments + small pure helpers (task 1087, C3 — dedup audit
16
+ // 2026-06-14 §2A/§2B db-tx/db-deep). Each is behavior-neutral: the SQL helpers
17
+ // produce strings byte-identical to the inlined copies they replace (modulo
18
+ // whitespace, which SQL ignores), and the query helpers run the identical query
19
+ // with the identical bound params. Per-site DRIFT that a naive merge would
20
+ // break is preserved at the call site (documented per helper).
21
+ // ---------------------------------------------------------------------------
22
+
23
+ // median of a numeric array (sorts a COPY; the caller's array is left intact).
24
+ function median(arr) {
25
+ const s = [...arr].sort((a, b) => a - b);
26
+ const mid = Math.floor(s.length / 2);
27
+ return s.length % 2 === 0 ? (s[mid - 1] + s[mid]) / 2 : s[mid];
28
+ }
29
+
30
+ // Smoke tasks (`__smoke__*`) are excluded from every public/analytics feed.
31
+ // Unqualified `title` is safe to drop into a `tasks t LEFT JOIN builders b`
32
+ // query too, since `builders` has no `title` column (audit §2B db-deep).
33
+
34
+ const SMOKE_NOT_LIKE = `title NOT LIKE '\\_\\_smoke\\_\\_%' ESCAPE '\\'`;
35
+
36
+ // stale-claim predicate over (claims c LEFT JOIN session_pulses sp): a claim is
37
+ // stale when its last pulse — or, with no pulse, its claim time — is older than
38
+ // the `param` ($N) hours value. NOTE: the two pulse.js sweeper copies use
39
+ // `interval '1 minute'` (a different unit) in a separate module, so they are
40
+ // intentionally NOT shared here (audit §2B db-deep).
41
+
42
+ const staleClaimHourPredicate = (param) =>
43
+ `(sp.last_pulse_at IS NOT NULL
44
+ AND sp.last_pulse_at < now() - (${param}::int * interval '1 hour'))
45
+ OR (sp.last_pulse_at IS NULL
46
+ AND c.claimed_at < now() - (${param}::int * interval '1 hour'))`;
47
+
48
+ // grade pass-count fragment: SUM of passed-as-1. `col` is the (possibly
49
+ // alias-qualified) boolean column ('passed' or 'g.passed'); { where } restricts
50
+ // the sum to quality-graded rows via FILTER (task 1002652 — every aggregate
51
+ // site passes gradeQualityWhere, and keeps coalesce on because FILTER can zero
52
+ // out a group). { coalesce: false } preserved for callers that want a raw SUM.
53
+
54
+ const gradePassCount = (col, { coalesce = true, where = null } = {}) => {
55
+ const filter = where ? ` FILTER (WHERE ${where})` : '';
56
+ const inner = `SUM(CASE WHEN ${col} THEN 1 ELSE 0 END)${filter}`;
57
+ return coalesce ? `COALESCE(${inner}, 0)::int` : `${inner}::int`;
58
+ };
59
+
60
+ // Quality-graded row predicate (task 1002652). Two task_grades shapes carry
61
+ // score 0 / passed=false that are NOT quality verdicts and must never enter an
62
+ // aggregate: a grader OUTAGE (#943 — signals.panel_outcome='unavailable',
63
+ // rubric axes json-null) and a panel row whose gating axis
64
+ // (functional_fidelity / code_quality) is json-null (an errored worker, not a
65
+ // judged 0). jsonb_typeof + COALESCE keep legacy rows counted (missing keys /
66
+ // SQL-NULL rubric_scores → typeof NULL → '' ≠ 'null') and keep the predicate
67
+ // NULL-safe under NOT (a bare NULL operand would drop rows from the
68
+ // n_unavailable bucket).
69
+
70
+ const gradeQualityWhere = (alias = '') => {
71
+ const a = alias ? `${alias}.` : '';
72
+ return `(COALESCE(${a}signals->>'panel_outcome','') <> 'unavailable'
73
+ AND COALESCE(jsonb_typeof(${a}rubric_scores->'functional_fidelity'),'') <> 'null'
74
+ AND COALESCE(jsonb_typeof(${a}rubric_scores->'code_quality'),'') <> 'null')`;
75
+ };
76
+
77
+ // The shared `bounds`/`days` DAY_SERIES_CTE for the costSummary day-series
78
+ // queries moved with costSummary into modules/economy/cost.js (BV1.R77 / ADR
79
+ // 0093 §§1-2) — its only two callers left the monolith.
80
+
81
+ // Seal a session_logs row inside an open transaction (ended_at is NOT NULL in
82
+ // the schema, so a "sealed" log = the closing row). `outcome` stays a PARAMETER
83
+ // — resolveClaim passes a variable ('shipped'/'partial'/'abandoned'), the two
84
+ // release paths pass the literal 'abandoned' (audit §2B db-tx).
85
+
86
+ async function sealSessionLog(client, { builderId, taskId, claimId, startedAt, outcome, notesMd }) {
87
+ await client.query(
88
+ `INSERT INTO session_logs (builder_id, task_id, claim_id, started_at, ended_at, outcome, notes_md)
89
+ VALUES ($1, $2, $3, $4, now(), $5, $6)`,
90
+ [builderId, taskId, claimId, startedAt, outcome, notesMd]
91
+ );
92
+ }
93
+
94
+ // The builder on the most-recent claim for a task (the worker who did the
95
+ // work). Returns null when the task has no claim on record at all — each caller
96
+ // supplies its OWN fallback (confirmer / requester / grade-caller) at the call
97
+ // site, so that per-site drift is preserved (audit §2B db-deep, #538/#661).
98
+
99
+ async function mostRecentClaimHolderId(client, taskId) {
100
+ const { rows } = await client.query(
101
+ `SELECT builder_id FROM claims WHERE task_id = $1 ORDER BY claimed_at DESC, id DESC LIMIT 1`,
102
+ [taskId]
103
+ );
104
+ return rows[0]?.builder_id ?? null;
105
+ }
106
+
107
+ module.exports = {
108
+ SMOKE_NOT_LIKE,
109
+ gradePassCount,
110
+ gradeQualityWhere,
111
+ median,
112
+ mostRecentClaimHolderId,
113
+ sealSessionLog,
114
+ staleClaimHourPredicate,
115
+ };