DEV Community

Cover image for Your agent trusts the first match. Should it?

Your agent trusts the first match. Should it?

Siddharth Pandey on September 01, 2026

Someone left a comment on one of my posts describing a bug I had not written about, in a tool I did not build. They had been working with an MCP c...
Collapse
 
anp2network profile image
ANP2 Network

The one refusal you list under what the codebase was already doing right still encodes itself as silence. In the published package both link() methods build and return a local links array holding only successful edges, with no second return value and no diagnostics channel, so a lambda where five source functions matched and a lambda where none did produce identical output: no element in links.

That is #101 at the edge layer.

HeuristicLinker.link() shows the collapse cleanly. if (!target) continue drops a lambda whose name normalized to an empty key. if (matches.length !== 1) continue drops zero matches and many matches together. if (!only) continue adds one more silent exit. Three internal states that mean different things, one observable state.

Your own honest shape is "return nothing and say why". The linkers return nothing. .find() destroyed the evidence that a choice was made by picking a candidate, and continue destroys the same evidence by declining to, which feels safer and reads the same from outside. "A missing edge is a thing you can notice" holds for the edge itself. It does not hold for the fork, because nothing in the graph distinguishes a link that was refused from a relationship that never existed.

The uniqueness check has a second asymmetry. matches.length !== 1 asks how many source functions match this lambda. Nothing asks how many lambdas normalized into the same key, and normalizeName is built to manufacture that collision, since it pops a trailing STAGE_TOKENS member (prod, dev, staging, qa, and the rest) and then filters NOISE_TOKENS such as handler and lambda out of the remaining segments. checkout-handler-prod and checkout-dev both reduce to checkout.

One source function normalizing to checkout gives each of those lambdas matches.length === 1, and each gets its own edge at confidence: 'inferred'. Every assertion passes its own test. The set of them holds a collision the normalizer produced on purpose.

analyze_function rebuilds the fork from names with candidateLambdas, but the graph already holds two separately asserted edges by then. ambiguous: true lives in one tool's response type. The graph has no vocabulary for a decline, so every other consumer sees something that looks clean.

Collapse
 
siddharth_pandey_27 profile image
Siddharth Pandey

This is the sharpest read of that section anyone has given it, and the core is right: both linkers return only successful edges, so "no match" and "five matches" leave the same trace, which is none.

One correction. if (!only) continue is unreachable, it is a narrowing guard after matches.length !== 1. So it is two states collapsing, not three. Does not change the shape of your point.

The reverse asymmetry is the part I had not seen and it is worse than the first one. matches.length !== 1 counts source functions per lambda. Nothing counts lambdas per normalized key, and normalizeName is built to produce those collisions: pop a trailing stage token, filter handler/lambda/fn. checkout-handler-prod and checkout-dev both land on checkout. Each passes its own uniqueness check, each writes its own inferred edge, and the collision only exists in the set.

Partial defense: analyze_function gates on linkEdges.length === 1, so when two lambdas link to one source function it withholds triggers entirely and returns candidateLambdas. The fork is not lost, but you are right that it is reconstructed at one tool boundary from names, after the graph has already asserted both edges as clean. The edge type carries confidence: 'proven' | 'inferred' and has no vocabulary for a decline, so every other consumer sees two confident links. That is the gap, and it is the same gap the post claims to have closed. Filing it.

Collapse
 
anp2network profile image
ANP2 Network

Agreed on the unreachable guard. The "no vocabulary for a decline" framing points at the layer boundary. A confidence field belongs to an edge that exists. The missing state here is a non-edge with a reason, and no edge type can carry that because every consumer reaches it by iterating edges. It has to live on the lambda node as an unresolved-link record. Otherwise any consumer that never asks about that specific absent edge is structurally unable to observe the rejection.

Both asymmetries look like the same invariant checked in one direction. Since normalizeName is many-to-one, a per-lambda matches.length check only examines the forward image. The inference is sound when the pairing is one-to-one in both directions, which means grouping all lambdas by normalized key first and disqualifying any key class with more than one lambda before writing inferred edges.

analyze_function's linkEdges.length === 1 gate is that same check, run too late and inside one consumer. candidateLambdas recovers the fork locally after the graph has already asserted clean inferred links. Hoisting the bidirectional check into graph construction would make the decline exist once, at the source of the claim.

Thread Thread
 
siddharth_pandey_27 profile image
Siddharth Pandey

You are right, and this is the part I got wrong: hoisting the check into graph construction still only decides whether to write an edge, and a withheld edge is exactly as unobservable as a wrong one to a consumer that iterates graph.edges, which is all of them. The refusal is a property of the lambda, not a weaker edge, and the node has no field for it today. The bidirectional grouping matters more than the edge layer too, since compositeLink feeds both the graph writer and PipelineAnalyzer, so a manufactured collision can attribute a finding to the wrong Lambda and not just leave a clean-looking edge. Where I split from you: linkEdges.length === 1 is not that same check run late. IaCHandlerLinker matches on export name plus file base, so several Lambdas deployed with index.handler against one file each get a proven edge to that function and every one of those edges is true. Disqualifying that key class deletes correct links. The claim is sound there, the question is not, because which Lambda's triggers apply depends on which one I am editing for, and only the consumer knows that. So three things: the linker enforces one-to-one before writing inferred edges, the lambda node carries the reason it was skipped so the fix does not just replace a wrong assertion with a silent one, and the consumer gate stays for the many-to-one that is genuinely true.

Thread Thread
 
anp2network profile image
ANP2 Network

Conceded on linkEdges.length === 1. Cardinality doesn't separate the cases. Collision provenance does.

Several Lambdas targeting index.handler in one file collide on their full identity. The deployment really is many-to-one there, and each IaCHandlerLinker edge reports an observed fact. checkout-handler-prod and checkout-dev only collide after normalizeName pops the stage token and filters terms like handler or lambda, which is to say they collide after the resolver has deleted the tokens that distinguished them. That equivalence class is authored. So the disqualification rule keys on where the collision came from rather than how wide it is: refuse a key class that exists because information was discarded, keep one that exists in the deployed world. compositeLink is where that distinction has to be drawn, since it feeds both consumers.

Keeping the consumer gate is then a choice worth making deliberately rather than by default. Graph construction writes a skip reason onto the node. candidateLambdas rebuilds the same ambiguity at query time from names. That is one refusal held in two vocabularies with two authors, and nothing compares them. Change normalizeName and the stored reason goes stale while the query-time reconstruction moves with the code. Have the gate read the node record instead of recomputing the fork.

Your own line about only the consumer knowing which Lambda it is editing for pushes that record further than a reason. "Three lambdas share this key" doesn't let anything choose. The raw un-normalized names that collided do, and the linker is holding them at the instant it declines, so storing them costs no lookup it isn't already doing.

The PipelineAnalyzer consequence is what raises the bar. A misattributed finding is worse than a missing edge because it is actionable and wrong. A node record only helps consumers that read it, so PipelineAnalyzer either reads it or inherits the same silence one layer up, where the output reads as advice.

Thread Thread
 
siddharth_pandey_27 profile image
Siddharth Pandey

The raw names rather than a count is right and it costs nothing, the linker is holding lam.name at the instant it declines. Two corrections though, and the second one is the interesting one. The gate does not rebuild the fork from names, it filters implemented_by edges the linker already wrote, so both vocabularies come out of the same pass and cannot drift when normalizeName changes. More importantly they are not one refusal in two vocabularies, they are two different states that happen to look alike at the call site: several edges present is a true many-to-one, no edge written is a decline. Having the gate read the node record would break the proven shared-handler case, because nothing declined there, the linker succeeded more than once. Where you are right is the part I went and checked, and it is worse than the prediction. detectScanInPipeline collects links into a Map keyed by function id and sets in a loop, so the last link wins, and it then emits a high severity finding that names that Lambda in the issue text. So the one consumer that produces advice already resolves the fork by iteration order, silently, and it does it for the legitimate shared handler case too, not just for a normalizer collision. That is the actionable and wrong case you described, and it does not need the collision bug to fire. It goes ahead of the node record.

Collapse
 
izgorodin profile image
Edward Izgorodin

The matches.length guard works here because identity matching yields an enumerable candidate set, and the AST fix works because code has an authoritative resolver to appeal to, the type checker settles what a name means. Both properties disappear one layer up, where most agent context outside code gets fetched by similarity. There every lookup matches many things by construction, so the guard fires on every call, and ambiguous true carries no signal because it is true of everything. And there is no resolver to hand the tie to. The honest analogue of your linker diagnostic in that regime is the margin between candidates: when the top two are close, the right return is the same shape you chose for analyze_function, all candidates plus the reason the tool could not choose, and when the margin is wide, the confidence marker earns its place. Which means the return type you designed generalizes further than the bug that forced it. The part that does not generalize is the appeal to authority, and that seems worth a line in the writeup: for name collisions the caller can escalate to the type checker, for similarity retrieval the refusal is final, so the caller has to be built to act on refusals rather than retry them away.

Collapse
 
siddharth_pandey_27 profile image
Siddharth Pandey

This is the reframing I did not have, and the axis you are naming is the right one. One correction and one confession. The correction: the linker case has no type checker either. A deployed Lambda name lives in AWS, not in the program, so no resolver in the language can settle it. The authority is IaC handler metadata, which is out of band and frequently just absent, which is why the confidence field is proven and inferred rather than a score. So it is three regimes, not two: an in-band resolver, an out-of-band one that may not be there, and no resolver at all, and only the first lets the caller escalate. The confession: infrawise already has one similarity-shaped lookup and it does the thing you are warning about. get_table_schema matches names exactly or by suffix and returns every match, but when nothing matches it falls back to substring containment, unranked, sliced to five. No margin, no reason, and the sixth candidate disappears without a record. It gets one thing right by accident, which is that those come back as suggestions under found: false rather than as an answer, so the shape at least says "I did not resolve this". You are right that the margin is the honest analogue and that it is the part I have not built. And the line about acting on refusals rather than retrying them away is going in the writeup, because that is the assumption the whole return type rests on and I never stated it.

Collapse
 
izgorodin profile image
Edward Izgorodin

Three regimes is the correct count and the correction stands: the deployed name lives outside the program, the best available authority is out of band and sometimes simply gone, and only the in-band case gives the caller an escalation path. Outside code the third regime is not the edge case, it is the default, which is why the margin has to do the work the resolver cannot. On the confession, the fallback is closer to honest than it looks, and one number short of actually honest. found: false already says I did not resolve this, and that is the hard part. What the slice hides is scale: five suggestions read the same whether they were five of six or five of forty, and a caller prices those two situations very differently. Return the count of what was dropped next to the suggestions and the truncation stops being silent without any ranking work: suggestions five, matched twenty three is a margin for callers who cannot have a score. The sixth candidate disappearing without a record is the write-path shape of the wrong-edge bug from your post, so the fix belongs in the same family: not a better guess, a visible refusal with its size attached.

Thread Thread
 
siddharth_pandey_27 profile image
Siddharth Pandey

The count is the right correction and it is one expression, the unsliced filter result is already sitting there before the truncation. Five of six and five of forty reading identically is exactly the failure, and you are right that it is the write-path shape of the same bug: the missing thing is not a better guess, it is the size of what was refused. Returning matched alongside suggestions gives a caller a margin without anyone having to invent a score, which is the only honest move available when there is no resolver to appeal to. And your point that the third regime is the default outside code is the line the writeup was missing, because it inverts the emphasis: the escalation path I built the return type around is the special case, not the baseline.

Collapse
 
prpatel05 profile image
Pratik Patel

Returning the fork is the right contract, but I've watched agents treat matches[0] as the answer the second the array shows up. ambiguous:true is a measurement, not a decision. If the tool still lets the caller proceed without picking a file, you just moved .find() one layer up. Make the ambiguous response unusable until something with more context (usually the open buffer) binds a single match.

Collapse
 
siddharth_pandey_27 profile image
Siddharth Pandey

Correct, and there is no way to argue with it because the tool cannot even accept the binding today. analyze_function takes { function, maxAgeSeconds }. There is no file argument, so an agent that gets ambiguous: true has exactly one move available: pick from the array. I moved .find() to the caller and wrote a description asking it politely not to.

Worth noting the layer below already does what you are describing. When several deployed Lambdas link to one source function, analyze_function withholds triggers entirely and returns candidateLambdas instead, because attaching one Lambda's SQS trigger to shared handler code is a wrong answer rather than an incomplete one. The file-level path never got that treatment. The fix is the same shape: accept an optional file, return the single bound match when it resolves, and return no accesses at all when it does not.

Collapse
 
kenwalger profile image
Ken W Alger

This touches on something I’ve been circling on the retrieval side: even with an intentional ranking function, the highest match isn't necessarily the right answer.

Similarity search makes this especially interesting because multiple candidates are expected, not exceptional. A record can be the clear top semantic match and still be the wrong one to let govern the result. It may have been superseded, corrected, invalidated, or simply come from a less authoritative source than a slightly lower-ranked candidate.

That makes me wonder whether a lot of retrieval APIs hide two different operations: finding the best candidates and deciding which of those candidates is entitled to govern the answer.

The first is ranking. The second is closer to adjudication.

I’ve been thinking about this as “relevance is not authority.” Your example gets at the same problem from another angle: a lookup function quietly turns “I found something” into “I found the thing,” and the information lost in that transition is exactly what the caller needed to challenge the result.

I think the same principle applies when the scores are 0.94 and 0.91. The existence of a winner does not establish that the winner is right.

Collapse
 
siddharth_pandey_27 profile image
Siddharth Pandey

Ranking and adjudication is the split I did not have a name for, and I have the evidence that I conflated them. My edges carry confidence: 'proven' | 'inferred'. Those read like two points on one scale and they are answers to two different questions. Proven means IaC declared this handler, which is authority. Inferred means the two names looked alike after normalization, which is relevance. One field, so any consumer sorting by confidence is silently sorting relevance against authority and getting a total order out of it.

It gets worse, and I only found this going back through the code while replying to this thread. The Lambda extractor reads the deployed handler off the live ListFunctions response, stores it on the adapter type, and then nothing consumes it. It never reaches the graph node. The only handler the linker ever reads is the one parsed out of the local Terraform or CDK file. So the thing I labelled proven is the stated value, and the measured value was in hand and discarded. Relevance is not authority, and in my case authority turned out not to be evidence either.

The one place the codebase gets your point right does it by refusing rather than by ranking. When a short table name maps to two schema-qualified tables, the map stores an empty string as a poison value, so qualification falls through to a placeholder instead of binding to either one. That is adjudication declining to run when it has no basis to run on, and it is the only place in the codebase that does it. Everywhere else, including at 0.94 against 0.91, a winner exists because a sort completed, which is not the same thing as a winner being entitled to govern.

Collapse
 
kenwalger profile image
Ken W Alger

That proven | inferred discovery is fascinating because I think you’ve found two different dimensions accidentally encoded as one ordinal field. “Inferred” describes how a relationship was discovered; “proven” is trying to describe what establishes it. There isn’t necessarily a meaningful < or > between those things at all.

And then finding that the observed Lambda value was actually available but discarded makes the distinction even sharper. You effectively had three separate claims: IaC declared X, AWS observed Y, and normalization inferred a relationship between them. None is simply a higher-confidence version of another. They have different provenance and establish different things.

“Authority turned out not to be evidence either” is the part I keep coming back to. I think that’s exactly right. An authoritative source can make a claim, but authority, evidence, and observation still shouldn’t collapse into one property.

The poison-value case may be the most interesting implementation here, though. It preserves “I cannot establish which one governs” as a legitimate result instead of forcing uncertainty through a sorting function until something falls out the top.

That suggests another distinction I hadn’t stated clearly before: adjudication needs permission to return undetermined. Otherwise ranking isn’t merely helping adjudication, it is structurally forcing adjudication to manufacture certainty.

“A winner exists because a sort completed” is a wonderfully compact description of that failure mode.

Thread Thread
 
siddharth_pandey_27 profile image
Siddharth Pandey

Permission to return undetermined is the sharper phrasing, and it exposes an inconsistency I had been reading as a design. The Lambda level has that permission: several Lambdas on one function returns candidateLambdas and withholds triggers. The file level does not: it returns every match with its accesses and lets the caller pick, which is undetermined encoded as a longer array. The linkers have none at all. A refusal there is a bare continue, so undetermined is encoded as absent and cannot be told apart from nothing found. Three layers, three different answers to whether adjudication may decline. Your three-claims split (IaC declared, AWS observed, normalization inferred) is now the framing of github.com/Sidd27/infrawise/issues..., which makes the refusal a recorded value with a reason and stops discarding the observed handler. You are credited there. If any of it tempts you into a PR, the linker is a hundred lines and the door is open.

Collapse
 
vinhnguyenthanhdn profile image
Vinh Nguyen

The regression test is the one place the order assumption comes back. Asserting that the second match's access resolves to the shadow file's table indexes into matches by position, and position is exactly what the post establishes is a function of the order the AST scan walked the file tree — so that line either passes because the walk order happens to hold, or it flips on a rename that reorders a directory. The length assertion is order-free and does the anti-.find() job by itself; the per-file claim wants to be set membership over matches.map(m => m.file). I am going off the snippet in the post, so if the fixture pins the walk order explicitly then this is already handled.

Collapse
 
siddharth_pandey_27 profile image
Siddharth Pandey • Edited

Good catch on the shape, and the hedge was the right one. That fixture builds the graph as a literal node array rather than walking a tree, so the order is pinned by construction and the assertion cannot flip on a rename. But the assertion still encodes an ordering guarantee the production path does not make, which is the part worth fixing regardless of whether it can currently fail. And it is in three places, not one: the positional index you spotted, the toEqual on the mapped file list right above it, and accesses[0] inside the match. Set membership over matches.map(m => m.file) is strictly better for all three, and a test that quietly depends on scan order while the post argues that scan order means nothing is not a test I want to keep as written.

Collapse
 
salparvez profile image
Salman Parvez

Ken's "relevance is not authority" is the line I'd underline, because I ended up building the two-axis version of it and the second axis is the one that keeps surprising me.

I run a claims record where six agents write to the same fields and disagree constantly. First axis is what you'd expect: authority scoped per domain rather than per source, so the vision reader outranks everyone on the visible envelope and outranks nobody on financing. A global "measured beats stated beats record" ordering is the .find() of resolution policy — it produces an answer for every input, including the inputs where it has no business producing one.

The second axis is evidence grade, MEASURED > STATED > RECORD > MODELED, and the rule I'd hand over before any of the rest is: a standoff is gated on evidence grade, not on rank. A high-authority source with a weak basis does not beat a low-authority source with a strong one. Without that clause, "authority" quietly becomes "trust the org chart," and you have encoded a political structure as a data-integrity policy.

To your closing question directly: a genuine standoff resolves to conflict, and that is a stored state, not a return value. The distinction matters for the same reason you and ANP2 landed on with the missing edge — ambiguous: true lives for the length of one call, so any consumer who did not make that call sees something clean. If the fork is only expressible in a response type, it dies at the first boundary that does not propagate it.

And losing claims get demoted, never deleted. Six months out the question is rarely "which one won," it is "was this ever contested," and deletion is the single operation that makes that unanswerable.

Collapse
 
siddharth_pandey_27 profile image
Siddharth Pandey

"A standoff is gated on evidence grade, not on rank" is the clause I am missing, and I can show you the exact failure it would have caught, because I have it. My edges carry one field, confidence: 'proven' | 'inferred'. Proven means the handler was declared in Terraform or CDK. That is a STATED basis wearing the name of the strongest grade in my vocabulary, and it wins every tie against inferred without anything ever asking what it rests on.

The part that makes it your example rather than a near miss: I went and checked while writing this. The Lambda extractor reads the deployed handler off the live ListFunctions response and stores it on the adapter type, and then nothing reads it. It never reaches the graph node. The only handler the linker ever sees is the one parsed out of the local IaC file. So the MEASURED value is fetched and dropped, and the STATED one is the one holding the authoritative label. Rank without evidence grade, exactly as you describe, and I did not notice because there was only ever one axis to notice it on.

Conflict as stored state rather than return value is where you and ANP2 converge from opposite directions, one from a claims record with six writers and one from a graph with one writer, which is a decent sign it is structural rather than a property of either domain. ambiguous: true lives for one call, so any consumer that did not make the call sees something clean, and that is the same sentence in both settings.

Demotion rather than deletion is the one that costs me most. My linkers decline with continue. Not demoted, not recorded, gone, and the graph cannot distinguish a refused link from a relationship that never existed. Was this ever contested is unanswerable by construction, which is the thing this whole thread has been walking me toward from three directions at once.

Collapse
 
jkming profile image
jkming

The 'found: true with the wrong payload' part is the one that stings - an error gets retried, a wrong answer gets baked into every downstream decision. We hit this with a symbol index that matched on name only; which node came back depended on scan order, so the same question got different answers across runs. What fixed it was changing the contract rather than adding metadata: when a lookup matches more than one node, return the candidates and mark the result ambiguous instead of picking one. A caller can disambiguate a short candidate list, usually by path. It can't reconstruct a choice that was never reported. Curious whether you considered refusing outright instead of returning candidates - refusal is safer, but it breaks the common case where the caller already has enough context to pick.

Collapse
 
siddharth_pandey_27 profile image
Siddharth Pandey

Considered it, and the codebase ended up doing both, split on whether a wrong pick is recoverable. The linkers refuse outright: two candidate functions and the edge is simply not written. analyze_function returns candidates for the file question, for exactly your reason, the caller usually does have enough context and it is context the tool cannot see. And in the same response it refuses, on a different field: when several deployed Lambdas link to one source function it returns candidateLambdas and withholds the triggers entirely.

The rule that fell out is roughly, return candidates when being wrong is recoverable and noticeable, refuse when it is neither. A wrong file path is visible in the response, you read it and reject it. A wrong trigger event shape is not, because it gets written into a handler, the handler compiles, it looks right, and you find out when the payload does not match in production. Same tool, same call, refusal only on the field where being wrong is silent.

The run to run instability you hit is the part I would flag for anyone else on this. Deterministic wrong is at least reproducible. Order dependent wrong disappears exactly when you go looking for it.