Skip to content

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.

NeedExisting capabilityBoundary
Create or change related state atomicallyOne Model action with multiple @Apply targets, including parent/child creationExternal calls, schedules and unrelated repositories are not in that transaction
Load by identityFluxzero.loadModel(id)Uses the Model’s authoritative load path; identity knowledge is not authorization
Persist current state without Model eventsDOCUMENT plus eventPublication = NEVERNo stored historical document values; Graphs may use current-value fallback; does not suppress incoming command/webrequest logs, results or application logs
Keep DOCUMENT state internalNo 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 changesRETRY or FAIL and injected Models/GraphsUse assertions within the action; a separate query/read followed by a write is not an atomic check
Delete from current stateReturn null from @ApplyDoes not erase retained history; follows configured parent ownership
Erase selected Model storagemodelRepository().deleteModel(...) or a confirmed deletion planFences 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) {}

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.

A fresh read, a pinned snapshot and commit conflict protection are different guarantees.

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); }
}

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 during a Model mutationSnapshot and commit protection
Injected T / Graph<T>Attempt snapshot; injected values and inspected relationships are dependencies
Synchronous loadGraph, loadCurrentGraph, graph.current() on its repository/namespaceSame attempt snapshot and staged state; inspected values/relationships join its readset, including empty collections
Arbitrary loadModel in a helperAuthoritative load and applicable handler/cache boundary; does not itself register a new dependency: inject the Model or read its Graph
Search / loadCurrentModelStateSearch/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.

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.

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.

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.

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