The format follows Keep a Changelog and the project adheres to Semantic Versioning.
[Unreleased]
[0.27.4] - 2026-08-30
Fixed
- The Hex package description was stale. It still read "fan-out/synthesis"
— the phrasing the README dropped in v0.27.3 — so the hex.pm listing would
have described a narrower library than the one it ships. Now matches the
README's opening line, dynamic fan-out included.
*.taris also gitignored, sincemix hex.builddrops the tarball in the project root.
[0.27.3] - 2026-08-30
Fixed
- The README feature list omitted three whole capabilities. Fan-out was absent beyond the multi-model special case, and portable flow definitions and the flow compiler were absent entirely — so a reader of the repo front page could not tell that a workflow can be data rather than code, nor that fan-out exists at all. The list now covers workflow templates, the compiler (with a link to its guide, which nothing in the README pointed at), fan-out and fan-in, and dynamic fan-out; the opening line says a collection may be fixed or computed mid-run.
- No upgrade notes for schema v9. Additive-column versions (v5, v6, v8)
reasonably went without, but v9 is not purely additive: it replaces v1's
plain
(workflow_id, step_name)index with a unique one, and neither is builtconcurrently, so on a largeworkflow_nodestable the build takes a lock that blocks writes. The new section gives the migration, theconcurrentlypre-create that makes v9 no-op past it, the query to find duplicate step names if the build fails, and a warning about the new"expanded"event state for consumers that match onstate.
[0.27.2] - 2026-08-30
Fixed
The dynamic fan-out design record described a ruleset the code no longer had. v0.27.1 updated this changelog and the flow compiler guide but left
docs/dynamic_fan_out_plan.mdlisting three refusals and not theitem_idlength bound, so a reader working from the design would have met that constraint only by hitting it.The new section carries the reasoning rather than just the rule, because each part had a live alternative worth recording against being quietly reversed later: refused rather than truncated (two suffixes sharing a 255-character prefix would collide after truncation), counted in codepoints (bytes over-count outside ASCII, graphemes under-count a composed character), and the column deliberately not widened.
Baton.Flow.Workers.Expander's moduledoc had the same gap in its list of refusals and is likewise complete.Documentation only — no behavior change.
[0.27.1] - 2026-08-30
Fixed
A fan-out
item_idthat overflowsstep_nameis now refused by name instead of by the database (#8).step_nameisvarchar(255), and nothing bounded the expansion id built from it.It could not really bite a static fan-out, whose suffixes come from the caller's own input. Dynamic fan-out moves that root to
$steps.<dep>, which for anllmnode makes the suffix model output — and the motivating case invites it: a node fanning per claim term is naturally authored as"item_id": "$item.term", and claim language routinely runs past 255 characters.It also failed in the worst possible way. The expansion happens in one transaction, so Postgres raised
22001 string_data_right_truncationfrom inside it — raised, not returned, so no result was stored, soBaton.Worker's idempotency guard did not short-circuit and the expander re-ran. Because the collection comes from an already-completed upstream node, every retry resolved the identical items and failed identically, until the attempt budget was gone and the step discarded with a Postgrex message rather than a reason anyone could act on.Both expanders now build the id through
Baton.Flow.FanOutSpec.expansion_id/2and discard with{:fan_out_item_id_too_long, node_id, length, limit}— the static path at compile time, the dynamic path on its first attempt.Baton.Expansionkeeps its own check as the engine backstop, since a host expander built onBaton.Workerreaches the insert without passing through the flow layer.Not truncated: two suffixes sharing a 255-character prefix would then collide on the unique index, turning a clear refusal into a confusing one. Length is counted in codepoints, which is what Postgres counts for
varchar(n)— bytes over-count outside ASCII, and graphemes under-count a composed character and would let through a name the database then rejects.The flow compiler guide now says plainly that
item_idshould be a short stable identifier the producing node mints, not a human-readable label.
[0.27.0] - 2026-08-29
Added
Dynamic fan-out — a
Baton.Flow.FanOutSpecwhosecollectionis rooted at$steps.expands at run time, one node per item in a list an upstream step produced. Until now a collection had to come from$inputor$context, so a flow could not fan out over a computed result, and the workaround — one job processing the whole list — is the shape that degenerates and burns tokens.Requires migration v9.
%NodeSpec{ id: "assess", type: "llm", deps: ["extract"], fan_out: %FanOutSpec{ collection: "$steps.extract.data.claims", item_id: "$item.claim_id" } }Nothing downstream changes: each expansion carries its own
$item, and a reader declaring a dep onassessreads$steps.assessas the ordered list of results — the same shape a static fan-out gives it. A node can depend on a static and a dynamic fan-out at once.The node compiles to a single expander step (
Baton.Flow.Workers.Expander) holding the logical id. When it runs it creates the children in one transaction, adopts them as its own dependencies, and parks until they settle (Baton.Expansion). Rewriting its own deps rather than its readers' is what makes it safe: a reader's dependencies never change under it, so there is no window in which it sees the step as finished while the work it must wait for is missing from its dep list. Because the expander sits inscheduledthroughout, a workflow can never be announced finished mid-expansion, and the waiting itself isBaton.CheckandBaton.Rescheduledoing what they already do for any dependent.Retry-safety comes from the children themselves: their existence is the record that the expansion happened, so a crash before the commit re-expands from scratch and a crash after it skips to waiting. There is no separate marker that could disagree with the rows it describes.
If any child ends non-completed the expander discards, mirroring what a reader would have seen from a static fan-out whose branch exhausted its retries — so
ignore_discarded: truemeans the same thing either way. The manifest is stored first, so a tolerant reader still resolves the partial list.max_itemsonBaton.Flow.FanOutSpec(default 200) caps an expansion. A static fan-out over the cap fails to compile ({:fan_out_too_large, id, count, cap}); a dynamic one discards its expander rather than inserting the jobs. It is the same guard against a degenerate producer that dynamic fan-out exists to avoid, from the other direction. Omitted fromdump/1at its default, so untouched definitions serialize byte-identically."expanded"step event —Baton.Events.broadcast_step_expanded/2and[:baton, :step, :expanded], carryingdetail: %{count: n}. The one event meaning the graph itself changed shape; a UI holding a compiled graph should re-read it.
Changed
Baton.Flow.Validatoraccepts a$steps.-rooted fan-out collection, under two rules. The collection must name a step the node declared a dep on (:collection_not_a_dep) — the same rule config bindings already obey, so an expansion cannot bypass dependency gating — and that step must not itself have fanned out (:collection_from_fan_out), since reading through an expansion yields a list of lists whose meaning is left undecided rather than guessed.Previously every
$steps.-rooted collection was rejected as:collection. That reason now means only "the root is not one of$input/$context/$steps", and the two cases above are reported precisely.
Migration
v9 adds
fan_out_ofanditem_indextoworkflow_nodes(both nullable and additive — every existing row and the whole static path leave them nil), an index on(workflow_id, fan_out_of), and a unique index on(workflow_id, step_name).The unique index is new protection rather than plumbing: step-name uniqueness was enforced only in memory by
Baton.add/4, which cannot arbitrate inserts made mid-run from a running job. Existing data cannot violate it — a workflow insert is all-or-nothing — so it builds cleanly. A host with a large live table can pre-create it concurrently in its own migration, which makes v9'screate_if_not_existsa no-op.It also earns its keep at run time: a child id colliding with an existing step name is caught there and converted to
{:discard, {:expanded_node_id_conflict, name}}with the transaction rolled back, rather than a constraint error retrying until the budget runs out.v9 then drops v1's plain index on the same two columns in the same order, which the unique index fully supersedes. Keeping both would cost a second B-tree write on every node insert — a price a 200-row expansion pays 200 times.
down_v9recreates it before removing the unique one, so the rollback never leaves that lookup uncovered.
[0.26.0] - 2026-08-23
Changed
Baton.Flow.RequestAssemblyaccepts a list body from a prompt resolver, not only a binary. A host whose wire wants structure that plain text cannot carry — an Anthropiccache_controlbreakpoint on the system prompt is the motivating case — can now resolve a prompt to content parts, and they reach the request opts (and the user message) exactly as the resolver produced them.Baton neither builds nor inspects the parts: what a part is is between the host's resolver and the client it ends up at, so the list is passed through whole and
is_list/1is the whole check. Text or parts and nothing else — a resolver returning any other shape is still{:invalid_prompt_result, slot, value}, which discards rather than retries.An empty list is now blank in the same way
""already was: the:systemopt is dropped rather than sent empty.Additive — every existing resolver returns a binary and is unaffected. This was the one hop in the portable-flow path that could not carry a cache breakpoint: a host resolver could produce the blocks and its wire could send them, but assembly in between refused the body and discarded the job.
[0.25.0] - 2026-08-22
Added
Baton.Completion.status/1— read a workflow's settled state instead of waiting to be told it. Returns{:finished, outcome, failed_steps},:running, or:unknown, with no announcement and no side effects.The terminal
{:workflow_finished, _}broadcast fires exactly once and is never replayed: theworkflow_completionsclaim that guarantees the once-ness also guarantees that no later path will announce again. So a subscriber that was down, not yet started, or on a node that wasn't listening at that instant misses the event permanently — its own run record sits atrunningforever while the workflow it tracks has long since finished. Nothing in baton could tell it otherwise; the outcome was recorded but not readable.status/1closes that hole. A host can now sweep its own still-open run records and settle any whose workflow has in fact finished, which turns a missed broadcast from permanent data loss into a delay until the next sweep. The recorded outcome wins when a completion row exists, so whatstatus/1reports matches what was broadcast even afterBaton.Retentionhas pruned the steps behind it — in that casefailed_stepscomes back empty, the per-step detail being gone.:unknownis deliberately distinct from:running: a workflow baton has neither steps nor a completion row for was never inserted or has been fully pruned, and a caller should not read that as still-in-flight.
Changed
Baton.Completion's announce path now derives its outcome and failed-step list through the same private classificationstatus/1uses, so the two can't drift. No behaviour change.
[0.24.0] - 2026-08-22
Added
Snooze reasons. Every
"snoozed"broadcast now names what the step is waiting for: the payload'sdetailmap carries%{reason: "deps", seconds: 15}, wherereasonis one of"deps"(upstream dependency pending, tagged byBaton.Worker's dep-check branch),"rate_budget"(hostBaton.RateLimiter.acquire/3starved),"provider_limit"(aclassify_error/1snooze verdict — HTTP 429/529 under the default taxonomy),"batch_slot"(hostacquire_batch/1starved), or"step"(the step snoozed itself without saying why), andsecondsis the announced wait — what the step asked Oban for, an upper bound on the actual wait sinceBaton.Reschedulewakes dep-snoozed jobs early. Engine-originated snoozes tag themselves at their origin inBaton.LLMStepas{:snooze, seconds, reason};Baton.Worker.__handle_result__/3is the choke point that broadcasts each tag and strips the tuple back to the 2-tuple Oban accepts, so a host can decompose a run's waiting by cause without touching any step.Motivated by cost/time accounting: three of the five waits were previously invisible — a rate-budget, provider-limit, or batch-slot snooze returned from inside
perform_workflow/1passed through with no event at all, leaving only the dependency wait and the batch"awaiting"observable.
Changed
Baton.Events.broadcast_step_snoozed/1is now/3(job, reason, seconds); the arity-1 form is gone.Baton.LLMStep.run/2andrun_batch/3return tagged 3-tuple snoozes for engine-originated waits. The bare 2-tuple remains valid from step code (request/1, an overriddenperform_workflow/1) and is tagged:stepat the worker, so existing steps need no change."awaiting"broadcasts carrysecondsin theirdetailmap (%{batch_id: id, seconds: poll_interval}), alongside the batch id, so batch wait time is accumulable the same way snooze wait is. The batch engine's own snoozes are tagged:awaitinginternally and deliberately do not also broadcast"snoozed"— one wait, one event family.
[0.23.0] - 2026-08-22
Added
Per-node retry backoff.
Baton.Flow.NodeSpecacceptsretry_backoff_secondsalongsidemax_attempts: a flow node'sbackoff/1(Baton.Backoff.node_backoff/1) reads it straight fromjob.argson each attempt and, when set, uses that flat delay (plus a few seconds of jitter) instead of the worker's own default curve — Oban's exponential formula forBaton.Flow.Workers.Action,Baton.LLMWorker's jitteredfailures^3 + 15forBaton.Flow.Workers.LLMand everyBaton.LLMStep. Needs no compiler support (unlikemax_attempts, it is read from node config, not stamped onto the Oban job), so it applies to fan-out expansions and hand-builtNodeSpecs alike.NodeSpec.load/1and the validator both reject a non-positive value.Motivated by a guard's resample: it means "draw again," not "something is wrong," so a node whose guards resample often against cheap, expected misses can skip the climbing wait a genuine failure earns.
[0.22.0] - 2026-08-22
Added
- Per-node retry budget.
Baton.Flow.NodeSpecacceptsmax_attempts— the portable spelling of the Oban option — and the compiler builds that node's job(s) with it instead of the worker default (Baton.LLMWorker's 3). A fan-out stamps the budget onto every expansion. It bounds genuine attempts (snoozes still inflate the counters symmetrically andBaton.Backoff.deflate/1rebases them away), which is what a host wants when guards resample aggressively against cheap, prompt-cached calls. Emitted in dumps only when set, so stored snapshots of untouched definitions are byte-identical;NodeSpec.load/1andBaton.Flow.Validatorboth reject a non-positive value.
[0.21.0] - 2026-08-21
Added
- Provider-advertised
Retry-Afteris honoured. A 429/529 whose error term carries a:retry_aftervalue (seconds) or aretry-afterheader under:headers(a plain map, a Req-style map of value lists, or a list of pairs; name matched case-insensitively) now snoozes for the advertised delay instead of the fixed:rate_limit_snooze, clamped to 3600s so a malformed value can't park a job for a week. Errors without either key — including every client built to the documented%{status:, body:}minimum — behave exactly as before, so this is opt-in per host client (Baton.LLMStep.default_classify/2). - Multi-node guide.
guides/multi_node.mdcollects the invariants for running one Postgres-backed cluster across several nodes: why the engine is already multi-node-safe, the Lifeline/stale-threshold/step-timeout ordering, why per-node queue limits multiply and the rate limiter must use shared storage, and the leader-gating rule for event-driven writers.
Fixed
- Failed calls credit their rate-limit reservation back.
Baton.RateLimiter.reconcile/3only ran after a response with usage, so the budgetacquire/3reserved for a call that then 429'd, timed out, or died in transport was never returned — a tight ITPM/OTPM bucket leaked its own estimate on every failure and starved itself. The live engine now reconciles the error path too, with%{input: 0, output: 0}actuals: an implementation doingestimate - actualarithmetic credits the full reservation back with no special-casing (Baton.LLMStep).
[0.20.0] - 2026-08-20
Added
- Node guards. A portable
llmnode may declare a top-levelguardslist — result checks the host applies afterhandle_response. Baton validates only the shape (a list of maps, each with a non-empty string"kind";Baton.Flow.NodeSpec), rejects guards on non-LLM nodes (Baton.Flow.Validator, mirroring the transport check), and threads the outcome + node + job to the host runner configured underconfig :baton, flow_runtime: [guard_runner: ...](the newBaton.Flow.GuardRunnerbehaviour). A guarded node with no runner configured is discarded rather than run unguarded. Guard kinds, field paths, and budget policy are entirely the host's business.
Changed
- Rejected samples are now costed. A paid model call whose answer the
pipeline refused —
max_tokenstruncation, a decode failure, ahandle_responsethat returned an error to draw a fresh sample — used to vanish fromworkflow_step_stats, because usage only rode{:ok, result}maps.Baton.LLMStepnow records the attempt's usage directly at the point of rejection (live and batch engines both), so a step that resamples reads its true spend. One stats row per attempt; the accepted attempt is recorded throughBaton.LLMWorkerexactly as before.
Removed
Baton.Flow.MinLength. Schema-declaredminLengthfloors are no longer enforced by the flow LLM worker's decode step. The mechanism had exactly one failure policy — fail the step — and the first node that needed a second one (degrade to a partial result once the retry budget is spent) had to reimplement the whole check host-side. Length floors are now a host guard (see Node guards above), where the policy is per-node configuration. Hosts upgrading must move anyminLengththey relied on into a guard spec; the schema key itself is inert (providers ignore it).
[0.19.1] - 2026-08-10
Fixed
LLM step stats are now readable when a workflow is announced finished.
Baton.LLMWorkerrecorded them after delegating to the base handler, and the base handler completes the step — which, for the last step of a workflow, announces the workflow finished. A host readingBaton.Stats.workflow_totals/1in its completion handler saw an empty table and snapshotted nils for cost, tokens, and latency.Only workflows whose final step is an LLM step were affected; a flow ending in an action never noticed, because every LLM step had written long before. A single-node run is the case that breaks.
Baton.Worker.__handle_result__/3gained anon_storedcallback that runs after the result is persisted (idempotency guard armed) and before the step completes;LLMWorkerrecords stats there. Both orderings that matter are now pinned by a test.
Fixed
A seeded step no longer announces its workflow as failed.
seed_steps:materializes job-lessworkflow_nodesrows, soNodes.step_states/1read them asstate: nil— the same shape a pruned step has — andBaton.Completioncounts a nil state as a failure. Every seeded workflow therefore announced:failedhowever well its real jobs did, andworkflow_finishedcarried the seed names infailed_steps.step_states/1now selectsseeded_atandCompletionreads a seeded row ascompleted, the same distinctionBaton.Check.classify_dep/2has drawn since 0.17.0. A pruned row (no job, no marker) still counts as a failure, and a genuinely discarded job still fails the workflow.This made the entire single-node-trial path unusable for hosts on 0.17.0+: a trial that succeeded was still recorded as a failure. Anyone using
seed_steps:should take this release.
[0.18.0] - 2026-08-10
Added
seed_fan_in:compile option — seed a fanned-out upstream node as its individual expansions rather than as one fan-in list:Compiler.compile(definition, seed_steps: %{"section_112_1" => …, "section_112_2" => …}, seed_fan_in: %{"section_112" => ["section_112_1", "section_112_2"]} )Dependents still declare a dep on the logical id, and now get both halves of what a real fan-out gives them:
$steps.section_112as the ordered fan-in list (the group's order decides it), and one result per expansion under its own step name — which is what a result-scanning consumer (Baton.Results.get_all_results/1, prefix matching on step names) reads. 0.17.0's fan-in-shaped seed covers only the bindings half, so a seeded suffix ending in a step that scans results by name saw nothing; this is that gap closed. Typed errors for a group naming an unseeded expansion, a logical id also seeded directly, and the usual shape checks.
[0.17.0] - 2026-08-09
Added
seed_steps:compile option — supply upstream results at compile time instead of computing them:Compiler.compile(definition, seed_steps: %{"rounds" => %{"data" => …}})A dep naming a seeded step is satisfied even though no node in the definition carries that id, so a definition containing a single node — or only the suffix of a larger graph — compiles and runs alone. Each seed is materialized as a real
workflow_nodesrow (seeded_atset, no Oban job) in the same insert transaction, visible to$stepsbindings,Baton.Resultsscans, and dependency gating exactly like a completed step. A job whose deps are all seeded is insertedavailable; mixed seeded/live deps park as usual and the seeded ones count as complete for completion-triggered promotion. Seed a fanned-out upstream node as its fan-in (the ordered list of expansion envelopes under the logical id). Built for host prompt-workbench trials: run one node of a production flow against captured upstream state, paying for exactly one model call.debug:compile option —Compiler.compile(..., debug: true)forces per-workflowworkflow_debug_logscapture (whatBaton.new(debug: true)already did, exposed at the compile boundary), independent of the globalBaton.Debugsetting.Baton.Flow.RequestAssembly— the generic llm node's request construction (bindings → assigns → prompts → wire opts), extracted fromBaton.Flow.Workers.LLM.request_generic/4into a pure function both the worker and host preview surfaces call, so what a preview shows and what a worker sends cannot drift. Malformed configs (bad bindings, unknown prompt source, nil/empty model, unknown response mode, non-map shapes) return typed{:error, reason}— never raise. An unknown response mode is now rejected before the call instead of failing decode after tokens were spent.
Changed
- Schema v8:
workflow_nodes.oban_job_idis nullable (seeded rows have no job) andworkflow_nodes.seeded_atmarks a seeded row explicitly — dependency gating still reads a job-less row without the marker as pruned. Bump your Baton migration toversion: 8. Baton.Retention.delete_orphans/2no longer treats a NULLoban_job_idas a dead job; seeded rows are reclaimed when their workflow has no jobs left (the same rule as completions).
[0.16.0] - 2026-08-08
Added
A client can declare a failure permanent, and the error taxonomy honours it:
{:error, {:cancel, {:batch_unsupported_provider, "openai"}}}default_classify/2turns that into{:cancel, reason}— the step is cancelled on its first attempt rather than retried.The existing
4xx → cancelrule reads HTTP, which only covers failures that reached the provider. A client can also fail before the request goes out — a model routed to a provider whose batch API it doesn't implement, a missing credential, an endpoint it can't speak — and no retry fixes any of those. The alternative was hosts fabricating a plausible status code to get the cancel they wanted, which is a lie in the error record; this lets them say what they mean.Found the hard way: a live probe of a mixed-provider flow burned three attempts per step on an unbatchable provider before discarding, cancelling twenty-odd dependents slowly instead of at once. Ordinary errors are unaffected — the marker is opt-in.
[0.15.0] - 2026-08-08
Added
Portable flow nodes can choose batch mode, with
"transport" => "batch"in anllmnode's config:config: %{ "model" => "claude-sonnet-4-20250514", "user_prompt" => %{"body" => "..."}, "transport" => "batch", "poll_interval" => 600 }0.14.0's
use Baton.LLMStep, mode: :batchbinds the transport when the module compiles, which suits a step module that exists to do one thing. It can't reach a portable flow: everyllmnode in every definition runs through the singleBaton.Flow.Workers.LLM, so one compile-time choice would batch all of them or none. That worker now reads the transport from the node on each attempt instead."poll_interval"and"batch_deadline"are the same seconds-valued options, and anything omitted falls back to the engine's defaults.Nothing else about the node changes — prompts, bindings, schemas, adapters, and what downstream nodes read are all identical.
One definition can compile for either transport.
Baton.Flow.Compiler.compile/2takestransport: "live" | "batch"as a per-run default for everyllmnode that doesn't declare one — so the same flow answers an analyst live in minutes and runs batched at half cost overnight, without a second copy. A node's own explicit"transport"wins (pinning, say, a cheap synthesis step live even in a batch run), andpoll_interval:/batch_deadline:compile options fill tuning defaults on nodes that end up batched — tuning belongs to the run, since only the caller knows what sits between submission and the provider. The override merges before validation (asequentialfan-out still rejects"batch"), and both the flow snapshot and each job's args carry the merged config: what ran is what is recorded.Baton.Flow.Validatorchecks the transport, returning{:invalid_transport, node_id, reason}. Every way of getting this wrong fails silently otherwise: an unrecognized transport simply runs live, and the first sign is the bill. It rejects an unknown value (:unsupported), tuning keys on a node that isn't batched (:tuning_without_batch— almost always a typo intransport), transport keys on anactionnode (:not_an_llm_node), and non-positive-integer seconds (:invalid_poll_interval/:invalid_batch_deadline).It also rejects
"batch"on a fan-out gatedsequential(:sequential_fan_out). That gate chains expansions so each waits for the previous, which batched is N waits of up to 24 hours apiece — and its only purpose, priming a prompt cache whose TTL is minutes, cannot survive the gap. No configuration makes the pair do what its author meant, so it's an error rather than a footgun. Usegate: "parallel".
Migration
- None. Batch mode's schema v7 requirement is unchanged from 0.14.0, and a definition that names no transport behaves exactly as before.
[0.14.0] - 2026-08-07
Note: 0.13.0 was tagged but never published to Hex, so this release carries its
sequence_afterchanges too. Hosts upgrading from 0.12.x should read both entries — and run two schema versions (v6 and v7).
Added
Batch mode for LLM steps. One line —
use Baton.LLMStep, mode: :batch— moves a step onto the provider's Message Batches API: roughly half the token cost, hours-scale latency. Every callback (
request/1,decode/1,handle_response/3,output_schema/0,classify_error/1) is unchanged. Only the transport differs: the engine submits a one-request batch, parks the job on snoozes until the batch ends, then runs the result through the same decode → handle → attach-usage pipeline. To the rest of the DAG a batch step is an ordinary step that happens to take hours — dependency triggering, completion, retries, and stats all behave as before.Snoozing is what makes the waiting free: Oban raises
max_attemptsalongsideattempt, so a step can poll for a day with its retry budget intact, and ascheduledjob holds the workflow open and its dependents parked.New options:
:poll_interval(default300s) and:batch_deadline(default90_000s, a backstop above the provider's own 24h expiry).Baton.LLMClient, the client contract as an explicit behaviour, withcomplete/2plus three optional batch callbacks (submit_batch/2,poll_batch/2,batch_results/2). Adopting it is optional — the live path still resolvescomplete/2at runtime, so existing clients are untouched. A batch step whose client lacks the callbacks cancels with{:batch_unsupported, client}on its first attempt rather than failing against a gap no retry can close.Baton.Results.store_checkpoint/2,get_checkpoint/1, andclear_checkpoint/1— engine scratch for a step whose work spans several attempts, kept deliberately separate from results. A stored result is completion (the idempotency guard finishes any job that has one); a checkpoint means the opposite, and no dependent can see it. Batch mode uses it to carry the batch id across snoozes; any long-running step can use it for crash-safe progress.Baton.RateLimiter.acquire_batch/1(optional) — gates batch submissions. Provider batch traffic draws from a separate pool, so there is nothing to reserve against the ITPM/OTPM budgetsacquire/3protects and noreconcile/3counterpart; what can still be exceeded is the submission rate. Limiters that don't export it are unaffected.An
awaitingstep event, broadcast on submit and on every poll with adetailmap (%{batch_id: id}). It distinguishes waiting on someone else's work fromsnoozed, which means waiting on dependencies — a step parked for six hours should be visibly parked, not silently flickering. The payload of every step event now carriesdetail(niloutside batch mode).Consumer impact: anything matching on the payload's
statemust tolerate the new value; keep a catch-all clause.Batch usage is stamped
service_tier: "batch"so aBaton.Pricingmodule can apply the discount. Nothing downstream can recover the transport — a stored cost looks identical either way — and the provider's own reply doesn't carry it through Baton's usage normalization, so the engine stamps it.latency_msfor a batch step is the end-to-end turnaround, which is the number worth comparing against a live twin.
Migration
- Schema v7 adds
workflow_nodes.checkpoint. Bump theversion:in yourBaton.Migration.up/1call and runmix ecto.migrate. Nothing else changes; steps that never use batch mode never write the column.
[0.13.0] - 2026-08-03
Changed
The
sequentialfan-out gate now orders without depending. Its chaining edge moved out ofdepsinto a newsequence_after, andBaton.Checkresolves it by a weaker rule: snooze while the predecessor is pending, proceed on every terminal state — completed, cancelled, discarded, pruned, or stale.The gate exists to prime a shared prompt cache or to pace a rate limit; no data flows from one expansion to the next. Modelling that as a dependency meant a predecessor that died invalidated successors that never read it, and the failure compounded down the chain. One exhausted branch of a 16-item fan-out would discard, cancel the next expansion, which cancelled the next, until the cascade reached the reader configured to tolerate exactly this (
ignore_discarded: true) — as a wall of cancelled deps, which that flag does not cover. The run died holding every completed step in it, including expensive unrelated branches. This is the same failure 0.12.0 set out to fix for the reader; the gate was a second path to it that the flag could not reach.A permanently failing expansion now simply drops out: the ones behind it run on their own merits, and the reader sees the partial collection it was configured to accept. Declared deps are untouched — they still carry data and still cascade — and expansions still wait their turn.
Added
Baton.add/4acceptssequence_after:, the hand-assembled spelling of the same ordering edge. Validation sees it: a cycle through ordering edges deadlocks exactly as one through deps does, and both are rejected at insert.compiled_graphcarries the ordering edge, assequence_afteron the node and an edge marked"kind" => "sequence", so a rendered graph still shows a sequential expansion as a chain. Both are omitted when there is no gate, leaving snapshots of ungated definitions byte-identical.
Migration
- Schema v6 adds
workflow_nodes.sequence_after. Bump theversion:in yourBaton.Migration.up/1call and runmix ecto.migrate. In-flight workflows compiled before the upgrade keep the gate edge indepsand continue to behave the old way; the new behaviour applies to workflows compiled after it.
[0.12.3] - 2026-08-03
Added
minLengthdeclared in a node'soutput_schemais now enforced locally after decode (Baton.Flow.MinLength). Providers validate the shape of a structured-output reply, not its content: Anthropic and OpenAI both accept a schema carryingminLengthon a string property and then return a shorter value — the keyword is documented as unsupported, but it is ignored rather than rejected, so a host that writes one gets silence instead of an error.The failure mode this closes: a model asked for several fields at once will occasionally answer the analytical ones in full and stub the long prose one — literally
"placeholder"— and every layer downstream then treats the stub as the answer, because it is a schema-valid string. Observed on a four-field patent-prosecution assessment that returned a 1,000-characterallowance_reasonand a full estoppel array beside anarrativeof"placeholder", twice on the same patent, at a rate low enough that replaying the identical request six times never reproduced it.A violation fails
decode/2, which makes it an ordinary step failure: the response is discarded and the step retries against a fresh sample. This pairs with the backoff fix in 0.12.2 — a retry provoked here is priced as the step's first genuine failure rather than its seventy-first, so it actually happens within a useful interval.Objects (
properties) and arrayitemsare walked, so a minimum on a nested field is enforced too;nullpasses, since a nullable field that came back null is absent rather than short. OnlyminLengthon strings is checked — this is deliberately not a general JSON Schema validator — and nodes whose schemas declare no minimum are unaffected.
[0.12.2] - 2026-08-03
Fixed
Retry backoff was computed from
attempt, which Baton's own dependency waiting inflates. Baton waits on a dependency by snoozing, and Oban counts a snooze as an attempt —snooze_job/3raisesattemptandmax_attempts, so the retry budget survives but the counter stops meaning "times this ran and failed". Every backoff callback was reading it as though it did.The damage scales with how long a step waits. A step deep in a
sequentialfan-out snoozes once per poll until its predecessors finish, so it reaches attempt 70 before it first executes; its first genuine failure was priced as its 71st. Observed on a 16-item fan-out: a step that failed once on a transient truncation was deferred 64 minutes, and by the tail of the run the same single failure would have been deferred over four days. Nothing distinguishes that from a dead run, and the retry that clears it — these were one-shot transient failures — never gets a chance to happen inside any human's patience. Worse, a step that eventually exhausts its budget takes its dependents with it, so a recoverable failure could cascade into a cancelled run that had already paid for every other step.Baton.Backoffis the fix:failures/1counts genuine failures (Oban callsbackoff/1before appending the current error, so the count islength(errors) + 1), anddeflate/1rebuildsattemptandmax_attemptsas they would have been without snoozes, preserving the remaining budget.Baton.LLMWorkernow drives its jittered curve off the failure count.jittered_backoff/1additionally accepts anOban.Job; the integer form is unchanged, so direct callers and tests keep working.Baton.Workernow definesbackoff/1at all. It previously inherited Oban's default untouched, which has the same defect and a steeper exponent — a snoozed action step could draw an 18-hour wait on its first failure. It delegates to Oban's default curve applied to deflated counters, so the shape of the curve is unchanged.
No configuration changes. Backoffs get shorter, never longer, and only for jobs that snoozed — a workflow with no dependency waiting is unaffected.
[0.12.1] - 2026-08-03
Fixed
The
sequentialfan-out gate chained every expansion to the first one instead of to its predecessor, so the gate serialized nothing: expansions 2..N all became runnable the moment expansion 1 completed. The accumulator inBaton.Flow.Compiler.expand_node/3prepends, so its head is the previous expansion —List.last/1reached past all of them to the first.A two-item fan-out cannot show the difference (expansion 1 is both the first and the previous), which is why the existing coverage passed; the regression test uses four.
The gate's purpose is to let one call populate a shared prompt cache before the rest run, and that still happened — every expansion did wait for the first. What was lost is the serialization itself, so a host relying on
sequentialto bound concurrency or to pace a rate-limited provider was getting parallel fan-out. Hosts that only wanted the cache warm are unaffected in behaviour and will now see the expansion run slower and in order, which is what the gate has always documented.
[0.12.0] - 2026-08-03
Added
Baton.Flow.NodeSpecnow carriesignore_discardedandignore_cancelled. These are the portable spelling of optionsBaton.add/4already accepted;Baton.Flow.Compilerpasses them through to each expanded job, so a code-defined or stored flow definition can finally reach behaviour that was previously available only to workflows assembled by hand.The motivating case is a reader that depends on a fan-out. A fan-out over N items is N independent jobs, and by default a single one of them exhausting its retries cancels every downstream node — discarding the work of the other N-1 along with every unrelated branch of the graph. A host observed one claim of an 11-claim fan-out fail this way and lose the entire run, including two expensive unrelated LLM steps that had already completed.
ignore_discarded: trueon the reader lets it run against the partial collection instead.Both default to
false, and are omitted fromNodeSpec.dump/1unless set, so stored snapshots anddefinition_refdigests of untouched definitions are byte-identical to before. No migration is required: the underlyingworkflow_nodescolumns have existed since the initial schema.The flags are node-wide rather than per-dependency — a node that tolerates a discarded fan-out branch also tolerates a discarded required dep. A reader whose required input goes missing fails on binding resolution instead of proceeding with a hole, but hosts should not treat the flag as precise.
[0.11.0] - 2026-07-29
Changed
Baton.RateLimiter.reconcile/3now carries input and output token counts, not input alone.estimateandactualare each%{input: non_neg_integer(), output: non_neg_integer()}instead of a bare integer —estimate.inputis whatacquire/3already received,estimate.outputis the call's ownopts[:max_tokens](0 when unset), andactual.outputcomes from the response'susage.output_tokens. This is a breaking change to the behaviour's callback contract: an implementation written againstreconcile(account, estimate :: integer(), actual :: integer())must update its pattern match to the map shape.Baton.RateLimiter.Noopis unaffected (it ignores its arguments). A host that only tracks input-tokens-per-minute today can keep doing exactly that by readingestimate.input/actual.inputand ignoring.output— nothing about the semantics of the input dimension changed, only its container. Enables tracking an output-tokens-per-minute (OTPM) budget the same way ITPM already works, without Baton needing to know anything about how a host buckets or bills output tokens.
[0.10.0] - 2026-07-28
Added
- A node's
modelmay be a binding, not only a literal id.Baton.Flow.Workers.LLMresolvesconfig["model"]throughBaton.Flow.Binding, so$item.modelgives each node of a fan-out its own model — a fan-out over a list of models is now just the ordinary pattern — and$input.modeldefers the choice to the caller. Literals pass through untouched, so nothing changes for a definition that names its model directly. A model that resolves to a non-string still discards as:invalid_model, and a binding that cannot resolve discards with its binding error rather than burning the job's retries. No validator change was needed:$steps.…in a model was already held to the declared-dep rule, since the validator scans every expression in a node's config.
[0.9.0] - 2026-07-28
Added
- Fan-in bindings.
$steps.<node_id>on a dependency that fanned out now resolves to that expansion's results as a list, in expansion order, so a downstream node can finally read what a fan-out produced. Previously the results were reachable only under their expanded step names (review_1,review_2), which a definition cannot name — the validator rejects any$stepsid that is not a declared dep, and expanded ids do not exist until compile time. The reader still names the logical node; nothing about dependency gating changes. Baton.Flow.Bindingmaps a path segment over a list instead of failing, so$steps.review.data.findingplucks that path from each expansion. Strict: an element missing the segment fails the whole expression rather than yielding a short list. This only turns previous errors into values — no existing expression changes meaning.Baton.Flow.Compilerstamps each job withflow_fan_in, the expanded step names of its fanned-out deps, soBaton.Flow.Runtimecan group them without reading the run snapshot. Jobs compiled before this release simply have no fan-in entries and resolve exactly as they did.
[0.8.0] - 2026-07-20
Changed
- Fan-out
gatevaluewarm_firstrenamed tosequential(Baton.Flow.FanOutSpecstring form andBaton.Flow.FanOutatom form). The name now describes the mechanism — the expanded nodes are chained into a sequence — rather than the intended payoff (priming a shared prompt cache), which was only ever incidental and is documented onBaton.Flow.FanOut. Backward compatible:warm_firstis still accepted on load, validate, and compile, and is normalized tosequentialwhen a definition is loaded, so definitions and run snapshots persisted before this release still work.
[0.6.1] - 2026-07-19
Changed
Baton.LLMStep.default_classify/2now cancels the job on terminal HTTP 4xx client errors (400, 401, 403, 404, 413, 422, …) instead of retrying them. Retrying resends the identical request — a bad parameter, auth failure, or model capability mismatch (e.g. a thinking config the model rejects) can never succeed, so it only burned attempts and tokens. 408 (request timeout) stays retryable; 429/529 still snooze. Steps that need different behaviour overrideclassify_error/1as before.
[0.6.0] - 2026-07-18
Added
- Portable serialized flows —
Baton.Flow.Definitionis a versioned, JSON-only source representation of a flow (nodes, deps, per-node config) a host can store anywhere (Ecto, Git, files) with no executable code or module names inside it.Baton.Flow.Validatorchecks it,Baton.Flow.Compilerexpands and compiles it into an executable Baton workflow, and generic workers (Baton.Flow.Workers.LLM,Baton.Flow.Workers.Action) run every node — no consumer-defined worker modules required. - Host contracts and bindings —
Baton.Flow.Bindingresolves$-prefixed dotted paths rooted atinput,context,steps,run, oritemagainst a JSON-compatible runtime environment, so a node declares its inputs as data. Hosts supply an allow-listedBaton.Flow.Registry(action and LLM-adapter keys), aBaton.Flow.ContextProvider, and aBaton.Flow.PromptResolver; stored definitions reference these by key, never by module. - Immutable run snapshots — portable executions are persisted via
Baton.WorkflowRun/Baton.WorkflowRuns, capturing the logical definition, compiled graph, input, and per-node results for later inspection and projection. - Portable
Baton.Flow.Definitionnodes may declare compile-time fan-out with JSON-safe collection and item-ID bindings plusparallelorwarm_firstgating. Compilation expands downstream dependencies, exposes the current value as$item, and records both logical and expanded node IDs in the run snapshot. - Portable LLM nodes may name an allow-listed host
Baton.Flow.LLMAdapterfor domain-specific message preparation and response normalization. Baton still owns transport, decoding, usage accounting, and the editable node config; adapters never place executable module names in stored definitions. - Generic LLM workers pass host-provided tools through to LLM clients, and the default LLM worker runtime is bounded by a finite timeout.
Changed
- Deterministic (non-transport) flow prompt errors are discarded instead of retried, and generic flow prompt failures are labeled by prompt for clearer diagnostics.
[0.5.0] - 2026-07-14
Added
Baton.Flow— declarative flows: describe a DAG once as a list ofBaton.Flow.Steps, then either build it into an executable Baton workflow (Baton.Flow.build/4) or project it into a node/edge graph for display (Baton.Flow.project/1). Single-sourcing the two means they can't drift.Baton.Flow.Step— a step's worker, logical deps,kind(:llmor:function), an optionalBaton.Flow.FanOut, and an opaquemetabag a consumer stows its own data in (prompt keys, payload types, …);project/1echoesmetaback on each node but never interprets it.Baton.Flow.FanOut— one node per item in a collection drawn from the build subject, with:parallelor:warm_firstgating (the first expanded node warms a shared cache; the rest depend on it).project/1lays nodes out by topological layer and introspects each worker'soutput_schema/0(fromBaton.LLMStep), when present.- Domain-agnostic: the build subject is an opaque term, and consumer-specific
data rides on
meta— reusable by any Baton consumer, not just one app.
[0.4.0] - 2026-07-13
Added
Baton.LLMStep— a structured contract for LLM steps that owns the transport loop every step used to hand-write. A step implementsrequest/1(build messages + client options) and, usually,handle_response/3(turn the decoded payload into the stored result); the engine times the call, invokesBaton.Debug.call_llm/3, classifies transport errors, decodes the reply, and attaches usage. This collapses ~30 lines of identicalcaseplumbing per step and removes a class of copy-paste bugs (a missed error clause that retried a non-retryable request, or dropped a retryable one).- Canonical error taxonomy (
default_classify/2): HTTP 429 and 529 →{:snooze, n}(no retry attempt consumed), everything else →{:error, reason}(retried permax_attempts).max_tokenstruncation is a retryable error, never a stored result. Override per step withclassify_error/1. - Automatic usage recording — the
llm_usagemap is built from the client's normalizedresponse.usage(cache keys renamed to theworkflow_step_statscolumns, extra keys likeweb_search_requestspassed through) and attached to any{:ok, map}result that doesn't carry one. - First-class
output_schema/0— when defined, injected into the client options as:output_schemaautomatically, so the schema is declared once and both the API call and introspection tooling read the same one. - Tolerant JSON decoding via
decode_json/1(raw → fenced block → brace slice); overridedecode/1(e.g. identity for free-text steps). {:done, result}short-circuit fromrequest/1— store a result with no model call and no usage, for guards like "this item has nothing to process".
- Canonical error taxonomy (
[0.3.0] - 2026-06-23
Added
- Event-driven downstream dispatch. When a step completes, the next step's
queue is now woken immediately via an Oban
:insertnotification (Baton.Dispatch,Baton.RescheduleReporter) instead of waiting for the Stager's next cycle. On a deep DAG this removes ~1s of scheduling latency per edge. The nudge fires from[:oban, :job, :stop](after Oban commits the parentcompleted), so it never wakes a dependent too early. - Startup nudge.
Baton.insertwakes the root jobs' queues right after the insert transaction commits, closing the same gap at workflow start. Baton.Application. Baton now starts a minimal application that attaches its telemetry handlers (Baton.RescheduleReporter,Baton.CompletionReporter) app-wide, so fast dispatch and crash detection work withoutBaton.Plugininstalled.- Prompt crash detection.
Baton.CompletionReporterlistens to[:oban, :job, :exception]and announces a crash-terminated workflow the instant its terminal:discardlands, rather than waiting for the plugin sweep. - Schema v4: a partial expression index on
oban_jobs ((meta->>'workflow_id'))backingBaton.Plugin's failed-workflow detection and orphan scan, so those sweeps stay cheap asoban_jobsgrows. Requires running a migration — see "Upgrading" in the README. - Separate prune cadence.
Baton.Pluginnow runs its health sweep (:interval, orphan rescue + failure notification) and its bulk prune (:prune_interval, default 5 min) on independent timers, with a[:baton, :plugin, :prune]telemetry span.
Changed
Baton.Plugin's sweep is now a backstop rather than the primary finish detector — the crash and completion paths above settle workflows promptly, so:intervalcan be lengthened without delaying notifications.- Default
snooze_secondslowered from 30 to 15. With event-driven dispatch the snooze/park is a fallback that rarely fires; the smaller value shortens the worst-case tail when a nudge is ever lost. Host-configurable as before.
[0.2.0] - 2026-06-21
Added
Initial extraction as a standalone library.
Node-backed state store (
workflow_nodes) — nooban_jobs.metamutation.Worker macros:
Baton.Worker,Baton.LLMWorker.Dependency gating, completion-triggered rescheduling, retry idempotency.
Multi-model fan-out + synthesis (
Baton.MultiModel).Baton.Plugin(Oban plugin) for orphan rescue and failure telemetry.Baton.Pricingbehaviour + reference implementation.Per-step stats and context-window capture (optional features).
Versioned schema via
Baton.Migration.Terminal
{:workflow_finished, _}PubSub event (and[:baton, :workflow, :finished]telemetry) when a workflow's last step settles, with:completed/:failedoutcome and the failed step names (Baton.Completion).Baton.Pluginbackstops that notification for workflows that settle without a clean worker return (hard crash / Oban kill).Schema v2:
workflow_completionstable — an atomic claim guaranteeing the finished notification fires exactly once across the worker and plugin paths.Configurable LLM client for
Baton.Debug.call_llm/3(config :baton, llm_client: ...).Opt-in data retention:
Baton.Plugincan prune Baton's own tables (workflow_nodes,workflow_step_stats,workflow_debug_logs,workflow_completions) once their Oban job is pruned, with an optional shorter age cap forworkflow_debug_logs(prune: true,debug_log_max_age:). SeeBaton.Retention.Schema v3:
workflow_artifactstable. Step results larger thaninline_threshold_bytes(default 32 KB) are gzipped and spilled here instead of inline onworkflow_nodes, keeping the hot dependency-gating table small under high concurrency. The storage backend is pluggable via theBaton.ResultStorebehaviour (defaultBaton.ResultStore.Postgres);Baton.Resultsresolves references transparently, so its public API is unchanged. Requires running a migration — see "Upgrading" in the README.max_result_bytesguardrail (default 16 MB): a step whose encoded result exceeds it fails with{:error, :result_too_large}rather than persisting a blob that would pressure the shared store.Optional node-local read cache (
Baton.ResultCache, off by default), content-addressed by sha256 so a retried step's overwrite is never served stale. Skips the backend round-trip and gunzip/decode on repeated reads (fan-in / multi-model synthesis). Tunable viaresult_cache_enabledandmax_cache_bytes.
Changed
Baton.Migration.up/downnow default to the latest schema version when:versionis omitted.Baton.MultiModel.configure/2model injection now happens insideBaton.add/4, so the documentedconfigure |> Baton.addusage works.Baton.MultiModel.add/4is deprecated (now a passthrough).- A step whose result cannot be persisted now fails (and retries per
max_attempts) instead of silently completing — previously thestore_resulterror was swallowed and downstream steps could wait forever. Baton.Retention.delete_orphans/2anddelete_workflow/2return maps now include a:workflow_artifactscount.
Removed
Baton.Stats.record_cache_hit(dead code — was never called).
Fixed
Baton.LLMWorkerno longer injects the Oban Pro-only:kill_timeoutoption, which madeuse Baton.LLMWorkerfail to compile under Oban OSS. Replaced with a configurabletimeout/1callback (:timeoutoption, default:infinity).- PubSub broadcast failures no longer crash workers — broadcasting is now
best-effort over telemetry (
Baton.Events). Baton.LLMWorkerstores the step result before recording stats and no longer raises on an unexpectedllm_usagekey, preventing a wasted/repeated LLM call on retry.