Declared-header order is still load-bearing because the driver TU is sequential¶
Index: Known gaps
Status (2026-10-12): PARTIAL. The scope-confirmed header growth waiver landed (comparability.py:845,1298 reference _header_sequence_is_scope_confirmed_growth). Still open: the sequential aggregate driver TU itself, so reordering an existing header still refuses. Canonical sorting is measured unsound and rejected; the remaining options are recorded in ADR-082.
profile_fingerprint's header_sequence field records declared-header
order, and it does so for a real reason: dumper.py generates one
aggregate driver translation unit that #includes the declared headers in
sequence, so a header's macros/pragmas can change how every header parsed
after it resolves.
The comparability gate originally waived only a strict trailing append
(_header_sequence_is_additive_reorder_free), which is the one shape that
proves no existing header's preprocessing context changed. That is correct
for a deliberately declared -H a.h -H b.h order and badly mis-fitted to a
directory-discovered one, where the order is an incidental product of
sorting whatever the sweep found. Real integration evidence: a project
adding one public header named json.h to a set sorting as
data.h, log.h, ... landed it in the middle, the waiver declined, and an
ordinary public-header addition refused the entire comparison —
profile_fingerprint mismatch; differing fields: header_sequence, no
verdict, and no findings on any axis, the binary's own exported-symbol
identity included.
What was fixed, in two rounds. The first round changed the
disposition: an order-preserving insertion of headers the scope
fingerprint independently confirms as new became a non-fatal
ComparabilityMismatch (fatal=False) whose residual risk was recorded as
a bounded assurance reduction on the declaration/layout dimensions. That
removed the refusal but kept pricing the insertion — and the price is real:
AnalysisAssurance.status reads partial, which assurance.require_complete
gates on, so a project running that (correct, unweakened) setting still
failed on an ordinary public-header addition, now with
"extraction contexts were not provably identical" instead of a refusal.
The second round removed the price, because the hazard it named is not one this contract prices anywhere else:
- A declared header's content is not part of
profile_fingerprintat all —comparability_fields._header_identitieskeys a header by its root-relative path, never its bytes. So an existingdata.hthat gains a#defineor a#pragma packbetween two versions changes the parse context of every header after it and is compared at full assurance, by design: the declared surface's content is the subject of the comparison, not evidence about the extraction environment. Adding those same declarations as a new file is the same fact in a different shape. - The added header's position is an incidental product of the project's
discovered, sorted public-header set. Pricing it makes assurance depend
on the new header's spelling —
json.hcosts assurance,zzz.hdoes not, for the same library change. - The scope axis already treats the identical addition as ordinary
evolution at full assurance (
_scope_field_is_additive_superset), and the trailing-append waiver already accepts the added header's own parse.
So the header_sequence carve-out is now stated over
comparability_sequences._header_sequence_is_scope_confirmed_growth — the
union of the append and insertion shapes — and scope-confirmed growth is
waived outright wherever the new header sorts.
comparability_profile._declared_header_insertion_mismatch and
inserted_header_entries were deleted rather than left as a second,
unreachable path. The non-fatal (fatal=False) machinery itself stays:
it expresses a general rule, has no producer today, and
analysis_assurance_comparability.py remains the (still correct) bridge
that stops a bounded run from reporting complete.
What still refuses, unchanged: a reorder of an existing declared
header, growth whose extra entries the declared surface does not confirm as
new (e.g. a header fed to the L2 frontend on the new side only, whose
content the old snapshot never parsed), a duplicate/sentinel/undecodable
sequence, and any other diverging profile field riding alongside the
addition — compiler family or version, target triple, pointer width,
endianness, ABI dialect, language standard, macro ops, pass-through flags,
or an unexplained include_sequence (a changed -I topology, which decides
which dependency header an #include resolves to). The carve-out only ever
removes header_sequence from the working set, so that safety property is
structural rather than a list to keep in sync.
What remains open: the sequential aggregate TU itself. Parsing each
declared header in an independently scoped TU would make declared order
non-load-bearing outright — a reorder would then be comparable too, and
this whole carve-out family could retire — but that is a dumper change
carrying a per-header TU cost, and it was deliberately not attempted
alongside either disposition fix. --diagnostic-comparison is explicitly
not the answer to any of this: it downgrades assurance wholesale
instead of resolving the extraction question.
What the 2026-10-12 check established¶
The aggregate TU is built in declared order by both backends
(extract/headers/ast_config.py::clang_aggregate_text,
extract/castxml_header_compat.py::castxml_aggregate_text), so declared order
changes what is extracted. Measured with z_base.h (defines Base) and
a_user.h (declares two functions over Base, no include), built into one
shared library and dumped through dumper.dump:
- Declared
z_base.h, a_user.h: both functions extracted, under castxml and clang. - Declared
a_user.h, z_base.h: castxml excludesa_user.hafter its L2 parse fails, logs "excluding them and retrying (reduced evidence)", and returns zero declarations. Clang raisesSnapshotError(unknown type nameBase). - Canonical sorting is unsound. A sorted order puts
a_user.hbeforez_base.h, which is the second row: it converts a valid declared order into an empty or failed extraction. Sorting is rejected. - Removing the refusal alone is unsound. Comparing the same unchanged
library dumped in the two orders, with
check_contracts_comparablebypassed, reportsBREAKING:func_removed make_base,func_removed base_x, plusexported_not_publicfor both. The refusal is what stops this false break. - A per-header TU is not output-neutral either. A standalone dump of
a_user.hfails (clang: unknown typeBase), so one TU per header turns the order dependence into a loud failure or reduced evidence for every non-self-contained header. It also multiplies parse cost by the header count.
Pinned by tests/test_driver_tu_declared_order.py: the aggregate text equals
the declared order for every permutation (oracle: expected lines written from
the input list), declared order changes the extracted declarations under both
backends, and reordering existing headers on real dumps still refuses with
header_sequence. A change that makes extraction order-independent must update
these tests deliberately.
What changed (ADR-082, accepted and implemented). The declared order still governs
extraction. A dump now proves, per snapshot, whether its declarations equal the sorted
order's, by parsing both orders and comparing the extracted declarations record for record.
A proven snapshot records the canonical order in ExtractionContract.header_canonical
(schema v59). The gate accepts a reorder of an identical header set only when both sides
carry the same proof. Adding or removing headers keeps the existing header_sequence rules.
Still refused, by design. A reorder whose declarations are unproven (for example
a_user.h before z_base.h, where the sorted parse fails), any side without the proof
(including every pre-v59 snapshot), and a macro or pragma leak that changes no declaration:
the witness cannot see it, and the residual is recorded in ADR-082.