# Changelog

Every release and what changed in it. Newest first; versions follow calver (`YY.M.D`) and match `oryxflow.__version__`.

All notable changes to **oryxflow** are recorded here. This file is read by humans *and* by AI coding agents diagnosing regressions after an upgrade, so the format is load-bearing:

- Newest first. One `## [version] - YYYY-MM-DD` heading per release; version is calver `YY.M.D` matching `setup.py` / `oryxflow.__version__`. Unreleased work goes under `## [Unreleased]`.
- Group bullets under `### Added` / `### Changed` / `### Deprecated` / `### Removed` / `### Fixed` / `### Security` (Keep a Changelog: https://keepachangelog.com/).
- **Every breaking change is a bullet that STARTS with the literal token `BREAKING:`** and carries a same-bullet `Migration:` clause with the old→new fix.
- **Name the actual symbol in backticks** (`` `Task.persist` ``, `` `RunResult.summary()` ``), never prose. Agents grep this file for the symbol in their traceback.

## \[26.11.6\] - 2026-11-06

### Added

- `oryxflow.codehash.strip_docstrings`, the public name of the docstring normalizer that code hashes use.
- `task_ran` and `task_failed` events carry lineage in OpenLineage vocabulary, kept flat: `project` (the job namespace: the project folder's name), `inputs` and, on `task_ran`, `outputs`. Each entry is one dataset `{namespace, name, datasetVersion}`: `namespace` is the project that owns it, `name` its path relative to that project's root (`None` for a dependency without file outputs), and `datasetVersion` the `output_id` of that materialization (`None` when the task's code is not tracked). Inputs also carry the dependency's `task_id` and `family`. An input's `datasetVersion` equals the `datasetVersion` in the dependency's own `task_ran` outputs, across `FlowImport` too, so `jq '.inputs[] | {name, datasetVersion}'` answers "which build fed this", and the log converts to OpenLineage RunEvents without re-running. Events written before this change have none of these fields. Design: `docs/todo/20261028-sys-runlog-lineage.md`.
- `oryxflow.utils.excel_write(..., format_float=None)` (also a per-sheet option, and passed through `save(formatted=True)`): the number format of float columns that no `formats` key or glob matches, e.g. `format_float='pct'` for a sheet of rates. Any name or code `formats` accepts; a mistyped name raises `KeyError`. `None` keeps the `'float'` preset. `oryxflow.report.resolve_formats` gains the matching `float_default` argument.
- `bind(**values)` on a task loaded with `FlowImport` overrides its exported defaults where you require it: `@oryxflow.requires(exported.ReturnsMarket.bind(refresh='2026-07-18'))` (compare with last quarter). A `built` entry works as is (`X.bind(**X.built[0])`; its `prod` mark is ignored). Design: a bound parameter stays on the imported task (it is part of that task's id, which locates the producer's file) but is not copied onto the requiring task (`core._apply_spec` skips `_oryxflow_bound`) nor passed to it by `Task.clone()`, so it does not enter the consumer's flow parameters or any downstream task\_id. Reruns follow the input through the dependency fold (`TaskData._dep_state`): a new bound value, a new exported default, or the producer recomputing that build reruns the consumer; the exact input of every run is recorded in the `task_ran` event's `inputs`.
- `Workflow(..., export=False)` and `WorkflowMulti(..., export=False)`: runs of that flow never update `tasks_export.py`, so an experiment flow cannot add its parameter values to `built`.
- A run warns when a project module it did not import marks a class `export = True` that `tasks_export.py` does not list yet (`FlowExport.unloaded`).
- `Workflow.get_task()` (and so `flow.outputLoad()`, `flow.run()`, `flow.preview()`) accepts a task instance, keeping its own parameter values: `flow.outputLoad(tasks.X(segment='a'))` for a task with a parameter the flow doesn't set. Before: `TypeError: 'X' object is not callable`.
- `preview()` marks the parameters fixed on an imported task (its exported defaults or `bind()`): `Returns[bound: refresh=2026-07-18, sector=office]-{...}`, apart from the values that came through the flow.
- `TaskNotebook.publish`: a path pattern (or list) filled with the task's parameters, such as `publish = 'reports/render/viz-{market}-performance.html'`. Every run also writes the report there (a `.ipynb` pattern gets the executed notebook), through a temporary file. The task counts as complete only while every published copy exists, so a deleted copy is rebuilt. Two reports of one run that would publish to the same file raise `ValueError` naming both.
- `TaskNotebook.report_title` (pattern like `publish`) sets the html `<title>`. Default: the published html's file name, else the task family. Before, every report's title was "Notebook".
- `TaskNotebook.template_deps`: what else the report's code identity watches. Default `'auto'`: the project modules the template's code cells import, and the project modules those import. A list of file globs watches those files instead (`['auto', 'text/*.md']` keeps the imports too); `None` watches the template only.
- `oryxflow.report.default_inputs(task, **params)`: one line after the parameters cell makes a template run both under `TaskNotebook` (it returns the injected `oryxflow_inputs`) and by hand in Jupyter (it builds `task`, a class plus parameters or an instance, and returns its inputs; `FileNotFoundError` when they are not built yet).
- `oryxflow.tasks.notebook.NotebookCellError`, raised when a template cell fails: a `papermill.PapermillExecutionError` subclass whose message is two lines -- the template, the cell (`In[20]`), its failing source line, the exception, and the saved `.failed.ipynb` -- without terminal color codes. The kernel's full traceback, also without color codes, is in `.traceback` and in the chained cause (so in `TaskFailure.traceback` and the `task_failed` event). `NotebookCellError.failed_path` is the saved notebook.
- `oryxflow.report.FORMATS['pct1']`, the same as `'pct'` (one decimal), next to `pct0` and `pct2`.
- A template's parameters cell can be a code cell whose first line is `# parameters` instead of one tagged `parameters` (tags are hard to set outside Jupyter). The template file is not changed.

### Changed

- BREAKING: an exported task's defaults are its prod values. `FlowImport` binds every imported parameter to its exported default, so `@oryxflow.requires(exported.X)` copies no parameter onto your task: before, the producer's parameters passed through the flow and entered your task ids. A same-named parameter of your task is no longer passed to the imported one either. Your task reruns when the producer changes a default (a new quarter) or recomputes that build. Other values: `exported.X.bind(sector='retail')`, or `exported.X(sector=self.sector)` in your own `requires()` for a value that varies with your task; `@oryxflow.requires_each(exported.X, sector=[...])` fans out over one. Migration: remove the producer's parameters from your `Workflow` params / `flow_params.py`; where you chose a non-default value, `bind()` it. Your tasks' ids change once, so they recompute once.
- BREAKING: an exported task (`export = True`) needs a default for every parameter, the value consumers should get. `flow.run()` stops before building with `ValueError: <Task>.<param>: exported parameters need a default -- the value consumers should get (prod)`, and so does `FlowExport.generate()`. Parameters named `prod` or `bind` can't be exported (they clash with `built` and `bind()`). Migration: give each exported parameter its prod value as default (`date_asof = oryxflow.Parameter(default='2026Q1')`); `flow_params.py` overrides are for experiments, run with `Workflow(..., env='dev')`. When prod spans several values of a dimension (sectors), export an aggregator that holds them all.
- `built` in `tasks_export.py` marks the entry whose values are the defaults with `'prod': True`; `external missing output` messages show it as `(prod)`.
- A run in the published data folder that writes a non-default build of an exported task because the run asked for it (`Workflow(X, {'sector': 'retail'})`) warns: `X: experiment written to the prod data folder (non-default sector=retail); run experiments with Workflow(..., env='dev')`. A build another task needs (an aggregator over every sector) doesn't warn. An `env=` run beside the published folder no longer warns that `tasks_export.py` keeps pointing consumers at the published folder.
- `state.RECORD_V` is 6. A code-status record written by 26.10.22 (v5), or by 26.10.21 (v4) under the same Python minor version, is compared with the code exactly: the code is re-hashed with that version's rules (`codehash.rules_of`, `codecheck.comparable_record`), so an edit made before or after upgrading reruns the task, and an unchanged task is re-stamped without rerunning. Before (26.10.22), every older record was trusted and re-stamped silently on the first build that touched it, which blessed edits made after the upgrade to tasks that build did not reach. A record that can't be compared (an older format, or v4 from another Python) is still kept without rerunning, but the build now says so: one `StalenessWarning` naming every such task, in `RunResult.warnings` and the log, and a `code_warning` event per task (`kind: 'old_record'`) that `events.print_status()` lists until it is rerun or accepted. Migration: none for this upgrade. Records that 26.10.22 already re-stamped blindly can't be checked: `reset()` any task you edited since its last run under 26.10.21. Don't go back: once a data dir has run on this version, don't run it on 26.10.22 again (another branch, another machine). 26.10.22 treats these records as an older format, trusts and re-stamps them without comparing code, so edits made meanwhile go undetected -- the same blind spot this release fixes.
- Class attributes `export`, `export_source` and `built` are left out of the code hash (`codehash._METADATA_ATTRS`), like `code_version`: adding or removing `export = True` no longer reruns the task and everything downstream. Records from 26.10.22 that hashed them are compared with the old rules (above), so nothing reruns on upgrade.
- BREAKING: `tasks_export.py` lists each class's parameters by name, and `built` entries hold Python values with their keys sorted (`{'debug': False, 'refresh': datetime.date(2026, 10, 18)}`; a value with no literal form, such as an enum, stays a string). Before, parameters followed module import order, so the file changed depending on which script ran, and `built` values were strings (`'False'`). Existing files are rewritten in the new form on the next producer run; the task ids are unchanged. Migration: compare `built` values with Python values (`b['debug'] is False`), not strings.
- Adding a `code_version()` method to a task that was tracked automatically reruns it once. Before, its first value was adopted without rerunning, so the recipe `{'data': hash_files(path), 'code': ...}` never noticed that the file had changed since the cached run. Adding a plain `code_version = '1'` pin stays free.
- A consumer of an imported task whose producer keeps no code-status record (its code tracking is off) reruns when the producer rewrites that output (by file time); before, it never reran for those. Consumers of such producers rerun once after upgrading.
- `tasks_export.py` update failures are in `RunResult.warnings`, the log and an `export_failed` event, not only a `UserWarning`.
- `TaskNotebook` reruns when a project module its template imports changes (for example `viz/report.py` after `from viz import report`). Before, only the template's cells and the task's own Python counted, so editing a plotting or text helper left a stale report while the run showed success. Comments and docstrings don't count, nor do installed packages, nor modules that define tasks or a flow (a template imports `tasks` to run by hand; a task edit already reaches the report through its data). Upgrading reruns every report whose template imports a project module (such as `viz/`, `utils/` or `cfg.py`) once, on the first run, even if nothing changed: expected, no action needed. Reports whose template imports none keep their cached output. The rerun reason names what changed: `code change (template dep viz/report.py)`, `code change (template cells)` or `code change (task code)`, for reports made by this version or later (older reports show the two code tokens).
- BREAKING: `oryxflow.report.inputLoad(task=...)` on a dependency with several outputs and no `keys` returns `oryxflow.report.Outputs`, a list of the outputs in `persists` order as `self.inputLoad()` returns, that also takes an output's name (`out['series']`, `.keys()`, `.items()`). Before, it returned a dict, so `a, b = rpt.inputLoad(task='x')` silently unpacked the output NAMES. Migration: `out['name']` keeps working; replace iteration over the names (`for k in out`, `set(out)`) with `out.keys()`.
- Two `@oryxflow.requires_each` fan-outs of different tasks over the same values are keyed by task family (`RegionChart_north`, and `inputLoad(flatten=False)['RegionChart']['north']`) instead of raising `ValueError: dependency key ... is produced by both`. Fan-outs whose values don't overlap keep the bare value keys; the same task fanned out twice over the same values still raises.
- `oryxflow.utils.excel_write`: a sheet spec's `formats` is merged over the shared `formats=` (the sheet's keys win, and its globs are tried first; a `None` value drops a shared column format). Before, it replaced them. `formats=False` on a sheet still writes it without number formats.
- `TaskNotebook` no longer makes papermill log `Passed unknown parameter: <name>` for each task parameter the template does not declare (a report inherits every parameter of its upstream band). Names added by `notebook_parameters()` still warn. The kernel runs with `PYDEVD_DISABLE_FILE_VALIDATION=1` unless you set it, which silences the debugger's "frozen modules" note.
- `oryxflow.utils.excel_write`: a column named in a workbook-level `highlight`, `colorscale`, `wrap_columns`, `group_starts` or `id_columns` applies to the sheets that have it. Before, a sheet without the column raised `KeyError`. A name no sheet has still raises, and so does a sheet spec's own option naming a column that sheet lacks.
- `oryxflow.report.table` shows a named index as ordinary leading columns headed by its name, so `formats` and `rename` can address it. Before, pandas put the name on its own header row with a blank band under the column headers.
- `oryxflow.report.table` right-aligns columns of numbers stored with object dtype (a transposed frame), as it does float and int columns.
- `excel_write` takes an open binary file handle as `path` (the atomic `.tmp` + `os.replace` recipe is in the Excel docs). The reports guide says which Python runs the `python3` kernel, how to pick another with `kernel_name`, and that the task's Python counts only with `code_version_auto` on.
- `pip install "oryxflow[evals]"` installs `oryxeval` (Python 3.10+; nothing on 3.9).

### Removed

- BREAKING: `oryxflow.evals` moved to its own package, `oryxeval` (same names; existing eval caches stay valid when only the import lines change). Migration: `pip install oryxeval`, then change only the import lines: `import oryxflow.evals as ev` -\> `import oryxeval as ev`, `from oryxflow.evals.<module> import X` -\> `from oryxeval.<module> import X`. Keep `import oryxflow` and leave every function body as it is: `code_version()`, `case()` and their helpers are hashed into the cache key, so any edit there (even `oryxflow.hash_files` -\> `ev.hash_files`) re-runs every arm. The next run reuses the cache but prints StalenessWarnings; clear them once, from `evals/`: `python -c "import oryxflow; oryxflow.accept_code()"` (re-stamps both stages of every cached arm; the `accept_code(<Eval>)` the warning names covers only the scoring stage). New evals use `ev.hash_files`.

### Fixed

- Code-status records written by a NEWER oryxflow (record `v` above `oryxflow.state.RECORD_V`) are no longer re-stamped: the build's advisory sweep and `accept_code` leave them as they are, the task is kept without rerunning, and one `StalenessWarning` (event kind `newer_record`) names the tasks. Before, an older library rewrote them in its own format and silently blessed any edit made since. `oryxflow.state.is_newer(rec)` is the check. Releases up to 26.10.22 still re-stamp newer records: do not run a data folder a newer oryxflow built with them.
- `TaskNotebook` html reports show dollar amounts as text: `$1.2bn to $3bn` was rendered as a LaTeX formula. The html no longer loads MathJax; set `html_mathjax = True` on the task to render formulas.
- A task whose dependency check moved without any upstream task running in the build reports `upstream output changed since last run`, or `dependency check re-based (record from an older oryxflow)` on the first run after upgrading (`RunResult.reasons`, the `task_ran` event). Before, both read `upstream rerun`, pointing at an upstream that hadn't run. The reason names the input that moved: `upstream output changed since last run: now Returns_2026_10_18_... (was Returns_2026_07_18_...)` when an imported task is bound to other parameters, `...: rewritten <task_id>` when a dependency's output was rewritten (records carry a new `dep_ids` field; a record written before it gives the plain text).
- A run whose flow imports another project's tasks no longer counts those imported classes as this project's when it updates `tasks_export.py` (which could drop entries when both projects share a data-dir parent).
- Imported (`external`) tasks are no longer code-checked by the consumer: no false `StalenessWarning` comparing the export stub with the producer's record (with the expensive-recompute text for a short task), and the consumer's advisory sweep and `accept_code()` never write records into the producer's data dir.
- The code-change warning for a task with automatic tracking off (`settings.code_version_auto = False`) says so, instead of naming the expensive-recompute guard.
- A tracked task (a `TaskNotebook`, or one with a `code_version`) whose dependency is not tracked (`settings.code_version_auto = False` and no `code_version`) reruns when that dependency recomputes. Before, the dependency kept no code-status record, so `TaskData._dep_state` saw no change and the tracked task stayed stale with a green run unless its own output was deleted. An untracked dependency now counts by its output file's modification time. A record written by 26.10.22 matches the old fold once and is re-stamped, so upgrading reruns nothing.
- Two tasks sharing an upstream whose output was missing both rerun. Before, `core.build` visited the first, which reran the upstream, and the second then looked complete and was skipped: stale, with a green run, depending only on dependency order. Now a complete task whose upstream ran in the same build reruns (when `settings.check_dependencies` is on, the default).
- `reset_downstream()`, `invalidate_downstream()` and `run(forced=...)` delete every task in the band that has output on disk. Before, they skipped a task that looked incomplete only because an upstream output was missing, and its stale files were kept.
- A `@requires_each` grid computed from the task's own value of the fanned parameter (`period=lambda t: [prior(t.period)]`, "the same loader at the previous period") raises a `TypeError` that says what to write. Before, it raised a bare `AttributeError` from the lambda. The task can't carry the parameter it fans out over, by design. Write `requires()` by hand with `@oryxflow.inherits` and `self.clone(Loader, period=prior(self.period))`.
- `TaskNotebook` runs a template that has no kernelspec in its metadata (one written with `nbformat`), using `kernel_name`. Before, papermill failed with `AttributeError: kernelspec`.
- `excel_write` with the same option both shared and on a sheet spec (`excel_write(p, {'a': {'df': df, 'formats': {...}}}, formats={...})`) raised `TypeError: dict() got multiple values for keyword argument`.

## \[26.10.22\] - 2026-10-22

### Added

- Sharing outputs between projects. A task with `export = True` is part of the project's interface: every `run()` that includes one rewrites `tasks_export.py` at the project root (next to `data/`) with a read-only copy of each exported task -- docstring, output format, `persists`, parameters with defaults, `external = True`, the data path relative to the file, and `built`, the parameter values whose output exists. The file is only rewritten when it changes; `run(export=False)` skips it, and a failure to write it only warns.
- `FlowImport('<project dir or export file>')` loads another project's `tasks_export.py`; its tasks are attributes (`exported.SectorReturns`, all in `.tasks`). `@oryxflow.requires` them like your own: parameters pass through the flow, and your task reruns when the other project recomputes. Each export loads under its own module name, so two projects with same-named files don't mix.

### Changed

- BREAKING: `FlowExport(tasks=None, flows=None, save=False, path_export=None)` writes only tasks marked `export = True` (before: every task unless `export = False`), one class per task with the class's defaults (before: one per task instance, so a fanned-out flow repeated a class), paths relative to the export file and in posix form (a Windows path was written with backslashes, which Python reads as escapes), and `generate()` returns the source. The `path_export` default is `tasks_export.py` next to the data dir. Migration: add `export = True` to the tasks to share; `flow.run()` now writes the file, so drop explicit `FlowExport` calls.
- BREAKING: `FlowImport(path, module='tasks_export')` replaces `FlowImport(path, module, path_data)`: the export file carries its data path, and the tasks are attributes. It raises load errors (it printed "Module ... not found." and returned an empty object) and warns when the file was not generated by oryxflow. Migration: `oryxflow.FlowImport('../project1').TaskName` instead of `FlowImport(path='../project1', module='tasks_export', path_data='data').tasks.TaskName` with `path=dirpath` on the workflow.
- A run that meets two different task classes with the same task id (same name and parameters, e.g. an imported task and one of yours) raises a `ValueError` naming both; before, the engine silently treated them as one task. A flow's `path=` / `env=` no longer re-points tasks loaded with `FlowImport` (they read the other project's data folder). A class redefined in the same module (a re-run notebook cell) is not affected.
- An external task with no output fails with `external missing output (looked for <path>); built: <values> -- this task is produced outside this flow: ...` instead of a bare `external missing output` (`TaskFailure.reason`, the `RuntimeError` from `run()`, the `task_failed` event).
- BREAKING: `state.RECORD_V` is 5: code fingerprints use an interpreter-stable AST dump (`codehash._dump`), so they compare across Python versions, and `codehash.PY_TAG` and the records' `py` field are gone. On the first build after upgrading, every existing code-status record is trusted and re-stamped without comparing code, so an edit made before the upgrade and not yet rerun is not detected. Migration: `reset()` any task you edited but have not rerun since its last run; after that first build, detection is exact again. Upgrade every machine that shares a data dir together: an older oryxflow and this one re-stamp each other's records and neither detects edits.
- `hash_files` reads files in 1 MB chunks and caches the digest by each file's size and modification time, so a large data file is not re-read on every completeness check. Digests are unchanged. The docs show the recipe for a task that reads a shared data file: return `{'data': hash_files(path), 'code': oryxflow.codehash.task_code_hash(self)}` from `code_version()` so edits to `run()` still rerun it.
- BREAKING: a task with `task_group` writes under `<data dir>/group=<name>/`. Before, the folder was created at the root of the drive (`/group=<name>/`, `C:\group=<name>\`), outside the data dir. Migration: move an existing `group=<name>` folder from the drive root into the data dir, or let the task rerun.

### Removed

- The `export` install extra (`oryxflow[export]`, Jinja2): `FlowExport` no longer needs it.

### Fixed

- `state._write` retries `os.replace` on `PermissionError` (Windows: a sync client or virus scanner briefly holding `.oryxflow-code-status.json`) for about 4.5 s before raising (after a write gives up, writes in the next minute try once, so a read-only data dir does not stall every write). A failed record write in the build's sweep over complete tasks (`codecheck.Advisor.advise`) now warns instead of aborting the build -- the next build writes it -- and a failed write after a task ran is a warning instead of a debug line.
- Code edits made under one Python version to a task last run under another are detected (the task reruns). Before, a record from another interpreter was treated as complete and silently re-stamped with the edited code's fingerprint.
- Unused-input lint (`inputcheck.check_class`, `Workflow.check_inputs`, `preview()`): with keyed dependencies, a non-literal `task=` (`{n: self.inputLoad(task=n) for n in names}`) or a whole-input load used other than through `data['key']` is reported `unanalyzed`, not `unused`.

## \[26.10.21\] - 2026-10-21

### Added

- `oryxflow.utils.excel_write(path, data, **options)` writes finished-looking Excel files: styled header, filter arrows, frozen header, number formats and auto-sized columns. A sheet can also be a spec dict with `title`, `notes`, `highlight`, `colorscale`, `banded`, `wrap_columns`, `group_starts`, `id_columns` and `freeze`.
- `ExcelPandasTarget.save(..., formatted=True)` and `ExcelPandasSheetsTarget.save(..., formatted=True)` (so `self.save(..., formatted=True, formats={'*_pct': 'pct'})` in `TaskExcelPandas` and `TaskExcelPandasSingle`) write through `excel_write`. The default `formatted=False` is unchanged.
- `oryxflow.report`: the format presets `FORMATS` (`'pct'`, `'int'`, `'thousands'`, `'date'`, ...), `fmt(value, spec)` and `excel_code(spec)`. A misspelled preset name raises `KeyError` listing the valid names.
- `oryxflow.tasks.notebook.TaskNotebook` runs a parameterized template notebook with papermill and saves the executed `.ipynb` plus a code-free `.html` report (via `oryxflow.targets.notebook.NotebookTarget`). It reruns when its upstream data, parameters, task code or the template's cells change, not when the template's saved outputs change. Needs the new `notebook` extra: `pip install "oryxflow[notebook]"`.
- `oryxflow.report.table(df, formats=...)` (a pandas Styler), `format_frame(df, formats)`, `inputs_of(task)` and `inputLoad(spec=None, task=None, keys=None)` for report notebooks. `table` takes the same `formats=` dict as the Excel writer.

## \[26.10.8\] - 2026-10-08

### Changed

- BREAKING: `core.task_id_str` caps the readable parameter summary in a `task_id` (and so in every output file name) at 80 characters (`core.TASK_ID_TRUNCATE_SUMMARY`, set with `settings.set_parameter_len(summary=...)`). Before, only each value was truncated (64 chars, up to 20 values, a `ListParameter` counting its whole list), so a task inheriting several long or list parameters got a 269-character path -- past the Windows 260 limit, and past what the Excel desktop app opens (about 218). Uniqueness is unchanged: the 10-char hash still covers the full parameters. Tasks whose summary was already 80 characters or less keep their `task_id`. Migration: none required -- tasks with a longer summary get a new `task_id`, so they recompute once on the next run (their old output files and code-status records are left orphaned; delete them by hand if you want the space back).

## \[26.10.5\] - 2026-10-05

### Added

- `oryxflow.evals`: **a cell is now two cached stages -- the model calls, then the scoring.** `TaskEval` generates an `<Eval>Outputs` task (`TaskJson`, one record per case x repeat: output, inputs, timing, metrics, attributes) that the scoring task requires. Editing an evaluator, changing a judge rubric or swapping the judge model re-scores the stored outputs with ZERO calls to the model under test; editing what the arm reads re-runs both. Before, one `code_version()` keyed both, so a scorer change either re-billed every call or served stale scores. A subclass's `code_version()` keeps its meaning (what the ARM reads) and now keys the model-call stage only; the new `TaskEval.scorer_version()` keys the scoring stage and defaults to every evaluator's code and configuration (`as_spec()`). A typed output is rebuilt with `model_validate`, so `ctx.output.attr` scorers are unchanged. Evaluators that read the run's span tree (`HasMatchingSpan`, `ToolCorrectness`, `TrajectoryMatch`, `ArgumentCorrectness`, `MaxToolCalls`, `MaxModelRequests`, or any with `needs_trace = True`) score in the model-call stage, because spans are not stored.
- `oryxflow.evals`: `sweep(rescore=True)` / `ev.cli --rescore` -- re-run only the scorers over the stored outputs (after a transient judge failure, say). The bill prints `re-scoring N arms from   stored outputs: no model calls` and counts an LLM judge's calls separately.
- `oryxflow.evals`: `baseline=` on `sweep()` (or a `baseline` attribute on a `TaskEval`; default: an arm named `baseline`). The verdict then reports every arm's paired difference from it with its interval, and names no winner -- mixed results need a written judgement, not a pass/fail.
- `oryxflow.evals`: `EvalResult.side_by_side(path=None, all=False, template=None)` -- every arm's output per case as markdown, by default only the cases where the arms disagree on a column the verdict reads, plus failures. Rendered with Jinja from the stored outputs (no model call); `template=` takes a path or a string.
- `oryxflow.evals`: `load_cases()` also takes a list of row dicts, routed exactly like a file's rows -- for a probe that keeps a handful of cases inline.
- `oryxflow.evals`: `ev.cli --side-by-side` writes `EvalResult.side_by_side()` to the eval module's own `results/<date>-<arms>-side-by-side.md`, wherever the run was launched from.
- `oryxflow.evals`: `ev.cli` warns when launched from a directory other than the eval script's own -- the cache and relative paths resolve against the working directory, so such a run reads and writes a different cache.
- `oryxflow.codehash.callable_hashes(*fns)` / `callable_code_hash(*fns)` -- the function-level sibling of `task_hashes`: a function's own source plus the closure of the module-level symbols it references. What lets two stages of one class own disjoint code identities.
- `settings.warn_nested_dir` (default `True`) — a one-time warning when the output directory resolved for a run is empty but a directory of the same name already exists in a parent. The output directory has always resolved against the current working directory, so running a flow from a subdirectory finds an empty `data/` beside it, reports every task incomplete and recomputes everything — re-paying for any metered API call those tasks make, with no signal that it happened. The warning names both directories and the fix; resolution is unchanged and nothing is relocated (searching upward for an existing cache and silently using it was rejected: a flow quietly reading a *different* cache is worse than one quietly rebuilding). Emitted from `set_dir()` only, never inside the execution loop, and suppressed when the local directory already holds output (a deliberate second project) or by `oryxflow.settings.warn_nested_dir = False`.
- `code_version` may now be declared as a **method** instead of a constant token: whatever it returns is folded into that task's code identity, so a task can invalidate on bytes that are not Python — a `.sql` file it executes, a prompt template it renders, a `.yaml` config it loads, a resolved model snapshot id. Automatic tracking (`settings.code_version_auto`) hashes the AST of your Python and is blind to all of those, so until now the only escapes were pinning a constant `code_version` and remembering to bump it, or `reset()` by hand — and forgetting either one silently served a stale output. Precedence is unchanged: an explicit `code_version`, constant or method, overrides automatic tracking for that task, and no existing task changes behavior. Two constraints, documented rather than enforced: the method runs on **every completeness check** (memoized per traversal), so its body must stay local and cheap — never a network call; resolve remote values once outside the task and pass them in as a `Parameter`. And it must be deterministic — a `code_version()` returning a timestamp reruns the task every run, which is the declared consequence, not a bug. Editing the method body no longer fires a spurious stale-code warning: `_is_code_version_stmt()` (`oryxflow/codehash.py`) now strips a `code_version` `FunctionDef` from the advisory AST hash, as it already stripped a class-body `code_version = ...` pin.
- `oryxflow.hash_files(*patterns, root=None)` — a stable digest of the **content** of every file matching these glob patterns, for use inside a task's `code_version()` when the bytes that determine its result are files (SQL, templates, config) rather than Python. Paths resolve relative to `root` (default: the current working directory) and are sorted, so the digest does not depend on filesystem enumeration order, and a rename registers as a change. Raises `FileNotFoundError` when a pattern matches nothing: a typo'd path that silently hashed to "no files" would report every run as unchanged, which is the failure this exists to prevent.
- `oryxflow.evals` — an LLM-eval harness over [pydantic-evals](<https://pydantic.dev/docs/ai/evals/>), installed with the new `evals` extra (`pip install oryxflow[evals]`; pydantic-evals, scipy, typer). Nothing in it is imported by `oryxflow/__init__.py`, so a user without the extra is unaffected; import it explicitly as `import oryxflow.evals as ev`. pydantic-evals keeps scoring — cases, evaluators, repeats, concurrency, retries, the success/failure split — and oryxflow adds the matrix and the cache: one parquet per arm, so a re-run of an unchanged arm makes zero calls and editing the target function or the case file re-runs only the cells that read it.
- `ev.sweep(target, cases=..., metric=..., **arms)` runs the grid and returns an `ev.EvalResult` (`.df`, `.verdict()`, `.report()`, `.best()`). `target` may be a plain async function — no class and no scaffold for a first eval — or an `ev.TaskEval` subclass. Any keyword holding a list is an axis. The projected call count, the cached/new split and a cost estimate print **before** anything is billed, and a swept parameter the function cannot accept raises rather than running N identical cells labelled as branches. `watch=` (glob patterns, or a callable) folds files the target **reads** — a prompt template, a `.sql` file — into the cache key, so editing one re-runs the arms that read it; without it a prompt sits outside the key entirely and an edit re-runs nothing.
- `ev.TaskEval` is the class form (a `TaskPqPandas` with `persists = ['cases']`): declare `dataset`, `metric`, `guardrail`, `slices` and one `case()` coroutine. `preflight()` makes one real call before the sweep, so a credential failure costs one call instead of a matrix; above `max_failure_rate` (default 20%) the run raises `ev.EvalFailureRateError` and **saves nothing**, because a parquet of exceptions is indistinguishable from a measurement. `concurrency` is `significant=False`, so it cannot fragment the cache.
- `ev.load_cases(path, inputs=..., expected=...)` reads cases from `.csv` / `.yaml` / `.jsonl`, routing columns by name; a `@path/to/file.md` value is replaced by that file's text. `synthetic` cases are reported beside the headline and never in it, and `holdout` cases are withheld from every arm with the count stated.
- `ev.Metric(label, column, where=, higher_is_better=, budget=, coverage=)` is the number a sweep is judged on. `ev.rate_ci` / `ev.delta_ci` (scipy, clustered by case) put a confidence interval on it. The verdict names **no winner** when the delta interval spans zero, never says "wins" unqualified when a guardrail budget broke, flags a metric that is identically 0% or 100% in every arm, and flags a denominator that moved between arms.
- `ev.git_tree(ref, paths, dest=, expect_absent=)` materializes a baseline arm from a git ref (byte-exact, and the only mechanism that can restore a *deleted* file); `ev.git_sha(ref)` is the resolved SHA to use as that arm's `code_version()`. `ev.Variant(old, new)` is the anchored-replacement probe for a rewrite that has not shipped, and raises when its anchor is gone rather than silently becoming a no-op.
- `ev.cli(TaskEvalSubclass)` builds a Typer command line whose flags are **derived** from the task's Parameters, so adding a parameter adds a repeatable flag and the two cannot drift. Built-ins: `--repeats --concurrency --reset --check --csv --yes`; `--check` runs `preflight()` only and exits. `ev.cli` / `ev.build_app` resolve on first use, so `import oryxflow.evals` does not pay for Typer.

### Changed

- `oryxflow.evals`: the `evals` extra now requires `pydantic-evals>=2` (for the tool-call evaluators, `CaseLifecycle` and Windows-safe report output) and adds `jinja2` (for `side_by_side()`).
- `oryxflow.evals`: `reset=True` / `--reset` discards BOTH stages, model calls included. Use `rescore` to re-run only the scorers.
- `oryxflow.evals`: the function form's arm identity now also follows what the target CALLS (`codehash.callable_code_hash`), not only the target's own body -- editing a project-local helper or prompt loader re-runs the arms that use it.
- `oryxflow.evals`: `rep` is read from pydantic-evals' own `"<name> [i/N]"` naming when outputs are stored, instead of being re-derived from row order (`TaskEval._assign_reps` is now a no-op kept as an override point).
- `requires_each`: the "declares a parameter and also fans out over it" `TypeError` now names the legitimate case it used to steer wrong - a task that compares its own value with others (`sector` vs `sector_compare`) - and points to `self.requires_grid(...)` in `requires()` rather than only "Remove the declaration". The `requires_each` docstring shows that form with `inputLoadConcat(tagkeys=[...])`. Message and docs only; behavior unchanged.
- `state.RECORD_V` is now `4`: the code fingerprint folds in the resolved `code_version` token, and freshness records store that **resolved** token rather than the raw attribute. Records written at `v3` are treated as unverifiable and silently re-stamped by `build()`'s advisory sweep — a one-time re-baseline, never a mass rerun, exactly as the v2→v3 transition did.
- `oryxflow.evals`: **judge validation.** `ev.judge_alignment(df, judge, human)` reports a judge's TPR, TNR and Cohen's kappa against human labels, and `ev.corrected_rate(observed,   alignment)` applies the Rogan-Gladen correction -- the standard estimator for a proportion measured with an imperfect test, which is what an LLM judge is. Declare it on the metric (`ev.Metric('pairing', 'pairing_holds', human='pairing_human')`) and the verdict prints the alignment and the corrected rate per arm. Kappa is reported *and labelled* as the one to read, because raw agreement inflates under class imbalance: on a 90%-pass set a judge that passes everything scores 90% agreement and has learned nothing (kappa 0). The correction is REFUSED when `TPR + TNR <= 1`, where the estimator's denominator collapses and the "correction" is amplified noise. This matters beyond tidiness: an imperfect judge pulls every rate toward its own error floor, so it shrinks the very difference a sweep exists to detect -- a true 85% vs 55% reads as 78% vs 60% through a judge that is 88% accurate.
- `oryxflow.evals`: a **guardrail inside budget on the point estimate but not on its interval** now says so (`the interval for prod reaches 25% -- at this sample size the   guardrail is not demonstrated, only not yet violated`). At n=12, `0% (0/12)` is entirely consistent with a true rate above a 5% budget, and the bound is the thing to act on.
- `oryxflow.evals`: a **lone arm scoring 100%** is flagged as saturated rather than reported as a win -- a case set that never fails cannot show a regression or an improvement, so the response is harder cases. (Multi-arm saturation was already caught by the dead-metric check.)
- `oryxflow.evals`: `ev.PromptArm` -- where one arm READS its prompt files and the cache key that MATCHES them, in one object (`.dir()` and `.code_version()`). Written by hand those are two places that must agree, and when they drift the failure is silent: a baseline arm reading a checked-out tree but keyed on a hash of the LIVE files re-runs when a live prompt is edited, and does *not* re-run when it is pointed at a different ref. A working-tree arm is keyed on the content of its `globs`, a `ref` arm on the resolved sha; both hand back the same layout via `subdir=`, so a loader never has to know which arm it got. An arm with no key at all is refused at construction.
- `oryxflow.evals`: the cost confirmation now offers the one-call preflight **at the prompt** -- `Run 56 new calls for X? (y/n/c=check one call first)`. `c` spends one call, reports whether the wiring works, and asks again. A `--check` flag only helps someone who already knows it exists, and the person who needs it most is running the eval for the first time. Deliberately still *offered* rather than run automatically before the bill: a tool that bills money should not make its first call unasked, however cheap.
- `oryxflow.evals`: `sweep()` accepts `evaluators=` and passes them to the dataset it generates. The target function is handed only `inputs`, so scoring against a case's `expected_output` — the shape every classifier eval has — previously required building a `Dataset` by hand and passing both `cases=` and `dataset=`.
- `oryxflow.evals`: a sweep axis with a **single value** no longer appears in the arm name. It is identical in every arm, so it added width to every row of every table and distinguished nothing: `model_id=['gemini-3.5-flash'], prompt_version=['prod','preship']` now names the arms `prod` and `preship` rather than `model_id_gemini-3.5-flash_prompt_version_prod`. When *no* axis varies the full name is kept, since a lone cell called `default` says less.
- `oryxflow.evals`: `verdict()` no longer reprints the call-count header that `sweep()` already emitted as the bill, and "Weakest slices" lists only slices actually **below** the arm's own rate — a 100% slice under that heading reads as a warning about something that is fine.

### Fixed

- `oryxflow.evals`: a `TaskEval` subclass now re-runs when its case set changes. Only the function form folded the cases into its cache key, so editing `cases.csv` under the class form served the previous cases' results.
- `oryxflow.evals`: under pandas 3 the `<field>_preview` columns were never written, because text columns get a `str` dtype instead of `object`.
- `oryxflow.evals`: the per-case frame was discarding most of what the report carried. `_to_frame()` read only assertions, scores, metadata and the output, so **six** things went on the floor. Now columns: `case.metrics` (what `increment_eval_metric` recorded -- token counts, retries, anything a task measures about its own call), `case.attributes` (`set_eval_attribute` -- a resolved model snapshot, say), `case.expected_output` (flattened to `expected` / `expected_*`), `case.labels`, `case.total_duration`, and `report.report_evaluator_failures`. The `expected_*` recovery is the one that changes how code reads: a confusion matrix or a per-label breakdown is now a crosstab of two frame columns instead of joining the answer key back on by case name.
- `oryxflow.evals`: `EvalResult.verdict()` reports **measured usage** when the task recorded it -- `measured usage: input tokens 9,612, output tokens 2,720` -- read from the conventional `input_tokens` / `output_tokens` / `total_tokens` / `cost` columns. Before this, cost could only ever be the pre-run estimate, because the real numbers pydantic-evals collected were being thrown away. Reported by name rather than inferred from any numeric column: guessing which column is a token count eventually sums something that is not one.
- `oryxflow.evals`: an evaluator that **raised** is no longer indistinguishable from one that declined to score. `ReportCase.evaluator_failures` was read by nothing, so a judge crashing on every case (a missing API key, say) left its column absent and the metric reported `n/a` with no hint that anything was wrong. The frame now carries `evaluator_errors` / `evaluator_error` and the verdict prints them above the rate. `case.labels` was likewise dropped and is now a column like assertions and scores.
- `oryxflow.evals`: a sync `case()` (or a sync `sweep()` target) no longer silently serializes the run. `concurrency` is scheduled as coroutines by pydantic-evals, so a blocking case holds the event loop and the cases run one at a time while the setting still reads as honored -- same numbers, \~5x the wall clock (8 half-second cases at `concurrency=4`: 1.0s async, 4.9s sync). It now emits a `RuntimeWarning` naming the task and the ignored concurrency. Nothing about oryxflow itself is async; the requirement comes from the eval runner, and `concurrency=1` is the quiet way to opt out.
- `oryxflow.evals`: the dead-metric flag no longer fires on a **guardrail sitting inside its budget in every arm**. `0%` false-positives on both sides is the outcome a guardrail exists to confirm, and reporting it as "this usually measures the harness" teaches the reader to ignore the one line that matters when it is real. A guardrail identically *over* budget is still flagged.
- `oryxflow.evals`: declining the cost confirmation on the `ev.cli` path exits 0 with a one-line message instead of raising through to a traceback. Choosing not to spend is the outcome the confirmation exists to make easy.
- `oryxflow.evals`: a `Metric` no longer counts an **unmeasured** row as a failed one. A row whose metric column is null (an LLM judge that grades only some cases, or one switched off with `--judge-model none`) is now excluded from the rate exactly as an errored row is, instead of being coerced to `False`. Previously that both deflated the numerator and padded the denominator: a prompt passing 2 of 2 judged rows out of 6 reported `33%`, and a metric with no verdicts at all reported a confident `0%` with a confidence interval. `Metric.compute()` now counts only measured rows, a wholly unmeasured metric returns `NaN` and renders `n/a`, and `Metric.unmeasured()` / the verdict state how many rows carried no verdict. New `Metric.eligible()` is the denominator before that filter.
- `oryxflow.evals`: `sweep()` no longer silently overwrites a case metadata column named `arm` (or one named after a sweep axis) with its own cell label. `arm` is the natural word for a dataset that compares, say, an `outline` arm with a `guidelines` arm, and the tag destroyed it: any `slices=('arm',)` then reported the arm label back with no error and a plausible-looking table. The clash is knowable from the cases alone, so it now raises **before any call is made**, naming the key and the fix. `arm` joins `RESERVED_METADATA`.
- Automatic code invalidation (`settings.code_version_auto`) no longer raises `RuntimeError:   dictionary changed size during iteration` when a task's import graph includes a package inserted into `sys.path` at run time and a lazy import fires mid-hash. `oryxflow/codehash.py` now snapshots `sys.modules` before iterating it, which is also the correct semantics: the hash describes the module set as it stood when the traversal began, which is what `codehash.freeze()` already brackets `build()` to guarantee.

## \[26.8.2\] - 2026-08-02

### Added

- `Workflow.dependents(task, root=None, paths=False)` and `Workflow.dependencies(task=None,   target=None, paths=False)` (plus `WorkflowMulti` variants with a `flow=` selector) — ask the DAG "what depends on this task" / "what does it depend on" instead of grepping or hand-rolling a walk over `requires()`. `task` may be a **class, family string, or instance**; a class/string is *not* instantiated, so it works for fanned-out / DAG-internal families that `get_task()` can't build. `paths=True` returns the ordered `root→task` routes (a list of task lists) instead of the deduped set. Backed by `core.find_deps` (set) and the new `core.find_paths` (ordered, re-exported as `oryxflow.find_paths`); both are now memoized, so the walk is polynomial on diamond-heavy DAGs instead of exponential. `dependents(X)` is the discoverable, correctly-named form of the confusingly-argued `taskflow_downstream(task, task_downstream)` (kept as an alias).
- `Workflow.check_inputs(tasks=None, raise_on_unused=False, include_clean=False)` — static AST lint that reports a declared `@oryxflow.requires` dependency whose data `run()` **loads and never reads**. Such a dead dependency is invisible to every dependency query (the edge is real, only the data is dead) yet still forces its whole upstream band on every cold build. `preview()` surfaces these automatically in an `UNUSED INPUTS` block, deduped per family; `run()` does not lint, keeping the execution path free (call `check_inputs()` explicitly for CI). Three verdicts — `unused` / `clean` / `unanalyzed` (a shape it can't prove is `unanalyzed`, never silently `clean`); outer unpack elements are dependencies, inner are that dep's `persists` (a top-level `_` is a finding, an inner `_` is normal). Suppress a deliberately-unused dependency with a `# oryxflow: input-unused` comment. New module `oryxflow/inputcheck.py`.
- `@oryxflow.requires_each(task, **grid)` — declare one dependency **per value** instead of one dependency: `@oryxflow.requires_each(ModelTrain, model=MODELS)` on the task that combines them. Like `@oryxflow.requires` it copies the dependency's parameters onto the decorated task, minus the ones being fanned out (those differ per branch, so the combining task must not carry them), and it defines `requires()` as a `requires_grid` over the values. Naming several parameters fans out over their cartesian product. Use it instead of hand-writing `{v: Task(param=v, shared=self.shared) for v in values}`, which only reaches the branches with the parameters you remember to forward.
- `@oryxflow.requires`, `@oryxflow.inherits` and `@oryxflow.requires_each` now **stack** on the same task, in any order and any number. The normal combining task needs the fan-out *and* a shared dependency that is deliberately not fanned out — the table the branches were built from, a baseline to score them against, labels to render with: `@oryxflow.requires({'input': ReportInput})` above `@oryxflow.requires_each(RegionNarrative, region=REGIONS)`. Previously each decorator owned `requires()` outright, so the second one raised and the only way through was `@oryxflow.inherits` plus a hand-written `requires()`. The parameter rule holds across all of them: the combining task gets every dependency's parameters except the fanned-out ones.
- `@oryxflow.requires_each` accepts a single-entry `{name: Task}` dict to name the fan-out group; the group defaults to the dependency's own task family. A named group qualifies its dependency keys with that name (`chart_north`), which is how two fan-outs over the same values are disambiguated. Unnamed groups keep bare value keys, so existing tasks are unaffected.
- `@oryxflow.requires_each` and `Task.requires_grid` accept a **callable** grid value — `region=lambda self: REGIONS[self.sector]` — for a fan-out computed from the task's own parameters, which previously forced a hand-written `requires()`. The callable sees the task's parameters, not its inputs.
- `@oryxflow.requires_each` and `Task.requires_grid` accept `derive={'name': fn}` — a further parameter set **per branch** from that branch's fanned values, for the setting that follows from the value the branch was built for: `@oryxflow.requires_each(RegionLoad, region=list(SOURCE), derive={'source': lambda v: SOURCE[v['region']]})`. Each function is handed that branch's values (`v['region']`) and its result is passed to the branch as a parameter, so it counts towards the branch's `task_id`: editing one entry in `SOURCE` invalidates exactly that branch. Previously the only places to put such a lookup were the branch's `run()` — where it is invisible to the cache, so changing it silently returned the old output — or a hand-written `requires()`, which drops every parameter you forget to forward. Derived names stay out of the dependency keys (`inputLoad(task='north')` is unchanged) and off the combining task, for the same reason fanned names are.
- `inputLoad(flatten=False)` groups a fan-out's branches under one key (`{'input': df, 'RegionNarrative': {'north': ..., 'south': ...}}`), so a task that mixes a fan-out with shared dependencies no longer has to pop the keys it recognises and assume the rest are branches. `inputLoad(task='<group>')` and `inputLoadConcat(task='<group>')` select just the branches; `inputLoadConcat(flatten=False)` returns one DataFrame per group.

### Changed

- Traversal-scoped memoization makes no-op re-runs and `preview()` near-instant on wide fan-out DAGs. Three engine questions used to recurse over each task's whole upstream closure once per **path** through the DAG, not once per task: `TaskData.complete(cascade=True)`, `_resolve_requires()`, and `Task._code_fingerprint`. On a 41-branch fan-out over a shared aggregator (75 tasks) a no-op `run()` did 1,428 completeness checks, 586 `requires()` resolutions and 8,439 fingerprint evaluations; `preview()` did 5,552 / 873 / 13,685. A new per-traversal memo (`oryxflow.core.traversal_scope`, opened by `build()`, `preview()`, `Workflow.complete()`, the `taskflow_*` walks, `dependents`/`dependencies` and `accept_code`) collapses each to **one execution per unique task** — the same no-op `run()` now does 75 / 43 / 75. Behaviour is unchanged: completeness answers are dropped whenever a task materializes (`save()`), is invalidated (`reset()`/`invalidate()`), or runs inside a build; the code/DAG-shape memos live for the traversal (the engine already forbids code changes mid-build, per `codehash.freeze()`). A bare `Task.complete()` outside any traversal is unmemoized, exactly as before. With cloud storage this also cuts the per-object existence API calls by the same factor.
- BREAKING: `cls` and `derive` join `path` and `flows` as **reserved parameter names** — declaring `derive = oryxflow.Parameter(...)` (or `cls`) on a task now raises `ValueError` at class definition. Both are arguments of `Task.clone()` / `Task.requires_grid()`, so the argument shadows the parameter: `self.clone(cls=Other)` and `@oryxflow.requires_each(Dep, derive={...})` would bind to the argument and the parameter would never receive a value. Migration: rename the parameter (`derive_features`, `model_cls`).
- BREAKING: fanning out over a name the dependency has no parameter for now raises `TypeError` (`@oryxflow.requires_each(RegionLoad, sector=[...])` where `RegionLoad` has no `sector`). It used to produce one dependency key per value all pointing at the **same** task, because `clone()` builds its kwargs from the target's `get_params()` and drops the rest — so `inputLoadConcat()` returned N copies of one branch's output, tagged as if they were different branches. The error lists the parameters the dependency does have. Migration: declare the parameter on the dependency, or fan out over one it has.
- BREAKING: a task decorated with `@oryxflow.requires_each(Dep, x=[...])` that also declares its own `x = oryxflow.Parameter(...)` now raises `TypeError` at class definition. The declaration used to survive, putting one branch's value into the combining task's `task_id` — so you got one combining task *per value*, each combining all the branches, cached under different ids at N times the cost, with no warning. Migration: delete the declaration; the combining task is the point the branches converge into and must not carry the fanned parameter.
- BREAKING: two dependencies resolving to the same key now raise `ValueError` from `requires()` instead of one silently replacing the other (previously reachable when a fan-out value collided with a named dependency). Migration: name one of them — `@oryxflow.requires_each({'chart': Chart}, region=REGIONS)` or `@oryxflow.requires({'input': ReportInput})`.
- `python_requires` raised to `>=3.9` — up from `>=3.5`, which never held: the package has used f-strings (3.6+) throughout for some time, and `install_requires` already imposes 3.9 in practice via pandas and pyarrow. PyPI version classifiers added to match. This corrects the metadata; it does not drop support for any interpreter the package actually ran on.
- BREAKING: `oryxflow.utils.requires_grid(task_cls, param, values, **base)` is now the `Task.requires_grid(cls, **grid)` method — same job, done properly. As a free function it had no `self`, so it could not carry the calling task's parameters down to the branches: every shared parameter had to be repeated in its `base` kwargs, and one left out was silently missing from the children (they got the default instead of the flow's value — a wrong result, not an error). The method clones per branch, so parameters propagate exactly as they do through `clone()`. It also fans out over several parameters at once — `self.requires_grid(ModelTrain, model=MODELS,   horizon=[1, 5, 20])` gives the cartesian product. Keys are the value itself for one parameter, `name_value` pairs joined with `_` for several, and are what `inputLoad(task=...)` selects on. Migration: `requires_grid(ModelTrain, 'model', MODELS)` becomes `self.requires_grid(ModelTrain, model=MODELS)` inside `requires()`, and any parameter you were passing through `base` can be deleted — it is carried automatically.

### Fixed

- Decorating a task with two dependency decorators no longer raises `"<Task>: defines requires() AND is decorated with @requires"` when the task defines no `requires()` at all. The check now distinguishes a hand-written `requires()` (still an error — the decorator would silently replace it) from a decorator-generated one.
- `inputLoadConcat()` now warns when it would row-stack a shared dependency in with a fan-out's branches, which produces a union frame across unrelated schemas. Pass `task='<group>'` to concatenate just the branches, or `flatten=False` for one frame per group.
- BREAKING: declaring a Parameter named `path` or `flows` now raises `ValueError` at class definition instead of failing silently. `path` is a keyword-only argument the engine uses for the flow's data directory, so `MyTask(path='a.csv')` never reached a Parameter of that name — it kept its **default**, meaning every value mapped to the same task, and that default was then used as the output directory (`x.csv/MyTask/...`). Migration: rename the parameter (`file`, `filename`).
- BREAKING: decorating a task that defines its own `requires()` with `@oryxflow.requires` / `@oryxflow.requires_each` now raises `TypeError`. The decorator assigns `requires` after the class body is evaluated, so the hand-written method was silently discarded and the task ran with whatever the decorator declared. Migration: keep one — drop the decorator and write `requires()` (with `self.requires_grid(...)` for a fan-out), or delete the method.
- `inputLoadConcat()` / `concat_iter()` warn when a tag column would overwrite an existing column whose values differ from the tag — previously real per-row data (a date column, a category) was silently replaced by one scalar parameter value. Re-tagging with the value already present is unchanged and silent, since that is how each level of a multi-level aggregation legitimately rewrites the level below's tag columns. Silence it with `tagkeys=[...]` or `tag=False`.
- `preview()` / `oryxflow.utils.print_tree()` now show parameters for **every** task in the tree, not just the root. A positional-argument slip made the recursion pass `clip_params` as `show_params`, so every child rendered as `[TaskName- (PENDING)]` — in a fan-out over a parameter grid the branches were indistinguishable. `show_params=False` now also reaches the children.
- The `RuntimeError` raised by `oryxflow.run()` / `Workflow.run()` on failure now names the failing task **and its parameters** instead of only `Exception found running flow, check trace` — e.g. `Exception found running flow: ModelTrain(model=forest, seed=7): ValueError: training diverged`. Up to three root-cause failures are listed. The original exception is still chained via `from`.

## \[26.7.26\] - 2026-07-26

### Changed

- BREAKING: `TaskAggregator` is now a `requires()`-based group node instead of a task that yields its members from `run()`. Because the group is a regular DAG node, it works with `Workflow` / `WorkflowMulti` (previously every call raised `UnknownParameterException: ... unknown parameter   flows`), `preview()` expands it to show each member, and per-flow `path`/`env`, `reset_upstream()` and `FlowExport` reach its members. The group still saves nothing of its own and is complete when every task it requires is complete. The old form now raises a `RuntimeError` at construction naming the fix. Migration: move the members from `yield` statements in `run()` into `requires()` (or `@oryxflow.requires`) and leave `run()` empty — `class Agg(oryxflow.tasks.TaskAggregator): def run(self): yield T1(); yield T2()` becomes `class Agg(oryxflow.tasks.TaskAggregator): def requires(self): return [T1(), T2()]`.

## \[26.7.21\] - 2026-07-21

### Security

- Releases are now published to PyPI via GitHub Actions **Trusted Publishing** (OIDC) instead of a stored API token, and every uploaded file carries a PyPI-recorded **attestation** (PEP 740 / Sigstore) proving it was built from this repository by CI. Verify on the PyPI file detail page for this release. No install-side change — `pip install oryxflow` is unaffected.

## \[26.7.12\] - 2026-07-12

### Added

- Automatic code invalidation, on by default (`settings.code_version_auto = True`): every task derives its code identity from the AST hash of its own class plus the project-local symbols it transitively references (`codehash.task_hashes`, `'<relpath>::<symbol>'` granularity), so a real logic edit (in the task **or a helper it calls**) reruns the task and everything downstream on the next `run()`, overwriting in place — while editing an unrelated sibling task in the same file reruns nothing (one monolithic `tasks.py` stays cheap). References to other Task classes are dependency wiring, never a code dependency (a pinned upstream's unbumped edit can't ripple through `requires()` mentions); unresolvable constructs degrade conservatively to whole-module granularity. No attribute to maintain, and comment/docstring/formatting edits never rerun (AST normalization). Existing caches are grandfathered on first contact (baseline stamped, zero reruns). Set `settings.code_version_auto = False` for explicit-only tracking. The functional API is covered automatically (auto is ambient, no per-task surface). Records live in `<dirpath>/.oryxflow-code-status.json` and travel with the data dir.
- `Task.code_version` (str or int, default `None`): a per-task **pin** that suspends automatic tracking of that task's own logic — it recomputes only on a deliberate bump (the task and everything downstream), for expensive tasks where a refactor-triggered recompute must be a decision, or logic the hash can't see. Records are mode-aware (they store both the token and the `source_hashes` as of the last materialization), and the `code_version` line itself is stripped by the AST normalization (typing it in / deleting / bumping it is a token change, never a source change), so pinning/unpinning unchanged code never recomputes ("just resumes"), an edit masked during a pinned-unbumped window is caught the moment the pin comes off, and pinning in the same edit as a logic change forces a rerun instead of blessing stale output.
- Dependency propagation folds **output identity** (`output_id`, fresh per actual materialization, preserved across re-stamps and `accept_code`): downstream reruns exactly when an upstream rematerialized — pin toggles and accepts never ripple, and a `reset()`+rerun upstream propagates downstream even across separate builds.
- Staleness advisory for pinned tasks: code changed without a bump → cached output is reused and the run warns via `StalenessWarning` (a `UserWarning` subclass, visible without `enable_logging()`), a loguru record, a `code_warning` event, and `RunResult.warnings`. The printed/logged channels dedupe per process on the message — parameterized instances of one family produce identical text, and a `WorkflowMulti` run is one build per flow over shared upstreams, so per-task dedupe would still flood stdout — re-arming when the condition changes or the affected tasks rerun/are accepted; `RunResult.warnings` lists each distinct message once per run (`MultiRunResult.warnings` dedupes across flows), and only the event stream records every occurrence.
- `oryxflow.accept_code(task)` / `accept_code()`: acknowledge an output-equivalent code change without rerunning. With a task instance it re-stamps the task **and its entire upstream dep tree** (post-order), stamping a fresh baseline record for outputs that have none yet (this is what clears the `output predates current code` mtime-guard warning after an upgrade); `Workflow.accept_code(task=None)` / `WorkflowMulti.accept_code(task=None, flow=None)` wrap it; called bare they cover **every imported task family that resolves with the flow's parameters** (a multi-final pipeline is fully blessed in one call, from a fresh process — no prior run needed), and a list of tasks is accepted everywhere (on `WorkflowMulti` prefer the flow method — the module-level bulk form doesn't know the flows' parameters). Prints a one-line summary of what it re-stamped (or that nothing was accepted). The tree walk is fault-isolated: a task whose `requires()`/ `output()` raises is skipped and reported instead of aborting the walk (a broken `requires()` also can't poison the node's own blessing). Never touches `output_id`, so accepting never triggers downstream recomputes.
- `TaskData.keep_versions` (default `False`): with `code_version` set, outputs live under a readable `.../<Task>/v<version>/` segment so old versions survive bumps (explicit pins only; auto-tracked tasks overwrite in place).
- Expensive-recompute guard (`settings.code_version_auto_expensive_s`, default 600): an auto-tracked task whose last materialization (recorded as `duration_s`) took longer is held complete when its code changes and the run warns (`StalenessWarning`, all channels) with the three exits — `reset()` to recompute, `accept_code` if output-equivalent, or pin with `code_version` — so a refactor can't silently burn a long run. `None`/`0` disables the guard.
- Records carry schema/interpreter tags (`state.RECORD_V`, `py`): a record with a different/missing `v` or Python minor is treated as unverifiable — complete, then silently re-stamped (grandfather trust level, `output_id` preserved) — never a mass rerun after an upgrade.
- `build()` mtime-revalidates code hashes at most once per module per build (`codehash.freeze()`/`unfreeze()`), keeping the auto-hash overhead on small DAGs low.
- Event stream `oryxflow.events`: every run appends `run_started` / `task_ran` / `task_failed` / `run_finished` / `code_warning` / `code_accepted` / `task_log` events to `.oryxflow/events.jsonl` (stable head; earlier months offload to `events-YYYYMM.jsonl`, immutable). Plain JSONL — `tail`/`grep`/`jq` work; writes are async and never fail a run; disable with `settings.events = False`. Query via `oryxflow.events.status()` (session-start: pending warnings, last run per family, recent failures), `events.runs(task_family=, flow=,   last=)`, `events.iter_events()` — all return data and print nothing; `events.print_status()` prints the status summary (the session-start orientation call for scripts and `python -c`).
- `RunResult.run_id`, `RunResult.reasons` (`{task_id: 'output missing' |   'code change (auto: <file>::<symbol>)' | 'code change (a -> b)' | 'upstream rerun'}`), `RunResult.warnings`. `MultiRunResult` gains aggregate `.ran`/`.complete`/`.failed`/`.reasons`/`.warnings` across flows. `task_ran` events carry params, code fingerprint, source hashes, `auto` flag, git SHA/dirty, duration and the rerun reason; `WorkflowMulti` stamps each per-flow build's events with its flow name.
- Task-authored `self.logger.*(...)` lines are captured as `task_log` events during a build (works with logging disabled), so in-run scalars become queryable memory.
- New settings: `settings.events`, `settings.eventspath`, `settings.state_filename`.

### Changed

- `settings.db` (unused) renamed to `settings.state_filename` (the per-data-dir record file name, `.oryxflow-code-status.json`).

## \[26.7.11\] - 2026-07-11

### Changed

- Documentation rewrite and PyPI packaging updates; no API changes.

## \[26.6.6\] - 2026-06-06

### Added

- Initial release of `oryxflow`: the self-contained task engine (`Task`, `requires`/`inherits`, the parameter set, `Workflow`/`WorkflowMulti`, targets and task I/O formats), with no external workflow-engine dependency.
