Skip to content

Storage format v2 — project packages, occurrence-preserving identity, and explicit evidence availability

Origin: A structural review of the storage layer at b299afd (post-#861 multibuild fingerprint work), asking whether the current format is the right long-term model for real projects, multi-library releases, and multibuild matrices.

ADR: ADR-062. ADR-059's physical envelope is kept unchanged; ADR-015's single-document logical model is what v2 replaces.

Type: Initiative plan (cross-cutting). Touches abicheck/storage/ (new), abicheck/serialization.py, abicheck/snapshot_io.py, abicheck/model.py, abicheck/bundle_facts.py, abicheck/bundle_manifest.py, abicheck/bundle_multibuild.py, abicheck/bundle_variants_config.py, abicheck/snapshot_cache.py, abicheck/comparability.py, and the dump/compare/scan front ends.

Effort: XL (phased). Risk: Phase 0 is additive and inert (new leaf primitives, no producer or reader wired). Phase 1 introduces a second storage model behind an opt-in and a permanent import adapter. Phase 2 is performance and cache work over the Phase 1 model.

Relationship to G38: G38 owns what bundle-level and multibuild comparisons must answer — the finding taxonomy, persisted BundleFacts, variant pairing that never unions, and the C-boundary signature gate. This plan owns how any of that is stored. They are complementary and must not both grow a container format: if G38's Phase 2 lands first it targets today's BundleFacts document and becomes a section under D6 later; if this plan's Phase 1 lands first, G38's Phase 2 targets the ProjectSnapshot package directly. Do not implement a third persisted bundle shape.


Problem

The current format is a strong v1 single-library interchange format with a production-grade compression and I/O layer, and a transitional multi-binary container. It is not yet the right project-scale storage architecture. The full evidence is in ADR-062's Context; the seven load-bearing findings are:

# Finding Consequence
1 Whole-document construct/load (asdict() deep copy, one parsed document) Peak memory scales with the whole release, not the compared pair; a large BundleFacts can approach the 1 GiB decoded ceiling
2 First-wins identity in index(), bare-name typedefs, one-ElfSymbol symbol_map, name -> offset bases Real, valid evidence is silently dropped from lookup
3 One SCHEMA_VERSION carrying layout + producer epoch + reliability + comparison contract Every historical producer defect needs a new special case
4 False/[]/None conflating missing, unsupported, failed, and genuinely-absent Safety inferred from an empty collection
5 Canonical form not fully specified (list order significant to the hash; a map whose insertion order carries template-argument order) Determinism is assumed, not checkable
6 Shared build/source/graph evidence embedded per library ~57-59 MB graph repeated per artifact in a multi-library release
7 Multibuild modelled but not captured; four overlapping persistence shapes No coherent multi-variant baseline from a normal workflow

Explicit non-goal: solving any of this by storing less evidence, or by replacing JSON with a new binary codec. Compression already handles the volume (~264 MB → ~7.7 MB on oneDAL). The cost is construction, retention, duplication, and lost meaning.


Goal and acceptance criteria

Phase 0 — correctness and schema-stability primitives

Additive leaf primitives in a new abicheck/storage/ package, with no producer or reader wired to them, so every existing snapshot, baseline set, and BundleFacts document is bit-for-bit unchanged.

  • A0.1 FactAvailability distinguishes present / partial / not_collected / unsupported / failed / not_applicable, carries producer, recipe, scope, confidence, and diagnostics, and answers "may a comparison rely on this?" as one predicate rather than a per-call-site convention. An AvailabilityLedger resolves a family-level default against per-entity overrides.
  • A0.2 EntityId and OccurrenceId are separate types. An OccurrenceSet groups occurrences under an entity without dropping any, and records an IdentityConflict where occurrences cannot be reconciled — the replacement for today's warn-and-keep-the-first.
  • A0.3 ELF symbol occurrences are keyed by (artifact, name, version, default-ness, binding, type, visibility, definition status), so two versioned definitions of one bare name are two occurrences.
  • A0.4 Canonical encoding: unordered collections carry an explicit stable sort key, ordered collections are arrays, capture metadata is excluded from the semantic-hash domain via one reserved slot at the document root (never by key name at arbitrary depth, which cannot tell a hostname from a platform or a pid field from a process id), and the digest is invariant under key order, insertion order, and pretty-printing.
  • A0.5 The version axes of ADR-062 D2 are separate fields. Two fail closed — package_format_version (the reader may not locate the structures) and comparison_contract_version (the verdict could be wrong) — and both also fail closed on a value the package does not state validly: absent, malformed, non-integral or non-positive alike, re-checked where the reader decides rather than trusted from the deserializer. The remaining five are informational and parse defensively. Imported legacy snapshots preserve their source schema/producer generation.
  • A0.6 Property-style tests state each primitive's contract as invariants (not only example cases), per the root AGENTS.md "Primitive-level property tests" guidance.

Status: implemented (this change). See "Landed in Phase 0" below.

Phase 1 — unified project and multibuild storage

Status: A1.1 (as a zip transport, 2026-09-30)/A1.2/A1.3/A1.4 implemented — the object model (PackageManifest/VariantRef/ArtifactRef/ObjectRef/ObjectStore, PackageManifest.project_sections), a real directory-backed store (abicheck/project_snapshot_store.py's DirectoryObjectStore plus its manifest/ref writer/reader, also publishing/reading project_sections — everything but the .tar.zst transport form), the v1-v25 import adapter (storage/import_v1.py), a single-library snapshot round-tripping through the store as a one-artifact project, and A1.4 (folding BundleFacts/ baseline sets onto that same sectioned representation) are all landed. A1.4 was briefly landed twice over, by two independently-landed slices with non-interoperable physical layouts (storage/import_bundle_facts.py/ import_baseline_set.py, taking an already-persisted document and attaching composition facts to VariantRef.sections; and abicheck/bundle_facts_store .py, taking a live BundleFacts object and attaching them to PackageManifest.project_sections/ArtifactRef.native_identity instead) — reconciled (Track 1): bundle_facts_store.py is now a thin wrapper over bundle_facts_serialization.bundle_facts_to_dict/bundle_facts_from_dict plus storage.import_bundle_facts, so one physical layout serves both the live-object and document entry points; see A1.4's own entry below. A1.7 is now also implemented (directory packages only, matching A1.1's own "everything but .tar.zst" scope). A1.5's storage criterion holds and is tested; its decoded-size criterion is deferred to A2.1/A2.5. A1.6 is implemented (see its own entry); A1.8 is implemented (see its own entry). See "Landed in Phase 1" below.

  • A1.1 ProjectSnapshotStore reads and writes the D6 layout over a directory abstraction, with a deterministic .tar.zst transport form. Implemented except the .tar.zst transport form, which remains open.
  • A1.2 A v1-v25 import adapter maps every existing snapshot into the v2 in-memory model, preserving source schema and producer generation. No existing baseline is rewritten in place. Implemented, and now for every document field, not just semantic_ir/semantic_ir_conflicts: storage/legacy_sections.py's explicit per-section field split puts the rest onto D8's named sections too (each still carrying its own pre-existing JSON encoding internally, not a further per-field typed decode) — see "Landed in Phase 1" below.
  • A1.3 A single-library snapshot is representable as a one-artifact project, and round-trips through the store unchanged at the semantic-digest level. Implemented — see tests/test_project_snapshot_store.py's full package round trip.
  • A1.4 Baseline sets and BundleFacts are expressed as sections of one package rather than parallel top-level formats. Implemented, and reconciled to one physical layout (Track 1). It briefly landed twice, by two independently-landed slices with non-interoperable physical layouts: storage.import_bundle_facts/storage.import_baseline_set (ADR-063 Track C 8B) fold an already-persisted document onto VariantRef.sections; abicheck/bundle_facts_store.py (ADR-063 Track B "8B") independently folded a live BundleFacts object onto PackageManifest.project_sections/ ArtifactRef.native_identity instead — a package written by one could not be read by the other. Closed by rebuilding bundle_facts_store.py's write_bundle_facts_package/read_bundle_facts_package 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: the live-object and persisted-document entry points now produce and consume the identical VariantRef.sections[BUNDLE_COMPOSITION_SECTION_KIND] layout. PackageManifest.project_sections/ArtifactRef.native_identity- for-filename/aliases are retired for this path (no remaining writer populates them for a BundleFacts package); both fields stay as general PackageManifest/ArtifactRef mechanisms — native_identity still carries the real library name here and is used unrelatedly by import_baseline_set.py.
  • A1.5 BuildSourcePack, project source graphs, and toolchain profiles are stored once per project/variant and referenced by digest.
  • A1.6 bundle_variants: is wired into .abicheck.yml discovery, the capture pipeline is told which variant it is producing, and both declared and captured coordinates are stored and verified. Implemented — see A1.6's own entry below.
  • A1.7 Stored/live and stored/stored release comparison is reachable from the standard CLI. Implemented for directory packages (the .tar.zst transport form remains A1.1's own open item) — see "Landed in Phase 1" below and A1.7's own detailed entry.
  • A1.8 Non-ELF artifacts (PE, Mach-O, Python-visible, header-only) are retained as project members with bundle-level resolution declared as an ELF-only capability rather than silently excluded. Implemented — see A1.8's own entry below.

Phase 2 — scale and performance

  • A2.1 Section lazy loading (size-bounded chunking withdrawn by ADR-062's 2026-10-30 amendment, D18); a comparison loads two manifests and all L0 binary sections. It then opens only the sections the requested analyses read, one matched library pair at a time, through a digest-keyed (evidence) decoded-section cache.
  • A2.2 Streaming object encoding replaces whole-document asdict() + one JSON string. Status (2026-10-31): text streamed; sections encoded and written one at a time. The string half: write_snapshot/ write_snapshot_compact/bundle writes stream json_stream.iter_json_* fragments through snapshot_stream_write.write_snapshot_text_stream, so no whole JSON string or its bytes is held (zstd one-shot only when the frame must declare its size). The tree half: storage/snapshot_section_stream.py encodes, packages and writes one section at a time, bytes unchanged. Peak 550 -> 286 MiB on a two-section synthetic, 39.8 -> 37.1 MiB on a binary-only libstdc++ dump (23.9 MB JSON, one dominant section). The record-level step remains: the largest section is still built whole before it is written; see known gap.
  • A2.3 The snapshot cache moves onto the content-addressed store, keyed per ADR-062 D11, with a byte-quota LRU. Eviction implemented (2026-10-31). storage/byte_quota_lru.py replaces the dump cache's 100-entry count with a per-tier byte quota (store.MAX_BYTES, 4 GiB, internal policy like D12). It evicts LRU-by-mtime, breaks ties by name, and never evicts the entry just written. It also sweeps .<name>.*.tmp residue that a crashed write leaves behind once that residue is over an hour old. The key was already content-based per D11 (workflows/dump/cache/key.py: binary/input digests, contract, generations, profile). Key made content-only (2026-10-31). The mtime fold is gone from the whole-snapshot and clang AST keys; both keys, and the castxml one, now hash every file under an include root (any suffix), file-valued includes, the compiler's built-in search directories and CPATH-style directories, via extract/cache_input_digest.py. Raw entries carry a declaring-header sidecar so a reloaded entry scopes like a fresh one. Still open: moving the entries from whole-snapshot files onto the package ObjectStore. That move gives per-section reuse. It depends on A2.1's lazy section loading, which another workstream owns, because a per-section cache entry is only cheaper if a hit can load fewer sections. Re-checked 2026-10-31 on top of D18 step 2: still not built. A dump-cache hit's consumers (compare, scoping, the finished tier) read declarations, types and debug layout, which are the sections D18 defers, so a per-section entry would decode the same bytes; and extraction is monolithic, so no section can be reused across a binary change. Per D11 ("caching is per section so an unchanged binary ... can be reused") the move pays only once an extractor can reuse a section, which no producer does yet. Tests: tests/test_byte_quota_lru.py runs random access sequences against an independent OrderedDict LRU model. It also enumerates an unlink failure at each victim and checks stale and live temp residue.
  • A2.4 Optional, rebuildable SQLite indexes; never canonical truth. Deferred (2026-10-31): no query consumer. Every current reader opens a package by manifest and digest (read_project_manifest, ObjectStore.get), and no code path searches across snapshots or packages by entity. An index without a reader would be a second, unused copy to keep consistent. Revisit when a cross-package query appears, such as "which stored baselines export symbol X". package.py's indexes/index.sqlite layout slot stays reserved.
  • A2.5 Measured: peak RSS for a project comparison no longer scales with the total decoded size of the package. Decision (2026-11-02, maintainer): the target is that release fan-out peak memory does not grow with the number of members, bounded by the largest member (about 8x its decoded size); the streaming parse is deferred. Status (2026-11-02, after the packed contract type graph): met for the default fan-out, reduced but not eliminated for in-process members. The 2026-10-31 growth (304/498/611 MiB at N=1/4/8) was per-member retention, ~75% of it the contract evidence's TypeGraphSnapshot (one per side, whole-surface, held as tuples of string pairs by every finished member's DiffResult until the release folds run); it is now held packed and decoded per read (contract_evidence.TypeGraphSnapshot). compare old/ new/ peak RSS, synthetic members of 6000 functions (23 MiB documents), N=1/4/8, origin/main -> this change:
Fan-out main packed graph
default (member processes) 270/248/275 MiB 262/248/262 MiB
in-process, 1 thread (ABICHECK_MEMBER_PROCESSES=0 ABICHECK_MAX_THREADS=1) 248/276/308 MiB 245/264/272 MiB
in-process, default threads 311/464/453 MiB 309/464/470 MiB

The threaded row's step at N=4 is concurrency (several pairs resident at once), bounded by the worker count, not by N. In-process tracemalloc over compare_release_members (1200 declarations per member, N=2/4/8/16): retained 2.0/3.9/7.7/15.3 -> 0.6/1.2/2.2/4.4 MiB (0.95 -> 0.26 MiB per member); at 6000 declarations, N=1/4/8: 4.5/18.1/36.1 -> 1.1/4.5/8.9 MiB. tests/test_release_member_memory_bound.py (slow) asserts < 0.35 MiB retained per member at 1200 declarations and a 2N peak under 1.3x the N peak; it fails on the tuple-backed graph. Still per member, by design (~0.19 MiB per 1000 declarations): the packed graph, the compact bundle evidence the cross-library pass reads and the public-declaration roots -- so an in-process fan-out still grows slowly with N. The element-level streaming decode of declarations (estimated ~360 -> ~180 MiB peak on a 140 MiB snapshot, table below) is deferred as a separate future item, not part of A2.5. Per-section parse measurements (2026-11-02): synthetic sectioned snapshot, 140 MiB pretty-printed (18k functions with 4 params, 1.8k records with 8 fields, 1.8k variables), Python 3.13, 4 cores:

Measure (load_snapshot) Value
wall time, load / load + first IR access 2.4-2.5 s / 4.7 s
peak RSS (no tracemalloc) 401 MiB
peak traced, load and load + access 359.6 MiB
parsed JSON tree of the whole document 226 MiB
decoded snapshot after load + access 43 MiB
declarations share of the text 97 of 116 MiB of section text
Parse strategy (traced peak, parse only) Peak
whole text + whole tree (today) 359 MiB
whole text + declarations only (best case of deferring every other section) 323 MiB
streamed per section, whole text never held, all trees kept 287 MiB
element-level decode (estimate: text + decoded snapshot) ~180 MiB

A per-section parse would gain 10-20% at most: D14's record-extras split and the generation migration are whole-document checks whose errors are load-time contracts, so every section is still parsed at load, and the 20% case needs a streaming reader that duplicates snapshot_io's bomb guards. The deferred item is element-level streaming of declarations; see known-gaps "ADR-062 D14-D18", D18 (3).


Phases

Phase 0 (implemented)

New abicheck/storage/ package — the ADR-061 storage layer, which was declared in architecture/modules.yaml but had no package yet. Its modules are leaves: they import nothing from abicheck except the public root surfaces, so the layer's dependency direction is satisfied trivially and nothing in the existing pipeline changes behavior.

Phase 1

  1. storage/package.py — manifest, refs, and the object-store abstraction. Object model landed, including multi-artifact/multi-variant shape (PackageManifest.artifact_refs/variant_refs are already tuples, not singletons — see A1.1's own design note below for what's actually missing).
  2. storage/import_v1.py — the v1-v25 adapter (A1.2), including the *_facts_reliable flags becoming FactAvailability records. Landed.
  3. Express a single-library dump as a one-artifact project (A1.3). Landed — project_snapshot_store.py/project_snapshot_legacy.py; see docs/contribute/adr/063-one-semantic-pipeline.md's Phase 8 note for the single-file-by-default CLI wiring this actually shipped as.
  4. Express a BundleFacts (N libraries plus an instantiation manifest) and an actions/baseline set as a multi-artifact project (A1.4). Landed, reconciled to one physical layout — storage/import_bundle_facts.py/ import_baseline_set.py (ADR-063 Track C 8B) fold an already-persisted document onto VariantRef.sections; bundle_facts_store.py's write_bundle_facts_package/read_bundle_facts_package is now a thin wrapper over the same module, folding a live BundleFacts object onto the identical VariantRef.sections layout instead of the earlier, non-interoperable PackageManifest.project_sections (storage/package.py)/ ArtifactRef.native_identity shape. Both are published/read through project_snapshot_store.py's write_project_manifest/ read_project_manifest — see A1.4's own entry above.
  5. Landed: stored/live release-comparison reachability (A1.7) — cli_compare_release.py's per-library fan-out now accepts a multi-artifact ProjectSnapshot package directory as either operand, via workflows.release_package.resolve_release_package_map; see A1.7's own entry below.
  6. Open, designed below: the .tar.zst transport form (the remainder of A1.1), BuildSourcePack/source-graph digest-deduplicated shared evidence (the remainder of A1.4/A1.5). bundle_variants: CLI wiring (A1.6) and non-ELF artifact membership (A1.8) have landed.

AvailabilityLedger.declare and .override rebuild, revalidate, and re-sort the whole mapping per call, so building a ledger of n overrides costs O(n² log n) (CodeRabbit review). That is deliberate for Phase 0, where nothing calls them in a loop and the validating, canonically-ordered reassignment is what makes the ledger's state impossible to corrupt in place. A1.5's digest-deduplicated shared-object writer below is where it starts to matter — the first producer that calls override per entity — so the container decision belongs with that producer, which knows the insertion pattern, rather than being guessed at now. Whatever replaces it must keep both properties the current shape buys: every stored key validated, and iteration order a function of content rather than of insertion.


A1.1 remainder — the .tar.zst transport form

Status (2026-09-30): implemented as a zip, not a .tar.zst. The container was changed by a maintainer decision, for the reason G40's plan records ("Zip, not tar"): random access to one member through the central directory. This keeps the repository at one single-file zip family instead of adding a tar container beside G40's zip, which this plan's own "no third persisted bundle shape" rule argues against.

  • abicheck/storage/project_package_archive.py implements pack_project_package/unpack_project_package/ is_project_package_archive. Members are stored uncompressed, the file identifies itself by a leading mimetype member, and the pinned ZipInfo is shared with G40 in storage/zip_member.py.
  • compare accepts an archive anywhere it accepts a package directory. The CLI unpacks at the operand boundary (frontends/cli/options/operand_path. py); the typed API reads a single-artifact archive via workflows.project_package_input.
  • tests/test_project_package_archive.py covers:
  • byte-identical round trip and determinism;
  • every hostile member shape refused before any write, including traversal, absolute, backslash, symlink, compressed, duplicate, stray and wrong-mimetype members;
  • compare on two archives equal to compare on their directories;
  • a corrupt operand exiting 64;
  • the typed API leaving no unpacked copy behind.

The design text below is kept as the record of the original .tar.zst proposal.

Earlier status: not implemented. Everything else D6 specifies for the directory layout is real (project_snapshot_store.DirectoryObjectStore plus its manifest/ref writer and reader over ADR-059's physical envelope); only the single-file transport wrapper is missing, which is why a package today is "many small files… awkward to scp/commit/upload as a CI artifact" (this plan's own Problem statement, finding row derived from #6/#7) whenever it is used (the directory writer/reader remain real, typed-API primitives and a compare/scan --against input shape even though no dump CLI flag produces one — see ADR-063 Phase 8's landing note).

Goal. A ProjectSnapshotStore directory tree round-trips through one deterministic archive file, so a multi-artifact package is exactly as easy to move around as today's single .abi.json.zst — one path to upload as a CI artifact, attach to a release, or hand to a teammate.

Design. A thin archive/unarchive pair, not a new format: pack(store_dir, out_path) walks the D6 tree (manifest.json, refs/**, objects/sha256/**) in one canonical order — sorted by relative path, the same "iteration order a function of content, not insertion" discipline storage/canonical.py already establishes for in-document collections — and streams each file into a tar archive, zstd-compressed by ADR-059's existing snapshot_io.py compressor (same codec, same decompression-bomb limits; this is a container format change, not a new compression dependency). unpack(archive_path, dest_dir) is the exact inverse: it must reject a member path that would escape dest_dir (../, an absolute path, a symlink target outside the tree) the same way snapshot_io.py's own decompression guard rejects a runaway decompressed size — an untrusted .tar.zst handed to compare is adversarial input, not merely malformed. Determinism is checked the same way A0.4 checks canonical JSON: two packs of the same logical content (built via different traversal orders, e.g. Python dict insertion order varying between constructing the manifest one way vs. another) must produce byte-identical archives — fixed mtime/uid/gid/mode on every tar member, sorted member order, no embedded timestamps in the outer zstd frame.

Files. abicheck/project_snapshot_transport.py (new, sibling to project_snapshot_store.py/project_snapshot_legacy.py, for the same import-layering reason those live outside storage/ — this is a CLI/ filesystem-facing concern, not a leaf primitive): pack_project_snapshot/ unpack_project_snapshot. snapshot_io.py gains no new public surface; this module calls its existing compressor/decompressor functions directly over the tar byte stream instead of a single JSON document's bytes.

Tests. Round-trip identity (pack then unpack reproduces the exact directory tree, file-for-file, byte-for-byte); determinism (two packs of logically-identical content, built via different in-memory construction order, produce identical archive bytes); the path-traversal/symlink-escape adversarial cases, each asserted to raise rather than write outside dest_dir; a real multi-artifact package (see A1.4/A1.5 below) at a realistic size, per this repository's "toolchain/wire format changes need a round-trip test at production scale" convention.

Acceptance criteria. A directory-backed package produced by project_snapshot_store.py packs and unpacks losslessly; compare/ scan --against's existing directory-package input path (workflows.input_resolution.resolve_input) accepts a .tar.zst archive interchangeably with an already-unpacked directory (unpacked to a temp directory internally, the same way a compressed single-file .abi.json.zst is transparently decompressed today).


A1.4 — folding baseline sets/BundleFacts into sections

Status: implemented, reconciled to one physical layout (Track 1, closing the gap this section flagged). It was briefly implemented twice, in parallel (ADR-063 Track C 8B and, independently, Track B "8B") — see "What landed" below for both slices as they landed, and "The reconciliation" for how the collision was closed: bundle_facts_store.py (Track B) is now a thin wrapper over storage.import_bundle_facts (Track C 8B), so there is one physical layout, reachable from both the live-BundleFacts-object entry point and the persisted-document one. G38 Phase 2's own persisted BundleFacts shape landed first (see "Relationship to G38" above), so this item's actual target — per that section's own contingency — became BundleFacts document fields (and an actions/baseline-produced baseline set's manifest.json) becoming sections, not a fresh container design. Two independent slices then built two different, non-interoperable adapters for that same target before either was aware of the other; both are described below rather than picking a winner silently.

Track C 8B — what landed, vs. what this section originally sketched. The original draft below this note (kept for its still-relevant reasoning about why cross-library facts need a home distinct from any one ArtifactRef) proposed a new PackageManifest.project_sections: Mapping[str, ObjectRef] field. This slice instead reuses VariantRef — a BundleFacts document's per_library_snapshots share exactly one matched build (G38 has no notion of one bundle spanning several variants), so its own composition facts (variant_fingerprint, manifest, filesystem_aliases, library_filenames) are exactly the kind of "this shared build's own facts, not any one artifact's" content VariantRef.declared/.captured already existed for one axis of (coordinate matching); this item generalizes that to arbitrary content via a new VariantRef.sections: Mapping[str, ObjectRef] field, symmetric with ArtifactRef.sections one level up. A baseline set's own manifest.json metadata (manifest_version, project_ref, profile, snapshot_schema, fact_set, baseline_generation, generator) gets the identical treatment via its own section kind. This slice's own adapters (storage/import_bundle_facts.py/import_baseline_set.py) take an already-persisted document, matching the "storage takes an already-serialized document" contract import_v1.py established.

Track B "8B" — what landed independently. abicheck/bundle_facts_store.py's write_bundle_facts_package/ read_bundle_facts_package take a live BundleFacts object instead (one ArtifactRef per library under a shared VariantRef, via import_legacy_snapshot against one shared store), and use the original PackageManifest.project_sections: Mapping[str, ObjectRef] sketch this section originally proposed: BundleFacts.manifest is the first real project_sections entry, and filesystem_aliases/library_filenames/the real library name move onto each library's own ArtifactRef.native_identity instead. Not landed by this slice: a shared BuildSourcePack/project source graph as a project_sections entry — closing finding #6's "~57-59 MB graph repeated per artifact" needs that half specifically (A1.5 below).

The reconciliation — done (Track 1). The two slices did not share a physical layout — a package written by one's writer could not be read by the other's reader — because each was built without visibility into the other landing the identical plan item at the same time. Since Track C 8B's adapters already establish "storage takes an already-serialized document" as this area's contract, the fix rebuilds bundle_facts_store.py's writer/reader as a thin wrapper — bundle_facts_to_dict/ bundle_facts_from_dict (already the canonical live-object ↔ document bridge) composed with storage.import_bundle_facts.import_bundle_facts/ export_bundle_facts — retiring the separate project_sections/ native_identity-for-filename/aliases layout for this path. bundle_facts_store.py's own test suite was rewritten to match (the tests pinned to the retired internal layout — project_sections presence, the native_identity-encoded filename/alias shape, the module's own private manifest/alias encoding helpers — no longer apply; the round-trip and budget-guard behavior they exercised is preserved and re-asserted against the new implementation). PackageManifest.project_sections/ ArtifactRef.native_identity themselves are not deleted — they remain general PackageManifest/ArtifactRef mechanisms (native_identity is still used, unrelatedly, by import_baseline_set.py) — only their use for this path is retired.

Goal (both slices). A release directory (today: N per-library .abi.json[.zst] files, sometimes a manifest.json, sometimes a BundleFacts document embedding all N snapshots again) is representable as one ProjectSnapshot package: one PackageManifest naming N ArtifactRefs under a shared VariantRef, with cross-library evidence (BuildSourcePack, the project source graph, the instantiation manifest) stored once and referenced by digest from every artifact that needs it — closing finding #6 ("~57-59 MB graph repeated per artifact") directly, since today's per-snapshot embedding is exactly what A1.5 replaces.

One further deviation, noted rather than silently absorbed: filesystem_aliases/library_filenames stayed as their original {library: value} dict shape inside the bundle-composition section's own JSON payload, rather than moving onto each library's own ArtifactRef .native_identity as this section originally proposed — native_identity is a str -> str map (ADR-062 D6), and filesystem_aliases is a list of alias strings per library, not a single string, so moving it there would need either a new list-valued field on ArtifactRef or a serialization convention (join-with-separator, JSON-in-a-string) neither of which existed before this item and neither of which is obviously right without a second real producer to validate the choice against — deferred, not attempted, the same "no design gap to fabricate a fix for" reasoning the root AGENTS.md applies to a heuristic with no counterexample driving it yet. binary_sha256 (a baseline set's own per-library staged-binary identity, already a single string) did move onto ArtifactRef.native_identity as designed, since it has no such shape mismatch.

Every per-library snapshot must agree on one source_schema_version — PackageManifest.versions carries exactly one StorageVersions for the whole package, so a bundle/baseline-set whose member snapshots were captured under different schema versions is refused outright rather than silently picking one and re-exporting the other's provenance wrong. Every real producer captures every member from one build, so this is not a narrowing of what can actually occur in practice.

Files. abicheck/storage/package.py (VariantRef.sections field + validation, to_dict/from_dict); abicheck/storage/dto.py (BUNDLE_COMPOSITION_SECTION_KIND/BASELINE_SET_SECTION_KIND and their *_to_dto/*_from_dto pairs); abicheck/storage/import_bundle_facts.py and abicheck/storage/import_baseline_set.py (new — the import/export pair for each container shape, each calling import_v1.import_legacy_snapshot once per library rather than adding a second per-library import path). abicheck/storage/legacy_sections.py gains no new field allowlist entries (cross-library facts were never AbiSnapshot fields, so A1.2's per-AbiSnapshot split is untouched by this item).

Tests. tests/unit/storage/test_import_bundle_facts.py and test_import_baseline_set.py cover both adapters' round-trip, validation, and cross-schema-version-mismatch behavior; test_dto.py/ test_project_package.py cover the two new section kinds and VariantRef.sections directly.


A1.5 — digest-deduplicated shared evidence (BuildSourcePack/source graph)

Status (2026-09-30): the storage criterion holds and is now tested; the decoded-size criterion is not met and belongs with A2.1/A2.5. Checked rather than assumed. The build section is {build_source_pack, build_source} and nothing per-library. The graph is its own graph section, and every package writer, including import_bundle_facts, goes through import_legacy_snapshot. So N libraries embedding one capture write N byte-identical build sections, and ObjectStore.put stores them once under one digest.

tests/test_shared_build_evidence_dedup.py states this as a property over 2-5 libraries and generated evidence: - identical evidence is exactly one build object; - each library keeps its own declarations object; - every library reads its own pack back; - two different packs are never merged.

No new writer or section shape was needed. What remains is the second criterion. Loading still decodes each artifact's build section into its own BuildSourcePack, so decoded size scales with N even though stored size does not. Sharing one decoded object across snapshots is unsafe while BuildSourcePack is mutable, and this is A2.1's lazy-loading and A2.5's measurement territory, not a storage-layout change. The earlier text is kept below for the design record.

Earlier status: not implemented. Distinct from A1.4 above: this item is about a shared BuildSourcePack/project source graph/toolchain profile — evidence several libraries in one bundle can genuinely share byte-for-byte — being stored once and referenced by digest from every artifact that needs it, closing finding #6 ("~57-59 MB graph repeated per artifact") directly, since today's per-snapshot embedding (each AbiSnapshot.build_source_pack carrying its own full copy) is exactly what this item replaces. A1.4's landed VariantRef.sections gives this item a home to generalize into (one more section kind holding a project-wide ObjectRef, or several artifacts' own sections["build"] entries collapsing to one digest automatically once they share byte-identical content — ObjectStore addressing is already by digest, not by declared kind, so no separate dedup pass is needed beyond writing through the store's existing put/digest-return contract) — but no producer yet builds a package this way, so the actual wiring remains real, separately-scoped future work.

Acceptance criteria (once implemented). A property test: two libraries sharing byte-identical BuildSourcePack content store exactly one object, not two. Peak decoded size for an N-library release with shared build/source evidence no longer scales with N × (per-library size + shared-evidence size); it scales with (sum of per-library sizes) + (shared-evidence size once).


A1.6 — bundle_variants: CLI wiring

Status: implemented (directory packages; the one-file transport form is A1.1's own concern). A deliberately new design — the deleted bundle_variants_config.py (ADR-065 S1) was not restored.

What landed.

  • Config — model/bundle_variants.py: the .abicheck.yml bundle_variants: block (variant name → target_triple, compiler_family, feature_toggles mapping, required bool, default true) parsed into BundleVariantsConfig/BundleVariantSpec, with total, eager validation (unknown keys, wrong types, missing coordinates, a toggle shadowing a fixed coordinate, an unsafe or case-colliding variant name — every finding at once). bundle_variants is a recognized BuildConfig top-level key, and the same validator runs on every .abicheck.yml ingestion (build_config_schema.WHOLE_BLOCK_VALIDATORS, shared with deployment:), not only in the capture command. BundleVariantSpec.declared() is the VariantRef.declared map ({"target_triple", "compiler_family", **feature_toggles}).
  • Capture — workflows/bundle_variants_capture.py, driven by abicheck project capture-variants --variant NAME=PATH ... --package DIR [--config FILE] [--variant-header NAME=PATH] [--variant-include NAME=DIR] [--dry-run] [-o FORMAT=DEST] (frontends/cli/project_capture_variants.py, attached to the existing project group — no new root command). Three ordered phases: plan_variant_capture (before any extraction: an undeclared/duplicate input, or a required variant with no input, a missing path, or nothing capturable, is a VariantCaptureError → exit 64; an optional one becomes a reported skip); capture_variants (every variant dumped in memory through the shared run_dump_request; a required variant's extractor failure raises with nothing written, an optional one is skipped and reported); then one write into a staging directory renamed into place last, so a failure never leaves a partial package. Each captured variant is one VariantRef with declared from config and captured from captured_coordinates() — only what the snapshots recorded (dwarf_advanced.toolchain compiler family/version/producer, DWARF target arch, container format, ELF machine/class, header-parse standard/frontend; a mixed variant records the sorted distinct values). The two maps are never merged. A variant-level bundle_variant_spec section records required and the toggle keys. A skipped optional variant has no VariantRef at all.
  • Artifact ids are namespaced by variant (<variant>.<library-id>, opaque via resolve_ref_ids when that is not ref-id safe), closing A1.7's carried-forward limitation that two variants of one package could not share a library name; every reader already recovers the library name from ArtifactRef.native_identity.
  • Pairing — compare/variant_pairing.pair_variant_views (the replacement for the deleted pair_variants()): by variant_id only, never by coordinates. workflows/variant_pairing.release_variant_pairing reads both packages' views when both release operands are stored packages; the result rides ReleaseScopePlan.variant_pairing onto the ADR-065 acquisition record and out as the release JSON's comparison_scope.variant_pairing (release schema 1.10; $defs/comparison_scope in compare_report.schema.json). A declared-coordinate difference is variant_boundary_changed with declared_changes, distinct from captured_changes (a version bump). A variant only one side carries is unmatched_old/unmatched_new with a "absence is not removal" reason and its recorded required flag; unmatched_required lists the required ones. Report-only: which variant's libraries are compared is still --variant old=/new=, and no verdict or exit code changes.

Tests. tests/test_bundle_variants_config.py (every wrong type for every key from an independent table, unsafe names, compositional finding collection over every subset of breakages, strict .abicheck.yml ingestion); tests/test_bundle_variants_capture.py (an exhaustive 64-case matrix — two variants × required/optional × {captured, no input, missing path, extractor failure} — against an independently written oracle, including "no file and no staging directory is left behind"; declared vs. captured disagreeing and both kept; pairing properties over every pair of subsets of four ids in both orders; pairing over packages written by real capture-variants runs, through the real compare CLI, schema-validated; an unmatched required variant; an unrelated extra baseline variant not changing the selected comparison; and an integration-marked capture of a real gcc-built -g shared object whose captured.compiler_family comes from DW_AT_producer).

Open, deliberately not in this slice. An unmatched required variant is reported, not gated — whether it should contribute to the ADR-065 --on-incomplete-scope axis is a policy decision for that ADR. Pairing is reported, but the per-library fan-out still compares exactly one selected variant per side (a multi-variant package with no --variant selection is still A1.7's usage error), so "compare every paired variant in one run" is a future fan-out extension. captured records toolchain facts DWARF carries; a build-system-confirmed feature-toggle value is not captured (no producer records one today).


A1.7 — stored/live and stored/stored release comparison from the standard CLI

Status: implemented (directory packages; the .tar.zst transport form remains A1.1's own open item — this item consumes whatever A1.1 eventually produces there unchanged, since it only ever reads a ProjectSnapshot directory). compare/scan --against already accepted a single-artifact directory package as an operand (ADR-063 Phase 8's landing note); this item is the multi-artifact counterpart: a release-level fan-out that compares two packages (or a package against a live directory of binaries) library-by-library and variant-by-variant, the multi-artifact counterpart to cli_compare_release.py's existing directory-of-.so-files fan-out.

Goal. stored/live, live/stored, and stored/stored release comparisons are reachable the same way live/live already is — not a second, parallel command family, per this plan's "Relationship to G38" principle of one container format and this repository's admission bar against a second vocabulary next to an established one. Met: there is no new root command — compare <old> <new> fans out to the release engine for this operand shape exactly as it already did for a loose directory.

Design. cli_compare_release.py's existing per-library fan-out (_compare_release_libraries, and — per this ADR-063 work's own D1 migration above — _resolve_stranded_library) already resolves each library through the shared CompareRequest/DumpRequest pipelines; this item's job is giving that fan-out a package as one of its two top-level operands (today: two directories of loose files) — unpacking a directory package into the same old_map/new_map: dict[str, Path] shape the fan-out already builds from a loose directory, so every downstream step (matching, per-pair comparison, bundle analysis, --bundle-facts-out) is unchanged code operating on resolved paths — a package is a source for that map, not a new code path through the fan-out. workflows.release_package.resolve_release_package_map does the unpacking: for each artifact in the selected variant, it materializes a real, independently-readable single-artifact ProjectSnapshot sub-package directory (written via write_project_manifest, sharing the multi-artifact package's own objects/ store via a symlink rather than copying section content), so the existing single-artifact resolution path (workflows.input_resolution._resolve_project_snapshot_directory) reads each one completely unchanged. Each sub-package is keyed by the same canonical library-matching key a live directory operand's own _build_match_map/binary_utils._canonical_library_key computes (read off ArtifactRef.native_identity's library_filename/library_name fact, falling back to the artifact's own opaque artifact_id when neither is recorded), which is what lets a stored-side map match a live-side or another stored-side one for the same library. cli_resolve.classify_compare_operand now distinguishes a single-artifact package directory (still resolved directly as one snapshot, A1.3's original "file" classification, unchanged) from a multi-artifact one (now classified "directory", routing to the release fan-out) via _package_dir_is_multi_artifact. --old-variant/ --new-variant select which VariantRef to compare when a package carries more than one (defaulting to the package's only variant when it carries exactly one, a usage error — click.UsageError, exit 64 — when it carries several and neither flag is given — the same "ambiguity is a hard usage error, not a silent first-match" discipline SymbolIdentityIndex's unique_alias_match already establishes for a different kind of ambiguity elsewhere in this codebase).

Files. cli_resolve.py (classify_compare_operand's multi- vs. single-artifact split); cli_compare_release_matrix.py (_resolve_release_package_side, wired into _prepare_compare_release_inputs); cli_compare_release.py/frontends/cli/commands/compare.py/cli_options.py (the --old-variant/--new-variant flags and their plumbing from compare through to the release engine — deliberately routed around cli_compare_helpers.py's run_compare, itself already at its own no-growth line ceiling, via ctx.meta/ctx.params rather than a new typed parameter on that function); project_snapshot_legacy.py (materialize_release_variant_artifacts, the storage-layer half: one sub-package directory per artifact, named by the artifact's own already- unique, already-safe artifact_id — never by a caller-controlled display name, which is exactly the source of a directory-collision risk an earlier revision of this item had); workflows/release_package.py (the workflows-layer half materialize_release_variant_artifacts's {artifact_id: (Path, ArtifactRef)} is re-keyed through — canonical-key matching needs binary_utils._canonical_library_key, extract-classified, which storage-classified project_snapshot_legacy.py may not import; see that module's own docstring for the full dependency-direction account). Deliberately does not yet build on bundle_facts_store.py's own ArtifactRef.native_identity conventions any more tightly than reading the same two well-known keys (library_filename/library_name) both of today's not-yet-reconciled multi-artifact writers (bundle_facts_store.py and storage/import_bundle_facts.py) already stamp — see A1.4's own entry above for the reconciliation this still owes.

Tests. tests/test_cli_compare_release_project_snapshot_package.py: all three of stored/live, live/stored, stored/stored against a 3-library fixture package (one breaking removal, one compatible addition, one no-change member — enough to tell a real per-library fan-out apart from one that collapsed every member onto the same verdict), each asserted to produce the same per-library verdict/finding-count outcomes a live/live run over the equivalent loose-file directories would — the "Stored-versus-live parity" row this plan's own Validation corpus section already commits to, now exercised by a real test rather than only stated as a corpus requirement. Also covers the ambiguous-variant usage error and --old-variant disambiguation.

Acceptance criteria. Closes finding #7's "no coherent multi-variant baseline from a normal workflow" for the comparison half (A1.6 above owns the capture half, now landed too); no new root CLI command (AGENTS.md's admission bar) — this is compare's existing release fan-out gaining a new operand shape, not a new verb. Known limitation carried forward, closed by A1.6: two variants of one package could not share a library name, since bundle_facts_store._artifact_id_for_library derives ArtifactRef.artifact_id from the library name alone, not (variant, name). A1.6's capture now namespaces every artifact id by its variant, and its tests exercise this item's variant selection against packages a real multi-variant capture wrote.


A1.8 — non-ELF artifact membership

Status: implemented. storage/import_v1.artifact_kind_for_document (and its live-snapshot twin workflows/bundle_facts_capture. artifact_kind_of_snapshot, both over one artifact_kind_from_facts rule) derives ArtifactRef.kind first-match-wins: header_only → "header_only", a stated platform ("elf"/"pe"/"macho") verbatim, a Python-visible surface (python_ext/python_api) → "python", else the historical "elf". Previously a header-only or Python-only member was mislabeled "elf". A member manifest with no "binary" section reads back without error. bundle_snapshot_from_facts no longer drops a non-ELF member silently: it records it on BundleSnapshot.resolution_not_applicable (member → kind), bundle_analysis.analyze_bundle carries both sides' entries into BundleDiffResult.resolution_not_applicable_members, and the BundleFacts compare JSON emits a resolution_not_applicable_members block ({"artifact_kind", "resolution": "not_applicable"} per member, only when non-empty). The ELF members' resolution graph is unchanged. Tests: tests/test_non_elf_artifact_membership.py (parametrized over every non-ELF kind, plus an exhaustive kind-rule enumeration against an independent oracle). Not covered: the live directory fan-out's own bundle.build_bundle_snapshot still skips non-ELF files during discovery (it never dumps them into a member in the first place).

The original design text follows.

Goal. A PE/Mach-O/Python-visible/header-only library is a first-class package member — representable, storable, and readable — even though bundle-level resolution (dependency-graph edges, ABI-affecting-type propagation) stays an ELF-only capability, per D6's own explicit split between "can this be a member" and "can this be resolved".

Design. No new schema: ArtifactRef(kind="pe", ...) / kind="header_only" already round-trips through to_dict/from_dict today (confirmed by reading ArtifactRef.__post_init__ — kind is validated as non-empty text, never restricted to a fixed enum). This item is therefore almost entirely a producer + capability-declaration change, not a storage change: bundle_facts_store.py (A1.4/A1.5) must accept a non-ELF AbiSnapshot/equivalent fact object without assuming sections["binary"] exists (a header-only member legitimately has no "binary" section at all, per ArtifactRef.sections's own docstring), and whatever consumes PackageManifest.artifact_refs for bundle-level resolution (bundle._compute_resolution_graph and siblings) must treat a non-ELF kind as "a member with no resolution edges" rather than raising — the same "declared as an ELF-only capability rather than silently excluded" framing D6 already states, made mechanical: a resolution pass that silently drops a non-ELF member is the bug this item exists to close, one that silently fails on it (no diagnostic) or crashes is a regression this item must not introduce either.

Files. bundle_facts_store.py (A1.4/A1.5) — accept any ArtifactRef.kind, not only "elf"; abicheck/bundle.py/abicheck/bundle_soname.py (wherever _compute_resolution_graph lives today) — an explicit non-ELF branch that records "no resolution edges, capability not applicable" rather than inferring absence from a missing ELF-shaped field.

Tests. A package with one ELF member and one header-only member; bundle-level resolution runs over the ELF member unchanged and reports the header-only member's absence from the resolution graph as an explicit, named fact (not a silent gap a reader has to infer from the member simply not appearing).

Acceptance criteria. Closes finding #7's "non-ELF artifacts... silently excluded" half specifically — a PE/Mach-O/Python/header-only library survives a round trip through the package format and appears in a package listing/report, even where no resolution graph can place it.

  • A1.9 ADR-062 2026-10-30 amendment (D14-D18). Partially implemented; per-item detail under "ADR-062 amendment of 2026-10-30 (D14-D18): A1.9 status" below.
  • D17, implemented: versioned immutable refs and an atomic manifest publish, with the package_format_version bump to 2 and a D13 adapter for the v1 id-path layout. Writers are serialized. This closes the "third, deliberately deferred gap" below and its failed-check variant.
  • D14, implemented: must_understand and extensions carriers; detail below.
  • D15, implemented: a generated, CI-diffed field inventory, the byte-identity test, and the section_availability header table (read per section since 2026-11-03: not_collected blocks like failed; generated inventory from encoder declarations).
  • D16, partially implemented: the evidence digest, persisted on ArtifactRef and consumed by the same-snapshot coverage note, and the transport digest of each package-archive member (2026-10-31). comparison_input is not implemented (no consumer).
  • 2026-10-31 residual closure: packages carry the D14/D15 carriers (ArtifactRef.carriers, ref schema 2), a package section's unknown must-understand name blocks only that section, nested unknown record keys are kept, the PE dump records a failed debug section, a function_presence detector reports removals while functions is blocked, package GC and a Windows publish lock; detail below. Bundles carry the carriers: bundle packages on ArtifactRef.carriers, G38 documents and G40 archives as a sectioned member under bundle schema_version 5 (storage/bundle_member_carriers.py; tests/unit/storage/test_bundle_carriers_roundtrip.py). 2026-11-03: no writer accepts a flat mapping that lost carriers (snapshot_to_dict refuses to make one); detail below.
  • D18, step 1 implemented (per-section decode split, no new laziness); lazy loading beyond the graph section is not.
  • Frozen container corpus (2026-11-03): tests/fixtures/containers/ freezes one sample of each container and platform/frontend the generation corpus does not cover: the G38 document, the G40 archive, the directory ProjectSnapshot package, and clang-frontend, Mach-O and PE snapshots. tests/test_container_corpus.py holds each one to the generation corpus's invariants (loads with no unexpected notice, compares clean against a fresh production, byte and respelling drift only as a declared migration; re-encoding is byte-identical unless a version moved with its D13 migration). The container-corpus gate (scripts/container_corpus_gate.py) fails a bumped encoder version until scripts/freeze_container_corpus.py adds an entry under it. The corpus found that project capture-variants stamped a package assembled from stored snapshots unstated, dropping the generation-drift notice its members carried (fixed).
  • Exit: one regression test per amendment probe (D14's and D17's exist), the oneDAL/MKL corpus, Windows and macOS, and concurrent-publisher serialization (tested on Linux).

  • A1.10 ADR-062 2026-11-03 amendment (D19): an optional new fact is a declared extension fact, not a global SCHEMA_VERSION bump. Implemented: storage/extension_fact_catalog.py (ExtensionFact, ReaderSemantics, the static EXTENSION_FACTS catalog), storage/extension_facts.py (write_extension_fact/read_extension_fact), a write refusing header extras for a section it does not write, and the schema-bump-reason ai-readiness check (scripts/schema_bump_gate.py) over snapshot_schema_versions.SCHEMA_BUMP_REASONS. Tests: tests/unit/storage/test_extension_facts.py (an older reader loses only an optional fact; a must-understand fact blocks only its section, or the document; the package path blocks the same section) and tests/test_schema_bump_gate.py. Not implemented: a typed per-record extension fact (record-level unknown fields are already preserved by D14). The catalog is empty; ADR-079's export_read is the first planned entry.

Phase 2

Lazy loading, streaming encode, cache migration, indexes, transport, and the measurement work that sets D12's level table (D8's chunk size was withdrawn by ADR-062 D18).

Status: A2.1 partially implemented — the graph section, and on eligible documents declarations/types/debug layout (ADR-062 D18 step 2, 2026-10-31; detail under A1.9's D18 entry) (evidence-entity-model Phase 5a, with 5b/5c's compact encoding of the same section). Implemented here, as this plan's A2.1, not as a parallel mechanism:

  • snapshot_from_dict/decode_snapshot read a sectioned document with defer_graph=True (storage/sectioned_document.py, storage/import_v1.export_legacy_sections): the graph section's SectionDTO validation, migration and SourceGraphSummary construction run on first read of AbiSnapshot.surface_graph or its build_source.source_graph alias (one shared model/lazy_graph.PendingGraph), once and under a lock. The undecoded payload is the parsed section JSON, held until then; the whole document is still one json.loads.
  • Equality, pickling, deep and shallow copies, snapshot-cache round trips and dump→load→save bytes behave as with eager decoding; a decode failure raises the eager error at access, never an empty graph.
  • The section itself is now the compact graph table (storage/graph_table_codec.py, schema v49), holding observed evidence only: on real oneDAL libonedal_core it is ~10 MB instead of 79 MB.

Not implemented: lazy loading of any other section, per-library-pair release of sections in a project comparison, A2.2's record-level encoding (sections stream), A2.3/A2.4, and the A2.5 measurement for a project comparison. A default stored-vs-stored compare still reads both graphs (the L5 source-graph diff, cross-source checks, assurance and the content digest all do), so on that path lazy loading saves no decode; ADR-062 D8's implementation note records why the L5 diff was not gated. Measurements: the evidence-entity-model plan's "Phase 5 follow-up measurements".

ADR-062 amendment of 2026-10-30 (D14-D18): A1.9 status

The amendment's data-evolution, digest-role and publication rules (plan item A1.9), item by item. Open items are also listed in known gaps.

  • D17, immutable refs and atomic publication: implemented. Package format v2 (PACKAGE_FORMAT_VERSION = 2) writes each VariantRef/ ArtifactRef document once to refs/<kind>/<id>/<ref-digest>.json (storage/package_publish.py). The document carries its own ref_schema_version; ref_digest is semantic_digest over the whole document; manifest.json records {id, ref_digest} per ref in variant_refs/artifact_refs. A reader recomputes ref_digest and fails closed on a mismatch, or on a newer ref_schema_version. write_project_manifest verifies every section object, writes the ref documents, then replaces manifest.json by temp file plus atomic rename, all under storage/cross_process_lock.single_flight, so writers to one root are serialized. A v1 package's id-path layout (refs/<kind>/<id>.json plus bare id lists) is still read: the D13 adapter. A v1-only reader refuses a v2 package through D2's fail-closed package_format_version axis. tests/test_package_immutable_publication.py covers these cases:
  • a failed object check for each section owner;
  • a crash at every write (each ref document, then the manifest);
  • a changed-content republish under the same ids;
  • a writer waiting on the lock holder;
  • six concurrent writers with and without the lock (a package is never mixed);
  • malformed {id, ref_digest} entries, a tampered ref document and a newer ref_schema_version.

  • Garbage collection (2026-10-31): workflows/package_gc.collect_package_garbage removes the refs/objects the live manifest no longer names, under the publish lock and past a grace period (ADR-062 D17's implementation decisions); DirectoryObjectStore.put refreshes a reused object's mtime. Windows writers serialize on msvcrt.locking. tests/test_package_gc_and_locks.py enumerates republish counts x file age x a crashed unpublished writer x dry run against a live set parsed from the JSON on disk, GC racing writers, a writer whose staged object was collected (refused, never dangling), and the msvcrt path against a simulated msvcrt (exclusion, deadline, unsupported filesystem).

Residual: no CLI for GC, and the amendment's oneDAL/MKL, real-Windows and macOS validation has not been run. - D16, digest roles: partially implemented. snapshot_content_digest is the publication digest and is unchanged. The evidence digest (storage/evidence_digest.py, evidence_digest_of_document) is semantic_digest over the snapshot_to_dict() document with the volatile capture fields dropped (created_at, source_mtime, source_mtime_epoch, source_path, build_id) and the identity-matched record lists (top-level functions/variables/types/enums, ELF symbols/imports) stably sorted by the identity compare matches on, so records sharing one identity keep their order (index() is first-wins). Parameters, fields, bases, enum members and DT_NEEDED are never sorted. An EVIDENCE_FORM_VERSION is hashed in. - Persistence: import_legacy_snapshot records it as the optional ArtifactRef.evidence_digest, written only when set. No ref_schema_version bump: a reader that predates the field ignores it and still verifies it through ref_digest, and a ref without it keeps its exact bytes. A bump would make every such reader refuse the package for a field it can ignore (D14: neither mechanism is needed). - Consumer: the same-snapshot coverage note (workflows.gate.snapshot_identity_digests, now owned by storage.snapshot_digest_cache.evidence_identity_digests). It answers "same evidence?", so two dumps differing only in capture metadata or list order now get the note. The memo is keyed by role. The order of work keeps the bounded path at one serialization per snapshot: both persisted digests; a declared-field or record-count prefilter; an equal publication pair already computed; a different publication pair with nothing the evidence form normalizes differing (decided from the model, no serialization); otherwise the evidence digests, lazily. same_persisted_content is unchanged. - Tests: tests/unit/storage/test_evidence_digest.py (every permutation of every unordered list, and each volatile field, against compare(...).changes == []; real changes including ordered-list reorders), the F3 harness cell, and the per-role bound in tests/test_snapshot_digest_recomputation.py. - transport (2026-10-31): storage/transport_digest.py; the package archive's packer writes each member's stored-bytes SHA-256 into its zip extra field and the unpacker verifies every member before writing (tests/test_package_gc_and_locks.py: a flipped byte in every member position, a partial set, a legacy archive with none). - Open: comparison_input has no consumer (no comparison-result cache exists). - D14, optional and must-understand extensions: implemented. storage/wire_extensions.py splits every carrier off a sectioned document before decode and merges it back on write (sectioned_document.to_sectioned_document(extensions=...)): unknown section kinds and blocked sections verbatim, header must_understand/ extensions, the document-level must_understand, and (wire_record_extensions.py) unknown fields of function, variable, record-type and enum records, keyed by identity and ordinal. The decoded snapshot holds them in a side table, not a domain field (ADR-063 D8); the evidence and publication digests include them. A section with an unknown must-understand name, a newer section_schema_version, or a failed availability entry is not decoded: AbiSnapshot.unreadable_sections (runtime-only) names it, compare/section_readers.py (checked against an AST scan of each detector) resolves exactly its readers to not_evaluated with a not comparable: coverage gap, the surface-metric roll-up is skipped, and analysis_assurance reads partial. provenance and a document- or manifest-level unknown name refuse instead. SectionDTO and PackageManifest carry the fields; a directory package blocks a section object with an unknown section-level name (refused before 2026-10-31) or a newer section_schema_version (refused before 2026-11-02; the manifest-level version check now leaves a newer non-identity section to import_v1.export_legacy_sections, which blocks it with the single-file cause text, wire_extensions.section_blocking_reason). Test: tests/test_package_section_version_blocks_section.py (section x cause x side, package vs. single-file through abicheck compare, re-save, and the identity refusal). No version bump (each carrier is written only when non-empty; ADR-062's D14 implementation decisions). Tests: tests/unit/storage/test_extension_preservation.py (the former strict xfail probes), tests/test_section_readers_contract.py, tests/test_cli_compare_must_understand.py (CLI, JSON and Markdown report, exit codes), and the updated section-version gate tests. 2026-10-31: packages carry the carriers (ArtifactRef.carriers, written at ref_schema_version 2 only when set; the left-out sections are the carriers' undecoded ones; carriers_for_reader re-decides blocking on read), a package section object's unknown must-understand name blocks only that section, unknown keys are kept at any record nesting depth and on the binary metadata objects, and a not_collected section re-saves absent. function_presence reports removals while functions is blocked. Tests: tests/unit/storage/test_package_carriers_roundtrip.py (Hypothesis over per-section state x unknown sections x nested keys x document names: single-file -> package -> load -> save exact, blocked set equal to the generated one, digests), tests/test_blocked_section_compare_matrix.py (every section x cause x side x file/package through abicheck compare), tests/test_pe_pdb_section_failure.py. 2026-11-03, flat-mapping writers: snapshot_to_dict refuses a snapshot whose flat document would lose stored state (wire_extensions.refuse_lossy_flattening; flattening_loses counts a not_collected section, which the flat split re-saved as present), so every flat mapping a writer (to_sectioned_document, import_legacy_snapshot, import_bundle_facts, import_baseline_set) can be handed is complete; snapshot_encode.flat_document_and_carriers is the one split that yields the flat document of such a snapshot, and only beside its carriers. write_legacy_snapshot_package takes only an AbiSnapshot; the dump write and the probe-matrix file write the snapshot (the dump write used to section a flat payload, storing a PE dump's failed debug section as present). Test: tests/unit/storage/test_flat_mapping_writers.py (Hypothesis over the same carrier combinations, every writer entry point: write then read re-saves the generated document or fails loudly). Residuals: see known gaps. - D15, wire contract: implemented. The field inventory is generated from the section encoders' declarations, not from encoded output (2026-11-03): tests/_wire_inventory.declared_field_inventory reads the SectionDTO header fields, each section codec's required and optional payload fields (import_v1.LEGACY_SECTION_DOCUMENT_CODECS), the wire_field_names of every record class the record encoder walks (RECORD_LISTS/RECORD_OBJECTS and their nested record lists), the document keys and the availability entry with its stored statuses. So an optional key the fixed corpus never writes is listed too. test_wire_contract.py diffs it against tests/fixtures/wire_contract/field_inventory.json (a moved section needs a version bump and a registered migration) and, as the conformance half, requires every key a written corpus document carries to be declared (tests/_wire_inventory.observed_field_inventory); key paths inside opaque values (a fact object) are pinned by the section sha256 instead. semantic_ir is left to its own codec's tests. Section availability (2026-11-03): section_availability maps each written section to present; a failed entry has diagnostics, no payload, and stays in section_schema_versions so a pre-D15 reader refuses rather than reading it empty; a not_collected entry has no payload and no version entry. This build reads failed and not_collected sections of the default-filled kinds (binary, build, debug, declarations, layout, types; wire_extensions.DEFAULT_FILLED_SECTION_KINDS) as unusable: their readers are not_evaluated with a not comparable: gap, and the run reads partial, so a not-collected section never reads as a collected-empty one (before 2026-11-03 a not_collected section decoded to defaults and was compared as empty). graph and semantic_ir decode absence as None, which already means not collected. A section absent from a document with no entry is unrecorded and reads as before the table existed. The decoded snapshot holds every non-present section in the runtime-only AbiSnapshot.section_availability (model/section_availability.py: not_collected, failed, blocked, unrecorded), set together with unreadable_sections (storage/section_states.py), and the compare report lists them per side in analysis_assurance.section_availability (report schema 7.1). Producer API: section_states.record_section_availability (and record_section_failure), which blocks the live snapshot's readers too, so a live operand and its stored dump compare alike. No version bump: the table is a document-level key a pre-D15 reader ignores, not_collected entries were already accepted by the reader, and only their reading changed. Tests: tests/test_section_availability_matrix.py (every state x default-filled section x side through write -> read -> abicheck compare, single file and package, plus live operands against their written and re-read dumps), tests/unit/storage/test_wire_contract.py. Producers: only the PE dump records a state (debug failed when a located PDB fails to parse); no producer records not_collected yet (known gap). Earlier state, kept for the record: partially implemented — the inventory was recorded from the encoder's output for the fixed corpus, and section-level availability entries did not exist. - D14, must-understand versus optional extensions: implemented; see the "D14, optional and must-understand extensions: implemented" entry above. (This entry once read "not implemented", with strict xfail probes in tests/unit/storage/test_extension_preservation_gap.py; those probes now pass as tests/unit/storage/test_extension_preservation.py.) - D18, selective load: steps 1 (decode split) and 2 (lazy declaration store) implemented 2026-10-31; per-pair release and the shared decoded-section cache not built (step 2 and measurements below).

Design of the split. storage/snapshot_section_decode.DECODE_STEPS lists every decode step with reads (the raw sections it may look at) and after (the steps whose decoded output it consumes). Each step gets a SectionView of the flat document holding only its sections' keys (legacy_sections.section_of_field); reading another section's key raises UndeclaredSectionRead, so the declarations are enforced on every load. snapshot_codec.decode_snapshot composes the steps; the result is byte-identical (save bytes, publication and evidence digests) to the single-function decoder on the 51-document fixture corpus.

Step Reads After Kind
prelude: unwrap, D14 carriers, migrate_and_describe, schema gate whole document -- whole-document
declarations, types, binary, debug, build, layout, provenance, semantic_ir own section -- per-section
from_headers debug binary, declarations, types join (inference for a legacy document without the key)
reliability_flags debug from_headers join
legacy_fact_backfill declarations, types, debug, binary declarations, types, from_headers, reliability_flags join; rewrites decoded entities in place
graph graph, build build join (already lazy, PendingGraph)
extraction_scope layout declarations, types join (ownership attaches to entities)
finalize_semantic_ir -- declarations, types, debug, semantic_ir join
tail: ELF-binding backfill, closure-identity renumbering (finalize_snapshot) -- whole snapshot whole-snapshot

Next step (not done). A section may load lazily only when every join that reads or rewrites it is a no-op for the document: extractor generation current (no spelling migration), from_headers recorded, every reliability flag recorded and the schema past the backfill thresholds. semantic_ir assembly is deferred by making AbiSnapshot.semantic_ir a PendingGraph-style cell whose loader forces the declarations/types/ debug cells first and then runs finalize_snapshot_semantic_ir; the closure-identity renumbering must move into that cell or be proven a no-op for current documents. debug was not made lazy in this step: the from_headers, reliability-flag and backfill joins read it on every load. Timing: the split costs nothing measurable (161 MiB synthetic flat document, 20k functions/4k records/2k variables, snapshot_from_dict best of 3: 7.27 s before, 7.13 s after). Evaluated 2026-10-31: the cross-section decode in decode_snapshot is now split per section (step 1, above), but the semantic_ir funnel in AbiSnapshot.__post_init__ remains a prerequisite for deferred semantic-IR assembly; measurements and the blocker are in known-gaps "ADR-062 D14-D18". D14 follow-up landed the same day: the console summary of compare states partial assurance and names each not-comparable section.

Step 2 (implemented 2026-10-31). declarations, types and debug layout decode into AbiSnapshot.semantic_ir's declaration store, so they are deferred together: semantic_ir is a LazyGraphField and a lazily loaded snapshot holds a PendingGraph whose decoder (model/lazy_declarations.DeferredSemanticIR) rebuilds the eager snapshot (store, semantic_ir section, extraction-scope ownership, finalize_snapshot_semantic_ir) and yields its IR. Eligibility (storage/snapshot_lazy_decode.lazy_ir_eligible): from_headers recorded, schema_version >= LEGACY_BACKFILL_FLOOR (every legacy fact-backfill rule skipped; a test derives the floor from the rules the backfill passes), and the document text proven marker-free (closure renumbering is a no-op). Other documents decode eagerly. The ELF-binding backfill and the stored export-absence reconciliation are queued into the cell. 41 of the 50 corpus documents load lazily; the 9 pre-from_headers ones stay eager. tests/unit/storage/test_snapshot_lazy_decode.py differentially tests lazy against eager (corpus, Hypothesis-generated snapshots, compare results, copies, pickle, access-order permutations, concurrent first access) and checks that each detector whose DETECTOR_SECTIONS avoid the deferred sections leaves them undecoded.

A2.5 measurement (2026-10-31, synthetic, origin/main vs this change, same operands). One 140 MiB snapshot (20k functions, 2k records, 2k variables): load 3.9 s -> 2.4-2.8 s with the store pending; load plus first access 4.4-5.2 s, the same as eager (4.4-5.2 s); peak RSS 419 MiB in both, set by the whole-document json.loads. Project comparison (compare old/ new/, 8 libraries per side, 42 MiB each, 6k functions): 29.7-30.2 s / 393 MiB before, 31.5-35.9 s / 391-406 MiB after, identical JSON reports. A2.5 is not met: a full compare reads declarations/ types (nearly every detector does), so the cell is decoded anyway, and the parsed document dominates the peak. Per-pair release and the shared decoded-section cache were therefore not built: no consumer path reads a section it could share or release early. What remains is in known-gaps.

A2.5 re-measurement (2026-10-31, synthetic directory release, 53 MiB members with 6k functions, compare old/ new/). Peak RSS N=1 304 MiB, N=4 498 MiB, N=8 611 MiB: still growing with N although the fan-out loads one pair at a time. tracemalloc attributes about 5 MiB of live memory per finished member (1.5k-function members) to parsed-JSON objects, decoded ELF symbols, public-header type graphs (contract_evidence_collect.build_type_graph) and execution-cache entries (elf_facts alias index, elf_symbol_filter, diff_namespaces); the rest is allocator high-water. A2.5 still not met; the per-section streamed parse, BundleFacts lazy members and the evidence-keyed decoded-section cache were not started, because the measured growth is not in the parse.


Validation corpus

A storage redesign is not complete on round-trip unit tests alone. These are acceptance tests, not nice-to-haves.

Identity preservation. Two same-bare-name typedefs in different classes; several versions of one ELF symbol; mixed GLOBAL/WEAK bindings across versions; repeated and virtual base subobjects; TU-local functions and variables with identical spellings; uninstantiated template methods with identical bare names; MSVC functions whose decorated identity differs only by return type; one source declaration observed by Clang, CastXML, DWARF, and PDB.

Determinism. Randomizing producer traversal, dictionary insertion, TU completion, and parallel extraction order must produce the same semantic digests, the same package root digest, and the same comparison result. Pretty-printed bytes need not match across output styles; semantic digests must.

Stored-versus-live parity. For every bundle-level finding family, all four of live/live, stored/live, live/stored, and stored/stored must produce equivalent findings, verdicts, evidence status, and attribution.

Real-project scale. oneDAL (multi-library, CPU and DPC variants); a template-heavy C++ project (LLVM/Clang, Qt, Boost, or PyTorch C++); a symbol-version-heavy C project (OpenSSL); a Windows DLL/PDB project; a Mach-O dylib or framework; a pybind11 extension; a header-only target mixed with compiled libraries. Record decoded size, stored size, deduplication ratio, peak RSS, write time, manifest load time, full compare time, and bytes/objects actually loaded.


Out of scope

  • A new binary or columnar wire encoding inside an object.
  • Dropping any evidence layer to reduce size.
  • New bundle-resolution semantics for PE/Mach-O (D6 requires membership and capability declaration, not a new resolver).
  • The ChangeKind/report-schema consequences of newly explicit NOT_COMPARABLE outcomes (ADR-050/ADR-042 surfaces).
  • Making the SQLite index mandatory.

Landed in Phase 0

Module Contract
abicheck/storage/availability_ledger.py FactStatus, Confidence, FactAvailability, AvailabilityLedger (A0.1)
abicheck/storage/fact_availability.py FactAvailability — internal record leaf, re-exported by availability.py
abicheck/storage/availability_status.py FactStatus, Confidence, COMPARABLE_STATUSES, GAP_STATUSES, ASSERTS_NO_PRODUCER, STATUS_ORDER, CONFIDENCE_ORDER, worse_status, worse_confidence — internal vocabulary leaf, re-exported by availability.py only for FactStatus/Confidence
abicheck/storage/occurrence_identity.py EntityKind, ObservationKind, EntityId, OccurrenceId, OccurrenceSet, IdentityConflict, elf_symbol_occurrence (A0.2/A0.3)
abicheck/storage/entity_ids.py EntityId, EntityKind, ObservationKind, OccurrenceId, elf_symbol_occurrence — internal identifier leaf, re-exported in full by identity.py; plus DOMAIN_ENTITY_ID_SCHEMA_VERSION, domain_entity_id_to_dto, domain_entity_id_from_dto (ADR-063 Phase 2's storage v2 wire bridge to model.identity.EntityId — a separate domain type from this module's own EntityId above, so not re-exported by identity.py, whose surface is this module's pre-existing packed-key wire DTO and the occurrence-set collection built on it)
abicheck/storage/canonical.py canonical_form, canonical_json, raw_digest, semantic_digest, strip_capture_metadata, CAPTURE_METADATA_KEY (A0.4; raw_digest A1.1)
abicheck/storage/versioning.py PACKAGE_FORMAT_VERSION, COMPARISON_CONTRACT_VERSION, UNSTATED_VERSION, StorageVersions, ProducerIdentity, ReaderCompatibility, check_reader_compatibility (A0.5), EXTRACTOR_GENERATION, RESOLVER_GENERATION, SOURCE_GENERATION, GENERATION_DRIFT_NOTICE_MARKER, reader_generation_compatibility (D2 generation drift)
abicheck/storage/fact_decode.py decode_fact, decode_fact_with_legacy_presence, decode_abi_slot_fact, abi_fundamental_from_json (the one-Fact[T] JSON decode and its legacy-presence variant, moved out of fact_codec.py, which re-exports them; the schema v69 ABI-fact rebuild) — internal, not re-exported by the package
abicheck/storage/fact_payload_validation.py validated_identities, validated_template_param_defaults (ADR-085 D1; the decoded-payload checks fact_codec.py applies to tuple-valued facts) — internal, not re-exported by the package
abicheck/storage/guards.py identity_text, binary_buffer, decision_key, key_collection, required_field, row_sequence, item_iterable, provenance_text, diagnostics_from, mapping, enum_member, instance_of, strict_int (ADR-063 Phase 2) — internal, not re-exported by the package

guards.py is the one row the package does not re-export: it holds the value guards each module used to restate at its own doors. Three copies of one rule is how the rule drifts, and review found that drift four separate times on this branch — always as one site missing a check its siblings already had — so AGENTS.md invariant 6's deferred unification was done here rather than in Phase 1. Its surface is still pinned by the same table, because "internal" describes who imports it, not whether it may go unadvertised.

Each row is exactly that module's __all__, and tests/unit/storage/test_landed_surface.py asserts it — a row advertising a primitive that no longer exists (VOLATILE_KEYS, replaced by CAPTURE_METADATA_KEY) sent a reviewer looking for an API that was never there, so the table is checked rather than maintained by hand.

Tests live in tests/unit/storage/, stating each primitive's contract as invariants alongside the example cases (A0.6).

Landed in Phase 1: A1.1's object model, directory store, import adapter, and one-artifact projects

The object model (A1.1's first half), the directory-backed store (A1.1's second half), the v1-v25 import adapter (A1.2), and expressing a single-library dump as a one-artifact project (A1.3) are implemented — jointly with ADR-063 Phase 8, whose own D8 constraint (every DTO a distinct, versioned, explicitly-encoded class, never asdict) this landing satisfies for the one domain type it actually sections today (SemanticIR).

Module Contract
abicheck/storage/package.py MANIFEST_RELPATH, SECTION_KINDS, ObjectRef, VariantRef, ArtifactRef, PackageManifest, ObjectStore, InMemoryObjectStore, object_relpath, variant_ref_relpath, artifact_ref_relpath, artifact_object_refs (A1.1; artifact_object_refs lists an artifact's section and carriers objects)
abicheck/storage/env_limits.py env_byte_limit — the one parser behind the snapshot envelope's decompression-bomb ceilings (snapshot_io.py's decoded/stored caps and the public ABICHECK_SNAPSHOT_MAX_DECODED_BYTES / ABICHECK_SNAPSHOT_MAX_STORED_BYTES environment knobs, plus their legacy underscore spellings). A leaf reading only os.environ, so the rule that a malformed or non-positive value is ignored — rather than honoured as a ceiling that would reject every read — has one implementation rather than one per limit; internal, not re-exported by the package
abicheck/storage/ast_size_observer.py observe_ast_sizes, report_ast_size, set_ast_intake_hook, mark_ast_intake — the last two let workflows/memory_trace.py bracket AST intake (ast.intake:start/:done) from parse sites that may not import the workflows layer. Also carries the byte size of each header AST a dump acquires (cold clang parse or AST-cache load) to an observer in the same context, which is how the release fan-out's memory admission (workflows/release_admission.py) costs a member by measured AST size. A context-variable leaf with no other state; internal, not re-exported by the package
abicheck/storage/ast_parse_exclusions.py HEADER_PARSE_EXCLUDED_KEY, attach_parse_exclusions, record_parse_exclusions — the top-level headers a clang header-AST parse dropped (the #error retry), carried on the tree and in a sidecar beside the AST-cache entry so a warm run records the same partial header coverage (evidence-entity-model gap A3). Internal, not re-exported by the package
abicheck/storage/cache_integrity.py DEPS_SUFFIX, digest_file, entry_intact, evict, evict_with_derived, record_digest, sidecar_path, verify_entry — a <entry>.sha256 content digest per L2 header-AST cache entry, and the <entry>.deps dependency-manifest suffix that eviction also removes; a reader evicts and re-parses an entry that no longer matches it, and records one for a legacy entry on first use. Detects corruption, not tampering by a writer of the cache directory. Internal, not re-exported by the package
abicheck/storage/byte_quota_lru.py CacheEntry, evict_directory_to_quota, select_victims — byte-quota LRU eviction for a cache directory (ADR-062 D11): oldest mtime first, ties by name, never the protected entry; sweeps stale .tmp residue. Internal, not re-exported by the package
abicheck/storage/ast_cache_budget.py DEFAULT_MAX_BYTES, MIN_AGE_SECONDS, RECHECK_INTERVAL_SECONDS, configured_max_bytes, enforce_ast_cache_budget, note_use — a least-recently-used byte budget (env ABICHECK_AST_CACHE_MAX_BYTES, default 16 GiB, 0 disables) for each L2 header-AST cache backend directory, re-checked at most every five minutes; each use stamps the entry mtime, and entries used in the last hour are never evicted. Internal, not re-exported by the package
abicheck/storage/ast_cache_location.py AST_DISK_CACHE, ast_cache_entry_path, backend_cache_dir, reference_scratch_scoped — where an L2 header-AST cache entry lives (one sub-directory and extension per backend, under LOCALAPPDATA or XDG_CACHE_HOME, falling back to the temp directory) and the disk cache's policy on the central cache wrapper: with ABICHECK_REFERENCE_MODE=1 every call hands out a fresh scratch path, so no entry is read back, removed when the enclosing reference_scratch_scoped call (dumper.dump) returns. Internal, not re-exported by the package
abicheck/storage/code_identity.py PACKAGE_ROOT, SOURCE_SUFFIXES, compute_code_fingerprint, abicheck_code_fingerprint, compute_modules_fingerprint, abicheck_modules_fingerprint, module_source_path — the identity of the abicheck code that produced a cached artifact: a content hash of every .py/.pyi file in the installed package (path and content, no mtimes or bytecode), or of a named set of modules, memoized per process. The whole-package fingerprint keys every disk cache whose entries abicheck itself computed (snapshot, L3 build evidence, L4 per-TU source ABI, the header-graph projection sidecar); the named-module fingerprint keys the clang header-AST cache with the modules that shape a stored entry (extract.header_ast_cache_producers). Internal, not re-exported by the package
abicheck/storage/generation_scope.py REPO_ROOT, RECORD_PATH, EXTRACTOR_ROOT_GLOBS, EXTRACTOR_EXTRA_FILES, RESOLVER_SEMANTIC_FILES, RESOLVER_NON_SEMANTIC_FILES, RESOLVER_NAME_WORDS, SOURCE_EXTRACTOR_DIR, SOURCE_TIER_FILES, scope_files, file_digests, fingerprint — the source scope whose semantics the extractor and resolver generations stand for, and its recorded fingerprint (ADR-062 D2); internal, not re-exported
abicheck/storage/generation_migrations.py FACT_GENERATIONS_KEY, PLACEHOLDER_REASONS, SPELLING_MIGRATIONS, GenerationSpellingChange, decode_fact_generations, decode_spellings_generation, encode_fact_generations, extractor_generation_of, is_placeholder_reason, migrate_and_describe, pending_extent_shift, without_snapshot_generation_notices — a single-file snapshot's generation stamp, its drift/unstated load notice (through the shared reader-compatibility check), the per-generation type-spelling migrations applied on load, and the array-extent shift a comparison across generations demotes instead (ADR-062 D2); internal, not re-exported
abicheck/storage/castxml_array_migration.py CASTXML_ARRAY_COUNT_GENERATION, migrate_castxml_array_counts, shift_array_counts — the generation-15 load migration of a castxml snapshot's stored array counts (last index -> element count), applied on snapshot load; internal, not re-exported
abicheck/storage/castxml_base_offset_migration.py CASTXML_BASE_OFFSET_BITS_GENERATION, migrate_castxml_base_offsets — the generation-39 load migration of a castxml snapshot's stored base-class offsets (castxml's bytes -> the bits every other producer stores), applied on snapshot load; internal, not re-exported
abicheck/storage/clang_vtable_base_migration.py CLANG_VTABLE_BASE_RESOLUTION_GENERATION, migrate_clang_vtable_bases — the generation-40 load migration lowering a pre-40 clang/hybrid snapshot's confirmed (present) vtable fact on a record with bases to partial (the older clang backend could leave a base unresolved and still store the vtable as confirmed), applied on snapshot load; internal, not re-exported
abicheck/storage/decode_memo.py memo_fact, enum_decoder — the snapshot decoder's per-row memos: a repeated plain fact shape is answered with the Fact the full decode already built (a bounded LRU memory cache on model.execution_cache, so reference mode bypasses it and a long-lived process never grows past the bound), and an enum string resolves by one dict lookup. Same value as the slow path; malformed input still raises from it. Internal, not re-exported by the package
abicheck/storage/cross_process_lock.py single_flight, produce_ast_once_across_processes — one producer per disk-cache entry across forked release members and compare sides: an advisory file lock per AST key (and per projection sidecar), so the first process writes the entry and a sibling that waited reads it. A no-op without POSIX file locking, on an unwritable lock directory, or in reference mode. Internal, not re-exported by the package
abicheck/storage/tu_parse_store.py TuParseStore, content_key, digest_file, dep_digest — the one content-addressed store for per-invocation parse results (plan l4-l2-extraction-convergence Phase 6): each entry records the digest of every file its parse read and misses when one changes or disappears. The caller supplies the key parts; the store only folds them. Internal, not re-exported by the package
abicheck/storage/castxml_xml.py parse_castxml_xml and parse_castxml_text — parse a castxml XML document from a path or from text (a cached L2 header-AST payload or fresh castxml output) through defusedxml's parser with a tree builder that stores one object per distinct attribute value, cutting the parsed tree by about a third, and that keeps only ARGUMENT_ATTRIBUTES_READ on each Argument element and shares one element per distinct parameter spelling (116 → 26 MiB live on a 26 MB MKL header dump). Internal, not re-exported by the package
abicheck/storage/atomic_file.py atomic_write — write a cache file through a same-directory temp file and os.replace, so a concurrent reader sees either no file or a complete one, never a torn one. replace_file — that os.replace, retried with a bounded back-off when Windows transiently refuses it (another handle or writer on the destination); the snapshot writer uses it too. Moved unchanged from dumper_cache.py; its stream-copy sibling was removed in dead-code-and-single-owner Stage G. Internal, not re-exported by the package
abicheck/storage/project_package_archive.py pack_project_package, unpack_project_package, is_project_package_archive, MIMETYPE — A1.1's one-file zip transport of a package directory; unpacking refuses any non-layout, symlinked, compressed, duplicate or over-budget member before writing. Internal, reached through workflows.storage
abicheck/storage/evidence_digest.py EVIDENCE_FORM_VERSION, UNORDERED_LISTS, VOLATILE_CAPTURE_FIELDS, evidence_digest_of_document — ADR-062 D16's evidence digest over a snapshot document: volatile capture metadata dropped, identity-matched record lists stably sorted by identity, semantically ordered lists untouched, the form version hashed in. Internal, not re-exported by the package
abicheck/storage/wire_extensions.py AVAILABILITY_KEY, DOCUMENT_MUST_UNDERSTAND_KEY, EXTENSIONS_KEY, DEFAULT_FILLED_SECTION_KINDS, IDENTITY_SECTION_KIND, MUST_UNDERSTAND_KEY, UNDERSTOOD_EXTENSIONS, WIRE_EXTENSIONS_KEY, WireExtensions, attach, attach_package_carriers, carriers_for_reader, check_must_understand, extensions_of, failed_reason, flattening_loses, merge_wire_extensions, not_collected_reason, refuse_blocked_identity, refuse_orphaned_header_extras, refuse_lossy_flattening, refuse_unwritable_extensions, section_blocking_reason, split_wire_extensions, unknown_must_understand — ADR-062 D14/D15 carriers of the sectioned document: unknown sections, header must-understand names and extension maps, the section availability table, and the sections a load must block, split off on read and merged back on write; WireExtensions.to_wire is the form a directory package stores and carriers_for_reader re-decides what blocks on read. Internal, not re-exported by the package
abicheck/storage/extension_fact_catalog.py EXPORT_READ, EXTENSION_FACTS, ExtensionFact, ExtensionFactRegistry, ReaderSemantics — ADR-062 D19's declared catalog of optional extension facts: name, section, reader semantics (optional, section- or document-level must-understand), what absence reads as, owning ADR, encode/decode. wire_extensions.UNDERSTOOD_EXTENSIONS is its names. Its first fact is ADR-079's abicheck.export_read, on the binary section. Internal, not re-exported by the package
abicheck/storage/extension_facts.py apply_export_read_fact, read_extension_fact, sync_export_read_fact, write_extension_fact — record a declared fact on a snapshot's D14 carriers (its section header's extensions map and, when its semantics require it, its must-understand list) and read it back as a typed fact (present, not collected, or failed); sync_export_read_fact/apply_export_read_fact carry ADR-079's export read between the platform blocks and that fact on write and load. Internal, not re-exported by the package
abicheck/storage/section_states.py apply_section_states, record_section_availability, record_section_failure, section_states — ADR-062 D15 section-level availability: sets a snapshot's unreadable sections and section availability (model/section_availability.py: not collected, failed, blocked, unrecorded) together from the carriers a load kept, and the producer API that records a failed or not-collected section on a live snapshot so it compares as its stored dump does. Internal, not re-exported by the package
abicheck/storage/bundle_member_carriers.py carriers_by_member, encode_bundle_member, require_sectioned_member_version — ADR-062 D14/D15 carriers of a bundle member: a member whose flat document would lose them is stored as its sectioned document under bundle schema version 5 (G38 document, G40 archive), a bundle package stores them on ArtifactRef.carriers, and a sectioned member under a lower declared version is refused. Internal, not re-exported by the package
abicheck/storage/closure_graph_renumber.py renumber_graph, renumber_snapshot_graphs — the closure-marker strip-and-renumber pass applied to a snapshot's surface/source graph with the flat fields' ordinals (graph-only closures numbered after them); a stored graph is renumbered on its first decode. Internal, called by the closure-identity pass
abicheck/storage/wire_record_extensions.py RECORD_EXTENSIONS_KEY, RECORD_LISTS, RECORD_OBJECTS, RecordExtras, collect_record_extras, nested_record_classes, restore_record_extras — ADR-062 D14 at record level: unknown fields of identity-matched records and of the binary section's metadata objects, at any nesting depth (parameters, record fields, enum members, ELF symbols/imports, PE/Mach-O exports), kept by identity, ordinal and nested position and written back on re-save. Internal, not re-exported by the package
abicheck/storage/snapshot_lazy_decode.py LEGACY_BACKFILL_FLOOR, decode_lazily, eager_section_decode, lazy_ir_eligible — ADR-062 D18 step 2: a document on which every join touching the declarations, types and debug-layout sections is a no-op loads with them pending in the snapshot's semantic IR (model/lazy_declarations.py); any other document decodes eagerly. Internal, not re-exported by the package
abicheck/storage/transport_digest.py TRANSPORT_EXTRA_ID, stored_transport_digest, transport_digest, transport_extra — ADR-062 D16's transport digest: the SHA-256 of a package-archive member's stored bytes, carried in its zip extra field and verified on unpack. Internal, not re-exported by the package
abicheck/storage/snapshot_section_stream.py EncodeKeys, sectioned_document_stream — storage plan A2.2: the sectioned snapshot document as a json_stream.LazyItems that encodes, packages and writes one section at a time (each section's keys through the restricted encoder, its D14/D15 carriers restored), so a write never holds the whole encoded tree. Reached through snapshot_encode.streamed_sectioned_document. Internal, not re-exported by the package
abicheck/storage/snapshot_record_compaction.py compact_records — the snapshot encoder's per-record omissions (uncaptured slot facts, default ELF version bindings, unobserved ODR conflicts, an unrecorded advanced-extraction status), moved out of the snapshot encoder. Internal, not re-exported by the package
abicheck/storage/wire_field_names.py wire_field_names — the keys the snapshot encoder writes for a dataclass record, shared by the encoder and the ADR-062 D14 record reader. Internal, not re-exported by the package
abicheck/storage/package_publish.py REF_DIRS, REF_ENTRY_FIELDS, REF_SCHEMA_VERSION, REF_SCHEMA_VERSION_KEY, publish_lock_name, ref_digest_entries, ref_digest_of, ref_document, ref_record, versioned_ref_relpath — ADR-062 D17's ref layout (amends D6): the immutable refs/<kind>/<id>/<ref-digest>.json path, the ref document with its own layout version, its ref digest (the semantic digest of the whole document), the package-format-v2 manifest entries naming each ref by id and ref digest, and the cross-process lock name serializing writers. Internal, not re-exported by the package
abicheck/storage/zip_member.py deterministic_zipinfo — the pinned zip member header shared by G40's bundle archive and the package transport. Internal, not re-exported by the package
abicheck/storage/snapshot_runtime_only.py RUNTIME_ONLY_SNAPSHOT_FIELDS — the one declaration of the AbiSnapshot fields a single run sets and the document never carries, each with its reason. The snapshot encoder drops exactly these keys and the field round-trip test reads this mapping instead of keeping a copy. Internal, not re-exported by the package
abicheck/storage/dwarf_layout_facts.py decode_dwarf_member_abi, drop_uncaptured_dwarf_layout_facts, dwarf_layout_dicts — schema v69: encode and decode FieldInfo.type_abi_fact, the fundamental-type ABI fact of each persisted DWARF layout member (dwarf.structs and the ODR variants), so a stored header snapshot's depth projection carries the fact a binary-only dump reads from the member DIE. Written only when present; a pre-v69 document decodes it as None. Internal, not re-exported by the package
abicheck/storage/json_compact.py compact_json_stream, compacted_ast, CompactedAst, migrate_legacy_entry, open_cached_entry — streams a JSON document (clang's -ast-dump=json) into a compact, ASCII-only copy that is parsed and then renamed into the L2 AST cache; value-preserving by construction (a line never starts or ends inside a JSON string). migrate_legacy_entry rewrites a pretty-printed entry stored before compaction-at-store in place, on first read. Internal, not re-exported by the package
abicheck/storage/ref_ids.py REF_SUFFIX, safe_ref_id, reject_filesystem_collisions — cross-platform ref-id path safety, split out of package.py's own 800-line production cap (ADR-063 Track C 8B); resolve_ref_ids — name-to-safe-artifact-id resolver the two bundle/baseline-set import adapters use for a library name they don't control -- internal, not re-exported by the package
abicheck/storage/dto.py BASELINE_SET_SECTION_KIND, BINARY_SECTION_KIND, BUILD_SECTION_KIND, BUNDLE_COMPOSITION_SECTION_KIND, DEBUG_SECTION_KIND, DECLARATIONS_SECTION_KIND, GRAPH_SECTION_KIND, LAYOUT_SECTION_KIND, PROVENANCE_SECTION_KIND, SECTION_SCHEMA_VERSIONS, SEMANTIC_IR_SECTION_KIND, TYPES_SECTION_KIND, SectionDTO, baseline_set_metadata_from_dto, baseline_set_metadata_to_dto, binary_from_dto, binary_to_dto, build_from_dto, build_to_dto, bundle_composition_from_dto, bundle_composition_to_dto, debug_from_dto, debug_to_dto, declarations_from_dto, declarations_to_dto, graph_from_dto, graph_to_dto, layout_from_dto, layout_to_dto, legacy_section_from_dto, legacy_section_to_dto, migrate_section_dto, provenance_from_dto, provenance_to_dto, semantic_ir_from_dto, semantic_ir_to_dto, types_from_dto, types_to_dto (A1.1's per-section DTO envelope, jointly ADR-063 Phase 8's D8 constraint; TYPES_SECTION_KIND/types_from_dto/types_to_dto are ADR-063 Track 4 (8B)'s first typed-DTO promotion beyond semantic_ir, see types_section_codec.py; GRAPH_SECTION_KIND/graph_from_dto/graph_to_dto are its second, see graph_section_codec.py; the remaining six *_SECTION_KIND/*_from_dto/*_to_dto triples are its third slice, see sparse_section_codec.py -- every D8 legacy section kind now has a dedicated DTO; BUNDLE_COMPOSITION_SECTION_KIND/BASELINE_SET_SECTION_KIND and their *_from_dto/*_to_dto pairs are A1.4's own two variant-level section kinds, ADR-063 Track C 8B, see import_bundle_facts.py/import_baseline_set.py below)
abicheck/storage/legacy_sections.py LEGACY_SECTION_KINDS, SCHEMA_VERSION_KEY, join_legacy_document, missing_required_section_fields, split_legacy_document (D8's full legacy-document section partition)
abicheck/storage/import_v1.py CARRIERS_OBJECT_KIND, export_legacy_snapshot, import_legacy_snapshot (A1.2; CARRIERS_OBJECT_KIND is the ObjectRef.kind of ArtifactRef.carriers)
abicheck/storage/import_bundle_facts.py BUNDLE_FACTS_ARTIFACT_TYPE, export_bundle_facts, import_bundle_facts (A1.4: a persisted G38 BundleFacts document, folded into a one-variant, multi-artifact PackageManifest by calling import_v1 once per library and attaching the container's own composition facts to VariantRef.sections)
abicheck/storage/import_baseline_set.py export_baseline_set, import_baseline_set (A1.4's other half: an actions/baseline-produced baseline set's manifest.json plus its already-resolved per-library snapshot documents, folded the same way)
abicheck/storage/variant_composition.py read_variant_composition_degraded_members, read_variant_composition_inventory_complete, read_variant_composition_library_filenames, read_variant_composition_manifest_payload (ADR-065 D2 added the inventory-complete reader, the capture's own assertion that lets a comparison against a stored package prove a removal or an addition -- the package type alone never does; D8 added the degraded-members reader, the marker a stored package preserves so the release fan-out records such a member as failed; A1.7: single-variant reads of import_bundle_facts.py's own composition layout, split into its own module purely to keep that module under the 800-line production cap. The manifest reader reads just one variant's own composition-embedded manifest back, for a single-artifact materialized sub-package that never has the full multi-artifact PackageManifest export_bundle_facts itself needs; the filenames reader recovers the same variant's real, possibly-versioned on-disk filenames, since import_bundle_facts itself stamps only the bundle key onto each artifact's own native_identity)
abicheck/storage/sectioned_document.py SECTION_SCHEMA_VERSIONS_KEY, SECTIONS_KEY, from_sectioned_document, is_sectioned_document, to_sectioned_document (Phase 8 redesign: the D8 section split packaged as one JSON document instead of a directory-backed package -- see the module's own docstring)

ObjectRef/VariantRef/ArtifactRef/PackageManifest are the in-memory document model of D6's manifest.json plus the ref documents it names. ObjectStore is D7's digest-addressed put/get/has abstraction, kept a Protocol rather than a filesystem client: this migrated layer may import only model (storage/AGENTS.md's "Permitted imports"), so it cannot itself wrap ADR-059's snapshot_io.py envelope — a concrete, .tar.zst-transportable store is a separate implementation, outside this package, built over both this module and snapshot_io. InMemoryObjectStore is the one process-local reference implementation package.py itself ships.

abicheck/project_snapshot_store.py (flat-root, deliberately outside storage/ for the identical import-layering reason ObjectStore's own docstring already gives) is A1.1's real, directory-backed store: DirectoryObjectStore implements ObjectStore over ADR-059's snapshot_io.py envelope — every object written zstd-compressed at objects/sha256/<aa>/<digest>.json.zst (or .bin.zst for a raw binary payload), content-addressed and deduplicated exactly as D7 describes. write_project_manifest/read_project_manifest (plus the lazy read_manifest_summary/read_variant_ref/read_artifact_ref primitives the eager reader is built from) fan a PackageManifest out across the rest of D6's directory tree: manifest.json carries only versions and the two id lists — not the full embedded records PackageManifest.to_dict() itself still returns for the in-memory convenience — with each variant/artifact's full record at its own refs/variants/<id>.json/refs/artifacts/<id>.json. The deterministic .tar.zst transport form D6 also describes is not implemented — everything above operates on a real directory only.

abicheck/storage/import_v1.py's import_legacy_snapshot takes one already-serialized legacy document (serialization.snapshot_to_dict()'s shape — storage/ cannot import serialization.py itself, so a caller outside the package builds the document) and an ObjectStore, and returns a one-artifact, one-variant PackageManifest with the object content already written into the store — A1.2 and A1.3 together. StorageVersions. source_schema_version is carried through from the document's own schema_version key unchanged, so a migration or audit can always answer "what producer epoch actually emitted this" (D2). What is actually migrated onto a typed, D8-constrained representation today is semantic_ir/semantic_ir_conflicts alone (storage/dto.py's semantic_ir_to_dto/semantic_ir_from_dto, built on storage/semantic_ir_codec.py's semantic_ir_to_document/ semantic_ir_from_document — extracted from that module's existing snapshot-dict-mutating encode_semantic_ir/decode_semantic_ir into a pure object-in/document-out pair this DTO layer builds on rather than duplicates). Every other field the legacy document carries is now split across D8's own named binary/declarations/types/layout/debug/ build/graph/provenance sections too (storage/legacy_sections.py's split_legacy_document/join_legacy_document): one explicit, reviewed allowlist per section (_SECTION_FIELDS) names exactly which top-level document keys belong to it, checked in both directions (an unassigned key on import, or a key outside its own section's allowlist on export, is a hard ValueError, never a silent drop into a catch-all). What this split does not do is decode a section's own internal shape into a typed domain object the way semantic_ir is — elf/dwarf/build_source/... still carry exactly the JSON serialization.snapshot_to_dict() already produced, just inside their own now-independently-versioned, content-addressed section rather than one shared blob. import_v1.export_legacy_snapshot is the exact inverse of the import side: given an ArtifactRef and the ObjectStore it was written into, it reads every section back, migrates each to its section kind's current version, and reassembles the original snapshot_from_dict()-shaped document, schema_version included.

Not yet implemented, and still open: storing BuildSourcePack/project source graphs/toolchain profiles once per project and referencing them by digest (A1.5 — folding baseline sets/BundleFacts into sections, A1.4, is done) and the .tar.zst transport form. (bundle_variants: CLI/config wiring, A1.6, and stored release comparison, A1.7, have landed.) Decoding a legacy section's internal shape into a typed domain object (rather than carrying the existing JSON as-is), and giving ArtifactRef.sections a per-section FactAvailability (the "known, deliberately deferred gap" below), are both real future work this landing does not attempt.

A known, deliberately deferred gap (flagged in review, Codex): ArtifactRef.sections has no accompanying D3 FactAvailability/AvailabilityLedger per section, so an absent section key cannot yet distinguish "not collected" from "unsupported" from "failed" the way a real producer will eventually need to report. Not closed here because D8's per-section content schemas still don't exist for most section kinds — wiring D3's vocabulary in now would mean guessing at a shape (per-artifact? per-section-kind?) with nothing real to validate the guess against, exactly the premature-design risk this file's own "Known gaps over risky reactive patches" convention warns about. Revisit once A1.4/A1.5 (folding sections/BundleFacts) gives this a real, multi-section producer to design against.

A second, deliberately deferred gap: import_legacy_snapshot writes an empty ArtifactRef.native_identity — a legacy document's build_id field means an opaque CI identifier and is explicitly not reused for D6's native binary identity (content SHA-256, ELF build ID, Mach-O UUID, PE/PDB identity); populating it needs the artifact's own binary, which an adapter operating on an already-serialized document does not have. Real, separately- scoped future work for whichever caller has the binary in hand at import time.

A third, deliberately deferred gap — closed by A1.9 (ADR-062 D17): refs are now written once to digest-named paths and manifest.json is replaced atomically, so a republish never overwrites a live ref. The original record follows. write_project_manifest writes refs/*.json before manifest.json (the previous gap's own fix), which makes first publication of a set of ids safe but not republishing changed content under ids that are already live — a second call against a package another reader might concurrently be loading can overwrite a ref file the currently-published manifest still names. Closing it needs either a staged-directory-then-atomic-root-swap publish protocol or content-addressed (never-overwritten) ref paths, and no caller in this landing republishes an existing package (every current caller creates a fresh one), so there is no real update caller yet to design the fix against — see the function's own docstring. Revisit once A1.6/A1.7 (variant capture, stored/live comparison) gives this a real caller.

Tests live in tests/unit/storage/test_project_package.py (the object model), tests/unit/storage/test_dto.py (the DTO envelope, including a property test that payload key insertion order never changes the persisted bytes — the general form of ADR-063 Phase 8's own D8 test requirement), tests/unit/storage/test_import_v1.py (the import adapter), and tests/test_project_snapshot_store.py (the directory-backed store and manifest/ref writer/reader, including a full package round trip through a real directory) — the same property-style-plus-example-cases convention as Phase 0 (A0.6/A1's "Validation corpus" identity-preservation cases).

Documentation ownership

docs/AGENTS.md requires every new public-facing feature or surface to register a topic in docs/_meta/topics.yaml in the same PR, and a Phase-0 reviewer asked why storage v2 had none yet (Codex review). At the time the answer was that neither Phase 0 nor A1.1's own first slice added such a surface: no CLI command or flag, no report field, no config namespace, no Action input, and nothing in the product produced, consumed, or persisted a byte through these primitives yet — package.py performed no filesystem or network I/O, and ObjectStore was a protocol plus one in-memory reference implementation, not a place any real package was written to or read from.

That Phase-0-era gap is closed. As stated then, the trigger was concrete rather than "later": the PR that first persists a ProjectSnapshot — this file's own "Landed in Phase 1" section above, abicheck/project_snapshot_store.py's real, directory-backed DirectoryObjectStore/write_project_manifest/read_project_manifest — is the one that made this user-facing, and it registered the topic docs/_meta/topics.yaml already commits to below. The registry's canonical_page is required to be the one published narrative page a human reads (never contribute/), which is why the trigger names a real reference/ page rather than pointing back at this plan document:

  project-snapshot-storage:
    canonical_page: reference/project-snapshot-format.md
    fact_sources:
      - abicheck/storage/availability_ledger.py
      - abicheck/storage/fact_availability.py
      - abicheck/storage/availability_status.py
      - abicheck/storage/occurrence_identity.py
      - abicheck/storage/entity_ids.py
      - abicheck/storage/canonical.py
      - abicheck/storage/versioning.py
      - abicheck/storage/package.py
      - abicheck/storage/dto.py
      - abicheck/storage/legacy_sections.py
      - abicheck/storage/import_v1.py
      - abicheck/project_snapshot_store.py

Still not part of the documented Python API: abicheck/__init__.py does not re-export any of these, and the separate python-api topic's fact_sources still name only the service* modules that page actually describes — registering the project-snapshot-storage topic above answers "is this documented for a reader who finds reference/project-snapshot- format.md", not "is this part of the stable Python API", which remains a distinct, later question this landing does not answer either way.

Deliberately not done in Phase 0, so that no existing behavior changed at that point: nothing produced, consumed, or persisted these types yet; AbiSnapshot.index() still resolved first-wins; SCHEMA_VERSION was untouched; and no CLI surface, report field, or exit code moved. Phase 1's own landing above states plainly that this is still true of everything dump/compare/scan read or write today — only a real filesystem package outside that pipeline exists now.