@neat.is/core 0.9.13-dev.20260901 → 0.9.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -1284,13 +1284,19 @@ function resolveOwningService(graph, nodeId) {
1284
1284
  }
1285
1285
  return null;
1286
1286
  }
1287
+ function isFrontierEdge(e) {
1288
+ return e.provenance === import_types.Provenance.FRONTIER;
1289
+ }
1290
+ function rankOf(p) {
1291
+ return p === import_types.Provenance.FRONTIER ? -1 : import_types.PROV_RANK[p];
1292
+ }
1287
1293
  function bestEdgeBySource(graph, edgeIds) {
1288
1294
  const best = /* @__PURE__ */ new Map();
1289
1295
  for (const id of edgeIds) {
1290
1296
  const e = graph.getEdgeAttributes(id);
1291
- if (isFrontierNode(graph, e.source)) continue;
1297
+ if (isFrontierNode(graph, e.source) || isFrontierEdge(e)) continue;
1292
1298
  const cur = best.get(e.source);
1293
- if (!cur || import_types.PROV_RANK[e.provenance] > import_types.PROV_RANK[cur.provenance]) {
1299
+ if (!cur || rankOf(e.provenance) > rankOf(cur.provenance)) {
1294
1300
  best.set(e.source, e);
1295
1301
  }
1296
1302
  }
@@ -1300,9 +1306,9 @@ function bestEdgeByTarget(graph, edgeIds) {
1300
1306
  const best = /* @__PURE__ */ new Map();
1301
1307
  for (const id of edgeIds) {
1302
1308
  const e = graph.getEdgeAttributes(id);
1303
- if (isFrontierNode(graph, e.target)) continue;
1309
+ if (isFrontierNode(graph, e.target) || isFrontierEdge(e)) continue;
1304
1310
  const cur = best.get(e.target);
1305
- if (!cur || import_types.PROV_RANK[e.provenance] > import_types.PROV_RANK[cur.provenance]) {
1311
+ if (!cur || rankOf(e.provenance) > rankOf(cur.provenance)) {
1306
1312
  best.set(e.target, e);
1307
1313
  }
1308
1314
  }
@@ -1312,7 +1318,10 @@ var PROVENANCE_CEILING = {
1312
1318
  OBSERVED: 1,
1313
1319
  INFERRED: 0.7,
1314
1320
  EXTRACTED: 0.5,
1315
- STALE: 0.3
1321
+ STALE: 0.3,
1322
+ // A FRONTIER surface (ADR-226) is a proposal about a cause NEAT could not observe
1323
+ // — the least-trusted claim it makes, below STALE (which was at least once seen).
1324
+ FRONTIER: 0.2
1316
1325
  };
1317
1326
  function volumeWeight(spanCount) {
1318
1327
  if (!spanCount || spanCount <= 0) return 0.5;
@@ -1547,7 +1556,7 @@ function rootCauseFromIncidents(nodeId, incidents, errorEvent) {
1547
1556
  });
1548
1557
  }
1549
1558
  function isFailingCallEdge(e) {
1550
- return e.type === import_types.EdgeType.CALLS && (e.signal?.errorCount ?? 0) > 0;
1559
+ return e.type === import_types.EdgeType.CALLS && !isFrontierEdge(e) && (e.signal?.errorCount ?? 0) > 0;
1551
1560
  }
1552
1561
  function callSourcesForService(graph, serviceId17) {
1553
1562
  const ids = [serviceId17];
@@ -1570,8 +1579,8 @@ function failingCallDominates(e, id, curEdge, curId) {
1570
1579
  const ec = e.signal?.errorCount ?? 0;
1571
1580
  const cc = curEdge.signal?.errorCount ?? 0;
1572
1581
  if (ec !== cc) return ec > cc;
1573
- if (import_types.PROV_RANK[e.provenance] !== import_types.PROV_RANK[curEdge.provenance]) {
1574
- return import_types.PROV_RANK[e.provenance] > import_types.PROV_RANK[curEdge.provenance];
1582
+ if (rankOf(e.provenance) !== rankOf(curEdge.provenance)) {
1583
+ return rankOf(e.provenance) > rankOf(curEdge.provenance);
1575
1584
  }
1576
1585
  return id < curId;
1577
1586
  }
@@ -1622,11 +1631,11 @@ function dominantStaleCall(graph, serviceId17, visited) {
1622
1631
  for (const edgeId of graph.outboundEdges(src)) {
1623
1632
  const e = graph.getEdgeAttributes(edgeId);
1624
1633
  if (e.type !== import_types.EdgeType.CALLS) continue;
1625
- if (isFrontierNode(graph, e.target)) continue;
1634
+ if (isFrontierNode(graph, e.target) || isFrontierEdge(e)) continue;
1626
1635
  const owner = resolveOwningService(graph, e.target);
1627
1636
  if (!owner || visited.has(owner.id)) continue;
1628
1637
  const cur = bestByCallee.get(owner.id);
1629
- if (!cur || import_types.PROV_RANK[e.provenance] > import_types.PROV_RANK[cur.provenance]) {
1638
+ if (!cur || rankOf(e.provenance) > rankOf(cur.provenance)) {
1630
1639
  bestByCallee.set(owner.id, e);
1631
1640
  }
1632
1641
  }
@@ -1899,6 +1908,7 @@ function nodeContext(graph, nodeId, incidents, now = Date.now()) {
1899
1908
  for (const edgeId of graph.inboundEdges(n)) {
1900
1909
  const e = graph.getEdgeAttributes(edgeId);
1901
1910
  if (e.type === import_types.EdgeType.CONTAINS) continue;
1911
+ if (isFrontierEdge(e)) continue;
1902
1912
  errorsFromCallers += e.signal?.errorCount ?? 0;
1903
1913
  inboundVolume += e.callCount ?? e.signal?.spanCount ?? 0;
1904
1914
  if (e.provenance === import_types.Provenance.STALE) stale = true;
@@ -1912,6 +1922,7 @@ function nodeContext(graph, nodeId, incidents, now = Date.now()) {
1912
1922
  for (const edgeId of graph.outboundEdges(n)) {
1913
1923
  const e = graph.getEdgeAttributes(edgeId);
1914
1924
  if (e.type === import_types.EdgeType.CONTAINS) continue;
1925
+ if (isFrontierEdge(e)) continue;
1915
1926
  outboundVolume += e.callCount ?? e.signal?.spanCount ?? 0;
1916
1927
  if (e.type === import_types.EdgeType.CALLS) outboundErrors += e.signal?.errorCount ?? 0;
1917
1928
  if (e.provenance === import_types.Provenance.STALE) stale = true;
@@ -1945,6 +1956,11 @@ function isSaturated(ctx) {
1945
1956
  function isBoundaryTimeoutSymptom(ctx) {
1946
1957
  return ctx.errorsEmittedHere > 0 && ctx.boundaryTimeout === true && ctx.errorsFromCallers === 0 && ctx.hasOutboundDeps === true && ctx.observedErroringDownstream !== true;
1947
1958
  }
1959
+ var UNREACHABLE_INBOUND_ERROR_RATE = 0.5;
1960
+ var UNREACHABLE_MIN_INBOUND = 3;
1961
+ function isUnreachableSeed(ctx) {
1962
+ return ctx.callCount >= UNREACHABLE_MIN_INBOUND && ctx.errorsFromCallers > 0 && ctx.errorsFromCallers >= UNREACHABLE_INBOUND_ERROR_RATE * ctx.callCount && ctx.errorsEmittedHere === 0 && ctx.outboundVolume === 0;
1963
+ }
1948
1964
  function classifyNode(ctx) {
1949
1965
  if (ctx.errorsEmittedHere > 0) {
1950
1966
  if ((ctx.stale || isSaturated(ctx)) && ctx.errorsFromCallers >= ctx.errorsEmittedHere) {
@@ -1953,6 +1969,7 @@ function classifyNode(ctx) {
1953
1969
  if (isBoundaryTimeoutSymptom(ctx)) return "symptom-only";
1954
1970
  return "primary-failure";
1955
1971
  }
1972
+ if (isUnreachableSeed(ctx)) return "unreachable";
1956
1973
  if (ctx.errorsFromCallers > 0) return "symptom-only";
1957
1974
  return "unrelated";
1958
1975
  }
@@ -2189,22 +2206,44 @@ function routeMatches(declared, incident) {
2189
2206
  const b = normalizeRoute(incident);
2190
2207
  return a === b || a.startsWith(b) || b.startsWith(a);
2191
2208
  }
2192
- function structuralUpstreamPointer(graph, boundaryNode, incidents) {
2193
- const route = boundaryIncidentRoute(boundaryNode, incidents);
2194
- const callees = declaredOutboundCallees(graph, boundaryNode);
2209
+ function firstDeclaredCallee(graph, node, route) {
2210
+ const callees = declaredOutboundCallees(graph, node);
2195
2211
  if (callees.length === 0) return null;
2196
2212
  const narrowed = route !== void 0 ? callees.filter((c) => c.route !== void 0 && routeMatches(c.route, route)) : callees;
2197
2213
  const chosen = narrowed.length === 1 ? narrowed[0] : callees.length === 1 ? callees[0] : null;
2198
- if (!chosen) return null;
2199
- let node = chosen.target;
2200
- const seen = /* @__PURE__ */ new Set([boundaryNode, node]);
2201
- for (let depth = 0; depth < ROOT_CAUSE_MAX_DEPTH; depth++) {
2202
- const next = declaredOutboundCallees(graph, node);
2203
- if (next.length !== 1 || seen.has(next[0].target)) break;
2204
- node = next[0].target;
2205
- seen.add(node);
2214
+ return chosen ? chosen.target : null;
2215
+ }
2216
+ function observedOutboundNextServices(graph, nodeId) {
2217
+ const targets = /* @__PURE__ */ new Set();
2218
+ for (const n of nodeScope(graph, nodeId)) {
2219
+ if (!graph.hasNode(n)) continue;
2220
+ for (const edgeId of graph.outboundEdges(n)) {
2221
+ const e = graph.getEdgeAttributes(edgeId);
2222
+ if (!DEP_EDGE_TYPES.has(e.type)) continue;
2223
+ if (e.provenance !== import_types.Provenance.OBSERVED) continue;
2224
+ if (e.target !== nodeId) targets.add(e.target);
2225
+ }
2226
+ }
2227
+ return [...targets];
2228
+ }
2229
+ function resolveHangHop(graph, boundaryNode, incidents) {
2230
+ const route = boundaryIncidentRoute(boundaryNode, incidents);
2231
+ const own = firstDeclaredCallee(graph, boundaryNode, route);
2232
+ if (own) {
2233
+ return route !== void 0 ? { source: boundaryNode, target: own, route } : { source: boundaryNode, target: own };
2234
+ }
2235
+ const resolved = observedOutboundNextServices(graph, boundaryNode).map((n) => ({ source: n, target: firstDeclaredCallee(graph, n, route) })).filter((r) => r.target !== null);
2236
+ if (resolved.length === 1) {
2237
+ const hop = resolved[0];
2238
+ return route !== void 0 ? { ...hop, route } : hop;
2206
2239
  }
2207
- return route !== void 0 ? { node, route } : { node };
2240
+ return null;
2241
+ }
2242
+ function stagedHangProposal(graph, boundaryNode, incidents) {
2243
+ const hop = resolveHangHop(graph, boundaryNode, incidents);
2244
+ if (!hop) return null;
2245
+ if (!graph.hasEdge((0, import_types.frontierEdgeId)(hop.source, hop.target, import_types.EdgeType.CALLS))) return null;
2246
+ return hop.route !== void 0 ? { node: hop.target, route: hop.route } : { node: hop.target };
2208
2247
  }
2209
2248
  function enrichWithNavigation(graph, errorNodeId, tagged, incidents, now) {
2210
2249
  const legacy = tagged.result;
@@ -2239,29 +2278,39 @@ function enrichWithNavigation(graph, errorNodeId, tagged, incidents, now) {
2239
2278
  confidence: Math.min(legacy.confidence, 0.4),
2240
2279
  ...lastProv ? { provenance: lastProv } : {}
2241
2280
  });
2281
+ } else if (seedCtx && isUnreachableSeed(seedCtx)) {
2282
+ const name = displayNameOf(seedNode);
2283
+ candidates.push({
2284
+ node: seedNode,
2285
+ classification: "unreachable",
2286
+ reason: `${name} is unreachable: its callers' requests fail (${seedCtx.errorsFromCallers} erroring inbound calls) and it produced no telemetry of its own \u2014 no server spans, no outbound calls \u2014 so it never served. The failure is observed, but its cause is not in the trace: a startup failure, a crash before the first span, or an unschedulable / unhealthy pod. Inspect ${name}'s deploy state and logs \u2014 there is no code fault to find in the graph here.`,
2287
+ context: seedCtx,
2288
+ confidence: legacy.confidence,
2289
+ provenance: import_types.Provenance.OBSERVED
2290
+ });
2242
2291
  } else if (seedCtx && isBoundaryTimeoutSymptom(seedCtx)) {
2243
- const pointer = structuralUpstreamPointer(graph, seedNode, incidents);
2244
2292
  const seedName = displayNameOf(seedNode);
2245
- const routeNote = pointer?.route ? ` serving ${pointer.route}` : "";
2246
- if (pointer) {
2247
- const causeName = displayNameOf(pointer.node);
2248
- candidates.push({
2249
- node: pointer.node,
2250
- classification: "primary-failure",
2251
- reason: `${causeName} is the likely root cause (structural, INFERRED \u2014 not observed): the boundary ${seedName} only timed out waiting, and the actual culprit hung without exporting a span, so this is walked from the declared call graph${routeNote}, not from runtime signal. Restore instrumentation / inspect ${causeName} to confirm.`,
2252
- context: nodeContext(graph, pointer.node, incidents, now),
2253
- confidence: Math.min(legacy.confidence, PROVENANCE_CEILING.EXTRACTED),
2254
- provenance: import_types.Provenance.INFERRED
2255
- });
2256
- }
2293
+ const proposal = stagedHangProposal(graph, seedNode, incidents);
2294
+ const routeNote = proposal?.route ? ` serving ${proposal.route}` : "";
2257
2295
  candidates.push({
2258
2296
  node: seedNode,
2259
2297
  classification: "symptom-only",
2260
- reason: pointer ? `${seedName} timed out waiting on a downstream that hung and exported nothing \u2014 a boundary reporting an upstream fault, not the fault itself.` : `${seedName} timed out but nothing it can see downstream is erroring \u2014 the cause is downstream and unobserved (the culprit hung and exported no span). No single declared callee${routeNote} resolves, so no confident cause is named; inspect ${seedName}'s declared downstream.`,
2298
+ reason: proposal ? `${seedName} timed out waiting on a downstream that hung and exported no span \u2014 a boundary reporting an upstream fault, not the fault itself. The cause is unobservable; NEAT proposes the staged FRONTIER surface below, a hypothesis to confirm, not an observed cause.` : `${seedName} timed out but nothing it can see downstream is erroring \u2014 the cause is downstream and unobserved (the culprit hung and exported no span). No FRONTIER surface${routeNote} is staged, so no cause is proposed; inspect ${seedName}'s declared downstream and restore instrumentation.`,
2261
2299
  context: seedCtx,
2262
2300
  confidence: Math.min(legacy.confidence, 0.3),
2263
2301
  ...lastProv ? { provenance: lastProv } : {}
2264
2302
  });
2303
+ if (proposal) {
2304
+ const causeName = displayNameOf(proposal.node);
2305
+ candidates.push({
2306
+ node: proposal.node,
2307
+ classification: "primary-failure",
2308
+ reason: `${causeName} is the proposed hang cause (FRONTIER \u2014 a hypothesis, not observed): the boundary ${seedName} only timed out waiting and the culprit hung without exporting a span, so NEAT staged a FRONTIER surface on the unobservable hop${routeNote}. It graduates to OBSERVED when ${causeName} comes back; until then, inspect it / restore instrumentation to confirm, or it is culled.`,
2309
+ context: nodeContext(graph, proposal.node, incidents, now),
2310
+ confidence: Math.min(legacy.confidence, PROVENANCE_CEILING.FRONTIER),
2311
+ provenance: import_types.Provenance.FRONTIER
2312
+ });
2313
+ }
2265
2314
  } else if (staleChain) {
2266
2315
  const culprit = staleChain.culprit;
2267
2316
  const culpritName = displayNameOf(culprit);
@@ -2325,6 +2374,9 @@ function fixRecommendationForTop(top, seedNode, legacy) {
2325
2374
  return legacy.fixRecommendation;
2326
2375
  }
2327
2376
  const name = top.node.replace(/^service:/, "");
2377
+ if (top.classification === "symptom-only" && top.context.boundaryTimeout === true) {
2378
+ return `${name} timed out waiting on a downstream that hung and exported no span \u2014 the cause is unobservable from traces. Inspect the FRONTIER surface NEAT staged on the unobservable hop (see candidates); it graduates to OBSERVED when that service recovers. Restore instrumentation on that path to confirm, rather than treating ${name} as the fault.`;
2379
+ }
2328
2380
  if (top.provenance === import_types.Provenance.STALE) {
2329
2381
  return `Live telemetry for this path has gone quiet; the last-observed topology traces the failure downstream to ${name}. Restore instrumentation (or re-run with live traces) to confirm, then inspect ${name}.`;
2330
2382
  }
@@ -6286,6 +6338,14 @@ function mergeColumnsAt(graph, tableNodeId, columns, provenance, confidence) {
6286
6338
  function mergeObservedColumns(graph, tableNodeId, columns) {
6287
6339
  mergeColumnsAt(graph, tableNodeId, columns, import_types8.Provenance.OBSERVED, OBSERVED_COLUMN_CONFIDENCE);
6288
6340
  }
6341
+ function mergeObservedDeployState(graph, serviceNodeId, state) {
6342
+ if (!graph.hasNode(serviceNodeId)) return;
6343
+ const attrs = {};
6344
+ if (typeof state.image === "string" && state.image.length > 0) attrs["observedImage"] = state.image;
6345
+ if (typeof state.readyReplicas === "number") attrs["observedReadyReplicas"] = state.readyReplicas;
6346
+ if (Object.keys(attrs).length === 0) return;
6347
+ graph.mergeNodeAttributes(serviceNodeId, attrs);
6348
+ }
6289
6349
  function ensureDatabaseNode(graph, host, engine) {
6290
6350
  const id = (0, import_types8.databaseId)(host);
6291
6351
  if (graph.hasNode(id)) return id;
@@ -6982,6 +7042,32 @@ function promoteFrontierNodes(graph, opts = {}) {
6982
7042
  }
6983
7043
  return promoted;
6984
7044
  }
7045
+ function stageFrontierEdge(graph, source, target, type) {
7046
+ if (!graph.hasNode(source) || !graph.hasNode(target)) return false;
7047
+ if (graph.hasEdge((0, import_types8.observedEdgeId)(source, target, type))) return false;
7048
+ const key = (0, import_types8.frontierEdgeId)(source, target, type);
7049
+ if (graph.hasEdge(key)) return false;
7050
+ graph.addEdgeWithKey(key, source, target, {
7051
+ id: key,
7052
+ source,
7053
+ target,
7054
+ type,
7055
+ provenance: import_types8.Provenance.FRONTIER
7056
+ });
7057
+ return true;
7058
+ }
7059
+ function promoteFrontierEdges(graph) {
7060
+ const graduated = [];
7061
+ graph.forEachEdge((edgeId, attrs) => {
7062
+ const e = attrs;
7063
+ if (e.provenance !== import_types8.Provenance.FRONTIER) return;
7064
+ if (graph.hasEdge((0, import_types8.observedEdgeId)(e.source, e.target, e.type))) {
7065
+ graduated.push(edgeId);
7066
+ }
7067
+ });
7068
+ for (const edgeId of graduated) graph.dropEdge(edgeId);
7069
+ return graduated.length;
7070
+ }
6985
7071
  function rewireFrontierEdges(graph, frontierId2, serviceId17) {
6986
7072
  const inbound = [...graph.inboundEdges(frontierId2)];
6987
7073
  const outbound = [...graph.outboundEdges(frontierId2)];
@@ -7090,6 +7176,7 @@ function startStalenessLoop(graph, options = {}) {
7090
7176
  project: options.project
7091
7177
  });
7092
7178
  if (options.onPolicyTrigger) await options.onPolicyTrigger(graph);
7179
+ if (options.onReconcile) await options.onReconcile(graph);
7093
7180
  } catch (err) {
7094
7181
  console.error("staleness tick failed", err);
7095
7182
  }
@@ -16956,6 +17043,25 @@ function detectColumnDrift(node) {
16956
17043
  }
16957
17044
  return out;
16958
17045
  }
17046
+ var RECOMMENDATION_DEPLOY_IMAGE_MISMATCH = "The running image differs from the manifest-declared image \u2014 the rollout has not taken (the old ReplicaSet is still serving). Re-apply the deployment or investigate why the new image failed to roll out.";
17047
+ var DEPLOY_DIVERGENCE_CONFIDENCE = 0.9;
17048
+ function detectDeployDivergence(svc) {
17049
+ const out = [];
17050
+ if (typeof svc.declaredImage === "string" && typeof svc.observedImage === "string" && svc.declaredImage !== svc.observedImage) {
17051
+ out.push({
17052
+ type: "deploy-mismatch",
17053
+ kind: "image",
17054
+ source: svc.id,
17055
+ target: svc.id,
17056
+ declaredImage: svc.declaredImage,
17057
+ observedImage: svc.observedImage,
17058
+ confidence: DEPLOY_DIVERGENCE_CONFIDENCE,
17059
+ reason: `Service ${svc.name} declares image ${svc.declaredImage} but its running pods report ${svc.observedImage} \u2014 the deploy has not taken (the old version is still serving), so no incident fires.`,
17060
+ recommendation: RECOMMENDATION_DEPLOY_IMAGE_MISMATCH
17061
+ });
17062
+ }
17063
+ return out;
17064
+ }
16959
17065
  var SYMBOL_MISMATCH_PATTERNS = [
16960
17066
  {
16961
17067
  // "'ListProductsResponse' object has no attribute 'products_list'" and kin.
@@ -17283,6 +17389,7 @@ function computeDivergences(graph, opts = {}) {
17283
17389
  const svc = n;
17284
17390
  for (const d of detectHostMismatch(graph, nodeId, svc)) all.push(d);
17285
17391
  for (const d of detectCompatDivergences(graph, nodeId, svc)) all.push(d);
17392
+ for (const d of detectDeployDivergence(svc)) all.push(d);
17286
17393
  return;
17287
17394
  }
17288
17395
  if (n.type === import_types60.NodeType.InfraNode && n.kind === "sql-table") {
@@ -17323,7 +17430,12 @@ function computeDivergences(graph, opts = {}) {
17323
17430
  // orders last so at equal confidence the structural and symbol divergences
17324
17431
  // lead, per the contract ("rank below the definitive structural and symbol
17325
17432
  // divergences").
17326
- "observed-failing": 6
17433
+ "observed-failing": 6,
17434
+ // Deploy mismatch (ADR-225) is a definitive structural declared-vs-observed
17435
+ // divergence carrying high confidence (0.9), so the confidence sort already
17436
+ // places it among the structural leaders; this slot only breaks an exact
17437
+ // confidence tie and sits last as a stable tiebreaker.
17438
+ "deploy-mismatch": 7
17327
17439
  };
17328
17440
  filtered.sort((a, b) => {
17329
17441
  if (b.confidence !== a.confidence) return b.confidence - a.confidence;
@@ -18973,6 +19085,11 @@ async function runConnectorPoll(connector, ctx, graph, resolveTarget) {
18973
19085
  unresolved++;
18974
19086
  continue;
18975
19087
  }
19088
+ if (signal.deployState) {
19089
+ ensureServiceNode(graph, resolved.serviceName, NO_ENV);
19090
+ mergeObservedDeployState(graph, resolved.targetNodeId, signal.deployState);
19091
+ continue;
19092
+ }
18976
19093
  if (signal.incident) {
18977
19094
  ensureServiceNode(graph, resolved.serviceName, NO_ENV);
18978
19095
  if (!ctx.errorsPath || !graph.hasNode(resolved.targetNodeId)) {
@@ -23255,6 +23372,36 @@ var import_node_path77 = __toESM(require("path"), 1);
23255
23372
  var import_node_module = require("module");
23256
23373
  init_otel();
23257
23374
 
23375
+ // src/hang-sensor.ts
23376
+ init_cjs_shims();
23377
+ var import_types101 = require("@neat.is/types");
23378
+ var RECONCILE_INCIDENT_LIMIT = 500;
23379
+ function stageHangSurfaces(graph, incidents) {
23380
+ const boundaries = /* @__PURE__ */ new Set();
23381
+ for (const ev of incidents) {
23382
+ if (ev.affectedNode && graph.hasNode(ev.affectedNode)) boundaries.add(ev.affectedNode);
23383
+ }
23384
+ let staged = 0;
23385
+ for (const boundary of boundaries) {
23386
+ const ctx = nodeContext(graph, boundary, incidents);
23387
+ if (!isBoundaryTimeoutSymptom(ctx)) continue;
23388
+ const hop = resolveHangHop(graph, boundary, incidents);
23389
+ if (!hop) continue;
23390
+ if (stageFrontierEdge(graph, hop.source, hop.target, import_types101.EdgeType.CALLS)) staged++;
23391
+ }
23392
+ return staged;
23393
+ }
23394
+ async function reconcileFrontierSurfaces(graph, errorsPath) {
23395
+ promoteFrontierEdges(graph);
23396
+ let incidents = [];
23397
+ try {
23398
+ incidents = await readErrorEvents(errorsPath, { limit: RECONCILE_INCIDENT_LIMIT });
23399
+ } catch {
23400
+ incidents = [];
23401
+ }
23402
+ stageHangSurfaces(graph, incidents);
23403
+ }
23404
+
23258
23405
  // src/connectors/kubernetes/index.ts
23259
23406
  init_cjs_shims();
23260
23407
 
@@ -23432,6 +23579,7 @@ var IMAGE_PULL_REASONS = /* @__PURE__ */ new Set(["ImagePullBackOff", "ErrImageP
23432
23579
  var CRASH_LOOP_REASON = "CrashLoopBackOff";
23433
23580
  var FIELD_SEP5 = "\0";
23434
23581
  var K8S_TARGET_KIND = "k8s-workload";
23582
+ var K8S_DEPLOY_STATE = "deploy-state";
23435
23583
  function packK8sTargetName(identity) {
23436
23584
  return [identity.serviceName, identity.fault].join(FIELD_SEP5);
23437
23585
  }
@@ -23563,18 +23711,47 @@ function mapDeploymentToSignal(deployment, pods, config) {
23563
23711
  }
23564
23712
  };
23565
23713
  }
23714
+ function observedDeployState(deployment, pods) {
23715
+ const readyReplicas = typeof deployment.status?.readyReplicas === "number" ? deployment.status.readyReplicas : 0;
23716
+ let image;
23717
+ for (const pod of podsForDeployment(deployment, pods)) {
23718
+ for (const cs of pod.status?.containerStatuses ?? []) {
23719
+ if (cs.state?.running && typeof cs.image === "string" && cs.image.length > 0) {
23720
+ image = cs.image;
23721
+ break;
23722
+ }
23723
+ }
23724
+ if (image) break;
23725
+ }
23726
+ return image !== void 0 ? { image, readyReplicas } : { readyReplicas };
23727
+ }
23728
+ function deployStateSignal(deployment, pods, config) {
23729
+ const name = deployment.metadata?.name;
23730
+ if (typeof name !== "string" || name.length === 0) return null;
23731
+ const serviceName = serviceNameFor(deployment, config);
23732
+ return {
23733
+ targetKind: K8S_TARGET_KIND,
23734
+ targetName: packK8sTargetName({ serviceName, fault: K8S_DEPLOY_STATE }),
23735
+ callCount: 0,
23736
+ errorCount: 0,
23737
+ lastObservedIso: nowIso2(),
23738
+ deployState: observedDeployState(deployment, pods)
23739
+ };
23740
+ }
23566
23741
  function mapWorkloadsToSignals(deployments, pods, config) {
23567
23742
  const out = [];
23568
23743
  for (const deployment of deployments) {
23569
- const signal = mapDeploymentToSignal(deployment, pods, config);
23570
- if (signal) out.push(signal);
23744
+ const deployState = deployStateSignal(deployment, pods, config);
23745
+ if (deployState) out.push(deployState);
23746
+ const incident = mapDeploymentToSignal(deployment, pods, config);
23747
+ if (incident) out.push(incident);
23571
23748
  }
23572
23749
  return out;
23573
23750
  }
23574
23751
 
23575
23752
  // src/connectors/kubernetes/resolve.ts
23576
23753
  init_cjs_shims();
23577
- var import_types102 = require("@neat.is/types");
23754
+ var import_types103 = require("@neat.is/types");
23578
23755
  var NO_ENV3 = "unknown";
23579
23756
  function createK8sResolveTarget(graph) {
23580
23757
  return (signal) => {
@@ -23585,7 +23762,7 @@ function createK8sResolveTarget(graph) {
23585
23762
  return {
23586
23763
  targetNodeId: resolveFusedServiceId(graph, serviceName, NO_ENV3),
23587
23764
  serviceName,
23588
- edgeType: import_types102.EdgeType.CALLS
23765
+ edgeType: import_types103.EdgeType.CALLS
23589
23766
  };
23590
23767
  };
23591
23768
  }
@@ -23720,7 +23897,7 @@ function unroutedErrorsPath(neatHome3) {
23720
23897
  }
23721
23898
 
23722
23899
  // src/daemon.ts
23723
- var import_types105 = require("@neat.is/types");
23900
+ var import_types106 = require("@neat.is/types");
23724
23901
  function daemonJsonPath(scanPath) {
23725
23902
  return import_node_path77.default.join(scanPath, "neat-out", "daemon.json");
23726
23903
  }
@@ -23849,7 +24026,7 @@ function spanBelongsToSingleProject(graph, project, serviceName) {
23849
24026
  if (!serviceName) return true;
23850
24027
  if (serviceNameMatchesProject(serviceName, project)) return true;
23851
24028
  return graph.someNode(
23852
- (_id, attrs) => attrs.type === import_types105.NodeType.ServiceNode && attrs.name === serviceName
24029
+ (_id, attrs) => attrs.type === import_types106.NodeType.ServiceNode && attrs.name === serviceName
23853
24030
  );
23854
24031
  }
23855
24032
  async function bootstrapProject(entry, connectors = [], neatHome3) {
@@ -23893,7 +24070,11 @@ async function bootstrapProject(entry, connectors = [], neatHome3) {
23893
24070
  const stopPersist = startPersistLoop(graph, outPath, { exitOnSignal: false });
23894
24071
  const stopStaleness = startStalenessLoop(graph, {
23895
24072
  staleEventsPath: paths.staleEventsPath,
23896
- project: entry.name
24073
+ project: entry.name,
24074
+ // Graduate FRONTIER surfaces whose OBSERVED twin has arrived + stage surfaces
24075
+ // for current hangs (ADR-226), each post-ingest tick — the periodic point
24076
+ // where OBSERVED state has settled and the errors ledger is current.
24077
+ onReconcile: (g) => reconcileFrontierSurfaces(g, paths.errorsPath)
23897
24078
  });
23898
24079
  const stopConnectors = await startConnectorPolling({
23899
24080
  project: entry.name,