A ticket's log category names who LOGGED it, never who owns the code

An automated incident ticket has to land in some repository, and the only thing the pipeline reliably knows about the fault is the logger's Category. Routing on it works whenever the component that wrote the line and the component that broke are the same — which is most of the time, and is exactly what makes the remaining cases expensive.

What was measured

Three tickets described code that does not exist in the repository they were filed in. MeshWeaver.AI, MeshWeaver.SelfUpdate.Aks, MeshWeaver.LogWatcher and MeshWeaver.Observability have zero tracked files in the platform repository and live here; one of the three even ended with "Where to look: MeshWeaver.AI.AgentChatClient", which over there names a directory containing two build folders. They were moved by hand.

The instrument that should have caught it had a blind spot with a precise shape. The router already separated two answers a repository name alone conflates — "the configuration says this belongs to X" and "nothing here knows, put it in the default" — and a defaulted ticket said so on its own face:

| Routing | not determined — no configured route matches the category . This repository is the configured fallback, not a finding about who owns the fault; the category names the LOGGER, which may not be the subject. |

But that row fires only when NOTHING matched. All three misfiled tickets had a matching prefix, so each reported itself DETERMINED and said nothing at all. The caveat was printed in the case where the reader is already suspicious, and withheld in the case that actually misroutes. The router's own test suite documented the boundary in as many words — "nothing in this class can see which repository owns an assembly" — which is how a known limitation stays a defect.

🚨 The obvious fix is not obviously right, and its stated evidence does not hold

The tempting repair is "route by the first stack frame in an in-house assembly instead of by Category". Two open tickets were cited as proof that a category-keyed router misroutes in the opposite direction. Re-measured, neither supports the claim:

The conclusions those two were cited for happen to be right. The evidence offered for them was not. That matters more than a footnote: the argument for making the frame win rested on those two, and with them gone there is no measurement showing the frame is the better key. A third point cuts the other way — one ticket's top frame is OpenAI.AsyncSseUpdateCollection1…`, a third-party assembly in neither repository — so the frame is not even always available, let alone always better.

What the two keys can establish between them

Neither key is authoritative:

key what it establishes how it fails
log Category which component wrote the line silent when the reporter is not the subject
first in-house stack frame where the fault ran absent entirely for a log line with no stack; the top frame is often third-party

So the router reads both, and the rule is chosen to match what is actually known:

The important property is that a contested route is still determined in the narrow sense that a route matched. Collapsing the two would have lost the undetermined state, which already works.

Why the SAME prefix table answers both questions

A route prefix (MeshWeaver.AI.) is a namespace prefix, and a stack frame is a namespace-qualified method. So the frame key is the existing table applied to a different string, and "in-house" means exactly "some configured route claims it" — there is no second list of in-house assembly roots to keep in step, and no root hard-coded anywhere. A deployment that adds a route extends both keys at once.

That is the answer to the standing objection that an assembly→repository map cannot be maintained: the split has moved before and will move again, so the map is derived from configuration that has to be right anyway for the primary key to work, and an undetermined route is the honest output when it is not.

🚨 Two traps in reading a frame out of an incident

Both were found by review, after the first version of this change had a green suite, and each would have shipped a fix that did nothing or broke a consumer.

A sample is a WHOLE BURST, not a line. BurstAggregator stores one sample per burst as string.Join('\n', burst.Raw.Lines), and the frame regex is anchored ^\s*at\s+…$ with no RegexOptions.Multiline — so ^ matches only at the start of the whole string. Handing samples to the frame walk unsplit therefore matches nothing unless the entire blob happens to be one at … line: the walk is inert on production data, and silent about it. It fails precisely on the case it exists for — a burst whose top frame is third-party and whose in-house frame is further down.

And it was a fact about the TEST first. The original harness took one line per argument and built one sample each, a shape the producer never produces; a harness more permissive than the real producer is how a fix reads green and does nothing. The harness now joins the lines exactly as BurstAggregator does, and removing the split makes the third-party-top-frame case fail — which is the only reason to believe the split is load-bearing.

Adding a positional parameter to a shipped record is a binary break. A record's primary constructor is one method, so a fifth positional parameter — even with a default — deletes the four-argument .ctor and the four-value Deconstruct from the assembly. Source callers recompile happily, which is what makes it invisible in-repo; a consumer already compiled against an earlier minor gets MissingMethodException. That breaks the fleet's same-major-serves rule for a change that is not a major bump, so ContestedReason is a non-positional init property set with with { … }.

What this does not fix