ADR025: Unified codec catalogue defines every persistent format
- Date: 2026-08-09
- Status: Accepted
Context
LeanCorpus 2.x has several independent descriptions of its persistent formats.
CodecConstants, CodecFormats, CodecMigrationRegistry, CodecFormatTable,
production readers and writers, migration extension lists, temporary-file
recognition and typed CodecKit formats can each describe a different part of the
same file.
These descriptions have drifted. Several version constants are newer than their
registered version cases, and CodecFormats.Create selects the first registered
case rather than enforcing its currentVersion argument. Normal indexing can
therefore write an older envelope generation after migration has written the
declared current trailer generation. Inspection also handles some headers through
BinaryReader and others through IndexInput, while compound files hide the
versions of their logical members.
The mutable process-wide CodecMigrationRegistry.Default is not a complete
extension point. It permits silent replacement by codec ID but does not register
file matching, inspection, validation, migration, compound-file handling or
temporary-file cleanup. This is unsuitable for plugins, AOT, deterministic tests
and processes hosting indexes with different format sets.
LeanCorpus 3.0 must continue to read supported indexes from 2.0 onwards,
but it must write one self-identifying current storage generation. Large postings,
vector, BKD and graph files must remain streaming or random access rather than
being forced through byte[].
Decision
One immutable catalogue
Introduce CodecCatalogBuilder and immutable CodecCatalog. The default
catalogue is built statically from built-in declarations. A host can create a
different immutable catalogue by adding explicit third-party declarations before
calling Build(). Registration discovery must not use reflection.
CodecMigrationRegistry, CodecFormatTable, format-version constants,
extension-based migration switches and temporary-file lists cease to be
independent authorities. The mutable migration registry and disconnected typed
format specifications are removed. Fixed internal legacy envelopes remain only
where supported older files require them, and only catalogue declarations may
define current metadata.
Every persistent format is represented, including externally framed JSON and
container formats such as .seg, segments_N, statistics files and .cfs.
Those descriptors use an explicit external or container framing policy rather
than pretending to be canonical binary frames.
Families and file roles
The catalogue has two descriptor levels:
CodecFamilyDescriptoridentifies a logical subsystem and any coordinated migration or cross-file validation. Stored fields, term vectors and vectors are examples of multi-file families.CodecFileDescriptoridentifies one logical file role. It contains its stable format ID, family, display name, file matcher, current format version, supported versions, framing policy, access kind, checksum policy, validation, migration capability and temporary-file patterns.
A file matcher can recognise fixed extensions and generated names such as per-field vector sidecars. An extension is an indexing aid, not the format's identity. Multiple physical files may match one intentional generated role, but two descriptors must not make overlapping claims.
Each supported body version records its integer version, diagnostic label, body
handler or specialist reader, legacy framing, read support and migration
behaviour. Materialised formats may use ICodec<T>. Sequential and random-access
formats retain specialist body implementations over bounded inputs. A typed
CodecKit/Formats definition must either be used by production persistence,
describe an authoritative structured subcomponent, or be removed.
Build() rejects:
- duplicate format IDs or family IDs;
- silent replacement of a built-in;
- duplicate or ambiguous physical claims;
- invalid or non-namespaced third-party identifiers;
- empty, duplicate or unordered version sets;
- a current version absent from the supported set;
- a current version that is not the newest writable version;
- missing migration, validation or temporary-file metadata required by the descriptor's declared behaviour.
The catalogue is the only editable source of the current format version. If compile-time constants remain necessary, they are generated or derived from that source.
Frame and body versions are independent
Canonical binary files use the self-identifying frame specified by ADR026. The frame version describes the physical wrapper. The format version describes the body layout. Moving an unchanged Norms v3 body from a legacy trailer to Frame v1 does not create Norms v4.
Frame version and format version remain separate in APIs, diagnostics, inspector output, compatibility decisions and migration plans. Current 3.0 writers never emit a legacy envelope, trailer or custom header.
One current storage lifecycle
Direct writes, flushes, merges and migration all resolve the same descriptor and terminate at the same current writer. Call sites do not choose a current version or frame themselves.
CodecFileWriter owns canonical framing. A write session exposes append-only
body operations and position, with IBufferWriter<byte> support where useful.
It does not expose arbitrary seeking. A specialist format that requires metadata
backpatching must precompute or bound that metadata, or use an explicitly reviewed
specialist path.
A caller must invoke Complete() to write a valid footer. Disposal without
completion leaves an incomplete frame. The atomic write helper owns recognised
temporary-file creation, completion, disposal, durable flushing when requested,
close-before-rename publication and failure cleanup. This preserves ADR010's
Windows lifetime requirement.
CodecFileReader.Open returns frame metadata and a bounded body IndexInput
view without materialising the body. Explicit Read<T>, ReadBody and Validate
operations cover structured decoding, bounded byte-array materialisation and
integrity validation respectively. Stream APIs adapt to this implementation and
must not contain another framing parser.
Compatibility and legacy readers
LeanCorpus 3.0 reads the 2.x generations that are intentionally listed in catalogue version history and writes only the canonical generation. Opening and searching a supported old index remains allowed. Mutating an old-format index continues to require migration unless a later decision explicitly relaxes that policy.
Legacy support is read-only and descriptor-driven. Internal readers cover the legacy CodecKit envelope, legacy trailer and the specialised stored-fields and postings headers. Heuristic trailer detection remains confined to these legacy readers. Once canonical magic is present, a malformed or unsupported canonical frame is rejected and is never retried as legacy data.
Normal reads reject unknown future frame or body versions before semantic decoding. Inspection may report their structural metadata without decoding. Recovery tooling may request raw body access only through an explicit forensic operation.
Logical files and compound storage
Codec consumers operate on logical files through an abstraction such as
ISegmentFileSource. Loose storage opens a file directly. Compound storage opens
the existing bounded memory-mapped slice described by ADR024. Body readers do not
know which physical source supplied the logical input.
Inventory, inspection, compatibility, validation and migration enumerate the
members of .cfs as logical files. The container's own magic, version and
directory are validated separately. Compatibility decisions use member frame and
format versions rather than treating the container as proof that its members are
current.
Migration never patches a member in place. It opens the source container, stages migrated and unchanged logical members as loose files, validates them, repacks them with the normal compound writer, validates the new container, closes all handles and publishes atomically.
Descriptor-driven migration
Each file or family declares one migration capability:
Nonefor no migration work;Reframewhen body bytes can be streamed unchanged into the canonical frame;Rewritewhen an old semantic body must pass through the normal current writer;CoordinatedRewritewhen a family such as.fdtand.fdxmust be rewritten atomically;Unsupportedwhen inspection is possible but rebuilding is required.
Migration planning is based on catalogue inventory and family actions rather than extension switches. Reframing streams bytes without materialisation. Rewriting invokes the same current writer as flush and merge. A migrated segment must remain current after a later merge.
Integrity, limits and validation
Canonical binary descriptors record a checksum unless a descriptor has a documented reason to opt out. xxHash64 is the default. Fast open validates frame structure, identity, versions, flags, footer consistency and ranges without scanning a large body. Materialising reads verify while consuming the body, and deep validation recomputes every available checksum.
Limits distinguish nested codec frames, explicitly materialised bodies, physical
codec files, sequences, strings, scratch buffers, decompression and nesting. A
multi-gigabyte random-access file can be valid while ReadBody correctly rejects
materialising it. Declared lengths are checked for sign, representation,
operation-specific limits and containment before allocation or slicing.
Validation is layered into storage or container checks, frame checks, semantic body checks and cross-file family checks. Inspector, compatibility, validator, migrator, recovery and CLI tooling consume the same catalogue-backed logical inventory. They do not infer semantics independently from a filename.
Configuration and public API
Normal users receive CodecCatalog.Default. Explicit catalogue configuration is
passed through writer, reader, inspection, compatibility and migration options as
required. Multiple indexes in one process may use different immutable catalogues.
ICodec<T> and the immutable checksum-provider CodecRegistry remain. The new
name CodecCatalog avoids conflating persistent-format declarations with
checksum-provider registration. Misleading 2.x format and migration APIs may be
removed in this major release rather than preserved indefinitely.
Rationale
A single declaration makes version drift and incomplete third-party registration structurally preventable. Immutability makes behaviour deterministic after index configuration and avoids process-wide plugin races. Family descriptors model the actual atomicity of paired files instead of deduplicating file actions later.
Owning only framing and metadata at the common layer preserves the performance
properties of sequential and random-access codecs. Requiring every body to be an
ICodec<byte[]> would reintroduce the buffering that ADR001 and ADR009 sought to
remove.
Treating compound members as logical files preserves ADR024's zero-copy slices while removing a blind spot in inspection and migration. Keeping legacy framing behind explicit read-only adapters preserves supported indexes without allowing old writing paths to remain current architecture.
Consequences
- 3.0 has one authoritative declaration and current writer for each persistent format.
- Canonical framing is defined separately by ADR026 and no longer forces body version bumps.
- Flush, merge, direct writes and migration cannot select different generations.
- Existing supported indexes remain readable, but old-format mutation requires migration and older LeanCorpus releases cannot read newly written 3.0 frames.
- Third-party formats register explicitly through an immutable builder and must declare their complete storage integration.
- Compound inspection and migration operate on member files and validate the container separately.
- Large files retain streaming and random-access readers. Whole-file allocation and checksum scans are explicit operations.
- The 2.x registries, duplicate tables, legacy current writers and extension switches are removed as consumers move to the catalogue.
- Golden frame tests, generated catalogue invariants, historical fixtures, corruption tests, migration monotonicity tests and Windows and Linux coverage are required before 3.0 release.