This document specifies a new Nasdanika module — org.nasdanika.waypoint — providing a generic abstraction for tracking the lineage and payload of an execution as it advances through some structure: a graph, a file system, an EMF model, an AST, a state machine. A Waypoint records where execution is, how it got there, what data it carries, and — for richer variants — which realm it runs against and which OpenTelemetry span scopes it.
The model is inspired by three complementary precedents:
- Workflow / process engine tokens, which carry payload along a process and support rollback / retry through transaction groups.
- Git commit history (immutable, parents, fast-forward, merge, reset).
- Petri-net tokens and classical workflow semantics.
Originally this was scoped to graph traversal and was going to live alongside org.nasdanika.graph.message. On reflection, the abstraction has no inherent dependency on graphs and warrants its own module.
The module also absorbs Realm, ExclusiveRealm, and ReadWriteRealm — interfaces previously in org.nasdanika.common. They belong with the waypoint abstraction they serve (see §6.3) and are independently useful for any client that needs a guarded, command-accessed state without lineage.
OpGraph (https://op-graph.models.nasdanika.org/) needs a way to thread state along an execution path, fork at parallel gateways, join at merges, retry on failure, and emit telemetry. The same need recurs in many places:
| Structure | E (execution position) |
Typical S (state) |
|---|---|---|
| OpGraph | Node, Connection |
Named variable map |
| Generic graph traversal | Element, Connection |
Visited-set / accumulator |
| File-system walk | Path |
Accumulated byte count |
| Ecore traversal | EObject, EReference |
EditingDomain / ResourceSet |
| Compiler pass | AST node | Symbol table |
| State machine | State, transition | Variables |
| HTTP server request flow | Filter / handler | Auth context, MDC |
| NSML mapping execution | (EObject source, MappingRule) |
MappingContext |
A single, structure-agnostic vocabulary — waypoint, commit, merge, state, realm, telemetry — covers all of these, and lets specialized engines (OpGraph runtime, NSML mapping engine, EMF traversal, file walkers) compose freely with the same primitives.
The waypoint API draws on — and in places deliberately doesn't replicate — several existing bodies of work. This section surveys the landscape and explains why the module exists as a separate thing rather than building on one of the alternatives.
Workflow / process-engine tokens. Camunda, Flowable, and Activiti model a running BPMN process as a tree of Execution objects: each execution has a parent, a position (an ActivityImpl), local variables, and a lifecycle. jBPM predates them. Temporal (ex-Cadence) takes the same shape into a durable, replayable workflow execution — each WorkflowExecution has a complete event history that is essentially our waypoint lineage promoted to a persistent first-class artifact. Apache Airflow's task instances are similar, but the DAG structure is rigid.
AI-era state graphs. LangGraph's StateGraph carries a typed State through nodes, supports sync and async node bodies, has a checkpointer (persistent state), interrupts (pause/resume), conditional edges, and parallel branches with state merging. It is essentially StateWaypoint<Node, State> with merge semantics, implemented in Python, bound to its own graph structure. Microsoft Semantic Kernel's Process Framework is converging on the same shape. CrewAI and AutoGen carry context less explicitly.
Observability / context propagation. Project Reactor's Context is the immutable-threaded-state piece, but has no lineage — it's a leaf in your chain, not a tree. Micrometer Observation is much closer to TelemetryWaypoint: explicit parent/child, scopes, handlers, designed to wrap arbitrary execution. OpenTelemetry's Span does most of TelemetryWaypoint's job; our interface is a thin adapter. SLF4J MDC is mutable thread-local with no lineage.
Functional state threading. The State monad in Vavr (Java), Cyclops (Java), Arrow (Kotlin), Cats (Scala). StateWaypoint#map is a State-monad bind extended with branching. None of these pairs state threading with execution-position tracking or telemetry, because they are language-level rather than domain-level abstractions.
Content-addressed history. jGit, Pijul, Mercurial. Event-sourcing frameworks — Axon, EventStoreDB — express the same idea as causation and correlation IDs on events.
Specifications. JSR 352 (Java Batch) StepContext, checkpoint, restart. JTA savepoints. Older but the rollback semantics map directly.
- OpenTelemetry
Span/Context.TelemetryWaypointis a thin adapter, not a parallel telemetry system. Theorg.nasdanika.waypoint.telemetrysubmodule is the only place this matters. - Micrometer
Observation. An alternativeTelemetryWaypointbackend for Spring-adjacent consumers — adds nothing to the core module. - Reactor
Context. We carry waypoints through reactive pipelines using it; we do not replicate it.
- Generic over the position type
E. Every existing system fixes the position type — BPMN activity, Airflow task, LangGraph node, Temporal workflow. None lets the consumer say "my position is aPath," or "my position is anEObjectplus theEReferencewe traversed". That is the central novelty, and it's the property that lets one set of primitives serve OpGraph, NSML, EMF traversal, and file walks alike. - Composable facets. State, realm, telemetry as orthogonal mixins. Existing systems either bake everything into a monolithic execution object (Camunda
ExecutionEntity, Temporal workflow handle) or split telemetry off entirely (OpenTelemetry separate from business logic). Neither makes it easy to choose your set of facets per use case. - Library, not runtime. Camunda, Temporal, LangGraph are runtimes — you cede control to their engine. Waypoint is a library — the engine you build calls into it. This matches OpGraph's positioning: you embed it; it doesn't embed you.
The contribution is in the factoring — structure-agnostic position, composable facets, reactive-streams-native primitives — not in any single primitive. That is enough to justify the module inside the Nasdanika orbit, where there is concrete downstream demand (OpGraph runtime, NSML execution, EMF traversal, file walks). It is probably not enough to justify the module as a standalone library competing for adoption against Micrometer, LangGraph, and Temporal. The published module aims to be useful to anyone who happens to need its shape; it does not chase ecosystem competition.
org.nasdanika.waypoint (core — this document)
org.nasdanika.waypoint.telemetry (OpenTelemetry integration)
org.nasdanika.waypoint.git (jGit-backed history persistence)
org.nasdanika.waypoint.emf (ResourceSet-backed state, change recording)
org.nasdanika.waypoint.emf.transaction (EMF Transaction-based RealmWaypoint)
org.nasdanika.waypoint.graph (Adapters for org.nasdanika.graph)
org.nasdanika.waypoint.nsml (NSML mapping execution — see §11)
Core depends only on the JDK, org.reactivestreams, and reactor-core (for Mono / Flux). Every other technology lives in an optional submodule so that downstream consumers pay only for what they use.
Method and type names borrow from jGit and from java.util.stream deliberately. The intent is that someone fluent in Git or in the Streams API recognises the verbs without explanation.
| Concept | jGit term | This API | Rationale |
|---|---|---|---|
| Predecessor commits | getParents() |
getParents() |
Direct match. |
| One parent by index | getParent(int) |
getParent(int) |
RevCommit#getParent(int). |
| Parent count | getParentCount() |
getParentCount() |
RevCommit#getParentCount(). |
| Successor commits | computed via RevWalk |
getChildren() (opt-in) |
Git doesn't store children; forward traversal here needs them. |
| Element / position | (n/a) | getElement() |
Structural payload — node, file, EObject. |
| Append (1 parent) | CommitBuilder.commit() |
commit(E) |
Verb form, mirrors Git fast-forward. |
| Append (n parents) | merge commit | merge(E, parents) |
Same verb as Git merge. |
| Identity | getId() → ObjectId |
getId() (optional) |
For correlation, persistence, equality. |
The draft's createChild(...) is replaced by the two verbs commit and merge. They carry the Git mental model intact and avoid the noun-y, structure-irrelevant "child".
Every interface in this module exposes both synchronous and asynchronous variants of the same operation. The async variant returns a cold Mono (Project Reactor), suffixed Async: execute / executeAsync, apply / applyAsync, commit / commitAsync (where applicable), adapt / adaptAsync. This mirrors the Azure OpenAI Java SDK convention and several other contemporary Java APIs — one interface, two modalities. Callers pick the modality at the call site; implementations are free to share code between them.
The principle: if an operation can be expressed in both modalities, it should be. The waypoint module never forces async-only or sync-only on a caller.
public interface Waypoint<E> {
/** The element this waypoint records execution at. */
E getElement();
/**
* Parents of this waypoint in creation order. Empty list iff this is a
* root waypoint. Single-element list for linear progressions, multiple
* elements for joins / merges.
*/
List<? extends Waypoint<E>> getParents();
/** Equivalent to {@code getParents().size()}. Matches jGit. */
default int getParentCount() {
return getParents().size();
}
/** Parent at index {@code n}. Matches jGit. */
default Waypoint<E> getParent(int n) {
return getParents().get(n);
}
/**
* Children of this waypoint. May be empty if the implementation does
* not track children (which is the default — see §14 Open Questions).
* Unlike Git commits, we expose children because the typical
* traversal-time use case reads them forward.
*/
Collection<? extends Waypoint<E>> getChildren();
/** Single-parent (fast-forward) descendant. */
Waypoint<E> commit(E next);
/**
* Multi-parent (merge) descendant. {@code this} is the first parent;
* {@code additionalParents} follow in iteration order.
*
* @param next element of the new waypoint
* @param additionalParents joining parents, may be empty
*/
Waypoint<E> merge(E next, Iterable<? extends Waypoint<E>> additionalParents);
/**
* Varargs convenience over {@link #merge(Object, Iterable)}.
* See §7 for the varargs/generics caveat.
*/
@SuppressWarnings({"unchecked", "varargs"})
default Waypoint<E> merge(E next, Waypoint<E>... additionalParents) {
return merge(next, Arrays.asList(additionalParents));
}
}Adds a state value of type S. State is read-only from the waypoint's perspective; transformations produce new waypoints. This matches reactive-streams / functional style — waypoints are values, not mutable cells. (The escape hatch for genuinely mutable state is RealmWaypoint, §6.4.)
public interface StateWaypoint<E, S> extends Waypoint<E> {
/** The state value carried by this waypoint. */
S getState();
// ── Narrowed returns ─────────────────────────────────────────────
@Override
List<? extends StateWaypoint<E, ?>> getParents();
@Override
Collection<? extends StateWaypoint<E, ?>> getChildren();
/** Fast-forward that propagates the current state unchanged. */
@Override
default StateWaypoint<E, S> commit(E next) {
return commit(next, getState());
}
/**
* Note: Java doesn't allow widening parameter types on override,
* so the inherited {@code merge(E, Iterable<? extends Waypoint<E>>)}
* stays with that signature. Implementations check parents are
* {@code StateWaypoint} at runtime if needed.
*/
@Override
StateWaypoint<E, S> merge(E next, Iterable<? extends Waypoint<E>> additionalParents);
// ── State-aware factories ────────────────────────────────────────
/** Fast-forward with a new state. */
<T> StateWaypoint<E, T> commit(E next, T nextState);
/** Merge with an explicit new state. */
<T> StateWaypoint<E, T> merge(
E next,
T nextState,
Iterable<? extends StateWaypoint<E, ?>> additionalParents);
// ── Mapping ──────────────────────────────────────────────────────
/**
* Compute the next state from this waypoint and joining parents,
* synchronously, on the calling thread.
*
* The list passed to the mapper has {@code this} as element 0;
* joining parents follow in iteration order.
*/
<T> StateWaypoint<E, T> map(
E next,
Function<? super List<? extends StateWaypoint<E, ?>>, ? extends T> mapper,
Iterable<? extends StateWaypoint<E, ?>> additionalParents);
/**
* Asynchronous counterpart of {@link #map}. The mapper returns a
* {@code Mono<T>}; the result is a {@code Mono<StateWaypoint<E,T>>}.
*/
<T> Mono<StateWaypoint<E, T>> mapAsync(
E next,
Function<? super List<? extends StateWaypoint<E, ?>>, ? extends Mono<T>> mapper,
Iterable<? extends StateWaypoint<E, ?>> additionalParents);
}Notes:
- Mapper signatures use PECS variance (
? super X,? extends Y) — same convention asStream#map,Stream#collect,Collectors. - Async returns are
Mono/Mono<Void>, neverCompletionStage. Adapters can be added later viaMono#toFuture/Mono.fromFuture.
For state that cannot be propagated by value: an EditingDomain, a JDBC Connection, a Repository, an event-loop Context, a JFace Realm. Clients don't manipulate the state directly — they submit commands that execute against it inside the realm's invariants (synchronization, transactions, undo recording).
A Realm<S> is a bounded space of state whose invariants — threading, transactions, undo — require that access go through commands rather than direct mutation. The interface is freestanding (it has clients that need a guarded space without lineage) and RealmWaypoint<E, S> extends it (§6.4).
The mental model mirrors:
org.eclipse.core.databinding.observable.Realm.exec(Runnable).EditingDomain.getCommandStack().execute(Command).Vertx.runOnContext/Context.runOnContext.
These three interfaces — Realm, ExclusiveRealm, ReadWriteRealm — move into the waypoint module from org.nasdanika.common, where they were experimental. They are consolidated here because that is where their primary client (RealmWaypoint) lives, and because org.nasdanika.common should remain minimal.
public interface Realm<S> {
/** Run a command against the guarded state; returns when the command completes. */
void execute(Consumer<? super S> command);
/** Asynchronous {@link #execute}. */
Mono<Void> executeAsync(Consumer<? super S> command);
/** Compute a value from the guarded state. */
<T> T apply(Function<? super S, ? extends T> command);
/**
* Asynchronous {@link #apply}. The command returns a {@code Mono<T>};
* the realm flattens it. Same shape as {@code Mono#flatMap}.
*/
<T> Mono<T> applyAsync(Function<? super S, ? extends Mono<T>> command);
// ── Adaptation ───────────────────────────────────────────────────
/**
* View this realm as a {@code Realm<T>}. The adapter runs inside
* this realm on every command access — lens semantics, not snapshot.
* Construction is cheap; per-access cost is the adapter cost.
*
* The returned realm preserves this realm's threading and transaction
* discipline; it just substitutes T-shaped state for S-shaped.
*/
<T> Realm<T> adapt(Function<? super S, ? extends T> adapter);
/**
* Async-adapter variant. The adapter runs once asynchronously; the
* resulting {@code Realm<T>} wraps the produced T as a snapshot.
* Snapshot rather than lens because re-running an async adapter on
* every command access is rarely what callers want; if you do want
* that, write a {@link #adapt} that delegates to a sync helper which
* blocks on the async source.
*/
<T> Mono<Realm<T>> adaptAsync(Function<? super S, ? extends Mono<T>> adapter);
}The Function<? super S, ? extends T> / Function<? super S, ? extends Mono<T>> adapter type is intentionally general. The adapter can be a field selection, a projection, an NSML transformation, an OpGraph mapping, an AI-agent invocation that supplies semantic context, or any composition of these. The most consequential use case — NSML transformations as adapters — is worked out in §11.6, because it is what makes Realm and Waypoint more than independent abstractions sharing a module: they enable each other's most interesting compositions. Sync adapt suits cheap deterministic adapters; async adaptAsync suits expensive or AI-driven ones where snapshot semantics are correct.
public interface ExclusiveRealm<S> extends Realm<S> {
/*
* Refinement of Realm with single-concurrent semantics: at most one
* execute or apply (sync or async) is in-flight at a time. Subsequent
* operations queue until the in-flight one completes.
*
* No additional methods are required for the basic contract; the
* type carries the semantics. Implementations may add a
* {@code tryExecute} / {@code tryApply} pair for non-blocking
* attempts, and a {@code Disposable} hook for cancellation —
* deferred to follow-up.
*/
@Override
<T> ExclusiveRealm<T> adapt(Function<? super S, ? extends T> adapter);
// adaptAsync inherited; returns Mono<Realm<T>>. The async snapshot
// does not need exclusive semantics by default. A caller who wants
// an exclusive snapshot can wrap the resulting Realm<T> via a small
// utility (Realms.exclusive(Realm<T>)) — TBD.
}public interface ReadWriteRealm<S> {
/*
* Does NOT extend Realm. Clients route reads and writes explicitly
* via getReadRealm() / getWriteRealm(); the type itself does not
* expose execute/apply directly. This makes the read/write contract
* visible in the type system, in the spirit of
* java.util.concurrent.locks.ReentrantReadWriteLock and EMF
* Transaction's read/write distinction.
*/
/**
* View for shared concurrent reads. Multiple read operations may run
* concurrently; reads block while a write is in progress.
*/
Realm<S> getReadRealm();
/**
* View for exclusive writes. Writes serialize against each other and
* against in-flight reads.
*/
ExclusiveRealm<S> getWriteRealm();
// ── Adaptation ───────────────────────────────────────────────────
/**
* Adapt to a {@code ReadWriteRealm<T>}. Reads and writes on the
* adapted realm apply the adapter inside the corresponding view of
* the underlying realm — lens semantics, preserved across read and
* write.
*/
<T> ReadWriteRealm<T> adapt(Function<? super S, ? extends T> adapter);
/** Async-adapter variant — snapshot semantics, see {@link Realm#adaptAsync}. */
<T> Mono<ReadWriteRealm<T>> adaptAsync(Function<? super S, ? extends Mono<T>> adapter);
}A ReadWriteRealm<S> is not a Realm<S>. The reason is honesty: when a caller has a Realm<S> reference they don't know whether the operation they're about to issue is a read or a write, and the realm has no way to route it correctly. Forcing the caller to ask for getReadRealm() or getWriteRealm() puts the right knowledge at the right place.
A waypoint over a realm-managed state. Realm methods are inherited; no new methods are needed beyond the narrowed Waypoint returns.
public interface RealmWaypoint<E, S> extends Waypoint<E>, Realm<S> {
@Override
List<? extends RealmWaypoint<E, S>> getParents();
@Override
Collection<? extends RealmWaypoint<E, S>> getChildren();
@Override
RealmWaypoint<E, S> commit(E next);
@Override
RealmWaypoint<E, S> merge(E next, Iterable<? extends Waypoint<E>> additionalParents);
// execute, executeAsync, apply, applyAsync, adapt, adaptAsync
// inherited from Realm<S>. Closure-capture of the waypoint inside
// the command body removes any need for BiConsumer<Waypoint, S>
// ergonomic overloads — callers write
// wp.execute(state -> doStuff(wp, state));
// and the compiler does the right thing.
}A draft of RealmWaypoint had execute(BiConsumer<RealmWaypoint<E,S>, S>) etc. — passing both waypoint and state explicitly. That's not needed once Realm is its own interface: the caller's closure captures the waypoint reference. The simpler signatures inherited from Realm are sufficient.
It has state. But clients aren't expected to call getState() directly — they go through execute / apply to respect the realm's threading and transaction rules. Reading state outside a command can be unsafe.
Recommendation: keep them as siblings. Provide a combined interface RealmStateWaypoint<E, S> for the cases where direct state read is also safe (immutable snapshot state, for instance). This keeps the API honest about thread-safety; clients that genuinely want both opt in by typing against the combined interface.
Wraps an OpenTelemetry Span and Context. Each commit / merge corresponds to a child span; close() ends the span. The waypoint is AutoCloseable so it works in try-with-resources.
public interface TelemetryWaypoint<E> extends Waypoint<E>, AutoCloseable {
@Override
List<? extends TelemetryWaypoint<E>> getParents();
@Override
Collection<? extends TelemetryWaypoint<E>> getChildren();
@Override
TelemetryWaypoint<E> commit(E next);
@Override
TelemetryWaypoint<E> merge(E next, Iterable<? extends Waypoint<E>> additionalParents);
/**
* OpenTelemetry context for propagation across process boundaries
* (HTTP headers, message attributes).
*/
Context getContext();
/**
* Tracer for instrumenting code that wants to create spans
* beneath this waypoint's span without creating a child waypoint.
*/
Tracer getTracer();
/** Optional meter, for waypoints that also scope metrics. */
Meter getMeter();
/** End the span associated with this waypoint. Idempotent. */
@Override
void close();
/** Async close — when the span lifecycle ends with an async op. */
Mono<Void> closeAsync();
/** Run a command with this waypoint's context activated. */
void execute(Consumer<? super TelemetryWaypoint<E>> command);
Mono<Void> executeAsync(Consumer<? super TelemetryWaypoint<E>> command);
<T> T apply(Function<? super TelemetryWaypoint<E>, ? extends T> command);
<T> Mono<T> applyAsync(
Function<? super TelemetryWaypoint<E>, ? extends Mono<T>> command);
}The applyAsync mapper returns Mono<T> and the method returns the flattened Mono<T> — the same shape as Mono#flatMap, avoiding an awkward Mono<Mono<T>>.
Waypoint<E>... parents produces an "unchecked generic array creation for varargs parameter" warning at every call site. @SafeVarargs requires the annotated method to be final, static, or private — none of which applies to a regular interface method. It can be applied to a private interface method in Java 9+, but not to a default one.
Options considered, in order of preference:
- Drop the varargs from the interface entirely. Only declare the
Iterableoverload. Callers useList.of(a, b)orSet.of(a, b). Cleanest, most reactive-streams-style. - Keep a
defaultvarargs convenience method that delegates to theIterableoverload, with@SuppressWarnings({"unchecked","varargs"})on the declaration. Call sites stay clean. - Static varargs factory in a
Waypointsutility class that isfinaland can legally bear@SafeVarargs.
Recommendation: option 2 — pragmatic, idiomatic with List.of / Stream.of, and the warning is suppressed exactly once at the declaration site.
We need waypoints that are both telemetric and stateful, or both telemetric and realm-bound. Two strategies, ship both.
public interface TelemetryStateWaypoint<E, S>
extends TelemetryWaypoint<E>, StateWaypoint<E, S> {
@Override
List<? extends TelemetryStateWaypoint<E, ?>> getParents();
@Override
TelemetryStateWaypoint<E, S> commit(E next);
// ...narrowed returns
}
public interface TelemetryRealmWaypoint<E, S>
extends TelemetryWaypoint<E>, RealmWaypoint<E, S> { /* ... */ }
public interface RealmStateWaypoint<E, S>
extends RealmWaypoint<E, S>, StateWaypoint<E, S> { /* ... */ }Pro: single object, strong typing at call sites. Con: combinatorial growth when facets multiply (auth context, MDC, transaction handle, …). Bridge methods are needed to narrow return types, and default methods can clash between super-interfaces.
public interface Waypoint<E> {
// ...
default <F> Optional<F> facet(Class<F> facetType) {
return Optional.empty();
}
}
// Usage:
waypoint.facet(TelemetryFacet.class)
.ifPresent(t -> t.span().addEvent("..."));
waypoint.facet(StateFacet.class)
.ifPresent(s -> useState(s.value()));Pro: open-ended; aligns with Eclipse IAdaptable and EMF EObject.eAdapters() — both already used by Nasdanika.
Con: less type-safe; needs explicit lookup.
Recommendation: ship both. Composed interfaces cover the common cases with strong typing; facet(...) keeps Waypoint<E> future-proof.
A WaypointFactory<E, W> produces the root waypoint(s) and encapsulates traversal-wide policy (child tracking on/off, threading model, telemetry hookups, …).
public interface WaypointFactory<E, W extends Waypoint<E>> {
/** New root with no parents. */
W root(E element);
/**
* New root with the given parents. Useful for resuming a traversal
* or splicing sub-traversals together.
*/
W root(E element, Iterable<? extends W> parents);
}
public interface StateWaypointFactory<E, S>
extends WaypointFactory<E, StateWaypoint<E, S>> {
StateWaypoint<E, S> root(E element, S initialState);
}
public interface RealmWaypointFactory<E, S>
extends WaypointFactory<E, RealmWaypoint<E, S>> { /* ... */ }
public interface TelemetryWaypointFactory<E>
extends WaypointFactory<E, TelemetryWaypoint<E>> {
TelemetryWaypoint<E> root(E element, Tracer tracer);
}A Waypoints utility class hosts decorators and static helpers, and is the right place for @SafeVarargs static varargs methods (see §7):
public final class Waypoints {
private Waypoints() {}
public static <E> WaypointFactory<E, Waypoint<E>> recording();
public static <E, S> StateWaypointFactory<E, S> stateful();
public static <E, W extends Waypoint<E>> WaypointFactory<E, W> withChildTracking(
WaypointFactory<E, W> delegate);
@SafeVarargs
public static <E> List<Waypoint<E>> parents(Waypoint<E>... parents) {
return List.of(parents);
}
}A small companion utility class Realms is the equivalent for realm operations that need static helpers (e.g. an exclusive(Realm<T>) decorator that wraps a non-exclusive realm in an exclusive one):
public final class Realms {
private Realms() {}
public static <S> ExclusiveRealm<S> exclusive(Realm<S> delegate);
public static <S> ReadWriteRealm<S> readWrite(
Realm<S> readView, ExclusiveRealm<S> writeView);
}Reference TelemetryWaypoint implementation backed by the OpenTelemetry SDK:
- Span creation per
commit/merge. Single-parent →Span.setParent; multi-parent → primary parent viasetParent, joining parents viaSpan.addLink. - Context propagation across process boundaries via
TextMapPropagator. - Optional
Meterfor metrics scoped to the span.
GitWaypoint<E, S> persists each commit to a Git repository — element + state are serialized to blobs and recorded as a real RevCommit. Branches and merges happen at the jGit level. Use cases: durable execution history for long-running or replayable processes, rollback via git reset / git revert, forensic analysis.
public interface GitWaypoint<E, S> extends StateWaypoint<E, S> {
ObjectId getCommitId();
RevCommit getCommit();
Ref getBranch();
}
public final class GitWaypointFactory<E, S>
implements StateWaypointFactory<E, S> {
public GitWaypointFactory(
Repository repository,
String branch,
Serializer<E> elementSerializer,
Serializer<S> stateSerializer) { /* ... */ }
}Bridges to EMF ResourceSet / Resource:
ResourceSetStateWaypoint<E>—S = ResourceSet; child waypoints see snapshots via copying.ChangeRecordingStateWaypoint<E>— wrapsorg.eclipse.emf.ecore.change.util.ChangeRecorder; each step records aChangeDescriptionthat can be applied forward or applied-inverse (rollback).
Bridges to org.eclipse.emf.transaction. Commands issued via RealmWaypoint#execute are wrapped in RecordingCommands and pushed to the editing domain's command stack, so EMF undo/redo and write-locks work transparently. The EMF TransactionalEditingDomain's read/write distinction maps naturally onto ReadWriteRealm (§6.3.3).
Adapters for org.nasdanika.graph. Generic Element (node/connection) is the natural E. This is the original OpGraph use case; it lives here rather than in core so the core module has no graph dependency.
A worked example tying the module together, and the reason org.nasdanika.waypoint.nsml (and an analogous integration into NSML itself) is on the roadmap.
NSML — the Nasdanika Semantic Mapping Language — describes transformations from source models to target/semantic models via rules. An execution of an NSML mapping is a traversal of the source structure that produces target structure. Each rule firing is an execution step. The fit with waypoints is direct.
Position (E). A MappingStep value combining the source element being mapped and the rule that fired:
record MappingStep(EObject source, MappingRule rule) {}E = MappingStep gives the lineage immediate meaning — every waypoint says "rule R was applied to source S."
State (S). A MappingContext holding:
- The target
ResourceSetbeing built. - A bindings map: source
EObject→ targetEObject(s). - Accumulated diagnostics (errors, warnings, low-confidence flags from AI rules).
- Source-tracing references for downstream provenance queries.
In practice the mapping context is realm-managed — writes to the target ResourceSet go through commands — so RealmStateWaypoint<MappingStep, MappingContext> is the right shape. Reading the bindings map for routing decisions is safe; writes go through execute.
Telemetry. Each commit / merge is a span. Rule attributes go on the span: nsml.rule.id, nsml.rule.confidence, nsml.source.uri. Rule bodies that call AI agents emit child spans with OpenTelemetry gen-ai semantic-convention attributes (gen_ai.system, gen_ai.request.model, gen_ai.usage.input_tokens, gen_ai.usage.output_tokens). The waypoint tree is the trace tree.
Lineage as provenance. "Why does this target element exist?" is answered by walking parents from the waypoint that produced it. The answer is structurally available, not reconstructed from logs. Every target element carries a reference (an EAnnotation, an EMF Adapter, or a side index) to the waypoint whose rule firing produced it.
// Engine startup
var factory = new NsmlWaypointFactory(targetResourceSet, openTelemetry);
RealmStateWaypoint<MappingStep, MappingContext> root = factory.root(
new MappingStep(null, null), // synthetic root step
MappingContext.fresh());
// A deterministic rule firing
var step = new MappingStep(sourceEObject, rule);
RealmStateWaypoint<MappingStep, MappingContext> child = root.commit(step);
child.execute(ctx -> { // closure captures `child`
var targetEObject = rule.apply(sourceEObject, ctx);
ctx.bind(sourceEObject, targetEObject);
ctx.annotateProvenance(targetEObject, child);
});
// An AI-backed rule
RealmStateWaypoint<MappingStep, MappingContext> agentStep =
child.commit(new MappingStep(otherSource, agentRule));
agentStep.applyAsync(ctx -> agent
.chooseTargetConcept(otherSource, ctx)
.doOnNext(answer -> ctx.recordConfidence(agentStep, answer.confidence()))
.map(answer -> answer.targetConcept()))
.subscribe(/* consumer */);
// Retry on low confidence — sibling under the same parent
var lastAnswer = agentStep.<Answer>apply(ctx -> ctx.lastAnswer(agentStep));
if (lastAnswer.confidence() < 0.6) {
var retryStep = child.commit(
new MappingStep(otherSource, agentRule.withHigherTemperature()));
// ...retry on the new branch; old branch retained for audit
}Three properties of the waypoint API matter for agentic rules, and all of them come from the core design rather than from special-purpose orchestration:
- Real asynchrony with backpressure.
applyAsyncreturns a coldMono; agents are I/O-bound; fan-out (Flux.merge,Flux.concatMap) is governed by Reactor's standard backpressure mechanics. - Retry from a known-good state.
pred.commit(retryStep)snaps back to a known-good waypoint. Retries with different prompts, temperatures, or models become siblings under the same parent. Nothing is lost from the audit trail — the rejected attempt remains in the waypoint tree. - Observable cost and latency. Per-step OTEL spans capture model, token usage, latency, cost. Aggregated across a mapping run, you get per-rule and total AI cost of producing the target model — which matters in production.
A deterministic NSML rule ("if source is UMLClass, create OOPClass") and an agentic rule ("ask the model to choose the best target concept given source and surrounding context") produce identical execution records — same lineage, same telemetry shape, same retry semantics. The only difference is in the rule body. That's a clean factoring you don't get if agent invocation is built into a special-purpose orchestrator: declarative and agentic transformations are no longer different kinds of system, just different rule bodies.
org.nasdanika.waypoint.nsml
├── NsmlWaypoint // alias for RealmStateWaypoint<MappingStep, MappingContext>
├── NsmlWaypointFactory // root waypoints, engine wiring, OTEL hookup
├── MappingStep // (sourceEObject, rule)
├── MappingContext // target ResourceSet + bindings + diagnostics
├── ProvenanceAnnotator // links target elements to the producing waypoint
└── agent
├── AgentRule // base for AI-backed rules
└── GenAiSpanAttributes // OTEL gen-ai semantic-convention helpers
NSML transformations — and OpGraph mappings, and agentic semantic transformations more broadly — fit naturally as the adapter function passed to Realm.adapt / Realm.adaptAsync. The pattern is: you have a Realm<S> over some source representation; you want a Realm<T> view over a derived or semantic representation; the derivation is itself an NSML transformation that may be deterministic, async, or agentic.
// Source realm: a guarded source model (EditingDomain, database connection,
// in-memory model — anything realm-managed).
Realm<SourceModel> sourceRealm = ...;
// An NSML transformation, async because some rules are agentic.
NsmlTransformation<SourceModel, TargetModel> transform = NsmlEngine.compile(rules);
// Get a semantic view via the transformation. The transformation runs once
// asynchronously; the resulting Realm<TargetModel> wraps that snapshot.
Mono<Realm<TargetModel>> targetView =
sourceRealm.adaptAsync(transform::applyAsync);
// Use the semantic view as if it were a real realm.
targetView.flatMap(view ->
view.applyAsync(target -> downstream.process(target)))
.subscribe();When the transformation is deterministic and cheap, the synchronous form works:
Realm<TargetModel> liveView = sourceRealm.adapt(cheapTransform::apply);
liveView.execute(target -> ...); // re-applies the transformation per accessIn practice almost any non-trivial NSML transformation — and certainly any agentic one — should use adaptAsync:
- Re-running a substantive NSML transformation on every Realm access is expensive even when deterministic.
- Re-running an agentic transformation is both expensive and non-deterministic — the agent may give a different answer on each invocation, which is rarely what the caller wants.
- The NSML transformation's own execution is internally a
RealmStateWaypointchain (§11.1); snapshotting once gives you a single, complete, auditable execution record for the adaptation rather than a parade of partial records, one per access.
This composes recursively. The snapshot Realm<TargetModel> returned by adaptAsync can itself be adapted further (e.g. a second NSML pass), used as the state for a StateWaypoint chain, or passed to a sub-engine. The NSML transformation that produced it is observable via OpenTelemetry, retryable via waypoint sibling branches (§11.3), and persistable via the Git submodule. At every layer, the semantic content of the source becomes a Realm.
The general pattern — NSML as a view definition language over realm-managed state — is the strongest single argument for why Realm and Waypoint live in the same module. They are not merely composable; the combination is more interesting than either part alone, and the combination is what specialized engines (NSML, OpGraph, agentic semantic pipelines) actually need.
The waypoint graph itself is append-only; rollback is achieved by:
- Locate a waypoint at the desired pre-failure point (
pred). - Create a new child of
predfor the retry:pred.commit(retryElement). - For
StateWaypoint,pred.getState()is the starting state for the retry. - For
GitWaypoint, this maps togit reset --hard <pred-commit>and continuing. - For
ChangeRecordingStateWaypoint, inverse-apply theChangeDescriptions back topredbefore retrying.
Failure handling is deliberately not part of the core Waypoint<E> interface — different traversal engines have different failure semantics (best-effort, transactional, compensating). They build on top of these primitives.
- Synchronous methods (
commit,merge,map,execute,apply,adapt) run on the caller's thread. Implementations that need a specific thread (e.g. EMFEditingDomainon the UI thread) document this and may block or dispatch. - Asynchronous methods (
*Async) return a coldMono. Nothing happens until subscription — matching Project Reactor convention. - Backpressure: a single waypoint op is a single-value emission, so
Fluxdoesn't appear here. Stream-of-waypoints APIs (walkers, traversal engines) sit on top of this module and may useFlux. - Sync/async parity: every operation that can be expressed in both modalities is expressed in both (§5.1). Implementations may share code between them; callers pick at the call site.
- Children tracking — opt-in or always on? Always-on simplifies the API but retains memory for long-running traversals. Recommendation: opt-in.
getChildren()returns empty by default;Waypoints.withChildTracking(factory)enables it. - Identity —
getId()on the base interface, or only onGitWaypoint? Recommendation: optionaldefault Optional<String> getId() { return Optional.empty(); }, overridden where meaningful. - Equality — reference equality, or
equalsby parents + element + id? Recommendation: reference equality only, mirroring jGit (RevCommit#equalsis identity-by-ObjectId). - Walker / iterator API — should the module ship a generic
WaypointWalkeranalogous to jGit'sRevWalk? Probably yes, but in a follow-up. - Annotations / metadata — open
Map<String,Object>for ad-hoc decoration (timing, errors, tags)? Recommendation: yes, on a smallAnnotatedmixin or as a facet, not on the base interface. - Cancellation — should
executeAsync/mapAsyncpropagate cancellation back to in-flight commands when theMonois unsubscribed? Recommendation: yes, by passing areactor.core.publisher.SignalTypelistener or usingDisposablehooks; details TBD per submodule. - Realm adapt semantics — lens vs snapshot.
adaptis lens,adaptAsyncis snapshot. Documented in §6.3.1; the NSML-as-adapter use case (§11.6) confirms the choice for the async path — re-running an agentic transformation on every access is wrong. The remaining open question is whether we need a sync-snapshot variant for expensive but deterministic NSML transformations; conceivable, no confirmed call site yet. An async-lens variant has no confirmed use case at all.
| Concern | Choice |
|---|---|
| Parent terminology | getParents(), getParent(int), getParentCount() — jGit RevCommit. |
| Append verbs | commit(E), merge(E, parents) — Git semantics, not createChild. |
| Collection types | List<? extends Waypoint<E>> for ordered parents; Collection<? extends Waypoint<E>> for children. |
| Variance | ? super on consumers, ? extends on producers — Stream / Collectors. |
| Iteration parameter | Iterable<? extends Waypoint<E>> — accepts List, Set, Collection, custom. |
| Varargs ergonomics | Default method delegating to the Iterable overload; warning suppressed once. |
| Sync/async parity | One interface, two modalities; async methods suffixed Async — Azure OpenAI Java SDK convention. |
| Asynchronous return | Mono<T> / Mono<Void> — Project Reactor. |
| Mapper signatures | Function<? super X, ? extends Y> — Stream#map convention. |
AutoCloseable |
On TelemetryWaypoint for try-with-resources span lifecycle. |
| Realm idiom | execute(Consumer) / apply(Function) — JFace Realm, EMF EditingDomain, Vert.x Context#runOnContext. Closure-capture replaces BiConsumer-style overloads. |
Realm, ExclusiveRealm, and ReadWriteRealm move from org.nasdanika.common to org.nasdanika.waypoint. They were experimental, so no stable consumers should exist; nonetheless, the migration is a package rename plus the addition of async methods and adapt / adaptAsync. Existing implementations need to provide the new methods (mostly mechanical) or extend an abstract base class that supplies sensible defaults.
The existing org.nasdanika.graph.message package stays as-is; an adapter in org.nasdanika.waypoint.graph will let traversal engines emit waypoints that also produce messages where useful.
- Create
org.nasdanika.waypointmodule skeleton (Maven coordinates,module-info.java, package layout). - Move
Realm,ExclusiveRealm,ReadWriteRealmfromorg.nasdanika.common, add async methods andadapt/adaptAsync. - Implement the reference
Waypoint<E>andStateWaypoint<E, S>with in-memory, opt-in child tracking. - Port the OpGraph execution PoC to
StateWaypoint. - Spike
GitWaypointagainst jGit to validate rollback semantics on a real repository. - Spike the NSML integration (§11) against a real mapping — both a deterministic rule and an agent-backed rule — to validate the
RealmStateWaypoint+ telemetry shape end-to-end. - Decide §14 open questions — particularly identity, child-tracking default, walker API, and lens vs snapshot semantics for async
adapt— before stabilising the API.