All findings · Lucene finding · LFV-288 · uh-builder-withflags-ignored

UnifiedHighlighter.Builder#withFlags is silently ignored: the highlighter always takes its flags from the four boolean builder options

The highlighter never uses the set given to UnifiedHighlighter.Builder#withFlags: it always uses the flags of the four boolean builder options, by default all four. Ask for {MULTI_TERM_QUERY} and a phrase query on "alpha beta alpha gamma" still gives the default <b>alpha beta</b> alpha gamma instead of <b>alpha</b> <b>beta</b> <b>alpha</b> gamma.

Summary

Priority
Low, rank 874Builder#withFlags is silently ignored for every caller, but no caller is known and the boolean options work around it.
Impact
minor
Since
9.1.0 (#412, 2021)introduced 2021-12-19 · about 4 years
Affected
9.1.0 to 10.6.0, and main
Status
documented
Found by
non-formal: code reading, then a public-API JUnit

Upstream status: Fixed, by Christoph Büscher (PR #16983, approved by Luca Cavanna; an independent fix, later than our listing). Fixed on main by #16983 (merged 2026-11-04, milestone 11.0.0); the PR does not cite this list. Not backported to branch_10_6 as of 2026-11-04. Novelty stays new (owner decision 2026-11-04: listed publicly 2026-10-27, the fix came later and independently). Source

AI-assisted page: this finding was first signalled by code reading or testing and then checked with AI tools; the checks it went through, and what each found, are under Prior reports, history and checks. It has been fixed upstream (see Upstream status). Snapshot of 2026-11-06.

The finding in 30 seconds

The builder keeps your withFlags set, but the highlighter's constructor never reads it: it checks its own flags field, still null at that point, and falls back to the four boolean options.

UnifiedHighlighter flags

  1. UnifiedHighlighter marks query matches in stored text. Four flags steer how: PHRASES (match phrase queries strictly), MULTI_TERM_QUERY (highlight wildcard, prefix, fuzzy and similar queries), PASSAGE_RELEVANCY_OVER_SPEED, and WEIGHT_MATCHES (use the Weight.matches API, which marks a whole phrase as one span).
  2. UnifiedHighlighter.Builder sets them in one of two ways: four boolean options, all true by default, or one call, withFlags(Set<HighlightFlag>), whose set "will override" the four.
  3. Without PHRASES, the terms of a phrase query are marked wherever they occur; with the defaults the phrase is marked as one match: <b>alpha beta</b>.
Summary card of the bug. Part 1, what the user does: the minimal repro (builderWithoutSearcher, withFlags(EnumSet.of(MULTI_TERM_QUERY)), no PHRASES, PhraseQuery "alpha" "beta", highlightWithoutSearcher on "alpha beta alpha gamma"); expected <b>alpha</b> <b>beta</b> <b>alpha</b> gamma, actual <b>alpha beta</b> alpha gamma, the defaults' strict phrase match; every set given to withFlags is ignored, even {}. Part 2, why: withFlags stores the set in the builder's flags (line 310), which only Builder#getFlags() reads, so {MULTI_TERM_QUERY} is never read; evaluateFlags(Builder) checks the unqualified flags (line 540), the highlighter's own field (line 131), still null while it is built; so it falls through to the four boolean options (lines 543-548), all true, giving PHRASES, MULTI_TERM_QUERY, PASSAGE_RELEVANCY_OVER_SPEED and WEIGHT_MATCHES. Fix: return the builder's set when it is not null; until then the four boolean options are a working workaround.
The builder holds your withFlags set, but the constructor's check reads the highlighter's own flags field, still null, so the four boolean defaults are used and the phrase is marked as <b>alpha beta</b>. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You build a highlighter with UnifiedHighlighter.builderWithoutSearcher(analyzer).withFlags(EnumSet.of(MULTI_TERM_QUERY)). The javadoc says this set "will override" the four boolean builder options.
  2. You highlight PhraseQuery("body", "alpha", "beta") in the text "alpha beta alpha gamma".
  3. You expect the flags you asked for. Without PHRASES every occurrence of each term is marked: <b>alpha</b> <b>beta</b> <b>alpha</b> gamma. The same flags chosen through the boolean options give exactly that.
  4. You get <b>alpha beta</b> alpha gamma, the output of a default highlighter. The constructor checks the highlighter's own flags field, still null at that moment, instead of the builder's, so your set is never read and all four default flags apply.
  5. No error, and search hits do not change. Every set is ignored, even the empty one, in every release since 9.1.0. The four boolean options work and are the workaround.

Who is affected

Only applications that call the opt-in Builder#withFlags; the default builder never calls it, and Solr, Elasticsearch and OpenSearch set flags another way. They silently get the default flags: different snippet markup, and costly multi-term highlighting cannot be turned off this way. Hits, scores and the index are unaffected; the boolean options work.

Mechanism, terms and numbers476 words

UnifiedHighlighter.Builder has two ways to choose the highlight flags: four boolean options (withHandleMultiTermQuery, withHighlightPhrasesStrictly, withPassageRelevancyOverSpeed, withWeightMatches), or withFlags(Set<HighlightFlag>), whose javadoc says the set "will override" the four. The second way does nothing. withFlags stores the set in Builder.flags, but the constructor's evaluateFlags(Builder uhBuilder) checks flags, which there means the highlighter's own field, still null while the constructor runs, and then derives the flags from the four booleans; Builder.flags is never read. With withFlags(EnumSet.of(MULTI_TERM_QUERY)), PhraseQuery("body", "alpha", "beta") on "alpha beta alpha gamma" gives the default strict markup <b>alpha beta</b> alpha gamma instead of <b>alpha</b> <b>beta</b> <b>alpha</b> gamma. Nothing throws, and hits, scores and the index are unaffected; only the highlighting configuration is wrong. It shipped with the Builder in 9.1.0 (#412), affects every release up to 10.6.0 and main, and the four boolean options are a working workaround. The fix makes evaluateFlags(Builder) use the builder's set.

The bug (main 645d4fe14a20, UnifiedHighlighter.java)

private Set<HighlightFlag> flags;                                   // :131  the highlighter's own field
  ...
public static class Builder {
  private Set<HighlightFlag> flags;                                 // :270  Builder.flags
  public Builder withFlags(Set<HighlightFlag> values) {             // :309
    this.flags = values;                                            // :310
    return this;
  }
}
public UnifiedHighlighter(Builder builder) {
  ...
  this.flags = evaluateFlags(builder);                              // :468
protected Set<HighlightFlag> evaluateFlags(Builder uhBuilder) {     // :539
  if (flags != null) {                                              // :540  this.flags, still null here
    return flags;
  }
  return flags = evaluateFlags(uhBuilder.handleMultiTermQuery, uhBuilder.highlightPhrasesStrictly,
                               uhBuilder.passageRelevancyOverSpeed, uhBuilder.weightMatches);   // :543-548
}
  1. The set is stored. withFlags writes Builder.flags (:309-312). Only Builder#getFlags() (:430-432) ever reads it.
  2. The check reads the wrong object. Inside evaluateFlags(Builder uhBuilder) the unqualified flags is the highlighter's private field (:131), not uhBuilder.flags. The field has no initializer and is written only at :468 and, inside this method, at :543, so when the constructor calls the method the check is always false.
  3. The booleans always win. evaluateFlags(boolean, boolean, boolean, boolean) (:502-530) builds a fresh EnumSet from the four options and adds WEIGHT_MATCHES only when the other three are set (:519-528). The defaults (:108-111, all true) give all four flags.
  4. The flags drive the highlighting. getFlags(String) returns the stored set (:586-594). WEIGHT_MATCHES selects the Weight.matches path, which marks the whole phrase (:1221-1224); PHRASES without it builds a strict PhraseHelper (:1225-1235); MULTI_TERM_QUERY decides whether multi-term queries are highlighted at all (:1248-1250); two flags choose the offset strategy for re-analysis (:1294-1299).

The numbers

PhraseQuery("body", "alpha", "beta") on "alpha beta alpha gamma", highlightWithoutSearcher, StandardAnalyzer: withFlags({MULTI_TERM_QUERY}) gives <b>alpha beta</b> alpha gamma, the same as a default builder (all four flags). The same {MULTI_TERM_QUERY} chosen through withHighlightPhrasesStrictly(false) and withPassageRelevancyOverSpeed(false) gives <b>alpha</b> <b>beta</b> <b>alpha</b> gamma. withFlags with the empty set still highlights a PrefixQuery (<b>Lucene</b>), while all four booleans false does not.

Exposure and likelihood310 words
  • What goes wrong. The highlighter runs with the flags of the four booleans (by default all four) instead of the requested set: strict instead of loose phrases, whole-phrase instead of per-word markup, multi-term queries highlighted although left out, and possibly a different passage ranking and offset strategy. Nothing throws.
  • What it does not touch. Search hits, scores and the index; which documents and fields are highlighted; withFieldMatcher, masked fields and withMaxLength, which the constructor copies separately (:469-472). On stock main, a sweep of 6,528 highlights over every withFlags argument, boolean combination, three offset sources and eight query types saw only flag sets the booleans can produce, and no text from another field.
  • A cost you cannot switch off this way. "MTQ highlighting can be expensive, particularly when using offsets in postings" (withHandleMultiTermQuery javadoc, :325-327). A caller who leaves MULTI_TERM_QUERY out of its withFlags set still pays for it, as with the default configuration.
  • Who is exposed. Applications that call Builder#withFlags (opt-in; no production code in Lucene does). Read-only code search found no caller outside copies of Lucene's own files: Solr overrides getFlags(String), and Elasticsearch and OpenSearch subclass the highlighter and edit or override the getFlags result. Code search does not cover private code.
  • Scale. None needed: every stock UnifiedHighlighter built with withFlags is affected, deterministically, on the first call. A subclass that overrides evaluateFlags(Builder) sets its own flags, as Lucene's test helper overriddenBuilderForTests does (TestUnifiedHighlighter.java:139-157).
  • Workaround. The four boolean options can express 9 of the 16 flag sets, every set that meets the documented WEIGHT_MATCHES requirement (:344-346); a getFlags(String) override can express any set.
  • Lucene's own tests. TestUnifiedHighlighter#overrideFieldMatcherForTests copies a highlighter with withFlags(original.getFlags(fieldName)) (TestUnifiedHighlighter.java:133), so the three testFieldMatcher* tests run their copies with the default flags instead of the randomized ones. No test checks what withFlags does.

Minimal repro

One JUnit class, one method, public API only: a highlighter built with withFlags({MULTI_TERM_QUERY}) and no index or searcher highlights a phrase query in a four-word text. The test body uses no random input; it fails with assertions on and off.

TestUnifiedHighlighterBuilderWithFlags.java (one test class)38 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.search.uhighlight;

import java.util.EnumSet;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.search.PhraseQuery;
import org.apache.lucene.search.uhighlight.UnifiedHighlighter.HighlightFlag;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestUnifiedHighlighterBuilderWithFlags extends LuceneTestCase {

  public void testWithFlagsIsHonoured() throws Exception {
    UnifiedHighlighter uh =
        UnifiedHighlighter.builderWithoutSearcher(new StandardAnalyzer())
            .withFlags(EnumSet.of(HighlightFlag.MULTI_TERM_QUERY))
            .build();
    PhraseQuery query = new PhraseQuery("body", "alpha", "beta");
    // expected: no PHRASES flag, so every query term is highlighted; actual: strict phrase match
    assertEquals(
        "<b>alpha</b> <b>beta</b> <b>alpha</b> gamma",
        uh.highlightWithoutSearcher("body", query, "alpha beta alpha gamma", 1));
  }
}
Command
cd $LUCENE && ./gradlew :lucene:highlighter:test --tests org.apache.lucene.search.uhighlight.TestUnifiedHighlighterBuilderWithFlags -Ptests.seed=DEADBEEF
Expected
<b>alpha</b> <b>beta</b> <b>alpha</b> gamma: without PHRASES, every occurrence of each term is marked.
Observed
<b>alpha beta</b> alpha gamma: the default flags' strict phrase markup, because the withFlags set is never read.

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

More files, environment, full output255 words

The class sits in org.apache.lucene.search.uhighlight, next to TestUnifiedHighlighter, and uses only builderWithoutSearcher and highlightWithoutSearcher with a StandardAnalyzer: no Directory, no IndexWriter, and no random input in the test body (LuceneTestCase still seeds its usual randomization). The expected string is what the same flag set gives when chosen through the boolean options on stock main (a separate control run), and what the fixed highlighter gives. It compiles on 9.1.0 and later.

Regression test: this same test, as written. With the recommended fix it passes, together with the highlighter module's format, ECJ and forbidden-apis checks.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails; 10.6.0, 10.5.2, 10.0.0, 9.12.3, 9.1.0 (assertions on): fail; with the fix: passes
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64 (the only platform run; plain Java logic)
Command (environment)
cp $REPRO/TestUnifiedHighlighterBuilderWithFlags.java $LUCENE/lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/
cd $LUCENE && ./gradlew :lucene:highlighter:test --tests org.apache.lucene.search.uhighlight.TestUnifiedHighlighterBuilderWithFlags -Ptests.seed=DEADBEEF
# assertions off fails the same way: add -Ptests.asserts=false (the recorded runs added --offline --no-daemon)

Observed output

# gradle, scratch worktree of 645d4fe14a20, seed DEADBEEF, assertions on
TestUnifiedHighlighterBuilderWithFlags > testWithFlagsIsHonoured FAILED
    org.junit.ComparisonFailure: expected:<<b>alpha[</b> <b>beta</b> <b>alpha</b>] gamma> but was:<<b>alpha[ beta</b> alpha] gamma>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:BCCD93156E411B7E]:0)
        at app/junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:117)
        at app/junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:146)
        at app//org.apache.lucene.search.uhighlight.TestUnifiedHighlighterBuilderWithFlags.testWithFlagsIsHonoured(TestUnifiedHighlighterBuilderWithFlags.java:34)

# standalone runner, seed DEADBEEF: the same message on main (assertions on and off) and, with assertions on, on 10.6.0, 10.5.2, 10.0.0, 9.12.3 and 9.1.0

# control on stock main (not the repro): the same flags through the boolean options
withHighlightPhrasesStrictly(false).withPassageRelevancyOverSpeed(false): getFlags = [MULTI_TERM_QUERY], '<b>alpha</b> <b>beta</b> <b>alpha</b> gamma'
default builder:                                                         getFlags = [PHRASES, MULTI_TERM_QUERY, PASSAGE_RELEVANCY_OVER_SPEED, WEIGHT_MATCHES], '<b>alpha beta</b> alpha gamma'

Why it looks unintended

  • The withFlags javadoc says its set "will override" the four boolean options, with no precondition.
  • The check that should read the builder's set is always false in the constructor; in the introducing PR it sat in the Builder and worked until a refactor moved it.
  • Lucene's own test helper copies a highlighter with withFlags(original.getFlags(field)), which needs withFlags to work.
The full argument231 words
  • The contract. "User-defined set of HighlightFlag values which will override the flags set by withHandleMultiTermQuery(boolean), withHighlightPhrasesStrictly(boolean), withPassageRelevancyOverSpeed(boolean) and withWeightMatches(boolean). Here the user can either specify the set of HighlightFlags to be applied or use the boolean flags to populate final list of HighlightFlags." (:299-308). No javadoc, CHANGES or MIGRATE text qualifies it.
  • The code shows the intent. The guard if (flags != null) return flags; (:540) is always false when the constructor calls the method; it is consistent with an intended check of the builder's set. Builder#getFlags() exposes the stored set (:430-432).
  • History. In #412, at revision d65ee8ab42 (2021-12-05), evaluateFlags() was a Builder method, so its check read Builder.flags: by its source, withFlags would have taken effect (revisions read, not built). Revision a6f70412c9 (2021-12-18, "Attempt at making UH with builder back-portable to v9.x") moved it into UnifiedHighlighter as evaluateFlags(Builder uhBuilder) with the check unchanged, so flags now meant the highlighter's own field. No version ever read uhBuilder.flags there (git log -G).
  • The review. The PR author summarised a revision on 2021-10-30 as "if flags is initialized then boolean flags are ignored". On 2021-12-03 a reviewer (dsmiley) asked "Isn't empty valid?" about a check that ignored an empty set, so even an empty set was meant to count.
  • Lucene's own test helper relies on it (TestUnifiedHighlighter.java:133).
The devil's-advocate check218 words

The strongest case for "intended" (the cross-model reviews, the intended-behaviour check and the adjudication built it): the class is @lucene.experimental (:94); on #412 a reviewer wrote "I'm dubious we even want this on the builder since it's more of an advanced case to customize these flags. Someone can always subclass the getter to customize" (2021-10-30, about a revision that had added a setFlags setter); and the back-port plan on #11234 said "the flags computation needs to be as it is already on 9x instead of moved off to the builder". So the booleans would be the real API and withFlags vestigial.

Why it fails: withFlags and its "will override" javadoc were kept and shipped in every release since 9.1.0, and an experimental tag lets an API change, not a public method do nothing; the back-port note is about where the flags are computed, and, by its source, the revision before the move read the set; the same reviewer later asked "Isn't empty valid?"; no won't-fix or by-design statement was found in the recorded searches. The cross-model reviews (OpenAI GPT-5.6) rated the case weak. Removing withFlags is a legitimate design choice for maintainers who want one way to set flags, but it is still a change, because the method has never worked as its javadoc says.

Suggested fix

Recommended Honour withFlags with a private copy of the set (recommended, validated)

In evaluateFlags(Builder), return a private copy of uhBuilder.flags when it is set (+6 lines). Validated: the repro passes with assertions on and off, the whole highlighter module (723 tests) passes, testFieldMatcher* 50 times over, and the format, ECJ and forbidden-apis checks pass.

protected Set<HighlightFlag> evaluateFlags(Builder uhBuilder) {
  if (flags != null) {
    return flags;
  }
  if (uhBuilder.flags != null) {
    // Builder#withFlags overrides the boolean options; keep a private, mutable copy
    Set<HighlightFlag> copy = EnumSet.noneOf(HighlightFlag.class);
    copy.addAll(uhBuilder.flags);
    return flags = copy;
  }
  return flags = evaluateFlags(uhBuilder.handleMultiTermQuery, uhBuilder.highlightPhrasesStrictly,
                               uhBuilder.passageRelevancyOverSpeed, uhBuilder.weightMatches);
}
All options and trade-offs4 options · 593 words

Two fixes were validated through gradle in a scratch worktree of 645d4fe14a20; the recommended one also keeps the highlighter's set private. Two further options were not built.

Honour withFlags with a private copy of the set (recommended, validated)

When the builder has a set, use a fresh EnumSet copy of it; otherwise derive the flags from the four booleans as today.

protected Set<HighlightFlag> evaluateFlags(Builder uhBuilder) {
  if (flags != null) {
    return flags;
  }
  if (uhBuilder.flags != null) {
    // Builder#withFlags overrides the boolean options; keep a private, mutable copy
    Set<HighlightFlag> copy = EnumSet.noneOf(HighlightFlag.class);
    copy.addAll(uhBuilder.flags);
    return flags = copy;
  }
  return flags = evaluateFlags(uhBuilder.handleMultiTermQuery, uhBuilder.highlightPhrasesStrictly,
                               uhBuilder.passageRelevancyOverSpeed, uhBuilder.weightMatches);
}

Trade-offs

  • Why the copy. Today the highlighter's set is always a fresh EnumSet it owns, and downstream subclasses edit it (Elasticsearch calls getFlags(field).remove(HighlightFlag.WEIGHT_MATCHES) in its constructor, by code reading). The copy keeps that true for withFlags callers, and it stops later changes to the caller's set from reaching the highlighter (both executed in the fix-design probe).
  • EnumSet.noneOf plus addAll also accepts an empty set, which EnumSet.copyOf(Collection) rejects for a non-EnumSet argument.
  • Cost: one small set copy per highlighter construction; nothing per highlight.
  • Open point: the fix takes any explicit set as given, including WEIGHT_MATCHES without its documented prerequisites (:344-346); whether such sets should be taken as given, normalized or rejected is a question for the maintainers (option C).

What the checks validated

  • Gradle, scratch worktree of 645d4fe14a20, OpenJDK 25.0.3, seed DEADBEEF (2026-10-31). Stock: the repro fails. With the fix: the repro and TestUnifiedHighlighter (156 tests) pass with assertions on and off.
  • :lucene:highlighter:test, the whole module with the repro in place: 35 classes, 723 tests, 0 failures, 0 skipped.
  • The three testFieldMatcher* tests, whose copied highlighters now keep their randomized flags, with -Ptests.iters=50: 600 tests, 0 failures.
  • checkGoogleJavaFormat, ecjLintMain, ecjLintTest (with the repro in place), forbiddenApisMain, forbiddenApisTest: pass.
  • A fix-design probe (not the repro) passes 2 of 2: an immutable caller set with a subclass that edits its flags, and a caller that changes its own set after build().

Honour withFlags as is (smallest, validated)

Add if (uhBuilder.flags != null) { return flags = uhBuilder.flags; } after the existing check (+3 lines; the finder's and the independent reproduction's fix).

Trade-offs

The highlighter shares the caller's set. Executed with a fix-design probe: after withFlags(Set.of(MULTI_TERM_QUERY)), a subclass that calls getFlags(f).remove(WEIGHT_MATCHES) in its constructor throws UnsupportedOperationException, and adding PHRASES to the caller's mutable set after build() changes the highlighter's flags to [PHRASES, MULTI_TERM_QUERY].

What the checks validated

The repro and TestUnifiedHighlighter (156 tests) pass through gradle on 645d4fe14a20; earlier, every hunt repro passed with it, and a run of 476 highlighter tests changed only that repro's result.

Also enforce the WEIGHT_MATCHES requirement (not built)

Drop WEIGHT_MATCHES, or throw IllegalArgumentException, when the set lacks one of the other three flags, as the boolean path does (:519-528).

Trade-offs

Not needed for this bug, and inconsistent with the other way to set flags: a getFlags(String) override can return any set, and Lucene's own randomized tests already run sets such as {WEIGHT_MATCHES, MULTI_TERM_QUERY, PHRASES} without PASSAGE_RELEVANCY_OVER_SPEED (TestUnifiedHighlighter.java:82-95).

Deprecate and remove withFlags (not built)

Document a getFlags(String) override as the way to pass an arbitrary set, rewrite the test helper to copy the booleans, and remove withFlags, for example with the 11.0 deprecation clean-up tracked in #16918.

Trade-offs

A legitimate design choice if maintainers want one way to set flags. Removes a documented option; until removal the method still does nothing.

Downstream note

Solr and OpenSearch choose highlight flags by overriding getFlags(String), and Elasticsearch edits the getFlags(field) result in its constructor; none of them calls withFlags, so by code reading they are not affected.

How it was found

Code reading. While modelling how the highlighter chooses its offset strategy, for which the flag set is an input, the finder saw that the constructor never reads the builder's set, and confirmed it with a JUnit on the stock jars. The unit's model then had to set flags through the boolean options.

The method in full135 words

Method: non-formal (code reading, then JUnit tests).

  • A hunt unit on highlighter offset sources was writing a bounded-exhaustive model of getOptimizedOffsetSource and getOffsetStrategy, where the flag set is an input. Reading UnifiedHighlighter, the finder saw that evaluateFlags(Builder) tests the highlighter's own field. A JUnit on the stock main jars confirmed it (fails with assertions on and off, and on 10.5.2 and 9.12.3). No model or tool output produced the first signal; the model then sets its flag sets through the boolean options because withFlags has no effect.
  • An independent reproducer, a devil's-advocate verifier (who traced the PR history), cross-model reviews (OpenAI GPT-5.6) and an adjudication checked it; a follow-up ran it from 9.0.0 to main, searched for prior reports and screened it for security; the repro was then minimized to one public-API test.

Prior reports, history and checks

Prior reports

No prior report found in 215 recorded searches (2026-10-26: GitHub, commits, the mailing lists, JIRA, downstream projects and the web; GitHub rechecked 2026-10-31). Closest: the introducing PR #412 and its issue #11234, which never noticed the defect; #16918 plans 11.0 removals in the same class.

Searches2 searches

Verdict new: no issue, PR, commit, list thread, JIRA entry or web page describes withFlags being ignored or evaluateFlags(Builder) not reading the builder's set. Related, not reports: #412 and #11234 (LUCENE-10197, the introducing change; they discuss where the flags are computed); #16918 (open, 2026-10-27, "Deprecations slated for removal in 11.0"), which lists the deprecated UnifiedHighlighter(IndexSearcher, Analyzer) constructor, its setters and evaluateFlags for removal (the only deprecated overload is evaluateFlags(UnifiedHighlighter), :559-566) and does not mention withFlags; #12431, a different highlighter bug whose workaround overrides getFlags(String). Open PRs #12295 and #13165 touch other lines of the file. Upstream main 2f424fd428da (2026-10-30) still has the code.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs, code and commits (apache/lucene and downstream), the open PRs' changed files, dev@, java-user@, issues@, commits@, builds@, JIRA, web2026-10-26215 recorded queries (90 issue/PR, 18 code, 9 commit, 6 downstream, 60 mailing-list, 13 JIRA, 19 web); #412 read in full (12 comments, 56 review comments, 30 reviews), #11234 (16 comments), all 20 UnifiedHighlighter issues and PRs since 2021-12-01, the changed files of all 413 open PRs, the Solr, Elasticsearch and OpenSearch highlighter sourcesno report; 23 hits read, none the same bug (closest #412, #11234, #12431)
GitHub (recheck)2026-10-31withFlags, evaluateFlags, "UnifiedHighlighter" flags ignored, "HighlightFlag" in apache/lucene issues and PRs; the file's commit history on upstream mainwithFlags: 0 hits; evaluateFlags: #16918 only; the file last changed in 54283150a81 (2026-06-14, unrelated)

History

Introduced with the Builder by #412 (b1b1a890d08, back-port a9f812c1cf9, 2021-12-19), first released in 9.1.0; a refactor inside the PR moved the check out of the Builder. Every release from 9.1.0 to 10.6.0 and main is affected; 9.0.0 has no Builder. Not fixed.

Versions tested6 rows

Introduced by #412, "LUCENE-10197: UnifiedHighlighter should use builders for thread-safety" (author apanimesh061, issue #11234; merge commit "LUCENE-10197: UnifiedHighlighter now has a Builder"): b1b1a890d08 on main and the 9.x back-port a9f812c1cf9 (committed by dsmiley), both 2021-12-19. git log -S over UnifiedHighlighter.java for withFlags( and evaluateFlags(Builder uhBuilder) finds only these two commits, git blame credits every line involved to them, and git log -G 'uhBuilder\.flags' is empty. Inside the PR, revision d65ee8ab42 (2021-12-05) still had the check in the Builder, where by its source it read the builder's set; a6f70412c9 (2021-12-18) moved it (revisions read, not built). The parent commits have no Builder, so no commit-level bisect is possible.

Releases not executed are inferred: the release tags contain the introducing commit, and the evaluateFlags(Builder) body is byte-identical in all 33 releases from 9.1.0 to 10.6.0 and on main (follow-up for 9.1.0 to 10.5.2; the fact check added 10.6.0). Upstream main 2f424fd428da (2026-10-30) is unchanged.

Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedminimal repro fails with assertions on and off (strict markup instead of per-word)
10.6.0, 10.5.2, 10.0.0, 9.12.3, 9.1.0 affectedminimal repro fails, same message (assertions on)
9.1.0 firstfirst release with UnifiedHighlighter.Builder; the finder's and the independent reproduction's tests fail too
main ea57360912ed, a9779ef3c193 affectedthe finder's, the independent reproduction's, the cross-model review's and the intended-behaviour check's tests fail
9.0.0 not affectedno Builder and no withFlags (reflection probe); the test does not compile
with the recommended fix (645d4fe14a20) not affectedrepro passes; the whole highlighter module (723 tests) passes

Independent checks

An independent reproducer, a devil's-advocate verifier and a cross-model review said real; a second cross-model review, which could not run code, returned a procedural not-real while agreeing with the root cause. The adjudication confirmed with high confidence, and the fix made every check's test pass.

Checks and verdicts302 words

Every executing check ran on stock builds of main (ea57360912ed, a9779ef3c193 or 645d4fe14a20, with no highlighter change between them) or on releases.

Each independent check and its verdict
CheckVerdictWhat it found
Finder (JUnit) candidateFails 1 of 1 on main with assertions on and off, 10.5.2 and 9.12.3 (getFlags returns the four defaults instead of [MULTI_TERM_QUERY]); its boolean-option control passes; passes with the one-line fix.
Cross-model review, refute lens (OpenAI GPT-5.6) not real, low (procedural)Read-only, could not run Java, so the refute review voted not real on procedure only. It agreed with the root cause, found the inputs in contract and rated the works-as-designed case weak; 6 of its 8 evidence items support real.
Independent reproducer realOwn test: withFlags with the empty set still highlights a PrefixQuery, all booleans false does not; fails 2 of 2 on main (on and off), 10.5.2, 9.12.3 and 9.1.0; sanity checks pass; its fix passes.
Cross-model review, works-as-designed lens (OpenAI GPT-5.6) real, mediumWorks-as-designed case weak; root cause agrees. The reviewer ran no code; a separate run re-checked its claims with executed runs, including an output probe (PhraseQuery on a two-sentence text) that fails on main and passes with the fix.
Devil's advocate realOwn test fails 3 of 5 on main (the 3 claim tests; the 2 fact tests pass), including the test helper's copy idiom; traced the defect to PR revision a6f70412c9.
Adjudication confirmed, highRe-ran the independent reproduction's test (seed C0FFEE01): 2 of 2 fail with assertions on and off, 2 of 2 pass with the fix; impact class minor, security none.
Brief runs (2026-10-31) reproduced, fix validatedMinimal repro on main and five releases; boolean-option control; gradle on 645d4fe14a20 with both fixes; fix-design probe.

Diagram file

The diagram as a still image, generated with AI and checked against the code. It is under 10 MB, the limit for a GitHub attachment.

Sources and provenance

Finding record, Lucene and upstream sources, repro files and diagram notes1,237 words

Finding record

LFV-288, snapshot of 2026-11-06

Registry title
UnifiedHighlighter.Builder#withFlags is silently ignored: the highlighter always takes its flags from the four boolean builder options
Area
highlighter / UnifiedHighlighter builder and highlight flags (UnifiedHighlighter.evaluateFlags(Builder) never reads Builder.flags)
Priority
Low, rank 874: Builder#withFlags is silently ignored for every caller, but no caller is known and the boolean options work around it.
Impact class
minor
Impact note
Applications on any release from 9.1.0 through 10.6.0 or on main that configure a UnifiedHighlighter with the opt-in Builder#withFlags (no production caller in Lucene; Solr, Elasticsearch and OpenSearch set flags by overriding getFlags(String) instead, by code reading) silently get the flags derived from the four boolean options, by default all four flags, instead of the set they passed, so for example multi-term-query or strict-phrase highlighting cannot be switched off this way and the snippet markup differs from what was requested; search hits are unaffected, nothing throws, no scale is needed, and the four boolean builder options are a working workaround that can express every flag set meeting the documented WEIGHT_MATCHES requirement (9 of the 16 subsets).
Summary
You build a UnifiedHighlighter with Builder#withFlags(EnumSet.of(MULTI_TERM_QUERY)), whose javadoc says the set 'will override' the four boolean builder options, and expect it to behave like the same flags chosen through those options: for a PhraseQuery 'alpha beta' on 'alpha beta. alpha gamma.' that gives '<b>alpha</b> <b>beta</b>. <b>alpha</b> gamma.'. Instead the constructor's evaluateFlags(Builder) tests the highlighter's own flags field, which is still null at that point, rather than the builder's, so the set passed to withFlags is never read: getFlags returns the defaults [PHRASES, MULTI_TERM_QUERY, PASSAGE_RELEVANCY_OVER_SPEED, WEIGHT_MATCHES] and the phrase is highlighted strictly as '<b>alpha beta</b>. alpha gamma.'. Every set is ignored the same way, including the empty set (a PrefixQuery is still highlighted), with or without assertions.
Introduced
b1b1a890d0890e7e35bb401cc8d851a45cc155f1 on main and its 9.x back-port a9f812c1cf90b0c35af8a82b8c76d826601684c7 (both 2021-12-19, 'LUCENE-10197: UnifiedHighlighter now has a Builder (#412)', author apanimesh061, the back-port committed by dsmiley; issue #11234 / LUCENE-10197) [read: git log -S and git blame in the full-history clone, every buggy line blames to these commits, and git log -G 'uhBuilder\.flags' is empty on all history; exec: the API is absent on 9.0.0 and the repro fails on 9.1.0]. The defect arose inside PR #412: at revision d65ee8ab42 (2021-12-05) evaluateFlags() was a Builder method, so its 'if (Objects.nonNull(flags)) return flags;' read Builder.flags; revision a6f70412c9 (2021-12-18, 'Attempt at making UH with builder back-portable to v9.x') moved it into UnifiedHighlighter as evaluateFlags(Builder uhBuilder) without changing the guard, so 'flags' now names the highlighter's own field [read: PR revisions fetched read-only; those revisions were not built]. withFlags shipped broken with the API, so this is not a regression of behaviour that ever worked in a release (2021-12-19)
First affected version
9.1.0 [exec] (first release with UnifiedHighlighter.Builder and withFlags, from 9.x commit a9f812c1cf9; 10.0.0 is the first release of the main-line commit and is affected [exec]); 9.0.0 has no Builder [exec: a reflection probe reports Builder absent and withFlags absent, and the repro does not compile]
Affected versions
every release with UnifiedHighlighter.Builder: the 20 releases 9.1.0 to 9.12.3, the 12 releases 10.0.0 to 10.5.2, 10.6.0 (released 2026-10-29) and main ea57360912ed [exec: the minimal repro fails on the 10.6.0 jars (2026-10-31); the finder repro fails on 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main, on main with -ea and -da; the independent reproduction's test fails 2 of 2 on 9.1.0, 9.12.3, 10.0.0, 10.5.2 and main, on main with -ea and -da; read: the other releases contain the introducing commit and their evaluateFlags(Builder) body is byte-identical]; not affected: 9.0.0 and earlier, which have no Builder (9.0.0 executed); unreleased maintenance branches not checked
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that could only read the code (its low-confidence “not real” is procedural), an intended-behaviour check, a prior-report search and a skeptical audit; fixed upstream.
Found by
non-formal: code reading
Upstream and prior reports
none found (2026-10-26, the prior-report record: 215 recorded queries, namely 90 GitHub issue and PR queries, 18 GitHub code queries, 9 commit queries, 6 downstream-repo queries, 60 mailing-list queries over dev@, java-user@, issues@, commits@ and builds@, 13 JIRA-archive queries and 19 web queries; direct reads of the introducing PR #412 (12 comments, 56 review comments, 30 reviews), issue #11234 / LUCENE-10197 (16 comments), 20 UnifiedHighlighter issues and PRs created since 2021-12-01, the changed files of all 413 open apache/lucene PRs, the upstream commits touching UnifiedHighlighter.java on main, branch_10x, branch_10_6 and branch_9x, and the Solr, Elasticsearch and OpenSearch highlighter sources; 23 hits read and classified, none the same bug; the finder, the cross-model review and the intended-behaviour check found nothing either); not fixed upstream (evaluateFlags(Builder) is unchanged on main ea57360912ed, the upstream HEAD at search time; the last change to UnifiedHighlighter.java, 54283150a81 (#16255), is unrelated; no open PR touches evaluateFlags, withFlags or the constructor); closest items, not reports: #412 (the introducing PR; a reviewer asked 'Isn't empty valid?' about an earlier check that ignored an empty withFlags set, and the author's 2021-10-30 summary says that when flags is initialized the boolean flags are ignored, which is design intent), #11234 / LUCENE-10197 (its issue, with the back-port plan 'the flags computation needs to be as it is already on 9x'), #12431 (a different highlighter bug whose suggested workaround overrides getFlags(String))
Lucene commit tested
main 645d4fe14a20 (2026-10-31): the minimal test fails through gradle in a scratch worktree and through a standalone runner with assertions on and off, and with assertions on on 10.6.0, 10.5.2, 10.0.0, 9.12.3 and 9.1.0. The recommended fix passes it, together with the whole highlighter module (35 classes, 723 tests) and the module's format, ECJ and forbidden-apis checks. The hunt, the verifiers and the adjudication ran main ea57360912ed, and a refresh re-ran their repros on a9779ef3c193; nothing under lucene/highlighter/ changed between ea57360912ed and 645d4fe14a20.

Lucene and upstream sources

Finding record
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:131 (at ea57360912ed) (private Set<HighlightFlag> flags), :270 (Builder.flags), :299-312 (Builder#withFlags javadoc 'will override the flags set by withHandleMultiTermQuery, withHighlightPhrasesStrictly, withPassageRelevancyOverSpeed and withWeightMatches' and 'the user can either specify the set of HighlightFlags to be applied or use the boolean flags'; setter stores this.flags = values), :468 (constructor: this.flags = evaluateFlags(builder)), :532-549 (evaluateFlags(Builder) javadoc 'Evaluate the highlight flags and set the flags variable'; body tests 'if (flags != null) return flags;' on the highlighter's own field, then evaluates the four booleans)
  • lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/TestUnifiedHighlighter.java:130-137 (at ea57360912ed) (overrideFieldMatcherForTests copies flags with .withFlags(original.getFlags(fieldName)) at :133)
The finding in 30 seconds
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:108-111, (at 645d4fe14a20) :131, :270, :299-312, :430-432, :468, :502-530, :539-549, :586-594, :1163-1183, :1219-1251, :1294-1299
Who is affected
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:325-327, (at 645d4fe14a20) :344-346, :469-472
  • lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/TestUnifiedHighlighter.java:82-95, (at 645d4fe14a20) :130-157, :1175, :1262, :1484
Why it looks unintended
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:94, (at 645d4fe14a20) :299-312, :430-432, :540
  • lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/TestUnifiedHighlighter.java:130-137 (at 645d4fe14a20)
Suggested fix
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:519-528 (at 645d4fe14a20)
  • lucene/highlighter/src/test/org/apache/lucene/search/uhighlight/TestUnifiedHighlighter.java:82-95 (at 645d4fe14a20)
Prior reports
  • https://github.com/apache/lucene/pull/412
  • https://github.com/apache/lucene/issues/11234
  • https://github.com/apache/lucene/issues/16918
  • lucene/highlighter/src/java/org/apache/lucene/search/uhighlight/UnifiedHighlighter.java:559-566 (at 645d4fe14a20)

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and the minimal repro's output: the user's call, the constructor's check, the highlight result, every set ignored while the boolean options work, a fix, and a summary card (the PNG).

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.