ADR-062: Project Snapshot Storage v2 — Content-Addressed Sections, Explicit Fact Availability, and Occurrence-Preserving Identity¶
Date: 2026-08-26
Status: Proposed — partially implemented; amended 2026-11-03 (D19 accepted and implemented: an optional new fact is a declared extension fact, not a global SCHEMA_VERSION bump); amended 2026-10-30 (D14-D18, rules accepted; D14, D15 and D17 implemented, D16 partially (evidence and transport), D18 steps 1-2 (per-section decode split; lazy declarations/types/debug layout via a deferred semantic_ir cell) implemented 2026-10-31, per-pair release and the shared decoded-section cache pending -- see known-gaps). Phase 0 primitives implemented (abicheck/storage/:
fact availability, entity/occurrence identity with conflict preservation,
canonical encoding and semantic digest, and the separated version axes).
Phase 1's A1.1-A1.3 are implemented: abicheck/storage/package.py carries
the ProjectSnapshot manifest/ref/object-store object model
(PackageManifest, VariantRef, ArtifactRef, ObjectRef, the ObjectStore
protocol, and the D6 path-layout functions); abicheck/project_snapshot_store.py
is a real, directory-backed ObjectStore implementation
(DirectoryObjectStore) plus a manifest/ref writer and reader over ADR-059's
physical envelope — everything in D6's layout except the .tar.zst transport
form, which remains open; abicheck/storage/import_v1.py's
import_legacy_snapshot/export_legacy_snapshot are the v1-v25 import
adapter and its exact inverse, expressing a single-library legacy document as
a one-artifact, one-variant project that round-trips through the store at the
semantic-digest level. Landed jointly with ADR-063 Phase 8, whose own D8
constraint (abicheck/storage/dto.py's SectionDTO — a distinct, versioned,
explicitly-encoded class per section, never asdict) this now satisfies for
every legacy document field, not just semantic_ir/semantic_ir_conflicts:
abicheck/storage/legacy_sections.py's split_legacy_document/
join_legacy_document partition the rest across D8's binary/
declarations/types/layout/debug/build/graph/provenance sections,
each independently versioned and explicitly allowlisted per field (an
unassigned field is a hard import-time error, never a silent catch-all). What
remains unsplit is each section's own internal shape — elf/dwarf/
build_source/... still carry the existing JSON encoding inside their own
section, not a further per-field typed decode. A1.4 is implemented too
(ADR-063 Track C 8B): abicheck/storage/import_bundle_facts.py/
import_baseline_set.py fold a persisted G38 BundleFacts document and an
actions/baseline set respectively onto this same sectioned representation,
calling import_legacy_snapshot once per library and attaching each
container's own composition facts to a new VariantRef.sections field.
Of the 2026-10-30 amendment's data-evolution rules (plan item A1.9), D17
is implemented (package format v2: immutable refs/<kind>/<id>/<ref-digest>.json
documents named by {id, ref_digest} in manifest.json, an atomic manifest
commit, serialized writers — abicheck/storage/package_publish.py), D14
(storage/wire_extensions.py: preserved unknown sections, header and record
extensions, must_understand on section headers, the document and the
manifest; a blocked section resolves its readers to not comparable through
compare/section_readers.py), D15 (a field inventory generated by
tests/_wire_inventory.py and diffed in CI, plus the section_availability
header table), D16 partially (the evidence digest, persisted as
ArtifactRef.evidence_digest and read by the same-snapshot coverage note,
and the transport digest of each package-archive member; comparison_input
is not), and D18 is not implemented; see
the plan's per-item status.
This is now wired into dump/compare/scan as the default, single-file
shape (Phase 8 redesign). The directory-backed package this ADR
originally specified (manifest.json/refs//objects/sha256/...) turned
out to be pure storage-UX cost for the single-artifact case every dump
performs today — many small files instead of one, awkward to
scp/commit/upload as a CI artifact — for none of its real payoff (content
dedup, independent per-section objects), which only materializes once a
project shares content across multiple artifacts. storage.sectioned_document
packages the identical D8 section split as one JSON document instead;
serialization.snapshot_to_json/write_snapshot write it and
snapshot_from_dict/load_snapshot read it by default, with no CLI flag
required. An older flat .abi.json a prior build wrote stays fully
readable. The directory writer/reader
(abicheck/project_snapshot_legacy.py's write_legacy_snapshot_package/
read_legacy_snapshot_document) remain available as typed-API primitives —
compare/scan --against still accept a directory package as an input
path — but no dump CLI flag produces one today.
A1.4's document-folding slice is implemented (ADR-063 Track C 8B):
abicheck/storage/import_bundle_facts.py/import_baseline_set.py fold an
already-persisted G38 BundleFacts document and an actions/baseline set
respectively into this sectioned representation, calling
import_legacy_snapshot once per library and attaching each container's own
composition facts to VariantRef.sections (a new field this track added).
A1.4/A1.5's live-object slice is implemented too, and now reconciled onto
the same physical layout (Track 1): abicheck/bundle_facts_store.py's
write_bundle_facts_package/read_bundle_facts_package are the
producer/reader of a multi-ArtifactRef PackageManifest from a live
BundleFacts object — one BundleFacts becomes N ArtifactRefs under one
shared VariantRef — and are now a thin wrapper over
bundle_facts_serialization.bundle_facts_to_dict/bundle_facts_from_dict
plus storage.import_bundle_facts.import_bundle_facts/export_bundle_facts,
so a package written from a live object and one written from a persisted
document share the identical VariantRef.sections["bundle_composition"]
layout. PackageManifest.project_sections (abicheck/storage/package.py)
remains D7's general cross-artifact-evidence slot, but this path no longer
populates it. abicheck/project_snapshot_store.py's write_project_manifest/
read_project_manifest publish and read both VariantRef.sections and
project_sections back through the real D6 directory layout.
Former known gap, now closed (Track 1): these two tracks briefly solved
the identical plan item (A1.4, "fold BundleFacts into the sectioned
representation") independently and landed with two different,
non-interoperable physical layouts for the same composition facts —
bundle_facts_store.py originally split variant_fingerprint/
filesystem_aliases/library_filenames onto VariantRef.captured/
ArtifactRef.native_identity and stored only the instantiation manifest in
project_sections, while import_bundle_facts.py bundles all four facts
into one VariantRef.sections["bundle_composition"] object — a package
written by one could not be read by the other's reader. Since
storage/import_bundle_facts.py already established "storage takes an
already-serialized document" as this area's governing contract (matching
import_v1.py), the fix rebuilt bundle_facts_store.py's live-object
writer/reader as a thin wrapper over bundle_facts_serialization
.bundle_facts_to_dict/bundle_facts_from_dict plus storage
.import_bundle_facts.import_bundle_facts/export_bundle_facts, retiring the
separate project_sections/native_identity-for-filename/aliases layout in
favor of the single VariantRef.sections one — including rewriting
bundle_facts_store.py's own test suite (much of it previously pinned to
the internal layout being retired) with the same adversarial-review rigor
the original had.
A1.7 (stored/live and stored/stored release-comparison CLI reachability)
is also implemented: abicheck compare (directory/package release
fan-out, cli_compare_release.py) now accepts a multi-artifact
ProjectSnapshot package directory as either operand, unpacked via
workflows.release_package.resolve_release_package_map into
per-library single-artifact sub-packages the existing single-artifact
resolution path already reads unchanged; --old-variant/--new-variant
select among multiple declared variants. Directory packages only — the
.tar.zst transport form (A1.1's own remainder, below) is not yet produced
by anything, so nothing yet exercises this against one.
A1.6 (bundle_variants: capture wiring) has landed: the
.abicheck.yml bundle_variants: block (model/bundle_variants.py,
strictly validated on every config load) drives abicheck project
capture-variants, which captures every declared variant into one package
(workflows/bundle_variants_capture.py) — one VariantRef per variant
with D9's two maps kept apart (declared from config, captured from what
the dumps recorded), a required variant that cannot be captured refused
before anything is written, an optional one skipped and reported with no
VariantRef. Two stored packages compared through compare now report
comparison_scope.variant_pairing (by variant_id; declared-coordinate
changes as a variant-boundary change distinct from captured drift; an
unmatched variant never read as removed). See the plan's A1.6 entry.
Digest-deduplicated shared evidence beyond what the slices above already
give and the .tar.zst transport form (the remainder of A1.5/A1.1) remain
not implemented, along with BuildSourcePack/project source-graph dedup
(the ~57-59 MB-per-artifact finding this ADR's Context names) (non-ELF
artifact membership, A1.8, has since landed — see the plan's A1.8 entry) —
note this is the
remainder of Phase 1, not Phase 2 (Phase 2 is the separate
scale/performance work: lazy loading, streaming encode, cache migration,
indexes). A full per-item design for A1.1's .tar.zst remainder and
A1.5/A1.6/A1.8 (Goal/Design/Files/Tests/Acceptance criteria each)
exists in docs/contribute/plans/storage-format-v2.md's "Phases" →
"Phase 1" section (A1.7's own entry there is now a landing note, not only a
design); the
object model these items build on (PackageManifest.artifact_refs/
variant_refs, ArtifactRef.kind/.sections) already supports every one
of them without a schema change, which is why each item below is scoped as
a producer/CLI-wiring gap rather than a storage-format gap. All of Phase 2
is not implemented.
Decision maker: abicheck maintainers
Supersedes (partially): ADR-015's
single-document logical model. ADR-059's
physical envelope is kept unchanged and explicitly not superseded.
Context¶
The snapshot format has grown from a single-library JSON dump into the persistence layer for a whole product line. Four distinct persistence shapes now coexist:
- per-library
.abi.json[.zst]snapshots (AbiSnapshot, schema v25); - baseline sets (
manifest.jsonplus per-library snapshots, sometimes with staged binaries); BundleFacts, which embeds every per-library snapshot in one JSON document (schema version tracked byBUNDLE_FACTS_SCHEMA_VERSIONinabicheck/bundle_facts.py, bumped by CLI cleanup phase two'sartifact_typeself-describing-marker addition; still one undivided document regardless of the exact version);BuildSourcePack, which may be embedded inside each snapshot or stored out of band.
Each is individually reasonable. Together they give more than one answer to "what is the baseline for this project", "where does release membership live", "which file owns shared build/source evidence", and "what is the integrity root of a release".
The physical layer is in good shape. ADR-059's envelope detects compression
by magic bytes, rejects suffix conflicts, writes deterministically and
atomically, and enforces stored-size, decoded-size, and zstd-window limits.
Real measurements on oneDAL show it working: a ~149 MB daal snapshot and a
~115 MB oneapi::dal snapshot compress from roughly 264 MB to about 7.7 MB.
Most of that volume is genuine L5 graph evidence, not accidental bloat.
So the problem is not the codec, and the fix is not to store less evidence. The problems are in the logical and container model:
- Whole-document construction and loading.
snapshot_to_dictcallsdataclasses.asdict(), which deep-copies the entire object graph before JSON encoding; the read path decompresses, decodes, and parses one complete document before reconstructing every dataclass. Several full representations can be live at once, so a 150 MB decoded snapshot costs far more than its 5 MB on disk.BundleSignatureEvidencewas introduced precisely because release comparison could not afford to retain full snapshots; that is a symptom, not a general solution. One decodedBundleFactsdocument for a large release can also approach the reader's 1 GiB decoded-size ceiling even though every library in it is individually valid. - Identity loss.
AbiSnapshot.index()is first-wins for functions, variables, and types: duplicates are warned about and then dropped from the lookup maps.typedefsis keyed by bare name (v25 addedtypedefs_qualifiedalongside it, but the lossy map remains).ElfMetadata.symbol_mapmaps a bare symbol name to exactly oneElfSymbol, although ELF legitimately carries several versions, bindings, and default/non-default definitions of one name. Base offsets are abase name -> offsetmapping, which cannot express repeated or path-distinguished base subobjects. The multi-TU merge already documents further cases it cannot resolve (MSVC decorated names that encode return type, uninstantiated template methods with no mangled name, plain-C statics with no TU scoping). - One overloaded version integer.
SCHEMA_VERSIONis at 25 and encodes at least four independent facts: JSON field layout, producer implementation epoch, per-fact reliability, and comparison-critical contract compatibility. Bumps v9, v19-v23, and v25 exist because a producer began emitting a correct value where it previously emitted a real-but-wrong default — which the loader reconstructs into snapshot-level*_facts_reliableflags. A normalization or resolver change can alter meaning without changing any field; adding an optional display field should not imply a new evidence recipe. - Missing conflated with false. A plain
bool,[],{}, orNonecannot distinguish "positively established" from "producer never ran", "producer does not support this", "extraction failed", and "not applicable". The*_facts_reliableflags are correct compatibility patches for the specific historical cases they name, but they do not scale to a general evidence model, and they say nothing when one side was captured at L2 and the other at L4. - Incompletely specified canonical form.
snapshot_to_json()does not globally sort keys, entity list order can follow producer traversal order, and the stable snapshot hash sorts dictionary keys but treats list order as significant.BundleFactsserialization cannot use recursive key sorting at all, because one template-instantiation mapping uses insertion order to carry template-argument order — structural order and incidental map order are not separated. - Duplicated shared evidence. A
BuildSourcePackembedded per library, inside aBundleFactsthat embeds every library, repeats project-wide source graphs, compile databases, and toolchain data once per artifact. The oneDAL graph section alone is roughly 57-59 MB decoded per snapshot. - Multibuild is modelled but not captured.
bundle_multibuild.py(since deleted as unreachable;compare/variant_pairing.pyis the pairing that runs) gets the semantics right — variants are paired, never unioned, and a same-side fingerprint collision is an error — but the ordinary capture pipeline still writes thedefaultvariant fingerprint,bundle_variants:is not wired into.abicheck.ymldiscovery, and the stored/live release comparison is not reachable from the standard CLI. A coherentcpu/gcc + cpu/clang + dpc/icx + aarch64/gccbaseline cannot be produced by a normal workflow today. - Derived graphs are rebuilt with today's semantics.
BundleFactscorrectly stores authoritative per-library ELF metadata and rebuilds the resolution graph rather than persisting a second copy. But provider selection, alias normalization, symbol-version handling, and reachability have each been corrected over time, so the same stored baseline can yield a different derived graph under a later abicheck — with nothing recorded about which semantics produced the original answer.
BuildSourcePack already demonstrates the shape the rest of the project
should adopt: a manifest, normalized facts, optional raw output, and a
content hash computed over normalized content with volatile runtime data
excluded. This ADR elevates that approach from one embedded subsection to
the storage model for the whole project.
Decision¶
D1 — Keep the physical envelope; replace the logical model¶
ADR-059's compression detection, atomic write path, deterministic gzip/zstd output, and decompression-bomb limits are retained unchanged and remain the only place compression is implemented. v2 changes what is stored and how it is addressed, not how bytes are framed. No new binary codec is introduced, and no evidence layer is dropped to save space: JSON compresses this content extremely well, and the remaining cost is construction, retention, and duplication rather than disk.
D2 — Separate the version axes¶
One integer stops carrying four meanings. A v2 package records, as independent fields:
| Axis | Meaning |
|---|---|
package_format_version |
container/manifest layout |
section_schema_version |
per-section field layout (per section kind) |
normalization_recipe |
how spellings/paths/identities were normalized |
producer (name, version, binary_digest) |
what emitted the facts |
extractor_generation |
extraction semantics epoch |
resolver_generation |
derived-graph/resolution semantics epoch |
comparison_contract_version |
what a reader must understand to compare safely |
Exactly two axes fail closed, for two different reasons: a newer
package_format_version means the reader may not be able to locate the
package's structures at all, and a newer comparison_contract_version means
comparing without understanding the change could produce a wrong verdict.
Both also fail closed when the package does not state the axis validly —
absent, malformed, non-integral or non-positive alike. An axis that exists to
refuse unknown semantics cannot treat "unknown" as agreement, and that
reasoning is symmetric: a package that never says what layout it has is no
more parseable than one whose layout is too new. Neither axis is validated
only by the deserializer; a reader must re-check both where it decides,
because the version object is constructible directly.
The remaining five are informational to a reader that does not recognize them, which is what lets an optional display field ship without implying a new evidence recipe.
(Three drafts of this paragraph were wrong in the same direction and each was
caught by review. The first said only comparison_contract_version fails
closed at all; the second granted that both do but kept the
absent-value rule for the contract axis alone, so a consumer implementing the
ADR could synthesize a current format version for a package whose layout is
unknown; and the implementation itself had to be corrected twice before it
matched — a negative version, then a fractional one, each slipping past the
guard written for the previous case. Recorded rather than tidied away,
because the repeated direction is the finding: it is always the
container-layout axis that gets quietly weakened to match the narrower rule,
and weakening it lets a reader silently misparse a package instead of
refusing it.)
Capability is stated explicitly rather than derived from schema
history, and an imported legacy snapshot preserves its
source_schema_version and source_producer_generation so migrations and
audits stay honest instead of accumulating one special case per newly
discovered historical producer defect.
D3 — Fact availability is explicit, never inferred from a default value¶
Every comparison-relevant fact family carries a FactAvailability record:
status (present / partial / not_collected / unsupported /
failed / not_applicable), producer, producer version, recipe, scope,
confidence, and diagnostics. Availability is declared once per fact family,
overridden per entity only where an entity genuinely differs, and
summarized in a coverage ledger.
An empty vtable list then means exactly "the producer ran and established
there are no virtual entries", distinct from unsupported. A comparison
that requires a family whose availability is not present/partial
resolves through policy to NOT_COMPARABLE or an explicitly
reduced-confidence result. It never infers safety from an empty
collection. This generalizes the *_facts_reliable flags, which remain
as the import-time expression of the same fact for legacy snapshots.
D4 — Preserve every occurrence; never resolve identity by dropping data¶
Two identities are modelled separately:
EntityId— the logical declaration/symbol/type believed to be the same across occurrences and releases.OccurrenceId— one specific observation: an AST declaration, a TU occurrence, a binary symbol version, a DWARF DIE, a PDB record, a source location.
Every occurrence is stored. Resolution may group occurrences under one
entity, but must never destroy multiplicity. Where occurrences cannot be
reconciled confidently, both are retained and an IdentityConflict is
recorded — replacing today's warn-and-keep-the-first behavior.
Concretely, an ELF symbol occurrence is keyed by artifact, name, version,
default-ness, binding, type, visibility, and definition/import status
rather than by bare name; a base subobject becomes a list entry carrying
base type, inheritance path, offset, virtuality, access, producer, and
availability rather than a name -> offset mapping.
D5 — One canonical logical encoding¶
- every unordered collection has an explicit, stable sort key;
- every semantically ordered collection is an array, never a map whose
insertion order carries meaning (template arguments become
[{"parameter": …, "value": …}, …]); - a map is used only when its keys are unique and its order is meaningless;
- float normalization is specified:
-0.0and0.0agree, an integral float and the same integer agree, and a non-finite value is refused rather than emitted as a bareNaN/Infinityliteral no conforming JSON parser accepts; - volatile capture metadata (timestamps, hostnames, wall-clock durations, absolute scratch paths) lives outside the semantic-hash domain;
- semantic digests are computed from the normalized logical object, independently of pretty-printing, key order, or compression.
Randomizing producer traversal, dictionary insertion, TU completion, and parallel extraction order must not change any semantic digest or any comparison result.
Path normalization is deliberately not specified here, and is Phase 1 work. An earlier draft of this list claimed it alongside float normalization; nothing defined or implemented it, so a producer had a requirement it could not satisfy consistently and two equivalent captures could hash differently on path spelling or platform (Codex review). Stating a rule nobody can implement is worse than stating none: it reads as settled.
It is not a line item because it is not a small one — separator direction,
case folding on case-insensitive filesystems, absolute versus source-root-
relative form, symlink resolution, and ~-redaction (which ADR-032 D7
already applies to CompileUnit.output, for persistence rather than for
hashing) each change what two captures of one build agree on, and getting any
of them wrong silently merges or splits content. It belongs with the
normalization_recipe axis D2 already reserves, so that a package states
which rule produced its digests rather than every reader assuming the current
one. Until then, a path reaching the hash domain is hashed as written.
D6 — One ProjectSnapshot package¶
Baseline sets, bundle facts, and per-library snapshots converge on a single content-addressed package. A one-library scan is a package with one artifact; bundle facts become one section rather than a parallel top-level format.
project.abicheck/
manifest.json # small; loads immediately
refs/variants/<variant-id>.json
refs/artifacts/<artifact-id>.json
objects/sha256/<aa>/<digest>.json.zst
indexes/index.sqlite # optional, rebuildable, never canonical truth
The transport form is a deterministic .tar.zst of that directory. The
SQLite index accelerates queries and is regenerable from manifests and
objects, so baseline durability never depends on a database migration.
Artifact records carry native binary identity explicitly (content SHA-256,
ELF build ID / Mach-O UUID / PE-PDB identity) — the existing top-level
build_id means an opaque CI identifier and is not reused for this.
Membership is stated for every artifact kind, including PE, Mach-O,
Python-visible, and header-only targets, with bundle-level resolution
declared as an ELF-only capability rather than silently excluding
non-ELF entries.
D7 — Shared evidence is stored once and referenced by digest¶
Project source graphs, variant build evidence, toolchain profiles, compile database slices, common header surfaces, and raw extractor artifacts are content-addressed objects. An artifact references the shared object plus its own overlay instead of embedding a private copy.
D8 — Sections are independently addressable and lazily loaded¶
Snapshot content is split into binary, declarations, types, layout,
debug, build, source_abi, graph, provenance, diagnostics, and
raw_refs sections, chunked to a bounded decoded size (target 1-8 MB,
tuned against real measurements). A project comparison loads two manifests,
all small L0 binary sections, then one matched library pair at a time,
releasing each pair's sections before the next. Peak memory must scale
with the largest active section or library pair, not with the total decoded
size of the release.
Implementation note (2026-09-24, evidence-entity-model Phase 5a; the first
landed slice of storage-format-v2 Phase 2 A2.1). The graph section of a
single-file sectioned snapshot is now loaded lazily: snapshot_from_dict
leaves the section's DTO validation, migration and graph construction for
the first read of AbiSnapshot.surface_graph (or of the
build_source.source_graph alias, which shares one pending cell;
model/lazy_graph.py). A decode failure raises at that access, with the same
error an eager load raised, and never reads as an empty graph. The rest of
D8 (chunking, per-pair release of sections) is not implemented.
Decision: the L5 graph diff is not gated. A default stored-vs-stored
compare of two header-graph snapshots still reads the graph: the L5
diff_source_graph_findings diff, the private_header_leak and
public_to_internal_dependency cross-source checks, the assurance
_graph_completeness pass and the snapshot content digest all run on every
such compare. Gating the L5 diff to runs that ask for it would remove its
RISK-tier findings from default output, which changes findings; nothing in
the Phase 5 measurements justifies that (the verdict was identical in every
variant, but the findings are part of what a default run reports), so lazy
loading ships without it. Lazy loading therefore saves the decode only on
runs that never read the graph (a --depth binary/debug compare, which
strips the header graph before anything reads it, and other
snapshot-metadata-only readers); the compare-side cost is addressed by the compact
graph encoding instead (Phase 5b).
D9 — Variant identity is created at capture time¶
The capture pipeline is told which variant it is producing. A package stores both declared and captured-and-verified variant coordinates. Stable variant identity (target, compiler family, feature toggles) is kept separate from build state that may legitimately change between releases (compiler version, standard, flags, artifact membership) — so adding or removing a library is a change inside a matched variant, not a new variant. Variants are never unioned; old-only and new-only variants stay explicit outcomes.
D10 — Evidence profiles define completeness¶
"Store all relevant information" means: for the selected profile, every required fact family is either present or carries a machine-readable reason why it is not.
| Profile | Required |
|---|---|
binary |
artifact identity, exports/imports, versions, dependencies, loader facts |
abi |
binary plus debug/layout and signature evidence where available |
api |
abi plus public-header declarations and the extraction contract |
source_abi |
api plus L3/L4 build and source facts |
deep_graph |
source_abi plus L5 graph and reachability evidence |
reproducible |
the selected profile plus raw evidence references |
D11 — Caches key on content, not on mtime¶
Cache identity is binary content digest + input tree content digest +
extraction contract + producer/extractor generation + normalization recipe +
requested profile. Size and mtime may be used to avoid recomputing a
digest, but never to define semantic identity, so touching an unchanged
header does not discard an expensive extraction. Eviction uses a byte quota
with LRU rather than an entry count, and caching is per section so an
unchanged binary, a shared graph, or an unchanged variant can be reused.
Implementation note (2026-10-31). The identity half is implemented for the
whole-snapshot and header-AST caches: content digests only
(extract/cache_input_digest.py), over every file under an include root,
file-valued includes, the compiler's built-in search directories and
CPATH-style directories; a stat signature only lets a process skip
re-reading an unchanged file. Byte-quota LRU is implemented
(storage/byte_quota_lru.py). Per-section caching is not (storage plan
A2.3). D11 itself is still Proposed: accepting it is a maintainer decision.
Implementation note (2026-11-01): dependency manifests. Hashing the
content of every file under the compiler's built-in search directories
(~4,400 files) cost 30-60 ms per key derivation, about three derivations
per dump and header-graph attach, and failed the header-graph perf gate.
System include directories now enter every key by spelling and order only
(plus the compiler's content identity, already folded). The system headers
a parse actually read are recorded at store time in a dependency manifest
beside the entry -- ccache's "direct mode" -- owned by
extract/cache_dep_manifest.py: the file list comes from the frontend's own
depfile (-MD -MF; both clang and castxml honour it), each file with its
content digest. A key hit is served only while every recorded file still
digests the same and no file has appeared that would shadow one from an
earlier search directory; a missing or corrupt manifest, or a dependency
modified after the parse started, is a miss / no store. The clang AST entry
(<key>.json.deps), the castxml parse-store entry and its clang-derived
vector/variable-template sidecars, and the whole-snapshot entry
(<key>.deps, the union of the manifests of every parse the dump ran or
was served, gathered by cache_dep_manifest.recording()) all use it. A
frontend run that writes no depfile (a DPC++ multi-pass driver run) records
the old full-content digest of each system directory instead. Project
inputs (headers, include roots, file-valued includes, CPATH-style
directories) stay content-hashed in the key.
D12 — Compression level is internal policy, not a new user knob¶
Local/extraction cache uses zstd level 3, ordinary CI baselines 10-15, archival release publication 19; already-compressed or raw objects are not recompressed. After structural deduplication the marginal value of maximum compression falls further, so this stays a policy table rather than another tuning surface.
D13 — Migration is adapter-based and non-destructive¶
Every v1-v25 snapshot stays readable through an import adapter into the v2 in-memory model. No existing baseline is rewritten in place, and no user is required to regenerate a baseline to keep comparing. Writers adopt v2 behind an explicit opt-in until the validation corpus in the plan passes.
Consequences¶
Positive. Peak memory decouples from release size. Duplicate and ambiguous entities stop disappearing silently. "Not collected" stops reading as "not present". Determinism becomes checkable rather than assumed. One package answers project, release, variant, and integrity questions. Shared graphs are stored once. Historical baselines can be compared under, or explicitly refused by, the semantics that produced them.
Negative / cost. A second storage model exists during migration, and
the import adapter is real, permanent surface. Content-addressed layout is
harder to inspect by hand than one JSON file (mitigated by the transport
.tar.zst and the rebuildable index). Occurrence preservation makes some
sections larger before deduplication makes the package smaller. Explicit
availability will surface genuine NOT_COMPARABLE results that today pass
silently — that is the point, but it is a visible behavior change and is
therefore gated behind D13's opt-in.
What this ADR does not decide¶
- The wire encoding inside an object (JSON is retained; a columnar or binary encoding is not proposed and is not expected to be the bottleneck).
- Concrete chunk-size and zstd-level constants, which are set from the plan's measurement corpus rather than chosen here.
- Whether the SQLite index ever becomes mandatory (it does not today).
- Bundle-level resolution semantics for PE and Mach-O — D6 requires membership and capability declaration, not a new resolver.
- The
ChangeKindor report-schema consequences of newly explicitNOT_COMPARABLEoutcomes; those are ADR-050's and ADR-042's surfaces.
References¶
- ADR-015 — original snapshot format (historical)
- ADR-023 — bundle layer
- ADR-050 — comparability contract
- ADR-059 — physical storage envelope (kept)
- ADR-061 — the
storagelayer this package occupies - G38 — bundle facts and multibuild pairing
- Storage format v2 plan — phasing, acceptance criteria, validation corpus
Amendment (2026-10-21): the source tier has its own generation axis¶
D2's extractor axis covers abicheck/extract/** and the root parse-stage
modules. The L4 source extractors and the L5 graph producers under
abicheck/buildsource/ were in neither generation axis, so a change to their
semantics moved no stated generation and no stored package reported drift.
Folding them into EXTRACTOR_GENERATION was rejected: it would drift every L2
(binary-plus-headers) package on an L4/L5 edit, which is false drift.
The source tier is therefore a third axis, with the same mechanism as the other two:
- Constant.
SOURCE_GENERATIONinstorage/versioning.pyis this build's source-tier semantics epoch. - Scope.
abicheck/buildsource/source_extractors/(every module, derived from the tree) plus the explicitSOURCE_TIER_FILESinstorage/generation_scope.py: the L4 replay and link producers and the L5 graph producers and folds. Comparison and finding modules (source_diff,source_graph_compare,source_graph_findings,cross_source_checks*) are out, because they decide verdicts from recorded facts rather than deciding what is recorded.source_extractors/resolver.pydecides provider selection, so it stays on the resolver axis. The three scopes are disjoint, which a test enforces. - Stamping.
StorageVersions.source_generationis stated only by a package that carries source-tier (L4/L5) facts.this_build_generationstakescarries_source_factsfor this. A package without them omits the key and stays unstated, so its serialized bytes do not change and it never drifts. Packages written before this amendment state no source generation. - Drift. A stated source generation that differs from this build's is
reported as
semantics_differ, the same as the extractor and resolver axes. It is never a refusal: the source tier is a derived-answer caveat under ADR-028 D3.FactGenerationswidens from a pair to(extractor, resolver, source). - Record.
storage/generation_fingerprints.jsongains asourceentry, recorded withscripts/record_generation_fingerprints.py --axis source. The extractor and resolver entries are unchanged.
The extractor axis's own scope and generation do not change, so this amendment
moves no existing stored package's generation. The residual limit is the same
as for every axis: a fingerprint cannot tell a refactor from a semantic change,
so --no-semantic-change --reason is a reviewed claim.
Amendment (2026-10-30): data-evolution rules, digest roles, and immutable publication¶
Status of this amendment: the rules D14-D18 are accepted; D14, D15 and D17 are implemented, D16 partially, D18 only its decode split. Their implementation is tracked as plan item A1.9 in the storage format v2 plan. The rest of this ADR stays Proposed.
Context. An external review of main reported the following. Its figures
were measured outside this repository and are recorded as reported, not
reproduced. A1.9 turns each probe into a named regression test.
- JSON + zstd stays (D1). A 3000-function library dump compresses from 25.4 MB to 107 KB. Splitting declarations into fixed blocks grew their compressed size by 63%.
- A full load takes about 108 MiB RSS. The cost now lies in evolution and loading, not in encoding.
| Probe | Reported | Where | Rule |
|---|---|---|---|
| unknown section kind | the whole document is refused | storage/sectioned_document.py (single-file sectioned document) |
D14 |
| unknown field in a function record | accepted, then dropped on re-save | DTO decode/encode | D14 |
only created_at differs |
digest changes | snapshot_content_digest (storage/snapshot_encode.py) |
D16: by design |
functions reordered, comparison NO_CHANGE |
digest changes | snapshot_content_digest |
D16 |
| republish whose object check fails | manifest.json unchanged, previous package unreadable |
write_project_manifest |
D17 |
The last row is a failure mode beyond the deferred gap that
write_project_manifest's docstring records. That gap covers concurrent
readers and interruption. This one is a plain refused publish. Refs are
written before the object check raises, so the old manifest ends up naming
overwritten refs.
D14 — Optional and must-understand extensions¶
Carrier. Each section header (SectionDTO) and each package manifest gains
a must_understand list. It names the extensions, sections or fields, that a
reader must recognize before using that section for comparison. Anything not
listed is optional.
Optional data is preserved. Each DTO record and each section header gains
an extensions map from names to canonical JSON values (D5). It is never raw
bytes and never a domain-object field (ADR-063 D8). A reader keeps an unknown
optional field or section there and writes it back unchanged on re-save.
Dropping it is a defect. extensions is inside the evidence and
publication digests (D16).
Unknown must-understand data blocks only its dependents. An unknown name
in must_understand blocks only the analyses that read that section. Those
analyses resolve to NOT_COMPARABLE, with a diagnostic that names the
extension. Unrelated sections are still compared. An unknown section kind not
listed in must_understand is kept in extensions instead of refusing the
document. This replaces the current blanket refusal in
sectioned_document.py.
A newer section_schema_version of a known kind. The reader treats the
section as must-understand and blocks its dependents. It does not refuse the
whole document. This brings the code into line with D2, which calls the axis
informational at the package level.
Which mechanism to use:
comparison_contract_version(D2, fail-closed for the whole package) is bumped only when a change can alter the verdict of analyses that do not read the new data. Examples are a change to matching or identity rules.- Any other semantic addition uses
must_understand. - Display or provenance additions use neither.
Implementation decisions (2026-10-30).
- Scope of a name. A section header's
must_understandnames extensions or section kinds; an unknown one blocks that section. A document- or manifest-levelmust_understandapplies to the whole document or package, so an unknown name there refuses it (no section to narrow it to). Theprovenancesection holds the identity every analysis reads, so blocking it refuses the document too. - Which analyses.
compare/section_readers.pydeclares the stored sections each detector (and the surface-metric roll-up) reads, checked against an AST scan of the detector's code. A blocked detector isnot_evaluatedwith acoverage_gapstartingnot comparable:and naming the section and extension;analysis_assurancereadspartial. No exit code changes: a whole-runnot_comparable(exit 16) would also discard the analyses that did compare. - Directory packages (2026-10-31, revised). A section object read from a
directory package with an unknown must-understand name blocks that
section's readers and is kept verbatim for a re-save, exactly as in a
single-file document; only the
provenancesection refuses. (It was refused outright before.) - Newer section versions in directory packages (2026-11-02). A section
object stored at a
section_schema_versionnewer than the reader's, and stated so in the manifest'ssection_schema_versions, blocks only that section's readers with the same cause text as a single-file document and is kept verbatim for a re-save; the manifest-level version check no longer refuses the package for it. A newerprovenancestill refuses. - Directory packages carry the carriers (2026-10-31, revised).
write_legacy_snapshot_package, given theAbiSnapshot, stores its carriers (WireExtensions.to_wire: kept sections and their versions, header extras, record extras, non-presentavailability entries and the document-level must-understand list) as one content-addressed object named by the new optionalArtifactRef.carriers. Every section the carriers hold undecoded (failed, not collected, blocked, kept verbatim) is left out ofArtifactRef.sectionsand of the manifest'ssection_schema_versions, so no default-filled payload is stored as evidence. On read,wire_extensions.carriers_for_readerre-decides what blocks for the reading build (what blocks depends on the reader, sounreadableis not stored), including a kept section of a kind the reader knows but the writer did not decode. Theevidencedigest stored on the ref hashes the carriers in, so it equals the digest of the snapshot the package was written from. Versioning: a ref that setscarriersis written atref_schema_version2; every other ref stays at 1 with unchanged bytes andref_digest. A reader of version 1 refuses a version-2 ref through D17's existing fail-closed check, which is required: it would otherwise read the left-out sections as empty. Nopackage_format_versionbump: the layout is unchanged, and the per-ref axis already fails closed (D2: the narrowest axis that makes an older reader refuse). A ref stating version 1 while carryingcarriersis refused as corrupt. A flat mapping handed towrite_legacy_snapshot_packagecannot say what it lost and is written as what it says (known gap). - Bundles carry the carriers (2026-10-31). A bundle package stores each
member's carriers on its
ArtifactRef.carriers, exactly as a directory package. A G38 bundle document or G40 bundle archive has no ref to hang them on, so a member whose flat document would lose state (wire_extensions.flattening_loses) is stored as its single-file sectioned document, which carries every carrier and whichsnapshot_from_dictalready reads (storage/bundle_member_carriers.py); every other member keeps its flat bytes. Versioning (D2): the narrowest axis an older reader fails closed on is the bundle's ownschema_version, so such a document or archive declares 5 (SECTIONED_MEMBERS_SCHEMA_VERSION); one without a sectioned member keeps declaring what it did (2-4) and its bytes. A reader at 4 refuses a 5, instead of decoding a sectioned member as a flat snapshot with every field defaulted; a sectioned member under a declared version below 5 is refused as corrupted. No D13 adapter: no stored document changes meaning. - Nested record fields (2026-10-31). Unknown keys are kept at any depth
inside an identity-matched record (parameters, record fields, enum members)
and on the
binarysection's ELF/PE/Mach-O metadata objects (including ELF symbols and imports, PE and Mach-O exports), keyed by the top-level record's identity and ordinal and the nested record's list position. The nested record classes are the list-of-dataclass fields of each model class's type hints (wire_record_extensions.nested_record_classes), so they cannot drift from the model. - Presence when
functionsis blocked (2026-10-31). Thefunctionsdetector readstypes(anddebug,graph,layout,semantic_ir), so a blockedtypessection made a plain function removal unreportable. A separatefunction_presencedetector, reading onlybinaryanddeclarations, reports removals classified exactly asfunctionsdoes (diff_symbols._check_removed_function), and runs only whilefunctionsis blocked -- dedupe by construction, no post-pass. Pairing is by mangled name only (the extern-C and ctor/dtor reconciliations compare signatures). - Runtime carrier. The decoded snapshot holds the carriers on the
runtime-only,
compare=FalsefieldAbiSnapshot.wire_extensions, besideunreadable_sections, sodataclasses.replace, copy and pickle keep them together; a sectioned write of a snapshot naming unreadable sections but holding no carriers is refused. - No version bump. Every carrier is written only when non-empty, so
existing documents keep their bytes. A pre-D14 reader still never misreads
the new states: it refuses an unknown section kind and a newer section
version on its own. It would ignore a
must_understandname on a known section's header, so a writer adding one bumps that section'ssection_schema_version.
D15 — The wire contract is independent of runtime classes¶
ADR-063 D8 owns the Domain → DTO vN → wire layering. This amendment adds three requirements to it:
- A field inventory. A checked-in per-section field inventory, generated
from the
SectionDTOencoders, is diffed in CI. A wire change without asection_schema_versionbump and a D13 adapter fails. - Byte identity. A fixed corpus serializes to identical bytes before and after a runtime-only dataclass refactor.
- Section availability. At section level, the states use D3's
FactAvailabilitystatuses. A missing section meansnot_collected. A present but empty section meanspresent. An extractor failure is stored asfailed, with diagnostics. A missing section never reads aspresent. The carrier is the section-header table in the document or manifest, not the payload. Every section kind the producer attempted has a header entry with itsFactAvailability, even when no payload object exists. Sofailedis a header entry with no payload, andnot_collectedis a header entry with that status, or no entry at all for producers that predate this rule. The field inventory covers the header entry.
Implementation decisions (2026-10-30). The inventory is generated by
tests/_wire_inventory.field_inventory from the document the encoders
write for a fixed corpus; tests/unit/storage/test_wire_contract.py diffs
it against tests/fixtures/wire_contract/field_inventory.json and accepts a
moved section only with a section_schema_version bump and a registered
migration from the pinned version. The availability carrier is a
document-level section_availability table beside
section_schema_versions: a key a pre-D15 reader ignores, so no version
bump. A failed entry stays listed in section_schema_versions without a
payload, so such a reader refuses the document rather than read the section
as empty; this build reads it as unusable and blocks its readers like an
unknown must-understand name. Producers record a failure with
storage.wire_extensions.record_section_failure, which also blocks the
readers of the live snapshot, so an in-process comparison and one of the
stored dump agree. Its production caller (2026-10-31) is the PE dump: a PDB
that is located but fails to parse records debug as failed with the
parse error on the export-table path, where debug holds nothing but the
PDB's facts. A missing PDB is not_collected (optional inputs stay
optional). Other sections aggregate several extractors whose failures are
fact-family FactAvailability inside the payload, so no other extractor
fails a whole section; a header-scoped PE dump's debug section also holds
the header parse's provenance and is not failed wholesale (known gap).
Re-checked 2026-10-31 for castxml and DWARF: a castxml failure aborts the
dump (requested headers are not optional), and a DWARF open/parse failure
covers neither debug (which also holds the header AST's provenance and
reliability facts) nor layout (scope and contract facts); DWARF is also an
optional input on the binary path, so failing a section for it would let an
unrequested input lower a passing run's assurance. Neither gets a producer. A
not_collected entry is written back absent on re-save (it was re-saved as
present, which made the document unreadable). A not_collected section adds no
assurance note when a detector reads it, by decision: every pre-D15 document
and every optional section a run did not request (build, source) is
not_collected, so a note would fire on nearly every comparison, and the
missing evidence is already reported where the detector reads it, through
the fact-family FactAvailability defaults the decoder fills. Only failed
and blocked sections take the unreadable_section_gap path.
Amendment (2026-11-03): a not_collected section is read per section.
The decision above is superseded. Its premise was that not_collected would
cover every unrequested section; in practice the writer stamps every
section it writes present (an unrequested build is written, empty), a
pre-D15 document has no table, and no producer writes a not_collected
entry. So an explicit entry is rare, and where one appears the decoder did
not report the gap: it filled the section's fields with defaults, and the
comparison reported every entity the other side held as removed or added
(a not_collected types section read as type_removed with assurance
complete). Now, for the kinds whose decoded fields cannot say "absent"
(binary, build, debug, declarations, layout, types), a
not_collected entry blocks its readers exactly as failed does; the
identity section cannot be not collected; graph and semantic_ir decode
absence as None and are unaffected. A section absent from a document with
no entry is unrecorded and reads as before. The decoded snapshot carries
every non-present section in AbiSnapshot.section_availability, and the
compare report lists them in analysis_assurance.section_availability
(report schema 7.1). The producer API is
storage.section_states.record_section_availability (failed or
not_collected); record_section_failure moved there. The inventory is
now generated from the encoders' declarations
(tests/_wire_inventory.declared_field_inventory), and a written corpus
must use only declared keys. No snapshot or section version bump: a pre-D15
reader ignores the table, and the reader already accepted not_collected
entries; only its reading of them changed.
D16 — One digest per question, none silently redefined¶
| Role | Covers | Today |
|---|---|---|
transport |
the exact stored file bytes, after compression | storage/transport_digest.py: SHA-256 of each package-archive member's stored bytes, in its zip extra field |
publication |
the exact published snapshot, including capture metadata such as created_at |
snapshot_content_digest, unchanged |
evidence |
D5-normalized facts, excluding volatile metadata and the order of unordered collections | storage/evidence_digest.py; persisted as the optional ArtifactRef.evidence_digest |
comparison_input |
the evidence digests plus the contract and policy inputs one comparison read |
not implemented: no comparison-result cache exists to consume it |
Each role answers one question:
transport: is this stored file intact, before decompression?publication: is this the exact artifact that a release recorded?evidence: is this the same evidence? Used for dedup and caches.comparison_input: is a cached comparison result still valid?
The object store's address is a separate thing and keeps its meaning. JSON
objects are addressed by semantic_digest, and raw payloads by raw_digest
over the uncompressed payload (project_snapshot_store.py). That is payload
integrity, not transport.
snapshot_content_digest keeps its meaning. The created_at probe is that
digest working as designed, not a defect. The reorder probe is a defect only
if it reproduces on the evidence digest. D5 already requires stable sort
keys there, and A1.9 adds the test. evidence and comparison_input are
added as new fields. No stored digest is reinterpreted.
Implementation decisions (2026-10-31). transport. The one place
stored bytes travel as a unit is the package archive
(storage/project_package_archive.py), so the packer writes the SHA-256 of
each member's stored (zstd-compressed) bytes into that member's zip extra
field (header id 0x6461); unpack_project_package verifies every member
before writing anything and refuses a mismatch by member name, or an archive
in which only some members carry the field. An extra field keeps the layout,
so an older reader still unpacks a new archive and an archive without any
field (packed before this) is still read: no format bump. The directory
store keeps verifying each object's content address on read; hashing its
compressed files a second time would answer no new question.
comparison_input stays unimplemented: no comparison-result cache exists,
and a digest with no consumer would be an unverified claim.
D17 — Immutable refs and atomic publication (amends D6)¶
D6's ref layout changes to versioned, never-overwritten paths:
manifest.json records {id, ref_digest} per ref, not bare ids.
ref_digest is storage.canonical.semantic_digest (sha256, sha256:<hex>)
over the complete canonical ref document. The document includes its id, its
schema version, and every object digest it names. A ref carries no volatile
capture metadata; any it gains belongs in the manifest. A reader recomputes
ref_digest from the file it loads. A mismatch with either the path or the
manifest entry fails closed, as a corrupted package, the same way an object
digest mismatch does today. It stays the
root and the only commit point.
- Objects and refs are written and verified.
manifest.jsonis replaced by write-to-temp plus atomic rename.- A failed or interrupted publish leaves the previous manifest and every ref it names intact.
Version and migration. This is a layout change. It bumps
package_format_version, so older readers refuse new packages, as D2
requires. A D13 adapter reads the id-path layout. Regression tests cover:
- a failed object check;
- an interruption between the ref writes and the manifest write;
- a changed-content republish under the same ids.
Concurrent writers to one root are serialized. They take
storage/cross_process_lock.py's lock for the whole publish.
Implementation decisions (2026-10-31). Windows. The lock uses
msvcrt.locking over the lock file's first byte where fcntl is absent;
both are released by the OS when the holder dies. Garbage collection.
workflows/package_gc.collect_package_garbage(root, min_age_seconds=3600,
dry_run=False) removes the files under refs/ and objects/ that the live
manifest does not name, directly or through its refs. It holds the publish
lock, so no publish is mid-commit; readers take no lock, so only files older
than the grace period go, and a reader holding a manifest older than that
fails closed (every read is digest-verified). Writers stage objects before
taking the lock, so DirectoryObjectStore.put refreshes the mtime of an
object it reuses, an in-flight .tmp file is never collected inside the
default grace period, directories are never removed, and a writer whose
staged object was collected anyway is refused at its locked re-verification
rather than publishing a dangling reference. It is a Python API only: ADR-054
admits a new root command only past its bar, and no project subcommand
exposes package maintenance yet. This narrows
ADR-066 D1's "content digest" for release history to the publication
digest.
D18 — Selective load before more compression (amends D8)¶
D8's size-bounded chunking target, 1-8 MB decoded, is withdrawn.
- Sections are not pre-chunked. The 63% measurement argues against it.
- A section is split only when a measured access pattern needs part of it.
- The performance lever is D8's lazy, per-section loading. D8's preload of
the small L0
binarysections stays, because pairing needs them before any analysis is chosen. Every other section is opened only when a requested analysis reads it. - Multi-library runs share an immutable, digest-keyed (
evidence) decoded-section cache (D11).
Implementation decisions (2026-10-31). Step 1, the per-section decode split, is implemented; lazy loading of any further section is not.
- Each section kind has its own decoder
(
storage/snapshot_section_decode.py); every derivation spanning sections is a named join with declaredreads(raw sections) andafter(decoded steps), listed inDECODE_STEPS. A step sees the document only through a view of its declared sections, which raises on an undeclared read, so the declarations are enforced, not documented. - The eager path composes the steps and must stay byte-identical (saved
bytes,
publicationandevidencedigests); a test pins this against the same steps fed the whole document, and each section decoder against its section alone. - The joins that block laziness are kept as joins rather than redesigned:
from_headersinference, the reliability flags and the ADR-063 legacy fact backfill (which rewrites decodeddeclarations/types), the generation spelling migration,semantic_irassembly and closure-identity renumbering. A section becomes lazy only for a document on which every join touching it is a no-op;semantic_irassembly is deferred into a cell that forces its input sections. The plan's A1.9 D18 entry holds the dependency table and the next step.
Implementation decisions (2026-10-31, step 2). The declarations,
types and debug-layout sections decode into one attribute,
AbiSnapshot.semantic_ir (the IR owns the declaration store), so they are
deferred together, with the semantic_ir section and the per-entity half of
extraction_scope:
AbiSnapshot.semantic_iris aLazyGraphField; a lazily loaded snapshot holds aPendingGraphwhose decoder (model/lazy_declarations. DeferredSemanticIR) rebuilds the snapshot the eager decode would build and yields its IR.semantic_irassembly (finalize_snapshot_semantic_ir) runs inside that cell.- Eligibility (
storage/snapshot_lazy_decode.lazy_ir_eligible): recordedfrom_headers;schema_versionat or above every legacy fact-backfill rule (so the backfill is a no-op); the document text scanned and free of closure markers, which proves the closure-identity renumbering a no-op. Any other document decodes eagerly. The generation spelling migration runs on the whole document first in both modes. - The ELF-binding backfill and the stored export-absence reconciliation run
on every load and rewrite declarations, so they are queued into the cell
(
defer_declaration_step) and run in their eager order when it decodes. - A decode error in a deferred section is raised at every access, the error
the eager load raises. A malformed deferred section is therefore reported
at first use, not at load;
eager_section_decode()restores load-time reporting. - Not built: per-pair release of decoded sections and the shared
decoded-section cache. Nearly every detector reads
declarations/types, so a full compare decodes the cell anyway, and the peak is set by the whole-documentjson.loads, which lazy decoding does not remove. No real consumer path would read the cache, so per D11 it is not built. Measurements are in the plan's A1.9 D18 entry. - 2026-11-02: a per-section parse (each section's JSON parsed from its own
text span) was measured and not adopted. D14's record-extras split and the
generation migration still parse every section at load, and the best
case is 20% below today's peak. The element-level
declarationsdecode that would bound the per-member peak is deferred as a separate future item (maintainer decision); release fan-out peak does not grow with member count. Figures are in the plan's A2.5 entry.
Exit criteria and non-goals¶
A1.9 is complete when these hold:
- each probe has a passing regression test;
- validation passes on the oneDAL/MKL corpus and on Windows and macOS;
- a test shows a second concurrent publisher blocked or refused.
This amendment introduces no binary codec and no user-facing compression knob (D1, D12).
Amendment (2026-11-03): an optional fact is an extension, not a global bump¶
Status of this amendment: Accepted — implemented (D19). Plan item A1.10 in the storage format v2 plan.
Context. Almost every new persisted fact so far raised the global
SCHEMA_VERSION (storage/snapshot_schema_versions.py, 69 at this
amendment). A reader hard-rejects a document newer than its own version
(_MIN_SCHEMA_VERSION_REQUIRING_HARD_REJECTION), so one optional new fact
made every new dump unreadable for every older build, including the
analyses that never read the fact. D14 already gives each section header an
extensions map that an older reader keeps verbatim, and a
must_understand list that blocks only that section (or, at document
level, the document). D2 says to use the narrowest axis that makes an older
reader fail closed. The global integer was the widest one, used by default.
D19 — An optional new fact is a declared extension fact¶
Rule.
- A new fact whose absence an older reader already reads correctly as
"not collected" is an extension fact. Its value is stored in its
section header's
extensionsmap. It does not bumpSCHEMA_VERSION, anysection_schema_version, or any package axis. must_understandis set only when ignoring the fact would make an older reader's result wrong, not merely less informed. ASECTION_MUST_UNDERSTANDfact blocks exactly the analyses that read its section (D14). ADOCUMENT_MUST_UNDERSTANDfact refuses the document.must_understand_whennarrows the requirement to the values that need it, so a common value costs an older reader nothing.- The global
SCHEMA_VERSIONis reserved for envelope changes (the document's own shape: top-level keys, section set or layout, framing) and wire changes (an existing field's encoding or meaning, so an older reader would misread a value it believes it understands). Each bump after v69 records its kind and why it cannot be an extension fact.
Mechanism.
storage/extension_fact_catalog.py:ExtensionFactdeclares a fact's dotted name, its section, itsReaderSemantics(OPTIONAL/SECTION_MUST_UNDERSTAND/DOCUMENT_MUST_UNDERSTAND), what its absence reads as, its owning ADR, its encode/decode pair and an optionalmust_understand_when.EXTENSION_FACTSis this build's catalog, a static tuple in that module, never filled by registration calls from elsewhere, so what a build understands cannot depend on import order.wire_extensions.UNDERSTOOD_EXTENSIONSis derived from it. A fact name is<namespace>.<name>with at least one dot, so it can never be read as a section kind (the two sharemust_understand).storage/extension_facts.py:write_extension_factrecords a declared fact on a snapshot's D14 carriers and maintains itsmust_understandentry;read_extension_factreturns aFact:PRESENT,NOT_COLLECTEDwhen absent, orFAILEDwhen the section is blocked for this reader or the stored value does not decode. The value is never a domain-model field (ADR-063 D8). It is in theevidencedigest, survives every re-save and in-process copy, and travels into packages and bundles with the other carriers.- A write refuses header extensions recorded for a section the document
does not write (for example a
graphfact on a snapshot with no graph): D14 makes dropping them a defect (wire_extensions.refuse_orphaned_header_extras, on both the materialized and the streamed write path). snapshot_schema_versions.SCHEMA_BUMP_REASONSmaps each version afterSCHEMA_BUMP_RULE_BASELINE(69) toSchemaBump(kind, why_not_extension), kindenvelopeorwire. Theschema-bump-reasonai-readiness check (scripts/schema_bump_gate.py) fails a bump without a row, a placeholder reason, an unknown kind, a row for a version not allocated, and a moved baseline (the baseline is pinned in the gate as well).
Scope. Section-header facts. A per-record extension fact (one value per
function or type) is not implemented; record-level unknown fields are
already preserved (D14), but a typed per-record API is future work. The
flat snapshot_to_dict document has no header to carry a fact, so a member
or package written from it follows wire_extensions.flattening_loses
(stored sectioned), as for every other carrier. Existing v1-v69 fields are
not migrated: the rule applies going forward.
Older readers. Every build that accepts a v69 document reads the
sectioned extensions map, except main commit 9bc29954f (2026-10-29),
which allocated v69 one day before D14 landed and was never released; the
newest tag, v0.7.0 (2026-10-13), reads up to v60 and refuses v69 outright. D14 said a
writer adding a must_understand name to a known section also bumps that
section's section_schema_version, to protect a pre-D14 reader. For an
extension fact that protection is not required: no released reader can
accept the document and ignore the name. A D14 reader blocks the section
either way, so the bump would change only the diagnostic text.
Applied to ADR-079. ADR-079's export_read (complete/partial/
failed per platform) is the first candidate. An older reader that ignores
a partial value reads a cut-short export table as complete, which is
wrong, so the fact is SECTION_MUST_UNDERSTAND on the binary section with
must_understand_when true for any non-complete value. A dump with a
complete read is readable by every older reader at no cost, and one with a
partial read blocks only the analyses that read binary there, instead of
refusing the whole document. ADR-079's D5 global bump is superseded by this
mapping; its D1-D4 stand.
Consequences. A dump that adds an optional fact stays readable by the previous release. A must-understand fact narrows an older reader's result instead of refusing it. A reviewer sees every global bump's justification in the diff that makes it.