JVM-lineage visibility & transitivity — research notes for PROP-050
01Research worker report, 2026-08-23. Consumer: PROP-050 §5. Scope: JPMS, Java sealed classes, Kotlin
internal/ explicit API, Swift access levels / SE-0386 / SE-0409 /@_spi.
0. One-line orientation
02Four systems, four different answers to "who sees what, and how far does it travel". Only two of them (Swift package/@_spi, JPMS qualified exports) actually implement friendship; both are provider-declared. Our inversion (consumer-declared, budget-motivated) flips several lessons — flagged inline as [INVERSION].
1. JPMS
Primitives
| Primitive | Effect |
|---|---|
requires M |
This module reads M. Not propagated to my consumers. |
requires transitive M |
I read M and every module that reads me also reads M ("implied readability"). The re-export mark. |
requires static M |
Compile-time only; optional at runtime. |
exports pkg |
pkg's public types readable by any module that reads me. |
exports pkg to A, B |
Qualified export — only named modules. |
opens pkg [to …] |
Deep reflective access (incl. private members) at runtime; no compile-time access. |
open module |
Every package opened. Migration convenience. |
uses / provides |
Service loader wiring (an indirect readability path — worth noting: services deliberately bypass the naming graph). |
Defaults & rationale
04Default is nothing exported, nothing opened, nothing read — strong encapsulation. Rationale: reliable configuration + preventing accidental dependence on internals (the sun.misc.Unsafe problem the whole JSR existed to solve).
Transitivity — exact answer
05Yes, it is a full recursive closure over multiple hops. JLS §7.7.1: "For each enumerated module A that requires B: A reads B. If B requires transitive C, then A reads C as well as B. This augmentation is recursive: since A reads C, if C requires transitive D, then A reads D as well as C and B." (JLS se11 §7.7.1). So in the A→B→C→D example A does read D. Stated rationale in the spec: to permit arbitrary amounts of refactoring — a module author can split their content into sub-modules without breaking consumers.
06Note the asymmetry: exports is not transitive; readability is. Access = readability ∧ export. Two independent gates.
Exclusion / pruning
07JPMS has no pruning primitive — no unfriend, no exclude. Once implied readability is granted upstream, a downstream consumer cannot refuse it. The only escapes are command-line overrides that widen, never narrow: --add-reads, --add-exports, --add-opens, --add-modules. This is a real gap and unfriend/exclude is the right instinct — but note why JPMS could skip it: readability costs nothing at runtime. [INVERSION] For us readability costs tokens, so pruning is mandatory, not optional.
Enforcement point
08Compile time (javac — "package is not visible" / "module not found"), plus module-resolution time at JVM startup (missing module, split package, cycle → hard fail before main), plus runtime IllegalAccessError / InaccessibleObjectException for reflection. Three-phase enforcement, and resolution-time is the important one for us.
Community verdicts
- 09
requires transitive= crutch, not design principle. Consensus rule: use it only when your exported signatures mention the dependency's types (aDriverreturningjava.util.logging.Loggerforcesjava.sql requires transitive java.logging). Otherwise make consumers declare it. Aggregator modules (java.se) are cited as the downside: consumers get a pile they never asked for (Coderanch: "panacea or crutch?", nipafx, dev.java). - Qualified exports = special-case tool, not routine. Intended for multi-module frameworks sharing internals; they hard-code consumer names into the provider, i.e. deliberate coupling. Notable detail: target modules need not exist — an unresolvable target is a warning, not an error (dev.java). Also
exports pkgandexports pkg to …are mutually exclusive for the same package. - The flag-day lesson is brutal and directly applicable. Classpath→module-path was a dual-world split: libraries had to test both because behaviour differs,
jlink's payoff was locked behind a fully explicit graph (automatic modules block it), split packages were fatal for existing artifacts, and Colebourne's verdict for library authors was literally "negative benefits" — don't modularize yet. Adoption stalled ~a decade. The JDK-internal flip was staged over years: JDK 9 relaxed → JEP 396 default-strong with--illegal-accessescape → JEP 403 (17) escape removed.
10COPY: the two-gate model (readability ≠ export), resolution-time failure, warn-don't-fail on an unknown allowlist target, and the "re-export only what appears in your own surface" rule of thumb — for us: a package marks a dep transitive only when its own boot text is unreadable without that dep's text. AVOID: the aggregator pattern (a single requires pulling the world is exactly context seepage), the absence of consumer-side pruning, and above all the flag-day. Never make the static-lane/compaction payoff require 100 % of the closure to declare access — that is jlink's chicken-and-egg trap verbatim.
2. Sealed classes — the allowlist lesson
| Primitive | Effect |
|---|---|
sealed … permits A, B |
Only the named types may directly extend. |
permits omitted |
Inferred from same-compilation-unit subtypes. |
final / sealed / non-sealed on each permitted subtype |
Mandatory: close, re-close narrower, or reopen. |
12Constraint that matters: "The sealed class and its permitted subclasses must belong to the same module, and, if declared in an unnamed module, to the same package" (JEP 409; Oracle). Enforced at compile time; the JVM re-checks PermittedSubclasses at load time. Motivation: a closed world the compiler can reason over (exhaustive switch), which is only sound if the whitelist is verifiable — hence the same-maintenance-domain rule.
13Lesson for a friends-allowlist: an allowlist is only trustworthy when the allowlist and its members share one maintenance domain, and there is an explicit, syntactically visible escape hatch (non-sealed) rather than silent widening. [INVERSION] Our friendship is consumer-declared at the root — that gives us the single maintenance domain for free (one file, one owner), which is strictly better than permits. The cost transfers to name stability: the root allowlists nodes it did not author, so package coordinates become load-bearing API. Mitigate: allowlist entries are matched against the resolved closure at install time, unmatched entries warn (JPMS qualified-export precedent), and a non-sealed-style per-node open marks a hole visibly.
3. Kotlin
| Primitive | Effect | Default |
|---|---|---|
public |
everywhere | yes |
internal |
visible within the module | — |
protected / private |
class+subclasses / class-or-file | — |
15"Module" = "a set of Kotlin files compiled together": an IntelliJ module, a Maven project, a Gradle source set, an Ant task invocation (docs). This is the friction: the visibility boundary is not a declared entity — it is whatever the build tool happened to compile together, so it shifts when the build changes, and needs special cases (the test source set can see main's internal). Second friction: the JVM has no internal, so it compiles to public + name mangling (foo$module_name); Java callers can reach it, and public members of internal classes aren't mangled at all (Kotlin/Java interop, 4comprehension). Enforcement: compile-time only, advisory at the bytecode layer.
16Explicit API mode (explicitApi() / explicitApiWarning() / -Xexplicit-api={strict|warning}) forces explicit visibility modifiers and explicit return types on the public surface, production sources only (docs). It is the opt-in, per-project, warning-then-error ratchet JPMS never offered.
17COPY: Explicit API mode wholesale — a per-root explicit_visibility = warn|strict flag that requires every edge to state access instead of defaulting silently. That is how you flip a default without a flag day. AVOID: Kotlin's module definition. Bind visibility to the declared edge / package coordinate, never to "whatever landed in the same materialised lane" — an implicit compilation-unit boundary is exactly the mistake, and in our system the lane composition is itself the variable.
4. Swift — the closest match to our design
| Primitive | Effect |
|---|---|
private / fileprivate / internal (default) / package / public / open |
linear lattice; internal = module |
package (SE-0386) |
visible to modules built with the same -package-name string; SwiftPM supplies it; packageAccess: false opts a target out |
@_spi(Name) on a decl |
usable only by clients whose import says @_spi(Name) import M — bilateral handshake; shipped via a separate .private.swiftinterface |
internal import M / package import M / public import M (SE-0409) |
per-edge, consumer-declared: how far this dependency propagates to my clients |
@_implementationOnly import (legacy) |
predecessor; hid transitive imports only, didn't block direct access |
19The exact gap package filled: a helper module inside a multi-module package had to be public to be usable by its siblings, which also exposed it to the world — no level meant "shared within my group, invisible outside". Rejected alternatives are directly instructive: submodules (bakes structure into ABI), internal(PackageName) (too verbose/general), and named groups within a package — abandoned because it "would tend to lead to large, complex manifests mingling the details of all the layers." That is a direct warning against fine-grained per-edge friendship grants at the root.
20Enforcement: compile-time, by comparing the package-name string recorded in the built interface. Transitivity: none — package is a flat, symmetric, unordered group, not a graph relation.
21SE-0409 is the single closest precedent to our per-edge access: the consumer annotates its own import edge to control leakage to its own clients — dependency-creep control, i.e. our seepage control. Critically, its default is still public in Swift 5 and 6, with the flip to internal deferred to a future language mode. Apple, with full control of compiler and build tool, still refused the flag day.
22COPY: (a) SE-0409's per-edge, consumer-declared propagation control — that is our model, validated; (b) @_spi's bilateral handshake (provider names a group, consumer opts in by name) — it makes seepage impossible to acquire accidentally and gives both sides a veto; (c) packageAccess: false as the shape of exclude; (d) the staged default flip. AVOID: string-matched implicit groups (-package-name) — invisible in source, silently coupling; and heed the rejected-alternatives note: keep friendship grants coarse (named groups / roles), not per-edge pairs, or the root manifest becomes the "large, complex manifest mingling all the layers" Swift explicitly refused to build. [INVERSION] Since ours is budget control, not secrecy, the enforcement output should be a pruning report (what was excluded, tokens saved) rather than an error — Swift/JPMS can hard-fail because a missing symbol breaks the build; a missing prompt lane merely makes the agent dumber, so the failure must be made legible instead.