Quarkus and Panache entities #
Raw markdown: /quarkus.md
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:
- Private fields. Not public.
- Hand-written getters and setters. Write them yourself; do not let Quarkus generate them.
- Read through the accessors in your unit. Including the identity field.
@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; }
}
// 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
idworks by accident. It survives the rewrite, so the entity looks fine until you touch a second field. Name ituuidorrefand even that read fails. - Dev mode lies.
quarkusDevwrites no jar, so entity-touching units behave differently there. Test against a packaged build.
Then read #
- THE ENTITY RULE for the measured table and the
javapcommands. - Panache lifecycle across the unit boundary if a write from a unit disappears without an error.
- For humans
- For robots
- Quarkus builds every entity twice
- Why the SDK inserts the transformed jar in addCodeSource
- Print the classpath, do not reason about it
- quarkusDev is unsupported for entity-touching units
- THE ENTITY RULE
- Re-measuring the entity rule yourself
- Provenance: what the 2026-08-08 re-measurement changed
- The two literal failure texts
- Panache lifecycle across the unit boundary
- The custody line
- What is measured and what is inferred
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.
- Quarkus builds every entity twice
- Why the SDK inserts the transformed jar in addCodeSource
- Print the classpath, do not reason about it
- quarkusDev is unsupported for entity-touching units
- THE ENTITY RULE
- Re-measuring the entity rule yourself
- Provenance: what the 2026-08-08 re-measurement changed
- The two literal failure texts
- Panache lifecycle across the unit boundary
- 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:
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.
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:
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:
# 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.addTransformedBytecodejavadoc)
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.
# application.properties
quarkus.log.category."sh.interlock.sdk.runtime.JavaRunner".level=DEBUG
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. 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.
# NOT this, for entity-touching units:
./gradlew :your-api:quarkusDev
# This:
./gradlew :your-api:quarkusBuild
java -jar build/quarkus-app/quarkus-run.jar
Verify.
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. 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:
@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.
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 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:
./scripts/check-units-compile.sh
The script itself is written out once, on the testing page: 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.
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 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.
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.
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.
- 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 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.
- 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 aMapor 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.
// 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:
# 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 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 theallowModificationflag; theTransactionRequiredException,ContextNotActiveExceptionandLazyInitializationExceptionstrings, quoted fromTransactionScopedSession, andAbstractLazyInitializer/AbstractPersistentCollection;RequestScopedSessionHolder.destroy()closing without a flush; Panache'scontains-guardedpersist. MEASURED from the SDK:JavaRunner.runcallinghandler.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,@OneToManyor@OneToOneanywhere, 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 setshibernate.enable_lazy_load_no_trans=truetakes 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.jtaships in the packaged app'slib/main/, which is on every authored unit's compile classpath. The full list is what is on a unit's 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.
Verify.
# 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 for wiring, compile anchors, the shadow boundary, and the request/response contract.