# 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/<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 `<root>/app/<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<String> 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 `<root>/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<Object> {
    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.*
