You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Four more ways to oblige an implementer that the interface-addition gate does not see (measured) #3489
#3465 shipped Interface additions (implementers declared) (PR #3487), which fires when a diff adds
a member an outside implementer would have to write. While building it, four other ways to oblige
an implementer were measured against the detector and found invisible. The control in the same
run — public abstract void B(); added to a public abstract class — reports implementer-obliging-added correctly, so each blank below is a real blind spot rather than a broken
probe.
#
the change
why an implementer breaks
why the detector is blind
cost to close
a
an interface gains a base interface (interface IFoo : IBar)
every implementer of IFoo must now supply IBar's members
surface_additions diffs member SETS per type; it never diffs base lists
small — index the base list per public type and report a growth
b
an interface gains an overload of a name it already declares (void M(string) beside void M(int))
it is a member every implementer must write
member granularity is the NAME, deliberately
large — it would change what "member" means across the whole report, the removal half included, and the current granularity is a documented choice (#3103)
c
an existing default member is made abstract
the body implementers relied on is gone
nothing is added or removed
undetectable by a surface detector — this is shape 7 wearing an interface, and #3276 already records why
d
a protected abstract member is added to a public abstract class
CS0534 in an external subclass, exactly as for a public one
only public members are indexed, so it never appears as an addition
small — a separate protected-abstract index, kept out of publicMembersAtBase so the removal half's numbers do not move
(a) and (d) are worth doing and are independent of each other. (b) is a real gap but its fix is
disproportionate and touches the removal gate; (c) is out of reach by construction and should stay
documented rather than attempted.
Whatever lands must be falsified the same way #3487 was: a RED arm on a reintroduced instance and a
GREEN arm with the declaration present, with the denominator printed and a floor that fails on zero. scripts/check-type-forwards.py --self-test is the harness; each mechanism must be shown to redden
cases when disabled.
Reference: Doc/Architecture/CrossRepoPairGate → "Four ways to oblige an implementer that this gate
does NOT see", which carries the same table and the measurement.
Related but distinct: #3454 (satellite pin-consistency gates), #2689 (the dependent-compile canary).
Closed by hand — #3503 merged as 8ef74ed66, verified an ancestor of origin/main
The merge queue does not fire Closes links, so this is manual and the ancestry was measured.
(a) and (d) are closed; (b) and (c) stay open with their reasons, exactly as this issue scoped it.
#
the change
now
a
an interface gains a base interface
✅ implementer-obliging-base-added
d
a protected abstract member on a public abstract class
✅ implementer-obliging-protected-added
b
an overload of a name an interface already declares
⛔ open — the fix changes what "member" means across the removal half too, and would fire on every overload added anywhere
c
an existing default member made abstract
⛔ out of reach by construction — shape 7 wearing an interface (#3276)
All three trigger categories are one verdict in three grammars, so they share one Implementers:
declaration and one gate.
The part of this worth reading later
🚨 The (d) scan measured ZERO over a tree that has five, and the zero was blindness. MEMBER_MODIFIERS deliberately holds no access keyword but public, so _leading_modifiers stops
dead at protected and answers set() — every downstream test then reads false and the shape stays
invisible. Caught by cross-checking the denominator against grep rather than by trusting the count.
The fix strips access keywords inside the new scan: widening the shared frozenset would have moved publicMembersAtBase and recalibrated the removal half's floors and history — the cure breaking the
patient. Verified byte-identical before and after: 1 974 / 12 345 / 132 / 452.
🚨 And the second zero-discrimination went the other way.grep finds sixprotected abstract
declarations; the scan reports five, and the scan is RIGHT — RoutingServiceBase is internal abstract class, so nothing outside can subclass it. The crude instrument was the grep. A discrepancy
is a question, not a verdict, and it resolves in whichever direction the evidence points.
Verification
check-type-forwards.py --self-test76/76 (was 68; +8 surface cases). Four falsification arms,
each watched failing and then restored: _base_list → set() reds 3; _obliges_external_subclass → False reds 1; the where-clause guard disabled reds 1; the consumer's trigger set narrowed to #3465's category alone reds 2.
One structural change worth knowing about: parse_members and the self-test's _index now return
dataclasses instead of widening tuples. Not tidying — the harness's tuple silently dropped both new
indexes while the production readers carried them, so the suite would have scored a new mechanism
green having never passed it in. Surface's own docstring already said why: "a positional fifth
element is exactly how a new field gets dropped at one call site."
Follow-up filed: #3508 — this pull request's own gate (#3492/#3500) ran on neither of its two
builds, because the branch predated the gate and the queue build skips that job. No harm here (the
delta was measured by hand and tabulated in the PR body) but that was authorship luck, not a property
of the system.
Activity
rbuergi commentedon Sep 6, 2026
Closed by hand — #3503 merged as
8ef74ed66, verified an ancestor oforigin/mainThe merge queue does not fire
Closeslinks, so this is manual and the ancestry was measured.(a) and (d) are closed; (b) and (c) stay open with their reasons, exactly as this issue scoped it.
implementer-obliging-base-addedprotected abstractmember on a public abstract classimplementer-obliging-protected-addedabstractAll three trigger categories are one verdict in three grammars, so they share one
Implementers:declaration and one gate.
The part of this worth reading later
🚨 The (d) scan measured ZERO over a tree that has five, and the zero was blindness.
MEMBER_MODIFIERSdeliberately holds no access keyword butpublic, so_leading_modifiersstopsdead at
protectedand answersset()— every downstream test then reads false and the shape staysinvisible. Caught by cross-checking the denominator against
greprather than by trusting the count.The fix strips access keywords inside the new scan: widening the shared frozenset would have moved
publicMembersAtBaseand recalibrated the removal half's floors and history — the cure breaking thepatient. Verified byte-identical before and after: 1 974 / 12 345 / 132 / 452.
🚨 And the second zero-discrimination went the other way.
grepfinds sixprotected abstractdeclarations; the scan reports five, and the scan is RIGHT —
RoutingServiceBaseisinternal abstract class, so nothing outside can subclass it. The crude instrument was the grep. A discrepancyis a question, not a verdict, and it resolves in whichever direction the evidence points.
Verification
check-type-forwards.py --self-test76/76 (was 68; +8 surface cases). Four falsification arms,each watched failing and then restored:
_base_list → set()reds 3;_obliges_external_subclass → Falsereds 1; thewhere-clause guard disabled reds 1; the consumer's trigger set narrowed to#3465's category alone reds 2.
One structural change worth knowing about:
parse_membersand the self-test's_indexnow returndataclasses instead of widening tuples. Not tidying — the harness's tuple silently dropped both new
indexes while the production readers carried them, so the suite would have scored a new mechanism
green having never passed it in.
Surface's own docstring already said why: "a positional fifthelement is exactly how a new field gets dropped at one call site."
Follow-up filed: #3508 — this pull request's own gate (#3492/#3500) ran on neither of its two
builds, because the branch predated the gate and the queue build skips that job. No harm here (the
delta was measured by hand and tabulated in the PR body) but that was authorship luck, not a property
of the system.