# Embedding the SDK in a host

## 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

1. **Add the SDK** to your host build: `sh.interlock:interlock-java-sdk:0.1.0-SNAPSHOT`.
2. **Call `InterlockSDK.init(key)` once** and produce the `Engine` as an application-scoped bean.
3. **Seed a plain instance** as context. Not an injected bean.
4. **Serve every unit** through one route: `@Path("/app/{id:.+}")`.

```java
@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.jar` and your app jar never arrives. Keep the seed and add
  `Engine.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 `contextForGenerated` if 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`/`find` operate 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](#the-reach-boundary).

### Then read

- [The wiring](#the-wiring) for every builder slot and its default.
- [Seeding a CDI bean anchors the wrong jar](#seeding-a-cdi-bean-anchors-the-wrong-jar) if a unit
  cannot see your package.
- [The reach boundary](#the-reach-boundary) to decide which of your tables units may touch.
- [The /app passthrough, in full](#the-app-passthrough-in-full) for the resource you copy.
- [Quarkus and Panache entities](quarkus.md) before your first unit, if your host uses Panache.

## 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](quarkus.md).

**Read that page too if your host uses Panache entities.** It is not optional reading, and it is
where the days went.

- [The wiring](#the-wiring)
- [Compile anchors](#compile-anchors)
- [Seeding a CDI bean anchors the wrong jar](#seeding-a-cdi-bean-anchors-the-wrong-jar)
- [What is on a unit's compile classpath](#what-is-on-a-units-compile-classpath)
- [Engine.unitClass, the testing door](#engineunitclass-the-testing-door)
- [DirectoryCodeSource, offline and from a git checkout](#directorycodesource-offline-and-from-a-git-checkout)
- [Trust: authored versus generated](#trust-authored-versus-generated)
- [The shadow boundary](#the-shadow-boundary)
- [The reach boundary](#the-reach-boundary)
- [Context versus session](#context-versus-session)
- [Capabilities, never raw power](#capabilities-never-raw-power)
- [Prefer a narrow record over an entity](#prefer-a-narrow-record-over-an-entity)
- [req.str reads the body too](#reqstr-reads-the-body-too)
- [The full Request surface](#the-full-request-surface)
- [Result.status and Refusal](#resultstatus-and-refusal)
- [The /app passthrough, in full](#the-app-passthrough-in-full)
- [Assert effects, not status codes](#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](/reference#interlocksdk). 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.**

```java
@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.**

```bash
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](#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](#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.

```java
// 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:

```bash
# 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:

```java
Engine.addCompileAnchor(MyApp.class);   // a REAL class, not whatever getClass() says
```

**Why it exists.** `InterlockSDK.build()` anchors `getClass()` of every seeded value, verbatim:

```java
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.**

```java
@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();
}
```

```java
@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:

```bash
# 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](#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 may `import io.quarkus.narayana.jta.QuarkusTransaction` on a packaged Quarkus host, and
  that is why [the custody line](quarkus.md#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.interlock` and your own packages. It may not *declare* a class into any of them.
  That is [the shadow boundary](#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.path`
  is copied verbatim, so whatever the process was launched with is what javac sees. Under
  `./gradlew test` that includes `build/classes/java/test` and 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](testing.md#this-test-does-not-replace-the-packaged-gate-and-cannot).

**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:

```java
// 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:

```bash
# 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.

```bash
# 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.**

```java
@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.**

```bash
./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:

1. **Everything it returns is `authored`.** A plain directory cannot record `generated`, so it must
   not accidentally grant generated code full capability. Never point this at a directory where
   machine-generated units land.
2. **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.**

```java
// 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.**

```bash
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.

```bash
# 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.**

```java
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:

```java
@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);
}
```

```bash
./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.

```bash
# 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.Whatever` when the host does not already have that exact name.
  The reserved list is what refuses it.
- **Replacing** a class the host already loaded. `MemoryClassLoader` is 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.**

```java
// 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:

```bash
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.

```bash
# 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](#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:

```java
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](#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:

```bash
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
```

```bash
# 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:

```java
@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:

```java
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:

```bash
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.**

```java
// 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:

```bash
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](#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](#the-reach-boundary) — 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](#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](quarkus.md#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:

```java
/**
 * 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:

```bash
# 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](quarkus.md#the-entity-rule), which the
[two-second gate](testing.md#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.**

```java
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:

```bash
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.

```bash
# 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:

```java
// 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.**

```java
// 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:

```bash
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:

```bash
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:

```java
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:

```json
{ "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.**

```java
// 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`:

```bash
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
```

```bash
# 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
```

```bash
# 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`.

```java
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:

```java
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.**

```bash
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.**

```java
// 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.

```bash
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](quarkus.md), 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.*
