Model state and protection boundaries
Use the Model APIs for ordinary atomic state transitions before building a separate persistence layer. Storage, query visibility, authorization and protection of secrets are different decisions.
| Need | Existing capability | Boundary |
|---|---|---|
| Create or change related state atomically | One Model action with multiple @Apply targets, including parent/child creation | External calls, schedules and unrelated repositories are not in that transaction |
| Load by identity | Fluxzero.loadModel(id) | Uses the Model’s authoritative load path; identity knowledge is not authorization |
| Persist current state without Model events | DOCUMENT plus eventPublication = NEVER | No stored historical document values; Graphs may use current-value fallback; does not suppress incoming command/webrequest logs, results or application logs |
| Keep DOCUMENT state internal | No effective searchability (the default unless an ancestor activates it) | Identity reads remain possible; search and search-based parent/ancestor selection require effective searchability |
| Check concurrent changes | RETRY or FAIL and injected Models/Graphs | Use assertions within the action; a separate query/read followed by a write is not an atomic check |
| Delete from current state | Return null from @Apply | Does not erase retained history; follows configured parent ownership |
| Erase selected Model storage | modelRepository().deleteModel(...) or a confirmed deletion plan | Fences stale writes; global event logs, other copies and backups have separate lifecycles |
For example, current delivery status may need identity loads but neither replay nor unrestricted search:
@Model( persistence = ModelPersistence.DOCUMENT, eventPublication = EventPublication.NEVER)record DeliveryProgress(@EntityId DeliveryProgressId progressId, boolean completed) {}@Model( persistence = [ModelPersistence.DOCUMENT], eventPublication = EventPublication.NEVER)data class DeliveryProgress(@EntityId val progressId: DeliveryProgressId, val completed: Boolean)Keep event sourcing when historical values or event-driven reactions are needed. An EVENT_SOURCED Model cannot
change state without storing the corresponding event. With DOCUMENT, NEVER must be the effective setting:
more specific apply/message configuration can override broader defaults. It suppresses the applied update’s Model
event; it is not a general-purpose “do not log this request” switch.
Model read boundaries
Section titled “Model read boundaries”A fresh read, a pinned snapshot and commit conflict protection are different guarantees.
A separately addressed Model invariant
Section titled “A separately addressed Model invariant”An independent Product must remain active when a Reservation is committed. Bind its ID explicitly in the command; it does not need a parent relation to the Reservation:
@Model record Product(@EntityId String productId, boolean active) {}@Model record Reservation(@EntityId String reservationId) {}
record Reserve(String reservationId, String productId) { @AssertLegal void check(@jakarta.annotation.Nullable Product product) { if (product == null || !product.active()) { throw new IllegalCommandException("Product is not active"); } }
@Apply(conflictPolicy = ModelConflictPolicy.RETRY) Reservation apply() { return new Reservation(reservationId); }}@Model data class Product(@EntityId val productId: String, val active: Boolean)@Model data class Reservation(@EntityId val reservationId: String)
data class Reserve(val reservationId: String, val productId: String) { @AssertLegal fun check(product: Product?) { if (product?.active != true) throw IllegalCommandException("Product is not active") }
@Apply(conflictPolicy = ModelConflictPolicy.RETRY) fun apply() = Reservation(reservationId)}productId matches the Product’s @EntityId property. @Nullable (or a nullable Kotlin parameter) permits an absent Product to reach the explicit
business rejection; it does not make the rule optional. A typed Id<Product> or an explicit @Association can
disambiguate a different property. With @Association("product"), trusted message metadata can supply product
even when the payload has no such property. Metadata wins unless excludeMetadata = true; never trust caller-owned
metadata for authorization. Binding must be available before handler invocation, including prefetch and retry:
a value that a custom resolver invents only during the method body is not automatically an identified Model target.
The same rule can inject Graph<Product> and check get(). A returned validation object may carry its own
productId; nested Fluxzero.assertLegal(new ProductGuard(productId)) inside the mutation joins that mutation’s
reads too. Independent calls made outside a mutation remain read-only checks, not reservations of future writes.
If Product changes after a successful read but before commit, RETRY reevaluates and rejects an inactive Product; FAIL reports the conflict. A cached starting state is allowed. An assertion that already rejects is final: no freshness request or retry is made merely to see whether newer state might permit it.
Read boundaries are not interchangeable
Section titled “Read boundaries are not interchangeable”| Read during a Model mutation | Snapshot and commit protection |
|---|---|
Injected T / Graph<T> | Attempt snapshot; injected values and inspected relationships are dependencies |
Synchronous loadGraph, loadCurrentGraph, graph.current() on its repository/namespace | Same attempt snapshot and staged state; inspected values/relationships join its readset, including empty collections |
Arbitrary loadModel in a helper | Authoritative load and applicable handler/cache boundary; does not itself register a new dependency: inject the Model or read its Graph |
Search / loadCurrentModelState | Search/document read contract, not a commit dependency or an event-bound Graph |
Historical previous() / at... | Historical view, not a current invariant dependency |
Outside mutations, ordinary Model-event reads use the event’s boundary; explicit current Graph reads open a fresh storage-verified view. Inside mutations, current shortcuts never create a second snapshot. Do not hand detached Graphs from a separate query or earlier operation to an invariant and assume their old reads become protected.
DOCUMENT-only mutations retain document authority even when events were published: preparation does not replay them. Simple single-target writes with built-in RETRY defer the extra namespace head read until an additional transactional dependency is requested. It verifies the original target revision before pinning. A mismatch restarts the entire evaluation eagerly and consumes the normal retry budget, including when discovered in a helper or after-assertion. Assertions and applies must remain repeatable. Complex targets, known Graph dependencies, aliases, parent bindings, batches and custom conflict handling retain eager preparation: one batched head read, with head/document races retried at most eight times before user code runs. Once pinned, an unavailable document version fails explicitly rather than moving the snapshot; use EVENT_SOURCED for historical reconstruction.
If deletion/cascade preparation loses a required pinned DOCUMENT version after concurrent replacement or deletion,
it raises ModelCommitConflictException before submitting the mutation. This preparation conflict is terminal under
all conflict policies, including RETRY: neither conflict handling nor provisional batch dependencies rerun it against
newer state. getReadConflict() supplies the original commit ID, unavailable canonical Model ID and pinned read index.
getResult() is null in this case, because there was no storage rejection or observed current relationship position.
For ordinary storage conflicts the opposite holds: getResult() is present and getReadConflict() is null; existing
RETRY/FAIL/ACCEPT handling is unchanged. This does not change bounded retries for fresh, not-yet-pinned current reads.
For JDBC, this classification requires a Runtime that returns typed historical-read evidence. An older Runtime
still fails closed with its ordinary service error; the SDK does not infer conflict semantics by parsing error text.
Older SDKs can read the updated Runtime’s ordinary error envelope and ignore the optional evidence. No stored-data
migration is involved.
Consumed Graph alias lookups also protect the selected mapping (including absence), canonical-ID precedence and
the selected owner’s head. This includes id() and relation-only access. Exact-ID reads do not depend on aliases;
merely creating an unused Graph does not add a lookup dependency. ACCEPT retains only apply-time dependencies.
SDK rc.13 and earlier do not attach manual Graph loads or separately invoked assertion helpers to the active readset; use direct injection there. The corrected behavior above applies after that fix. Reproduce invariants with active/inactive/missing state, warm caches and a second writer between read and commit, not just sequential commands.
Before adding a workaround, identify the values and relationships that carry the invariant, their ID binding,
read boundary and conflict policy. @AssertLegal, a current read or RETRY alone does not make arbitrary I/O
transactional. The query guide at Model query guide covers document/search consistency separately.
Model cache tracking
Section titled “Model cache tracking”Current Model loads can use cache entries whose state has been validated against the model store. The SDK starts boundary observation asynchronously and maintains one tracking loop per cache tracker; concurrent first loads do not create additional loops or reset its cursor. A transient initial failure leaves current loads on the ordinary store-validation path and allows a later load to retry initialization.
Update pages use an exclusive cursor and strictly increasing state indices. Empty heartbeats retain that cursor; fixed or backwards-moving fixture clocks do not permit repeated indices. If tracking fails, current loads validate against the store until a valid recovery page has been fully processed. Applications should not suppress ordering failures or disable the cache to compensate. These guarantees do not change event-bound or historical read semantics.
Non-searchable is not private
Section titled “Non-searchable is not private”A DOCUMENT Model keeps one internal current document. Searchability adds indexes to that canonical representation; node queries, related-content predicates and Graph composition share it. There is no separately editable public Model copy. Use Model operations for business changes and the guarded schema migration route for serializer upcasts.
A false value on the node means no independent search request. A composed searchable ancestor can still include that node. Do not use collection names or searchability as authorization: another application with direct access to the store can still read stored content. Enforce access through trusted handlers and namespace/access controls; an injected Graph is not itself an authorization check.
@ProtectData redacts annotated fields in stored messages and restores retained values for handling. Protection on
an input does not carry over to values copied into Model state, documents, snapshots, results or application logs.
A result payload can declare its own protected fields, handled by the normal RESULT-dispatch interceptor; this is not
a blanket guarantee for WebResponse bodies or native HTTP. There is no general encryption-at-rest, KMS/key-rotation,
secret-store or backup guarantee. Verify each required protection separately; custom serialization is an extension
point, not automatic key management.
Deletion, versions and rollout
Section titled “Deletion, versions and rollout”Logical deletion is not physical erasure. Physical Model erasure removes the selected stream, snapshots, current sources/projections and cache state, and fences delayed writes from restoring erased data. Descendant erasure requires its own explicitly selected scope/plan. Shared event payloads can remain while another surviving Model references them. The global event log is not removed by Model erasure. Copies in other stores, logs, exports and backups require their own retention/deletion and restore policy.
Descendant planning also follows retained deleted-parent relationships after a nested logical cascade. Ordinary detachments and moves made before deletion do not become deleted-parent lineage. Inspect the plan before confirming it: upgrading does not retroactively restore lineage markers omitted by older implementations. Verify the full scope of an already deleted tree independently before physical erasure; an incomplete plan is not proof of complete cleanup.
Physical erasure does not emit a domain transition or a root Graph tombstone. In the Runtime, an existing stored
Graph of a surviving ancestor can retain the erased child until its projector catches up; completion of
deleteModel() alone does not wait for that refresh. Wait for every affected registered ancestor projection to
reach the deletion result’s state index before treating those stored Graphs as refreshed. Independently maintained
read models and external copies require their own cleanup.
Use a matching SDK and service implementation for the storage/wire capabilities you enable. Older separate public Model projections are not authoritative canonical nodes. Preserve DOCUMENT-only state, choose the existing canonical collection deliberately and rebuild search indexes before changing writers. Recompile Model contracts and coordinate writer rollout; do not let old writers restore obsolete projection definitions.
See Model persistence, migration and retained-state tests, and sensitive message data for the owning APIs.
Graph reads during reconstruction
Section titled “Graph reads during reconstruction”During event-sourced reconstruction, synchronous Graph reads made by @Apply use that step’s input context,
just like injected Models/Graphs. Earlier substeps in the same commit and payload-to-Model phase updates remain
visible; later changes, the current step’s completed output and unrelated pending message-batch values do not.
This includes a dynamically discovered Model that the apply itself returns as a write. Current shortcuts stay in
this replay context too. Explicit historical views retain their requested boundary, and another repository or
namespace cannot borrow the replay context. Ordinary event handlers still see the state including their event.
© 2026 Fluxzero