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:
- one of them is human-filed and has no Evidence table and no
Categoryline at all, so it cannot be an example of a category misroute; - the other's category is
MeshWeaver.PluginCatalog.InstanceAutoRegistrationService, which names its subject exactly and routes correctly — the opposite of a counter-example. (It was also cited under the wrong repository; that issue number does not exist here.)
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:
- they agree → routing is determined, and the ticket says nothing extra.
- only the category matched → determined, as before.
- only a frame matched (no category route) → still not determined, and the frame is named on the ticket as a candidate owner. It does not decide the destination: that the frame is the better key is a reasonable belief, not a measured one, and moving tickets on an unproven rule is how the three misfilings happened in the first place.
- both matched and they DISAGREE → contested. The ticket stays where the category sent it — the destination does not move, because neither key is authoritative — and the row says both readings with both prefixes, so a reader re-routes with the evidence in front of them instead of inheriting a verdict.
- an accepted triage override settles all of it. A named owner is a decision; there is nothing left to contest.
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
- A determined, agreeing route can still be wrong. If the catch-all sends
MeshWeaver.to the platform and the fault is in an assembly that lives here with no longer route configured, both keys read the same prefix and agree. Only a longer route, or an override, separates them. Contested detection finds disagreement, not error. - A log line with no stack gets no second opinion at all, which is the ordinary case for a plain
crit:/fail:line. The mechanism is silent there, deliberately: a caveat printed on every ticket is a caveat nobody reads. - Moving a ticket that is already filed is a human act. Nothing here transfers an issue; the routing decision is made once, when it is filed.
- Nothing here reads a running deployment's routing configuration, so how often
contestedfires in production is unknown. That nothing moves where a ticket lands is what makes it safe to find out.