@bongos/core 1.19.316 → 1.19.318
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 +108 -33
- package/modules/economy/cost.js +84 -19
- package/modules/lifecycle/db-analytics.js +719 -0
- package/modules/lifecycle/db-claim-reads.js +165 -0
- package/modules/lifecycle/db-claims.js +934 -0
- package/modules/lifecycle/db-deps-criteria.js +441 -0
- package/modules/lifecycle/db-goals.js +763 -0
- package/modules/lifecycle/db-grade.js +233 -0
- package/modules/lifecycle/db-overrides.js +181 -0
- package/modules/lifecycle/db-rank-authz.js +446 -0
- package/modules/lifecycle/db-shared.js +115 -0
- package/modules/lifecycle/db-ship.js +994 -0
- package/modules/lifecycle/db-tasks.js +810 -0
- package/modules/lifecycle/db-versions.js +118 -0
- package/modules/lifecycle/db.js +167 -5564
- package/modules/lifecycle/publish-reconciler.js +7 -2
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/coreB-carve-proof.sh +18 -9
- package/scripts/gds/fitness.js +34 -26
- package/scripts/gds/identity-strip.js +75 -0
- package/scripts/gds/provision.js +7 -3
- package/scripts/gds/run-unit-tests.js +8 -1
- package/scripts/gds/session-cost.js +16 -0
- package/scripts/gds/session-digest-build.js +261 -0
- package/scripts/gds/ship.js +7 -198
- package/src/module-api.js +5 -1
- package/tests/auto_satisfy_criteria.mjs +3 -2
- package/tests/dependencies_migration.mjs +3 -2
- package/tests/fitness.mjs +43 -0
- package/tests/goal_abuse_matrix.mjs +3 -2
- package/tests/goal_advisory.mjs +3 -2
- package/tests/government_claim_eligibility.mjs +3 -1
- package/tests/government_rank_sync.mjs +9 -2
- package/tests/helpers.mjs +19 -0
- package/tests/migration_allocation.mjs +3 -1
- package/tests/publish_reconciler.mjs +41 -1
- package/tests/rank_gate.mjs +3 -1
- package/tests/reward_gate.mjs +3 -1
- package/tests/session_cost.mjs +64 -0
- package/tests/session_cost_row_raise.mjs +139 -0
- package/tests/session_records.mjs +72 -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
|
+
};
|