Skip to content

Model persistence

Fluxzero event-sources Models by default. The stored update sequence reconstructs current state and supports historical reads. DOCUMENT persistence stores current Model state without retaining historical document revisions.

Persistence configuration does not determine the Model boundary.

Event sourcing is the default:

@Model
record Account(@EntityId AccountId accountId, Money balance) {
}

Each applied update is stored in this account’s model stream. Loading reconstructs the account by replaying its @Apply methods, optionally starting at a snapshot or checkpoint. Keep event sourcing when the application needs exact previous() values or other historical Graph reads. DOCUMENT alone keeps current state; caches and snapshots do not replace retained event history.

Historical Graph views normally use current DOCUMENT-only values when a historical revision is unavailable. Use graph.strict(true) or fluxzero.model.graph.strict=true for exact history; see Temporal Graphs for fallback behavior.

Select DOCUMENT to load state from its internal current document. Search remains an independent choice:

@Model(persistence = ModelPersistence.DOCUMENT)
record UserPreferences(@EntityId UserId userId, Preferences preferences) {
}

The document is maintained synchronously with the Model commit. It supports authoritative identity reads without making the value searchable. persistence controls state storage and loading, not event publication: applied updates are still stored and published by default. DOCUMENT alone does not retain previous document versions.

Set searchable = true when the node should be searchable. By default that also activates the composed descendants through their typed @Parent contracts. Configure searchSettings = @SearchSettings(includeDescendants = false) for only the node itself. A child’s searchable = false means it makes no independent request; it can still participate in a searchable ancestor’s composed scope. Per-node SearchSettings configure the canonical collection and time fields even when activation is inherited. Collection defaults remain internal, type-isolated names.

Searchability is not authorization. Enforce access in query and command handlers; see Model state and protection boundaries.

Use Fluxzero.loadCurrentModelState("account-id", Account.class) (or its typed-ID overload) when a consumer shares the Model state contract but not the writer’s historical event contracts. The corresponding repository method is loadCurrentState. This is an explicit document read, not a fallback after replay fails.

The returned ModelState<T> contains the value, exact persisted ID, durable Model head and the namespace-wide boundary at which that head was verified. It checks the current document against the durable head without loading events, updating the ordinary Model cache, or inheriting an event handler’s historical boundary. The writer must maintain a canonical Model document through DOCUMENT or effective searchability, and the consumer must share its document configuration and value schema (including nested value types and upcasters).

Verified reads require a matching Runtime. Trusted Model document materialization/adoption records a SHA-256 fingerprint of the complete durable head and serialized state atomically with the document fence. This detects an ordinary search write that replaces the body without advancing the Model version. Ordinary reads do not compute this fingerprint; maintaining a canonical Model source does. Older separate public Model projections do not own that proof. Existing documents with no proof are not retroactively certified: they need a new trusted Model materialization/adoption at an eligible boundary. An old Runtime response, an unproven fence, or mismatched body/version fails explicitly; there is no weaker verification fallback.

A missing Model has a null head/value; a deleted Model has a deleted head and null value. A missing, unversioned or lagging document for a live Model fails explicitly. If the document advances between the two reads, verification retries at most eight times; it never silently accepts stale state or replays events. This is current as observed during the operation, not a promise that concurrent writers cannot change it immediately afterwards.

This first-class result is deliberately read-only: it has no Graph mutation/history methods and does not join a commit readset. Use injected Models/Graphs for transactional assertions; separate state reads are not a coherent multi-model snapshot. Supporting document-based Graph mutations would additionally require retaining the read strategy through the complete evaluation and retries. Ordinary loadModel, loadGraph and loadCurrentGraph retain their existing authoritative/replay contracts; sharing only Model classes does not guarantee replay compatibility.

The set supports exactly the meaningful combinations:

  • {EVENT_SOURCED} reconstructs from events. Effective searchability additionally maintains one indexed node document.
  • {DOCUMENT} loads from one internal current document. That document has no search indexes unless searchability is enabled.
  • {EVENT_SOURCED, DOCUMENT} reconstructs from events and also keeps the internal current document.

Searchability never creates a separate editable public Model projection. Node queries, relationship-content predicates and Graph composition use the same canonical node representation. A stored complete Graph is an additional optional projection.

An empty set or duplicate representation is rejected during Model discovery.

By default an event-sourced Model fails when its stream contains an event for which no applicable @Apply exists. Set ignoreUnknownEvents = true only when skipping such historical events is an explicit compatibility decision; it changes reconstructed state and is not a substitute for upcasting or retaining old handlers.

Independent models prevent unrelated histories from accumulating in one enormous stream. An order line, payment attempt or short-lived process can keep its own small history while still participating in one multi-model commit.

Selective Graph navigation across applications

Section titled “Selective Graph navigation across applications”

Use children(path), children(path, modelName), or namedChildren(modelName) for metadata-first selection returning List<Graph<?>>. The corresponding descendants/namedDescendants methods traverse deeper placements. Each accepts a final boolean knownOnly, defaulting to true: this selects only locally known Model types, not necessarily every stored child. Pass false when a quota or inventory must include unknown types:

var reservations = graph.namedChildren("stock-reservation", false);
int count = reservations.size(); // No child values or historical events are loaded.

Names are exact resolved logical Model names, including any configured prefix, not serializer aliases or Java supertypes. The same name in two applications denotes the same shared contract, not separate ownership. Counting remains metadata-only even when that name is known locally but historical event classes are unavailable. modelName() exposes the logical name; knownType() is empty for a locally unknown Model. Such nodes retain IDs and relationship navigation, but reading their type/value/history or updating them fails explicitly. Unknown does not mean absent or deleted. A known class likewise does not suppress replay or application errors.

Class-based selection matches locally known assignable types only, including when querying Object.class. Unknown intermediate nodes do not hide known descendants. Direct child paths are exact (null means pathless); descendant paths are root-relative slash-separated paths (null means all paths). Counts count placements: a shared Model reached through different graph paths can occur more than once.

Selections share a pinned boundary and preserve mutation Graph relationship dependencies, including empty results, add/remove/reparent and retries. Values are reconstructed only when requested at that boundary; no automatic current-state fallback or unknown-event skipping occurs. Full Graph materialization/serialization still requires the contracts for the values being materialized. Custom repositories without metadata navigation retain their existing loading behavior and cannot promise unknown-type-safe selection.

The default repository resolves lazy root aliases through head metadata without replay. Initial lookup uses the current alias table, also when reading historical Model state. The selected canonical ID (or absence) then stays fixed together with the value/relationship boundary: reassigning the alias cannot redirect an existing Graph. Inside a Model mutation, consuming an alias lookup also records a commit dependency, including a missing alias or an identity-/relationship-only read. Alias assignment, removal, reassignment and canonical-ID shadowing are validated atomically; exact-ID reads do not depend on aliases. No historical alias table is implied: the initial lookup still uses the current mapping. RETRY reevaluates a successful stale decision; FAIL rejects its commit. Remote alias navigation requires the accompanying Runtime update: alias heads use the existing general transport to preserve both requested and canonical IDs; non-alias compact replies remain unchanged.

With metadata-capable repositories, selectPaths(...) selects before reconstruction: creating a view performs no storage reads. Its paths are relative to the selected Graph, including a child Graph, and its ancestor placements remain navigable. Custom repositories retain their existing value-based fallback. Exact-ID find(...) scans relationship metadata before inspecting aliases; typed lookup registers the requested local contract and excludes unrelated unknown types. Alias and parent-scoped functional-ID matching may still require values of matching types. sequenceNumber() and revisionStateIndex() use pinned head evidence for still-lazy persisted nodes; pending and custom revisions retain their own semantics.

The default repository discovers roots for loadCurrentGraph(...) and untyped Fluxzero.loadGraph(id) from head metadata without replay. These factories pin their boundary during the call; later value and relationship reads retain that boundary. Untyped root discovery still requires a locally known root Model contract. This changes when values are reconstructed, not their authoritative persistence/replay contract or transaction scope.

Outside a mutation, loadCurrentGraph establishes a fresh storage boundary even when the root is cached; an older root observation cannot prove unchanged relationships. Event-sourced root values remain lazy. For a DOCUMENT-only root, the factory resolves its head and document coherently before returning and retains that value: subsequent replacement or deletion cannot change the returned root. Relationships and descendants remain lazy; this does not create historical document versions. Within a mutation it instead reuses the attempt boundary and joins its readset. An unpinned current value read retries the complete lookup a bounded number of times if its head and document change during resolution. Historical, already pinned and mutation-shared reads never silently advance. Persistent instability fails with an explicit platform error.

eventPublication determines whether an apply produces an event:

  • ALWAYS stores/publishes the applied update;
  • IF_MODIFIED suppresses it when the resulting model did not change;
  • NEVER stores no model event but can still commit a current document;
  • DEFAULT inherits the next broader setting.

publicationStrategy controls where a produced event goes:

  • STORE_AND_PUBLISH links it to the model stream and publishes it once globally;
  • STORE_ONLY links it to model streams without global publication;
  • PUBLISH_ONLY publishes it without adding a model-stream membership;
  • DEFAULT inherits configuration.

An event-sourced model may only change through STORE_AND_PUBLISH or STORE_ONLY, because its next load must be able to reconstruct that state. Fluxzero rejects create, update and logical-delete transitions configured as PUBLISH_ONLY or eventPublication = NEVER before commit. A publication-only no-op remains valid, and a document-based model can still change without storing the event because its current document is the load source.

For an event targeting several models, each stream receives a lightweight membership. The full payload remains inline for ordinary small target sets; large multi-target payloads may be stored once and shared. Hard deletion removes only the selected stream membership, and the shared payload is deleted only after no surviving membership refers to it. The global event log is independent.

A singular Model-returning @Apply may update an injected parent or further ancestor through the existing @Parent relation. A directly supplied write-target ID keeps precedence; when none exists, the selected ancestor supplies the write identity without duplicating its ID in the command. Merely injecting an ancestor does not update it. Selection uses the pinned state before applying the changes, so a command may delete a child and update its parent atomically. Ambiguous ancestors must be qualified with @Association("parentPath"); replay retains the corresponding dependencies.

The SDK evaluates one complete model commit and submits:

  • the original applied events;
  • target model memberships;
  • expected/read state boundary;
  • resulting direct documents when required;
  • snapshots or checkpoints when due;
  • relation additions and closures;
  • conflict and graph-projection completion policies.

The runtime assigns monotone, time-derived stateIndex values and commits the co-located event store, model streams, heads, relations, commit result and projection signals atomically in its JDBC transaction.

The runtime completes direct documents and snapshots before it releases a successful model-commit result. When event and search storage use separate runtime-managed databases, the core action retains the exact serialized materialization until the fenced search write succeeds. A retry or the runtime’s restart worker reapplies that original package; application code is never re-evaluated to repair an already committed action. Materialized whole-root graph projections may continue asynchronously unless AWAIT was selected.

commitPolicy controls when the automatic atomic commit starts and when handler or batch completion awaits it. The default starts after Model apply handlers and awaits all started commits at batch completion. Keep DEFAULT unless an application has a measured ordering, latency or concurrency reason to choose another ModelCommitPolicy; the policy does not split one multi-Model action into separate commits.

Use searchable for a searchable current representation while retaining the chosen persistence policy:

@Model(searchable = true, searchSettings = @SearchSettings(collection = "line-items"))
record LineItem(
@EntityId LineItemId lineItemId,
@Parent(pathInParent = "lines") OrderId orderId,
ProductId productId) {
}

This Model remains event-sourced. Searchability maintains a canonical indexed node document; it does not require adding DOCUMENT persistence. For DOCUMENT-only Models, the same canonical document is also authoritative state. Model commits own that representation. Use normal Model operations for business changes and guarded document-schema migration for upcasts. Build an independent read model when an independently editable representation is needed.

search(LineItem.class) returns node values and includes no child objects. It can restrict those values by typed parent or ancestor ID even when the ancestor has no search document. Filtering by ancestor content requires an indexed ancestor source. The same canonical node data is used by related-content predicates and Graph composition.

searchGraph(LineItem.class) returns the node and its composed descendants, subject to includeDescendants. With the default GraphProjectionMode.NONE, the query stitches those indexed documents on demand. Select graphProjection = @GraphProjection(mode = GraphProjectionMode.ASYNC) to maintain an additional complete Graph in advance, or AWAIT when commits must wait for that projection. An application-wide wait setting does not activate materialization on a NONE root. Searchability must already be enabled independently.

pathInParent alone never creates a document. An event-sourced, non-searchable Model needs no component document. A DOCUMENT Model with no effective searchability keeps only its internal, unindexed current state. See Choosing Model and Graph queries for query semantics and performance boundaries.

Models use a shared current-state cache and an action-local cache:

  • a current local hit requires no head-validation round trip;
  • one namespace-level long poll tracks durable model updates;
  • remote updates fence or refresh only affected cached IDs;
  • document-based models are refreshed from their direct document;
  • event-sourced models reuse a cached replay base and load only the missing suffix;
  • hard deletion evicts the ID without retaining private tombstone state in the SDK cache.

Use FluxzeroBuilder.withModelCache(cache) for a dedicated model cache, or FluxzeroBuilder.disableAutomaticModelCaching() to disable both shared model caching and its update tracker.

The default cachingDepth keeps a previous revision cheaply available. It is a performance setting, not the source or retention limit of historical truth: EVENT_SOURCED can reconstruct earlier values after cache eviction. Increase the depth only when measured historical reads warrant it. DOCUMENT alone has no durable previous versions; do not make previous()-based business logic depend on a coincidentally warm cache.

Disable shared caching with cached = false; action-local consistency remains available while evaluating one model action.

Only introduce periodic snapshots when a Model’s measured replay cost/history size warrants them. They are disabled by default (snapshotPeriod = 0) and do not enable search. For an account with a long, frequently replayed history, the following is an illustrative tuning choice, not a standard Model configuration:

@Model(
snapshotPeriod = 1000,
maxSnapshotCount = 2,
checkpointPeriod = 100
)
record Account(@EntityId AccountId accountId, Money balance) {
}
  • snapshotPeriod writes a durable snapshot after the configured number of model events;
  • maxSnapshotCount sets the cleanup target: positive values target that many, 0 keeps one, and any negative value (for example -1) retains all periodic snapshots;
  • checkpointPeriod keeps intermediate replay checkpoints where configured;
  • stale snapshot or cache writes can never overwrite a higher stateIndex.

For unlimited retention, use @Model(snapshotPeriod = 100, maxSnapshotCount = -1) in Java or Kotlin. Upgrade the Runtime before enabling a negative count: older Runtime versions reject it. The default remains one snapshot, and a non-positive snapshotPeriod still disables periodic snapshots. Unlimited retention preserves future snapshots; it cannot restore snapshots already removed. Physical Model erasure still deletes snapshots. Storage grows with the number and size of snapshots, so choose the snapshot period deliberately.

Snapshots are an optimization. The model stream remains authoritative for event-sourced loading unless publication was explicitly configured to omit required history. Fluxzero does not retain a full direct document in every Model commit: the direct search collection contains the current document, while maxSnapshotCount controls snapshot cleanup for that Model. The event payloads remain the long-term history.

The retention count is not a commit-time invariant: overlapping Runtime writers may keep extra snapshots. A later snapshot write with a bounded count also trims that previous excess; an idle Model may retain it until then. No background cleanup deadline is promised. Snapshot insertion still completes before successful commit acknowledgement. This contract is the same in Java and Kotlin; applications must not rely on an exact concurrent snapshot count.

Loading an old Model with billions of unrelated namespace commits does not scan the namespace log. The Runtime selects the Model’s own stream and the SDK starts from its newest applicable snapshot or checkpoint, then replays only that Model’s remaining suffix. That suffix is transferred in membership- and byte-bounded pages; while the SDK applies one page it may fetch exactly one following transport page, without delivering events out of order or retaining an unbounded read-ahead queue. Choose snapshot periods from the expected per-Model history and state size; namespace age by itself does not increase replay work. Long histories for one individual Model still deserve an explicit snapshot and retention policy.

Put a shared rule such as “a closed project cannot change” on the Project itself. Opt in explicitly with @AssertLegal(cascade = true); ordinary assertions do not propagate automatically.

@Model
public record Project(@EntityId ProjectId projectId, boolean closed) {
@AssertLegal(cascade = true)
void assertOpen() {
if (closed) {
throw new IllegalCommandException("This project is closed.");
}
}
}
@Model
public record Task(@EntityId TaskId taskId, @Parent ProjectId projectId, boolean completed) {}
public record CompleteTask(TaskId taskId) {
@Apply
Task apply(Task task) {
return new Task(task.taskId(), task.projectId(), true);
}
}

ProjectId extends Id<Project> and TaskId extends Id<Task> provide the relation types. Updating a Task now checks its Project without repeating the rule on CompleteTask. A separate LineItem with @Parent TaskId taskId inherits that check too. The Project’s own changes are checked as well: closing an open Project succeeds, changing it again fails. Use a functional exception for a business refusal and keep its customer-facing message free of IDs.

A no-arg method covers all effective mutation payloads. Add a typed payload parameter to select that payload family, or use allowedClasses when the method does not need the payload. Classes and interfaces match assignable types:

@AssertLegal(cascade = true, allowedClasses = ProjectChange.class)
void assertChangeAllowed(User user) {
// ProjectChange is an application-defined interface implemented by the guarded commands.
// Perform the same domain/authorization check for this Project and its descendants.
}

Normal User, Message, Metadata, Model/Graph and custom parameter injection remains available. allowedClasses and typed payload selection both apply; neither a User-only nor a no-arg signature requires a dummy payload parameter. The filter applies to the Model’s own changes too. Choose an unrestricted rule for a general lock; a narrow payload filter deliberately leaves other actions outside that rule. Interceptors select the effective payload: suppressed updates run no checks, replacements/splits are checked using their resulting payloads. Automatic child deletion uses the effective command that caused the deletion for assertion selection and injection.

For a relation that must not inherit the parent’s rules, declare @Parent(validateAncestors = false) ProjectId projectId. This stops that route for the child and its entire subtree. Local checks and checks from other enabled parent routes still apply. Deletion ownership and search propagation are independent settings; validateAncestors is SDK policy and is not persisted on the relationship.

For one particular mutation, use an Apply override:

public record CorrectTaskStatus(TaskId taskId, boolean completed) {
@Apply(ancestorValidation = AncestorValidation.DISABLED)
Task apply(Task task) {
return new Task(task.taskId(), task.projectId(), completed);
}
}

This exempts only that mutation from inherited cascading assertions. Payload assertions, the Task’s own assertions, and other mutations in the same commit remain active. DEFAULT inherits an earlier Apply override for the same result, or enables validation when none exists. ENABLED replaces an earlier DISABLED during payload-then-Model Apply composition; it cannot reopen a validateAncestors = false relationship. An exception to Project’s own rule belongs in its rule/payload selection, not in this inherited-validation override.

Checks use the state before each effective update, including earlier updates in the same atomic operation. afterHandler = true uses the complete final state instead. For result-bound creation, moves and multi-Model applies, new routes and the final Apply policy can only be known after the pure apply returns; their immediate checks still use the logical before-state. Applies and assertions must remain deterministic and free of external effects. Fluxzero.assertLegal checks immediate rules conservatively for its resolved targets. It does not execute Apply methods or apply their exemptions; result-dependent routes/policies require assertAndApply or ordinary command handling. Event replay does not run assertions.

When initially resolved targets or known result-bound write candidates have matching cascading rules, the operation opens a storage-current read boundary before loading its initial state. A cached child’s cursor alone cannot prove that its parent is current. Checks inside an existing operation retain its pinned boundary and staged changes; targets discovered later also join that boundary. A standalone assertLegal remains a check, not a reservation.

Every enabled, locally known parent route participates, through all descendant levels. Shared ancestors are checked once per effective update/phase. A move checks both the old and the new route; deleting or detaching a child cannot escape its old parent’s rule. Direct Graph mutations and automatic deletions participate too. A missing parent value has no instance rule to execute and stops that route; this feature does not itself require parents to exist.

Read ancestors join the commit’s pinned read boundary and conflict dependencies, including absence. RETRY reloads and reruns the rules; FAIL rejects a conflict. When cascading validation participates, configured ACCEPT becomes FAIL for that attempt so a concurrent close cannot silently retain stale permission. Choose RETRY when automatic reevaluation is desired. Unrelated commits retain their configured policy.

This is SDK-side domain logic. It needs the relevant Model classes and the entire parent route in the executing application; it cannot run assertions from unavailable code or protect mutations made by an older/unaware writer. Deploy the rule to every application that writes the protected scope. Code/annotation changes take effect in that writer immediately without rewriting stored relationships. No additional Runtime feature or wire field is required; the existing Model protocol and its atomic conflict validation remain prerequisites.

With no reachable cascading rules the SDK keeps the ordinary mutation path and adds no ancestor loads or relationship queries. Matching rules follow relevant parent routes upwards, reuse loaded values and deduplicate shared ancestors; they never scan all descendants to validate one child change. Payload filters and disabled routes are checked before loading additional Models. The cost of active rules includes the required ancestor reads and your assertion code.

Kotlin uses the same annotations and semantics:

@Model
data class Project(@EntityId val projectId: ProjectId, val closed: Boolean) {
@AssertLegal(cascade = true)
fun assertOpen() {
if (closed) throw IllegalCommandException("This project is closed.")
}
}

For a filtered rule use @AssertLegal(cascade = true, allowedClasses = [ProjectChange::class]); for one action’s exception use @Apply(ancestorValidation = AncestorValidation.DISABLED).

Clients may send the model state boundary they evaluated. If one of the relevant models changed before runtime commit:

  • ACCEPT appends the original event and rebases documents, snapshots and relations on the merged current models;
  • FAIL rejects and rolls back the complete action;
  • RETRY rejects, reloads all action dependencies and reruns assertions, interceptors and applies within a bounded retry policy;
  • DEFAULT inherits model, builder or property configuration and otherwise means RETRY, independently of fluxzero.defaults.version. Explicit fluxzero.model.conflictPolicy (FLUXZERO_MODEL_CONFLICT_POLICY) settings override it.

First creations use the same RETRY policy as updates: changed Product state or parent membership triggers a fresh evaluation. Normal factory compatibility still rejects an occupied target after retry; use a nullable existing-Model apply only when an upsert is intended. An explicitly staged Graph update that started from absence cannot overwrite a concurrent creation. Explicit ACCEPT still fails first-creation conflicts to prevent rebasing into an overwrite. ACCEPT only validates dependencies read by applies and written targets, since it preserves the original event and skips assertions and interceptors during rebase. RETRY and FAIL retain the full evaluation readset. Conflict-free eligible commits use the same cached-head planning and atomic namespace-boundary check regardless of policy; a failed boundary check falls back to current-head validation under the state lock.

Before reevaluating a rejected commit with DOCUMENT-only dependencies, RETRY waits for pending authoritative document materialization. If a newer document has already replaced the requested version, preparation may advance to a consistent boundary before rerunning the action. This does not move an already evaluated or historical snapshot, and it does not retry a business assertion that rejects the newly loaded state.

Apply-ancestor dependencies include the relationship collections that determined their selection, including intermediate ancestors and the absence of a nullable ancestor. A concurrent reparenting therefore still triggers rebase even if the old ancestor’s own value is unchanged; assertions are not rerun to reload these roots.

An injected Graph contributes its inspected Model values and relationship collections, not every eagerly loaded node. children("children", Child.class) protects that membership even when empty: another writer cannot bypass a capacity assertion merely by creating a different child ID. Filters and scans protect examined candidates, including rejected values and negative results. Cached transformations and joined parallel scans retain their evidence; work escaping the synchronous evaluation does not. Explicit history, external search and unrelated repository reads are not live dependencies.

LocalClient and JDBC validate those reads atomically with the write. RETRY reruns evaluation at a fresh pinned snapshot; FAIL rejects. ACCEPT excludes assertion-/interceptor-only reads and preserves apply dependencies through repeated rebases. Path-scoped reads conservatively include every child type at that path; remapped paths include all paths of their source. Physical erasure invalidates older Graph reads namespace-wide because it removes historical evidence. Physical cleanup advances the namespace atomically, also protecting reads made after erasure preparation. Stale erased-value reads conflict, while fresh reads of their absence remain valid. Eligible reads at the exact cached boundary or across known contiguous head-only writes by that Runtime retain JDBC’s atomic-CAS route; other older reads require database validation. Unused or value-only Graph injection resolved directly by ID adds no membership proof or query. Indirect ancestor injection still protects the relationships traversed to select that ancestor. Head-only writes remain batchable, while relationship writers separate dependent readers into ordered waves. New child memberships use existing indexes; only closed relationships require extra change-index entries. Removing or reparenting still retains evidence when the writer does not inject a Graph, since another transaction may have read the affected collection.

Custom repositories must construct SDK Graph views (for example with Graphs.compose) for transactional navigation. Opaque custom Graph implementations fail explicitly; ordinary non-transactional custom reads remain supported.

Relationship-aware commits require the corresponding Runtime protocol support. Graph materialization is not required to protect relationship reads.

The default conflict policy protects concurrent Model operations. Choose an explicit override only when the application needs different conflict behavior.

Returning null from @Apply logically deletes a model. Its current document and relations close, but its event history remains and the same ID may later be recreated.

Logical parent deletion recursively deletes children whose relevant @Parent keeps the default deleteOnParentDeletion = true, including pathless and shared-DAG descendants. Moving a child away or recreating the parent in the same atomic commit prevents that lifecycle cascade.

Use Parent shorthands for common relationship rules

Section titled “Use Parent shorthands for common relationship rules”

When a relationship must have an existing parent, a bounded number of children, or a parent that cannot disappear while children remain, declare that directly on the child’s @Parent:

@Model
record LineItem(
@EntityId LineItemId lineItemId,
@Parent(required = true, maxChildren = 100, preventParentDeletion = true)
OrderId orderId,
ProductId productId) {}

required rejects an empty reference or a parent that does not exist in the final commit state; parent and child may still be created together. maxChildren counts distinct children of the same logical Model type and composition path for one parent. The default -1 is unlimited, 0 rejects growth, and values below -1 are invalid. A scope that was already over its new limit may stay unchanged or shrink, but may not grow further.

preventParentDeletion rejects logical parent deletion while a matching child survives. It disables cascade on that edge even when deleteOnParentDeletion remains true; explicitly moving or deleting the child in the same commit can release the parent. Detaching does too when the final reference still satisfies required. A surviving required child also blocks parent deletion when cascade is disabled. Physical erasure remains a separate administrative operation.

These settings are SDK shorthands for ordinary Model and Graph checks. Their parent and membership reads use the same commit readset as application-written assertions: concurrent relationship changes conflict, and RETRY evaluates the rule again at a fresh boundary. They add no Runtime-specific constraint policy or stored relationship fields. Every writer that must uphold the rule must therefore use an SDK version containing the declaration and include that Model contract in its generated Model index. Use an explicit assertion instead when the rule needs a custom error, combines other state, or has semantics beyond these three checks.

Failures are IllegalCommandExceptions. ModelErrors.parentRequired, parentNotFound, maxChildrenExceeded, and parentDeletionBlocked expose the SDK’s exact messages for focused TestFixture assertions.

Explicit hard deletion removes the selected model stream, heads, snapshots, cache and direct document. This is a separate destructive operation: a physical descendant cascade must first be planned and confirmed. Detached descendants remain discoverable through former-parent lineage for lifecycle and GDPR cleanup.

Published audit events are not erased implicitly. Apply the application’s event-log retention and data-protection policies separately.

For actionable read failures and a bounded local catalog comparison, see Model contract diagnostics. Inspection does not qualify historical replay.

Enable SDK annotation processing in every module that defines Models (Kotlin: kapt with the SDK processor). The processor generates the Model index used for discovery. Shared contract modules must be compiled with the processor and be available to consuming applications. Discovery does not register their handlers. See Model for the discovery contract and explicit processor configuration.

Separate JARs and Spring Boot nested JARs preserve individual indexes. A classic shaded/fat JAR must merge them, just like other overlapping generated resources. In Maven Shade, add this transformer (ordinary service merging alone is insufficient):

<transformer implementation="org.apache.maven.plugins.shade.resource.AppendingTransformer">
<resource>META-INF/io.fluxzero.sdk.modeling.Model</resource>
</transformer>

Direct model commits are optimized around IDs:

  • target streams and relation adjacency are distributed over Fluxzero’s stable ID segments;
  • JDBC writes are byte-bounded and batched rather than issued once per target;
  • payload sharing is adaptive and used only when physical amplification justifies it;
  • current and historical graph loads batch nodes by storage segment;
  • ordinary direct loads do no relationship work.

Use plain value objects for details owned entirely by one Model. Independent child lifecycles belong in separate Models. Split independently changing or searchable children into their own models.

Use @InterceptApply(assertCurrent = AssertCurrent.ENABLED) to retain legality checks for the input of that interceptor. AssertCurrent is in io.fluxzero.sdk.persisting.eventsourcing. DEFAULT follows fluxzero.interceptApply.assertCurrent (FLUXZERO_INTERCEPT_APPLY_ASSERT_CURRENT): when absent, it is enabled from fluxzero.defaults.version=2026.10.04 and disabled for older or absent defaults versions. An explicit annotation choice wins over the application property, which wins over the defaults version. Use DISABLED, or property false for unconfigured interceptors, when rewriting is deliberately allowed before checking legality. Configuration is resolved for the owning application when its helpers/plans are created.

Current immediate assertions run before the selected interceptor, inside the same commit attempt and against its current state. The input is checked once even when it splits into several outputs or is suppressed. Every replacement keeps its normal checks. A bare unchanged input is not checked twice in the same scope; a new instance or message envelope receives its own checks. In A → B → C, each interceptor controls its own current input, not always A. Payload, Message, metadata, user and custom parameter injection use that input’s context. There is no combined original/replacement parameter. Existing nested legality checks also participate.

All Model afterHandler=true assertions run against the complete final state of the atomic Model operation, including every intercepted output and automatic child deletion. This includes ordinary payload, Model and nested assertions as well as retained current-input and cascading checks. Immediate assertions still run before their own step and see earlier staged changes. Each deferred check retains its own payload, metadata and user context; a refusal rolls back the whole operation. Splitting an action does not introduce intermediate after-handler phases. This boundary covers one Model operation, not separate commands or commits. Aggregate/Entity keeps its existing handler-completion phase. These checks are pure validation and must not perform external effects. Immediate-only Model assertLegal does not run after-handler assertions. Apply-only legacy paths and replay do not start running assertions. Interceptors and assertions must remain free of external side effects.

Model assertion reads participate in the pinned commit and conflict checks. RETRY evaluates them again; an attempt that validates a current input upgrades configured ACCEPT to FAIL, so concurrent changes cannot retain stale permission. Other ACCEPT operations retain their behavior. S285 rules still guard effective Model mutations; an output’s Apply exception does not undo current-input checks. This requires no new Runtime protocol or Runtime upgrade. With current-input checks disabled, no validation history or additional Model reads are retained.


© 2026 Fluxzero