Aether architecture: hexagonal design and internals
How the engine works as a pure scheduler, which interfaces form its ports, and how the scope-tree model drives workflow execution from dispatch to finalization.
Use this file to discover all available pages before exploring further.
Aether is built around one organizing principle: the engine is a pure scheduler, not an executor. It reads workflow documents, maintains the phase state machine, determines which tasks are ready to run, and dispatches them through a broker — but it never contains a single line of business logic. Every infrastructure concern is expressed as a Go interface, injected at construction time, and enforced at the compiler level by the internal/ package boundary. Understanding this separation is the foundation for understanding every other design decision in Aether.
Determine readiness — for a dag template, a task is ready when all its declared dependencies have reached a terminal phase. For a loop template, the next iteration is ready when the previous one completes and the loop condition still holds.
Dispatch — call broker.TaskBroker.Dispatch with a TaskAssignment that carries all resolved inputs, secrets, and metadata the worker needs.
React — when OnTaskStarted or OnTaskCompleted fires, update phase state, fire lifecycle hooks, and call advanceScope to re-evaluate the graph.
The engine never reads files, never makes network calls, never evaluates expressions directly (that is delegated to 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.
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.
Each port is a Go interface in its own package. The package contains only the interface definition and any supporting types — no implementation. Implementations are provided by the caller through functional options (option.go) and are never imported by the core engine package.
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.
Every workflow execution forms a tree of TaskRun 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 TaskRun records sharing the given parentRunID
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 parentRunID and repeats
This recursive walk means a deeply nested DAG inside a loop inside another DAG all converges through the same advanceScope function. There are no special cases for nesting depth.
When 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.
Workers never query the store.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.
Executors return an ExecCode integer. The engine maps ExecCode to Phase:
ExecCode
Default phase
ExecCodeSucceeded (0)
Succeeded
ExecCodeFailed
Failed
ExecCodeError
Error
ExecCodeTimeout
Timeout
ExecCodeSuspended
Suspended
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.
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:
Subsystem
File(s)
Responsibility
DAG traversal
dag.go
FindReadyTasks, FindTemplate, FindTask
Parameter binding
binding/
{{inputs.parameters.name}} interpolation, expression environment building
Validation
validate.go
Structural and semantic workflow validation before any state is persisted
Retry
retry.go
ShouldRetry — evaluates retry policy against current phase and retry count
Timeout
timeout.go
Deadline arithmetic and watchdog integration
Merge
merge.go
Output parameter merging (executor result wins over template-declared values)
Phase
phase.go
EvalPhaseConditions — maps ExecCode to Phase with optional expression override
Hooks
hooks.go
FireWorkflowHooks, FireResumeHook — dispatches to hook.Notifier
Defaults
defaults.go
FillDefaults, FillCronDefaults — applies missing field defaults before validation