Embedding the SDK in a host #
Raw markdown: /host.md
For humans #
Read this if you are wiring the SDK into your own JVM application, or you own that wiring. Skip it if you only write units and somebody else stood the host up.
Do this #
- Add the SDK to your host build:
sh.interlock:interlock-java-sdk:0.1.0-SNAPSHOT. - Call
InterlockSDK.init(key)once and produce theEngineas an application-scoped bean. - Seed a plain instance as context. Not an injected bean.
- Serve every unit through one route:
@Path("/app/{id:.+}").
@Produces @ApplicationScoped
public Engine engine() {
return InterlockSDK.init(System.getenv("INTERLOCK_SDK_KEY"))
.context("app", new MyContext()) // plain instance, not an @Inject field
.stores(new DbStoreFactory())
.build();
}
Monitoring is included #
There is no step 5 for observability. The engine already times every unit it runs — count, errors, average and worst latency, per unit, per UTC day — and reports the aggregates to your project every few seconds. They appear on your project dashboard's Serving card next to the platform's own numbers, endpoint by endpoint, with the last error message on hover. Nothing to install, no agent, no exporter: if a request went through the engine, it is already on the board. The cost on your request path is four lock-free counter increments; the wire cost is one small POST per flush, sized by how many distinct units ran, never by traffic.
What will bite you #
- Seeding an injected CDI bean anchors the wrong jar. A normal-scoped bean is a proxy, so javac is pointed at
generated-bytecode.jarand your app jar never arrives. Keep the seed and addEngine.addCompileAnchor(MyApp.class). - Nothing warns you at startup. The unit fails to compile much later with
package com.example.app does not exist. That reads like a typo in the unit. - Generated code cannot name a host class. It compiles against the SDK and the JDK only. Open a read-only view with
contextForGeneratedif it needs host data. - Units cannot reach your entities by default. A unit that names a persistence entity is refused at compile, because its static
deleteAll/persist/findoperate on the whole table. Export the tables units may touch by name —.export(Workspace.class, Task.class)— and your identity and auth tables stay closed. See The reach boundary.
Then read #
- The wiring for every builder slot and its default.
- Seeding a CDI bean anchors the wrong jar if a unit cannot see your package.
- The reach boundary to decide which of your tables units may touch.
- The /app passthrough, in full for the resource you copy.
- Quarkus and Panache entities before your first unit, if your host uses Panache.
- For humans
- For robots
- The wiring
- Compile anchors
- Seeding a CDI bean anchors the wrong jar
- What is on a unit's compile classpath
- Engine.unitClass, the testing door
- DirectoryCodeSource, offline and from a git checkout
- Trust: authored versus generated
- The shadow boundary
- The reach boundary
- Context versus session
- Capabilities, never raw power
- Prefer a narrow record over an entity
- req.str reads the body too
- The full Request surface
- Result.status and Refusal
- The /app passthrough, in full
- Assert effects, not status codes
- What is measured and what is inferred
For robots #
This page is for the developer standing up a JVM application that runs Interlock code units in its own process. It covers three things, in the order you will need them: wiring the SDK, the trust boundary that decides what a unit may reach, and the request/response contract between a unit and your HTTP layer.
Every rule below is written in the same five parts: the rule, why it exists, the failure it prevents (with the literal error text you would search for), a minimal example, and the command that verifies it.
The examples share one deliberately dull machine, so that nothing on this page needs decoding before the rule is visible. A unit counter/CounterApi sits in front of a host facade that stores one number per key: add increments it, read returns it, and a second add inside a cooldown window is refused. That is the entire example. Where you see it, substitute your own verbs.
Pinned versions for everything on this page: sh.interlock:interlock-java-sdk:0.1.0-SNAPSHOT (InterlockSDK.VERSION == "1"), Quarkus 3.15.1, Java 21. Quarkus-specific behaviour lives on its own page: Quarkus and Panache entities.
Read that page too if your host uses Panache entities. It is not optional reading, and it is where the days went.
- The wiring
- Compile anchors
- Seeding a CDI bean anchors the wrong jar
- What is on a unit's compile classpath
- Engine.unitClass, the testing door
- DirectoryCodeSource, offline and from a git checkout
- Trust: authored versus generated
- The shadow boundary
- The reach boundary
- Context versus session
- Capabilities, never raw power
- Prefer a narrow record over an entity
- req.str reads the body too
- The full Request surface
- Result.status and Refusal
- The /app passthrough, in full
- Assert effects, not status codes
The wiring #
The rule. One call stands the whole embed up. InterlockSDK.init(key, context) returns a builder; override only what you actually change; build() returns an Engine. The capability slots are codeSource, stores, secrets, ai and geo. context and contextForGenerated seed context rather than replace a capability, and serviceUrl and warmUp tune the client, which is why the fluent surface is nine methods and not five. Full list with each default on Reference. Produce the Engine as an application-scoped bean and inject it; the SDK never forces a JVM singleton.
Why it exists. Everything a host used to hand-write is now a default: the code source is an InterlockClient that fetches from INTERLOCK_URL (default https://api.interlock.sh), caches, and subscribes to the change feed; storage is per-unit in-memory; secrets come from environment variables with foo-bar mapped to FOO_BAR; il.ai() is brokered through Interlock so the host holds no model credential. A host that writes those by hand writes five chances to get one wrong.
The failure it prevents. A host that constructs new Engine(...) directly and passes its own CodeSource skips InterlockSDK.build(), and build() is the only thing that auto-anchors your context classes for javac. The symptom is a unit that cannot see your host types at all:
compile failed for 'hello/HelloApi':
ERROR line 3: package com.example.app does not exist
Example.
@ApplicationScoped
public class InterlockWiring {
@Produces
@ApplicationScoped
public Engine engine() {
return InterlockSDK.init(System.getenv("INTERLOCK_SDK_KEY"))
.context("app", new MyContext()) // authored units see this
.stores(new DbStoreFactory()) // your database, not the in-memory default
.build();
}
/**
* Force creation at startup. A produced @ApplicationScoped bean is otherwise instantiated on
* first injection, so a host with no traffic never opens its change feed.
*/
void eagerInit(@Observes StartupEvent ev, Engine engine) {
Logger.getLogger(InterlockWiring.class).info("Interlock engine ready at startup");
}
}
stores(StoreFactory) takes Store store(String codeId, String env). secrets(SecretResolver) takes String resolve(String name). ai(Ai) replaces the brokered provider with your own. codeSource(CodeSource) replaces the fetch path entirely; for a purely generative host that only calls runSource, pass (id, env) -> null.
Verify.
curl -s localhost:8099/q/health && echo
# then: the engine announces itself at boot
grep -m1 "Interlock engine ready at startup" your-app.log
Compile anchors #
The rule. Engine.addCompileAnchor(SomeHostClass.class) is how javac learns your host classes exist. Register one anchor per code source your host exposes. An anchor's protection-domain code source is the host classpath entry: the SDK calls clazz.getProtectionDomain().getCodeSource().getLocation(), resolves it to a file, and adds that file to the compile classpath. Registering an anchor also auto-reserves its package (see the shadow boundary).
You usually do not call this yourself. InterlockSDK.build() walks the seeded context map, takes getClass() of every non-null, non-Supplier value whose package is not java., javax., jakarta., sun. or jdk., and anchors each one. A host that uses a ContextProvider instead of a seed map gets no auto-anchoring, because a dynamic provider has no statically known values, and must call Engine.addCompileAnchor by hand.
Why it exists. The runtime classloader already shares host classes with a unit, because the unit's MemoryClassLoader has the host loader as its parent. javac does not use the runtime classloader. It needs a real -classpath string, and in a Quarkus fast-jar java.class.path is the expanded library list, which does not contain the application's own jar. The anchor's protection domain is the one route by which your jar reaches the compile classpath.
The failure it prevents. Without an anchor for a class a unit imports:
compile failed for 'counter/CounterApi':
ERROR line 4: package com.example.app does not exist
ERROR line 12: cannot find symbol
What an anchor class may be. Any class of your own that ships inside the application jar. It never has to be the class a unit imports, it is never asked to do anything, and an empty marker class declared next to your other host code is a perfectly good answer. The one thing it must not be is a CDI client proxy, which resolves to the wrong jar: see Seeding a CDI bean anchors the wrong jar.
Example. One anchor is enough. A single call does both jobs, because both come from the same class: its code source is your app jar, and its package is what gets reserved.
// Only needed when you use a dynamic ContextProvider, or expose a type you never seed.
Engine.addCompileAnchor(MyApp.class); // adds com.example.app's jar AND reserves com.example
A second anchor is worth adding only when a second, separate jar of yours has to reach javac too. Anchoring another class from the same jar changes nothing, and anchoring one whose package shares its top two segments reserves the same namespace twice.
Verify. Turn on DEBUG for the runner and read the classpath rather than reasoning about it:
# quarkus.log.category."sh.interlock.sdk.runtime.JavaRunner".level=DEBUG
grep -o "java.classpath .*" your-app.log | tr ':' '\n' | grep -c .
grep -o "java.classpath .*" your-app.log | tr ':' '\n' | grep your-app-name
Source note (spec versus code). addCodeSource is a private static helper on sh.interlock.sdk.runtime.JavaRunner, not a public method on Engine. There is nothing to call. The public surface is Engine.addCompileAnchor(Class) and JavaRunner.reservePackage(String); addCodeSource is the internal mechanism those two drive, and it is described here only because knowing it exists is what makes the Quarkus page make sense.
One thing the auto-anchoring does not do: unwrap a CDI client proxy. Read the next section before you seed anything annotated @ApplicationScoped.
Seeding a CDI bean anchors the wrong jar #
The rule. Seed a plain instance as a context value: new MyContext(), constructed in your producer. If the value you want to seed is a CDI bean with a normal scope (@ApplicationScoped, @RequestScoped, @SessionScoped), what you actually hold is a client proxy, and anchoring it points javac at the wrong jar. Fix it with an explicit anchor for a real class out of your own app jar alongside the seed:
Engine.addCompileAnchor(MyApp.class); // a REAL class, not whatever getClass() says
Why it exists. InterlockSDK.build() anchors getClass() of every seeded value, verbatim:
seed.values().stream()
.filter(v -> v != null && !(v instanceof java.util.function.Supplier))
.map(Object::getClass)
.filter(InterlockSDK::isHostClass)
The SDK does not unwrap proxies, and that is not an omission you can wait out. There is no ClientProxy, no Arc, and no io.quarkus.arc reference anywhere in interlock-java-sdk/src/main/java: the SDK is framework-neutral by construction and could not call ArC's unwrap without taking a Quarkus dependency it deliberately does not have. So for a normal-scoped bean, getClass() is com.example.app.MyContext_ClientProxy, a class ArC generates at build time. In a fast-jar that class is read from quarkus/generated-bytecode.jar. Your own classes are read from app/<app>.jar. Quarkus gives each class the protection domain of the jar it was read from (io.quarkus.bootstrap.runner.JarResource.init() builds one ProtectionDomain(new CodeSource(url, null), null) per jar), so clazz.getProtectionDomain().getCodeSource().getLocation() returns the generated jar and the app jar is never added.
There is no second route to it. quarkus-run.jar's manifest Class-Path lists only lib/boot/*, so java.class.path contains neither app/ nor lib/main/, and an anchor's protection domain is the one way the application's own jar reaches javac.
The failure it prevents. Not a clean "nothing resolves". A partial one, which is worse. addCodeSource walks up from the jar it resolved looking for quarkus/transformed-bytecode.jar, and from quarkus/generated-bytecode.jar it finds it one level up. That jar holds every host class the Quarkus build happened to rewrite, so those still resolve. Everything Quarkus did not rewrite lives only in app/<app>.jar, which is now absent. Measured on a real host application (Team Lakes), and these are its own class names, not names you are meant to have: OfficeContext.class is in the transformed jar, but its nested OfficeContext$Whereabouts record is only in app/; the seeded root TeamLakesInterlockContext is only in app/ while its nested OfficeOps record is in the transformed jar. Which half of your facade compiles is decided by which classes the build touched.
So you get either the blunt form:
compile failed for 'notes/NoteApi':
ERROR line 4: package com.example.app does not exist
or the form that costs an afternoon, where the facade resolves and the record it returns does not:
compile failed for 'counter/CounterApi':
ERROR line 12: cannot find symbol
symbol: class CounterView
location: class com.example.app.MyContext
Example.
@Produces
@ApplicationScoped
public Engine engine() {
// YES: a plain instance. getClass() is MyContext, whose code source is app/<app>.jar.
return InterlockSDK.init(key)
.context("app", new MyContext())
.build();
}
@Inject MyContext ctx; // @ApplicationScoped: this field holds MyContext_ClientProxy
@Produces
@ApplicationScoped
public Engine engine() {
// NO on its own: this anchors generated-bytecode.jar and your app jar never reaches javac.
// If the facade must be a bean (it injects other beans, it has a lifecycle), keep the seed and
// add the real class by hand. addCompileAnchor is idempotent; the extra entry is harmless.
Engine.addCompileAnchor(MyApp.class);
return InterlockSDK.init(key).context("app", ctx).build();
}
A real host application (Team Lakes) takes the first road for exactly this reason: its producer is an @ApplicationScoped bean, but the value it seeds is new TeamLakesInterlockContext(...), built by hand from injected collaborators. The bean-ness stays in the producer and never reaches the seed map.
Verify. Three checks, all runnable against your own packaged host. The third is decisive:
# 1. what you seed. A value that is an injected normal-scoped bean seeds its proxy, not itself.
grep -rn "\.context(" src/main/java | grep -v contextForGenerated
# 2. the proxies your build generated, and the jar they live in
unzip -l build/quarkus-app/quarkus/generated-bytecode.jar | grep '_ClientProxy'
# 3. DECISIVE. Your app jar must be on the unit compile classpath.
# quarkus.log.category."sh.interlock.sdk.runtime.JavaRunner".level=DEBUG
grep -o "java.classpath .*" your-app.log | tr ':' '\n' | grep -E "/app/|generated-bytecode"
# app/<your-app>.jar present → anchored correctly
# generated-bytecode.jar and NO app/ entry → you anchored a proxy; add the explicit anchor
What is measured and what is inferred. MEASURED, 2026-08-08: contextTypes() calling Object::getClass with no unwrap, and the absence of any ArC reference in the SDK sources; JarResource.init() giving a per-jar protection domain (Quarkus 3.15.1 bootstrap-runner source); quarkus-run.jar's manifest listing only lib/boot/*; and the jar placement of _ClientProxy, OfficeContext, OfficeContext$Whereabouts and TeamLakesInterlockContext on a packaged real host application (Team Lakes), whose own class names those are. INFERRED: the end-to-end failure. No host in the reference tree seeds a normal-scoped bean, so the chain was followed through source and jar layout rather than reproduced. What would settle it: seed an @ApplicationScoped facade, package, boot, and read the DEBUG classpath line from check 3.
What is on a unit's compile classpath #
The rule. A unit is compiled by javac inside the running host, against a -classpath the SDK assembles itself. This is the whole list, in the order JavaRunner.classpath(reduced) builds it, the JDK excepted: javac supplies the platform classes and they never appear in the string.
An authored unit gets:
| on the classpath | where it comes from |
|---|---|
| the JDK platform classes | javac's own, never added by the SDK |
| the SDK jar | the code source of InterlockHandler and of JavaRunner |
every entry of the host JVM's java.class.path, verbatim | the process the host is running as |
for each .jar on that classpath, the jars in the sibling app/, lib/main/ and lib/boot/ directories | expandQuarkusAppLayout, which is how a fast-jar's own libraries are found |
| each registered compile anchor's code source | addCodeSource resolving the anchor's protection domain |
quarkus/transformed-bytecode.jar and quarkus/generated-bytecode.jar | inserted immediately ahead of any jar whose layout contains them, so the rewritten classes shadow the originals |
A generated unit gets the first two rows and stops there. classpath(reduced) adds the SDK's code source and returns before it looks at java.class.path, the layout, or the anchors. SDK and JDK, nothing else, so a generated unit cannot even name a host class. See Trust: authored versus generated.
Three consequences worth stating outright, because each one gets guessed wrong:
- The host's dependency jars ARE reachable from an authored unit.
lib/main/*is on the list, so a unit mayimport io.quarkus.narayana.jta.QuarkusTransactionon a packaged Quarkus host, and that is why the custody line can say a unit is allowed to open its own transaction. Reachability is a consequence of what your host happens to ship, not a promise the SDK makes: drop the dependency and the unit stops compiling. - Importing and declaring are different questions. A unit may import from
io.quarkus,jakarta,sh.interlockand your own packages. It may not declare a class into any of them. That is the shadow boundary, and it is enforced separately. - In a packaged host there are no test classes on it, and in a test JVM there are.
java.class.pathis copied verbatim, so whatever the process was launched with is what javac sees. Under./gradlew testthat includesbuild/classes/java/testand every test-scope dependency, which means a unit can compile against a fixture in your suite and fail on the server, where no such entry exists. This is one of two ways a test-run classpath differs from the server's; the other is the transformed bytecode, and both are why the harness test does not replace the packaged gate.
Why it exists. The classpath is the trust tier made real one step before runtime. Capability tiers gate il.context() when the unit is already running; the compile classpath decides what the unit can even name, which is somewhere it cannot be talked around. And for authored units the shape is unobvious enough that three separate attempts got it wrong by reasoning about it: in a fast-jar, java.class.path is the expanded library list and contains neither quarkus-run.jar nor the layout root, so the host's own jar reaches javac only through an anchor's protection domain.
The failure it prevents. Naming something that is not there, which surfaces as a javac diagnostic wrapped in an EngineException on the first run of the unit:
compile failed for 'gen/Sneaky':
ERROR line 2: package com.example.app does not exist
For a generated unit that message is the tier working correctly and not a bug to route around. For an authored unit it means the opposite: a missing anchor, or a dependency the host does not ship.
Example. What each tier can name, written as the two units:
// AUTHORED: host classes via an anchor, host dependencies via lib/main, the SDK always.
import com.example.app.MyContext; // anchored host jar
import io.quarkus.narayana.jta.QuarkusTransaction; // lib/main, because the host ships it
import sh.interlock.sdk.Json; // the SDK
// GENERATED: neither of the first two lines compiles. SDK and JDK only.
Verify. Print it, do not reason about it. The runner logs the full string at DEBUG:
# quarkus.log.category."sh.interlock.sdk.runtime.JavaRunner".level=DEBUG
grep -o "java.classpath .*" your-app.log | tr ':' '\n' | grep -c . # how many entries
grep -o "java.classpath .*" your-app.log | tr ':' '\n' | grep -n "lib/main" | head
# the transformed jar must appear BEFORE the app jar it shadows:
grep -o "java.classpath .*" your-app.log | tr ':' '\n' | grep -n "transformed-bytecode\|/app/"
The generated tier is the other half of this, and a host can see it without any SDK checkout: run Engine.runSource (or store a unit with origin generated) whose source imports one of your own classes, and read the compile failure. package com.example.app does not exist from generated code is the tier working, not a missing anchor.
# MAINTAINER-ONLY (Interlock repository checkout, not your host):
./gradlew :interlock-java-sdk:test --tests '*UnitBoundaryTest*'
Engine.unitClass, the testing door #
The rule. engine.unitClass(id, env) compiles a Java unit through the real pipeline and returns its Class<?>. Use it with reflection in your ordinary test suite. You cannot cast the result to a host interface, and that is the boundary working, not a workaround.
Resolution order, from the source: the binary name the unit id implies (notes/NoteApi gives notes.NoteApi) if the unit's own file produced it, then the handler class, then the unit's first own class. It throws if the unit is not java:
unit 'notes/style' is jsx, not java
Why it exists. A library unit has no handler, so run refuses it. Without unitClass there is no way to exercise a pure-function unit at all, and the pure functions are exactly the code most worth testing. The reflective call also compiles the unit through sibling resolution, package validation and the real host classpath, so the test proves the unit builds, not just that it parses.
The failure it prevents. A unit tree that has never been compiled against the host. The literal error you would otherwise meet first, on a live request, hours later. This one is quoted from a real host application (Team Lakes), so the unit and class names are its own, not names you are meant to have:
compile failed for 'world/WorldBackend':
ERROR line 41: cityOf(java.lang.String) is not public in world.WorldPrompts; cannot be accessed from outside package
That exact defect shipped and sat undetected until a harness test found it in two seconds.
Example.
@QuarkusTest
class UnitsTest {
@Test
void doublerCompilesAndDoubles() throws Exception {
Engine engine = new Engine(new DirectoryCodeSource(Path.of("code")),
(id, env) -> new MapStore(), name -> null, NO_AI);
// code/lib/Doubler.java: public static int twice(int n) { return n * 2; }
Class<?> doubler = engine.unitClass("lib/Doubler", "dev");
Method twice = doubler.getMethod("twice", int.class);
// Reflection is required: lib.Doubler is deliberately NOT on the test classpath.
Object doubled = twice.invoke(null, 21);
assertEquals(42, ((Integer) doubled).intValue());
}
}
Verify.
./gradlew :your-api:test --tests '*UnitsTest*'
DirectoryCodeSource, offline and from a git checkout #
The rule. new DirectoryCodeSource(Path.of("code")) reads units straight off a disk tree. fetch("notes/NoteApi", "dev") reads code/notes/NoteApi.java. No server, no key, no sync. Point it at your code/ directory — units sit directly under it.
The tree is flat: there are no environment directories. The env argument labels the CodeUnit that comes back; it never selects a path, so fetch(id, "prod") and fetch(id, "dev") read the same bytes off the same file. Which environments actually carry that code is a property of the server, not of your checkout.
Two constraints that are in the source and will bite otherwise:
- Everything it returns is
authored. A plain directory cannot recordgenerated, so it must not accidentally grant generated code full capability. Never point this at a directory where machine-generated units land. - Text units only (
java,jsx,js,html,css,md,txt). Binary asset units keep their extension in the id and live behind the content-addressed blob store; a test that needs them needs a server.
Why it exists. Two callers with the same need. Tests run the host's real units against the sources in its own repo. Offline development boots a host against its checkout, accepting that nothing hot-reloads without the change feed.
The failure it prevents. Passing the wrong root. fetch returns null for anything it cannot find, and the engine turns that into:
sh.interlock.sdk.runtime.CodeNotFoundException: no code 'notes/NoteApi' in env 'dev'
Ids containing .. return null too, deliberately: ids are unit names, never path traversal.
Example.
// root is the directory whose children are dev/, staging/, prod/
CodeSource src = new DirectoryCodeSource(Path.of("code"));
Engine engine = InterlockSDK.init(null) // null key: legitimate ONLY because codeSource is overridden
.codeSource(src)
.context("app", new MyContext())
.warmUp(false) // no SSR pool needed for a unit-compile test
.build();
What init(null) means. The key is the credential for the default code source. build() constructs an InterlockClient with it only when you did not call codeSource(...), so once a DirectoryCodeSource is in place nothing ever reads the key and null is the honest value to pass. Two consequences worth knowing before you copy this into a running host: with the default code source a null or blank key logs interlock: no SDK key — every unit fetch will fail with 401 at boot and the change feed never connects; and because il.ai() and il.geo() are brokered through that same InterlockClient, a host with its own CodeSource and no .ai(...) of its own gets the honest no-op provider (live() == false) rather than a brokered one.
Verify.
ls code/notes/NoteApi.java # the path DirectoryCodeSource will read
./gradlew :your-api:test --tests '*UnitsTest*' # your harness, pointed at Path.of("code")
Point it at code, the directory the units are in. One level too deep and fetch looks for code/notes/notes/NoteApi.java, and you get no code 'notes/NoteApi' in env 'dev' for a file you can see. If your checkout still has a code/ directory it is the OLD layout — move the files up (git mv code/* code/) and leave .interlock-index/ exactly where it is.
# MAINTAINER-ONLY (Interlock repository checkout, not your host):
./gradlew :interlock-java-sdk:test --tests '*JavaSiblingTest*'
Trust: authored versus generated #
The rule. CodeUnit.origin is either "authored" or "generated" (CodeUnit.AUTHORED / CodeUnit.GENERATED; a unit with no recorded origin is treated as authored). A generated unit runs with reduced capability, and reduced means two separate things:
- It sees only the context entries the host opened with
InterlockSDK.contextForGenerated(key, value). Deny by default: a host that never calls it exposes nothing. - It compiles against an SDK-only classpath.
JavaRunner.classpath(reduced)adds the SDK's own code source and the JDK, then returns. No host jar, no anchors, nothing.
Trust is a property of the call chain, not of the unit being entered. Once a run is reduced, every nested il.call stays reduced, so generated code cannot launder privilege by calling an authored unit.
Why it exists. Capability tiers that only gate il.context() are true one step too late. A generated Java unit handed the full host classpath could still name a host entity and write new Membership().setRole("OWNER"). Making the tier true at the compiler puts it somewhere a unit cannot talk its way around.
Origin is carried from the code store as metadata, never inferred from source text, class name or unit id, or generated code could rename itself into privilege.
The failure it prevents. A generated unit that names a host class is refused at compile:
compile failed for 'gen/Sneaky':
ERROR line 2: package com.example.app does not exist
That is the intended outcome, not a bug to work around. If a generated unit legitimately needs host data, open a read-only view with contextForGenerated, or hand it per run with engine.runClosed(id, env, req, Map.of("catalog", view)).
Example.
InterlockSDK.init(key)
// Authored units get the mutating gateway.
.context("app", new MyContext())
// Generated units get a read-only view, and nothing else in the process.
.contextForGenerated("catalog", new CatalogView())
.build();
Verify. In your own host's test suite, ask a generated run what it can see. Engine.runSource is the generated tier, so the same probe answers differently there than an authored unit does:
@Test
void generatedCodeSeesOnlyWhatWasOpenedToIt() {
String src = """
public class Probe implements InterlockHandler<Object> {
public Object handle(Request req, Interlock il) {
return java.util.Map.of("app", il.context().has("app"),
"catalog", il.context().has("catalog"));
}
}""";
Object seen = engine.runSource("probe", "java", src, req).value;
assertEquals(Map.of("app", false, "catalog", true), seen);
}
./gradlew :your-api:test --tests '*UnitsTest*'
app must be false: it was seeded with context(...), so a generated unit cannot reach it. catalog must be true: it was opened deliberately with contextForGenerated(...). A host that has never called contextForGenerated should see both come back false.
# MAINTAINER-ONLY (Interlock repository checkout, not your host):
./gradlew :interlock-java-sdk:test --tests '*UnitBoundaryTest*'
The shadow boundary #
The rule. A unit cannot declare a class into a reserved package. The refusal happens twice: at COMPILE with a readable sentence, and at LOAD in the classloader.
Reserved by default, from JavaRunner's static initializer:
java javax jdk sun com.sun sh.interlock io.quarkus jakarta
Plus every host package, auto-reserved from the top two segments of each compile anchor's package. Anchoring a class in com.example.billing reserves com.example. Matching is prefix-wise: com.example reserves com.example itself and everything under com.example..
Because directory is package, the unit id is the thing that trips this. code/com/example/Evil.java declares package com.example; and is refused.
Why it exists. Two distinct attacks, closed by two distinct mechanisms.
- Sitting inside a host package to reach its package-private members. The JVM would happily let a child loader define
com.example.Whateverwhen the host does not already have that exact name. The reserved list is what refuses it. - Replacing a class the host already loaded.
MemoryClassLoaderis left parent-first (the default), so every class the host has already wins, and a unit compiling a class of the same name never gets a look in.
The failure it prevents, and the two literal texts to search for. At COMPILE, an EngineException whose message is:
unit 'com/example/Sneaky' declares package 'com.example', which is reserved — a unit cannot define a class into the JVM's, the SDK's, or the host's namespace, only import from them
At LOAD, a ClassNotFoundException from MemoryClassLoader.findClass:
refusing to define 'com.example.Sneaky' — its package is reserved to the host
The load-time refusal is reached only when the parent did not have the class, which is exactly the "new class into the host's namespace" case. Two layers, because this is the boundary the whole "units are trusted but bounded" model rests on.
Example.
// A host with no seeded context still wants its tree fenced:
JavaRunner.reservePackage("com.example");
// Or get it free: anchoring any class in com.example.* reserves com.example.
Engine.addCompileAnchor(MyApp.class);
Verify. Try it on your own host. Put a unit in your own namespace and watch the compile refuse it, then move it back:
mkdir -p code/com/example && cat > code/com/example/Sneaky.java <<'EOF'
package com.example;
public class Sneaky { public static String who() { return "not host code"; } }
EOF
./scripts/check-units-compile.sh
# the gate compiles it happily: javac does not know about reserved packages.
# the REFUSAL is the runtime's, so ask the host:
interlock sync code && interlock run com/example/Sneaky --env dev
# expect: declares package 'com.example', which is reserved
rm -rf code/com
The two-second gate and the reservation check answer different questions, and this is the one case where the gate is green and the host still says no. That is deliberate: the gate reproduces javac, and reservation is enforced by the SDK on either side of it.
# MAINTAINER-ONLY (Interlock repository checkout, not your host):
./gradlew :interlock-java-sdk:test --tests '*UnitBoundaryTest*'
# five boundary cases: SDK namespace, host namespace, JVM namespace,
# an ordinary unit package that must still work, and generated-classpath isolation
A note on what this is not. A Java unit is full-trust code in the host JVM. addCompileAnchor gates javac's visibility, not runtime reachability: a unit can reflect into any host class and do IO in process, and Java units are not wall-clock capped the way JS/JSX units are. The shadow boundary stops a unit from becoming host code. It does not sandbox it. Studio write access to a project whose key a production host embeds is equivalent to deploy access on that host. Grant it accordingly.
One deliberate exception: persistence entities are denied by default. A unit that names a host @Entity/Panache class is refused at compile, with a sentence, because entity statics (deleteAll, persist, find, listAll) act on the whole table — a blast radius a host rarely means to hand a unit:
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.
Re-permit per type with Engine.exportEntity(Account.class) (or the blunt Engine.exportAllEntities() for a first-party host that treats its schema as unit-reachable) — prefer handing a read-view where a unit only needs to read. Entity shape is detected by name, so the SDK needs no jakarta.persistence dependency; deliberate reflection through the SDK loader is NOT sandboxed — this is a compile-time guardrail against careless and generated code, not a security boundary.
The reach boundary #
The rule. A unit cannot reach a host persistence entity it was not exported. Entities are denied by default; everything else your host exposes — gateways, contexts, records, QuarkusTransaction, your dependency jars — stays reachable. The refusal happens twice, the same two places as the shadow boundary: at COMPILE with a readable sentence, and at LOAD in the classloader.
This is the twin of the shadow boundary. That rule is about declaring — a unit may not define a class into your namespace. This one is about reaching — a unit may not name Account and call Account.deleteAll() on a table you never meant to hand it.
Why entities specifically. With Panache active-record the mutators are static methods on the entity class itself: deleteAll(), persist(), find(...), listAll() each operate on the whole table. So naming the class is granting the table. The threat modelled is not a malicious tenant — a Java unit is full-trust code (see the note below) — it is careless or generated code with a blast radius you never intended: a coding agent that emitted SessionToken.deleteAll() because it looked reasonable. Safe by default means that surface is closed unless you open it by name.
Default deny, opt-in export. Name the tables your units work with, and only those:
return InterlockSDK.init(key)
.context("app", new MyContext())
// the tables units may reach — and nothing else. Account, SessionToken,
// sessions, anything you do not name here stays closed to unit code.
.export(Workspace.class, Project.class, Task.class)
.build();
If a unit only needs to READ a row, prefer handing it a record (see Prefer a narrow record over an entity) over exporting the entity, so the table's mutators never cross into unit code at all. exportAllEntities() exists for a single first-party team that treats its whole schema as unit-reachable — prefer naming entities, so the tables you didn't mean to expose are visible by their absence.
The failure it prevents, and the two literal texts to search for. At COMPILE, an EngineException:
unit 'app/Wipe' references host entity 'com.example.SessionToken', 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(SessionToken.class) if a unit should have full table access, or hand the unit a read-view instead.
At LOAD — reached when a unit resolves the entity by a string-literal Class.forName, which the compile scan cannot see — a ClassNotFoundException:
refusing to load host entity 'com.example.SessionToken' — it is not exported to units (its static operations act on the whole table); a host exports it with InterlockSDK.export(...)
The export policy is host-only, and a unit cannot open its own gate. InterlockSDK.export(...) (and Engine.exportEntity/exportAllEntities) are set once, at boot, from your wiring. The SDK refuses any export call that has a unit frame on its stack, so a unit calling Engine.exportAllEntities() — directly, or through reflection — throws instead of granting:
a code unit cannot export all host entities — the entity export policy is the reach boundary's own control, set once at boot from host wiring (InterlockSDK.export / Engine.exportEntity), never from unit code
Verify. Point a unit at a table you did not export and watch the compile refuse it:
mkdir -p code/probe && cat > code/probe/Wipe.java <<'EOF'
import sh.interlock.sdk.Interlock;
import sh.interlock.sdk.InterlockHandler;
import sh.interlock.sdk.Request;
public class Wipe implements InterlockHandler<Object> {
public Object handle(Request req, Interlock il) {
com.example.SessionToken.deleteAll(); // a table the host never exported
return "wiped";
}
}
EOF
interlock sync code && interlock run probe/Wipe --env dev
# expect: references host entity 'com.example.SessionToken', which is not exported to units
rm -rf code/probe
# MAINTAINER-ONLY (Interlock repository checkout, not your host):
./gradlew :interlock-java-sdk:test --tests '*UnitReachBoundaryTest*'
# deny-at-compile, an exported entity IS reachable, an ordinary host class stays reachable,
# literal-name reflection refused at load, and a unit cannot self-export
A note on what this is not. Same limit as the shadow boundary. A Java unit is full-trust code in the host JVM; the reach boundary closes the accidental and generated blast radius — an innocent-looking deleteAll(), a literal-name reflection — and turns reaching an entity into a deliberate, reviewable act rather than a one-liner. It does not sandbox an author who sets out to route around it. The export-policy control surface itself IS closed against reflection; the entity reach is not. Grant Studio write access to a production project accordingly.
Context versus session #
The rule. Seed one typed root carrying capabilities, read with il.context(MyContext.class). Pass per-request facts separately, read with il.session(MySession.class). A unit READS session.owner(). It can never ASSERT it.
The examples below call the host's own per-request type AppSession. Name yours whatever you like, but do not call it Session: Hibernate's org.hibernate.Session is already on a Quarkus host's classpath, and a bare Session in an import list is then genuinely ambiguous to a reader and one careless IDE completion away from being ambiguous to the compiler.
Mechanically they are different maps. Engine.run(id, env, req, local) puts local in a thread-local. il.context() sees the seeded map merged with local (per-run entries shadow globals of the same name, so narrowing is the default outcome). il.session(Class) searches only local, unmerged.
Do not also register the same object under a string key. Two names for one thing is two things to keep in step.
Why it exists. If one object both told a unit who it was and let it act, a unit could act as something it merely described. Keeping them apart makes that unrepresentable rather than discouraged. And a unit asking for the caller of THIS request must not be able to receive a process-wide object that happens to share its type, or a host bug becomes a unit silently acting on the wrong identity.
il.context(Class) is the typed door, and it is the one a Java unit should use. A string lookup returns Object, so every Java caller casts anyway: the string bought nothing except a lookup that fails at runtime instead of at compile time, and a ClassCastException raised at the call site rather than where the mistake was.
The failure it prevents. Seed two objects of the same type and both accessors throw rather than picking one arbitrarily:
java.lang.IllegalArgumentException: the host seeded more than one context of type com.example.app.MyContext
java.lang.IllegalArgumentException: more than one per-run value of type com.example.auth.AppSession
The worse failure, the one this shape prevents entirely, has no error text: a unit reads a userId out of the POST body, acts as that user, and every test passes.
Example.
Host side:
@Inject SessionBridge sessions; // request-scoped; full source in the passthrough section below
Result r = engine.run(id, env,
new RequestImpl(params, Map.of(), parsedBody, "/" + id, method, null),
// the per-run map: facts about THIS caller, resolved by the host's own auth
sessions.local());
Unit side:
public class CounterApi implements InterlockHandler<Object> {
public Object handle(Request req, Interlock il) {
MyContext ctx = il.context(MyContext.class); // capability: same every run
AppSession me = il.session(AppSession.class); // fact: this caller, this request
// NOT req.str("userId") — a body naming somebody else must change nothing.
return ctx.add(me.userId(), req.integer("by", 1));
}
}
Verify. Assert it over HTTP, with a body that lies:
curl -s -X POST localhost:8099/app/counter/counter-api \
-H 'content-type: application/json' \
-d '{"action":"add","userId":"someone-else","by":1}' | jq .
# the counter that moves must be the SESSION's, never the one named by userId
Capabilities, never raw power #
The rule. What a host context exposes is capabilities. Never a DataSource, never an EntityManager, never anything that lets a unit write arbitrary SQL. Whatever a host seeds, ANY unit can use, including a JSX unit somebody authors in Studio five minutes from now.
Why it exists. The context map is not an internal API. It is a published surface with a hot-deploy path in front of it. A DataSource in the context is a DROP TABLE one interlock sync away, and the sync that does it looks exactly like every other sync.
The failure it prevents. There is no error text for this one, which is the point. A unit holding a raw handle fails silently and correctly right up until it does not. The nearest thing to a warning you will get is the one the SDK does emit, when a unit-local type crosses il.call:
il.call id:notes/NoteApi returned unit-local class notes.Draft — unit classes are per-importer; types crossing units should be host classes
Example.
// NO: raw power. Every unit in the project can now write any SQL.
InterlockSDK.init(key).context("db", dataSource).build();
// YES: a facade of named verbs. Each one is a decision the host already made.
public final class MyContext {
public long add(String key, int by) { ... }
public boolean reset(String key) { ... }
public String pref(String key, String name, String fallback) { ... }
public CounterView counter(String key) { ... }
}
InterlockSDK.init(key).context("app", new MyContext()).build();
Verify. Grep the seed, and read what a unit could reach through each type:
grep -rn "\.context(" --include='*.java' src/main/java | grep -v contextForGenerated
# for each seeded type, this must return nothing:
javap -p -cp build/classes/java/main com.example.app.MyContext \
| grep -E "DataSource|EntityManager|Connection|Session\("
The Session\( in that pattern is Hibernate's org.hibernate.Session, one more raw handle a facade must not hand out. It is not il.session(...), and it is not the per-request type of your own that Context versus session calls AppSession. If you named that type Session anyway, this audit will flag your own correct code, which is the second reason not to.
Prefer a narrow record over an entity #
The rule. When a unit needs facts about a row, prefer handing it a record carrying exactly those facts over handing it the row. This is a design recommendation, and it is the default a new facade method should start from.
It is no longer the only thing standing between a unit and a table. An entity does not cross this boundary unless you export it by name — a unit that names a non-exported entity is refused at compile. So the question is now two decisions deep, and a record answers both at once by making the entity never appear:
| question | page |
|---|---|
| may a unit reach this table at all? | The reach boundary: denied unless .export(Entity.class) |
| you exported it, so should this facade method return the row or a record? | here: prefer a record |
| it returns a row anyway, so what shape must that entity have? | THE ENTITY RULE: private fields, hand-written accessors |
Follow only this page and the entity rule never fires, because no unit ever names an entity — and now the reach boundary makes that the enforced default, not just good taste. Export a table and return records from it and you get the mutation safety of the reach boundary with the read-shape discipline of a record. Export it and hand back rows and the entity rule is what keeps those rows compiling across a packaged deploy. Each rule narrows the one below it.
Why it exists. An entity is a bundle: the fields you meant, the fields you forgot, and every method on it including delete(). A record is an answer to a question.
The worked example. A unit needs two facts about a stored counter before it decides anything: is there a row for that key at all, and what number does it hold. So the host exposes counter(key), and the record it returns has one field per question:
/**
* The two facts about a stored counter that a unit needs.
*
* @param found whether a row exists for that key
* @param value the count it holds; 0 when there is no row
*/
public record CounterView(boolean found, long value) { }
public CounterView counter(String key) {
Counter c = Counter.find("key", key).firstResult();
return c == null ? new CounterView(false, 0L) : new CounterView(true, c.getValue());
}
Handing over the Counter row would also have handed over every other column on it, its persistence lifecycle, and a delete().
The failure it prevents. Two, and only one of them has an error text. The one that does is the entity access failure the Quarkus page is about, which a record cannot produce because a record is not rewritten at build time:
java.lang.IllegalAccessError: tried to access protected field com.example.billing.Note.status
The one that does not is a unit reading a field nobody meant to publish, forever, silently. Choosing a record makes the first one unreachable for that method. It does not repeal the entity rule for the methods that still return rows.
Verify. This lists the entity types in the facade's public signature. It is an inventory, not a gate, and a non-empty result is not automatically a defect:
# each @Entity type in a context facade's public signature is a deliberate decision
javap -p -cp build/classes/java/main com.example.app.MyContext \
| grep -E "Counter|Note|Membership|Workspace\b"
Read the output as a worklist with two acceptable outcomes per line: narrow it to a record, or keep it and confirm the entity satisfies THE ENTITY RULE, which the two-second gate then proves for every unit that touches it. What is never acceptable is a row crossing the boundary that nobody made a decision about.
req.str reads the body too #
The rule. req.str(name, def) reads the query parameters first, then the JSON body. Query wins. The same precedence applies to integer, number and bool, all of which go through the same first(name) lookup.
Two members do not follow it, and the asymmetry is in the source: list(name) and has(name) read params only. A key that exists only in the body is invisible to req.has(...).
Why it exists. Query wins because it is the more specific address: it is in the URL a caller typed. Flipping the precedence would let a body silently override an explicit ?env= on a shared endpoint.
The failure it prevents, and it is the reason this rule is written this loudly. Before str read the body, every unit API silently ran its default branch on every POST. Well-formed request. 200 response. Wrong code path. Nothing anywhere reporting it. There is no error text. That is the whole problem.
It was found only because an end-to-end check asserted on the EFFECT, "did the stored number actually change?", rather than on the status code.
Example.
public Object handle(Request req, Interlock il) {
// reads ?action=add AND {"action":"add"}; the query form wins if both are present
String action = req.str("action", "read");
return switch (action) {
case "add" -> add(req, il);
case "read" -> read(req, il);
default -> throw new Refusal(400, "unknown-action", "no action called " + action);
};
}
Verify. Send the same field two ways and check the branch actually taken:
curl -s -X POST 'localhost:8099/app/counter/counter-api' \
-H 'content-type: application/json' -d '{"action":"add","by":5}' | jq .
# query must win over body:
curl -s -X POST 'localhost:8099/app/counter/counter-api?action=read' \
-H 'content-type: application/json' -d '{"action":"add","by":5}' | jq .
Read the two bodies, not the two status codes. The first must show the add branch, the second the read branch. Identical output from both is the failure this rule exists for.
# MAINTAINER-ONLY (Interlock repository checkout, not your host):
./gradlew :interlock-java-sdk:test --tests '*RequestBodyTest*'
The full Request surface #
The rule. This is the whole interface. There is nothing else.
| member | returns | reads |
|---|---|---|
str(String name, String def) | String | query, then body |
str(String name) | String | as above, default null |
integer(String name, int def) | int | query, then body; unparseable gives def |
number(String name, double def) | double | query, then body; unparseable gives def |
bool(String name, boolean def) | boolean | query, then body; true for true/1/yes/on |
list(String name) | List<String> | query only; a single comma-joined value is split |
has(String name) | boolean | query only |
header(String name) | String | headers, name lower-cased |
cookie(String name) | String | parsed out of header("cookie") |
path() | String | the request path |
method() | String | GET, POST, or CALL for an il.call |
body() | Map<String,Object> | the parsed JSON body, never null |
principal() | String | the host-defined authenticated principal, or null |
mark(String label) | void | a timing checkpoint; no-op in v1 |
There is no 64-bit reader, and number is not a substitute. integer returns int and number returns double; nothing on the interface returns long. Casting the double is a silent precision-loss bug above 2^53, which is inside the range of an ordinary cents amount or a snowflake id. For a 64-bit value, read the text and parse it:
// WRONG: doubles lose the low bits of a large amount, and nothing reports it.
long amountCents = (long) req.number("amountCents", -1);
// RIGHT: read it as a string and parse, defaulting for yourself.
String raw = req.str("amountCents", null);
long amountCents = raw == null ? -1L : Long.parseLong(raw.trim());
Long.parseLong throws on rubbish, where number would have returned the default. Catch it and throw new Refusal(400, "bad-amount", ...) if a malformed value should be a refusal rather than a 500.
Why it exists as a table. Because list and has not reading the body is the kind of asymmetry that gets assumed away, and assuming it away produces a unit that works on GET and quietly does nothing on POST.
The failure it prevents.
// WRONG on a POST: has() never sees a body-only key, so this always takes the else branch.
if (req.has("tags")) { ... } else { ... }
// RIGHT: read the value and test it.
String tags = req.str("tags", null);
if (tags != null) { ... }
Note on headers. principal() is host-supplied; Interlock does not own your session. The host decides what reaches header(...) at all. Strip cookie and authorization from the header map before constructing a RequestImpl if you do not want a unit reading your session cookie through req.cookie(...). The reference passthrough below passes Map.of() for headers, which is the strictest option.
Verify. Read the interface off the SDK jar your own host already ships. If the table above and this output disagree, the output wins:
SDK=$(ls build/quarkus-app/lib/main/*interlock-java-sdk*.jar)
javap -cp "$SDK" sh.interlock.sdk.Request
And prove the list/has asymmetry over the wire rather than trusting the table:
curl -s -X POST 'localhost:8099/app/counter/counter-api' \
-H 'content-type: application/json' -d '{"tags":"a,b"}' | jq .
# a handler branching on req.has("tags") takes the ELSE branch here; one branching on
# req.str("tags", null) does not. That difference is the rule.
Result.status and Refusal #
The rule. A unit says no by throwing Refusal, not by returning a map:
throw new Refusal(409, "cooldown", "you just got in touch — give them a moment");
new Refusal(reason, message) defaults the status to 409. The engine catches it in runUnit and turns it into Result.json(refusal.body()).withStatus(refusal.status()), where the body is:
{ "ok": false, "reason": "cooldown", "error": "you just got in touch — give them a moment" }
reason is a stable machine-readable token a UI branches on, never a sentence. error is the sentence a person reads.
401 is the one the passthrough makes common. SessionBridge below hands every request an AppSession, anonymous included, so a unit that needs a signed-in caller finds one that is not signed in rather than a missing session. That is a refusal, and it is 401, not 409: a unit reading session.userId() == null should throw new Refusal(401, "signed-out", "sign in to do that"). Use 403 when the caller is known and still not allowed. The other three, from the constructor's own javadoc: 409 for "the world says no", 404 for "not yours or not there", 400 for "that request does not make sense".
Result.status is advisory, not authoritative. The host maps it, and SHOULD clamp anything outside a sane range, because it is a hot-deployable value on the response path.
An il.call from another unit sees the exception, not a body. Engine.invoke does not catch Refusal; only runUnit does. That is the honest shape: a refusal is not a value a caller should be able to mistake for data.
Why it exists. A unit that could only answer 200 silently turns every "no" into an apparent success. It is thrown rather than returned so it cannot be forgotten in the middle of a method the way a status field can.
The failure it prevents.
// WRONG: a client that ignores the body believes the increment went through.
return Map.of("ok", false, "reason", "cooldown"); // → HTTP 200
// RIGHT
throw new Refusal(409, "cooldown", "you just got in touch"); // → HTTP 409
A sharp edge, verified in the source today. The samples/taskman reference host builds its response with Response.ok(...) and ignores r.status entirely, so every Refusal it serves comes back as HTTP 200 with a refusal body. teamlake-api's AppResource does honour it, with the clamp. If you copied the taskman passthrough, you have this bug. Use the version below.
Verify. Over the wire, on the status line, not the body. This is the check that catches a passthrough which ignores r.status:
curl -s -o /dev/null -w '%{http_code}\n' -X POST localhost:8099/app/counter/counter-api \
-H 'content-type: application/json' -d '{"action":"add","by":5}'
# repeat immediately, inside the cooldown window: the second call must print 409, not 200
# and the passthrough itself: a host that never reads r.status has this bug by construction
grep -n "r.status\|Response.ok" src/main/java/**/AppResource.java
# MAINTAINER-ONLY (Interlock repository checkout, not your host):
./gradlew :interlock-java-sdk:test --tests '*RefusalTest*'
The /app passthrough, in full #
The rule. Serve every unit through one generic resource. @Path("/app/{id:.+}"), with .+ so that ids containing slashes (counter/counter-api) match.
Why it exists. Ids are paths. A host that declares one JAX-RS route per unit has re-introduced the deploy step that units exist to remove.
The failure it prevents. Using {id} instead of {id:.+} matches one segment only, so every unit in a subdirectory 404s. Since code/<dir>/<Unit>.java is the normal shape, that is almost all of them:
No 'counter/counter-api' unit published yet.
Example. Two classes, and between them there is nothing left for you to invent: the resource, with the status clamp, the provenance header, the cache header and the three failure branches that matter, and the one bean it injects. The imports are part of the example, because reconstructing them across JAX-RS, CDI, MicroProfile Config, JBoss Logging and two Interlock packages is its own afternoon. The split that catches people: Engine, Result, RequestImpl and both exception types are in sh.interlock.sdk.runtime, while Json is in sh.interlock.sdk.
package com.example.app.web;
import sh.interlock.sdk.Json;
import sh.interlock.sdk.runtime.CodeNotFoundException;
import sh.interlock.sdk.runtime.Engine;
import sh.interlock.sdk.runtime.EngineException;
import sh.interlock.sdk.runtime.RequestImpl;
import sh.interlock.sdk.runtime.Result;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.UriInfo;
import org.eclipse.microprofile.config.inject.ConfigProperty;
import org.jboss.logging.Logger;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@Path("/")
public class AppResource {
private static final Logger LOG = Logger.getLogger(AppResource.class);
@Inject Engine engine;
/** Who is asking, for the unit about to run. Source below, and it is the only piece you write. */
@Inject SessionBridge sessions;
/** A real production host pins prod, so only a promotion can change running code. */
@ConfigProperty(name = "interlock.env", defaultValue = "prod")
String env;
@GET @Path("/app/{id:.+}")
public Response get(@PathParam("id") String id, @Context UriInfo uriInfo) {
return run(id, uriInfo, "GET", null);
}
@POST @Path("/app/{id:.+}")
public Response post(@PathParam("id") String id, @Context UriInfo uriInfo, String body) {
return run(id, uriInfo, "POST", body);
}
private Response run(String id, UriInfo uriInfo, String method, String body) {
try {
Map<String, List<String>> params = new LinkedHashMap<>();
uriInfo.getQueryParameters().forEach((k, v) -> params.put(k, new ArrayList<>(v)));
Map<String, Object> parsed = Map.of();
if (body != null && !body.isBlank()) {
try {
parsed = Json.toMap(body);
} catch (Exception ignore) {
// A non-JSON body is legitimate; the unit decides what to do with it.
}
}
// Headers are Map.of(): a unit never sees cookie or authorization.
// The fourth argument is the per-run map that il.session(Class) reads. The bridge
// returns the map already built, so there is exactly one place that decides its key.
Result r = engine.run(id, env,
new RequestImpl(params, Map.of(), parsed, "/" + id, method, null),
sessions.local());
// A unit that refused chose its own status. Serving that as 200 would let a client
// that ignores the body believe it got through. Clamped, because this is a
// hot-deployable value on the response path.
int status = r.status >= 200 && r.status < 600 ? r.status : 200;
Response.ResponseBuilder rb = switch (r.kind) {
case HTML -> Response.status(status).entity(r.text).type("text/html; charset=utf-8");
case TEXT -> Response.status(status).entity(r.text).type(r.contentType);
case JSON -> Response.status(status).entity(Json.toJson(r.value)).type("application/json");
};
if (r.provenance() != null) {
rb.header("X-Interlock-Unit", r.provenance()); // "id@vN (env)"
}
// Without this, browsers heuristically cache HTML and a reload after a mutation
// can serve the PRE-mutation page.
rb.header("Cache-Control", "no-store, must-revalidate");
return rb.build();
} catch (CodeNotFoundException e) {
LOG.warn("app unit missing id:" + id + " — sync or author it in Studio");
return Response.status(404).type(MediaType.TEXT_PLAIN)
.entity("No '" + id + "' unit published yet.").build();
} catch (EngineException e) {
// The UNIT failed (compile error, threw, timed out). That is the unit author's bug,
// not a host crash. Detail in dev; a clean message everywhere else.
LOG.warn("app unit-error id:" + id + " — " + e.getMessage());
String detail = "dev".equals(env)
? "Unit '" + id + "' failed:\n" + e.getMessage()
: "This experience is temporarily unavailable.";
return Response.status(500).type(MediaType.TEXT_PLAIN).entity(detail).build();
}
}
}
And the bean it injects. AppSession here is your own type, whatever your auth already produces:
package com.example.app.web;
import jakarta.enterprise.context.RequestScoped;
import jakarta.inject.Inject;
import java.util.Map;
// AppSession and CurrentUser are your own types. Import them from wherever they already live.
/**
* The one place a request becomes something a unit can read.
*
* <p>It is a separate bean rather than a method on the resource because the passthrough is not the
* only caller. The moment host code invokes a unit itself, that call needs a session too, and a
* unit that has identity over HTTP and silently none when reached internally presents as
* "it works in my browser".
*/
@RequestScoped
public class SessionBridge {
/** The key the session travels under. Units never see it: il.session(Class) matches on TYPE. */
private static final String KEY = "session";
/** Whatever your host already uses to answer "who is this request". */
@Inject CurrentUser current;
/**
* Never throws and never returns null. A public page rendered for nobody is a normal state,
* not an error, so a signed-out visitor gets an anonymous session rather than none, and a unit
* never has to tell "not passed" apart from "not signed in".
*/
public AppSession session() {
return current.user().map(AppSession::of).orElseGet(AppSession::anonymous);
}
/** The per-run map an engine.run carries. Built here so nothing else names the key. */
public Map<String, Object> local() {
return Map.of(KEY, session());
}
}
That is the whole passthrough. The only things left to your host are the two types this page cannot write for you, and both are yours already: CurrentUser, your existing auth, and AppSession, the record you want units to read. A real host usually adds one overload, local(String scopeId), when a caller can name which tenant or workspace they are asking about; the reference host does exactly that. Keep one arity per host so a reader never has to guess which one a snippet means.
Result.kind is exactly JSON, HTML, TEXT. A unit returning Html gives HTML; a bare String gives TEXT with text/plain; charset=utf-8 (deliberately not a JSON-quoted document); everything else gives JSON.
Verify.
curl -si localhost:8099/app/counter/counter-api | head -5
# the provenance header proves which unit and which version served it:
curl -si localhost:8099/app/counter/counter-api | grep -i x-interlock-unit
# a nested id must resolve — this is the {id:.+} check:
curl -s -o /dev/null -w '%{http_code}\n' localhost:8099/app/hello/hello-api
Assert effects, not status codes #
Assert effects, not status codes.
A status code tells you the host answered. It does not tell you the unit did the right thing, or anything at all. When
req.str()did not read the JSON body, every unit API in a production host silently ran its default branch on every POST: well-formed request, 200 response, wrong code path, and no log line anywhere. Every status-code assertion in the suite stayed green.It was found by one check that asked a different question: did the stored number actually change?
The rule. An end-to-end assertion names the effect in the world, then goes and looks for it.
Why it exists. A 200 is produced by the host, not by the unit's logic. Any bug that changes which branch a unit takes without throwing is invisible to a status assertion, and "took the wrong branch quietly" is the single most common unit defect.
The failure it prevents. It has no error text. That is exactly why it needs its own callout.
Example.
// WEAK: passes even when the unit ran a completely different branch.
given().body(Map.of("action", "add", "by", 5))
.post("/app/counter/counter-api")
.then().statusCode(200);
// STRONG: name the effect, then look for it.
given().body(Map.of("action", "add", "by", 5))
.post("/app/counter/counter-api")
.then().statusCode(200);
given().queryParam("action", "read").get("/app/counter/counter-api")
.then()
.body("count", equalTo(5))
.body("owner", equalTo(SESSION_USER)); // from the session, not the body
// And the refusal path, on the status line, because a client that ignores the body must fail.
// A second add inside the cooldown window:
given().body(Map.of("action", "add", "by", 5))
.post("/app/counter/counter-api")
.then().statusCode(409).body("reason", equalTo("cooldown"));
Verify. The rule for your own suite: for every mutating endpoint, there must be a later assertion that reads the state back. A grep that finds statusCode(200) with no read-back in the same test is a test that cannot catch this class of bug.
grep -rn "statusCode(200)" src/test/java | wc -l
grep -rln "statusCode(200)" src/test/java | xargs grep -Ln "get\(|body\(\""
# the second command lists tests that assert a status and never read anything back
What is measured and what is inferred #
| claim | status |
|---|---|
| Reserved package list and both refusal texts | MEASURED: read from JavaRunner source, pinned by UnitBoundaryTest |
| Generated units compile against an SDK-only classpath | MEASURED: JavaRunner.classpath(reduced) returns before adding host entries; pinned by UnitBoundaryTest |
req.str precedence (query, then body) | MEASURED: RequestImpl.first(name); pinned by RequestBodyTest |
list/has read query only | MEASURED: RequestImpl.list and has read params directly |
Refusal body shape and default 409 | MEASURED: Refusal.body() and the two-arg constructor |
il.call sees the Refusal exception, not a body | MEASURED: Engine.invoke has no Refusal catch; only runUnit does |
taskman's sample passthrough drops Result.status | MEASURED: samples/taskman/.../AppResource.java uses Response.ok(...) |
A DataSource in the context is a DROP TABLE one sync away | INFERRED: architectural, no failing case on record |
Companion page: Quarkus and Panache entities, which covers the entity rule, the twice-built bytecode, and the custody line. If your host uses Panache, read it before you write your first unit.