In this section

The Six Ways Generated Security Queries Fail

Module 0

Introduction

When an AI assistant writes a query for you, it goes wrong in a small number of recognizable ways, and this section names them. Not because a taxonomy is interesting, but because "check the output carefully" is advice with no end: you can always look harder, so under pressure you stop looking at all. A short list with a check attached to each entry is finishable, which is the only kind anybody performs on a busy shift.

0.1 explained why these failures happen: a model produces the most probable continuation rather than the correct one. What follows is the six shapes that takes, each with its own tell and its own check. By the end you can look at a generated query, name which of the six it is exposed to, and run the check that resolves it in under a minute.

Scenario

It is the middle of a shift and you have eleven alerts open. An assistant has produced four queries for you in the last twenty minutes. You do not have time to reason from first principles about each one, and you do have about sixty seconds per artifact. What you need is not a philosophy of verification but a list short enough to run from memory, with a specific check attached to each entry.

Every generated error in this course is one of six shapes. The list is not a taxonomy for its own sake: each entry comes with a check, and the checks are what turn "review this carefully" into something you can finish.

They share an origin. A model produces the likely continuation, and in a query language the likely continuation is the common column, the common time field, the common join. Where common and correct diverge, you get one of these six.

SIX MODES, THREE THINGS THEY CORRUPT WHAT IT LOOKS AT 1 Plausible field right column, wrong value ResultType == 0 2 Silent window time filter excludes the event, returns rows HOW IT COMBINES 3 Wrong join key correlates on a field that is not unique 4 Confident absence zero rows read as a finding WHAT IT CLAIMS 5 Wrong question correct query, answers something adjacent 6 Invented precision a figure that was generated, not measured CHECK NUMBER 4 FIRST, ALWAYS Every other mode leaves you something to examine. An empty result hands you a conclusion for free.

The grouping is the useful part: what a mode corrupts tells you which clause to read first.

01

Plausible field

The right column, compared against the wrong value
query to run

The first mode is the one you will meet most often and the one hardest to see, because nothing about it is malformed. The query uses a column that exists and means something other than you assumed. The column is real, the comparison is valid, the query runs, and the result is a populated table. Every surface signal says the answer is sound, which is why this one survives a careful read.

SigninLogs
| where UserPrincipalName == "r.scott@ne.com"
| where ResultType == 0
| summarize Failures = count() by IPAddress

Change one character, == 0 to != 0, and run it again: 87 events across five addresses, one of which accounts for 83 of them. Opposite finding, and the variable was called Failures throughout.

The variable is named Failures. The filter selects ResultType == 0, which is success. The query counts successes and labels them failures, and the label is the only place the intent survives.

Why a model produces it. ResultType is the right column and zero is the most common value in the training distribution. Both halves are individually likely. Their combination happens to invert the meaning.

The check. Name the semantic claim: for this to be right, zero would have to mean failure. Then confirm it in the schema. Thirty seconds.

A mismatch between a variable name and the filter above it is one of the highest-yield signals available to you.

The name records what the author meant and the filter records what the query does. When a model writes both, the name usually reflects your request and the filter reflects the training distribution, so a disagreement between them is a disagreement between your intent and the output.

02

Silent window

A time filter that excludes the event and still returns rows
query to run

The second mode turns on arithmetic rather than meaning. The time filter excludes the event, and still returns rows, because an estate that produces activity every day will produce some inside almost any window you choose. What you get back is real data about the wrong period, and a count alone cannot tell you which period it came from.

SigninLogs
| where UserPrincipalName == "r.scott@ne.com"
| where TimeGenerated > datetime("2026-03-03T00:00:00Z")
| where ResultType != 0
| summarize Failures = count()

Run it, then delete the time filter and run it again. 2 becomes 87. The filter was the analysis, not the where ResultType != 0 above it.

This returns 2. It is a real count of real failures, and the analyst reading it concludes the account saw two failed attempts.

The brute force against this account ran on the night of 2 March, between 22:14 and 22:55. Eighty-three failures sit one day outside the window. Nothing in a result of 2 suggests that the number would be 83 if the window began a few hours earlier.

THE WINDOW WAS THE ANALYSIS 2 Mar 22:00 3 Mar 00:00 4 Mar 00:00 83 failures 22:14 to 22:55 THE WINDOW THE QUERY USED returns 2, and both of them are ordinary one day A result of 2 looks like an answer. The gap between the two blocks is not visible in it.

Move the left edge back six hours and the count changes by an order of magnitude. Nothing in the output says so.

Why a model produces it. Dates are the single most common thing to get slightly wrong, because the request is usually relative ("around the alert", "the last few days") and the conversion to an absolute window is a small arithmetic step with no feedback if it lands off by a day.

If moving the boundary by a day changes the count by an order of magnitude, the boundary was doing the work rather than the filter.

The check. Widen the window deliberately, a day in each direction, and see whether the answer changes shape.

03

Wrong join key

A correlation on a field that is not unique, inflating the count

The third mode only appears when you combine sources, which makes it rarer and harder to spot when it does. Two tables are correlated on a field that is not unique, and the result multiplies or drops rows. The query is syntactically perfect and the inflation is invisible in the output, because a larger number looks like more evidence rather than like an error.

A larger number reads as more evidence, which is why an inflated join is the one failure that makes you more confident.

Joining sign-in data to device data on a username looks obviously correct and is not: a user has several devices, a device has several users, and the join produces a row for every combination. A count over that result is inflated by an amount that depends on the data rather than on anything visible in the query.

WHAT WENT IN                      ROWS
  sign-ins for the account         14
  device records for the account    6
 
WHAT CAME OUT OF THE JOIN
  joined on UserPrincipalName      84
 
84 IS LARGER THAN EITHER INPUT. THAT IS THE TELL, AND IT IS THE
ONLY ONE: THE QUERY IS VALID AND THE OUTPUT IS A TIDY TABLE.

The subtler version is a join on a field that is unique in the sample the analyst is looking at and not in general. It works in testing and silently misbehaves at scale.

Why a model produces it. Joining on the field with a shared name is the overwhelmingly common pattern, and whether that field is a valid key is a fact about the schema rather than about the query.

Here it is against the estate. An analyst wants to know how many alerts have supporting evidence attached, and the query joins the alert table to the evidence table.

SecurityAlert
| join kind=inner AlertEvidence on $left.SystemAlertId == $right.AlertId
| summarize AlertsWithEvidence = count()

Run it. 225. The estate has 116 alerts in total, so a count of alerts has come back larger than the number of alerts that exist.

The cause is in the evidence table rather than in the query. Evidence rows are per artifact, not per alert: 228 rows spread across 117 alert identifiers, so an alert with four pieces of evidence contributes four rows to the join. The variable is named AlertsWithEvidence and it is counting evidence, not alerts.

Nothing in the output says so. 225 is plausible for an estate this size, and being larger than the truth rather than smaller, it reads as thorough rather than as wrong.

SANITY CHECK, TEN SECONDS
 
  alerts in the estate      116
  the query says            225
 
A COUNT OF A THING CANNOT EXCEED THE NUMBER OF THAT THING.
WHENEVER IT DOES, THE JOIN IS THE FIRST PLACE TO LOOK.

The check. Before trusting a count over a join, count the rows on each side. If the joined result has more rows than the larger input, the key is not unique and the number is wrong.

04

Confident absence

Zero rows read as a finding rather than as a wrong question
query to run

The fourth mode is the one to check first. The query returns nothing, and the nothing is read as evidence.

Every other failure hands you something to be suspicious of. This one hands you a conclusion, for free.

It is also the conclusion an analyst under time pressure most wants to reach, which is the second half of why it goes unchallenged.

DeviceLogonEvents
| where AccountName == "svc-sql"
| summarize Logons = count()

Run it: 0. Now prove the query could ever have worked. Delete the account filter and run it again: the table returns rows, so it is populated. The account is simply not in it, because its authentication is in IdentityLogonEvents.

This returns 0. An analyst investigating whether a database service account has been used on workstations sees zero and concludes it has not.

The Reasonable Mistake

Zero rows is not a negative result

Two states produce an empty table: the thing did not happen, and you did not look where it happened. They are indistinguishable from the output and they lead to opposite actions. Treat every empty result as unresolved until you have shown the query would have found the thing had it been there.

The account has been used. It appears in IdentityLogonEvents, which is where domain authentication is recorded, and there it shows a single NTLM logon to a laptop at 22:08 on 12 March. DeviceLogonEvents is a different table with a different scope, and querying the wrong one returns an empty result rather than an error.

Why this is the most dangerous of the six. Every other failure gives you something to look at. An empty result gives you a conclusion for free, and it is the conclusion an analyst under time pressure most wants: nothing here, close it.

The check. An empty result is never a finding until you have proved the query can return rows at all. Remove the specific filter and confirm the table has data of that kind. If dropping the account name still returns nothing, you are querying the wrong table.

ZERO ROWS: TWO CAUSES, OPPOSITE ACTIONS 0 rows It did not happen A real negative finding. Close the alert. You looked in the wrong place Wrong table, wrong window, wrong field. Keep going. The output is identical in both cases. Nothing in an empty table tells you which one you are in. Drop the narrowest filter. Still empty? Wrong table.

Zero rows supports two opposite actions and the screen looks the same in both. Drop the narrowest filter to find out which one you are in.

05

Right answer, wrong question

A correct query answering something next to what you asked

The fifth mode is the only one where nothing is wrong with the artifact at all. The query is correct, and it answers something adjacent to what you asked.

You ask which hosts ran PowerShell during the incident window. You get a query that counts PowerShell executions per host across the whole period, sorted descending. It is a good query. It runs, it returns seventeen hosts, and the top of the list is the busiest host in the estate rather than the one involved in the incident.

Nothing is wrong with it. It is simply not what you asked, and because the output is a plausible answer to a plausible question, there is no jar of recognition to alert you.

Every check you can run against the query passes. The error is in the gap between the query and your question.

Why a model produces it. Your request contained a constraint ("during the incident window") that is easy to drop, and dropping it produces a more generic query, which is the more likely continuation.

Run the query the analyst was actually given.

DeviceProcessEvents
| where FileName == "powershell.exe"
| summarize Executions = count() by DeviceName
| sort by Executions desc

Seventeen hosts, and the top of the list is NE-LEWIS-LT with 57 executions. It looks like an answer, and an analyst scanning the top three would take those as the hosts of interest.

The request was which hosts ran PowerShell during the incident window. There is no time filter in this query at all. Seventeen hosts is every host that has ever run PowerShell in thirty days of telemetry, and NE-LEWIS-LT is at the top because it is a busy laptop rather than because it is involved in anything.

The constraint the analyst stated is simply absent, and its absence is invisible: a query with no time filter looks exactly like a query whose time filter matched everything.

WHAT YOU ASKED                       CLAUSE IN THE QUERY
 
which hosts                          summarize by DeviceName    yes
ran PowerShell                       FileName == "powershell"   yes
during the incident window           ---                        MISSING
 
THREE CONSTRAINTS STATED. TWO CLAUSES WRITTEN. THE OUTPUT LOOKS
THE SAME EITHER WAY.

The check. Read your original request and the query side by side, and count the constraints in each. Every constraint you stated should appear as a clause. A missing constraint is not visible from the output.

06

Invented precision

A figure that was generated rather than measured

The sixth mode is the one people expect from these tools and the rarest in query work, because a query either finds a value or does not. The answer states a number, a name or a time that the data does not support. It arrives at the end of an investigation, in the sentence you are about to put in a ticket: an assistant reads a set of events and reports that "the attacker accessed 14 files over approximately two hours", when the events show file access without a count anyone tallied and a duration nobody measured.

The number is not random. It is a plausible number, which is worse, because implausible numbers get challenged.

Here is a generated incident summary for the laptop in the credential-theft chain. Read it as an analyst would, at the end of a long shift, with a ticket to write.

Generated summary · assistant output

Analysis of NE-LEWIS-LT indicates the actor accessed 14 files over a period of approximately two hours, beginning shortly after the initial credential dump. File activity was concentrated in user document directories and is consistent with staging prior to exfiltration.

Both marked figures are wrong, and neither is wrong in a way that looks wrong.

Run the events the summary describes:

DeviceFileEvents
| where DeviceName == "NE-LEWIS-LT"
| summarize Files = dcount(FileName), Events = count(),
            First = min(Timestamp), Last = max(Timestamp)

113 distinct files across 289 events, spanning 13 February to 13 March. Not fourteen files, and not two hours: a month.

A wildly wrong figure would have been challenged. Fourteen files and two hours are believable, which is the point: a plausible continuation is what the mechanism produces.

Why a model produces it. Summarizing is generation, not calculation. A summary that includes a specific figure reads as more authoritative, and specificity is a property of good summaries in the training data regardless of whether the figure was derived.

The check. For any figure in a generated summary, ask which query produced it. If the answer is "the summary did", the figure is a claim rather than a measurement, and it does not go in a report.

Test it on your own assistant

Try this Ask it to break its own query
Write me a KQL query against SigninLogs and DeviceLogonEvents that

correlates failed cloud sign-ins with endpoint logons for the same user.

Then tell me which of these six problems the query is exposed to: a wrong field value, a time window that excludes the event, a join key that is not unique, an empty result read as evidence, answering an adjacent question, or a figure that was not measured.

What to look at. Whether it names the join key. Correlating two tables on a username is the textbook case of a key that is not unique, and it is the failure most likely to be in the query it just wrote.

What this demonstrates. Handed the vocabulary, these systems apply it well, which makes the six modes a shared language with the tool rather than only your own checklist. It also shows the limit. Naming the mode is the cheap half; settling it needs your data.

07

Using the list

Running all six as a sweep, in the order that costs least

The modes are not equally likely everywhere. Query work is dominated by 1, 2 and 5, correlation brings in 3, and reporting brings in 6. Number 4 applies everywhere and is checked first.

The sweep, in the order to run it

Each mode already came with its own check. What section 07 adds is the order, because the six are not equally cheap and running them cost-first means most problems surface in the first twenty seconds.

ORDER  CHECK                          CATCHES   COSTS
 
1      Empty? Drop the narrowest      mode 4    one edit
       filter and re-run
 
2      Read the filters against       modes     thirty seconds
       your own request               1 and 5
 
3      Widen the window a day         mode 2    one edit, and it
       each way                                 tells you whether
                                                the window mattered
 
4      If there is a join, count      mode 3    only where two
       both sides                               tables are combined
 
5      For any figure you will        mode 6    applies when you
       repeat: which query                      write, not when
       produced it?                             you query

Why the order matters more than it looks. Under time pressure you will not finish the sweep. Running it in this order means the checks you do finish are the ones that catch the failures that close incidents wrongly, and the one you abandon is the one that produces a slightly overstated figure in a report. Both are worth catching. Only one of them lets an intrusion continue.

08

Practice

Run the sweep on four queries and time yourself
hands on

Six modes read is not six modes learned. The sweep below is the whole technique in one pass, and the point of timing yourself is that the number falls fast: what takes two minutes on the first query takes under sixty seconds by the end of a shift, because you stop deciding which check to run and start running them in order.

Practice Run the sweep, in order, and time it
  1. Empty result? Drop the narrowest filter and re-run.
  2. Read the filters against your own request: every constraint a clause, every variable name matching what the filter does.
  3. Widen the time boundary a day each way. Does the answer change shape?
  4. If there is a join, count both sides.
  5. For any figure you would repeat: which query produced it?
First run takes about two minutes. By the end of a shift it should be under sixty seconds.

Then pick one mode and hunt it deliberately this week. Confident absence is the one to start with, because an empty result is the only failure that hands you a conclusion for free.

Next: section 0.4 introduces the estate every exercise runs against, and the kinds of knowledge about it that no assistant can be given.