ADR-077: Exposure Modes and Change-Sensitive Propagation¶
Date: 2026-10-11
Status: Proposed — partially implemented: D4's role half shipped
2026-10-12 (diff_source_graph's changed_edges/role_unknown_edges, the
public_dependency_role_changed/_unknown findings); exposure modes, the
sensitivity table, D4's mode half and D5 are not implemented. Amends
ADR-046 D5
(TraversalPolicy) and ADR-044
(effective public ABI). Sequenced after the identity and per-member
relevance gaps listed under "Prerequisites".
Context¶
abicheck already has most of the semantic machinery for L2:
- the header AST is normalized into
SemanticIR; - an L2 graph is built by default whenever headers are given;
- type edges carry a role (
param/return/field/base/…); - inline and template bodies contribute call and reference edges;
- every edge has provenance and identity resolution;
- coverage is three-valued (
present/proven_absent/unknown).
What it cannot answer is the question a maintainer actually asks:
Which public contracts depend on the property that changed on this entity, and which facts prove it?
Reachability (public_api → … → T) answers a weaker question. Whether a
change to T matters depends on how each path uses T:
Use of T |
Changes that matter |
|---|---|
| passed or returned by value; embedded by value; base class | size, alignment, layout, triviality (calling convention) |
pointer/reference to an incomplete T |
the name and identity only; layout does not matter |
inline body reads T::f |
that one field's offset, type and existence |
| constant or enumerator used in a default argument or inline body | its value; consumers must be rebuilt (source/behaviour, not link) |
T named in a constraint or a specialization |
whether a call is still accepted and which overload or specialization it selects |
inline or template body calls g |
whether g exists and is still exported (symbol availability) |
ADR-046 D5 already names five propagation shapes: layout,
symbol-availability, source-contract, behavioural and deployment. But
TraversalPolicy there is a traversal filter only. Nothing ties a
ChangeKind to the shape it travels on, and TypeEdge.role records
syntactic position, not the usage mode. The result is an over-approximation
in both directions:
- a layout change to a type that is reachable only through an opaque pointer can still count against the verdict (a false positive);
- a role change on an existing edge was invisible to the structural diff (closed known gap, fixed by D4's role half);
- body-dependent edges are declined wholesale, because coverage is one
blanket
header_inline_bodiesunit (known gap), a false-clean.
This ADR is not about a graph database or a new physical store. Per the
ADR-063 D5 amendment and policy/public_surface_closure.py, derived
answers are computed from the snapshot's current declarations, not read back
from stored graph attributes.
Decision¶
D1. ExposureMode on dependency facts¶
Each dependency fact from a public entry to an entity carries an
ExposureMode, a closed model/ vocabulary that sits alongside the
existing role:
BY_VALUE, BASE, OPAQUE_HANDLE (pointer/reference to a type that is
incomplete on the public surface), COMPLETE_INDIRECT (pointer/reference to
a complete type), FIELD_ACCESS(field), VALUE_USE (constant/enumerator in
a default argument or inline body), CONSTRAINT, CALL, UNKNOWN.
UNKNOWN is a real value. Producers that cannot decide the mode emit it;
they never default to the weakest mode.
Mode is part of relation identity. Today TypeEdge and GraphEdge
deduplicate on (src, dst, kind, role) (type_graph.py,
model/graph_facts.py), so f(T, T*) has two param uses of T that
need different modes (BY_VALUE, COMPLETE_INDIRECT/OPAQUE_HANDLE) but
collapse into one relation. The dedup key becomes
(src, dst, kind, role, mode). Collapsing two modes into one relation is
never allowed, because the sensitivity table would then silently apply
whichever mode survived. A consumer that wants the coarser relation takes
the union of its modes, and the change is sensitive if any mode in that
union is sensitive.
D2. A sensitivity table owned by policy/¶
A single table maps (ChangeKind family, ExposureMode) to an affected
contract: ABI_LAYOUT, SYMBOL_LINK, SOURCE_COMPILE, REBUILD_ONLY,
NONE, or UNKNOWN. ADR-046 D5's five propagation shapes become the rows'
contract axis, and each TraversalPolicy is derived from this table
instead of hand-built per detector. Rules:
- propagation crosses an edge only when the table says the change is sensitive to that edge's mode;
UNKNOWN(mode or coverage) never yieldsNONE, so missing evidence narrows the conclusion and is reported (AGENTS.md, "Weaker evidence narrows conclusions");- the table changes relevance and attribution, never the observed
Change. A change judgedNONEfor every public path is recorded and disposed with its rule and reason (ADR-067), not dropped.
D3. Impact answers carry their proof¶
The query "which public contracts depend on property P of entity E" returns, for each affected entry, the edge chain with each edge's mode, provenance and coverage status. This is the explanation the report renders (for example: public function → by value → nested field → changed layout). ADR-052's impact model owns rendering. This ADR owns only the query's content.
D4. Role- and mode-aware structural diff¶
diff_source_graph gains a changed-relation result (same
(src, dst, kind), different role or mode). It neither turns that into a
removed+added pair nor drops it. Existing consumers of the coarse
added/removed sets are unaffected (ADR-046 D1's promise is kept).
Role half implemented (2026-10-12). GraphSummaryDiff.changed_edges holds
each dependency whose role set differs; when exactly one side carries
role-less edges for it (a graph stored before roles were stamped) the entry
goes to role_unknown_edges, a coverage gap rather than a change.
policy/source_graph_role_findings.py reports a public entry's change as
public_dependency_role_changed (RISK) and a non-comparable one as
public_dependency_role_unknown (COMPATIBLE). The mode half waits on
ExposureMode (D1).
D5. Body coverage per declaration¶
A header pass records, for each inline or template body, an ADR-063
Fact[T]: PRESENT with the body digest, or a reason it was not seen.
Absence is decided per side. An observed edge proves its own presence on
the side where it was seen, whatever that side's coverage. A body-dependent
edge is proven_absent on a side only when that declaration's body is
PRESENT on that side. So a removal (edge seen in old) needs only the
new body PRESENT, and an addition (edge seen in new) needs only the old
body PRESENT. Requiring both sides would discard valid one-sided negative
evidence and recreate the false-clean this decision closes. A side whose
body is not PRESENT and shows no edge stays unknown.
D6. Facts from the same parse¶
New modes and families come from compact projections of the existing L2
AST parse (l4-l2-extraction-convergence.md).
The template/override/callback facts already come that way
(buildsource/semantic_fact_folds.py; closed gap).
No rule may add its own clang invocation.
Prerequisites (tracked elsewhere, not reopened here)¶
requires-constrained overloadEntityIdcollisions and the out-of-line member-template scope split (known-gaps, "EntityId scope collisions in template declarations"). Propagating along ambiguous identities would make both false positives and false suppressions worse.- Per-member relevance: keep
proven_unreachablefindings out of member verdicts (known-gaps, "A directorycomparestill feeds the union-Hset…"). - Enum/typedef paths moved onto
type_reachability(known-gaps, "Type reachability (direct vs. transitive stdlib references)").
Out of scope¶
- Compiler witnesses for overload resolution, ADL, concepts and implicit
conversions stay governed by
ADR-060. This ADR
supplies the
CONSTRAINTmode and a proof path a future witness can attach to. It does not reopen the deferral. Note on verdict mapping for ADR-060: a witness proves that one break exists, and the absence of a witness proves nothing about all consumers. - Macro-configuration matrices (comparing the contract under N declared
-Dsets) extend ADR-074 and ADR-065 variants. They need their own plan once D1–D3 give a per-configuration contract.
Validation¶
Each mode/contract row ships the full workflow: an independent example
(examples/ plus catalog/ground_truth.json), extraction, a storage round
trip, detector, policy, then the rendered report. It must also include a
near-miss safe case (for example, the same layout change behind
OPAQUE_HANDLE that must not gate) and incomplete-input variants that must
come out UNKNOWN, not NONE. Both GCC- and Clang-built fixtures are
required where layout is involved.
Consequences¶
- Fewer false positives come from scoping propagation by sensitivity rather than by reachability. False-cleans close through per-body coverage and role/mode diffs.
- The schema changes:
ExposureModeon persisted dependency facts and body-coverage facts mean a snapshot schema bump and a migration (ADR-062). - The sensitivity table becomes a reviewed, test-enforced artifact. A new
ChangeKindfamily must declare its row, the same way a new kind declares itsdefault_verdict.
Cross-references¶
ADR-044,
ADR-046 D1/D3/D5,
ADR-052,
ADR-060,
ADR-063,
ADR-067,
G29 Phase 5,
l4-l2-extraction-convergence.md.