@teamscala/service-boot 0.23.421 → 0.23.426

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.
@@ -0,0 +1,423 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten @indivisible-unit owner=lane/idle-pools-hold-the-ceiling reason="one report-shaping unit: band selection, narrative and chart shaping change together"
4
+ * @edit edit directly
5
+ *
6
+ * report-data.ts — quiz-results → report data generation.
7
+ *
8
+ * Shapes a stored assessment response into the data the report puck_page
9
+
10
+ * renders. The response already carries total_score + category_scores
11
+ * (computed at submit time), so this is pure shaping — no re-scoring and no
12
+ * authored copy. The `computeReportData` resolver_fn (a resource_loader
13
+ * source, key `reportData`) runs this at SSR; the report blocks bind its
14
+ * fields through the unified page-data surface (`dataSource:"page-data"` +
15
+ * `fieldPath:"reportData.<key>"`).
16
+ *
17
+ * @see scala-docs/curated/apps/messaging.md is unrelated; the stage model
18
+ * lives on `assessments.stage_bands` (migration 560/561).
19
+ */
20
+
21
+ /** A chart data point — structural match for @teamscala/chart-block's ChartPoint. */
22
+ export interface ReportChartPoint {
23
+ label: string;
24
+ value: number;
25
+ color?: string;
26
+ }
27
+
28
+ /** A derived pillar position for the Positioning card (label + advisory summary). */
29
+ export interface PillarPosition {
30
+ label: string;
31
+ summary: string;
32
+ }
33
+
34
+ /** One roadmap phase — a focus word plus its actions. */
35
+ export interface RoadmapPhase {
36
+ focus: string;
37
+ actions: string[];
38
+ }
39
+
40
+ /**
41
+ * A list item carrying its own ordinal. The number lives WITH the item so a
42
+ * fixed-slot layout cannot print a badge for an item that does not exist: a
43
+ * stage authoring four strengths into six slots leaves slots five and six with
44
+ * no number and no text, rather than a bare numeral beside empty space. Numbers
45
+ * spelled in the page instead would be a second copy of the list's length.
46
+ */
47
+ export interface NumberedItem {
48
+ index: string;
49
+ text: string;
50
+ }
51
+
52
+ /** A titled action with its explanatory line. */
53
+ export interface TitledAction {
54
+ title: string;
55
+ description: string;
56
+ }
57
+
58
+ /**
59
+ * One stage band, as authored on `assessments.stage_bands`. Every field is
60
+ * copy the business owns; nothing here is computed. `max` is the inclusive
61
+ * upper bound of the band — the final band omits it and catches the rest.
62
+ */
63
+ export interface StageBand {
64
+ name: string;
65
+ max?: number;
66
+ tagline?: string;
67
+ executiveSummary?: string;
68
+ strongestAreas?: string[];
69
+ priorityActions?: TitledAction[];
70
+ strategicFocus?: string;
71
+ strengths?: string[];
72
+ opportunities?: string[];
73
+ twelveMonthTarget?: string;
74
+ /**
75
+ * The remaining elements of Anna's five-page template. Every one is COPY,
76
+ * authored per stage like the rest — none can be derived from the response.
77
+ * Wiring them here makes each a row edit rather than a build task: the page
78
+ * binds them and hides the section (`visibleWhenPath`) until a stage fills
79
+ * it in, so Anna can land them one at a time without a deploy.
80
+ */
81
+ whoYouHelp?: string;
82
+ knownFor?: string;
83
+ insights?: string[];
84
+ contentThemes?: TitledAction[];
85
+ contentRhythm?: string;
86
+ northStar?: string;
87
+ priorityFocus?: string;
88
+ priorityFocusSummary?: string;
89
+ recommendedTier?: string;
90
+ tierSummary?: string;
91
+ whyItFits?: string;
92
+ whatItSolves?: string;
93
+ implementationFocus?: string;
94
+ expectedOutcome?: string;
95
+ finalWord?: string;
96
+ roadmap?: Record<string, RoadmapPhase>;
97
+ pillarNotes?: Record<string, string>;
98
+ }
99
+
100
+ /**
101
+ * One pillar as the report presents it: the authored label and subtitle, this
102
+ * person's score, the stage's note, and the pillar's colour. Held in the
103
+ * assessment's own category order (not sorted by score) because the design
104
+ * shows the three pillars in a fixed sequence with fixed colours.
105
+ */
106
+ export interface PillarCard {
107
+ label: string;
108
+ subtitle: string;
109
+ value: number;
110
+ max: number;
111
+ note: string;
112
+ color: string;
113
+ }
114
+
115
+ /** Per-category display authored on `assessments.category_descriptions`. */
116
+ export interface CategoryDisplay {
117
+ description?: string;
118
+ subtitle?: string;
119
+ color?: string;
120
+ max?: number;
121
+ }
122
+
123
+ /** The shape a report resource_loader stamps into loaderData for the blocks. */
124
+ export interface ReportData {
125
+ /** the three pillars in authored order, for the pillar cards */
126
+ pillarCards: PillarCard[];
127
+ reportScore: ReportChartPoint[];
128
+ reportPillars: ReportChartPoint[];
129
+ narrative: string;
130
+ totalScore: number;
131
+ /** overall band label — the Snapshot stage badge */
132
+ stage: string;
133
+ /** band advice — the Snapshot stage summary */
134
+ stageSummary: string;
135
+ /** highest-scoring pillar — the Positioning "Strongest Pillar" column */
136
+ strongest: PillarPosition | null;
137
+ /** mid pillar — the Positioning "Biggest Opportunity" column */
138
+ opportunity: PillarPosition | null;
139
+ /** lowest pillar — the Positioning "Authority Gap" column */
140
+ authorityGap: PillarPosition | null;
141
+ /** section 4 — what this person already has going for them */
142
+ strongestAreas: string[];
143
+ /** section 6 — the numbered actions for this stage */
144
+ priorityActions: TitledAction[];
145
+ /** section 7 */
146
+ strategicFocus: string;
147
+ /** section 8, left column */
148
+ strengths: string[];
149
+ /** section 8, right column */
150
+ opportunities: string[];
151
+ /** section 8 left, numbered for badge rendering */
152
+ strengthsList: NumberedItem[];
153
+ /** section 8 right, numbered for badge rendering */
154
+ opportunitiesList: NumberedItem[];
155
+ /** section 9 */
156
+ twelveMonthTarget: string;
157
+ /** section 10 — three phases, each a focus word plus four actions */
158
+ roadmap: RoadmapPhase[];
159
+ /** Anna's remaining template elements — empty until a stage authors them. */
160
+ whoYouHelp: string;
161
+ knownFor: string;
162
+ insights: string[];
163
+ contentThemes: TitledAction[];
164
+ contentRhythm: string;
165
+ northStar: string;
166
+ priorityFocus: string;
167
+ priorityFocusSummary: string;
168
+ recommendedTier: string;
169
+ tierSummary: string;
170
+ whyItFits: string;
171
+ whatItSolves: string;
172
+ implementationFocus: string;
173
+ expectedOutcome: string;
174
+ finalWord: string;
175
+ }
176
+
177
+ /** Minimal view of the stored response — total_score + category_scores. */
178
+ export interface ReportResponseInput {
179
+ total_score?: number | null;
180
+ category_scores?: Record<string, number> | null;
181
+ }
182
+
183
+ /** Minimal view of the assessment metadata — title plus the stage model. */
184
+ export interface ReportAssessmentInput {
185
+ title?: string | null;
186
+ stage_bands?: unknown;
187
+ /** authored pillar order, e.g. ["Be Seen", "Be Heard", "Be Chosen"] */
188
+ categories?: unknown;
189
+ /** per-pillar display: subtitle, colour, description */
190
+ category_descriptions?: unknown;
191
+ }
192
+
193
+ /** The fallback pillar colour when the assessment authors none. */
194
+ const DEFAULT_PILLAR_COLOR = "var(--color-primary)";
195
+
196
+ /**
197
+ * Match a pillar label to its authored stage note.
198
+ *
199
+ * The stage designs key these notes by the distinctive word — `seen`, `heard`,
200
+ * `chosen` — while the assessment names the pillars "Be Seen", "Be Heard",
201
+ * "Be Chosen". A plain lowercase lookup therefore missed EVERY note, and the
202
+ * positioning cards silently fell back to their generic role lines: present,
203
+ * plausible, and identical for every respondent, so nothing looked broken.
204
+
205
+ * Tries the whole label first, then the last word, so either authoring style
206
+ * resolves and neither has to know about the other.
207
+ */
208
+ export function pillarNote(band: StageBand | null, label: string): string {
209
+ const notes = band?.pillarNotes;
210
+ if (!notes) return "";
211
+ const whole = label.trim().toLowerCase();
212
+ const lastWord = whole.split(/\s+/).pop() ?? whole;
213
+ return (notes[whole] ?? notes[lastWord] ?? "").trim();
214
+ }
215
+
216
+ /**
217
+ * Build the pillar cards in the assessment's OWN category order. Sorting by
218
+ * score is right for "strongest / gap" positioning and wrong here: a reader
219
+ * comparing two reports needs the pillars in the same place every time, and
220
+ * each pillar keeps one colour across every report.
221
+ */
222
+ export function buildPillarCards(
223
+ assessment: ReportAssessmentInput | null | undefined,
224
+ categoryScores: Record<string, number>,
225
+ band: StageBand | null,
226
+ ): PillarCard[] {
227
+ const categories = Array.isArray(assessment?.categories)
228
+ ? (assessment.categories as unknown[]).filter(
229
+ (c): c is string => typeof c === "string" && c.length > 0,
230
+ )
231
+ : Object.keys(categoryScores);
232
+ const display = (
233
+ assessment?.category_descriptions && typeof assessment.category_descriptions === "object"
234
+ ? assessment.category_descriptions
235
+ : {}
236
+ ) as Record<string, CategoryDisplay | string>;
237
+ return categories.map((label) => {
238
+ const raw = display[label];
239
+ const cfg: CategoryDisplay = typeof raw === "string" ? { description: raw } : (raw ?? {});
240
+ const score = Number(categoryScores[label] ?? 0);
241
+ return {
242
+ label,
243
+ subtitle: cfg.subtitle ?? "",
244
+ value: Number.isFinite(score) ? Math.max(0, Math.round(score)) : 0,
245
+ max: typeof cfg.max === "number" && cfg.max > 0 ? cfg.max : 100,
246
+ note: pillarNote(band, label) || cfg.description || "",
247
+ color: cfg.color || DEFAULT_PILLAR_COLOR,
248
+ };
249
+ });
250
+ }
251
+
252
+ /**
253
+ * Read the authored bands off the assessment row. Returns [] when the column
254
+ * is null or malformed, which is the signal to fall back rather than throw —
255
+ * a report that renders a score with no stage copy is degraded; one that 500s
256
+ * because a JSON column is empty is broken.
257
+ */
258
+ export function readStageBands(assessment?: ReportAssessmentInput | null): StageBand[] {
259
+ const raw = assessment?.stage_bands;
260
+ if (!raw || typeof raw !== "object") return [];
261
+ const bands = (raw as { bands?: unknown }).bands;
262
+ if (!Array.isArray(bands)) return [];
263
+ return bands.filter(
264
+ (band): band is StageBand =>
265
+ !!band && typeof band === "object" && typeof (band as StageBand).name === "string",
266
+ );
267
+ }
268
+
269
+ /**
270
+ * Select the band a score falls in. Bands are ascending by `max`; the first
271
+ * whose `max` the score does not exceed wins, and a band without `max` is the
272
+ * catch-all. Authored order is respected rather than re-sorted, so a
273
+ * mis-ordered row is visible in the output instead of silently corrected.
274
+ */
275
+ export function selectStageBand(bands: StageBand[], score: number): StageBand | null {
276
+ for (const band of bands) {
277
+ if (typeof band.max !== "number") return band;
278
+ if (score <= band.max) return band;
279
+ }
280
+ return bands.length > 0 ? (bands[bands.length - 1] ?? null) : null;
281
+ }
282
+
283
+ /**
284
+ * The narrative is authored per band and carries `{{score}}` where the design
285
+ * spelled a number, so one band serves every score inside it. With no band,
286
+ * the fallback states the score and nothing it cannot support.
287
+ */
288
+ export function generateNarrative(
289
+ totalScore: number,
290
+ band: StageBand | null,
291
+ assessment?: ReportAssessmentInput | null,
292
+ ): string {
293
+ const authored = band?.executiveSummary?.trim();
294
+ if (authored) return authored.replaceAll("{{score}}", String(totalScore));
295
+ const title = assessment?.title?.trim() || "your assessment";
296
+ return `You scored ${totalScore} on ${title}.`;
297
+ }
298
+
299
+ /** Pair each item with its 1-based ordinal for badge rendering. */
300
+ export function numberItems(items: string[] | undefined): NumberedItem[] {
301
+ if (!Array.isArray(items)) return [];
302
+ return items
303
+ .filter((text): text is string => typeof text === "string" && text.trim().length > 0)
304
+ .map((text, i) => ({ index: String(i + 1), text: text.trim() }));
305
+ }
306
+
307
+ /** Flatten the authored `{month1, month2, month3}` map into render order. */
308
+ export function orderRoadmap(roadmap: Record<string, RoadmapPhase> | undefined): RoadmapPhase[] {
309
+ if (!roadmap || typeof roadmap !== "object") return [];
310
+ return Object.keys(roadmap)
311
+ .sort((a, b) => a.localeCompare(b, "en", { numeric: true }))
312
+ .map((key) => roadmap[key])
313
+ .filter((phase): phase is RoadmapPhase => !!phase && Array.isArray(phase.actions));
314
+ }
315
+
316
+ /**
317
+ * Resolver call shape: the resolver_fn executor invokes every resolver as
318
+ * `fn({ input, client, params })` (see resolveByResolverFn in
319
+ * @teamscala/service-boot/resolve-from-loader-config). computeReportData is
320
+ * registered as the `computeReportData` resolver_fn row, so it receives that
321
+ * context — input.response / input.assessment come from the loader's
322
+ * resolver_input ({response: ':response', assessment: ':assessment'}).
323
+ */
324
+ export function computeReportData({
325
+ input,
326
+ }: {
327
+ input: {
328
+ response?: ReportResponseInput | null;
329
+ assessment?: ReportAssessmentInput | null;
330
+ };
331
+ }): ReportData {
332
+ const { response, assessment } = input;
333
+ const totalScore = Math.max(0, Math.round(Number(response?.total_score ?? 0)));
334
+ const categoryScores = response?.category_scores ?? {};
335
+ const reportScore: ReportChartPoint[] = [{ label: "Score", value: totalScore }];
336
+ const reportPillars: ReportChartPoint[] = Object.entries(categoryScores)
337
+ .filter(([, value]) => Number.isFinite(Number(value)))
338
+ .map(([label, value]) => ({ label, value: Math.max(0, Math.round(Number(value))) }))
339
+ .filter((p) => p.label.length > 0)
340
+ .sort((a, b) => b.value - a.value);
341
+
342
+ const band = selectStageBand(readStageBands(assessment), totalScore);
343
+ const narrative = generateNarrative(totalScore, band, assessment);
344
+ const pillarCards = buildPillarCards(assessment, categoryScores, band);
345
+ // The chart bars take each pillar's own colour, so the bar chart and the
346
+ // pillar cards cannot disagree about which colour means which pillar.
347
+ // Only an AUTHORED colour travels to the chart. When the assessment sets
348
+ // none, the bars keep the chart's own default rather than being stamped with
349
+ // the cards' fallback — an assessment that never asked for pillar colours
350
+ // gets exactly the output it got before.
351
+ const colorFor = new Map(
352
+ pillarCards.filter((c) => c.color !== DEFAULT_PILLAR_COLOR).map((c) => [c.label, c.color]),
353
+ );
354
+ for (const point of reportPillars) {
355
+ const color = colorFor.get(point.label);
356
+ if (color) point.color = color;
357
+ }
358
+
359
+ // The positioning trio names the pillar; the advisory line beside it is the
360
+ // band's note for that pillar when one is authored, otherwise the generic
361
+ // role of that column. Falling back per-column rather than dropping the
362
+ // whole card keeps the section rendering while copy is still being written.
363
+ const noteFor = (label: string, fallback: string): string =>
364
+ pillarNote(band, label) || fallback;
365
+ const pos = (p: ReportChartPoint | undefined, fallback: string): PillarPosition | null =>
366
+ p ? { label: p.label, summary: noteFor(p.label, fallback) } : null;
367
+
368
+ const strongest = pos(
369
+ reportPillars[0],
370
+ "Your strongest pillar — protect and compound this advantage.",
371
+ );
372
+ const opportunity =
373
+ reportPillars.length >= 3
374
+ ? pos(
375
+ reportPillars[1],
376
+ "Your biggest growth opportunity — focused effort here lifts overall authority.",
377
+ )
378
+ : null;
379
+ const authorityGap =
380
+ reportPillars.length >= 2
381
+ ? pos(
382
+ reportPillars[reportPillars.length - 1],
383
+ "Your lowest pillar — the single biggest lever for improvement right now.",
384
+ )
385
+ : null;
386
+
387
+ return {
388
+ pillarCards,
389
+ reportScore,
390
+ reportPillars,
391
+ narrative,
392
+ totalScore,
393
+ stage: band?.name ?? "",
394
+ stageSummary: band?.tagline ?? "",
395
+ strongest,
396
+ opportunity,
397
+ authorityGap,
398
+ strongestAreas: band?.strongestAreas ?? [],
399
+ priorityActions: band?.priorityActions ?? [],
400
+ strategicFocus: band?.strategicFocus ?? "",
401
+ strengths: band?.strengths ?? [],
402
+ opportunities: band?.opportunities ?? [],
403
+ strengthsList: numberItems(band?.strengths),
404
+ opportunitiesList: numberItems(band?.opportunities),
405
+ twelveMonthTarget: band?.twelveMonthTarget ?? "",
406
+ roadmap: orderRoadmap(band?.roadmap),
407
+ whoYouHelp: band?.whoYouHelp ?? "",
408
+ knownFor: band?.knownFor ?? "",
409
+ insights: band?.insights ?? [],
410
+ contentThemes: band?.contentThemes ?? [],
411
+ contentRhythm: band?.contentRhythm ?? "",
412
+ northStar: band?.northStar ?? "",
413
+ priorityFocus: band?.priorityFocus ?? "",
414
+ priorityFocusSummary: band?.priorityFocusSummary ?? "",
415
+ recommendedTier: band?.recommendedTier ?? "",
416
+ tierSummary: band?.tierSummary ?? "",
417
+ whyItFits: band?.whyItFits ?? "",
418
+ whatItSolves: band?.whatItSolves ?? "",
419
+ implementationFocus: band?.implementationFocus ?? "",
420
+ expectedOutcome: band?.expectedOutcome ?? "",
421
+ finalWord: band?.finalWord ?? "",
422
+ };
423
+ }
@@ -20,11 +20,11 @@ import { resolveEmailSender } from "@teamscala/email/identity/resolve-email-send
20
20
  import { decryptSecretConfig } from "@teamscala/encryption/crypto/secret-config";
21
21
  import { loadRegistryConfig } from "@teamscala/db/registry/load-config";
22
22
  import { fetchOrgBrand } from "@teamscala/resolver-engine/data-resolvers/fetch-org-brand";
23
- import type { StageBand } from "@teamscala/assessment-block/report-data";
23
+ import type { StageBand } from "./assessment-report-data.ts";
24
24
  import {
25
25
  generateNarrative,
26
26
  orderRoadmap,
27
- } from "@teamscala/assessment-block/report-data";
27
+ } from "./assessment-report-data.ts";
28
28
 
29
29
  export async function sendAuthorityReportEmail(opts: {
30
30
  to: string;
@@ -4,11 +4,11 @@
4
4
  * @edit edit directly
5
5
  *
6
6
  * The valibot schemas + row helpers of the chatSession ORPC router: procedure
7
- * input schemas, the conversation ownership check, and the stored-row → wire
8
- * message mapping.
7
+ * input schemas, and the stored-row → wire message mapping.
9
8
  *
10
9
  * Split out of fn-routers/chat-session-router.ts (455 lines) — byte-for-byte
11
- * move; import paths adjusted only.
10
+ * move; import paths adjusted only. Conversation ownership lives in its own
11
+ * sibling, ./conversation-ownership.ts (single-canonical-unit).
12
12
  */
13
13
 
14
14
  import * as v from "valibot";
@@ -90,25 +90,8 @@ export async function throwForbidden(message: string): Promise<never> {
90
90
  throw new ORPCError("FORBIDDEN", { message });
91
91
  }
92
92
 
93
- /**
94
- * Reject if the conversation does not belong to the calling user. Ownership
95
- * by user_id is the complete proof (the row was created with this user's id);
96
- * no org check needed.
97
- */
98
- export async function assertOwns(
99
- prisma: any,
100
- conversationId: string,
101
- user: SessionUser,
102
- ): Promise<any> {
103
- const conv = await prisma.conversations.findUnique({
104
- where: { id: conversationId },
105
- });
106
- if (!conv || conv.user_id !== user.id) await throwForbidden("Conversation not found.");
107
- return conv;
108
- }
109
-
110
93
  /** Map stored message rows to the wire shape the chat client restores. */
111
- export function mapMessages(msgs: any[]) {
94
+ export function mapMessages(msgs: unknown[]) {
112
95
  return msgs
113
96
  .filter((m: any) => m.role === "user" || m.role === "assistant")
114
97
  .map((m: any) => ({
@@ -37,7 +37,6 @@ import { authed, type Context } from "./auth-router.ts";
37
37
  import { persistChatMessages } from "./persist-chat-messages.ts";
38
38
 
39
39
  import {
40
- assertOwns,
41
40
  assistantType,
42
41
  clearSchema,
43
42
  createOrRestoreSchema,
@@ -50,6 +49,7 @@ import {
50
49
  SessionUser,
51
50
  throwForbidden,
52
51
  } from "./chat-session-router-shared.ts";
52
+ import { assertOwns } from "./conversation-ownership.ts";
53
53
 
54
54
  export const chatSessionRouter = {
55
55
  // createOrRestore — resume the caller's most-recent ACTIVE thread for this
@@ -0,0 +1,23 @@
1
+ /**
2
+ * @system service-boot
3
+ * @status handwritten
4
+ * @edit edit directly
5
+ *
6
+ * Conversation ownership assertion for the chatSession ORPC router — the
7
+ * reject-if-not-yours gate every mutating procedure runs before touching rows.
8
+ */
9
+
10
+ import { throwForbidden, type SessionUser } from "./chat-session-router-shared.ts";
11
+
12
+ /* eslint-disable @typescript-eslint/no-explicit-any */
13
+ export async function assertOwns(
14
+ prisma: any,
15
+ conversationId: string,
16
+ user: SessionUser,
17
+ ): Promise<unknown> {
18
+ const conv = await prisma.conversations.findUnique({
19
+ where: { id: conversationId },
20
+ });
21
+ if (!conv || conv.user_id !== user.id) await throwForbidden("Conversation not found.");
22
+ return conv;
23
+ }
@@ -16,7 +16,7 @@
16
16
  */
17
17
 
18
18
  import { registerCustomRouter } from "@teamscala/orpc/app-router";
19
- import { puckPagesRouter } from "@teamscala/service-boot/page-server-orpc/puck-pages-router";
19
+ import { puckPagesRouter } from "../page-server-orpc/puck-pages-router";
20
20
  import { watchDataChange } from "@teamscala/realtime-utils/data-change-procedure";
21
21
  import { watchPresence } from "@teamscala/realtime-utils/presence-procedure";
22
22
  import { agreementSendRouter } from "./agreement-send-router.ts";
@@ -49,7 +49,7 @@ export type { CertificateData, CertificateCopy } from "./agreement-certificate.t
49
49
  interface Context {
50
50
  prisma: any;
51
51
  headers: Headers;
52
- resolveUser?: (headers: Headers, prisma: any) => Promise<any>;
52
+ resolveUser?: (headers: Headers, prisma: any) => Promise<unknown>;
53
53
  }
54
54
 
55
55
  const pub = os.$context<Context>();
@@ -22,14 +22,14 @@ import { recordEvent } from "@teamscala/event-log/record-event";
22
22
  import {
23
23
  readStageBands,
24
24
  selectStageBand,
25
- } from "@teamscala/assessment-block/report-data";
25
+ } from "./assessment-report-data.ts";
26
26
  import { sendAuthorityReportEmail } from "./assessment-report-email.ts";
27
27
  import { scoreAnswers, toBandSlug, type StoredQuestion } from "./assessment-scoring.ts";
28
28
 
29
29
  interface Context {
30
30
  prisma: any;
31
31
  headers: Headers;
32
- resolveUser?: (headers: Headers, prisma: any) => Promise<any>;
32
+ resolveUser?: (headers: Headers, prisma: any) => Promise<unknown>;
33
33
  }
34
34
 
35
35
  const pub = os.$context<Context>();
@@ -17,7 +17,7 @@ import { persistChatMessages } from "./persist-chat-messages.ts";
17
17
  interface Context {
18
18
  prisma: any;
19
19
  headers: Headers;
20
- resolveUser?: (headers: Headers, prisma: any) => Promise<any>;
20
+ resolveUser?: (headers: Headers, prisma: any) => Promise<unknown>;
21
21
  }
22
22
 
23
23
  const pub = os.$context<Context>();
@@ -33,6 +33,22 @@ export const FaultEscalationConfigSchema = v.object({
33
33
  ),
34
34
  ),
35
35
  resolve_after_minutes: v.number(),
36
+ /** Per-kind volume sources closing the quiet-vs-dead gap: a fault kind
37
+ * whose producer can DIE maps to the kind whose row VOLUME proves the
38
+ * pipeline alive. Without a source, resolution on quiet is unchanged;
39
+ * with one, resolve additionally requires the volume floor in-window,
40
+ * and quiet-with-zero-volume HOLDS the episode open instead (one hold
41
+ * notice per episode-state — measured 2026-08-27: the dead llm-router
42
+ * emitted zero failures and the sweep closed as healthy). */
43
+ volume_sources: v.optional(
44
+ v.record(
45
+ v.string(),
46
+ v.object({
47
+ volume_kind_pattern: v.string(),
48
+ min_volume: v.optional(v.number()),
49
+ }),
50
+ ),
51
+ ),
36
52
  /** The orchestrator's work item — escalations are COMMENTS on it, never
37
53
  * task rows (the fleet-task-placement CHECK constraint refuses a system
38
54
  * task without session identity, and an escalation is a finding, not an