A single step within a workflow DAG.
Holds everything the engine needs that is not part of Oban's own job
record: the step's dependencies, its cancellation-handling flags, and its
stored result. Linked to the Oban job by oban_job_id.
deps and sequence_after are both edges, and the difference is what flows
along them. A dep carries data, so a dep that dies takes its dependents with
it. sequence_after carries only order — it is how the sequential fan-out
gate paces an expansion — so a predecessor that dies simply releases the
gate. See Baton.Check.
result and checkpoint are both step-written maps, and the difference is
what they mean. A result is the step's output and its completion: the
idempotency guard finishes any step that has one, and dependents read it. A
checkpoint is engine scratch for work still in flight — the provider batch id
a mode: :batch LLM step carries across snoozes — invisible to dependents
and never a signal that anything finished.
A node created mid-run by Baton.Expansion — one item of a dynamic fan-out —
carries fan_out_of (the logical step it expands) and item_index (its
position). Together they are both the expansion's identity, which is what
Baton.Expansion reads to know it has already run, and its order, which is
what the flow runtime reads to rebuild the fan-in list. A statically
expanded node leaves both nil: its grouping was known at compile time and
rides on the reading job's args instead.
A seeded node (seeded_at set, oban_job_id nil) has no job at all:
its result was supplied at compile time via Baton.Flow.Compiler's
seed_steps: option. Dependency gating treats it as completed — the marker
is explicit so a job-less row without it still means "job pruned".
Seeded results are always stored inline, never spilled to artifacts.
This schema is the source of truth for workflow structure and results. The
Oban job's meta carries only immutable identifiers (workflow_id,
workflow_name, workflow_label) for cheap lookups that start from
oban_jobs; it is written once at insert and never mutated.
Summary
Functions
The longest a step_name may be, in characters.
{length, limit} when name is too long to store, nil when it fits.
Types
@type t() :: %Baton.Node{ __meta__: term(), checkpoint: map() | nil, deps: [String.t()], fan_out_of: String.t() | nil, id: term(), ignore_cancelled: boolean(), ignore_discarded: boolean(), inserted_at: term(), item_index: integer() | nil, oban_job_id: integer() | nil, result: map() | nil, seeded_at: DateTime.t() | nil, sequence_after: String.t() | nil, step_name: String.t(), updated_at: term(), workflow_id: String.t(), workflow_label: String.t() | nil }
Functions
@spec step_name_limit() :: pos_integer()
The longest a step_name may be, in characters.
step_name is varchar(255). That is comfortable for a hand-authored step
and comfortable for a static fan-out, whose suffixes come from the caller's
own input — but a dynamic fan-out draws its suffixes from an upstream
result, which for an LLM node means model output. A node fanning per claim
term is one long quotation away from the limit, and the database's answer to
an over-long name is an error raised from inside the expansion transaction,
which retries forever against an input that cannot change. So the limit is
checked before the insert, and named.
@spec step_name_overflow(String.t()) :: {pos_integer(), pos_integer()} | nil
{length, limit} when name is too long to store, nil when it fits.
Counts codepoints, which is what Postgres counts for varchar(n) — not
bytes (which over-count outside ASCII) and not graphemes (which under-count
when a character is composed, and would let a name through that the database
then rejects).