VibeVM
Contents
On this page
en
Publisher
org.vibevm.core
Version
1.0.0latest
Audiences
Reading time
7 min
Rendered
Read aloud
never

JVM-lineage visibility & transitivity — research notes for PROP-050

01

Research 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

03
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

  • 09requires transitive = crutch, not design principle. Consensus rule: use it only when your exported signatures mention the dependency's types (a Driver returning java.util.logging.Logger forces java.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 pkg and exports 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-access escape → 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

11
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

14
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

18
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 Mbilateral 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: nonepackage 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.

For an agent

This page has a machine mirror. The citation carries the version rather than latest, so what an agent quotes does not move under it.

spec://org.vibevm.core/vibevm@1.0.0/research/dependency-visibility-2026-08/01-jvm-lineage

.md.xmlllms.txt