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.
- Build a two-state DFA by hand: state 0 accepts and loops on
aand above; any character belowa, such as0, goes to a sink state that never accepts. It acceptsbandzzand rejects0. - Call
Operations.isTotal(a), or index the terms0,bandzzand countnew AutomatonQuery(term, a). You expectfalseand 2 hits, which is what Lucene 9.12.3 gives. - Since 10.0.0 you get
true. The query compiles toAUTOMATON_TYPE.ALLand matches all 3 documents, including0. No error. - The cause:
isTotalchecks 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. - The same language without the sink (
removeDeadStates) givesfalseand 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 anAutomatonProvider).
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.
- Deterministic automaton (DFA): from every state, each character leads to at most one next state. Lucene's
Automatonnumbers 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).
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.
isTotal(a)(844-846) callsisTotal(a, Character.MIN_CODE_POINT, Character.MAX_CODE_POINT)(856-879).- 857:
BitSet states = getLiveStates(a);is {state 0}: the sink cannot reach an accept state, so it is not live. - 861-862, under the comment "all reachable states must be accept states": state 0 is accepting.
- 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. - 878:
return seenStates > 0;gives true. The sink, which is reachable and not accepting, was never checked.
AutomatonQueryalways buildsnew CompiledAutomaton(automaton, false, true, isBinary)(AutomatonQuery.java:99).CompiledAutomatoncallsOperations.isTotal(automaton), orisTotal(automaton, 0, 0xff)for a binary automaton, and ontruesetsAUTOMATON_TYPE.ALL, "Automaton that accepts all possible strings" (CompiledAutomaton.java:55-56, 179-199).- For
ALL,getTermsEnumreturnsterms.iterator(): every term of the field (CompiledAutomaton.java:352-353).Terms.intersecton that compiled automaton throwsIllegalArgumentException("please use CompiledAutomaton.getTermsEnum instead")instead (Terms.java:79-80, executed).
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.
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 withAutomaton.Builderand passed throughOperations.determinize, which keeps its dead state (determinizereturns its result withoutremoveDeadStates,Operations.java:793-795):isTotalistruealthough0is rejected. ARegexpQuerywith anAutomatonProviderthat returns the repro's automaton for the named automaton<lower>(returned unchanged,RegExp.java:711-727): 3 hits instead of 2, compiled typeALL. - 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) andLevenshteinAutomata(120,000). SeveralAutomata.make*builders assert there are none (Automata.java:564, 589, 609), and Operations' concatenate, union, intersection, complement, repeat and reverse callremoveDeadStateson the automata they build. That is not a guarantee for every path:optionaldocuments "This may create a dead state" (Operations.java:148),determinizereturns its result withoutremoveDeadStates(793-795), andoptional,repeat,intersectionandminuscan 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.experimentalautomaton API. - Scale. None: two states, three documents.
- Silent and fail-open. No exception. This bug's wrong answer is always
true("accepts everything"): throughCompiledAutomatonit becomesALLand anAutomatonQueryover-matches, and a caller's match-all shortcut fires.Terms.intersecton that compiled automaton throwsIllegalArgumentExceptioninstead (Terms.java:79-80, executed);SortedDocValues.intersectandSortedSetDocValues.intersectreturn every value forALL(read). - Downstream decisions (read in Elasticsearch main on 2026-10-26, not executed).
Privilege.grantsAllisOperations.isTotal(automaton)(Privilege.java:49).FieldPermissionscachesisTotal(permittedFieldsAutomaton)(FieldPermissions.java:110-112); when it is true, every field is granted (227),hasFieldLevelSecurity()is false (244-246) andfilter()returns the reader unfiltered (249-253). The wildcard field mapper and the query languages'AbstractStringPatternuse 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.totalizebuilds exactly that shape ("added transitions to a dead state",Operations.java:1308-1345). It does not leak out:complementthen flips every state's acceptance (351-354), so that sink becomes an accepting state of the complement, which the finalremoveDeadStates(356) keeps.
The test shows that Operations.isTotal returns true for a deterministic automaton with a reachable dead state, although the automaton rejects "0".
/*
* 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));
}
}
# 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>).
More files, environment, full output40 words
| lucene | Lucene main e1faeba08bde (2026-11-02) |
|---|---|
| jdk | 25.0.3+9-LTS |
# 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)- 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,
isTotalisfalse. 9.12.3 saysfalsefor 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.ALLis 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'sreturn a.getNumStates() > 0;, and the merged code returnsseenStates > 0. In the motivating issue #13706 an earlier draft by rmuir documented "The automaton must be deterministic with no transitions to dead states" and returnedfalsewhenhasDeadStatesFromInitial(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,hasDeadStatesFromInitialandremoveDeadStatesare public (Operations.java:444-460, 1003-1007). Where a method cannot handle them it says so:getCommonPrefixdocuments and throwsIllegalArgumentException"if the automaton has dead states reachable from the initial state" (1127-1134).isTotaldocuments nothing of the kind. - Same language, two answers.
aandremoveDeadStates(a)accept the same strings, yetisTotalreturnstrueandfalse. 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.
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 forisTotal(a)on code-point labels and forCompiledAutomaton'sisTotal(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 conservativefalsefor a total language, from a dead state that can be reached only through a label outside[min, max]; no answer goes fromfalsetotrue(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
NORMALinstead ofALL: 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 newTestOperationstest); with the fix 406 tests, 0 failed (also seed1234ABCDwith assertions off). - The whole
:lucene:core:test: 8,870 tests in 748 classes, 0 failures, 322 skipped.queryparser,highlighter,monitor,queries,memory,suggestandanalysis:common: 4,144 tests, all pass.checkGoogleJavaFormatpasses 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.
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.
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.
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).
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.isTotalreturnedtrueon non-total DFAs, and every such case had a dead state (finding F2a). Dafny proved a correctedisTotalsound 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
AutomatonQuerytest, 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.
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.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues and PRs (apache/lucene), commits, downstream trackers, mailing lists, JIRA archive, via a scripted prior-report search | 2026-10-26 | two runs, 96 and 45 queries: Operations, CompiledAutomaton, AutomatonQuery, isTotal, getLiveStates, "dead state", "sink state", "accepts all strings", "matches all terms"; web search was not run | no report; 279 distinct hits in the final run, the top ones read |
| Extra GitHub searches | 2026-10-26 | 17 queries, e.g. isTotal is:open, "Operations.isTotal", isTotal NFA, "complete DFA", getLiveStatesFromInitial; isTotal on three downstream repositories | only #13706, #13707 and #16382 mention isTotal in apache/lucene; all read in full |
| Mailing lists (lists.apache.org) and JIRA archive | 2026-10-26 | 26 direct archive queries over dev@, java-user@, issues@ and commits@; 6 JQL queries | dev@ and java-user@: no message mentions isTotal; issues@: only mirrors of the 3 items; JIRA text ~ "isTotal": 0 |
| Open PRs and branches | 2026-10-26 | changed files of all 413 open PRs; isTotal history on main, branch_10x, branch_10_6 and branch_10_5 | 12 open PRs touch util/automaton/ or AutomatonQuery.java, none Operations.java; no branch changes isTotal after #13707 |
| Earlier searches | 2026-10-22 and 2026-10-20..23 | the study (13 GitHub phrasings, JIRA, git log -S isTotal) and the public-list check | no report |
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.
| Version or commit | Verdict | Result |
|---|---|---|
main ea57360912ed (unreleased 11.0) | affected | 3 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) | affected | the same 3 of 4 fail; 10.0.0 also with -da |
| 9.12.0, 9.12.1, 9.12.2, 9.12.3 | not affected | 4 of 4 pass (the old minimal-only isTotal returns false) |
ea3a9b89278 (#13707) vs its parent 87bc8270ffa | bisect | the 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 check | 4 of 4 pass |
main ea57360912ed with the fix | fix | the repro passes; all 8,870 core tests pass |
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".
| Check | Verdict | What it found |
|---|---|---|
| Study verifier (automaton) | new bug | Its 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 b83101cdd2 | 8 tests, 8 failures, as in the study. |
| Hunt verification | real | The 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 high | An 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.0 | 16 releases plus main, a commit A/B and single-hunk revert checks. |
| Prior-report follow-up | new | 158 queries; nothing found. |
| This page's own runs | reproduced; fix validated | Plain 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 fixed | None 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. |
Finding record, Lucene and upstream sources and repro files617 words
- 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-eaand-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 on7cecf79523and the public-list rerun onb83101cdd2;Operations.javahas the same blob at7cecf79523andea57360912ed.
- 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-957Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/automaton/CompiledAutomaton.java:36-61, 179-199, 344-353Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/AutomatonQuery.java:29-41, 59-99a Lucene clone releases/lucene/9.12.0 lucene/core/src/java/org/apache/lucene/util/automaton/Operations.java:864-879Lucene 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-1345Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/automaton/RegExp.java:711-727; Automata.java:564, 589, 609; Automaton.java:43elastic/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.isTotalLucene 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-1134Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/search/AutomatonQuery.java:32, 64, 75, 88, 99Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/util/automaton/CompiledAutomaton.java:36-41, 55-56, 181Lucene main ea57360912ed: lucene/core/src/java/org/apache/lucene/index/AutomatonTermsEnum.java:297Lucene 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:176a 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
- TestIsTotalDeadState.java (JUnit test):
repro/LFV-049/src/org/apache/lucene/util/automaton/TestIsTotalDeadState.java(35 lines)