# Reference

## For humans

**Use this to** look up a type, method or signature whose name you already have.
**Do not read it end to end.** A guide page will teach you more, then send you back here.

### Jump to

- [Request](#request) for what a unit reads out of a call.
- [Store](#store) for the per-unit persistence surface.
- [Result](#result) for what a handler may return.
- [Host-side API](#host-side-api) for the types a host wires up.
- [Packages at a glance](#packages-at-a-glance) for the import line of any type here.

### What will bite you

- **The page has two halves.** Unit-side and host-side types live in different packages. Taking one
  from the wrong half is the usual way a first afternoon goes sideways.
- **This page breaks ties.** Every signature was read off the SDK source. When a guide page
  disagrees, believe this one and treat the other as out of date.

## For robots

Flat lookup for the whole SDK: the surface a **unit** can touch, and the surface a **host** embeds.
Every signature here was read off the SDK source, not off a design document. If a page elsewhere on
this site disagrees with this one, this one is right.

Package: `sh.interlock.sdk` unless noted. `Result` is `sh.interlock.sdk.runtime.Result`. The
host-side types are split across two packages; see [Packages at a glance](#packages-at-a-glance) for
the import line of every type on this page.

Unit-side:

- [Request](#request)
- [Interlock (il)](#interlock-il)
- [Store](#store)
- [Result](#result)
- [Refusal](#refusal)
- [Java handler contract](#java-handler-contract)
- [JS/JSX handler contract](#jsjsx-handler-contract)
- [Languages and ids](#languages-and-ids)

Host-side:

- [Host-side API](#host-side-api)
- [Packages at a glance](#packages-at-a-glance)
- [InterlockSDK](#interlocksdk)
- [Brokered challenge solving](#brokered-challenge-solving)
- [Tasks clients](#tasks-clients)
- [Engine](#engine)
- [CodeSource and its implementations](#codesource-and-its-implementations)
- [StoreFactory, SecretResolver, Ai](#storefactory-secretresolver-ai)
- [ContextProvider](#contextprovider)
- [Json, Html and the host-side errors](#json-html-and-the-host-side-errors)

## Request

`sh.interlock.sdk.Request`. The read surface for a request, uniform across Java and JS. The
implementation described below is `sh.interlock.sdk.runtime.RequestImpl`.

| Signature | Returns | Reads |
|---|---|---|
| `String str(String name, String def)` | the value, or `def` | query param, then JSON body |
| `String str(String name)` | the value, or `null` | query param, then JSON body |
| `int integer(String name, int def)` | parsed int, or `def` | query param, then JSON body |
| `double number(String name, double def)` | parsed double, or `def` | query param, then JSON body |
| `boolean bool(String name, boolean def)` | parsed boolean, or `def` | query param, then JSON body |
| `List<String> list(String name)` | never null, empty when absent | **query params only** |
| `boolean has(String name)` | whether the name is present | **query params only** |
| `String header(String name)` | header value, or `null` | headers, case-insensitive |
| `String cookie(String name)` | cookie value, or `null` | the `cookie` header |
| `String path()` | the request path | |
| `String method()` | the HTTP method | |
| `Map<String, Object> body()` | never null, `{}` when absent | the parsed JSON body |
| `String principal()` | host-defined principal, or `null` | |
| `void mark(String label)` | | |

### The body rule

**`str`, `integer`, `number` and `bool` read the query parameter first and then the JSON body.** A
value in either place is found; on a tie the query wins, because it is the more specific address (it
is in the URL the caller typed) and because the reverse precedence would let a body silently override
an explicit `?env=` on a shared endpoint.

**`has` and `list` do not consult the body.** They read the query parameter map only. Guarding a
body-carried field with `req.has(name)` therefore fails; test `req.str(name) != null` instead, or
read `req.body()` directly for a body-carried collection. See
[req.has and req.list do not read the body](/failures#reqhas-and-reqlist-do-not-read-the-body).

### Parsing details

| Reader | Behaviour |
|---|---|
| `integer` / `number` | the value is trimmed and parsed; a parse failure returns `def`, it does not throw |
| 64-bit values | **there is no `long` reader.** `number` returns a `double`, so casting it loses the low bits above 2^53. Read `str(name, null)` and `Long.parseLong` it |
| `bool` | true for `true`, `1`, `yes`, `on` (trimmed, case-insensitive); any other present value is false; absent returns `def` |
| `list` | a single value containing commas is split on `,` and trimmed, with empties dropped; repeated params are returned as an unmodifiable list |
| `header` | names are matched lower-cased, so `req.header("Content-Type")` and `req.header("content-type")` are the same lookup |
| `cookie` | parses `header("cookie")`, splitting on `;` then on the first `=`, with an exact name match |
| `mark` | declared as a timing and diagnostics checkpoint; the runtime implementation is currently a no-op |

### Requests made by il.call

`il.call(id, params)` builds a request from the params map. Each entry is placed in **both** the
query map (stringified) and the body, so every reader above finds it. On such a request
`path()` is `/call` and `method()` is `CALL`.

## Interlock (il)

`sh.interlock.sdk.Interlock`. The capability gateway handed to every unit: the one controlled surface
to reach anything external.

| Signature | Notes |
|---|---|
| `Store store()` | this unit's persistent namespace |
| `Store store(String codeId)` | another unit's namespace, same env |
| `String secret(String name)` | host secret resolved at runtime, `null` if unset; never in source |
| `Object call(String id, Map<String, Object> params)` | invoke another unit, return its raw value |
| `String url(String id, Map<String, Object> params)` | build a stable URL for a unit by id |
| `void log(Object... args)` | one line, streamed to any watching client |
| `Ai ai()` | generative AI; `chat(model, prompt)` and `chat(prompt)` are the core |
| `Geo geo()` | defaulted; a host that wires nothing returns `Geo.Location.UNKNOWN` |
| `HostContext context()` | never null, empty when the host provides none |
| `Object context(String key)` | one context value, or `null` |
| `<T> T context(Class<T> type)` | the typed door, and the one a Java unit should use |
| `<T> T session(Class<T> type)` | the per-run value of this type, or `null` |
| `String unitId()` | id of the unit currently executing; defaults to `null` |
| `String unitEnv()` | env of this run; defaults to `null` |
| `int unitVersion()` | stored version of this unit, `0` when unknown |

### context versus session

`context(Class)` is seeded once at init and is the same for every run. `session(Class)` is handed in
per invocation. They are deliberately separate objects: a unit that asks for a session must not
silently receive a process-wide object, and a value that changes every request must not be reachable
through the accessor for values that never do.

`context(Class)` throws `IllegalArgumentException` when the host seeded more than one object
assignable to the type. That is a host bug, raised rather than resolved by picking one arbitrarily.
See [more than one context of that type](/failures#more-than-one-context-of-that-type).

### HostContext

`sh.interlock.sdk.HostContext`. What `il.context()` returns. Read-only: the host seeds a plain `Map`
at init, and a unit gets this accessor rather than the map, with `Supplier` values resolved lazily on
the key actually read.

| Signature | Notes |
|---|---|
| `Object get(String key)` | `Supplier`-resolved; `null` if absent |
| `boolean has(String key)` | true when the host provided the key, even if its value is null |
| `Iterable<String> keys()` | names only, no values materialized |
| `boolean isEmpty()` | true when the host provided no context at all |

From JS it reads as an object (`il.context().db`); from Java as `il.context().get("db")`, or better,
`il.context(Db.class)`.

## Store

`sh.interlock.sdk.Store`. A per-unit key-value namespace. **Reads return the value directly.** There
are no wrappers, and the storage implementation never leaks.

A host implements only the five fundamentals; the typed getters and bulk put are interface defaults
derived from them. A host may override a default when the backing store can do it cheaper.

| Signature | Kind | Returns |
|---|---|---|
| `Object get(String key)` | fundamental | the value, or `null` |
| `Map<String, Object> all()` | fundamental | the whole namespace |
| `void put(String key, Object value)` | fundamental | |
| `void remove(String key)` | fundamental | |
| `void clear()` | fundamental | |
| `<T> T get(String key, Class<T> type)` | default | an unchecked cast of `get(key)`; no conversion |
| `String getString(String key)` | default | `String.valueOf(value)`, or `null` |
| `Map<String, Object> getMap(String key)` | default | the value when it is a `Map`, else `null` |
| `List<Object> getList(String key)` | default | a **copy** of the value when it is a `List`, else `null` |
| `void putAll(Map<String, Object> values)` | default | null-tolerant; puts each entry |

### Java

```java
var store = il.store();
store.put("counts", Map.of("a", 1, "b", 2));
Map<String, Object> counts = store.getMap("counts");   // the value, directly
List<Object> names = store.getList("names");           // a copy, safe to mutate
Long total = store.get("total", Long.class);
Map<String, Object> all = new HashMap<>(store.all());
store.putAll(all);

// another unit's namespace, by id
Object hits = il.store("lib/Counter").get("hits");
```

### JavaScript

The same object, reached across the polyglot boundary, so the methods and their names are identical.
`get(key, Class)` is the one entry with no natural JS form.

```js
const store = il.store();
store.putAll({ variant: 'B', at: Date.now() });
const variant = store.get('variant');        // 'B'
const all = store.all();
store.remove('at');

const hits = il.store('lib/Counter').get('hits');
```

Cross-namespace access is deliberate coordination, not a back door. Do not scribble where you do not
own the namespace.

## Result

`sh.interlock.sdk.runtime.Result`. What a unit produced, plus how to serve it. Units usually do not
construct one: the engine converts whatever the handler returned (see the conversion table below).

| Member | Type | Notes |
|---|---|---|
| `kind` | `Result.Kind` | `JSON`, `HTML` or `TEXT` |
| `value` | `Object` | set for `JSON` |
| `text` | `String` | set for `HTML` and `TEXT` |
| `contentType` | `String` | |
| `status` | `int` | **defaults to 200** |
| `unitId`, `unitEnv`, `unitVersion` | `String`, `String`, `int` | provenance, set by the engine |
| `Result withStatus(int status)` | `Result` | returns `this` for chaining |
| `String provenance()` | `String` | `id@vN (env)`, or `null` when there is no provenance |
| `static Result json(Object value)` | `Result` | content type `application/json` |
| `static Result html(String markup)` | `Result` | content type `text/html; charset=utf-8` |
| `static Result text(String body, String contentType)` | `Result` | |

**`status` is advisory, not authoritative.** The host maps it, is free to clamp or ignore it, and
should ignore anything outside a sane range, because this is a hot-deployable value on the response
path.

### How a returned value becomes a Result

| The handler returned | Becomes | Content type |
|---|---|---|
| an `Html` | `Result.html(markup)` | `text/html; charset=utf-8` |
| a bare `String` | `Result.text(...)` | `text/plain; charset=utf-8` |
| anything else (`Map`, `List`, POJO, number) | `Result.json(value)` | `application/json` |

A bare `String` is plain text, not a JSON document: it serves without JSON quoting. Structured data
stays JSON.

Static-language units are served from their source: `.html` as `text/html; charset=utf-8`, `.css` as
`text/css; charset=utf-8`, `.md` as `text/markdown; charset=utf-8`, anything else as
`text/plain; charset=utf-8`.

## Refusal

`sh.interlock.sdk.Refusal extends RuntimeException`. A unit saying no, with the status a host should
serve it as.

| Signature | Notes |
|---|---|
| `Refusal(int status, String reason, String message)` | |
| `Refusal(String reason, String message)` | **status defaults to 409** |
| `int status()` | |
| `String reason()` | a stable machine-readable token a UI can branch on, never a sentence |
| `Map<String, Object> body()` | the served body, described below |

`body()` is a `LinkedHashMap` in this order:

```json
{"ok": false, "reason": "cooldown", "error": "you just got in touch"}
```

`error` is the exception's message. `reason` is the constructor's `reason`.

```java
if (msSinceLast < COOLDOWN_MS) {
    throw new Refusal(409, "cooldown", "you just got in touch");
}
```

Suggested statuses, from the constructor's own documentation: `409` for "the world says no", `404`
for "not yours or not there", `400` for "that request does not make sense".

Two more the javadoc does not list, and the first is common enough to state: `401` when the unit
needs a signed-in caller and `il.session(...)` gave it an anonymous one, which is the normal shape in
a passthrough that also serves public pages; `403` when the caller is known and still not allowed.

**Thrown, not returned**, so it cannot be forgotten in the middle of a method the way a status field
can.

- **Over HTTP:** the engine catches it on the single execution path, for every language and every
  entry point, and serves `Result.json(body()).withStatus(status())` with the unit's provenance
  attached. Hosts do not have to remember to do this.
- **Through `il.call`:** the exception itself reaches the calling unit. That is the honest shape,
  because a refusal is not a value a caller should be able to mistake for data.

Returning a map with `ok:false` remains correct when "no" is an ordinary outcome. Use `Refusal` when a
client that ignores the body must still fail. See [a refusal is served as 200](/failures#a-refusal-is-served-as-200).

## Java handler contract

`sh.interlock.sdk.InterlockHandler<R>` is a `@FunctionalInterface` with one method:

```java
R handle(Request req, Interlock il);
```

A unit is one file with one `public class` implementing it. The returned value is the response.

```java
package caller;

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

import java.util.Map;

public class UseIt implements InterlockHandler<Object> {
  public Object handle(Request req, Interlock il) {
    il.log("running");
    return Map.of("ok", true, "name", req.str("name", "world"));
  }
}
```

Prefer a typed `R` over `Object` or `Map`. The interface between a unit and the JVM should be a dumb
POJO.

### Rules the compiler enforces

| Rule | What happens when you break it |
|---|---|
| **Directory is package.** `code/caller/UseIt.java` declares `package caller;`, or declares nothing. Enforced only when the unit declares a package, so package-less units keep working. | [declared package does not match the directory](/failures#declared-package-does-not-match-the-directory) |
| **Reserved packages.** A unit may not *declare* a class into `java`, `javax`, `jdk`, `sun`, `com.sun`, `sh.interlock`, `io.quarkus`, `jakarta`, or any host package (auto-reserved from the top two segments of each compile anchor). Importing from them is fine. Refused at compile **and** at load. | [package is reserved](/failures#package-is-reserved-compile) |
| **Handler discovery is by attribution.** Only a class compiled from this unit's own source can be its handler: the first non-interface, non-abstract one assignable to `InterlockHandler`. A dependency's handler classes land in the same loader, so an interface scan would be a lottery. | the wrong class would run |
| **No handler means a library unit.** It compiles and it is importable, and running it is refused with a sentence. | [unit is a library and cannot run](/failures#unit-is-a-library-and-cannot-run) |
| **Generated units are never importable.** They stay behind `il.call`, where reduced trust holds. | [a sibling import does not resolve](/failures#a-sibling-import-does-not-resolve) |

Helpers can be package-private classes in the same file. Java units are project-trusted code and are
**not** hard-capped by the run watchdog.

To reach a Java unit's class reflectively (the testing door, and the only way to touch a library
unit): `Engine.unitClass(id, env)`.

## JS/JSX handler contract

### .js

The default export is the handler, and it must be executable.

```js
export default (req, il) => {
  il.log('running');
  return { ok: true, name: req.str('name', 'world') };   // object or array becomes JSON
};
```

- A global `html(markup)` function is bound in every JS run and returns the same `Html` the Java side
  returns, so a unit can serve server-built markup.
- `console.log` is routed to the run's log stream and reaches any watching client.
- Every run gets a **fresh context** and a wall-clock watchdog. The default cap is 30000 ms and the
  host can change it. See [unit timed out](/failures#unit-timed-out).
- No default export, or a non-executable one, is
  [refused](/failures#js-unit-has-no-default-export).

### .jsx

The default export is the **root React component**; the runtime transpiles server-side, renders, and
serves a full HTML page.

```jsx
import React, { useState, useEffect } from 'react';
import { Total } from 'counter/Total';   // a sibling unit, imported by id
import 'counter/styles.css';             // CSS by id

export default function App() {
  const [count, setCount] = useState(null);
  useEffect(() => { il.call('counter/api', { by: 1 }).then(setCount); }, []);
  if (count === null) return <p>Loading…</p>;
  return <Total count={count} />;
}
```

- **Imports resolve by id, in the same env**, never by relative path. React itself is a real import
  provided by the runtime, not a CDN global.
- CSS is imported by id with its extension, because asset ids keep their extension.
- The import walk is bounded at **16** levels, which in practice catches cycles. See
  [import graph is too deep](/failures#import-graph-is-too-deep).
- The same watchdog and timeout apply as for `.js`.

### .html

Served as-is.

## Languages and ids

Known languages: `js`, `jsx`, `java`, `html`, `css`, `md`, `txt`.

**Ids are paths.** `code/world/WorldApi.java` has id `world/WorldApi`. Text ids drop the
extension; **assets keep it** (`assets/logo.png` stays `assets/logo.png`), because otherwise
`logo.png` and `logo.webp` would collide into one unit. camelCase ids also resolve kebab-case in
URLs, so `world/WorldApi` and `world/world-api` address the same unit.

Envs are `dev`, `staging` and `prod`, moved by promotion and never by re-syncing.

## Host-side API

Everything above is what a **unit** sees. Everything below is what a **host** writes: the types you
import into your own application to stand the engine up and serve units over HTTP. Two audiences,
two type sets, deliberately different. `Interlock` is handed to a unit; `InterlockSDK` is called by
a host. A host never implements `Interlock`, and a unit never sees `Engine`.

`Request` and `Result` are the two types both sides touch: the host builds or adapts a `Request`, the
engine hands a `Result` back. Both are documented above.

### Packages at a glance

Every type on this page, with the package to import it from. Nothing here is guessed; each row was
read off the file named in the last column.

| Type | Package | Kind | Source file |
|---|---|---|---|
| `Interlock` | `sh.interlock.sdk` | interface | `Interlock.java` |
| `RequestImpl` | `sh.interlock.sdk.runtime` | final class | `runtime/RequestImpl.java` |
| `InterlockHandler<R>` | `sh.interlock.sdk` | `@FunctionalInterface` | `InterlockHandler.java` |
| `InterlockSDK` | `sh.interlock.sdk` | final class, static factories | `InterlockSDK.java` |
| `Request` | `sh.interlock.sdk` | interface | `Request.java` |
| `Store` | `sh.interlock.sdk` | interface | `Store.java` |
| `HostContext` | `sh.interlock.sdk` | interface | `HostContext.java` |
| `Ai` | `sh.interlock.sdk` | interface | `Ai.java` |
| `AiRun` | `sh.interlock.sdk` | final durable handle | `AiRun.java` |
| `Geo` | `sh.interlock.sdk` | interface | `Geo.java` |
| `Challenges` | `sh.interlock.sdk` | final class of nested contracts | `Challenges.java` |
| `Media` | `sh.interlock.sdk` | final class of nested types | `Media.java` |
| `Models` | `sh.interlock.sdk` | final class of `String` constants | `Models.java` |
| `Json` | `sh.interlock.sdk` | final class, static only | `Json.java` |
| `Html` | `sh.interlock.sdk` | final class, static factory | `Html.java` |
| `Refusal` | `sh.interlock.sdk` | `extends RuntimeException` | `Refusal.java` |
| `Timer` | `sh.interlock.sdk` | class | `Timer.java` |
| `TaskProducer` | `sh.interlock.sdk.tasks` | interface | `tasks/TaskProducer.java` |
| `HttpTaskProducer` | `sh.interlock.sdk.tasks` | final class | `tasks/HttpTaskProducer.java` |
| `TaskWorker` | `sh.interlock.sdk.tasks` | final class, `AutoCloseable` | `tasks/TaskWorker.java` |
| `TaskWorkerBuilder` | `sh.interlock.sdk.tasks` | final builder | `tasks/TaskWorkerBuilder.java` |
| `TaskTransport` | `sh.interlock.sdk.tasks` | interface | `tasks/TaskTransport.java` |
| `HttpTaskTransport` | `sh.interlock.sdk.tasks` | final class, `AutoCloseable` | `tasks/HttpTaskTransport.java` |
| `TaskHandler` | `sh.interlock.sdk.tasks` | `@FunctionalInterface` | `tasks/TaskHandler.java` |
| `TaskContext` | `sh.interlock.sdk.tasks` | interface | `tasks/TaskContext.java` |
| `Wire` | `sh.interlock.sdk.tasks` | final class of protocol records | `tasks/Wire.java` |
| `Engine` | `sh.interlock.sdk.runtime` | final class | `runtime/Engine.java` |
| `Result` | `sh.interlock.sdk.runtime` | final class | `runtime/Result.java` |
| `CodeSource` | `sh.interlock.sdk.runtime` | `@FunctionalInterface` | `runtime/CodeSource.java` |
| `CodeUnit` | `sh.interlock.sdk.runtime` | record | `runtime/CodeUnit.java` |
| `DirectoryCodeSource` | `sh.interlock.sdk.runtime` | final class, `implements CodeSource` | `runtime/DirectoryCodeSource.java` |
| `StoreFactory` | `sh.interlock.sdk.runtime` | `@FunctionalInterface` | `runtime/StoreFactory.java` |
| `SecretResolver` | `sh.interlock.sdk.runtime` | `@FunctionalInterface` | `runtime/SecretResolver.java` |
| `ContextProvider` | `sh.interlock.sdk.runtime` | `@FunctionalInterface` | `runtime/ContextProvider.java` |
| `CodeInvoker` | `sh.interlock.sdk.runtime` | interface | `runtime/CodeInvoker.java` |
| `EngineException` | `sh.interlock.sdk.runtime` | `extends RuntimeException` | `runtime/EngineException.java` |
| `CodeNotFoundException` | `sh.interlock.sdk.runtime` | `extends EngineException` | `runtime/CodeNotFoundException.java` |
| `UnitBuild` | `sh.interlock.sdk.runtime` | final class (`UnitBuild.Check`) | `runtime/UnitBuild.java` |
| `InterlockClient` | `sh.interlock.sdk.client` | final class, `implements CodeSource` | `client/InterlockClient.java` |
| `HostIdentity` | `sh.interlock.sdk.client` | record | `client/HostIdentity.java` |
| `RunExecutor` | `sh.interlock.sdk.client` | interface | `client/RunExecutor.java` |

The three packages, in one line each: `sh.interlock.sdk` is what a unit sees plus the host bootstrap,
`sh.interlock.sdk.runtime` is the engine and its wiring seams, `sh.interlock.sdk.client` is the wire
to the Interlock service.

### InterlockSDK

`sh.interlock.sdk.InterlockSDK`. A final class: the host-facing bootstrap. Static factories start it,
fluent setters override defaults, `build()` returns an `Engine`. There is no public constructor.

| Signature | Notes |
|---|---|
| `static InterlockSDK init(String sdkKey, Map<String, Object> context)` | seed context map; a null map is tolerated |
| `static InterlockSDK init(String sdkKey)` | no context; add keys with `context(k, v)` |
| `static InterlockSDK init(String sdkKey, ContextProvider context)` | per-request/per-unit provider; a null provider becomes `ContextProvider.EMPTY` |
| `static Engine engine(String sdkKey, Map<String, Object> context)` | the no-override shortcut, equal to `init(key, context).build()` |
| `static final String VERSION` | the SDK's own version, currently `"1"` |

Overload note: `init(String, Map)` and `init(String, ContextProvider)` are distinct overloads, so a
null second argument is ambiguous to javac. Use `init(key)` when you have no context.

Fluent setters, each returning `this`:

| Signature | Default when you do not call it |
|---|---|
| `InterlockSDK codeSource(CodeSource src)` | an `InterlockClient` built from `serviceUrl` plus the key |
| `InterlockSDK stores(StoreFactory f)` | a private per-unit in-memory store factory |
| `InterlockSDK secrets(SecretResolver r)` | env vars, with `foo-bar` read as `FOO_BAR` |
| `InterlockSDK ai(Ai a)` | brokered through Interlock when the code source is an `InterlockClient`, otherwise an honest no-op reporting `live() == false` |
| `InterlockSDK geo(Geo g)` | brokered through Interlock when the code source is an `InterlockClient`, otherwise `Geo.Location.UNKNOWN` |
| `InterlockSDK context(String key, Object value)` | nothing seeded |
| `InterlockSDK contextForGenerated(String key, Object value)` | nothing exposed to generated code (deny by default) |
| `InterlockSDK serviceUrl(String url)` | `INTERLOCK_URL`, falling back to `https://api.interlock.sh` |
| `InterlockSDK warmUp(boolean w)` | `true`, so the React SSR runtime is warmed at build |

| Terminal | Notes |
|---|---|
| `Engine build()` | auto-anchors the seeded context classes for javac, constructs the `Engine`, warms SSR unless `warmUp(false)` |
| `Engine start()` | alias for `build()` |

`context(String, Object)` and `contextForGenerated(String, Object)` throw `IllegalStateException`
when the bootstrap was started with `init(key, ContextProvider)`: a dynamic provider has nothing to
add to. `contextForGenerated` also seeds the ordinary context, so an entry added there is visible to
authored units as well as generated ones.

`build()` calls `Engine.addCompileAnchor` for each statically-known seed value whose class is the
host's own (JDK, `javax`, `jakarta`, `sun` and `jdk` classes are skipped, and so is any value that is
a `Supplier`). A host that constructs `new Engine(...)` by hand gets none of that and must anchor by
hand.

The host's presence is announced from the environment: `INTERLOCK_HOST_ID` and `INTERLOCK_HOST_NAME`
(both defaulting to the machine hostname), `INTERLOCK_ENV` (default `dev`), `INTERLOCK_REMOTE_RUN`
(default `false`) and `INTERLOCK_REMOTE_RUN_ALLOW_PROD` (default `false`).

```java
import sh.interlock.sdk.InterlockSDK;
import sh.interlock.sdk.runtime.Engine;

Engine engine = InterlockSDK.init(System.getenv("INTERLOCK_SDK_KEY"))
        .context("app", myContext)
        .stores(myDbStoreFactory)
        .build();
```

### Brokered challenge solving

**The rule.** A Java host sends a provider-neutral `Challenges.Request` through
`InterlockClient.challenges().solve(request)`. The host never receives or selects the upstream solver,
never supplies its credential, and never controls its task protocol. The request carries only a
kind, the public site parameters, an idempotency key and—when relevant—a worker language.

**Why it exists.** One Java control plane must own routing, settlement and billing. A retry with the
same idempotency key is the same paid transaction, and the API charges that transaction from the
provider-reported cost instead of guessing from a flat CAPTCHA price. Keeping those decisions out
of the crawler also keeps the downloaded Node package provider-neutral.

`Challenges` exposes `CANONICAL_KINDS`, `VARIANTS`, their combined `KINDS`, and explicit
`DISABLED_KINDS` / `UNAVAILABLE_KINDS`. `Request.managed(...)` asks Interlock to use its managed
solver; `Request.supplied(...)` selects a customer-supplied route when the server allows one. Its
`ChallengeParameters` types the inputs browser challenges share (`pageUrl`, `siteKey`, `action`,
`userAgent`); anything else a technology needs goes in with `with(name, value)`, spelled as the
technology names it. `Result` returns the canonical `kind`, its `output` shape, and a
`ChallengeSolution`: `token`, `text`, `userAgent` and browser-ready `cookies` when the family has
them, and the whole `answer` for positional or structured families. It never returns an upstream
task id, price or provider metadata.

**The failure it prevents.** Invalid work fails before money can be spent, with literal validation
messages such as:

```
an idempotency key of 8 to 200 characters is required
```

Server, transport, disabled and unavailable failures cross the SDK only as
`Challenges.Unavailable`; its public message is always:

```
Interlock challenge service unavailable
```

Branch on `Unavailable.code()`, not its message. Do not retry with a new idempotency key unless it is
a genuinely new logical solve.

**Minimal example.**

```java
import sh.interlock.sdk.ChallengeParameters;
import sh.interlock.sdk.Challenges;
import sh.interlock.sdk.client.InterlockClient;

var request = Challenges.Request.managed(
        "turnstile",
        ChallengeParameters.page(pageUrl, siteKey),
        crawlAttemptId);
Challenges.Result solved = client.challenges().solve(request);
String token = solved.solution().token();
```

**Verify it.**

```bash
./gradlew :interlock-java-sdk:test --tests sh.interlock.sdk.ChallengesTest
```

### Tasks clients

`sh.interlock.sdk.tasks` is role-separated. `HttpTaskProducer` takes a producer key and exposes
`submit`, `get`, `cancel`, `watch`, and `await`. `HttpTaskTransport` takes a worker key and is passed
to `TaskWorker.builder(transport)`; the builder registers typed handlers and returns a worker whose
`start`, `drain`, `stats`, `join`, and `close` methods own its lifecycle.

| Signature | Notes |
|---|---|
| `new HttpTaskProducer(String interlockUrl, String producerKey)` | producer authority only |
| `Wire.TaskView submit(Wire.SubmitRequest request)` | durable admission; caller idempotency key is optional but recommended |
| `Wire.TaskView get(String taskId)` | canonical state/result |
| `Wire.TaskView cancel(String taskId, String reason)` | cooperative when already running |
| `AutoCloseable watch(String taskId, Consumer<Map<String,Object>> onEvent)` | SSE notification; journal remains truth |
| `Wire.TaskView await(String taskId, Duration timeout, Duration poll)` | watch plus polling fallback; for jobs/CLIs, not request threads |
| `new HttpTaskTransport(String interlockUrl, String workerKey)` | worker authority only |
| `TaskWorker.builder(TaskTransport)` | configure identity, pool, concurrency, spool and handlers |
| `handle(String type, int version, EffectSafety safety, TaskHandler handler)` | one versioned handler; undeclared safety is never inferred |
| `TaskWorker start()` / `void drain()` / `void close()` | claim work; stop claiming but renew; then graceful shutdown |

`TaskContext` exposes stable task/attempt/effect ids, correlations, buffered `progress` and `log`,
cooperative cancellation, and an optional durable cursor. Full delivery semantics, Java and Node
examples, error meanings, and verification commands are on [Durable tasks and workers](/tasks).

### Engine

`sh.interlock.sdk.runtime.Engine`. A final class declared
`public final class Engine implements CodeInvoker, AutoCloseable`. **It is `AutoCloseable`**:
`close()` shuts the JS and JSX runners down and closes the code source when that source is itself
`AutoCloseable`, which stops the change-feed thread and the refresh executor.

Constructors (both public, so a host can bypass `InterlockSDK` for tests):

| Signature |
|---|
| `Engine(CodeSource code, StoreFactory stores, SecretResolver secrets, Ai ai)` |
| `Engine(CodeSource code, StoreFactory stores, SecretResolver secrets, Ai ai, ContextProvider context)` |

The four-argument form uses `ContextProvider.EMPTY`. A null `context` in the five-argument form is
also treated as `EMPTY`.

Running:

| Signature | Notes |
|---|---|
| `Result run(String id, String env, Request req)` | load the unit and run it |
| `Result run(String id, String env, Request req, Map<String, Object> local)` | as above, plus a context scoped to this one execution; `local` overlays the host's declared context and is discarded on return |
| `Result runClosed(String id, String env, Request req, Map<String, Object> only)` | the unit sees exactly `only` and nothing the host declared globally; a null `only` becomes `Map.of()` |
| `Result runSource(String id, String language, String source, Request req)` | run source directly, with no id to register and no fetch; the ephemeral unit gets env `gen` and version `0`, and nothing is stored |
| `Object invoke(String id, String env, Map<String, Object> params)` | the `CodeInvoker` method, which is how `il.call(...)` re-enters the engine; returns the unit's raw value |
| `Class<?> unitClass(String id, String env)` | the compiled class behind a Java unit, for reflection; throws `EngineException` when the unit is not Java |
| `void close()` | from `AutoCloseable` |

Both `runSource` and `runClosed` exist. `runSource` is the generative primitive (source in, `Result`
out) and `runClosed` is the trust boundary (context in, nothing else visible). They are independent:
`runClosed` runs a stored unit, `runSource` runs a string.

Statics:

| Signature | Notes |
|---|---|
| `static void addCompileAnchor(Class<?> hostClass)` | lets Java units compile against that class's jar, and reserves its top two package segments |
| `static void exportEntity(Class<?> entity)` | re-permits ONE persistence entity to units (denied by default — entity statics act on whole tables); prefer read-views |
| `static void exportAllEntities()` | the blunt opt-out for a first-party host: every entity unit-reachable, pre-boundary behaviour |
| `static void setRunTimeoutMs(long ms)` | wall-clock cap for one JS/JSX execution; a non-positive value resets to the default |
| `static long getRunTimeoutMs()` | the current cap, default `30000` |
| `static final Set<String> KNOWN_LANGUAGES` | `js`, `jsx`, `java`, `html`, `css`, `md`, `txt` |

`addCompileAnchor` gates javac's visibility, not runtime reachability. A Java unit is full-trust code
in the host JVM and is not wall-clock capped; only JS and JSX are.

Operations and integration acts:

| Signature | Notes |
|---|---|
| `void warmUp()` | pre-build the React SSR runtime; safe to call more than once, never throws |
| `Map<String, Object> status()` | source-cache and feed state, per-runner cache stats, SSR pool occupancy, current run timeout |
| `boolean publish(String id, String env, String language, String source)` | write one unit to Interlock |
| `int publishAll(Path dir, String env)` | publish every file under a directory whose extension is a known language; returns how many were written |
| `String sourceOf(String id, String env)` | the stored source, or null; for showing code to a human |
| `List<String> listUnits(String env)` | the unit ids in this project |
| `String weaverPrompt(String variant, String env)` | the resolved weaver prompt for a variant |
| `UnitBuild.Check validate(String id, String language, String source)` | does this source build; `UnitBuild.Check` is a record of `(boolean ok, String errors)` |
| `String jsxArtifact(String id, String source)` | the transpiled artifact for a jsx source, for a saver to store under `JsxRunner.artifactKey(source)`; throws on broken JSX |

Everything in that table except `warmUp`, `status`, `validate` and `jsxArtifact` needs an
`InterlockClient` behind the engine. With any other `CodeSource` they throw `EngineException`.

Brokered capability, host-side twins of the `il` calls:

| Signature | Notes |
|---|---|
| `String ai(String system, String prompt)` | a completion on the default rung |
| `String ai(String system, String prompt, String model)` | `model` is a `Models` tier or family alias, null for the default rung |
| `Engine geo(Geo g)` | wire the location provider; returns `this` |
| `Geo.Location geo(String ip)` | never throws, never null; an unplaceable address comes back as `Geo.Location.UNKNOWN` |
| `Media.Result media(Media.Kind kind, String model, String prompt, Media.Options opts)` | the general form |
| `Media.Result image(String model, String prompt, Media.Options opts)` | |
| `Media.Result image(String prompt)` | on the `Models.MEDIUM` rung |
| `Media.Result video(String model, String prompt, Media.Options opts)` | |
| `Media.Result transcribe(String model, String audioUrl)` | |
| `Media.SpeechSession speech(String model, String language)` | `language` is BCP-47, or null to let the transcriber detect it |
| `Media.SpeechSession speech()` | default rung, language auto-detected |

Note the two `geo` methods are an overload pair with different meanings: `geo(Geo)` is a setter that
returns the engine, `geo(String)` is a lookup that returns a `Geo.Location`.

Generation:

| Signature | Notes |
|---|---|
| `InterlockClient.Generated generate(String intent)` | Interlock derives the unit id and returns it |
| `InterlockClient.Generated generate(String intent, String reuseKey)` | `reuseKey` is a host-owned identity: what counts as "the same unit" |
| `InterlockClient.Generated generate(String intent, String reuseKey, String variant)` | `variant` names a generator the project declares in its weaver index |
| `Result generateAndRun(String intent, Request req)` | generate and run with no host capabilities |
| `Result generateAndRun(String intent, Request req, Map<String, Object> local)` | |
| `Result generateAndRun(String intent, String reuseKey, Request req, Map<String, Object> local)` | throws `EngineException` with the diagnostics when the generated unit does not build |

`InterlockClient.Generated` is a record:
`(String unitId, String language, boolean compiles, boolean published, String errors, String source)`.

### CodeSource and its implementations

`sh.interlock.sdk.runtime.CodeSource` is a `@FunctionalInterface`: where code comes from. Return null
when the (id, env) pair is absent.

| Signature | Notes |
|---|---|
| `CodeUnit fetch(String id, String env)` | the one abstract method |
| `default List<String> list(String env)` | every unit id in that env; **defaults to an empty list** |

`list` is what makes a Java sibling import resolvable, because javac resolves a package by listing
it. A source that only answers `fetch` still runs units; sibling imports are simply off.

`sh.interlock.sdk.runtime.CodeUnit` is the record it returns:

| Member | Notes |
|---|---|
| `CodeUnit(String id, String env, String language, String source, int version, String origin, String artifact, String artifactKey)` | the canonical constructor |
| `CodeUnit(String id, String env, String language, String source, int version, String origin)` | artifact fields default to null |
| `CodeUnit(String id, String env, String language, String source, int version)` | origin defaults to `CodeUnit.AUTHORED`, artifact fields to null |
| `static final String AUTHORED` | `"authored"` |
| `static final String GENERATED` | `"generated"` |
| `boolean generated()` | true when `origin` is `GENERATED`, meaning reduced capability |

`artifact` is an optional precompiled form of the source (for jsx: the server's Babel output,
computed at save so no process pays the transpiler for a saved unit). It is a cache with a key,
never an authority: `artifactKey` binds it to the exact source and transpiler version, and a runner
that finds the key stale or mismatched silently rebuilds from source. A source that returns nulls
here — every source predating the field, every non-jsx unit — runs exactly as before.

Two implementations ship with the SDK.

`sh.interlock.sdk.client.InterlockClient` is the default, used when the host does not call
`codeSource(...)`. It is declared `public final class InterlockClient implements CodeSource,
AutoCloseable` and fetches from the Interlock service, caches, and subscribes to the change feed.

| Constructor | Notes |
|---|---|
| `InterlockClient(String baseUrl, String token)` | announces `HostIdentity.anonymous("dev")`, so it receives code updates and is never a run target |
| `InterlockClient(String baseUrl, String token, HostIdentity identity)` | a null identity falls back to `HostIdentity.anonymous("dev")` |

A trailing slash on `baseUrl` is stripped. A null or blank token logs a warning at boot and skips the
feed, because the feed endpoint is authenticated.

Two hooks a host rarely sets by hand (the `Engine` constructor registers both when the code source is
an `InterlockClient`): `void onRefresh(BiConsumer<CodeUnit, CodeUnit> hook)`, called with (previous,
fresh) before the fresh unit is stored, and `void onRun(RunExecutor executor)`, which is a no-op
unless the host opted in to remote run.

`sh.interlock.sdk.client.HostIdentity` is a record:
`(String hostId, String name, String env, String sdkVersion, List<String> contextKeys, boolean
remoteRunEnabled, boolean allowProd)`, with `static HostIdentity anonymous(String env)` and
`boolean announces()`.

`sh.interlock.sdk.runtime.DirectoryCodeSource` reads units off a disk tree, with no server, key or
sync. One constructor:

| Constructor | Notes |
|---|---|
| `DirectoryCodeSource(Path root)` | `root` is the `code/` directory, the one whose children are the environments |

So `new DirectoryCodeSource(Path.of("code")).fetch("notes/NoteApi", "dev")` reads
`code/notes/NoteApi.java`. A null env is read as `dev`. An id containing `..` returns null,
because an id is a unit name and never a path. Everything it returns is `AUTHORED`, so do not point
it at a directory where machine-generated units land. Text units only: binary assets keep their
extension and live behind the blob store, which needs a server.

### StoreFactory, SecretResolver, Ai

The three seams a host implements. The first two are single-method
`@FunctionalInterface` types, so a lambda is the whole implementation.

| Type | Package | Abstract method |
|---|---|---|
| `StoreFactory` | `sh.interlock.sdk.runtime` | `Store store(String codeId, String env)` |
| `SecretResolver` | `sh.interlock.sdk.runtime` | `String resolve(String name)` |

```java
InterlockSDK.init(key)
        .stores((codeId, env) -> new MyDbStore(codeId, env))
        .secrets(name -> vault.lookup(name))
        .build();
```

`Ai` is `sh.interlock.sdk.Ai`, and it is **not** a `@FunctionalInterface`: it has two abstract
methods, so it cannot be a lambda.

| Signature | Kind | Notes |
|---|---|---|
| `String chat(String model, String prompt)` | abstract | name the model with a `Models` constant, not a vendor id |
| `boolean live()` | abstract | whether a real provider is configured, as opposed to an offline stub |
| `default String chat(String prompt)` | default | delegates to the `Models.MEDIUM` rung |
| `default String chatWithOptions(ChatOptions options, String prompt)` | default | chooses `managed-only`, `self-hosted-only`, or explicit self-hosted-then-managed routing |
| `default AiRun submit(ChatOptions options, String prompt)` | default | submits a durable run when the host is a brokered Interlock client; other providers refuse rather than pretending to be durable |
| `record CachePolicy(String key, long ttlSeconds)` | nested type | explicit exact completed-result reuse; `exact(key)` defaults to 24 hours |
| `ChatOptions cache(String key, long ttlSeconds)` | builder | returns options with exact completed-result reuse enabled |
| `ChatOptions cache(String key)` | builder | same, with the 24-hour default TTL |
| `default Media.Result media(Media.Kind kind, String model, String prompt, Media.Options opts)` | default | returns `Media.Result.empty(kind)`; **the one method a media-capable implementation must override**, since every typed media helper routes through it |
| `default boolean supports(Media.Kind kind)` | default | `false` |
| `default Media.Request request(Media.Kind kind)` | default | the step-by-step builder, run with `Media.Request.run()` |

The typed media helpers are all defaults over `media`, and each fixes one `Media.Kind`:
`image(model, prompt, opts)`, `image(model, prompt)`, `image(prompt)`, `imageEdit(model, prompt,
sources)`, `upscaleImage(model, source)`, `removeBackground(model, source)`, `video(model, prompt,
opts)`, `video(model, prompt)`, `videoFromImage(model, prompt, source)`, `upscaleVideo(model,
source)`, `avatarVideo(model, prompt, opts)`, `music(model, prompt, opts)`, `music(model, prompt)`,
`speech(model, text, opts)`, `speech(model, text)`, `soundEffect(model, prompt)`, `transcribe(model,
audioUrl)`, `isolateVoice(model, audioUrl)`.

A minimal host `Ai` is therefore two methods:

```java
InterlockSDK.init(key).ai(new Ai() {
    @Override public String chat(String model, String prompt) { return myProvider.complete(model, prompt); }
    @Override public boolean live() { return true; }
}).build();
```

Model names are `sh.interlock.sdk.Models` constants: price rungs `MOST_EXPENSIVE`, `EXPENSIVE`,
`MEDIUM`, `CHEAP`, `CHEAPEST`, plus family aliases, plus the self-hosted model
`Models.QWEN_3_6_35B_A3B` (`"qwen3-6-35b-a3b"`) — Qwen3.6-35B-A3B Q8_0 run by a worker your own
project operates (`interlock-task-client`); naming it routes there and never to a managed vendor,
and with no online worker the call is refused (`NO_ELIGIBLE_INFERENCE_WORKER`, 409) unbilled. They
are `String` constants, so a raw vendor id still compiles; it is just the one call that can age
badly.

Placement is explicit through `Ai.ChatOptions`. `selfHosted(model)` refuses with
`NO_ELIGIBLE_INFERENCE_WORKER` when the project has no eligible worker; it never spends with a
managed provider behind the caller's back. `withManagedFallback(model)` is the opt-in policy that
allows that fallback.

Completed-result caching is also explicit through `ChatOptions.cache`; ordinary chat is uncached.
The caller-owned key is a namespace/version, while Interlock privately fingerprints it together
with the project and the complete effective request. This prevents a convenient key such as
`"summary"` from reusing the wrong model, prompt, schema, or tenant's answer. Only terminal successful
text/structured responses without images, tools, or tool results are eligible. The first exact
request invokes and bills the model; a hit creates a request receipt but no model invocation, model
tokens, or model charge. Provider prompt-cache tokens remain a separate measurement.

```java
Ai.ChatOptions options = new Ai.ChatOptions(Models.CHEAP, Ai.RoutePolicy.MANAGED_ONLY)
        .cache("product-summary:v2", 3_600);
String summary = il.ai().chatWithOptions(options, prompt);
```

Keys must be 1–160 characters and TTLs 60–2,592,000 seconds. Invalid values fail before model work
with `AI cache key must be between 1 and 160 characters` or
`AI cache ttlSeconds must be between 60 and 2592000`. A production host must set
`INTERLOCK_AI_CACHE_SECRET`; otherwise an opted-in request fails closed with
`AI result caching is not configured on this Interlock` rather than storing an unprotected result.

**Verify.** The database test proves exact hit, zero-charge reuse, expiry, tenant isolation,
ineligible rounds, and concurrent single-flight behavior:

```bash
./gradlew :interlock-java-api:test --tests sh.interlock.api.ai.AiResultCacheDbTest -Dinterlock.test.db=true
```

For a call that may outlive an HTTP connection, submit a durable `AiRun` instead of holding one
request open:

```java
AiRun run = il.ai().submit(
        Ai.ChatOptions.selfHosted(Models.QWEN_3_6_35B_A3B),
        "Summarise the release evidence");
AiRun.Snapshot result = run.await(java.time.Duration.ofMinutes(10));
```

`AiRun.id()` is the recovery key. `latest()` returns the last known snapshot, `refresh()` reads the
canonical durable state, `await(Duration)` polls until a terminal state, and `cancel(reason)` asks
the canonical task to stop. A stream or caller disconnect does not cancel the run and is not a
reason to submit it again. The snapshot carries the task id, state, route policy, answer or error,
and the single usage-event id once metered.

The durable HTTP and Node submit options also accept bounded `maxTokens` (1–16,384) and `timeoutMs`.
Use them for genuinely long generations; keeping an already-terminal stream open is not a long
inference and should never be presented as edge-timeout evidence.

### ContextProvider

`sh.interlock.sdk.runtime.ContextProvider`. A `@FunctionalInterface`, and the parameter type of
`InterlockSDK.init(String, ContextProvider)`. What a unit reaches through `il.context()`.

| Signature | Notes |
|---|---|
| `Map<String, Object> contextFor(String codeId, String env)` | the one abstract method; never null |
| `default Map<String, Object> contextFor(String codeId, String env, boolean generated)` | returns an **empty map** for generated code unless overridden, which is the deny-by-default rule |
| `static final ContextProvider EMPTY` | no host context |

A value that is a `java.util.function.Supplier` is invoked per request; anything else is passed
through as is. That is what lets a host hand out a request-scoped transaction rather than a
process-global singleton. `InterlockSDK` overrides the three-argument form with the entries the host
opened through `contextForGenerated`.

### Json, Html and the host-side errors

| Type | Package | Surface |
|---|---|---|
| `Json` | `sh.interlock.sdk` | `static String toJson(Object value)`, `static <T> T fromJson(String json, Class<T> type)`, `static Map<String, Object> toMap(String json)` |
| `Html` | `sh.interlock.sdk` | `static Html of(String markup)`, `String markup()`, `String toString()` |
| `EngineException` | `sh.interlock.sdk.runtime` | `EngineException(String message)`, `EngineException(String message, Throwable cause)` |
| `CodeNotFoundException` | `sh.interlock.sdk.runtime` | `CodeNotFoundException(String id, String env)` |
| `Refusal` | `sh.interlock.sdk` | see [Refusal](#refusal) above |

`Json` is a final class with a private constructor: static methods only, a thin Jackson wrapper. Both
`toJson` and `fromJson` wrap any failure in a plain `RuntimeException` whose message names the method
that failed.

`Html` is final with a private constructor, so `Html.of(markup)` is the only way to make one. A null
markup becomes the empty string. A handler returning an `Html` serves
`text/html; charset=utf-8`.

`EngineException extends RuntimeException` and is the clean, unit-facing failure. The engine throws
it for a language it cannot execute, for `unitClass` on a unit that is not Java, for a generated unit
that does not build, and for any client-backed call (`publish`, `sourceOf`, `generate` and the rest)
on an engine whose code source is not an `InterlockClient`.

`CodeNotFoundException extends EngineException`, so catching `EngineException` catches it too. The
engine throws it when `CodeSource.fetch` returns null, and its message is
`no code '<id>' in env '<env>'`. Hosts map it to HTTP 404.

`Refusal` is `sh.interlock.sdk.Refusal`, on the unit side of the fence but relevant to a host for one
reason: on the HTTP path the engine already catches it and turns it into
`Result.json(body()).withStatus(status())`, so a host serves it by honouring `Result.status` and does
not need a catch of its own. Through `il.call` it stays an exception, because `Engine.invoke` does
not catch it.
## RequestImpl

The concrete `Request` a host builds when it serves a unit itself. You need it for the `/app`
passthrough on [Embedding the SDK](/host#the-app-passthrough-in-full), which is the one piece of
host code the docs tell you to copy verbatim.

Package: `sh.interlock.sdk.runtime`.

```java
public RequestImpl(Map<String, List<String>> params,
                   Map<String, String> headers,
                   Map<String, Object> body,
                   String path,
                   String method,
                   String principal)
```

| arg | notes |
|---|---|
| `params` | query parameters, each name to its values |
| `headers` | lower-cased names. **Do not pass `cookie` or `authorization`**: a unit has no business seeing a caller's credential, and `req.cookie(name)` parses the raw header |
| `body` | the parsed JSON body, or `Map.of()`. This is what makes `req.str` find a value that arrived in the body |
| `path` | what `req.path()` returns |
| `method` | `"GET"`, `"POST"`, … |
| `principal` | who the host decided the caller is, or `null`. The **only** sanctioned way a unit learns that; it is a fact the host asserts, never one the unit can claim |

The trailing `null` in the passthrough example is `principal`. Pass a real value once your host has
resolved a session, and pass `null` while it has not.

`RequestImpl.fromParams(Map<String, Object>)` builds one from a bare parameter map, which is what
`il.call` uses.
