<?xml version="1.0" encoding="UTF-8"?>
<spec xmlns="https://vibevm.org/spec/1">
  <title>JVM-lineage visibility &amp; transitivity — research notes for PROP-050</title>
  <quote p="1">Research worker report, 2026-08-23. Consumer: [PROP-050 §5](../../common/PROP-050-dependency-visibility.xml#prior-art).
Scope: JPMS, Java sealed classes, Kotlin `internal` / explicit API, Swift access levels / SE-0386 / SE-0409 / `@_spi`.</quote>
  <section title="0. One-line orientation">
    <p p="2">Four 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]**.</p>
  </section>
  <section title="1. JPMS">
    <section title="Primitives">
      <table p="3">
        <tr>
          <td>Primitive</td>
          <td>Effect</td>
        </tr>
        <tr>
          <td>`requires M`</td>
          <td>This module *reads* M. Not propagated to my consumers.</td>
        </tr>
        <tr>
          <td>`requires transitive M`</td>
          <td>I read M **and** every module that reads me also reads M ("implied readability"). The re-export mark.</td>
        </tr>
        <tr>
          <td>`requires static M`</td>
          <td>Compile-time only; optional at runtime.</td>
        </tr>
        <tr>
          <td>`exports pkg`</td>
          <td>pkg's `public` types readable by any module that reads me.</td>
        </tr>
        <tr>
          <td>`exports pkg to A, B`</td>
          <td>Qualified export — only named modules.</td>
        </tr>
        <tr>
          <td>`opens pkg [to …]`</td>
          <td>Deep reflective access (incl. private members) at runtime; no compile-time access.</td>
        </tr>
        <tr>
          <td>`open module`</td>
          <td>Every package opened. Migration convenience.</td>
        </tr>
        <tr>
          <td>`uses` / `provides`</td>
          <td>Service loader wiring (an *indirect* readability path — worth noting: services deliberately bypass the naming graph).</td>
        </tr>
      </table>
    </section>
    <section title="Defaults &amp; rationale">
      <p p="4">Default 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).</p>
    </section>
    <section title="Transitivity — exact answer">
      <p p="5">**Yes, 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](https://docs.oracle.com/javase/specs/jls/se11/html/jls-7.html#jls-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.</p>
      <p p="6">Note the asymmetry: `exports` is **not** transitive; readability is. Access = readability ∧ export. Two independent gates.</p>
    </section>
    <section title="Exclusion / pruning">
      <p p="7">JPMS 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.</p>
    </section>
    <section title="Enforcement point">
      <p p="8">Compile 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.</p>
    </section>
    <section title="Community verdicts">
      <list ordered="false" p="9">
        <item>**`requires 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?"](https://coderanch.com/t/746105/java/requires-transitive-JPMS-panacea-crutch), [nipafx](https://nipafx.dev/java-modules-implied-readability/), [dev.java](https://dev.java/learn/modules/implied-readability/)).</item>
        <item>**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](https://dev.java/learn/modules/qualified-exports-opens/)). Also `exports pkg` and `exports pkg to …` are mutually exclusive for the same package.</item>
        <item>**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](https://blog.joda.org/2018/03/jpms-negative-benefits.html). Adoption stalled ~a decade. The JDK-internal flip was staged over years: JDK 9 relaxed → [JEP 396](https://openjdk.org/jeps/396) default-strong with `--illegal-access` escape → [JEP 403](https://openjdk.org/jeps/403) (17) escape removed.</item>
      </list>
      <p p="10">**COPY:** 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.</p>
    </section>
  </section>
  <section title="2. Sealed classes — the allowlist lesson">
    <table p="11">
      <tr>
        <td>Primitive</td>
        <td>Effect</td>
      </tr>
      <tr>
        <td>`sealed … permits A, B`</td>
        <td>Only the named types may directly extend.</td>
      </tr>
      <tr>
        <td>`permits` omitted</td>
        <td>Inferred from same-compilation-unit subtypes.</td>
      </tr>
      <tr>
        <td>`final` / `sealed` / `non-sealed` on each permitted subtype</td>
        <td>Mandatory: close, re-close narrower, or **reopen**.</td>
      </tr>
    </table>
    <p p="12">Constraint 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](https://openjdk.org/jeps/409); [Oracle](https://docs.oracle.com/en/java/javase/21/language/sealed-classes-and-interfaces.html)). 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.</p>
    <p p="13">**Lesson 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.</p>
  </section>
  <section title="3. Kotlin">
    <table p="14">
      <tr>
        <td>Primitive</td>
        <td>Effect</td>
        <td>Default</td>
      </tr>
      <tr>
        <td>`public`</td>
        <td>everywhere</td>
        <td>**yes**</td>
      </tr>
      <tr>
        <td>`internal`</td>
        <td>visible within the **module**</td>
        <td>—</td>
      </tr>
      <tr>
        <td>`protected` / `private`</td>
        <td>class+subclasses / class-or-file</td>
        <td>—</td>
      </tr>
    </table>
    <p p="15">**"Module" = "a set of Kotlin files compiled together"**: an IntelliJ module, a Maven project, a Gradle source set, an Ant task invocation ([docs](https://kotlinlang.org/docs/visibility-modifiers.html)). 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](https://kotlinlang.org/docs/java-to-kotlin-interop.html), [4comprehension](https://4comprehension.com/kotlins-internal-visibility-modifier-and-java-interoperability/)). Enforcement: compile-time only, advisory at the bytecode layer.</p>
    <p p="16">**Explicit API mode** (`explicitApi()` / `explicitApiWarning()` / `-Xexplicit-api={strict|warning}`) forces explicit visibility modifiers and explicit return types on the public surface, production sources only ([docs](https://kotlinlang.org/docs/whatsnew14.html)). It is the **opt-in, per-project, warning-then-error ratchet** JPMS never offered.</p>
    <p p="17">**COPY:** 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.</p>
  </section>
  <section title="4. Swift — the closest match to our design">
    <table p="18">
      <tr>
        <td>Primitive</td>
        <td>Effect</td>
      </tr>
      <tr>
        <td>`private` / `fileprivate` / `internal` (default) / `package` / `public` / `open`</td>
        <td>linear lattice; `internal` = module</td>
      </tr>
      <tr>
        <td>`package` ([SE-0386](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0386-package-access-modifier.md))</td>
        <td>visible to modules built with the **same `-package-name` string**; SwiftPM supplies it; `packageAccess: false` opts a target out</td>
      </tr>
      <tr>
        <td>`@_spi(Name)` on a decl</td>
        <td>usable only by clients whose import says `@_spi(Name) import M` — **bilateral handshake**; shipped via a separate `.private.swiftinterface`</td>
      </tr>
      <tr>
        <td>`internal import M` / `package import M` / `public import M` ([SE-0409](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0409-access-level-on-imports.md))</td>
        <td>**per-edge**, consumer-declared: how far this dependency propagates to *my* clients</td>
      </tr>
      <tr>
        <td>`@_implementationOnly import` (legacy)</td>
        <td>predecessor; hid transitive imports only, didn't block direct access</td>
      </tr>
    </table>
    <p p="19">**The 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.</p>
    <p p="20">Enforcement: 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.</p>
    <p p="21">**SE-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.</p>
    <p p="22">**COPY:** (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.</p>
  </section>
</spec>
