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
FactAvailabilitydistinguishespresent/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. AnAvailabilityLedgerresolves a family-level default against per-entity overrides. - A0.2
EntityIdandOccurrenceIdare separate types. AnOccurrenceSetgroups occurrences under an entity without dropping any, and records anIdentityConflictwhere 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
pidfield 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) andcomparison_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
ProjectSnapshotStorereads and writes the D6 layout over a directory abstraction, with a deterministic.tar.zsttransport form. Implemented except the.tar.zsttransport 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
BundleFactsare 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 ontoVariantRef.sections;abicheck/bundle_facts_store.py(ADR-063 Track B "8B") independently folded a liveBundleFactsobject ontoPackageManifest.project_sections/ArtifactRef.native_identityinstead — a package written by one could not be read by the other. Closed by rebuildingbundle_facts_store.py'swrite_bundle_facts_package/read_bundle_facts_packageas a thin wrapper overbundle_facts_serialization.bundle_facts_to_dict/bundle_facts_from_dictplusstorage.import_bundle_facts.import_bundle_facts/export_bundle_facts: the live-object and persisted-document entry points now produce and consume the identicalVariantRef.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 aBundleFactspackage); both fields stay as generalPackageManifest/ArtifactRefmechanisms —native_identitystill carries the real library name here and is used unrelatedly byimport_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.ymldiscovery, 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.zsttransport 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 streamjson_stream.iter_json_*fragments throughsnapshot_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.pyencodes, 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.pyreplaces 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>.*.tmpresidue 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 andCPATH-style directories, viaextract/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 packageObjectStore. 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.pyruns random access sequences against an independentOrderedDictLRU 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'sindexes/index.sqlitelayout 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'sDiffResultuntil 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¶
storage/package.py— manifest, refs, and the object-store abstraction. Object model landed, including multi-artifact/multi-variant shape (PackageManifest.artifact_refs/variant_refsare already tuples, not singletons — see A1.1's own design note below for what's actually missing).storage/import_v1.py— the v1-v25 adapter (A1.2), including the*_facts_reliableflags becomingFactAvailabilityrecords. Landed.- Express a single-library dump as a one-artifact project (A1.3). Landed
—
project_snapshot_store.py/project_snapshot_legacy.py; seedocs/contribute/adr/063-one-semantic-pipeline.md's Phase 8 note for the single-file-by-default CLI wiring this actually shipped as. - Express a
BundleFacts(N libraries plus an instantiation manifest) and anactions/baselineset 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 ontoVariantRef.sections;bundle_facts_store.py'swrite_bundle_facts_package/read_bundle_facts_packageis now a thin wrapper over the same module, folding a liveBundleFactsobject onto the identicalVariantRef.sectionslayout instead of the earlier, non-interoperablePackageManifest.project_sections(storage/package.py)/ArtifactRef.native_identityshape. Both are published/read throughproject_snapshot_store.py'swrite_project_manifest/read_project_manifest— see A1.4's own entry above. - Landed: stored/live release-comparison reachability (A1.7) —
cli_compare_release.py's per-library fan-out now accepts a multi-artifactProjectSnapshotpackage directory as either operand, viaworkflows.release_package.resolve_release_package_map; see A1.7's own entry below. - Open, designed below: the
.tar.zsttransport 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.pyimplementspack_project_package/unpack_project_package/is_project_package_archive. Members are stored uncompressed, the file identifies itself by a leadingmimetypemember, and the pinnedZipInfois shared with G40 instorage/zip_member.py.compareaccepts 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 viaworkflows.project_package_input.tests/test_project_package_archive.pycovers:- 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;
compareon two archives equal tocompareon 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.ymlbundle_variants:block (variant name →target_triple,compiler_family,feature_togglesmapping,requiredbool, defaulttrue) parsed intoBundleVariantsConfig/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_variantsis a recognizedBuildConfigtop-level key, and the same validator runs on every.abicheck.ymlingestion (build_config_schema.WHOLE_BLOCK_VALIDATORS, shared withdeployment:), not only in the capture command.BundleVariantSpec.declared()is theVariantRef.declaredmap ({"target_triple", "compiler_family", **feature_toggles}). - Capture —
workflows/bundle_variants_capture.py, driven byabicheck 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 existingprojectgroup — 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 aVariantCaptureError→ exit 64; an optional one becomes a reported skip);capture_variants(every variant dumped in memory through the sharedrun_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 oneVariantRefwithdeclaredfrom config andcapturedfromcaptured_coordinates()— only what the snapshots recorded (dwarf_advanced.toolchaincompiler 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-levelbundle_variant_specsection recordsrequiredand the toggle keys. A skipped optional variant has noVariantRefat all. - Artifact ids are namespaced by variant (
<variant>.<library-id>, opaque viaresolve_ref_idswhen 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 fromArtifactRef.native_identity. - Pairing —
compare/variant_pairing.pair_variant_views(the replacement for the deletedpair_variants()): byvariant_idonly, never by coordinates.workflows/variant_pairing.release_variant_pairingreads both packages' views when both release operands are stored packages; the result ridesReleaseScopePlan.variant_pairingonto the ADR-065 acquisition record and out as the release JSON'scomparison_scope.variant_pairing(release schema 1.10;$defs/comparison_scopeincompare_report.schema.json). A declared-coordinate difference isvariant_boundary_changedwithdeclared_changes, distinct fromcaptured_changes(a version bump). A variant only one side carries isunmatched_old/unmatched_newwith a "absence is not removal" reason and its recordedrequiredflag;unmatched_requiredlists 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_versionbump 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_understandandextensionscarriers; detail below. - D15, implemented: a generated, CI-diffed field inventory, the
byte-identity test, and the
section_availabilityheader table (read per section since 2026-11-03:not_collectedblocks likefailed; generated inventory from encoder declarations). - D16, partially implemented: the
evidencedigest, persisted onArtifactRefand consumed by the same-snapshot coverage note, and thetransportdigest of each package-archive member (2026-10-31).comparison_inputis 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 faileddebugsection, afunction_presencedetector reports removals whilefunctionsis blocked, package GC and a Windows publish lock; detail below. Bundles carry the carriers: bundle packages onArtifactRef.carriers, G38 documents and G40 archives as a sectioned member under bundleschema_version5 (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_dictrefuses to make one); detail below. - D18, step 1 implemented (per-section decode split, no new laziness);
lazy loading beyond the
graphsection 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 directoryProjectSnapshotpackage, and clang-frontend, Mach-O and PE snapshots.tests/test_container_corpus.pyholds 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). Thecontainer-corpusgate (scripts/container_corpus_gate.py) fails a bumped encoder version untilscripts/freeze_container_corpus.pyadds an entry under it. The corpus found thatproject capture-variantsstamped 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_VERSIONbump. Implemented:storage/extension_fact_catalog.py(ExtensionFact,ReaderSemantics, the staticEXTENSION_FACTScatalog),storage/extension_facts.py(write_extension_fact/read_extension_fact), a write refusing header extras for a section it does not write, and theschema-bump-reasonai-readiness check (scripts/schema_bump_gate.py) oversnapshot_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) andtests/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'sexport_readis 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_snapshotread a sectioned document withdefer_graph=True(storage/sectioned_document.py,storage/import_v1.export_legacy_sections): thegraphsection'sSectionDTOvalidation, migration andSourceGraphSummaryconstruction run on first read ofAbiSnapshot.surface_graphor itsbuild_source.source_graphalias (one sharedmodel/lazy_graph.PendingGraph), once and under a lock. The undecoded payload is the parsed section JSON, held until then; the whole document is still onejson.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 oneDALlibonedal_coreit 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 eachVariantRef/ArtifactRefdocument once torefs/<kind>/<id>/<ref-digest>.json(storage/package_publish.py). The document carries its ownref_schema_version;ref_digestissemantic_digestover the whole document;manifest.jsonrecords{id, ref_digest}per ref invariant_refs/artifact_refs. A reader recomputesref_digestand fails closed on a mismatch, or on a newerref_schema_version.write_project_manifestverifies every section object, writes the ref documents, then replacesmanifest.jsonby temp file plus atomic rename, all understorage/cross_process_lock.single_flight, so writers to one root are serialized. A v1 package's id-path layout (refs/<kind>/<id>.jsonplus bare id lists) is still read: the D13 adapter. A v1-only reader refuses a v2 package through D2's fail-closedpackage_format_versionaxis.tests/test_package_immutable_publication.pycovers 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 newerref_schema_version. -
Garbage collection (2026-10-31):
workflows/package_gc.collect_package_garbageremoves 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.putrefreshes a reused object's mtime. Windows writers serialize onmsvcrt.locking.tests/test_package_gc_and_locks.pyenumerates 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 themsvcrtpath against a simulatedmsvcrt(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 explicitNOT_COMPARABLEoutcomes (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.