Skip to content

Commit b6dba0f

Browse files
tae2089claude
andcommitted
fix: label cross-namespace trace_flow members with their real namespace
Flow memberships were stamped with the start namespace, so members reached through a cross_ref edge carried an unusable label and clients could not resolve foreign node ids. The tracer now batch-loads member nodes when the reader supports it and stamps each membership with the node's stored namespace; the MCP response exposes it only in cross-namespace mode, keeping single-namespace payloads unchanged. The persisted-flow rebuild path is unaffected because FlowRebuildStore has no batch node read. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent b725e3e commit b6dba0f

3 files changed

Lines changed: 58 additions & 7 deletions

File tree

internal/adapters/inbound/mcp/handler_analysis.go

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,9 +39,11 @@ type impactRadiusResponse struct {
3939

4040
// traceFlowMember captures one ordered member inside a traced flow.
4141
// @intent serialize flow member references without exposing the full node record.
42+
// @domainRule Namespace is set only in cross-namespace mode so single-namespace responses stay unchanged.
4243
type traceFlowMember struct {
43-
NodeID uint `json:"node_id"`
44-
Ordinal int `json:"ordinal"`
44+
NodeID uint `json:"node_id"`
45+
Ordinal int `json:"ordinal"`
46+
Namespace string `json:"namespace,omitempty"`
4547
}
4648

4749
// traceFlowMetadata records bounded trace settings and fallback-edge summary data.
@@ -232,6 +234,9 @@ func (h *handlers) traceFlow(ctx context.Context, request mcp.CallToolRequest) (
232234
members := make([]traceFlowMember, len(flow.Members))
233235
for i, m := range flow.Members {
234236
members[i] = traceFlowMember{NodeID: m.NodeID, Ordinal: m.Ordinal}
237+
if crossNamespace {
238+
members[i].Namespace = m.Namespace
239+
}
235240
}
236241

237242
result, err := marshalJSON(traceFlowResponse{

internal/adapters/inbound/mcp/handler_crossns_test.go

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -144,18 +144,25 @@ func TestTraceFlow_CrossNamespace(t *testing.T) {
144144
})
145145
var payload struct {
146146
Members []struct {
147-
NodeID uint `json:"node_id"`
147+
NodeID uint `json:"node_id"`
148+
Namespace string `json:"namespace"`
148149
} `json:"members"`
149150
}
150151
if err := json.Unmarshal([]byte(resultTextOf(t, result)), &payload); err != nil {
151152
t.Fatalf("unmarshal: %v", err)
152153
}
153-
got := map[uint]bool{}
154+
got := map[uint]string{}
154155
for _, m := range payload.Members {
155-
got[m.NodeID] = true
156+
got[m.NodeID] = m.Namespace
156157
}
157-
if !got[authValidate] || !got[authAudit] {
158-
t.Fatalf("flow members = %+v, want continuation into auth-svc nodes %d and %d", payload.Members, authValidate, authAudit)
158+
if _, ok := got[authValidate]; !ok {
159+
t.Fatalf("flow members = %+v, want continuation into auth-svc node %d", payload.Members, authValidate)
160+
}
161+
if _, ok := got[authAudit]; !ok {
162+
t.Fatalf("flow members = %+v, want continuation into auth-svc node %d", payload.Members, authAudit)
163+
}
164+
if got[authValidate] != "auth-svc" || got[authAudit] != "auth-svc" {
165+
t.Fatalf("cross members namespaces = %v, want auth-svc labels so clients can resolve foreign node ids", got)
159166
}
160167
}
161168

internal/app/analyze/flow/flow.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,41 @@ type Tracer struct {
2222
store EdgeReader
2323
}
2424

25+
// nodeBatchReader is the optional capability used to resolve members' true namespaces.
26+
// @intent let cross-namespace readers label foreign members without widening the EdgeReader contract.
27+
type nodeBatchReader interface {
28+
GetNodesByIDs(ctx context.Context, ids []uint) ([]graph.Node, error)
29+
}
30+
31+
// stampMemberNamespaces replaces the context-namespace default with each member node's stored namespace.
32+
// @intent make cross-namespace flow members resolvable by callers; single-namespace traces are unchanged
33+
// because every member already lives in the context namespace.
34+
// @domainRule stores without batch node reads keep the context-namespace stamp (persisted-flow rebuild path).
35+
func (t *Tracer) stampMemberNamespaces(ctx context.Context, members []graph.FlowMembership) error {
36+
reader, ok := t.store.(nodeBatchReader)
37+
if !ok || len(members) == 0 {
38+
return nil
39+
}
40+
ids := make([]uint, len(members))
41+
for i, m := range members {
42+
ids[i] = m.NodeID
43+
}
44+
nodes, err := reader.GetNodesByIDs(ctx, ids)
45+
if err != nil {
46+
return err
47+
}
48+
namespaceByID := make(map[uint]string, len(nodes))
49+
for _, n := range nodes {
50+
namespaceByID[n.ID] = n.Namespace
51+
}
52+
for i := range members {
53+
if ns, found := namespaceByID[members[i].NodeID]; found {
54+
members[i].Namespace = ns
55+
}
56+
}
57+
return nil
58+
}
59+
2560
// TraceOptions bounds how far a single flow trace is allowed to expand.
2661
// @intent let callers cap traversal cost when tracing large call graphs
2762
type TraceOptions struct {
@@ -136,6 +171,10 @@ func (t *Tracer) TraceFlowBounded(ctx context.Context, startNodeID uint, opts Tr
136171
frontier = nextFrontier
137172
}
138173

174+
if err := t.stampMemberNamespaces(ctx, members); err != nil {
175+
return nil, err
176+
}
177+
139178
node, _ := t.store.GetNodeByID(ctx, startNodeID)
140179
name := fmt.Sprintf("flow_from_%d", startNodeID)
141180
if node != nil {

0 commit comments

Comments
 (0)