All findings · Lucene finding · LFV-619 · daterange-parsecalendar-accepts-nonexistent-day-of-month

DateRangePrefixTree rejects day 32 but silently rolls a day its month does not have into the next month, so [2014-11-01 TO 2014-11-31] also matches December 1st

The spatial-extras DateRangePrefixTree rejects 2014-11-32 with a ParseException but reads 2014-11-31 as December 1st, so the range [2014-11-01 TO 2014-11-31] also matches a document dated 2014-12-01. In every release since 5.0.0; a 9-line month-length check fixes it.

Summary

Priority
Low, rank 425An 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
wrong error
Since
5.0.0 (LUCENE-5648, 2014); day 32 rejected since 7.7.0introduced 2014-06-05 · over 12 years
Affected
5.0.0 to 10.6.0 (10.6.0 by reading) and main
Status
documented
Found by
code reading for an exhaustive date-tree check

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

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.

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.
The day check against a real month: day 32 is rejected, but day 31, which November does not have, is moved to December 1st, so "all of November" also covers December 1st. Select the still to open it at full size. Generated with AI, checked against the code.
  1. You index three documents dated 2014-11-15, 2014-12-01 and 2014-12-02 with NumberRangePrefixTreeStrategy over DateRangePrefixTree, and search with the range [2014-11-01 TO 2014-11-31], a natural way to write "all of November".
  2. You expect a ParseException: November has 30 days, and the same parser rejects 2014-11-32 and 2014-13-01 with one.
  3. You get no error: the range becomes [2014-11 TO 2014-12-01] and the query returns 2014-11-15 and 2014-12-01.
  4. 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-30 becomes 2014-03-02 and 2013-02-29 becomes 2013-03-01, at most 3 days past the month's end.

Who is affected

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.

Terms

  • DateRangePrefixTree: a spatial-extras prefix tree for dates and date ranges. NumberRangePrefixTreeStrategy indexes and queries its shapes; parseCalendar reads one date (2014-11-30, 2014-11-30T10, ...) and parseShape also reads a range [a TO b].
  • Template calendar: the tree clones a java.util.Calendar for every value. The two shipped templates, DEFAULT_CAL and JAVA_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.

Step 1: the day is checked against 1..31 only

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.

Step 2: reading the fields rolls the day over

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.

Step 3: ranges

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.

How far it shifts (executed sweep: every day 1-32 of every month in 14 years from 1 to 9999)

templatenon-existent days acceptedreal dates that round-tripday 32
JAVA_UTIL_TIME_COMPAT_CAL93 (all past the month's length)5,115 of 5,115168 of 168 rejected
DEFAULT_CAL101 (91 past the month's length + the 10 days of the October 1582 cutover gap)5,107 of 5,107168 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 DateRangePrefixTree with NumberRangePrefixTreeStrategy and 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 -31 covers the next month's first day for every 30-day month. A document value 2014-11-31 is indexed as 2014-12-01.
  • Downstream (code reading only, not executed): Solr's DateRangeField builds this tree on JAVA_UTIL_TIME_COMPAT_CAL and sends range strings to parseShape, and single values without Z that do not start with NOW to parseCalendar, for documents and queries. Values with Z go 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; DateRangePrefixTree and NumberRangePrefixTreeStrategy are @lucene.experimental (DateRangePrefixTree.java:39, NumberRangePrefixTreeStrategy.java:42). Within it nothing special is needed: either shipped template calendar, any IndexWriterConfig and 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.parse for modern dates (it rejects these strings). A non-lenient template calendar is no clean way out: 2014-11-31 then fails with IllegalArgumentException: MONTH: 10 -> 11 from toString/parseShape, later than and unlike the fixed-field checks, which throw ParseException at parse time; and the deliberate T24 is rejected too.

Minimal repro

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.

TestDateRangeNonexistentDay.java (JUnit test)35 lines
/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.lucene.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));
  }
}
Command
# 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: ParseException for 2014-11-31 (as for 2014-11-32); actual: parses to 2014-12-01, with -ea and -da.

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

More files, environment, full output37 words

Environment

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

Observed output

# Lucene main 645d4fe14a20, assertions on (-ea), seed DEADBEEF: 1 test(s) run, 1 failed
    => java.lang.AssertionError: 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)

Why it looks unintended

  • Since LUCENE-8640 (7.7.0) the parser rejects every other out-of-range field value: day 32, month 13, hour 25, minute 60.
  • parseCalendar reverses toString, documented as ISO_INSTANT-consistent; Instant.parse rejects 2014-11-31 but accepts T24: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) added parseAndCheck for 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 throw ParseException (executed controls). The upstream TestDateRangePrefixTree.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 parseCalendar is "The reverse of toString(java.util.Calendar)" (DateRangePrefixTree.java:462); toString(Calendar) is "consistent with DateTimeFormatter#ISO_INSTANT" (:386). Instant.parse rejects 2014-11-31T00:00:00Z and 2014-02-29T00:00:00Z and accepts 2014-11-30T24:00:00Z as the next midnight, the rollover the parser allows on purpose (hours 0..24). The parser's looser T24:30 (read as 00:30 of the next day) is rejected by Instant.parse and is not part of this claim.
  • The range parser promises an exception: parseShape "@throws ParseException If 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.

Suggested fix

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 -31 for "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: getActualMaximum follows the template's calendar system, so 1500-02-29 stays valid under DEFAULT_CAL (Julian leap year) and is rejected under the proleptic JAVA_UTIL_TIME_COMPAT_CAL. A java.time-based check would get the first case wrong.
  • Shipped templates only: validated with DEFAULT_CAL and JAVA_UTIL_TIME_COMPAT_CAL; the constructor accepts any caller-supplied Calendar template, and custom Calendar subclasses 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 looser T24:30 form unchanged.
  • Cost: one Calendar clone and one getActualMaximum per 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 (testControls passes); 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; TestDateRangePrefixTree and TestDateNRStrategy with 20 iterations pass, 380 tests; ecjLintMain, ecjLintTest and checkGoogleJavaFormat of 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 with DEFAULT_CAL (91 of 91) now throws ParseException; all 5,107 / 5,115 real dates still round-trip; 2014-11-30T24 still reads 2014-12-01T00; 1500-02-29 stays valid under DEFAULT_CAL only. The 10 cutover-gap days of DEFAULT_CAL still 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).

Round-trip field check (also covers the 1582 cutover gap)

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.

Parse on a non-lenient clone of the template

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.

Document the leniency

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.

Workaround for users today (not a fix): validate the date first

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.

Downstream note

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.

How it was found

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 DateRangePrefixTree with a java.time reference model, which rejects dates such as 2014-02-30. Reading parseCalendar while 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.

Prior reports, history and checks

Prior reports

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.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene and downstream repositories), dev@, java-user@, issues@, commits@, the JIRA archive, the web2026-10-26the 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 GitHub2026-10-2619 manual GitHub searches; the LUCENE-8640 PR review (apache/lucene-solr#538) read directlyno report; the review covers delimiter messages and test placement
JIRA (LUCENE, SOLR), mailing lists, the web2026-10-2610 JIRA queries with 11 issues read in full, 14 list queries with 6 messages read, 11 web queriesno issue mentions an impossible day of month
all 407 open apache/lucene PRs; upstream main2026-10-26, re-checked 2026-10-28changed-file lists (PR #16911's 7,630-file list incomplete); read-only API: commits on DateRangePrefixTree.java, issue search for new reportsonly #12295 touches the file (instanceof cleanup); last commit on the file 2026-06-14; no new issue

History

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.

Versions and branches executed, with results
Version or commitVerdictResult
main a9779ef3c193 affectedrolls 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 affectedrolls 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 affectedrolls 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.0rolls 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.0rolls over; day 32 is accepted too (no field checks yet)

Independent checks

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.

Each independent check and its verdict
CheckVerdictWhat it found
finder candidatePublic-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), mediumCould 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 realOwn 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 moderateRead-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, mediumBuilt 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, mediumRe-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 keepHistory 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 validatedGradle 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 correctedAn 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.

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

Finding record

LFV-619, snapshot of 2026-11-06

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.

Lucene and upstream sources

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-555
  • lucene/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

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

Repro files

About the diagrams

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.

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.