Skip to content

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.

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.
}

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.

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.

@Model
public 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:

@HandleEvent
void 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.
}

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:

@HandleEvent
void 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.
}

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.

TransitionCurrent graphPrevious graph
Create rootRoot existsnull
Add childIncludes childDoes not include child
Update childChild has new valueChild has preceding value
Move childOld root loses child; new root gains itEach root retains its own before view
Logically delete rootEmpty rootDeleted 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.

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

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

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.

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

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.

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(); // true

Kotlin:

val strict = graph.strict(true)
val ordinary = strict.strict(false)
val exactHistory = strict.isStrict // true

strict(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.

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