# Java units importing Java units

## For humans

**Read this if** you are splitting Java work into helpers and importers.
**Skip it if** every Java unit you write stands alone.

### Do this

**A Java unit's package is its directory.** The compiler refuses any other answer.

1. **Declare the package that matches the folder.** `code/lib/MathBox.java` declares `package lib;`.
2. **Import it by that name** from a unit in another folder: `import lib.MathBox;`.
3. **Drop the import for a unit in the same folder.** Same directory means same package.

```java
// code/caller/UseIt.java
package caller;

import lib.MathBox;

public class UseIt implements InterlockHandler<Object> {
    public Object handle(Request req, Interlock il) {
        return MathBox.twice(21);   // 42
    }
}
```

### What will bite you

There is no shared library. Each importer compiles its own copy of every helper it uses.

- **Mutable statics are not shared.** Each importer gets its own copy, so the cache never hits and
  the counter reads low. Nothing errors. Keep shared state in the store, which is genuinely shared.
- **A helper's own class cannot cross `il.call`.** The cast fails on the far side and the error names
  the same class twice. Pass maps, lists, strings and numbers, or a type from your host jar.

### Then read

- [Directory is package](#directory-is-package) for the refusal text and the root case.
- [Resolution is javac's SOURCE_PATH, not a shared classloader](#resolution-is-javacs-source_path-not-a-shared-classloader)
  for why the copies exist.
- [Rule 2: no mutable statics in an imported unit](#rule-2-no-mutable-statics-in-an-imported-unit)
  for the compile warning that catches it.

## For robots

Java units can import other Java units, the way JSX units always could. This page is the rule set that
makes that safe rather than merely possible. It is where you land if you pasted
`package lib does not exist`, `NoClassDefFoundError`, a `ClassCastException` whose two class names
print identically, or a `mutable static field` warning out of a host log.

Read [Units](units.md) first for ids, environments, and the handler contract.

Everything here was read out of
`interlock-java-sdk/src/main/java/sh/interlock/sdk/runtime/JavaRunner.java` and `Engine.java`, and is
pinned by `interlock-java-sdk/src/test/java/sh/interlock/sdk/runtime/JavaSiblingTest.java` (10 tests).
Versions: JDK 21, in-process `javax.tools` compiler, Caffeine 3.1.8 for the artifact cache.

Error text is quoted **verbatim from the source that throws it**. Rules proven by a test or a live run
are labelled `MEASURED`; rules that follow from reading the code with no test pinning them,
`INFERRED`.

Every **Verify** block below runs in your own host project. A few lines additionally name the SDK's
own test suite; those are marked **MAINTAINER-ONLY** and need an Interlock repository checkout, so
skip them and run the unmarked command above them.

- [Directory is package](#directory-is-package)
- [Same directory needs no import](#same-directory-needs-no-import)
- [Directory names must be Java identifiers](#directory-names-must-be-java-identifiers)
- [Resolution is javac's SOURCE_PATH, not a shared classloader](#resolution-is-javacs-source_path-not-a-shared-classloader)
- [Rule 1: unit-local types must not cross `il.call`](#rule-1-unit-local-types-must-not-cross-ilcall)
- [Rule 2: no mutable statics in an imported unit](#rule-2-no-mutable-statics-in-an-imported-unit)
- [Invalidation cascades](#invalidation-cascades)
- [Handler discovery is by attribution](#handler-discovery-is-by-attribution)
- [Generated units are never importable](#generated-units-are-never-importable)
- [Hosts opt in via `CodeSource.list`](#hosts-opt-in-via-codesourcelist)
- [Standalone build checks see no siblings](#standalone-build-checks-see-no-siblings)
- [Native image: no compiler](#native-image-no-compiler)

## Directory is package

**Rule.** A Java unit's package **is** its directory under `code/`.
`code/notes/NoteApi.java` declares `package notes;` and is imported as
`import notes.NoteApi;`. The id/binary-name mapping is mechanical: `notes/NoteApi` maps to
`notes.NoteApi`, and `notes/gen/Thing` to `notes.gen.Thing`.

The check runs **only when the unit declares a package**, so every package-less unit written before
this existed keeps working untouched. A package-less unit can neither import nor be imported: it sits
in the default package, which javac cannot name from anywhere else.

**Why it exists.** A unit's package is its directory the same way its id is its URL. If a unit could
declare any package it liked, an `import` would resolve a name that its id contradicts, and there
would be two answers to "where does this class live".

**Failure it prevents.** A unit whose declared package and directory disagree, refused at compile with
the fix in the sentence:

```
unit 'notes/Wrong' declares package 'elsewhere' but its directory requires package 'notes' — a unit's package is its directory, the way its id is its URL
```

At the root of the tree the same refusal reads:

```
unit 'Wrong' declares package 'elsewhere' but its directory is the root, which is the default package — a unit's package is its directory, the way its id is its URL
```

**Example.**

```java
// code/lib/MathBox.java
package lib;

public class MathBox {
    public static int twice(int n) { return n * 2; }
}
```

```java
// code/caller/UseIt.java
package caller;

import lib.MathBox;
import sh.interlock.sdk.Interlock;
import sh.interlock.sdk.InterlockHandler;
import sh.interlock.sdk.Request;

public class UseIt implements InterlockHandler<Object> {
    public Object handle(Request req, Interlock il) {
        return MathBox.twice(21);   // 42
    }
}
```

**Verify.** In your own tree, the gate compiles with `-sourcepath` pointing at the unit root, which is
the same resolution the runtime uses, and then the running host proves it end to end:

```bash
./scripts/check-units-compile.sh          # javac resolves lib.MathBox out of code/lib/MathBox.java
interlock run notes/NoteRenderer --env dev   # and so does the host, at run time
```

A wrong `package` line fails the first command with `package lib does not exist` or with the
mismatch text quoted above, in about two seconds.

```bash
# MAINTAINER-ONLY (Interlock repository checkout, not your host):
./gradlew :interlock-java-sdk:test --tests 'sh.interlock.sdk.runtime.JavaSiblingTest'
```

`MEASURED` (`aUnitImportsAUnitAcrossDirectories`, `aPackageMustMatchItsDirectory`,
`packagelessLegacyUnitsStillRun`).

## Same directory needs no import

**Rule.** Two units in the same directory are in the same package and reference each other with **no
import statement at all**. This is ordinary javac same-package resolution, not a special case.

**Why it exists.** It gives Java the "directory as module" feel the JSX side always had. A folder is
the unit of cohesion.

**Failure it prevents.** Writing `import notes.NoteApi;` from `code/notes/NoteRenderer.java`,
which is an import of a class in your own package. javac accepts it, so this one costs you nothing
but noise. The failure that *does* bite is the opposite reading: assuming a same-directory reference
needs an import, not finding one, and concluding the file must be dead code.

**Example.**

```java
// code/notes/NoteRenderer.java — no import line
package notes;

import sh.interlock.sdk.Interlock;
import sh.interlock.sdk.InterlockHandler;
import sh.interlock.sdk.Request;

public class NoteRenderer implements InterlockHandler<Object> {
    public Object handle(Request req, Interlock il) {
        return NoteApi.render(req.integer("size", 8));
    }
}
```

**Verify.** In your own tree, delete the same-package import line and confirm both the gate and the
running host are unmoved by it. That is the whole rule: the reference resolves either way, so the
import was never what made it work.

```bash
grep -rn "^import notes\." code/notes/          # expect nothing; a same-package import is noise
./scripts/check-units-compile.sh
interlock run notes/NoteRenderer --env dev
```

```bash
# MAINTAINER-ONLY (Interlock repository checkout, not your host):
./gradlew :interlock-java-sdk:test --tests 'sh.interlock.sdk.runtime.JavaSiblingTest.sameDirectoryNeedsNoImport'
```

`MEASURED`.

## Directory names must be Java identifiers

**Rule.** A directory whose name is not a valid Java identifier cannot be a package, so units inside it
cannot be imported and cannot themselves declare a package. `JavaRunner.packageForDir` returns the
empty string (the default package) if **any** path segment fails
`[A-Za-z_$][A-Za-z0-9_$]*`. The same test is applied to the file's base name in
`SiblingIndex.resolvePackage`, which skips ids that could not be a Java type name.

So `code/my-lib/Thing.java` is a unit and runs fine, but it is invisible to javac.

**Why it exists.** Ids are URL-shaped and permit kebab-case; Java packages are not. Rather than
mangling a name behind your back (and giving one unit two identities), the tree simply does not offer
a kebab directory as a package.

**Failure it prevents.** The confusing half of this is the error you get if you *do* declare a package
in a kebab directory. The directory resolves to the root, so the message names the root and not the
kebab:

```
unit 'my-lib/Thing' declares package 'myLib' but its directory is the root, which is the default package — a unit's package is its directory, the way its id is its URL
```

And if you declare nothing, the import from elsewhere fails with the ordinary javac diagnostic, which
never mentions the directory name:

```
compile failed for 'caller/UseIt':
  ERROR line 3: package my_lib does not exist
```

**Example.**

```
code/my-lib/Thing.java   ❌ runs, but cannot be imported
code/myLib/Thing.java    ✅ package myLib;
code/lib/Thing.java      ✅ package lib;
```

**Verify.** Inventory your own tree first: any directory under the unit root whose name is not a Java
identifier is a directory nothing can import out of.

```bash
find code/ -mindepth 1 -type d | grep -Ev '/[A-Za-z_$][A-Za-z0-9_$]*$'
# expected output: nothing. Every line is a directory invisible to javac.
```

To see the rule fire rather than infer it, move one library unit into a kebab directory, add an
import of it from another unit, and run the gate. Expect
`package my_lib does not exist`, then move it back.

```bash
./scripts/check-units-compile.sh
```

`INFERRED`. The identifier check is explicit in `packageForDir` and `resolvePackage`, but no test in
`JavaSiblingTest` uses a kebab directory. If this bites you, that is the test to add.

## Resolution is javac's SOURCE_PATH, not a shared classloader

**Rule.** Sibling resolution is javac's own dependency discovery. `MemoryFileManager` claims
`StandardLocation.SOURCE_PATH` and serves sibling unit **sources** when javac lists a package. javac
compiles exactly what the unit actually references (`-implicit:class`), and the resulting classes land
in **this importer's own classloader**.

The consequence is the whole design: **each importer gets its own compiled copy of its dependencies.**
There is no shared commons loader and no shared class identity.

**Why it exists.** Sharing one dependency loader couples unrelated unit lifetimes: closing that
loader during cache eviction can break classes that are still live. Per-importer duplication makes
that failure class unrepresentable.

**Failure it prevents.**

```
java.lang.NoClassDefFoundError: lib/MathBox
```

after a shared helper's loader was evicted or closed while live classes still referenced it. Under the
per-importer model there is no shared loader to close: an importer's classes live and die with that
importer's cache entry.

The price is paid in two rules, both made **loud** rather than left to be remembered. They are the
next two sections.

**Example.** Nothing to write. This is the mechanism, and the log line proves it fired:

```
java.compile id:notes/NoteRenderer siblings:[notes/NoteApi]
```

emitted at INFO by `JavaRunner.compile` whenever a unit compiled with at least one sibling.

**Verify.** Run the host with INFO logging and hit a unit that imports a sibling:

```bash
curl -s localhost:8099/app/notes/note-renderer > /dev/null
grep -n "java.compile id:" your-app.log
```

`MEASURED` (live run on a real host application (Team Lakes), whose own `FloorCanvas` unit moved out
of the host jar into `code/world/` and was served over HTTP from the synced tree with exactly
this line, naming that host's ids).

## Rule 1: unit-local types must not cross `il.call`

**Rule.** Values crossing an `il.call` boundary must be **host types** or plain data (Maps, Lists,
Strings, numbers). Never a class declared in a unit.

The engine warns, once per class name, when a unit-local class comes back out of `il.call`:

```
il.call id:caller/UseIt returned unit-local class lib.Pick — unit classes are per-importer; types crossing units should be host classes
```

The check is `JavaRunner.unitLocal(o)`: true when the object's classloader is a `MemoryClassLoader`.

**Why it exists.** `lib.Pick` compiled into unit A and `lib.Pick` compiled into unit B are **different
runtime classes with the same name**. A cast on the far side fails, and the exception prints the same
name twice.

**Failure it prevents.** The confusing cast:

```
java.lang.ClassCastException: class lib.Pick cannot be cast to class lib.Pick (lib.Pick is in unnamed module of loader sh.interlock.sdk.runtime.JavaRunner$MemoryClassLoader @1b6d3586; lib.Pick is in unnamed module of loader sh.interlock.sdk.runtime.JavaRunner$MemoryClassLoader @4a7f959b)
```

**Example.**

```java
// ❌ crosses the boundary as a unit-local class
public Pick handle(Request req, Interlock il) {
    return new Pick("boots", 4900);
}

// ✅ crosses as plain data
public Map<String, Object> handle(Request req, Interlock il) {
    return Map.of("sku", "boots", "cents", 4900);
}
```

A shared type that genuinely must be a class on both sides belongs in the **host** jar, where there is
one copy, reached through `il.context(SomeHostType.class)`.

**Verify.** Watch the host log for the warning:

```bash
grep -n "returned unit-local class" your-app.log
```

`MEASURED` for the warning (it fires from `Engine.invoke`). `INFERRED` for the exact
`ClassCastException` text above: the two-loader message is the JVM's standard format for this
situation, reconstructed rather than captured, so the loader hashes will differ on your run. Search
for `cannot be cast to class lib.Pick` and the duplicated name is the tell.

## Rule 2: no mutable statics in an imported unit

**Rule.** A library unit must not hold **mutable static state**. Each importer compiles its own copy of
the class, so a mutable static is not shared state, it is N copies of state that *look* shared.

The compile warns, per offending field, for every dependency class (never for the unit's own classes,
which are its business):

```
unit 'caller/UseIt' compiled in sibling 'lib/Counter' which has mutable static field 'hits' — every importer gets its OWN copy of that static
```

`static final` fields are exempt, as are synthetic fields.

**Why it exists.** Said once, at compile time, instead of discovered in production as a cache that
never hits, a counter that reads low, or a lazily initialized singleton that initializes twice.

**Failure it prevents.** No exception, ever. That is the point. The symptom is a shared cache with a 0%
hit rate and two units each convinced they own the only copy.

**Example.**

```java
// ❌ every importer gets its own `hits`
package lib;
public class Counter {
    public static int hits;
    public static void bump() { hits++; }
}

// ✅ state lives in the store, which is genuinely shared
package lib;
import sh.interlock.sdk.Interlock;
public class Counter {
    public static void bump(Interlock il) {
        var s = il.store("lib/Counter");
        Object n = s.get("hits");
        s.put("hits", (n == null ? 0L : ((Number) n).longValue()) + 1);
    }
}
```

**Verify.**

```bash
grep -n "which has mutable static field" your-app.log
```

`MEASURED` (`JavaRunner.warnMutableStatics` walks every dependency class's declared fields after a
successful compile).

## Invalidation cascades

**Rule.** A compiled unit records **which sibling sources javac actually read**, by SHA-256 of the
source. On the next run, a recorded hash that no longer matches the live source invalidates the cache
entry and the importer recompiles.

Two details that matter:

- It records what was **read**, not what was **listed**. An unrelated unit sitting in the same
  directory does not become an invalidation trigger (`UnitSource.read` flips only in
  `getCharContent`).
- The **id list** (which units exist) is snapshotted for **10 seconds** per environment in
  `Engine.siblings`, because javac asks for package listings on every compile and `CodeSource.list`
  may be an HTTP call. That staleness delays only how soon a **newly created** unit becomes
  importable. Edits to existing dependencies are caught by the hash cascade, not by the list.

**Why it exists.** Without the cascade, an importer whose own source is unchanged keeps serving a
compiled copy of last week's helper. Its cache key is its own source hash, and its own source did not
change.

**Failure it prevents.** No error text. You edit `lib/MathBox`, sync, hit `caller/UseIt`, and get the old
answer:

```
expected: <30> but was: <20>
```

**Example.**

```java
// before
public static int twice(int n) { return n * 2; }   // caller/UseIt returns 20
// after the edit and a sync
public static int twice(int n) { return n * 3; }   // caller/UseIt returns 30 on its NEXT run
```

**Verify.** In your own project, watch the cache flag on the importer flip after you edit its
dependency. `JavaRunner` logs `cached:true|false` per run at INFO:

```bash
curl -s localhost:8099/app/caller/use-it            # warm it: expect cached:false then cached:true
curl -s localhost:8099/app/caller/use-it
# now edit code/lib/MathBox.java, publish it, and run the IMPORTER again
interlock sync code
curl -s localhost:8099/app/caller/use-it
grep -o "java.run id:caller/UseIt cached:[a-z]*" your-app.log | tail -3
# expect: false, true, false. The third false is the cascade.
```

```bash
# MAINTAINER-ONLY (Interlock repository checkout, not your host):
./gradlew :interlock-java-sdk:test --tests 'sh.interlock.sdk.runtime.JavaSiblingTest.editingADependencyRecompilesItsImporter'
```

`MEASURED`. The 10-second list snapshot is `INFERRED` from `Engine.siblings` (`now - snap.at() > 10_000`);
no test pins the window.

## Handler discovery is by attribution

**Rule.** The handler is chosen among classes **attributed to the unit's own source file**, not by
scanning the loader for the first class implementing `InterlockHandler`.

`MemoryFileManager.getJavaFileForOutput` records, per emitted class, which source file it was born
from: a `UnitSource` means a dependency, anything else means the primary unit. Only classes with a
null origin are candidates, and among those the first concrete, non-abstract, non-interface
implementer wins.

**Why it exists.** Compilation output now contains dependency classes, **including their handlers**. A
dependency that happens to be a runnable unit puts its own `InterlockHandler` into the importer's
loader. "First class implementing `InterlockHandler`" would be a lottery.

**Failure it prevents.** A request answered by the wrong unit, with a 200 and no error anywhere. The
test asserts on exactly this: an importer whose dependency's handler returns `"WRONG HANDLER"` must
still run its own.

**Example.**

```java
// code/dep/Answer.java — a runnable unit that is ALSO imported
package dep;
public class Answer implements InterlockHandler<Object> {
    public static String value() { return "dep"; }
    public Object handle(Request req, Interlock il) { return "WRONG HANDLER"; }
}

// code/caller/UseIt.java — importing it must not inherit its handler
package caller;
import dep.Answer;
public class UseIt implements InterlockHandler<Object> {
    public Object handle(Request req, Interlock il) { return "mine-" + Answer.value(); }
}
```

Running `caller/UseIt` returns `mine-dep`.

**Verify.** Run the importer and read the body, not the status. The provenance header names which
unit answered, so the two facts are one request apart:

```bash
curl -si localhost:8099/app/caller/use-it | grep -i x-interlock-unit   # must name caller/UseIt
curl -s  localhost:8099/app/caller/use-it                              # must NOT be "WRONG HANDLER"
```

```bash
# MAINTAINER-ONLY (Interlock repository checkout, not your host):
./gradlew :interlock-java-sdk:test --tests 'sh.interlock.sdk.runtime.JavaSiblingTest.discoveryRunsTheImportersHandlerNotTheDependencys'
```

`MEASURED`.

## Generated units are never importable

**Rule.** A unit whose `CodeUnit.origin` is `generated` is **never offered to javac**.
`SiblingIndex.resolvePackage` skips it, so the import simply does not resolve. Generated code stays
behind `il.call`, where the reduced-trust chain holds.

**Why it exists.** Compiling generated source *into* an authored importer would run it with the
importer's capability. That is precisely the trust escalation origin tracking exists to prevent.
Origin is store metadata, never inferred from the source text, the class name, or the id, so a
generated unit cannot rename itself into privilege.

**Failure it prevents.** It converts a silent privilege escalation into an ordinary compile error:

```
compile failed for 'caller/UseIt':
  ERROR line 2: package lib does not exist
```

If a unit you know exists refuses to import, check its origin before you check your spelling.

**Example.**

```java
// lib/MathBox is stored with origin "generated"
import lib.MathBox;   // ❌ does not resolve, at all

Object n = il.call("lib/MathBox", Map.of("n", 21));   // ✅ this is the door
```

**Verify.** Only a host that actually produces generated units can see this fire. If yours does, add
an import of one to an authored unit and run the gate; the expected result is
`package lib does not exist` while `il.call` on the same id keeps working:

```bash
./scripts/check-units-compile.sh
interlock run caller/UseIt --env dev
```

If your host has no generated units, there is nothing here to run and nothing to worry about: the
rule can only tighten what a unit may import.

```bash
# MAINTAINER-ONLY (Interlock repository checkout, not your host):
./gradlew :interlock-java-sdk:test --tests 'sh.interlock.sdk.runtime.JavaSiblingTest.aGeneratedUnitIsNotImportable'
```

`MEASURED`.

## Hosts opt in via `CodeSource.list`

**Rule.** Sibling imports require the host's `CodeSource` to implement `list(String env)`. The
interface default returns `List.of()`, so a source that only answers `fetch` keeps working exactly as
before, with sibling imports simply off.

```java
public interface CodeSource {
    CodeUnit fetch(String id, String env);

    default java.util.List<String> list(String env) {
        return java.util.List.of();
    }
}
```

**Why it exists.** javac resolves `notes.NoteApi` by **listing package `notes`**, and a package
listing is a directory listing of unit ids. An import that cannot be enumerated cannot be resolved.
The default is empty rather than abstract so that every existing implementation, including the
one-lambda test doubles the interface was shaped for, keeps compiling unchanged.

**Failure it prevents.** A host that upgrades the SDK, writes a sibling import, and gets a compile
error naming a package it can see on disk:

```
compile failed for 'caller/UseIt':
  ERROR line 3: package lib does not exist
```

The unit tree is correct. The `CodeSource` is not enumerable. `sh.interlock.sdk.client.InterlockClient`
and `DirectoryCodeSource` both implement `list`; a hand-rolled lambda source does not.

**Example.**

```java
// ❌ sibling imports are off: a lambda only implements fetch
CodeSource src = (id, env) -> myDb.load(id, env);

// ✅ enumerable
CodeSource src = new CodeSource() {
    @Override public CodeUnit fetch(String id, String env) { return myDb.load(id, env); }
    @Override public List<String> list(String env) { return myDb.ids(env); }
};

// ✅ straight off a git checkout, offline, no server and no key
CodeSource src = new DirectoryCodeSource(Path.of("code"));
```

**Verify.** Read the interface off the SDK jar your own host already ships, no Interlock checkout
needed. The `list` default is what decides whether sibling imports are available at all:

```bash
SDK=$(ls build/quarkus-app/lib/main/*interlock-java-sdk*.jar)
javap -cp "$SDK" sh.interlock.sdk.runtime.CodeSource
# expect the default:  public default java.util.List<java.lang.String> list(java.lang.String);
```

Then prove your own source implements it rather than inheriting the empty default: run a unit that
imports a sibling and look for the compile line naming the dependency.

```bash
grep -n "java.compile id:" your-app.log
```

```bash
# MAINTAINER-ONLY (Interlock repository checkout, not your host):
./gradlew :interlock-java-sdk:test --tests 'sh.interlock.sdk.runtime.JavaSiblingTest.directoryCodeSourceServesAGitTree'
```

`MEASURED`.

## Standalone build checks see no siblings

**Rule.** `UnitBuild.check(id, language, source)` (reachable as `Engine.validate`) compiles a unit in
**isolation**, with `siblings` explicitly `null`. A unit that imports a sibling therefore reports that
import as an error there and still runs perfectly on the host, where the engine resolves it.

This is a known, accepted limitation, not a bug in your unit.

**Why it exists.** `UnitBuild` validates source a control plane will never run: no side effects, the
artifact discarded, a fresh runner per check. It already cannot see a host's classes, and sibling
resolution is the same kind of context.

**Failure it prevents.** It does not prevent a failure; it **causes a misleading one**, and that is why
it is written down. The diagnostic is indistinguishable from a genuinely broken import:

```
compile failed for 'caller/UseIt':
  ERROR line 3: package lib does not exist
```

Before chasing it, check where the check ran. `interlock check` skips `.java` and `.jsx` entirely and
prints:

```
  skip    dev/caller/UseIt.java (checked server-side by warm-on-change)
```

so a green `interlock check` is not evidence that a Java unit compiles.

**The check that is worth running instead.** Compile every Java unit through the real pipeline, in the
host's own test suite, with `DirectoryCodeSource` + `Engine.unitClass` + reflection. It exercises
sibling resolution, package validation, and the host classpath, so a broken unit fails in about two
seconds instead of arriving as a boot WARN nobody reads in time. On a real host application (Team
Lakes) this caught a shipped bug immediately: that host's `WorldBackend` unit called a
package-private `WorldPrompts.cityOf` and could never have compiled on the host, because nothing had
ever compiled it.

```java
@Test
void everyUnitCompiles() {
    try (Engine engine = new Engine(new DirectoryCodeSource(Path.of("code")),
                                    stores, secrets, ai)) {
        for (String id : new DirectoryCodeSource(Path.of("code")).list("dev")) {
            engine.unitClass(id, "dev");   // throws EngineException with real diagnostics
        }
    }
}
```

Reflection is required and is not a workaround: the unit is deliberately not on the host's test
classpath, and that is the boundary working.

**Verify.** See the two answers disagree on the same unit, on your own project. The standalone check
reports the sibling import as missing; the host runs it:

```bash
interlock check                          # prints "skip dev/caller/UseIt.java" for Java units
interlock run caller/UseIt --env dev        # the same unit answers, siblings resolved
./scripts/check-units-compile.sh         # and this is the check that is actually worth trusting
```

If your host exposes `Engine.validate` on an admin route, calling it for a unit that imports a
sibling returns `package lib does not exist` while the same unit runs. That disagreement is the rule,
not a defect.

`MEASURED` (the `null` resolver is passed explicitly: `new JavaRunner().precompile(id, "check", source, null, false)`).

## Host entities are denied by default

**Rule.** A Java unit may compile against the host's classes — except persistence entities.
Naming a host `@Entity`/Panache class in a unit is refused at compile unless the host exported
that entity with `Engine.exportEntity(TheEntity.class)`.

**Why it exists.** Entity statics (`deleteAll`, `persist`, `find`, `listAll`) act on the WHOLE
table. A careless or generated `Account.deleteAll()` in a unit empties a production table with
nothing warning — proven by a probe unit that did exactly that before the boundary existed.

**Failure it prevents.** The refusal is a sentence at compile time, where the author is:

```
unit 'reports/cleaner' references host entity 'Account', which is not exported to units — a unit
may not reach a persistence entity directly, because its static operations (deleteAll, persist,
find, listAll) act on the whole table. Export it with InterlockSDK.export(Account.class) if a unit
should have full table access, or hand the unit a read-view instead.
```

**Example.**

```java
// host wiring — one line per entity a unit may genuinely own:
Engine.exportEntity(Task.class);        // units may now use Task.find/persist/…
// or, for a first-party host that treats its whole schema as unit-reachable:
Engine.exportAllEntities();
```

Prefer handing the unit a read-view through the host context where it only needs to read.

**Verify.** Write a unit naming a non-exported entity and save it — the save's build check reports
the sentence above, naming your entity. `INFERRED → MEASURED` by `UnitReachBoundaryTest` in the
SDK suite, which first proved the pre-boundary probe could empty a fixture table.

## Native image: no compiler

Java units need the **JDK compiler in-process**. In a native image `ToolProvider.getSystemJavaCompiler()`
returns null and the unit refuses to run:

```
no Java compiler available (native image?) — Java units need JVM-mode hosting
```

JS units run in both. `MEASURED` (`JavaRunner.compile`, first branch).
