# Interlock — complete documentation This file is the entire Interlock documentation corpus, concatenated in dependency order. It is generated from the same sources that render https://docs.interlock.sh, so it cannot fall out of date relative to the site. Conventions used throughout: - Every rule is paired with the failure it prevents and the literal error text. - Claims are marked MEASURED (with the command to re-run) or INFERRED. - Ids are paths: code/world/WorldApi.java has id world/WorldApi. Source of truth for each section: https://docs.interlock.sh/.md ## Contents 1. Start here (start) — What Interlock is, the shape of a project, and the first unit that works. 2. The model (units) — What a unit is: ids are paths, envs promote, the handler contract, library units, the per-unit store. 3. Java units importing Java units (java) — Directory is package. Resolution is javac's sourcepath, not a shared classloader, and each importer gets its own compiled copy. 4. Embedding the SDK in a host (host) — Wiring, compile anchors, trust and the shadow boundary, context vs session, requests and responses, Refusal. 5. Quarkus and Panache entities (quarkus) — Quarkus builds every entity twice and the JVM loads the transformed copy. The entity accessor rule, measured. The most expensive page here. 6. Testing (testing) — The two-second compile gate, the unit-harness test, assert effects not status codes, and why every measurement needs a control arm. 7. Operating a project (operating) — .interlock-key binds a tree to a project, the sync index is the base, and how conflicts surface. 8. Durable tasks and workers (tasks) — Submit work once, run it on outbound-only Java or Node workers, and understand leases, retries, cancellation and unknown outcomes. 9. Search over your own data (search) — An index inside your own JVM, a synonym graph that knows what your things are, and results that say when they cannot be trusted. 10. Self-hosted AI workers (self-hosted-ai) — Install Qwen on your Mac or Linux hardware, choose an explicit route policy, and operate recoverable AI runs. 11. Model fleets (model-fleet) — Scaffold a model project, enroll narrow trainer and inference machines once, and operate verified self-updating workers. 12. Failure catalogue (failures) — Symptom to cause to fix. Start here if you arrived by pasting an error. 13. Reference (reference) — The flat API surface: Request, Interlock, Store, Result, Refusal, handler contracts. 14. Verify a host (verify) — The eight commands that prove a host is wired correctly, in order. /start tells you to run these, so they belong in the corpus. # Start here This page takes you from nothing to a unit running inside your own application. It is in two parts, and they are very different sizes. **Part 1 takes a minute.** You write a file and call it. **Part 2 takes an afternoon.** You wire the SDK into your own application so units run in your JVM, against your classes and your data. Most of that time is your build file, not our API. Do them in that order. Wiring the host first leaves you debugging two unfinished things at once. ## Part 1: your first unit ### Install the CLI ```bash curl -fsSL https://interlock.sh/assets/install.sh | bash ``` One file, no package manager: the script (itself an Interlock unit) downloads the CLI from `/assets/interlock-cli.js` and puts `interlock` on your PATH. Node 18+ is the only requirement — the installer checks and says so if it is missing. ### Create the project ```bash interlock init ``` This signs you in, writes a `.interlock-key` into the directory, and installs the Interlock skills into your AI tools. The key binds this tree to one project on one server, and the CLI walks up from the working directory to find it the same way git finds `.git`. ### Write a file Ids are paths. A file at `code/hello.js` has the id `hello`. ```javascript export default (req, il) => { const name = req.str('name', 'world'); il.log('greeting ' + name); return { ok: true, msg: 'hello ' + name }; }; ``` The default export is the handler. What you return becomes the response: an object is JSON, a string is `text/plain`, and `html(...)` serves markup. A file with no handler is a **library unit**, which other units can import but nobody can call as a page. ### Publish it and call it ```bash interlock sync code ``` ```bash interlock run hello ``` ```json { "ok": true, "msg": "hello world" } ``` Now change the message in the file, run `interlock sync code` again, and call it again. That is the whole loop, and it is the thing worth feeling before you read anything else. For a tighter loop, `interlock yolo code` mirrors the directory in both directions continuously. Read [sync versus yolo](/operating#sync-versus-yolo) before you use it on a tree you care about. ### Java works the same way A class instead of a function: ```java package examples; import sh.interlock.sdk.*; public class HelloJava implements InterlockHandler { @Override public Greeting handle(Request req, Interlock il) { return new Greeting(true, "hello " + req.str("name", "world")); } } ``` A Java unit's package is its directory, exactly as its id is its URL. So `code/examples/HelloJava.java` declares `package examples;`. Getting this wrong is refused at compile time with a sentence that says so. See [Java units](/java#directory-is-package). Prefer a typed return over `Object` or `Map`. The interface between a unit and your JVM should be a dumb POJO. ### Moving code towards production Environments are `dev`, then `staging`, then `prod`, and code moves between them by **promotion**, never by re-syncing: ```bash interlock promote hello --from dev ``` A production host pins `prod`, so only a promotion changes what it runs. That is the entire safety story for hot-deployable code, and it is why a careless sync cannot reach production on its own. ## Part 2: running units inside your own application Everything above ran on an Interlock server. This part is what makes units useful: the same unit, running in **your** process, able to call your classes and read your database. ### 1. Add the dependency ```groovy dependencies { implementation 'sh.interlock:interlock-java-sdk:0.1.0-SNAPSHOT' } ``` ```xml sh.interlock interlock-java-sdk 0.1.0-SNAPSHOT ``` While the version carries `-SNAPSHOT` it is **not on Maven Central**, and there is no public artifact yet. Ask whoever operates your Interlock server which coordinate to use and where to resolve it from: either an internal Maven repository they publish to, or a checkout of the Interlock repository you build yourself. With a checkout that is one command, and then your build resolves it out of your local repository: ```bash ./gradlew :interlock-java-sdk:publishToMavenLocal ``` ```groovy repositories { mavenLocal(); mavenCentral() } ``` ### 2. The whole build file This is the entire file rather than the Interlock fragment, because a fragment is not a build. Two separate acceptance runs lost most of their time here, reinventing roughly 130 lines that nobody had written down. ```groovy plugins { id 'java' id 'io.quarkus' version '3.15.1' } repositories { mavenLocal(); mavenCentral() } // mavenLocal FIRST: the SDK is a -SNAPSHOT dependencies { implementation enforcedPlatform('io.quarkus.platform:quarkus-bom:3.15.1') implementation 'sh.interlock:interlock-java-sdk:0.1.0-SNAPSHOT' implementation 'io.quarkus:quarkus-arc' implementation 'io.quarkus:quarkus-rest' // the JAX-RS stack the /app passthrough assumes implementation 'io.quarkus:quarkus-rest-jackson' implementation 'io.quarkus:quarkus-hibernate-orm-panache' implementation 'io.quarkus:quarkus-jdbc-postgresql' // or your driver implementation 'io.quarkus:quarkus-narayana-jta' // units may open their own transaction implementation 'io.quarkus:quarkus-smallrye-health' // /q/health, which /verify step 1 curls } java { toolchain { languageVersion = JavaLanguageVersion.of(21) } } // REQUIRED. Units are compiled at RUNTIME against the packaged application. If code/ leaks into a // sourceSet, Gradle compiles them at BUILD time against the untransformed classpath instead, which // silently defeats everything on the Quarkus page: entities have not been rewritten yet, so a unit // that is wrong in production compiles clean here. sourceSets { main { java { srcDirs = ['src/main/java'] } } } ``` `settings.gradle` needs the plugin repository or `id 'io.quarkus'` will not resolve: ```groovy pluginManagement { repositories { gradlePluginPortal(); mavenCentral() } } rootProject.name = 'my-host' ``` **Where `code/` lives.** At the repository root, a sibling of `build.gradle`, and never under `src/`. The gate script assumes it (`UNITS=code`), the CLI assumes it, and the `sourceSets` line above is what keeps Gradle out of it. **Which JAX-RS stack.** `quarkus-rest`, which is RESTEasy Reactive. The `/app` passthrough on [Embedding the SDK](/host#the-app-passthrough-in-full) is written against it, and a `String body` method parameter behaves differently on the classic stack. ### 3. Build the Engine `InterlockSDK.init` takes your SDK key and returns a builder. It is fluent, and `build()` returns the `Engine`: ```java Engine engine = InterlockSDK.init(System.getenv("INTERLOCK_SDK_KEY")) .codeSource(new InterlockClient(url, key)) .stores(myStores) .secrets(mySecrets) .context("app", myContext) // your own class, whatever it is .build(); ``` There are three `init` overloads and none of them takes a configuring lambda: `init(String sdkKey)`, `init(String sdkKey, Map context)`, and `init(String sdkKey, ContextProvider context)`. **Use `build()` rather than constructing `new Engine(...)` yourself.** `build()` is what auto-anchors your context classes for javac. Skip it and units fail to compile with `package com.example.billing does not exist`. An anchor is how javac finds your host's classes: the anchor's protection-domain code source **is** the host classpath entry, and registering one also reserves its package against units declaring into it. **You do not normally call `Engine.addCompileAnchor` yourself**, because `build()` anchors every class you seeded as context. Call it by hand only when you use a dynamic `ContextProvider`, or when units name a type you never seed. Full wiring on [Embedding the SDK](/host#compile-anchors). Packages, because getting these wrong costs you a compile: `sh.interlock.sdk` holds `InterlockSDK`, `Request`, `Interlock`, `Refusal`, `Html` and `Json`. **`Engine` and `Result` are in `sh.interlock.sdk.runtime`**, along with `CodeSource`, `DirectoryCodeSource`, `StoreFactory`, `SecretResolver` and the exception types. The full table is on [Reference](/reference#packages-at-a-glance). ### 4. Which secret is which Five names, and mixing them up fails quietly, so keep them straight: | Name | What it is | Who reads it | |---|---|---| | `INTERLOCK_SDK_KEY` | your HOST's key, minted per project | `InterlockSDK.init(...)` in your application | | `.interlock-key` | the same kind of key, written into a project directory by `interlock init` | the CLI, so it knows which project a tree belongs to | | `INTERLOCK_URL` | which Interlock server to talk to | both the SDK and the CLI | | `INTERLOCK_ENV` | which env the SDK fetches units from | the SDK | | `interlock.env` | the same choice, as Quarkus config | your host, if you prefer properties to environment | The host key and the CLI key are the same kind of secret and can be the same value. Set `INTERLOCK_ENV` (or `interlock.env`) deliberately: pointing a production host at `dev` is silent and serves the wrong code. ## What will bite you Three things, in the order people meet them. **The project key binds the tree.** `.interlock-key` ties that directory to one project on one server. Run the CLI in a tree you copied and you publish into somebody else's project. Read [Operating a project](/operating) before your first sync. **Quarkus rewrites your entities after your build.** If your host is Quarkus and your units touch Panache entities, read [Quarkus](/quarkus) before you write the first one. Quarkus builds every entity twice and the JVM loads the rewritten copy. A direct field read compiles clean and then dies on the first real request: ``` java.lang.IllegalAccessError: tried to access protected field com.example.billing.Note.uuid ``` The rule: **give unit-facing entities private fields with hand-written accessors, and read them through those accessors, including the identity field.** A field literally named `id` keeps its `public` modifier and so appears to work, which is exactly why this hides. Name it `uuid`, `ref` or `hash` and it is privatized like anything else. Full table, the `javap` commands that measured it, and the mechanism: [the entity rule](/quarkus#the-entity-rule). **A 200 proves nothing.** It proves your request was accepted, not that it did anything. Assert effects, not status codes, and read the body back. That distinction is the only reason a bug where every POST body was silently ignored was ever found. ## Before you call it working Run the [verification sequence](/verify). It is eight commands and it checks the things that fail quietly rather than the things that fail loudly. If you are an agent rather than a person: read `/llms-full.txt` instead of clicking through these pages. It is this page and every other one, in a single fetch, in dependency order. # 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` 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=`, `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`, 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 { 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("
")`) | `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("

42

"); // 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 all(); void put(String key, Object value); void remove(String key); void clear(); ``` and four conveniences derived from them: `get(key, Class)`, `getString`, `getMap`, `getList`, plus `putAll(Map)`. **Why it exists.** A store that returned `Optional` 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)` 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 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` | **the one a Java unit should use** | | `il.session(Class)` | `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 list(String name); boolean has(String name); String header(String name); String cookie(String name); String path(); String method(); Map 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). # Java units importing Java units ## For humans **Read this if** you are splitting Java work into helpers and importers. **Skip it if** every Java unit you write stands alone. ### Do this **A Java unit's package is its directory.** The compiler refuses any other answer. 1. **Declare the package that matches the folder.** `code/lib/MathBox.java` declares `package lib;`. 2. **Import it by that name** from a unit in another folder: `import lib.MathBox;`. 3. **Drop the import for a unit in the same folder.** Same directory means same package. ```java // code/caller/UseIt.java package caller; import lib.MathBox; public class UseIt implements InterlockHandler { public Object handle(Request req, Interlock il) { return MathBox.twice(21); // 42 } } ``` ### What will bite you There is no shared library. Each importer compiles its own copy of every helper it uses. - **Mutable statics are not shared.** Each importer gets its own copy, so the cache never hits and the counter reads low. Nothing errors. Keep shared state in the store, which is genuinely shared. - **A helper's own class cannot cross `il.call`.** The cast fails on the far side and the error names the same class twice. Pass maps, lists, strings and numbers, or a type from your host jar. ### Then read - [Directory is package](#directory-is-package) for the refusal text and the root case. - [Resolution is javac's SOURCE_PATH, not a shared classloader](#resolution-is-javacs-source_path-not-a-shared-classloader) for why the copies exist. - [Rule 2: no mutable statics in an imported unit](#rule-2-no-mutable-statics-in-an-imported-unit) for the compile warning that catches it. ## For robots Java units can import other Java units, the way JSX units always could. This page is the rule set that makes that safe rather than merely possible. It is where you land if you pasted `package lib does not exist`, `NoClassDefFoundError`, a `ClassCastException` whose two class names print identically, or a `mutable static field` warning out of a host log. Read [Units](units.md) first for ids, environments, and the handler contract. Everything here was read out of `interlock-java-sdk/src/main/java/sh/interlock/sdk/runtime/JavaRunner.java` and `Engine.java`, and is pinned by `interlock-java-sdk/src/test/java/sh/interlock/sdk/runtime/JavaSiblingTest.java` (10 tests). Versions: JDK 21, in-process `javax.tools` compiler, Caffeine 3.1.8 for the artifact cache. Error text is quoted **verbatim from the source that throws it**. Rules proven by a test or a live run are labelled `MEASURED`; rules that follow from reading the code with no test pinning them, `INFERRED`. Every **Verify** block below runs in your own host project. A few lines additionally name the SDK's own test suite; those are marked **MAINTAINER-ONLY** and need an Interlock repository checkout, so skip them and run the unmarked command above them. - [Directory is package](#directory-is-package) - [Same directory needs no import](#same-directory-needs-no-import) - [Directory names must be Java identifiers](#directory-names-must-be-java-identifiers) - [Resolution is javac's SOURCE_PATH, not a shared classloader](#resolution-is-javacs-source_path-not-a-shared-classloader) - [Rule 1: unit-local types must not cross `il.call`](#rule-1-unit-local-types-must-not-cross-ilcall) - [Rule 2: no mutable statics in an imported unit](#rule-2-no-mutable-statics-in-an-imported-unit) - [Invalidation cascades](#invalidation-cascades) - [Handler discovery is by attribution](#handler-discovery-is-by-attribution) - [Generated units are never importable](#generated-units-are-never-importable) - [Hosts opt in via `CodeSource.list`](#hosts-opt-in-via-codesourcelist) - [Standalone build checks see no siblings](#standalone-build-checks-see-no-siblings) - [Native image: no compiler](#native-image-no-compiler) ## Directory is package **Rule.** A Java unit's package **is** its directory under `code/`. `code/notes/NoteApi.java` declares `package notes;` and is imported as `import notes.NoteApi;`. The id/binary-name mapping is mechanical: `notes/NoteApi` maps to `notes.NoteApi`, and `notes/gen/Thing` to `notes.gen.Thing`. The check runs **only when the unit declares a package**, so every package-less unit written before this existed keeps working untouched. A package-less unit can neither import nor be imported: it sits in the default package, which javac cannot name from anywhere else. **Why it exists.** A unit's package is its directory the same way its id is its URL. If a unit could declare any package it liked, an `import` would resolve a name that its id contradicts, and there would be two answers to "where does this class live". **Failure it prevents.** A unit whose declared package and directory disagree, refused at compile with the fix in the sentence: ``` unit 'notes/Wrong' declares package 'elsewhere' but its directory requires package 'notes' — a unit's package is its directory, the way its id is its URL ``` At the root of the tree the same refusal reads: ``` unit 'Wrong' declares package 'elsewhere' but its directory is the root, which is the default package — a unit's package is its directory, the way its id is its URL ``` **Example.** ```java // code/lib/MathBox.java package lib; public class MathBox { public static int twice(int n) { return n * 2; } } ``` ```java // code/caller/UseIt.java package caller; import lib.MathBox; import sh.interlock.sdk.Interlock; import sh.interlock.sdk.InterlockHandler; import sh.interlock.sdk.Request; public class UseIt implements InterlockHandler { public Object handle(Request req, Interlock il) { return MathBox.twice(21); // 42 } } ``` **Verify.** In your own tree, the gate compiles with `-sourcepath` pointing at the unit root, which is the same resolution the runtime uses, and then the running host proves it end to end: ```bash ./scripts/check-units-compile.sh # javac resolves lib.MathBox out of code/lib/MathBox.java interlock run notes/NoteRenderer --env dev # and so does the host, at run time ``` A wrong `package` line fails the first command with `package lib does not exist` or with the mismatch text quoted above, in about two seconds. ```bash # MAINTAINER-ONLY (Interlock repository checkout, not your host): ./gradlew :interlock-java-sdk:test --tests 'sh.interlock.sdk.runtime.JavaSiblingTest' ``` `MEASURED` (`aUnitImportsAUnitAcrossDirectories`, `aPackageMustMatchItsDirectory`, `packagelessLegacyUnitsStillRun`). ## Same directory needs no import **Rule.** Two units in the same directory are in the same package and reference each other with **no import statement at all**. This is ordinary javac same-package resolution, not a special case. **Why it exists.** It gives Java the "directory as module" feel the JSX side always had. A folder is the unit of cohesion. **Failure it prevents.** Writing `import notes.NoteApi;` from `code/notes/NoteRenderer.java`, which is an import of a class in your own package. javac accepts it, so this one costs you nothing but noise. The failure that *does* bite is the opposite reading: assuming a same-directory reference needs an import, not finding one, and concluding the file must be dead code. **Example.** ```java // code/notes/NoteRenderer.java — no import line package notes; import sh.interlock.sdk.Interlock; import sh.interlock.sdk.InterlockHandler; import sh.interlock.sdk.Request; public class NoteRenderer implements InterlockHandler { public Object handle(Request req, Interlock il) { return NoteApi.render(req.integer("size", 8)); } } ``` **Verify.** In your own tree, delete the same-package import line and confirm both the gate and the running host are unmoved by it. That is the whole rule: the reference resolves either way, so the import was never what made it work. ```bash grep -rn "^import notes\." code/notes/ # expect nothing; a same-package import is noise ./scripts/check-units-compile.sh interlock run notes/NoteRenderer --env dev ``` ```bash # MAINTAINER-ONLY (Interlock repository checkout, not your host): ./gradlew :interlock-java-sdk:test --tests 'sh.interlock.sdk.runtime.JavaSiblingTest.sameDirectoryNeedsNoImport' ``` `MEASURED`. ## Directory names must be Java identifiers **Rule.** A directory whose name is not a valid Java identifier cannot be a package, so units inside it cannot be imported and cannot themselves declare a package. `JavaRunner.packageForDir` returns the empty string (the default package) if **any** path segment fails `[A-Za-z_$][A-Za-z0-9_$]*`. The same test is applied to the file's base name in `SiblingIndex.resolvePackage`, which skips ids that could not be a Java type name. So `code/my-lib/Thing.java` is a unit and runs fine, but it is invisible to javac. **Why it exists.** Ids are URL-shaped and permit kebab-case; Java packages are not. Rather than mangling a name behind your back (and giving one unit two identities), the tree simply does not offer a kebab directory as a package. **Failure it prevents.** The confusing half of this is the error you get if you *do* declare a package in a kebab directory. The directory resolves to the root, so the message names the root and not the kebab: ``` unit 'my-lib/Thing' declares package 'myLib' but its directory is the root, which is the default package — a unit's package is its directory, the way its id is its URL ``` And if you declare nothing, the import from elsewhere fails with the ordinary javac diagnostic, which never mentions the directory name: ``` compile failed for 'caller/UseIt': ERROR line 3: package my_lib does not exist ``` **Example.** ``` code/my-lib/Thing.java ❌ runs, but cannot be imported code/myLib/Thing.java ✅ package myLib; code/lib/Thing.java ✅ package lib; ``` **Verify.** Inventory your own tree first: any directory under the unit root whose name is not a Java identifier is a directory nothing can import out of. ```bash find code/ -mindepth 1 -type d | grep -Ev '/[A-Za-z_$][A-Za-z0-9_$]*$' # expected output: nothing. Every line is a directory invisible to javac. ``` To see the rule fire rather than infer it, move one library unit into a kebab directory, add an import of it from another unit, and run the gate. Expect `package my_lib does not exist`, then move it back. ```bash ./scripts/check-units-compile.sh ``` `INFERRED`. The identifier check is explicit in `packageForDir` and `resolvePackage`, but no test in `JavaSiblingTest` uses a kebab directory. If this bites you, that is the test to add. ## Resolution is javac's SOURCE_PATH, not a shared classloader **Rule.** Sibling resolution is javac's own dependency discovery. `MemoryFileManager` claims `StandardLocation.SOURCE_PATH` and serves sibling unit **sources** when javac lists a package. javac compiles exactly what the unit actually references (`-implicit:class`), and the resulting classes land in **this importer's own classloader**. The consequence is the whole design: **each importer gets its own compiled copy of its dependencies.** There is no shared commons loader and no shared class identity. **Why it exists.** Sharing one dependency loader couples unrelated unit lifetimes: closing that loader during cache eviction can break classes that are still live. Per-importer duplication makes that failure class unrepresentable. **Failure it prevents.** ``` java.lang.NoClassDefFoundError: lib/MathBox ``` after a shared helper's loader was evicted or closed while live classes still referenced it. Under the per-importer model there is no shared loader to close: an importer's classes live and die with that importer's cache entry. The price is paid in two rules, both made **loud** rather than left to be remembered. They are the next two sections. **Example.** Nothing to write. This is the mechanism, and the log line proves it fired: ``` java.compile id:notes/NoteRenderer siblings:[notes/NoteApi] ``` emitted at INFO by `JavaRunner.compile` whenever a unit compiled with at least one sibling. **Verify.** Run the host with INFO logging and hit a unit that imports a sibling: ```bash curl -s localhost:8099/app/notes/note-renderer > /dev/null grep -n "java.compile id:" your-app.log ``` `MEASURED` (live run on a real host application (Team Lakes), whose own `FloorCanvas` unit moved out of the host jar into `code/world/` and was served over HTTP from the synced tree with exactly this line, naming that host's ids). ## Rule 1: unit-local types must not cross `il.call` **Rule.** Values crossing an `il.call` boundary must be **host types** or plain data (Maps, Lists, Strings, numbers). Never a class declared in a unit. The engine warns, once per class name, when a unit-local class comes back out of `il.call`: ``` il.call id:caller/UseIt returned unit-local class lib.Pick — unit classes are per-importer; types crossing units should be host classes ``` The check is `JavaRunner.unitLocal(o)`: true when the object's classloader is a `MemoryClassLoader`. **Why it exists.** `lib.Pick` compiled into unit A and `lib.Pick` compiled into unit B are **different runtime classes with the same name**. A cast on the far side fails, and the exception prints the same name twice. **Failure it prevents.** The confusing cast: ``` java.lang.ClassCastException: class lib.Pick cannot be cast to class lib.Pick (lib.Pick is in unnamed module of loader sh.interlock.sdk.runtime.JavaRunner$MemoryClassLoader @1b6d3586; lib.Pick is in unnamed module of loader sh.interlock.sdk.runtime.JavaRunner$MemoryClassLoader @4a7f959b) ``` **Example.** ```java // ❌ crosses the boundary as a unit-local class public Pick handle(Request req, Interlock il) { return new Pick("boots", 4900); } // ✅ crosses as plain data public Map handle(Request req, Interlock il) { return Map.of("sku", "boots", "cents", 4900); } ``` A shared type that genuinely must be a class on both sides belongs in the **host** jar, where there is one copy, reached through `il.context(SomeHostType.class)`. **Verify.** Watch the host log for the warning: ```bash grep -n "returned unit-local class" your-app.log ``` `MEASURED` for the warning (it fires from `Engine.invoke`). `INFERRED` for the exact `ClassCastException` text above: the two-loader message is the JVM's standard format for this situation, reconstructed rather than captured, so the loader hashes will differ on your run. Search for `cannot be cast to class lib.Pick` and the duplicated name is the tell. ## Rule 2: no mutable statics in an imported unit **Rule.** A library unit must not hold **mutable static state**. Each importer compiles its own copy of the class, so a mutable static is not shared state, it is N copies of state that *look* shared. The compile warns, per offending field, for every dependency class (never for the unit's own classes, which are its business): ``` unit 'caller/UseIt' compiled in sibling 'lib/Counter' which has mutable static field 'hits' — every importer gets its OWN copy of that static ``` `static final` fields are exempt, as are synthetic fields. **Why it exists.** Said once, at compile time, instead of discovered in production as a cache that never hits, a counter that reads low, or a lazily initialized singleton that initializes twice. **Failure it prevents.** No exception, ever. That is the point. The symptom is a shared cache with a 0% hit rate and two units each convinced they own the only copy. **Example.** ```java // ❌ every importer gets its own `hits` package lib; public class Counter { public static int hits; public static void bump() { hits++; } } // ✅ state lives in the store, which is genuinely shared package lib; import sh.interlock.sdk.Interlock; public class Counter { public static void bump(Interlock il) { var s = il.store("lib/Counter"); Object n = s.get("hits"); s.put("hits", (n == null ? 0L : ((Number) n).longValue()) + 1); } } ``` **Verify.** ```bash grep -n "which has mutable static field" your-app.log ``` `MEASURED` (`JavaRunner.warnMutableStatics` walks every dependency class's declared fields after a successful compile). ## Invalidation cascades **Rule.** A compiled unit records **which sibling sources javac actually read**, by SHA-256 of the source. On the next run, a recorded hash that no longer matches the live source invalidates the cache entry and the importer recompiles. Two details that matter: - It records what was **read**, not what was **listed**. An unrelated unit sitting in the same directory does not become an invalidation trigger (`UnitSource.read` flips only in `getCharContent`). - The **id list** (which units exist) is snapshotted for **10 seconds** per environment in `Engine.siblings`, because javac asks for package listings on every compile and `CodeSource.list` may be an HTTP call. That staleness delays only how soon a **newly created** unit becomes importable. Edits to existing dependencies are caught by the hash cascade, not by the list. **Why it exists.** Without the cascade, an importer whose own source is unchanged keeps serving a compiled copy of last week's helper. Its cache key is its own source hash, and its own source did not change. **Failure it prevents.** No error text. You edit `lib/MathBox`, sync, hit `caller/UseIt`, and get the old answer: ``` expected: <30> but was: <20> ``` **Example.** ```java // before public static int twice(int n) { return n * 2; } // caller/UseIt returns 20 // after the edit and a sync public static int twice(int n) { return n * 3; } // caller/UseIt returns 30 on its NEXT run ``` **Verify.** In your own project, watch the cache flag on the importer flip after you edit its dependency. `JavaRunner` logs `cached:true|false` per run at INFO: ```bash curl -s localhost:8099/app/caller/use-it # warm it: expect cached:false then cached:true curl -s localhost:8099/app/caller/use-it # now edit code/lib/MathBox.java, publish it, and run the IMPORTER again interlock sync code curl -s localhost:8099/app/caller/use-it grep -o "java.run id:caller/UseIt cached:[a-z]*" your-app.log | tail -3 # expect: false, true, false. The third false is the cascade. ``` ```bash # MAINTAINER-ONLY (Interlock repository checkout, not your host): ./gradlew :interlock-java-sdk:test --tests 'sh.interlock.sdk.runtime.JavaSiblingTest.editingADependencyRecompilesItsImporter' ``` `MEASURED`. The 10-second list snapshot is `INFERRED` from `Engine.siblings` (`now - snap.at() > 10_000`); no test pins the window. ## Handler discovery is by attribution **Rule.** The handler is chosen among classes **attributed to the unit's own source file**, not by scanning the loader for the first class implementing `InterlockHandler`. `MemoryFileManager.getJavaFileForOutput` records, per emitted class, which source file it was born from: a `UnitSource` means a dependency, anything else means the primary unit. Only classes with a null origin are candidates, and among those the first concrete, non-abstract, non-interface implementer wins. **Why it exists.** Compilation output now contains dependency classes, **including their handlers**. A dependency that happens to be a runnable unit puts its own `InterlockHandler` into the importer's loader. "First class implementing `InterlockHandler`" would be a lottery. **Failure it prevents.** A request answered by the wrong unit, with a 200 and no error anywhere. The test asserts on exactly this: an importer whose dependency's handler returns `"WRONG HANDLER"` must still run its own. **Example.** ```java // code/dep/Answer.java — a runnable unit that is ALSO imported package dep; public class Answer implements InterlockHandler { public static String value() { return "dep"; } public Object handle(Request req, Interlock il) { return "WRONG HANDLER"; } } // code/caller/UseIt.java — importing it must not inherit its handler package caller; import dep.Answer; public class UseIt implements InterlockHandler { public Object handle(Request req, Interlock il) { return "mine-" + Answer.value(); } } ``` Running `caller/UseIt` returns `mine-dep`. **Verify.** Run the importer and read the body, not the status. The provenance header names which unit answered, so the two facts are one request apart: ```bash curl -si localhost:8099/app/caller/use-it | grep -i x-interlock-unit # must name caller/UseIt curl -s localhost:8099/app/caller/use-it # must NOT be "WRONG HANDLER" ``` ```bash # MAINTAINER-ONLY (Interlock repository checkout, not your host): ./gradlew :interlock-java-sdk:test --tests 'sh.interlock.sdk.runtime.JavaSiblingTest.discoveryRunsTheImportersHandlerNotTheDependencys' ``` `MEASURED`. ## Generated units are never importable **Rule.** A unit whose `CodeUnit.origin` is `generated` is **never offered to javac**. `SiblingIndex.resolvePackage` skips it, so the import simply does not resolve. Generated code stays behind `il.call`, where the reduced-trust chain holds. **Why it exists.** Compiling generated source *into* an authored importer would run it with the importer's capability. That is precisely the trust escalation origin tracking exists to prevent. Origin is store metadata, never inferred from the source text, the class name, or the id, so a generated unit cannot rename itself into privilege. **Failure it prevents.** It converts a silent privilege escalation into an ordinary compile error: ``` compile failed for 'caller/UseIt': ERROR line 2: package lib does not exist ``` If a unit you know exists refuses to import, check its origin before you check your spelling. **Example.** ```java // lib/MathBox is stored with origin "generated" import lib.MathBox; // ❌ does not resolve, at all Object n = il.call("lib/MathBox", Map.of("n", 21)); // ✅ this is the door ``` **Verify.** Only a host that actually produces generated units can see this fire. If yours does, add an import of one to an authored unit and run the gate; the expected result is `package lib does not exist` while `il.call` on the same id keeps working: ```bash ./scripts/check-units-compile.sh interlock run caller/UseIt --env dev ``` If your host has no generated units, there is nothing here to run and nothing to worry about: the rule can only tighten what a unit may import. ```bash # MAINTAINER-ONLY (Interlock repository checkout, not your host): ./gradlew :interlock-java-sdk:test --tests 'sh.interlock.sdk.runtime.JavaSiblingTest.aGeneratedUnitIsNotImportable' ``` `MEASURED`. ## Hosts opt in via `CodeSource.list` **Rule.** Sibling imports require the host's `CodeSource` to implement `list(String env)`. The interface default returns `List.of()`, so a source that only answers `fetch` keeps working exactly as before, with sibling imports simply off. ```java public interface CodeSource { CodeUnit fetch(String id, String env); default java.util.List list(String env) { return java.util.List.of(); } } ``` **Why it exists.** javac resolves `notes.NoteApi` by **listing package `notes`**, and a package listing is a directory listing of unit ids. An import that cannot be enumerated cannot be resolved. The default is empty rather than abstract so that every existing implementation, including the one-lambda test doubles the interface was shaped for, keeps compiling unchanged. **Failure it prevents.** A host that upgrades the SDK, writes a sibling import, and gets a compile error naming a package it can see on disk: ``` compile failed for 'caller/UseIt': ERROR line 3: package lib does not exist ``` The unit tree is correct. The `CodeSource` is not enumerable. `sh.interlock.sdk.client.InterlockClient` and `DirectoryCodeSource` both implement `list`; a hand-rolled lambda source does not. **Example.** ```java // ❌ sibling imports are off: a lambda only implements fetch CodeSource src = (id, env) -> myDb.load(id, env); // ✅ enumerable CodeSource src = new CodeSource() { @Override public CodeUnit fetch(String id, String env) { return myDb.load(id, env); } @Override public List list(String env) { return myDb.ids(env); } }; // ✅ straight off a git checkout, offline, no server and no key CodeSource src = new DirectoryCodeSource(Path.of("code")); ``` **Verify.** Read the interface off the SDK jar your own host already ships, no Interlock checkout needed. The `list` default is what decides whether sibling imports are available at all: ```bash SDK=$(ls build/quarkus-app/lib/main/*interlock-java-sdk*.jar) javap -cp "$SDK" sh.interlock.sdk.runtime.CodeSource # expect the default: public default java.util.List list(java.lang.String); ``` Then prove your own source implements it rather than inheriting the empty default: run a unit that imports a sibling and look for the compile line naming the dependency. ```bash grep -n "java.compile id:" your-app.log ``` ```bash # MAINTAINER-ONLY (Interlock repository checkout, not your host): ./gradlew :interlock-java-sdk:test --tests 'sh.interlock.sdk.runtime.JavaSiblingTest.directoryCodeSourceServesAGitTree' ``` `MEASURED`. ## Standalone build checks see no siblings **Rule.** `UnitBuild.check(id, language, source)` (reachable as `Engine.validate`) compiles a unit in **isolation**, with `siblings` explicitly `null`. A unit that imports a sibling therefore reports that import as an error there and still runs perfectly on the host, where the engine resolves it. This is a known, accepted limitation, not a bug in your unit. **Why it exists.** `UnitBuild` validates source a control plane will never run: no side effects, the artifact discarded, a fresh runner per check. It already cannot see a host's classes, and sibling resolution is the same kind of context. **Failure it prevents.** It does not prevent a failure; it **causes a misleading one**, and that is why it is written down. The diagnostic is indistinguishable from a genuinely broken import: ``` compile failed for 'caller/UseIt': ERROR line 3: package lib does not exist ``` Before chasing it, check where the check ran. `interlock check` skips `.java` and `.jsx` entirely and prints: ``` skip dev/caller/UseIt.java (checked server-side by warm-on-change) ``` so a green `interlock check` is not evidence that a Java unit compiles. **The check that is worth running instead.** Compile every Java unit through the real pipeline, in the host's own test suite, with `DirectoryCodeSource` + `Engine.unitClass` + reflection. It exercises sibling resolution, package validation, and the host classpath, so a broken unit fails in about two seconds instead of arriving as a boot WARN nobody reads in time. On a real host application (Team Lakes) this caught a shipped bug immediately: that host's `WorldBackend` unit called a package-private `WorldPrompts.cityOf` and could never have compiled on the host, because nothing had ever compiled it. ```java @Test void everyUnitCompiles() { try (Engine engine = new Engine(new DirectoryCodeSource(Path.of("code")), stores, secrets, ai)) { for (String id : new DirectoryCodeSource(Path.of("code")).list("dev")) { engine.unitClass(id, "dev"); // throws EngineException with real diagnostics } } } ``` Reflection is required and is not a workaround: the unit is deliberately not on the host's test classpath, and that is the boundary working. **Verify.** See the two answers disagree on the same unit, on your own project. The standalone check reports the sibling import as missing; the host runs it: ```bash interlock check # prints "skip dev/caller/UseIt.java" for Java units interlock run caller/UseIt --env dev # the same unit answers, siblings resolved ./scripts/check-units-compile.sh # and this is the check that is actually worth trusting ``` If your host exposes `Engine.validate` on an admin route, calling it for a unit that imports a sibling returns `package lib does not exist` while the same unit runs. That disagreement is the rule, not a defect. `MEASURED` (the `null` resolver is passed explicitly: `new JavaRunner().precompile(id, "check", source, null, false)`). ## Host entities are denied by default **Rule.** A Java unit may compile against the host's classes — except persistence entities. Naming a host `@Entity`/Panache class in a unit is refused at compile unless the host exported that entity with `Engine.exportEntity(TheEntity.class)`. **Why it exists.** Entity statics (`deleteAll`, `persist`, `find`, `listAll`) act on the WHOLE table. A careless or generated `Account.deleteAll()` in a unit empties a production table with nothing warning — proven by a probe unit that did exactly that before the boundary existed. **Failure it prevents.** The refusal is a sentence at compile time, where the author is: ``` unit 'reports/cleaner' references host entity 'Account', which is not exported to units — a unit may not reach a persistence entity directly, because its static operations (deleteAll, persist, find, listAll) act on the whole table. Export it with InterlockSDK.export(Account.class) if a unit should have full table access, or hand the unit a read-view instead. ``` **Example.** ```java // host wiring — one line per entity a unit may genuinely own: Engine.exportEntity(Task.class); // units may now use Task.find/persist/… // or, for a first-party host that treats its whole schema as unit-reachable: Engine.exportAllEntities(); ``` Prefer handing the unit a read-view through the host context where it only needs to read. **Verify.** Write a unit naming a non-exported entity and save it — the save's build check reports the sentence above, naming your entity. `INFERRED → MEASURED` by `UnitReachBoundaryTest` in the SDK suite, which first proved the pre-boundary probe could empty a fixture table. ## Native image: no compiler Java units need the **JDK compiler in-process**. In a native image `ToolProvider.getSystemJavaCompiler()` returns null and the unit refuses to run: ``` no Java compiler available (native image?) — Java units need JVM-mode hosting ``` JS units run in both. `MEASURED` (`JavaRunner.compile`, first branch). # 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/.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/.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/.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/.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 { 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 { 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 { 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` | **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` | 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//.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> params = new LinkedHashMap<>(); uriInfo.getQueryParameters().forEach((k, v) -> params.put(k, new ArrayList<>(v))); Map 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. * *

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 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.* # Quarkus and Panache entities ## For humans **Read this if** your host is Quarkus and any unit reads or writes a Panache entity. **Skip it if** no entity ever reaches unit code. ### Do this Give every entity a unit can touch: 1. **Private fields.** Not public. 2. **Hand-written getters and setters.** Write them yourself; do not let Quarkus generate them. 3. **Read through the accessors** in your unit. Including the identity field. ```java @Entity public class Note extends PanacheEntityBase { @Id private String ref; private String body; public String getRef() { return ref; } public String getBody() { return body; } public void setBody(String b) { this.body = b; } } ``` ```java // in a unit String body = note.getBody(); // yes String body = note.body; // no ``` ### What will bite you - **Quarkus rewrites your entities at build time.** Your unit compiles against one copy and the JVM loads another. The unit compiles clean, then fails on the first request that touches a field. - **A field named `id` works by accident.** It survives the rewrite, so the entity looks fine until you touch a second field. Name it `uuid` or `ref` and even that read fails. - **Dev mode lies.** `quarkusDev` writes no jar, so entity-touching units behave differently there. Test against a packaged build. ### Then read - [THE ENTITY RULE](#the-entity-rule) for the measured table and the `javap` commands. - [Panache lifecycle across the unit boundary](#panache-lifecycle-across-the-unit-boundary) if a write from a unit disappears without an error. ## For robots This is the page that cost several days and three wrong diagnoses. All three were wrong for the same reason: they reasoned about the classpath instead of printing it. If your host is Quarkus and any unit touches an `@Entity` class, read this page before you write that unit. Nothing here is a style preference. Each rule below has a literal error text attached because that is how you will arrive. Pinned versions: **Quarkus 3.15.1**, Hibernate ORM with Panache, Java 21, `sh.interlock:interlock-java-sdk:0.1.0-SNAPSHOT`. The bytecode measurements were taken with `javap` against packaged applications on **2026-08-08**; the commands are included so you can re-run them on your own version, and you should, because this is build-tool behaviour and it can move. Host wiring, trust, and the request contract live on the companion page: [Embedding the SDK in a host](host.md). - [Quarkus builds every entity twice](#quarkus-builds-every-entity-twice) - [Why the SDK inserts the transformed jar in addCodeSource](#why-the-sdk-inserts-the-transformed-jar-in-addcodesource) - [Print the classpath, do not reason about it](#print-the-classpath-do-not-reason-about-it) - [quarkusDev is unsupported for entity-touching units](#quarkusdev-is-unsupported-for-entity-touching-units) - [THE ENTITY RULE](#the-entity-rule) - [Re-measuring the entity rule yourself](#re-measuring-the-entity-rule-yourself) - [Provenance: what the 2026-08-08 re-measurement changed](#provenance-what-the-2026-08-08-re-measurement-changed) - [The two literal failure texts](#the-two-literal-failure-texts) - [Panache lifecycle across the unit boundary](#panache-lifecycle-across-the-unit-boundary) - [The custody line](#the-custody-line) ## Quarkus builds every entity twice **The rule.** A packaged Quarkus application contains **two copies** of every entity class: | path | contents | |---|---| | `build/quarkus-app/app/.jar` | the **ORIGINAL** bytecode, exactly as javac produced it | | `build/quarkus-app/quarkus/transformed-bytecode.jar` | the **REWRITTEN** copy, after Hibernate enhancement and Panache field replacement | **The JVM loads the transformed one.** A unit is compiled at RUNTIME, long after the build's rewrite pass, and is never itself rewritten. So a unit must be compiled against the transformed copy or it is compiled against a class that will not be the one it meets. **Why it exists.** Quarkus does its enhancement at build time rather than with a runtime agent. The untransformed jar stays on disk because it is the compile output the build produced; the transformed jar is an additional artifact the runtime classloader prefers. **The failure it prevents.** Given only `app/`, javac sees `public String status` and compiles a direct field read. The JVM then loads a class where that field is `protected`: ``` java.lang.IllegalAccessError: tried to access protected field com.example.billing.Note.status ``` The trap has a second jaw. Switching the unit to the generated `getStatus()` accessor fails the other way: those accessors exist only in the transformed jar, so they are invisible both to javac given `app/` and to your host's ordinary test classpath. **Example.** Confirm both jars exist and disagree: ```bash APP=build/quarkus-app javap -p -cp "$APP/app"/*.jar com.example.billing.Note | head -8 javap -p -cp "$APP/quarkus/transformed-bytecode.jar" com.example.billing.Note | head -8 ``` **Verify.** ```bash ls -l build/quarkus-app/quarkus/transformed-bytecode.jar unzip -l build/quarkus-app/quarkus/transformed-bytecode.jar | grep -i Note ``` --- ## Why the SDK inserts the transformed jar in addCodeSource **The rule.** The SDK adds `quarkus/transformed-bytecode.jar` (and `quarkus/generated-bytecode.jar`) to the unit compile classpath **immediately ahead of** the host's own jar, and it does so inside `JavaRunner.addCodeSource`, the helper that resolves a compile anchor's protection-domain code source. On a classpath, first wins, so the rewritten classes shadow the originals. That location is not an implementation detail. **It is the only place the layout root is knowable.** **Why it exists.** `java.class.path` in a running fast-jar is the expanded **LIBRARY** list. It contains neither `quarkus-run.jar` nor the layout root that `app/` and `quarkus/` sit under. So every "walk up from a classpath entry to find the layout root" approach searches a tree that is not there and silently does nothing. The host's own jar reaches the compile classpath by a completely different route: an anchor class's protection domain resolves to `/app/.jar`. That resolution is the single moment at which the root is in hand, so that is where the transformed jar gets inserted. The helper walks up to three levels from the entry, adding `quarkus/transformed-bytecode.jar` and `quarkus/generated-bytecode.jar` at the first level where either exists. **The failure it prevents.** A classpath that is 189 entries long and contains neither jar you were looking for, with no error at all to say so. Verified live at the time of the fix: the transformed jar landed at position 188 and the original at 191. **Example.** The relevant shape, from `JavaRunner`: ```java private static void addCodeSource(Set entries, Class clazz) { URL loc = clazz.getProtectionDomain().getCodeSource().getLocation(); File f = new File(loc.toURI()); if (f.exists()) { // BEFORE the jar itself: if this class ships from a Quarkus fast-jar, the // build rewrote its entities and left the ORIGINALS here. First wins. addTransformedBytecode(entries, f); entries.add(f.getAbsolutePath()); } } ``` **Verify.** Confirm the ordering in the real classpath, do not assume it: ```bash # enable: quarkus.log.category."sh.interlock.sdk.runtime.JavaRunner".level=DEBUG grep -o 'java.classpath .*' app.log | tr ':' '\n' | grep -n -E 'transformed-bytecode|app/' # the transformed jar's line number MUST be lower than the app jar's ``` The one-time INFO line at boot says the same thing, verbatim: ``` java units: Quarkus transformed bytecode on the compile classpath — units can use host entity accessors ``` --- ## Print the classpath, do not reason about it This is the lesson the whole page rests on. Both statements are quoted verbatim from the source and the implementation log: > Three attempts missed this by reasoning about the classpath instead of printing it. The DEBUG line > in `classpath()` exists so the next person does not repeat that. > > *(`JavaRunner.addTransformedBytecode` javadoc)* > Root cause found by PRINTING the classpath instead of reasoning about it — which should have been > the first move, not the fifth. > > *(`docs/agent-implementations/host-onto-interlock.md`, 2026-08-08)* **The rule.** When a unit will not compile against a host class, or compiles and then dies with a linkage error, your first action is to print the compile classpath. Not your second, not your fifth. **Why it exists.** Three separate diagnoses were produced by reasoning about jar manifests, about `Class-Path` entries, and about ordering. All three were internally consistent. All three were wrong, because they all assumed the layout root was reachable from `java.class.path`, and it is not. No amount of further reasoning was going to find that. One `LOG.debug` did. **The failure it prevents.** A day per wrong theory, and the strong feeling of progress that comes from each one being plausible. **Example.** ```properties # application.properties quarkus.log.category."sh.interlock.sdk.runtime.JavaRunner".level=DEBUG ``` ```bash grep -o 'java.classpath .*' app.log | tr ':' '\n' | nl | tail -20 ``` **Verify.** The DEBUG line is emitted by `JavaRunner.join(...)` on every compile. If you do not see it, the category is wrong or nothing has compiled yet; hit a Java unit once and look again. What you should expect that line to contain, entry by entry, is written down once as a rule: [what is on a unit's compile classpath](host.md#what-is-on-a-units-compile-classpath). Print first, then compare against that list; do not derive the list from the print. --- ## quarkusDev is unsupported for entity-touching units **The rule.** `quarkusDev` (and `quarkus:dev`) transforms entities **in memory** and writes no transformed jar. Units that touch host entities are therefore unsupported there. Package the application and run the packaged jar. Units that touch no entities are unaffected. **Why it exists.** The SDK's insertion depends on a file existing at `/quarkus/`. In dev mode there is no such file, so the compile classpath falls back to whatever the anchor resolves to, which in dev mode is a classes directory holding untransformed classes. **The failure it prevents.** A unit that works in dev and fails packaged, or the reverse, with the change being invisible in your source tree. The SDK says so once at boot rather than letting you find out: ``` java units: no transformed-bytecode jar found — if this is quarkusDev, units that touch host entities will not compile against their accessors; run the packaged jar for entity-touching units ``` That WARN is emitted only when at least one compile anchor is registered, so a host with no host classes exposed never sees it. **Example.** ```bash # NOT this, for entity-touching units: ./gradlew :your-api:quarkusDev # This: ./gradlew :your-api:quarkusBuild java -jar build/quarkus-app/quarkus-run.jar ``` **Verify.** ```bash grep -m1 "no transformed-bytecode jar found" app.log && echo "DEV MODE: entity units will not work" grep -m1 "Quarkus transformed bytecode on the compile classpath" app.log && echo "PACKAGED: ok" ``` --- ## THE ENTITY RULE **First, reachability — this rule only applies once an entity can be named at all.** A unit is denied every persistence entity by default; the host opts a table in with `InterlockSDK.export(Entity.class)` — see [The reach boundary](host.md#the-reach-boundary). So the order is: export the table (or, better, hand a record and never export it), and only *then* does the shape rule below decide whether a unit that names it compiles and runs. An entity you never export never reaches this rule. **The rule.** State it exactly like this. It is subtle, and it was measured, not reasoned. | entity as declared | transformed bytecode | usable from a unit? | |---|---|---| | public fields, **no** hand-written accessors | fields → `protected`, accessors generated | ❌ | | public fields, **with** hand-written accessors | fields stay **public**, nothing generated | ✅ either style | | private fields, with hand-written accessors | fields widened to **package-private**, nothing generated | ✅ accessors only | | **any field literally named `id`** | left **public**, regardless of the rows above | ✅ direct field read | | **`@Id` on a field NOT named `id`** (`uuid`, `hash`, `ip`) | privatized like any ordinary field | ❌ **even for the id** | **The mechanism, in one sentence.** *Quarkus privatizes an entity field only when it GENERATES that field's accessor. Write the accessor yourself and it leaves the field alone.* **The identity exemption tracks the JAVA FIELD name, not the `@Id` annotation and not the mapped column name.** `@Column(name = "id") private String ref` is still privatized: what matters is that the field is called `ref`. This is the row people get wrong, and it is expensive. A field literally called `id` stays public whatever else the entity does, which is why `entity.id` usually works and therefore **why this bug hides for so long**: the first thing a unit reads is normally an id, the id works, and the entity looks fine. Name the Java FIELD anything else and the exemption does not apply. `@Id @Column public String ip` and `@Id public String hash` were both **measured** as `protected` with generated accessors, so a host whose identity field is not called `id` gets `IllegalAccessError` **on the id itself** — the one access everybody assumes is safe. **`PanacheEntity` versus `PanacheEntityBase`, which nobody mentions until it costs a day.** Every entity on this page extends `PanacheEntityBase`, deliberately. The Quarkus reflex is the other one, `PanacheEntity`, the convenience superclass that supplies the identity for you. Measured against Quarkus 3.15.1, `javap` on `io.quarkus.hibernate.orm.panache.PanacheEntity` is three members: ``` public java.lang.Long id; public io.quarkus.hibernate.orm.panache.PanacheEntity(); public java.lang.String toString(); ``` A **public field named `id`, and no getter for it**. It is inherited from a library jar your build never rewrites, and `id` is also the exempt name in the table above, so it is public twice over. An entity extending `PanacheEntity` therefore hands a unit a working `entity.id` on the very first read a unit ever does, while every field the entity declares itself still obeys the table. The second read is where it dies. That is exactly the hiding pattern this page is about, arriving one inheritance clause earlier than you were watching for it, and it is why the entity above extends `PanacheEntityBase` and declares its own `id`. Neither superclass is wrong, and switching is not the fix. If you use `PanacheEntity`, know that `entity.id` proves nothing about the rest of the class, and give every field **you** declare private plus a hand-written accessor exactly as above. Note also that there is no inherited `getId()` to call: the `getId()` you may see in a `javap` of the transformed jar is generated at build time, so it does not exist on the classes your host's own tests compile against. Write one yourself if you want unit code to read the same whichever superclass an entity happens to extend. > **If you take one thing from this page:** give unit-facing entities private fields with > hand-written accessors, and reach for accessors rather than direct field reads even for the id. > That shape is correct under every row above. > > And do not WRITE to an entity from unit code. Reading through an accessor is > always safe; whether a write survives depends on what the host thread holds, which the > unit cannot see. The facade does the writing. **The requirement is the hand-written accessors, not the `private`.** Hand-written accessors are the only member that exists **identically in both bytecode copies**. That is what lets ONE unit source compile against the host's ordinary test classpath AND against the packaged application. Relying on the generated accessors fails the other way round: they do not exist on the classes host tests see, so converting units to generated accessors would work at runtime and break every host test. **`private` is optional, and recommended anyway.** What it buys is one thing, and it is worth it: it stops the mechanism from silently regressing. Delete an accessor from an entity with **public** fields and Quarkus quietly regenerates it and privatizes the field. Every unit reading that field breaks on the next deploy, while every host test stays green. With **private** fields, the same deletion fails the host build immediately, at the compiler, in front of the person who did it. **Why the rule exists at all.** Because the two bytecode copies are only a problem where they disagree, and a hand-written member is the one thing they cannot disagree about. **Example.** The shape that ships: ```java @Entity @Table(name = "note") public class Note extends PanacheEntityBase { @Id @Column(length = 36, updatable = false) private String id = UUID.randomUUID().toString(); // private + accessor: correct under EVERY row @Column(nullable = false, length = 64) private String workspaceId; // private: a deleted accessor fails the build @Column(length = 16) private String status; public String getWorkspaceId() { return workspaceId; } public void setWorkspaceId(String v) { this.workspaceId = v; } public String getStatus() { return status; } public void setStatus(String v) { this.status = v; } } ``` Unit side. Note what this example IS: a facade method that hands a whole `@Entity` row to a unit, which is the exact case this rule governs. ```java public class NoteApi implements InterlockHandler { public Object handle(Request req, Interlock il) { // note() returns the ROW. That is a decision the host made deliberately, because this // unit writes back through the accessors. A read-only caller should get a record instead. Note n = il.context(MyContext.class).note(req.str("id")); // READ through the accessor. Do NOT write from unit code: whether the write survives // depends on what the host thread is holding, and the unit cannot tell. See "Panache // lifecycle across the unit boundary" below, which is the rule that governs writes. String status = n.getStatus(); // accessor: identical in both bytecode copies return Map.of("id", n.getId(), // accessor, not n.id — correct whatever the field is called "status", n.getStatus()); } } ``` **How this sits with the companion page.** [Prefer a narrow record over an entity](host.md#prefer-a-narrow-record-over-an-entity) recommends that a facade hand a unit a record of the facts it needs rather than the row, and for a read-only caller it is the better shape: the snippet above would receive a record carrying the two fields it actually reads, and no entity would appear in this file at all. That recommendation and this rule are not in competition. The record guidance decides **whether** a row crosses; this rule decides what the entity must look like **when one does**, which is a case that keeps happening (writes through accessors, incremental migrations, facades older than the guidance) and is the reason the rule was measured in the first place. A host that took the record advice to its limit would never trip the entity rule, and that host would still be right to keep this page's shape on its entities, because the first facade method to return a row should not also be the first one to discover the trap. **Verify.** The two-second gate. Compile **every** Java unit against the **packaged** application, transformed jar first, before anything boots: ```bash ./scripts/check-units-compile.sh ``` The script itself is written out once, on the testing page: [the gate script](testing.md#the-gate-script). There is deliberately only one copy. It auto-discovers the `quarkus-app` directory, packages first unless you pass `--no-build`, and excludes `*.conflict-server` files, none of which a second hand-rolled copy on this page would keep in step. This gate found a whole unit tree that had been broken for a session behind an unrelated outage. Run it as the first step of your pre-flight, ahead of anything that boots. --- ## Re-measuring the entity rule yourself **The rule.** Do not take the table on faith across a Quarkus upgrade. Re-run the measurement, and **include a control arm**. **Why it exists.** The entity rule was only settled by running the experiment against a control entity that had no hand-written accessors. A one-armed measurement would have concluded the opposite: the row you happen to look at tells you nothing about the mechanism, only about that row. **The failure it prevents.** Concluding "Quarkus privatizes entity fields" (true of the entity you looked at, false as a rule) and then making every field private for no reason, or concluding "Quarkus leaves fields alone" and shipping a unit that dies on the first deploy. **Example.** The exact commands, run against **your own** packaged application. Every class name below is a placeholder: substitute three of your own entities, one per shape, and one of your own field names in the last line. `com.example.billing.Note` is this page's running example, not a class that exists anywhere. ```bash APP=your-api/build/quarkus-app ORIG="$APP/app"/your-api-*.jar XFRM="$APP/quarkus/transformed-bytecode.jar" # ARM 1 — one of YOUR entities with public fields and NO hand-written accessors (the control) javap -p -cp "$ORIG" com.example.billing.Note javap -p -cp "$XFRM" com.example.billing.Note # ARM 2 — one of YOUR entities with public fields and hand-written accessors javap -p -cp "$ORIG" com.example.billing.Tag javap -p -cp "$XFRM" com.example.billing.Tag # ARM 3 — one of YOUR entities with private fields and hand-written accessors (what should ship) javap -p -cp "$ORIG" com.example.billing.Workspace javap -p -cp "$XFRM" com.example.billing.Workspace # The one-liner that answers the whole question for a single field of yours: javap -p -cp "$XFRM" com.example.billing.Note | grep -E ' status;| getStatus\(| setStatus\(' ``` **What the 2026-08-08 run measured, as evidence rather than as a template.** It used `com.teamlakes.api.office.Presence`, `com.teamlakes.api.world.OfficeWorld` and `com.teamlakes.api.floor.FloorObject`, which belong to a real host application (Team Lakes). They are cited so the numbers on this page can be traced, and they are named in full so nothing here reads like a class you are supposed to have. Do not put them in the commands above; put yours. Read the **field modifier** in the transformed output, and read whether an accessor appeared that was not in the original. Those two facts are the entire measurement. **Verify.** You have a valid measurement only when all three arms are present in the same run and the arms disagree with each other. If they all agree, you measured your codebase's conventions, not Quarkus's behaviour. --- ## Provenance: what the 2026-08-08 re-measurement changed These are **already applied** to the table above. They are kept because a measured claim that changed should say what it changed from and why, and because anyone holding an older copy of this page needs to know which lines moved. Nothing below is outstanding. **MEASURED**, `javap` against two independently packaged Quarkus 3.15.1 applications (`teamlake-api` and `interlock-java-api`) on 2026-08-08. Every class named below is one of theirs, cited so the measurement can be traced; none of them is a class you are meant to have. The verdict column of [the entity rule](#the-entity-rule) is unchanged. Two descriptions in it are not exactly right, and one of them will cost you a day if you rely on it. ### 1. `private` fields are widened to package-private, not left unchanged Row 3 says "unchanged". Measured, the transformed copy widens `private` to **package-private**: | entity | original | transformed | |---|---|---| | `com.teamlakes.api.world.OfficeWorld.officeId` | `private java.lang.String officeId;` | `java.lang.String officeId;` | | `com.teamlakes.api.floor.FloorObject.kind` | `private java.lang.String kind;` | `java.lang.String kind;` | | `com.teamlakes.api.attention.Attention.officeId` | `private java.lang.String officeId;` | `java.lang.String officeId;` | The verdict stands: a unit is in a different package from your entity, so a package-private field is just as unreachable as a private one, and the accessors are still the way in. Nothing about the recommendation changes. The word "unchanged" is simply inaccurate, and if you `javap` your own entity expecting `private` and see no modifier, this is why. ### 2. The exemption tracks the field NAME `id`, not the `@Id` annotation This is the one that matters. "`@Id` is left alone in every case" is **false as measured**. What is left alone is a field literally **named `id`**. An `@Id` field with any other name is privatized exactly like an ordinary field, generated accessors and all. | declaration | class | transformed | |---|---|---| | `@Id @Column(length=64) public String id;` | `sh.interlock.api.model.Project` | `public java.lang.String id;` ✅ still public | | `@Id @Column(length=40) public String id;` | `sh.interlock.api.files.StoredFile` | `public java.lang.String id;` ✅ still public | | `@Id @Column(length=36) public String id;` | `com.teamlakes.api.office.Presence` | `public java.lang.String id;` ✅ still public | | `@Id @Column(length=45) public String ip;` | `sh.interlock.api.geo.GeoLocationRow` | `protected java.lang.String ip;` ❌ **privatized** | | `@Id @Column(...) public String hash;` | `sh.interlock.api.model.StoredBlob` | `protected java.lang.String hash;` ❌ **privatized** | The three `id`-named cases keep the public field even though Panache also generated `getId()` and `setId()` for them, which is itself an exception to the one-sentence mechanism above. The two differently-named identity fields got the ordinary treatment: `protected` field plus generated `getIp()`/`setIp()` and `getHash()`/`setHash()`. **Consequence, and it is practical.** If your entity's identity column is not called `id`, a unit reading it directly compiles against `app/` and dies with: ``` java.lang.IllegalAccessError: tried to access protected field sh.interlock.api.geo.GeoLocationRow.ip ``` Give it a hand-written accessor like every other field, or do not touch it from a unit. **Cause: INFERRED.** The most likely explanation is that Panache special-cases the name `id` because `PanacheEntity` itself declares `public Long id`, so the field-replacement pass skips that name to avoid colliding with the inherited one. The measurement is unambiguous; the reason is not verified, and the rule you should act on is the measurement. ### 3. Row 2 was not re-verifiable on 2026-08-08 No entity in either tree currently has the shape "public fields **with** hand-written accessors", so row 2 could not be re-measured today. It rests on the original measurement recorded in `docs/agent-implementations/host-onto-interlock.md` (2026-08-08), taken on `OfficeWorld` before that entity was converted to private fields. The nearest live data point is `sh.interlock.api.model.StoredCode`, whose two hand-written methods (`getType()`, `getStringId()`) are not accessors for any persistent field, and whose persistent fields were all privatized: consistent with the mechanism, not a test of it. To produce row 2 on your own host, add a temporary entity with a public field and a hand-written getter for that exact field, package, and `javap` it. Delete it afterwards. --- ## The two literal failure texts **The rule.** These are the strings to search for. Both mean the same defect: the unit was compiled against the original bytecode while the JVM loaded, or will load, the transformed copy. **At runtime**, an `IllegalAccessError`: ``` tried to access protected field X.y ``` The full JVM message names the accessing class and both modules, for example: ``` java.lang.IllegalAccessError: class notes.NoteApi tried to access protected field com.example.billing.Note.status (notes.NoteApi is in unnamed module of loader sh.interlock.sdk.runtime.JavaRunner$MemoryClassLoader @1f2a3b4c; com.example.billing.Note is in unnamed module of loader io.quarkus.bootstrap.runner.RunnerClassLoader @5d6e7f80) ``` `tried to access protected field` is the substring to grep. **At compile**, a javac diagnostic surfaced inside the SDK's `EngineException`: ``` y has protected access in X ``` In context: ``` compile failed for 'notes/NoteApi': ERROR line 27: status has protected access in com.example.billing.Note ``` **Why they are different.** The compile-time form is the **good** direction, and it is what the SDK's transformed-jar insertion buys you: with the rewritten classes on the compile classpath, a missing accessor becomes a compile error you see in two seconds instead of an `IllegalAccessError` on a live request. If you are seeing the runtime form, the transformed jar is not on your compile classpath, and that is a wiring problem, not an entity problem. Go back to [print the classpath](#print-the-classpath-do-not-reason-about-it). **The fix for both is the same:** give the field a hand-written accessor on the entity, and call that accessor from the unit. **Verify.** ```bash grep -c "tried to access protected field" app.log grep -c "has protected access in" app.log # and the gate that makes both unnecessary: ./scripts/check-units-compile.sh ``` --- ## Panache lifecycle across the unit boundary The entity rule above is about the **shape** of a row a unit can name. This one is about whether that row is still connected to a database when the unit touches it. They are separate questions and the second one is the expensive one, because most of its answers have no error text. **The rule.** A unit runs **on the host's own thread, inline**, inside whatever JTA transaction and CDI request context that thread already had. `JavaRunner.run` calls `handler.handle(req, il)` directly: no executor, no thread hop, no `@Transactional`, no `@ActivateRequestContext`, no session of its own. The SDK adds nothing to the persistence context and takes nothing away. So the state of a row handed across the boundary was decided by the host one frame earlier, and there are exactly three states the host can leave it in. | what the host thread has when the unit runs | the row the facade handed over | a setter on it | a lazy association on it | |---|---|---|---| | an open transaction: the unit is reached from inside `@Transactional`, or from inside a `QuarkusTransaction…call(() -> engine.run(…))` block | **managed** | **persists**, flushed at the host's commit | loads | | no transaction, CDI request context active: the ordinary `@Path` resource that calls `engine.run` without annotating it | **managed** by the request-scoped session, if the facade also read it there; **detached** if the facade read it inside a transaction that has since committed | **never persists** | loads in the first case, throws in the second | | neither: a background thread, a boot-time warm-up, a `@Scheduled` method | detached | never persists | throws | Row two is where nearly every host actually is, because that is what a plain JAX-RS resource gives you. It is also the row with the silent outcome, so read it twice. **Why it exists.** Quarkus decides all of this in one method, `io.quarkus.hibernate.orm.runtime.session.TransactionScopedSession.acquireSession()`, and it has three branches and no fourth. In a transaction you get the transaction-scoped session with modification allowed. Outside one, with a request context, you get the request-scoped session with modification **not** allowed. With neither, you get an exception. The unit inherits whichever branch the host's thread was already in, because the unit is not a new thread and not a new scope. **The failure it prevents.** Four outcomes from one line of unit code, and only three of them say anything. **Silent.** The row is still managed by the request-scoped session (the facade read it in the same request, without a transaction). The unit calls a setter and then `persist()`. Nothing happens, and nothing is logged. Panache's `AbstractJpaOperations.persist` is `if (!session.contains(entity)) session.persist(entity)`, so for an already-managed entity it skips the call entirely, and `RequestScopedSessionHolder.destroy()` closes that session at end of request with a plain `close()` and no flush. The write is dropped between two correct-looking lines. **Loud, on a write to a detached row** (the facade read it inside `QuarkusTransaction.requiringNew()` and returned it after the commit): ``` jakarta.persistence.TransactionRequiredException: Transaction is not active, consider adding @Transactional to your method to automatically activate one. ``` **Loud, on a lazy association of a detached row:** ``` org.hibernate.LazyInitializationException: Could not initialize proxy [com.example.billing.Note#42] - no session ``` ``` org.hibernate.LazyInitializationException: failed to lazily initialize a collection of role: com.example.billing.Workspace.notes: no session or session was closed ``` **Loud, on anything at all from a thread with no request context** (this is the one a boot-time warm call or a background job hits, and it fires on reads too): ``` jakarta.enterprise.context.ContextNotActiveException: Cannot use the EntityManager/Session because neither a transaction nor a CDI request context is active. Consider adding @Transactional to your method to automatically activate a transaction, or @ActivateRequestContext if you have valid reasons not to use transactions. ``` **What the host should do instead.** Two rules, and between them the whole question stops existing. 1. **Do not hand the row over.** Hand a record of the facts the unit actually needs. This is [prefer a narrow record over an entity](host.md#prefer-a-narrow-record-over-an-entity) on the companion page, and lifecycle is its strongest argument: a record carries no session, so it cannot be detached, cannot be lazily anything, and cannot pretend a setter did something. 2. **Open and close the transaction entirely inside the facade method.** Whatever crosses back out is data, not a handle. The reference host does this in every gateway method: `QuarkusTransaction.requiringNew().call(() -> …)` wraps the queries and the return value is a `Map` or a record. A unit therefore never holds a row across a transaction boundary, because it never holds a row. If a unit must **write**, give the facade a verb that performs the write inside its own transaction and returns the outcome. Never `row.setX(...)` from unit code, in any of the three rows above: it is correct in one of them and silently wrong in another, and the unit cannot tell which one it is in. **Example.** The two shapes. ```java // YES. The transaction opens and closes inside the host call; a record comes back out. public record CounterView(boolean found, long value) { } public CounterView counter(String key) { return QuarkusTransaction.requiringNew().call(() -> { Counter c = Counter.find("key", key).firstResult(); return c == null ? new CounterView(false, 0L) : new CounterView(true, c.getValue()); }); } // NO. This compiles, and what it does depends on the caller's thread. public Counter counterRow(String key) { return Counter.find("key", key).firstResult(); // no transaction of its own } // …and in the unit: Counter c = ctx.counterRow(key); c.setValue(c.getValue() + 1); c.persist(); // dropped, or TransactionRequiredException ``` The facade method that returns a bare `Counter` is the shape to grep your own host for. It is the one that reads as if it works. **Verify.** Prove it on your own host, with a write and a read-back, because the failing case does not raise: ```bash # 1. a unit that mutates a row through a facade, then a SEPARATE read that must show the change curl -s -X POST localhost:8099/app/counter/counter-api \ -H 'content-type: application/json' -d '{"action":"add","key":"k1","by":5}' curl -s 'localhost:8099/app/counter/counter-api?action=read&key=k1' | jq -r .count # 5 or the rule was broken. A 200 on the first call proves nothing. # 2. the three loud texts, if any of them are firing grep -E "TransactionRequiredException|LazyInitializationException|ContextNotActiveException" your-app.log # 3. the shape check: a context facade method whose return type is an @Entity is a lifecycle decision javap -p -cp build/classes/java/main com.example.app.MyContext \ | grep -E "Counter|Note|Membership|Workspace\b" ``` Check 1 is the only one that catches the silent case, and it is [assert effects, not status codes](testing.md#assert-effects-not-status-codes) applied to persistence. Check 3 is the same inventory the companion page runs for a different reason: there, what a row over-exposes; here, what a row cannot survive. **What is measured and what is inferred here.** - **MEASURED**, read from Quarkus 3.15.1 and Hibernate ORM 6.6.0 sources on 2026-08-08: the three `acquireSession()` branches and the `allowModification` flag; the `TransactionRequiredException`, `ContextNotActiveException` and `LazyInitializationException` strings, quoted from `TransactionScopedSession`, and `AbstractLazyInitializer` / `AbstractPersistentCollection`; `RequestScopedSessionHolder.destroy()` closing without a flush; Panache's `contains`-guarded `persist`. **MEASURED** from the SDK: `JavaRunner.run` calling `handler.handle(req, il)` inline on the caller's thread, with no transaction or scope of its own. - **INFERRED**: the lazy-association row of the table. The reference host maps every reference as a plain id column and declares **no** `@ManyToOne`, `@OneToMany` or `@OneToOne` anywhere, so the association behaviour is derived from the Hibernate throw sites rather than observed at this boundary. What would settle it: add one lazy association to a host entity, hand the detached parent to a unit, touch the association, and capture the message. A host that sets `hibernate.enable_lazy_load_no_trans=true` takes a different branch entirely and will not see these texts. - **INFERRED**: the first table row's "flushed at the host's commit". It follows from the entity being in the transaction-scoped session, which Hibernate flushes on commit, but no host in the reference tree calls a unit from inside an open transaction, so it was not observed. This is also the row worth avoiding on purpose: it makes a unit able to commit a write by accident. --- ## The custody line **The rule.** Reasoning moves to a unit. Custody stays in the host. | Stays in the host: CUSTODY | Moves to a unit: REASONING | |---|---| | `@Entity` classes and their tables | prompts, validation, clamping | | `@Transactional` boundaries | ordering, orchestration, eligibility rules | | background threads / `@Scheduled` reapers | the API surface (verbs, shaping) | | session establishment; auth | anything a bad `interlock sync` should be able to change safely | **The reason, in one sentence.** *Hot-deployable schema or hot-deployable auth turns fast iteration into fast damage.* **Why it exists.** The unit boundary should be decided by what is safe to change in thirty seconds, not by what happens to compile. An entity that becomes a unit is a database you can lose on a bad sync. Auth that becomes a unit is an authorization decision anyone with Studio write access can edit. **Two things the line does not forbid.** - A unit may open a transaction for its own write: `QuarkusTransaction.requiringNew()`. Owning the boundary is what stays in the host; needing one is not forbidden. That it *compiles* is not an accident of this page: `io.quarkus.narayana.jta` ships in the packaged app's `lib/main/`, which is on every authored unit's compile classpath. The full list is [what is on a unit's compile classpath](host.md#what-is-on-a-units-compile-classpath). - A request-scoped unit cannot outlive its call. Hand long work to a host executor through a context capability, and let the host own the thread. **The failure it prevents.** Not an error text, a class of incident: a schema change and an auth change that both ship without a build, a review, or a rollback path, at the speed of a file save. Every subsystem that resists this line is a finding worth writing down, not a rule worth bending. **Example.** The split, applied to one subsystem: ``` STAYS (host jar) MOVES (code/counter/) Counter @Entity CounterApi the endpoint, one verb per action Counter.prune bulk row delete CounterRules the clamp on the step, a pure function session resolution auth (no DB, no clock, no entity) ``` The clamp needed two facts about the stored counter, so the host added `MyContext.counter(key)` returning `(found, value)` rather than exposing the `Counter` row. See [prefer a narrow record over an entity](host.md#prefer-a-narrow-record-over-an-entity). **Verify.** ```bash # no entity, transaction boundary, or scheduler may live in the unit tree grep -rn "@Entity\|@Transactional\|@Scheduled" code/ --include='*.java' # expected output: nothing ``` --- ## What is measured and what is inferred Class names in the MEASURED rows below belong to the two real applications the measurement ran against, a real host application (Team Lakes) and `interlock-java-api`. They are cited so a reader can trace the evidence, and none of them is a class your own host is expected to have. | claim | status | |---|---| | Two bytecode copies exist; the JVM loads the transformed one | **MEASURED**: both jars present and disagreeing, Quarkus 3.15.1, 2026-08-08 | | public fields + no hand-written accessors → `protected` + generated accessors | **MEASURED**: `Presence`, `Membership`, `Room`, `Project`, `Account`, `StoredFile`, `GeoLocationRow`, `StoredCode` | | private fields + hand-written accessors → widened to package-private | **MEASURED**: `OfficeWorld`, `FloorObject`, `Attention` (corrects "unchanged") | | A field named `id` keeps its `public` modifier | **MEASURED**: `Project`, `StoredFile`, `Presence`, `Membership`, `Room`, `OfficeWorld` | | An `@Id` field NOT named `id` is privatized like any other | **MEASURED**: `GeoLocationRow.ip`, `StoredBlob.hash` (corrects "`@Id` is left alone in every case") | | public fields + hand-written accessors → fields stay public | **MEASURED 2026-08-08 on `OfficeWorld`**, recorded in `host-onto-interlock.md`; **not re-verifiable today**, no entity has that shape | | Panache skips the name `id` because `PanacheEntity` declares it | **INFERRED**: plausible cause, not verified | | The transformed jar is inserted in `addCodeSource`, ahead of the original | **MEASURED**: `JavaRunner.addCodeSource` source, and the printed classpath (transformed 188, original 191) | | `quarkusDev` writes no transformed jar | **MEASURED**: the boot WARN fires there and not on a packaged run | | A unit runs inline on the host's thread, in the host's transaction and scope | **MEASURED**: `JavaRunner.run` calls `handler.handle(req, il)` directly; no executor, no `@Transactional`, no `@ActivateRequestContext` anywhere on the path | | Outside a transaction, a write from a unit is refused or silently dropped | **MEASURED**: `TransactionScopedSession.acquireSession` (`allowModification == false`), Panache's `contains`-guarded `persist`, `RequestScopedSessionHolder.destroy()` closing without a flush | | A lazy association on a detached row throws | **INFERRED**: the Hibernate 6.6.0 throw sites are read from source, but the reference host declares no associations at all, so it was not observed at this boundary | --- *Companion page: [Embedding the SDK in a host](host.md) for wiring, compile anchors, the shadow boundary, and the request/response contract.* # Testing ## For humans **Read this if** you write Java units. **Skip the first two sections if** you only write JS or JSX. ### Do this 1. **Add the compile gate** to your repo as `scripts/check-units-compile.sh`. It compiles every Java unit against the packaged app before anything boots. Takes about two seconds. 2. **Run it in CI and as a pre-commit hook.** It is the cheapest check on this page. 3. **Assert what happened**, not that the request returned 200. ```bash # not this curl -s -o /dev/null -w '%{http_code}' "$HOST/app/counter/api" # 200 proves nothing ``` ```bash # this curl -s -X POST "$HOST/app/counter/api" -d '{"action":"add","by":1}' curl -s "$HOST/app/counter/api" | grep -q '"count":1' && echo OK ``` ### What will bite you - **Your build output is not what production loads.** Compile against the packaged app or the mismatch shows up after deploy, to a visitor. - **A 200 hides a wrong branch.** A unit can answer successfully while running the wrong code. That is exactly how a bug where every POST body was ignored survived. - **The harness test does not replace the gate.** It runs in your ordinary test JVM, which has a different classpath. You need both. ### Then read - [The two-second gate](#the-two-second-gate) for the script. - [Assert effects, not status codes](#assert-effects-not-status-codes) for the pattern. ## For robots Four patterns, each one here because it caught something real. Every rule below states the failure it prevents, the literal error text that failure produces, a minimal example, and the command that proves the rule holds in your own repo. Versions: measured 2026-08-08 on Quarkus 3.15.1, JDK 21.0.2, `interlock-java-sdk` 0.1.0-SNAPSHOT, against a packaged fast-jar. Every `javap` and `javac` command below was run, not reasoned about. Re-run them on your own version before trusting the table in [Every measurement needs a control arm](#every-measurement-needs-a-control-arm). ## The two-second gate **The rule.** Compile EVERY Java unit against the PACKAGED application, with `quarkus/transformed-bytecode.jar` first on the classpath, before anything boots. Make it the first gate in your pre-flight, ahead of the test suite and ahead of the server. **Why it exists.** A unit is compiled at RUNTIME, by the host JVM, against the classes that JVM actually loaded. Quarkus builds every entity twice: `app/.jar` holds the ORIGINAL bytecode and `quarkus/transformed-bytecode.jar` holds the rewritten copy, and the JVM loads the transformed one. A unit compiled against anything else (your repo's `build/classes`, a stale jar, a `quarkusDev` session that writes no transformed jar at all) is compiled against a host API that is not the one it will meet. The gate reproduces the server's classpath exactly, so the compile either succeeds for the same reason the server will succeed, or fails now. **The failure it prevents.** A whole unit tree broken for an entire session, hidden behind an unrelated outage: nothing had ever compiled those units, so nothing had ever reported them. The two error texts to search for are quoted from a real host application (Team Lakes), so `Presence` below is that host's own entity, not a class you are expected to own: ``` error: status has protected access in Presence ``` at unit compile time (the entity has no hand-written accessor, so Quarkus privatized the field), and ``` java.lang.IllegalAccessError: tried to access protected field com.teamlakes.api.office.Presence.status ``` at runtime, on the first HTTP request after a deploy (the unit compiled against the ORIGINAL bytecode, where the field is still public, and the JVM loaded the transformed copy). Without the gate the second one is how you find out, in production, from a visitor. Boot also warns once when the transformed jar is missing entirely, which is what `quarkusDev` looks like: ``` WARN java units: no transformed-bytecode jar found — if this is quarkusDev, units that touch host entities will not compile against their accessors; run the packaged jar for entity-touching units ``` **Minimal example.** The whole gate is one `javac` invocation. Transformed jar first, then the original and the libraries, with `-sourcepath` pointing at the unit tree so sibling imports resolve the way the runtime resolves them (directory is package): ```bash javac -nowarn -d /tmp/units -cp "$APP/quarkus/transformed-bytecode.jar:$APP/app/*:$APP/lib/main/*:$APP/lib/boot/*" -sourcepath code/ $(find code/ -name '*.java') ``` **Verify it.** Save the script in the next section as `scripts/check-units-compile.sh`. That path is the canonical one: every page in these docs invokes the gate by exactly that name, so a link, a CI job, and a pre-commit hook all name the same file. Then: ```bash ./scripts/check-units-compile.sh ``` Green output is `==> all units compile`. Exit status is 1 on any compile error, so it fails a CI job or a pre-flight script without further plumbing. ## The gate script Copy this verbatim. It packages first (Gradle and Maven both no-op when nothing changed), finds the `quarkus-app` directory, puts the transformed jar first, and compiles the whole unit tree. It runs in about two seconds on a warm build: 13 units in 2.0s wall clock on the reference repo. ```bash #!/usr/bin/env bash # scripts/check-units-compile.sh — compile every Java unit the way the RUNNING SERVER will. # # Not the way this repo's own test classpath would: Quarkus builds every entity twice, and the # packaged application loads the transformed copy. This puts that copy FIRST, exactly as the SDK's # JavaRunner does, so a unit that only compiles against this repo's classes fails HERE, in about # two seconds, instead of on the first HTTP request after a deploy. # # Packaging first is not optional: checking units against a stale jar reports the last build's host # API, which is the same lie this script exists to catch. Gradle and Maven no-op when nothing # changed. Pass --no-build only when you have just packaged by hand. # # Usage: ./scripts/check-units-compile.sh [--no-build] (the cd below makes the CWD irrelevant) # Override: APP= UNITS= BUILD_CMD='' set -euo pipefail cd "$(git rev-parse --show-toplevel)" UNITS="${UNITS:-code}" APP="${APP:-}" find_app() { [[ -n "$APP" ]] && return 0 local c for c in */build/quarkus-app build/quarkus-app */target/quarkus-app target/quarkus-app; do if [[ -f "$c/quarkus/transformed-bytecode.jar" ]]; then APP="$c" return 0 fi done return 0 } if [[ "${1:-}" != "--no-build" ]]; then echo "==> packaging (needed for the transformed bytecode)" if [[ -n "${BUILD_CMD:-}" ]]; then eval "$BUILD_CMD" elif [[ -x ./gradlew ]]; then ./gradlew quarkusBuild -q elif [[ -x ./mvnw ]]; then ./mvnw -q package -DskipTests else echo "!! no ./gradlew or ./mvnw — set BUILD_CMD='...' or pass --no-build" >&2 exit 1 fi fi find_app if [[ -z "$APP" ]]; then echo "!! no transformed bytecode found — package a fast-jar first (quarkusDev writes none)" >&2 exit 1 fi # Transformed FIRST. The JVM loads that copy, so javac must see it ahead of the original in app/. CP="$APP/quarkus/transformed-bytecode.jar" for j in "$APP"/app/*.jar "$APP"/lib/main/*.jar "$APP"/lib/boot/*.jar; do [[ -f "$j" ]] && CP="$CP:$j" done OUT="$(mktemp -d)" trap 'rm -rf "$OUT"' EXIT FILES="$(find "$UNITS" -name '*.java' -not -name '*.conflict-server' | sort)" if [[ -z "$FILES" ]]; then echo "==> no Java units under $UNITS — nothing to check" exit 0 fi echo "==> compiling $(echo "$FILES" | wc -l | tr -d ' ') unit(s) against $APP" # -sourcepath is the sibling-import rule: directory is package, so javac resolves notes.NoteApi # out of code/notes/NoteApi.java the same way JavaRunner's file manager serves it. if javac -nowarn -d "$OUT" -cp "$CP" -sourcepath "$UNITS" $FILES; then echo "==> all units compile" else echo "!! units do not compile against the packaged application" >&2 exit 1 fi ``` Three details are load-bearing, and each of them was a bug before it was a line of script: 1. **`transformed-bytecode.jar` comes first.** Put the launcher jar or `app/` first and javac silently resolves the untransformed classes instead, because javac honours a jar manifest's `Class-Path` and `quarkus-run.jar` lists `app/`. 2. **`--no-build` is opt-in, not the default.** Checking units against a stale jar reports the last build's host API, which is exactly the lie the gate exists to catch. 3. **`-not -name '*.conflict-server'`** keeps a sync conflict file from failing the build with a phantom error. Conflicts are covered in [operating](/operating). Both outcomes, verified on a real host application (Team Lakes), whose own paths and classes the output below names: ```bash ./scripts/check-units-compile.sh --no-build ``` ``` ==> compiling 13 unit(s) against teamlake-api/build/quarkus-app ==> all units compile ``` and with one broken unit in the tree: ``` ==> compiling 1 unit(s) against /path/to/quarkus-app code/probe/Broken.java:6: error: status has protected access in Presence public static String statusOf(Presence p) { return p.status; } ^ 1 error !! units do not compile against the packaged application ``` ## The unit-harness test **The rule.** Run your real units through the real pipeline inside the host's ordinary test suite: `DirectoryCodeSource` pointed at the git checkout, `Engine.unitClass(id, env)` to compile a unit, and reflection to exercise it. No server, no key, no sync, no DB. **Why it exists.** `unitClass` compiles through the same path a request takes: sibling resolution, package validation, the host compile anchors, the transformed classpath. So a harness test is two things at once, a behaviour test for the unit's logic and a compile gate for the unit tree, and it runs in a couple of seconds inside a suite people already run. **The failure it prevents.** A broken unit that surfaces only as a boot WARN nobody reads in time, or as a 500 for the first visitor. When a real host moved logic into units, the harness caught a shipped bug on its first run: a unit called a package-private method on a sibling and could never have compiled on the host, because until that moment nothing had ever compiled it. The errors the harness turns into red tests are: ``` no code 'notes/NoteApi' in env 'dev' ``` (`CodeNotFoundException`: wrong id, wrong env, or the code root resolved to the wrong directory) and ``` unit 'counter/panel' is jsx, not java ``` (`EngineException` from `unitClass`, which only compiles Java units). **Minimal example.** ```java class NoteUnitsTest { private static Engine engine; private static Class noteApi; @BeforeAll static void compileTheUnits() { // Gradle runs tests with the module directory as CWD; the unit tree is the repo's. Path code = Files.isDirectory(Path.of("../code")) ? Path.of("../code") : Path.of("code"); // The same registration the host does at boot: javac needs the host classes on a real // -classpath, and an anchor's protection-domain code source is how the runner finds them. Engine.addCompileAnchor(MyApp.class); // null key: the key is the credential for the DEFAULT code source, and this test replaces // that source, so nothing ever reads it. No server, no network, no key in CI. engine = InterlockSDK.init(null) .codeSource(new DirectoryCodeSource(code)) .warmUp(false) .build(); noteApi = engine.unitClass("notes/NoteApi", "dev"); } @Test void theUnitCompilesWithItsSiblingsAndCarriesItsPackage() { assertEquals("notes.NoteApi", noteApi.getName(), "directory is package — the id notes/NoteApi implies exactly this binary name"); } @Test void everyHandlerInTheDirectoryCompilesThroughTheRealPipeline() { for (String id : new String[]{"notes/NoteList", "notes/NoteStore", "notes/NoteRenderer"}) { assertEquals("notes." + id.substring("notes/".length()), engine.unitClass(id, "dev").getName()); } } } ``` `DirectoryCodeSource` reads text units straight off `code/.` and marks everything `authored`, so never point it at a directory where generated units land. Binary asset units are not served by it: their bytes live behind the content-addressed blob store, and a test that needs them needs a server. ### This test does NOT replace the packaged gate, and cannot **The harness runs in your ordinary test suite. The gate runs against a packaged fast-jar. Those are two different classpaths, and the difference is exactly the entity rule.** Here is the mechanism, from `JavaRunner`. The host classes reach javac through `addCodeSource(anchor)`, which resolves the anchor's protection domain to a file and then calls `addTransformedBytecode` on it. That helper returns immediately unless the file is a `.jar`: ```java private static void addTransformedBytecode(Set entries, File entry) { if (!entry.getName().endsWith(".jar")) { return; } ``` In a Gradle or Maven test JVM the anchor's code source is `build/classes/java/main`, a **directory**. Nothing inserts `quarkus/transformed-bytecode.jar`, because in a test run there is no such jar to insert (`quarkusDev` and `@QuarkusTest` transform in memory and write none). So the harness compiles every unit against the **original** bytecode, where an entity field Quarkus will later privatize is still `public` and javac has no complaint. The packaged host then loads the transformed copy and the same unit dies on the first request. That is the green-test-red-production condition in full, and it is the precise thing the entity rule exists to catch. The classpaths differ a second way, in the opposite direction. `classpath()` copies the JVM's `java.class.path` verbatim, and under `./gradlew test` that string carries `build/classes/java/test` and every test-scope dependency. So the harness can also compile a unit **too** successfully, against a jar the packaged server does not ship. Both differences push the same way: a green harness is not a statement about the server. The full list is [what is on a unit's compile classpath](/host#what-is-on-a-units-compile-classpath). Which is why the split is: | check | classpath it compiles against | catches | misses | |---|---|---|---| | the harness test | the host's test classpath: `build/classes/java/main`, untransformed, plus everything else the test JVM was launched with | wrong id or env, a sibling that does not resolve, a reserved package, a package-private call across units, ordinary type errors, and the unit's actual **behaviour** | every entity-privatization failure, because the field is still public in the classes it sees; and it can pass a unit that compiles only against a test-scope jar | | [the two-second gate](#the-two-second-gate) | the packaged app, `transformed-bytecode.jar` first | exactly that failure: `status has protected access in Presence` | anything about behaviour; it compiles and asserts nothing | Neither is a superset of the other. The harness is the only one that runs your unit's logic; the gate is the only one that sees the bytecode the server will load. Run both, gate first, which is what [the order to run them](#the-order-to-run-them) does. One diagnostic to know: if the harness is the thing reporting a protected-access error, your test JVM somehow does have a transformed jar on its classpath, and the boot line tells you which world you are in. `no transformed-bytecode jar found` in the log means you are on the untransformed classpath and the gate is doing work this test cannot. **Verify it.** ```bash ./gradlew test --tests '*UnitsTest' ``` ```bash # and prove the two checks read different bytecode. The first path is a DIRECTORY, which is why the # harness gets no transformed jar; the second is the jar only the packaged gate ever sees. ls -d */build/classes/java/main ls -l */build/quarkus-app/quarkus/transformed-bytecode.jar ``` ## Reflection is the boundary, not a workaround **The rule.** Reach unit classes reflectively, and do not try to make them compile-visible to the host. If reflection feels like a smell, that feeling is the boundary reporting itself correctly. **Why it exists.** The unit is deliberately not on the host's test classpath. It is authored code that an `interlock sync` can change without rebuilding the host, compiled at runtime into its own per-importer classloader. A host that could `import notes.NoteApi` would be a host that has to be rebuilt whenever a unit changes, which is the entire property the unit tree exists to provide. **The failure it prevents.** Two, in opposite directions. Trying to import the unit type into a host test fails at compile: ``` error: package notes does not exist import notes.NoteApi; ``` Casting the `Class` result to a host type of the same name fails at runtime with a `ClassCastException` whose two class names print identically, because they are the same name in two different loaders. The same trap exists across `il.call`, and the SDK warns about it once per class rather than letting you meet it at the failure site: ``` WARN il.call id:notes/NoteList returned unit-local class notes.NoteApi — unit classes are per-importer; types crossing units should be host classes ``` **Minimal example.** The unit under test is `code/lib/Counter.java`: it holds an `int[]` of slots, and it declares a static `of(int[])` factory, an `add(...)` method and a `raw()` reader. Nothing else about it matters. Reflective doors wrap those three methods, named so the test body still reads like the behaviour it asserts: ```java // the only handle a host test can have on a unit class: no import, no cast private static Class counter; // = engine.unitClass("lib/Counter", "dev") private static Object of(int[] slots) throws Exception { return counter.getMethod("of", int[].class).invoke(null, (Object) slots); } private static int[] raw(Object c) throws Exception { return (int[]) counter.getMethod("raw").invoke(c); } // Amount is a record declared in the HOST, so both sides mean the same class by that name. private static void add(Object c, int slot, int by) throws Exception { counter.getMethod("add", Amount.class).invoke(c, new Amount(slot, by)); } @Test void addsAccumulateInTheSlotTheyName() throws Exception { Object c = of(new int[4]); add(c, 0, 2); add(c, 0, 3); assertEquals(5, raw(c)[0], "two adds to the same slot sum"); assertEquals(0, raw(c)[3], "and no other slot moves"); } ``` Types that genuinely need to cross the boundary (arguments and return values, like `Amount` above) are HOST classes, not unit classes. That is the same rule the `il.call` warning states, and it is why the doors take `int[]` and host records rather than unit types. **Verify it.** The negative proof matters more than the positive one: add the import to a host test and confirm the build refuses it. ```bash ./gradlew compileTestJava ``` ## Assert effects, not status codes **The rule.** An end-to-end check asserts that the EFFECT happened, read back through the API. A 200, or a non-empty body, or a schema match, is not an assertion. **Why it exists.** A unit can answer correctly-shaped 200s while running entirely the wrong branch. Status is produced by the transport; the effect is produced by the logic under test. **The failure it prevents.** The one that justifies the whole rule: `req.str(name, def)` used to read query parameters only and ignore the JSON body, so EVERY unit API silently ran its default branch on every POST. Well-formed request, 200 response, wrong code path, nothing anywhere reporting it. No error text exists for this failure, which is precisely the point: it was found only because one e2e check asked "did the mute request arrive?" instead of "did the POST return 200?". `req.str` reads query params AND the JSON body today, query wins, but a check that asserts on status will hide the next bug of this shape just as well as it hid this one. Related: a unit that can only answer 200 turns every refusal into an apparent success. Throw `Refusal` (default status 409) rather than returning a map with an `ok:false` in it, so a client that ignores the body still fails visibly. **Minimal example.** Two requests: one that causes the effect, one that reads it back. ```bash curl -fsS -X POST http://localhost:8099/app/counter/api \ -H 'content-type: application/json' \ -d '{"action":"add","by":1}' ``` ```bash curl -fsS http://localhost:8099/app/counter/api \ | python3 -c 'import json,sys; print(json.load(sys.stdin)["count"])' ``` The assertion is that the second command prints exactly one more than it printed before the POST. Assert the negative too: read it again without posting and expect the SAME number, because a read that increments is a bug the first assertion cannot see. And assert refusals by reason, not by absence: an increment the unit is meant to reject must come back `409` with a `reason` token, not a `200` carrying an `ok:false` in the body. **Verify it.** Run your full-circle script and read the assertions, not the exit code: ```bash ./run-full-local-macos-test.sh ``` If any assertion in it can pass while the handler runs its default branch, it is a status-code assertion wearing an effect's clothes. ## Every measurement needs a control arm **The rule.** Any claim about how the platform behaves needs at least two arms: the case you suspect and a control that differs in exactly the property under test. Print the artifact; do not reason about it. **Why it exists.** A one-armed measurement can only confirm the hypothesis you brought to it. The entity rule is the worked case: measuring only an entity with public fields and no hand-written accessors shows the fields turning `protected` and accessors appearing, which supports exactly the wrong conclusion ("Quarkus privatizes entity fields, so units must use the generated accessors"). Generated accessors do not exist on the classes host tests compile against, so that conclusion fails in the other direction, and days went into three wrong diagnoses because of it. The control entity, which had hand-written accessors, is what showed the actual mechanism: **Quarkus privatizes a field only when it GENERATES that field's accessor.** **The failure it prevents.** Shipping the inverted rule, whose two error texts are exactly the ones the [two-second gate](#the-two-second-gate) catches: `y has protected access in X` at compile, and `tried to access protected field X.y` at runtime. Also the quieter version: an entity with public fields whose accessor someone later deletes. Quarkus regenerates it, privatizes the field, every unit reading that field breaks on the next deploy, and every host test stays green. Private fields make that same deletion fail the host build immediately, which is the only reason to prefer them. **Minimal example.** The measurement, both arms, run against a packaged real host application (Team Lakes). The class names below are that host's own, not names you own. Test arm, an entity with public fields and no hand-written accessors: ```bash unzip -o -q "$APP/quarkus/transformed-bytecode.jar" 'com/teamlakes/api/office/Presence.class' -d /tmp/t && javap -p /tmp/t/com/teamlakes/api/office/Presence.class | grep -E ' status;|getStatus| id;' ``` ``` public java.lang.String id; protected java.lang.String status; public java.lang.String getStatus(); ``` Control arm, an entity with private fields and hand-written accessors, same jar, same command shape: ```bash unzip -o -q "$APP/quarkus/transformed-bytecode.jar" 'com/teamlakes/api/world/OfficeWorld.class' -d /tmp/c && javap -p /tmp/c/com/teamlakes/api/world/OfficeWorld.class | grep -E 'officeId|getOfficeId' ``` ``` java.lang.String officeId; public java.lang.String getOfficeId(); ``` The control arm is what settles it. Nothing was generated, and `getOfficeId()` is byte-identical to the one in `app/.jar`, which is why ONE unit source can compile against the host's ordinary test classpath AND against the packaged application. Note also that `@Id` stays `public` in both arms, which is why `entity.id` works from a unit regardless and why this bug hides for so long. | entity as declared | transformed bytecode | usable from a unit? | |---|---|---| | public fields, **no** hand-written accessors | fields become `protected`, accessors generated | no | | public fields, **with** hand-written accessors | fields stay **public**, nothing generated | yes, either style | | private fields, with hand-written accessors | accessors untouched, field access relaxed to package-private | yes, accessors only | **Verify it.** Re-run both arms on your own Quarkus version and your own entities before relying on the table, and diff the two copies of the same class: ```bash javap -p /tmp/t/com/teamlakes/api/office/Presence.class > /tmp/transformed.txt && unzip -o -q "$APP"/app/*.jar 'com/teamlakes/api/office/Presence.class' -d /tmp/o && javap -p /tmp/o/com/teamlakes/api/office/Presence.class > /tmp/original.txt && diff /tmp/original.txt /tmp/transformed.txt ``` ## The order to run them Cheapest and most specific first, so the slow gates only ever run on code that already passed the fast ones. The first two are not interchangeable and neither one is optional. The gate is the only check that compiles against the bytecode the packaged server loads; the harness is the only check that runs what a unit actually does. See [this test does not replace the packaged gate](#this-test-does-not-replace-the-packaged-gate-and-cannot). ```bash ./scripts/check-units-compile.sh # packaged classpath, transformed bytecode: the entity rule ``` ```bash ./gradlew test --tests '*UnitsTest' # test classpath, untransformed: ids, siblings, behaviour ``` ```bash ./gradlew test ``` ```bash ./run-full-local-macos-test.sh ``` # Operating a project ## For humans **Read this if** you run the `interlock` CLI. **Skip it if** you only use Studio in the browser. ### Do this 1. **Check which project you are pointed at** before your first sync in a tree. ```bash interlock whoami ``` 2. **Confirm the key file is beside `code/`.** Parent-directory keys do not authorize a code tree. ```bash ls -la .interlock-key ``` 3. **Dry-run anything destructive.** ```bash interlock sync code --dry ``` ### What will bite you - **A tree with the wrong key syncs into someone else's project.** It reads as a clean success. It can delete your working copy and pull a stranger's units in. - **`yolo` is a listener, not a reconciliation command.** It refuses to start unless the tree and target already match byte for byte. Use explicit `sync` to reconcile and review differences first. - **Two identities, one binary.** `interlock` is PRODUCTION. `interlock-local` is your dev server. - **Conflicts are yours to resolve.** They appear as `.conflict-server`. If you are an agent, do not resolve one on a human's behalf. ### Then read - [.interlock-key binds a tree to a project](#interlock-key-binds-a-tree-to-a-project). - [Verified command reference](#verified-command-reference) for flags that actually exist. ## For robots How a git tree, a project, and a server stay bound to each other, and what breaks when they come apart. Every command below was checked against `code/assets/interlock-cli.js`; nothing here is invented surface. The CLI is Node 18+ with zero dependencies. The API base resolves in this order: `--api`, then `INTERLOCK_URL`, then the profile config, then `http://localhost:8090`. ## .interlock-key binds a tree to a project **The rule.** A code tree declares which Interlock project it belongs to by carrying a `.interlock-key` in the exact directory that owns `code/`. `sync`, `yolo`, `status`, `pull`, and `push` require that exact binding before they read the tree or reach the network. They do not inherit a parent-directory key and do not fall back to the home login. Ordinary non-tree commands may still walk upward for the nearest key. **Why it exists.** Credential resolution for tree commands is the exact tree owner's `.interlock-key`, then stop. `INTERLOCK_SDK_KEY`, a parent key, and the home profile do not authorize a tree. A home profile proves who the operator is; it does not prove which project owns this tree. An SDK key is bound to exactly one project server-side, and exact placement makes the filesystem boundary equally explicit. **The failure it prevents.** This one is destructive and it looks fine while it happens. The old behavior let `yolo` waive the missing-key warning and use a personal home token. It then diffed THIS tree against THAT token's project: server-only units appeared in the working copy and local-only units published to the wrong environment. A key inherited from a monorepo root creates the same cross-project ambiguity for sibling APIs. Both cases now fail locally: ``` interlock: yolo refuses an unbound code tree. Expected /Users/you/workspace/yourapp/.interlock-key (the exact owner of /Users/you/workspace/yourapp/code). Parent keys and the home login are intentionally ignored for tree synchronization. ``` There is no confirmation, environment-variable, or `--project` escape hatch: rejection happens before network, sync-index, or lock activity. CI must provision `.interlock-key` in the project workspace with mode 0600. **Minimal example.** Bind the tree once, from its root: ```bash interlock connect --dir . ``` It signs you in, lets you pick or create a project, mints a key for this directory, and writes the file with mode 0600: ``` interlock: wrote /Users/you/workspace/yourapp/.interlock-key (project yourapp, key il_9f3c2a…, mode 0600) interlock: every command run at or below /Users/you/workspace/yourapp now uses this project. ``` The established filename stays `.interlock-key`, but new files are versioned JSON so project and server metadata can evolve without adding more credential files: ```json { "version": 1, "project": "your-project-id", "api": "https://interlock.sh", "key": "" } ``` The CLI still reads legacy one-line keys. Treat the entire JSON document as secret and keep `**/.interlock-key` in `.gitignore`. For non-tree commands, `INTERLOCK_SDK_KEY` still overrides the file. Tree commands always use the exact owner file. Tooling only ever CREATES `.interlock-key` and never rewrites it. **Verify it.** ```bash interlock whoami ``` It prints the active profile, the API base, which credential is in play, and the project directory the key governs. A home-profile credential remains valid for account commands, but tree commands will reject it. ## Commit the key file deliberately **The rule.** Decide, once and in writing, whether `.interlock-key` is tracked. Tracking it is a legitimate choice for a small team; drifting into it is not. Whatever you choose, never print more than the file's first 8 characters. **Why it exists.** The key is a credential AND a binding. Untracked, a fresh clone has no project and the first `sync` in it is the destructive case above. Tracked, `git diff .interlock-key` catches a stray key that some tool wrote, and a new machine works immediately. Note that the CLI's own `connect` output advises the opposite default: ``` interlock: add .interlock-key to .gitignore — it is a credential. ``` That is the safe default for a public repo. A team that tracks it on purpose should say so where a reader will look, because `.gitignore` has no effect on an already-tracked file and listing it there would tell a reader the key is absent when it is not. **The failure it prevents.** Two, symmetrically. A gitignored key means every teammate's fresh clone silently uses their personal token. A key that is tracked by accident, with nobody having decided it, is an undocumented secret in the history that no rotation plan covers. **Minimal example.** The comment that makes the decision reviewable, at the top of `.gitignore`: ``` # .interlock-key is deliberately TRACKED, not ignored — the same call made for the DB password. # It is NOT listed below, because gitignore has no effect on an already-tracked file and listing # it would tell a reader the key is absent from the repo when it is not. # When the team grows: `git rm --cached .interlock-key`, add it back here, rotate the key. ``` Never echo the file. When you must show which key is in play, show a prefix: ```bash cut -c1-8 .interlock-key ``` **Verify it.** ```bash git ls-files --error-unmatch .interlock-key ``` Exit 0 means tracked, exit 1 means ignored. Either is fine; not knowing is not. ## One key, one project, one server **The rule.** An SDK key is bound to one project ON ONE SERVER, and `.interlock-key` is a single slot. A host project develops against RELEASED Interlock. Do not point a tree at a dev Interlock server "just to try something". **Why it exists.** The sync index is per server (`code/.interlock-index///…`), so a tree can legitimately sync to several servers and keep separate bases for each. The KEY is not per server. There is one slot, so repointing the tree overwrites the credential that identified it. **The failure it prevents.** Pointing a host tree at a local Interlock overwrote the production key and deleted entries from the committed sync base for the production server, which then read as mass deletion on the next production sync. There is no error text for this: it is a clean, successful sync against the wrong server. **Minimal example.** When the host project needs an Interlock change, review that code and cut a release; do not aim the tree at a development server. If you genuinely need a second server from the same tree, keep the key file for the primary and pass the other explicitly for that one command: ```bash INTERLOCK_SDK_KEY=$OTHER_KEY interlock sync code --api http://localhost:8090 ``` **Verify it.** Before any sync you are unsure about, print the plan and change nothing: ```bash interlock sync code --dry ``` The first line names the profile and the API base it will talk to, for example `sync (DRY RUN) [prod] → https://api.interlock.sh (dev; 41 unit(s) in git)`. ## Two CLI identities **The rule.** `interlock` targets PRODUCTION (`~/.interlock/config.json`). `interlock-local` targets a local dev server (`~/.interlock/local.json`). Same binary, chosen by the invoked name, by `--local`, or by `INTERLOCK_PROFILE=local`. **Why it exists.** Two configs make the target unambiguous at the call site, in the shell history, and in a script that somebody reads six months later. A flag you can forget would not. **The failure it prevents.** Syncing a work-in-progress tree to production because the config happened to point there. Production syncs also prompt: ``` interlock: this targets PRODUCTION. Continue? [y/N] ``` Pass `--yes` in CI, deliberately. Note again that `yolo` implies `--yes`, so a `yolo` against the production profile announces itself and then just runs: ``` YOLO: two-way live mirror with PRODUCTION — saves deploy, Studio edits land in your tree. No prompts. Godspeed. ``` **Minimal example.** Day-to-day loop against the local server, release against production: ```bash interlock-local sync code ``` ```bash interlock sync code --yes ``` **Verify it.** ```bash interlock version ``` It prints the CLI version, the active profile in brackets, and the API base, for example `interlock 0.1.0 [prod] · node v22.4.0 · api https://api.interlock.sh`. ## code/.interlock-index/ is the sync base **The rule.** `code/.interlock-index///..interlock` records, per unit, the server version and the content hash as of the last sync. Commit it in the SAME commit as the code change it describes. Indexes for `localhost-*` servers are gitignored, as is `code/.interlock-index/yolo.lock`. **Why it exists.** The index is what makes every sync decision three-way. The version says whether the SERVER moved; the hash says whether the LOCAL file moved. Without it, sync can only compare two sides and has to guess which one wins. | local vs index | server vs index | sync does | |---|---|---| | unchanged | unchanged | nothing | | changed | unchanged | push, compare-and-swap on the version (a 409 becomes a conflict, never a lost update) | | unchanged | changed | pull into the tree | | changed | changed | CONFLICT, nothing overwritten | **The failure it prevents.** A commit that changes a unit but not its index entry makes the next sync see a local change against a stale base, which at best is a needless push and at worst is a conflict manufactured out of nothing. Committing the index with the code keeps the base honest for everyone who pulls. The per-server layout prevents a worse one: a single shared index read an empty second server as mass remote deletion and wiped a working tree (git restored it, because git is the master). If you are upgrading from a single-server layout, the CLI migrates it in place and says so: ``` (index migrated to per-server layout: .interlock-index/interlock.sh/dev) ``` **Minimal example.** ```bash interlock-local sync code && git add code && git status --short ``` `code/caller/UseIt.java` and `code/.interlock-index//dev/caller/UseIt.java.interlock` should appear in the same commit. **Verify it.** A clean tree that has just been synced must be a no-op on the next sync: ```bash interlock sync code --dry ``` Expect `0 pushed, 0 pulled` and a nonzero `unchanged`. Anything else means the committed base does not match what was actually synced. ## Conflicts surface as .conflict-server **The rule.** When both sides moved, nothing is overwritten. The server's copy is written next to your file as `.conflict-server` (gitignored), and you resolve explicitly with `--ours ` to keep the local copy or `--theirs ` to take the server's. **Why it exists.** A three-way sync can detect the collision but cannot know which side is right. Writing the server copy beside yours makes the comparison a plain `diff` instead of a fetch, and leaves both versions on disk while you decide. **The failure it prevents.** Silent clobbering in either direction. The conflict is printed with the exact commands that resolve it: ``` !! CONFLICT caller/UseIt (dev) — changed locally AND on the server (server v7) server copy: code/caller/UseIt.java.conflict-server keep yours: interlock sync code --ours caller/UseIt · take server: --theirs caller/UseIt ``` Deletions collide the same way and say so: ``` !! CONFLICT caller/UseIt (dev) — you deleted it, but the server changed it (v7). Restore it: interlock sync code --theirs caller/UseIt (or delete it in Studio) ``` A non-yolo `sync` exits 1 when anything conflicted or failed, so a pre-flight script notices. **Minimal example.** Look before choosing: ```bash diff code/caller/UseIt.java code/caller/UseIt.java.conflict-server ``` ```bash interlock sync code --theirs caller/UseIt ``` Resolution removes the `.conflict-server` file and rewrites the index entry. If the id matches nothing on either side, the CLI says so and changes nothing: ``` interlock: could not resolve 'caller/UseIt' — no such unit locally or on the server ``` **Verify it.** ```bash find code -name '*.conflict-server' ``` Empty output means nothing is outstanding. Add that check to your pre-flight; a stale `.conflict-server` file is also a `*.java` file that a naive compile gate would try to build, which is why [the gate script](/testing#the-gate-script) excludes it by name. ## An agent must not resolve a conflict on a human's behalf **The rule.** An AI agent surfaces a conflict, shows both sides, and STOPS. It does not run `--ours` or `--theirs` unless the human asked for that specific resolution. **Why it exists.** A conflict means two people made a decision about the same unit. Only they can say which decision survives. Every other part of sync is recoverable from git; a resolution is the one step that deliberately discards one side. **The failure it prevents.** An agent picking `--ours` because the local tree is the one it can see throws away a colleague's Studio edit that exists nowhere else, with a green summary line reporting success. There is no error text, because nothing failed. **Minimal example.** What an agent should produce instead of a resolution: ``` CONFLICT caller/UseIt (dev), server v7. local: code/caller/UseIt.java server: code/caller/UseIt.java.conflict-server diff: 23 lines differ, both changed the cooldown branch Resolve with `interlock sync code --ours caller/UseIt` (keep local) or `--theirs caller/UseIt` (take server). Which one do you want? ``` **Verify it.** After any agent-run sync, the conflicts must still be there: ```bash find code -name '*.conflict-server' ``` If the agent reported conflicts and this comes back empty without you having chosen, the rule was broken. ## sync versus yolo **The rule.** `sync` runs once and stops. `yolo` first computes that same plan without changing anything. Byte-identical stale or missing sync records are refreshed automatically because there is no ownership decision to make. If authored content differs in a terminal, it lists the affected units and offers `[G]` make the target match Git, `[S]` import the target into the tree, `[D]` show a syntax-colored target → local Git diff, `[R]` review the complete plan, or `[C]` cancel. A direction requires a typed second confirmation; YOLO runs the normal three-way/CAS sync, rechecks byte-for-byte equality, and attaches only when that recheck is clean. Non-interactive callers still change nothing unless they explicitly pass `--git-wins --yes` or `--server-wins --yes`. Once attached, local saves upload, server and Studio edits arrive in the tree, and deletes propagate both ways. Run `yolo` only when a human explicitly asked for it, and only in a tree whose exact owner directory has `.interlock-key`. **Why it exists.** The mirror is genuinely useful when someone is editing in Studio and you want those edits in git as they happen. It is also a process that deletes files in your working copy without asking after startup, which is fine when the tree is bound to the right project and catastrophic when it is not. Startup therefore makes ownership explicit instead of sending the operator away to understand sync metadata and run a second command. Importing the server is refused while authored files are dirty in Git, because “take theirs” without a recovery path is data loss. **The failure it prevents.** Two mirrors fighting over the same tree, which the lock refuses: ``` interlock: another yolo is already mirroring this tree (pid 4711) — stop it first ``` And a non-interactive caller that did not choose ownership: ``` interlock: YOLO startup refused: dev and this tree are not already in sync. Nothing was changed. Run interactively, or pass --git-wins --yes / --server-wins --yes explicitly. ``` The larger failure remains a mirror running in an unbound tree, which is the wipe described in [.interlock-key binds a tree to a project](#interlock-key-binds-a-tree-to-a-project). The CLI now rejects that state before opening the mirror. **Minimal example.** Safe daily loop: ```bash interlock-local sync code ``` Mirror, when a human asked for it and `whoami` shows the right key: ```bash interlock-local yolo code ``` If the preflight differs, choose a side in that same command. For scripts and CI, spell it out: ```bash interlock yolo code dev --git-wins --yes ``` Once mirroring, it reports live feed state (`⚡ live feed connected`, or a fall back to 2.5s polling), and every download prints `↓ v () → tree (review with git)`. Review those with `git diff` and commit what you keep. **Verify it.** Before starting a mirror, prove the target and the plan: ```bash interlock whoami && interlock sync code --dry ``` ## Verified command reference Everything below exists in `code/assets/interlock-cli.js`. Flags not listed here were not verified. | command | what it does | |---|---| | `interlock connect [--dir ] [--project ] [--label ]` | sign in, pick or create a project, mint a key, write `.interlock-key` (mode 0600) | | `interlock init` | sign in if needed, then install the Interlock skills into Claude Code, Codex, and Cursor for this project; skips sign-in when a key already exists | | `interlock whoami` | profile, API base, which credential is in play, which directory the key governs | | `interlock version` | CLI version, active profile, API base | | `interlock sync [dir] [--dry] [--prune] [--git-wins\|--server-wins] [--env ] [--yes] [--project ]` | one-shot three-way sync; ownership policies require `--yes`, and server-wins refuses a dirty authored tree | | `interlock sync [dir] --ours ` / `--theirs ` | resolve one conflict, keeping local or taking the server copy | | `interlock yolo [dir] [--git-wins\|--server-wins] [--yes]` | live two-way mirror; a terminal guides drift reconciliation, scripts must state ownership, and attach still requires a clean recheck; one per tree | | `interlock push [--promote]` | upsert one unit, id and env inferred from the `code/…` path | | `interlock pull [--env ] [--dir ]` | download an env's units into the tree, then review with git | | `interlock run [--env ] [--save ]` | execute a unit; exit 4 when no such unit, exit 1 on other failures | | `interlock get [--env ] [--save ]` | fetch a unit's source; binary units require `--save` | | `interlock list [--env ]` | list units with language and version | | `interlock search [target] [--in ] [--limit ] [-C \| -B -A ]` | ranked content search across units, with optional asymmetric line context | | `interlock tasks list [--limit n]` | list durable tasks in the selected project | | `interlock tasks submit [--input JSON] [--schema n] [--idempotency-key k]` | submit one task; input is JSON and schema version is a separate integer | | `interlock tasks get\|watch\|cancel ` | inspect, follow, or cooperatively cancel one task | | `interlock tasks workers` | show ready, full, draining and offline task workers | | `interlock tasks doctor [--spool ]` | verify Tasks authorization, clock drift, queue/outbox health, and optional spool access | | `interlock check [dir]` | author-time syntax gate for `.js` units; `.jsx` and `.java` are reported as skipped and guarded server-side | | `interlock promote [--from ]` | dev to staging to prod | | `interlock projects [new --name ]` | list your projects, or create one | | `interlock keys --project ` | manage project keys; `new --kind sdk\|task-producer\|task-worker` chooses authority and the plaintext is shown once | | `interlock members --project ` | manage project members | | `interlock login [--api ] [--token ]` | set this profile's API base and token | Environment: `INTERLOCK_SDK_KEY` (overrides the key file), `INTERLOCK_URL` (API base), `INTERLOCK_PROFILE=local` (same as `--local`). # Durable tasks and workers ## For humans Interlock Tasks lets an application submit work over HTTPS and lets a machine behind NAT run it without opening a port. Interlock remembers the task, leases one attempt to an eligible worker, records progress and logs, and keeps the canonical result. The always-open SSE connection makes claims fast; it is only a wake-up hint. A disconnected stream may add one polling interval and may never lose work. ### Do this Mint two keys for the same project. They are deliberately different credentials: ```bash interlock keys new "app server" --kind task-producer --project interlock keys new "garage worker" --kind task-worker --project ``` Run either complete sample: ```bash # Java worker, then Java submitter in another terminal INTERLOCK_WORKER_KEY=il_... ./gradlew :tasks-java:run --args=worker INTERLOCK_PRODUCER_KEY=il_... ./gradlew :tasks-java:run --args='submit Ada' # Node 20+ worker, then Node submitter INTERLOCK_WORKER_KEY=il_... node samples/tasks-node/worker.mjs INTERLOCK_PRODUCER_KEY=il_... node samples/tasks-node/producer.mjs Ada ``` Or use the CLI from a connected project: ```bash interlock tasks submit demo/hello --input '{"name":"Ada"}' --idempotency-key hello-ada interlock tasks list interlock tasks watch interlock tasks workers interlock tasks doctor --spool /path/to/worker-spool ``` Open `/tasks` for the attention-first overview, `/task-types` for per-type capacity and history, and `/workers` for the fleet and one worker's registered handlers. ### What will bite you - Execution is **at least once**. Use `taskId`/`effectKey` as the idempotency key at any external effect that supports one. A lease can expire after the effect happened and before completion was acknowledged. - A handler that may have committed a non-idempotent effect must report an unknown outcome rather than request a retry. The task becomes `outcome-unknown`; it does not masquerade as failure. - A task type includes an integer schema version as a separate field. Submit `audio/transcribe` plus `schemaVersion: 1`, never the string `audio/transcribe@1` as `type`. - Inline input and result envelopes are capped. Put large bytes behind a signed URL or an Interlock file reference; Tasks carries the reference, hash, size and type, not the blob. - Worker and producer keys do not overlap. `SCOPE_DENIED` means the key is for the other role (or is an ordinary SDK key with no Tasks authority). - `NO_ELIGIBLE_INFERENCE_WORKER` is a real answer for a self-hosted model: no healthy worker in this project serves it, no managed fallback was used, and nothing was billed. ### Then read - [The wire and state contract](https://github.com/mufumbo/interlock/blob/main/docs/product/tasks.md) - [Java sample](https://github.com/mufumbo/interlock/tree/main/samples/tasks-java) - [Node sample](https://github.com/mufumbo/interlock/tree/main/samples/tasks-node) - [Failure catalogue](/failures#tasks-says-stale_attempt-or-outcome-unknown) - [Self-hosted AI workers](/self-hosted-ai) ## For robots The task row and ordered journal are truth. Treat SSE frames as notifications to re-read truth. Every worker mutation carries `taskId`, `attemptId`, `fence`, and `leaseToken`; a stale attempt gets `409 STALE_ATTEMPT` and must stop reporting. Never reinterpret that refusal as an accepted duplicate. ## Java ```java HttpTaskProducer producer = new HttpTaskProducer(url, producerKey); Wire.TaskView submitted = producer.submit(Wire.SubmitRequest.of( "demo/hello", 1, Map.of("name", "Ada"))); Wire.TaskView done = producer.await(submitted.id(), Duration.ofMinutes(2), Duration.ofSeconds(1)); ``` ```java try (HttpTaskTransport transport = new HttpTaskTransport(url, workerKey); TaskWorker worker = TaskWorker.builder(transport) .workerId("garage-mac") .maxConcurrent(2) .handle("demo/hello", 1, EffectSafety.PURE, (input, ctx) -> Map.of("message", "hello")) .start()) { worker.join(); } ``` The Java worker uses separate handler and housekeeping executors. Saturating handler capacity does not starve lease renewal. It writes a terminal completion to an atomic local spool before sending it and removes the record only after the control plane answers. ## Node ```js import { TaskProducer, TaskWorker } from 'interlock-node-sdk'; const producer = new TaskProducer({ serviceUrl, key: producerKey }); const task = await producer.submit('demo/hello', 1, { name: 'Ada' }); const done = await producer.await(task.id); ``` ```js const worker = new TaskWorker({ serviceUrl, key: workerKey, workerId: 'garage-node', maxConcurrent: 2 }); worker.handle('demo/hello', 1, async (input, ctx) => { ctx.progress(50, 'working'); return { message: `Hello, ${input.name}!` }; }, { effectSafety: 'pure' }); await worker.start(); ``` The Node worker has the same correctness shape: HTTPS claims and reports, SSE hints plus polling, renewal independent of handler promises, cooperative cancellation, bounded concurrency, and an atomic disk completion spool. ## Outcomes and retry | Handler outcome | Task behavior | |---|---| | returns | attempt succeeds; current fenced completion becomes canonical | | retryable failure | re-queued only when task policy and declared effect safety both permit it | | unknown | `outcome-unknown`; no automatic repeat of a possibly committed effect | | other exception | terminal failure | Submission is effectively once for a caller-provided idempotency key within one project. Reusing the key with the same canonical request returns the original task; changing the body returns `IDEMPOTENCY_CONFLICT`. ## Operations 1. `interlock tasks doctor` proves the Tasks API is reachable and authorized and reports clock drift, queue depth, online workers and outbox debt. Pass `--spool` on a worker machine to prove its completion spool is readable and writable. 2. `interlock tasks workers` distinguishes ready, full, draining and offline. Full means all slots are doing work; do not restart it as though it were offline. 3. Drain a worker locally (`TaskWorker.drain()` or SIGTERM in the supplied runners). It renews existing attempts and claims none. Revoking a worker key is an authority action, not a graceful drain. 4. If queued work has no matching worker, compare the literal `type@version`, pool and project. Interlock does not guess hardware capability or silently route across projects. ## Verify These are the fast, reproducible gates: ```bash ./gradlew :interlock-java-sdk:test :tasks-java:compileJava npm test --prefix interlock-node-sdk node --check samples/tasks-node/worker.mjs node --check samples/tasks-node/producer.mjs ``` For the control plane and browser surface: ```bash ./gradlew :interlock-java-api:test -Dinterlock.test.db=true ./run-web-test.sh ``` The browser run captures desktop and phone Tasks frames. Review them: no horizontal clipping, literal task types preserve case, status always has words in addition to color, and the next action for a no-worker task is visible above the metrics. ## Operations evidence Tasks schema changes are versioned in Flyway migration `V2__interlock_tasks.sql`; Hibernate no longer has to invent this subsystem's production DDL. A consistent scheduler-only backup and a causal scratch restore use: ```bash scripts/tasks-backup-restore.sh backup /secure/tasks-$(date +%F).sql TASKS_RESTORE_ADMIN_USER=... TASKS_RESTORE_ADMIN_PASSWORD=... \ scripts/tasks-backup-restore.sh verify /secure/tasks-$(date +%F).sql ``` The verifier restores into a purpose-named scratch database, checks orphan attempts/events and current-attempt references, reports counts and checksum, then removes only that scratch database. For repeatable latency and fairness measurements against one or several API nodes: ```bash INTERLOCK_TASK_PRODUCER_KEY=... INTERLOCK_TASK_WORKER_KEY=... \ node scripts/tasks-load.mjs --urls http://node-a:8090,http://node-b:8090 \ --tasks 1000 --workers 16 ``` That smoke reports enqueue→claim separately from enqueue→terminal. A production edge fleet gate will not label itself qualified without the requested stream tier, HTTPS, slow readers, a reconnect storm, and origin heap/file-descriptor snapshots: ```bash INTERLOCK_TASK_PRODUCER_KEY=... INTERLOCK_TASK_WORKER_KEY=... INTERLOCK_METRICS_KEY=... \ node scripts/tasks-load.mjs --gate 1000 --urls https://api.interlock.sh \ --tasks 1000 --workers 1000 --slow-readers 10 --reconnect-streams 1000 \ --metrics-url https://api.interlock.sh/api/metrics ``` # Search over your own data ## For humans Interlock Search indexes your application's own records and answers queries **inside your host's JVM**. A search is a method call over memory-mapped files: there is no cluster to run, no network hop on the query path, and no copy of your data anywhere else. It also understands what your things *are*. A synonym graph — an ontology you edit, version and publish like code — lets a search for `fruit` find a product called `green banana`, without that product ever containing the word. ### Do this One class describes your data. Two methods are required. ```java public final class TaskSearchConfig implements InterlockSearchConfig { public String index() { return "tasks"; } public SearchSchema schema() { return SearchSchema.builder() .primaryLocale("en") .field(FieldSpec.text("title").perLocale().withConcepts().boost(4)) .field(FieldSpec.keyword("state").faceted()) .build(); } public SearchDocument document(Task task) { return SearchDocument.builder(String.valueOf(task.id)) .text("title", "en", task.title) .keyword("state", task.done ? "done" : "open") .updatedAt(task.updatedAt) .build(); } } ``` One line wires it: ```java Engine engine = InterlockSDK.init(key) .search(new TaskSearchConfig()) .build(); ``` The host writes documents when its own data changes, and searches whenever it likes: ```java TypedIndex tasks = engine.searchFor(new TaskSearchConfig()); tasks.put(task); SearchResult result = tasks.search("green banana"); ``` A unit searches through the capability gateway: ```js const result = il.search().index('tasks') .query('bananas').filter('state', 'open').limit(20).run(); ``` ### What comes back ```java record SearchResult(List hits, List tiers, long totalEstimate, Status status, String truncation, List degradedReasons) ``` `status` is the part worth reading. `OK` is a complete answer. `DEGRADED` is a real answer with something wrong — read `degradedReasons`. `UNAVAILABLE` means the engine could not look at all. **An empty list never means "unavailable".** "Nothing matched" and "I could not look" are different facts, and a search that collapses them teaches its caller to trust an empty result it should not. Check `status` before you conclude anything from `hits`. Hits arrive in bands. `perfect` matched every concept in the query with nothing left over; `regular` is an ordinary match; `related` is a deliberate generalization, offered only when the exact answer was thin and always labelled so a reader can tell the difference. ### Fields | kind | use | |---|---| | `text` | prose. The only kind free-text search reads | | `keyword` | exact values: a state, a brand, a category. Filtered, never analyzed | | `long` · `double` · `date` | numbers and times. Filtered by range | | `bool` | a flag | `perLocale()` stores one value per locale and searches the locales a request asks for. `withConcepts()` feeds the field to the synonym graph. `boost(n)` weights a field: a title usually matters more than a description. ### The synonym graph A graph is a set of concepts, the concepts each one is a kind of, and the concepts each one must not be confused with. Attach a published graph and searches start understanding your domain: ```java engine.attachGraph(graph); ``` Two kinds of exclusion, and the difference matters. An **exclusive** rule always excludes: a search for `biscuit` must never return dog-food biscuits. An **inclusive** rule excludes *unless* the declaring concept is present too: a search for a diet must not return ordinary bread, but bread made for that diet is exactly what was wanted. Rules are inherited. State one on a category and it holds for everything under it, including the things added next year. Asking for something by name always beats somebody else's rule about it. **Changing a graph queues work.** Documents carry what they are kinds of, so a published change leaves some of them stale. The index finds them itself and reports the backlog: ```java int pending = engine.attachGraph(newGraph); engine.drainGraphRewrites(500); // chunked; returns what remains ``` While that backlog is non-empty, results say `DEGRADED` with `graph-rewrite-pending`. That is on purpose: a publish that is still landing is a fact worth admitting. ### Repairing an index An index is derived data and can always be thrown away. Give your config a `source()` and you get the repair paths for free: ```java engine.rebuildAll(); // read everything again engine.reconcileAll(); // fix only what drifted ``` A write never throws — an index that can fail a user's save would be worse than no search. A failed write is remembered instead, shows up in `status()`, and is repaired by the next reconcile. ### Customizing Two seams, for the things a general engine cannot know about your data. `DocumentWriter` decorates the write side: add a field the mapping alone cannot produce. `FieldEnricher` is the packaged case — a value computed off the write path (a generated summary, say), stored with the hash of the text it was computed from, and treated as **absent** the moment that text changes. A summary of content that no longer exists is worse than no summary. `CandidateSearcher` decorates the read side: refine what was retrieved. `SearchPass` is the packaged case — boost an exact occurrence, require one, or drop candidates that fail a stricter test. A pass refines what was retrieved and cannot add to it, so anything that must be findable has to be reachable by the query first. ### Explaining a result ```java SearchRequest.builder().query("fruit").explain(true).build(); ``` Each hit then carries what matched, through which word, and what it was taken to be a kind of. The harder question has its own call: ```java index.explainAbsent(docId, request); ``` It answers **why a document is not in the results** — outranked, filtered, or excluded by a rule — because a document at rank four thousand looks exactly like one that was thrown out. ### Common words Articles and prepositions are handled per language, and never deleted from your index. Deleting them is the usual trick and it quietly loses data: a band called *The Who* becomes unfindable, and no amount of query tuning brings it back, because the words are no longer there. Instead they are discounted when you search. Asking for "the best coffee maker" is not narrowed by "the", and a document whose whole title is common words is still found by them. Fourteen languages ship in the box (en, es, pt, fr, de, it, nl, sv, pl, ru, tr, ja, zh, ko). To add one, or to replace ours, put a properties file on your classpath before the SDK's: ```properties # search-stopwords/da.properties stopwords=og,i,jeg,det,at,en,den,til,er,som,pa,de,med,han,af ``` A language with no file gets an empty list: slightly less precision on long queries, never an error. ### What this is not Single-JVM indexes. A write on one node does not update another node's index; the reconcile pass is the repair story. No sharding, no replication, no log analytics, no aggregation language. If you need a search cluster, use a search cluster. This is for applications that want good search over their own data without operating one. ## For agents - One capability, always `il.search()`. There is no per-domain facade; what an index is for is configuration. - Check `status` before reading `hits`. `UNAVAILABLE` and `DEGRADED` are answers. - `document()` must be deterministic and cheap. Anything slow or model-backed belongs in a `FieldEnricher`, which runs off the write path. - Never index a value that changes faster than the corpus — prices, stock levels, anything per-place. Index identity; join the volatile part after retrieval. - Field names are lower camel case. Names starting `_` are the engine's and are refused. - A schema change re-versions every document; the reconcile pass rewrites them. - Compile a graph with `SearchEngine.graphTextAnalyzer()`. Any other normalizer silently stops matching. # Self-hosted AI workers ## The promise Your Mac or Linux box runs the model. It opens outbound HTTPS/SSE to Interlock, claims only work for its project, and calls a model server bound to loopback. Interlock never opens a connection to your hardware or model port. Interlock does receive the ordinary prompt and result because it is the durable control plane; “self-hosted” means you own compute and uptime, not control-plane-blind end-to-end encryption. Qwen3.6-35B-A3B Q8_0 is the first supported profile. Expect about 38 GB for model weights and a 64 GB Apple Silicon Mac or a Linux GPU host with comparable usable memory. Model source is `unsloth/Qwen3.6-35B-A3B-GGUF`; review its Apache-2.0 license and the upstream Qwen license before installing. Quantisation changes quality and memory use, so the deployment alias includes the exact profile rather than the vague word “Qwen”. ## Install In Project settings, open **Your hardware** and choose **Create install command**. Run the one-time command on the machine. The key is shown once and has only `tasks:register`, `tasks:claim`, and `tasks:report`; it cannot submit application work or read another project. The installer: - supports macOS with a system LaunchDaemon and Linux with a systemd service, both running as the unprivileged installing user and starting without login; - verifies a pinned public-key signature on both the release manifest and jar, then verifies the manifest SHA-256 and byte count before an atomic upgrade; - stores the key in an owner-only environment file, never in a service definition or command line; - binds/adopts llama.cpp on `127.0.0.1`; non-loopback configuration is refused unless the operator deliberately sets `LLM_ALLOW_REMOTE=1`; - keeps the previous jar for rollback and preserves secrets, logs, and models on uninstall. Lifecycle commands use the same downloaded installer: ```bash bash setup.sh status bash setup.sh doctor bash setup.sh update bash setup.sh restart bash setup.sh uninstall # service/app only; env, logs and model cache remain ``` The host needs Java 21, `curl`, `openssl`, Python 3, and llama.cpp's `llama-server`. It needs outbound TCP 443 to `api.interlock.sh` and the asset host, plus outbound model download access on first boot. It needs no inbound firewall rule, public IP, tunnel, SSH exposure, port 8080, or port 11434. ## Route policy Placement is explicit: | Policy | Meaning | |---|---| | `managed-only` | Never uses customer hardware. A self-hosted model name is a configuration error. | | `self-hosted-only` | Never creates managed-provider spend. No eligible worker is a typed, unbilled refusal. | | `self-hosted-then-managed` | Uses the project worker when healthy, otherwise the managed default. This fallback is opt-in. | Java: ```java var options = Ai.ChatOptions.withManagedFallback(Models.QWEN_3_6_35B_A3B); String answer = il.ai().chatWithOptions(options, "Summarise this session"); AiRun run = il.ai().submit(Ai.ChatOptions.selfHosted(Models.QWEN_3_6_35B_A3B), prompt); AiRun.Snapshot done = run.await(Duration.ofMinutes(10)); ``` Node: ```js const run = await client.aiRunSubmit(prompt, { model: 'qwen3-6-35b-a3b', routePolicy: 'self-hosted-only', timeoutMs: 600000, }); const done = await client.aiRunAwait(run.id, { timeoutMs: 660000 }); ``` HTTP uses `POST /api/ai/runs`, `GET /api/ai/runs/{id}`, and `POST /api/ai/runs/{id}/cancel`. `/api/ai/runs/{id}/stream` immediately sends an SSE hello, continues with durable task-event deltas and 25-second heartbeats, and may be disconnected at any time: `GET` by run ID remains canonical. A long inference is not coupled to a Cloudflare request deadline. ## Operate it “Ready” means the worker registered `ai/chat@1`, was seen within 90 seconds, is not draining, and has capacity. “Full” is healthy—all slots are busy. “Drained” renews current leases and claims no new work. “Offline” means Interlock has not seen it recently; do not confuse that with a model that is still loading locally. Before maintenance, drain and wait for running work to reach zero. Update, run `doctor`, then restart. For key rotation, create a new Task Worker key, replace it in the owner-only env, restart, prove the worker ready, then revoke the old key. For a model replacement, use a new deployment alias until readiness and a real completion pass; do not relabel different weights as the old deployment. The billed route is `ai.self-hosted.qwen3.6-35b-a3b`. Evidence includes the task/run, worker, deployment, attempt, token counts, duration, terminal state, and route policy. An eligibility or admission refusal costs zero. One inference produces one AI charge; the underlying Task does not produce a second generic charge. ## Diagnose and roll back 1. Run `setup.sh doctor`: release signature/checksum, environment permissions, loopback model boundary, and boot service must all pass. 2. In **Your hardware**, distinguish offline, drained, full, and ready. Open `/tasks` for the exact `ai/chat@1` attempt and event timeline. 3. Check local logs under `~/.interlock/task-worker/logs`. Prompts/results are customer data; do not paste them into tickets. Worker logs redact them by default. 4. If the model is unavailable, `self-hosted-only` should return `NO_ELIGIBLE_INFERENCE_WORKER`, not silently spend money. Use managed fallback only when its cost and privacy boundary are acceptable. 5. Roll back an upgrade by stopping the service, replacing `app/worker.jar` with `app/worker.prev`, and starting it. `uninstall` removes the service/application without deleting the environment, logs, or model cache. # Model fleets A model project owns its agreement, schemas, prompts, trainer, evaluator, and typed application client. Interlock owns immutable inputs and outputs, releases, channels, placement, machine credentials, downloads, process supervision, telemetry, and history. The unit of operation is a **model group**, not a laptop. One group has many immutable releases and may use any number of training and inference machines. ## Scaffold a model project From the application repository: ```bash interlock models init my-model --trainer python --client java interlock models register --dir my-model ``` The generated `installation-notes.sh` is the executable machine handoff. Run it from any location: ```bash ./my-model/installation-notes.sh ``` It uses the authenticated Interlock CLI and its own project directory to rotate the group's client and trainer keys, then prints two complete, server-bound commands ready to paste onto the respective machines. The file contains no secret; the newly created keys exist only in that command output and are shown once. Running it again creates a fresh pair and invalidates only older bootstrap keys—not machine credentials that have already been exchanged. Generated model bytes, datasets, checkpoints, and reports belong in Interlock Files, not Git. Interactive terminals get a compact colored handoff with separate inference and training copy blocks. Redirected output is plain text, and the standard `NO_COLOR` environment variable disables ANSI styling explicitly. ## Two keys, neither an SDK key Each group has exactly two active, rotatable bootstrap authorities: ```bash interlock models key rotate client --dir my-model interlock models key rotate trainer --dir my-model ``` The **client key** may enroll inference hosts for this group. The **trainer key** may enroll training and evaluation hosts for this group. Neither can administer Interlock, mutate a release, submit arbitrary Tasks, access another group, or act as an application SDK key. The installer exchanges the group key once for a credential bound to one worker id, one group, and one role. It stores only that machine credential. Rotating a group key immediately stops new enrollments while already-enrolled machines continue. Revoke one machine credential to remove only that machine. ## Install an inference machine The host needs Java 21, Python 3, and the selected runtime. For llama.cpp on macOS: ```bash brew install llama.cpp ``` Then run: ```bash export INTERLOCK_MODEL_KEY='' curl -fsSL https://PROJECT.interlock.sh/assets/install-model-host.sh | sh -s -- \ --url https://PROJECT.interlock.sh --group my-model --role client unset INTERLOCK_MODEL_KEY ``` Use `--runtime mlx-lm` when this host should serve MLX releases. A client install starts no default model. It downloads and starts only immutable deployments assigned by Interlock to this group. ## Install a trainer ```bash export INTERLOCK_MODEL_KEY='' curl -fsSL https://PROJECT.interlock.sh/assets/install-model-host.sh | sh -s -- \ --url https://PROJECT.interlock.sh --group my-model --role trainer unset INTERLOCK_MODEL_KEY ``` The trainer receives each dataset, base model, checkpoint, and executable procedure as an immutable, hash-verified Interlock artifact. Auth remains in the generic bridge; procedure code receives only attempt-scoped file paths and the bounded metrics/checkpoint protocol. On an Apple Silicon trainer, opt in to the pinned MLX-to-GGUF derivation toolchain when this model group needs NVIDIA-compatible candidates: ```bash export INTERLOCK_MODEL_KEY='' curl -fsSL https://PROJECT.interlock.sh/assets/install-model-host.sh | sh -s -- \ --url https://PROJECT.interlock.sh --group my-model --role trainer \ --enable-mlx-gguf-deriver unset INTERLOCK_MODEL_KEY ``` The flag is macOS-trainer-only and disabled by default. It installs exact MLX, Python and llama.cpp versions, computes a build identity from the complete converter toolchain, and advertises only that identity. Nothing runs until an explicit matching derivation is created. The trainer performs the offline fusion/conversion; an NVIDIA client only consumes the resulting immutable GGUF after its own release and gates. Derivation never changes an existing version, deployment, alias or channel. A Python procedure declares its exact interpreter and package versions in its immutable dependency lock. On first use, the worker creates a content-keyed virtual environment under the model group's Nightshift directory, installs the hash-verified public Interlock Python SDK served by the same control plane, and reuses that environment only for an identical lock + SDK hash. macOS trainers use `uv` to resolve or install the exact pinned Python patch release rather than silently accepting a different Homebrew interpreter. Third-party Python packages currently come from their standard package index at this first-use boundary; datasets, procedures, SDK code, models, checkpoints, and releases come from Interlock. Exact Python binaries come from uv's managed runtime distribution. This distinction is intentional and visible—Interlock does not claim to mirror Python or PyPI today. Do not pass a reusable key on the command line unless necessary. `--client-key` and `--trainer-key` exist for automation, but command arguments can remain in shell history and process listings. ## What one bootstrap installs On macOS, the same command installs the worker LaunchDaemons and the compiled **Nightshift** menu-bar app. Nightshift is Interlock's local fleet companion: it discovers every model group enrolled on that Mac and lets the owner make each inference or training role always available, available only from 10 PM to 7 AM local time, or paused. It is a local availability ceiling; production versions, placements, and releases remain controlled by Interlock. Nightshift and its root helper are downloaded from the same Interlock origin, SHA-256 verified, and upgraded with the worker. There is no AskIdeal-specific app or source checkout. Enrolled Macs do not compile Nightshift and need no Xcode or Swift toolchain. Interlock publishes a precompiled universal arm64+x86_64 app through visible `assets/nightshift-macos-universal.zip` and an atomic `assets/nightshift-macos.json` release pointer. The worker checks that manifest every 20 minutes, downloads the ZIP with its release hash as the cache key, verifies the exact SHA-256, and replaces Nightshift only when the bytes change. Nightshift releases are therefore independent of model versions and worker-harness releases. One Mac may enroll several groups, including groups belonging to different Interlock projects. Each enrollment keeps its own origin and narrow machine credential. Runtime state lives under: ```text ~/.nightshift/groups// ├── group.json ├── roles// └── versions// ``` On Linux, the installer creates systemd services and a timer without the macOS UI. On both systems the worker: - starts before login and restarts after failure; - holds an outbound SSE connection for low-latency wakeups, with polling as the recovery path; - stores its machine key in an owner-only environment file, never in a service definition; - downloads the generic Task Client from the Interlock control plane; - verifies its SHA-256 against `/model-worker/manifest.json` before an atomic replacement; - checks for a new harness every 20 minutes and restarts the worker only after verification; - downloads model and procedure artifacts from Interlock, not GitHub or a developer laptop. Re-running the same origin, group, and role is idempotent: the install resolves its stable model id, retains its machine credential and local schedule, and does not exchange the group bootstrap key again. ### Uninstall Nightshift from a Mac Choose **Uninstall Nightshift…** at the bottom of the Nightshift menu. The native confirmation is a whole-machine action: it stops every registered Interlock model worker on that Mac and removes their launchd jobs, local group versions, machine credentials, Nightshift services, helper, and app. It does not delete shared model caches, source checkouts, unrelated services or databases, or any model, dataset, evaluation, training run, release, or history stored in Interlock. The host becomes offline in Interlock; revoke its machine credential from the Models fleet when the physical machine is no longer trusted. Installing again requires a fresh client or trainer bootstrap key. ## Operate the fleet Use the Models fleet page to pause or resume a worker while keeping its durable installation. A paused inference host drains its model processes; resuming reconciles the current assignments. ```bash interlock models status --dir my-model interlock models key list --dir my-model ``` macOS service state is visible in Nightshift or with `sudo launchctl print system/sh.interlock.model-worker..client`. Linux state is visible with `systemctl status sh.interlock.model-worker.my-model.client`. ## Security boundary Commodity hardware is untrusted infrastructure. The server therefore derives group and role from the exchanged credential, not from the capabilities a worker reports. Training placement, evaluation placement, deployment reconciliation, artifact downloads, claims, reports, and SSE all verify the exact machine identity. A compromised box can interrupt work assigned to that one box; it cannot expand its own authority. # Failure catalogue ## For humans 1. **Find your error** in the table at [Find your error](#find-your-error), immediately below. 2. **Match the distinctive part**, not the whole line. Unit ids, class names and numbers differ from yours. 3. **Not listed?** Search the page for a few words of your error. Some entries describe a symptom, because the worst failures print nothing. 4. **Still nothing?** The failure is probably your host's. Check the host log at the same timestamp. ## For robots You probably arrived here by pasting an error into a search box. Find your text below, read two sentences, follow the link. You do not need to understand Interlock's model first. Every error string on this page is quoted verbatim from the source that produces it. If your text differs only in the unit id, the class name, or a number, you are in the right place. ## Find your error | If you see | Go to | |---|---| | `tried to access protected field` | [IllegalAccessError: tried to access protected field](#illegalaccesserror-tried-to-access-protected-field) | | `has protected access in` | [has protected access in (unit compile)](#has-protected-access-in-unit-compile) | | `no transformed-bytecode jar found` | [no transformed-bytecode jar found](#no-transformed-bytecode-jar-found) | | A POST returns 200 but runs the wrong branch | [POST silently takes the default branch](#post-silently-takes-the-default-branch) | | `req.has(...)` is false for a field that is in the JSON body | [req.has and req.list do not read the body](#reqhas-and-reqlist-do-not-read-the-body) | | A refusal comes back as HTTP 200 | [A refusal is served as 200](#a-refusal-is-served-as-200) | | `NoClassDefFoundError` after editing a shared class | [NoClassDefFoundError after editing a shared class](#noclassdeffounderror-after-editing-a-shared-class) | | `returned unit-local class` | [il.call returned unit-local class](#ilcall-returned-unit-local-class) | | `every importer gets its OWN copy of that static` | [Mutable static in an imported unit](#mutable-static-in-an-imported-unit) | | An importer serves stale dependency behaviour | [Importer serves stale dependency behaviour](#importer-serves-stale-dependency-behaviour) | | `package lib does not exist` / `cannot find symbol` on a sibling | [A sibling import does not resolve](#a-sibling-import-does-not-resolve) | | Works in `quarkusDev`, fails packaged, or the reverse | [Works in quarkusDev, fails packaged](#works-in-quarkusdev-fails-packaged) | | `interlock: WARNING — no .interlock-key` | [interlock yolo wiped the tree](#interlock-yolo-wiped-the-tree) | | `which is reserved` at unit compile | [Package is reserved (compile)](#package-is-reserved-compile) | | `refusing to define` | [Package is reserved (load)](#package-is-reserved-load) | | `a unit's package is its directory` | [Declared package does not match the directory](#declared-package-does-not-match-the-directory) | | `is a library — no class implements InterlockHandler` | [Unit is a library and cannot run](#unit-is-a-library-and-cannot-run) | | `Operation is not allowed for:` | [A .js unit cannot import a sibling](#a-js-unit-cannot-import-a-sibling) | | `has no default export function` | [JS unit has no default export](#js-unit-has-no-default-export) | | `no code '...' in env '...'` | [No code in that env](#no-code-in-that-env) | | `timed out after` | [Unit timed out](#unit-timed-out) | | `which does not exist in this project` | [A JSX import does not exist](#a-jsx-import-does-not-exist) | | `is deeper than 16` | [Import graph is too deep](#import-graph-is-too-deep) | | `no Java compiler available` | [No Java compiler available](#no-java-compiler-available) | | `the host seeded more than one context of type` | [More than one context of that type](#more-than-one-context-of-that-type) | | `!! CONFLICT` | [Sync conflict](#sync-conflict) | | `another yolo is already mirroring this tree` | [Another yolo is already running](#another-yolo-is-already-running) | | `STALE_ATTEMPT` or `outcome-unknown` | [Tasks says STALE_ATTEMPT or outcome-unknown](#tasks-says-stale_attempt-or-outcome-unknown) | | `SCOPE_DENIED` or `NO_ELIGIBLE_INFERENCE_WORKER` | [Tasks says SCOPE_DENIED or NO_ELIGIBLE_INFERENCE_WORKER](#tasks-says-scope_denied-or-no_eligible_inference_worker) | | An AI event stream disconnects before the answer arrives | [AI run stream disconnected](#ai-run-stream-disconnected) | ## IllegalAccessError: tried to access protected field **Symptom.** The unit compiled cleanly. At runtime, on the first line that touches an entity field: ``` java.lang.IllegalAccessError: tried to access protected field com.example.Counter.hits from class UseIt ``` **Cause.** Quarkus builds every entity twice. `app/.jar` holds the original bytecode and `quarkus/transformed-bytecode.jar` holds the rewritten copy, in which Hibernate has turned the public field `protected` and generated accessors for it. The JVM loads the transformed copy. If javac saw the original, the unit compiled against a `public` field that does not exist at runtime. **Fix.** Give the entity **hand-written accessors**. Quarkus privatizes an entity field only when it generates that field's accessor, so writing the accessor yourself leaves the field alone and gives you a member that exists identically in both bytecode copies. Full table and the `javap` commands that measured it: [the entity rule](/quarkus#the-entity-rule). Also confirm the SDK actually found the transformed jar, which it announces once at boot: see [no transformed-bytecode jar found](#no-transformed-bytecode-jar-found). ## has protected access in (unit compile) **Symptom.** The unit never runs. Compilation is refused, and the javac diagnostic is wrapped in the SDK's envelope: ``` compile failed for 'caller/UseIt': ERROR line 12: hits has protected access in com.example.Counter ``` **Cause.** The same mechanism as above, caught one step earlier: javac was shown the transformed bytecode (correctly), and the entity has no hand-written accessor, so the field really is `protected` and there is no generated accessor visible to a unit either. **Fix.** Add hand-written accessors to the entity (private fields plus explicit getters and setters is the shape that is correct in every case). See [the entity rule](/quarkus#the-entity-rule). **Why it hid for so long:** a field literally named `id` is left public whatever else the entity does, so `entity.id` keeps working and the entity looks fine. That exemption tracks the field **name**, not the `@Id` annotation. If your identity column is called `uuid`, `hash` or `ip`, it is privatized like any other field and you will get this error **on the id itself**. ## no transformed-bytecode jar found **Symptom.** One WARN at boot, emitted once per JVM: ``` java units: no transformed-bytecode jar found — if this is quarkusDev, units that touch host entities will not compile against their accessors; run the packaged jar for entity-touching units ``` The healthy case logs this instead: ``` java units: Quarkus transformed bytecode on the compile classpath — units can use host entity accessors ``` **Cause.** `quarkusDev` transforms entities in memory and writes no transformed jar, so there is nothing for the SDK to put ahead of the original classes on the compile classpath. **Fix.** Run a packaged build for anything that touches host entities. `quarkusDev` is unsupported for entity-touching units and packaged builds are unaffected. See [Quarkus builds every entity twice](/quarkus#quarkus-builds-every-entity-twice). ## POST silently takes the default branch **Symptom.** There is no error. The request is well-formed, the response is `200`, and the unit ran the wrong code path: ``` $ curl -s -XPOST -H 'content-type: application/json' -d '{"action":"add"}' \ https://host.example/app/counter/api {"ok":true,"action":"read"} ``` **Cause.** An older `req.str()` read query parameters only, so a named value that arrived in the JSON body was invisible and every POST quietly ran its default branch. Nothing anywhere reported it. **Fix.** Upgrade the SDK. `str`, `integer`, `number` and `bool` read the query parameter first and then the JSON body, so a value in either place is found and the query wins on a tie. See [Request](/reference#request). Then fix the test that let it through: **assert effects, not status codes.** This bug was found only because an end-to-end check asserted on the effect ("did the mute request arrive?") rather than on the status code. See [assert effects, not status codes](/testing#assert-effects-not-status-codes). ## req.has and req.list do not read the body **Symptom.** No error. `req.str("action")` finds the value, but the guard around it does not: ``` req.has("action") // false, although {"action":"add"} is the JSON body req.list("names") // [], although {"names":["a","b"]} is the JSON body ``` **Cause.** The body fallback lives in the scalar readers. `has(name)` and `list(name)` consult the query parameter map only. **Fix.** For a body-carried value, test it with the scalar reader instead of `has`: `req.str("action") != null`. For a body-carried collection, read `req.body().get("names")` and cast. See [Request](/reference#request), which marks exactly which readers consult the body. ## A refusal is served as 200 **Symptom.** The unit meant to say no. The client sees success: ``` HTTP/1.1 200 OK {"ok":false,"reason":"cooldown","error":"you just got in touch"} ``` **Cause.** The unit returned a map instead of throwing. A returned value is a result, and a result is served with status `200`, so a client that ignores the body still believes it got through. **Fix.** Throw a `Refusal`: ```java throw new Refusal(409, "cooldown", "you just got in touch"); ``` The engine turns it into a JSON result carrying `{"ok":false,"reason":…,"error":…}` and the status you gave it (`409` if you used the two-argument constructor). It is thrown rather than returned so it cannot be forgotten mid-method the way a status field can, and an `il.call` from another unit sees the exception rather than a value it could mistake for data. See [Refusal](/reference#refusal). Returning `{ok:false}` is still fine when "no" is an ordinary outcome that no client needs to fail on. ## NoClassDefFoundError after editing a shared class **Symptom.** A shared helper is edited, and live requests in unrelated units start dying: ``` java.lang.NoClassDefFoundError: lib/MathBox ``` **Cause.** A shared classloader for common code. When it is closed or replaced, every already-loaded class that came from it is orphaned, and the failure surfaces far from the edit. **Fix.** Nothing to do on current Interlock, because the shape is unrepresentable: sibling resolution is javac's own SOURCE_PATH and **each importer gets its own compiled copy** of its dependencies. There is no shared loader to close. If you see this, a host component outside the unit tree is sharing a loader. See [how sibling imports resolve](/java#same-directory-needs-no-import). The cost of per-importer copies is two rules, and they have their own entries: [unit-local classes crossing il.call](#ilcall-returned-unit-local-class) and [mutable statics](#mutable-static-in-an-imported-unit). ## il.call returned unit-local class **Symptom.** A WARN, once per class, and then a cast failure on the far side whose two class names print identically: ``` il.call id:caller/UseIt returned unit-local class lib.Pick — unit classes are per-importer; types crossing units should be host classes ``` **Cause.** A class compiled out of a unit exists once per importer, so the `lib.Pick` the caller knows and the `lib.Pick` it received are different classes with the same name. **Fix.** Make the payload a host type or a plain `Map`. The host's own classes are the only boundary-safe currency between units. See [types crossing units](/java#rule-1-unit-local-types-must-not-cross-ilcall). ## Mutable static in an imported unit **Symptom.** A WARN at compile time, and a counter that never seems to climb: ``` unit 'caller/UseIt' compiled in sibling 'lib/Counter' which has mutable static field 'hits' — every importer gets its OWN copy of that static ``` **Cause.** Each importer compiles its own copy of the imported unit, so a mutable static in a shared unit is not shared state. It is N copies of state that look shared. **Fix.** Move the state into the per-unit store, or into a host-provided capability. Make the field `final` if it is genuinely a constant. See [per-importer copies](/java#same-directory-needs-no-import) and [the store](/reference#store). ## Importer serves stale dependency behaviour **Symptom.** No error. A dependency was edited, its own callers work, and one importer keeps behaving like the old version. **Cause.** A compiled unit records which sibling sources javac read, with hashes, and a cache hit whose recorded hashes no longer match the live sources is rebuilt in place. If the importer is not picking the change up, the cascade is not seeing the new source: usually the host's [`CodeSource`](/host#the-wiring) is serving a cached or stale `fetch`, or the dependency was edited in a different env from the one the importer runs in (sibling resolution is same-env, always). **Fix.** Confirm the dependency really changed in the importer's env (`interlock get --env `), then re-run. Newly *created* units can take up to ten seconds to become importable because the id listing is snapshotted for that long; edits to existing dependencies are not subject to that delay. See [invalidation cascades](/java#invalidation-cascades). ## A sibling import does not resolve **Symptom.** A unit that imports another unit refuses to compile: ``` compile failed for 'caller/UseIt': ERROR line 3: package lib does not exist ``` **Cause.** Sibling imports need a **package listing**, and a package listing is a listing of unit ids. The host's `CodeSource.list(env)` defaults to empty, which turns sibling imports off. Three other things also make a unit invisible to an importer: it is in a different directory than the import implies (directory is package), its id is not a legal Java type name, or it is a **generated** unit, which is never importable by design. **Fix.** Implement `CodeSource.list(env)` in the host. See [hosts opt in via CodeSource.list](/host#the-wiring) and [directory is package](/java#directory-is-package). ## Works in quarkusDev, fails packaged **Symptom.** A unit that touches a host entity behaves one way under `quarkusDev` and another way in the packaged application, in either direction. **Cause.** The two builds do not present the same classes to javac. [`quarkusDev` writes no transformed jar](#no-transformed-bytecode-jar-found), so units compile against the original bytecode there; the packaged application compiles against the transformed copy, which is also what the JVM loads. **Fix.** Package before checking, always. Compile every Java unit against the packaged application with the transformed jar first, before anything boots. See [the two-second gate](/testing#the-two-second-gate). ## interlock yolo wiped the tree **Symptom.** Before the damage, this warning (it asks for confirmation unless `--yes` is passed): ``` interlock: WARNING — no .interlock-key in /Users/you/workspace/app or any parent. This will diff the tree against whatever project /Users/you/.interlock/config.json selects, and delete anything the tree has that that project does not. Fix: run `interlock connect` here, or pass --project . ``` After it: every unit gone from the working copy, and another project's units in their place. **Cause.** `.interlock-key` binds a tree to a project, and the CLI walks up to find it the way git finds `.git`. Without one, the CLI falls back to the home-directory token, which is scoped to a different project. The mirror then correctly concludes that every unit in this tree was deleted on the server, removes them, and pulls the other project's units in. The sync index is keyed by server, not by project, so nothing else warns you. **Fix.** Run `interlock connect` in the tree, which writes `.interlock-key`, and commit that file deliberately. Recover the working copy from git. Never print more than the key's first 8 characters. See [.interlock-key binds a tree to a project](/operating#interlock-key-binds-a-tree-to-a-project). Related: a host project develops against **released** Interlock. `interlock` is production and `interlock-local` is a local dev server. Pointing the production identity at a dev server overwrites the production key and corrupts the committed sync base. See [two CLI identities](/operating#two-cli-identities). ## Package is reserved (compile) **Symptom.** The unit refuses to compile: ``` unit 'caller/UseIt' declares package 'com.example.api', 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 ``` **Cause.** This is correct behaviour, not a bug. A unit may not declare a class into a reserved package. Reserved are `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. Otherwise a unit could sit inside the host's namespace and reach its package-private members. **Fix.** Put the unit in its own directory, which is its own package. You can still **import** from every reserved package. See [the shadow boundary](/host#the-shadow-boundary). ## Package is reserved (load) **Symptom.** The refusal at class-load time rather than compile time: ``` java.lang.ClassNotFoundException: refusing to define 'com.example.api.Foo' — its package is reserved to the host ``` **Cause.** The same rule, enforced a second time in the unit classloader. Delegation is parent-first, so an already-loaded host class can never be replaced; this second layer refuses to *define* a new class into a reserved package the host does not happen to have. **Fix.** Same as above: this is the boundary working. See [the shadow boundary](/host#the-shadow-boundary). ## Declared package does not match the directory **Symptom.** ``` unit 'lib/Money' declares package 'example.lib' but its directory requires package 'lib' — a unit's package is its directory, the way its id is its URL ``` Or, for a unit at the root of the tree: ``` unit 'Money' declares package 'lib' but its directory is the root, which is the default package — a unit's package is its directory, the way its id is its URL ``` **Cause.** Directory is package. `code/notes/NoteApi.java` declares `package notes;` and nothing else. This is enforced only when a unit declares a package at all, so package-less units keep working; but a unit that opts in must match, or an import would resolve a name its id contradicts. **Fix.** Change the `package` line to match the directory, or move the file. See [directory is package](/java#directory-is-package). ## Unit is a library and cannot run **Symptom.** ``` unit 'lib/Money' is a library — no class implements InterlockHandler, so it can be imported by other units but not run ``` **Cause.** A unit with no handler is a library unit. It compiles and it is importable, and asking it to run is the Java equivalent of asking `constants.jsx` to be a page. Note that handler discovery is by **attribution**: only a class compiled from this unit's own source can be its handler, because a dependency's handler classes land in the same loader. **Fix.** Nothing, if it really is a library. If you meant it to run, give it a `public class` implementing `InterlockHandler` in its own file. To reach a library from a test, use `Engine.unitClass(id, env)` and reflection. See [the handler contract](/reference#java-handler-contract) and [the unit-harness test](/testing#the-unit-harness-test). ## A .js unit cannot import a sibling **Symptom.** The unit saves and compiles fine. At runtime, the first request fails: ``` JS error in 'docs/page': Error: Operation is not allowed for: docs/manifest ``` **Cause.** A `.js` unit is evaluated as a **standalone ES module with no resolver**. There is nothing to turn `'docs/manifest'` into a unit, so the engine refuses the operation. Only `.jsx` units resolve imports by id, because those are transpiled with an id resolver, and `.java` units resolve by package. **Fix.** Data can cross a unit boundary; behaviour cannot. Ask the other unit for its value with `il.call` instead of importing it: ```js const manifest = il.call('docs/manifest', {}); // returns the unit's value ``` `il.call` also returns a static unit's SOURCE verbatim, which is how a `.md` or `.txt` unit is read from JavaScript. To share actual behaviour, put it in a `.jsx` unit (imports by id) or a `.java` unit (imports by package), or inline it. **Verify.** ```bash interlock run ``` See [The model](/units#the-handler-contract) and, for Java, [Java units](/java#directory-is-package). ## JS unit has no default export **Symptom.** ``` unit 'hello' has no default export function (expected `export default (req, il) => …`) ``` **Cause.** A `.js` unit's handler is its default export, and the default export must be executable. A module that exports a default object, or only named exports, has no handler. **Fix.** `export default (req, il) => { … }`. See [the JS handler contract](/reference#jsjsx-handler-contract). ## No code in that env **Symptom.** ``` no code 'caller/UseIt' in env 'prod' ``` **Cause.** The unit exists in `dev` and was never promoted. Envs move by promotion, never by re-syncing, and a production host pins `prod` precisely so that only a promotion can change running code. **Fix.** `interlock promote caller/UseIt --from dev`. Check what is actually there with `interlock list --env prod`. Note that ids are case-insensitive through a kebab slug, so `caller/UseIt` and `caller/use-it` address the same unit; a genuinely different id is a genuinely different unit. See [promotion](/units#environments-and-promotion). ## Unit timed out **Symptom.** ``` unit 'hello' timed out after 30000ms and was stopped ``` **Cause.** JS and JSX runs get a wall-clock watchdog so a runaway unit is interrupted instead of pinning a host thread forever. The default cap is 30000 ms and the host can change it. Java units are project-trusted code and are **not** hard-capped in-process, so this error never names a Java unit. **Fix.** Move the long work off the request. A request-scoped unit cannot outlive its call, so hand long work to a host executor through a context capability. See [the custody line](/quarkus#the-custody-line). ## A JSX import does not exist **Symptom.** ``` unit 'counter/panel' imports 'counter/Total', which does not exist in this project ``` Or, when the engine has no resolver wired at all: ``` unit 'counter/panel' imports 'counter/Total', but this engine has no way to resolve unit imports ``` **Cause.** JSX imports resolve **by id**, not by relative path, and in the same env as the entry unit. `'counter/Total'` is a unit id; there is no `./Total`. **Fix.** Import by id, and confirm the id exists in this env with `interlock list`. CSS is imported by id too, extension included (`import 'counter/styles.css'`). See [frontend units](/units#the-handler-contract). ## Import graph is too deep **Symptom.** ``` import graph from 'counter/panel' is deeper than 16 — is there a loop of unit imports? ``` **Cause.** The JSX import walk is bounded at 16 levels. In practice this means a cycle. **Fix.** Break the cycle. Pull the shared piece into a third unit that both sides import. ## No Java compiler available **Symptom.** At the first Java unit run: ``` no Java compiler available (native image?) — Java units need JVM-mode hosting ``` **Cause.** Java units are compiled at runtime by the JDK's own compiler, which a native image does not carry. **Fix.** Run the host in JVM mode. See [embedding the SDK](/host#the-wiring). ## More than one context of that type **Symptom.** ``` java.lang.IllegalArgumentException: the host seeded more than one context of type com.example.MyContext ``` **Cause.** `il.context(MyContext.class)` looks a context up by type, and the host seeded two objects assignable to it. This is raised rather than resolved by picking one arbitrarily, because it is a host bug worth hearing about immediately. **Fix.** Seed **one** typed root carrying capabilities. Do not also register the same object under a string key: two names for one thing is two things to keep in step. Per-request facts belong in `il.session(MySession.class)`, not in a second context. See [context and session](/host#context-versus-session). ## Challenge service unavailable **Symptom.** A Java host receives `Challenges.Unavailable`, whose public message is: ``` Interlock challenge service unavailable ``` **Cause.** The broker refused the task, the selected capability is disabled or unavailable, the provider failed, or the control-plane request did not complete. The SDK deliberately does not copy the upstream response into the exception: provider identity, credentials and protocol stay inside Interlock. **Fix.** Read `Unavailable.code()`. Retry the same logical solve with the **same** idempotency key; using a new key can create a second paid transaction. If the code is `CHALLENGE_DISABLED` or `CHALLENGE_UNAVAILABLE`, do not loop—choose an available local/browser recovery path or return the site's block honestly. See [brokered challenge solving](/reference#brokered-challenge-solving). ## Sync conflict **Symptom.** ``` !! CONFLICT ping (dev) — changed locally AND on the server (server v7) server copy: code/ping.js.conflict-server keep yours: interlock sync code --ours ping · take server: --theirs ping ``` **Cause.** The unit changed locally **and** on the server since the last sync. Nothing is overwritten; the server copy is written beside your file as `.conflict-server`. **Fix.** Read both, then resolve explicitly with `--ours ` or `--theirs `. If you are an agent: show the human the conflict and let them pick. Do **not** resolve a conflict on a human's behalf without being asked. See [conflicts](/operating#conflicts-surface-as-fileconflict-server). ## Another yolo is already running **Symptom.** ``` another yolo is already mirroring this tree (pid 12345) — stop it first ``` **Cause.** One `yolo` per tree, held by a lock file under `code/.interlock-index/`. Two live mirrors on one tree would fight over every save. **Fix.** Stop the other one, or remove the stale lock if that process is gone. See [sync versus yolo](/operating#sync-versus-yolo). ## Tasks says STALE_ATTEMPT or outcome-unknown **Symptom.** A worker report gets `409 STALE_ATTEMPT`, or the Tasks console says: “The worker may have finished, but Interlock cannot prove the outcome.” **Cause.** `STALE_ATTEMPT` means this attempt's lease/fence was replaced. Its result is not current and must not overwrite the newer attempt. `outcome-unknown` means a non-retryable effect may have happened before ownership or its acknowledgement was lost; repeating it could double the effect. **Fix.** A stale worker stops reporting and drops that local spool record because the refusal is a definitive answer. For an unknown outcome, inspect the external system using the stable `taskId` or `effectKey`; resolve it manually or make the effect idempotent before allowing retries. Do not turn either state into success or an automatic retry. **Minimal check.** The stale-fence database test must refuse the old completion while preserving the new canonical attempt: ```bash ./gradlew :interlock-java-api:test --tests '*TaskCoreDbTest*stale*' -Dinterlock.test.db=true ``` See [Durable tasks and workers](/tasks#outcomes-and-retry). ## Tasks says SCOPE_DENIED or NO_ELIGIBLE_INFERENCE_WORKER **Symptom.** A Tasks call returns `SCOPE_DENIED`, or a self-hosted Qwen call returns `NO_ELIGIBLE_INFERENCE_WORKER` with HTTP 409. **Cause.** Producer and worker credentials have opposite authority. An ordinary SDK key has neither. For self-hosted inference, the named model is served only by a healthy `ai/chat@1` worker in the same project; Interlock never substitutes a managed model silently. **Fix.** Mint the correct key kind with `interlock keys new --kind task-producer|task-worker`. For self-hosted inference, start or repair the project's worker and confirm it is not draining. Do not solve either error by broadening every key or enabling surprise managed fallback. ```bash interlock tasks doctor interlock tasks workers ``` ## AI run stream disconnected **Symptom.** A durable AI run was accepted and returned a run id, but its event stream closes, times out at an edge, or the submitting process restarts before the answer appears. **Cause.** The stream is notification, not ownership. The durable run and its underlying task keep running after that connection disappears; the run's canonical `GET` state is the source of truth. **Fix.** Keep the original run id and reconnect or fetch it. In Java, call `run.refresh()` or `run.await(Duration.ofMinutes(10))`; in Node, call `client.aiRunGet(id)` or `client.aiRunAwait(id)`. Do **not** resubmit the prompt merely because the stream ended: that creates a second inference instead of recovering the first one. ```bash curl -H "Authorization: Bearer $INTERLOCK_KEY" \ "https://api.interlock.sh/api/ai/runs/$RUN_ID" ``` The recovered terminal snapshot should contain the original task id and exactly one `usageEventId`. A missing eligible self-hosted worker is different: it is refused synchronously as `NO_ELIGIBLE_INFERENCE_WORKER` and creates neither a durable run nor a billable usage event. # 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 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 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 params)` | invoke another unit, return its raw value | | `String url(String id, Map 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 context(Class type)` | the typed door, and the one a Java unit should use | | ` T session(Class 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 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 all()` | fundamental | the whole namespace | | `void put(String key, Object value)` | fundamental | | | `void remove(String key)` | fundamental | | | `void clear()` | fundamental | | | ` T get(String key, Class type)` | default | an unchecked cast of `get(key)`; no conversion | | `String getString(String key)` | default | `String.valueOf(value)`, or `null` | | `Map getMap(String key)` | default | the value when it is a `Map`, else `null` | | `List getList(String key)` | default | a **copy** of the value when it is a `List`, else `null` | | `void putAll(Map values)` | default | null-tolerant; puts each entry | ### Java ```java var store = il.store(); store.put("counts", Map.of("a", 1, "b", 2)); Map counts = store.getMap("counts"); // the value, directly List names = store.getList("names"); // a copy, safe to mutate Long total = store.get("total", Long.class); Map 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 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` 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 { 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

Loading…

; return ; } ``` - **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` | `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 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 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> 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 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 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 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 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 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 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 local)` | | | `Result generateAndRun(String intent, String reuseKey, Request req, Map 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 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 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 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 contextFor(String codeId, String env)` | the one abstract method; never null | | `default Map 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 fromJson(String json, Class type)`, `static Map 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 '' in 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> params, Map headers, Map 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)` builds one from a bare parameter map, which is what `il.call` uses. # Verify a host ## For humans **Run this on** a first setup, a fresh clone, a new machine, or after somebody changed the deploy. **Skip it as** a daily ritual. It does not replace your own tests. ### Do this 1. Start at [step 1, the CLI can see your project](#1-the-cli-can-see-your-project). 2. Work down. Eight commands, in order. 3. Stop at the first red result and read the page that step names. ### The order matters - **Each step assumes the ones above it passed.** A confusing failure near the bottom is usually a quiet failure near the top that nobody looked at. - **Do not jump to the step that resembles your symptom.** Starting from the top costs two minutes and saves an hour. ### The two that pay for the page - **[Step 1](#1-the-cli-can-see-your-project)** tells you which project the CLI is talking to. That is the difference between a sync and an accident. - **[Step 4](#4-every-java-unit-compiles-against-the-packaged-application)** compiles every Java unit against the real packaged application, before anything boots. ### If a step fails - [If something failed](#if-something-failed) hands you the full [Failure catalogue](/failures). ## For robots The commands that prove a host is wired correctly, in order. Run them top to bottom. Each one checks a single thing and tells you which page explains the failure. Do not skip ahead. Every step assumes the ones above it passed, and a failure three steps down is usually a failure one step up that nobody checked. ## 1. The CLI can see your project ```bash interlock whoami ``` Prints the account, the project and the server the CLI is pointed at. If the project is not the one you expect, you are about to sync into somebody else's tree. Stop and read [Operating a project](/operating#interlock-key-binds-a-tree-to-a-project). Confirm the key file is where you think it is: ```bash ls -la .interlock-key && head -c 8 .interlock-key && echo ``` Never print more than the first 8 characters. The whole file is a credential. ## 2. The tree and the server agree ```bash interlock sync code --dry ``` A dry run lists what WOULD change and touches nothing. On a clean tree it should report no changes. If it proposes deleting every unit you have, the CLI is authenticated against a different project. That is the failure described in [Operating a project](/operating#interlock-key-binds-a-tree-to-a-project). ## 3. No unresolved conflicts ```bash find code -name '*.conflict-server' ``` Empty output, or you have unresolved conflicts. Resolve them deliberately with `--ours ` or `--theirs `. If you are an agent: do not resolve these on the human's behalf without being asked. ## 4. Every Java unit compiles against the PACKAGED application This is the two-second gate, and it is the highest-value check on the page. It compiles every unit against the real packaged classpath, transformed jar first, before anything boots. ```bash ./scripts/check-units-compile.sh ``` The script is on the [Testing page](/testing#the-two-second-gate), ready to copy. Run it in CI and as a pre-commit hook. Failures here are almost always the entity rule: ``` error: status has protected access in Presence ``` That means the entity has no hand-written accessor. `Presence` is the entity of the real host application this was measured on (Team Lakes); yours will name your own class. See [the entity rule](/quarkus#the-entity-rule). ## 5. Package the host and boot it ```bash ./gradlew build -x test ``` Package before checking anything entity-related. `quarkusDev` transforms bytecode in memory and writes no jar, so a unit that touches an entity behaves differently there. The SDK logs a one-time boot WARN saying so. See [Quarkus](/quarkus#quarkusdev-is-unsupported-for-entity-touching-units). ## 6. A unit actually runs ```bash interlock run ``` Runs it server-side and streams the logs back. If the unit has no handler you get the library-unit refusal, which is correct behaviour for a library and explained on [The model](/units#library-units). ## 7. Assert an EFFECT, not a status code The single most valuable test you can write against a host. A 200 proves the request was accepted, not that it did anything: ```bash curl -s -X POST "$HOST/app/reminders/mute" -H 'content-type: application/json' -d '{"minutes":30}' curl -s "$HOST/app/reminders/state" | grep -q '"muted":true' && echo OK || echo FAILED ``` The second line is the test. The first one passed even when `req.str()` ignored request bodies entirely, which is exactly how that bug survived. See [Testing](/testing#assert-effects-not-status-codes). ## 8. Refusals answer with a status ```bash curl -s -o /dev/null -w '%{http_code}\n' -X POST "$HOST/app/reminders/touch" ``` A unit that can only answer 200 turns every "no" into an apparent success. A refusal should come back as its real status with `{"ok":false,"reason":...}`. See [Refusal](/host#resultstatus-and-refusal). ## If something failed Go to the [Failure catalogue](/failures) and search for your literal error text. Every entry is symptom first.