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 codebase-intelligence server. They asked it about a symbol. It answered with a file path, a definition, and a list of references, all well-formed. The answer described a shadow definition sitting in experiments/, not the real one in src/. Nothing errored. Nothing was stale. The tool had found two definitions with the same name and returned whichever one it reached first.
I went and read my own code. analyze_function is the tool an assistant is supposed to call before writing a handler: it reports which tables that function queries, how it queries them, which permissions its role is missing, and the exact event shape of its trigger. It looked like this:
const funcNode = currentGraph.nodes.find(
(n) => n.type === 'function' && n.name === functionName,
);
.find(). First match wins, silently. That is issue #103, and the person who caused me to open it was describing someone else's product.
The failure mode that has no error to report
I had already thought about two ways infrastructure context goes wrong, and shipped fixes for both.
The first is unread. A source fails to extract, the tool returns an empty list, and an empty list reads exactly like "there is nothing there". Ask whether a queue has a dead-letter queue after the SQS extractor threw a permissions error, and "no DLQ configured" is a sentence the tool has no right to say. That is issue #101, and the fix was to attach a per-source status to every response so a failed read is never mistaken for an absent resource.
The second is stale. The data was read correctly, and then someone ran terraform apply. The snapshot is internally consistent and describes an account that no longer exists. That is issue #102: every response now carries when the infrastructure was read and how long ago, so a caller can judge a three-day-old answer against the question it is answering.
Issue #103 is neither. The extraction succeeded. The data is seconds old. Every field in the response is a real value read from a real source. The response is simply about a different function than the one you asked about, and there is no signal anywhere in it that says so. Freshness metadata does not help. Source status does not help. Both report, correctly, that everything worked.
This is the part I want to argue about, because it changes what a tool owes its caller. Staleness and read failures are conditions you can attach metadata to. Wrong-candidate resolution is a property of the function signature. A lookup that returns one thing when two things matched has already destroyed the evidence that a choice was made. No amount of metadata bolted onto the response can reconstruct it.
Why the wrong answer is the confident one
Function node IDs in the graph are file-scoped. They are built as function:${op.filePath}:${op.functionName} (src/graph/index.ts:452), so getOrder in src/handler.ts and getOrder in experiments/handler.ts are two genuinely distinct nodes with two distinct sets of outgoing edges.
The lookup matched on n.name alone. Which node came back depended on the order the AST scan happened to walk the file tree.
Now trace what an assistant does with that. It calls analyze_function for getOrder because it is about to modify src/handler.ts. It gets back found: true, a real file path, a real list of table accesses, real findings. Suppose the scan reached experiments/handler.ts first: that scratch file queries public.users, while the real handler scans public.orders and has a high-severity finding attached to that scan. The assistant now believes the function it is editing touches users, has no scan problem, and needs no index work. Every downstream decision it makes is coherent, well-reasoned, and built on the wrong file.
Compare that to a tool that fails loudly. A permissions error is annoying and it is honest. You retry, you fix the role, you move on. A well-formed answer about the wrong file is worse than an error, because nothing in your workflow is designed to catch it. You are not going to double-check a response that looks perfectly correct.
The same bug, one layer down
Once I knew the shape, I found it again in the AST scanner. Issue #44: resolving an identifier to its string value did this:
sourceFile
.getDescendantsOfKind(SyntaxKind.VariableDeclaration)
.find((d) => d.getName() === name);
A file-wide search for a variable name, returning the first declaration found, regardless of which scope the call site was in. Two functions in one file, each with its own const tableName, and every query in the second function got attributed to the first function's table.
The consequences compound in both directions. Edges land on the wrong table node, so an analyzer flags a missing index on a table that does not need one, and misses a full scan on the table that does. A false finding and a suppressed real finding, from one wrong resolution.
The fix was to stop searching by name and ask the type checker instead:
if (Node.isIdentifier(node)) {
const symbol = node.getSymbol();
if (symbol) {
for (const decl of symbol.getDeclarations()) {
if (Node.isVariableDeclaration(decl)) {
const init = decl.getInitializer();
if (init) return resolveStringValue(init, sourceFile);
}
}
}
}
getSymbol() resolves the identifier the way TypeScript itself resolves it, from the call site outward through enclosing scopes. The name-matching search was never resolution. It was a guess that happened to be right most of the time, which is the most dangerous kind of wrong.
Two different layers of the same codebase, written months apart, both reached for "find the thing with this name" and both got it wrong in the same way. That is not carelessness. .find() is what the language hands you when you ask for a lookup, and it produces a value rather than a complaint. The API shape pulls you toward the bug.
What the codebase was already doing right
The uncomfortable part of issue #103 was that two other resolution paths in the same file already handled ambiguity properly. I had solved this problem, twice, and then not applied it in the third place.
Short-name table qualification. SQL text names tables unqualified (orders), while extracted nodes are schema-qualified (public.orders), so code edges have to be resolved against the extracted schema. When a short name maps to more than one qualified table, the map stores an empty string as a poison value:
qualifiedByShortName.set(key, qualifiedByShortName.has(key) ? '' : n.name);
The empty string is falsy, so qualify() falls through to a placeholder schema instead of binding to one of the two real tables. A collision produces a node that is visibly unresolved rather than an edge pointing confidently at a coin flip.
Schema lookup. get_table_schema uses filter, not find. Ask for orders and you get every table matching that short name across every database, and the tool contract says so explicitly. When nothing matches, it returns up to five suggestions instead of an empty result that might be read as "no such table".
The Lambda linkers. Both linkers that connect a deployed Lambda to the source function implementing it contain the same line:
if (matches.length !== 1) continue;
The IaC linker reads handler paths out of Terraform or CDK and links only when exactly one source function matches both the file base and the export name; those links are marked confidence: 'proven'. The heuristic linker normalizes names and links only when exactly one function matches; those are confidence: 'inferred'. Either way, two candidates means no link at all. The graph would rather have a missing edge than a wrong one.
So the pattern was established. analyze_function was the one place that had not adopted it.
Return the fork, do not resolve it
The fix is small, which is usually the case once the shape is named:
const funcNodes = currentGraph.nodes.filter(
(n) => n.type === 'function' && n.name === functionName,
);
filter instead of find. Then per-file detail moves into a matches array, one entry per source file defining a function with that name, each with its own file, accesses, and missingPermissions, because all three derive from that specific node's edges. When more than one matched, the response carries ambiguous: true, so the caller sees a fork rather than having to notice that an array got longer.
The regression test is deliberately literal about the scenario from the comment:
it('analyze_function returns every same-named function, not just the first', ...)
It builds a graph with getOrder in handler.ts and getOrder in experiments/handler.ts, then asserts ambiguous is true, matches has length 2, and the second match's access resolves to the table only the shadow file touches. If someone reintroduces a .find(), that test fails on the length assertion before anything else.
The same rule applies one level up, where several deployed Lambdas share a handler path. index.handler repeated across a stack is the ordinary case, not the exotic one, and it means several Lambda nodes legitimately link to a single source function. When that happens the tool returns candidateLambdas with each Lambda name and its link confidence, and withholds the triggers entirely, because attaching one Lambda's SQS trigger to code shared by five of them is exactly the confident-and-wrong answer this whole exercise is about. When exactly one Lambda links, you get resolvedLambda: { lambda, confidence } instead, and the triggers come with it.
Notice what is deliberately absent: there is no file input for disambiguating the call. Adding one would move the decision back to the caller before the caller knows the fork exists. Returning the candidates lets whoever asked pick using context the tool does not have, which is usually just "the file I currently have open".
That is the tradeoff I would defend. The response got larger and slightly harder to consume. An assistant now has to read ambiguous and decide, rather than taking a single answer and running. In exchange, there is no configuration of the repo where the tool asserts something it did not prove.
The general rule
If a lookup can match more than one thing, the return type has to be able to say so. A tool that resolves ambiguity internally is not saving its caller work, it is making a decision on the caller's behalf using less information than the caller has, and then hiding that a decision happened at all.
Three shapes are honest: return every candidate, return nothing and say why, or return one with an explicit confidence marker. What is not honest is returning one of several as if it were the only one. Once you look for it, .find() on a name shows up everywhere, and each one is a small silent assertion that names are unique when the code plainly says they are not.
For an AI assistant this matters more than it does for a human reading the same output. A person who gets back experiments/handler.ts when they asked about src/handler.ts notices the path. An assistant folds it into context and moves on, and the wrong file is now a premise for everything it writes next.
Infrawise is on GitHub and npm if you want to see how the graph, the linkers, and the MCP tools fit together. npx infrawise start gets you a live analysis and an .mcp.json without a config file.
Key takeaways
- Fresh, successful, and wrong is a distinct failure mode from stale or unread, and neither freshness metadata nor source-status metadata will catch it. It lives in the function signature, not the response body.
-
.find()on a name is an assertion that names are unique. In a file-scoped graph, in a scoped language, or across the stacks of a monorepo, that assertion is false more often than it looks. - A wrong answer that is well-formed is more expensive than an error, because nothing downstream is built to question it.
- If ambiguity is possible, make the return type able to express it: all candidates, or nothing with a reason, or one with a confidence marker. Not one of several, unmarked.
- Refusing to link is a valid outcome.
if (matches.length !== 1) continue;produces a graph with a missing edge instead of a wrong one, and a missing edge is a thing you can notice.
When a lookup in your codebase matches two things, what does it return today, and would you be able to tell from the output that there was ever a second candidate?
Top comments (28)
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 locallinksarray 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 inlinks.That is #101 at the edge layer.
HeuristicLinker.link()shows the collapse cleanly.if (!target) continuedrops a lambda whose name normalized to an empty key.if (matches.length !== 1) continuedrops zero matches and many matches together.if (!only) continueadds 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, andcontinuedestroys 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 !== 1asks how many source functions match this lambda. Nothing asks how many lambdas normalized into the same key, andnormalizeNameis built to manufacture that collision, since it pops a trailingSTAGE_TOKENSmember (prod,dev,staging,qa, and the rest) and then filtersNOISE_TOKENSsuch ashandlerandlambdaout of the remaining segments.checkout-handler-prodandcheckout-devboth reduce tocheckout.One source function normalizing to
checkoutgives each of those lambdasmatches.length === 1, and each gets its own edge atconfidence: 'inferred'. Every assertion passes its own test. The set of them holds a collision the normalizer produced on purpose.analyze_functionrebuilds the fork from names withcandidateLambdas, but the graph already holds two separately asserted edges by then.ambiguous: truelives in one tool's response type. The graph has no vocabulary for a decline, so every other consumer sees something that looks clean.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) continueis unreachable, it is a narrowing guard aftermatches.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 !== 1counts source functions per lambda. Nothing counts lambdas per normalized key, and normalizeName is built to produce those collisions: pop a trailing stage token, filterhandler/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 carriesconfidence: '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.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.
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.
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.
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.
Conceded on both corrections. The gate is downstream of implemented_by edges the linker already wrote, so normalizeName changes cannot split the vocabularies there. And the states are genuinely different: several edges present is a true many-to-one, while zero edges is a decline. Reading the node record in that gate would have collapsed the proven index.handler case and removed valid shared-handler evidence.
detectScanInPipeline is the hotter bug. A Map keyed by function id with set in a loop makes the last implemented_by link win, then emits HIGH severity text naming that one Lambda.
The defect is worse than "the wrong Lambda might be named." The emitted finding carries no record that selection happened. The output is not falsifiable from itself. A report consumer holding only the issue text cannot distinguish a single-candidate finding from a three-candidate finding resolved by iteration order. That is the expensive failure mode: wrongness with no trace left in the artifact.
So there is no correct winner to pick. Picking a better winner is the wrong repair. The finding has to carry multiplicity: one finding per linked Lambda, or one finding that names all linked Lambdas. Anything that names exactly one Lambda asserts a fact the analysis does not have.
Severity amplifies that. A HIGH severity finding naming a resource gets acted on by resource name. A missing finding costs attention. A confidently misnamed finding spends change on the wrong resource, and the later audit trail says the tool named it.
Agreed that this goes ahead of the node record. The node record fixes what a consumer can observe; the Map fix stops the consumer that gives advice from giving wrong advice today.
That does not demote the node record. Its value just changed shape. Absence and refusal look identical to the reader of a report in the same way they looked identical to a consumer iterating implemented_by edges.
Multiplicity rather than a better winner is right, and the shape question settles once you look at what the finding is actually keyed on. The dedupe key in that loop is function id plus table id, not lambda. The defect is a scan on a table from a function. The Lambda is context for how often it runs, not the subject. So it is one finding naming every linked Lambda, not one per Lambda. One per Lambda also invents a disagreement that does not exist: severity is computed per link as inferred ? 'verify' : 'high', so a function reached by one proven and one inferred edge would emit a high and a verify about the same scan, and anyone triaging keeps the loud one.
Your severity point is worse than you put it. The hedging sentence, the one saying the link is inferred by name match so verify it before acting, is appended only on the inferred branch. The proven branch gets no qualifier at all. And the shared index.handler case is proven by construction, because it comes out of IaCHandlerLinker reading a declared handler. So the one case where the cardinality is genuinely many-to-one is the case that produces HIGH, names exactly one Lambda, and carries no hedge. The confidence marker is quietest precisely where the selection happened.
Blast radius is one detector, for what it is worth, and I checked rather than assumed. detectRepeatedTableAccess builds components with union-find and names functions rather than Lambdas, so it has no last-write-wins step and no resource name in the issue text. detectScanInPipeline is the only finding in that file that puts a Lambda name in front of a reader.
Provenance rather than width as the disqualification rule is the part I will be stealing. An equivalence class that exists because normalizeName deleted the tokens that distinguished the members is authored by the resolver. One that exists because two deployments really do point at the same file is observed. Same cardinality, opposite epistemic status, and only the second one is evidence.
Keying on function id plus table id is right: one finding should name every linked Lambda, since a per-Lambda split would manufacture a contradictory high/verify pair around the same scan. That is the correct shape.
Once every linked Lambda appears in one record, a scalar severity no longer has a coherent subject. Provenance belongs to each edge, and the finding now spans an edge set that can mix inferred and declared links. Taking the maximum lets a proven edge launder the inferred names into an unhedged HIGH, even though those names still rest on a name match, while taking the minimum lets one inferred edge mute a scan whose relationship to another Lambda in the same finding was never in doubt. Both aggregations destroy the thing a triager needs. The scan is the defect. It is there whatever Lambda ends the link. Link provenance governs execution frequency and blast radius, so it belongs beside each Lambda name, while severity attaches to the function-table pair. The hedge then stops being a sentence appended on one branch and becomes a per-name marker. It cannot go missing.
"Proven by construction" needs a tighter claim boundary. IaCHandlerLinker reading a declared handler proves that a deployment declares a handler file. detectScanInPipeline publishes something stronger: that the scan executes under that Lambda. Those two propositions are one module-graph step apart, and shared index.handler is exactly the case where that step is unverified, because an entrypoint gets shared so that separate deployments can reach different code behind it. The confidence marker is then attached to a narrower claim than the one being published. That is an argument about what the two words name. It is offered without having read IaCHandlerLinker, so the question that settles it is whether the edge stops at the declared handler file or resolves through the module graph to the function that contains the scan. If it stops at the file, the proven branch is proven about the deployment and inferred about the finding.
One note on the provenance rule: authored-versus-observed has to be recorded by the resolver at the moment the class is built. Downstream the two are indistinguishable, and normalizeName's deleted tokens are not recoverable after the fact.
It stops at the exported function, not the file, and goes no further in either direction. IaCHandlerLinker matches f.name === exportName && fileBaseNoExt(f.file) === fileBase, so index.handler binds to the function node named handler in index.ts. The scan edge then has to originate from that same node, and the scanner attributes an access to its lexically nearest enclosing function. So proven names two facts: a deployment declares this export, and this export's body lexically contains the scan call. It never establishes that the call runs on that deployment's invocations. Your false positive is real: a scan in one branch of a shared handler body fires for every deployment declaring it, labelled proven. Two silent misses sit beside it. A scan in a helper the handler calls is attributed to the helper, which has no link, so no finding at all. A scan inside a nested callback in the handler body is attributed to the arrow function, same result. Three grades again: declared, contained, executed. The field names the first and the finding publishes the third.
On severity I take the per-name marker and keep one aggregation. The pair's marginal claim over the plain scan finding, which already fires HIGH for any unfiltered scan, is "and it runs on every event". That premise holds if at least one link is proven, so high when any name is proven, verify when none is. That is a max, but the laundering you describe needs the hedge to be a sentence on one branch. With a marker on each name there is nothing to launder. Agreed on recording at build time: the linker has the information on the line where it says continue and drops it there. Filed as github.com/Sidd27/infrawise/issues... (the finding shape) and github.com/Sidd27/infrawise/issues... (recorded refusals, the reverse guard, carrying the deployed handler), both crediting you. If either tempts you into a PR I would take it gladly. You have read this code more closely than anyone outside the repo.
The per-name marker plus max aggregation resolves the laundering problem. Once each name carries its own evidence level, high-when-any and verify-when-none is a reasonable roll-up.
What worries me more now is the pair of silent misses. A scan in a shared handler branch produces a visible false positive, and a reader can challenge it. A scan attributed to an unlinked helper, or to a nested arrow function, disappears before there is anything to inspect. Nothing in the output can reveal that miss rate. Counting refusals at the exact
continueturns the unknown into a number the tool can report: how many scan candidates failed attribution, with the reason attached. That measures known attribution failures. Real recall would still need an independent reference set to compare against.On the grades, contained is the honest ceiling for a static pass. Executed needs evidence that an invocation actually reached the call, and no static analysis produces that, even where it can prove reachability under stated assumptions. So the finding text should publish its own grade, something like "contained in the declared export body", rather than leaving a reader to infer executed from the word proven.
Thanks for filing both, and for the invitation. Those two issues are the right places for the distinction to land.
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.
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.
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.
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.
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.
Correct, and there is no way to argue with it because the tool cannot even accept the binding today.
analyze_functiontakes{ function, maxAgeSeconds }. There is nofileargument, so an agent that getsambiguous: truehas 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_functionwithholds triggers entirely and returnscandidateLambdasinstead, 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 noaccessesat all when it does not.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.
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.
That
proven | inferreddiscovery 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.
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.
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
matchesby 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 overmatches.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.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 overmatches.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.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: truelives 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.
"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.
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.
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.
Some comments may only be visible to logged-in visitors. Sign in to view all comments.