All findings · Lucene finding · LFV-049 · operations-istotal-dead-state

Operations.isTotal can say "accepts everything" for a DFA that can reach a dead state, so an AutomatonQuery built from it matches every term

Since Lucene 10.0.0, Operations.isTotal can answer "accepts every string" for a deterministic automaton that rejects some strings: it happens when the only strings the automaton rejects are those that run into a reachable dead (sink) state. An AutomatonQuery built from such an automaton then matches every term: 3 hits instead of 2 in a three-document index.

Summary

Priority
Low, rank 531Top of Low (moved down from Medium): the 10.0 isTotal regression makes an AutomatonQuery match every term for a DFA with a reachable dead state, and Elasticsearch uses isTotal in authorization checks, but every Lucene automaton builder strips dead states and no downstream path that keeps one is known, so the over-match needs a hand-built automaton.
Impact
silent wrong results
Since
10.0.0 (#13707, merged 2024-09-05)introduced 2024-09-05 · about 2 years
Affected
10.0.0 to 10.5.2, 10.6.0 (by reading) and main; correct on 9.12.0 to 9.12.3 (run)
Status
documented
Found by
Exhaustive translation validation against an exact reference semantics

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

Operations.isTotal(a) should be true only if the automaton accepts every string. CompiledAutomaton, and so every AutomatonQuery, uses it to take a match-all shortcut.

  1. Build a two-state DFA by hand: state 0 accepts and loops on a and above; any character below a, such as 0, goes to a sink state that never accepts. It accepts b and zz and rejects 0.
  2. Call Operations.isTotal(a), or index the terms 0, b and zz and count new AutomatonQuery(term, a). You expect false and 2 hits, which is what Lucene 9.12.3 gives.
  3. Since 10.0.0 you get true. The query compiles to AUTOMATON_TYPE.ALL and matches all 3 documents, including 0. No error.
  4. The cause: isTotal checks only live states (getLiveStates, Operations.java:857). The sink cannot reach an accept state, so it is skipped, while the transition into it still counts as covering the alphabet.
  5. The same language without the sink (removeDeadStates) gives false and 2 hits. Lucene's own RegExp, wildcard, prefix, range and fuzzy automata had no dead state in sampled checks, so it takes an automaton that the application builds (by hand, by determinizing its own NFA, or through an AutomatonProvider).

Who is affected

Code that builds its own deterministic automaton with a reachable sink (by hand, by determinizing its own NFA, or through an AutomatonProvider) and calls isTotal or runs it as an AutomatonQuery. Lucene's own query builders produced none in sampled checks. Downstream code uses isTotal as a grant-all decision, for example in field-level security. Affects 10.0.0 to 10.5.2 and main.

Mechanism, terms and numbers800 words

Operations.isTotal(a) is documented to return true only if the automaton accepts all strings. Since Lucene 10.0.0 it can return true for a deterministic automaton that rejects some strings: it checks only the live states, so a reachable non-accepting dead (sink) state is never looked at, while the transition into the sink still counts as covering the alphabet. The answer is wrong when every live state accepts and covers the alphabet, so that the only rejected strings are those that run into the sink; a reachable dead state alone is not enough. CompiledAutomaton turns the wrong true into AUTOMATON_TYPE.ALL, so an AutomatonQuery silently matches every term of the field (3 documents instead of 2 in the repro). Lucene 9.12.x answers correctly. The cause is #13707, which generalised isTotal from minimal to any deterministic automaton. A direct isTotal call with this automaton is inside its documented contract (determinism only); the AutomatonQuery path also crosses the CompiledAutomaton class javadoc, which says a deterministic automaton "must not have dead states", a rule that AutomatonQuery does not restate. Lucene's own query builders produced no dead state in sampled checks, so the trigger is an automaton that the application builds itself. Downstream code uses isTotal as a grant-all decision, but the bug is not security-sensitive: the trigger is an automaton the application builds, out of scope of Lucene's SECURITY.md (registry none since the strict re-evaluation of 2026-10-27). A one-line fix passes the repro and the whole core test suite.

Terms

  • Deterministic automaton (DFA): from every state, each character leads to at most one next state. Lucene's Automaton numbers its states; state 0 is the start.
  • Dead state (sink): a state from which no accepting state can be reached. A complete DFA, the textbook form, sends every character it rejects into one such sink, which loops on everything.
  • Live state: a state reachable from the start that can still reach an accept state (getLiveStates, Operations.java:922-930).

The automaton of the repro

  state 0 (accepting):     [U+0000 .. U+0060]  -> state 1   ('0', ' ', '`' ... go to the sink)
                           [U+0061 .. U+10FFFF] -> state 0   ('a' and above stay)
  state 1 (not accepting): [U+0000 .. U+10FFFF] -> state 1   (the sink)

It is deterministic, accepts b and zz, and rejects 0. Operations.removeDeadStates(a) gives the same language with only state 0.

The bug, line by line (Operations.java at ea57360912ed)

  1. isTotal(a) (844-846) calls isTotal(a, Character.MIN_CODE_POINT, Character.MAX_CODE_POINT) (856-879).
  2. 857: BitSet states = getLiveStates(a); is {state 0}: the sink cannot reach an accept state, so it is not live.
  3. 861-862, under the comment "all reachable states must be accept states": state 0 is accepting.
  4. 863-871, the gap scan ("no gaps are allowed"): [0, 0x60] and [0x61, 0x10FFFF] leave no gap. The scan looks at label ranges only, not at where they go, so the range that leads into the sink counts as covered.
  5. 878: return seenStates > 0; gives true. The sink, which is reachable and not accepting, was never checked.

From isTotal to the query

  • AutomatonQuery always builds new CompiledAutomaton(automaton, false, true, isBinary) (AutomatonQuery.java:99).
  • CompiledAutomaton calls Operations.isTotal(automaton), or isTotal(automaton, 0, 0xff) for a binary automaton, and on true sets AUTOMATON_TYPE.ALL, "Automaton that accepts all possible strings" (CompiledAutomaton.java:55-56, 179-199).
  • For ALL, getTermsEnum returns terms.iterator(): every term of the field (CompiledAutomaton.java:352-353). Terms.intersect on that compiled automaton throws IllegalArgumentException("please use CompiledAutomaton.getTermsEnum instead") instead (Terms.java:79-80, executed).

What 9.12 did

The 9.12 javadoc said "The automaton must be minimized", and the body recognised one shape only: state 0 accepting, with a single self-loop over the whole alphabet. Everything else got false. That could be a missed optimization, but never a wrong true. #13707 (10.0.0) replaced it with the live-state loop. The repro's automaton has a dead state, so it is not minimal: it was outside 9.12's documented domain, and 9.12's correct false shows the regression rather than a promise.

Breadth

Over all 2,100,084 DFAs with 1 to 3 states over the labels a, b, c, compared with a brute-force oracle that does not use Lucene: 37,634 false positives (1.8%), all with dead states, and 0 false negatives (alphabet [a, c]). A reachable dead state alone is not enough: 574,233 of those automata have one, and isTotal is wrong on 37,634 of them and right on the rest. With the alphabet [b, c] (label a outside it) main gives 48,120 false positives, again all with dead states. A separate, older and conservative mode (a wrong false when a custom alphabet is narrower than the labels: 38,656 cases in the [b, c] scope, also in 9.12.0) is out of scope here; the recommended fix does not fix it and raises it to 43,466 (see the fix trade-offs).

Exposure and likelihood425 words
  • Trigger paths, all executed on main. A hand-built Automaton (the repro). An NFA built with Automaton.Builder and passed through Operations.determinize, which keeps its dead state (determinize returns its result without removeDeadStates, Operations.java:793-795): isTotal is true although 0 is rejected. A RegexpQuery with an AutomatonProvider that returns the repro's automaton for the named automaton <lower> (returned unchanged, RegExp.java:711-727): 3 hits instead of 2, compiled type ALL.
  • Not hit, as far as checked: Lucene's own query builders. Sampling found no dead state in automata from RegExp (about 59,800 random regexps, the study), TermRangeQuery, PrefixQuery, WildcardQuery (20,000 each) and LevenshteinAutomata (120,000). Several Automata.make* builders assert there are none (Automata.java:564, 589, 609), and Operations' concatenate, union, intersection, complement, repeat and reverse call removeDeadStates on the automata they build. That is not a guarantee for every path: optional documents "This may create a dead state" (Operations.java:148), determinize returns its result without removeDeadStates (793-795), and optional, repeat, intersection and minus can return an input unchanged (155, 204, 209, 381, 393-400), so a dead state that an input already has can survive them.
  • Default or opt-in. No setting is involved, but the trigger is opt-in: the application builds the automaton through the @lucene.experimental automaton API.
  • Scale. None: two states, three documents.
  • Silent and fail-open. No exception. This bug's wrong answer is always true ("accepts everything"): through CompiledAutomaton it becomes ALL and an AutomatonQuery over-matches, and a caller's match-all shortcut fires. Terms.intersect on that compiled automaton throws IllegalArgumentException instead (Terms.java:79-80, executed); SortedDocValues.intersect and SortedSetDocValues.intersect return every value for ALL (read).
  • Downstream decisions (read in Elasticsearch main on 2026-10-26, not executed). Privilege.grantsAll is Operations.isTotal(automaton) (Privilege.java:49). FieldPermissions caches isTotal(permittedFieldsAutomaton) (FieldPermissions.java:110-112); when it is true, every field is granted (227), hasFieldLevelSecurity() is false (244-246) and filter() returns the reader unfiltered (249-253). The wildcard field mapper and the query languages' AbstractStringPattern use it as a match-all shortcut. The study found no Elasticsearch path that builds one of these automata with a dead state.
  • How likely. Unknown. No prior report was found in about two years since 10.0.0, and no Lucene path that produces such an automaton is known; neither measures real-world frequency. A complete DFA with a sink is the textbook form, and Lucene's own package-private Operations.totalize builds exactly that shape ("added transitions to a dead state", Operations.java:1308-1345). It does not leak out: complement then flips every state's acceptance (351-354), so that sink becomes an accepting state of the complement, which the final removeDeadStates (356) keeps.

Minimal repro

The test shows that Operations.isTotal returns true for a deterministic automaton with a reachable dead state, although the automaton rejects "0".

TestIsTotalDeadState.java (JUnit test)35 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.util.automaton;

import org.apache.lucene.tests.util.LuceneTestCase;

public class TestIsTotalDeadState extends LuceneTestCase {

  public void testIsTotalWithReachableDeadState() {
    Automaton a = new Automaton();
    int s0 = a.createState();
    int dead = a.createState();
    a.setAccept(s0, true);
    a.addTransition(s0, dead, Character.MIN_CODE_POINT, 'a' - 1);
    a.addTransition(s0, s0, 'a', Character.MAX_CODE_POINT);
    a.finishState();
    assertFalse(Operations.run(a, "0"));
    // expected: false, the automaton rejects "0"; actual: true
    assertEquals("isTotal of a DFA that rejects \"0\"", false, Operations.isTotal(a));
  }
}
Command
# in a copy of this finding's repro folder (repro/LFV-049/, which holds src/)
# Lucene checkout at the tested commit, built with: ./gradlew --no-daemon assemble   (JDK 25)
LUCENE=/path/to/lucene; V=12.0.0-SNAPSHOT
CP=$LUCENE/lucene/core/build/libs/lucene-core-$V.jar
CP=$CP:$LUCENE/lucene/codecs/build/libs/lucene-codecs-$V.jar
CP=$CP:$LUCENE/lucene/test-framework/build/libs/lucene-test-framework-$V.jar
CP=$CP:randomizedtesting-runner-2.9.2.jar:junit-4.13.2.jar:hamcrest-3.0.jar   # test-framework dependencies (Maven Central)
CP=$CP:junit-platform-console-standalone-6.1.3.jar   # JUnit Platform console launcher (Maven Central)

javac -d classes -cp "$CP" $(find src -name '*.java')

# run 1: assertions on, seed DEADBEEF
java -ea -esa --add-modules jdk.incubator.vector --enable-native-access=ALL-UNNAMED -Dtests.seed=DEADBEEF \
     -cp "classes:$CP" org.junit.platform.console.ConsoleLauncher \
     execute --select-class org.apache.lucene.util.automaton.TestIsTotalDeadState
Expected vs actual
Expected: isTotal(a) == false (javadoc: "Returns true if the given automaton accepts all strings"); actual: true (expected:<false> but was:<true>).

All files of this repro: repro/LFV-049/

More files, environment, full output40 words

Environment

luceneLucene main e1faeba08bde (2026-11-02)
jdk25.0.3+9-LTS

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: isTotal of a DFA that rejects "0" expected:<false> but was:<true>
       org.junit.Assert.fail(Assert.java:89)
       org.junit.Assert.failNotEquals(Assert.java:835)
       org.junit.Assert.assertEquals(Assert.java:120)
       org.apache.lucene.util.automaton.TestIsTotalDeadState.testIsTotalWithReachableDeadState(TestIsTotalDeadState.java:33)

Why it looks unintended

  • The javadoc: "Returns true if the given automaton accepts all strings". Its only precondition is determinism, and its only allowed error is a wrong false.
  • Same language, two answers: without the sink, isTotal is false. 9.12.3 says false for both.
  • #13707, which made the change, says the new code "should work generally for any DFA".
The full argument377 words
  • The contract. Operations.java:838: "Returns true if the given automaton accepts all strings." 840: "The automaton must be deterministic, or this method may return false." 849-852 say the same for a min/max alphabet. The in-code comment at 861 states the rule: "all reachable states must be accept states".
  • The consumers. AutomatonQuery "will match documents that contain terms accepted by a given finite-state machine" (AutomatonQuery.java:32) and "terms that are accepted are considered a match" (64, 75, 88). AUTOMATON_TYPE.ALL is the "Automaton that accepts all possible strings" (CompiledAutomaton.java:55-56).
  • History. #13707 ("Relax Operations.isTotal() to work with a deterministic automaton", by rmuir, approved by dweiss and ChrisHegarty, merged 2024-09-05; CHANGES under 10.0.0: "Improve Operations.isTotal() to work with non-minimal automata") changed the javadoc from "must be minimized" to "must be deterministic". rmuir wrote that it "should work generally for any DFA, so IMO it is safe". getLiveStates(a) was already in rmuir's first coding of dweiss's idea in #13706, and the stated reason for it is that it "is easier to reason about"; the PR's randomized check later found "the situation where all states are dead states" for the draft's return a.getNumStates() > 0;, and the merged code returns seenStates > 0. In the motivating issue #13706 an earlier draft by rmuir documented "The automaton must be deterministic with no transitions to dead states" and returned false when hasDeadStatesFromInitial(a); dweiss described the rule as "a deterministic, total automaton must accept full language in each state reachable from root". The merged code dropped the guard; no comment discusses a reachable dead state next to live states. This is design context from before the bug shipped, not a report of it.
  • Tests. TestOperations.testIsTotal (TestOperations.java:182-220, added by #13707) has minimal, non-minimal and "not total, but close" cases, and no automaton with a dead state.
  • Dead states are legal. hasDeadStates, hasDeadStatesFromInitial and removeDeadStates are public (Operations.java:444-460, 1003-1007). Where a method cannot handle them it says so: getCommonPrefix documents and throws IllegalArgumentException "if the automaton has dead states reachable from the initial state" (1127-1134). isTotal documents nothing of the kind.
  • Same language, two answers. a and removeDeadStates(a) accept the same strings, yet isTotal returns true and false. A predicate on the language cannot do that.
The devil's-advocate check280 words

The strongest case for "intended" (the cross-model refute review rated it "moderate"): the CompiledAutomaton class javadoc says "For deterministic automaton, it must not have dead states but is not necessarily minimal" (CompiledAutomaton.java:36-41), AutomatonTermsEnum assumes "a DFA with no transitions to dead states" (AutomatonTermsEnum.java:297), CompiledAutomaton.java:181 says "NOTE: only approximate, because automaton may not be minimal", 9.12's isTotal documented "The automaton must be minimized", so the explicit-sink automaton was outside 9.12's domain too, the automaton API is @lucene.experimental, Lucene's own query builders produced no dead states in sampled checks, and SECURITY.md leaves untrusted input to the caller.

Why it fails: (1) the direct isTotal contract has no dead-state precondition and allows only a wrong false; (2) the "only approximate" comment predates #13707 (it is at CompiledAutomaton.java:176 in 9.12.0), when isTotal could only miss an optimization; (3) AutomatonQuery's javadoc does not repeat the CompiledAutomaton rule, and its constructor always simplifies; (4) the answer changes for the same language; (5) 9.12.x is right for the same input (an input outside 9.12's "must be minimized" precondition, so this shows the regression rather than a promise), and the rule as stated in #13706 (dweiss: "each state reachable from root") and in #13707's own code comment at Operations.java:861 ("all reachable states must be accept states") covers the sink; (6) the one-line fix keeps all 8,870 core tests passing (compatibility, not intent). Verdict: not intended, a defect in Operations.isTotal; the AutomatonQuery symptom follows from it. A maintainer may still call the AutomatonQuery input a violation of CompiledAutomaton's precondition; then AutomatonQuery's javadoc, which does not repeat the rule while its constructor always simplifies, is at least a documentation defect.

Suggested fix

Recommended Check every state reachable from the initial state (one line)

Check every state reachable from the start, not only the live ones: in Operations.isTotal, replace getLiveStates(a) with getLiveStatesFromInitial(a) (one line, plus a test). The repro passes, and so do all 8,870 core tests and seven other modules. It does not fix the older narrow-alphabet false negative (direct callers only) and adds some conservative cases to it.

-    BitSet states = getLiveStates(a);
+    BitSet states = getLiveStatesFromInitial(a);
All options and trade-offs4 options ยท 664 words

Option A was validated with gradle in a scratch worktree at ea57360912ed; option B was validated by the study on 7cecf79523; C and D were not run. Workaround today: call Operations.removeDeadStates(a) before isTotal, AutomatonQuery or CompiledAutomaton.

Check every state reachable from the initial state (one line) (validated by the checks) (recommended)

For a deterministic automaton whose labels lie in [min, max], the language is total exactly when every state reachable from state 0 is accepting and covers [min, max]: the rule the comment at Operations.java:861 states. The empty cases stay right: with 0 states the set is empty and seenStates > 0 is false; if no accept state is reachable, state 0 is in the set and is not accepting, so the loop returns false at once.

-    BitSet states = getLiveStates(a);
+    BitSet states = getLiveStatesFromInitial(a);

Trade-offs

  • Removes every dead-state false positive: in the study's exhaustive scope, 0 false positives and 0 false negatives for labels inside the alphabet.
  • Complete when every label lies inside [min, max]: always so for isTotal(a) on code-point labels and for CompiledAutomaton's isTotal(a, 0, 0xff) on a binary automaton. It does not fix the older conservative mode (narrow custom alphabets) and changes its count: in the study's [b, c] scope main gives 48,120 false positives and 38,656 false negatives, the fix 0 and 43,466. The 4,810 new answers are all a conservative false for a total language, from a dead state that can be reached only through a label outside [min, max]; no answer goes from false to true (executed). Only direct callers that pass a narrower alphabet see them; option B removes them.
  • Does less work: it skips getLiveStatesToAccept, which builds a reversed copy of the automaton (Operations.java:959-1001); not benchmarked.
  • An automaton with a reachable dead state now compiles to NORMAL instead of ALL: correct, and Lucene's own query builders were not seen to produce such automata.

What the checks validated

  • Gradle, scratch worktree at ea57360912ed, JDK 25: 36 classes (automaton, regexp, wildcard, prefix, range, fuzzy and terms-enum tests and 5 related query classes) with the repro test: stock 406 tests, 4 failed (the 3 repro tests and the new TestOperations test); with the fix 406 tests, 0 failed (also seed 1234ABCD with assertions off).
  • The whole :lucene:core:test: 8,870 tests in 748 classes, 0 failures, 322 skipped. queryparser, highlighter, monitor, queries, memory, suggest and analysis:common: 4,144 tests, all pass. checkGoogleJavaFormat passes on the diff.
  • The plain program and the provider probe give the 9.12.3 answers with the fix; the cross-model reviewer's own test and 133 existing tests pass with it.

Walk only in-alphabet labels from the start, with a clipped gap scan (the study's patch B) (validated by the checks)

About 30 lines in isTotal: a breadth-first walk from state 0 that follows only labels inside [min, max], with each transition clipped to the alphabet before the gap check.

Trade-offs

Also fixes the conservative false negatives for narrow custom alphabets (0 oracle disagreements in both scopes): the full fix for direct callers that pass a custom alphabet. A larger change than A. The corrected specification it implements was proved sound and complete in Dafny by the study (not re-audited by the hunt).

What the checks validated

By the study on 7cecf79523 (classpath shadow, not gradle): 0 disagreements with the brute-force oracle in both scopes, and 297 tests from 24 Lucene test classes pass.

Guard in CompiledAutomaton only

Call removeDeadStates, or skip the ALL shortcut when hasDeadStatesFromInitial(a), before isTotal.

Trade-offs

Fixes AutomatonQuery and the provider path, but not direct callers of isTotal, which is where the downstream authorization use is. Not run.

Document "no dead states" in the isTotal javadoc (not recommended)

Keep the code and add the precondition.

Trade-offs

Keeps a fail-open answer in a public predicate that downstream code uses for grant-all decisions, and every 10.x release keeps it.

Downstream note

The wrong answer is always true ("accepts everything"). isTotal requires only determinism, dead states are legal, and downstream code acts on the answer: in Elasticsearch a true turns field-level security off. It needs a sink in an automaton the application builds; no such path or exploit is known. Out of scope of Lucene's SECURITY.md (registry none since 2026-10-27).

How it was found

Exhaustive translation validation: an AI tool enumerated every small automaton, ran Lucene's Operations on the real jar and compared each answer with an exact, independent textbook reference semantics. isTotal was wrong only on DFAs with dead states, and a corrected version was proved in Dafny. A separate AI verifier reproduced it from scratch.

The method in full203 words

Method: bounded-exhaustive translation validation plus a deductive proof (the study's semi-formal tier, 2026-10-22, main 7cecf79523), in the experiment that verified org.apache.lucene.util.automaton.

  • An AI tool wrote FvRef, an independent textbook semantics for automata that decides language equality, emptiness and totality exactly. It enumerated every automaton in small scopes, ran each Lucene operation on it, and compared. isTotal returned true on non-total DFAs, and every such case had a dead state (finding F2a). Dafny proved a corrected isTotal sound and complete and machine-checked counterexamples for main. The study's attribution table records it as found blind: the task did not point at this bug.
  • A separate AI verifier re-derived it from scratch: a brute-force oracle over all 2,100,084 DFAs with 1 to 3 states over 3 labels (37,634 false positives, all with dead states), the end-to-end AutomatonQuery test, runs on 9.12.0, 10.0.0 and main, and two patches checked against 297 Lucene tests.
  • On 2026-10-26 the hunt re-ran it on ea57360912ed, built the release matrix and the commit A/B, searched for prior reports, and had it reviewed by an independent reviewer from another model family (OpenAI GPT-5.6). For this page the fix was validated with gradle and two more trigger paths were run.

Prior reports, history and checks

Prior reports

No prior report found in GitHub issues and PRs, commits, the JIRA archive, the dev@, java-user@ and issues@ lists or downstream trackers (158 queries, 2026-10-26; general web search not run). A GitHub search for isTotal finds only 3 items, all read. The closest is #13706, the design discussion of the change. No upstream fix found.

Searches5 searches

No report of this bug was found in the sources searched, and no upstream change to isTotal after #13707 was found (main, branch_10x, branch_10_6 and branch_10_5 history; open PRs). Closest items, none of them this bug: #13707 (the cause), #13706 (its design discussion: an unmerged draft guarded against dead states), #16816 (open: rewrites an ALL AutomatonQuery to FieldExistsQuery, so a wrongly ALL query would still match every document with the field), #11049 (2021: the no-dead-states precondition of AutomatonQuery), #16382 (a WildcardQuery caller of isTotal), and the downstream review thread elastic/elasticsearch#158570, which mentions isTotal walking "every live state" but not the false positive.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues and PRs (apache/lucene), commits, downstream trackers, mailing lists, JIRA archive, via a scripted prior-report search2026-10-26two runs, 96 and 45 queries: Operations, CompiledAutomaton, AutomatonQuery, isTotal, getLiveStates, "dead state", "sink state", "accepts all strings", "matches all terms"; web search was not runno report; 279 distinct hits in the final run, the top ones read
Extra GitHub searches2026-10-2617 queries, e.g. isTotal is:open, "Operations.isTotal", isTotal NFA, "complete DFA", getLiveStatesFromInitial; isTotal on three downstream repositoriesonly #13706, #13707 and #16382 mention isTotal in apache/lucene; all read in full
Mailing lists (lists.apache.org) and JIRA archive2026-10-2626 direct archive queries over dev@, java-user@, issues@ and commits@; 6 JQL queriesdev@ and java-user@: no message mentions isTotal; issues@: only mirrors of the 3 items; JIRA text ~ "isTotal": 0
Open PRs and branches2026-10-26changed files of all 413 open PRs; isTotal history on main, branch_10x, branch_10_6 and branch_10_512 open PRs touch util/automaton/ or AutomatonQuery.java, none Operations.java; no branch changes isTotal after #13707
Earlier searches2026-10-22 and 2026-10-20..23the study (13 GitHub phrasings, JIRA, git log -S isTotal) and the public-list checkno report

History

Introduced by #13707 (ea3a9b89278, merged 2024-09-05), which generalised isTotal from minimal to any deterministic automaton; first released in 10.0.0. Every release from 10.0.0 to 10.5.2 and main is wrong; 9.12.0 to 9.12.3 are correct. A commit A/B and a single-hunk revert pin it.

Versions tested6 rows

#13707, "Relax Operations.isTotal() to work with a deterministic automaton" (rmuir; approved by dweiss and ChrisHegarty; merged 2024-09-05 as ea3a9b89278 on main). The 10.0 line was cut from main after it, so there is no separate backport; git tag --contains ea3a9b89278 lists exactly 10.0.0 through 10.6.0, and it never reached 9.x. The isTotal(a, min, max) text is byte-identical at ea3a9b89278, 10.0.0, 10.5.2, ea57360912ed and the branch_10_6 and branch_10x heads of 2026-10-24.

Every release row ran the same 4-test JUnit on the release's Maven Central jars (SHA-1 checked), seed DEADBEEF, -ea.

Versions and branches executed, with results
Version or commitVerdictResult
main ea57360912ed (unreleased 11.0) affected3 of 4 fail with -ea, -da and gradle; the plain program gives 3 hits, type ALL
10.0.0 to 10.5.2 (all 12 releases) affectedthe same 3 of 4 fail; 10.0.0 also with -da
9.12.0, 9.12.1, 9.12.2, 9.12.3 not affected4 of 4 pass (the old minimal-only isTotal returns false)
ea3a9b89278 (#13707) vs its parent 87bc8270ffa bisectthe parent's automaton package passes 4 of 4, #13707's fails 3 of 4; the packages differ only in the #13707 hunk
10.0.0 and main with only the #13707 hunk reverted revert check4 of 4 pass
main ea57360912ed with the fix fixthe repro passes; all 8,870 core tests pass

Independent checks

The study's verifier, the public-list rerun, the hunt's re-verification and a cross-model refute review all reproduced it. The reviewer, from another model family, wrote its own test, rated the "intended" argument moderate and returned verdict real, confidence high. This page's runs validated the fix on the whole core suite.

Checks and verdicts304 words

Each check worked from the code or its own runs. The only disagreement-shaped point is intent for the AutomatonQuery symptom, shown in full in "Why it is a bug".

Each independent check and its verdict
CheckVerdictWhat it found
Study verifier (automaton) new bugIts own 8 tests (all fail on main), a brute-force oracle over 2,100,084 DFAs, runs on 9.12.0, 10.0.0 and main, and two patches checked against 297 Lucene tests.
Public-list rerun reproduces on b83101cdd28 tests, 8 failures, as in the study.
Hunt verification realThe 4-test JUnit fails 3 of 4 on main with -ea and -da, on 10.5.2 and 10.0.0; 9.12.3 passes; the determinize path also fails; the one-line fix passes.
Cross-model refute review real, confidence highAn independent reviewer from another model family (OpenAI GPT-5.6) wrote and ran its own 3-state test: it fails on main with -ea, -da and a second seed, passes on 9.12.3 and with the fix, and 133 existing tests pass with the fix. It rated the "intended" argument moderate. The repro driver re-ran its test with the same results.
History follow-up introduced by #13707, first released in 10.0.016 releases plus main, a commit A/B and single-hunk revert checks.
Prior-report follow-up new158 queries; nothing found.
This page's own runs reproduced; fix validatedPlain program on main (-ea, -da), 10.5.2, 10.0.0 and 9.12.3; a third trigger path (AutomatonProvider); gradle stock and with the fix, including the whole core suite and seven other modules.
Fact checks of this page bug stands; wording fixedNone changed a conclusion. Their findings on wording and scope (the trigger condition, Terms.intersect, the fix's effect on narrow alphabets, the getLiveStates rationale, the builders claim, the security scope) were fixed, with one new exhaustive run; one finding was not adopted.

Sources and provenance

Finding record, Lucene and upstream sources and repro files617 words

Finding record

LFV-049, snapshot of 2026-11-06

Registry title
Operations.isTotal returns true for a deterministic automaton that can reach a dead state, so an AutomatonQuery can match every term
Area
util / automaton
Priority
Low, rank 531: Top of Low (moved down from Medium): the 10.0 isTotal regression makes an AutomatonQuery match every term for a DFA with a reachable dead state, and Elasticsearch uses isTotal in authorization checks, but every Lucene automaton builder strips dead states and no downstream path that keeps one is known, so the over-match needs a hand-built automaton.
Impact class
silent wrong results
Impact note
Code that hands Lucene a hand-built deterministic automaton with a reachable dead state (Automaton/Automaton.Builder, or determinize of such an NFA), and downstream code that uses isTotal as a match-all decision; Lucene's own RegExp, wildcard, prefix and Levenshtein automata never contain one; any scale; regression released in 10.0.0-10.6.0 (10.6.0 by reading).
Summary
You call Operations.isTotal, or build an AutomatonQuery, on a deterministic automaton that rejects some strings and has a reachable dead state, such as a complete DFA with an explicit sink state built by hand. You expect false, as Lucene 9.12.0 returns. Instead, since 10.0.0, isTotal checks only live states, so a transition into the dead state counts as coverage and it returns true; CompiledAutomaton then compiles the query to AUTOMATON_TYPE.ALL, and in the repro an AutomatonQuery matched 3 documents instead of 2.
Introduced
#13707 (ea3a9b89278) (2024-09-05)
First affected version
10.0.0 (regression; 9.12.0 correct)
Affected versions
10.0.0-10.5.2, branch_10_6 and main [exec on 9.12.0, 10.0.0, main]
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed in the earlier formal verification study by independent AI checks and a rerun on Lucene main; also a cross-model review that agreed; not yet triaged by a person.
Found by
semi-formal: exhaustive check
Upstream and prior reports
none found (study searches 2026-10-22; public-list check 2026-10-20..23)
Lucene commit tested
main ea57360912ed (commit 2026-10-24; tested 2026-10-26): the plain program and the JUnit repro through the repro runner with -ea and -da, and through gradle in a scratch worktree, stock and with the fix (the worktree was removed after the runs). Also the Maven Central jars 9.12.0-9.12.3 and every release from 10.0.0 to 10.5.2 (SHA-1 checked), and the introducing commit and its parent (A/B). The study reproduced it on 7cecf79523 and the public-list rerun on b83101cdd2; Operations.java has the same blob at 7cecf79523 and ea57360912ed.

Lucene and upstream sources

Finding record
  • SECURITY.md:28, 50 (threat model)
The finding in 30 seconds
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/automaton/Operations.java:837-879, 922-957
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/automaton/CompiledAutomaton.java:36-61, 179-199, 344-353
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/AutomatonQuery.java:29-41, 59-99
  • a Lucene clone releases/lucene/9.12.0 lucene/core/src/java/org/apache/lucene/util/automaton/Operations.java:864-879
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/automaton/Automaton.java:38-41 (transitions sorted when a state is finished)
Who is affected
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/automaton/Operations.java:56, 350-356, 793-795, 1308-1345
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/automaton/RegExp.java:711-727; Automata.java:564, 589, 609; Automaton.java:43
  • elastic/elasticsearch main, read-only GitHub API 2026-10-26: x-pack/plugin/core/src/main/java/org/elasticsearch/xpack/core/security/authz/privilege/Privilege.java:49; .../authz/permission/FieldPermissions.java:110-112, 227, 244-253 (last change d45ce17cdd3, 2026-09-11); code search for Operations.isTotal
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/automaton/Operations.java:148, 155, 204, 209, 351-356, 381, 393-400 (optional, repeat, complement, minus, intersection)
Why it looks unintended
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/automaton/Operations.java:838-861, 444-460, 1003-1007, 1127-1134
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/AutomatonQuery.java:32, 64, 75, 88, 99
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/automaton/CompiledAutomaton.java:36-41, 55-56, 181
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/AutomatonTermsEnum.java:297
  • Lucene main ea57360912ed: lucene/core/src/test/org/apache/lucene/util/automaton/TestOperations.java:182-220; lucene/CHANGES.txt:2332 (10.0.0 section from line 2139)
  • a Lucene clone releases/lucene/9.12.0 CompiledAutomaton.java:176
  • a Lucene clone releases/lucene/9.12.0 Operations.java:863, 870 ("must be minimized")
Suggested fix
  • Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/automaton/Operations.java:857, 936-940, 959-1001

Other statements trace to the hunt's working files (models, logs, reviews and briefs), which are not published.

Repro files

Built 2026-11-06 from the hunt's record of this finding and its doc file. Lucene main under test in this hunt: ea57360912ed. Every listed finding was re-run on Lucene main e1faeba08bde on 2026-11-02.