The parser reads a date string field by field and checks each number against a fixed range: month 1..12, day 1..31, hour 0..24. The day is never compared with the length of its month. A lenient java.util.Calendar then quietly turns November 31 into December 1, and that later date is what gets indexed or searched.
- You index three documents dated 2014-11-15, 2014-12-01 and 2014-12-02 with
NumberRangePrefixTreeStrategyoverDateRangePrefixTree, and search with the range[2014-11-01 TO 2014-11-31], a natural way to write "all of November". - You expect a
ParseException: November has 30 days, and the same parser rejects2014-11-32and2014-13-01with one. - You get no error: the range becomes
[2014-11 TO 2014-12-01]and the query returns 2014-11-15 and 2014-12-01. - Why: the day is checked only against 1..31 and then set on a lenient
java.util.Calendar. When the fields are read, November 31 turns into December 1.2014-02-30becomes 2014-03-02 and2013-02-29becomes 2013-03-01, at most 3 days past the month's end.
Applications using the opt-in, experimental DateRangePrefixTree that parse date strings typed by people or built by code, for example [2014-02-01 TO 2014-02-31] for "all of February": the range silently covers up to 3 extra days, and such a document value is indexed as the later date. Valid dates are unaffected. Rare: no report found since 5.0.0.
Mechanism, terms and numbers638 words
DateRangePrefixTree (spatial-extras, @lucene.experimental) parses ISO-like date strings for NumberRangePrefixTreeStrategy. Since LUCENE-8640 (7.7.0) its parseCalendar range-checks the month, day, hour, minute and second fields (not the year or the fraction digits) and throws ParseException for day 32, month 13, hour 25 or minute 60. But the day is checked only against 1..31, never against the length of its month, and is then set on a lenient java.util.Calendar. When the fields are read, the impossible day silently becomes a real day of the next month: 2014-11-31 becomes 2014-12-01, 2014-02-30 becomes 2014-03-02, 2013-02-29 becomes 2013-03-01. The range [2014-11-01 TO 2014-11-31] parses to [2014-11 TO 2014-12-01], and an Intersects query on a three-document index returns 2014-11-15 and 2014-12-01. Both shipped template calendars give these results on these modern dates (they differ only for Julian leap years and the 1582 cutover), with assertions on and off. Every release since 5.0.0 (the first with the class) does it; the inconsistency with the day-32 check exists since 7.7.0. A 9-line month-length check makes these strings throw ParseException, keeps the deliberate hour 24 working, and passes the module's tests.
- DateRangePrefixTree: a spatial-extras prefix tree for dates and date ranges.
NumberRangePrefixTreeStrategyindexes and queries its shapes;parseCalendarreads one date (2014-11-30,2014-11-30T10, ...) andparseShapealso reads a range[a TO b]. - Template calendar: the tree clones a
java.util.Calendarfor every value. The two shipped templates,DEFAULT_CALandJAVA_UTIL_TIME_COMPAT_CAL, are lenient (isLenient() == true): a lenient calendar accepts November 31 and normalizes it to December 1 when it computes its fields.
parseCalendar (DateRangePrefixTree.java:469-539 at a9779ef3c193) starts from a clone of the template (:472). The month goes through parseAndCheck(str, offset, 1, 12) (:491-492), the day through parseAndCheck(str, offset, 1, 31) and then cal.set(Calendar.DAY_OF_MONTH, parsedVal) (:498-499). parseAndCheck (:548-555) only compares with the bounds it is given. Any exception inside its try block becomes a ParseException (:534-538), so day 32 is rejected; November 31 passes.
toShape(Calendar) (:308-334) reads the fields with cal.get(...) (:315-326), and toString(Calendar) (:391 on) does the same (:396-429). The first get makes the lenient calendar compute the time and rewrite its fields: November 31 becomes December 1. That is the cell that is indexed or searched.
NumberRangePrefixTree.parseShape (NumberRangePrefixTree.java:205-221) parses each bound of [a TO b] the same way (:215, through parseUnitShape, DateRangePrefixTree.java:457-459), and toRangeShape (NumberRangePrefixTree.java:117-128) normalizes [2014-11-01 TO 2014-12-01] to [2014-11 TO 2014-12-01]: all of November plus December 1st.
| template | non-existent days accepted | real dates that round-trip | day 32 |
|---|---|---|---|
JAVA_UTIL_TIME_COMPAT_CAL | 93 (all past the month's length) | 5,115 of 5,115 | 168 of 168 rejected |
DEFAULT_CAL | 101 (91 past the month's length + the 10 days of the October 1582 cutover gap) | 5,107 of 5,107 | 168 of 168 rejected |
Each accepted day past its month's length becomes a real date at most 3 days after the named month's end (2014-02-31 -> 2014-03-03); the 10 cutover-gap days of DEFAULT_CAL move 10 days forward inside October (1582-10-10 -> 1582-10-20). DEFAULT_CAL uses Julian leap years before 1582, so 1500-02-29 is real there and rolls only under the proleptic JAVA_UTIL_TIME_COMPAT_CAL. Every accepted string parses to exactly the shape of the real date it rolls to (101 of 101 and 93 of 93); for three sample pairs per template calendar (a day, a millisecond timestamp, a range) it also indexes the same terms as that date, with CheckIndex clean (the hunt's security probe, main and 10.5.2).
Exposure and likelihood262 words
- Who. Applications that use
DateRangePrefixTreewithNumberRangePrefixTreeStrategyand parse date strings that people type or code builds. A range written as[2014-02-01 TO 2014-02-31]covers 2014-03-01..03; code that builds month ends by appending-31covers the next month's first day for every 30-day month. A document value2014-11-31is indexed as 2014-12-01. - Downstream (code reading only, not executed): Solr's
DateRangeFieldbuilds this tree onJAVA_UTIL_TIME_COMPAT_CALand sends range strings toparseShape, and single values withoutZthat do not start withNOWtoparseCalendar, for documents and queries. Values withZgo through Solr's own date parser, which rejects these dates. Other downstream search engines checked do not use this class. - Opt-in, experimental. The spatial-extras module is opt-in;
DateRangePrefixTreeandNumberRangePrefixTreeStrategyare@lucene.experimental(DateRangePrefixTree.java:39,NumberRangePrefixTreeStrategy.java:42). Within it nothing special is needed: either shipped template calendar, anyIndexWriterConfigand codec, one value. - Silent and small. No exception and no index damage; the value means a different real date, at most 3 days past the named month's end. Deterministic; the same with assertions off.
- How likely. Rare: standard date pickers and formatters normally do not produce November 31 (stored or hand-built strings can). Registry likelihood "rare", priority low.
- Workaround today. Validate the date part first, for example with
java.time.LocalDate.parsefor modern dates (it rejects these strings). A non-lenient template calendar is no clean way out:2014-11-31then fails withIllegalArgumentException: MONTH: 10 -> 11fromtoString/parseShape, later than and unlike the fixed-field checks, which throwParseExceptionat parse time; and the deliberateT24is rejected too.
The test shows that DateRangePrefixTree.parseCalendar accepts a day its month does not have (2014-11-31) and rolls it into the next month, while it rejects day 32 with a ParseException.
/*
* 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.spatial.prefix.tree;
import java.text.ParseException;
import java.util.Calendar;
import org.apache.lucene.tests.util.LuceneTestCase;
public class TestDateRangeNonexistentDay extends LuceneTestCase {
public void testNovember31IsRejected() throws Exception {
DateRangePrefixTree tree = new DateRangePrefixTree(DateRangePrefixTree.DEFAULT_CAL);
Calendar cal;
try {
cal = tree.parseCalendar("2014-11-31");
} catch (@SuppressWarnings("unused") ParseException expected) {
return;
}
fail("expected ParseException for 2014-11-31 (as for 2014-11-32), got " + tree.toString(cal));
}
}
# in a copy of this finding's repro folder (repro/LFV-619/, 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:$LUCENE/lucene/spatial-extras/build/libs/lucene-spatial-extras-$V.jar
CP=$CP:$LUCENE/lucene/spatial3d/build/libs/lucene-spatial3d-$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.spatial.prefix.tree.TestDateRangeNonexistentDay
# the module(s) spatial-extras also need their third-party runtime jars
- Expected vs actual
- Expected:
ParseExceptionfor2014-11-31(as for2014-11-32); actual: parses to2014-12-01, with-eaand-da.
More files, environment, full output37 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: expected ParseException for 2014-11-31 (as for 2014-11-32), got 2014-12-01
org.junit.Assert.fail(Assert.java:89)
org.apache.lucene.spatial.prefix.tree.TestDateRangeNonexistentDay.testNovember31IsRejected(TestDateRangeNonexistentDay.java:33)- Since LUCENE-8640 (7.7.0) the parser rejects every other out-of-range field value: day 32, month 13, hour 25, minute 60.
parseCalendarreversestoString, documented as ISO_INSTANT-consistent;Instant.parserejects2014-11-31but acceptsT24:00, the hour-24 rollover the parser allows on purpose.- No upstream test or LUCENE-8640 discussion mentions month length.
The full argument255 words
- The method rejects every other out-of-range field value. LUCENE-8640 ("Date Range format validation",
CHANGES.txt:6973, 7.7.0 section) addedparseAndCheckfor the month, day, hour, minute and second (DateRangePrefixTree.java:491, 498, 505, 512, 519; the year and the fraction digits are not range-checked). Day 32, month 13, hour 25 and minute 60 throwParseException(executed controls). The upstreamTestDateRangePrefixTree.testInvalidDateException(TestDateRangePrefixTree.java:282-353) asserts it for month 13 and hour 25. November 31 shows the same symptom as November 32, a silent move into December, yet only one is rejected. - The formatter it reverses cannot emit it, and the analogous ISO parser rejects it. No javadoc says how impossible input is handled, but
parseCalendaris "The reverse oftoString(java.util.Calendar)" (DateRangePrefixTree.java:462);toString(Calendar)is "consistent withDateTimeFormatter#ISO_INSTANT" (:386).Instant.parserejects2014-11-31T00:00:00Zand2014-02-29T00:00:00Zand accepts2014-11-30T24:00:00Zas the next midnight, the rollover the parser allows on purpose (hours 0..24). The parser's looserT24:30(read as 00:30 of the next day) is rejected byInstant.parseand is not part of this claim. - The range parser promises an exception:
parseShape"@throws ParseExceptionIf there is a problem" (NumberRangePrefixTree.java:203). - Nothing asks for the rollover. No upstream test parses a day missing from its month. LUCENE-8640's issue and PR review discuss delimiters, cost and hour 24 ("We might have hours from 0 to 24 inclusively"), never month length or leap years.
- The LUCENE-8640 comment "reject it absolutely since we parting a range not a date" was about malformed delimiters, not month length.
The devil's-advocate check195 words
The strongest case for "works as designed": no javadoc sentence says impossible dates are rejected; parseCalendar is specified only as the reverse of toString, whose output never contains 2014-11-31, so this is out-of-contract input and a fix is hardening. The class is Calendar-based and @lucene.experimental, a Calendar is lenient by default, the template is configurable (LUCENE-7278), LUCENE-8640 was an enhancement about syntax, and other Lucene date parsers are lenient too (DateTools.stringToDate reads 20140230 as 2014-03-02; the classic and flexible query parsers call setLenient(true)).
Why it fails: the parser itself already rejects every other out-of-range field value (day 32, month 13, hour 25, minute 60), whatever the lenient calendar would do with it; the rollover it keeps on purpose, hour 24, is one ISO also accepts for T24:00; and the "use a strict calendar" way out is undocumented, surfaces the error later as an IllegalArgumentException instead of the ParseException the fixed-field checks give at parse time, and also rejects hour 24. @lucene.experimental lowers the severity, not the inconsistency. The adjudication confirmed with medium confidence for this reason: the mechanism is certain, but maintainers may reasonably call the fix validation hardening.
Recommended Check the day against the month's real length (+9 lines in parseCalendar)
After parsing the day, compare it with the month's real length (getActualMaximum(DAY_OF_MONTH) on a clone of the calendar, so its precision state is kept) and throw; the existing catch turns that into a ParseException. +9 lines, no reindexing. The repro and the whole spatial-extras suite (120 tests) pass; the swept real dates and hour 24 parse as before.
All options and trade-offs5 options · 885 words
One fix was applied in a scratch worktree at a9779ef3c193 and run through gradle (seed DEADBEEF) and through the classpath sweep; the alternatives were not run.
Check the day against the month's real length (+9 lines in parseCalendar) (validated by the checks) (recommended)
After parsing the day, take a clone of the calendar, set the day to 1 and compare the parsed day with getActualMaximum(DAY_OF_MONTH); throw IllegalArgumentException, which the existing catch turns into ParseException like the other field checks. The clone keeps the isSet() precision state that toShape relies on (the class javadoc warns that any get sets every field, DateRangePrefixTree.java:35-37). The finder wrote this fix; the independent reproducer and the adjudication ran it too.
--- a/lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/DateRangePrefixTree.java
+++ b/lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/DateRangePrefixTree.java
@@ -496,6 +496,15 @@ public class DateRangePrefixTree extends NumberRangePrefixTree {
checkDelimiter(str, offset - 1, '-');
parsedVal = parseAndCheck(str, offset, 1, 31);
+ // reject a day that does not exist in this month (e.g. Feb 30); a lenient Calendar would
+ // otherwise roll it over into the next month. Checked on a clone to keep cal's isSet() state.
+ Calendar monthCal = (Calendar) cal.clone();
+ monthCal.set(Calendar.DAY_OF_MONTH, 1);
+ int daysInMonth = monthCal.getActualMaximum(Calendar.DAY_OF_MONTH);
+ if (parsedVal > daysInMonth) {
+ throw new IllegalArgumentException(
+ "Invalid value: " + parsedVal + ", expecting from 1 to " + daysInMonth + "]");
+ }
cal.set(Calendar.DAY_OF_MONTH, parsedVal);
offset += 3;
if (lastOffset < offset) return cal;Trade-offs
- Invalid input only: strings that name a non-existent day now throw
ParseException, for queries and for documents. An application that relied on the rollover (for example appending-31for "end of month") gets an error instead of a slightly wrong range; worth a CHANGES entry. Every real date in the sweep parses as before: 5,107 (DEFAULT_CAL) and 5,115 (JAVA_UTIL_TIME_COMPAT_CAL) of 5,376 day-precision strings per template round-trip before and after. Other precisions and years were not swept (the module's 120 tests pass with the fix). - Calendar-aware:
getActualMaximumfollows the template's calendar system, so1500-02-29stays valid underDEFAULT_CAL(Julian leap year) and is rejected under the prolepticJAVA_UTIL_TIME_COMPAT_CAL. Ajava.time-based check would get the first case wrong. - Shipped templates only: validated with
DEFAULT_CALandJAVA_UTIL_TIME_COMPAT_CAL; the constructor accepts any caller-suppliedCalendartemplate, and customCalendarsubclasses were not tested. - Leaves the 1582 cutover gap: under
DEFAULT_CAL,1582-10-05..1582-10-14(days the hybrid calendar skips) still move forward 10 days. Out of scope here; the round-trip option covers it. - Keeps hour 24 (
2014-11-30T24->2014-12-01T00) and the looserT24:30form unchanged. - Cost: one
Calendarclone and onegetActualMaximumper parsed value with a day; parse path only. Not benchmarked. - No reindexing: indexes built from valid strings are unchanged; a value indexed earlier from an impossible string keeps the date it rolled to.
- The message mirrors
parseAndCheck's, including its stray]; a PR may tidy both.
What the checks validated
- Stock (scratch worktree, gradle, seed DEADBEEF): the repro fails 2 of 3 with assertions on and off (
testControlspasses); the 3 closest suites (TestDateRangePrefixTree,TestDateNRStrategy,TestNumberRangeFacets) pass, 20 tests. - With the fix: the repro passes 3 of 3 with assertions on and off; the 3 closest suites pass, 20 tests; the whole spatial-extras test suite with the repro in place passes, 27 classes, 120 tests, 5 skipped, 0 failures;
TestDateRangePrefixTreeandTestDateNRStrategywith 20 iterations pass, 380 tests;ecjLintMain,ecjLintTestandcheckGoogleJavaFormatof the module pass. - Sweep (classpath runner, main jars with the fixed class shadowed): every non-existent day with
JAVA_UTIL_TIME_COMPAT_CAL(93 of 93) and every month-length case withDEFAULT_CAL(91 of 91) now throwsParseException; all 5,107 / 5,115 real dates still round-trip;2014-11-30T24still reads2014-12-01T00;1500-02-29stays valid underDEFAULT_CALonly. The 10 cutover-gap days ofDEFAULT_CALstill roll. - Earlier hunt runs with the same fix: the finder's test passes and the upstream date suites pass 100 of 100; the independent reproducer's 3 tests pass 3 of 3 (also in the adjudication's re-run).
After parsing, compare every parsed field with what a clone's get() returns and throw on any difference, special-casing hour 24.
Trade-offs
Catches every rollover, including the 10 cutover-gap days of DEFAULT_CAL, but is more code (the method returns early after each field) and must keep hour 24 and the isSet() state right. Not run.
Call setLenient(false) on the working calendar.
Trade-offs
The error then surfaces later, as an IllegalArgumentException (MONTH: 10 -> 11) from toString/toShape instead of a ParseException at parse time, and the deliberate hour 24 is rejected too (executed by the verifiers on stock main with a strict template). Not recommended.
State in the parseCalendar and parseShape javadoc that a day past the month's end rolls into the next month.
Trade-offs
No behaviour change; leaves the inconsistency with day 32 and the silent extra matches.
Check the date part with java.time.LocalDate.parse before passing the string (it rejects 2014-11-31 and 2014-02-29).
Trade-offs
java.time is proleptic Gregorian, so for dates before 1582 under DEFAULT_CAL it disagrees with the tree's own calendar.
Solr's DateRangeField is built on this tree and passes range strings to parseShape (read from code, not run), so a Solr range query or rule written with a non-existent day, such as [2014-11-01 TO 2014-11-31], silently covers the next month's first days instead of failing.
Found by reading the parser while writing the java.time reference for an exhaustive check of DateRangePrefixTree: the reference rejects dates such as 2014-02-30 that the parser accepts. A direct public-API probe and a JUnit on the stock jars confirmed it.
The method in full128 words
Method: code reading (non-formal tier), in a hunt unit on spatial-extras range trees and counters.
- The unit was building a bounded-exhaustive harness for
DateRangePrefixTreewith ajava.timereference model, which rejects dates such as 2014-02-30. ReadingparseCalendarwhile writing it showed that the day is checked only against 1..31. - A direct public-API probe showed the parser accepts it and rolls it into March; a JUnit on the stock main jars, with the day-32 rejection as its control, then failed with assertions on and off, and on 10.5.2 and 9.12.3.
- It was then checked by an independent reproducer, an intended-behaviour devil's advocate, read-only reviews by a model from another family (OpenAI GPT-5.6), an adjudication, a history, prior-report and security follow-up, and an audit. Found 2026-10-26 on main
a9779ef3c193.
No prior report found (2026-10-26, re-checked 2026-10-28): 149 logged queries over GitHub, JIRA, the mailing lists and the web, and a changed-file scan of all 407 open PRs (one list incomplete). The closest item is LUCENE-8640 (#9686), which added the 1..31 day check and never discussed month length.
Searches4 searches
Verdict new: no report by anyone. Keyword search cannot prove absence; GitHub search does not index PR review comments (only the LUCENE-8640 review was read); the file list of open PR #16911 (7,630 files) exceeds the API limit. Nearest items, none of them this bug: #9686 (LUCENE-8640) and apache/lucene-solr#538, which added the per-field bounds; SOLR-13139, the delimiter report that started it; #8333 (LUCENE-7278), the configurable template calendar; SOLR-9085, pre-1582 dates (context for the cutover gap); #6710 (LUCENE-5648), the original code, whose comment admits loose separators, not loose days; #9831, #8865 and the open #12295, all different.
| Where | When | How | Found |
|---|---|---|---|
| GitHub issues, PRs and commits (apache/lucene and downstream repositories), dev@, java-user@, issues@, commits@, the JIRA archive, the web | 2026-10-26 | the hunt's prior-report tool, two runs: 92 queries (class and method names, error messages, leap year / day of month / month length / rollover / lenient / "February 30" / "2014-11-31") | no report; only LUCENE-8640, LUCENE-7278, LUCENE-8787 and LUCENE-7814 |
| apache/lucene, apache/solr, two other downstream search engines, all of GitHub | 2026-10-26 | 19 manual GitHub searches; the LUCENE-8640 PR review (apache/lucene-solr#538) read directly | no report; the review covers delimiter messages and test placement |
| JIRA (LUCENE, SOLR), mailing lists, the web | 2026-10-26 | 10 JIRA queries with 11 issues read in full, 14 list queries with 6 messages read, 11 web queries | no issue mentions an impossible day of month |
| all 407 open apache/lucene PRs; upstream main | 2026-10-26, re-checked 2026-10-28 | changed-file lists (PR #16911's 7,630-file list incomplete); read-only API: commits on DateRangePrefixTree.java, issue search for new reports | only #12295 touches the file (instanceof cleanup); last commit on the file 2026-06-14; no new issue |
The rollover is as old as the parser: LUCENE-5648 (bb267a143e0, 2014), first released in 5.0.0, checked no field at all. LUCENE-8640 (8e69d12dd7e, 2019, 7.7.0) added the fixed 1..31 day check without a month-length check. Executed on 10 releases from 5.0.0 to 10.5.2 and on main: all roll over.
Versions tested5 rows
Behaviour since bb267a143e0 "LUCENE-5648: DateRangePrefixTree and NumberRangePrefixTreeStrategy" (by dsmiley, 2014-06-05, svn era; branch_5x backport 55594bad9b0, 2014-09-18): the parsed numbers were set on a lenient Calendar with no checks, so day 32 and month 13 rolled over too.
Inconsistency since 8e69d12dd7e "LUCENE-8640: date range syntax validation" (by mkhludnev, authored 2019-01-26, committed 2019-01-27; backports d482312b854 branch_8x and 62ee5df5e17 branch_7x; issue #9686; contributed PR apache/lucene-solr#538, committed directly), which added parseAndCheck with the day as 1..31; f543b4e1f47 (2019-01-28) set minutes and seconds to 0..59. git blame -w (and GitHub's blame view, which skips the reformat listed in .git-blame-ignore-revs) traces today's day check to 8e69d12dd7e; plain git blame shows the reformat 2cbf261032d on :498. The only later edits to the day-parsing lines are that reformat and a rename (54283150a81). Other commits touched the file after LUCENE-8640 (for example 424558ff880, LUCENE-8787, the fraction parsing), not the day check. Not a regression; never fixed. Releases between the executed ones were not run; the day-parsing lines changed only by formatting. 4.10.4 has no DateRangePrefixTree.
| Version or commit | Verdict | Result |
|---|---|---|
main a9779ef3c193 | affected | rolls over with both template calendars; this doc's test fails 2 of 3 (assertions on and off); passes 3 of 3 with the fix |
| 10.0.0, 10.5.2 | affected | rolls over; the finder's test fails as claimed; this doc's test fails 2 of 3 on 10.5.2 |
| 9.0.0, 9.12.3 | affected | rolls over; the finder's test fails as claimed (on 9.0.0 an adapted copy, only the LuceneTestCase import changed); this doc's test fails 2 of 3 on 9.12.3 |
| 7.7.0, 8.0.0, 8.11.4 | first with the day-32 check: 7.7.0 | rolls over while day 32 is rejected (LUCENE-8640); the finder's test (adapted copy, only the LuceneTestCase import changed) fails as claimed |
| 5.0.0, 6.0.0, 7.6.0 | first release 5.0.0 | rolls over; day 32 is accepted too (no field checks yet) |
An independent reproducer and an intended-behaviour devil's advocate reproduced it with their own tests, controls passing; read-only reviews by a model from another family (OpenAI GPT-5.6) ran no code (one real, one not real on procedure only); an adjudication confirmed it with medium confidence, and this doc's gradle runs reproduce it and validate the fix.
Checks and verdicts510 words
Every executed check reproduced the rollover on stock main; every control passed, and every fix run passed. Neither cross-model review could run Java (reading only); a separate run re-ran the repro for each and it failed as claimed.
| Check | Verdict | What it found |
|---|---|---|
| finder | candidate | Public-API probe and a one-method JUnit (compat calendar, day-32 control): fails 1 of 1 on main (-ea, -da), 10.5.2 and 9.12.3; passes with the fix; the upstream date suites pass 100 of 100 with the fix. |
| cross-model review, refute lens (OpenAI GPT-5.6) | not real (procedural), medium | Could not run code; its static reading agrees with the lenient-Calendar root cause. Its substantive point, that an impossible date is outside toString's output so a fix is hardening, is the devil's-advocate case above. A separate re-run fails as claimed. |
| independent reproducer | real | Own 3-method JUnit in another package: 2 of 3 fail on main (-ea, -da), 10.5.2 and 9.12.3 with both templates, controls pass; added the query-level effect (the December 1st document matches); 3 of 3 pass with the fix; found the 1582 cutover case and that a strict template throws IllegalArgumentException. |
| cross-model review, intended lens (the cross-model review, OpenAI GPT-5.6) | real, medium; intended argument moderate | Read-only, ran no code; root cause agreed and inputs judged in contract. A separate re-run fails as claimed. |
| intended-behaviour devil's advocate | real, medium | Built the strongest intended case (lenient Calendar, syntax-only LUCENE-8640, hour 24, lenient DateTools and query parsers) and found it does not survive; probes of a strict template, java.time and the downstream formatter; the finder's test fails on main (-ea, -da), 10.5.2 and 9.12.3. |
| adjudication | confirmed, medium | Re-ran the independent reproduction's test: 2 of 3 fail with -ea and -da, 3 of 3 pass with the fix. Medium because no javadoc sentence promises rejection and the class is experimental. |
| Follow-up and audit | keep | History probe on 10 releases and main, prior-report and security follow-ups, registry re-runs and an independent credibility audit: the finder's test fails 1 of 1 on main with -ea and -da. |
| this doc | reproduced; fix validated | Gradle in a scratch worktree: stock 2 of 3 fail (assertions on and off), fixed 3 of 3 pass and the whole module passes (120 tests); a classpath sweep of 5,376 day strings per template calendar shows 93 and 101 accepted non-existent days on stock and 0 and 10 (the 1582 cutover gap) with the fix. |
| fact checks of this doc | reproduced; wording corrected | An independent fact check re-ran the doc's gradle command on a copy of a9779ef3c193: 2 of 3 fail with identical messages (assertions on and off, and a second seed); with fix.diff 3 of 3 pass and the whole module passes (120 tests, 5 skipped); the doc's test also fails 2 of 3 on the 10.5.2 and 9.12.3 release jars. A second, read-only review by a model from another family (OpenAI GPT-5.6) confirmed the mechanism, counts and fix and flagged wording that overstated the scope. The corrections of both are applied. |
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.
- PNG 442 KB
Finding record, Lucene and upstream sources, repro files and diagram notes1,433 words
- Registry title
- DateRangePrefixTree.parseCalendar (and parseShape) rejects day 32 but accepts a day that its month does not have (2014-11-31, 2014-02-30, 2013-02-29) and silently rolls it into the next month, so the range [2014-11-01 TO 2014-11-31] also matches December 1st
- Area
- spatial-extras / prefix trees (DateRangePrefixTree date parsing: parseCalendar, also reached through NumberRangePrefixTree.parseShape for single dates and range bounds)
- Priority
- Low, rank 425: An opt-in, experimental spatial-extras parser silently rolls impossible dates like 2014-11-31 into the next month so a query matches up to 3 extra days, but it needs an invalid date string and has gone unreported for about 11 years.
- Impact class
- wrong error
- Impact note
- Hits applications that use the opt-in, @lucene.experimental DateRangePrefixTree of the spatial-extras module (in practice also Solr's DateRangeField, which is built on it; read from code, not executed) and parse a date or range string that names a day its month does not have (day 29-31 in a short month, February 29 of a common year), with either shipped template calendar and no scale: instead of the ParseException that day 32 or month 13 gets, the value silently becomes a real date up to 3 days later, so a query range such as [2014-11-01 TO 2014-11-31] also matches documents dated 2014-12-01 and an indexed value is indexed as the later date; valid strings are unaffected and nothing crashes or corrupts; deterministic, -ea and -da, every release from 5.0.0 (the day-32 check that makes the gap inconsistent exists since 7.7.0).
- Summary
- You parse a date such as 2014-11-31, or a range such as [2014-11-01 TO 2014-11-31], with DateRangePrefixTree.parseCalendar or parseShape and expect a ParseException, which the parser has given since LUCENE-8640 for day 32, month 13, hour 25 and minute 60. Instead the day is checked only against 1..31 and set on a lenient java.util.Calendar, which moves it into the next month when the fields are read: 2014-11-31 becomes 2014-12-01, 2014-02-30 becomes 2014-03-02, and the range parses to [2014-11 TO 2014-12-01], so an Intersects query on a NumberRangePrefixTreeStrategy index also returns a document dated 2014-12-01.
- Introduced
- bb267a143e009f7b1375f7fa9755b76fcc82ec4f (trunk, svn r1600555, 'LUCENE-5648: DateRangePrefixTree and NumberRangePrefixTreeStrategy', author GitHub dsmiley; branch_5x got it through the bulk backport 55594bad9b0, LUCENE-5944, 2014-09-18; no PR, svn era, JIRA LUCENE-5648 is now GitHub #6710): the parser set the parsed day on a lenient Calendar from the start, at first with no field validation at all (day 32 and month 13 rolled over too). The specific inconsistency, day 32 rejected but a day past the month's end rolled over, dates from 8e69d12dd7e2b6d2341322e4ac7e5c33b789507a ('LUCENE-8640: date range syntax validation', committed by GitHub mkhludnev, 2019-01-26; backports d482312b854 branch_8x and 62ee5df5e17 branch_7x; issue #9686; the contributed PR apache/lucene-solr#538 was closed and the change committed directly), which added parseAndCheck with fixed per-field bounds (day 1..31) and no month-length check [read: git blame of the current day check, git log on the file; exec: the version-neutral probe shows day 32 accepted on 7.6.0 and rejected on 7.7.0]. Not a regression; later edits to these lines are formatting and renames only (2cbf261032d reformat, 54283150a81 checkDelimeter -> checkDelimiter) (2014-06-05 (bb267a143e00, trunk); 2014-09-18 (branch_5x backport); 2019-01-26 (8e69d12dd7e, LUCENE-8640 per-field validation without a month-length check))
- First affected version
- 5.0.0 [exec] (first release with DateRangePrefixTree; up to 7.6.0 no field was validated, so day 32 and month 13 rolled over as well); 7.7.0 [exec] is the first release in which day 32 is rejected while a non-existent day is still rolled over (LUCENE-8640 shipped in 7.7.0 and 8.0.0); 4.10.4 and earlier have no DateRangePrefixTree [exec: the probe does not compile against lucene-spatial 4.10.4]
- Affected versions
- Every release from 5.0.0 through 10.6.0 (10.6.0 by reading) and main a9779ef3c193, never fixed. [exec] the follow-up's version-neutral main() probe shows 2014-02-30 -> 2014-03-02, 2014-11-31 -> 2014-12-01, 2013-02-29 -> 2013-03-01 and 2014-04-31T10 -> 2014-05-01T10 on 5.0.0, 6.0.0, 7.6.0, 7.7.0, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main (on main with both template calendars), with day 32 accepted up to 7.6.0 and rejected from 7.7.0, and passes on main with the fix shadowed; the finder's JUnit (with the pre-9.1 test-framework package for 7.x-9.0) fails as claimed on 7.7.0, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0, 10.5.2 and main (-ea and -da on main) and on 7.6.0 fails earlier, at its day-32 control; the independent reproduction's JUnit fails 2 of 3 on main (-ea and -da), 10.5.2 and 9.12.3. [read] releases between the executed points: the day-parsing lines changed only by formatting and renames since 8e69d12dd7e; upstream main a95c754ee003 (2026-10-26) has no parsing change and the only open PR touching the file (#12295) replaces instanceof casts, so 10.6.0 (released 2026-10-29) and the unreleased 11.0 line are affected (not executed). Not executed: x86 and older JDKs (pure Java Calendar logic; lenient GregorianCalendar rollover is long-standing JDK behaviour). JDK 25.0.3, aarch64
- 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 and voted “not real” while agreeing with the root cause (the adjudication 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: code reading
- Upstream and prior reports
- none found (2026-10-26, read-only; the prior-report record verdict new, 149 logged query records: two scripted prior-report searches with 92 queries over apache/lucene issues, PRs and commits, downstream repositories, the dev@, java-user@, issues@ and commits@ lists, the JIRA archive and the web; 19 manual GitHub searches in apache/lucene, apache/solr, elastic/elasticsearch, opensearch-project/OpenSearch and all of GitHub; 10 JIRA searches over LUCENE and SOLR with 11 issues read in full; 14 Lucene/Solr list searches with 6 messages read; 11 web searches; the LUCENE-8640 PR review thread; and the changed-file lists of all 407 open apache/lucene PRs; the finder's 51-query run and the intended-behaviour check's 40-query intent run agree). Closest items, none the same bug: #9686 (LUCENE-8640, added the per-field 1..31 day bound; neither the issue nor the PR review apache/lucene-solr#538 discusses month length, leap years or rollover), SOLR-13139 (the delimiter report that started LUCENE-8640), #8333 (LUCENE-7278, configurable template Calendar), SOLR-9085 (DateRangeField before 1582), #9831 (LUCENE-8787, fraction digits), #8865 (LUCENE-7814, huge years), #6710 (LUCENE-5648, the original code, whose 'NOTE: We aren't validating separator chars' comment admits loose separators, not loose days). Not fixed upstream: no parsing change on upstream main a95c754ee003; the only open PR touching DateRangePrefixTree.java (#12295) rewrites two instanceof casts. Confidence high for GitHub, JIRA and the ASF lists, medium for the open web. Update 2026-11-02: #16911 ('Decommission branch 10x', open when searched) merged 2026-10-29 as 48cf3f515d6, which emptied branch_10x to a README tombstone (10.6 is the last 10.x minor; 10.x fixes go to branch_10_6); it fixes nothing here.
- Lucene commit tested
- main
a9779ef3c193(runs of 2026-10-28): this doc's gradle runs in a scratch worktree (stock and fixed, assertions on and off, the module's tests) and its classpath sweep of every day 1-32 of every month in 14 years (stock and fixed); the hunt's runs on 5.0.0, 6.0.0, 7.6.0, 7.7.0, 8.0.0, 8.11.4, 9.0.0, 9.12.3, 10.0.0 and 10.5.2 (Maven Central jars). OpenJDK 25.0.3 on Linux aarch64.
- Finding record
lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/DateRangePrefixTree.java:39 (at a9779ef3c193) (@lucene.experimental), :66-71 (DEFAULT_CAL = Calendar.getInstance(UTC, ROOT), lenient), :77-87 (JAVA_UTIL_TIME_COMPAT_CAL, a lenient GregorianCalendar), :308-334 (toShape reads the fields with cal.get(), which normalizes a lenient day), :386 (toString(Calendar) 'consistent with DateTimeFormatter#ISO_INSTANT'), :462-469 (parseCalendar javadoc 'The reverse of toString(java.util.Calendar)', '@param str not null and not empty'), :498-499 (parseAndCheck(str, offset, 1, 31) then cal.set(Calendar.DAY_OF_MONTH, parsedVal)), :505 (hour 0..24), :534-538 (catch -> ParseException), :548-555 (parseAndCheck: fixed bounds only)lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/NumberRangePrefixTree.java:203-205 (at a9779ef3c193) (parseShape '@throws java.text.ParseException If there is a problem'; range bounds go through parseUnitShape)lucene/spatial-extras/src/test/org/apache/lucene/spatial/prefix/tree/TestDateRangePrefixTree.java:282-351 (at a9779ef3c193) (testInvalidDateException: ParseException for 2000-13-12, 2000-13-41T13Z, 2000-11-12T25Z and the :61 variants, all rejected at month 13 or hour 25; no upstream test parses a day missing from its month)lucene/CHANGES.txt:6973 (at a9779ef3c193) ('LUCENE-8640: Date Range format validation', in the 7.7.0 section)
- The finding in 30 seconds
lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/DateRangePrefixTree.java:62-87, 236-238, 308-334, 391, 396-429, 457-459, 469-539 (:472, :491-492, :498-499, :534-538), 548-555lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/NumberRangePrefixTree.java:117-128, 205-221
- Who is affected
lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/DateRangePrefixTree.java:39; NumberRangePrefixTreeStrategy.java:42
- Why it looks unintended
lucene/CHANGES.txt:6891, 6949, 6973 (LUCENE-8640 in the 7.7.0 Improvements section)lucene/spatial-extras/src/test/org/apache/lucene/spatial/prefix/tree/TestDateRangePrefixTree.java:282-353 (testInvalidDateException)lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/DateRangePrefixTree.java:386, 462; tree/NumberRangePrefixTree.java:203
- Suggested fix
lucene/spatial-extras/src/java/org/apache/lucene/spatial/prefix/tree/DateRangePrefixTree.java:35-37, 534-538, 548-555
- TestDateRangeNonexistentDay.java (JUnit test):
repro/LFV-619/src/org/apache/lucene/spatial/prefix/tree/TestDateRangeNonexistentDay.java(35 lines)
The query returns 2014-11-15, 2014-11-30 and 2014-12-01 (2014-12-02 is not returned); the repro, with three documents, returns 2014-11-15 and 2014-12-01.
assets/LFV-619/bug.png(442 KB)
![Diagram of the bug: the November 2014 calendar has days 1 to 30. The parser checks the day only against 1 to 31, so 2014-11-32 is rejected with a ParseException while 2014-11-31 is accepted and moved by the lenient Calendar to 2014-12-01. The range [2014-11-01 TO 2014-11-31] therefore becomes [2014-11 TO 2014-12-01] and also matches the document dated 2014-12-01.](../assets/LFV-619/bug.png)