# Units

## For humans

**Read this if** you are writing your first unit, or a unit is not answering the way you expected.
**Skip it if** you only run units other people wrote.

### Do this

A unit is one file. Four things decide how it behaves:

1. **The file.** `.java`, `.js`, `.jsx`, `.html`, `.css`, `.md`, `.txt`, or a binary asset.
2. **The id.** Its path under `code/`, minus the extension. Move the file and you rename it
   everywhere: URL, `il.call`, storage.
3. **The handler.** One entry point: `export default` in JS, `implements InterlockHandler<R>` in Java.
4. **The return value.** It is the response. `Html` serves HTML, a bare `String` serves text,
   anything else serves JSON.

```js
// code/hello.js  →  id "hello"
export default (req, il) => {
  il.log('running');
  return { ok: true, name: req.str('name', 'world') };
};
```

### What will bite you

- **A file with no handler is a library.** It compiles and other units can import it, but it will not
  run as a page. The refusal names which kind of file it decided you had, so read it.
- **Envs move forward by promotion only.** Re-syncing never reaches `prod`. A host pointed at the
  wrong env serves whatever is there with a 200 and no error anywhere.

### Then read

- [The handler contract](#the-handler-contract) for both entry points and their refusal texts.
- [Ids are paths](#ids-are-paths) for how a file path becomes a URL.
- [Environments and promotion](#environments-and-promotion) for the promote command.

## For robots

The unit model: what a unit is, where it lives, how it is addressed, what it must export, and what it
is handed at run time.

Everything on this page was read out of `interlock-java-sdk/src/main/java/sh/interlock/sdk/` and
`interlock-java-api/src/main/java/sh/interlock/api/service/CodeService.java`. Versions in play: JDK
21 (`build.gradle`), Quarkus 3.15.1 (`gradle.properties`), GraalVM polyglot 23.1.2
(`interlock-java-sdk/build.gradle`).

Error text on this page is quoted **verbatim from the source that throws it**, so it can be pasted
into a search box. Where a rule was proven by a test or a live run it is labelled `MEASURED`; where
it follows from reading the code but has no test pinning it, `INFERRED`.

- [What a unit is](#what-a-unit-is)
- [Environments and promotion](#environments-and-promotion)
- [Ids are paths](#ids-are-paths)
- [Assets keep their extension](#assets-keep-their-extension)
- [camelCase ids resolve kebab-case](#camelcase-ids-resolve-kebab-case)
- [The handler contract](#the-handler-contract)
- [Return types and content types](#return-types-and-content-types)
- [Library units](#library-units)
- [The per-unit store](#the-per-unit-store)
- [The `il` surface](#the-il-surface)
- [The `req` surface](#the-req-surface)

## What a unit is

**Rule.** A unit of code is **one file**: `.java`, `.js`, `.jsx`, `.html`, `.css`, `.md`, `.txt`, or a
binary asset. It is published with `interlock sync`, fetched by the host through a `CodeSource`, and
compiled and executed **in the host's own JVM**. Git is the master; the server is a cache of the git
tree.

The runnable language set is one constant, `Engine.KNOWN_LANGUAGES`:

```
js, jsx, java, html, css, md, txt
```

`Engine.STATIC_LANGS` is `html`, `css`, `md`, `txt`: those four are served as-is and never executed.
`js`, `jsx` and `java` are the executable three.

**Why it exists.** One authority for the language list means a typo is caught at save time, where the
author is, instead of at run time on a visitor's request.

**Failure it prevents.** A unit saved with a language nobody runs. Two different literals depending on
where you hit it:

```
unsupported language: typescript
```

(from `UnitBuild.check`, at validation time)

```
cannot execute language: typescript
```

(from `Engine.executeIn`, at run time)

**Example.**

```
code/caller/UseIt.java    → runnable Java unit
code/caller/panel.jsx     → runnable experience
code/caller/style.css     → static, served as text/css
```

**Verify.** In your own tree: every id the server holds, with the language it was recognised as. An
extension the platform does not know never reaches this list.

```bash
interlock list --env dev
# each row is `id  language  vN`; the language column is the authority, not the file extension
```

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

`MEASURED` (`EngineTest.knownLanguagesIsTheSingleAuthority`, `EngineTest.anUnrunnableLanguageFailsLoudly`).

## Environments and promotion

**Rule.** Every unit exists per environment: `dev`, `staging`, `prod`. Code moves between them by
**promotion**, never by re-syncing. A production host pins its env (`interlock.env=prod` in the
taskman sample, `AppResource.env`), so only a promotion can change what it runs.

**Why it exists.** A host pinned to `dev` serves a Studio save on the very next request. That is the
right behaviour for a demo and the wrong behaviour for traffic. Pinning `prod` makes "what is
running" a deliberate act rather than a side effect of somebody's editor.

**Failure it prevents.** Syncing to `dev` and expecting a `prod`-pinned host to pick it up. The host
answers with `CodeNotFoundException`, which hosts map to 404:

```
no code 'caller/UseIt' in env 'prod'
```

The inverse failure has no error text at all, which is what makes it worse: a host left on `dev`
silently serves unreviewed code with a 200.

**Example.**

```bash
interlock sync code                        # writes code/** to the server
interlock promote caller/UseIt --from dev  # dev → staging → prod
interlock list --env prod                  # confirm it landed
```

**Verify.**

```bash
interlock list --env dev
interlock list --env prod
```

The two listings print `id  language  vN`. A unit present in `dev` and absent from `prod` has not been
promoted. `MEASURED` (the promote route is `POST /api/code/{id}/promote?from=<env>`,
`code/assets/interlock-cli.js`).

## Ids are paths

**Rule.** A unit's id is its path under `code/`, minus the extension, folders included.

```
code/world/WorldApi.java   → id  world/WorldApi
code/hello.js              → id  hello
```

The id is what every other surface addresses: `il.call("world/WorldApi", …)`,
`il.url("world/WorldApi", …)`, `interlock run world/WorldApi`, and the host's HTTP passthrough. The
Interlock service serves units at `/run/{id}`; an embedding host typically mounts its own
passthrough, e.g. `@Path("/app/{id:.+}")` in `samples/taskman/src/main/java/com/taskman/AppResource.java`.

**Why it exists.** One name for a unit in the filesystem, in the store, in code, and in the URL. Two
naming schemes would be two things to keep in step.

**Failure it prevents.** Calling a unit by a name that is not its path. The engine cannot invent one:

```
no code 'WorldApi' in env 'dev'
```

**Example.**

```java
// in code/world/Panel.java
Object answer = il.call("world/WorldApi", Map.of("n", 21));
```

**Verify.**

```bash
interlock run world/WorldApi --env dev
```

`MEASURED` (`DirectoryCodeSource.fetch` maps `world/WorldApi` + `dev` to
`code/world/WorldApi.java`, pinned by `JavaSiblingTest.directoryCodeSourceServesAGitTree`).

## Assets keep their extension

**Rule.** Text ids drop the extension. Ids under `assets/` **keep** it.

```
code/assets/logo.png  → id  assets/logo.png  → /assets/logo.png
```

**Why it exists.** If assets dropped their extension the way text units do, `logo.png` and `logo.webp`
would both collapse to the id `assets/logo` and the second sync would silently overwrite the first.

**Failure it prevents.** A binary sitting outside `assets/`, which the CLI used to skip in silence
(78 PNGs sat in a tree for weeks looking synced). It now says so, once per file:

```
⚠ img/logo.png is a binary file outside assets/ — not synced. Move it to assets/logo.png to serve it at /assets/logo.png
```

**Example.**

```
code/assets/logo.png     ✅ id assets/logo.png
code/img/logo.png        ❌ warned and skipped
```

**Verify.** Make the CLI tell you, in your own tree, changing nothing:

```bash
interlock sync code --dry
# every misplaced binary prints a warning naming the file; a clean tree prints none
find code/ -type f ! -path 'code/assets/*' \
  ! -name '*.java' ! -name '*.js' ! -name '*.jsx' ! -name '*.html' \
  ! -name '*.css' ! -name '*.md' ! -name '*.txt'
# expected output: nothing. Every line is a file that will be warned about and skipped.
```

`MEASURED` (the warning is emitted per file by `warnMisplaced` on every sync walk).

## camelCase ids resolve kebab-case

**Rule.** Ids keep the casing you authored them with. The **Interlock service** additionally stores a
lowercase-kebab slug per path segment, and resolves a request by exact id first, then by slug. So
`examples/HelloJava` is also reachable at `/run/examples/hello-java`.

Segment rules, from `CodeService.kebab`: `_` and space become `-`, and a `-` is inserted before an
uppercase letter that follows a lowercase letter, a digit, or an uppercase letter that itself
precedes a lowercase one (so `HelloJava` → `hello-java`, `WorldAPIClient` → `world-api-client`).

**Why it exists.** Java wants `CamelCase` type names; URLs want kebab. Authoring one and addressing
the other should not require two units.

**Failure it prevents, and the sharp edge.** Slug resolution lives in `CodeService` (the service), not
in the SDK. `DirectoryCodeSource.fetch` resolves the id as a literal path with **no** slug fallback.
A host running units off a git checkout, offline, therefore gets:

```
no code 'examples/hello-java' in env 'dev'
```

for an id that resolves fine against a real Interlock service. Address units by their authored id in
code; reserve the kebab form for URLs a human types.

**Example.**

```bash
curl -s localhost:8090/run/examples/hello-java   # service: resolves via slug
```

```java
engine.run("examples/HelloJava", "dev", req);    // DirectoryCodeSource: exact id only
```

**Verify.** Ask both resolvers the same question and watch them disagree. That disagreement is the
rule:

```bash
# the service resolves either form
interlock run examples/HelloJava --env dev
interlock run examples/hello-java --env dev

# your host, if it runs off a git checkout with DirectoryCodeSource, resolves only the authored id
curl -s -o /dev/null -w '%{http_code}\n' localhost:8099/app/examples/HelloJava    # 200
curl -s -o /dev/null -w '%{http_code}\n' localhost:8099/app/examples/hello-java   # 404
```

`MEASURED` for the service path (`CodeService.resolve` tries id then slug). `INFERRED` for the
DirectoryCodeSource gap: it follows from `fetch` building `envRoot.resolve(id + "." + ext)` with no
fallback, and no test currently pins it.

## The handler contract

**Rule.**

- **JS / JSX**: `export default`. For `.js` it is `(req, il) => value`; for `.jsx` it is a React
  component.
- **Java**: the class implementing `InterlockHandler<R>`, whose single method is
  `R handle(Request req, Interlock il)`.

Prefer a typed `R` (a plain POJO or record) over `Object` or `Map`. The interface between a unit and
the JVM should be a dumb data class.

**Why it exists.** The return value *is* the response. A single declared entry point means the runtime
never has to guess which function to call, and the type on `handle` is the only place the response
shape is written down.

**Failure it prevents.** A file that looks like a unit and has no entry point:

```
unit 'hello' has no default export function (expected `export default (req, il) => …`)
```

(JS, `JsRunner`)

```
unit 'caller/UseIt' is a library — no class implements InterlockHandler, so it can be imported by other units but not run
```

(Java, `JavaRunner.run`. See [Library units](#library-units): for a helper this is correct, for a
page it means you forgot `implements InterlockHandler`.)

**Example.**

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

public class UseIt implements InterlockHandler<UseIt.Out> {

    public record Out(int doubled, boolean ok) { }

    public Out handle(Request req, Interlock il) {
        int n = req.integer("n", 21);
        il.log("doubling", n);
        return new Out(n * 2, true);
    }
}
```

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

**Verify.** Run a unit that has no entry point and read the sentence. Both texts are reachable from
your own project in one command each:

```bash
interlock run caller/UseIt --env dev   # a handler: answers
interlock run lib/MathBox --env dev    # a library: refuses, and says which kind of file it is
```

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

`MEASURED` for the Java library refusal (`JavaSiblingTest.aLibraryUnitRefusesToRunAndSaysWhy`).
`INFERRED` for the JS text: it is thrown from `JsRunner` but no test asserts on the sentence.

## Return types and content types

**Rule.** `Engine.toResult` shapes the handler's return value:

| returned | served as |
|---|---|
| `Html` (`Html.of("<main>…</main>")`) | `text/html; charset=utf-8` |
| a bare `String` | `text/plain; charset=utf-8` |
| anything else (Map, List, POJO, number, boolean) | `application/json` |

**Why it exists.** A bare `String` is almost always prose, not a JSON document. Quoting it into
`"…"` would make every plain-text unit serve a JSON string that a client has to unwrap.

**Failure it prevents.** Returning `Json.toString(map)` (a `String`) instead of the map, then watching
a browser client fail to parse a body that *looks* like JSON but arrives as `text/plain`:

```
SyntaxError: Unexpected token in JSON at position 0
```

Return the `Map` and let the engine serialize it.

**Example.**

```java
return Map.of("ok", true);              // application/json
return "queued";                        // text/plain
return Html.of("<main><h1>42</h1></main>");    // text/html
```

**Verify.**

```bash
curl -sD- localhost:8090/run/caller/UseIt -o /dev/null | grep -i content-type
```

`MEASURED` (`Engine.toResult`, exercised by `EngineTest`).

## Library units

**Rule.** A unit with **no handler** is a library unit. It compiles, it is importable by other units,
and it refuses to run with an error that says what a library unit is. `constants.jsx` is not a page,
and neither is `lib/MathBox.java`.

**Why it exists.** Splitting logic into a helper should not require inventing a fake handler, and a
helper accidentally exposed at a URL should fail loudly rather than answer 200 with nothing.

**Failure it prevents.** Requesting a helper as if it were an endpoint:

```
unit 'lib/MathBox' is a library — no class implements InterlockHandler, so it can be imported by other units but not run
```

**Example.**

```java
// code/lib/MathBox.java — importable, not runnable
package lib;

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

`lib` here is just a directory somebody chose, not a reserved name: any valid Java identifier works,
and it has nothing to do with the `lib/main/` and `lib/boot/` directories of a packaged Quarkus
fast-jar, which are the host's own libraries and are never part of a unit id.

To exercise one from a test, use the reflective door rather than an HTTP call:

```java
Class<?> c = engine.unitClass("lib/MathBox", "dev");
assertEquals(14, c.getMethod("twice", int.class).invoke(null, 7));
```

`Engine.unitClass` returns the class the id implies (`lib/MathBox` → `lib.MathBox`), falling back to
the handler, then to the unit's first own class. The host cannot cast the result, because the unit is
deliberately not on the host's test classpath. That is the boundary working, not a workaround.

**Verify.** A library unit must compile, be importable, and refuse to run. All three are checkable in
your own project:

```bash
./scripts/check-units-compile.sh          # it compiles
interlock run caller/UseIt --env dev         # its importer runs and uses it
interlock run lib/MathBox --env dev       # it refuses, naming itself a library
```

The refusal is the point: a helper reachable at a URL should say so rather than answer 200 with
nothing.

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

`MEASURED` (`aLibraryUnitRefusesToRunAndSaysWhy`, `unitClassIsTheReflectiveDoorToALibrary`).

## The per-unit store

**Rule.** `il.store()` is this unit's own key-value namespace. **Reads return the value directly, with
no wrapper.** `il.store("other/Unit")` reaches another unit's namespace.

The interface (`sh.interlock.sdk.Store`) has five fundamentals a host must implement:

```java
Object get(String key);
Map<String, Object> all();
void put(String key, Object value);
void remove(String key);
void clear();
```

and four conveniences derived from them: `get(key, Class<T>)`, `getString`, `getMap`, `getList`, plus
`putAll(Map)`.

**Why it exists.** A store that returned `Optional<Value>` or `{value: …}` would put a wrapper in every
call site of every unit for the benefit of none of them.

**Failure it prevents (the sharp edge).** `get(String, Class<T>)` is **not** a checked cast. Its body
is `(T) get(key)` under `@SuppressWarnings("unchecked")`, so with erasure the cast happens at the
**assignment site**, not inside the store. Ask for the wrong type and the exception names the wrong
place:

```
java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String
```

thrown at your `String s = store.get("count", String.class);` line, with nothing pointing at the
store. Use `getString`, which goes through `String.valueOf`, when you want coercion rather than a
cast.

`getMap` and `getList` do *not* have this problem: both test with `instanceof` and return `null` on a
type mismatch. `getList` also copies into a fresh `ArrayList`, so mutating the result does not
mutate whatever the host handed back.

**Example.**

```js
const store = il.store();
store.put('count', (store.get('count') || 0) + 1);
```

```java
var store = il.store();
long n = ((Number) store.get("count")).longValue();
store.put("count", n + 1);

String label = store.getString("label");            // coerces, never throws
Map<String, Object> cfg = store.getMap("config");   // null if it is not a Map
```

**Verify.** Read the interface off the SDK jar your own host ships. This is the whole surface, and
the five fundamentals are the ones a custom `StoreFactory` must implement:

```bash
SDK=$(ls build/quarkus-app/lib/main/*interlock-java-sdk*.jar)
javap -cp "$SDK" sh.interlock.sdk.Store
```

`MEASURED` for the interface shape (it is the file). `INFERRED` for the ClassCastException site: it
follows from erasure plus the unchecked cast, and no SDK test currently pins it.

## The `il` surface

Every method on `sh.interlock.sdk.Interlock`, as declared:

| call | returns | notes |
|---|---|---|
| `il.store()` | `Store` | this unit's namespace |
| `il.store(codeId)` | `Store` | another unit's namespace |
| `il.secret(name)` | `String` | host-resolved, `null` when unset, never in source |
| `il.call(id, params)` | `Object` | run another unit, get its raw value |
| `il.call(id, params, tag)` | `Object` | run a unit with an optional phase tag in its trace |
| `il.withTag(tag)` | `Interlock` | independent capability whose traced child calls inherit the tag |
| `il.url(id, params)` | `String` | stable URL by id |
| `il.log(args…)` | `void` | streams to a watching client while the unit runs |
| `il.ai()` | `Ai` | generative AI |
| `il.geo()` | `Geo` | defaulted; a host that wires nothing answers `Geo.Location.UNKNOWN` |
| `il.context()` | `HostContext` | read-only; never null, empty when the host seeds none |
| `il.context(String key)` | `Object` | one entry |
| `il.context(Class<T>)` | `T` | **the one a Java unit should use** |
| `il.session(Class<T>)` | `T` | per-request facts, `null` when the host passed none |
| `il.unitId()` / `il.unitEnv()` / `il.unitVersion()` | `String` / `String` / `int` | this unit's own provenance |

## Name a phase in traces (Java)

Give calls a short optional customer tag so a waterfall can show the purpose of the work:

```java
Object ranked = il.withTag("Rank candidates").call("example/rank", params);
// Equivalent one-call form:
Object rankedAgain = il.call("example/rank", params, "Rank candidates");
```

`withTag` returns an independent capability; it does not mutate `il`. Child local and remote
traced calls inherit the tag, and a nested `withTag` overrides it for that branch. Parallel
branches can therefore use different tags. Tags are trace metadata, never added to prompts or
unit parameters. Control characters are removed, whitespace is trimmed, and tags are limited
to 120 characters; an empty tag is untagged.

A tag can appear on several layers of the same operation (unit, agent, inference, model call).
Those rows describe the operation's execution layers, not additional calls created by the tag.
Existing callers need no changes; historical traces without a tag remain untagged.

## Name the AI phase (Java)

```java
String explanation = il.ai().withTag("Explain choices").chat(Models.MEDIUM, prompt);
```

The optional tag names the phase in traces without changing the prompt, model, or response.
`withTag` returns an independent AI capability, so concurrent calls can carry different tags.
Chat and conversation calls inherit a tag from `il.withTag(...)`; a nested AI `withTag(...)`
overrides it. The same tag may appear on the agent, inference, and model-call rows for one
logical operation.

Tags are trimmed, control characters are removed, and the length is limited to 120 characters.
Empty tags and historical traces without tags remain untagged. This scope applies to chat and
conversation calls only; durable `submit` and media calls do not propagate it.

**Rule.** In Java, reach context by **type** (`il.context(MyContext.class)`), not by string.

**Why it exists.** A string lookup returns `Object`, so every Java caller casts anyway. The string
bought nothing except a lookup that fails at run time instead of compile time, and a
`ClassCastException` raised at the call site rather than where the mistake was. A host seeds one
object per capability, so the type already *is* the identity of the thing being asked for.

**Failure it prevents.** Two of them. The string form gives you:

```
java.lang.ClassCastException: class java.lang.String cannot be cast to class com.example.MyContext
```

at your cast, for a key that was simply misspelled. And if a host seeds two objects of the same type,
`context(Class)` refuses rather than picking one arbitrarily:

```
the host seeded more than one context of type com.example.MyContext
```

**Example.**

```java
MyContext ctx = il.context(MyContext.class);   // typed, compile-checked
if (ctx == null) {
    throw new Refusal(503, "unwired", "this host seeds no context of that type");
}
```

From JS the string form remains the natural one and is untouched: `il.context().db`.

**Verify.** Read the surface off the SDK jar your own host ships, then confirm your seed reaches a
unit by the typed door:

```bash
SDK=$(ls build/quarkus-app/lib/main/*interlock-java-sdk*.jar)
javap -cp "$SDK" sh.interlock.sdk.Interlock | grep context

interlock run world/WorldApi --env dev
# a unit whose first line is il.context(YourContext.class) answers, or throws the 503 above
```

`MEASURED` (the duplicate-seed `IllegalArgumentException` is thrown in `Interlock.context(Class)`).

## The `req` surface

Every method on `sh.interlock.sdk.Request`, as declared:

```java
String        str(String name, String def);
String        str(String name);          // default overload → str(name, null)
int           integer(String name, int def);
double        number(String name, double def);
boolean       bool(String name, boolean def);
List<String>  list(String name);
boolean       has(String name);
String        header(String name);
String        cookie(String name);
String        path();
String        method();
Map<String,Object> body();
String        principal();               // host-defined, or null
void          mark(String label);        // timing/diagnostics checkpoint
```

**Rule.** `req.str(name, def)` reads **query params AND the JSON body**, and query wins.

**Why it exists.** It did not always. Before it did, every unit API silently ran its default branch on
every POST: a well-formed request, a 200 response, the wrong code path, and nothing anywhere
reporting it.

**Failure it prevents.** The one with no error text. That bug was found only because an end-to-end
check asserted on the **effect** ("did the mute request actually arrive?") rather than on the status
code. Assert effects, not status codes: a unit that can only answer 200 makes every "no" look like a
"yes".

**Example.**

```java
public Object handle(Request req, Interlock il) {
    String target = req.str("target", null);        // reads ?target=… or {"target": "…"}
    if (target == null) {
        throw new Refusal(400, "no-target", "say who");
    }
    return Map.of("ok", true, "target", target);
}
```

**Verify.** Send the field in the body only, and read the body of the answer. A unit that answers the
default has not read the body:

```bash
curl -s -X POST localhost:8099/app/world/world-api \
  -H 'content-type: application/json' -d '{"target":"bob"}' | jq .
# expect {"ok":true,"target":"bob"}, not the 400 refusal
```

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

`MEASURED` (4 body tests, added alongside the fix).
