+ style={{ maxHeight: (isClaim(n.kind) ? QUEUE_HEIGHT : HEIGHT) - 18, overflowY: 'auto' }}>
{n.kind}: {n.label}
{context}
{source &&
sourceId: {source}
}
- {n.kind === 'queue' &&
+ {isClaim(n.kind) &&
{tt('Telemetry claim · AWS identity unverified')}
claimedAccountId: {text(n.meta?.claimedAccountId) || '—'}
claimedRegion: {text(n.meta?.claimedRegion) || '—'}
@@ -104,7 +107,7 @@ export default function ServiceMapPage() {
sourcePosition: viewport.vertical ? Position.Bottom : Position.Right,
targetPosition: viewport.vertical ? Position.Top : Position.Left,
style: { background: dark ? deep : light, border: `${n.id === focusId ? 2 : 1}px solid ${border}`,
- borderRadius: 10, padding: 8, width: 240, height: n.kind === 'queue' ? QUEUE_HEIGHT : HEIGHT,
+ borderRadius: 10, padding: 8, width: 240, height: isClaim(n.kind) ? QUEUE_HEIGHT : HEIGHT,
color: dark ? '#E3E9EE' : '#16202A', cursor: 'pointer' },
};
});
@@ -183,9 +186,9 @@ export default function ServiceMapPage() {
{selected &&
{tt('선택한 리소스 상세')} · {selected.kind}: {selected.label}
- {selected.kind === 'queue' && {tt('Telemetry claim · AWS identity unverified')}
}
+ {isClaim(selected.kind) && {tt('Telemetry claim · AWS identity unverified')}
}
- {['environment','cluster','namespace','sourceId','host','system','broker','destination','identityProvenance','deployment','pods','spanCount','errorSpanCount','unknownStatusSpanCount','claimedAccountId','claimedRegion'].map(key =>
+ {['environment','cluster','namespace','sourceId','host','system','broker','destination','arn','provider','model','endpoint','port','identityProvenance','deployment','pods','spanCount','errorSpanCount','unknownStatusSpanCount','claimedAccountId','claimedRegion'].map(key =>
selected.meta?.[key] != null ? - {key}
- {String(selected.meta[key])}
: null)}
{/* A claimed queue ARN is never an inventory bridge. */}
diff --git a/web/components/topology/E2eGraphCanvas.tsx b/web/components/topology/E2eGraphCanvas.tsx
index ba841fbff..d7de9cad0 100644
--- a/web/components/topology/E2eGraphCanvas.tsx
+++ b/web/components/topology/E2eGraphCanvas.tsx
@@ -387,6 +387,7 @@ export default function E2eGraphCanvas({ graph: inputGraph }: { graph: E2eGraph
{tt('스냅샷 표시 시각은 개별 노드·관계의 수집 시각이나 관측 구간이 아닙니다.')}
}
{['cluster', 'namespace', 'deployment', 'pod', 'pods', 'resolved', 'podName', 'podNamespace', 'instanceId', 'az', 'subnetId', 'serviceName', 'ip', 'vpcId', 'region', 'match', 'host', 'dbName', 'componentId', 'type',
+ 'arn', 'provider', 'model', 'endpoint', 'port', 'claimedAccountId', 'claimedRegion', 'identityProvenance',
'count', 'members', 'membersTruncated', 'memberIdentities', 'ownership_evidence', 'ownership_reason', 'ambiguity', 'e2e_correlation_blocked', 'workloadReadStatus'].map((key) => {
if (groupedTarget && (key === 'pod' || key === 'namespace')) return null;
const nested = object(selected.meta.endpoint) ? selected.meta.endpoint[key] : undefined;
diff --git a/web/lib/inference-evidence.ts b/web/lib/inference-evidence.ts
new file mode 100644
index 000000000..33cb5b35e
--- /dev/null
+++ b/web/lib/inference-evidence.ts
@@ -0,0 +1,34 @@
+import type { TraceSpan } from './trace-source';
+
+/** These are reported destination identities, never verified inventory ownership. */
+export function runtimeClaim(value: unknown) {
+ if (typeof value !== 'string' || value.length > 512) return null;
+ const match = /^arn:(aws(?:-[a-z0-9-]+)?):bedrock-agentcore:([a-z]{2,8}(?:-[a-z0-9]+)+-\d+):(\d{12}):runtime\/([A-Za-z0-9_][A-Za-z0-9_-]{0,255})$/.exec(value);
+ return match ? {
+ arn: value, name: match[4], claimedAccountId: match[3], claimedRegion: match[2],
+ identityProvenance: 'telemetry_claim' as const,
+ } : null;
+}
+
+export function reportingRuntime(span: TraceSpan) {
+ const claim = runtimeClaim(span.runtimeResourceArn ?? span.cloudResourceId);
+ if (!claim || (span.accountId && span.accountId !== claim.claimedAccountId)
+ || (span.region && span.region !== claim.claimedRegion)
+ || (span.cloudResourceId && span.cloudResourceId !== claim.arn)) return null;
+ return claim;
+}
+
+export function modelClaim(span: TraceSpan) {
+ if (span.kind !== 'CLIENT' || span.runtimeArn !== undefined) return null;
+ const provider = span.genAiProvider
+ ?? (/^(?:Amazon )?Bedrock Runtime$/i.test(span.rpcService ?? '')
+ || span.rpcService === 'bedrock-runtime' ? 'aws.bedrock' : undefined);
+ const model = span.genAiModel;
+ if (!provider || !/^[a-z][a-z0-9._-]{0,63}$/.test(provider) || !model
+ || !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/.test(model) || model.includes('://')) return null;
+ const endpoint = span.inferenceEndpoint?.toLowerCase().replace(/\.$/, '');
+ if (endpoint !== undefined && (endpoint.length > 253 || !endpoint.split('.').every(label =>
+ /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label)))) return null;
+ return { provider, model, endpoint: endpoint ?? null, port: span.inferencePort ?? null,
+ identityProvenance: 'telemetry_claim' as const };
+}
diff --git a/web/lib/inference-topology.test.ts b/web/lib/inference-topology.test.ts
new file mode 100644
index 000000000..4144904d5
--- /dev/null
+++ b/web/lib/inference-topology.test.ts
@@ -0,0 +1,138 @@
+import { describe, expect, it, vi } from 'vitest';
+vi.mock('@/lib/datasources', () => ({}));
+vi.mock('@/lib/mcp-lambda-invoke', () => ({}));
+import { mapOtelRow } from './trace-source';
+import { buildTraceGraph } from './trace-graph';
+
+const runtimeArn = 'arn:aws:bedrock-agentcore:ap-northeast-2:123456789012:runtime/insurance_analysis-AbCd012345';
+function span(attributes: Record
= {}, resource: Record = {}, kind = 'Client') {
+ return mapOtelRow({
+ TraceId: 'trace-a', SpanId: 'span-a', ServiceName: 'orchestrator', SpanKind: kind,
+ Timestamp: '2026-11-15T00:00:00Z', Duration: 5_000_000,
+ ResourceAttributes: { 'cloud.account.id': '123456789012', 'cloud.region': 'ap-northeast-2',
+ 'service.namespace': 'agents', 'deployment.environment': 'demo', ...resource },
+ SpanAttributes: attributes,
+ });
+}
+
+describe('inference dependency observations', () => {
+ it('keeps an exact runtime destination from an isolated client span without claiming inventory ownership', () => {
+ const graph = buildTraceGraph([span({
+ 'agentcore.runtime.arn': runtimeArn, 'agentcore.runtime.name': 'untrusted-display-name',
+ })], [], []);
+ const runtime = graph.nodes.find(node => node.kind === 'runtime');
+ expect(runtime).toMatchObject({
+ label: 'insurance_analysis-AbCd012345',
+ meta: { arn: runtimeArn, claimedAccountId: '123456789012', claimedRegion: 'ap-northeast-2',
+ identityProvenance: 'telemetry_claim' },
+ });
+ expect(runtime!.meta).not.toHaveProperty('infra_ref');
+ expect(runtime!.meta).not.toHaveProperty('accountId');
+ expect(graph.edges).toContainEqual(expect.objectContaining({
+ source: graph.nodes.find(node => node.kind === 'service')!.id, target: runtime!.id, rel: 'calls',
+ }));
+ });
+
+ it('links the same runtime claim to its reporting service and that service to its model', () => {
+ const request = span({ 'agentcore.runtime.arn': runtimeArn });
+ const modelCall = { ...span({
+ 'gen_ai.system': 'aws.bedrock', 'gen_ai.request.model': 'global.anthropic.example-v1',
+ 'server.address': 'bedrock-runtime.ap-northeast-2.amazonaws.com',
+ 'gen_ai.prompt': 'private prompt', 'gen_ai.completion': 'private response',
+ }, { 'cloud.resource_id': runtimeArn }), service: 'insurance_analysis', spanId: 'model-call' };
+ const graph = buildTraceGraph([request, modelCall], [], []);
+ const runtime = graph.nodes.find(node => node.kind === 'runtime')!;
+ const model = graph.nodes.find(node => node.kind === 'model')!;
+ const service = graph.nodes.find(node => node.label === 'insurance_analysis')!;
+ expect(graph.nodes.filter(node => node.kind === 'runtime')).toHaveLength(1);
+ expect(graph.edges).toEqual(expect.arrayContaining([
+ expect.objectContaining({ source: runtime.id, target: service.id, rel: 'hosts' }),
+ expect.objectContaining({ source: service.id, target: model.id, rel: 'calls' }),
+ ]));
+ expect(model.meta).toMatchObject({ model: 'global.anthropic.example-v1', provider: 'aws.bedrock',
+ endpoint: 'bedrock-runtime.ap-northeast-2.amazonaws.com', identityProvenance: 'telemetry_claim' });
+ expect(JSON.stringify(graph)).not.toContain('private prompt');
+ expect(JSON.stringify(graph)).not.toContain('private response');
+ });
+
+ it('retains distinct GPU model endpoints and weighted aggregate call counts', () => {
+ const first = { ...span({ 'gen_ai.provider.name': 'vllm', 'gen_ai.request.model': 'vendor/model',
+ 'server.address': 'gpu-a.sllm.svc.cluster.local' }), weight: 7 };
+ const second = { ...span({ 'gen_ai.provider.name': 'vllm', 'gen_ai.request.model': 'vendor/model',
+ 'server.address': 'gpu-b.sllm.svc.cluster.local' }), spanId: 'second', weight: 2 };
+ const graph = buildTraceGraph([first, second], [], []);
+ expect(graph.nodes.filter(node => node.kind === 'model')).toHaveLength(2);
+ expect(graph.edges.filter(edge => edge.rel === 'calls').map(edge => edge.meta.spanCount).sort())
+ .toEqual([2, 7]);
+ });
+
+ it('recognizes Bedrock SDK model attributes without requiring a remote server span', () => {
+ const graph = buildTraceGraph([span({
+ 'rpc.service': 'Bedrock Runtime', 'rpc.method': 'InvokeModel',
+ 'aws.bedrock.model_id': 'vendor.model-v1', 'server.address': 'bedrock-runtime.us-east-1.amazonaws.com',
+ })], [], []);
+ expect(graph.nodes.find(node => node.kind === 'model')?.meta).toMatchObject({
+ model: 'vendor.model-v1', provider: 'aws.bedrock',
+ });
+ });
+
+ it('keeps model servers on different ports distinct', () => {
+ const graph = buildTraceGraph([8000, 8080].map(port => ({
+ ...span({ 'gen_ai.system': 'vllm', 'gen_ai.request.model': 'vendor/model',
+ 'server.address': 'gpu.internal', 'server.port': String(port) }), spanId: `span-${port}`,
+ })), [], []);
+ expect(graph.nodes.filter(node => node.kind === 'model')).toHaveLength(2);
+ });
+
+ it('does not join identical cluster-local model names across clusters', () => {
+ const graph = buildTraceGraph(['cluster-a', 'cluster-b'].map(cluster => ({
+ ...span({ 'gen_ai.system': 'vllm', 'gen_ai.request.model': 'vendor/model',
+ 'server.address': 'gpu.sllm.svc.cluster.local' }, { 'k8s.cluster.name': cluster }),
+ spanId: cluster,
+ })), [], []);
+ expect(graph.nodes.filter(node => node.kind === 'model')).toHaveLength(2);
+ });
+
+ it('does not infer a direct model call from model annotations on a runtime invocation', () => {
+ const graph = buildTraceGraph([span({ 'agentcore.runtime.arn': runtimeArn,
+ 'gen_ai.system': 'aws.bedrock', 'gen_ai.request.model': 'example.model' })], [], []);
+ expect(graph.nodes.filter(node => node.kind === 'runtime')).toHaveLength(1);
+ expect(graph.nodes.filter(node => node.kind === 'model')).toHaveLength(0);
+ });
+
+ it.each([
+ { 'agentcore.runtime.arn': 'arn:aws:s3:::not-a-runtime' },
+ { 'agentcore.runtime.arn': `${runtimeArn}?token=private` },
+ { 'agentcore.runtime.name': 'same-name-without-arn' },
+ { 'gen_ai.request.model': 'model-without-provider' },
+ { 'gen_ai.provider.name': 'vllm', 'gen_ai.request.model': 'https://private@example.test/model' },
+ ])('rejects incomplete or malformed destination claims: %j', attributes => {
+ const graph = buildTraceGraph([span(attributes)], [], []);
+ expect(graph.nodes.filter(node => ['runtime', 'model'].includes(node.kind))).toEqual([]);
+ });
+
+ it('does not turn server or internal annotations into outgoing model calls', () => {
+ const graph = buildTraceGraph(['Server', 'Internal'].map((kind, index) => ({
+ ...span({ 'gen_ai.system': 'aws.bedrock', 'gen_ai.request.model': 'example.model',
+ 'agentcore.runtime.arn': runtimeArn }, {}, kind), spanId: `span-${index}`,
+ })), [], []);
+ expect(graph.edges.filter(edge => edge.rel === 'calls')).toEqual([]);
+ });
+
+ it('does not bind a runtime resource whose account conflicts with the reporting resource', () => {
+ const graph = buildTraceGraph([span({}, {
+ 'cloud.resource_id': runtimeArn, 'cloud.account.id': '999999999999',
+ })], [], []);
+ expect(graph.edges.filter(edge => edge.rel === 'hosts')).toEqual([]);
+ });
+
+ it('isolates claims across telemetry sources and environments', () => {
+ const graph = buildTraceGraph([
+ { ...span({ 'agentcore.runtime.arn': runtimeArn }), sourceId: 'clickhouse:1' },
+ { ...span({ 'agentcore.runtime.arn': runtimeArn }), sourceId: 'clickhouse:2', spanId: 'b' },
+ { ...span({ 'agentcore.runtime.arn': runtimeArn }, { 'deployment.environment': 'prod' }),
+ sourceId: 'clickhouse:1', spanId: 'c' },
+ ], [], []);
+ expect(graph.nodes.filter(node => node.kind === 'runtime')).toHaveLength(3);
+ });
+});
diff --git a/web/lib/trace-graph.ts b/web/lib/trace-graph.ts
index 2a3e14d60..4232caef8 100644
--- a/web/lib/trace-graph.ts
+++ b/web/lib/trace-graph.ts
@@ -2,6 +2,7 @@ import { createHash } from 'node:crypto';
import type { TraceIdentity, TraceSpan, ServiceGraphCall } from './trace-source';
import { queueClaimMeta, queueDestinationArn } from './trace-evidence';
import { DB_ENDPOINT_KINDS } from './infra-topology';
+import { modelClaim, reportingRuntime, runtimeClaim } from './inference-evidence';
export interface TraceNode { id: string; kind: string; label: string; meta: Record }
export interface InstanceObservation {
@@ -188,6 +189,13 @@ export function buildTraceGraph(
return added;
}
};
+ const runtimeNode = (claim: NonNullable>, span: TraceSpan) => {
+ // Exact ARN claims can connect caller and runtime telemetry only within one source/environment.
+ const id = nodeId('runtime', { sourceId: span.sourceId, environment: span.environment }, claim.arn);
+ if (!nodes.has(id)) nodes.set(id, { id, kind: 'runtime', label: claim.name,
+ meta: { ...claim, sourceId: span.sourceId ?? null, environment: span.environment ?? null } });
+ return id;
+ };
for (const span of byId.values()) {
// An aggregate row stands for `weight` spans; a raw span for one.
@@ -201,6 +209,20 @@ export function buildTraceGraph(
const traceIds = meta.traceIds as string[];
if (traceIds.length < 20 && !traceIds.includes(span.traceId)) traceIds.push(span.traceId);
+ const hostRuntime = reportingRuntime(span);
+ if (hostRuntime) edge(runtimeNode(hostRuntime, span), sid, 'hosts', w);
+ const targetRuntime = span.kind === 'CLIENT' ? runtimeClaim(span.runtimeArn) : null;
+ if (targetRuntime) edge(sid, runtimeNode(targetRuntime, span), 'calls', w);
+ const model = modelClaim(span);
+ if (model) {
+ // Preserve cluster/namespace even with a host: private DNS may be reused by another cluster.
+ // An unknown endpoint is additionally caller-local.
+ const id = nodeId('model', span,
+ JSON.stringify([model.provider, model.model, model.endpoint, model.port, model.endpoint ? null : span.service]));
+ if (!nodes.has(id)) nodes.set(id, { id, kind: 'model', label: model.model,
+ meta: { ...model, sourceId: span.sourceId ?? null, environment: span.environment ?? null } });
+ edge(sid, id, 'calls', w);
+ }
if (span.parentSpanId) {
const parent = byId.get(spanKey(span.sourceId, span.traceId, span.parentSpanId));
if (parent) {
diff --git a/web/lib/trace-source.test.ts b/web/lib/trace-source.test.ts
index 9b390dc0f..75781216a 100644
--- a/web/lib/trace-source.test.ts
+++ b/web/lib/trace-source.test.ts
@@ -29,6 +29,7 @@ import {
extractServiceGraphCalls,
type TraceSpan,
} from './trace-source';
+import { buildTraceGraph } from './trace-graph';
const span = (over: Partial = {}): TraceSpan => ({
traceId: 't1', spanId: 's1', service: 'svc', kind: 'SERVER', startMs: 0, durationMs: 1, ...over,
@@ -108,6 +109,30 @@ describe('ClickHouseOtelAggregateSource (whole-window aggregates)', () => {
return { rows, collectionStatus: rows.length ? 'ok' : 'empty' };
});
+ it('retains low-volume inference groups alongside frequent health traffic across the whole window', async () => {
+ const arn = 'arn:aws:bedrock-agentcore:ap-northeast-2:123456789012:runtime/analysis-AbCd012345';
+ answer([
+ group({ SpanKind: 'Server', SpanAttributes: {}, Weight: '50000', SampleSpanId: 'health' }),
+ group({ SpanAttributes: { 'agentcore.runtime.arn': arn }, Weight: '2', SampleSpanId: 'runtime' }),
+ group({ SpanAttributes: { 'gen_ai.provider.name': 'aws.bedrock', 'gen_ai.request.model': 'vendor.model',
+ 'server.address': 'bedrock-runtime.ap-northeast-2.amazonaws.com' },
+ Weight: '1', SampleSpanId: 'model' }),
+ ], []);
+ const out = await new ClickHouseOtelAggregateSource().recentSpans(60, 1000, END);
+ expect(out.status).toBe('ok');
+ const sql = invokeMcpLambdaTool.mock.calls[0][0].args.sql;
+ expect(sql).toContain("SpanAttributes['agentcore.runtime.arn'] != ''");
+ expect(sql).toContain("SpanAttributes['gen_ai.request.model'] != ''");
+ expect(sql).toContain("'gen_ai.provider.name'");
+ expect(sql).not.toContain("'gen_ai.prompt'");
+ expect(sql).toContain('GROUP BY ServiceName');
+ const graph = buildTraceGraph(out.items, out.calls ?? [], []);
+ expect(graph.nodes.filter(node => node.kind === 'runtime')).toHaveLength(1);
+ expect(graph.nodes.filter(node => node.kind === 'model')).toHaveLength(1);
+ expect(graph.edges.filter(edge => edge.rel === 'calls').map(edge => edge.meta.spanCount).sort())
+ .toEqual([1, 2]);
+ });
+
it('accepts only the catalog exporter template and reads its optional columns', () => {
expect(otelAggregateShape(undefined)).toEqual({ table: 'otel_traces', spanKind: true, statusCode: true, links: true });
const catalog = 'SELECT TraceId, SpanId, ParentSpanId, ServiceName, Timestamp, Duration, ResourceAttributes, ' +
diff --git a/web/lib/trace-source.ts b/web/lib/trace-source.ts
index c32102efc..b5d74e2fc 100644
--- a/web/lib/trace-source.ts
+++ b/web/lib/trace-source.ts
@@ -38,6 +38,14 @@ export interface TraceSpan extends TraceIdentity {
cloudResourceId?: string;
hostId?: string;
serviceInstanceId?: string;
+ /** Explicit inference metadata only; prompts, completions and HTTP bodies are never retained. */
+ runtimeArn?: string;
+ runtimeResourceArn?: string;
+ genAiProvider?: string;
+ genAiModel?: string;
+ inferenceEndpoint?: string;
+ inferencePort?: number;
+ rpcService?: string;
messagingSystem?: string;
messagingDestination?: string;
messagingBroker?: string;
@@ -209,6 +217,12 @@ function spanMetadata(out: TraceSpan, resource: Obj, span: Obj): void {
cloudResourceId: identityClaim(resource['cloud.resource_id']),
hostId: identityClaim(resource['host.id']),
serviceInstanceId: identityClaim(resource['service.instance.id']),
+ runtimeResourceArn: identityClaim(resource['agentcore.runtime.arn']),
+ runtimeArn: identityClaim(span['agentcore.runtime.arn']),
+ genAiProvider: identityClaim(span['gen_ai.provider.name']) ?? identityClaim(span['gen_ai.system']),
+ genAiModel: identityClaim(span['gen_ai.request.model']) ?? identityClaim(span['aws.bedrock.model_id']),
+ inferenceEndpoint: identityClaim(span['server.address']) ?? identityClaim(span['net.peer.name']),
+ rpcService: identityClaim(span['rpc.service']),
k8sNamespace: text(resource['k8s.namespace.name']), k8sCluster: text(resource['k8s.cluster.name']),
k8sPod: text(resource['k8s.pod.name']), k8sDeployment: text(resource['k8s.deployment.name']),
dbSystem: text(span['db.system']), dbName: text(span['db.name']),
@@ -221,6 +235,7 @@ function spanMetadata(out: TraceSpan, resource: Obj, span: Obj): void {
for (const [key, value] of Object.entries(fields)) if (value !== undefined) Object.assign(out, { [key]: value });
const address = text(span['server.address']) ?? text(span['net.peer.name']);
const port = numeric(span['server.port']) ?? numeric(span['net.peer.port']);
+ if (port !== undefined && Number.isInteger(port) && port > 0 && port <= 65535) out.inferencePort = port;
if (out.messagingSystem && address && port !== undefined
&& Number.isInteger(port) && port > 0 && port <= 65535) {
out.messagingBroker = `${address.trim().toLowerCase()}:${port}`;
@@ -430,10 +445,13 @@ const AGG_RESOURCE_KEYS = [
'service.name', 'cloud.account.id', 'cloud.region', 'deployment.environment.name',
'deployment.environment', 'service.namespace', 'service.version', 'cloud.resource_id', 'host.id',
'service.instance.id', 'k8s.namespace.name', 'k8s.cluster.name', 'k8s.deployment.name',
+ 'agentcore.runtime.arn',
] as const;
const AGG_SPAN_KEYS = [
'db.system', 'db.name', 'server.address', 'net.peer.name', 'server.port', 'net.peer.port',
'peer.service', 'messaging.system', 'messaging.destination.name', 'messaging.destination',
+ 'agentcore.runtime.arn', 'gen_ai.provider.name', 'gen_ai.system', 'gen_ai.request.model',
+ 'aws.bedrock.model_id', 'rpc.service',
] as const;
// Node scope only (trace-graph.ts scope()); edge endpoints must land on the same service nodes.
const AGG_IDENTITY_KEYS = [
@@ -448,7 +466,8 @@ function aggregateSql(shape: OtelAggregateShape, windowMins: number, endMs: numb
`AND Timestamp <= fromUnixTimestamp64Milli(${endMs})`;
const kind = shape.spanKind ? 'SpanKind' : "'' AS SpanKind";
const status = shape.statusCode ? 'StatusCode' : "'' AS StatusCode";
- // Non-db/messaging spans drop their peer attributes so HTTP peers do not multiply groups.
+ // Ordinary HTTP peers still collapse. Explicit inference destinations must survive
+ // grouping even when the managed service exports no matching server span.
const groups = 'SELECT ServiceName, SpanKind, StatusCode, RA AS ResourceAttributes, SA AS SpanAttributes, ' +
'count() AS Weight, intDiv(sum(Duration), 1000000) AS DurationMsSum, toUnixTimestamp64Milli(max(Timestamp)) AS LastTimestamp, ' +
'toUnixTimestamp64Milli(min(Timestamp)) AS FirstTimestamp, any(TraceId) AS SampleTraceId, ' +
@@ -458,7 +477,9 @@ function aggregateSql(shape: OtelAggregateShape, windowMins: number, endMs: numb
`mapSort(mapApply((k, v) -> (k, if(k = 'cloud.resource_id' AND NOT (${EC2_CLAIM}), 'unbindable', v)), ` +
`mapFilter((k, v) -> has(${list(AGG_RESOURCE_KEYS)}, k) AND (k NOT IN ('host.id', 'service.instance.id') ` +
`OR ${EC2_CLAIM}), ResourceAttributes))) AS RA, ` +
- "if(SpanAttributes['db.system'] != '' OR SpanAttributes['messaging.system'] != '', " +
+ "if(SpanAttributes['db.system'] != '' OR SpanAttributes['messaging.system'] != '' " +
+ "OR SpanAttributes['agentcore.runtime.arn'] != '' OR SpanAttributes['gen_ai.request.model'] != '' " +
+ "OR SpanAttributes['aws.bedrock.model_id'] != '', " +
`mapSort(mapFilter((k, v) -> has(${list(AGG_SPAN_KEYS)}, k), SpanAttributes)), ` +
'mapFilter((k, v) -> false, SpanAttributes)) AS SA ' +
`FROM ${shape.table} WHERE ${window}) ` +
From 662f383a464e8f778380e096ca1b19d6b68be1b3 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=EC=98=A4=EC=A4=80=EC=84=9D=28Junseok=20Oh=29DevOps?=
<52226147+Atom-oh@users.noreply.github.com>
Date: Sun, 15 Nov 2026 07:30:05 +0000
Subject: [PATCH 2/3] fix: address review feedback (iteration 1/5)
---
agent/lambda/inventory_read_mcp.py | 24 +++++++
agent/lambda/test_inventory_read_mcp.py | 29 +++++++-
docs/api-reference.md | 33 ++++++++-
docs/runbooks/agent-sql-reader.md | 31 +++++++--
docs/runbooks/graph-read-contract.md | 7 ++
...SF8H7_trace_inference_claim_provenance.sql | 67 +++++++++++++++++++
web/app/api/graph/route.test.ts | 8 +++
web/app/api/graph/route.ts | 4 +-
.../topology/E2eGraphCanvas.test.tsx | 9 +++
web/components/topology/E2eGraphCanvas.tsx | 1 +
web/lib/fixtures/trace-inference-claims.json | 47 +++++++++++++
web/lib/inference-evidence.ts | 24 +++++--
web/lib/inference-topology.test.ts | 11 ++-
13 files changed, 281 insertions(+), 14 deletions(-)
create mode 100644 terraform/foundation/migrations/01M7F17PJXXY3P2T3TGDJSF8H7_trace_inference_claim_provenance.sql
create mode 100644 web/lib/fixtures/trace-inference-claims.json
diff --git a/agent/lambda/inventory_read_mcp.py b/agent/lambda/inventory_read_mcp.py
index d92d045cf..20e0238f0 100644
--- a/agent/lambda/inventory_read_mcp.py
+++ b/agent/lambda/inventory_read_mcp.py
@@ -75,6 +75,10 @@ def _inventory_stale_after_minutes(env=None):
"account/region fallbacks. identityProvenance is always telemetry_claim, even when an ARN "
"names the host; queues never bridge into inventory. Shared destination ARNs join across "
"callers only within datasource/environment; the same ARN can have separate nodes in each scope."
+ " Runtime and requested-model identities are also telemetry claims, never inventory ownership "
+ "or proof of successful inference. Runtime qualifiers come only from a valid runtime ARN; "
+ "requested models have null account/region claims. A hosts edge is reported runtime context, "
+ "not a traced request. The requested model/endpoint does not prove the provider behind a Gateway."
)
MATERIALIZED_TOPOLOGY_NOTE = (
@@ -190,6 +194,23 @@ def build_topology_chain(by_type, root=None):
return chains
+def _inference_claim_meta(kind, meta):
+ """Typed, bounded read projection; never trust stored ownership or provenance."""
+ fields = {"sourceId": 512, "environment": 512}
+ fields.update({"arn": 512} if kind == "runtime" else {"provider": 64, "model": 256, "endpoint": 253})
+ safe = {key: meta[key] for key, limit in fields.items()
+ if isinstance(meta.get(key), str) and len(meta[key]) <= limit}
+ port = meta.get("port")
+ if kind == "model" and type(port) in (int, float) and 1 <= port <= 65535 and port == int(port):
+ safe["port"] = port
+ arn = re.fullmatch(
+ r"arn:aws(?:-[a-z0-9-]+)?:bedrock-agentcore:([a-z]{2,8}(?:-[a-z0-9]+)+-\d+):(\d{12}):runtime/[A-Za-z0-9_][A-Za-z0-9_-]{0,255}",
+ safe.get("arn", ""),
+ ) if kind == "runtime" else None
+ return {**safe, "claimedAccountId": arn[2] if arn else None,
+ "claimedRegion": arn[1] if arn else None, "identityProvenance": "telemetry_claim"}
+
+
def _fetch_topology_graph(resource_id=None, cls="flow", limit=500):
"""Read the materialized topology graph from topology_nodes/edges (ADR-010[legacy 043]).
@@ -312,6 +333,9 @@ def _parse_meta(m):
"meta": _parse_meta(r.get("meta"))} for r in node_rows if r.get("id")]
if cls == "trace":
for node in nodes:
+ if node["kind"] in ("runtime", "model"):
+ node["meta"] = _inference_claim_meta(node["kind"], node["meta"])
+ continue
if node["kind"] != "queue":
continue
meta = node["meta"].copy()
diff --git a/agent/lambda/test_inventory_read_mcp.py b/agent/lambda/test_inventory_read_mcp.py
index 8763a3bd3..68b16358a 100644
--- a/agent/lambda/test_inventory_read_mcp.py
+++ b/agent/lambda/test_inventory_read_mcp.py
@@ -949,7 +949,7 @@ def setUpClass(cls):
cls._psql((migrations / name).read_text())
for suffix in ("_topology_inventory_evidence.sql", "_graph_attempt_disclosure.sql",
"_graph_projection_parity.sql", "_trace_queue_claim_provenance.sql",
- "_graph_read_indexes.sql"):
+ "_graph_read_indexes.sql", "_trace_inference_claim_provenance.sql"):
for path in sorted(migrations.glob("*" + suffix)):
cls._psql(path.read_text())
@@ -1003,6 +1003,22 @@ def setUp(self):
def tearDown(self):
inv._execute_override = None
+ def test_inference_view_projection_preserves_only_typed_claim_evidence(self):
+ cases = json.loads((Path(__file__).resolve().parents[2]
+ / "web/lib/fixtures/trace-inference-claims.json").read_text())
+ for index, case in enumerate(cases):
+ meta = json.dumps(case["meta"]).replace("'", "''")
+ self._psql(
+ "INSERT INTO public.topology_nodes(account_id,id,kind,label,meta,run_id,class) VALUES "
+ f"('self','claim-{index}','{case['kind']}','reported','{meta}'::jsonb,'test','trace');"
+ )
+ rows = self._execute("SELECT id,meta FROM topology_nodes ORDER BY id")
+ self.assertEqual([row["meta"] for row in rows], [case["expected"] for case in cases])
+ permissions = self._execute(
+ "SELECT has_table_privilege(current_user,'public.topology_nodes','SELECT') AS base_read"
+ )
+ self.assertFalse(permissions[0]["base_read"])
+
def _execute(self, sql, params=None):
# Translate the Data API's named scalar binds into PostgreSQL PREPARE binds.
# JSON ID sets remain scalar string binds. This shim verifies SQL/view semantics;
@@ -1340,6 +1356,17 @@ def _state(self, status="ok", details=None, captured_at=CAPTURED):
return {"status": status, "attempted_at": self.ATTEMPTED, "captured_at": captured_at,
"details": {"sources": [], "retainedPrevious": False} if details is None else details}
+ def test_inference_claim_projection_matches_web_contract(self):
+ cases = json.loads((Path(__file__).resolve().parents[2]
+ / "web/lib/fixtures/trace-inference-claims.json").read_text())
+ for case in cases:
+ with self.subTest(case=case["name"]):
+ body, _ = self._read(self._state(), nodes=[{
+ "id": "claim:one", "kind": case["kind"], "label": "reported target", "meta": case["meta"],
+ }])
+ self.assertEqual(body["nodes"][0]["meta"], case["expected"])
+ self.assertIn("Runtime and requested-model identities", body["note"])
+
def test_queue_arn_claims_remain_unverified_including_before_projection_migration(self):
cases = json.loads((Path(__file__).resolve().parents[2]
/ "web/lib/fixtures/trace-queue-claims.json").read_text())
diff --git a/docs/api-reference.md b/docs/api-reference.md
index 198f35f5e..f5cef9ea7 100644
--- a/docs/api-reference.md
+++ b/docs/api-reference.md
@@ -565,14 +565,43 @@ vetoes binding). Generated or custom templates, Tempo and other sources keep spa
graph mixing them sums both kinds of count.
Publication keeps at most 200 nodes and 500 edges (`nodeDrops`/`edgeDrops`). Nodes are selected
-from the heaviest edges first, both endpoints together, so a kept database, queue or workload
-always keeps its caller; remaining capacity goes to other nodes by span and metric volume.
+from the heaviest edges first, both endpoints together, including runtime/model relationships;
+remaining capacity goes to other nodes by span and metric volume.
Service-graph metrics contribute service→service `calls` only. Rows whose Tempo
`connection_type` is `database`, `messaging_system` or `virtual_node` name a database, queue or
uninstrumented peer rather than a service and are not drawn; databases and queues come from trace
spans with their host-qualified identity.
+### Trace inference claims
+
+The trace graph can also return `runtime` and `model` nodes from explicit span
+attributes. A client span with a valid AgentCore runtime ARN produces a `calls`
+relationship to that runtime. A reporting service with a matching runtime resource
+ARN and non-conflicting account/region produces `runtime → service` with `rel=hosts`.
+That relationship is reported execution context, not a traced request. An explicit
+provider and requested model on a client span produces a requested-model `calls`
+relationship; a valid runtime invocation does not also invent a direct model call.
+
+Runtime claims join only by exact ARN within source/environment. Model claims
+retain source/account/region/environment/cluster/namespace/port isolation; an
+unknown endpoint is also caller-local. Provider names are normalized to lowercase.
+Neither relationship proves successful inference or the final provider behind a
+Gateway, and capped publications still disclose partial coverage.
+
+HTTP, MCP and the current SQL-reader view force
+`identityProvenance=telemetry_claim` for these kinds, including retained rows.
+Only bounded typed `sourceId`, `environment`, runtime `arn`, or model
+`provider`/`model`/`endpoint` and a valid numeric `port` survive. Runtime
+`claimedAccountId`/`claimedRegion` are derived from a valid ARN; models and invalid
+runtime ARNs have null qualifiers. Stored ownership/provenance, `infra_ref`, raw
+provider blobs and prompt/response content are excluded.
+
+The SQL-reader projection requires the standalone migration described in the
+[inference projection runbook](runbooks/agent-sql-reader.md#trace-inference-projection).
+Until that migration is applied, the agent may have the node kind/label but lack
+its identity fields. A source merge is not deployment proof.
+
### Trace edge instance observations
Trace `queries` edges (service → `db`) may carry three additive, optional `meta` fields built by
diff --git a/docs/runbooks/agent-sql-reader.md b/docs/runbooks/agent-sql-reader.md
index 50c185e92..c49769f4d 100644
--- a/docs/runbooks/agent-sql-reader.md
+++ b/docs/runbooks/agent-sql-reader.md
@@ -330,9 +330,9 @@ baseline and this owner; audit the allowlist from this file, not the baseline.
### Current topology evidence contract
The current owner of `sql_reader.topology_nodes.meta` is
-[`01M27B0000C6QWJ50NRJ8YAH9D_trace_queue_claim_provenance.sql`](../../terraform/foundation/migrations/01M27B0000C6QWJ50NRJ8YAH9D_trace_queue_claim_provenance.sql).
-It selects named JSON keys with type checks for telemetry fields and a trace-queue
-exception. The current materialized flow writer persists `ownership_evidence` and
+[`01M7F17PJXXY3P2T3TGDJSF8H7_trace_inference_claim_provenance.sql`](../../terraform/foundation/migrations/01M7F17PJXXY3P2T3TGDJSF8H7_trace_inference_claim_provenance.sql).
+It preserves the prior queue/non-claim projection and adds bounded typed runtime/model
+claim projections. The current materialized flow writer persists `ownership_evidence` and
`targetCapturedAt` on **target nodes**, with VPC/subnet or ambiguity metadata where
applicable. `targetCapturedAt` dates only the target-group inventory row; it does not
date the independent task/subnet/pod evidence or establish current ownership. Host ECS
@@ -365,6 +365,10 @@ The projection is not an ownership validator:
destination-ARN-derived `claimedAccountId`/`claimedRegion`. Queue `accountId`,
`region` and `infra_ref` are removed. Neither parsed ARN syntax nor a storage
partition's `account_id` proves telemetry ownership.
+- Trace runtime/model nodes always expose `identityProvenance='telemetry_claim'`.
+ Runtime account/region qualifiers derive only from a valid runtime ARN; model
+ qualifiers remain null. Both kinds exclude stored ownership, inventory references
+ and raw provider content.
The node's exposed `captured_at` is graph materialization time, not its underlying
inventory capture or observation time. For collection quality, consult
@@ -393,13 +397,14 @@ state-read failure or detected publication change without extending the stored
multi-query snapshot. The internal writer scheduling clock `lastSourceAttemptedAtMs`
stays outside the projection and is covered by `web/lib/graph-reader-privacy.test.ts`.
Apply the new collection projection with the existing `make migrate` operator flow;
-the queue projection remains separately owned by the migration above.
+the node projection is owned by the inference migration above, preserving queue semantics.
### Trace queue projection / 트레이스 큐 투영
`01M279W0J9HNG1QT0MAS60KV8K_topology_graph_collection_state.sql` introduces graph evidence;
`01M2HM8BR5ZC0JZWGQ9ZFV1WT2_graph_projection_parity.sql` is the current collection-state projection owner, adding bounded inventory source clocks/provenance;
-`01M27B0000C6QWJ50NRJ8YAH9D_trace_queue_claim_provenance.sql` supersedes its node projection.
+`01M27B0000C6QWJ50NRJ8YAH9D_trace_queue_claim_provenance.sql` introduced the queue projection;
+the current inference migration preserves that branch.
After `make migrate`, spot-check `sql_reader.topology_nodes` where `class='trace' AND kind='queue'`:
`claimedAccountId/claimedRegion` must match only parsed destination ARN qualifiers, including
retained rows; non-ARN/malformed destinations have null claims and no reporter fallback.
@@ -408,6 +413,22 @@ copies stay absent for trace queues. SELECT is granted only on the view, never t
`scripts/v2/workers/test_graph_collection.py` exercises this contract on disposable PostgreSQL,
including grant restoration, reapplication, denied base-table reads and denied view writes.
+### Trace inference projection
+
+Apply `01M7F17PJXXY3P2T3TGDJSF8H7_trace_inference_claim_provenance.sql` through
+the reviewed standalone migration procedure before releasing the matching web
+and inventory-reader Lambda. `CREATE OR REPLACE VIEW` and `GRANT` are outside the
+automatic web migration subset; do not add a bypass or change historical headers.
+This preserves the existing view columns and SELECT grant and adds no base-table access.
+
+For `class='trace' AND kind IN ('runtime','model')`, spot-check the named-field
+projection, forced provenance and null model qualifiers. Malformed runtime ARNs
+must not inherit stored or caller account/region. The shared
+`web/lib/fixtures/trace-inference-claims.json` cases cover HTTP, MCP and the actual
+PostgreSQL view under `awsops_sql_reader` in `TestTopologySelectionSQL`, including
+continued denial of `public.topology_nodes` reads. Inference identity is still
+unavailable through an older view even when the Lambda adds a provenance note.
+
Trace edge instance observations (`instance_observations[]`, `instance_observations_omitted`,
`instance_observations_unbound`; see [API reference](../api-reference.md#trace-edge-instance-observations))
are not part of the agent evidence contract: `sql_reader.topology_edges` projects only numeric
diff --git a/docs/runbooks/graph-read-contract.md b/docs/runbooks/graph-read-contract.md
index 63dace111..0a3e958ee 100644
--- a/docs/runbooks/graph-read-contract.md
+++ b/docs/runbooks/graph-read-contract.md
@@ -36,6 +36,13 @@ After the reviewed deployment, inspect the registered source, fresh collection
state and one approved synthetic request. A keep-alive HTTP 200 is not model
execution proof.
+The matching SQL-reader projection is delivered by
+`01M7F17PJXXY3P2T3TGDJSF8H7_trace_inference_claim_provenance.sql`. Run it through
+the reviewed standalone migration path before web/Lambda release; views and
+grants are not admitted by automatic web migrations. The MCP and HTTP readers
+also project retained runtime/model claims at read time, preserving forced
+provenance and excluding stored ownership or raw provider content.
+
### Placement and live VPC connections
`/topology/infra` defaults to the persisted placement view from
diff --git a/terraform/foundation/migrations/01M7F17PJXXY3P2T3TGDJSF8H7_trace_inference_claim_provenance.sql b/terraform/foundation/migrations/01M7F17PJXXY3P2T3TGDJSF8H7_trace_inference_claim_provenance.sql
new file mode 100644
index 000000000..0573ea925
--- /dev/null
+++ b/terraform/foundation/migrations/01M7F17PJXXY3P2T3TGDJSF8H7_trace_inference_claim_provenance.sql
@@ -0,0 +1,67 @@
+-- since: 0.10.1
+-- Extend view-only trace evidence to runtime/model claims. No base-table grant.
+-- Queue and non-claim projections retain their existing contract.
+CREATE OR REPLACE VIEW sql_reader.topology_nodes
+WITH (security_invoker = false) AS
+SELECT account_id, id, kind, label, run_id, captured_at, class,
+ CASE WHEN class = 'trace' AND kind IN ('runtime', 'model') THEN
+ coalesce((
+ SELECT jsonb_object_agg(k, v)
+ FROM jsonb_each(CASE WHEN jsonb_typeof(meta) = 'object' THEN meta ELSE '{}'::jsonb END) AS fields(k, v)
+ WHERE (
+ jsonb_typeof(v) = 'string' AND (
+ (k IN ('sourceId', 'environment') AND length(v #>> '{}') <= 512)
+ OR (kind = 'runtime' AND k = 'arn' AND length(v #>> '{}') <= 512)
+ OR (kind = 'model' AND (
+ (k = 'provider' AND length(v #>> '{}') <= 64)
+ OR (k = 'model' AND length(v #>> '{}') <= 256)
+ OR (k = 'endpoint' AND length(v #>> '{}') <= 253)
+ ))
+ )
+ ) OR (
+ kind = 'model' AND k = 'port' AND
+ CASE WHEN jsonb_typeof(v) = 'number' THEN
+ (v #>> '{}')::numeric BETWEEN 1 AND 65535
+ AND (v #>> '{}')::numeric = trunc((v #>> '{}')::numeric)
+ ELSE false END
+ )
+ ), '{}'::jsonb)
+ || jsonb_build_object(
+ 'identityProvenance', 'telemetry_claim',
+ 'claimedAccountId', runtime_arn.parts[2],
+ 'claimedRegion', runtime_arn.parts[1]
+ )
+ ELSE
+ coalesce((
+ SELECT jsonb_object_agg(k, v) FROM jsonb_each(meta) AS fields(k, v)
+ WHERE (
+ k = ANY(ARRAY['invType','targetType','recordType','service','bucket','domain',
+ 'unresolved','resolvedTarget','ecsService','task','cluster','groupLabel','members',
+ 'aliases','port','health'])
+ OR (k = ANY(ARRAY['sourceId','accountId','region','environment','serviceNamespace',
+ 'namespace','deployment','system','host','dbName','infra_ref','destination','broker'])
+ AND jsonb_typeof(v) = 'string')
+ OR (k = ANY(ARRAY['spanCount','errorSpanCount','unknownStatusSpanCount','sampledDurationMs'])
+ AND jsonb_typeof(v) = 'number')
+ ) AND NOT (class = 'trace' AND kind = 'queue'
+ AND k = ANY(ARRAY['accountId','region','infra_ref']))
+ ), '{}'::jsonb)
+ || CASE WHEN class = 'trace' AND kind = 'queue' THEN jsonb_build_object(
+ 'identityProvenance', 'telemetry_claim',
+ 'claimedAccountId', destination_arn.parts[2],
+ 'claimedRegion', nullif(destination_arn.parts[1], '')
+ ) ELSE '{}'::jsonb END
+ END AS meta
+FROM public.topology_nodes
+LEFT JOIN LATERAL regexp_match(
+ CASE WHEN class = 'trace' AND kind = 'queue' AND jsonb_typeof(meta->'destination') = 'string'
+ THEN btrim(meta->>'destination', E' \t\n\r\f\013') END,
+ '^arn:[a-z0-9-]+:[a-z0-9-]+:([a-z0-9-]*):([0-9]{12}):[^[:space:]]+$'
+) AS destination_arn(parts) ON true
+LEFT JOIN LATERAL regexp_match(
+ CASE WHEN class = 'trace' AND kind = 'runtime' AND jsonb_typeof(meta->'arn') = 'string'
+ AND length(meta->>'arn') <= 512 AND meta->>'arn' !~ '[[:space:]]' THEN meta->>'arn' END,
+ '^arn:aws(?:-[a-z0-9-]+)?:bedrock-agentcore:([a-z]{2,8}(?:-[a-z0-9]+)+-[0-9]+):([0-9]{12}):runtime/[A-Za-z0-9_][A-Za-z0-9_-]{0,255}$'
+) AS runtime_arn(parts) ON true;
+
+GRANT SELECT ON sql_reader.topology_nodes TO awsops_sql_reader;
diff --git a/web/app/api/graph/route.test.ts b/web/app/api/graph/route.test.ts
index d1ee25b08..634bdc190 100644
--- a/web/app/api/graph/route.test.ts
+++ b/web/app/api/graph/route.test.ts
@@ -14,6 +14,7 @@ vi.mock('@/lib/db', () => ({ getPool: () => sharedPool }));
import { GET } from './route';
import { graphReadTransaction, graphTransaction, GraphReadBusy, GraphReadDeadline } from '@/lib/graph-transaction';
import claimCases from '../../../lib/fixtures/trace-queue-claims.json';
+import inferenceClaims from '../../../lib/fixtures/trace-inference-claims.json';
afterEach(() => vi.restoreAllMocks());
describe('graph collection evidence API', () => {
@@ -29,6 +30,13 @@ describe('graph collection evidence API', () => {
});
});
+ it.each(inferenceClaims)('projects retained inference evidence: $name', async fixture => {
+ query.mockImplementation(async (sql: string) => ({ rows: sql.includes('FROM topology_nodes')
+ ? [{ id: 'claim:one', kind: fixture.kind, label: 'reported target', meta: fixture.meta }] : [] }));
+ const body = await (await GET(new Request('http://localhost/api/graph?class=trace'))).json();
+ expect(body.nodes[0].meta).toEqual(fixture.expected);
+ });
+
it('returns failed collection evidence even when no graph nodes exist', async () => {
const response = await GET(new Request('http://localhost/api/graph?class=trace'));
expect(response.status).toBe(200);
diff --git a/web/app/api/graph/route.ts b/web/app/api/graph/route.ts
index 0dc30f31b..ba1a9fb85 100644
--- a/web/app/api/graph/route.ts
+++ b/web/app/api/graph/route.ts
@@ -4,6 +4,7 @@ import { downstream, upstream, FANOUT_CAP } from '@/lib/graph-query';
import { readGraphState, graphDiagnostic, type GraphClass } from '@/lib/graph-state';
import { graphReadTransaction, GraphReadBusy, GraphReadDeadline } from '@/lib/graph-transaction';
import { queueClaimMeta } from '@/lib/trace-evidence';
+import { inferenceClaimMeta } from '@/lib/inference-evidence';
import { redactInventorySecrets } from '@/lib/inventory-redaction';
import { splitUnionNodeId, unionNodeId, type GraphSelection } from '@/lib/graph-identity';
@@ -14,7 +15,8 @@ function evidenceNodes(rows: Record[], cls: string, union: boolean)
const meta = redactInventorySecrets(node.meta);
const { account_id, ...fields } = node;
return { ...fields, id: union && account_id ? unionNodeId(account_id, node.id) : node.id,
- meta: cls === 'trace' && node.kind === 'queue' ? queueClaimMeta(meta ?? {}) : meta };
+ meta: cls === 'trace' && node.kind === 'queue' ? queueClaimMeta(meta ?? {})
+ : cls === 'trace' && ['runtime', 'model'].includes(node.kind) ? inferenceClaimMeta(node.kind, meta ?? {}) : meta };
});
}
diff --git a/web/components/topology/E2eGraphCanvas.test.tsx b/web/components/topology/E2eGraphCanvas.test.tsx
index aecfba468..2eb635e66 100644
--- a/web/components/topology/E2eGraphCanvas.test.tsx
+++ b/web/components/topology/E2eGraphCanvas.test.tsx
@@ -59,6 +59,15 @@ async function select(label: string) {
}
describe('E2eGraphCanvas', () => {
+ it('keeps nested network endpoint fields without an object-valued inference endpoint row', async () => {
+ const nodes = graph.nodes.map(node => node.id === 'p1'
+ ? { ...node, meta: { endpoint: { ip: '10.0.1.1', podName: 'pod-a' } } } : node);
+ render();
+ const detail = await select('shop/pod-a');
+ expect(detail.getByText('10.0.1.1')).toBeTruthy();
+ expect(detail.queryByText('[object Object]')).toBeNull();
+ });
+
it.each(['ko', 'en', 'zh', 'ja'] as const)('names the actual context control in the %s guide', lang => {
const label = applyTerms(lang, '참고 정보 (캐시된 구성·경유 구성요소)');
const path = lang === 'ko' ? '../../../docs-site/docs/resources/topology.md'
diff --git a/web/components/topology/E2eGraphCanvas.tsx b/web/components/topology/E2eGraphCanvas.tsx
index d7de9cad0..3f06704c1 100644
--- a/web/components/topology/E2eGraphCanvas.tsx
+++ b/web/components/topology/E2eGraphCanvas.tsx
@@ -389,6 +389,7 @@ export default function E2eGraphCanvas({ graph: inputGraph }: { graph: E2eGraph
{['cluster', 'namespace', 'deployment', 'pod', 'pods', 'resolved', 'podName', 'podNamespace', 'instanceId', 'az', 'subnetId', 'serviceName', 'ip', 'vpcId', 'region', 'match', 'host', 'dbName', 'componentId', 'type',
'arn', 'provider', 'model', 'endpoint', 'port', 'claimedAccountId', 'claimedRegion', 'identityProvenance',
'count', 'members', 'membersTruncated', 'memberIdentities', 'ownership_evidence', 'ownership_reason', 'ambiguity', 'e2e_correlation_blocked', 'workloadReadStatus'].map((key) => {
+ if (key === 'endpoint' && object(selected.meta.endpoint)) return null;
if (groupedTarget && (key === 'pod' || key === 'namespace')) return null;
const nested = object(selected.meta.endpoint) ? selected.meta.endpoint[key] : undefined;
const value = selected.meta[key] ?? nested;
diff --git a/web/lib/fixtures/trace-inference-claims.json b/web/lib/fixtures/trace-inference-claims.json
new file mode 100644
index 000000000..9b0325e6a
--- /dev/null
+++ b/web/lib/fixtures/trace-inference-claims.json
@@ -0,0 +1,47 @@
+[
+ {
+ "name": "runtime ignores stored ownership",
+ "kind": "runtime",
+ "meta": {
+ "sourceId": "tempo:1", "environment": "demo",
+ "arn": "arn:aws:bedrock-agentcore:ap-northeast-2:123456789012:runtime/example-AbCd012345",
+ "accountId": "999999999999", "region": "us-west-2", "infra_ref": "private-inventory",
+ "claimedAccountId": "999999999999", "claimedRegion": "us-west-2",
+ "identityProvenance": "aws_verified", "row": {"secret": "hidden"}
+ },
+ "expected": {
+ "sourceId": "tempo:1", "environment": "demo",
+ "arn": "arn:aws:bedrock-agentcore:ap-northeast-2:123456789012:runtime/example-AbCd012345",
+ "claimedAccountId": "123456789012", "claimedRegion": "ap-northeast-2",
+ "identityProvenance": "telemetry_claim"
+ }
+ },
+ {
+ "name": "invalid runtime retains no account claim",
+ "kind": "runtime",
+ "meta": {"arn": "not-a-runtime-arn", "claimedAccountId": "999999999999", "region": "us-west-2"},
+ "expected": {"arn": "not-a-runtime-arn", "claimedAccountId": null, "claimedRegion": null, "identityProvenance": "telemetry_claim"}
+ },
+ {
+ "name": "requested model has no ownership qualifiers",
+ "kind": "model",
+ "meta": {
+ "sourceId": "clickhouse:2", "environment": "demo",
+ "provider": "aws.bedrock", "model": "vendor.model", "endpoint": "bedrock-runtime.ap-northeast-2.amazonaws.com", "port": 443,
+ "accountId": "999999999999", "region": "us-west-2", "infra_ref": "private-inventory",
+ "claimedAccountId": "999999999999", "claimedRegion": "us-west-2",
+ "identityProvenance": "aws_verified", "prompt": "hidden", "row": {"secret": "hidden"}
+ },
+ "expected": {
+ "sourceId": "clickhouse:2", "environment": "demo",
+ "provider": "aws.bedrock", "model": "vendor.model", "endpoint": "bedrock-runtime.ap-northeast-2.amazonaws.com", "port": 443,
+ "claimedAccountId": null, "claimedRegion": null, "identityProvenance": "telemetry_claim"
+ }
+ },
+ {
+ "name": "wrong JSON types are not exposed as model identities",
+ "kind": "model",
+ "meta": {"sourceId": 5, "provider": {}, "model": [], "endpoint": {"secret": "hidden"}, "port": "hidden", "row": {}},
+ "expected": {"claimedAccountId": null, "claimedRegion": null, "identityProvenance": "telemetry_claim"}
+ }
+]
diff --git a/web/lib/inference-evidence.ts b/web/lib/inference-evidence.ts
index 33cb5b35e..d6941f58d 100644
--- a/web/lib/inference-evidence.ts
+++ b/web/lib/inference-evidence.ts
@@ -4,7 +4,7 @@ import type { TraceSpan } from './trace-source';
export function runtimeClaim(value: unknown) {
if (typeof value !== 'string' || value.length > 512) return null;
const match = /^arn:(aws(?:-[a-z0-9-]+)?):bedrock-agentcore:([a-z]{2,8}(?:-[a-z0-9]+)+-\d+):(\d{12}):runtime\/([A-Za-z0-9_][A-Za-z0-9_-]{0,255})$/.exec(value);
- return match ? {
+ return match && match[0] === value ? {
arn: value, name: match[4], claimedAccountId: match[3], claimedRegion: match[2],
identityProvenance: 'telemetry_claim' as const,
} : null;
@@ -19,10 +19,10 @@ export function reportingRuntime(span: TraceSpan) {
}
export function modelClaim(span: TraceSpan) {
- if (span.kind !== 'CLIENT' || span.runtimeArn !== undefined) return null;
- const provider = span.genAiProvider
+ if (span.kind !== 'CLIENT' || runtimeClaim(span.runtimeArn) !== null) return null;
+ const provider = (span.genAiProvider
?? (/^(?:Amazon )?Bedrock Runtime$/i.test(span.rpcService ?? '')
- || span.rpcService === 'bedrock-runtime' ? 'aws.bedrock' : undefined);
+ || span.rpcService === 'bedrock-runtime' ? 'aws.bedrock' : undefined))?.toLowerCase();
const model = span.genAiModel;
if (!provider || !/^[a-z][a-z0-9._-]{0,63}$/.test(provider) || !model
|| !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/.test(model) || model.includes('://')) return null;
@@ -32,3 +32,19 @@ export function modelClaim(span: TraceSpan) {
return { provider, model, endpoint: endpoint ?? null, port: span.inferencePort ?? null,
identityProvenance: 'telemetry_claim' as const };
}
+
+/** Read-time projection also covers retained rows. Keep in sync with the SQL view and MCP. */
+export function inferenceClaimMeta(kind: string, meta: Record = {}): Record {
+ const safe: Record = {};
+ const fields: Record = { sourceId: 512, environment: 512 };
+ if (kind === 'runtime') fields.arn = 512;
+ else Object.assign(fields, { provider: 64, model: 256, endpoint: 253 });
+ for (const [key, limit] of Object.entries(fields)) {
+ if (typeof meta[key] === 'string' && meta[key].length <= limit) safe[key] = meta[key];
+ }
+ if (kind === 'model' && typeof meta.port === 'number'
+ && Number.isInteger(meta.port) && meta.port >= 1 && meta.port <= 65535) safe.port = meta.port;
+ const runtime = kind === 'runtime' ? runtimeClaim(safe.arn) : null;
+ return { ...safe, claimedAccountId: runtime?.claimedAccountId ?? null,
+ claimedRegion: runtime?.claimedRegion ?? null, identityProvenance: 'telemetry_claim' };
+}
diff --git a/web/lib/inference-topology.test.ts b/web/lib/inference-topology.test.ts
index 4144904d5..5f828d3be 100644
--- a/web/lib/inference-topology.test.ts
+++ b/web/lib/inference-topology.test.ts
@@ -5,7 +5,7 @@ import { mapOtelRow } from './trace-source';
import { buildTraceGraph } from './trace-graph';
const runtimeArn = 'arn:aws:bedrock-agentcore:ap-northeast-2:123456789012:runtime/insurance_analysis-AbCd012345';
-function span(attributes: Record = {}, resource: Record = {}, kind = 'Client') {
+function span(attributes: Record = {}, resource: Record = {}, kind = 'Client') {
return mapOtelRow({
TraceId: 'trace-a', SpanId: 'span-a', ServiceName: 'orchestrator', SpanKind: kind,
Timestamp: '2026-11-15T00:00:00Z', Duration: 5_000_000,
@@ -16,6 +16,15 @@ function span(attributes: Record = {}, resource: Record {
+ it('normalizes provider case and does not let an invalid runtime annotation suppress a model', () => {
+ const graph = buildTraceGraph([span({
+ 'gen_ai.system': 'AWS.Bedrock', 'gen_ai.request.model': 'vendor.model',
+ 'agentcore.runtime.arn': 'invalid-annotation',
+ })], [], []);
+ expect(graph.nodes.find(node => node.kind === 'model')?.meta.provider).toBe('aws.bedrock');
+ expect(graph.nodes.filter(node => node.kind === 'runtime')).toEqual([]);
+ });
+
it('keeps an exact runtime destination from an isolated client span without claiming inventory ownership', () => {
const graph = buildTraceGraph([span({
'agentcore.runtime.arn': runtimeArn, 'agentcore.runtime.name': 'untrusted-display-name',
From 3923f375ccbce4cc74d434285510e78cdf447219 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=EC=98=A4=EC=A4=80=EC=84=9D=28Junseok=20Oh=29DevOps?=
<52226147+Atom-oh@users.noreply.github.com>
Date: Sun, 15 Nov 2026 08:00:17 +0000
Subject: [PATCH 3/3] fix: address review feedback (iteration 2/5)
---
docs/api-reference.md | 5 +++--
docs/runbooks/agent-sql-reader.md | 11 ++++++-----
docs/runbooks/graph-read-contract.md | 4 ++++
docs/runbooks/steampipe-quota-and-staleness.md | 4 ++++
web/components/topology/E2eGraphCanvas.test.tsx | 14 ++++++++++++++
web/components/topology/E2eGraphCanvas.tsx | 17 +++++++++++++++--
6 files changed, 46 insertions(+), 9 deletions(-)
diff --git a/docs/api-reference.md b/docs/api-reference.md
index f5cef9ea7..a15b073f3 100644
--- a/docs/api-reference.md
+++ b/docs/api-reference.md
@@ -565,8 +565,9 @@ vetoes binding). Generated or custom templates, Tempo and other sources keep spa
graph mixing them sums both kinds of count.
Publication keeps at most 200 nodes and 500 edges (`nodeDrops`/`edgeDrops`). Nodes are selected
-from the heaviest edges first, both endpoints together, including runtime/model relationships;
-remaining capacity goes to other nodes by span and metric volume.
+from the heaviest edges first, both endpoints together. A kept database, queue, workload,
+runtime or model relationship keeps its caller; a `hosts` relationship keeps both its runtime
+and reporting service. Remaining capacity goes to other nodes by span and metric volume.
Service-graph metrics contribute service→service `calls` only. Rows whose Tempo
`connection_type` is `database`, `messaging_system` or `virtual_node` name a database, queue or
diff --git a/docs/runbooks/agent-sql-reader.md b/docs/runbooks/agent-sql-reader.md
index c49769f4d..e893345d5 100644
--- a/docs/runbooks/agent-sql-reader.md
+++ b/docs/runbooks/agent-sql-reader.md
@@ -382,13 +382,14 @@ raw provider JSON or widen grants. Computed `metadataTruncated` discloses omitte
`agent/lambda/test_inventory_view_contract.py` still reads the original
`01KYVY9J2E8AMF35WR4J7036A3_agent_sql_reader_role.sql` for its topology assertions.
Those baseline text assertions do **not** enforce the current topology projection.
-Inspect the current migration and the queue/view cases in
-`scripts/v2/workers/test_graph_collection.py`; this clarification does not retarget
-tests or claim a fresh PostgreSQL execution. The current collection-view projection and
+Inspect the current migration and shared inference fixtures in
+`agent/lambda/test_inventory_read_mcp.py::TestTopologySelectionSQL`.
+`scripts/v2/workers/test_graph_collection.py` covers the historical queue/collection
+projections, not the latest inference view owner. The current collection-view projection and
API repeatable-read/timeout/cap behavior are independently covered by
`web/lib/graph-read-postgres.test.ts`; the Python reader's actual canonical/raw-ID,
neighbourhood and truncation SQL is covered by `TestTopologySelectionSQL` under the
-current collection, queue-provenance and read-index migrations. See
+current collection, queue-provenance, inference-provenance and read-index migrations. See
[local test prerequisites](graph-read-contract.md#reader-postgresql-verification).
Its `selection`, `truncation`, `readOutcome` and `snapshotConsistent` envelope is
reader-specific and does not assert HTTP transaction parity. `readOutcome` describes
@@ -410,7 +411,7 @@ After `make migrate`, spot-check `sql_reader.topology_nodes` where `class='trace
retained rows; non-ARN/malformed destinations have null claims and no reporter fallback.
`identityProvenance` is always `telemetry_claim`; `accountId`, `region`, `infra_ref` and whole-row
copies stay absent for trace queues. SELECT is granted only on the view, never the base table.
-`scripts/v2/workers/test_graph_collection.py` exercises this contract on disposable PostgreSQL,
+`scripts/v2/workers/test_graph_collection.py` exercises the historical queue projection on disposable PostgreSQL,
including grant restoration, reapplication, denied base-table reads and denied view writes.
### Trace inference projection
diff --git a/docs/runbooks/graph-read-contract.md b/docs/runbooks/graph-read-contract.md
index 0a3e958ee..f8e6bb571 100644
--- a/docs/runbooks/graph-read-contract.md
+++ b/docs/runbooks/graph-read-contract.md
@@ -11,6 +11,10 @@ query before interpreting an absent service. The standard ClickHouse adapter
aggregates the whole bounded window; a deployment still using the older raw-span
adapter can lose low-volume calls behind health traffic.
+Outbound runtime/model calls also require a `CLIENT` span kind. A table without
+`SpanKind` leaves that classification unknown and cannot establish those outgoing
+calls; resource-side `hosts` observations may still be present.
+
Client spans with a valid `agentcore.runtime.arn` create a reported runtime
destination even without a server span. A reporting service with the same valid
runtime resource ARN can connect through a `hosts` relationship when its account
diff --git a/docs/runbooks/steampipe-quota-and-staleness.md b/docs/runbooks/steampipe-quota-and-staleness.md
index b38f23a62..3642a4a15 100644
--- a/docs/runbooks/steampipe-quota-and-staleness.md
+++ b/docs/runbooks/steampipe-quota-and-staleness.md
@@ -10,6 +10,10 @@ failed fallback remains incomplete. The collectors finish enumeration before
returning rows, reject repeated tokens/duplicate identities, and fail closed after
100 pages of at most 1,000 buckets.
+Legacy `EU` location constraints normalize to `eu-west-1`. Old rows keyed by
+`EU` or an unknown region can coexist with the corrected row while a collection
+is incomplete; only a complete sweep authorizes the existing prune phases.
+
Denied security-property reads remain explicit unknown attributes. A completed
listing is not proof that bucket security was inspected, and release readiness
still rejects unknown attributes. Failed pagination cannot authorize pruning or
diff --git a/web/components/topology/E2eGraphCanvas.test.tsx b/web/components/topology/E2eGraphCanvas.test.tsx
index 2eb635e66..fbcdd779b 100644
--- a/web/components/topology/E2eGraphCanvas.test.tsx
+++ b/web/components/topology/E2eGraphCanvas.test.tsx
@@ -59,6 +59,20 @@ async function select(label: string) {
}
describe('E2eGraphCanvas', () => {
+ it.each([
+ ['runtime', 'aws_verified'], ['model', undefined], ['queue', 'telemetry_claim'], ['service', 'telemetry_claim'],
+ ])('discloses unverified %s claims on both the card and selected-node details', async (kind, provenance) => {
+ const claim: E2eNode = { id: 'claim', kind: kind!, label: 'reported target', layer: 'service',
+ meta: { identityProvenance: provenance, claimedAccountId: '123456789012', claimedRegion: 'ap-northeast-2' } };
+ const { container } = render();
+ const notice = 'Telemetry claim · AWS identity unverified';
+ await waitFor(() => expect(container.querySelector(`[data-e2e-kind="${kind}"]`)?.textContent).toContain(notice));
+ const node = vi.mocked(ReactFlow).mock.lastCall?.[0].nodes?.find(n => n.id === 'claim');
+ expect(Number(node?.style?.height)).toBeGreaterThan(76);
+ const detail = await select('reported target');
+ expect(detail.getByText(notice)).toBeTruthy();
+ });
+
it('keeps nested network endpoint fields without an object-valued inference endpoint row', async () => {
const nodes = graph.nodes.map(node => node.id === 'p1'
? { ...node, meta: { endpoint: { ip: '10.0.1.1', podName: 'pod-a' } } } : node);
diff --git a/web/components/topology/E2eGraphCanvas.tsx b/web/components/topology/E2eGraphCanvas.tsx
index 3f06704c1..688e6cb56 100644
--- a/web/components/topology/E2eGraphCanvas.tsx
+++ b/web/components/topology/E2eGraphCanvas.tsx
@@ -113,6 +113,13 @@ function identityWithheld(node: E2eNode): boolean {
return node.meta.correlation === 'ambiguous'
|| ownershipVeto(node.meta, text(node.meta.region), text(node.meta.vpcId ?? node.meta.vpc_id));
}
+function telemetryClaim(node: E2eNode | null | undefined): boolean {
+ return node?.layer === 'service' && (['runtime', 'model', 'queue'].includes(node.kind)
+ || node.meta.identityProvenance === 'telemetry_claim');
+}
+function nodeSize(node: E2eNode | undefined) {
+ return telemetryClaim(node) ? { ...NODE_SIZE, height: 112 } : NODE_SIZE;
+}
export default function E2eGraphCanvas({ graph: inputGraph }: { graph: E2eGraph }) {
const { tt, lang } = useI18n();
@@ -186,7 +193,7 @@ export default function E2eGraphCanvas({ graph: inputGraph }: { graph: E2eGraph
}, [view, selected, overview, eligible.nodes]);
const fitOptions = useMemo(() => ({ ...viewport, padding: 0.18, maxZoom: 1.15 }), [viewport]);
const { nodes, edges } = useMemo(() => {
- const positions = new Map(layoutFlow(view, { nodeSize: () => NODE_SIZE }).map((p) => [p.id, p]));
+ const positions = new Map(layoutFlow(view, { nodeSize: id => nodeSize(byId.get(id)) }).map((p) => [p.id, p]));
const nodes: Node[] = view.nodes.map((n) => {
const flow = flowOf(n);
const color = identityWithheld(n) ? '#c08438'
@@ -204,10 +211,13 @@ export default function E2eGraphCanvas({ graph: inputGraph }: { graph: E2eGraph
{flow ? `${tt(METRIC_LABELS[text(n.meta.metric)] ?? (text(n.meta.metric) || '네트워크 관측'))} · ${metricValue(flow.value, text(n.meta.unit) || text(flow.unit), locale)}`
: `${tt(SOURCE_LABELS[n.layer])} · ${identityWithheld(n) ? tt('식별 보류') : n.kind}`}
+ {telemetryClaim(n) &&
+ {tt('Telemetry claim · AWS identity unverified')}
+ }
) },
style: {
- ...NODE_SIZE, padding: '8px 11px', borderRadius: 10, overflow: 'hidden',
+ ...nodeSize(n), padding: '8px 11px', borderRadius: 10, overflow: 'hidden',
fontSize: 13, color: dark ? '#e3e9ee' : '#16202a',
background: dark ? (n.kind === 'connection' ? '#0b3147' : '#18232f') : (n.kind === 'connection' ? '#eaf6fd' : '#ffffff'),
border: `${n.id === selected?.id ? 2 : 1}px solid ${color}`, cursor: 'pointer',
@@ -373,6 +383,9 @@ export default function E2eGraphCanvas({ graph: inputGraph }: { graph: E2eGraph
) : (
<>
{groupedTarget &&
+ {tt('Telemetry claim · AWS identity unverified')}
+
}