All findings · Lucene finding · LFV-095 · parent-field-docvalues-update-not-rejected

IndexWriter accepts doc-values updates of the reserved parent field, which breaks index-sorted document blocks

With index sorting and document blocks, IndexWriter marks the last document of each block in a reserved parent field, yet its doc-values update methods let the application rewrite that mark. One updateNumericDocValue call on a 2-document block is accepted and leaves a segment that fails CheckIndex.

Summary

Priority
Low, rank 1163IndexWriter accepts doc-values updates of the reserved parent field, which can damage index-sorted block indexes, but only through out-of-contract misuse, with a cheap reject fix.
Impact
data corruption
Since
9.10.0 (2024, with setParentField)introduced 2024-01-11 · about 2 years
Affected
every release from 9.10.0 to 10.6.0, and main
Status
documented
Found by
an Alloy model of index sorting with blocks plus code reading, then a JUnit repro

AI-assisted page: this finding was first signalled by a semi-formal check (an exact check of the real jar, or a modelling hypothesis that a tool then witnessed) 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

Background: document blocks in a sorted index

  1. Index sorting keeps the documents of every segment in the order of a sort, here a long field sort.
  2. addDocuments indexes a block, children first and the parent last; the block must stay together through sorting and merging.
  3. To find where blocks end, IndexWriter writes an internal mark, the parent field named by IndexWriterConfig#setParentField, on the last document of every block. A block sorts by its parent's value.
Diagram of the bug: an index sorted on the long field sort with parent field _parent. One block, a child with sort 9 and its parent with sort 1; IndexWriter marks only the parent, so the block sorts by 1. A document that sets _parent is rejected as a reserved field, but updateNumericDocValue(id:child, _parent, -1) is accepted. After the commit the child carries a mark too, the segment reads as two blocks, [child] sort 9 then [parent] sort 1, and CheckIndex reports: segment has indexSort=<long: "sort"> but docID=0 sorts after docID=1. With the fix the update is rejected with IllegalArgumentException.
A 2-document block in a sorted segment: IndexWriter's parent mark on the parent, an accepted update that marks the child too, and CheckIndex reading two blocks out of order. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. Sort the index on a long field sort and set setParentField("_parent"). Add one block with addDocuments: a child (sort=9) and its parent (sort=1), and commit. IndexWriter marks the parent in _parent; the whole block sorts by its parent's value, 1.
  2. Call updateNumericDocValue(id:child, "_parent", -1) and close the writer. You expect an IllegalArgumentException: a document that contains _parent is rejected as "a reserved field", and an update of the sort field is rejected too.
  3. Instead the call is accepted. The child now carries a parent mark, so the segment reads as two blocks, [child] (sort 9) then [parent] (sort 1), which is out of order: CheckIndex reports segment has indexSort=<long: "sort"> but docID=0 sorts after docID=1.
  4. Removing a parent's mark instead (updateDocValues with a null value) is accepted too. If that block was the last one of its segment and every document there has a sort value, the next forceMerge throws EOFException and closes the writer; once the update has been committed, writers opened later fail the same way, while reads still work. Otherwise the orphaned documents silently join the next block.
  5. It fails with assertions on and off. Affected: every release from 9.10.0 (the first with setParentField) to 10.6.0, and main. Only an application that writes its own configured parent field can trigger it.

Who is affected

Applications that combine index sorting, document blocks and setParentField (opt-in; required for blocks with a sort since 10.0) and that send a doc-values update naming their own parent field. Untrusted documents cannot: the document path rejects the field. Unlikely, but silent at the call; it surfaces as a broken segment or a writer that can no longer merge.

Mechanism, terms and numbers835 words

With an index sort and IndexWriterConfig#setParentField, IndexWriter writes a reserved numeric doc-values mark on the last document of every block, and CheckIndex and merges use those marks to find block boundaries. A document that contains the field is rejected as "a reserved field", but IndexWriter's doc-values update methods check only index-sort fields, so an application can overwrite or erase a mark. Giving a child a mark splits its block inside an already sorted segment: one updateNumericDocValue call on a committed 2-document block leaves a segment that CheckIndex rejects (segment has indexSort=<long: "sort"> but docID=0 sorts after docID=1), with no merge. Removing a parent's mark leaves its block unmarked: if that block was the last one of its segment and every document there has a sort value, the next merge throws EOFException and closes the writer, and once the update is committed later writers fail the same way; otherwise the orphans silently join the next block. Every release from 9.10.0 to 10.6.0 and main is affected. Only an application that writes the field it configured as its parent field can trigger it, and cross-model reviews call that out of contract, so it reads best as missing input validation. Low priority; a 16-line check fixes it.

Terms

  • Index sort: IndexWriterConfig#setIndexSort. Every segment keeps its documents in that order, and CheckIndex checks it.
  • Document block: documents added together with addDocuments, children first and the parent last. IndexWriter guarantees that sorting and merging never reorder or split a block.
  • Parent field: IndexWriterConfig#setParentField("_parent"). IndexWriter writes _parent = -1 on the last document of every block (IndexingChain.java:166-169, :604-609, :645-651). A block sorts by its parent's value. From 10.0.0 a parent field is required to combine blocks with an index sort (MIGRATE.md:875-881).

The bug

  1. A document that names the parent field is rejected: "_parent" is a reserved field and should not be added to any document (IndexingChain.java:616-619, :748-750).
  2. The doc-values update methods have no such check. updateNumericDocValue (IndexWriter.java:1962), updateBinaryDocValue (:1996), updateSortedNumericDocValue (:2037) and buildDocValuesUpdate (:2081, which serves updateDocValues, softUpdateDocument(s) and tryUpdateDocValue) reject only index-sort fields (:1965, :2003, :2041, :2098). Once a block has been indexed, _parent is an existing doc-values-only NUMERIC field, which FieldInfos.verifyOrCreateDvOnlyField (FieldInfos.java:562-647) accepts.
  3. CheckIndex.testSort walks only the documents that carry the parent field and compares each block's parent with the next (CheckIndex.java:1255-1282). Merges build block boundaries from the field's current values and give each document the sort value of the next marked document (MultiSorter.java:52-63, :166).

The numbers (the repro)

Before the update the segment is doc 0 = child (sort=9, no mark) and doc 1 = parent (sort=1, _parent=-1): one block, trivially sorted. updateNumericDocValue(id:child, "_parent", -1) gives doc 0 a mark, and closing the writer commits it as a doc-values generation of the same segment. testSort now sees two blocks, compares 9 with 1 and reports segment has indexSort=<long: "sort"> but docID=0 sorts after docID=1. Nothing re-sorts the segment. If the marked child had its parent's sort value, CheckIndex would stay clean.

Removing a mark

updateDocValues(term, new NumericDocValuesField("_parent", (Long) null)) on a parent is accepted too. Its block's documents then have no mark of their own, and the next merge gives them the sort value of the next marked document of their segment (MultiSorter.java:166). What follows depends on the layout:

  • The block was the last one of its segment, and every document there has a sort value (the layout of every earlier test that threw): nextSetBit returns NO_MORE_DOCS, and the first forceMerge(1) reads past the end of the sort values and throws RuntimeException(EOFException) (from DirectReader via IndexSorter$LongSorter; a plain ByteBuffersDirectory throws IndexOutOfBoundsException). The writer closes with a tragic exception. If the update was committed before the merge, writers opened later fail their first forceMerge(1) the same way (three tried), while readers still open and search; if the merge ran before any commit, the tragic close drops the update and a new writer merges fine.
  • Another block follows in the same segment, or some documents have no sort value: nothing throws and CheckIndex stays clean, but the orphans silently become members of the next block.

After a mark is added and a merge runs, a block join can attribute a child to another root.

Without an index sort, a verifier's control stayed clean through later merges; merge-time reorderers such as BPIndexReorderer, which also read the mark, were not tested. Changing a parent's mark to another non-null value is harmless. Once a block exists, updateBinaryDocValue and updateSortedNumericDocValue already fail with a type-mismatch message, because the field is NUMERIC. On an index with no documents yet, updateDocValues even creates _parent with the type it is given; after a binary one, the writer's next block fails with cannot change field "_parent" from doc values type=BINARY to inconsistent doc values type=NUMERIC.

Exposure and likelihood291 words
  • Who is exposed. Only applications that (a) use index sorting with document blocks and setParentField, and (b) send a doc-values update (updateNumericDocValue, updateDocValues, softUpdateDocument(s) or tryUpdateDocValue) that names the field they configured as the parent field. The default configuration has no parent field. Untrusted documents cannot trigger it (the document path rejects the field), but an application that passes user-chosen field names to these update methods would pass the trigger on; that was not ruled out.
  • Silent at the call. The update returns a sequence number like any other. The damage shows later: CheckIndex rejects the committed segment, or the next merge fails and closes the writer.
  • Lasting, once committed. In the tested remove-a-mark layouts (the unmarked block last in its segment, a sort value on every document, forceMerge(1)), once the update had been committed each writer opened later failed its first forceMerge and closed, while reads kept working (three reopened writers tried). When the merge ran before a commit, the tragic close dropped the update and a new writer merged fine. A natural merge chosen by the merge policy was not tested.
  • Wrong block membership. After a mark is added and a merge runs, ToParentBlockJoinQuery mapped a child to another block's root; a removed mark that does not make the merge fail silently regroups documents into the next block.
  • Scale. Two documents and one call; deterministic.
  • How likely. Low: no legitimate workflow needs to write the parent field. A code read of Elasticsearch, OpenSearch and Solr found no default-configuration path that sends such an update (Solr only if an administrator declares its internal _is_root_ field as an in-place-updatable docValues-only field; not executed).
  • Not claimed. Wrong top hits from sort-based early termination were never verified.

Minimal repro

One JUnit test on the public API: a 2-document block in a sorted index, one updateNumericDocValue on the parent field, then CheckIndex's index-sort check on the committed segment. No merge, one thread.

TestParentFieldDocValuesUpdate.java61 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.index;

import java.util.List;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.NumericDocValuesField;
import org.apache.lucene.document.StringField;
import org.apache.lucene.search.Sort;
import org.apache.lucene.search.SortField;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;

public class TestParentFieldDocValuesUpdate extends LuceneTestCase {

  public void testUpdateParentFieldOfChildKeepsIndexSorted() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      IndexWriterConfig iwc =
          new IndexWriterConfig()
              .setCodec(TestUtil.getDefaultCodec())
              .setIndexSort(new Sort(new SortField("sort", SortField.Type.LONG)))
              .setParentField("_parent");
      try (IndexWriter w = new IndexWriter(dir, iwc)) {
        Document child = new Document();
        child.add(new StringField("id", "child", Field.Store.NO));
        child.add(new NumericDocValuesField("sort", 9));
        Document parent = new Document();
        parent.add(new NumericDocValuesField("sort", 1));
        w.addDocuments(List.of(child, parent));
        w.commit();
        try {
          w.updateNumericDocValue(new Term("id", "child"), "_parent", -1);
        } catch (IllegalArgumentException e) {
          return;
        }
      }
      try (DirectoryReader reader = DirectoryReader.open(dir)) {
        CodecReader segment = (CodecReader) reader.leaves().get(0).reader();
        // expected: the update is rejected (IllegalArgumentException) or the segment stays in index-sort order; actual: out of order
        assertNull(CheckIndex.testSort(segment, iwc.getIndexSort(), null, false).error);
      }
    }
  }
}
Command
cp $REPRO/TestParentFieldDocValuesUpdate.java $LUCENE/lucene/core/src/test/org/apache/lucene/index/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.index.TestParentFieldDocValuesUpdate -Ptests.seed=DEADBEEF -Ptests.asserts=true
# same failure with -Ptests.asserts=false
Expected
The update is rejected with IllegalArgumentException, or the segment stays in index-sort order.
Observed
The update is accepted and CheckIndex.testSort reports segment has indexSort=<long: "sort"> but docID=0 sorts after docID=1 (assertions on and off).

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

More files, environment, full output238 words

Public API only (IndexWriter, IndexWriterConfig, CheckIndex.testSort, ByteBuffersDirectory; the test framework's LuceneTestCase and TestUtil.getDefaultCodec()). The test sits in org.apache.lucene.index next to the core tests but uses no package-private API. It passes if the update is rejected with IllegalArgumentException (the fixed behaviour) or if the segment stays in index-sort order. The update call itself does not throw on stock code; only the sort check fails.

The same test is the suggested regression test. With two lint-only edits (the unused catch parameter e renamed to _, the 133-column comment wrapped) it passes the core lint and still fails on stock main and passes with the fix.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT): fails with assertions on and off; with the fix: passes
jdkOpenJDK 25.0.3
osLinux aarch64

Observed output

# gradle, scratch worktree of 645d4fe14a20, seed DEADBEEF, -Ptests.asserts=true (environment lines cut)
TestParentFieldDocValuesUpdate > testUpdateParentFieldOfChildKeepsIndexSorted FAILED
    java.lang.AssertionError: expected null, but was:<org.apache.lucene.index.CheckIndex$CheckIndexException: segment has indexSort=<long: "sort"> but docID=0 sorts after docID=1>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:20EFBA983838F9D8]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.failNotNull(Assert.java:756)
        at org.junit.Assert.assertNull(Assert.java:738)
        at org.junit.Assert.assertNull(Assert.java:748)
        at org.apache.lucene.index.TestParentFieldDocValuesUpdate.testUpdateParentFieldOfChildKeepsIndexSorted(TestParentFieldDocValuesUpdate.java:57)

1 test completed, 1 failed

# -Ptests.asserts=false: the same failure and message
# with the fix: the update is rejected with IllegalArgumentException and the test passes (1 test, 0 failures)

# the remove-a-mark case (the hunt's second test, main ea57360912ed, -ea; its unmarked block is the last of its segment): the first forceMerge(1) after the update
java.lang.RuntimeException: java.io.EOFException
    org.apache.lucene.util.packed.DirectReader$DirectPackedReader1.get(DirectReader.java:204)
    org.apache.lucene.codecs.lucene90.Lucene90DocValuesProducer$5.longValue(Lucene90DocValuesProducer.java:1040)
    org.apache.lucene.index.IndexSorter$LongSorter.lambda$getComparableProviders$0(IndexSorter.java:298)
    org.apache.lucene.index.IndexSorter$ComparableValues$1.setTopValue(IndexSorter.java:103)
    [...] MultiSorter

Why it looks unintended

  • Lucene enforces the reservation elsewhere: a document that contains the field is rejected as "a reserved field".
  • Updates of index-sort fields are rejected, a guard added as a bug fix (LUCENE-7581: "would lead to corruption").
  • The mark exists "to maintain the API guarantee that the document order of a block is not altered by the IndexWriter" (setParentField javadoc).
The full argument258 words
  • Reserved, enforced by validation. IndexingChain.java:616-619 throws for a document that names the field; TestIndexSorting.testBlockContainsParentField asserts the message. The update API is a second public write path to the same field without that check.
  • The javadoc states the purpose (IndexWriterConfig.java:579-589): IndexWriter "will add an internal field to every root document", "In order to maintain the API guarantee that the document order of a block is not altered by the IndexWriter a marker for parent documents is required."
  • The same class is guarded, as a bug. LUCENE-7581 (2016) made IndexWriter reject doc-values updates of index-sort fields; its CHANGES entry says "since this would lead to corruption". #16565 (2026-08) added the missing guard to updateBinaryDocValue. TestIndexSorting.testBadDVUpdate asserts all four entry points.
  • The design discussion treats the field as IndexWriter's (#12829 review): its author called it "a field name that IW fully controls it's type and content" and "a purely internal field", and wrote "you can't use the parent field I think that is reserved for the top level"; a reviewer asked "are there any other restrictions on the use of the field? Could we clear up the javadoc to be more explicit?" and another answered "+1 to tighten up the language". Doc-values updates were not discussed. The #15960 commit message says the reserved mechanism existed "to guard the field name from external use".
  • Nothing permits it. No javadoc, MIGRATE entry, CHANGES entry or test covers a doc-values update of the parent field. The update javadoc (IndexWriter.java:1950-1961) asks only for an existing doc-values-only field, which _parent is.
The devil's-advocate check253 words

The cross-model reviews (OpenAI GPT-5.6, refute and works-as-designed lenses) made the strongest case: the parent field is documented as internal (IndexWriterConfig.java:579-589; MIGRATE.md:879, "The internally used parent field"), IndexWriter owns its values, and the application chose the name itself. Writing it through a generic API is mutation of writer-owned metadata, garbage in, garbage out; a missing check makes this hardening, not a correctness bug. Both rated it "not real", medium, while reproducing it and agreeing with the root cause and the fix. The cross-model fact check of this doc rated this case strong: a maintainer could reasonably close it as out of contract while still accepting the check as hardening.

Why it does not settle it: Lucene answers "callers might write the reserved field" with validation, already on the document path, and it fixed the closest analogue (updates of index-sort fields) as a bug; those fields belong to the application, so the precedent shows how Lucene treats updates that break the sort invariant, not that writer-owned fields are in contract. The update methods document their preconditions and _parent meets them; nothing in their javadoc excludes fields named in the writer config, and the other such field, the soft-deletes field, is updated through these very methods (a designed use), so the javadoc gives callers no hint. And the failure is silent at the call. What survives is the scope: it is missing input validation, with low likelihood, and only an application writing its own parent field triggers it. Verdict (adjudication): confirmed, medium.

Suggested fix

Recommended A. Reject the parent field in the four update entry points

Reject the configured parent field in IndexWriter's four doc-values update entry points with IllegalArgumentException, as the document path already does (+16 lines in IndexWriter.java). The repro, 12 related suites (383 tests) and the whole core index test package pass, and the change passes the core lint.

private void ensureNotParentField(String field) {
  String parentField = config.getParentField();
  if (parentField != null && parentField.equals(field)) {
    throw new IllegalArgumentException(
        "\"" + field + "\" is a reserved field and its doc values cannot be updated");
  }
}
// called first in updateNumericDocValue, updateBinaryDocValue and
// updateSortedNumericDocValue, and for each field in buildDocValuesUpdate
All options and trade-offs4 options ยท 706 words

Option A was run through gradle in a scratch worktree of 645d4fe14a20. B and C are alternatives that were not run. Hardening MultiSorter is not a fix: the add-a-mark case breaks the segment at commit, with no merge.

A. Reject the parent field in the four update entry points (validated by the checks) (recommended)

A private ensureNotParentField(field) helper in IndexWriter, called first in updateNumericDocValue, updateBinaryDocValue and updateSortedNumericDocValue, and for every field in buildDocValuesUpdate (which serves updateDocValues, softUpdateDocument(s) and tryUpdateDocValue). The message mirrors the document path's "is a reserved field".

private void ensureNotParentField(String field) {
  String parentField = config.getParentField();
  if (parentField != null && parentField.equals(field)) {
    throw new IllegalArgumentException(
        "\"" + field + "\" is a reserved field and its doc values cannot be updated");
  }
}
// called first in updateNumericDocValue, updateBinaryDocValue and
// updateSortedNumericDocValue, and for each field in buildDocValuesUpdate

Trade-offs

  • Runs before verifyOrCreateDvOnlyField, so the binary and sorted-numeric methods now say "reserved field" instead of the type-mismatch message, and updateDocValues cannot create the field with another type on an index without documents (executed).
  • Changes which error comes first: updateBinaryDocValue(parentField, null) now gets the reserved-field error instead of today's "cannot update a field to a null value" (executed), and an updateDocValues field named after the parent field is rejected before its doc-values type is checked.
  • The null-safe comparison keeps today's behaviour for a writer without a parent field and for a null field name.
  • Rejects whether or not an index sort is set, like the document path. Without a sort a verifier's control stayed clean (merge-time reorderers such as BPIndexReorderer, which also read the mark, were not tested), so a maintainer could limit the check to sorted indexes; rejecting always is simpler, keeps the field writer-owned and also protects code that reads the mark without an index sort.
  • Behaviour change: an application that writes its parent field through these methods now gets an exception; no upstream test or javadoc relies on that call.
  • Open PR #16810 edits the same four guard blocks (it merges the index-sort checks into one helper).
  • Worth adding: one sentence in the setParentField and update javadocs that the parent field cannot be updated (a reviewer asked for more explicit javadoc in #12829).

What the checks validated

  • Gradle in a scratch worktree of 645d4fe14a20 (--offline, seed DEADBEEF). Stock: the repro fails with assertions on and off.
  • With the fix: the repro plus TestIndexSorting, TestNumericDocValuesUpdates, TestBinaryDocValuesUpdates, TestSortedNumericDocValuesUpdates, TestMixedDocValuesUpdates, TestIndexWriter, TestAddIndexes, TestColumnBatchIndexing, TestIndexOptions, TestSoftDeletesRetentionMergePolicy and TestSoftDeletesDirectoryReaderWrapper pass with assertions on and off (12 suites, 383 tests, 0 failures, 3 skipped).
  • The whole org.apache.lucene.index test package with the fix (215 suites, 1396 tests, 0 failures, 56 skipped), and two parent-field suites in other modules: TestBPIndexReorderer (8 tests) and TestIndexSortBackwardsCompatibility (68 tests).
  • Lint with the fix: checkGoogleJavaFormat, ecjLintMain, ecjLintTest, forbiddenApisMain and forbiddenApisTest pass once the repro gets two lint-only edits (the unused catch parameter renamed to _, the long comment wrapped); with the edits the repro still fails on stock code and passes with the fix.
  • Earlier, the hunt's equivalent fix sketch on main ea57360912ed passed the finder's 2 tests, the first verifier's 5 (two seeds), 4 more probe tests and 242 existing tests.

B. Reject the parent field inside FieldNumbers.verifyOrCreateDvOnlyField (not run)

All four update paths call it (IndexWriter.java:1964, :2010, :2048, :2097) and it already knows parentFieldName (FieldInfos.java:384-390), so one check covers them.

Trade-offs

Puts an IndexWriter policy into a schema-verification method; for the binary and sorted-numeric methods the index-sort message still comes first.

C. Fold the check into #16810's helper (if that PR lands first; not run)

#16810 replaces the four copies of the index-sort check with one ensureNotIndexSortField helper; generalising it to "fields IndexWriter owns" (sort fields of the config and of existing segments, plus the parent field) keeps one guard.

Trade-offs

Ties this fix to an open, unreviewed PR.

Not a fix: harden MultiSorter against a missing trailing parent

It would change only the remove-a-mark case (the merge-time end state shared with LFV-011).

Trade-offs

The add-a-mark case breaks the segment at commit, with no merge involved, so the update must be rejected anyway.

Downstream note

A code read of Elasticsearch, OpenSearch and Solr found no default-configuration path that sends a doc-values update to the parent field, so the exposure is applications that pass their own parent-field name to IndexWriter's doc-values update methods.

How it was found

While building an Alloy 6.2 model of index sorting with document blocks, the finder read IndexWriter's doc-values update guards and saw that they do not reject the parent field. The Alloy check MergeAfterParentDVUpdate then found a 2-document counterexample, and a JUnit test on the stock jars confirmed it. Semi-formal: the model encoded a hypothesis from code reading.

The method in full150 words

Method: an Alloy 6.2 model plus code reading, then JUnit (the hunt's "semi-formal" tier).

  • A hunt unit (index sorting with document blocks) modelled the flush sort and the merge of blocks in Alloy (SortBlocks.als). While modelling which inputs can change parent marks, the finder read IndexWriter's update guards (IndexWriter.java:1962-2110 at the time) and wrote the predicate goodInputsAfterParentDVUpdate: marks may differ from the block structure after a doc-values update.
  • The check MergeAfterParentDVUpdate (blocks preserved by the merge) was satisfiable: a 2-document counterexample in which a parent mark no longer matches its block.
  • The finder's JUnit tests (add a mark; remove a mark) then failed on the stock jars of main, 10.5.2, 9.12.3 and 9.10.0, with assertions on and off, and passed with a fix sketch.
  • It counts as semi-formal because the model encoded a hypothesis formed while reading the code and the tool then showed its consequence.

Prior reports, history and checks

Prior reports

No prior report found (searched 2026-10-26, refreshed 2026-11-01) in GitHub issues, PRs and commits, the mailing lists, the JIRA archive or downstream repositories. Closest: #16809 / #16810, the same update guards for a different field.

Searches4 searches

No issue, PR, commit, list thread, JIRA entry or downstream report describes doc-values updates of the parent field. #16809 (open issue) and its fix PR #16810 (open) cover index-sort fields that are updatable when the writer config has no sort: the same four guard blocks and the same CheckIndex symptom, but a different field, trigger and fix, and #16810 adds no parent-field check. Precedents for treating this class as a bug: #8632 (LUCENE-7581) and #16565.

What was searched, when, and what turned up
WhereWhenHowFound
GitHub issues, PRs and commits (apache/lucene)2026-10-26a scripted prior-report search run (20 issue/PR and 3 commit queries) and 25 manual searches: setParentField, getParentField, "reserved field", "sorts after docID", the exact rejection message, MultiSorter EOFException, "parent field" with each update method namerelated only: #16809 / #16810, #16565, #8632, #12829, #13016, #15960, #13340 / #13341
Open PRs touching the fix files2026-10-26the file lists of all 412 open PRs; 31 touch the fix files, and the changed lines of the 18 non-test ones were grepped for the parent field; #16810 read in fullno PR adds a parent-field check to the update paths
Mailing lists, JIRA archive, downstream repositories, web2026-10-2620 list queries (dev@, java-user@, issues@, commits@), 4 JIRA queries, 2 + 5 downstream queries (Elasticsearch, OpenSearch, Solr), 6 web queriesno report; only notification traffic for the related PRs
GitHub, refresh2026-11-01items created since 2026-10-25 for "parent field", setParentField, updateDocValues, "reserved field"#16947 (merged 2026-10-30, disables sort-based early termination with document blocks, fixes #16887): a different bug; #16809 / #16810 still open

History

Introduced with the parent field itself by #12829 (df6bd25ce44, merged 2024-01-11; first released in 9.10.0 through its backport 0aa88910ca9). Every release from 9.10.0 to 10.6.0 and main fails; 9.9.2 has no setParentField.

Versions tested8 rows

#12829 ("Add support for index sorting with document blocks") created the parent field and the document-path "reserved field" check, but added nothing to the doc-values update methods, whose only guard is LUCENE-7581's index-sort-field check (2016). Later changes near the field (#13016, #13128, #15960, #16565, #16581) close nothing. Not a regression; not fixed on any branch as of upstream main 2f424fd428da (2026-10-30).

Versions and branches executed, with results
Version or commitVerdictResult
9.9.2 not affectedsetParentField does not exist; the repro does not compile
branch_9x 0aa88910ca9 (#12829 backport) vs parent 00e2fe6cacb committhe backport fails (finder's 2 tests, the first verifier's repro); its parent cannot compile the repro
9.10.0 affectedthe minimal test fails (-ea); the finder's tests fail with -ea and -da
9.11.0 - 9.12.3 affectedthe finder's tests fail on every release; the minimal test fails on 9.12.3
10.0.0 - 10.5.2 affectedthe finder's tests fail on every release; the minimal test fails on 10.0.0 and 10.5.2
10.6.0 affectedthe minimal test fails with -ea and -da
main 645d4fe14a20 affectedthe minimal test fails with -ea and -da (standalone and gradle)
fix A on 645d4fe14a20 not affectedthe minimal test passes; the update is rejected with IllegalArgumentException

Independent checks

The finder, independent verifiers, cross-model reviews and an adjudication all reproduced it. The cross-model reviews (OpenAI GPT-5.6) rated it "not real" because the field is internal and called the check hardening; the adjudication confirmed it as missing input validation.

Checks and verdicts420 words

Every check reproduced the failure and agreed with the root cause and the fix; the split is only about whether writing an internal field is in contract.

Each independent check and its verdict
CheckVerdictWhat it found
finder (Alloy model, then JUnit) candidateAlloy found a 2-document counterexample; its 2 JUnit tests fail on main (-ea, -da), 10.5.2, 9.12.3 and 9.10.0 and pass with its fix sketch.
cross-model review, refute lens (OpenAI GPT-5.6, executed) not real, mediumIts own test reproduces the CheckIndex failure (-ea, -da, two more seeds) and passes with the fix; it agrees with the root cause and the fix but rates the trigger out of contract.
independent reproducer realOwn test with a different layout: 2 of 5 fail on main (-ea, -da), 10.5.2, 9.12.3 and 9.10.0, 3 controls pass, fix 5/5; corrected the claim (no merge needed; value- and layout-dependent).
cross-model review, works-as-designed lens (OpenAI GPT-5.6, executed) not real, mediumOwn test fails 1 of 3 with both controls passing; the fix passes it and 143 existing tests; same contract objection; concedes the update javadocs do not restate the reservation.
intended-behaviour devil's advocate real, mediumOwn test fails 1 of 4; controls: a reserved-field document is rejected, a soft-deletes update is accepted by design, no damage without an index sort.
adjudication confirmed, mediumRe-ran the independent reproduction with a fresh seed (2 of 5 fail, 0 of 5 with the fix) and the finder repro (2 of 2 fail); the cross-model review's objection is about labelling, not reality.
history and security screen 9.10.0 first affected; not security-sensitive38 runs on 9.9.2, the 19 releases from 9.10.0 to 10.5.2, main and two commits; a security probe on main (-ea, -da) and 10.5.2.
minimal repro and this doc verified; fix validatedThe one-test repro fails on 645d4fe14a20 (-ea, -da), 10.6.0 and four older releases; with fix A it and the core index tests pass.
fact checks of this doc (one by an independent reviewer from another model family, OpenAI GPT-5.6) reproduced; corrections appliedRe-ran the repro and fix A with assertions on and off. Its probes narrowed the remove-a-mark case: the merge fails only when the unmarked block is the last of its segment with a sort value on every document, and later writers fail only once the update is committed. A later probe ran the path on an index without documents.

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

Finding record

LFV-095, snapshot of 2026-11-06

Registry title
IndexWriter accepts doc-values updates of the reserved parent field; with index sorting and document blocks this leaves a segment that fails CheckIndex, or an index whose merges fail
Area
index / IndexWriter doc-values updates
Priority
Low, rank 1163: IndexWriter accepts doc-values updates of the reserved parent field, which can damage index-sorted block indexes, but only through out-of-contract misuse, with a cheap reject fix.
Impact class
data corruption
Impact note
Only applications that use opt-in index sorting with IndexWriterConfig#setParentField and document blocks and that themselves send a doc-values update naming the field they configured as the parent field (documented as internal; the cross-model reviews call this out of contract), through updateNumericDocValue or a numeric field in updateDocValues, softUpdateDocument(s) or tryUpdateDocValue; untrusted documents cannot trigger it, no default-configuration path was found in Elasticsearch, OpenSearch or Solr (code reading), ordinary scale, released 9.10.0-10.6.0 and main.
Summary
You use index sorting with a parent field (IndexWriterConfig#setParentField) and document blocks, and call IndexWriter#updateNumericDocValue or #updateDocValues on that parent field, for example to give a child document a parent mark or to set a parent's mark to null. You expect the call to be rejected with IllegalArgumentException, as IndexWriter rejects doc-values updates of index-sort fields and any document that contains the parent field ('is a reserved field'), or else the index to stay valid. Instead the update is accepted: a child given a mark can put the segment out of index-sort order, so CheckIndex fails ('segment has indexSort=<long: "sort"> but docID=1 sorts after docID=2') as soon as the update is committed, and a removed mark makes the next merge throw EOFException in MultiSorter and close the writer when the unmarked block was the last of its segment and every document there has a sort value, and otherwise silently moves the orphaned documents into the next block.
Introduced
df6bd25ce44 (#12829 'Add support for index sorting with document blocks', main, first released in 10.0.0); its branch_9x backport 0aa88910ca9 shipped first, in 9.10.0 [read: git log -S setParentField, git tag --contains, git show of both commits; exec: with the backport commit's core, codecs and test-framework sources shadowed over the 9.10.0 jars, both finder tests fail (-ea and -da) and the independent reproduction's repro fails 2 of 5, while at its parent 00e2fe6cacb the repro does not compile (no setParentField)]; not a regression: #12829 created the parent field and a document-path 'reserved field' check but no check in the doc-values update methods, whose only guard is the index-sort-field check from LUCENE-7581 (4efbde4e762, 2016); no later commit opened or closed the gap (2024-01-11 (#12829 merged into main as df6bd25ce44; the branch_9x backport 0aa88910ca9 was committed 2024-01-17, author date 2024-01-11))
First affected version
9.10.0
Affected versions
all 19 releases from 9.10.0 through 10.5.2 (9.10.0, 9.11.0, 9.11.1, 9.12.0-9.12.3, 10.0.0, 10.1.0, 10.2.0-10.2.2, 10.3.0-10.3.2, 10.4.0, 10.5.0-10.5.2) and main ea57360912ed (unreleased 11.0.0) [exec: both finder tests fail on each, also with -da on 9.10.0, 9.12.3, 10.0.0, 10.5.2 and main; the independent reproduction's repro fails 2 of 5 on 9.10.0, 9.12.3, 10.0.0, 10.5.2 and main]; 10.6.0 (released 2026-10-29): the one-test minimal repro fails on its release jars (SHA-1 checked against Maven Central, -ea and -da, 2026-11-01) [exec]; 9.9.2 and earlier not affected (no IndexWriterConfig#setParentField; the repro does not compile on 9.9.2) [exec]; only a writer configured with a parent field can reach it; IndexWriter accepts one on an existing index only if the index has that field or no fields at all (#13016, #13341) [read]; not fixed upstream as of main ea57360912ed [read]
Status
documented (brief, checks and a per-finding doc exist)
Verification
Confirmed by AI-run checks: an independent repro, a cross-model review that disputed it (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
semi-formal: Alloy, code reading
Upstream and prior reports
none found (2026-10-26: a scripted prior-report search with 20 GitHub issue/PR, 3 commit, 2 downstream-repo, 20 mailing-list (dev@, java-user@, issues@, commits@), 4 JIRA-archive and 6 web queries; 25 manual GraphQL issue/PR searches on apache/lucene and 5 on downstream repos; file lists of all 412 open PRs; the discussion threads of #12829, #13016, #13341, #15960, #16565 and #16581 read; earlier finder (25 queries) and the intended-behaviour check (16 mailing-list, 2 JIRA) searches); related prior items: #16809 (open: index-sort fields are updatable when the writer config has no sort; same update-guard methods and CheckIndex symptom, different field, trigger and fix) and its open fix PR #16810 (adds no parent-field check, so it would not fix this), #16565 and #8632 (LUCENE-7581) (precedents: the index-sort-field update guard), #12829 (introduced the parent field), #13016 (states why the field is guarded), #15960 (document-path reserved-field check)
Lucene commit tested
main 645d4fe14a20 (2026-11-01): the minimal test fails through gradle in a scratch worktree with assertions on and off, and standalone on main (-ea, -da) and on the release jars of 9.10.0, 9.12.3, 10.0.0, 10.5.2 and 10.6.0 (10.6.0 also -da); it does not compile on 9.9.2. With the recommended fix it passes, together with 12 related core suites (383 tests, assertions on and off), the whole org.apache.lucene.index test package, two parent-field suites in other modules and the core lint. Upstream main 2f424fd428da (2026-10-30) has no change to the affected files since 645d4fe14a20 (read). Earlier, the hunt's and verifiers' tests ran on main ea57360912ed and on all 19 releases from 9.10.0 to 10.5.2.

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1950-1962 (updateNumericDocValue javadoc: an existing, doc-values-only field), :1965, :2003, :2041 and :2098 (the only guard: config.getIndexSortFields().contains), :2081-2127 (buildDocValuesUpdate)
  • lucene/core/src/java/org/apache/lucene/index/FieldInfos.java:391-398 (the soft-deletes field may not be the parent field), :562-647 (verifyOrCreateDvOnlyField accepts the doc-values-only NUMERIC parent field)
  • lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:614-618 (a document that contains the parent field: 'is a reserved field and should not be added to any document')
  • lucene/core/src/java/org/apache/lucene/index/MultiSorter.java:57-63 (block boundaries from the parent field's current values) and :166 (parents.nextSetBit(docID))
  • lucene/core/src/java/org/apache/lucene/index/IndexWriterConfig.java:579-589 (setParentField: 'an internal field', 'the API guarantee that the document order of a block is not altered by the IndexWriter')
  • lucene/core/src/java/org/apache/lucene/index/CheckIndex.java:1219-1301 (testSort; the 'segment has indexSort=... but docID=... sorts after docID=...' error at :1275)
  • lucene/MIGRATE.md:875-881 ('The internally used parent field')
The finding in 30 seconds
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:166-169, 604-609, 614-619, 645-651, 748-750
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexWriter.java:1664-1675, 1721-1724, 1938-1948, 1950-1979, 1996-2017, 2037-2057, 2070-2079, 2081-2127
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/FieldInfos.java:562-647; CheckIndex.java:1219-1301; MultiSorter.java:52-63, 166
  • a Lucene main checkout (645d4fe14a20) lucene/MIGRATE.md:875-881
Who is affected
  • a Lucene main checkout (645d4fe14a20) lucene/MIGRATE.md:875-881
Why it looks unintended
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/IndexingChain.java:616-619; IndexWriterConfig.java:579-589; IndexWriter.java:1950-1971
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/test/org/apache/lucene/index/TestIndexSorting.java:1987-2029 (testBadDVUpdate), 3243-3285 (testBlockContainsParentField); lucene/MIGRATE.md:875-881
  • GitHub apache/lucene#12829 review comments r1403178782, r1423606084, r1424197106, r1439728693 (read 2026-11-01); commit 34c9495241d message (#15960)
Suggested fix
  • a Lucene main checkout (645d4fe14a20) lucene/core/src/java/org/apache/lucene/index/FieldInfos.java:384-398, 562-647; IndexWriter.java:1964, 2010, 2048, 2097

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

Repro files

About the diagrams

Made in parallel with this doc from the code at 645d4fe14a20 and the repro's input and output.

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.