From 8502c6557687ffd31fb4996a3507f75ded5faca5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 07:50:43 +0000 Subject: [PATCH 1/2] WideFieldMask: ofFacets(int...) package-private; pin ledger L1/L2 in ApiSurfaceTest MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two T2/T3 leaks the mechanical fence (#74) could not see, because reflection cannot tell `int classid` from `int facet`. L1 — WideFieldMask.ofFacets(int...) built a field mask from raw facet SLOT positions: a byte position crossing the consumer wall. Zero production callers (only MaskNativeOpsTest, same package, unaffected). Demoted to package-private, not removed (I-LEGACY-API-FEATURE-GATED: same shape, narrower reach) — it stays as the in-package bridge behind ofMatchBits and the bit-layout tests. The name-side replacement needed no ABI symbol: RowStore.hop(int edgeClassid, Mask) passes allFacets() and lgj_hop narrows natively by edge_participation(classid); its javadoc already said "a caller cannot widen past what the class actually permits." allFacets() = "let the class decide" is now the consumer's whole participation vocabulary. ApiSurfaceTest pins it BY NAME: re-publicising ofFacets fails the build. L2 — closes by the EXISTING gate; the ledger overstated it. abi.md:312: "Java's public API never sees an address." The offset+stride carrier is Engine.LaneWindow in internal.ffm, used only inside RowStore/Mask/LaneProbe (package-private) and the sanctioned lab consumers; the internal.* prefix already fences it from every public signature. RowStore's row * strideBytes() reads the stride FROM the served descriptor (abi.md:367), never computes it — NAMED under kernel-membrane-warden, not GEOMETRY-LEAK. ApiSurfaceTest now proves it structurally: the class exists AND lives under a FORBIDDEN prefix. Not run in-session: the Java suite (JDK 21 here vs JDK 27 + preview FFM). WideFieldMask.java compiled standalone; the L1 reflection pin proven standalone (declared, not on getMethods(), allFacets public). CI runs the rest. LATEST_STATE entry in this commit. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016WkNBjHc2e3zuyz9i8qJEv --- .claude/board/LATEST_STATE.md | 36 ++++++++++++++++ .../adaworldapi/lancegraph/WideFieldMask.java | 17 ++++++-- .../lancegraph/ApiSurfaceTest.java | 43 +++++++++++++++++++ 3 files changed, 93 insertions(+), 3 deletions(-) diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index b832cf3..8b4db33 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -1,3 +1,39 @@ +## 2026-09-05 — ledger L1/L2: the semantic leaks the T2/T3 gate could not see + +**Branch `claude/membrane-l1-l2-semantic-leaks`.** Follows #74 (the mechanical +fence). These are the two rows `membrane-tiers.md` marked `[OPEN]` because +reflection cannot tell `int classid` from `int facet`. + +**L1 — `WideFieldMask.ofFacets(int...)` → package-private.** It built a mask +from raw facet SLOT positions: a byte position crossing the consumer wall. Zero +production callers (only `MaskNativeOpsTest`, same package, unaffected). The +name-side replacement already existed and needed no ABI: `RowStore.hop(int +edgeClassid, Mask)` passes `allFacets()` and `lgj_hop` narrows natively by +`edge_participation(classid)` — the javadoc already says "a caller cannot widen +past what the class actually permits." `allFacets()` = "let the class decide" +is now the consumer's whole participation vocabulary. Demoted, not removed +(I-LEGACY-API-FEATURE-GATED: same shape, narrower reach) — it stays as the +in-package bridge behind `ofMatchBits` and the bit-layout tests. Pinned BY NAME +in `ApiSurfaceTest` (re-publicising it fails the build). + +**L2 — closes by the EXISTING gate; the ledger overstated it.** Read again: +`abi.md:312` — "Java's *public* API never sees an address." The offset+stride +carrier is `Engine.LaneWindow` in `internal.ffm`, used only inside +`RowStore`/`Mask`/`LaneProbe` (package-private) and the sanctioned lab +consumers (bench, valhalla-lab). `ApiSurfaceTest`'s `internal.*` prefix already +fences it from every public signature. And `RowStore`'s `row * strideBytes()` +reads the stride FROM the served descriptor (`abi.md:367-368`), never computes +it — that is NAMED under `kernel-membrane-warden`, not GEOMETRY-LEAK. Added a +structural pin: the class exists AND lives under a FORBIDDEN prefix, so any +public signature carrying it is already a LEAK — L2 is closed by proof, not +promise. The genuine residual is not a leak but a design ceiling: Valhalla +views that KNOW the layout should be OGAR-emitted per ClassView, not +hand-carved in the lab (membrane-tiers.md Tier 3) — a future wave, not a fence. + +**Not run in-session:** the Java suite (JDK 21 here vs JDK 27 + preview FFM). +`WideFieldMask.java` compiled standalone and the L1 reflection pin proven +standalone; CI runs the rest. + ## 2026-09-04 — lgj_hop: the conjunction is one truth-table pass, and the lane is read as a lane (5×) **Branch `claude/pr-294-ragged-path-validation-170zcy`**, lgj-abi only — no diff --git a/java/src/main/java/com/adaworldapi/lancegraph/WideFieldMask.java b/java/src/main/java/com/adaworldapi/lancegraph/WideFieldMask.java index 5f864a5..1bf88ee 100644 --- a/java/src/main/java/com/adaworldapi/lancegraph/WideFieldMask.java +++ b/java/src/main/java/com/adaworldapi/lancegraph/WideFieldMask.java @@ -11,7 +11,7 @@ * positions, at which point it promotes once to {@code Wide(Box<[u64]>)}. * This Java type carries the Small tier only — one {@code long}, no promotion * path. A {@link RowStore} has exactly 32 facets today, so the low 32 bits are the only ones this - * library ever populates or reads through {@link #allFacets()}/{@link #ofFacets}: positions + * library ever populates or reads through {@link #allFacets()} (or, in-package, {@code ofFacets}): positions * 32..63 are addressable (a {@code long} has room for them, matching the Rust {@code Small} * representation bit-for-bit) but no {@link RowStore} shape in this codebase uses them. A future * surface past 64 fields needs the Wide-tier promotion on the Rust side, mirrored here as a @@ -44,7 +44,18 @@ public static WideFieldMask allFacets() { } /** - * Build a mask from the populated facet positions. + * Build a mask from raw facet SLOT positions — package-private by the T2/T3 + * membrane (lance-graph {@code .claude/knowledge/membrane-tiers.md}, ledger L1). + * + *

A slot index is a byte position, and byte positions never cross the consumer wall: a + * consumer names which edge class it is hopping ({@link RowStore#hop(int, Mask)} + * takes a classid) and the native side narrows participation to that class's + * {@code ClassView}-resolved facets ({@code edge_participation}). {@link #allFacets()} is + * therefore the consumer's whole vocabulary — "let the class decide" — never a hand-picked + * set of slots. This factory remains for the in-package bridge from the inspection surface + * ({@link #ofMatchBits(int)}) and for tests that pin the bit layout; it was public through + * ABI minor 10 and is demoted here, not removed (I-LEGACY-API-FEATURE-GATED: the shape is + * preserved, only its reach changes). * * @param positions each must be in {@code 0..31} — the 32-facet domain of a {@link RowStore} * row. Validated eagerly rather than silently folded to a no-op: the @@ -53,7 +64,7 @@ public static WideFieldMask allFacets() { * the call site, matching {@link FacetId}'s own convention in this codebase. * @throws IllegalArgumentException if any position is outside {@code 0..31} */ - public static WideFieldMask ofFacets(int... positions) { + static WideFieldMask ofFacets(int... positions) { java.util.Objects.requireNonNull(positions, "positions"); long bits = 0L; for (int p : positions) { diff --git a/java/src/test/java/com/adaworldapi/lancegraph/ApiSurfaceTest.java b/java/src/test/java/com/adaworldapi/lancegraph/ApiSurfaceTest.java index 94e59d7..7e2d12b 100644 --- a/java/src/test/java/com/adaworldapi/lancegraph/ApiSurfaceTest.java +++ b/java/src/test/java/com/adaworldapi/lancegraph/ApiSurfaceTest.java @@ -113,6 +113,49 @@ public static void run(Checks c) { } } + c.section("ledger L1: no public construction of a field mask from slot positions"); + // WideFieldMask.ofFacets(int...) built a mask from raw facet SLOT indices — a byte + // position crossing the wall. It is package-private now; a consumer names the edge + // CLASS (RowStore.hop takes a classid) and native narrows to that class's + // ClassView-resolved participation. Reflection cannot tell `int classid` from + // `int facet`, so this pin is by NAME: re-publicising ofFacets fails here. + boolean slotFactoryPublic = false; + for (Method m : WideFieldMask.class.getMethods()) { + if (m.getName().equals("ofFacets") && Modifier.isPublic(m.getModifiers())) { + slotFactoryPublic = true; + } + } + c.that("WideFieldMask.ofFacets(int...) is not public (slot indices never cross; name the class)", + !slotFactoryPublic); + c.that("WideFieldMask.allFacets() remains the consumer's participation vocabulary", + java.util.Arrays.stream(WideFieldMask.class.getMethods()) + .anyMatch(m -> m.getName().equals("allFacets") && Modifier.isPublic(m.getModifiers()))); + + c.section("ledger L2: lane geometry (offset/stride) is fenced by the membrane prefix"); + // The type that carries a served lane's offset+stride is Engine.LaneWindow, in + // internal.ffm. It is used INSIDE RowStore/Mask (which read the stride from the served + // descriptor, never compute it) and by the sanctioned internal.ffm consumers (bench, + // valhalla-lab). Prove the prefix fence structurally covers it, so L2 is closed by the + // gate above rather than by a promise: the class must exist and must live under a + // FORBIDDEN prefix — then any public signature carrying it is already a LEAK above. + Class laneWindow = null; + try { + laneWindow = Class.forName("com.adaworldapi.lancegraph.internal.ffm.Engine$LaneWindow"); + } catch (ClassNotFoundException e) { + // handled below + } + c.that("Engine.LaneWindow (the offset+stride carrier) exists", laneWindow != null); + boolean underFence = false; + if (laneWindow != null) { + for (String forbidden : FORBIDDEN) { + if (laneWindow.getName().startsWith(forbidden)) { + underFence = true; + } + } + } + c.that("Engine.LaneWindow lives under a FORBIDDEN prefix, so the prefix fence covers it", + underFence); + c.section("the escape hatch is named, not incidental"); // Raw native access must require deliberately reaching into an internal package. It must // never be something ordinary composition hands you. From 467d075ec59ebe65660c6bd2d1409a342a6244ea Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 5 Sep 2026 08:04:22 +0000 Subject: [PATCH 2/2] WideFieldMask: final class + private ctor (L1 by shape); ApiSurfaceTest sees through erasure (L2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Codex + CodeRabbit P2 on #75, both valid, both root causes: 1. Demoting ofFacets alone did not fence L1. WideFieldMask was a public record, so its canonical ctor `new WideFieldMask(1L << slot)` was a public bits-in path, and ofMatchBits(int) another. Now: final class, private ctor, ofFacets AND ofMatchBits package-private, value-based equals/ hashCode, no public value(). Still JEP 401 value-class-eligible (a value class may keep a private ctor + public factories). The ApiSurfaceTest pin is on the SHAPE, not a name: no public ctor, not a record, every public factory zero-arg — any future bits-in factory, whatever its name, fails. Only EMPTY and allFacets() remain public ("let the class decide"; RowStore.hop takes the classid, native narrows by edge_participation). FacetMatchView's {@link} to ofMatchBits -> {@code}. 2. The L2 pin was erasure-blind: leaksIn used getReturnType(), so `List` erased to `List` and passed. checkType() now walks generic signatures recursively (ParameterizedType, GenericArrayType, WildcardType, TypeVariable bounds) for returns, params and fields, with can-it-fire (`List` — a FORBIDDEN prefix, plain JDK, no preview — flagged; the erased-only check misses it, so the walk is load-bearing; nested + array generics flagged) and can-it-stay-silent (`List` not flagged). Standalone (JDK 21): shape proof 5/5, walker proof 4/4. Full Java suite in CI (JDK 27 + preview FFM). Blast radius: no caller used the record ctor, value(), or ofMatchBits outside the in-package tests; consumers use only the type and allFacets(). LATEST_STATE amended in this commit. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_016WkNBjHc2e3zuyz9i8qJEv --- .claude/board/LATEST_STATE.md | 14 ++ .../lancegraph/FacetMatchView.java | 2 +- .../adaworldapi/lancegraph/WideFieldMask.java | 54 ++++++- .../lancegraph/ApiSurfaceTest.java | 146 +++++++++++++++--- 4 files changed, 187 insertions(+), 29 deletions(-) diff --git a/.claude/board/LATEST_STATE.md b/.claude/board/LATEST_STATE.md index 8b4db33..8b615a2 100644 --- a/.claude/board/LATEST_STATE.md +++ b/.claude/board/LATEST_STATE.md @@ -34,6 +34,20 @@ hand-carved in the lab (membrane-tiers.md Tier 3) — a future wave, not a fence `WideFieldMask.java` compiled standalone and the L1 reflection pin proven standalone; CI runs the rest. +**Same-PR correction (codex + coderabbit P2, both valid).** (1) Demoting one +factory was not a fence: `WideFieldMask` was a public *record*, so its canonical +ctor `new WideFieldMask(1L << slot)` was a public bits-in path, and +`ofMatchBits(int)` another. Now a `final class` with a private ctor; `ofFacets` +AND `ofMatchBits` package-private; value-based `equals`/`hashCode`; no public +`value()`. Still JEP 401 value-class-eligible. The pin is on the SHAPE, not a +name: no public ctor, not a record, every public factory zero-arg. (2) The L2 +pin was erasure-blind: `leaksIn` used `getReturnType()`, so `List` would erase to `List`. `checkType()` now walks generic signatures +(ParameterizedType / GenericArrayType / WildcardType / TypeVariable), with +can-it-fire (`List` flagged; erased-only misses it — the walk is +load-bearing; nested + array generics flagged) and can-it-stay-silent +(`List` not flagged). Standalone: shape proof 5/5, walker proof 4/4. + ## 2026-09-04 — lgj_hop: the conjunction is one truth-table pass, and the lane is read as a lane (5×) **Branch `claude/pr-294-ragged-path-validation-170zcy`**, lgj-abi only — no diff --git a/java/src/main/java/com/adaworldapi/lancegraph/FacetMatchView.java b/java/src/main/java/com/adaworldapi/lancegraph/FacetMatchView.java index 1e32807..47743cc 100644 --- a/java/src/main/java/com/adaworldapi/lancegraph/FacetMatchView.java +++ b/java/src/main/java/com/adaworldapi/lancegraph/FacetMatchView.java @@ -56,7 +56,7 @@ public long rowCount() { * *

Low-level inspection / diagnostics. High-level query or traversal * implementations MUST NOT use this as their execution engine — see the root CLAUDE.md - * mask-native policy. {@link WideFieldMask#ofMatchBits(int)} is the typed bridge for a caller + * mask-native policy. {@code WideFieldMask.ofMatchBits(int)} (package-private bridge) is the typed bridge for a caller * that does need this bitset as a {@link WideFieldMask}. * * @throws IndexOutOfBoundsException if {@code row} is not in {@code [0, rowCount())} diff --git a/java/src/main/java/com/adaworldapi/lancegraph/WideFieldMask.java b/java/src/main/java/com/adaworldapi/lancegraph/WideFieldMask.java index 1bf88ee..535df45 100644 --- a/java/src/main/java/com/adaworldapi/lancegraph/WideFieldMask.java +++ b/java/src/main/java/com/adaworldapi/lancegraph/WideFieldMask.java @@ -24,17 +24,40 @@ * facet-adjacent vocabularies ({@link FacetId}, the raw match bitset, this type) with no declared * relationship between any of them. * + *

Why this is a final class and not a record (T2/T3 membrane, ledger L1)

+ * + *

A Java record's canonical constructor is unavoidably public. For this type that constructor + * takes the raw bits — {@code new WideFieldMask(1L << slot)} would be a public path from a facet + * SLOT position to a participation mask, and a slot index is a byte position, which never crosses + * the consumer wall (lance-graph {@code .claude/knowledge/membrane-tiers.md}). So the constructor + * is private and the ONLY public ways to obtain a mask are {@link #EMPTY} and {@link #allFacets()} + * — "let the class decide": a consumer names the edge CLASS ({@link RowStore#hop(int, Mask)} + * takes a classid) and the native side narrows participation to that class's + * {@code ClassView}-resolved facets. The bit-level factories ({@code ofFacets}, + * {@code ofMatchBits}) are package-private bridges for the inspection surface and the bit-layout + * tests. {@code ApiSurfaceTest} pins the shape: no public constructor, and every public factory + * takes zero arguments. + * *

Valhalla A/B candidate

* *

Same rules as {@link FacetId} and {@link MaskId}: final, immutable, identity-free, so the - * same source compiles as a {@code value record} under JEP 401. Two masks are "the same mask" - * when their bits are equal, never because they are the same object — nothing here relies on + * same source compiles as a {@code value class} under JEP 401 (a value class may keep a private + * constructor and public factories — the fence above costs nothing under flattening). Two masks + * are "the same mask" when their bits are equal, never because they are the same object — + * {@link #equals(Object)} and {@link #hashCode()} are value-based, and nothing here relies on * reference equality, so flattening changes no observable behaviour. */ -public record WideFieldMask(long value) { +public final class WideFieldMask { private static final int FACET_COUNT = 32; + /** The low 32 bits are this store's facets; the record-era {@code value} component, now private. */ + private final long value; + + private WideFieldMask(long value) { + this.value = value; + } + /** No facet participates. */ public static final WideFieldMask EMPTY = new WideFieldMask(0L); @@ -81,11 +104,17 @@ static WideFieldMask ofFacets(int... positions) { * The bridge from {@link FacetMatchView#matchesOf(long)}'s raw per-row bitset: bit {@code f} * of {@code matchBits} becomes facet position {@code f} of this mask. * + *

Package-private by the T2/T3 membrane (ledger L1): a raw facet bitset is + * a set of slot positions, and {@code matchesOf} is an inspection/diagnostics read — feeding + * its bits back into {@link RowStore#hop} would be Java deciding membership from per-row + * reads, the exact execution state the mask-native policy forbids. The bridge stays for the + * in-package tests that pin the bit layout; it is not consumer vocabulary. + * *

Zero-extended, never sign-extended — a negative {@code int} still becomes a mask whose * only meaningfully-populated positions are its low 32 bits, matching a {@link RowStore}'s * own facet count exactly. */ - public static WideFieldMask ofMatchBits(int matchBits) { + static WideFieldMask ofMatchBits(int matchBits) { return new WideFieldMask(Integer.toUnsignedLong(matchBits)); } @@ -111,14 +140,25 @@ public int count() { * *

Package-private: {@link RowStore} is the only caller that needs the raw bits directly; * every other consumer works through {@link #has}, {@link #count}, and the factories above. - * (The record's own canonical accessor, {@link #value()}, is unavoidably public — a Java - * record's canonical component accessor cannot be declared non-public — but this method is - * the one the facade's internals actually call, named the way the D-LGJ-W8 spec names it.) + * There is deliberately NO public accessor for the raw bits — the record-era public + * {@code value()} went with the record (see the class javadoc): reading the bits is a slot + * read, and slots do not cross the consumer wall in either direction. */ long bits() { return value; } + /** Value-based: two masks are equal iff their bits are equal (identity is irrelevant). */ + @Override + public boolean equals(Object o) { + return o instanceof WideFieldMask other && other.value == value; + } + + @Override + public int hashCode() { + return Long.hashCode(value); + } + @Override public String toString() { return "WideFieldMask[" + count() + " facets, 0x" + Long.toHexString(value) + "]"; diff --git a/java/src/test/java/com/adaworldapi/lancegraph/ApiSurfaceTest.java b/java/src/test/java/com/adaworldapi/lancegraph/ApiSurfaceTest.java index 7e2d12b..9507d40 100644 --- a/java/src/test/java/com/adaworldapi/lancegraph/ApiSurfaceTest.java +++ b/java/src/test/java/com/adaworldapi/lancegraph/ApiSurfaceTest.java @@ -4,7 +4,12 @@ import java.lang.reflect.Executable; import java.lang.reflect.Field; import java.lang.reflect.Method; +import java.lang.reflect.GenericArrayType; import java.lang.reflect.Modifier; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.lang.reflect.TypeVariable; +import java.lang.reflect.WildcardType; import java.nio.file.Files; import java.nio.file.Path; import java.util.ArrayList; @@ -113,23 +118,72 @@ public static void run(Checks c) { } } - c.section("ledger L1: no public construction of a field mask from slot positions"); - // WideFieldMask.ofFacets(int...) built a mask from raw facet SLOT indices — a byte - // position crossing the wall. It is package-private now; a consumer names the edge - // CLASS (RowStore.hop takes a classid) and native narrows to that class's - // ClassView-resolved participation. Reflection cannot tell `int classid` from - // `int facet`, so this pin is by NAME: re-publicising ofFacets fails here. - boolean slotFactoryPublic = false; - for (Method m : WideFieldMask.class.getMethods()) { - if (m.getName().equals("ofFacets") && Modifier.isPublic(m.getModifiers())) { - slotFactoryPublic = true; + c.section("ledger L1: no public construction of a field mask from raw bits, by SHAPE"); + // A facet slot index is a byte position and never crosses the wall. Fencing ONE factory + // is not a fence (codex + coderabbit P2 on #75): a public record's canonical ctor took + // the raw bits, and ofMatchBits(int) took a raw slot bitset. So WideFieldMask is a final + // class with a private ctor, and the pin is on the SHAPE, not a name: no public + // constructor, not a record, and every public factory takes ZERO arguments — the only + // public values are EMPTY and allFacets() ("let the class decide"; RowStore.hop takes + // the classid and native narrows by edge_participation). Any future public + // bits-in factory, whatever its name, fails here. + Class wfm = WideFieldMask.class; + c.that("WideFieldMask has no public constructor (a record's canonical ctor would be one)", + wfm.getConstructors().length == 0); + c.that("WideFieldMask is not a record (its public canonical ctor would take the raw bits)", + !wfm.isRecord()); + List bitsInFactories = new ArrayList<>(); + boolean allFacetsPublic = false; + for (Method m : wfm.getMethods()) { + if (!Modifier.isPublic(m.getModifiers()) || !Modifier.isStatic(m.getModifiers())) { + continue; + } + if (m.getReturnType() == wfm && m.getParameterCount() != 0) { + bitsInFactories.add(m.getName() + "/" + m.getParameterCount()); + } + if (m.getName().equals("allFacets")) { + allFacetsPublic = true; + } + if (m.getName().equals("ofFacets") || m.getName().equals("ofMatchBits")) { + bitsInFactories.add(m.getName() + " (must be package-private)"); } } - c.that("WideFieldMask.ofFacets(int...) is not public (slot indices never cross; name the class)", - !slotFactoryPublic); + c.that("every public WideFieldMask factory takes zero arguments (no bits-in path): " + + bitsInFactories, bitsInFactories.isEmpty()); c.that("WideFieldMask.allFacets() remains the consumer's participation vocabulary", - java.util.Arrays.stream(WideFieldMask.class.getMethods()) - .anyMatch(m -> m.getName().equals("allFacets") && Modifier.isPublic(m.getModifiers()))); + allFacetsPublic); + c.that("WideFieldMask exposes no public raw-bits accessor (value())", + java.util.Arrays.stream(wfm.getMethods()).noneMatch(m -> m.getName().equals("value"))); + + c.section("the fence sees THROUGH erasure (can-it-fire / can-it-stay-silent)"); + // The prefix pin below is only as good as the scan that applies it. codex P2 on #75: a + // getReturnType() check sees `List` as `List`. checkType() walks + // the generic signature; prove it fires on a hidden forbidden type (and nested/array + // generics), and stays silent on an equally generic clean one — a guard that fires on + // everything is as uninformative as one that never fires. + try { + List hit = new ArrayList<>(); + checkType(hit, ApiSurfaceTest.class, "fixture return", + ApiSurfaceTest.class.getDeclaredMethod("genericLeakFixture").getGenericReturnType()); + c.that("can-it-fire: List is flagged through erasure: " + hit, + !hit.isEmpty()); + List erasedOnly = new ArrayList<>(); + check(erasedOnly, ApiSurfaceTest.class, "fixture return", + ApiSurfaceTest.class.getDeclaredMethod("genericLeakFixture").getReturnType()); + c.that("the erased-only check would have MISSED it (so the generic walk is load-bearing)", + erasedOnly.isEmpty()); + List nested = new ArrayList<>(); + checkType(nested, ApiSurfaceTest.class, "fixture return", + ApiSurfaceTest.class.getDeclaredMethod("nestedGenericLeakFixture").getGenericReturnType()); + c.that("can-it-fire: Map[]> is flagged (nested + array generics)", + !nested.isEmpty()); + List quiet = new ArrayList<>(); + checkType(quiet, ApiSurfaceTest.class, "fixture return", + ApiSurfaceTest.class.getDeclaredMethod("genericCleanFixture").getGenericReturnType()); + c.that("can-it-stay-silent: List is NOT flagged", quiet.isEmpty()); + } catch (NoSuchMethodException e) { + c.that("generic fixtures are present: " + e, false); + } c.section("ledger L2: lane geometry (offset/stride) is fenced by the membrane prefix"); // The type that carries a served lane's offset+stride is Engine.LaneWindow, in @@ -137,7 +191,8 @@ public static void run(Checks c) { // descriptor, never compute it) and by the sanctioned internal.ffm consumers (bench, // valhalla-lab). Prove the prefix fence structurally covers it, so L2 is closed by the // gate above rather than by a promise: the class must exist and must live under a - // FORBIDDEN prefix — then any public signature carrying it is already a LEAK above. + // FORBIDDEN prefix — then any public signature carrying it, generic or not (the walk + // above), is already a LEAK. Class laneWindow = null; try { laneWindow = Class.forName("com.adaworldapi.lancegraph.internal.ffm.Engine$LaneWindow"); @@ -173,13 +228,17 @@ public static void run(Checks c) { private static List leaksIn(Class type) { List leaks = new ArrayList<>(); + // GENERIC signatures, not erased ones: `List` erases to `List` and + // would sail past a getReturnType() check. checkType() walks ParameterizedType / + // GenericArrayType / WildcardType / TypeVariable recursively so a forbidden type is a + // LEAK wherever it appears in the compiled signature (codex P2 on #75). for (Method m : type.getMethods()) { if (!isPublicApi(m) || m.getDeclaringClass() == Object.class) { continue; } - check(leaks, type, "method " + m.getName() + " return", m.getReturnType()); - for (Class p : m.getParameterTypes()) { - check(leaks, type, "method " + m.getName() + " parameter", p); + checkType(leaks, type, "method " + m.getName() + " return", m.getGenericReturnType()); + for (Type p : m.getGenericParameterTypes()) { + checkType(leaks, type, "method " + m.getName() + " parameter", p); } } @@ -187,8 +246,8 @@ private static List leaksIn(Class type) { if (!isPublicApi(ctor)) { continue; } - for (Class p : ctor.getParameterTypes()) { - check(leaks, type, "constructor parameter", p); + for (Type p : ctor.getGenericParameterTypes()) { + checkType(leaks, type, "constructor parameter", p); } } @@ -196,12 +255,57 @@ private static List leaksIn(Class type) { if (!Modifier.isPublic(f.getModifiers())) { continue; } - check(leaks, type, "field " + f.getName(), f.getType()); + checkType(leaks, type, "field " + f.getName(), f.getGenericType()); } return leaks; } + /** + * Walk a reflective {@link Type} and run {@link #check} on every {@link Class} it reaches: + * the raw class and each actual type argument of a {@link ParameterizedType}, the component + * of a {@link GenericArrayType}, the bounds of a {@link WildcardType} and {@link + * TypeVariable}. Erasure hides nothing from this. + */ + static void checkType(List leaks, Class owner, String where, Type t) { + if (t instanceof Class c) { + check(leaks, owner, where, c); + } else if (t instanceof ParameterizedType pt) { + checkType(leaks, owner, where, pt.getRawType()); + for (Type arg : pt.getActualTypeArguments()) { + checkType(leaks, owner, where + " ", arg); + } + } else if (t instanceof GenericArrayType gat) { + checkType(leaks, owner, where, gat.getGenericComponentType()); + } else if (t instanceof WildcardType wt) { + for (Type b : wt.getUpperBounds()) { + checkType(leaks, owner, where + " ", b); + } + for (Type b : wt.getLowerBounds()) { + checkType(leaks, owner, where + " ", b); + } + } else if (t instanceof TypeVariable tv) { + for (Type b : tv.getBounds()) { + checkType(leaks, owner, where + " ", b); + } + } + } + + // ── can-it-fire fixtures for checkType (private: never part of the scanned API) ────────── + // A forbidden type hidden behind erasure. java.lang.invoke.MethodHandle is under a FORBIDDEN + // prefix and is plain JDK (no preview), so this compiles on every toolchain the suite runs on. + private static java.util.List genericLeakFixture() { + return null; + } + + private static java.util.Map[]> nestedGenericLeakFixture() { + return null; + } + + private static java.util.List genericCleanFixture() { + return null; + } + private static boolean isPublicApi(Executable e) { return Modifier.isPublic(e.getModifiers()); }