@skyhook-io/radar-app 1.13.1 → 1.13.2

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 (47) hide show
  1. package/package.json +2 -2
  2. package/src/App.tsx +19 -1
  3. package/src/RadarApp.tsx +2 -2
  4. package/src/api/diagnose.test.ts +268 -0
  5. package/src/api/diagnose.ts +72 -9
  6. package/src/components/diagnose/AISettings.tsx +1 -1
  7. package/src/components/diagnose/AgentSetupNotice.tsx +5 -5
  8. package/src/components/diagnose/ApplyDialog.test.tsx +72 -0
  9. package/src/components/diagnose/DiagnoseContext.tsx +12 -17
  10. package/src/components/diagnose/DiagnoseSurface.test.tsx +211 -16
  11. package/src/components/diagnose/DiagnoseSurface.tsx +464 -133
  12. package/src/components/diagnose/Home.test.tsx +293 -0
  13. package/src/components/diagnose/Home.tsx +289 -119
  14. package/src/components/diagnose/InvestigationEvidencePane.test.tsx +2170 -0
  15. package/src/components/diagnose/InvestigationEvidencePane.tsx +2253 -0
  16. package/src/components/diagnose/InvestigationResourceEvidence.test.tsx +257 -0
  17. package/src/components/diagnose/InvestigationResourceEvidence.tsx +214 -0
  18. package/src/components/diagnose/InvestigationView.test.ts +17 -0
  19. package/src/components/diagnose/InvestigationView.tsx +1900 -393
  20. package/src/components/diagnose/LocalDiagnoseAction.tsx +42 -25
  21. package/src/components/diagnose/agentCatalog.ts +1 -1
  22. package/src/components/diagnose/diagnoseEvidenceTypes.ts +151 -0
  23. package/src/components/diagnose/investigationEvidence.test.ts +3109 -0
  24. package/src/components/diagnose/investigationEvidence.ts +3492 -0
  25. package/src/components/diagnose/investigationEvidencePresentation.test.ts +447 -0
  26. package/src/components/diagnose/investigationEvidencePresentation.ts +167 -0
  27. package/src/components/diagnose/investigationExplanation.test.ts +63 -0
  28. package/src/components/diagnose/investigationExplanation.ts +22 -0
  29. package/src/components/diagnose/investigationResourceEvidenceModel.ts +322 -0
  30. package/src/components/diagnose/investigationSourceFocus.test.ts +143 -0
  31. package/src/components/diagnose/investigationSourceFocus.ts +98 -0
  32. package/src/components/diagnose/investigationState.test.ts +695 -0
  33. package/src/components/diagnose/investigationState.ts +451 -0
  34. package/src/components/diagnose/parts.test.tsx +864 -3
  35. package/src/components/diagnose/parts.tsx +1337 -541
  36. package/src/components/diagnose/target.test.ts +39 -0
  37. package/src/components/diagnose/target.ts +36 -0
  38. package/src/components/diagnose/useDisclosureReveal.ts +117 -0
  39. package/src/components/home/MCPSetupDialog.tsx +2 -2
  40. package/src/components/home/mcpToolCatalog.test.ts +22 -0
  41. package/src/components/home/mcpToolCatalog.ts +3 -2
  42. package/src/components/issues/IssuesPane.tsx +5 -1
  43. package/src/components/settings/SettingsDialog.tsx +11 -13
  44. package/src/components/workload/WorkloadView.tsx +1 -1
  45. package/src/context/DiagnoseCustomization.tsx +11 -8
  46. package/src/index.css +63 -79
  47. package/src/index.ts +1 -1
@@ -0,0 +1,3492 @@
1
+ import {
2
+ CORE_RESOURCES,
3
+ defaultConditionTone,
4
+ displayKind,
5
+ englishPlural,
6
+ kindToPlural,
7
+ stripAnsi,
8
+ type Issue,
9
+ type IssueRecentChange,
10
+ type Topology,
11
+ } from "@skyhook-io/k8s-ui";
12
+ import { fnv1a32 } from "@skyhook-io/k8s-ui/utils/structure-hash";
13
+ import { apiVersionToGroup } from "../../utils/navigation";
14
+
15
+ import {
16
+ diagnosisSeverityTone,
17
+ type DiagnosisChangeContext,
18
+ type DiagnosisCrashCause,
19
+ type DiagnosisDNSContext,
20
+ type DiagnosisEvidenceLimitationBase,
21
+ type DiagnosisEvidenceTone,
22
+ type DiagnosisFilteredLogs,
23
+ type DiagnosisPodContainerRef,
24
+ type DiagnosisPodLogEntry,
25
+ type DiagnosisResourceContext,
26
+ type DiagnosisResourceRef,
27
+ type DiagnosisStartupBlocker,
28
+ } from "./diagnoseEvidenceTypes";
29
+ import type { RootCauseEvidence } from "../../api/diagnose";
30
+ import { investigationResourceEvidenceSummary } from "./investigationResourceEvidenceModel";
31
+
32
+ /**
33
+ * The projection deliberately consumes only the small, structural portion of
34
+ * Turn that it needs. TimelineItem is private to parts.tsx today; Turn[] is
35
+ * structurally assignable to this type without coupling evidence extraction to
36
+ * the transcript renderer.
37
+ */
38
+ export interface InvestigationEvidenceTurn {
39
+ timeline: readonly InvestigationEvidenceTimelineItem[];
40
+ question?: string;
41
+ apply?: boolean;
42
+ verify?: boolean;
43
+ status?: "running" | "done" | "error";
44
+ }
45
+
46
+ /** The resource the investigation was opened for. */
47
+ export interface InvestigationEvidenceTarget {
48
+ kind: string;
49
+ /** Kubernetes API group; empty means core. */
50
+ group: string;
51
+ namespace?: string;
52
+ name: string;
53
+ }
54
+
55
+ export type InvestigationEvidencePhase =
56
+ "initial" | "followup" | "verification" | "apply";
57
+
58
+ export type InvestigationEvidenceTimelineItem =
59
+ | { kind: "thinking"; text: string }
60
+ | {
61
+ kind: "tool";
62
+ id: string;
63
+ tool: string;
64
+ status: string;
65
+ summary?: string;
66
+ result?: string;
67
+ evidenceRef?: string;
68
+ radarEvidence?: boolean;
69
+ truncated?: boolean;
70
+ isError?: boolean;
71
+ };
72
+
73
+ export type InvestigationEvidenceTier =
74
+ "key" | "supporting" | "context" | "checked";
75
+
76
+ export type InvestigationEvidenceRelevance =
77
+ "target" | "producer-related" | "broader";
78
+
79
+ export type InvestigationEvidenceKind =
80
+ | "issue"
81
+ | "startup"
82
+ | "crash"
83
+ | "resource"
84
+ | "logs"
85
+ | "events"
86
+ | "changes"
87
+ | "dns"
88
+ | "network"
89
+ | "relationships"
90
+ | "topology"
91
+ | "inventory"
92
+ | "receipt";
93
+
94
+ type InvestigationSemanticDomain = "issue" | "startup" | "crash" | "dns";
95
+
96
+ export interface InvestigationEvidenceSource {
97
+ /** DOM-safe stable identity derived from turn index + the agent step ID. */
98
+ id: string;
99
+ turnIndex: number;
100
+ timelineIndex: number;
101
+ stepId: string;
102
+ tool: string;
103
+ /** The exact agent-emitted tool input shown in Activity. */
104
+ args?: string;
105
+ /** Stable flattened transcript order. */
106
+ order: number;
107
+ /** Which chronological phase of the run produced this source. */
108
+ phase: InvestigationEvidencePhase;
109
+ /** True only when the agent transport explicitly marked the tool result successful. */
110
+ confirmedSuccess: boolean;
111
+ /** Server-issued, turn-scoped identity for this exact retained result. */
112
+ evidenceRef?: string;
113
+ /**
114
+ * The highest-priority evidence group produced by this call. Evidence panes
115
+ * use it for one unique Activity → Evidence anchor even when a bundle fans
116
+ * out into several cards.
117
+ */
118
+ primaryGroupId?: string;
119
+ }
120
+
121
+ export interface InvestigationResourceContext extends DiagnosisResourceContext {
122
+ issueSummary?: {
123
+ count: number;
124
+ highestSeverity?: string;
125
+ topReason?: string;
126
+ bySource?: Record<string, number>;
127
+ };
128
+ auditSummary?: {
129
+ count: number;
130
+ highestSeverity?: string;
131
+ topFinding?: string;
132
+ };
133
+ policySummary?: unknown;
134
+ podSummary?: unknown;
135
+ }
136
+
137
+ export interface InvestigationGitOpsDiagnosis {
138
+ tool: "argocd" | "flux";
139
+ sync?: string;
140
+ health?: string;
141
+ operationPhase?: string;
142
+ suspended?: boolean;
143
+ ready?: string;
144
+ appliedRevision?: string;
145
+ }
146
+
147
+ export interface InvestigationKubernetesResource {
148
+ apiVersion: string;
149
+ kind: string;
150
+ metadata: {
151
+ name: string;
152
+ namespace?: string;
153
+ [key: string]: unknown;
154
+ };
155
+ spec?: unknown;
156
+ status?: unknown;
157
+ summaryContext?: unknown;
158
+ [key: string]: unknown;
159
+ }
160
+
161
+ /** Current `list_resources` row shape (`ai/context.ResourceSummary`). */
162
+ export interface InvestigationResourceSummary {
163
+ kind: string;
164
+ name: string;
165
+ namespace?: string;
166
+ status?: string;
167
+ ready?: string;
168
+ issue?: string;
169
+ age?: string;
170
+ terminating?: boolean;
171
+ restarts?: number;
172
+ lastTerminatedReason?: string;
173
+ lastRestartedAge?: string;
174
+ summaryContext?: {
175
+ health?: string;
176
+ issueCount?: number;
177
+ managedBy?: {
178
+ kind: string;
179
+ source: string;
180
+ name: string;
181
+ namespace?: string;
182
+ };
183
+ };
184
+ [key: string]: unknown;
185
+ }
186
+
187
+ export interface InvestigationEventEvidence {
188
+ reason: string;
189
+ message: string;
190
+ type: string;
191
+ count: number;
192
+ lastTimestamp: string;
193
+ }
194
+
195
+ export interface InvestigationTopologyNode {
196
+ id: string;
197
+ kind: string;
198
+ name: string;
199
+ status?: string;
200
+ data?: Record<string, unknown> | null;
201
+ }
202
+
203
+ export interface InvestigationTopologyEdge {
204
+ id?: string;
205
+ source: string;
206
+ target: string;
207
+ type: string;
208
+ label?: string;
209
+ }
210
+
211
+ export interface InvestigationNetworkRoute {
212
+ route: string;
213
+ target?: string;
214
+ outcome: string;
215
+ failedLayer?: string;
216
+ confidence?: string;
217
+ evidence?: string;
218
+ benign?: boolean;
219
+ }
220
+
221
+ export interface InvestigationNetworkEvidence {
222
+ subject: DiagnosisResourceRef;
223
+ verdict: "healthy" | "degraded" | "broken" | "unknown";
224
+ reason?: string;
225
+ diagnosis?: {
226
+ class?: string;
227
+ severity?: string;
228
+ summary: string;
229
+ route?: string;
230
+ nextAction?: string;
231
+ };
232
+ summary: {
233
+ tested: number;
234
+ passed: number;
235
+ failed: number;
236
+ derived?: number;
237
+ skipped: number;
238
+ headline: string;
239
+ };
240
+ routes: InvestigationNetworkRoute[];
241
+ }
242
+
243
+ export type InvestigationEvidenceData =
244
+ | {
245
+ type: "issue";
246
+ issue: Issue;
247
+ /**
248
+ * A broad `issues` query can return failures from other resources. Keep
249
+ * those factual observations, but do not let agent selection alone imply
250
+ * that they explain the resource under investigation.
251
+ */
252
+ relevance: "target" | "producer-related" | "broader";
253
+ }
254
+ | {
255
+ type: "startup";
256
+ blocker: DiagnosisStartupBlocker;
257
+ /** Exact blocker object when the diagnosis producer established it. */
258
+ subject?: DiagnosisResourceRef;
259
+ }
260
+ | {
261
+ type: "crash";
262
+ crash: DiagnosisCrashCause;
263
+ /** Namespace of the producing check's subject; pods live there. */
264
+ namespace?: string;
265
+ }
266
+ | {
267
+ type: "resource";
268
+ resource: InvestigationKubernetesResource;
269
+ resourceContext?: InvestigationResourceContext;
270
+ warnings: string[];
271
+ gitOpsDiagnosis?: InvestigationGitOpsDiagnosis;
272
+ }
273
+ | {
274
+ type: "logs";
275
+ pod: string;
276
+ container: string;
277
+ /** Namespace the producing call actually read; absent when unstated. */
278
+ namespace?: string;
279
+ previous: boolean;
280
+ logs?: DiagnosisFilteredLogs;
281
+ warnings: string[];
282
+ error?: string;
283
+ }
284
+ | {
285
+ type: "events";
286
+ events: InvestigationEventEvidence[];
287
+ scope: string;
288
+ }
289
+ | {
290
+ type: "changes";
291
+ changes: IssueRecentChange[];
292
+ scope: string;
293
+ changeContext?: DiagnosisChangeContext;
294
+ }
295
+ | { type: "dns"; dns: DiagnosisDNSContext }
296
+ | { type: "network"; network: InvestigationNetworkEvidence }
297
+ | {
298
+ type: "relationships";
299
+ root: DiagnosisResourceRef;
300
+ nodes: InvestigationTopologyNode[];
301
+ edges: InvestigationTopologyEdge[];
302
+ truncated: boolean;
303
+ }
304
+ | {
305
+ type: "topology";
306
+ stats: { nodes: number; edges: number };
307
+ namespaces: Array<{ namespace: string; chains: string[] }>;
308
+ problems: string[];
309
+ warnings: string[];
310
+ }
311
+ | {
312
+ type: "inventory";
313
+ resources: InvestigationResourceSummary[];
314
+ scope: string;
315
+ }
316
+ | {
317
+ type: "receipt";
318
+ checked: "issues" | "events" | "changes" | "inventory" | "logs";
319
+ scope: string;
320
+ message: string;
321
+ };
322
+
323
+ export interface InvestigationEvidenceObservation {
324
+ source: InvestigationEvidenceSource;
325
+ revision: number;
326
+ /** This exact observation predates a later successful verification of its proof scope. */
327
+ historical: boolean;
328
+ /** Whether this semantic item differs from its immediately previous observation. */
329
+ changedFromPrevious: boolean;
330
+ /**
331
+ * How this producer-backed observation relates to the resource being
332
+ * investigated. Broader observations remain useful context, but agent
333
+ * selection alone must never promote them as support for this target.
334
+ */
335
+ relevance: InvestigationEvidenceRelevance;
336
+ tier: InvestigationEvidenceTier;
337
+ tone: DiagnosisEvidenceTone;
338
+ title: string;
339
+ summary?: string;
340
+ data: InvestigationEvidenceData;
341
+ }
342
+
343
+ export interface InvestigationEvidenceGroup {
344
+ /** Stable DOM-safe identity for this semantic evidence item. */
345
+ id: string;
346
+ /** Raw deterministic identity used to merge repeated observations. */
347
+ identity: string;
348
+ kind: InvestigationEvidenceKind;
349
+ /** Latest observation predates the most recent completed verification turn. */
350
+ historical: boolean;
351
+ firstOrder: number;
352
+ observations: InvestigationEvidenceObservation[];
353
+ /** Strongest-provenance observation, newest when provenance is equal. */
354
+ latest: InvestigationEvidenceObservation;
355
+ /** Newest observation regardless of proof strength; used for chronology. */
356
+ chronologicalLatest: InvestigationEvidenceObservation;
357
+ }
358
+
359
+ export interface InvestigationEvidenceLimitation extends DiagnosisEvidenceLimitationBase {
360
+ /** A qualified history result, not a failed collection. */
361
+ presentation?: "history";
362
+ firstOrder: number;
363
+ sources: InvestigationEvidenceSource[];
364
+ }
365
+
366
+ export interface InvestigationEvidenceCoverage {
367
+ /** Completed calls to a tool with a typed evidence adapter. */
368
+ attempted: number;
369
+ /** Adapted calls that contributed at least one evidence group. */
370
+ projected: number;
371
+ /** Adapted calls with a producer-declared or transport limitation. */
372
+ limited: number;
373
+ /** Calls that produced a strict, successful zero-result receipt. */
374
+ checked: number;
375
+ }
376
+
377
+ export interface InvestigationEvidenceProjection {
378
+ groups: InvestigationEvidenceGroup[];
379
+ limitations: InvestigationEvidenceLimitation[];
380
+ sources: InvestigationEvidenceSource[];
381
+ /** Every retained tool item carrying a server-issued ref, eligible or not. */
382
+ evidenceRefSources: InvestigationEvidenceSource[];
383
+ /** Complete confirmed-success sources eligible for server-authored links. */
384
+ citableSources: InvestigationEvidenceSource[];
385
+ coverage: InvestigationEvidenceCoverage;
386
+ }
387
+
388
+ export interface InvestigationRootCauseEvidenceLink {
389
+ source: InvestigationEvidenceSource;
390
+ /** Canonical semantic group containing this source’s primary observation. */
391
+ originalGroupId?: string;
392
+ }
393
+
394
+ export interface InvestigationRootCauseEvidenceResolution {
395
+ status: "linked" | "missing" | "invalid";
396
+ links: InvestigationRootCauseEvidenceLink[];
397
+ }
398
+
399
+ function exactDiagnosisResourceRef(ref: {
400
+ kind?: unknown;
401
+ group?: unknown;
402
+ namespace?: unknown;
403
+ name?: unknown;
404
+ }): DiagnosisResourceRef | undefined {
405
+ const { kind, name } = ref;
406
+ if (
407
+ !nonEmptyString(kind) ||
408
+ kind.trim() !== kind ||
409
+ !nonEmptyString(name) ||
410
+ name.trim() !== name ||
411
+ (ref.group !== undefined &&
412
+ (typeof ref.group !== "string" || ref.group.trim() !== ref.group)) ||
413
+ (ref.namespace !== undefined &&
414
+ (typeof ref.namespace !== "string" ||
415
+ ref.namespace.trim() !== ref.namespace))
416
+ ) {
417
+ return undefined;
418
+ }
419
+ const group = ref.group || undefined;
420
+ const namespace = ref.namespace || undefined;
421
+ const knownKinds = CORE_RESOURCES.filter(
422
+ (resource) =>
423
+ resource.kind.toLowerCase() === kind.toLowerCase() &&
424
+ (group === undefined || resource.group === group),
425
+ );
426
+ const knownScopes = new Set(
427
+ knownKinds.map((resource) => resource.namespaced),
428
+ );
429
+ // This is deliberately only a negative guard. Unknown kinds/groups may be
430
+ // cluster-scoped CRDs, so suppress the link only when Radar's existing
431
+ // resource metadata identifies the kind's scope without ambiguity.
432
+ if (knownScopes.size === 1) {
433
+ if (knownScopes.has(true) && !namespace) return undefined;
434
+ if (knownScopes.has(false) && namespace) return undefined;
435
+ }
436
+ return {
437
+ kind,
438
+ name,
439
+ ...(group ? { group } : {}),
440
+ ...(namespace ? { namespace } : {}),
441
+ };
442
+ }
443
+
444
+ /**
445
+ * The Kubernetes resource an evidence item is about, when the producer payload
446
+ * states one unambiguously. Pod-shaped evidence resolves only through the
447
+ * namespace its producing check actually read; the investigation target's
448
+ * namespace is deliberately never borrowed.
449
+ */
450
+ export function investigationEvidenceSubjectRef(
451
+ data: InvestigationEvidenceData,
452
+ ): DiagnosisResourceRef | undefined {
453
+ const ref = ((): DiagnosisResourceRef | undefined => {
454
+ switch (data.type) {
455
+ case "issue":
456
+ return {
457
+ kind: data.issue.kind,
458
+ group: data.issue.group,
459
+ namespace: data.issue.namespace,
460
+ name: data.issue.name,
461
+ };
462
+ case "startup":
463
+ return data.subject;
464
+ case "resource": {
465
+ const apiVersion = data.resource.apiVersion;
466
+ const group = apiVersionToGroup(apiVersion);
467
+ return {
468
+ kind: data.resource.kind,
469
+ group: group || undefined,
470
+ namespace: data.resource.metadata.namespace,
471
+ name: data.resource.metadata.name,
472
+ };
473
+ }
474
+ case "logs":
475
+ if (!data.namespace) return undefined;
476
+ return { kind: "Pod", namespace: data.namespace, name: data.pod };
477
+ case "crash":
478
+ if (data.crash.pods.length !== 1 || !data.namespace) return undefined;
479
+ return {
480
+ kind: "Pod",
481
+ namespace: data.namespace,
482
+ name: data.crash.pods[0],
483
+ };
484
+ case "network":
485
+ return data.network.subject;
486
+ case "relationships":
487
+ return data.root;
488
+ default:
489
+ return undefined;
490
+ }
491
+ })();
492
+ return ref ? exactDiagnosisResourceRef(ref) : undefined;
493
+ }
494
+
495
+ function domToken(value: string): string {
496
+ // Underscore is reserved as the escape delimiter, so raw input can never
497
+ // impersonate an encoded code point ("/" vs. the literal "_x2f_"). The
498
+ // empty sentinel is safe for the same reason: a literal "_empty_" is escaped.
499
+ if (!value) return "_empty_";
500
+ return Array.from(value, (character) =>
501
+ /[A-Za-z0-9-]/.test(character)
502
+ ? character
503
+ : `_x${character.codePointAt(0)!.toString(16)}_`,
504
+ ).join("");
505
+ }
506
+
507
+ export function investigationEvidenceSourceId(
508
+ turnIndex: number,
509
+ stepId: string,
510
+ ): string {
511
+ return `turn-${turnIndex}-step-${domToken(stepId)}`;
512
+ }
513
+
514
+ export function investigationActivitySourceDomId(sourceId: string): string {
515
+ return `investigation-activity-${sourceId}`;
516
+ }
517
+
518
+ export function investigationEvidenceSourceDomId(sourceId: string): string {
519
+ return `investigation-evidence-${sourceId}`;
520
+ }
521
+
522
+ const investigationEvidenceRefRe = /^ev_[a-z2-7]{26,128}_[a-z2-7]{26,128}$/;
523
+
524
+ export function resolveInvestigationRootCauseEvidence(
525
+ projection: InvestigationEvidenceProjection,
526
+ evidence: RootCauseEvidence | undefined,
527
+ assessmentTurnIndex: number,
528
+ ): InvestigationRootCauseEvidenceResolution {
529
+ if (!evidence || evidence.status === "missing") {
530
+ return { status: "missing", links: [] };
531
+ }
532
+ if (evidence.status !== "linked") {
533
+ return { status: "invalid", links: [] };
534
+ }
535
+ const refs = evidence.refs;
536
+ if (
537
+ !refs ||
538
+ refs.length < 1 ||
539
+ refs.length > 3 ||
540
+ new Set(refs).size !== refs.length ||
541
+ refs.some((ref) => !investigationEvidenceRefRe.test(ref)) ||
542
+ refs.some((ref) => ref.split("_")[1] !== refs[0].split("_")[1])
543
+ ) {
544
+ return { status: "invalid", links: [] };
545
+ }
546
+
547
+ const byRef = new Map<string, InvestigationEvidenceSource[]>();
548
+ for (const source of projection.evidenceRefSources) {
549
+ if (source.turnIndex !== assessmentTurnIndex) continue;
550
+ if (!source.evidenceRef) continue;
551
+ const matches = byRef.get(source.evidenceRef) ?? [];
552
+ matches.push(source);
553
+ byRef.set(source.evidenceRef, matches);
554
+ }
555
+ const citableSourceIds = new Set(
556
+ projection.citableSources
557
+ .filter((source) => source.turnIndex === assessmentTurnIndex)
558
+ .map((source) => source.id),
559
+ );
560
+ const links: InvestigationRootCauseEvidenceLink[] = [];
561
+ for (const ref of refs) {
562
+ const matches = byRef.get(ref);
563
+ // Match the server's fail-closed binding: every current-turn occurrence
564
+ // counts before success/completeness eligibility is considered.
565
+ if (matches?.length !== 1 || !citableSourceIds.has(matches[0].id)) {
566
+ return { status: "invalid", links: [] };
567
+ }
568
+ const source = matches[0];
569
+ const originalGroup = source.primaryGroupId
570
+ ? projection.groups.find((group) => group.id === source.primaryGroupId)
571
+ : undefined;
572
+ links.push({
573
+ source,
574
+ originalGroupId: originalGroup?.observations.some(
575
+ (observation) => observation.source.id === source.id,
576
+ )
577
+ ? originalGroup.id
578
+ : undefined,
579
+ });
580
+ }
581
+ return { status: "linked", links };
582
+ }
583
+
584
+ export function investigationEvidenceStepIdsByTurn(
585
+ projection: InvestigationEvidenceProjection,
586
+ visibleGroupIds?: ReadonlySet<string>,
587
+ ): Map<number, Set<string>> {
588
+ const byTurn = new Map<number, Set<string>>();
589
+ const linkedSourceIds = new Set<string>();
590
+ for (const group of projection.groups) {
591
+ if (visibleGroupIds && !visibleGroupIds.has(group.id)) continue;
592
+ for (const observation of group.observations) {
593
+ if (visibleGroupIds && observation.source.primaryGroupId !== group.id)
594
+ continue;
595
+ linkedSourceIds.add(observation.source.id);
596
+ }
597
+ }
598
+ for (const limitation of projection.limitations) {
599
+ for (const source of limitation.sources) linkedSourceIds.add(source.id);
600
+ }
601
+ const navigableSources = new Map(
602
+ [...projection.sources, ...projection.citableSources].map((source) => [
603
+ source.id,
604
+ source,
605
+ ]),
606
+ );
607
+ for (const source of navigableSources.values()) {
608
+ if (!linkedSourceIds.has(source.id)) continue;
609
+ const stepIds = byTurn.get(source.turnIndex) ?? new Set<string>();
610
+ stepIds.add(source.stepId);
611
+ byTurn.set(source.turnIndex, stepIds);
612
+ }
613
+ return byTurn;
614
+ }
615
+
616
+ function stableHash(value: string): string {
617
+ // FNV-1a keeps long resource/log identities out of DOM IDs. The raw identity
618
+ // remains the Map key, so a hash collision can never merge evidence.
619
+ return fnv1a32(value).toString(36);
620
+ }
621
+
622
+ function record(value: unknown): Record<string, unknown> | undefined {
623
+ return value !== null && typeof value === "object" && !Array.isArray(value)
624
+ ? (value as Record<string, unknown>)
625
+ : undefined;
626
+ }
627
+
628
+ function nonEmptyString(value: unknown): value is string {
629
+ return typeof value === "string" && value.trim().length > 0;
630
+ }
631
+
632
+ function stringArray(value: unknown): string[] | undefined {
633
+ return Array.isArray(value) && value.every((item) => typeof item === "string")
634
+ ? value
635
+ : undefined;
636
+ }
637
+
638
+ function parseJSON(value: string): unknown {
639
+ try {
640
+ return JSON.parse(value);
641
+ } catch {
642
+ return undefined;
643
+ }
644
+ }
645
+
646
+ function kubernetesResource(
647
+ value: unknown,
648
+ ): InvestigationKubernetesResource | undefined {
649
+ const resource = record(value);
650
+ const metadata = record(resource?.metadata);
651
+ // Core Secrets intentionally use Radar's current safe detail contract rather
652
+ // than a Kubernetes object: identity + type + key names, with no values. Make
653
+ // that producer shape canonical for the projection instead of rejecting the
654
+ // exact evidence the agent saw.
655
+ if (
656
+ resource?.kind === "Secret" &&
657
+ !metadata &&
658
+ nonEmptyString(resource.name) &&
659
+ (resource.namespace === undefined ||
660
+ typeof resource.namespace === "string") &&
661
+ (resource.type === undefined || typeof resource.type === "string") &&
662
+ Array.isArray(resource.keys) &&
663
+ resource.keys.every((key) => typeof key === "string")
664
+ ) {
665
+ return {
666
+ ...resource,
667
+ apiVersion: "v1",
668
+ metadata: {
669
+ name: resource.name,
670
+ namespace: resource.namespace as string | undefined,
671
+ ...(record(resource.labels) ? { labels: resource.labels } : {}),
672
+ ...(record(resource.annotations)
673
+ ? { annotations: resource.annotations }
674
+ : {}),
675
+ },
676
+ } as InvestigationKubernetesResource;
677
+ }
678
+ if (
679
+ !resource ||
680
+ !nonEmptyString(resource.apiVersion) ||
681
+ !nonEmptyString(resource.kind) ||
682
+ !metadata ||
683
+ !nonEmptyString(metadata.name)
684
+ ) {
685
+ return undefined;
686
+ }
687
+ return resource as unknown as InvestigationKubernetesResource;
688
+ }
689
+
690
+ function gitOpsDiagnosis(
691
+ value: unknown,
692
+ ): InvestigationGitOpsDiagnosis | undefined {
693
+ const candidate = record(value);
694
+ if (
695
+ !candidate ||
696
+ (candidate.tool !== "argocd" && candidate.tool !== "flux")
697
+ ) {
698
+ return undefined;
699
+ }
700
+ for (const field of [
701
+ "sync",
702
+ "health",
703
+ "operationPhase",
704
+ "ready",
705
+ "appliedRevision",
706
+ ] as const) {
707
+ if (candidate[field] !== undefined && typeof candidate[field] !== "string")
708
+ return undefined;
709
+ }
710
+ if (
711
+ candidate.suspended !== undefined &&
712
+ typeof candidate.suspended !== "boolean"
713
+ )
714
+ return undefined;
715
+ return candidate as unknown as InvestigationGitOpsDiagnosis;
716
+ }
717
+
718
+ function resourceSummary(
719
+ value: unknown,
720
+ ): InvestigationResourceSummary | undefined {
721
+ const candidate = record(value);
722
+ if (
723
+ !candidate ||
724
+ !nonEmptyString(candidate.kind) ||
725
+ !nonEmptyString(candidate.name)
726
+ ) {
727
+ return undefined;
728
+ }
729
+ if (
730
+ candidate.namespace !== undefined &&
731
+ typeof candidate.namespace !== "string"
732
+ ) {
733
+ return undefined;
734
+ }
735
+ for (const field of ["status", "ready", "issue", "age"] as const) {
736
+ if (
737
+ candidate[field] !== undefined &&
738
+ typeof candidate[field] !== "string"
739
+ ) {
740
+ return undefined;
741
+ }
742
+ }
743
+ if (
744
+ (candidate.terminating !== undefined &&
745
+ typeof candidate.terminating !== "boolean") ||
746
+ (candidate.restarts !== undefined && typeof candidate.restarts !== "number")
747
+ ) {
748
+ return undefined;
749
+ }
750
+ const summaryContext = record(candidate.summaryContext);
751
+ if (
752
+ candidate.summaryContext !== undefined &&
753
+ (!summaryContext ||
754
+ (summaryContext.health !== undefined &&
755
+ typeof summaryContext.health !== "string") ||
756
+ (summaryContext.issueCount !== undefined &&
757
+ typeof summaryContext.issueCount !== "number"))
758
+ ) {
759
+ return undefined;
760
+ }
761
+ return candidate as unknown as InvestigationResourceSummary;
762
+ }
763
+
764
+ function issue(value: unknown): Issue | undefined {
765
+ const candidate = record(value);
766
+ if (
767
+ !candidate ||
768
+ !nonEmptyString(candidate.id) ||
769
+ (candidate.severity !== "critical" && candidate.severity !== "warning") ||
770
+ !nonEmptyString(candidate.kind) ||
771
+ !nonEmptyString(candidate.name) ||
772
+ !nonEmptyString(candidate.reason)
773
+ ) {
774
+ return undefined;
775
+ }
776
+ return candidate as unknown as Issue;
777
+ }
778
+
779
+ function recentChange(value: unknown): IssueRecentChange | undefined {
780
+ const candidate = record(value);
781
+ if (
782
+ !candidate ||
783
+ !nonEmptyString(candidate.kind) ||
784
+ (candidate.apiVersion !== undefined &&
785
+ !nonEmptyString(candidate.apiVersion)) ||
786
+ !nonEmptyString(candidate.name) ||
787
+ !nonEmptyString(candidate.changeType) ||
788
+ !nonEmptyString(candidate.timestamp)
789
+ ) {
790
+ return undefined;
791
+ }
792
+ return candidate as unknown as IssueRecentChange;
793
+ }
794
+
795
+ function diagnosisChangeContext(
796
+ value: unknown,
797
+ ): DiagnosisChangeContext | undefined {
798
+ const candidate = record(value);
799
+ if (!candidate || typeof candidate.changed !== "boolean") return undefined;
800
+ for (const field of ["what", "when", "evidence"] as const) {
801
+ if (candidate[field] !== undefined && typeof candidate[field] !== "string")
802
+ return undefined;
803
+ }
804
+ return {
805
+ changed: candidate.changed,
806
+ ...(typeof candidate.what === "string"
807
+ ? {
808
+ what:
809
+ candidate.what === "pod_template"
810
+ ? "The workload's Pod template changed"
811
+ : candidate.what,
812
+ }
813
+ : {}),
814
+ ...(typeof candidate.when === "string" ? { when: candidate.when } : {}),
815
+ ...(typeof candidate.evidence === "string"
816
+ ? { evidence: candidate.evidence }
817
+ : {}),
818
+ };
819
+ }
820
+
821
+ function event(value: unknown): InvestigationEventEvidence | undefined {
822
+ const candidate = record(value);
823
+ if (
824
+ !candidate ||
825
+ !nonEmptyString(candidate.reason) ||
826
+ !nonEmptyString(candidate.message) ||
827
+ !nonEmptyString(candidate.type) ||
828
+ typeof candidate.count !== "number" ||
829
+ !nonEmptyString(candidate.lastTimestamp)
830
+ ) {
831
+ return undefined;
832
+ }
833
+ return candidate as unknown as InvestigationEventEvidence;
834
+ }
835
+
836
+ function filteredLogs(value: unknown): DiagnosisFilteredLogs | undefined {
837
+ const candidate = record(value);
838
+ if (
839
+ !candidate ||
840
+ (!Array.isArray(candidate.lines) && candidate.lines !== null) ||
841
+ (Array.isArray(candidate.lines) &&
842
+ !candidate.lines.every((line) => typeof line === "string")) ||
843
+ typeof candidate.totalLines !== "number" ||
844
+ typeof candidate.matchedLines !== "number" ||
845
+ typeof candidate.fallback !== "boolean"
846
+ ) {
847
+ return undefined;
848
+ }
849
+ return {
850
+ ...(candidate as unknown as DiagnosisFilteredLogs),
851
+ lines: Array.isArray(candidate.lines)
852
+ ? candidate.lines.map((line) => stripAnsi(line))
853
+ : candidate.lines,
854
+ } as DiagnosisFilteredLogs;
855
+ }
856
+
857
+ function resourceRef(value: unknown): DiagnosisResourceRef | undefined {
858
+ const candidate = record(value);
859
+ if (
860
+ !candidate ||
861
+ !nonEmptyString(candidate.kind) ||
862
+ !nonEmptyString(candidate.name)
863
+ ) {
864
+ return undefined;
865
+ }
866
+ return candidate as unknown as DiagnosisResourceRef;
867
+ }
868
+
869
+ function networkRoute(value: unknown): InvestigationNetworkRoute | undefined {
870
+ const candidate = record(value);
871
+ if (
872
+ !candidate ||
873
+ !nonEmptyString(candidate.route) ||
874
+ !nonEmptyString(candidate.outcome)
875
+ ) {
876
+ return undefined;
877
+ }
878
+ for (const field of [
879
+ "target",
880
+ "failedLayer",
881
+ "confidence",
882
+ "evidence",
883
+ ] as const) {
884
+ if (candidate[field] !== undefined && typeof candidate[field] !== "string")
885
+ return undefined;
886
+ }
887
+ if (candidate.benign !== undefined && typeof candidate.benign !== "boolean")
888
+ return undefined;
889
+ return candidate as unknown as InvestigationNetworkRoute;
890
+ }
891
+
892
+ function networkEvidence(
893
+ value: Record<string, unknown>,
894
+ ): InvestigationNetworkEvidence | undefined {
895
+ const subject = resourceRef(value.subject);
896
+ const summary = record(value.summary);
897
+ const verdict = value.verdict;
898
+ const routesRaw = value.routes === undefined ? [] : value.routes;
899
+ if (
900
+ !subject ||
901
+ (verdict !== "healthy" &&
902
+ verdict !== "degraded" &&
903
+ verdict !== "broken" &&
904
+ verdict !== "unknown") ||
905
+ !summary ||
906
+ typeof summary.tested !== "number" ||
907
+ typeof summary.passed !== "number" ||
908
+ typeof summary.failed !== "number" ||
909
+ typeof summary.skipped !== "number" ||
910
+ !nonEmptyString(summary.headline) ||
911
+ (summary.derived !== undefined && typeof summary.derived !== "number") ||
912
+ !Array.isArray(routesRaw)
913
+ ) {
914
+ return undefined;
915
+ }
916
+ const routes = routesRaw
917
+ .map(networkRoute)
918
+ .filter((route): route is InvestigationNetworkRoute => Boolean(route));
919
+ if (routes.length !== routesRaw.length) return undefined;
920
+ const diagnosis = record(value.diagnosis);
921
+ if (
922
+ diagnosis &&
923
+ (!nonEmptyString(diagnosis.summary) ||
924
+ ["class", "severity", "route", "nextAction"].some(
925
+ (field) =>
926
+ diagnosis[field] !== undefined &&
927
+ typeof diagnosis[field] !== "string",
928
+ ))
929
+ ) {
930
+ return undefined;
931
+ }
932
+ if (value.reason !== undefined && typeof value.reason !== "string")
933
+ return undefined;
934
+ return {
935
+ subject,
936
+ verdict,
937
+ reason: value.reason as string | undefined,
938
+ diagnosis: diagnosis as
939
+ InvestigationNetworkEvidence["diagnosis"] | undefined,
940
+ summary: summary as unknown as InvestigationNetworkEvidence["summary"],
941
+ routes,
942
+ };
943
+ }
944
+
945
+ function topologyNode(value: unknown): InvestigationTopologyNode | undefined {
946
+ const candidate = record(value);
947
+ if (
948
+ !candidate ||
949
+ !nonEmptyString(candidate.id) ||
950
+ !nonEmptyString(candidate.kind) ||
951
+ !nonEmptyString(candidate.name)
952
+ ) {
953
+ return undefined;
954
+ }
955
+ return candidate as unknown as InvestigationTopologyNode;
956
+ }
957
+
958
+ function topologyEdge(value: unknown): InvestigationTopologyEdge | undefined {
959
+ const candidate = record(value);
960
+ if (
961
+ !candidate ||
962
+ !nonEmptyString(candidate.source) ||
963
+ !nonEmptyString(candidate.target) ||
964
+ !nonEmptyString(candidate.type)
965
+ ) {
966
+ return undefined;
967
+ }
968
+ return candidate as unknown as InvestigationTopologyEdge;
969
+ }
970
+
971
+ type TopologyPartiality = {
972
+ warnings: string[];
973
+ largeCluster: boolean;
974
+ hiddenKinds: string[];
975
+ requiresNamespaceFilter: boolean;
976
+ crdDiscoveryStatus?: NonNullable<Topology["crdDiscoveryStatus"]>;
977
+ estimatedNodes?: number;
978
+ summaryMode: boolean;
979
+ };
980
+
981
+ /**
982
+ * Both get_topology wire shapes carry the same completeness metadata. Keep the
983
+ * adapter strict: silently dropping a malformed flag would make a partial graph
984
+ * look complete in Evidence.
985
+ */
986
+ function topologyPartiality(
987
+ value: Record<string, unknown>,
988
+ ): TopologyPartiality | undefined {
989
+ const warnings =
990
+ value.warnings === undefined ? [] : stringArray(value.warnings);
991
+ const hiddenKinds =
992
+ value.hiddenKinds === undefined ? [] : stringArray(value.hiddenKinds);
993
+ const discovery = value.crdDiscoveryStatus;
994
+ const estimatedNodes = value.estimatedNodes;
995
+ if (
996
+ !warnings ||
997
+ !hiddenKinds ||
998
+ (value.largeCluster !== undefined &&
999
+ typeof value.largeCluster !== "boolean") ||
1000
+ (value.requiresNamespaceFilter !== undefined &&
1001
+ typeof value.requiresNamespaceFilter !== "boolean") ||
1002
+ (value.summaryMode !== undefined &&
1003
+ typeof value.summaryMode !== "boolean") ||
1004
+ (discovery !== undefined &&
1005
+ discovery !== "idle" &&
1006
+ discovery !== "discovering" &&
1007
+ discovery !== "ready") ||
1008
+ (estimatedNodes !== undefined &&
1009
+ (typeof estimatedNodes !== "number" ||
1010
+ !Number.isSafeInteger(estimatedNodes) ||
1011
+ estimatedNodes < 0))
1012
+ ) {
1013
+ return undefined;
1014
+ }
1015
+ return {
1016
+ warnings,
1017
+ largeCluster: value.largeCluster === true,
1018
+ hiddenKinds,
1019
+ requiresNamespaceFilter: value.requiresNamespaceFilter === true,
1020
+ crdDiscoveryStatus: discovery as
1021
+ NonNullable<Topology["crdDiscoveryStatus"]> | undefined,
1022
+ estimatedNodes,
1023
+ summaryMode: value.summaryMode === true,
1024
+ };
1025
+ }
1026
+
1027
+ function addTopologyLimitations(
1028
+ builder: ProjectionBuilder,
1029
+ source: InvestigationEvidenceSource,
1030
+ partiality: TopologyPartiality,
1031
+ ): void {
1032
+ for (const warning of partiality.warnings) {
1033
+ const normalized = warning.toLowerCase();
1034
+ builder.limit(
1035
+ source,
1036
+ "Topology coverage",
1037
+ warning,
1038
+ normalized.includes("large graph") || normalized.includes("too large")
1039
+ ? "truncated"
1040
+ : "unknown",
1041
+ );
1042
+ }
1043
+
1044
+ const scaleDetails: string[] = [];
1045
+ const estimate = partiality.estimatedNodes
1046
+ ? ` (about ${partiality.estimatedNodes} estimated nodes)`
1047
+ : "";
1048
+ if (partiality.requiresNamespaceFilter) {
1049
+ scaleDetails.push(
1050
+ `The all-namespace topology was not built because the cluster is too large${estimate}; run a namespace-scoped topology search to collect a smaller graph.`,
1051
+ );
1052
+ } else if (partiality.largeCluster) {
1053
+ scaleDetails.push(
1054
+ `Large-cluster optimizations were active${estimate}; high-cardinality detail may be grouped.`,
1055
+ );
1056
+ }
1057
+ if (partiality.hiddenKinds.length > 0) {
1058
+ scaleDetails.push(
1059
+ `Resource kinds omitted by the large-cluster optimization: ${partiality.hiddenKinds.join(", ")}.`,
1060
+ );
1061
+ }
1062
+ if (partiality.summaryMode) {
1063
+ scaleDetails.push(
1064
+ "Summary mode collapsed individual Pods into workload or Service counts.",
1065
+ );
1066
+ }
1067
+ if (scaleDetails.length > 0) {
1068
+ builder.limit(
1069
+ source,
1070
+ "Topology scale",
1071
+ scaleDetails.join(" "),
1072
+ "truncated",
1073
+ );
1074
+ }
1075
+
1076
+ if (
1077
+ partiality.crdDiscoveryStatus === "idle" ||
1078
+ partiality.crdDiscoveryStatus === "discovering"
1079
+ ) {
1080
+ builder.limit(
1081
+ source,
1082
+ "Custom Resource topology",
1083
+ partiality.crdDiscoveryStatus === "idle"
1084
+ ? "Custom Resource discovery had not started when this topology was captured; Custom Resource nodes and relationships may be missing."
1085
+ : "Custom Resource discovery was still in progress when this topology was captured; Custom Resource nodes and relationships may be missing.",
1086
+ "unknown",
1087
+ );
1088
+ }
1089
+ }
1090
+
1091
+ function scopeFromArgs(source: InvestigationEvidenceSource): string {
1092
+ if (!source.args) return "requested scope";
1093
+ const args = record(parseJSON(source.args));
1094
+ if (!args) return "requested scope";
1095
+ const kind = nonEmptyString(args.kind) ? displayKind(args.kind) : undefined;
1096
+ const namespace = nonEmptyString(args.namespace) ? args.namespace : undefined;
1097
+ const name = nonEmptyString(args.name) ? args.name : undefined;
1098
+ if (kind && name) {
1099
+ return kind + " " + (namespace ? namespace + "/" : "") + name;
1100
+ }
1101
+ if (kind && namespace) return kind + " resources in " + namespace;
1102
+ if (kind) return kind + " resources";
1103
+ if (namespace && name) return namespace + "/" + name;
1104
+ if (namespace) return "namespace " + namespace;
1105
+ if (name) return name;
1106
+ return "requested scope";
1107
+ }
1108
+
1109
+ const INVESTIGATION_RESULT_LABELS: Readonly<Record<string, string>> = {
1110
+ diagnose: "Workload diagnosis",
1111
+ issues: "Issue scan",
1112
+ get_resource: "Resource details",
1113
+ list_resources: "Resource inventory",
1114
+ get_events: "Kubernetes events",
1115
+ get_pod_logs: "Container logs",
1116
+ get_changes: "Recent changes",
1117
+ get_neighborhood: "Relationships",
1118
+ get_topology: "Topology",
1119
+ };
1120
+
1121
+ function investigationResultLabel(source: InvestigationEvidenceSource): string {
1122
+ return INVESTIGATION_RESULT_LABELS[source.tool] ?? "Investigation result";
1123
+ }
1124
+
1125
+ function resourceMatchesTarget(
1126
+ target: InvestigationEvidenceTarget,
1127
+ resource: {
1128
+ kind: string;
1129
+ group?: string;
1130
+ namespace?: string;
1131
+ name: string;
1132
+ },
1133
+ ): boolean {
1134
+ return (
1135
+ resource.kind.toLowerCase() === target.kind.toLowerCase() &&
1136
+ (resource.group ?? "").toLowerCase() === target.group.toLowerCase() &&
1137
+ (resource.namespace ?? "") === (target.namespace ?? "") &&
1138
+ resource.name === target.name
1139
+ );
1140
+ }
1141
+
1142
+ function relevanceForResource(
1143
+ builder: ProjectionBuilder,
1144
+ resource: {
1145
+ kind: string;
1146
+ group?: string;
1147
+ namespace?: string;
1148
+ name: string;
1149
+ },
1150
+ ): InvestigationEvidenceRelevance {
1151
+ return resourceMatchesTarget(builder.target, resource) ? "target" : "broader";
1152
+ }
1153
+
1154
+ function sourceArgsRelevance(
1155
+ builder: ProjectionBuilder,
1156
+ source: InvestigationEvidenceSource,
1157
+ impliedKind?: string,
1158
+ ): InvestigationEvidenceRelevance {
1159
+ const args = record(source.args ? parseJSON(source.args) : undefined);
1160
+ const kind = nonEmptyString(args?.kind) ? args.kind : impliedKind;
1161
+ if (!kind || !nonEmptyString(args?.name)) return "broader";
1162
+ return relevanceForResource(builder, {
1163
+ kind,
1164
+ // get_events/get_changes/issues cannot express an API group today. An
1165
+ // omitted group is therefore unspecified, not proof that the caller meant
1166
+ // the core API group. Use the known investigation target for that missing
1167
+ // dimension; if a producer does provide a group, exact matching still
1168
+ // applies (including an explicitly empty core group).
1169
+ group: typeof args?.group === "string" ? args.group : builder.target.group,
1170
+ namespace: nonEmptyString(args?.namespace) ? args.namespace : undefined,
1171
+ name: args.name,
1172
+ });
1173
+ }
1174
+
1175
+ function evidenceTierForRelevance(
1176
+ intended: InvestigationEvidenceTier,
1177
+ relevance: InvestigationEvidenceRelevance,
1178
+ ): InvestigationEvidenceTier {
1179
+ return relevance === "broader" ? "context" : intended;
1180
+ }
1181
+
1182
+ const DIAGNOSABLE_WORKLOAD_KINDS = new Set([
1183
+ "pod",
1184
+ "deployment",
1185
+ "statefulset",
1186
+ "daemonset",
1187
+ "rollout",
1188
+ ]);
1189
+
1190
+ function isDiagnosableWorkloadKind(kind: string): boolean {
1191
+ return DIAGNOSABLE_WORKLOAD_KINDS.has(kind.toLowerCase());
1192
+ }
1193
+
1194
+ function previousFromArgs(source: InvestigationEvidenceSource): boolean {
1195
+ return (
1196
+ record(source.args ? parseJSON(source.args) : undefined)?.previous === true
1197
+ );
1198
+ }
1199
+
1200
+ class ProjectionBuilder {
1201
+ readonly groups: InvestigationEvidenceGroup[] = [];
1202
+ readonly sources: InvestigationEvidenceSource[] = [];
1203
+ readonly limitations: InvestigationEvidenceLimitation[] = [];
1204
+ readonly projectedSources = new Set<string>();
1205
+ readonly checkedSources = new Set<string>();
1206
+ readonly limitedSources = new Set<string>();
1207
+ readonly semanticCoverageBySource = new Map<
1208
+ string,
1209
+ Set<InvestigationSemanticDomain>
1210
+ >();
1211
+
1212
+ private readonly groupByIdentity = new Map<
1213
+ string,
1214
+ InvestigationEvidenceGroup
1215
+ >();
1216
+ private readonly limitationByIdentity = new Map<
1217
+ string,
1218
+ InvestigationEvidenceLimitation
1219
+ >();
1220
+
1221
+ constructor(readonly target: InvestigationEvidenceTarget) {}
1222
+
1223
+ addSource(source: InvestigationEvidenceSource): void {
1224
+ this.sources.push(source);
1225
+ }
1226
+
1227
+ coverSemantic(
1228
+ source: InvestigationEvidenceSource,
1229
+ domain: InvestigationSemanticDomain,
1230
+ ): void {
1231
+ const covered = this.semanticCoverageBySource.get(source.id);
1232
+ if (covered) {
1233
+ covered.add(domain);
1234
+ } else {
1235
+ this.semanticCoverageBySource.set(source.id, new Set([domain]));
1236
+ }
1237
+ }
1238
+
1239
+ observe(
1240
+ identity: string,
1241
+ kind: InvestigationEvidenceKind,
1242
+ source: InvestigationEvidenceSource,
1243
+ observation: Omit<
1244
+ InvestigationEvidenceObservation,
1245
+ "source" | "revision" | "historical" | "changedFromPrevious" | "relevance"
1246
+ > & { relevance: InvestigationEvidenceRelevance },
1247
+ ): void {
1248
+ // Logs and synthesized startup/crash evidence do not carry a stable object
1249
+ // UID (and logs do not carry namespace in the producer row). Keep different
1250
+ // proof scopes separate so a same-named sibling can never inherit stronger
1251
+ // target provenance merely by colliding on its display identity.
1252
+ const partitionByRelevance =
1253
+ kind === "logs" ||
1254
+ kind === "startup" ||
1255
+ kind === "crash" ||
1256
+ (kind === "receipt" && identity.startsWith("previous-log-absence:"));
1257
+ const mapKey = `${kind}\u0000${identity}${
1258
+ partitionByRelevance
1259
+ ? `\u0000${observation.relevance}\u0000${scopeFromArgs(source)}`
1260
+ : ""
1261
+ }`;
1262
+ let group = this.groupByIdentity.get(mapKey);
1263
+ const previousObservation = group?.observations.at(-1);
1264
+ const changedFromPrevious = previousObservation
1265
+ ? evidenceSemanticSnapshot(previousObservation) !==
1266
+ evidenceSemanticSnapshot(observation)
1267
+ : false;
1268
+ const next: InvestigationEvidenceObservation = {
1269
+ ...observation,
1270
+ source,
1271
+ revision: group ? group.observations.length + 1 : 1,
1272
+ historical: false,
1273
+ changedFromPrevious,
1274
+ };
1275
+ if (!group) {
1276
+ group = {
1277
+ id: `evidence-${kind}-${stableHash(mapKey)}`,
1278
+ identity,
1279
+ kind,
1280
+ historical: false,
1281
+ firstOrder: source.order,
1282
+ observations: [],
1283
+ latest: next,
1284
+ chronologicalLatest: next,
1285
+ };
1286
+ this.groupByIdentity.set(mapKey, group);
1287
+ this.groups.push(group);
1288
+ }
1289
+ group.observations.push(next);
1290
+ group.chronologicalLatest = next;
1291
+ const relevanceRank: Record<InvestigationEvidenceRelevance, number> = {
1292
+ target: 0,
1293
+ "producer-related": 1,
1294
+ broader: 2,
1295
+ };
1296
+ // A broad inventory/read can re-observe an item whose relationship to the
1297
+ // target was already established by a scoped producer. Keep the broad read
1298
+ // in revision history, but never let weaker provenance replace the card's
1299
+ // authoritative observation. Equal provenance still advances normally.
1300
+ if (
1301
+ relevanceRank[next.relevance] <= relevanceRank[group.latest.relevance]
1302
+ ) {
1303
+ group.latest = next;
1304
+ }
1305
+ this.projectedSources.add(source.id);
1306
+ if (next.tier === "checked") this.checkedSources.add(source.id);
1307
+ }
1308
+
1309
+ limit(
1310
+ source: InvestigationEvidenceSource,
1311
+ label: string,
1312
+ message: string | undefined,
1313
+ kind: DiagnosisEvidenceLimitationBase["kind"],
1314
+ presentation?: InvestigationEvidenceLimitation["presentation"],
1315
+ ): void {
1316
+ if (!message?.trim()) return;
1317
+ const normalized = message.trim();
1318
+ const key = `${kind}\u0000${presentation ?? ""}\u0000${label}\u0000${normalized}`;
1319
+ const existing = this.limitationByIdentity.get(key);
1320
+ if (existing) {
1321
+ if (!existing.sources.some((item) => item.id === source.id)) {
1322
+ existing.sources.push(source);
1323
+ }
1324
+ } else {
1325
+ const limitation: InvestigationEvidenceLimitation = {
1326
+ source: label,
1327
+ message: normalized,
1328
+ kind,
1329
+ ...(presentation ? { presentation } : {}),
1330
+ firstOrder: source.order,
1331
+ sources: [source],
1332
+ };
1333
+ this.limitationByIdentity.set(key, limitation);
1334
+ this.limitations.push(limitation);
1335
+ }
1336
+ this.limitedSources.add(source.id);
1337
+ }
1338
+ }
1339
+
1340
+ export function evidenceSemanticSnapshot(
1341
+ observation: Pick<
1342
+ InvestigationEvidenceObservation,
1343
+ "tone" | "title" | "summary" | "data"
1344
+ >,
1345
+ ): string {
1346
+ if (observation.data.type === "resource") {
1347
+ // Tool-specific context can change the summary/tone without changing the
1348
+ // object. Keep that context in history, not in the resource-change signal.
1349
+ return JSON.stringify({
1350
+ type: "resource",
1351
+ resource: observation.data.resource,
1352
+ });
1353
+ }
1354
+ const data =
1355
+ observation.data.type === "issue"
1356
+ ? {
1357
+ type: observation.data.type,
1358
+ // These are the finding and details shown in the evidence card.
1359
+ // Collection timestamps and detector bookkeeping do not change it.
1360
+ issue: {
1361
+ kind: observation.data.issue.kind,
1362
+ group: observation.data.issue.group,
1363
+ namespace: observation.data.issue.namespace,
1364
+ name: observation.data.issue.name,
1365
+ reason: observation.data.issue.reason,
1366
+ severity: observation.data.issue.severity,
1367
+ cause: observation.data.issue.cause,
1368
+ message: observation.data.issue.message,
1369
+ },
1370
+ }
1371
+ : observation.data;
1372
+ return JSON.stringify({
1373
+ tone: observation.tone,
1374
+ title: observation.title,
1375
+ summary: observation.summary,
1376
+ data,
1377
+ });
1378
+ }
1379
+
1380
+ function contextFrom(value: unknown): InvestigationResourceContext | undefined {
1381
+ const candidate = record(value);
1382
+ if (!candidate || !nonEmptyString(candidate.tier)) return undefined;
1383
+ return candidate as unknown as InvestigationResourceContext;
1384
+ }
1385
+
1386
+ function addNarrowHint(
1387
+ builder: ProjectionBuilder,
1388
+ source: InvestigationEvidenceSource,
1389
+ value: Record<string, unknown>,
1390
+ ): void {
1391
+ if (nonEmptyString(value.narrowHint)) {
1392
+ const label = investigationResultLabel(source);
1393
+ builder.limit(
1394
+ source,
1395
+ label,
1396
+ label +
1397
+ " was narrowed to keep this investigation bounded. Additional matching evidence may exist.",
1398
+ "truncated",
1399
+ );
1400
+ }
1401
+ }
1402
+
1403
+ function addResourceContextLimitations(
1404
+ builder: ProjectionBuilder,
1405
+ source: InvestigationEvidenceSource,
1406
+ context: InvestigationResourceContext | undefined,
1407
+ ): void {
1408
+ if (!context) return;
1409
+ for (const value of Array.isArray(context.omitted) ? context.omitted : []) {
1410
+ const omitted = record(value);
1411
+ if (
1412
+ !omitted ||
1413
+ !nonEmptyString(omitted.field) ||
1414
+ !nonEmptyString(omitted.reason)
1415
+ )
1416
+ continue;
1417
+ builder.limit(
1418
+ source,
1419
+ omitted.field,
1420
+ `Resource context omitted: ${omitted.reason.replaceAll("_", " ")}.`,
1421
+ omitted.reason === "budget_exceeded" ? "truncated" : "unknown",
1422
+ );
1423
+ }
1424
+ if (context.referencedBy?.truncated) {
1425
+ const shown = context.referencedBy.items?.length ?? 0;
1426
+ builder.limit(
1427
+ source,
1428
+ "Relationships",
1429
+ `Referenced-by relationships were truncated (${shown} of ${context.referencedBy.total} returned).`,
1430
+ "truncated",
1431
+ );
1432
+ }
1433
+ if (context.appReferences?.staleSecretEnvTruncated) {
1434
+ builder.limit(
1435
+ source,
1436
+ "Application references",
1437
+ "Additional stale Secret environment reference groups were omitted.",
1438
+ "truncated",
1439
+ );
1440
+ }
1441
+ }
1442
+
1443
+ function addIssueLimitations(
1444
+ builder: ProjectionBuilder,
1445
+ source: InvestigationEvidenceSource,
1446
+ value: Issue,
1447
+ ): void {
1448
+ if (value.members_truncated) {
1449
+ builder.limit(
1450
+ source,
1451
+ `Radar Issue ${value.id}`,
1452
+ "The affected-resource member list was truncated.",
1453
+ "truncated",
1454
+ );
1455
+ }
1456
+ }
1457
+
1458
+ function resourceObservationSummary(
1459
+ resource: InvestigationKubernetesResource,
1460
+ context: InvestigationResourceContext | undefined,
1461
+ warnings: string[],
1462
+ gitOps?: InvestigationGitOpsDiagnosis,
1463
+ detailedIssueShown = false,
1464
+ ): string | undefined {
1465
+ if (!detailedIssueShown && context?.issueSummary?.topReason) {
1466
+ return context.issueSummary.topReason;
1467
+ }
1468
+ if (gitOps?.health) return `Health ${gitOps.health}`;
1469
+ if (gitOps?.ready) return `Ready ${gitOps.ready}`;
1470
+ if (gitOps?.sync) return `Sync ${gitOps.sync}`;
1471
+ if (gitOps?.suspended) return "Reconciliation suspended";
1472
+ const replicas = context?.workloadSummary?.replicas;
1473
+ if (replicas?.desired !== undefined) {
1474
+ const desired = replicas.desired;
1475
+ const ready = replicas.ready ?? 0;
1476
+ return `${ready}/${desired} replicas ready`;
1477
+ }
1478
+ if (context?.statusSummary?.phase) return context.statusSummary.phase;
1479
+ if (context?.issueSummary?.topReason) return context.issueSummary.topReason;
1480
+ return (
1481
+ investigationResourceEvidenceSummary(resource) ||
1482
+ warnings[0] ||
1483
+ resource.metadata.namespace
1484
+ );
1485
+ }
1486
+
1487
+ function addResourceObservation(
1488
+ builder: ProjectionBuilder,
1489
+ source: InvestigationEvidenceSource,
1490
+ resource: InvestigationKubernetesResource,
1491
+ context: InvestigationResourceContext | undefined,
1492
+ warnings: string[],
1493
+ gitOpsDiagnosis: InvestigationGitOpsDiagnosis | undefined,
1494
+ hasDetailedCriticalIssue: boolean,
1495
+ relevance: InvestigationEvidenceRelevance,
1496
+ ): void {
1497
+ const issueSummary = context?.issueSummary;
1498
+ const severity = issueSummary?.highestSeverity ?? "";
1499
+ const critical = severity.toLowerCase() === "critical";
1500
+ const hasLiveIssue = (issueSummary?.count ?? 0) > 0;
1501
+ const replicas = context?.workloadSummary?.replicas;
1502
+ const desired = replicas?.desired;
1503
+ const ready = replicas ? (replicas.ready ?? 0) : undefined;
1504
+ const available = replicas ? (replicas.available ?? 0) : undefined;
1505
+ const replicaShortfall =
1506
+ desired !== undefined &&
1507
+ desired > 0 &&
1508
+ ((ready !== undefined && ready < desired) ||
1509
+ (available !== undefined && available < desired) ||
1510
+ (replicas?.unavailable ?? 0) > 0);
1511
+ const adverseCondition = (context?.statusSummary?.conditions ?? []).some(
1512
+ (condition) => defaultConditionTone(condition) === "fail",
1513
+ );
1514
+ const gitOpsAdverse = Boolean(
1515
+ gitOpsDiagnosis &&
1516
+ (gitOpsDiagnosis.health?.toLowerCase() === "degraded" ||
1517
+ gitOpsDiagnosis.health?.toLowerCase() === "missing" ||
1518
+ gitOpsDiagnosis.sync?.toLowerCase() === "outofsync" ||
1519
+ gitOpsDiagnosis.ready?.toLowerCase().startsWith("false") ||
1520
+ ["failed", "error"].includes(
1521
+ gitOpsDiagnosis.operationPhase?.toLowerCase() ?? "",
1522
+ )),
1523
+ );
1524
+ const hasAdverseState = replicaShortfall || adverseCondition || gitOpsAdverse;
1525
+ const intendedTier: InvestigationEvidenceTier =
1526
+ critical && !hasDetailedCriticalIssue
1527
+ ? "key"
1528
+ : hasLiveIssue
1529
+ ? "supporting"
1530
+ : hasAdverseState
1531
+ ? "supporting"
1532
+ : "context";
1533
+ const tier = evidenceTierForRelevance(intendedTier, relevance);
1534
+ const tone = hasLiveIssue
1535
+ ? diagnosisSeverityTone(severity)
1536
+ : hasAdverseState
1537
+ ? "warning"
1538
+ : warnings.length > 0
1539
+ ? "warning"
1540
+ : "neutral";
1541
+ const namespace = resource.metadata.namespace;
1542
+ const identity = `${resource.apiVersion}:${resource.kind}:${namespace ?? ""}:${resource.metadata.name}`;
1543
+ builder.observe(identity, "resource", source, {
1544
+ tier,
1545
+ relevance,
1546
+ tone,
1547
+ title: `${resource.kind} ${namespace ? `${namespace}/` : ""}${resource.metadata.name}`,
1548
+ summary: resourceObservationSummary(
1549
+ resource,
1550
+ context,
1551
+ warnings,
1552
+ gitOpsDiagnosis,
1553
+ hasDetailedCriticalIssue,
1554
+ ),
1555
+ data: {
1556
+ type: "resource",
1557
+ resource,
1558
+ resourceContext: context,
1559
+ warnings,
1560
+ gitOpsDiagnosis,
1561
+ },
1562
+ });
1563
+ addResourceContextLimitations(builder, source, context);
1564
+ }
1565
+
1566
+ function addIssueObservation(
1567
+ builder: ProjectionBuilder,
1568
+ source: InvestigationEvidenceSource,
1569
+ value: Issue,
1570
+ producerRelevance: InvestigationEvidenceRelevance = "broader",
1571
+ ): void {
1572
+ const matchesTarget = resourceMatchesTarget(builder.target, {
1573
+ kind: value.kind,
1574
+ group: value.group ?? "",
1575
+ namespace: value.namespace,
1576
+ name: value.name,
1577
+ });
1578
+ const relevance = matchesTarget ? "target" : producerRelevance;
1579
+ builder.observe(`issue:${value.id}`, "issue", source, {
1580
+ tier:
1581
+ relevance === "broader"
1582
+ ? "context"
1583
+ : value.severity === "critical"
1584
+ ? "key"
1585
+ : "supporting",
1586
+ tone: diagnosisSeverityTone(value.severity),
1587
+ relevance,
1588
+ title: value.reason,
1589
+ summary: value.cause || value.message,
1590
+ data: { type: "issue", issue: value, relevance },
1591
+ });
1592
+ addIssueLimitations(builder, source, value);
1593
+ }
1594
+
1595
+ function startupBlocker(value: unknown): DiagnosisStartupBlocker | undefined {
1596
+ const candidate = record(value);
1597
+ if (
1598
+ !candidate ||
1599
+ !nonEmptyString(candidate.kind) ||
1600
+ !nonEmptyString(candidate.name) ||
1601
+ !nonEmptyString(candidate.reason) ||
1602
+ !nonEmptyString(candidate.severity) ||
1603
+ !nonEmptyString(candidate.message)
1604
+ ) {
1605
+ return undefined;
1606
+ }
1607
+ return candidate as unknown as DiagnosisStartupBlocker;
1608
+ }
1609
+
1610
+ function crashCause(value: unknown): DiagnosisCrashCause | undefined {
1611
+ const candidate = record(value);
1612
+ if (
1613
+ !candidate ||
1614
+ !Array.isArray(candidate.pods) ||
1615
+ !candidate.pods.every((pod) => typeof pod === "string") ||
1616
+ !nonEmptyString(candidate.container) ||
1617
+ !nonEmptyString(candidate.state) ||
1618
+ typeof candidate.exitCode !== "number" ||
1619
+ !nonEmptyString(candidate.logLine) ||
1620
+ !nonEmptyString(candidate.logSource) ||
1621
+ !nonEmptyString(candidate.logLineSelection)
1622
+ ) {
1623
+ return undefined;
1624
+ }
1625
+ return {
1626
+ ...(candidate as unknown as DiagnosisCrashCause),
1627
+ logLine: stripAnsi(candidate.logLine as string),
1628
+ };
1629
+ }
1630
+
1631
+ function addEvents(
1632
+ builder: ProjectionBuilder,
1633
+ source: InvestigationEvidenceSource,
1634
+ values: InvestigationEventEvidence[],
1635
+ identity: string,
1636
+ complete = true,
1637
+ emptyIsAuthoritative = false,
1638
+ relevance: InvestigationEvidenceRelevance = "broader",
1639
+ ): void {
1640
+ const scope = scopeFromArgs(source);
1641
+ if (values.length === 0) {
1642
+ if (!source.confirmedSuccess || !complete) return;
1643
+ if (!emptyIsAuthoritative) {
1644
+ builder.limit(
1645
+ source,
1646
+ "Events",
1647
+ "No events were returned. This result does not establish that no events occurred.",
1648
+ "unknown",
1649
+ );
1650
+ return;
1651
+ }
1652
+ builder.observe(identity, "receipt", source, {
1653
+ tier: evidenceTierForRelevance("checked", relevance),
1654
+ relevance,
1655
+ tone: "neutral",
1656
+ title: "No matching warning events",
1657
+ summary: scope,
1658
+ data: {
1659
+ type: "receipt",
1660
+ checked: "events",
1661
+ scope,
1662
+ message: "The warning-event query completed and returned no groups.",
1663
+ },
1664
+ });
1665
+ return;
1666
+ }
1667
+ builder.observe(identity, "events", source, {
1668
+ tier: evidenceTierForRelevance(
1669
+ values.some((item) => item.type.toLowerCase() === "warning")
1670
+ ? "supporting"
1671
+ : "context",
1672
+ relevance,
1673
+ ),
1674
+ relevance,
1675
+ tone: values.some((item) => item.type.toLowerCase() === "warning")
1676
+ ? "warning"
1677
+ : "info",
1678
+ title: "Kubernetes events",
1679
+ summary: `${values.length} event group${values.length === 1 ? "" : "s"} · ${scope}`,
1680
+ data: { type: "events", events: values, scope },
1681
+ });
1682
+ }
1683
+
1684
+ function addChanges(
1685
+ builder: ProjectionBuilder,
1686
+ source: InvestigationEvidenceSource,
1687
+ values: IssueRecentChange[],
1688
+ identity: string,
1689
+ changeContext?: DiagnosisChangeContext,
1690
+ complete = true,
1691
+ emptyIsAuthoritative = false,
1692
+ relevance: InvestigationEvidenceRelevance = "broader",
1693
+ ): void {
1694
+ const scope = scopeFromArgs(source);
1695
+ if (values.length === 0 && !changeContext?.changed) {
1696
+ if (!source.confirmedSuccess || !complete) return;
1697
+ if (!emptyIsAuthoritative) {
1698
+ builder.limit(
1699
+ source,
1700
+ "Recent changes",
1701
+ `No changes were returned for ${scope}. This result does not establish a complete change history.`,
1702
+ "unknown",
1703
+ "history",
1704
+ );
1705
+ return;
1706
+ }
1707
+ builder.observe(identity, "receipt", source, {
1708
+ tier: evidenceTierForRelevance("checked", relevance),
1709
+ relevance,
1710
+ tone: "neutral",
1711
+ title: "No tracked recent changes",
1712
+ summary: scope,
1713
+ data: {
1714
+ type: "receipt",
1715
+ checked: "changes",
1716
+ scope,
1717
+ message: "The requested change window returned no tracked changes.",
1718
+ },
1719
+ });
1720
+ return;
1721
+ }
1722
+ builder.observe(identity, "changes", source, {
1723
+ // A producer-correlated change supports the diagnosis. A merely recent
1724
+ // edit is chronology/context and must not imply causality by proximity.
1725
+ tier: evidenceTierForRelevance(
1726
+ changeContext?.changed ? "supporting" : "context",
1727
+ relevance,
1728
+ ),
1729
+ relevance,
1730
+ tone: "info",
1731
+ title: "Recent changes",
1732
+ summary: changeContext?.changed
1733
+ ? changeContext.what === "The workload's Pod template changed" &&
1734
+ changeContext.when
1735
+ ? `Pod template changed; newest ReplicaSet created ${changeContext.when} ago`
1736
+ : `${changeContext.what || "A workload change was observed"}${changeContext.when ? ` · ${changeContext.when} ago` : ""}`
1737
+ : `${values.length} change${values.length === 1 ? "" : "s"} · ${scope}`,
1738
+ data: {
1739
+ type: "changes",
1740
+ changes: values,
1741
+ scope,
1742
+ changeContext,
1743
+ },
1744
+ });
1745
+ }
1746
+
1747
+ function addLogs(
1748
+ builder: ProjectionBuilder,
1749
+ source: InvestigationEvidenceSource,
1750
+ value: DiagnosisPodLogEntry,
1751
+ previous: boolean,
1752
+ warnings: string[] = [],
1753
+ relevance: InvestigationEvidenceRelevance = "broader",
1754
+ namespace?: string,
1755
+ ): void {
1756
+ const lines = (value.logs?.lines ?? []).map((line) => stripAnsi(line));
1757
+ const normalizedWarnings = warnings.map((warning) => stripAnsi(warning));
1758
+ const normalizedError = value.error ? stripAnsi(value.error) : undefined;
1759
+ if (lines.length === 0) {
1760
+ builder.limit(
1761
+ source,
1762
+ `${value.pod} / ${value.container}`,
1763
+ value.error ||
1764
+ "No log lines were available. This does not mean the container is healthy.",
1765
+ value.error ? "error" : "unknown",
1766
+ );
1767
+ return;
1768
+ }
1769
+ // A producer-filtered excerpt is a candidate, not proof that its contents are
1770
+ // adverse. Query strings and routine request logs can contain words such as
1771
+ // "warning" or "critical" and still be successful traffic. Promote only an
1772
+ // explicit failure/error signature; keep benign excerpts available in Context.
1773
+ const diagnosticSignal = [...lines, ...normalizedWarnings].some((line) =>
1774
+ /(?:\b(?:error|exception|failed|failure|fatal|panic|crash|denied|refused|timeout|timed out|unhealthy|oomkill|back-?off)\b|\s5\d\d(?:\s|$))/i.test(
1775
+ line,
1776
+ ),
1777
+ );
1778
+ const selectedEvidence = value.logs?.fallback !== true && diagnosticSignal;
1779
+ const identity = `logs:${previous ? "previous" : "current"}:${value.pod}:${value.container}`;
1780
+ builder.observe(identity, "logs", source, {
1781
+ // FilterLogs' raw-tail fallback is useful provenance, but the producer did
1782
+ // not select it as diagnostic signal. Keep it in Context; only filtered
1783
+ // excerpts are Supporting evidence.
1784
+ tier: evidenceTierForRelevance(
1785
+ selectedEvidence ? "supporting" : "context",
1786
+ relevance,
1787
+ ),
1788
+ relevance,
1789
+ tone: selectedEvidence || normalizedError ? "warning" : "neutral",
1790
+ title: `${previous ? "Previous" : "Current"} logs · ${value.pod} / ${value.container}`,
1791
+ summary:
1792
+ lines.length > 0
1793
+ ? `${lines.length} selected line${lines.length === 1 ? "" : "s"}`
1794
+ : "No log lines available",
1795
+ data: {
1796
+ type: "logs",
1797
+ pod: value.pod,
1798
+ container: value.container,
1799
+ namespace,
1800
+ previous,
1801
+ logs: value.logs ? { ...value.logs, lines } : undefined,
1802
+ warnings: normalizedWarnings,
1803
+ error: normalizedError,
1804
+ },
1805
+ });
1806
+ if (normalizedError) {
1807
+ builder.limit(
1808
+ source,
1809
+ `${value.pod} / ${value.container}`,
1810
+ normalizedError,
1811
+ "error",
1812
+ );
1813
+ }
1814
+ }
1815
+
1816
+ function podContainerRef(value: unknown): DiagnosisPodContainerRef | undefined {
1817
+ const candidate = record(value);
1818
+ if (
1819
+ !candidate ||
1820
+ !nonEmptyString(candidate.pod) ||
1821
+ !nonEmptyString(candidate.container)
1822
+ ) {
1823
+ return undefined;
1824
+ }
1825
+ return candidate as unknown as DiagnosisPodContainerRef;
1826
+ }
1827
+
1828
+ function parseLogEntry(value: unknown): DiagnosisPodLogEntry | undefined {
1829
+ const candidate = record(value);
1830
+ if (
1831
+ !candidate ||
1832
+ !nonEmptyString(candidate.pod) ||
1833
+ !nonEmptyString(candidate.container)
1834
+ ) {
1835
+ return undefined;
1836
+ }
1837
+ if (candidate.logs !== undefined && !filteredLogs(candidate.logs))
1838
+ return undefined;
1839
+ if (candidate.error !== undefined && typeof candidate.error !== "string")
1840
+ return undefined;
1841
+ return candidate as unknown as DiagnosisPodLogEntry;
1842
+ }
1843
+
1844
+ function nonNegativeInteger(value: unknown): value is number {
1845
+ return typeof value === "number" && Number.isInteger(value) && value >= 0;
1846
+ }
1847
+
1848
+ function diagnoseCrashQueryCoversTarget(
1849
+ source: InvestigationEvidenceSource,
1850
+ ): boolean {
1851
+ const args = record(source.args ? parseJSON(source.args) : undefined);
1852
+ if (!args) return false;
1853
+
1854
+ // Crash retirement is target-wide. It therefore requires the producer's
1855
+ // normal all-container, full-time-window read. A caller-selected container,
1856
+ // since window, or shorter-than-default tail can validate that slice only;
1857
+ // it cannot clear a smoking gun from a stream it never revisited.
1858
+ if (nonEmptyString(args.container) || nonEmptyString(args.since))
1859
+ return false;
1860
+ if (args.tail_lines === undefined) return true;
1861
+ return (
1862
+ nonNegativeInteger(args.tail_lines) &&
1863
+ (args.tail_lines === 0 || args.tail_lines >= 100)
1864
+ );
1865
+ }
1866
+
1867
+ /**
1868
+ * A missing crash candidate is meaningful only when diagnose actually read the
1869
+ * complete log surface that its crash classifier consumes. Keep this tied to
1870
+ * the current producer contract: a partial pod sample, a capped response, or a
1871
+ * failed stream is absence of evidence and must not clear earlier crash proof.
1872
+ */
1873
+ function diagnoseCrashCoverageComplete(
1874
+ value: Record<string, unknown>,
1875
+ ): boolean {
1876
+ if (
1877
+ !nonNegativeInteger(value.pods) ||
1878
+ value.pods === 0 ||
1879
+ value.logsError !== undefined ||
1880
+ (value.crashCauseTruncated !== undefined &&
1881
+ typeof value.crashCauseTruncated !== "boolean") ||
1882
+ value.crashCauseTruncated === true
1883
+ ) {
1884
+ return false;
1885
+ }
1886
+
1887
+ const coverage = record(value.logCoverage);
1888
+ if (
1889
+ !coverage ||
1890
+ !nonNegativeInteger(coverage.resolvedPods) ||
1891
+ !nonNegativeInteger(coverage.selectedPods) ||
1892
+ !nonNegativeInteger(coverage.shownLines) ||
1893
+ !nonNegativeInteger(coverage.totalLines) ||
1894
+ !nonNegativeInteger(coverage.shownPods) ||
1895
+ !nonNegativeInteger(coverage.totalPods) ||
1896
+ coverage.resolvedPods !== value.pods ||
1897
+ coverage.selectedPods !== coverage.resolvedPods ||
1898
+ (coverage.selectionTruncated !== undefined &&
1899
+ typeof coverage.selectionTruncated !== "boolean") ||
1900
+ coverage.selectionTruncated === true ||
1901
+ (coverage.contentTruncated !== undefined &&
1902
+ typeof coverage.contentTruncated !== "boolean") ||
1903
+ coverage.contentTruncated === true ||
1904
+ coverage.shownLines !== coverage.totalLines ||
1905
+ coverage.shownPods !== coverage.totalPods
1906
+ ) {
1907
+ return false;
1908
+ }
1909
+
1910
+ const currentRaw = value.logsCurrent;
1911
+ const previousRaw = value.logsPrevious;
1912
+ if (
1913
+ !Array.isArray(currentRaw) ||
1914
+ currentRaw.length === 0 ||
1915
+ !Array.isArray(previousRaw)
1916
+ ) {
1917
+ return false;
1918
+ }
1919
+
1920
+ const current = currentRaw.map(parseLogEntry);
1921
+ const previous = previousRaw.map(parseLogEntry);
1922
+ if (
1923
+ current.some(
1924
+ (entry) => !entry || entry.error !== undefined || !entry.logs,
1925
+ ) ||
1926
+ previous.some((entry) => !entry || !entry.logs)
1927
+ ) {
1928
+ return false;
1929
+ }
1930
+
1931
+ const currentEntries = current as DiagnosisPodLogEntry[];
1932
+ const previousEntries = previous as DiagnosisPodLogEntry[];
1933
+ const streamKey = (entry: DiagnosisPodContainerRef) =>
1934
+ `${entry.pod}\u0000${entry.container}`;
1935
+ const currentKeys = new Set(currentEntries.map(streamKey));
1936
+ const previousKeys = new Set(previousEntries.map(streamKey));
1937
+ if (
1938
+ currentKeys.size !== current.length ||
1939
+ previousKeys.size !== previous.length ||
1940
+ currentKeys.size !== previousKeys.size ||
1941
+ [...currentKeys].some((key) => !previousKeys.has(key))
1942
+ ) {
1943
+ return false;
1944
+ }
1945
+
1946
+ const absencesRaw = value.expectedPreviousLogAbsences;
1947
+ if (absencesRaw !== undefined && !Array.isArray(absencesRaw)) return false;
1948
+ const absences = Array.isArray(absencesRaw)
1949
+ ? absencesRaw.map(podContainerRef)
1950
+ : [];
1951
+ if (absences.some((entry) => !entry)) return false;
1952
+ const absenceEntries = absences as DiagnosisPodContainerRef[];
1953
+ const absenceKeys = new Set(absenceEntries.map(streamKey));
1954
+ if ([...absenceKeys].some((key) => !previousKeys.has(key))) return false;
1955
+
1956
+ return previousEntries.every((entry) => {
1957
+ if (entry.error === undefined) return true;
1958
+ return nonEmptyString(entry.error) && absenceKeys.has(streamKey(entry));
1959
+ });
1960
+ }
1961
+
1962
+ function invalidPayload(
1963
+ builder: ProjectionBuilder,
1964
+ source: InvestigationEvidenceSource,
1965
+ section = investigationResultLabel(source),
1966
+ ): void {
1967
+ builder.limit(
1968
+ source,
1969
+ section,
1970
+ "Radar couldn't summarize this investigation step. Review it in Activity.",
1971
+ "unknown",
1972
+ );
1973
+ }
1974
+
1975
+ function adaptDiagnose(
1976
+ builder: ProjectionBuilder,
1977
+ source: InvestigationEvidenceSource,
1978
+ payload: unknown,
1979
+ ): void {
1980
+ const value = record(payload);
1981
+ if (value) {
1982
+ const network = networkEvidence(value);
1983
+ if (network) {
1984
+ const relevance = relevanceForResource(builder, network.subject);
1985
+ const adverse =
1986
+ network.verdict === "broken" || network.verdict === "degraded";
1987
+ builder.observe(
1988
+ `network:${network.subject.group ?? ""}:${network.subject.kind}:${network.subject.namespace ?? ""}:${network.subject.name}`,
1989
+ "network",
1990
+ source,
1991
+ {
1992
+ tier: evidenceTierForRelevance(
1993
+ adverse ? "supporting" : "context",
1994
+ relevance,
1995
+ ),
1996
+ relevance,
1997
+ tone:
1998
+ network.verdict === "broken"
1999
+ ? "error"
2000
+ : network.verdict === "degraded"
2001
+ ? "warning"
2002
+ : network.verdict === "healthy"
2003
+ ? "info"
2004
+ : "neutral",
2005
+ title: `${network.subject.kind} path · ${network.subject.namespace ? `${network.subject.namespace}/` : ""}${network.subject.name}`,
2006
+ summary:
2007
+ network.diagnosis?.summary ||
2008
+ network.reason ||
2009
+ network.summary.headline,
2010
+ data: { type: "network", network },
2011
+ },
2012
+ );
2013
+ if (network.summary.skipped > 0) {
2014
+ builder.limit(
2015
+ source,
2016
+ "Network path coverage",
2017
+ `${network.summary.skipped} intended route${network.summary.skipped === 1 ? " was" : "s were"} not tested. ${network.summary.headline}`,
2018
+ "unknown",
2019
+ );
2020
+ }
2021
+ return;
2022
+ }
2023
+ }
2024
+ const resource = kubernetesResource(value?.resource);
2025
+ if (!value || !resource) {
2026
+ invalidPayload(builder, source);
2027
+ return;
2028
+ }
2029
+ const bundleRelevance = relevanceForResource(builder, {
2030
+ kind: resource.kind,
2031
+ group: apiVersionToGroup(resource.apiVersion),
2032
+ namespace: resource.metadata.namespace,
2033
+ name: resource.metadata.name,
2034
+ });
2035
+ const relatedRelevance: InvestigationEvidenceRelevance =
2036
+ bundleRelevance === "target" ? "producer-related" : "broader";
2037
+ const bundledRowRelevance = (
2038
+ kind: string,
2039
+ name: string,
2040
+ ): InvestigationEvidenceRelevance => {
2041
+ if (bundleRelevance !== "target") return "broader";
2042
+ const sameAsRoot =
2043
+ kind.toLowerCase() === resource.kind.toLowerCase() &&
2044
+ name === resource.metadata.name;
2045
+ const group = sameAsRoot
2046
+ ? apiVersionToGroup(resource.apiVersion)
2047
+ : kind.toLowerCase() === "pod"
2048
+ ? ""
2049
+ : undefined;
2050
+ return resourceMatchesTarget(builder.target, {
2051
+ kind,
2052
+ group,
2053
+ namespace: resource.metadata.namespace,
2054
+ name,
2055
+ })
2056
+ ? "target"
2057
+ : "producer-related";
2058
+ };
2059
+ addNarrowHint(builder, source, value);
2060
+
2061
+ const relatedRaw = value.relatedIssues;
2062
+ const related = Array.isArray(relatedRaw)
2063
+ ? relatedRaw.map(issue).filter((item): item is Issue => Boolean(item))
2064
+ : [];
2065
+ const relatedValid =
2066
+ relatedRaw === undefined ||
2067
+ (Array.isArray(relatedRaw) && related.length === relatedRaw.length);
2068
+ if (!relatedValid) {
2069
+ invalidPayload(builder, source, "Classified issues");
2070
+ }
2071
+ for (const item of related) {
2072
+ // `diagnose` is itself scoped to one resource and declares these rows as
2073
+ // related evidence. That producer contract is stronger than a broad
2074
+ // `issues` query, even when the related row is a child Pod.
2075
+ addIssueObservation(builder, source, item, relatedRelevance);
2076
+ }
2077
+
2078
+ const context = contextFrom(value.resourceContext);
2079
+ if (value.resourceContext !== undefined && !context) {
2080
+ invalidPayload(builder, source, "Resource context");
2081
+ }
2082
+ const gitOps =
2083
+ value.gitopsDiagnosis === undefined
2084
+ ? undefined
2085
+ : gitOpsDiagnosis(value.gitopsDiagnosis);
2086
+ if (value.gitopsDiagnosis !== undefined && !gitOps) {
2087
+ invalidPayload(builder, source, "GitOps status");
2088
+ }
2089
+ if (
2090
+ source.confirmedSuccess &&
2091
+ related.length === 0 &&
2092
+ relatedValid &&
2093
+ isDiagnosableWorkloadKind(resource.kind)
2094
+ ) {
2095
+ const scope = scopeFromArgs(source);
2096
+ builder.observe(`issues:diagnose:${scope}`, "receipt", source, {
2097
+ tier: evidenceTierForRelevance("checked", bundleRelevance),
2098
+ relevance: bundleRelevance,
2099
+ tone: "neutral",
2100
+ title: "No classified workload issues",
2101
+ summary: scope,
2102
+ data: {
2103
+ type: "receipt",
2104
+ checked: "issues",
2105
+ scope,
2106
+ message:
2107
+ "Radar's workload diagnosis completed without a classified live issue for this resource.",
2108
+ },
2109
+ });
2110
+ }
2111
+ const warnings = stringArray(value.warnings) ?? [];
2112
+ addResourceObservation(
2113
+ builder,
2114
+ source,
2115
+ resource,
2116
+ context,
2117
+ warnings,
2118
+ gitOps,
2119
+ related.some((item) => item.severity === "critical"),
2120
+ bundleRelevance,
2121
+ );
2122
+
2123
+ const blockersRaw = value.startupBlockers;
2124
+ let blockersValid = blockersRaw === undefined || Array.isArray(blockersRaw);
2125
+ if (Array.isArray(blockersRaw)) {
2126
+ for (const raw of blockersRaw) {
2127
+ const blocker = startupBlocker(raw);
2128
+ if (!blocker) {
2129
+ blockersValid = false;
2130
+ invalidPayload(builder, source, "Startup evidence");
2131
+ continue;
2132
+ }
2133
+ builder.observe(
2134
+ `startup:${blocker.kind}:${blocker.name}:${blocker.reason}`,
2135
+ "startup",
2136
+ source,
2137
+ {
2138
+ tier: evidenceTierForRelevance(
2139
+ "key",
2140
+ bundledRowRelevance(blocker.kind, blocker.name),
2141
+ ),
2142
+ relevance: bundledRowRelevance(blocker.kind, blocker.name),
2143
+ tone: diagnosisSeverityTone(blocker.severity),
2144
+ title: blocker.reason,
2145
+ summary: blocker.message,
2146
+ data: {
2147
+ type: "startup",
2148
+ blocker,
2149
+ subject: (() => {
2150
+ const namespace = resource.metadata.namespace;
2151
+ if (!namespace) return undefined;
2152
+ const rootGroup = apiVersionToGroup(resource.apiVersion);
2153
+ const group =
2154
+ blocker.kind === resource.kind
2155
+ ? rootGroup
2156
+ : blocker.kind === "Pod"
2157
+ ? ""
2158
+ : blocker.kind === "ReplicaSet"
2159
+ ? "apps"
2160
+ : undefined;
2161
+ if (group === undefined) return undefined;
2162
+ return {
2163
+ kind: blocker.kind,
2164
+ ...(group ? { group } : {}),
2165
+ namespace,
2166
+ name: blocker.name,
2167
+ };
2168
+ })(),
2169
+ },
2170
+ },
2171
+ );
2172
+ }
2173
+ } else if (blockersRaw !== undefined) {
2174
+ invalidPayload(builder, source, "Startup evidence");
2175
+ }
2176
+
2177
+ const crashesRaw = value.crashCause;
2178
+ let crashesValid =
2179
+ (crashesRaw === undefined || Array.isArray(crashesRaw)) &&
2180
+ (value.crashCauseTruncated === undefined ||
2181
+ typeof value.crashCauseTruncated === "boolean");
2182
+ if (Array.isArray(crashesRaw)) {
2183
+ for (const raw of crashesRaw) {
2184
+ const crash = crashCause(raw);
2185
+ if (!crash) {
2186
+ crashesValid = false;
2187
+ invalidPayload(builder, source, "Crash evidence");
2188
+ continue;
2189
+ }
2190
+ const pods = [...crash.pods].sort().join(",");
2191
+ const crashRelevance =
2192
+ crash.pods.length === 1
2193
+ ? bundledRowRelevance("Pod", crash.pods[0])
2194
+ : relatedRelevance;
2195
+ builder.observe(
2196
+ `crash:${pods}:${crash.container}:${crash.state}:${crash.reason ?? ""}`,
2197
+ "crash",
2198
+ source,
2199
+ {
2200
+ tier: evidenceTierForRelevance("key", crashRelevance),
2201
+ relevance: crashRelevance,
2202
+ tone: "error",
2203
+ title: `${crash.container} ${crash.reason || crash.state}`,
2204
+ summary: crash.logLine,
2205
+ data: {
2206
+ type: "crash",
2207
+ crash,
2208
+ namespace: resource.metadata.namespace,
2209
+ },
2210
+ },
2211
+ );
2212
+ }
2213
+ } else if (crashesRaw !== undefined) {
2214
+ invalidPayload(builder, source, "Crash evidence");
2215
+ }
2216
+ if (value.crashCauseTruncated === true) {
2217
+ builder.limit(
2218
+ source,
2219
+ "Crash evidence",
2220
+ "Additional crash-cause candidates were omitted.",
2221
+ "truncated",
2222
+ );
2223
+ }
2224
+
2225
+ if (
2226
+ source.confirmedSuccess &&
2227
+ bundleRelevance === "target" &&
2228
+ isDiagnosableWorkloadKind(resource.kind)
2229
+ ) {
2230
+ if (relatedValid) builder.coverSemantic(source, "issue");
2231
+ if (blockersValid) builder.coverSemantic(source, "startup");
2232
+ if (
2233
+ crashesValid &&
2234
+ diagnoseCrashQueryCoversTarget(source) &&
2235
+ diagnoseCrashCoverageComplete(value)
2236
+ ) {
2237
+ builder.coverSemantic(source, "crash");
2238
+ }
2239
+ // DNSContext is emitted only for positive symptoms/configuration. The
2240
+ // producer has no explicit negative DNS coverage receipt, so its absence
2241
+ // cannot safely retire earlier DNS evidence.
2242
+ }
2243
+
2244
+ const expectedAbsencesRaw = value.expectedPreviousLogAbsences;
2245
+ const expectedAbsences = Array.isArray(expectedAbsencesRaw)
2246
+ ? expectedAbsencesRaw
2247
+ .map(podContainerRef)
2248
+ .filter((item): item is DiagnosisPodContainerRef => Boolean(item))
2249
+ : [];
2250
+ if (
2251
+ expectedAbsencesRaw !== undefined &&
2252
+ (!Array.isArray(expectedAbsencesRaw) ||
2253
+ expectedAbsences.length !== expectedAbsencesRaw.length)
2254
+ ) {
2255
+ invalidPayload(builder, source, "Previous-log status");
2256
+ }
2257
+ const expectedAbsenceKeys = new Set(
2258
+ expectedAbsences.map((item) => `${item.pod}\u0000${item.container}`),
2259
+ );
2260
+ if (source.confirmedSuccess) {
2261
+ for (const item of expectedAbsences) {
2262
+ const logRelevance = bundledRowRelevance("Pod", item.pod);
2263
+ builder.observe(
2264
+ `previous-log-absence:${item.pod}:${item.container}`,
2265
+ "receipt",
2266
+ source,
2267
+ {
2268
+ tier: evidenceTierForRelevance("checked", logRelevance),
2269
+ relevance: logRelevance,
2270
+ tone: "neutral",
2271
+ title: "No previous container instance expected",
2272
+ summary: `${item.pod} / ${item.container}`,
2273
+ data: {
2274
+ type: "receipt",
2275
+ checked: "logs",
2276
+ scope: `${item.pod} / ${item.container}`,
2277
+ message:
2278
+ "Captured container status shows zero restarts and no prior termination, so a previous log stream should not exist.",
2279
+ },
2280
+ },
2281
+ );
2282
+ }
2283
+ }
2284
+
2285
+ for (const [field, previous] of [
2286
+ ["logsCurrent", false],
2287
+ ["logsPrevious", true],
2288
+ ] as const) {
2289
+ const raw = value[field];
2290
+ if (Array.isArray(raw)) {
2291
+ for (const item of raw) {
2292
+ const entry = parseLogEntry(item);
2293
+ if (
2294
+ entry &&
2295
+ previous &&
2296
+ expectedAbsenceKeys.has(`${entry.pod}\u0000${entry.container}`) &&
2297
+ (entry.logs?.lines?.length ?? 0) === 0
2298
+ ) {
2299
+ continue;
2300
+ }
2301
+ if (entry) {
2302
+ addLogs(
2303
+ builder,
2304
+ source,
2305
+ entry,
2306
+ previous,
2307
+ [],
2308
+ bundledRowRelevance("Pod", entry.pod),
2309
+ resource.metadata.namespace,
2310
+ );
2311
+ } else
2312
+ invalidPayload(
2313
+ builder,
2314
+ source,
2315
+ previous ? "Previous logs" : "Current logs",
2316
+ );
2317
+ }
2318
+ if (raw.length === 0 && field === "logsCurrent") {
2319
+ builder.limit(
2320
+ source,
2321
+ "Current logs",
2322
+ "No container logs were available, so Radar could not evaluate them.",
2323
+ "unknown",
2324
+ );
2325
+ }
2326
+ } else if (raw !== undefined) {
2327
+ invalidPayload(
2328
+ builder,
2329
+ source,
2330
+ previous ? "Previous logs" : "Current logs",
2331
+ );
2332
+ } else if (
2333
+ field === "logsCurrent" &&
2334
+ typeof value.pods === "number" &&
2335
+ value.pods > 0 &&
2336
+ !nonEmptyString(value.logsError)
2337
+ ) {
2338
+ // Empty slices are omitted by the Go producer. With resolved pods this
2339
+ // means the read yielded no stream rows, not that logs proved anything.
2340
+ builder.limit(
2341
+ source,
2342
+ "Current logs",
2343
+ "No container logs were available, so Radar could not evaluate them.",
2344
+ "unknown",
2345
+ );
2346
+ }
2347
+ }
2348
+ if (nonEmptyString(value.logsError)) {
2349
+ builder.limit(source, "Logs", value.logsError, "error");
2350
+ }
2351
+ const logCoverage = record(value.logCoverage);
2352
+ if (logCoverage?.selectionTruncated === true) {
2353
+ const selected =
2354
+ typeof logCoverage.selectedPods === "number"
2355
+ ? logCoverage.selectedPods
2356
+ : "some";
2357
+ const resolved =
2358
+ typeof logCoverage.resolvedPods === "number"
2359
+ ? logCoverage.resolvedPods
2360
+ : "the resolved";
2361
+ builder.limit(
2362
+ source,
2363
+ "Log pod coverage",
2364
+ `Log collection selected ${selected} of ${resolved} pods.`,
2365
+ "truncated",
2366
+ );
2367
+ }
2368
+ if (logCoverage?.contentTruncated === true) {
2369
+ const shown =
2370
+ typeof logCoverage.shownLines === "number"
2371
+ ? logCoverage.shownLines
2372
+ : "a subset of";
2373
+ const total =
2374
+ typeof logCoverage.totalLines === "number"
2375
+ ? logCoverage.totalLines
2376
+ : "the returned";
2377
+ builder.limit(
2378
+ source,
2379
+ "Log excerpt coverage",
2380
+ `The response includes ${shown} of ${total} selected log lines after the size limit; these are excerpts, not the container's full log history.`,
2381
+ "truncated",
2382
+ );
2383
+ }
2384
+
2385
+ const eventsRaw = value.events;
2386
+ if (Array.isArray(eventsRaw)) {
2387
+ const events = eventsRaw
2388
+ .map(event)
2389
+ .filter((item): item is InvestigationEventEvidence => Boolean(item));
2390
+ if (events.length === eventsRaw.length) {
2391
+ addEvents(
2392
+ builder,
2393
+ source,
2394
+ events,
2395
+ `events:diagnose:${scopeFromArgs(source)}`,
2396
+ typeof value.eventsTotalGroups !== "number" ||
2397
+ value.eventsTotalGroups <= events.length,
2398
+ true,
2399
+ relatedRelevance,
2400
+ );
2401
+ } else {
2402
+ invalidPayload(builder, source, "Events");
2403
+ }
2404
+ } else if (
2405
+ eventsRaw === undefined &&
2406
+ value.gitopsDiagnosis === undefined &&
2407
+ !nonEmptyString(value.eventsError)
2408
+ ) {
2409
+ addEvents(
2410
+ builder,
2411
+ source,
2412
+ [],
2413
+ `events:diagnose:${scopeFromArgs(source)}`,
2414
+ true,
2415
+ true,
2416
+ relatedRelevance,
2417
+ );
2418
+ } else if (eventsRaw !== undefined) {
2419
+ invalidPayload(builder, source, "Events");
2420
+ }
2421
+ if (nonEmptyString(value.eventsError)) {
2422
+ builder.limit(source, "Events", value.eventsError, "error");
2423
+ }
2424
+ if (
2425
+ typeof value.eventsTotalGroups === "number" &&
2426
+ Array.isArray(eventsRaw) &&
2427
+ value.eventsTotalGroups > eventsRaw.length
2428
+ ) {
2429
+ builder.limit(
2430
+ source,
2431
+ "Events",
2432
+ `Radar received ${eventsRaw.length} of ${value.eventsTotalGroups} event groups.`,
2433
+ "truncated",
2434
+ );
2435
+ }
2436
+
2437
+ const changesRaw = value.recentChanges;
2438
+ const changesCoverageLimitedRaw = value.recentChangesCoverageLimited;
2439
+ const changesCoverageLimitedValid =
2440
+ changesCoverageLimitedRaw === undefined ||
2441
+ typeof changesCoverageLimitedRaw === "boolean";
2442
+ const changesCoverageLimited = changesCoverageLimitedRaw === true;
2443
+ if (!changesCoverageLimitedValid) {
2444
+ invalidPayload(builder, source, "Recent changes");
2445
+ }
2446
+ const changeContext =
2447
+ value.changeContext === undefined
2448
+ ? undefined
2449
+ : diagnosisChangeContext(value.changeContext);
2450
+ if (value.changeContext !== undefined && !changeContext) {
2451
+ invalidPayload(builder, source, "Change correlation");
2452
+ }
2453
+ if (Array.isArray(changesRaw)) {
2454
+ const changes = changesRaw
2455
+ .map(recentChange)
2456
+ .filter((item): item is IssueRecentChange => Boolean(item));
2457
+ if (changes.length === changesRaw.length) {
2458
+ addChanges(
2459
+ builder,
2460
+ source,
2461
+ changes,
2462
+ `changes:diagnose:${scopeFromArgs(source)}`,
2463
+ changeContext,
2464
+ value.recentChangesSaturated !== true &&
2465
+ !changesCoverageLimited &&
2466
+ changesCoverageLimitedValid,
2467
+ true,
2468
+ relatedRelevance,
2469
+ );
2470
+ } else {
2471
+ invalidPayload(builder, source, "Recent changes");
2472
+ }
2473
+ } else if (
2474
+ changesRaw === undefined &&
2475
+ value.gitopsDiagnosis === undefined &&
2476
+ !nonEmptyString(value.recentChangesError)
2477
+ ) {
2478
+ addChanges(
2479
+ builder,
2480
+ source,
2481
+ [],
2482
+ `changes:diagnose:${scopeFromArgs(source)}`,
2483
+ changeContext,
2484
+ value.recentChangesSaturated !== true &&
2485
+ !changesCoverageLimited &&
2486
+ changesCoverageLimitedValid,
2487
+ true,
2488
+ relatedRelevance,
2489
+ );
2490
+ } else if (changesRaw !== undefined) {
2491
+ invalidPayload(builder, source, "Recent changes");
2492
+ }
2493
+ if (nonEmptyString(value.recentChangesError)) {
2494
+ builder.limit(source, "Recent changes", value.recentChangesError, "error");
2495
+ }
2496
+ if (value.recentChangesSaturated === true) {
2497
+ builder.limit(
2498
+ source,
2499
+ "Recent changes",
2500
+ "The recent-change result limit was reached; additional changes may exist in the requested window.",
2501
+ "truncated",
2502
+ );
2503
+ }
2504
+ if (changesCoverageLimited) {
2505
+ builder.limit(
2506
+ source,
2507
+ "Recent changes",
2508
+ "Recent-change coverage is limited because Radar could not confirm permission to read every referenced source. The visible result cannot prove that no recent changes exist.",
2509
+ "unknown",
2510
+ );
2511
+ }
2512
+
2513
+ const dns = record(value.dnsContext);
2514
+ if (dns) {
2515
+ const signals = stringArray(dns.signals) ?? [];
2516
+ const findings = Array.isArray(dns.coreDNSFindings)
2517
+ ? dns.coreDNSFindings
2518
+ : [];
2519
+ if (signals.length > 0 || findings.length > 0) {
2520
+ const adverse =
2521
+ findings.length > 0 ||
2522
+ signals.some((signal) =>
2523
+ /\b(error|failed|failures?|timeouts?|nxdomain|servfail)\b/i.test(
2524
+ signal,
2525
+ ),
2526
+ );
2527
+ builder.observe(`dns:${scopeFromArgs(source)}`, "dns", source, {
2528
+ tier: evidenceTierForRelevance(
2529
+ adverse ? "supporting" : "context",
2530
+ relatedRelevance,
2531
+ ),
2532
+ relevance: relatedRelevance,
2533
+ tone: adverse ? "warning" : "info",
2534
+ title: adverse ? "DNS failure signals" : "DNS configuration",
2535
+ summary:
2536
+ signals[0] ||
2537
+ `${findings.length} CoreDNS finding${findings.length === 1 ? "" : "s"}`,
2538
+ data: { type: "dns", dns: dns as unknown as DiagnosisDNSContext },
2539
+ });
2540
+ }
2541
+ } else if (value.dnsContext !== undefined) {
2542
+ invalidPayload(builder, source, "DNS context");
2543
+ }
2544
+ }
2545
+
2546
+ function adaptIssues(
2547
+ builder: ProjectionBuilder,
2548
+ source: InvestigationEvidenceSource,
2549
+ payload: unknown,
2550
+ ): void {
2551
+ const value = record(payload);
2552
+ const raw = value?.issues;
2553
+ if (
2554
+ !value ||
2555
+ !Array.isArray(raw) ||
2556
+ typeof value.total !== "number" ||
2557
+ typeof value.total_matched !== "number"
2558
+ ) {
2559
+ invalidPayload(builder, source);
2560
+ return;
2561
+ }
2562
+ const issues = raw.map(issue).filter((item): item is Issue => Boolean(item));
2563
+ if (issues.length !== raw.length) {
2564
+ invalidPayload(builder, source);
2565
+ return;
2566
+ }
2567
+ addNarrowHint(builder, source, value);
2568
+ if (
2569
+ value.total_matched > issues.length &&
2570
+ !nonEmptyString(value.narrowHint)
2571
+ ) {
2572
+ builder.limit(
2573
+ source,
2574
+ "Issues",
2575
+ `The query returned ${issues.length} of ${value.total_matched} matching issues.`,
2576
+ "truncated",
2577
+ );
2578
+ }
2579
+ if (typeof value.filter_errors === "number" && value.filter_errors > 0) {
2580
+ builder.limit(
2581
+ source,
2582
+ "Issues filter",
2583
+ nonEmptyString(value.filter_error_sample)
2584
+ ? value.filter_error_sample
2585
+ : `${value.filter_errors} issue rows could not be evaluated by the filter.`,
2586
+ "error",
2587
+ );
2588
+ }
2589
+ if (value.recent_changes_truncated === true) {
2590
+ builder.limit(
2591
+ source,
2592
+ "Issue-related changes",
2593
+ "The issue response omitted some recent changes.",
2594
+ "truncated",
2595
+ );
2596
+ }
2597
+ if (value.correlation_truncated === true) {
2598
+ builder.limit(
2599
+ source,
2600
+ "Issue change correlation",
2601
+ "Change correlation was not evaluated for every returned issue.",
2602
+ "truncated",
2603
+ );
2604
+ }
2605
+ const visibility = record(value.visibility);
2606
+ if (
2607
+ visibility &&
2608
+ nonEmptyString(visibility.state) &&
2609
+ visibility.state !== "ok"
2610
+ ) {
2611
+ builder.limit(
2612
+ source,
2613
+ "Issue visibility",
2614
+ nonEmptyString(visibility.impact)
2615
+ ? visibility.impact
2616
+ : `Radar reported ${visibility.state} visibility for this issue query.`,
2617
+ "unknown",
2618
+ );
2619
+ }
2620
+ const issueChangesRaw = value.recent_changes;
2621
+ if (Array.isArray(issueChangesRaw)) {
2622
+ const changes = issueChangesRaw
2623
+ .map(recentChange)
2624
+ .filter((item): item is IssueRecentChange => Boolean(item));
2625
+ if (changes.length === issueChangesRaw.length) {
2626
+ addChanges(
2627
+ builder,
2628
+ source,
2629
+ changes,
2630
+ `changes:issues:${source.args ?? scopeFromArgs(source)}`,
2631
+ undefined,
2632
+ value.recent_changes_truncated !== true,
2633
+ );
2634
+ } else {
2635
+ invalidPayload(builder, source, "Issue-related changes");
2636
+ }
2637
+ } else if (issueChangesRaw !== undefined) {
2638
+ invalidPayload(builder, source, "Issue-related changes");
2639
+ }
2640
+ const clusterDNS = record(record(value.cluster_context)?.dns);
2641
+ if (clusterDNS) {
2642
+ const signals = stringArray(clusterDNS.signals) ?? [];
2643
+ const findings = Array.isArray(clusterDNS.findings)
2644
+ ? clusterDNS.findings
2645
+ : [];
2646
+ if (signals.length > 0 || findings.length > 0) {
2647
+ builder.observe(`dns:issues:${scopeFromArgs(source)}`, "dns", source, {
2648
+ tier: "context",
2649
+ relevance: "broader",
2650
+ tone: "warning",
2651
+ title: "Cluster DNS signals",
2652
+ summary:
2653
+ signals[0] ||
2654
+ `${findings.length} CoreDNS finding${findings.length === 1 ? "" : "s"}`,
2655
+ data: {
2656
+ type: "dns",
2657
+ dns: {
2658
+ signals,
2659
+ coreDNSFindings: findings as DiagnosisDNSContext["coreDNSFindings"],
2660
+ },
2661
+ },
2662
+ });
2663
+ }
2664
+ }
2665
+ if (issues.length === 0) {
2666
+ if (!source.confirmedSuccess || builder.limitedSources.has(source.id))
2667
+ return;
2668
+ const args = record(source.args ? parseJSON(source.args) : undefined);
2669
+ if (!nonEmptyString(args?.namespace)) {
2670
+ builder.limit(
2671
+ source,
2672
+ "Issues",
2673
+ "Radar found no matching issues, but the search covered only namespaces the current user can access.",
2674
+ "unknown",
2675
+ );
2676
+ return;
2677
+ }
2678
+ const scope = scopeFromArgs(source);
2679
+ const relevance = sourceArgsRelevance(builder, source);
2680
+ builder.observe(`issues:${source.args ?? scope}`, "receipt", source, {
2681
+ tier: evidenceTierForRelevance("checked", relevance),
2682
+ relevance,
2683
+ tone: "neutral",
2684
+ title: "No matching live issues",
2685
+ summary: scope,
2686
+ data: {
2687
+ type: "receipt",
2688
+ checked: "issues",
2689
+ scope,
2690
+ message:
2691
+ "Radar's live-issue query completed and returned no matching issues.",
2692
+ },
2693
+ });
2694
+ } else {
2695
+ for (const item of issues) addIssueObservation(builder, source, item);
2696
+ }
2697
+ }
2698
+
2699
+ function adaptGetResource(
2700
+ builder: ProjectionBuilder,
2701
+ source: InvestigationEvidenceSource,
2702
+ payload: unknown,
2703
+ ): void {
2704
+ // Current producer modes are discriminated by the resource's own identity,
2705
+ // never by guessing an undocumented legacy wrapper:
2706
+ // 1. bare Kubernetes resource
2707
+ // 2. {resource, resourceContext, warnings}
2708
+ // 3. the same wrapper with requested extras.
2709
+ // Core Secrets use the producer's deliberately value-free detail shape,
2710
+ // normalized by kubernetesResource above.
2711
+ const bare = kubernetesResource(payload);
2712
+ const wrapper = record(payload);
2713
+ const resource = bare ?? kubernetesResource(wrapper?.resource);
2714
+ if (!resource) {
2715
+ invalidPayload(builder, source);
2716
+ return;
2717
+ }
2718
+ const value = bare ? undefined : wrapper;
2719
+ const context = value ? contextFrom(value.resourceContext) : undefined;
2720
+ if (value?.resourceContext !== undefined && !context) {
2721
+ invalidPayload(builder, source, "Resource context");
2722
+ }
2723
+ const warnings = stringArray(value?.warnings) ?? [];
2724
+ const relevance = relevanceForResource(builder, {
2725
+ kind: resource.kind,
2726
+ group: apiVersionToGroup(resource.apiVersion),
2727
+ namespace: resource.metadata.namespace,
2728
+ name: resource.metadata.name,
2729
+ });
2730
+ addResourceObservation(
2731
+ builder,
2732
+ source,
2733
+ resource,
2734
+ context,
2735
+ warnings,
2736
+ undefined,
2737
+ false,
2738
+ relevance,
2739
+ );
2740
+ if (!value) return;
2741
+ addNarrowHint(builder, source, value);
2742
+
2743
+ const errors: Array<[string, string]> = [
2744
+ ["eventsError", "Events"],
2745
+ ["recentChangesError", "Recent changes"],
2746
+ ["metricsError", "Metrics"],
2747
+ ["revisionsError", "Revisions"],
2748
+ ["includeError", "Requested include"],
2749
+ ];
2750
+ for (const [field, label] of errors) {
2751
+ if (nonEmptyString(value[field])) {
2752
+ builder.limit(source, label, value[field] as string, "error");
2753
+ }
2754
+ }
2755
+
2756
+ if (Array.isArray(value.events)) {
2757
+ const events = value.events
2758
+ .map(event)
2759
+ .filter((item): item is InvestigationEventEvidence => Boolean(item));
2760
+ if (events.length === value.events.length) {
2761
+ addEvents(
2762
+ builder,
2763
+ source,
2764
+ events,
2765
+ `events:get-resource:${scopeFromArgs(source)}`,
2766
+ (typeof value.eventsTotalGroups !== "number" ||
2767
+ value.eventsTotalGroups <= events.length) &&
2768
+ !nonEmptyString(value.eventsError),
2769
+ true,
2770
+ relevance,
2771
+ );
2772
+ if (
2773
+ typeof value.eventsTotalGroups === "number" &&
2774
+ value.eventsTotalGroups > events.length
2775
+ ) {
2776
+ builder.limit(
2777
+ source,
2778
+ "Events",
2779
+ `Radar received ${events.length} of ${value.eventsTotalGroups} event groups.`,
2780
+ "truncated",
2781
+ );
2782
+ }
2783
+ } else {
2784
+ invalidPayload(builder, source, "Events");
2785
+ }
2786
+ }
2787
+ const recentChangesRaw = value.recentChanges;
2788
+ const recentChangesSaturatedRaw = value.recentChangesSaturated;
2789
+ const recentChangesCoverageLimitedRaw = value.recentChangesCoverageLimited;
2790
+ const hasRecentChangesResult =
2791
+ recentChangesRaw !== undefined ||
2792
+ recentChangesSaturatedRaw !== undefined ||
2793
+ recentChangesCoverageLimitedRaw !== undefined;
2794
+ const recentChangesMetadataValid =
2795
+ typeof recentChangesSaturatedRaw === "boolean" &&
2796
+ typeof recentChangesCoverageLimitedRaw === "boolean";
2797
+ if (hasRecentChangesResult && !recentChangesMetadataValid) {
2798
+ invalidPayload(builder, source, "Recent changes");
2799
+ }
2800
+ if (Array.isArray(recentChangesRaw)) {
2801
+ const changes = recentChangesRaw
2802
+ .map(recentChange)
2803
+ .filter((item): item is IssueRecentChange => Boolean(item));
2804
+ if (changes.length === recentChangesRaw.length) {
2805
+ addChanges(
2806
+ builder,
2807
+ source,
2808
+ changes,
2809
+ `changes:get-resource:${scopeFromArgs(source)}`,
2810
+ undefined,
2811
+ recentChangesMetadataValid &&
2812
+ recentChangesSaturatedRaw === false &&
2813
+ recentChangesCoverageLimitedRaw === false &&
2814
+ !nonEmptyString(value.recentChangesError),
2815
+ true,
2816
+ relevance,
2817
+ );
2818
+ } else {
2819
+ invalidPayload(builder, source, "Recent changes");
2820
+ }
2821
+ } else if (
2822
+ recentChangesRaw === undefined &&
2823
+ recentChangesMetadataValid &&
2824
+ !nonEmptyString(value.recentChangesError)
2825
+ ) {
2826
+ addChanges(
2827
+ builder,
2828
+ source,
2829
+ [],
2830
+ `changes:get-resource:${scopeFromArgs(source)}`,
2831
+ undefined,
2832
+ recentChangesSaturatedRaw === false &&
2833
+ recentChangesCoverageLimitedRaw === false,
2834
+ true,
2835
+ relevance,
2836
+ );
2837
+ } else if (recentChangesRaw !== undefined) {
2838
+ invalidPayload(builder, source, "Recent changes");
2839
+ }
2840
+ if (recentChangesSaturatedRaw === true) {
2841
+ builder.limit(
2842
+ source,
2843
+ "Recent changes",
2844
+ "The recent-change result limit was reached; additional changes may exist in the requested window.",
2845
+ "truncated",
2846
+ );
2847
+ }
2848
+ if (recentChangesCoverageLimitedRaw === true) {
2849
+ builder.limit(
2850
+ source,
2851
+ "Recent changes",
2852
+ `Change history for ${scopeFromArgs(source)} is incomplete.`,
2853
+ "unknown",
2854
+ "history",
2855
+ );
2856
+ }
2857
+ }
2858
+
2859
+ function adaptListResources(
2860
+ builder: ProjectionBuilder,
2861
+ source: InvestigationEvidenceSource,
2862
+ payload: unknown,
2863
+ ): void {
2864
+ if (!Array.isArray(payload)) {
2865
+ invalidPayload(builder, source);
2866
+ return;
2867
+ }
2868
+ const resources = payload
2869
+ .map(resourceSummary)
2870
+ .filter((item): item is InvestigationResourceSummary => Boolean(item));
2871
+ if (resources.length !== payload.length) {
2872
+ invalidPayload(builder, source);
2873
+ return;
2874
+ }
2875
+ const scope = scopeFromArgs(source);
2876
+ const args = record(parseJSON(source.args ?? ""));
2877
+ const kinds = [...new Set(resources.map((resource) => resource.kind))];
2878
+ const noun =
2879
+ kinds.length === 1
2880
+ ? kindToPlural(kinds[0]) === kinds[0].toLowerCase()
2881
+ ? displayKind(kinds[0])
2882
+ : englishPlural(displayKind(kinds[0]))
2883
+ : "Resources";
2884
+ const namespace = nonEmptyString(args?.namespace)
2885
+ ? args.namespace
2886
+ : undefined;
2887
+ const title = namespace ? `${noun} in ${namespace}` : noun;
2888
+ if (resources.length === 0) {
2889
+ // list_resources intentionally returns [] for some RBAC-filtered reads;
2890
+ // even a successful transport outcome therefore cannot prove absence.
2891
+ builder.limit(
2892
+ source,
2893
+ "Resource inventory",
2894
+ `Radar found no matching resources for ${scope}, but access restrictions may have hidden some results.`,
2895
+ "unknown",
2896
+ );
2897
+ return;
2898
+ }
2899
+ const hasAdverseResource = resources.some((resource) => {
2900
+ const health = resource.summaryContext?.health?.toLowerCase();
2901
+ return (
2902
+ health === "unhealthy" ||
2903
+ health === "degraded" ||
2904
+ (resource.summaryContext?.issueCount ?? 0) > 0
2905
+ );
2906
+ });
2907
+ builder.observe(`inventory:${source.args ?? scope}`, "inventory", source, {
2908
+ tier: "context",
2909
+ relevance: "broader",
2910
+ tone: hasAdverseResource ? "warning" : "neutral",
2911
+ title,
2912
+ summary: `${resources.length} returned${nonEmptyString(args?.group) ? ` · ${args.group}` : ""}`,
2913
+ data: { type: "inventory", resources, scope },
2914
+ });
2915
+ }
2916
+
2917
+ function adaptEvents(
2918
+ builder: ProjectionBuilder,
2919
+ source: InvestigationEvidenceSource,
2920
+ payload: unknown,
2921
+ ): void {
2922
+ const value = record(payload);
2923
+ if (!value || !Array.isArray(value.events)) {
2924
+ invalidPayload(builder, source);
2925
+ return;
2926
+ }
2927
+ const events = value.events
2928
+ .map(event)
2929
+ .filter((item): item is InvestigationEventEvidence => Boolean(item));
2930
+ if (events.length !== value.events.length) {
2931
+ invalidPayload(builder, source);
2932
+ return;
2933
+ }
2934
+ addNarrowHint(builder, source, value);
2935
+ addEvents(
2936
+ builder,
2937
+ source,
2938
+ events,
2939
+ `events:${source.args ?? scopeFromArgs(source)}`,
2940
+ !nonEmptyString(value.narrowHint),
2941
+ false,
2942
+ sourceArgsRelevance(builder, source),
2943
+ );
2944
+ }
2945
+
2946
+ function adaptPodLogs(
2947
+ builder: ProjectionBuilder,
2948
+ source: InvestigationEvidenceSource,
2949
+ payload: unknown,
2950
+ ): void {
2951
+ const value = record(payload);
2952
+ const logs = filteredLogs(payload) ?? filteredLogs(value);
2953
+ if (!logs) {
2954
+ invalidPayload(builder, source);
2955
+ return;
2956
+ }
2957
+ if (value) addNarrowHint(builder, source, value);
2958
+ const warnings = stringArray(value?.warnings) ?? [];
2959
+ const args = record(source.args ? parseJSON(source.args) : undefined);
2960
+ const pod = nonEmptyString(args?.name) ? args.name : "Pod";
2961
+ const container = nonEmptyString(args?.container)
2962
+ ? args.container
2963
+ : "default container";
2964
+ addLogs(
2965
+ builder,
2966
+ source,
2967
+ { pod, container, logs },
2968
+ previousFromArgs(source),
2969
+ warnings,
2970
+ sourceArgsRelevance(builder, source, "Pod"),
2971
+ nonEmptyString(args?.namespace) ? args.namespace : undefined,
2972
+ );
2973
+ }
2974
+
2975
+ function adaptChanges(
2976
+ builder: ProjectionBuilder,
2977
+ source: InvestigationEvidenceSource,
2978
+ payload: unknown,
2979
+ ): void {
2980
+ const value = record(payload);
2981
+ if (!value || !Array.isArray(value.changes)) {
2982
+ invalidPayload(builder, source, "Recent changes");
2983
+ return;
2984
+ }
2985
+ const changes = value.changes
2986
+ .map(recentChange)
2987
+ .filter((item): item is IssueRecentChange => Boolean(item));
2988
+ if (changes.length !== value.changes.length) {
2989
+ invalidPayload(builder, source, "Recent changes");
2990
+ return;
2991
+ }
2992
+ addNarrowHint(builder, source, value);
2993
+ let sourceErrors = 0;
2994
+ if (Array.isArray(value.sourcesErrored)) {
2995
+ for (const sourceError of value.sourcesErrored) {
2996
+ if (nonEmptyString(sourceError)) {
2997
+ sourceErrors += 1;
2998
+ builder.limit(source, "Recent changes", sourceError, "error");
2999
+ }
3000
+ }
3001
+ } else if (value.sourcesErrored !== undefined) {
3002
+ invalidPayload(builder, source, "Recent changes source coverage");
3003
+ }
3004
+ addChanges(
3005
+ builder,
3006
+ source,
3007
+ changes,
3008
+ `changes:${source.args ?? scopeFromArgs(source)}`,
3009
+ undefined,
3010
+ !nonEmptyString(value.narrowHint) && sourceErrors === 0,
3011
+ false,
3012
+ sourceArgsRelevance(builder, source),
3013
+ );
3014
+ }
3015
+
3016
+ function adaptNeighborhood(
3017
+ builder: ProjectionBuilder,
3018
+ source: InvestigationEvidenceSource,
3019
+ payload: unknown,
3020
+ ): void {
3021
+ const value = record(payload);
3022
+ const root = resourceRef(value?.root);
3023
+ const subgraph = record(value?.subgraph);
3024
+ if (
3025
+ !value ||
3026
+ !root ||
3027
+ !subgraph ||
3028
+ !Array.isArray(subgraph.nodes) ||
3029
+ !Array.isArray(subgraph.edges) ||
3030
+ typeof value.truncated !== "boolean"
3031
+ ) {
3032
+ invalidPayload(builder, source);
3033
+ return;
3034
+ }
3035
+ const nodes = subgraph.nodes
3036
+ .map(topologyNode)
3037
+ .filter((item): item is InvestigationTopologyNode => Boolean(item));
3038
+ const edges = subgraph.edges
3039
+ .map(topologyEdge)
3040
+ .filter((item): item is InvestigationTopologyEdge => Boolean(item));
3041
+ if (
3042
+ nodes.length !== subgraph.nodes.length ||
3043
+ edges.length !== subgraph.edges.length
3044
+ ) {
3045
+ invalidPayload(builder, source);
3046
+ return;
3047
+ }
3048
+ addNarrowHint(builder, source, value);
3049
+ if (value.truncated === true && !nonEmptyString(value.narrowHint)) {
3050
+ builder.limit(
3051
+ source,
3052
+ "Relationships",
3053
+ "The relationship view reached its resource limit and may be incomplete.",
3054
+ "truncated",
3055
+ );
3056
+ }
3057
+ for (const raw of Array.isArray(value.omitted) ? value.omitted : []) {
3058
+ const omitted = record(raw);
3059
+ if (
3060
+ !omitted ||
3061
+ !nonEmptyString(omitted.field) ||
3062
+ !nonEmptyString(omitted.reason)
3063
+ )
3064
+ continue;
3065
+ builder.limit(
3066
+ source,
3067
+ omitted.field,
3068
+ `Relationship context omitted: ${omitted.reason.replaceAll("_", " ")}.`,
3069
+ omitted.reason === "budget_exceeded" ? "truncated" : "unknown",
3070
+ );
3071
+ }
3072
+ builder.observe(
3073
+ `relationships:${root.group ?? ""}:${root.kind}:${root.namespace ?? ""}:${root.name}`,
3074
+ "relationships",
3075
+ source,
3076
+ {
3077
+ tier: "context",
3078
+ relevance: relevanceForResource(builder, {
3079
+ kind: root.kind,
3080
+ group: root.group ?? "",
3081
+ namespace: root.namespace,
3082
+ name: root.name,
3083
+ }),
3084
+ tone: "info",
3085
+ title: `Relationships around ${root.kind} ${root.name}`,
3086
+ summary: `${nodes.length} nodes · ${edges.length} relationships`,
3087
+ data: {
3088
+ type: "relationships",
3089
+ root,
3090
+ nodes,
3091
+ edges,
3092
+ truncated: value.truncated,
3093
+ },
3094
+ },
3095
+ );
3096
+ }
3097
+
3098
+ function adaptTopology(
3099
+ builder: ProjectionBuilder,
3100
+ source: InvestigationEvidenceSource,
3101
+ payload: unknown,
3102
+ ): void {
3103
+ const value = record(payload);
3104
+ if (!value) {
3105
+ invalidPayload(builder, source);
3106
+ return;
3107
+ }
3108
+ const partiality = topologyPartiality(value);
3109
+ if (!partiality) {
3110
+ invalidPayload(builder, source, "Topology coverage metadata");
3111
+ return;
3112
+ }
3113
+ const stats = record(value.stats);
3114
+ if (
3115
+ stats &&
3116
+ typeof stats.nodes === "number" &&
3117
+ typeof stats.edges === "number" &&
3118
+ Array.isArray(value.namespaces)
3119
+ ) {
3120
+ const namespaces = value.namespaces.flatMap((raw) => {
3121
+ const namespace = record(raw);
3122
+ const chains = stringArray(namespace?.chains);
3123
+ return namespace && nonEmptyString(namespace.namespace) && chains
3124
+ ? [{ namespace: namespace.namespace, chains }]
3125
+ : [];
3126
+ });
3127
+ if (namespaces.length !== value.namespaces.length) {
3128
+ invalidPayload(builder, source);
3129
+ return;
3130
+ }
3131
+ const problems =
3132
+ value.problems === undefined ? [] : stringArray(value.problems);
3133
+ if (!problems) {
3134
+ invalidPayload(builder, source, "Topology problems");
3135
+ return;
3136
+ }
3137
+ addTopologyLimitations(builder, source, partiality);
3138
+ builder.observe(
3139
+ `topology:${source.args ?? scopeFromArgs(source)}`,
3140
+ "topology",
3141
+ source,
3142
+ {
3143
+ tier: "context",
3144
+ relevance: "broader",
3145
+ tone: problems.length > 0 ? "warning" : "info",
3146
+ title: "Resource topology",
3147
+ summary: `${stats.nodes} nodes · ${stats.edges} relationships`,
3148
+ data: {
3149
+ type: "topology",
3150
+ stats: { nodes: stats.nodes, edges: stats.edges },
3151
+ namespaces,
3152
+ problems,
3153
+ warnings: partiality.warnings,
3154
+ },
3155
+ },
3156
+ );
3157
+ return;
3158
+ }
3159
+
3160
+ if (!Array.isArray(value.nodes) || !Array.isArray(value.edges)) {
3161
+ invalidPayload(builder, source);
3162
+ return;
3163
+ }
3164
+ const nodes = value.nodes
3165
+ .map(topologyNode)
3166
+ .filter((item): item is InvestigationTopologyNode => Boolean(item));
3167
+ const edges = value.edges
3168
+ .map(topologyEdge)
3169
+ .filter((item): item is InvestigationTopologyEdge => Boolean(item));
3170
+ if (
3171
+ nodes.length !== value.nodes.length ||
3172
+ edges.length !== value.edges.length
3173
+ ) {
3174
+ invalidPayload(builder, source);
3175
+ return;
3176
+ }
3177
+ const problems = nodes
3178
+ .filter((node) => node.status === "unhealthy" || node.status === "degraded")
3179
+ .map((node) => `${node.kind} ${node.name}: ${node.status}`);
3180
+ addTopologyLimitations(builder, source, partiality);
3181
+ builder.observe(
3182
+ `topology:${source.args ?? scopeFromArgs(source)}`,
3183
+ "topology",
3184
+ source,
3185
+ {
3186
+ tier: "context",
3187
+ relevance: "broader",
3188
+ tone: problems.length > 0 ? "warning" : "info",
3189
+ title: "Resource topology",
3190
+ summary: `${nodes.length} nodes · ${edges.length} relationships`,
3191
+ data: {
3192
+ type: "topology",
3193
+ stats: { nodes: nodes.length, edges: edges.length },
3194
+ namespaces: [],
3195
+ problems,
3196
+ warnings: partiality.warnings,
3197
+ },
3198
+ },
3199
+ );
3200
+ }
3201
+
3202
+ const ADAPTERS: Record<
3203
+ string,
3204
+ (
3205
+ builder: ProjectionBuilder,
3206
+ source: InvestigationEvidenceSource,
3207
+ payload: unknown,
3208
+ ) => void
3209
+ > = {
3210
+ diagnose: adaptDiagnose,
3211
+ issues: adaptIssues,
3212
+ get_resource: adaptGetResource,
3213
+ list_resources: adaptListResources,
3214
+ get_events: adaptEvents,
3215
+ get_pod_logs: adaptPodLogs,
3216
+ get_changes: adaptChanges,
3217
+ get_neighborhood: adaptNeighborhood,
3218
+ get_topology: adaptTopology,
3219
+ };
3220
+
3221
+ export function projectInvestigationEvidence(
3222
+ turns: readonly InvestigationEvidenceTurn[],
3223
+ target: InvestigationEvidenceTarget,
3224
+ ): InvestigationEvidenceProjection {
3225
+ const builder = new ProjectionBuilder(target);
3226
+ const evidenceRefSources: InvestigationEvidenceSource[] = [];
3227
+ const citableSources: InvestigationEvidenceSource[] = [];
3228
+ let order = 0;
3229
+ for (const [turnIndex, turn] of turns.entries()) {
3230
+ for (const [timelineIndex, item] of turn.timeline.entries()) {
3231
+ const itemOrder = order;
3232
+ order += 1;
3233
+ if (item.kind !== "tool") continue;
3234
+ // Full-local agents may load user MCP servers whose bare tool names collide
3235
+ // with Radar's. Only results matched by the server to the active private
3236
+ // transport ledger may enter the surface labelled "Radar evidence".
3237
+ if (item.radarEvidence !== true) continue;
3238
+ const source: InvestigationEvidenceSource = {
3239
+ id: investigationEvidenceSourceId(turnIndex, item.id),
3240
+ turnIndex,
3241
+ timelineIndex,
3242
+ stepId: item.id,
3243
+ tool: item.tool,
3244
+ args: item.summary,
3245
+ order: itemOrder,
3246
+ phase: turn.verify
3247
+ ? "verification"
3248
+ : turn.apply
3249
+ ? "apply"
3250
+ : turn.question
3251
+ ? "followup"
3252
+ : "initial",
3253
+ confirmedSuccess: item.status === "done" && item.isError === false,
3254
+ evidenceRef: item.evidenceRef,
3255
+ };
3256
+ if (item.evidenceRef) evidenceRefSources.push(source);
3257
+ if (item.status !== "done") continue;
3258
+ if (
3259
+ item.evidenceRef &&
3260
+ investigationEvidenceRefRe.test(item.evidenceRef) &&
3261
+ item.isError === false &&
3262
+ !item.truncated &&
3263
+ nonEmptyString(item.result)
3264
+ ) {
3265
+ citableSources.push(source);
3266
+ }
3267
+ const adapt = ADAPTERS[item.tool];
3268
+ if (!adapt) continue;
3269
+ builder.addSource(source);
3270
+ if (item.isError === true) {
3271
+ builder.limit(
3272
+ source,
3273
+ investigationResultLabel(source),
3274
+ item.result || "This investigation step failed.",
3275
+ "error",
3276
+ );
3277
+ continue;
3278
+ }
3279
+ if (item.truncated) {
3280
+ builder.limit(
3281
+ source,
3282
+ investigationResultLabel(source),
3283
+ "Only part of this investigation result was saved, so Radar could not summarize it here.",
3284
+ "truncated",
3285
+ );
3286
+ continue;
3287
+ }
3288
+ if (!nonEmptyString(item.result)) {
3289
+ builder.limit(
3290
+ source,
3291
+ investigationResultLabel(source),
3292
+ "This investigation step did not return details Radar could summarize.",
3293
+ "unknown",
3294
+ );
3295
+ continue;
3296
+ }
3297
+ const payload = parseJSON(item.result);
3298
+ if (payload === undefined) {
3299
+ invalidPayload(builder, source);
3300
+ continue;
3301
+ }
3302
+ adapt(builder, source, payload);
3303
+ if (item.isError !== false) {
3304
+ builder.limit(
3305
+ source,
3306
+ investigationResultLabel(source),
3307
+ "Radar cannot confirm whether this investigation step completed successfully. Available evidence is shown, but an empty result cannot confirm that nothing was found.",
3308
+ "unknown",
3309
+ );
3310
+ }
3311
+ }
3312
+ }
3313
+
3314
+ const tierRank: Record<InvestigationEvidenceTier, number> = {
3315
+ key: 0,
3316
+ supporting: 1,
3317
+ context: 2,
3318
+ checked: 3,
3319
+ };
3320
+ const primaryBySource = new Map<
3321
+ string,
3322
+ { groupId: string; rank: number; order: number }
3323
+ >();
3324
+ for (const group of builder.groups) {
3325
+ for (const observation of group.observations) {
3326
+ const candidate = {
3327
+ groupId: group.id,
3328
+ rank: tierRank[observation.tier],
3329
+ order: group.firstOrder,
3330
+ };
3331
+ const current = primaryBySource.get(observation.source.id);
3332
+ if (
3333
+ !current ||
3334
+ candidate.rank < current.rank ||
3335
+ (candidate.rank === current.rank && candidate.order < current.order)
3336
+ ) {
3337
+ primaryBySource.set(observation.source.id, candidate);
3338
+ }
3339
+ }
3340
+ }
3341
+ for (const source of builder.sources) {
3342
+ source.primaryGroupId = primaryBySource.get(source.id)?.groupId;
3343
+ }
3344
+
3345
+ // The investigation target, rather than the producer tool, is the proof
3346
+ // boundary for semantic diagnosis domains. A later successful target
3347
+ // diagnosis can therefore retire an exact-target issue first observed by
3348
+ // `issues`, while a broad or sibling read still cannot clear it.
3349
+ const targetProofScope = [
3350
+ builder.target.group.toLowerCase(),
3351
+ builder.target.kind.toLowerCase(),
3352
+ builder.target.namespace ?? "",
3353
+ builder.target.name,
3354
+ ].join("/");
3355
+ const semanticCoverageKey = (kind: InvestigationEvidenceKind) =>
3356
+ `semantic:${kind}:${targetProofScope}`;
3357
+ const collectionCoverageKey = (
3358
+ kind: InvestigationEvidenceKind,
3359
+ identity: string,
3360
+ ) => `collection:${kind}:${identity}`;
3361
+ const previousLogCoverageKey = (
3362
+ source: InvestigationEvidenceSource,
3363
+ podContainer: string,
3364
+ ) => `previous-log:${source.tool}:${scopeFromArgs(source)}:${podContainer}`;
3365
+ const retirementKey = (
3366
+ group: InvestigationEvidenceGroup,
3367
+ observation: InvestigationEvidenceObservation,
3368
+ ): string | undefined => {
3369
+ switch (group.kind) {
3370
+ case "issue":
3371
+ case "startup":
3372
+ case "crash":
3373
+ case "dns":
3374
+ return semanticCoverageKey(group.kind);
3375
+ case "events":
3376
+ case "changes":
3377
+ return collectionCoverageKey(group.kind, group.identity);
3378
+ case "logs":
3379
+ return group.identity.startsWith("logs:previous:")
3380
+ ? previousLogCoverageKey(
3381
+ observation.source,
3382
+ group.identity.slice("logs:previous:".length),
3383
+ )
3384
+ : undefined;
3385
+ case "network":
3386
+ return collectionCoverageKey(group.kind, group.identity);
3387
+ default:
3388
+ return undefined;
3389
+ }
3390
+ };
3391
+
3392
+ // Each completed verification contributes only the exact proof scopes its
3393
+ // successful producers covered. Keep every verification: supersession is
3394
+ // monotonic until a newer relevant observation reopens that semantic item.
3395
+ const verificationCoverage: Array<{
3396
+ turnIndex: number;
3397
+ keys: Set<string>;
3398
+ }> = [];
3399
+ turns.forEach((turn, turnIndex) => {
3400
+ if (!turn.verify || turn.status !== "done") return;
3401
+ const keys = new Set<string>();
3402
+ for (const group of builder.groups) {
3403
+ for (const observation of group.observations) {
3404
+ if (
3405
+ observation.source.turnIndex !== turnIndex ||
3406
+ !observation.source.confirmedSuccess ||
3407
+ observation.relevance === "broader"
3408
+ ) {
3409
+ continue;
3410
+ }
3411
+ if (observation.data.type === "receipt") {
3412
+ switch (observation.data.checked) {
3413
+ case "issues":
3414
+ keys.add(semanticCoverageKey("issue"));
3415
+ break;
3416
+ case "events":
3417
+ keys.add(collectionCoverageKey("events", group.identity));
3418
+ break;
3419
+ case "changes":
3420
+ keys.add(collectionCoverageKey("changes", group.identity));
3421
+ break;
3422
+ case "logs":
3423
+ if (group.identity.startsWith("previous-log-absence:")) {
3424
+ keys.add(
3425
+ previousLogCoverageKey(
3426
+ observation.source,
3427
+ group.identity.slice("previous-log-absence:".length),
3428
+ ),
3429
+ );
3430
+ }
3431
+ break;
3432
+ case "inventory":
3433
+ break;
3434
+ }
3435
+ continue;
3436
+ }
3437
+ if (
3438
+ observation.source.tool === "diagnose" &&
3439
+ observation.data.type === "resource"
3440
+ ) {
3441
+ for (const kind of builder.semanticCoverageBySource.get(
3442
+ observation.source.id,
3443
+ ) ?? []) {
3444
+ keys.add(semanticCoverageKey(kind));
3445
+ }
3446
+ }
3447
+ if (
3448
+ observation.source.tool === "diagnose" &&
3449
+ group.kind === "network"
3450
+ ) {
3451
+ keys.add(collectionCoverageKey("network", group.identity));
3452
+ }
3453
+ }
3454
+ }
3455
+ verificationCoverage.push({ turnIndex, keys });
3456
+ });
3457
+
3458
+ for (const group of builder.groups) {
3459
+ for (const observation of group.observations) {
3460
+ const key =
3461
+ observation.relevance !== "broader"
3462
+ ? retirementKey(group, observation)
3463
+ : undefined;
3464
+ observation.historical = Boolean(
3465
+ key &&
3466
+ verificationCoverage.some(
3467
+ (verification) =>
3468
+ verification.turnIndex > observation.source.turnIndex &&
3469
+ verification.keys.has(key),
3470
+ ),
3471
+ );
3472
+ }
3473
+ const latestRelevantObservation = [...group.observations]
3474
+ .reverse()
3475
+ .find((observation) => observation.relevance !== "broader");
3476
+ group.historical = latestRelevantObservation?.historical ?? false;
3477
+ }
3478
+
3479
+ return {
3480
+ groups: builder.groups,
3481
+ limitations: builder.limitations,
3482
+ sources: builder.sources,
3483
+ evidenceRefSources,
3484
+ citableSources,
3485
+ coverage: {
3486
+ attempted: builder.sources.length,
3487
+ projected: builder.projectedSources.size,
3488
+ limited: builder.limitedSources.size,
3489
+ checked: builder.checkedSources.size,
3490
+ },
3491
+ };
3492
+ }