Every known gap in ZeroZ Stack 0.6.1, in one place. This page exists because surprises are what make people abandon a framework, and because a coding agent that reads it will not generate code against features that do not exist.
ZeroZ Stack is an experimental proof-of-concept. Read this list as the honest boundary of the demonstration, not as a roadmap commitment.
These exist in the source as annotations or constants and do nothing. Do not build on them.
| Item | Status |
|---|---|
Protocol opcodes 0x11 SNAPSHOT, 0x12 UNSUBSCRIBE, 0x13 MUTATE, 0x14 ACK, 0x15 REJECT, 0x16 SIGNAL_SUB, 0x18 PUSH |
Declared and unreferenced. Reserved for future protocol work. |
| Versioned mutations, acknowledgement and conflict rejection | Reserved in the protocol. The implemented sync path has no version field, no ACK and no conflict detection. |
| Coalesced LiveSync mutations and UI-scheduler dispatch of inbound frames | Both are conditional on a PlatformScheduler, and WasmRmiClient.setPlatformScheduler is never called anywhere in the framework. So every setter sends its own mutation frame, and all inbound frames are applied inline on the WebSocket callback. |
Since 0.5.0 a dropped WebSocket recovers by itself: the channel reconnects with backoff, a built-in
banner shows the outage, shared signals re-subscribe, live objects are re-synced from the server,
edits and writes made while offline are sent on reconnect, and RMI calls fail immediately with
DisconnectedException instead of hanging. Since 0.6.1 an idle connection also sends a keepalive
every 25 seconds, so a proxy in front of the application does not close it for silence.
What automatic recovery deliberately does not cover:
- RMI calls are never replayed. A call that failed to a drop is the application's to retry — the
framework cannot know whether repeating it is safe. Catch
DisconnectedException, or disable controls whileWasmRmiClient.connectionState()is notCONNECTED. - A server restart empties the handle registry. Re-sync can only restore objects the server still knows. After a restart, live objects held by clients stay as they were and the application must re-fetch them the way it first obtained them; the server logs how many handles it could not restore. Shared signals recover fully either way.
- Session ids change on reconnect. Anything keyed by session id —
Scope.SESSIONpushes, application registries of "sessions viewing X" — points at a dead session after a drop. The application must re-register from aStateListeneronCONNECTED; observe the server-side CDI eventSessionClosedEventto clean up the stale entry. - A lost
LiveMutexstays lost. The server releases a session's locks when the socket closes. Reconnecting does not re-acquire; the holder is told throughsetLostListener. - Events broadcast during an outage are gone. Events are fire-and-forget news with no replay; this is unchanged and by design. State belongs in signals or LiveSync, which do recover.
- Offline writes are last-write-wins. A shared-signal write queued offline flushes as one write with the final value; intermediate values are not replayed. Concurrent edits from other clients during the outage are settled by the server's usual last-write-wins rules, not merged.
- Change notification is per object, not per field. Any inbound sync touching an instance re-runs every effect that read any of its getters. Fine-grained per-field tracking is not implemented.
- Whole-object, last-write-wins. No field-level merging. Two concurrent unlocked editors race and
the later write wins; serialize them with
LiveMutexwhere that matters. - No tracked collections. Setters are the tracking boundary; in-place collection edits are
invisible. Reassign through the setter or call
LiveMutationTracker.touch(obj). notifyChangedrequires a prior send. It throwsIllegalStateExceptionunless the object already has anObjectMapperhandle, which it gets by being serialized to a client.- Rejections carry a reason. The writer receives a corrective sync followed by a
0x15 REJECTframe naming the model and the reason, and every rejection cause is logged server-side. - Mutations do not coalesce in the current build — one frame per setter call. See the table above.
- Handles are never evicted. The
ObjectMapperis application-scoped with no per-session partitioning and no eviction, so handles accumulate for the process lifetime. - Only
@ClientWritableclasses accept client writes, and only objects the server has already synced can be mutated.
- Broadcast only. No per-topic subscription filtering on the server; a session receives every frame published within its scope and filters by topic client-side.
- Scoping is opt-in.
publish(topic, payload)reaches every connected session with no principal check. UsepublishToUserorpublishToSessionfor anything belonging to somebody. - Tenant scope requires a provider that reports a tenant.
Scope.TENANTfilters on the tenant anAuthenticationProviderattached to the session; a session with no tenant never matches. - At most once. A disconnected client misses events. No queueing, acknowledgement or redelivery.
- No replay. Late subscribers receive nothing.
- Serialization failures throw to the caller. The payload is checked once before the broadcast, so
publishfails loudly instead of appearing to succeed while reaching nobody.
Signals.sharedis JVM-global. The registry is static: one value per signal name for the whole server, across every user and tenant. That is the definition, not a gap — for one value per tenant, user, browser or session useSignals.scoped(name, initialValue, scope)instead (see SIGNALS.md).- Scoped signals hold every target's value for the process lifetime. Targets are created on first
use and never evicted, so a
Scope.CLIENTsignal in a long-running server accumulates one entry per browser that ever connected. Nothing pages them out or persists them across a restart. - A scoped signal's targets are not enumerable from a client, and
knownTargets()on the server reports only targets that have been touched — a target that has never been written is absent even though subscribing to it works and yields the initial value. - One default signal per payload type. The default wire name is the payload's class name, so two
unnamed declarations of the same type collide. A conflicting redeclaration now throws
IllegalStateExceptionrather than silently keeping the first; give signals explicit names. - Latest-wins only. No history, no replay, and
equals-equal consecutive values are dropped, so a rapidly changing signal can skip intermediate states. - Whole-value replacement. No per-field merging on client writes.
- Empty
writeRolesmeans anyone may write, anonymous sessions included. - Serialization failures throw to the caller — the value is checked once before the broadcast.
- A rejected client write is logged nowhere. The writer is snapped back with no server-side record. (Shared-signal writes still lack the reason frame that LiveSync mutations now get.)
- Validation on a client write checks the top-level value only — it does not recurse into fields or collection elements.
- Treat the signal graph as single-threaded.
ValueSignalsynchronizes its own reads, writes and listener notification, butComputedhas no synchronization at all, so the graph as a whole is not thread-safe even though one type in it is. KeyedListdiscards itsDisposable. Its effect cannot be released and lives as long as the upstream signal.bindTextandbindValuenow return theirs.bindValuerequires a writable signal. Passing aComputedthrows rather than silently degrading to one-way; usebindValueReadOnlywhen a one-way binding is what you want.
- No converters.
Binderbinds a field's value type directly to the bean property type; there is nowithConverter, so aStringfield cannot be bound to anintproperty. Use a field whose type matches, or convert in the getter/setter pair you pass tobind. - One validation message per field is surfaced at a time — the first violation wins.
- No
setReadOnlyon the binder or its bindings, and no validation-status handler hook. - A custom
HasValueimplementation must implementaddValueChangeListenerandremoveValueChangeListenerorBinderthrowsUnsupportedOperationExceptionwhen binding it. This is deliberate; fields extendingAbstractFieldalready satisfy it. - A
Computedstill returns its last value afterdispose().
- Transactions and rollback are available since 0.4.0, because the store runs on ZeroZ DB.
Send a
DbCommandthrough the injectedZeroZDbNode: everything it enlists commits atomically, and a command that throws persists nothing and restores the objects it touched. The rawEmbeddedStorageManagerremains available where the data is local, wherestoreAll(...)groups a write but cannot undo one. - Each
store()call is its own commit. Two calls where one was meant is the most common data-loss bug: a crash between them persists the first and loses the second. UsestoreAll. - No conflict detection at the storage layer. Two writers changing the same object: the later write wins, silently.
- Saving is manual. The framework never writes for you, including after a
@ClientWritableedit from a browser — implementLiveMutationListeneror the edit is lost on restart. - No query language. Reads are plain Java over the in-memory graph, so lookups are linear scans unless the application keeps its own index.
- Uncommitted state is visible. A change is in the object graph as soon as you make it, whether or not it has been saved, so another request can read it.
Supported:
-
Primitives and their wrappers:
int,long,double,float,boolean,short,byte,char -
String,UUID, enums -
BigDecimalandBigInteger— carried as their exacttoString()form, so scale and precision survive; safe for monetary amounts -
Instant,LocalDate,LocalTime,LocalDateTime,Duration -
Optional— empty and present both round-trip -
Collections:
List,Set,Map -
Arrays:
byte[],int[],long[],double[],float[],short[],char[],boolean[] -
@DataModelclasses, including cycles and shared references -
EclipseStore
Lazy<T>fields — see below
Not supported: object arrays (String[], MyModel[] — use a List), ZonedDateTime,
OffsetDateTime, ZoneId, Period, java.util.Date, java.sql.*.
Since 0.4.0 the annotation processor rejects an unsupported @DataModel field type at compile
time, naming the replacement, so the mistake no longer waits until runtime. The check is a
blocklist of types known to break, not an allowlist: a field typed Object, an interface or an
abstract class still compiles, because serialization dispatches on the runtime type.
A @DataModel may declare EclipseStore Lazy<T> fields. The reference travels as a session-scoped
handle and never as its contents, so a deferred subgraph stays deferred across the network:
@DataModel
public class Order {
private String id;
private Lazy<List<OrderLine>> lines; // handle on the wire
}// Client — suspends on the round trip, then caches. Reading again is free.
for (OrderLine line : order.getLines().get()) { ... }The server holds a real Lazy.Default backed by storage; the client holds a ClientLazy backed by an
RMI call. Both satisfy the same interface, so your model is unchanged. TeaVM links only the Lazy
interface and eliminates every EclipseStore implementation behind it — no storage class reaches the
browser bundle.
Limits and rules:
- Handles are bound to the session they were sent to. Another session presenting the same handle is refused, because a handle is a capability to read a subgraph.
- Handles are released when the session closes, unlike
ObjectMapperentries. - Lazy references originate on the server. A client cannot create one and send it up; assign the resolved value instead.
- The client and server must agree on the EclipseStore version — the
Lazyinterface changed shape between major versions, and a mismatch shows up as an obscurecannot access UsageMarkablecompile error. Both sides take the version from theeclipsestore.versionproperty. isStored()is always true on the client, andlastTouched()returns 0; usage marks drive server-side cache eviction and are inert in the browser.- Resolving a lazy field is a round trip. It is not automatically batched with anything else, so resolving many in a loop makes many calls.
Declare collection fields as the interface type — Set, List, Map — not as TreeSet,
LinkedList or TreeMap. Collections are rebuilt on the receiving side as LinkedHashSet,
ArrayList and LinkedHashMap, so a field declared as a concrete type outside that hierarchy fails
with a ClassCastException on deserialization. A TreeSet is written in its sorted order and arrives
ordered but without its Comparator, so later insertions are not re-sorted.
UUID is carried as its canonical string form rather than two longs, because TeaVM does not emulate
UUID.getMostSignificantBits().
- Server-side RMI argument validation recurses into
Listelements but not intoMapvalues or nested object fields. - Client-side validation is user feedback, never a security boundary. The server re-validates independently.
The annotation processor warns rather than fails for two footguns. Read your build output.
- A
@ClientWritablefield with no setter is skipped — its mutations are not tracked. @ClientWritablewithout@LiveSyncmeans mutations travel up but no state comes back down.
The client is written entirely in Java and compiled ahead-of-time by TeaVM. TeaVM has two backends,
JavaScript and WasmGC, and ZeroZ Stack uses the JavaScript backend today. Every example client module
sets <targetType>JAVASCRIPT</targetType>, and no module in the repository sets WEBASSEMBLY.
This is a deliberate interim choice, not a stale setting. TeaVM's WasmGC backend does not yet
provide functionality ZeroZ Stack depends on. WasmGC remains the intended destination — hence the
zerozstack-client module name — and the project will move to it once TeaVM's support is complete.
No application code changes with the backend: you write the same Java either way. The distinction matters only when describing what the build emits, so don't state that client code compiles to WebAssembly today.
- Only TeaVM-supported JDK APIs are available in client modules.
- Client code runs on a cooperative single-threaded scheduler.
java.lang.Threadexists and is what TeaVM calls a green thread — starting one re-enters TeaVM's scheduler and is the documented way to reach a context where a call may suspend. It buys no parallelism: nothing runs at the same time as anything else, and code that assumes real concurrency is wrong here. - A suspending call cannot start on a stack that began in native JavaScript. An RMI call inside a
DOM event handler, a
setTimeoutcallback, or a WebSocket frame handler fails with "suspension point reached from non-threading context". The router hits this on every navigation and handles it by running each navigation on a green thread; application code fetching from such a callback must do the same.
- Loaders run in sequence, not in parallel. A layout's loader and its child's cannot overlap, because of the single-threaded scheduler above. The guarantee routing gives is ordering — data before render, shared data fetched once in a layout — not concurrency.
- The whole chain is rebuilt on every navigation. A layout is not kept mounted while its children swap, so moving between two children of the same layout re-runs that layout's loader and rebuilds its components.
- One child per layout. Sibling outlets are not modelled.
- No wildcard or optional segments. Patterns are literal segments and
:paramswith a fixed count;/files/*pathis not supported. - No lazy loading, transitions or scroll restoration. Everything is in one bundle and the container's contents are replaced outright.
- Route guards are client-side only.
@RequiresRoledecides what to show; the server re-checks every call, and that is what protects data.
- Installing does not make an application work offline, and is not intended to. Every view loads
its data over the WebSocket, signals get their retained values from the server on subscribe, and
LiveSync objects live server-side. There is no client-side store, so with no connection there is
nothing to render. Opened offline, an application shows
/zeroz4j-offline.htmland stops there. This is a property of the architecture. Do not read the presence of a service worker as a promise of offline operation. - The service worker caches the shell only — the client bundle and the offline page. No data, and no application assets beyond what a page happens to request.
- Its caching strategy is fixed. Navigations are network-first, same-origin assets are
cache-first,
/wasm-rmiand cross-origin requests are never intercepted. An application needing different behaviour registers its own worker withPwa.install(path)and takes on the cache-invalidation problem the shipped one solves. - No background sync and no queued writes. An action taken with no connection is lost, not replayed later.
- Push delivery is not implemented. The framework collects a subscription; posting to it needs a signed VAPID JWT and RFC 8291 payload encryption, which is a library's job. Subscription lifecycle — deleting one after a 404 or 410 from the push service — is the application's.
- No icon generation. Applications supply their own PNGs at the sizes browsers want, including a maskable one.
- Installation and push need a secure origin.
http://localhostcounts; any other host needs HTTPS, and browsers offer neither without it.
- Messages are whole, never partial.
@OnMessagetakes a completeByteBuffer; there is no partial-message handling and no chunking. A response larger than the container's binary buffer does not raise an error — it closes the socket, with nothing in the log to say why. Raise the limit withzeroz.ws.maxBinaryMessageBytes, and design against sending very large payloads over RMI at all. - No limits are imposed by default.
zeroz.ws.maxBinaryMessageBytesandzeroz.ws.idleTimeoutMinutesare unset, so the container's own values apply — which for the message size is usually small. Without an idle timeout an abandoned browser tab holds a session and its server-side resources indefinitely. - Container-managed threads are platform threads. A Jakarta EE 10
ManagedThreadFactorycannot produce virtual threads, so a WAR deployment supplying one throughSessionThreadFactoryProvidertrades cheap threads for the container's naming, transaction and security context. Without such a provider, RMI calls run on framework-created virtual threads that carry none of that, and ajava:comp/env/…lookup inside a service fails. - The framework does not verify what a container's factory carries. Its contract is only that calls are dispatched on threads the supplied factory produced; whether those threads have the container's context is the container's contract, and worth an integration test in the application.
zerozstack-server-jaxrsis a catch-all at/. Do not add it to a WAR that has its own servlets.zerozstack-server-corecarries no JAX-RS or servlet type at all, which is what makes it safe inside somebody else's deployment.Zeroz4jShellServletis not auto-mapped. Deliberately: mapping it at/from inside the framework would reintroduce the collision the module split exists to prevent. The deployment declares the mapping.- Mapped at
/, the shell servlet replaces the container's default servlet. Nothing else serves static files after that, so it serves them: the classpath under/META-INF/resources/first, then the WAR's own web content through theServletContext.WEB-INFandMETA-INFare never served from the archive root. A file present in both places is served from the classpath. - A context path is handled, but only for what the framework owns. The shell is served with a
<base href>for the deployment's context path, and the router,Pwa.install()andZeroz4jClient.defaultWebSocketUrl()all read the application's root from it. Anything an application writes with a leading slash — anhref, afetch, a redirect, a cookiePath, a hand-built WebSocket URL — still escapes the context path, and does so silently until deployed. Build those withAppBase.location(...)/AppBase.url(...). - The
<base href>is skipped when the shell already declares one, and when it has no<head>. Both are deliberate, and both mean an application that does either owns the problem itself.
README describes multi-tenancy as available out of the box. Be precise about where it exists:
- Storage — isolated by
TenantResolverand the EclipseStoreTenantStorageProvider. - Server events and LiveSync — isolated when published with
Scope.TENANT, which requires anAuthenticationProviderthat reports a tenant.publish(topic, payload)with no scope still reaches every connected session. - Signals —
Signals.scoped(name, initialValue, Scope.TENANT)holds one value per tenant.Signals.shared(...)is a single global value by definition and crosses every boundary. - Not isolated: the
ObjectMapperhandle namespace is shared across tenants, and scoped signals keep every target's value in memory for the process lifetime with no eviction.
Nothing here is automatic: a tenant-scoped push is a scope you pass, and choosing GLOBAL — or
leaving the scope off — is what leaks.
- No example uses
@ClientWritable. The LiveSync up-direction is exercised only byServerLiveMutationTestinzerozstack-server-core. components-showcasepublishes to push topics that nothing subscribes to, using the low-levelbroadcastPush(String, Object)rather than a typedEventTopic. Do not copy that pattern.
Several documents predating 0.4.0 contain stale API claims and are being rewritten. Where a document
and the source disagree, the source is correct — please open an issue. docs/GETTING_STARTED.md,
docs/CODE_WALKTHROUGH.md, docs/ARCHITECTURE.md and docs/CONCEPTS.md carry warning banners naming
their specific known errors.