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. *.tar is also gitignored, since mix hex.build drops 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 built concurrently, so on a large workflow_nodes table the build takes a lock that blocks writes. The new section gives the migration, the concurrently pre-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 on state.

[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.md listing three refusals and not the item_id length 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_id that overflows step_name is now refused by name instead of by the database (#8). step_name is varchar(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 an llm node 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_truncation from inside it — raised, not returned, so no result was stored, so Baton.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/2 and 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.Expansion keeps its own check as the engine backstop, since a host expander built on Baton.Worker reaches 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_id should 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.FanOutSpec whose collection is 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 $input or $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 on assess reads $steps.assess as 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 in scheduled throughout, a workflow can never be announced finished mid-expansion, and the waiting itself is Baton.Check and Baton.Reschedule doing 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: true means the same thing either way. The manifest is stored first, so a tolerant reader still resolves the partial list.

  • max_items on Baton.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 from dump/1 at its default, so untouched definitions serialize byte-identically.

  • "expanded" step eventBaton.Events.broadcast_step_expanded/2 and [:baton, :step, :expanded], carrying detail: %{count: n}. The one event meaning the graph itself changed shape; a UI holding a compiled graph should re-read it.

Changed

  • Baton.Flow.Validator accepts 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_of and item_index to workflow_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's create_if_not_exists a 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_v9 recreates it before removing the unique one, so the rollback never leaves that lookup uncovered.

[0.26.0] - 2026-08-23

Changed

  • Baton.Flow.RequestAssembly accepts a list body from a prompt resolver, not only a binary. A host whose wire wants structure that plain text cannot carry — an Anthropic cache_control breakpoint 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/1 is 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 :system opt 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: the workflow_completions claim 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 at running forever while the workflow it tracks has long since finished. Nothing in baton could tell it otherwise; the outcome was recorded but not readable.

    status/1 closes 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 what status/1 reports matches what was broadcast even after Baton.Retention has pruned the steps behind it — in that case failed_steps comes back empty, the per-step detail being gone.

    :unknown is 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 classification status/1 uses, 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's detail map carries %{reason: "deps", seconds: 15}, where reason is one of "deps" (upstream dependency pending, tagged by Baton.Worker's dep-check branch), "rate_budget" (host Baton.RateLimiter.acquire/3 starved), "provider_limit" (a classify_error/1 snooze verdict — HTTP 429/529 under the default taxonomy), "batch_slot" (host acquire_batch/1 starved), or "step" (the step snoozed itself without saying why), and seconds is the announced wait — what the step asked Oban for, an upper bound on the actual wait since Baton.Reschedule wakes dep-snoozed jobs early. Engine-originated snoozes tag themselves at their origin in Baton.LLMStep as {:snooze, seconds, reason}; Baton.Worker.__handle_result__/3 is 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/1 passed through with no event at all, leaving only the dependency wait and the batch "awaiting" observable.

Changed

  • Baton.Events.broadcast_step_snoozed/1 is now /3 (job, reason, seconds); the arity-1 form is gone.
  • Baton.LLMStep.run/2 and run_batch/3 return tagged 3-tuple snoozes for engine-originated waits. The bare 2-tuple remains valid from step code (request/1, an overridden perform_workflow/1) and is tagged :step at the worker, so existing steps need no change.
  • "awaiting" broadcasts carry seconds in their detail map (%{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 :awaiting internally 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.NodeSpec accepts retry_backoff_seconds alongside max_attempts: a flow node's backoff/1 (Baton.Backoff.node_backoff/1) reads it straight from job.args on 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 for Baton.Flow.Workers.Action, Baton.LLMWorker's jittered failures^3 + 15 for Baton.Flow.Workers.LLM and every Baton.LLMStep. Needs no compiler support (unlike max_attempts, it is read from node config, not stamped onto the Oban job), so it applies to fan-out expansions and hand-built NodeSpecs alike. NodeSpec.load/1 and 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.NodeSpec accepts max_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 and Baton.Backoff.deflate/1 rebases 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/1 and Baton.Flow.Validator both reject a non-positive value.

[0.21.0] - 2026-08-21

Added

  • Provider-advertised Retry-After is honoured. A 429/529 whose error term carries a :retry_after value (seconds) or a retry-after header 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.md collects 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/3 only ran after a response with usage, so the budget acquire/3 reserved 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 doing estimate - actual arithmetic credits the full reservation back with no special-casing (Baton.LLMStep).

[0.20.0] - 2026-08-20

Added

  • Node guards. A portable llm node may declare a top-level guards list — result checks the host applies after handle_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 under config :baton, flow_runtime: [guard_runner: ...] (the new Baton.Flow.GuardRunner behaviour). 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_tokens truncation, a decode failure, a handle_response that returned an error to draw a fresh sample — used to vanish from workflow_step_stats, because usage only rode {:ok, result} maps. Baton.LLMStep now 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 through Baton.LLMWorker exactly as before.

Removed

  • Baton.Flow.MinLength. Schema-declared minLength floors 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 any minLength they 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.LLMWorker recorded 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 reading Baton.Stats.workflow_totals/1 in 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__/3 gained an on_stored callback that runs after the result is persisted (idempotency guard armed) and before the step completes; LLMWorker records 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-less workflow_nodes rows, so Nodes.step_states/1 read them as state: nil — the same shape a pruned step has — and Baton.Completion counts a nil state as a failure. Every seeded workflow therefore announced :failed however well its real jobs did, and workflow_finished carried the seed names in failed_steps. step_states/1 now selects seeded_at and Completion reads a seeded row as completed, the same distinction Baton.Check.classify_dep/2 has 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_112 as 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_nodes row (seeded_at set, no Oban job) in the same insert transaction, visible to $steps bindings, Baton.Results scans, and dependency gating exactly like a completed step. A job whose deps are all seeded is inserted available; 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 optionCompiler.compile(..., debug: true) forces per-workflow workflow_debug_logs capture (what Baton.new(debug: true) already did, exposed at the compile boundary), independent of the global Baton.Debug setting.

  • Baton.Flow.RequestAssembly — the generic llm node's request construction (bindings → assigns → prompts → wire opts), extracted from Baton.Flow.Workers.LLM.request_generic/4 into 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_id is nullable (seeded rows have no job) and workflow_nodes.seeded_at marks a seeded row explicitly — dependency gating still reads a job-less row without the marker as pruned. Bump your Baton migration to version: 8.
  • Baton.Retention.delete_orphans/2 no longer treats a NULL oban_job_id as 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/2 turns that into {:cancel, reason} — the step is cancelled on its first attempt rather than retried.

    The existing 4xx → cancel rule 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 an llm node's config:

    config: %{
      "model" => "claude-sonnet-4-20250514",
      "user_prompt" => %{"body" => "..."},
      "transport" => "batch",
      "poll_interval" => 600
    }

    0.14.0's use Baton.LLMStep, mode: :batch binds the transport when the module compiles, which suits a step module that exists to do one thing. It can't reach a portable flow: every llm node in every definition runs through the single Baton.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/2 takes transport: "live" | "batch" as a per-run default for every llm node 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), and poll_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 (a sequential fan-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.Validator checks 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 in transport), transport keys on an action node (:not_an_llm_node), and non-positive-integer seconds (:invalid_poll_interval / :invalid_batch_deadline).

    It also rejects "batch" on a fan-out gated sequential (: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. Use gate: "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_after changes 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_attempts alongside attempt, so a step can poll for a day with its retry budget intact, and a scheduled job holds the workflow open and its dependents parked.

    New options: :poll_interval (default 300 s) and :batch_deadline (default 90_000 s, a backstop above the provider's own 24h expiry).

  • Baton.LLMClient, the client contract as an explicit behaviour, with complete/2 plus three optional batch callbacks (submit_batch/2, poll_batch/2, batch_results/2). Adopting it is optional — the live path still resolves complete/2 at 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, and clear_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 budgets acquire/3 protects and no reconcile/3 counterpart; what can still be exceeded is the submission rate. Limiters that don't export it are unaffected.

  • An awaiting step event, broadcast on submit and on every poll with a detail map (%{batch_id: id}). It distinguishes waiting on someone else's work from snoozed, 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 carries detail (nil outside batch mode).

    Consumer impact: anything matching on the payload's state must tolerate the new value; keep a catch-all clause.

  • Batch usage is stamped service_tier: "batch" so a Baton.Pricing module 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_ms for 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 the version: in your Baton.Migration.up/1 call and run mix ecto.migrate. Nothing else changes; steps that never use batch mode never write the column.

[0.13.0] - 2026-08-03

Changed

  • The sequential fan-out gate now orders without depending. Its chaining edge moved out of deps into a new sequence_after, and Baton.Check resolves 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/4 accepts sequence_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_graph carries the ordering edge, as sequence_after on 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 the version: in your Baton.Migration.up/1 call and run mix ecto.migrate. In-flight workflows compiled before the upgrade keep the gate edge in deps and continue to behave the old way; the new behaviour applies to workflows compiled after it.

[0.12.3] - 2026-08-03

Added

  • minLength declared in a node's output_schema is 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 carrying minLength on 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-character allowance_reason and a full estoppel array beside a narrative of "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 array items are walked, so a minimum on a nested field is enforced too; null passes, since a nullable field that came back null is absent rather than short. Only minLength on 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/3 raises attempt and max_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 sequential fan-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.Backoff is the fix: failures/1 counts genuine failures (Oban calls backoff/1 before appending the current error, so the count is length(errors) + 1), and deflate/1 rebuilds attempt and max_attempts as they would have been without snoozes, preserving the remaining budget.

    • Baton.LLMWorker now drives its jittered curve off the failure count. jittered_backoff/1 additionally accepts an Oban.Job; the integer form is unchanged, so direct callers and tests keep working.
    • Baton.Worker now defines backoff/1 at 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 sequential fan-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 in Baton.Flow.Compiler.expand_node/3 prepends, so its head is the previous expansion — List.last/1 reached 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 sequential to 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.NodeSpec now carries ignore_discarded and ignore_cancelled. These are the portable spelling of options Baton.add/4 already accepted; Baton.Flow.Compiler passes 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: true on the reader lets it run against the partial collection instead.

    Both default to false, and are omitted from NodeSpec.dump/1 unless set, so stored snapshots and definition_ref digests of untouched definitions are byte-identical to before. No migration is required: the underlying workflow_nodes columns 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/3 now carries input and output token counts, not input alone. estimate and actual are each %{input: non_neg_integer(), output: non_neg_integer()} instead of a bare integer — estimate.input is what acquire/3 already received, estimate.output is the call's own opts[:max_tokens] (0 when unset), and actual.output comes from the response's usage.output_tokens. This is a breaking change to the behaviour's callback contract: an implementation written against reconcile(account, estimate :: integer(), actual :: integer()) must update its pattern match to the map shape. Baton.RateLimiter.Noop is unaffected (it ignores its arguments). A host that only tracks input-tokens-per-minute today can keep doing exactly that by reading estimate.input/actual.input and 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 model may be a binding, not only a literal id. Baton.Flow.Workers.LLM resolves config["model"] through Baton.Flow.Binding, so $item.model gives 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.model defers 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 $steps id 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.Binding maps a path segment over a list instead of failing, so $steps.review.data.finding plucks 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.Compiler stamps each job with flow_fan_in, the expanded step names of its fanned-out deps, so Baton.Flow.Runtime can 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 gate value warm_first renamed to sequential (Baton.Flow.FanOutSpec string form and Baton.Flow.FanOut atom 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 on Baton.Flow.FanOut. Backward compatible: warm_first is still accepted on load, validate, and compile, and is normalized to sequential when 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/2 now 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 override classify_error/1 as before.

[0.6.0] - 2026-07-18

Added

  • Portable serialized flowsBaton.Flow.Definition is 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.Validator checks it, Baton.Flow.Compiler expands 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 bindingsBaton.Flow.Binding resolves $-prefixed dotted paths rooted at input, context, steps, run, or item against a JSON-compatible runtime environment, so a node declares its inputs as data. Hosts supply an allow-listed Baton.Flow.Registry (action and LLM-adapter keys), a Baton.Flow.ContextProvider, and a Baton.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.Definition nodes may declare compile-time fan-out with JSON-safe collection and item-ID bindings plus parallel or warm_first gating. 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.LLMAdapter for 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 of Baton.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 (:llm or :function), an optional Baton.Flow.FanOut, and an opaque meta bag a consumer stows its own data in (prompt keys, payload types, …); project/1 echoes meta back on each node but never interprets it.
    • Baton.Flow.FanOut — one node per item in a collection drawn from the build subject, with :parallel or :warm_first gating (the first expanded node warms a shared cache; the rest depend on it).
    • project/1 lays nodes out by topological layer and introspects each worker's output_schema/0 (from Baton.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 implements request/1 (build messages + client options) and, usually, handle_response/3 (turn the decoded payload into the stored result); the engine times the call, invokes Baton.Debug.call_llm/3, classifies transport errors, decodes the reply, and attaches usage. This collapses ~30 lines of identical case plumbing 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 per max_attempts). max_tokens truncation is a retryable error, never a stored result. Override per step with classify_error/1.
    • Automatic usage recording — the llm_usage map is built from the client's normalized response.usage (cache keys renamed to the workflow_step_stats columns, extra keys like web_search_requests passed 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_schema automatically, 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); override decode/1 (e.g. identity for free-text steps).
    • {:done, result} short-circuit from request/1 — store a result with no model call and no usage, for guards like "this item has nothing to process".

[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 :insert notification (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 parent completed), so it never wakes a dependent too early.
  • Startup nudge. Baton.insert wakes 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 without Baton.Plugin installed.
  • Prompt crash detection. Baton.CompletionReporter listens to [:oban, :job, :exception] and announces a crash-terminated workflow the instant its terminal :discard lands, rather than waiting for the plugin sweep.
  • Schema v4: a partial expression index on oban_jobs ((meta->>'workflow_id')) backing Baton.Plugin's failed-workflow detection and orphan scan, so those sweeps stay cheap as oban_jobs grows. Requires running a migration — see "Upgrading" in the README.
  • Separate prune cadence. Baton.Plugin now 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 :interval can be lengthened without delaying notifications.
  • Default snooze_seconds lowered 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.

  • DAG builder with cycle detection (Baton, Baton.DAG).

  • Node-backed state store (workflow_nodes) — no oban_jobs.meta mutation.

  • 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.Pricing behaviour + 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/:failed outcome and the failed step names (Baton.Completion).

  • Baton.Plugin backstops that notification for workflows that settle without a clean worker return (hard crash / Oban kill).

  • Schema v2: workflow_completions table — 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.Plugin can 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 for workflow_debug_logs (prune: true, debug_log_max_age:). See Baton.Retention.

  • Schema v3: workflow_artifacts table. Step results larger than inline_threshold_bytes (default 32 KB) are gzipped and spilled here instead of inline on workflow_nodes, keeping the hot dependency-gating table small under high concurrency. The storage backend is pluggable via the Baton.ResultStore behaviour (default Baton.ResultStore.Postgres); Baton.Results resolves references transparently, so its public API is unchanged. Requires running a migration — see "Upgrading" in the README.

  • max_result_bytes guardrail (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 via result_cache_enabled and max_cache_bytes.

Changed

  • Baton.Migration.up/down now default to the latest schema version when :version is omitted.
  • Baton.MultiModel.configure/2 model injection now happens inside Baton.add/4, so the documented configure |> Baton.add usage works. Baton.MultiModel.add/4 is deprecated (now a passthrough).
  • A step whose result cannot be persisted now fails (and retries per max_attempts) instead of silently completing — previously the store_result error was swallowed and downstream steps could wait forever.
  • Baton.Retention.delete_orphans/2 and delete_workflow/2 return maps now include a :workflow_artifacts count.

Removed

  • Baton.Stats.record_cache_hit (dead code — was never called).

Fixed

  • Baton.LLMWorker no longer injects the Oban Pro-only :kill_timeout option, which made use Baton.LLMWorker fail to compile under Oban OSS. Replaced with a configurable timeout/1 callback (:timeout option, default :infinity).
  • PubSub broadcast failures no longer crash workers — broadcasting is now best-effort over telemetry (Baton.Events).
  • Baton.LLMWorker stores the step result before recording stats and no longer raises on an unexpected llm_usage key, preventing a wasted/repeated LLM call on retry.