# 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/<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 <id> --env <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 <id>.
```

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<T>` 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 <your-unit-id>
```

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 `<file>.conflict-server`.

**Fix.** Read both, then resolve explicitly with `--ours <id>` or `--theirs <id>`. 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.
