Temporal Graphs
A Graph<T> combines a Model with relationships at one read boundary. It lets you answer “what did this task and
its project look like when the change happened?” without mixing today’s Project with yesterday’s Task.
Use this for activity feeds, audit explanations, before/after notifications, and investigating business decisions. A historical graph is a read of retained history; it does not roll the application back or undo external effects.
Try React to Model transitions with Graph history for a small example comparing two observations.
Navigate through related Models
Section titled “Navigate through related Models”Suppose Task has a typed TaskId and a @Parent(pathInParent = "tasks") ProjectId projectId.
Both Task and Project use event-sourced @Model persistence.
Graph<Project> project = Fluxzero.loadGraph(projectId);for (Graph<Task> task : project.children(Task.class)) { Task value = task.get(); Project owner = task.ancestorModel(Project.class).orElse(null); // Render a task together with its project at this graph's boundary.}val project: Graph<Project> = Fluxzero.loadGraph(projectId)for (task in project.children(Task::class.java)) { val value: Task? = task.get() val owner: Project? = task.ancestorModel(Project::class.java).orElse(null) // Render a task together with its project at this graph's boundary.}Each returned child is another Graph, with its own identity, value and navigation. Use children(...) for immediate
children, descendants(...) for deeper traversal, and parent() or typed ancestor methods to walk toward a root.
Use childModels(...) when only the values are needed. Explicit paths select a particular relationship when several
branches contain the same type.
Navigation is lazy. Constructing a typed Graph doesn’t load the whole tree. State is read when you request values or relationships, and the selected boundary stays fixed. Reading more children later does not silently include commits that happened after that boundary. Relationship placement is independent of storing a child list in the parent.
Several parents in one relation
Section titled “Several parents in one relation”Use a collection when one relation can refer to several parents. Each distinct ID creates an ordinary Graph edge
with the same path and policies. Null or empty collections create no edges; null elements, raw or nested collections,
arrays and maps are rejected. List order does not define Graph child ordering. Typed IDs supply the element’s Model
type; untyped IDs can use @Parent(ParentModel.class), and polymorphic ID elements can use types.
@Modelpublic record SharedDocument( @EntityId DocumentId documentId, @Parent(pathInParent = "documents", deleteOnParentDeletion = false) Set<ProjectId> projectIds) {}Adding or removing IDs changes those edges atomically. Graph reads retain the usual relationship conflict checks. A singular parent lookup must still be unambiguous; use plural navigation to inspect several parents. With cascade enabled, deleting any owning parent deletes the child, even if other parents remain. Parent-scoped identity still requires one unambiguous scope. Schedules and cascading assertions use every declared parent ID as well.
Inspect the change that triggered a handler
Section titled “Inspect the change that triggered a handler”An event carrying a Model-commit boundary can inject the exact affected Model as a Graph:
@HandleEventvoid on(CompleteTask event, Graph<Task> task) { Task after = task.get(); Task before = task.previousValue(t -> t); boolean completionChanged = task.hasChanged(Task::completed); Project projectThen = task.ancestorModel(Project.class).orElse(null); // Use before/after to explain this completion, even during catch-up.}@HandleEventfun on(event: CompleteTask, task: Graph<Task>) { val after = task.get() val before = task.previousValue { it } val completionChanged = task.hasChanged { it.completed } val projectThen = task.ancestorModel(Project::class.java).orElse(null) // Use before/after to explain this completion, even during catch-up.}previousValue(selector) reads a property from the preceding value, or returns null when no value exists.
hasChanged(selector) compares that property, treating an absent before or after value as null.
A deleted Model can still arrive as an empty Graph<T>; a required bare T parameter would not match its absence.
A handler that explicitly names CompleteTask only receives that payload type. To observe every published change in
a Project and its descendants, use one unqualified Graph parameter, optionally accompanied by Message and
Metadata in any order:
@HandleEventvoid projectChanged(Graph<Project> project) { Graph<Project> before = project.previous(); List<Task> oldTasks = before == null ? List.of() : before.childModels("tasks", Task.class); List<Task> newTasks = project.childModels("tasks", Task.class); // Compare by taskId: new IDs were added, missing IDs removed, // and unequal values with the same ID were changed.}@HandleEventfun projectChanged(project: Graph<Project>) { val oldTasks = project.previous()?.childModels("tasks", Task::class.java) ?: emptyList() val newTasks = project.childModels("tasks", Task::class.java) // Compare by taskId: new IDs were added, missing IDs removed, // and unequal values with the same ID were changed.}Here previous() represents the graph immediately before the triggering change, including when only a descendant
changed. This is what makes an activity view possible without maintaining a list of every task command.
| Transition | Current graph | Previous graph |
|---|---|---|
| Create root | Root exists | null |
| Add child | Includes child | Does not include child |
| Update child | Child has new value | Child has preceding value |
| Move child | Old root loses child; new root gains it | Each root retains its own before view |
| Logically delete root | Empty root | Deleted root and its pre-deletion graph |
Each affected root is delivered once per committed substep. One commit can contain several substeps; they must not be flattened into one final state when explaining individual events. Normal retries can deliver a handler invocation again, so external notifications still need their own retry/idempotency design.
Logical deletion cascades follow the configured parent ownership. A cascaded child’s own before view precedes that child’s deletion substep; an ancestor deleted earlier in the same commit is already absent there. Physical erasure and suppressed event publication do not create domain notifications.
Walk Model revisions or choose a boundary
Section titled “Walk Model revisions or choose a boundary”For a Model’s own revision history, walk backward with previous() or stream with revisions():
Graph<Task> task = Fluxzero.loadGraph(taskId);task.revisions().limit(20).forEach(revision -> { Task value = revision.get(); long sequence = revision.sequenceNumber(); // Display a bounded page of this Task's revisions, newest first.});
Graph<Task> before = task.previous();Graph<Task> selected = task.atStateIndex(savedStateIndex);val task: Graph<Task> = Fluxzero.loadGraph(taskId)task.revisions().limit(20).forEach { revision -> val value = revision.get() val sequence = revision.sequenceNumber() // Display a bounded page of this Task's revisions, newest first.}
val before = task.previous()val selected = task.atStateIndex(savedStateIndex)previous() returns null at the creation revision. revisions() walks through available history from the current revision back toward creation,
newest first; it excludes the empty state before creation. A model that has never existed has an empty revision stream.
For an event-sourced model with retained history, creation followed by two stored revisions yields three entries,
independently of caching or snapshots. Stored revisions can also change SDK-owned metadata without changing the
Model value: cancelling a child deadline after a parent change is one example. Use the Graph sequence number or
revision state index to identify revisions, rather than an application-owned version property. Non-empty provisional views remain included before they are committed.
Deletion is a real revision whose get() returns null, and remains in the history. Do not filter all null values
when you need to show deletions. Loading a missing model still gives an empty Graph for identity and creation;
that empty view does not imply an existing revision. The legacy Entity.previous() contract is unchanged.
A business history can select the changes it cares about with hasChanged:
var history = task.revisions() .filter(revision -> revision.hasChanged(Task::version)) .limit(20) .toList();val history = task.revisions() .filter { revision -> revision.hasChanged { it.version } } .limit(20) .toList()Here Task.version must change for every business transition you want to show. The complete revision stream still
retains deadline metadata, and selecting adjacent changes preserves later returns to an earlier value.
Filtering before limit bounds returned rows, not the amount of history scanned; use an indexed projection for
large interactive histories.
Limit traversal for an interactive screen; a long history can require replay and I/O.
A root’s revision stream does not enumerate every descendant change. Adding Task B need not create another
Project value revision. The complete Graph-change event handler above supplies a special before boundary for the whole affected
graph. For arbitrary recorded points, atStateIndex(savedStateIndex) reconstructs values and relationships at the
requested durable boundary, without overlaying pending current writes.
stateIndex() identifies the graph’s read boundary; revisionStateIndex() identifies when that node’s revision
became effective. sequenceNumber() counts that Model’s revisions. These are different coordinates: do not use a
Model sequence number as a global state index, or assume a wall-clock timestamp identifies a unique commit.
Use playBackToCondition(...) or playBackToEvent(...) when selecting a retained revision by a condition or an event.
These return an Optional, because a matching revision may not be available.
Ask for current intent explicitly
Section titled “Ask for current intent explicitly”An old event may explain a task’s completion correctly while being unsuitable for deciding whether to send a reminder now. The task might since have been reopened, cancelled or assigned to someone else.
Graph<Task> eventView = task;Graph<Task> now = eventView.current();Task currentTask = now.get();val eventView = taskval now = eventView.current()val currentTask = now.get()Outside a mutation, current() opens a new current boundary for the same identity and repository. It leaves the
original Graph intact and does not make either view live. Fluxzero.loadCurrentGraph(taskId) serves the same deliberate
current-intent use case. Reapply any response filters or context to the new view.
Inside a Model mutation, current reads join that attempt’s boundary and staged changes. They do not open an unrelated snapshot halfway through a business decision. Use injected Models/Graphs and their tracked dependencies for invariants; an arbitrary search result or detached historical Graph is not a transaction readset.
Historical Graphs and strict reads
Section titled “Historical Graphs and strict reads”EVENT_SOURCED reconstructs historical values. DOCUMENT alone stores the current value. When an old document
version is no longer available, a historical Graph uses the current value of the same persisted ID instead.
If it has been deleted, get() returns null; if it has been recreated, the fallback may return that new value.
A node proven absent or deleted at the historical boundary stays absent. Relationships stay historical:
a moved child remains under its historical parent, and children added later do not enter the Graph.
This is the normal behavior, independent of fluxzero.defaults.version. Set fluxzero.model.graph.strict=true
(FLUXZERO_MODEL_GRAPH_STRICT) to require exact historical values application-wide. Its default is false.
Strict reads fail when a required historical document revision is unavailable.
The SDK and Runtime can be upgraded independently. Existing current reads, writes and reconstructible histories
continue to work with a compatible older Runtime; no strict-property override is required just to upgrade the SDK.
The new fallback needs Runtime support for historical head-only metadata reads. Until the Runtime supports those
reads, historical DOCUMENT-only reads that it previously rejected can still fail, including on a non-strict view.
The SDK does not replace missing historical presence or relationships with current metadata. Once the Runtime is
updated, the fallback works without application configuration changes. On an older Runtime, previous() keeps
using a retained predecessor, or returns null when none is retained, if the historical head is unavailable.
No storage migration is required.
Choose a view in Java:
Graph<Project> strict = graph.strict(true);Graph<Project> ordinary = strict.strict(false);boolean exactHistory = strict.isStrict(); // trueKotlin:
val strict = graph.strict(true)val ordinary = strict.strict(false)val exactHistory = strict.isStrict // truestrict(boolean) leaves the source view unchanged. isStrict() reads its configured policy without loading state.
strict(false) restores ordinary reads even when the application property requests strict reads.
The choice follows parents, children and previous(), including Graphs retained after an event or notification
handler finishes. atStateIndex(...) on a lenient view resolves values lazily. A fallback value is retained after
its first read in that view; later reads of the same view do not refresh it. Different nodes may observe different
current instants, so this does not promise an atomic current snapshot. stateIndex() and revisionStateIndex()
continue to describe the historical selection, not the age of a fallback value. Current scalar fields such as a
child’s parent ID may therefore differ from its historical Graph placement.
Assertions, applies and replay remain strict, even when they receive a previously read fallback Graph. Strictness
is a read policy, not durable document history or a way around conflict checking. Event-sourced replay failures,
unknown contracts, unavailable keys and physical-erasure failures remain errors. Keep EVENT_SOURCED when exact
historical values are required. Use current() when both current values and current relationships are intended.
Retain the history you intend to inspect
Section titled “Retain the history you intend to inspect”Every node whose exact historical value you require needs reconstructible event-sourced history. Plain @Model supplies
that. Adding DOCUMENT alongside EVENT_SOURCED keeps it; choosing only DOCUMENT retains current state rather than
durable document versions. A larger cache cannot supply missing historical data after restart.
Logical deletion retains history. Physical erasure, missing payload types, unavailable encryption keys or pruned source data can make a historical view unavailable. Decide retention and access rules for an audit feature before promising users that any past date can be reconstructed.
Materialized Graph documents are read optimizations. Document tracking may coalesce superseded versions, so it is appropriate for reconciling a current view. It is not a substitute for the event stream when every individual transition matters.
Test history with cold reconstruction as well as a warm fixture: create a graph, add and change children, move one,
and logically delete one. Check both values and membership before and after each boundary. Include a move between
two roots and the first revision’s null previous value. The schema-evolution tests
show how to exercise a fresh reader over retained data.
© 2026 Fluxzero