internal/ package boundary. Understanding this separation is the foundation for understanding every other design decision in Aether.
The engine as pure scheduler
The engine’s job is narrow and precisely bounded:- Determine readiness — for a
dagtemplate, a task is ready when all its declareddependencieshave reached a terminal phase. For alooptemplate, the next iteration is ready when the previous one completes and the loop condition still holds. - Dispatch — call
broker.TaskBroker.Dispatchwith aTaskAssignmentthat carries all resolved inputs, secrets, and metadata the worker needs. - React — when
OnTaskStartedorOnTaskCompletedfires, update phase state, fire lifecycle hooks, and calladvanceScopeto re-evaluate the graph.
expr.Evaluator), and never writes to a log. All of its observable effects pass through the injected interfaces.
PhaseSkipped and PhaseCancelled are set exclusively by the engine. Executors return an integer ExecCode; the engine maps it to a Phase. Users can influence the mapping via phaseConditions expressions, but they cannot set engine-reserved phases directly.Hexagonal architecture
Aether’s architecture is hexagonal (also called ports-and-adapters). The core engine lives at the center; all infrastructure lives at the edges behind interface ports. Dependencies flow inward only — implementations depend on the engine’s interface definitions, never the reverse.option.go) and are never imported by the core engine package.
The ten extension interfaces
Required interfaces
These four must be provided at construction time.aether.New returns an error if any is absent.
Optional interfaces
These are provided through functional options. The engine behaves identically without them, with features simply unavailable.
Two additional optional interfaces extend engine capabilities further:
Wiring example
The minimal required wiring, taken directly from the playground’sbuildEngine function:
WithExecutor and WithExecutorRegistry are two ways to register the same thing. Use WithExecutor to register plugins one at a time; use WithExecutorRegistry when you already have a pre-built *executor.Registry that you share with the broker or worker layer.
The scope-tree scheduling model
Every workflow execution forms a tree ofTaskRun records connected by ParentRunID. This tree is the “scope tree.” Understanding it explains how any level of nesting schedules correctly with the same algorithm.
Root scope. When a workflow is submitted, the engine creates a single root TaskRun for the entrypoint template. If the entrypoint is a dag, that TaskRun becomes the parent for all tasks in the DAG.
Child scopes. When a dag task references another template (which may itself be a dag, task, or loop), a new child TaskRun is created with ParentRunID set to the parent DAG’s TaskRun. This nesting can be arbitrarily deep (up to maxNestedDepth, default 3, max 10).
advanceScope. After any task completes, the engine calls advanceScope(parentRunID). This function:
- Loads all
TaskRunrecords sharing the givenparentRunID - Finds tasks whose dependencies are all terminal — these are newly ready
- Dispatches each ready task to the broker
- If all tasks in the scope are terminal, the scope itself is finalized
- Walks upward to the parent’s
parentRunIDand repeats
advanceScope function. There are no special cases for nesting depth.
fetch-data completes, advanceScope(entrypoint-dag) finds that transform-data’s only dependency is now Succeeded, dispatches it, then returns. When transform-data completes, the same function dispatches notify. When notify completes, all tasks in entrypoint-dag’s scope are terminal, so entrypoint-dag itself is finalized and the workflow completes.
Fat task assignments
Workers never query thestore.Store. This is a deliberate constraint, not an oversight.
When the engine dispatches a task, it builds a broker.TaskAssignment that carries everything the worker needs: resolved input parameters, executor type and schema, task and template names, retry count, timeout, and secret references. The worker receives this struct from the broker, calls executor.Plugin.Execute, and reports the result back through broker.CompleteTask. It never needs to look up the workflow or any sibling task’s state.
This design eliminates a class of distributed coupling problems. Workers can run in isolated processes, in separate data centers, or without any network access to the engine’s state store. They are simple, stateless, and independently testable.
Phase ownership
The engine is the sole writer ofPhase values on WorkflowRun and TaskRun records. The phase state machine is:
ExecCode integer. The engine maps ExecCode to Phase:
Users can influence the mapping with
phaseConditions expressions on a task declaration. For example, a task can treat ExecCodeFailed as Succeeded when a specific output condition holds. However, PhaseSkipped (set when a when condition evaluates to false) and PhaseCancelled (set by engine.Cancel) are exclusively engine semantics. Executors cannot return these phases.
The internal/ boundary
All scheduling algorithms, binding logic, validation, retry/timeout mechanics, and DAG traversal live under internal/. The Go compiler enforces this boundary: no code outside the github.com/BabySid/aether module can import internal/ packages. This means implementation details can never leak into the public API, and callers can never depend on internal behavior.
Outside of internal/, every package (except the top-level aether package itself) is a pure interface layer — it defines a contract and supporting types, nothing else. This makes each interface package a stable, independently versioned contract.
Key internal/ subsystems:
Engine public API surface
Engine is the only struct exposed to callers. Its API is intentionally small:
Everything else is opt-in through functional options. There are no global singletons, no
init() side effects, and no hidden configuration files.
Executor plugin
Implement the
executor.Plugin interface for your own task execution logicStore interface
Build a persistent
store.Store backed by your database of choiceBroker interface
Implement
broker.TaskBroker for distributed or queue-based task dispatchOther interfaces
expr.Evaluator, hook.Notifier, secret.Provider, timeout.Watcher, and more