All findings · Lucene finding · LFV-939 · blocktree-terms-meta-javadoc-grammar-stale

The default terms dictionary's file-format javadoc misplaces the postings header and still lists a root code

The javadoc of Lucene103BlockTreeTermsWriter, the reference for the default codec's terms files, describes a .tmd/.tim byte layout the writer does not produce: a parser that follows it reads 63 fields in a one-field index. Documentation only: Lucene's own writer and reader agree, so nothing breaks at runtime.

Summary

Priority
Low, rank 1280The default terms dictionary .tim/.tmd grammar has two stale elements going back to 8.6 and covers a central per-field file.
Impact
minor
Since
8.6.0 (LUCENE-9353, 2020) for the header; 10.3.0 (#14333, 2025) for the root codeintroduced 2020-06-16 · about 6 years
Affected
8.6.0-10.6.0 and main (root code: 10.3.0 on)
Status
documented
Found by
semi-formal: a differential grammar check over 64 generated default-codec indexes

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

A javadoc that documents the byte layout of the default codec's terms files lists one element that is never written and leaves out one that is.

Background: the block-tree terms files

  1. The default codec stores each field's terms in a block tree: .tim holds the term blocks, .tip a trie index into them, and .tmd small per-field metadata (term counts, statistics, where each field's trie lives).
  2. Each file starts with a header (codec magic, name, version, segment id and suffix) and ends with a checksum footer. The postings format also writes a small header of its own, the PostingsHeader, into one of these files.
  3. The Lucene103BlockTreeTermsWriter class javadoc gives each file's layout as a grammar, element by element, with VInt/VLong types. Lucene104PostingsFormat sends readers there for the details.
Diagram of the bug. Two rows of the .tmd file of the default codec. The javadoc row: Header, NumFields, then FieldStats with FieldNumber, NumTerms, RootCodeLength, root code bytes, SumTotalTermFreq and the rest. The writer row: Header, PostingsHeader (codec magic 0x3FD76C17, name Lucene104PostingsWriterTerms, block size 256), NumFields 1, then FieldStats with FieldNumber, NumTerms, SumTotalTermFreq, SumDocFreq, DocCount, MinTerm, MaxTerm, IndexStartFP, TrieRootNodeFp, IndexEndFp and no root code. Reading the writer's bytes with the javadoc gives NumFields 63, the first byte of the codec magic.
The .tmd file as the javadoc describes it and as the writer writes it: the postings header between the header and NumFields, and FieldStats without a root code. Read with the javadoc, NumFields becomes 63. Select the still to open it at full size. Generated with AI, checked against the code.

The finding, step by step

  1. You write a reader for the default codec's terms metadata file (.tmd) from the Lucene103BlockTreeTermsWriter javadoc: Header, NumFields, then per field FieldNumber, NumTerms, RootCodeLength, the root code bytes, SumTotalTermFreq, ...
  2. You index one document with one text field and expect NumFields = 1 right after the 55-byte header.
  3. You read 63. That is 0x3F, the first byte of the postings format's own header (codec magic 0x3FD76C17, name Lucene104PostingsWriterTerms, block size 256), which the writer puts here, once. The javadoc instead puts this PostingsHeader at the start of every field in .tim, where nothing writes it.
  4. Skip that header and the next stale element follows: with 3 documents ("a a a b", "b c", "c d e"), the documented RootCodeLength reads 9, which is really SumTotalTermFreq, and after 9 bytes of "root code" the documented SumTotalTermFreq reads 82, the first byte of the .tip file length. The trie terms index of 10.3.0 writes no root code.
  5. Lucene's own reader follows the writer, so indexing, merging and search are fine. Only the documentation is wrong: since 8.6.0 for the header, since 10.3.0 for the root code.

Who is affected

People who read or re-implement the default codec's .tim/.tmd format from this javadoc: format inspectors, ports, recovery tools. Every .tmd file the default codec writes shows it at NumFields, on 8.6.0 to 10.6.0 and main. Lucene itself, and every application using its API, is unaffected; no public report was found.

Mechanism, terms and numbers718 words

The class javadoc of Lucene103BlockTreeTermsWriter describes the default codec's terms files as typed element lists (a grammar), and Lucene104PostingsFormat sends readers there for the format details. Two parts no longer match the writer. The .tmd grammar says Header, NumFields, <FieldStats>^NumFields, ..., but the writer puts the postings writer's header (an IndexHeader plus the packed block size, 256) between the header and NumFields; the javadoc instead documents that PostingsHeader at the start of every .tim field, where nothing writes it (stale since LUCENE-9353 moved it to the new .tmd file in 8.6.0). And each FieldStats still lists RootCodeLength, Byte^RootCodeLength, which the trie terms index of 10.3.0 no longer writes: #14333 replaced the FST elements after IndexStartFP (FSTHeader, FSTMetadata) with TrieRootNodeFp, IndexEndFp and kept the root code lines. A parser that follows the text reads NumFields as 63, the first byte of the codec magic, and, with that header skipped, takes SumTotalTermFreq for RootCodeLength. Lucene's reader follows the writer, so only the documentation is wrong. Minor: the headers are documented as CodecHeaders but are IndexHeaders. Separate and much older: the .tim term-block productions are simplified, not byte-exact; this page and the suggested fix do not cover them.

The javadoc (main 645d4fe14a20, Lucene103BlockTreeTermsWriter.java)

  • :111-112: TermsDict (.tim) --> Header, FieldDict^NumFields, Footer; FieldDict --> PostingsHeader, NodeBlock^NumBlocks.
  • :151-155: TermsMeta (.tmd) --> Header, NumFields, <FieldStats>^NumFields, TermIndexLength, TermDictLength, Footer; FieldStats --> FieldNumber, NumTerms, RootCodeLength, Byte^RootCodeLength, SumTotalTermFreq?, SumDocFreq, DocCount, MinTerm, MaxTerm, IndexStartFP, TrieRootNodeFp, IndexEndFp.
  • :159: NumFields,FieldNumber,RootCodeLength,DocCount --> VInt; :170: "RootCode points to the root block for the field."
  • :121, :156, :187: Header --> CodecHeader (CodecUtil#writeHeader), and the .tim note at :131 ("Header is a CodecHeader storing the version information ..."): a minor wording point.

What the writer does (same file)

  • :298-330: CodecUtil.writeIndexHeader for .tim, .tip and .tmd (an IndexHeader is a CodecHeader plus the segment id and suffix: 55 bytes for .tmd).
  • :332: postingsWriter.init(metaOut, state); // have consumer write its format/header. The default postings writer writes an IndexHeader Lucene104PostingsWriterTerms and writeVInt(BLOCK_SIZE) (256) into the stream it is given, the .tmd (Lucene104PostingsWriter.java:223-227). Nothing goes into .tim, here or per field.
  • :1082-1092: per field, FieldNumber, NumTerms, SumTotalTermFreq (unless DOCS-only), SumDocFreq, DocCount, MinTerm, MaxTerm, then root.index.save(...), which writes IndexStartFP, the root node fp and IndexEndFp (TrieBuilder.java:263-268). No root code.
  • :1133-1141 (close): NumFields, the buffered FieldStats, the .tip and .tim lengths and the footer, so in the file the postings header sits between the .tmd header and NumFields.

The reader agrees with the writer

Lucene103BlockTreeTermsReader.java:145-152 checks the .tmd header and calls postingsReader.init(metaIn, state); :154 reads numFields; :160-179 read the FieldStats without a root code; FieldReader.java:80-82 read the three trie pointers. The format has one version (VERSION_START = VERSION_CURRENT = 0, :73-76), so no version writes a root code.

The bytes (executed)

  • .tmd, right after its header: 3f d7 6c 17 1c 'Lucene1...', the codec magic and the 28-character name of the postings writer's header; then the block size 256 and NumFields 1.
  • FieldStats for the 3 documents "a a a b", "b c", "c d e": 00 05 09 07 03 01 61 01 65 38 00 42 | 52 00 .. = FieldNumber 0, NumTerms 5, SumTotalTermFreq 9, SumDocFreq 7, DocCount 3, MinTerm a, MaxTerm e, IndexStartFP 56, TrieRootNodeFp 0, IndexEndFp 66, then TermIndexLength 82. Read with the javadoc, 9 becomes RootCodeLength and 82 SumTotalTermFreq.
  • .tim, right after its header, continues with the first term block (0b 2c 61 62 63 64 65 ...); the codec magic occurs once in .tim, in its own header. (The block is not read as documented either: its first VInt 0b = 11 is five entries plus the last-block flag, 5 << 1 | 1 (:735-742), not an EntryCount of 11; see Suggested fix.)

Root cause

LUCENE-9353 (8.6.0) changed postingsWriter.init(termsOut, state) to init(metaOut, state), moving the header from .tim to the new .tmd, and left the javadoc as it was. #14333 (10.3.0) created Lucene103BlockTreeTermsWriter from Lucene90BlockTreeTermsWriter and swapped the FST elements of FieldStats (FSTHeader, FSTMetadata, after the IndexStartFP it kept) for TrieRootNodeFp, IndexEndFp, but kept RootCodeLength, Byte^RootCodeLength.

Exposure and likelihood271 words
  • Who is hit. Anyone decoding .tim/.tmd bytes from the documentation. Lucene sends them there: Lucene104PostingsFormat.java:97 ("See {@link Lucene103BlockTreeTermsWriter} for more details on the format") and :149 for .tip. The file-format overview links its "Term dictionary" item (lucene104/package-info.java:154, "Details on each of these are provided in their linked pages", :188) and the .tim and .tip rows of its extension table (:258-265) to Lucene104PostingsFormat, which sends readers on here. .tmd is not in that table at all, so this javadoc is the only place its layout is given.
  • Default vs opt-in. The default postings format: its block-tree writer calls postingsWriter.init(metaOut, state) unconditionally in its constructor (:332) and Lucene104PostingsWriter.init always writes the header (:223-227), so every .tmd file written this way has it before NumFields (read in the code; seen in all 125 .tmd files of the hunt's sweep). No setting or scale is needed. Other postings formats plugged into the block tree write their own PostingsHeader there; not run.
  • How it shows. Not subtle (63 fields, garbage statistics), but the text gives no hint where the extra bytes come from: Lucene104PostingsFormat describes the PostingsHeader under its .tim heading, so a reader looks in the wrong file.
  • What it does not do. No wrong results, no runtime path, no index change: Lucene's code never reads the javadoc grammar. Lucene's TestTrie (4 tests) and TestLucene104PostingsFormat (34 tests, 3 skipped) pass (this doc's gradle run, with the javadoc-only fix applied; the refute reviewer also ran one method of each).
  • How many. A small audience; the searches found no public report since 8.6.0 (2020).

Minimal repro

One JUnit test, one document with one text field, public API only. It checks the .tmd header and reads the next VInt, which the javadoc calls NumFields. Deterministic, single thread, about 1.5 s; the same with assertions on and off.

TestLucene103BlockTreeTermsMetaJavadoc.java (JUnit, one test)53 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.codecs.lucene103.blocktree;

import org.apache.lucene.codecs.CodecUtil;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.IndexWriter;
import org.apache.lucene.index.IndexWriterConfig;
import org.apache.lucene.index.SegmentInfos;
import org.apache.lucene.store.ByteBuffersDirectory;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.IOContext;
import org.apache.lucene.store.IndexInput;
import org.apache.lucene.tests.util.LuceneTestCase;
import org.apache.lucene.tests.util.TestUtil;

public class TestLucene103BlockTreeTermsMetaJavadoc extends LuceneTestCase {

  public void testDocumentedTermsMetaSequence() throws Exception {
    try (Directory dir = new ByteBuffersDirectory()) {
      IndexWriterConfig iwc =
          new IndexWriterConfig().setCodec(TestUtil.getDefaultCodec()).setUseCompoundFile(false);
      try (IndexWriter w = new IndexWriter(dir, iwc)) {
        Document doc = new Document();
        doc.add(new TextField("f", "a", Field.Store.NO));
        w.addDocument(doc);
      }
      byte[] id = SegmentInfos.readLatestCommit(dir).info(0).info.getId();
      try (IndexInput in = dir.openInput("_0_Lucene104_0.tmd", IOContext.READONCE)) {
        CodecUtil.checkIndexHeader(
            in, "BlockTreeTermsMeta", 0, Integer.MAX_VALUE, id, "Lucene104_0");
        // expected 1; actual 63, the first byte of the undocumented PostingsHeader written here
        assertEquals("NumFields", 1, in.readVInt());
      }
    }
  }
}
Command
cp TestLucene103BlockTreeTermsMetaJavadoc.java $LUCENE/lucene/core/src/test/org/apache/lucene/codecs/lucene103/blocktree/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.codecs.lucene103.blocktree.TestLucene103BlockTreeTermsMetaJavadoc -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)
Expected
NumFields reads 1 (one indexed field), as the javadoc says it follows the header.
Observed
NumFields expected:<1> but was:<63>, with assertions on and off: 63 is the first byte of the postings header the writer puts there.

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

More files, environment, full output274 words
  • The test sits in the block-tree test package, like Lucene's own codec tests, and uses only public API (IndexWriter, SegmentInfos, CodecUtil, IndexInput). It checks the header as an IndexHeader, which is what the writer writes; the javadoc's "CodecHeader" is the minor wording point.
  • Results: fails 1 of 1 on main 645d4fe14a20 with assertions on and off, through gradle and standalone (1.43 s; also with a second seed. The test body uses no randomness; the test framework still randomizes its environment per seed, and the asserted byte, the first byte of the codec magic, is the same under both seeds).
  • It shows the first stale element only. The root-code element is reached only after skipping the misplaced header; the hunt's test (3 documents "apple banana", "banana cherry", "cherry date apple") shows it on main a9779ef3c193 (no codec change up to 645d4fe14a20): SumTotalTermFreq (after RootCodeLength=7) expected:<7> but was:<101>, next to NumFields expected:<1> but was:<63> and .tim PostingsHeader magic expected:<1071082519> but was:<162267489> (1071082519 is the codec magic 0x3FD76C17).
  • As a regression test: this test cannot guard the javadoc, because it transcribes a copy of the text and the bytes do not change with the fix. A regression form is this same test rewritten to the corrected grammar, which passes on main.

Environment

lucenemain 645d4fe14a20 (11.0.0-SNAPSHOT)
jdkOpenJDK 25.0.3 (build 25.0.3+9-LTS)
osLinux, aarch64
Command (environment)
cp TestLucene103BlockTreeTermsMetaJavadoc.java $LUCENE/lucene/core/src/test/org/apache/lucene/codecs/lucene103/blocktree/
cd $LUCENE && ./gradlew :lucene:core:test --tests org.apache.lucene.codecs.lucene103.blocktree.TestLucene103BlockTreeTermsMetaJavadoc -Ptests.seed=DEADBEEF
# assertions off: add -Ptests.asserts=false (same result)

Observed output

TestLucene103BlockTreeTermsMetaJavadoc > testDocumentedTermsMetaSequence FAILED
    java.lang.AssertionError: NumFields expected:<1> but was:<63>
        at __randomizedtesting.SeedInfo.seed([DEADBEEF:2E92ECB75904EC9A]:0)
        at org.junit.Assert.fail(Assert.java:89)
        at org.junit.Assert.failNotEquals(Assert.java:835)
        at org.junit.Assert.assertEquals(Assert.java:647)
        at org.apache.lucene.codecs.lucene103.blocktree.TestLucene103BlockTreeTermsMetaJavadoc.testDocumentedTermsMetaSequence(TestLucene103BlockTreeTermsMetaJavadoc.java:49)

Why it looks unintended

  • The .tmd grammar is a short, typed element list, and it lists an element no version writes: Lucene103 has one format version, without a root code.
  • Its own note calls the PostingsHeader "arbitrary per-file data": one per file, as written in .tmd, not one per .tim field.
  • Committers fixed this grammar before: #90 (2021) rewrote it after .tmd was introduced.
The full argument182 words
  • Contract text. Every .tmd element has a primitive type linked to a DataOutput method (Lucene103BlockTreeTermsWriter.java:157-161). "This document defines the index file formats used in this version of Lucene" (lucene104/package-info.java:50); "Details on each of these are provided in their linked pages" (:188).
  • Self-contradiction. "PostingsHeader and TermMetadata are plugged into by the specific postings implementation: these contain arbitrary per-file data (such as parameters or versioning information) and per-term data" (:136-138).
  • No version writes a root code. VERSION_START = VERSION_CURRENT = 0 (Lucene103BlockTreeTermsReader.java:73-76); the FieldStats line lists the FST-era RootCode next to its trie replacement TrieRootNodeFp.
  • Precedent. #90 ("LUCENE-9353: revise format documentation of Lucene90BlockTreeTermsWriter", 2021) rewrote this grammar after LUCENE-9353; its review discussed which FieldStats element is the optional one, and it was approved as a fix ("Thank you @mocobeta for fixing this!"). #823 (2022) restructured the .tim line. #15619 / #15625 (2026-01) fixed a stale block-size sentence in Lucene104PostingsFormat.
  • Nothing in the materials reviewed (the related PRs, JIRA issues and review threads, and CHANGES) says the grammar is meant to be approximate.
The devil's-advocate check291 words

Strongest case for "intended" (the refute reviewer, the GPT intended lens and this doc's cross-model fact check: moderate, not decisive; the intended-behaviour verifier: weak): the codec classes are @lucene.experimental (Lucene103BlockTreeTermsWriter.java:209); the overview "attempts to provide a high-level definition" (package-info.java:54), the policy of LUCENE-2946 (#4020, 2012, "change file format documentation from bit-for-bit to highlevel"); PostingsHeader/TermMetadata are italic plug-in placeholders, so their position could be schematic; and the page is not bit-exact elsewhere either: the .tim term-block productions leave out flag bits, a suffix-lengths section and the compression and run-length coding the writer uses (see Suggested fix; pointed out by the cross-model fact check of this doc).

Why it fails: "high-level" allows leaving detail out or simplifying an encoding (the .tip trie is deliberately {trie<byte[]>} with a TODO; the .tim blocks are simplified); it does not cover listing elements that no writer of this format writes, or leaving out a header that every .tmd file of the default codec carries. The LUCENE-2946 thread does not settle it: rmuir found exact docs fine "for the simple metadata files" and wanted a high-level explanation for the terms dictionary; that .tmd, added in 2020, is a small metadata file of the first kind is this page's reading. The placeholder reading does not explain the .tim position either: the current block-tree writer always passes the .tmd output to postingsWriter.init (:332), so a header written there lands once in .tmd, never in a per-field slot of .tim (the base class still names that argument termsOut and says implementations "typically" write a header, PostingsWriterBase.java:45-49). @lucene.experimental warns that the format may change between releases, not that the current page may be wrong. Every verifier and the adjudication rejected the "intended" reading.

Suggested fix

Recommended A. Correct the stale .tmd, .tim and .tip lines in the Lucene103BlockTreeTermsWriter class javadoc (build-checked; validated for the .tmd layout only)

Correct the stale lines in the Lucene103BlockTreeTermsWriter javadoc: .tmd becomes Header, PostingsHeader, NumFields, ...; the .tim FieldDict loses its PostingsHeader and FieldStats its root code; headers become IndexHeaders. Javadoc only, one file; the simplified .tim term-block productions stay as they are. Checked: the corrected repro parses .tmd exactly to its footer; core's checks and javadoc build pass.

All options and trade-offs4 options ยท 1,231 words

Four ways to resolve it. A is the fix; B and C are optional add-ons for the two other places with the same stale text; D is the alternative, not recommended.

Backport: the same stale lines are on branch_11x and branch_10_6 (line 112 and two RootCodeLength lines each, read on GitHub 2026-11-01); branch_10x was decommissioned on 2026-10-29 (#16911). A dry run of the diff on those branches was not done.

A. Correct the stale .tmd, .tim and .tip lines in the Lucene103BlockTreeTermsWriter class javadoc (build-checked; validated for the .tmd layout only) (recommended)

In Lucene103BlockTreeTermsWriter.java: FieldDict --> NodeBlock^NumBlocks (:112); TermsMeta (.tmd) --> Header, PostingsHeader, NumFields, <FieldStats>^NumFields, TermIndexLength, TermDictLength, Footer (:151-152); FieldStats without RootCodeLength, Byte^RootCodeLength (:153-155), RootCodeLength out of the VInt list (:159) and the RootCode note removed (:170); Header --> {@link CodecUtil#writeIndexHeader IndexHeader} (:121, :131, :156, :187). 11 insertions, 13 deletions.

diff --git a/lucene/core/src/java/org/apache/lucene/codecs/lucene103/blocktree/Lucene103BlockTreeTermsWriter.java b/lucene/core/src/java/org/apache/lucene/codecs/lucene103/blocktree/Lucene103BlockTreeTermsWriter.java
index 4e85a69b881..3035f4dddac 100644
--- a/lucene/core/src/java/org/apache/lucene/codecs/lucene103/blocktree/Lucene103BlockTreeTermsWriter.java
+++ b/lucene/core/src/java/org/apache/lucene/codecs/lucene103/blocktree/Lucene103BlockTreeTermsWriter.java
@@ -109,7 +109,7 @@ import org.apache.lucene.util.compress.LowercaseAsciiCompression;
  *
  * <ul>
  *   <li>TermsDict (.tim) --&gt; Header, FieldDict<sup>NumFields</sup>, Footer
- *   <li>FieldDict --&gt; <i>PostingsHeader</i>, NodeBlock<sup>NumBlocks</sup>
+ *   <li>FieldDict --&gt; NodeBlock<sup>NumBlocks</sup>
  *   <li>NodeBlock --&gt; (OuterNode | InnerNode)
  *   <li>OuterNode --&gt; EntryCount, SuffixLength, Byte<sup>SuffixLength</sup>, StatsLength, &lt;
  *       TermStats &gt;<sup>EntryCount</sup>, MetaLength,
@@ -118,7 +118,7 @@ import org.apache.lucene.util.compress.LowercaseAsciiCompression;
  *       &lt; TermStats ? &gt;<sup>EntryCount</sup>, MetaLength, &lt;<i>TermMetadata ?
  *       </i>&gt;<sup>EntryCount</sup>
  *   <li>TermStats --&gt; DocFreq, TotalTermFreq
- *   <li>Header --&gt; {@link CodecUtil#writeHeader CodecHeader}
+ *   <li>Header --&gt; {@link CodecUtil#writeIndexHeader IndexHeader}
  *   <li>EntryCount,SuffixLength,StatsLength,DocFreq,MetaLength --&gt; {@link DataOutput#writeVInt
  *       VInt}
  *   <li>TotalTermFreq --&gt; {@link DataOutput#writeVLong VLong}
@@ -128,8 +128,8 @@ import org.apache.lucene.util.compress.LowercaseAsciiCompression;
  * <p>Notes:
  *
  * <ul>
- *   <li>Header is a {@link CodecUtil#writeHeader CodecHeader} storing the version information for
- *       the BlockTree implementation.
+ *   <li>Header is a {@link CodecUtil#writeIndexHeader IndexHeader} storing the version information
+ *       for the BlockTree implementation.
  *   <li>DocFreq is the count of documents which contain the term.
  *   <li>TotalTermFreq is the total number of occurrences of the term. This is encoded as the
  *       difference between the total number of occurrences and the DocFreq.
@@ -148,15 +148,14 @@ import org.apache.lucene.util.compress.LowercaseAsciiCompression;
  * statistics (such as sum of total term freq).
  *
  * <ul>
- *   <li>TermsMeta (.tmd) --&gt; Header, NumFields, &lt;FieldStats&gt;<sup>NumFields</sup>,
- *       TermIndexLength, TermDictLength, Footer
- *   <li>FieldStats --&gt; FieldNumber, NumTerms, RootCodeLength, Byte<sup>RootCodeLength</sup>,
- *       SumTotalTermFreq?, SumDocFreq, DocCount, MinTerm, MaxTerm, IndexStartFP, TrieRootNodeFp,
- *       IndexEndFp
- *   <li>Header --&gt; {@link CodecUtil#writeHeader CodecHeader}
+ *   <li>TermsMeta (.tmd) --&gt; Header, <i>PostingsHeader</i>, NumFields,
+ *       &lt;FieldStats&gt;<sup>NumFields</sup>, TermIndexLength, TermDictLength, Footer
+ *   <li>FieldStats --&gt; FieldNumber, NumTerms, SumTotalTermFreq?, SumDocFreq, DocCount, MinTerm,
+ *       MaxTerm, IndexStartFP, TrieRootNodeFp, IndexEndFp
+ *   <li>Header --&gt; {@link CodecUtil#writeIndexHeader IndexHeader}
  *   <li>TermIndexLength, TermDictLength --&gt; {@link DataOutput#writeLong Uint64}
  *   <li>MinTerm,MaxTerm --&gt; {@link DataOutput#writeVInt VInt} length followed by the byte[]
- *   <li>NumFields,FieldNumber,RootCodeLength,DocCount --&gt; {@link DataOutput#writeVInt VInt}
+ *   <li>NumFields,FieldNumber,DocCount --&gt; {@link DataOutput#writeVInt VInt}
  *   <li>NumTerms,SumTotalTermFreq,SumDocFreq,IndexStartFP,TrieRootNodeFp,IndexEndFp --&gt; {@link
  *       DataOutput#writeVLong VLong}
  *   <li>Footer --&gt; {@link CodecUtil#writeFooter CodecFooter}
@@ -167,7 +166,6 @@ import org.apache.lucene.util.compress.LowercaseAsciiCompression;
  * <ul>
  *   <li>FieldNumber is the fields number from {@link FieldInfos}. (.fnm)
  *   <li>NumTerms is the number of unique terms for the field.
- *   <li>RootCode points to the root block for the field.
  *   <li>SumDocFreq is the total number of postings, the number of term-document pairs across the
  *       entire field.
  *   <li>DocCount is the number of documents that have at least one posting for this field.
@@ -184,7 +182,7 @@ import org.apache.lucene.util.compress.LowercaseAsciiCompression;
  *
  * <ul>
  *   <li>TermsIndex (.tip) --&gt; Header, TrieIndex<sup>NumFields</sup>Footer
- *   <li>Header --&gt; {@link CodecUtil#writeHeader CodecHeader}
+ *   <li>Header --&gt; {@link CodecUtil#writeIndexHeader IndexHeader}
  *       <!-- TODO: better describe trie output here -->
  *   <li>TrieIndex --&gt; {trie&lt;byte[]&gt;}
  *   <li>Footer --&gt; {@link CodecUtil#writeFooter CodecFooter}

Trade-offs

  • Keeps the class's typed style and fixes the two .tmd items, the misplaced .tim PostingsHeader and the header wording. The existing note (:136-138, "arbitrary per-file data") already explains the PostingsHeader and now matches where it sits; it stays in the .tim notes (moving that half to the .tmd notes is optional, not run).
  • PostingsHeader stays an italic placeholder, because the block tree is postings-format agnostic; for the default codec it is "Header, PackedBlockSize" (Lucene104PostingsFormat.java:103), so the corrected .tmd line is exact for the default postings writer; other postings formats define their own. IndexHeader is spelled as Lucene104PostingsFormat already spells it (:106).
  • Left as is, and older than this defect: the .tim term-block productions (:113-124) are simplified, not byte-exact. EntryCount is written as numEntries << 1 | isLastInFloor (:735-742), so the block 0b 2c 61 62 63 64 65 ... holds 5 entries, not 11; SuffixLength is a VLong token with the total suffix bytes, a leaf bit and a compression code (:920-925); the suffix bytes may be compressed (:883-930); the per-term suffix lengths are a section of their own (:934-946); singleton stats are run-length coded (:520-538). The flag bits were packed this way already in 4.10.4, the rest came with LUCENE-4702 (8.5.0). Describing that encoding exactly is a separate, larger javadoc change (not drafted, not run); fix A covers only the .tmd items, the misplaced header and the header wording.
  • Left out, could be added separately: that TrieRootNodeFp is relative to IndexStartFP (FieldReader.java:80-87), and the missing comma in TrieIndex^NumFields Footer (:186).

What the checks validated

Validated on 2026-11-01 in a scratch worktree of 645d4fe14a20 with the diff applied:

  1. The minimal repro rewritten to the corrected grammar (one method, the same one document) reads .tmd as Header, PostingsHeader (IndexHeader Lucene104PostingsWriterTerms, PackedBlockSize 256), NumFields 1, FieldStats (FieldNumber 0, NumTerms 1, SumTotalTermFreq 1, SumDocFreq 1, DocCount 1, MinTerm and MaxTerm a, the three trie pointers), TermIndexLength = .tip length, TermDictLength = .tim length, then CodecUtil.checkFooter, which throws unless the parse ends exactly at the footer; it also checks that no codec magic follows the .tim header. It does not parse the .tim term blocks, which fix A leaves as they are. It passes with assertions on and off, through gradle and standalone.
  2. The unchanged minimal repro still fails with the same message, as it must: the fix changes text, not bytes.
  3. TestTrie (4 tests) and TestLucene104PostingsFormat (34 tests, 3 skipped) pass, seed DEADBEEF.
  4. ./gradlew :lucene:core:check -x test passes (checkGoogleJavaFormat, ecjLint, forbiddenApis).
  5. ./gradlew :lucene:core:renderJavadoc passes with 0 warnings; the rendered page shows the corrected lines and no RootCode.

B (optional add-on). Say in Lucene104PostingsFormat where the PostingsHeader is written (not run)

Its "Term Dictionary" (.tim) section says the postings writer/reader "are actually responsible for encoding and decoding the PostingsHeader and TermMetadata sections described here" (Lucene104PostingsFormat.java:99-103), which places the header in .tim implicitly. One sentence saying that the PostingsHeader is written once to the terms metadata (.tmd) file, and TermMetadata per term in .tim, closes the loop.

Trade-offs

  • Only implicit today, so it is optional; it helps a reader who starts from the postings format page.

What the checks validated

Not run.

C (optional). The same text in the backward-codecs test writer Lucene90BlockTreeTermsWriter (not run)

lucene/backward-codecs/src/test/.../lucene90/blocktree/Lucene90BlockTreeTermsWriter.java has the same FieldDict --> PostingsHeader text (:121, note :145) and the CodecHeader wording (:130, :140, :165, :196); its root code really is written.

Trade-offs

  • A test-only writer for old-format indexes; fix it only for consistency between the copies. It is still on main (#16911 decommissioned branch_10x only).

What the checks validated

Not run.

D (not recommended). Replace the byte grammar with prose (not run)

Describe the files at a high level and drop the element lists.

Trade-offs

  • Fits the "high-level" wording of LUCENE-2946, but loses the detail the class has always given and that #90 kept; A is the smaller, more reviewable change.

What the checks validated

Not run.

Downstream note

Downstream relevance is limited to tools that decode .tim/.tmd bytes by hand from this javadoc (inspectors, ports, recovery tools); Lucene's own reader and every application using its API are unaffected.

How it was found

Semi-formal: a differential grammar check. Each default-codec file's grammar was transcribed from its javadoc and, separately, from its writer; one interpreter parsed the real bytes of 64 generated indexes under both and compared every value with Lucene's own reader. Under the javadoc grammar, all 125 .tim and 125 .tmd files diverged.

The method in full135 words
  • The check made 1,114 file checks (all file types) per run over 64 indexes (4 fixed and 60 seeded random feature configurations), run once with assertions on and once off.
  • Positive control: the writer's layout grammar parses every .tmd file exactly to its footer and, for every .tim, finds the postings header in .tmd rather than after the .tim header (0 failures; it does not parse the .tim term blocks). Vacuity check: 14 of 14 layout mutants with one element dropped are caught.
  • A JUnit test written from the result, then independent tests by verifiers, a reviewer from another model family (OpenAI GPT-5.6) and an adjudication reproduced it; 37 version-portable probe runs from 4.10.4 to main dated each element.
  • This is a differential check of documentation against bytes, not model checking or a proof.

Prior reports, history and checks

Prior reports

No prior report found in GitHub issues, PRs and commits, the JIRA archive, the mailing lists, downstream projects or the web (searched 2026-10-28, rechecked 2026-11-01). Nearest: the changes that made the text stale (LUCENE-9353, #14333) or carried it forward (#90, #823), none of which mentions it. Not fixed upstream.

Searches3 searches
  • Related, not this bug: #10393 / LUCENE-9353 and apache/lucene-solr#1473 (moved the header to .tmd; no doc discussion); #14333 (kept RootCodeLength; its one layout review comment is about the .tip trie); #90 and #823 (carried the .tim PostingsHeader forward); #4020 / LUCENE-2946 (2012 high-level doc policy); #15619 / #15625 (a different stale sentence in Lucene104PostingsFormat, fixed); an outside diagram repository of the FST-era format (no discrepancy reported).
  • Not fixed upstream: upstream main f815e679bf6c (2026-11-01) has the same Lucene103BlockTreeTermsWriter.java as 645d4fe14a20; branch_11x and branch_10_6 carry the same lines. Tracking issue #16937 does not name it.
  • Open javadoc PRs: #16949, #16936 and #16904 touch no block-tree or lucene104 file.
What was searched, when, and what turned up
WhereWhenHowFound
apache/lucene issues, PRs and commits; the JIRA archive; downstream repositories2026-10-28scripted prior-report searches (28 GitHub issue/PR, 5 commit, 4 downstream and 4 JIRA queries); 16 manual GitHub searches on the identifiers; the changed files of all 413 open PRs, with the patches of those touching the block-tree writer or postings format grepped; review comments of #14333, #90, #823 and lucene-solr#1473no report; RootCodeLength, TrieRootNodeFp, PostingsHeader, TermsMeta, FieldDict: 0 issues or PRs
Mailing lists and web2026-10-2820 queries over dev@, java-user@, issues@ and commits@; 9 web searchesno report; only review mails of the related PRs, javadoc mirrors and old format pages
apache/lucene (recheck)2026-11-01upstream main HEAD and the file's commits; branch_11x and branch_10_6 contents; issues and PRs for the identifiers (all time) and for 7 phrasings created since 2026-10-27; the files of the open javadoc PRs; #16937no report: 0 hits; the file is unchanged on upstream main f815e679bf6c

History

The postings header part dates from LUCENE-9353 (87a3bef50f8, 2020-06-16; first in 8.6.0), the root-code part from #14333 (878e23f7739, 2025-04-14; first in 10.3.0, backport #14563). 8.5.2 still matches on the header and 10.2.2 on the root code; from 8.6.0 (header) and 10.3.0 (root code) through 10.6.0 and main, the javadoc is wrong.

Versions tested5 rows
  • Part A (PostingsHeader): LUCENE-9353 "Move terms metadata to its own file" (87a3bef50f8 on main, 0dac659cd17 on branch_8x, both 2020-06-16, apache/lucene-solr#1473) changed postingsWriter.init(termsOut, state) to init(metaOut, state). Carried forward by #90 (5f5d1949e92, 2021-04-20, 9.0.0: the .tmd grammar without the header) and #823 (main 2d278a0efe2 / branch_9x 47bc1fff4d7, 2022-04-20, first released in 9.2.0 through the branch_9x commit: the per-field FieldDict --> PostingsHeader wording).
  • Part B (RootCode): #14333 "A specialized Trie for Block Tree Index" (878e23f7739, 2025-04-14; branch_10x backport 65fd8648eb6, #14563, 2025-05-09) created Lucene103BlockTreeTermsWriter. In 9.0-10.2 the default codec used Lucene90BlockTreeTermsWriter, which really writes a root code.
  • Part C (wording): the headers became IndexHeaders with LUCENE-5969 (4675aaf55d4, 2014-10-21; branch_5x 0f304eafb93, 2014-10-24), first in 5.0.0; 4.10.4 matches.
  • Where the text lived: 8.6-8.11 in BlockTreeTermsWriter (no .tmd grammar at all), 9.0-10.2 in Lucene90BlockTreeTermsWriter, 10.3.0 on in Lucene103BlockTreeTermsWriter.
  • Since: the file's later upstream commits (#14633, #15826, #15970, #16237, the latest 2026-06-11) do not touch the javadoc.
Versions and branches executed, with results
Version or commitVerdictResult
main 645d4fe14a20 affectedthe minimal repro fails with assertions on and off (standalone and gradle); fix A validated
10.6.0 (release commit 4ae946e5a9b), 10.5.2, 10.3.0 affectedthe hunt's test fails 3 of 3 (header placement and root code); same javadoc lines
10.2.2 partlyheader placement fails (the hunt's placement tests fail, 2 of 3); the root code is really written (FST era), so that part matches
9.12.3, 9.0.0, 8.11.4, 8.6.0 partlyno PostingsHeader in .tim (it moved to .tmd); the root code is really written (checked on 8.6.0, 9.0.0 and 9.12.3)
8.5.2 not affectedthe PostingsHeader really follows the .tim header (no .tmd file yet)

Independent checks

Confirmed by an independent reproducer, reviews by a reviewer from another model family (OpenAI GPT-5.6), an intended-behaviour devil's advocate and an adjudication (high confidence); an audit confirmed it as a low-priority doc defect. This doc's gradle run checked the fix (the .tmd layout and the build).

Checks and verdicts376 words

Every check re-ran a repro on main or read the same lines; each executing check also had a control that reads the files the way the writer writes them (the .tmd to its footer) and passes, so the failures do not come from the parsers.

Each independent check and its verdict
CheckVerdictWhat it found
cross-model review, refute lens (OpenAI GPT-5.6, executing) real, high confidenceIts own public-API check fails with assertions on and off; its actual-layout control matches DirectoryReader; Lucene's TestTrie and TestLucene104PostingsFormat pass; intended argument moderate.
independent reproducer realOwn 3-document test fails 4 of 5 on main (assertions on and off), 10.5.2 and 10.3.0; its writer-layout control parses .tmd exactly to the footer; the header tests also fail on 9.12.3.
cross-model review, intended lens (OpenAI GPT-5.6, reading) real, medium confidenceRoot cause agrees, inputs in contract, intended argument moderate; the review itself only read code; the hunt's test was re-run for it on main a9779ef3c193: 3 of 3 fail with assertions on and with assertions off.
intended-behaviour devil's advocate real, documentation only; intended argument weakBuilt and rejected the LUCENE-2946, placeholder and experimental arguments; own 600-document, 3-field test fails 2 of 4 with both controls passing on main and 10.5.2.
adjudication confirmed, high confidence, documentationRe-ran the independent reproduction's test with assertions on (control passes) and the hunt's with assertions off (3 of 3).
credibility audit confirmed, minorA fourth re-run on main; writer, reader and javadoc read; "a real, low-stakes documentation fix".
this doc's validation (2026-11-01) fix A build-checkedWith fix A applied to a scratch worktree of 645d4fe14a20: the repro rewritten to the corrected grammar parses .tmd exactly to its footer (assertions on and off; the .tim term blocks are not parsed); the unchanged repro still fails, as it must; TestTrie and TestLucene104PostingsFormat pass; :lucene:core:check -x test and :lucene:core:renderJavadoc pass.
fact checks of this doc (2026-11-01) bug real; corrections appliedAn independent fact check and a cross-model fact check (OpenAI GPT-5.6) re-ran the repro and confirmed the bytes, the code lines, the history and the security call. They found one wrong count and several overstatements, corrected here, and the simplified .tim term-block productions, now a caveat under Suggested fix.

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

Finding record

LFV-939, snapshot of 2026-11-06

Registry title
The file-format javadoc of the default terms dictionary (Lucene103BlockTreeTermsWriter) gives a wrong .tim/.tmd grammar: each .tmd FieldStats still lists RootCodeLength and root-code bytes, which the trie-based terms index no longer writes, and the PostingsHeader is documented at the start of every .tim field although it is written once in .tmd, before NumFields (documentation defect; the writer and reader agree)
Area
codecs / terms dictionary (Lucene103BlockTreeTermsWriter .tim/.tmd file-format javadoc vs its writer and reader)
Priority
Low, rank 1280: The default terms dictionary .tim/.tmd grammar has two stale elements going back to 8.6 and covers a central per-field file.
Impact class
minor
Impact note
Documentation only: Lucene's writer and reader agree, so indexing, merging, CheckIndex and search are unaffected in every configuration; the people misled are those who read, inspect or re-implement the default terms dictionary format from this class javadoc (Lucene104PostingsFormat sends readers there for the format details), and a parser that follows the text misreads NumFields in every .tmd file the default codec writes and finds no PostingsHeader in .tim (executed); the PostingsHeader placement has been wrong since 8.6.0 (2020) and the root-code leftover since 10.3.0 (2025); no scale or special setting is needed.
Summary
You decode the terms metadata (.tmd) and terms dictionary (.tim) files of the default codec by following the Lucene103BlockTreeTermsWriter javadoc, which says .tmd is Header, NumFields, FieldStats... with a RootCodeLength and root-code bytes in each FieldStats, and that every .tim field starts with a PostingsHeader. The writer instead puts the postings writer's header (an IndexHeader plus the block size) once into .tmd, right after its own header and before NumFields, writes no PostingsHeader into .tim and, since the trie terms index of 10.3.0, writes no root code; a parser built from the text reads NumFields as 63 (the first byte of the codec magic) and, even with that header skipped, takes SumTotalTermFreq for RootCodeLength. Lucene's own reader matches the writer, so nothing breaks at runtime; only the documentation is wrong.
Introduced
two stages plus a wording point [follow-up]: (A, PostingsHeader placement) 87a3bef50f8c08404ee8bd66ca868caf5dd072cb (main) / 0dac659cd17 (branch_8x backport, the commit that first shipped, in 8.6.0), LUCENE-9353 'Move terms metadata to its own file' (apache/lucene-solr#1473, by jpountz): it changed postingsWriter.init(termsOut, state) to init(metaOut, state), which moved the postings header from .tim to the new .tmd, and left the javadoc as it was [read: git show in the full-history clone; exec: the documented .tim PostingsHeader is present on 8.5.2 and absent from 8.6.0]; carried forward by #90 (5f5d1949e92, 2021-04-20, 9.0.0: wrote the .tmd grammar without the postings header) and #823 (main 2d278a0efe2 / branch_9x 47bc1fff4d7, 2022-04-20, 9.2.0 through the branch_9x commit: the per-field 'FieldDict --> PostingsHeader' wording); (B, root code) 878e23f77390058b6787e0a0537de9087d1019b3 (main) / 65fd8648eb6 (branch_10x backport #14563, in 10.3.0), #14333 'A specialized Trie for Block Tree Index' (by gf2121): it created Lucene103BlockTreeTermsWriter from Lucene90BlockTreeTermsWriter, replaced the FST elements of FieldStats by TrieRootNodeFp and IndexEndFp, but kept RootCodeLength, Byte^RootCodeLength and the note 'RootCode points to the root block' [exec: the documented FieldStats parse on 8.6.0 to 10.2.2, where the root code really is written, and fail from 10.3.0]; (C, minor wording) 'Header --> CodecHeader' while IndexHeaders are written, since LUCENE-5969 (4675aaf55d4 main line 2014-10-21, 0f304eafb93 branch_5x 2014-10-24) [exec: 4.10.4 pass, 5.0.0 fail] (2020-06-16 (author date of 87a3bef50f8 and 0dac659cd17: PostingsHeader placement); 2025-04-14 (author date of 878e23f7739 on main: root code; backport 65fd8648eb6 authored 2025-05-09); 2014-10-21 / 2014-10-24 (CodecHeader wording, LUCENE-5969))
First affected version
8.6.0 [exec] for the PostingsHeader placement (last unaffected release 8.5.2 [exec]); 10.3.0 [exec] for RootCodeLength/RootCode, the first release of Lucene103BlockTreeTermsWriter (last unaffected 10.2.2 [exec], FST era); 5.0.0 [exec] for the minor CodecHeader-vs-IndexHeader wording (4.10.4 [exec] unaffected)
Affected versions
PostingsHeader placement: 8.6.0 through 8.11.4, every 9.x and 10.x release through 10.6.0 (10.6.0 run as its release revision, the branch_10_6 head 4ae946e5a9b) and main a9779ef3c193; RootCode: 10.3.0 through 10.6.0 (the same branch_10_6 run) and main; not fixed upstream [exec: 37 version-portable follow-up probe runs, each checking one documented element, ended as expected on 4.10.4, 5.0.0, 8.5.2, 8.6.0, 8.11.4, 9.0.0, 9.12.3, 10.2.2, 10.3.0, 10.5.2, branch_10_6 4ae946e5a9b and main; the finder's JUnit fails 3 of 3 on main (-ea and -da), branch_10_6, 10.5.2 and 10.3.0, and 2 of 3 on 10.2.2 (placement tests only); the independent reproduction's JUnit fails 4 of 5, with its layout control passing, on main (-ea and -da), 10.5.2 and 10.3.0, and its 3 placement/header tests fail on 9.12.3; read: the releases in between from each tag's writer and javadoc]. Before 9.0 the stale text lived in the then-current block-tree writer class (BlockTreeTermsWriter, 8.6-8.11) and in Lucene90BlockTreeTermsWriter (9.x-10.2); the 8.6-8.11 javadoc did not document .tmd at all. Upstream main 645d4fe14a20 (2026-10-28) still carries the lines [read]; all runs used JDK 25 on Linux 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 agreed, an intended-behaviour check, a prior-report search and a skeptical audit; not yet triaged by a person.
Found by
semi-formal
Upstream and prior reports
none found (searched 2026-10-28: a scripted prior-report search with 28 GitHub issue/PR queries, 5 commit queries, 4 downstream-repository queries, 20 mailing-list queries over dev@, java-user@, issues@ and commits@, 4 JIRA-archive queries and the web (546 distinct hits, none the same bug); 16 manual GitHub searches on the identifiers and phrases (RootCodeLength, TrieRootNodeFp, PostingsHeader, TermsMeta, FieldDict: 0 issues or PRs); all 413 open apache/lucene PRs scanned by changed files and the patches of every one touching the block-tree writer or the postings format grepped for grammar edits; review comments of #14333, #90, #823 and apache/lucene-solr#1473, JIRA comments of LUCENE-9353). Nearest items, not the same bug: #10393 / LUCENE-9353 (made the PostingsHeader placement stale; no documentation discussion), #90 (2021, revised this grammar after LUCENE-9353 and was approved as a fix, but left the PostingsHeader in .tim), #823 (2022, wrote the per-field FieldDict wording), #14333 (made RootCodeLength stale; its only doc-layout review request is about the .tip trie section), #4020 / LUCENE-2946 (2012 policy for high-level file-format docs), #15619 / #15625 (a different stale sentence in Lucene104PostingsFormat, fixed 2026-01), an outside diagram repository of the FST-era format (no discrepancy reported). Recheck (2026-10-28): 'RootCodeLength', 'TrieRootNodeFp' and 'PostingsHeader' in apache/lucene 0 hits; none of the 29 issues and PRs created since 2026-10-26 (including a series of javadoc fixes, #16920-#16922, #16927, #16928, #16933-#16935) is about a file-format grammar; of the open PRs updated since 2026-10-26 only #16911 (on branch_10x only; open when searched, merged 2026-10-29: removes the backward-codecs Lucene90 block-tree reader and its test writer copy there, no grammar edit in core) and #16480 (a version-history line in lucene104/package-info.java) touch related files; by author date the latest commits on Lucene103BlockTreeTermsWriter.java are 6b67118c4a0 (2026-06-11, #16237, a rename) and older, none editing the javadoc grammar; upstream main 645d4fe14a20 (2026-10-28) still has the stale lines 112, 121, 131, 151, 153, 156, 159, 170 and 187. Not fixed upstream.
Lucene commit tested
main 645d4fe14a20: the minimal repro with assertions on and off, standalone and through gradle; fix A in a scratch worktree (the minimal repro rewritten to the corrected grammar, the closest suites, :lucene:core:check -x test, :lucene:core:renderJavadoc). The hunt's and the verifiers' tests on main a9779ef3c193 (no codec change up to 645d4fe14a20), the 10.6.0 release commit 4ae946e5a9b, 10.5.2, 10.3.0 and 9.12.3; 37 follow-up probe runs from 4.10.4 to main. Lucene103BlockTreeTermsWriter.java is byte-identical on 645d4fe14a20 and upstream main f815e679bf6c (2026-11-01) (read).

Lucene and upstream sources

Finding record
  • lucene/core/src/java/org/apache/lucene/codecs/lucene103/blocktree/Lucene103BlockTreeTermsWriter.java:111-112 (.tim TermsDict and FieldDict --> PostingsHeader, NodeBlock^NumBlocks), :121 and :131 (Header as CodecHeader), :136-138 (PostingsHeader is 'arbitrary per-file data'), :151-155 (.tmd TermsMeta and FieldStats with RootCodeLength, Byte^RootCodeLength), :156 and :187 (CodecHeader), :159 (RootCodeLength --> VInt), :170 ('RootCode points to the root block'), :298-330 (writeIndexHeader for .tim, .tip and .tmd), :332 (postingsWriter.init(metaOut, state)), :1082-1092 (FieldStats without a root code, then root.index.save), :1133-1141 (NumFields, field stats, .tip and .tim lengths, footer); identical at Lucene main ea57360912ed (compared 2026-10-28)
  • lucene/core/src/java/org/apache/lucene/codecs/lucene103/blocktree/TrieBuilder.java:262-267 (save: IndexStartFP, root node fp, IndexEndFp) and Lucene103BlockTreeTermsReader.java:73-76 (VERSION_START = VERSION_CURRENT = 0), :143-219 (reader follows the writer layout)
  • lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104PostingsWriter.java:222-227 (init: IndexHeader 'Lucene104PostingsWriterTerms' plus VInt BLOCK_SIZE into the stream it is given) and Lucene104PostingsFormat.java:93-104 (Term Dictionary section: 'See Lucene103BlockTreeTermsWriter for more details on the format'; PostingsHeader --> Header, PackedBlockSize)
  • lucene/backward-codecs/src/test/org/apache/lucene/backward_codecs/lucene90/blocktree/Lucene90BlockTreeTermsWriter.java:120-121, :145 (the same stale PostingsHeader text in the frozen test copy; its root code really is written)
The finding in 30 seconds
  • lucene/core/src/java/org/apache/lucene/codecs/lucene103/blocktree/Lucene103BlockTreeTermsWriter.java (lines 111-112, 113-124, 121, 131, 136-138, 151-161, 170, 187: javadoc; 298-332, 520-538, 735-742, 883-946, 1082-1092, 1133-1141: writer)
  • lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104PostingsWriter.java (lines 223-227, init) and Lucene104PostingsFormat.java (line 343, BLOCK_SIZE) and ForUtil.java (line 34)
  • lucene/core/src/java/org/apache/lucene/codecs/lucene103/blocktree/TrieBuilder.java (lines 263-268, save) and FieldReader.java (lines 80-87)
  • lucene/core/src/java/org/apache/lucene/codecs/lucene103/blocktree/Lucene103BlockTreeTermsReader.java (lines 73-76, 145-179)
Who is affected
  • lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104PostingsFormat.java (lines 97, 99-103, 149) and package-info.java (lines 50, 154, 188, 258-265)
Why it looks unintended
  • lucene/core/src/java/org/apache/lucene/codecs/lucene103/blocktree/Lucene103BlockTreeTermsWriter.java (lines 113-124, 136-138, 157-161, 188-189, 209, 332, 520-538, 735-742, 920-946)
  • lucene/core/src/java/org/apache/lucene/codecs/lucene103/blocktree/Lucene103BlockTreeTermsReader.java (lines 73-76)
  • lucene/core/src/java/org/apache/lucene/codecs/lucene104/package-info.java (lines 50, 54, 188)
  • lucene/core/src/java/org/apache/lucene/codecs/PostingsWriterBase.java (lines 45-49)
Suggested fix
  • lucene/core/src/java/org/apache/lucene/codecs/lucene104/Lucene104PostingsFormat.java (lines 99-106) and lucene/backward-codecs/src/test/org/apache/lucene/backward_codecs/lucene90/blocktree/Lucene90BlockTreeTermsWriter.java (lines 121, 130, 140, 145, 165, 196)

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

Repro files

About the diagrams

Made from the code at 645d4fe14a20 and the executed byte dumps (the .tmd bytes after the header; the FieldStats bytes for three documents), in parallel with this doc.

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.