Skip to content

9 min read

Runtime edges are unreachable at any traversal depth

On this page

A webhook from the payment provider now carries a settlement_currency field and the handler needs to persist it. Retrieval seeds on "webhook", "payment", "succeeded" and returns the webhook receiver, the signature verifier, the event schema, and the dispatcher. Expansion at depth one adds the eleven modules the dispatcher imports. The agent picks the closest thing it can see to a payment handler and edits handlers/charge_refunded.py, which is a real handler for a different event, and the patch is reviewed, merged, and does nothing at all. The file that had to change is handlers/payment_succeeded.py, and the reason no depth of expansion reaches it is visible in eleven lines.

python
# events/registry.py
HANDLERS: dict[str, Callable[[dict], None]] = {}

def register(event: str):
    def wrap(fn):
        HANDLERS[event] = fn
        return fn
    return wrap

# events/dispatcher.py
from events.registry import HANDLERS

def dispatch(payload: dict) -> None:
    handler = HANDLERS[payload["type"]]
    handler(payload)
python
# handlers/payment_succeeded.py
from events.registry import register

@register("payment.succeeded")
def on_payment_succeeded(payload: dict) -> None:
    ...

The arrows all point the wrong way. handlers/payment_succeeded.py imports the registry; the dispatcher imports the registry; neither imports the other, and nothing imports the handler at all except a bootstrap module that imports 214 modules for their side effects, which the degree cap from the previous chapter correctly refuses to traverse. Every edge in the graph is a real edge, correctly resolved. The connection between dispatch and on_payment_succeeded is not in the graph because it is not in the source; it is created at 3 a.m. when the process starts and HANDLERS fills up, and it is keyed by a string that only equals the handler's key at runtime.

Turning the traversal from one hop to two makes this strictly worse. Two hops from the four seeds on this repository produced 214 candidate files, none of which was the handler, at a cost of about 170,000 tokens if fed in whole. The right file is not further away than the retriever looked. It is not on the graph at all, and distance is the wrong axis.

The same gap in Django and in Spring

The shape recurs in every ecosystem, wearing different clothes. In a Django service, the routing table is a list of dotted strings, and the view class is reached by importing a module whose name was assembled from characters.

python
# urls.py
urlpatterns = [
    path("exports/", include("exports.urls")),
    path("admin/reindex/", "ops.views.ReindexView"),
]

A parser reading urls.py sees a call to path with two string arguments. It does not see an import of ops.views, because there is none; the string is resolved by django.urls.get_callable, which does the import itself at request-routing time. An agent asked to fix the reindex admin page can read urls.py and still have no edge to the view.

In a Spring service, the same gap is created by an annotation and a container — the framework object that constructs your classes for you at startup and decides, from annotations rather than from any written call, which implementation each constructor argument receives.

java
public interface SettlementClient { Receipt settle(Charge c); }

@Service @Qualifier("adyen")
public class AdyenSettlementClient implements SettlementClient { ... }

@Service @Qualifier("stripe")
public class StripeSettlementClient implements SettlementClient { ... }

@Service
public class RefundService {
  private final SettlementClient client;
  RefundService(@Qualifier("adyen") SettlementClient client) { this.client = client; }
}

RefundService imports the interface, so there is a clean, resolvable import edge from RefundService to SettlementClient. Following it lands the agent on the interface, which has no behaviour, and the two implementations are both one hop from the interface with nothing in the graph to distinguish them. An agent editing a settlement bug has a coin flip between Adyen and Stripe, and the tie-break — a string inside an annotation on a constructor parameter — is invisible to an import graph and to a call graph alike, because the call is made through the interface type.

Decorator registryDjango URL confSpring @Qualifier
What creates the edgeDict populated at import timeString resolved at routing timeContainer wiring at startup
Where the key livesDecorator argumentpath() string argumentAnnotation argument, two sites
What the parser seesTwo files importing a registryA list of string literalsInterface imported, two impls
Graph distance to targetUnreachableUnreachableReachable, indistinguishable
Key visible in sourceYes, as a literalYes, as a dotted literalYes, on both ends

The last row is the useful one. In all three cases, the source text contains the key on both ends of the missing edge. Nobody has to run the program to learn that "payment.succeeded" appears in a decorator in one file and as a dictionary lookup key in another; both facts are sitting in the syntax tree, unclaimed, because the indexer was only asked to record imports and calls.

Make the string a symbol

The repair is to stop treating string literals in registration positions as data and start treating them as identifiers. A decorator argument, a path() route, a @Qualifier value, an entry-point name in pyproject.toml, a bean id in an XML context — each is a name in a namespace the language does not know about, and an index can know about it.

Mechanically it is a query per registration idiom, extracting the literal along with the symbol it decorates.

python
REGISTRATION_QUERY = PY_LANGUAGE.query("""
  (decorated_definition
    (decorator
      (call function: (identifier) @dec (#match? @dec "^(register|handler|task|route)$")
            arguments: (argument_list (string) @key)))
    definition: (function_definition name: (identifier) @sym))
""")

Each hit produces a node in the graph that is neither a file nor a function.

json
{
  "kind": "runtime_key",
  "namespace": "events.registry.register",
  "key": "payment.succeeded",
  "declared_by": "handlers/payment_succeeded.py::on_payment_succeeded",
  "referenced_by": ["events/dispatcher.py::dispatch", "tests/test_webhooks.py::test_settled"],
  "confidence": "literal"
}

Two things follow from that record. The key becomes searchable text in its own right, so a task mentioning a payment.succeeded webhook now has a lexical match on the handler that defines it, and the handler stops being invisible to seeding. And the key becomes a graph node with edges to its declaration site and to every reference, so expansion from the dispatcher reaches the handler in two hops through a bridge that the language never created. The referenced_by side is found by the mirror-image query: string literals used as subscripts, get arguments, or comparison operands against a registry the indexer already knows about.

For the Spring case the same record is built from annotation arguments, with the qualifier string as the key, AdyenSettlementClient as the declaration and the RefundService constructor as the reference — which is enough to break the tie between two implementations that the type system deliberately made interchangeable. For Django, the dotted string is resolved directly to a symbol, since a dotted path is an import that happens to be written in quotes, and the resolution logic already exists in the import resolver.

Confidence is worth carrying on the record rather than treating every extraction as equally sound. A literal argument to a decorator whose name is on a known list is high confidence. A literal in a position that merely resembles a registration — a string passed to a function called add — is a guess. A key assembled from a constant that the indexer folded is somewhere in between, and one assembled from an f-string is a marker that something exists here rather than a usable key. Storing the tier lets the ranker treat a high-confidence bridge as a strong edge and a low-confidence one as a weak hint, and it lets you audit the extraction by sampling each tier rather than by reading everything.

Finding out which idioms a repository uses is a static question with a static answer. Count string literals by the function they are passed to, sorted by frequency, and the registration idioms rise to the top of the list on their own: a call taking a string that appears 90 times across 90 different files is almost always a registration of something. Cross-reference that list against the same literals appearing in subscript or comparison positions elsewhere, and the pairs that show up in both are registries. Half an hour of that on an unfamiliar repository produces a better list of patterns than a week of reading framework documentation, because it finds the two homegrown registries nobody documented alongside the Celery and Flask ones everybody knows about.

The cost is one query per idiom per framework, which is real ongoing work: Celery tasks, pytest fixtures by name, Flask blueprints, Rails routes, gRPC service registration, feature-flag keys, and Kafka topic names are all separate patterns, and a team adds them as they discover misses. Two dozen patterns cover most of what a given repository does, and the discovery mechanism is the failure log — every task where the agent edited a plausible neighbour instead of the target is a candidate pattern.

Why feeding in the whole package is not the answer

The proposal that surfaces when this problem is diagnosed is to give up on precision at the boundary: when a reference cannot be resolved, include the entire package that ought to contain the target. It is worth taking seriously for a moment, because on a six-file package it is a fine stopgap and costs perhaps 12,000 tokens.

It fails on the cases that produced the problem. The handlers/ package in the webhook service has 90 files and would cost roughly 400,000 tokens, well past any context window worth using, and the retrieval step would be reduced to "read the service". It does not help Spring at all, since the implementation classes routinely live in a different package from the interface and the failure is a choice between two files rather than an inability to find them. And a package-level fallback removes the signal: after it is installed, every unresolved edge looks handled, the failure rate stops being observable, and nobody finds out which idioms the indexer cannot see. A bounded version — fall back to the declaring package only when it is under some size, and count every time the fallback fires — keeps the stopgap without hiding the diagnosis.

Where a string index still misses

Indexing literals recovers the cases where the key is written down. Three families of case remain.

Keys that are computed do not appear as literals. @register(f"payment.{status}") inside a loop over a list of statuses, or HANDLERS[cfg["event_prefix"] + ".succeeded"], produce no string a query can extract. Simple constant folding helps — resolving Events.PAYMENT_SUCCEEDED to the literal it is assigned in an enum recovers a large share of real code, because most teams write the key once in a constants module and reference it symbolically — and beyond constant folding the honest answer is to record the registration site with a computed confidence and let a ranker treat it as a weaker signal.

Registration that happens outside the repository is not visible either. A plugin discovered through Python entry points — names an installed package declares in its own packaging metadata so that a host program can find and load it without importing it by name — has its registration in a distribution's metadata rather than in this tree, and the same is true of a handler contributed by a vendored library or a service mesh sidecar. The index can record that the registry exists and has external contributors, which at least gives the agent a place to say it does not know.

Cases where the correct target depends on runtime configuration have no single answer to find. A Spring @Profile("eu") bean and a @Profile("us") bean are both correct, and which one a bug reproduces against depends on the deployment. The right behaviour is to surface both and note the discriminator, since a patch that assumes one profile is a patch that breaks the other.

None of this decides what the agent should read first. The webhook handler is now findable and so are its two neighbours, the four seeds, the eleven imports at depth one, and the 214 files at depth two, which is a candidate pool rather than a context window. Turning that pool into an ordered list requires deciding what relevance means for a code task, and that definition turns out to be different from the one document retrieval uses — different enough that the files a change must touch and the files that discuss the change are routinely two disjoint sets.

Repo-scale code retrieval — read the current version at https://daviddominguez.dev/learn/en/repo-scale-code-retrieval/runtime-edges-are-unreachable-at-any-traversal-depth

Back to top