Current section
Files
Jump to
Current section
Files
CHANGELOG.md
# Changelog
The format follows [Keep a Changelog](https://keepachangelog.com/) and the
project adheres to [Semantic Versioning](https://semver.org/).
## [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.
[#8]: https://github.com/RudeWalrus/Baton/issues/8
## [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**.
```elixir
%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 event** — `Baton.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
`NodeSpec`s 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:
```elixir
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:
```elixir
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 option** — `Compiler.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:
```elixir
{: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:
```elixir
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 —
```elixir
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.
- **`c: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 flows** — `Baton.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 bindings** — `Baton.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.Step`s, 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.