Skip to content

Symbol-level ABI-compatibility store lane (abi/): min/max compatible version via versioned-symbol containment - #4262

Merged
s243a merged 10 commits into
mainfrom
claude/abi-symbol-store
Sep 14, 2026
Merged

s243a merged 10 commits into
mainfrom
claude/abi-symbol-store

Conversation

@s243a

@s243a s243a commented Sep 13, 2026

Copy link
Copy Markdown
Owner

Symbol-level ABI-compatibility store lane (examples/pkg_resolver/abi/)

Extends the package resolver from coarse version constraints (libfoo (>= 2.0)) to fine-grained ABI compatibility at the symbol level, computing the real [min, max] compatible library-version range for a binary via versioned-symbol containment. It sits above the frozen resolver — it only imports resolver:version_lt/2 and debian/deb_parse:parse_deb_version/2; resolver.pl, resolver_store.pl, and debian/ are untouched.

Model

  • Exact identity is the (soname, symbol, version-node) triple — nodes are opaque ELF labels matched by string equality, never parsed numerically or collapsed to bare names.
  • Two separate axes: ELF version nodes vs. the Debian package-version axis (ordered only through the frozen version_lt/2 over deb/3).
  • verneed attribution by the .gnu.version index, not by version name, so two libraries sharing a node name (e.g. COMMON_1) never collide.
  • Evidence completeness is explicit: complete (readelf, or a .symbols file cross-checked with --elf: absence is a fact) vs. curated (a plain .symbols lower-bound list: presence is evidence, absence proves nothing). A hard veto (missing / below_floor / no_default_export / soname_mismatch) requires complete, index-attributed evidence; otherwise the verdict is unknown.
  • Default-version binding (@@/index-2 vs. hidden @) is carried through the evidence aggregation so an unversioned reference binds only to a Base or default export; conflicting cross-tier bindings resolve to unknown, never a false verdict.
  • Verdicts: compatible(exact | curated | extrapolated) / incompatible([...]) / unknown([...]) / not_needed(So).

Contents

Prolog resolver (abi_resolve.pl), ingest (ingest_symbols.mjs: .symbols + readelf, atomic loud failures), tier cross-check (crosscheck.mjs), CLI, the fixture harness (run_abi_verify.sh), and test_abi.pl (real-data / version-axis / model / gcc-built-ELF fixtures). SYMBOL_ABI_HOWTO.md and README.md document the model with worked examples on real Ubuntu data; REVIEW_NOTES.md maps every review finding → fix → proving fixture.

Verification

run_abi_verify.sh: 122 passed, 0 failed, 0 skipped on real Ubuntu 22.04 data plus gcc-built ELF fixtures (loader used as ground truth). Frozen resolver/store/debian diff against main is empty.

Review

Reviewed across ten rounds by three independent models — original-Astra (gpt-6-astra), Sol (gpt-5.6-sol), and Fable — each finding real defects that are fixed and fixture-proven (see REVIEW_NOTES.md); final Astra verdict APPROVE.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RoXjhStCqoig6944pVNBGe

@s243a
s243a marked this pull request as draft September 13, 2026 04:55
s243a added a commit that referenced this pull request Sep 13, 2026
…s, explicit evidence

Addresses Astra's REQUEST-CHANGES review of #4262. Direction unchanged (three
evidence tiers; a driver above the frozen resolver); model + ingest rebuilt.

1. Identity is the exact (soname, symbol, version-node) triple, stored and
   matched by string equality; no collapse to bare names, no numeric intro.
2. Every obligation is preserved (non-numeric nodes, unversioned and weak
   references); NEEDED is checked; evidence completeness is explicit
   (evidence.jsonl) and a missing/unreadable ELF is a loud exit-3 failure
   that records INCOMPLETE evidence instead of an empty success. Verdicts are
   tri-state: compatible(exact|extrapolated) / incompatible / unknown.
3. Requirements are attributed via the ELF version INDEX
   (.gnu.version -> .gnu.version_r), so two libraries sharing a node name
   (COMMON_1) no longer collide.
4. The Debian package-version axis reuses debian/deb_parse + the frozen
   resolver:version_lt/2 on deb/3 (epoch, tilde, revision); ELF nodes are an
   unordered label axis. `.symbols` minimums are documented as curated lower
   bounds, not introduction dates (below_floor is reported distinctly).
5. Ranges are computed over the actual release axis (apt-cache madison +
   dpkg); [min,max] ends are compatible by construction (range(1,5) bug gone).
6. Cross-check compares exact sym@node sets: 3006/3006 (100%); corrected
   per-name earliest-row figure 2478/2478 (100%). The false "glibc 2.34
   merge divergence" explanation is removed from the docs.
7. `.symbols` templates: (optional)/(arch=..) processed (arch needs --arch);
   (symver)/(regex)/(c++)/quoted patterns/#include rejected loudly.

Verification (Ubuntu 22.04.5, libc6 2.35-0ubuntu3.15, /bin/ls): computed
floors 2.34 / 3.1~ equal coreutils' declared Pre-Depends; gcc-built fixtures
for foo@LIB_1-vs-foo@LIB_2 (loader confirms "undefined symbol: foo, version
LIB_1"), COMMON_1 cross-attribution, PUBLIC + unversioned obligations, and a
missing ELF. test_abi.pl: 63 passed, 0 failed. REVIEW_NOTES.md maps each
review point to code + proving fixture. Frozen resolver files untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoXjhStCqoig6944pVNBGe
@s243a
s243a force-pushed the claude/abi-symbol-store branch from 23bf873 to 0afcce3 Compare September 13, 2026 05:29
s243a and others added 3 commits September 13, 2026 00:22
Extend resolution from coarse version constraints to fine-grained ABI
compatibility at the SYMBOL level: for a binary, compute the min AND max
(newest) compatible library version via versioned-symbol-set containment --
the [min,max] range ldd won't give you. A driver above the frozen spec;
resolver.pl / resolver_store.pl untouched.

Model: a library version PROVIDES exported versioned symbols {sym@ver} +
soname; a binary REQUIRES referenced versioned symbols + NEEDED sonames;
compatible = soname match AND requires subset provides. Each symbol is stored
once as a validity interval [intro, inf) within a soname -- the same interval
store as snapshot membership, one level down.

- ingest_symbols.mjs -- 3-tier ingest -> P/2 interval JSONL:
  (1) Packages `Depends` = the coarse min, free from the repo index (noted);
  (2) `.symbols` control-member -> symprov intervals [soname|sym, intro#inf]
      (curated `minimum-version` = intro; zero binary download; the common
      Debian/Ubuntu path); `symbols-dir` batches a whole dpkg info dir;
  (3) readelf fallback -- `elf`/`requires` for any ELF anywhere.
- abi_resolve.pl -- new module (reuses resolver:version_lt/2): abi_min (the
  verneed floor), provides_at (intro =< V interval lookup), abi_compatible
  (name-intro containment, the max-side predicate), newest_abi_compatible,
  abi_range. + abi_cli.pl driver.
- test_abi.pl + run_abi_verify.sh -- real-data harness.
- README.md + SYMBOL_ABI_HOWTO.md -- model, the 3 ingest tiers, the min/max
  epistemics (symbol-absence = hard veto, presence = defeasible maybe),
  Debian/Ubuntu/any-ELF coverage, package-manager vs coding-agent consumers.

Verified on this machine (Ubuntu 22.04), 8/8 checks: libc6 .symbols -> 4827
symprov rows (3006 for libc.so.6); /bin/ls -> 112 symreq, NEEDED
[libselinux.so.1, libc.so.6]; abi_min(/bin/ls, libc.so.6) = 2.34;
abi_range = range(2.34, 2.35); simulating getenv removed at 2.30 caps the max
at 2.29, so min 2.34 > max 2.29 -> no_candidate. Batch across all 562 .symbols
= 256,225 interval rows / 2,231 sonames -- the genuine memory-pressure dataset.

Note: readelf-derived intro vs curated .symbols agree on 91.7% of shared
libc symbols; every divergence is the glibc 2.34 pthread/rt-into-libc merge
(curated .symbols dates those to 2.34, when they entered libc.so.6's stable
ABI; raw ELF keeps the historical libpthread tag) -- the curated metadata is
the more ABI-faithful source, which is why the .symbols tier is preferred.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoXjhStCqoig6944pVNBGe
…s, explicit evidence

Addresses Astra's REQUEST-CHANGES review of #4262. Direction unchanged (three
evidence tiers; a driver above the frozen resolver); model + ingest rebuilt.

1. Identity is the exact (soname, symbol, version-node) triple, stored and
   matched by string equality; no collapse to bare names, no numeric intro.
2. Every obligation is preserved (non-numeric nodes, unversioned and weak
   references); NEEDED is checked; evidence completeness is explicit
   (evidence.jsonl) and a missing/unreadable ELF is a loud exit-3 failure
   that records INCOMPLETE evidence instead of an empty success. Verdicts are
   tri-state: compatible(exact|extrapolated) / incompatible / unknown.
3. Requirements are attributed via the ELF version INDEX
   (.gnu.version -> .gnu.version_r), so two libraries sharing a node name
   (COMMON_1) no longer collide.
4. The Debian package-version axis reuses debian/deb_parse + the frozen
   resolver:version_lt/2 on deb/3 (epoch, tilde, revision); ELF nodes are an
   unordered label axis. `.symbols` minimums are documented as curated lower
   bounds, not introduction dates (below_floor is reported distinctly).
5. Ranges are computed over the actual release axis (apt-cache madison +
   dpkg); [min,max] ends are compatible by construction (range(1,5) bug gone).
6. Cross-check compares exact sym@node sets: 3006/3006 (100%); corrected
   per-name earliest-row figure 2478/2478 (100%). The false "glibc 2.34
   merge divergence" explanation is removed from the docs.
7. `.symbols` templates: (optional)/(arch=..) processed (arch needs --arch);
   (symver)/(regex)/(c++)/quoted patterns/#include rejected loudly.

Verification (Ubuntu 22.04.5, libc6 2.35-0ubuntu3.15, /bin/ls): computed
floors 2.34 / 3.1~ equal coreutils' declared Pre-Depends; gcc-built fixtures
for foo@LIB_1-vs-foo@LIB_2 (loader confirms "undefined symbol: foo, version
LIB_1"), COMMON_1 cross-attribution, PUBLIC + unversioned obligations, and a
missing ELF. test_abi.pl: 63 passed, 0 failed. REVIEW_NOTES.md maps each
review point to code + proving fixture. Frozen resolver files untouched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoXjhStCqoig6944pVNBGe
…, atomic ingest)

Sol's REQUEST-CHANGES on the redesigned symbol-level ABI lane, all points:

P1a  Provider bounds are tied to their evidence row (since/4 carries the
     evidence release; at/2 = its own) and every evidence row is aggregated
     per identity (ident_status/5: at-release decides, nearest below/above
     combined) instead of `->` committing to the first symprov/4. since(2.0)
     + at(1.0) at release 1.0 is compatible(exact), not below_floor. C7*,
     A25, C7e (a bound without its evidence row is not evidence).
P1b  Ingest keeps the @ vs @@ distinction from the .gnu.version hidden bit
     (cross-checked against the name); rows carry default|nondefault|
     unproven. An unversioned reference is satisfied only by Base or a
     default export; .symbols-only rows yield unknown; a nondefault-only
     export is a hard no_default_export veto. The loader's oldest-node rule
     (verdef index 2 binds even when hidden) is encoded and verified with the
     loader. D10/D10b/D10c (gcc libhid fixtures), C8*, A7b/A7c.
P1c  (optional) rows are exports only when --elf cross-checks them against
     the binary; without --elf the file is rejected. D8 inverted: the
     optional row absent from the ELF is NOT stored. libc6's .symbols is now
     ingested with --elf libc.so.6 (0 dropped, 0 disagreements).
P2a  symbols-dir: a file with an unresolvable block is rejected atomically
     (exit 3, no rows, no evidence). D11 + fixtures/batch.
P2b  Tag whitelist (every other tag rejects the file); --release validated
     by the same Debian-version gate everywhere. D9 tmpl_unknown_tag,
     bad_release (symbols-file and elf).
P2c  crosscheck.mjs fails on empty inputs / zero denominators.
P2d  soname_mismatch only under a declared replaces(New, Old) row
     (replaces.jsonl, `ingest replaces`); no stem heuristic. C9*, A19b.
P3   The dotted-version comparator over node names is gone; per-name
     comparison is node-set equality; fixtures/crosscheck pins the case.
Res. compatible(curated) for .symbols-derived presence; exact only for
     readelf at that release.

Also: absence from complete evidence propagates down (missing for Rel =< R1)
but not up (unknown for later releases); an observed present-then-absent
gives unknown(dropped_between). REVIEW_NOTES.md maps each point to the fix
and its fixture; README/HOWTO updated. run_abi_verify.sh: 92 passed, 0
failed, 0 skipped. Frozen resolver.pl / resolver_store.pl / debian/ untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoXjhStCqoig6944pVNBGe
@s243a
s243a force-pushed the claude/abi-symbol-store branch from 0afcce3 to 0e4253a Compare September 13, 2026 06:23
s243a and others added 7 commits September 13, 2026 09:25
…b-version gate, P3 fixture)

Sol's re-review closed five of eight findings and requested changes on four,
all rooted in one principle: a `.symbols` file is a curated LOWER-BOUND list,
not a complete export set, so its ABSENCE proves nothing.

- P1 curated absence (abi_resolve.pl:289): a plain `.symbols` ingest (no --elf)
  is now tagged `curated`, not `complete`; only readelf or an --elf cross-check
  is `complete`. prov_usable/4 + ev_says/5 let curated evidence establish
  PRESENCE but never ABSENCE (a curated omission -> unknown, never missing).
  Fixtures C10/C10b/C10c, A26 (real libselinux1), D7.
- P1 contradictory floor (abi_resolve.pl:311): a curated minimum above its
  evidence release is rejected at ingest (debLe, exit 3) and at load
  (assert_symprov rel_le(Deb,R0)). Fixtures C11/C11b, contradictory.symbols.
- P2 deb-version gate (ingest_symbols.mjs:114): validDebVersion() replaces the
  loose regex and rejects 1:, 1-, 1::2 the way dpkg --validate-version does.
  Fixtures bad_release_{1:,1-,1::2}.
- P3 crosscheck fixture: added a numeric-collapse pair (LIBX_2.1/LIBX_2.10) and
  a negative elf_numcollapse fixture that only a reintroduced numeric node
  comparator would pass.

Curated presence stays compatible(curated) (distinct from exact); below_floor
remains a declared-minimum veto that never overrides a direct presence
observation. Frozen resolver.pl / resolver_store.pl / debian/ untouched.
run_abi_verify.sh: 98 passed, 0 failed, 0 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoXjhStCqoig6944pVNBGe
…obustness nits

An independent Fable re-verification of the previous commit found that widening
the unversioned-path completeness gate to prov_usable/4 let a curated-only
NEEDED object fall through to a hard missing veto -- the very curated-absence
bug, reintroduced on the unversioned side (invisible to the harness because
/bin/ls's unversioned refs are all WEAK).

- unversioned_status/7: gate the missing veto on \+ prov_evidence(S,_,_,complete)
  for the NEEDED object; a curated-only lib now yields
  unknown(absent_from_incomplete_evidence), never missing. New fixtures C12/C12b
  (unversioned req vs curated-only omission -> unknown; vs complete omission ->
  missing).
- Robustness (Fable-flagged): debLe die()s clearly if dpkg is absent from PATH;
  load_abi_store/1 clears the partial store when a row throws; removed the now
  dead bound_evidence/4.

Frozen files untouched. run_abi_verify.sh: 100 passed, 0 failed, 0 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoXjhStCqoig6944pVNBGe
…, dpkg-equivalence)

Astra (original reviewer) re-reviewed and found new issues, the key one by
ingesting live libstdc++.so.6. All real findings fixed and fixture-proven.

P1:
- ingest: elfProvides now keeps STB_GNU_UNIQUE exports (was GLOBAL/WEAK only);
  dropping them (106 in libstdc++.so.6) caused false missing vetoes. Regression
  ingests libstdc++ and asserts a real UNIQUE export is stored.
- abi_resolve unversioned_status: a missing veto now requires absence ESTABLISHED
  at Rel (complete evidence at a release >= Rel); complete evidence only below
  Rel -> unknown(absence_unestablished), never a veto (C14/C14b).
- abi_resolve: binding_at/6 ties the default-version binding to the evidence
  applicable at Rel, so a stale/orphaned default row cannot yield a false
  compatible(exact) for an unversioned ref (C15/C15b/C15c).

P2:
- ingest archSelects: only exact arch names (+ !neg); dpkg wildcard patterns
  (any, linux-any, any-*) are rejected, not mis-selected (tmpl_arch_wild).
- ingest validDebVersion: epoch capped at 2147483647 (dpkg's limit).
- abi_resolve: hypothetical unversioned drop is node- and release-aware, like the
  versioned path (C13/C13b).

P3 (documented, not changed): crosscheck per-name is redundant with exact
identity (guarded); debLe uses dpkg (reference impl) while the resolver orders
via frozen version_lt and the authoritative floor check is Prolog rel_le.

Frozen files untouched. run_abi_verify.sh: 108 passed, 0 failed, 0 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoXjhStCqoig6944pVNBGe
…R1/R2)

A Fable re-verification of the Astra-fix commit caught two regressions the
binding_at rework introduced; the harness missed them because every unversioned
.symbols test queried AT the evidence release.

- R1 (false missing veto): binding_at took the binding only from the row AT Rel
  or BELOW, but ident_status can credit a curated .symbols floor row ABOVE Rel
  (combine, Min =< Rel). binding_at now also selects that above curated row.
  Fixtures C16/C16b (unversioned ref BELOW a curated floor -> compatible(curated)).
- R2 (cross-axis false unknown): the missing-veto coverage gate compared So's
  query release with a sibling's own evidence release. The gate now applies to
  the queried So only; siblings are evaluated at their own release. Fixture C17.

Also cleaned a stray NUL byte in the debLe cache-key string (ASCII escape in
source; runtime key unchanged).

Frozen files untouched. run_abi_verify.sh: 111 passed, 0 failed, 0 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoXjhStCqoig6944pVNBGe
…-review 2)

Astra re-reviewed and found more unversioned-path issues, all rooted in binding_at
being a separate re-derivation from ident_status and the veto branches not being
uniformly release-gated. Rather than patch case-by-case, the binding is unified
into the aggregation.

P1:
- ident_status now returns provided(Basis, Binding); says_status/combine carry the
  binding from the SAME row that establishes presence, so binding and presence can
  never diverge. Conflicting cross-tier bindings -> ambiguous -> unknown
  (default_binding_conflict), never a veto or confident compat. binding_at deleted;
  unversioned_in/unproven/nondefault + new unversioned_ambiguous read the binding
  from ident_status. Fixture C19.
- no_default_export is now release-gated too: the coverage gate precedes BOTH veto
  branches, so a nondefault export seen only BELOW the query release -> unknown,
  not a veto. Fixtures C18/C18b.

P2:
- archSelects validates every term first (an early match/negation can't skip a
  later unsupported term) and rejects tuples/GNU forms/comma-lists/any-wildcards
  (tmpl_arch_tuple, tmpl_arch_list).
- arch-bits/arch-endian use explicit ARCH_BITS/ARCH_ENDIAN tables; a non-tabulated
  --arch rejects the row rather than guessing (arch_bits_unknown).
- test isolation: epoch cap proven via the releases command (epoch_releases);
  hyp-drop node/release matching isolated by C13c/C13d.

Frozen files untouched. run_abi_verify.sh: 118 passed, 0 failed, 0 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoXjhStCqoig6944pVNBGe
…Fable M2/M3)

A Fable re-verification confirmed the binding unification introduced no
regression but surfaced two pre-existing false verdicts, now fixed.

- M2 (false compatible): ident_status/5 was mode-dependent -- combine/4 and
  says_status/3 carry their cut AFTER head unification, so a caller passing a
  bound Status (e.g. provided(_,default)) could skip the clause the unbound call
  fires and match a later one, giving compatible(extrapolated) for a symbol
  observed DROPPED. Fixed: ident_status computes into a fresh variable via
  ident_status_/5, then unifies -- every caller sees the one mode-independent
  status. Fixture C20.
- M3 (false no_default_export): combine clause 3 extrapolated the below-row
  binding and ignored a disagreeing above row. Fixed: merge_binding/3 marks two
  conflicting DEFINITE bindings ambiguous (-> unknown); unproven is no-info,
  never a conflict. Fixture C21.

Frozen files untouched. run_abi_verify.sh: 120 passed, 0 failed, 0 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoXjhStCqoig6944pVNBGe
Astra re-review found the M3 change regressed combine/4 clause 3: extrapolating a
present-below row past a NON-covering above row used merge_binding, which promoted
an `unproven` below binding to a future definite above binding -> a false
compatible where it should stay unknown(default_binding_unproven).

- combine clause 3 now uses extrapolate_binding/3: keep the below binding; flag
  ambiguous only when both are definite and differ; never import the above
  binding. Clause 2 (covering-curated, where the above floor applies at Rel)
  keeps merge_binding. Fixtures C22 (curated-unproven below + complete
  default-since above -> unknown, not compatible), C22b (ELF default above).
  C19/C21 still pass (definite conflicts -> ambiguous).
- Cleaned a stray NUL byte in REVIEW_NOTES.md.

Frozen files untouched. run_abi_verify.sh: 122 passed, 0 failed, 0 skipped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RoXjhStCqoig6944pVNBGe
@s243a
s243a marked this pull request as ready for review September 14, 2026 05:42
@s243a
s243a merged commit acb5818 into main Sep 14, 2026
16 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant