All findings · Lucene finding · LFV-352 · geo3d-outside-sortfield-equals-class-cast

Geo3DPointOutsideSortField.equals casts to the sibling class, so comparing two outside-distance sorts throws ClassCastException

Build the same Geo3D outside-distance sort twice and compare the two (directly, through Sort.equals or as a HashMap key): instead of true, equals throws ClassCastException, because it casts to the sibling inside-distance class. Every release from 6.1.0 to 10.6.0 has it; searching and sorting never reach the broken line.

Summary

Priority
Low, rank 684Geo3DPointOutsideSortField.equals throws ClassCastException when two outside-distance sorts are compared (for example by a Sort-keyed cache), a loud failure in a barely used API, so it goes with its loud spatial3d sibling LFV-282 in the crash rows.
Impact
crash
Since
6.1.0 (2016, LUCENE-7212), when the outside-distance sorts were addedintroduced 2016-05-18 · over 10 years
Affected
every release from 6.1.0 to 10.6.0, and main
Status
documented
Found by
static analysis (SpotBugs, a bytecode scan of equals methods), then a public-API JUnit

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. Not yet triaged by a human. Snapshot of 2026-11-06.

The finding in 30 seconds

equals checks that the other object is its own class, then casts it to a different class: the sibling inside-distance sort it was copied from. That cast can never succeed, so every comparison of two outside sorts on one field throws.

Background: Geo3D distance sorts

  1. Geo3DDocValuesField (module spatial3d) builds SortFields that order hits by distance from a shape: newDistanceSort and newPathSort (distance from a point or path), and five newOutside*Sort factories (distance outside a circle, box, polygon, large polygon or path; points inside get 0).
  2. The first kind is a Geo3DPointSortField, the outside kind a Geo3DPointOutsideSortField: two separate final, package-private subclasses of SortField.
  3. Like every SortField, they are meant to compare by value: SortField.equals and Sort.equals "return true if o is equal to this", and code such as a cache keyed by Sort relies on that.
Diagram of the bug: two sorts built by the same call, newOutsideDistanceSort("loc", 10, 20, 1000, PlanetModel.WGS84), are compared; Geo3DPointOutsideSortField.equals passes its class check at line 67 and then casts the other sort to the sibling class Geo3DPointSortField at line 68, which throws ClassCastException instead of returning true; the one-line fix casts to Geo3DPointOutsideSortField.
The minimal repro step by step: two identical outside-distance sorts, the class check and the cast to the sibling class inside equals, and the ClassCastException. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Build Geo3DDocValuesField.newOutsideDistanceSort("loc", 10, 20, 1000, PlanetModel.WGS84) twice: two sorts by distance outside the same 1,000 m circle, with equal hash codes.
  2. Compare them: a.equals(b), new Sort(a).equals(new Sort(b)), or a HashMap<Sort, ?> lookup. You expect true, as two identical inside-distance sorts give (and false for two different circles).
  3. Instead every one of these calls throws ClassCastException ("Geo3DPointOutsideSortField cannot be cast to class Geo3DPointSortField") at Geo3DPointOutsideSortField.java:68, with assertions on and off. All five newOutside*Sort factories do it.
  4. Why: equals first checks that the argument is its own class (line 67), then casts it to the sibling Geo3DPointSortField (line 68), a line copied from that class in 2016. The two classes are unrelated, so the cast always fails.
  5. Only comparing fails: sorted search and searchAfter never reach the cast (with an index sort, search does call this equals, against the index-sort field, and it returns false first), Lucene itself never compares two such sorts, and equals never returns a wrong true. The fix is one line: cast to Geo3DPointOutsideSortField.

Who is affected

Applications that sort with an outside-distance sort and whose own code compares two separately built ones on the same field, for example a result cache keyed by Sort. Opt-in and rare: no upstream test builds one and no use was found in Elasticsearch, OpenSearch or Solr. No wrong results; workaround: reuse one SortField instance.

Mechanism, terms and numbers721 words

Geo3DDocValuesField has five public factories that sort hits by distance outside a shape, newOutside{Distance,Box,Polygon,LargePolygon,Path}Sort, each returning a package-private Geo3DPointOutsideSortField as a plain SortField. Its equals (Geo3DPointOutsideSortField.java:63-70 at 645d4fe14a20) checks getClass() != obj.getClass() (:67) and then casts the argument to the sibling inside-distance class Geo3DPointSortField (:68). The two are unrelated final subclasses of SortField, so the cast can never succeed, and because SortField.equals passes for any two outside sorts on the same field, comparing any two of them throws ClassCastException: directly, through Sort.equals, or in a HashMap keyed by Sort. The line was copied from Geo3DPointSortField.equals, where it names its own class. It dates from LUCENE-7212 (2016) and is in every release from 6.1.0 to 10.6.0 and on main. Searching and sorting never reach the cast, Lucene's own code never compares two such sorts, and equals never returns a wrong true; the fix is one line.

The mechanism (main 645d4fe14a20)

  1. Every outside sort on a field looks the same to SortField.equals. The constructor calls super(field, SortField.Type.CUSTOM, false, Double.POSITIVE_INFINITY) (Geo3DPointOutsideSortField.java:32). SortField.equals (SortField.java:451-461) compares only field, type, reverse, comparator source and missing value, and SortField has no setter for them on main. So super.equals is true for any two outside sorts on the same field.
  2. The method then checks the class and casts to the wrong one (Geo3DPointOutsideSortField.java:63-70):
public boolean equals(Object obj) {
  if (this == obj) return true;                                // :65
  if (!super.equals(obj)) return false;                        // :66
  if (getClass() != obj.getClass()) return false;              // :67  obj is a Geo3DPointOutsideSortField
  final Geo3DPointSortField other = (Geo3DPointSortField) obj; // :68  unrelated final class: always throws
  return distanceShape.equals(other.distanceShape);            // :69  never reached
}

Geo3DPointOutsideSortField (:26) and Geo3DPointSortField (Geo3DPointSortField.java:26) are both final class ... extends SortField, so no object is both. The jar's bytecode has checkcast org/apache/lucene/spatial3d/Geo3DPointSortField at line 68.

  1. hashCode is value-based (:53-61, over the shape), so two identical outside sorts land in the same HashMap bucket and the lookup calls equals (a lookup with a different shape normally has a different hash code, so the map does not call equals for it). Sort.equals compares the field arrays with Arrays.equals (Sort.java:119), which calls each SortField.equals.
  2. What does not throw: the same instance (:65; Arrays.equals also short-circuits on identical references), another field name or null (:66), an inside sort (:67).
  3. Lucene's own code never reaches the cast. Its non-sandbox Sort comparisons are the early-termination check of sorted search, TopFieldCollector.canEarlyTerminateOnPrefix (TopFieldCollector.java:160-172, comparison at :168), which on every sorted search over an index-sorted segment compares the search sort fields with the segment's index-sort fields and so does call this equals; IndexWriter.isCongruentSort (IndexWriter.java:1232-1239) and the index-sort check in ParallelLeafReader.java:136, which compare index sorts only; and grouping's comparisons with Sort.RELEVANCE. An outside sort can never be an index sort (IndexWriterConfig.setIndexSort rejects a sort field with no IndexSorter, IndexWriterConfig.java:505-509, and SortField.getIndexSorter returns null for CUSTOM, SortField.java:598-604), so in the early-termination check equals returns false at :66 or :67, before the cast; Sort.RELEVANCE's field is null, so super.equals returns false first. An instrumented copy of the class showed it: a sorted search over an index-sorted index called this equals once (from canEarlyTerminateOnPrefix) and without an index sort not at all. An executed probe ran sorted search (10 hits returned) and searchAfter (does not throw) with outside sorts, alone and with a numeric sort, on a multi-segment index, with and without an index sort and an executor (3 of 3 pass on ea57360912ed and again on 645d4fe14a20). (Whether searchAfter pages correctly with these sorts is a separate bug, LFV-544.)

What each comparison gives (executed on main, field loc, WGS84)

comparisonexpectedon mainwith the fix
two identical newOutsideDistanceSorttrueClassCastExceptiontrue
two different shapes, same field, any of the five factories, both directionsfalseClassCastExceptionfalse
Sort.equals over two identical outside sortstrueClassCastExceptiontrue
HashMap<Sort, ?>.get with an equal Sort keythe entryClassCastExceptionthe entry
controls: a.equals(a); null, a foreign type, another field; inside vs outsidetrue; false; falseas expectedas expected
two identical inside-distance sorts (the sibling)truetruetrue
Exposure and likelihood204 words
  • Who is hit. Applications that sort with one of the five outside-distance sorts and whose own code compares two separately built ones on the same field: a result or request cache keyed by Sort (or by query plus sort), code that de-duplicates or compares requests, or an assertEquals in the application's tests. Every such comparison throws, deterministically; it does not depend on the data, an index or assertions.
  • Default vs opt-in. Opt-in and rare. spatial3d is an optional module, and these sorts look like a niche corner of it, judging by the code inspected (real-world usage volume is unknown): no upstream test builds any Geo3D sort field, and read-only code searches of Elasticsearch, OpenSearch and Solr found no use of Geo3DDocValuesField or of these factories (not exhaustive). Within that, it needs nothing special: the default PlanetModel.WGS84, ordinary arguments, no index.
  • Effect. A loud unchecked exception from equals. No wrong results, no hang, no resource use, no index effect, and never a wrong true, so a Sort-keyed cache cannot hand one request's results to another.
  • Workaround. Reuse one SortField instance per distinct sort (self-comparison returns true before the cast), or key caches on the sort's parameters instead of the Sort object.

Minimal repro

One JUnit test in lucene/spatial3d, public API only: build the same outside-distance sort twice and assertEquals them. No index, no randomness. It fails on main with assertions on and off, and on the 10.5.2 and 10.6.0 release jars.

TestGeo3DOutsideSortFieldEquals.java (lucene/spatial3d test, one method)37 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.spatial3d;

import org.apache.lucene.search.SortField;
import org.apache.lucene.spatial3d.geom.PlanetModel;
import org.apache.lucene.tests.util.LuceneTestCase;

public class TestGeo3DOutsideSortFieldEquals extends LuceneTestCase {

  public void testIdenticalOutsideDistanceSortsAreEqual() {
    SortField a =
        Geo3DDocValuesField.newOutsideDistanceSort("loc", 10, 20, 1000, PlanetModel.WGS84);
    SortField b =
        Geo3DDocValuesField.newOutsideDistanceSort("loc", 10, 20, 1000, PlanetModel.WGS84);
    // expected: a.equals(b) is true; actual: ClassCastException (cast to Geo3DPointSortField)
    try {
      assertEquals(a, b);
    } catch (ClassCastException e) {
      throw new AssertionError("expected a.equals(b) to return true, got " + e, e);
    }
  }
}
Command
cp $REPRO/TestGeo3DOutsideSortFieldEquals.java $LUCENE/lucene/spatial3d/src/test/org/apache/lucene/spatial3d/
cd $LUCENE && ./gradlew :lucene:spatial3d:test --tests org.apache.lucene.spatial3d.TestGeo3DOutsideSortFieldEquals -Ptests.seed=DEADBEEF
# also with -Ptests.asserts=false
Expected
a.equals(b) returns true for two identical newOutsideDistanceSort("loc", 10, 20, 1000, PlanetModel.WGS84) sorts.
Observed
ClassCastException (Geo3DPointOutsideSortField cannot be cast to Geo3DPointSortField) at Geo3DPointOutsideSortField.java:68, with assertions on and off.

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

More files, environment, full output209 words

The try/catch only turns the exception into an AssertionError that states expected vs actual; the cause keeps the original stack. The test sits in org.apache.lucene.spatial3d for locality, as Lucene's own tests do, but uses no package-private member. Nothing random reaches the code path; the seed only satisfies the test framework.

Suggested regression test: this same test, as its own class or as one method in TestGeo3DDocValues.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT); also the 10.5.2 and 10.6.0 release jars
jdkOpenJDK 25.0.3
osLinux aarch64 (the only platform run; pure Java logic)

Observed output

# main 645d4fe14a20, gradle, seed DEADBEEF, -Ptests.asserts=true (the same with false)
TestGeo3DOutsideSortFieldEquals > testIdenticalOutsideDistanceSortsAreEqual FAILED
    java.lang.AssertionError: expected a.equals(b) to return true, got java.lang.ClassCastException: class org.apache.lucene.spatial3d.Geo3DPointOutsideSortField cannot be cast to class org.apache.lucene.spatial3d.Geo3DPointSortField (org.apache.lucene.spatial3d.Geo3DPointOutsideSortField and org.apache.lucene.spatial3d.Geo3DPointSortField are in unnamed module of loader 'app')
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:8165F61726058C4A]:0)
        at org.apache.lucene.spatial3d.TestGeo3DOutsideSortFieldEquals.testIdenticalOutsideDistanceSortsAreEqual(TestGeo3DOutsideSortFieldEquals.java:34)

        Caused by:
        java.lang.ClassCastException: class org.apache.lucene.spatial3d.Geo3DPointOutsideSortField cannot be cast to class org.apache.lucene.spatial3d.Geo3DPointSortField (org.apache.lucene.spatial3d.Geo3DPointOutsideSortField and org.apache.lucene.spatial3d.Geo3DPointSortField are in unnamed module of loader 'app')
            at org.apache.lucene.spatial3d.Geo3DPointOutsideSortField.equals(Geo3DPointOutsideSortField.java:68)
            at junit@4.13.2/org.junit.Assert.isEquals(Assert.java:133)
            at junit@4.13.2/org.junit.Assert.equalsRegardingNull(Assert.java:129)
            at junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:112)
            at junit@4.13.2/org.junit.Assert.assertEquals(Assert.java:146)
            at org.apache.lucene.spatial3d.TestGeo3DOutsideSortFieldEquals.testIdenticalOutsideDistanceSortsAreEqual(TestGeo3DOutsideSortFieldEquals.java:32)
:lucene:spatial3d:test (FAILURE): 1 test, 1  failure

# the same ClassCastException at Geo3DPointOutsideSortField.java:78 with the 10.6.0 (-ea, -da) and 10.5.2 (-ea) release jars
# with the one-line fix: 1 test, 0 failures (assertions on and off)

Why it looks unintended

  • The class compares by value: hashCode and equals both use the shape. A cast that always fails after a same-class check is not a design.
  • The SortField and Sort javadoc promise a boolean, and TestSort.testEquals asserts that separately built equal sorts are equal.
  • Every sibling class casts to its own type; this line was copied from Geo3DPointSortField in 2016.
The full argument318 words
  • The class defines value equality. It overrides hashCode over distanceShape (Geo3DPointOutsideSortField.java:53-61), and its equals ends by comparing distanceShape (:69). A method that can never reach its own last line for two objects of its own class is not a design.
  • The contract promises a boolean. SortField.equals: "Returns true if o is equal to this" (SortField.java:447); Sort.equals: the same (Sort.java:112), comparing the field arrays element by element (:119). The Object#equals general contract describes an equivalence relation; for two instances of this class equals gives no answer at all. Nothing (javadoc, comment, CHANGES.txt, MIGRATE.md, test) documents identity-only equality or an exception for these sorts; the factory javadoc covers only ordering, missing and multiple values and argument checks. Geo3DDocValuesField is public, in an exported package, with no @lucene.experimental or @lucene.internal tag (the PlanetModel that every factory takes as an argument is tagged @lucene.experimental, PlanetModel.java:26).
  • Every sibling uses the matching cast after the same guard: Geo3DPointSortField.java:68, LatLonPointSortField.java:67, XYPointSortField.java:64.
  • Upstream tests treat sort equality as a contract: TestSort.testEquals (TestSort.java:73-92) asserts that separately built equal sorts are equal and different ones unequal, in both directions; TestSortedNumericSortField.testEquals and TestSortedSetSortField.testEquals do the same for single sort fields, in one direction. Sort/SortField value equality itself was added by LUCENE-1481 (#2555).
  • History shows a copy slip. Geo3DPointSortField was added on 2016-05-17 (07af00d8e7bc) with a correct equals; the next day cc263413457c added Geo3DPointOutsideSortField and copied that method, cast included (git blame -C -C traces line 68 to Geo3DPointSortField.java at 07af00d8e7bc). The LUCENE-7212 discussion (#8267, 49 comments) never mentions equals, hashCode or a cast, and no later commit touched the line.
  • Precedent. #12827 (merged 2023-11-21, in 9.9.0) fixed the same kind of slip, an equals that casts to a class it is not a subtype of, in PayloadFilteredTermIntervalsSource, with a one-line change.
The devil's-advocate check128 words

Strongest case (from the intended-behaviour checker and the cross-model reviews): the class is package-private, no Lucene code compares two of these sorts, no test builds one, the factories take an @lucene.experimental PlanetModel, no production user has been shown, and nobody has reported it since 2016, so the method is effectively unused and harmless.

Why it fails: that is about impact, not intent. Users get the object through public, documented, untagged factories (only their PlanetModel argument type is experimental) as a public SortField, and SortField.equals and Sort.equals are public, documented and tested; a cast that cannot succeed right after a same-class guard has no intended reading. The low impact is reflected in the priority (low), not in the verdict. The cross-model lenses rated the intended argument weak.

Suggested fix

Recommended Cast to Geo3DPointOutsideSortField

Cast to the class's own type: final Geo3DPointOutsideSortField other = (Geo3DPointOutsideSortField) obj; (one line). With it the minimal test passes with assertions on and off, and the whole lucene/spatial3d suite runs clean (182 tests, 0 failures).

  @Override
  public boolean equals(Object obj) {
    if (this == obj) return true;
    if (!super.equals(obj)) return false;
    if (getClass() != obj.getClass()) return false;
    final Geo3DPointOutsideSortField other = (Geo3DPointOutsideSortField) obj;
    return distanceShape.equals(other.distanceShape);
  }
All options and trade-offs2 options · 284 words

The recommended fix was applied with git apply to a scratch worktree of 645d4fe14a20 and run through gradle; the alternative was not run.

Cast to Geo3DPointOutsideSortField (validated by the checks) (recommended)

In Geo3DPointOutsideSortField.equals (:68), cast to the class's own type, as the sibling classes do.

  @Override
  public boolean equals(Object obj) {
    if (this == obj) return true;
    if (!super.equals(obj)) return false;
    if (getClass() != obj.getClass()) return false;
    final Geo3DPointOutsideSortField other = (Geo3DPointOutsideSortField) obj;
    return distanceShape.equals(other.distanceShape);
  }

Trade-offs

No adverse trade-off was identified in the reviewed code and tests. For the tested specifications (all five factories, identical and different shapes), equals then returns true for identical specs and false for different shapes, matching its value-based hashCode and the sibling classes. No other code changes.

What the checks validated

  • Gradle, scratch worktree of 645d4fe14a20 with the repro test added, seed DEADBEEF: the repro test passes with assertions on and off; TestGeo3DDocValues (the closest existing suite) passes; the whole :lucene:spatial3d:test (20 classes, 182 tests including the repro) passes with assertions on: 0 failures, 3 skipped. The same suite without the fix: 1 failure, the repro.
  • :lucene:spatial3d:checkGoogleJavaFormat passes with the fix.
  • Standalone runner with the fixed class shadowed: a five-factory probe passes 5 of 5 (identical specs true, different shapes false, Sort.equals true, the HashMap lookup finds the key), and an independent verifier's 9-test class passes 9 of 9.

Use a pattern-matching instanceof

Replace lines 67-68 with if (!(obj instanceof Geo3DPointOutsideSortField other)) return false;, the style SortField.equals itself uses (o instanceof SortField other, SortField.java:453). Not run.

Trade-offs

Equivalent here because the class is final; a two-line change instead of one, and it makes this exact slip impossible to repeat.

Downstream note

Downstream products are affected only if their own code compares or caches Sort objects that hold these outside-distance sorts; equals never returns a wrong true, so no cache can mix up results.

How it was found

Static analysis: SpotBugs flagged that equals checks for an operand type incompatible with its own class, at line 68. A bytecode scan of all 599 equals(Object) methods in core and the non-sandbox modules found exactly this one cast to an unrelated type. A public-API JUnit then reproduced it.

The method in full113 words

Method: non-formal (static analysis, then a directed JUnit), 2026-10-26, main ea57360912ed. A sweep of the non-sandbox module jars with SpotBugs 4.10.5 (-effort:max -low) reported EQ_CHECK_FOR_OPERAND_NOT_COMPATIBLE_WITH_THIS (rank 14) at Geo3DPointOutsideSortField.java:68. A custom bytecode scan of all 599 equals(Object) methods in core and the non-sandbox modules, looking for a cast to a type the declaring class is not a subtype of, found exactly one hit, this one. The finder's JUnit (identical distance sorts, different box sorts, Sort.equals over polygon sorts, an inside-sort control) then failed 3 of 4 on main with -ea and -da and on 10.5.2 and 9.12.3. A later minimisation step replaced it with the one-method test shown here.

Prior reports, history and checks

Prior reports

No earlier report found in GitHub issues and PRs (including the JIRA archive), commits, open PRs, the mailing lists, downstream projects or the web (searched 2026-10-26, re-run 2026-10-30). Closest: #12827, which fixed the same kind of slip in PayloadFilteredTermIntervalsSource in 2023.

Searches3 searches

Context, not reports: #12827 (the same slip in another class, fixed 2023); #8267 (LUCENE-7212, the introducing issue; nothing about equals); #14775 (open, the only open PR touching the file; adds inverseSort(), leaves equals unchanged); #9633 (LUCENE-8587, a different slip in GeoComplexPolygon.equals); #15480 and PRs #15483/#15486 (SortField immutability; touched the file only for setMissingValue); #2555 (LUCENE-1481, added Sort/SortField equality); #7802, #6572, #1903 (other equals issues in other classes). Tracking issue (#16937), dev@ thread and public bug-list page do not count; no maintainer or anyone else mentions this bug in them. Limits: keyword search only; GitHub search does not index review comments (the relevant threads were read directly); private channels are not searchable.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub, commits, downstream repositories, mailing lists, JIRA archive, web (search script)2026-10-26three runs, 141 queries over GitHub issues/PRs, commits, downstream repositories, dev@, java-user@, issues@ and commits@, the JIRA archive and the webno report; the run with the class, factory and comparator names got 0 GitHub, commit, downstream or JIRA hits
GitHub (apache/lucene, apache/lucene-solr, apache/solr, Elasticsearch, OpenSearch, Lucene.NET, code search)2026-10-2667 manual searches; the changed files of all 411 open apache/lucene PRs; the review threads of the 6 PRs that touched the file; 24 mailing-list and 11 JQL queries; 12 web queriesno report; closest #12827 (precedent)
re-run: search script, GitHub, lists, web2026-10-30239 queries and checks: 140 from two script runs, 99 manual, the changed files of all 408 open PRs, the review threads of #14775, #15483 and #15486no report; the cast is still on upstream main 2f424fd428da and branch_10_6

History

Born with the class: LUCENE-7212 (cc263413457c on main, aa339a2f0756 on branch_6x, 2016-05-18) copied equals from Geo3DPointSortField, cast included. First release 6.1.0; every release to 10.6.0 and main is affected; not fixed upstream.

Versions tested9 rows

cc263413457c ("LUCENE-7212: Add outside distance classes and methods.", Karl Wright, 2016-05-18) and its branch_6x twin aa339a2f0756 (same change and author date, the copy that shipped first, in 6.1.0) added Geo3DPointOutsideSortField with the wrong cast. They were committed directly under JIRA LUCENE-7212 (migrated as #8267, still open), so there is no PR. The line was copied from Geo3DPointSortField.equals, added the day before in 07af00d8e7bc, where the cast names its own class (git blame -C -C). On main, git log -S on the cast finds only cc263413457c (with --all, also the branch_6x twin and two repository-restructure commits), and git blame -C -C traces the line unchanged to its copy source; the file's 8 later commits did not change the line (the newest, f4f6d70c0c4c in #15486, removed the deprecated setMissingValue override). A javap check of all 91 lucene-spatial3d release jars from 6.1.0 to 10.6.0 finds the bad checkcast in every one. Upstream main 2f424fd428da and branch_10_6 (2026-10-30) still have it.

Versions and branches executed, with results
Version or commitVerdictResult
6.0.1 API absentno Geo3DDocValuesField (the probe does not compile)
6.1.0 first releaseClassCastException in equals with -ea and -da (main() probe)
6.6.6, 7.0.0, 7.7.3, 8.4.1 affectedClassCastException in equals (main() probe, factories without the PlanetModel argument)
8.5.0, 8.11.4, 9.0.0 affectedClassCastException in equals (main() probe with PlanetModel.WGS84)
9.12.3, 10.0.0, 10.5.2, 10.6.0 (4ae946e5a9b9) affectedthe minimal test fails 1 of 1 (10.6.0 with -ea and -da)
all 91 release jars 6.1.0 to 10.6.0 bytecode checkequals does checkcast Geo3DPointSortField in every jar
main 645d4fe14a20 affectedfails, gradle and standalone, assertions on and off
upstream main 2f424fd428da, branch_10_6 source readthe cast is still at line 68 / line 78 (2026-10-30)
main 645d4fe14a20 + one-line fix fixthe minimal test passes; the whole spatial3d suite: 182 tests, 0 failures, 3 skipped

Independent checks

Independent verifiers, a reading-only cross-model review, an adjudication and a credibility audit confirmed the finder's report; the review's refute lens said not real only because it could not run Java. A re-verification on 645d4fe14a20 and this doc's runs reproduced it and validated the fix through gradle.

Checks and verdicts406 words

Every executed check reproduced the same ClassCastException at the cast, and every control passed.

Each independent check and its verdict
CheckVerdictWhat it found
finder (static analysis, then a JUnit) candidateSpotBugs and a 599-method bytecode scan found the cast; the JUnit fails 3 of 4 on main (-ea, -da), 10.5.2 and 9.12.3, passes 4 of 4 with the fix; TestGeo3DDocValues passes with the fix.
Independent reproducer realOwn 9-test JUnit in another package: 5 of 9 fail on main (-ea, -da), 10.5.2 (-ea, -da) and 9.12.3, including HashMap<Sort,?>.get; the 4 controls pass; 9 of 9 with its own one-line fix.
cross-model review (OpenAI GPT-5.6, reading only) intended lens: real, medium; refute lens: not real (procedural)The refute lens could not run Java and calls its verdict procedural; its own findings agree with the root cause, put the inputs in contract and rate the intended argument weak. A separate re-run of the repro fails.
Intended-behaviour devil's advocate realBuilt the strongest not-a-bug case and showed it is about impact, not intent; re-ran the repro (-ea, -da: 3 of 4 fail).
adjudication confirmed, highRe-ran the independent reproduction's JUnit (-ea, -da: 5 of 9 fail, controls pass); not a duplicate.
credibility audit keepRe-ran the finder's JUnit with a fresh seed (-ea, -da: 3 of 4 fail); the auditor judged that a committer would merge the one-line fix without debate.
re-verification and tie-break on 645d4fe14a20 real, highThe minimal test fails on main (-ea, -da), 10.6.0 (-ea, -da) and 10.5.2; a five-factory probe with controls fails its 3 bug tests and passes 5 of 5 with the fix; javap shows the bad checkcast.
this doc's runs (2026-10-30) reproduced; fix validatedThe minimal test fails through gradle on main 645d4fe14a20 with assertions on and off; with the fix it passes, and the whole spatial3d suite has 0 failures in 182 tests.
fact checks of this doc (2026-10-30) bug confirmed; wording correctedAn independent fact check re-ran the repro through gradle (assertions on and off: 1 failure at line 68; with the fix 0, and the whole spatial3d suite 182 tests, 0 failures) and showed with an instrumented copy of the class that sorted search calls this equals only for the index-sort check, which returns false before the cast. A second, reading-only fact check by OpenAI GPT-5.6 found no error that changes a conclusion. Their wording corrections are applied.

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,342 words

Finding record

LFV-352, snapshot of 2026-11-06

Registry title
Geo3DPointOutsideSortField.equals casts its argument to the sibling class Geo3DPointSortField, so comparing two distinct outside-distance sort fields on the same field (directly, through Sort.equals, or in a HashMap keyed by Sort) throws ClassCastException instead of returning true or false
Area
spatial3d / Geo3DDocValuesField outside-distance sort fields (Geo3DPointOutsideSortField equality)
Priority
Low, rank 684: Geo3DPointOutsideSortField.equals throws ClassCastException when two outside-distance sorts are compared (for example by a Sort-keyed cache), a loud failure in a barely used API, so it goes with its loud spatial3d sibling LFV-282 in the crash rows.
Impact class
crash
Impact note
Hits only applications that sort with one of the five Geo3DDocValuesField.newOutside{Distance,Box,Polygon,LargePolygon,Path}Sort factories of the opt-in spatial3d module (no upstream test builds one, and a read-only code search found no use in Elasticsearch, OpenSearch or Solr) and whose own code compares, or hashes and then compares, two separately built such sort fields on the same field, for example a result cache keyed by Sort: with the default WGS84 model and no setting or scale needed, that equals call throws ClassCastException with -ea and -da, while Lucene's own sorted search and searchAfter (executed) and its index-sort, grouping and query-cache paths (read) never reach the cast, so there are no wrong results, no hang and no index damage; every release from 6.1.0 to 10.6.0 (10.6.0 by reading) and main is affected.
Summary
You build the same outside-distance sort twice, for example Geo3DDocValuesField.newOutsideDistanceSort("loc", 10, 20, 1000, PlanetModel.WGS84), and compare the two with equals, with Sort.equals, or by looking one up in a HashMap keyed by Sort, and expect true (or false for two different shapes), which is what the inside-distance sort gives. Instead equals throws ClassCastException ('Geo3DPointOutsideSortField cannot be cast to class Geo3DPointSortField'): the method was copied from the sibling class Geo3DPointSortField and still casts to that class right after checking that both objects are Geo3DPointOutsideSortField, so the cast can never succeed. Searching and sorting with these sort fields work; only comparing two of them fails.
Introduced
cc263413457c826914c3f3c1c26f885330a931aa (main, 'LUCENE-7212: Add outside distance classes and methods.', by Karl Wright (GitHub DaddyWri); first released in 7.0.0) and its branch_6x twin aa339a2f0756a487293c271a673e60e12722d661 (same change and author date, the commit that shipped first, in 6.1.0); committed under JIRA LUCENE-7212 (migrated as apache/lucene#8267, still open), no pull request. The wrong line was copied from Geo3DPointSortField.equals, added the day before in 07af00d8e7bc ('LUCENE-7212: Add Geo3D sorted document fields'), where the same cast is correct because it names its own class. [read: git blame -C -C on main attributes Geo3DPointOutsideSortField.java:68-69 to Geo3DPointSortField at 07af00d8e7bc; git log -L on the equals method and git log -S 'Geo3DPointOutsideSortField' list only cc263413457; exec: 6.0.1 has no such API, 6.1.0 fails]. Not a regression: the cast has never changed (2016-05-18)
First affected version
6.1.0 [exec] (the first release with Geo3DDocValuesField and the outside-distance sort factories; fails with -ea and -da); 6.0.1 has no such API (the probe does not compile)
Affected versions
6.1.0 to 10.6.0 (10.6.0 by reading) and main ea57360912ed; not fixed upstream. [exec] a public-API main() probe (two variants, before and after the PlanetModel argument that LUCENE-9150 added in 8.5.0) hits the ClassCastException in 5 of 5 checks (one per factory, one of them through Sort.equals) on 6.1.0 (-ea and -da), 6.6.6, 7.0.0, 7.7.3, 8.4.1, 8.5.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 (-ea and -da) and main, and passes 5 of 5 on main with the one-line fix; the finder's JUnit fails 3 of 4 (the control passes) on main (-ea, -da), 10.5.2 and 9.12.3; the independent reproduction's JUnit fails 5 of 9 (the 4 controls pass) on main (-ea, -da), 10.5.2 (-ea, -da) and 9.12.3. [read] all 81 release tags from 6.1.0 to 10.5.2 in the history clone contain the cast (8.8.2 and 8.9.0-8.11.3 are not in the clone and are inferred from 8.8.1 and the executed 8.11.4); upstream main e1c4184cb98, branch_10x, branch_10_6 and branch_9_12 still contain it (read-only check 2026-10-26). All runs on aarch64 with JDK 25.0.3
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 adjudication that ruled it real, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
non-formal
Upstream and prior reports
none found (2026-10-26, read-only, the prior-report record verdict new, 262 logged queries and checks: three scripted prior-report searches (141 queries over GitHub issues/PRs, commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; the run with the class, factory and comparator names got 0 GitHub, commit, downstream or JIRA hits), 67 manual GitHub searches over apache/lucene, apache/lucene-solr, apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch and apache/lucenenet plus code search, the changed files of all 411 open apache/lucene PRs, the review threads of the 6 PRs that touched the file, 24 direct mailing-list and 11 JQL queries, and 12 web queries; no hit is the same bug). Closest item: #12827 (merged 2023-11-21, released in 9.9.0) fixed the identical mistake, an equals that casts to a sibling class, in PayloadFilteredTermIntervalsSource; it does not mention Geo3D and has a different fix site, so it is a precedent, not a report of this bug. Also read, not this bug: #8267 (LUCENE-7212, still open, the introducing issue; its comments never mention equals, hashCode or a cast), apache/lucene-solr#1253 (LUCENE-9150, the last rewrite of the class), #14775 (open, the only open PR touching the file; adds inverseSort() and does not change equals), #7802 (LUCENE-6744), #6572 (LUCENE-5509, the LFV-039 item), #1903 (LUCENE-828). Not fixed upstream.
Lucene commit tested
main 645d4fe14a20 (11.0.0-SNAPSHOT), 2026-10-30: the minimal test through gradle in a scratch worktree with assertions on and off, stock and with the recommended fix (plus TestGeo3DDocValues and the whole lucene/spatial3d test suite with the fix and without it), and on the standalone runner with -ea and -da and fresh seeds; a five-factory probe with controls, stock and with the fix; the 10.5.2 and 10.6.0 release jars. The history runs cover 6.0.1 (API absent) and 6.1.0, 6.6.6, 7.0.0, 7.7.3, 8.4.1, 8.5.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and 10.6.0 (all affected), plus a bytecode check of all 91 lucene-spatial3d release jars from 6.1.0 to 10.6.0. The hunt itself ran on main ea57360912ed, identical in the files involved. Upstream main 2f424fd428da and branch_10_6 were read on 2026-10-30. Only Linux aarch64 and OpenJDK 25.0.3 were run.

Lucene and upstream sources

Finding record
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointOutsideSortField.java:53-61 (at ea57360912ed) (hashCode over distanceShape), :63-70 (equals; getClass check at :67, cast to Geo3DPointSortField at :68)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointSortField.java:63-70 (at ea57360912ed) (the sibling equals with the matching cast at :68)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DDocValuesField.java:249, (at ea57360912ed) :281, :311, :337, :365 (public static factories newOutside{Distance,Box,Polygon,LargePolygon,Path}Sort return SortField)
  • lucene/core/src/java/org/apache/lucene/search/SortField.java:446-461 (at ea57360912ed) (javadoc 'Returns true if o is equal to this'; equals), lucene/core/src/java/org/apache/lucene/search/Sort.java:113-120 (Sort.equals compares the SortField arrays element by element)
The finding in 30 seconds
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointOutsideSortField.java:26, :32, :53-61, :63-70
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointSortField.java:26, :63-70 (the sibling, cast to its own class at :68)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DDocValuesField.java:183, :216, :249, :281, :311, :337, :365 (factories); :257, :290, :314, :340, :373 (return new Geo3DPointOutsideSortField); :229-248 (newOutsideDistanceSort javadoc: points within the circle have distance 0.0)
  • lucene/core/src/java/org/apache/lucene/search/SortField.java:446-461 (javadoc and equals; no setters for field, type, reverse or missing value on main)
  • lucene/core/src/java/org/apache/lucene/search/Sort.java:112-120 (javadoc, Arrays.equals at :119)
  • lucene/core/src/java/org/apache/lucene/search/TopFieldCollector.java:59, :152, :160-172 (canEarlyTerminateOnPrefix, comparison at :168); lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1232-1239 (isCongruentSort); lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:504-509; lucene/core/src/java/org/apache/lucene/search/SortField.java:598-604 (getIndexSorter returns null for CUSTOM); lucene/core/src/java/org/apache/lucene/index/ParallelLeafReader.java:136; lucene/grouping/src/java/org/apache/lucene/search/grouping/AllGroupHeadsCollector.java:63, BlockGroupingCollector.java:275, :285, TopGroups.java:183, :405, AllGroupHeadsCollectorManager.java:146
Who is affected
  • grep of lucene/spatial3d/src/test (no SortField, no newOutside* call); grep of lucene (no newOutside* call outside spatial3d/src/java)
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointOutsideSortField.java:65 (self-comparison returns true before the cast)
Why it looks unintended
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointOutsideSortField.java:53-61, :63-70
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointSortField.java:68; lucene/core/src/java/org/apache/lucene/document/LatLonPointSortField.java:67; lucene/core/src/java/org/apache/lucene/document/XYPointSortField.java:64
  • lucene/core/src/java/org/apache/lucene/search/SortField.java:446-449; lucene/core/src/java/org/apache/lucene/search/Sort.java:112-120
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DDocValuesField.java:40, :51, :229-248; lucene/spatial3d/src/java/module-info.java:22; lucene/spatial3d/src/java/org/apache/lucene/spatial3d/geom/PlanetModel.java:26 (@lucene.experimental)
  • lucene/core/src/test/org/apache/lucene/search/TestSort.java:61-92; TestSortedNumericSortField.java:60; TestSortedSetSortField.java:47
Suggested fix
  • lucene/core/src/java/org/apache/lucene/search/SortField.java:453 (instanceof pattern in SortField.equals)
Diagrams
  • lucene/spatial3d/src/java/org/apache/lucene/spatial3d/Geo3DPointOutsideSortField.java:63-70

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

Repro files

About the diagrams

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.